Skip to content
hello tis
Go back

2026년 8월 4주차 회고: 동시성 제어와 CDC 컨슈머 최적화

3줄 요약

  • 『주니어 백엔드 개발자가 반드시 알아야 할 실무지식』 6장을 읽고 동시성 제어의 선택 기준을 정리했다.
  • CDC 컨슈머 처리량을 31,121/s에서 86,041/s로 높였다. 가장 큰 개선은 파싱이 아니라 컨슈머 병렬성이었다.
  • 동시성은 강한 잠금부터 검토하지 않고, 성능 문제는 프로파일에 보이는 CPU 사용량만 보고 판단하지 않는다.

지난주 Try 점검

이번 주의 공부: 동시성 제어는 단순한 방법부터 선택한다

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: 필요한 구간에만 잠금 적용
포인트 전환 로직 전체가 분산 잠금 범위에 포함되어 외부 API 호출 동안에도 잠금을 유지하는 AS-IS 흐름포인트 차감과 롤백 구간에만 분산 잠금을 적용하고 외부 API 호출은 잠금 밖에서 수행하는 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만까지 쌓였다.

Spring Boot의 상품 변경이 MySQL binlog, Debezium, Kafka를 거쳐 다시 컨슈머로 전달되는 CDC 파이프라인

MySQL 쓰기, Debezium 발행, Kafka 컨슈머 소비 순서로 단계마다 처리량이 줄어드는 CDC 파이프라인

처음에는 이벤트마다 남기는 로그를 의심했다. 로그를 끄니 20% 빨라졌다. CPU 프로파일에서는 JSON 파싱이 CPU 시간의 54%를 차지했지만, 필요한 필드만 읽는 파서를 직접 만들어 얻은 개선은 19.6%였다. 가장 큰 개선은 프로파일에 보이지 않던 컨슈머 병렬성이었다.

설정소비 속도변화
베이스라인31,121/s
컨슈머 병렬성 1→477,015/s+147%
전체 조합86,041/s+176%

파티션은 4개인데 컨슈머 스레드는 하나뿐이었다. 스레드를 파티션 수에 맞추자 처리량이 가장 크게 올랐다. ExecutionSample은 CPU에서 실행 중인 스레드만 보여주기 때문에 브로커 응답을 기다리는 시간은 프로파일에서 빠져 있었다. 이후에는 ThreadCPULoad로 CPU 포화 여부를 먼저 확인하고, 포화가 아니라면 Kafka 메트릭에서 대기와 병렬성을 확인하는 순서로 바꿨다.

최고 lag 115만은 발행이 끝난 뒤 38초 만에 모두 소진됐고 이벤트 유실도 없었다. 따라서 lag의 절대값만 보고 장애라고 판단하지 않는다. 발행 속도와 소비 속도를 나눠 보고, lag 추세와 lag ÷ 소비 속도로 계산한 예상 소진 시간을 함께 본다.

발행량과 소비량의 차이로 lag이 최고 115만까지 쌓였다가 발행 종료 후 0으로 줄어드는 곡선

또 여러 설정을 한꺼번에 바꾸지 않는다. max.partition.fetch.bytes를 다른 설정과 함께 적용했을 때는 효과가 있어 보였지만, 하나만 바꿔 다시 재니 처리량이 4% 떨어졌다. 기준 조건을 고정하고 요인을 하나씩 바꿔야 실제 효과를 구분할 수 있었다.

전체 실험 환경과 측정 과정은 [26.08.24] CDC 컨슈머 처리량을 초당 3만 건에서 8만 건으로 올리기에 따로 남겼다.

더 알아볼 것 / 질문

마무리하며

이번 주의 두 주제에서는 같은 기준을 얻었다. 동시성 제어는 가장 강한 잠금이 아니라 문제를 해결할 수 있는 가장 단순한 수단부터 선택한다. 성능 개선도 가장 눈에 띄는 코드가 아니라 측정으로 확인한 병목부터 시작한다.

다음 주 Try


Share this post:

Previous Post
사내 버셀 구축 - 1단계 홈서버 구축
Next Post
[26.08.24] CDC 컨슈머 처리량을 초당 3만 건에서 8만 건으로 올리기