race-condition

2 개의 포스트

cloudflare

hyper HTTP 라이브러리에서 버그를 발견한 방법 (새 탭에서 열림)

Cloudflare의 Images binding을 로컬 Unix 소켓 기반 구조로 개편한 뒤, 대용량 이미지 응답이 간헐적으로 잘리는 버그가 발생했다. 응답은 HTTP 200과 정상적인 `Content-Length`를 반환했지만 실제 본문은 수백 KB만 전달되어 이미지가 부분적으로 렌더링되거나 디코딩에 실패했다. 원인은 특정 조건에서 발생하는 hyper 라이브러리의 레이스 컨디션이었으며, 최종적으로 네 줄의 코드 수정으로 해결됐다. ### Images binding과 hyper의 데이터 흐름 - Workers는 바인딩을 통해 Images 서비스에 이미지 데이터를 직접 전달하고, 변환 결과를 스트림으로 받을 수 있다. - 이미지 변환 과정은 다음과 같이 진행된다. - Workers 런타임이 소켓을 통해 Images 서비스에 요청을 보낸다. - Images 서비스가 이미지를 합성·리사이즈·트랜스코딩한다. - 변환된 전체 이미지를 메모리 블록으로 hyper에 전달한다. - hyper가 데이터를 내부 버퍼에 저장한 뒤 소켓의 송신 버퍼로 기록한다. - 소켓의 양 끝에는 커널이 관리하는 버퍼가 있다. - 수신자가 충분히 빠르면 hyper가 한 번에 모든 데이터를 보내고 소켓을 종료할 수 있다. - 수신자가 조금이라도 느리면 송신 버퍼가 가득 차고, hyper는 공간이 생길 때까지 추가 쓰기를 기다려야 한다. ### 네트워크 중계에서 로컬 Unix 소켓으로 전환 - 초기 Images binding은 Workers 런타임과 Images 사이에서 FL이라는 내부 중계 서비스를 거쳤다. - FL은 DNS 조회와 라우팅 등 전체 네트워크 처리 파이프라인을 수행했기 때문에 오버헤드가 있었다. - 2025년 12월, Cloudflare는 FL을 같은 머신에서 실행되는 내부 Worker binding으로 교체했다. - 새 구조는 네트워크 소켓 대신 Unix 소켓으로 서비스를 직접 연결했다. - 이 변경으로 다음 효과를 기대했다. - 네트워크 스택과 FL 처리 과정 제거 - Images 요청 경로 단축 - Images 팀이 독립적으로 binding을 배포하고 변경 가능 - 그러나 출시 며칠 뒤부터 대용량 이미지 응답 실패 제보가 접수됐다. ### HTTP 200이지만 잘린 응답 - 고객 사례는 두 단계의 이미지 처리 파이프라인을 중첩한 비표준 구성이었다. - 내부 파이프라인: R2의 JPEG와 PNG를 합성해 JPEG 생성 - 외부 파이프라인: 결과 이미지를 다시 압축·변환·리사이즈 - 실제 문제는 내부 transformation binding의 반환 경로에서 발생했다. - 외부 파이프라인은 내부 응답에서 다음과 같은 모순을 받았다. - 상태 코드는 `200 OK` - `Content-Length`는 수 MB로 설정 - 실제 수신 본문은 일부 데이터에 불과함 - 한 사례에서는 예상 크기 3.3MB 중 약 200KB만 전달됐다. - 상위 계층에서는 다음 오류가 발생했지만, 실제 원인이 어느 서비스에 있는지는 즉시 알기 어려웠다. ```text error reading a body from connection: end of file before message length reached ``` - 브라우저에서는 이미지 형식에 따라 일부만 표시되거나, 하단이 회색으로 남거나, 아예 깨진 이미지로 표시됐다. ### 재현과 원인 범위 좁히기 - 개발팀은 고객의 중첩 파이프라인을 재현하는 Worker를 만들었다. - 이후 외부 파이프라인과 여러 계층을 하나씩 제거해 binding 단독으로도 문제를 재현할 수 있음을 확인했다. - 배치 요청을 보내는 간단한 스크립트로 재현을 자동화했다. - 초기 실행에서는 25건 중 19건이 실패했다. - 매번 도착한 데이터가 약 200KB였는데, 이는 운영 환경의 소켓 버퍼 크기와 매우 유사했다. - 이를 통해 문제는 고객 설정이 아니라 소켓 버퍼가 가득 찬 뒤 hyper가 응답 전송과 연결 종료를 처리하는 방식과 관련 있음을 추정할 수 있었다. - 최종 원인은 특정 타이밍에서 발생하는 hyper 내부의 레이스 컨디션으로 밝혀졌고, 수정에는 네 줄의 코드만 필요했다. ### 실용적인 결론 소켓 기반 스트리밍에서는 `200 OK`나 올바른 `Content-Length`만으로 전송 성공을 판단할 수 없다. 특히 송신 버퍼가 가득 차는 상황, 느린 수신자, 데이터 기록과 연결 종료가 동시에 일어나는 경로를 반드시 테스트해야 하며, 대용량·중첩 파이프라인을 포함한 재현 테스트가 간헐적인 네트워크 버그를 찾는 데 결정적이다.

