ClOr

ClOr

백엔드 실무 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.

Claude Code 해부학 (완결)

51만 줄 소스코드를 19편에 걸쳐 분석한 완결 시리즈

전체 시리즈 보기 →

백엔드 트러블슈팅

실무에서 겪은 장애와 해결 과정 기록

전체 시리즈 보기 →

최신 글

article thumbnail

시리즈 목차
1편: 트랜잭션 중첩 문제
2편: batchInsert & Lock Wait Timeout
3편: Reconciliation (현재 글)
4편: 멀티테넌시 + @Async에서 ThreadLocal이 사라지는 문제

이전 편 요약

1편에서 트랜잭션 중첩, 2편에서 Lock Wait Timeout을 해결했다. 동기화는 드디어 안정적으로 돌아가기 시작했다. 이제 끝난 줄 알았다.


증상: "퇴사한 사람이 아직 활성 상태입니다"

운영팀에서 이상한 리포트가 올라왔다.

"외부 시스템에서 퇴사 처리한 사용자가 플랫폼에서 아직 활성 상태로 남아있습니다."

확인해보니, 외부 시스템에서 삭제된 레코드가 플랫폼에서 is_deleted=0(활성)으로 남아있었다.

외부 시스템: [A, B, C] → 동기화 → 플랫폼: [A, B, C]
외부 시스템: [A, C]    → 동기화 → 플랫폼: [A, B, C]  ← B가 안 지워짐!

동기화가 upsert만 수행하고 있었기 때문이다. "있는 것을 넣는 것"은 되는데, "소스에서 사라진 것을 대상에서 정리하는 것"은 아예 없었다.

문제 정의: Reconciliation의 부재

이 "소스에서 사라진 것을 감지하고 정리하는 행위"를 Reconciliation(재조정)이라고 한다.

동작 upsert가 하는 것 Reconciliation이 하는 것
새 데이터 삽입 ✓
변경된 데이터 갱신 ✓
삭제된 데이터 감지 불가 ✗ 차집합으로 감지 → 삭제 ✓

핵심은 간단하다:

삭제 대상 = DB에 있는 키 - 소스에서 받은 키

설계: 삭제 전략을 데이터 유형별로 분리

모든 데이터를 같은 방식으로 삭제하면 안 된다. 복구 가능성과 비즈니스 영향이 다르기 때문이다.

데이터 유형 삭제 전략 이유
사용자 Soft Delete 퇴사 후 복직, 이력 조회 가능성
조직 Soft Delete 하위 사용자 참조, 감사 로그
사용자-역할 매핑 Hard Delete 1:1 매핑, 복구 불필요
권한 목록 Snapshot 교체 매번 전체 목록이 오므로 DELETE + INSERT가 깔끔

삭제 전략을 하나로 통일하면 편하지만, 데이터의 생명주기를 무시하는 설계가 된다. 결국 운영에서 터진다.


구현: Chunk 단위 Reconciliation

핵심 코드

fun reconcile(
    entityType: EntityType,
    sourceKeys: Set<String>,  // 이번 동기화에서 받은 전체 키
) {
    val dbKeys = repository.findActiveKeys(entityType)  // DB에 있는 활성 키
    val toDelete = dbKeys - sourceKeys                   // ← 차집합 = 삭제 대상

    if (toDelete.isEmpty()) return

    log.info("[Reconciliation] ${entityType.name}: ${toDelete.size}건 삭제 대상")

    when (entityType.deleteStrategy) {
        SOFT_DELETE -> repository.softDelete(toDelete)    // is_deleted = 1
        HARD_DELETE -> repository.hardDelete(toDelete)    // DELETE FROM
        SNAPSHOT    -> {                                   // 전체 교체
            repository.deleteAll(entityType)
            repository.insertAll(sourceKeys)
        }
    }
}

주의: chunk 단위 수신 시 타이밍 문제

Kafka에서 데이터가 chunk 단위(500건씩)로 들어오는데, chunk 하나만 받고 reconciliation을 돌리면 아직 안 온 데이터까지 삭제해버린다.

// ← 이렇게 하면 안 됨
chunk 1 (500건) 수신 → reconciliation → 나머지 1500건이 삭제됨!

해결:

// 전체 chunk 수신 완료 시점에만 reconciliation 실행
if (message.isLastChunk) {  // ← 마지막 chunk 플래그 확인
    val allSourceKeys = chunkAccumulator.getAll()
    reconcile(entityType, allSourceKeys)
    chunkAccumulator.clear()
} else {
    chunkAccumulator.add(message.keys)
}

결과

지표 Before After
잔류 데이터 (삭제 누락) 매일 10~30건 누적 0건
수동 정리 요청 주 2~3회 0회
데이터 정합성 단방향 (추가만) 양방향 (추가 + 삭제)
처리 시간 증가 +2초 (1만 건 기준, 무시 가능)

배운 점

  1. 동기화는 upsert만으로 끝나지 않는다. "소스에서 사라진 것"을 감지하는 것이 진짜 어려운 부분이다.
  2. 삭제 전략은 데이터의 생명주기에 따라 달라야 한다. Soft delete가 만능이 아니고, hard delete가 항상 위험한 것도 아니다.
  3. Chunk 단위 수신 시 reconciliation 타이밍을 반드시 고려해야 한다. 마지막 chunk 이후에만 실행하지 않으면 정상 데이터가 삭제된다.

다음 편 예고: 4편에서는 이 동기화 로직을 @Async로 비동기 전환했을 때 터지는 ThreadLocal 소실 문제를 다룬다. 멀티테넌시 환경에서 스레드 경계를 넘는 컨텍스트 전파, 생각보다 훨씬 까다로웠다.

profile

ClOr

@ClOr

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

ClOr · 백엔드 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.