3줄 요약
- 『주니어 백엔드 개발자가 반드시 알아야 할 실무지식』 6장을 읽고 동시성 제어의 선택 기준을 정리했다.
- CDC 컨슈머 처리량을 31,121/s에서 86,041/s로 높였다. 가장 큰 개선은 파싱이 아니라 컨슈머 병렬성이었다.
- 동시성은 강한 잠금부터 검토하지 않고, 성능 문제는 프로파일에 보이는 CPU 사용량만 보고 판단하지 않는다.
지난주 Try 점검
- 『주니어 백엔드 개발자가 반드시 알아야 할 실무지식』 6장 읽고 정리하기 → DB 제약 조건부터 분산 잠금까지 선택 기준을 스터디 노트에 정리했다.
- CDC를 로컬에서 직접 테스트하고 겪은 경험 정리하기 → Debezium, MySQL binlog, Kafka로 파이프라인을 만들고 컨슈머 처리량을 2.8배 높였다. 자세한 과정은 [26.08.24] CDC 컨슈머 처리량을 초당 3만 건에서 8만 건으로 올리기에 정리했다.
이번 주의 공부: 동시성 제어는 단순한 방법부터 선택한다
6장은 여러 요청이 같은 데이터를 동시에 변경할 때 생기는 문제와 잠금 사용법을 다룬다. 읽기 전에는 SQL이 순서대로 실행된다고 생각했다. 실제로는 여러 세션의 SQL이 병행 처리되고, 같은 인덱스 레코드를 변경할 때 InnoDB 잠금으로 충돌하는 부분만 직렬화된다.
여기서 구분할 것이 있다. 한 DML 문장이 전부 반영되거나 롤백되는 원자성과 여러 세션이 동시에 실행되는 동시성은 별개다. 아래 증분 쿼리는 한 문장 안에서 읽기와 쓰기가 이뤄져 갱신 유실을 막는다.
UPDATE account
SET balance = balance + 100
WHERE id = 1;
반면 값을 조회한 뒤 계산해서 별도 UPDATE로 저장하면 두 문장 사이에 다른 세션이 값을 바꿀 수 있다. 이때는 조건부 쿼리나 버전 검사, SELECT ... FOR UPDATE 같은 추가 제어가 필요하다.
실무에서는 다음 순서로 단순한 방법부터 검토한다.
| 상황 | 우선 검토할 방법 |
|---|---|
| 중복 데이터 생성 방지 | UNIQUE, INSERT IGNORE |
| 숫자 증감 | 단일 증분·조건부 UPDATE |
| 충돌이 드문 수정 | 낙관적 잠금 |
| 충돌이 잦은 수정 | 비관적 잠금 |
| 여러 서버의 작업 조정 | Redis 분산 잠금 |
| 요청 순서까지 보장 | 메시지 큐·단일 소비자 |
INSERT IGNORE도 SQL 전체를 순차 실행하는 기능은 아니다. 여러 세션이 같은 고유 키를 삽입하면 UNIQUE 인덱스에서 충돌한 작업만 기다린다. 먼저 실행한 트랜잭션이 커밋하면 나머지 삽입은 중복 키 경고와 함께 생략되고, 롤백하면 기다리던 세션이 삽입할 수 있다. 중복을 막는 것은 IGNORE가 아니라 DB의 고유 제약 조건이다.
실무에 적용할 점
외부 API 호출은 잠금 범위에서 분리한다
잠금 안에서 외부 시스템을 호출하면 네트워크 대기 시간만큼 잠금을 오래 점유한다. 타임아웃이 발생해도 외부 작업은 성공했을 수 있어서, DB만 롤백하면 데이터가 어긋날 수도 있다.
포인트 전환 사례에서는 분산 잠금 점유 시간 192ms 중 외부 API 호출이 126ms를 차지했다. 외부 호출을 잠금 밖으로 빼면서 점유 시간을 67ms로 줄였다. 대신 잠금 획득 횟수가 늘고 외부 결과와 DB 상태가 어긋날 가능성이 생겼다. 그래서 멱등키, 보상 처리, 대사처럼 결과가 불명확할 때의 복구 방법이 함께 필요하다.
| AS-IS: 전체 로직에 잠금 적용 | TO-BE: 필요한 구간에만 잠금 적용 |
|---|---|
![]() | ![]() |
Redis 분산 잠금은 중복 실행을 줄이는 수단이다
Redis 잠금은 여러 서버에서 실행되는 스케줄이나 캐시 재생성의 중복을 줄이는 데 유용하다. SET key token NX PX ttl로 획득하고, 해제할 때는 토큰이 일치하는지 확인해야 한다. 단순 DEL을 사용하면 만료 후 잠금을 획득한 다른 서버의 잠금을 지울 수 있다.
하지만 작업 중 TTL이 만료되거나 Redis 페일오버로 잠금이 유실되면 두 서버가 동시에 작업할 수 있다. 결제나 재고 차감처럼 반드시 한 번만 처리해야 하는 작업은 Redis 잠금에만 의존하지 않고 DB 고유 제약, 조건부 갱신, 멱등키로 한 번 더 막는다.
잠금을 사용하면 데드락과 잠금 시간 초과도 정상적인 운영 조건으로 본다. 재시도 횟수에 상한을 두고, 재시도되는 작업은 멱등하게 만든다. 운영에서는 잠금 점유 시간과 대기 시간, 획득 실패율, 재시도 횟수를 함께 본다.
이번 주의 실험: CDC lag은 크기보다 추세와 소진 시간을 본다
지난주에 남긴 CDC Try도 완료했다. Debezium과 MySQL binlog로 변경 이벤트를 Kafka에 발행하고, Spring Kafka 컨슈머가 이를 처리하는 파이프라인을 로컬에 만들었다. 초당 약 19만 건을 MySQL에 쓰자 최고 lag이 115만까지 쌓였다.
처음에는 이벤트마다 남기는 로그를 의심했다. 로그를 끄니 20% 빨라졌다. CPU 프로파일에서는 JSON 파싱이 CPU 시간의 54%를 차지했지만, 필요한 필드만 읽는 파서를 직접 만들어 얻은 개선은 19.6%였다. 가장 큰 개선은 프로파일에 보이지 않던 컨슈머 병렬성이었다.
| 설정 | 소비 속도 | 변화 |
|---|---|---|
| 베이스라인 | 31,121/s | — |
| 컨슈머 병렬성 1→4 | 77,015/s | +147% |
| 전체 조합 | 86,041/s | +176% |
파티션은 4개인데 컨슈머 스레드는 하나뿐이었다. 스레드를 파티션 수에 맞추자 처리량이 가장 크게 올랐다. ExecutionSample은 CPU에서 실행 중인 스레드만 보여주기 때문에 브로커 응답을 기다리는 시간은 프로파일에서 빠져 있었다. 이후에는 ThreadCPULoad로 CPU 포화 여부를 먼저 확인하고, 포화가 아니라면 Kafka 메트릭에서 대기와 병렬성을 확인하는 순서로 바꿨다.
최고 lag 115만은 발행이 끝난 뒤 38초 만에 모두 소진됐고 이벤트 유실도 없었다. 따라서 lag의 절대값만 보고 장애라고 판단하지 않는다. 발행 속도와 소비 속도를 나눠 보고, lag 추세와 lag ÷ 소비 속도로 계산한 예상 소진 시간을 함께 본다.
또 여러 설정을 한꺼번에 바꾸지 않는다. max.partition.fetch.bytes를 다른 설정과 함께 적용했을 때는 효과가 있어 보였지만, 하나만 바꿔 다시 재니 처리량이 4% 떨어졌다. 기준 조건을 고정하고 요인을 하나씩 바꿔야 실제 효과를 구분할 수 있었다.
전체 실험 환경과 측정 과정은 [26.08.24] CDC 컨슈머 처리량을 초당 3만 건에서 8만 건으로 올리기에 따로 남겼다.
더 알아볼 것 / 질문
- DB 잠금은 요청 순서를 보장하는가 — 충돌하는 작업을 한 번에 하나씩 처리하지만 FIFO를 보장하지 않는다. 도착 순서대로 반드시 처리해야 한다면 순번을 저장하거나 메시지 큐의 단일 소비자를 사용한다.
- Redis는 읽기-쓰기 잠금으로 읽기를 병렬화하는가 —
GET과SET같은 핵심 명령은 메인 스레드에서 순차 실행한다. 논블로킹 소켓과 이벤트 루프로 요청을 받고, 일부 I/O와 메모리 해제 같은 주변 작업을 다른 스레드로 넘긴다. - CDC 컨슈머 병렬화로 새 경합이 생기지 않았는가 — 스레드를 4개로 늘렸으므로
CdcEventStore의@Synchronized가 새로운 병목이 됐는지 다시 측정해야 한다.
마무리하며
이번 주의 두 주제에서는 같은 기준을 얻었다. 동시성 제어는 가장 강한 잠금이 아니라 문제를 해결할 수 있는 가장 단순한 수단부터 선택한다. 성능 개선도 가장 눈에 띄는 코드가 아니라 측정으로 확인한 병목부터 시작한다.
다음 주 Try
- 『주니어 백엔드 개발자가 반드시 알아야 할 실무지식』 7장 읽고 정리하기