kakao

잃어버린 리포트를 찾아서: 카카오 메시징 시스템의 경쟁 조건 문제와 안티 패턴 제거 과정 (새 탭에서 열림)

KIMS의 메시지 상태가 `SENT`에 멈춘 원인은 벤더사의 전송 결과 리포트가 메시지 레코드의 DB 커밋보다 먼저 도착하는 경쟁 조건이었다. 특히 응답이 빠른 특정 벤더와, 과금 후처리로 트랜잭션이 길어진 유료 메시지에서 문제가 집중되었으며, 전체 메시지의 약 0.02%에서 발생했다. 트랜잭션 범위를 줄이고 각 보장의 필요성을 재검토하는 과정에서, 불필요한 장기 트랜잭션과 외부 이벤트 발행을 분리해야 한다는 결론에 도달했다. ### KIMS 메시지 처리 구조 - KIMS는 카카오 내부 서비스용 SMS 전송 플랫폼으로, 하루 약 100만 건을 처리한다. - 다수의 IDC와 MSA 컴포넌트, 여러 외부 SMS 벤더로 구성되어 있다. - 기본 흐름은 다음과 같다. - API Server가 품질 지표를 기준으로 벤더를 선택한다. - 벤더 호출 후 메시지를 `SENT` 상태로 DB에 기록한다. - 벤더가 전송 결과 리포트를 전달한다. - Report Server가 메시지를 `REPORTED` 상태로 갱신한다. - 각 단계가 비동기적으로 분리되어 있어, 처리 순서가 뒤집힐 가능성이 존재했다. ### `SENT` 상태에 멈춘 메시지 - 일부 메시지가 리포트를 수신했음에도 `REPORTED`로 갱신되지 않았다. - Report Server 로그에는 벤더 리포트 수신 기록이 남아 있었다. - 즉, 리포트가 네트워크에서 유실된 것이 아니라 DB 반영 전에 폐기된 상황이었다. - 문제가 전체 메시지가 아닌 약 0.02%에서만 발생해 재현과 원인 분석이 어려웠다. ### 빠른 벤더와 긴 트랜잭션이 만든 경쟁 조건 - 리포트 누락은 특정 벤더로 전송된 메시지에서 집중적으로 발생했다. - 해당 벤더는 API 호출 후 평균 약 20ms, 문제 사례에서는 약 8ms 만에 리포트를 보냈다. - 반면 API Server는 벤더 호출 이후 후처리와 DB 영속화를 진행하고 있었다. - 유료 메시지는 과금 이벤트 발행 로직까지 하나의 `@Transactional` 범위에 포함되어 트랜잭션이 더 오래 유지됐다. - 결과적으로 처리 순서가 다음처럼 역전될 수 있었다. - API Server가 벤더 호출 - 과금 후처리와 이벤트 발행 수행 - 벤더가 리포트 전송 - Report Server가 상태 갱신 시도 - 아직 메시지 레코드가 DB에 없어 리포트가 유효하지 않은 것으로 판단되어 Drop - API Server가 뒤늦게 메시지를 DB에 저장 - 메시지의 초기 상태를 기록하는 Write 경로와 리포트를 읽어 상태를 갱신하는 Read 경로가 같은 시점에 교차하면서 Race Condition이 발생했다. ### 트랜잭션 범위 줄이기 - 기존 트랜잭션에는 DB 상태 변경뿐 아니라 Kafka 이벤트 발행 등 비즈니스 로직도 포함되어 있었다. - 이로 인해 Long-lived Transaction이 발생하고 DB Commit 시점이 지연됐다. - 과금 이벤트 발행을 `@Async`, `@TransactionalEventListener` 기반으로 분리해 커밋 이후 별도 스레드에서 실행하도록 변경했다. - 트랜잭션 책임을 상태 변경과 DB 영속화까지로 제한했다. - 평균 커밋 시점이 약 10ms 앞당겨졌고, 리포트 누락도 눈에 띄게 감소했다. - 외부 이벤트 발행 실패 때문에 DB 트랜잭션 전체가 롤백되는 Dual-write 문제도 완화됐다. - 외부 시스템으로 보내는 이벤트는 DB 트랜잭션과 본질적으로 동시에 롤백할 수 없으므로, 트랜잭션 내부에 무리하게 포함하는 방식은 부적절했다. ### 트랜잭션 보장의 필요성 재검토 글은 단순히 트랜잭션을 짧게 만드는 데서 그치지 않고, 해당 업무에 트랜잭션 자체가 필요한지 질문한다. MySQL의 `REPEATABLE READ`에서 제공되는 보장을 세 가지로 나누어 검토했다. - **원자성** - 여러 쓰기 작업 중 일부만 성공하는 상황을 막고 전체를 롤백하는 기능이다. - 해당 로직의 DB 쓰기는 사실상 단일 작업이었다. - 여러 테이블이나 레코드에 걸친 원자성이 필요하지 않다면 트랜잭션의 Abortability는 필수적이지 않을 수 있다. - **읽기 격리** - 라우팅 벤더 정보와 실시간 품질 지표를 조회하고 있었다. - 품질 지표는 분 단위로 갱신되며, 1분 전 데이터가 사용되어도 문제가 없었다. - 서로 독립적인 메타데이터를 조회하므로 동일한 스냅샷에서 읽어야 할 필요도 크지 않았다. - **쓰기 격리** - 변경 내용을 커밋 전까지 다른 트랜잭션에 노출하지 않는 보장이다. - JPA/Hibernate의 Dirty Checking에서는 변경 사항이 먼저 Persistence Context의 1차 캐시에 반영되고, 실제 DB Write는 트랜잭션 종료 시점까지 지연될 수 있다. - 이 지연이 리포트 처리보다 DB 저장이 늦어지는 직접적인 원인이 되었다. ### 실용적인 설계 교훈 - 외부 시스템의 응답 시간이 매우 짧을 수 있다는 전제에서 비동기 흐름을 설계해야 한다. - “호출 후 DB 저장”처럼 순서를 가정한 구조는 벤더별 응답 편차 때문에 경쟁 조건을 만들 수 있다. - 트랜잭션에는 반드시 원자적으로 처리해야 하는 DB 작업만 포함하고, 이벤트 발행·외부 API 호출·긴 후처리는 분리하는 것이 좋다. - 트랜잭션의 원자성·읽기 격리·쓰기 격리가 실제 비즈니스 요구사항인지 각각 검토해야 한다. - 리포트 처리에서는 아직 원본 메시지가 저장되지 않은 경우를 단순 Drop하지 말고, 재시도·지연 큐·Transactional Outbox 같은 보완책을 함께 고려해야 한다.