시리즈 목차
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만 건 기준, 무시 가능) |
배운 점
- 동기화는 upsert만으로 끝나지 않는다. "소스에서 사라진 것"을 감지하는 것이 진짜 어려운 부분이다.
- 삭제 전략은 데이터의 생명주기에 따라 달라야 한다. Soft delete가 만능이 아니고, hard delete가 항상 위험한 것도 아니다.
- Chunk 단위 수신 시 reconciliation 타이밍을 반드시 고려해야 한다. 마지막 chunk 이후에만 실행하지 않으면 정상 데이터가 삭제된다.
다음 편 예고: 4편에서는 이 동기화 로직을
@Async로 비동기 전환했을 때 터지는 ThreadLocal 소실 문제를 다룬다. 멀티테넌시 환경에서 스레드 경계를 넘는 컨텍스트 전파, 생각보다 훨씬 까다로웠다.
'백엔드 트러블슈팅' 카테고리의 다른 글
| Schema-per-Tenant 멀티테넌시 구현: JPA 대신 Exposed를 선택한 이유와 실전 삽질 (0) | 2026.03.29 |
|---|---|
| Kafka 동기화 삽질기 4편: 멀티테넌시 + @Async에서 ThreadLocal이 사라지는 문제 (0) | 2026.03.29 |
| BPMN 자동화 엔진 리팩토링: 이벤트 디스패치 · 상태 업데이트 역할 분리 5단계 정리 (0) | 2026.03.28 |
| Kafka 동기화 삽질기 2편: batchInsert Lock Wait Timeout과 청크 전략 (0) | 2026.03.28 |
| Kafka 동기화 삽질기 1편: 트랜잭션 중첩이 만든 유령 롤백 분석 (0) | 2026.03.28 |
