분산 환경에서 데이터 정합성 챙기기 - 사용자가 새로고침을 누르지 않게
서론
“왜 앱이랑 관리자 화면이랑 데이터가 달라요?”
고객센터에서 이런 연락이 오면 정말… 식은땀이 한두 방울이 아닙니다. 특히 돈이 오가는 계약 정보나 실시간 상태가 중요한 서비스라면 더더욱 그렇죠.
모놀리식 환경에서는 트랜잭션 하나로 해결되던 것들이, 분산 환경이나 외부 시스템과 연동되는 순간 정말 골치 아픈 문제로 변합니다. 네트워크는 언제든 끊길 수 있고, 배포 중에 요청이 유실될 수도 있으니까요.
이번 포스팅에서는 분산 환경에서 데이터 정합성을 지키기 위해 제가 실무에서 적용했던 몇 가지 전략들을 공유하려 합니다.
문제 상황 - 웹훅만 믿었다가 당한 일
제가 담당했던 임대 관리 플랫폼에는 ‘계약 정보 동기화’라는 기능이 있었습니다. 핵심 계약 데이터는 레거시 시스템에 있고, 저희 서비스는 이를 가져와서 보여주는 구조였죠.
초기 설계 - 너무 단순했다
graph LR
A[레거시 시스템] -->|웹훅 전송| B[API 서버]
B -->|DB 업데이트| C[(MySQL)]
D[모바일 앱] -->|조회| C초기 설계는 정말 단순했습니다. 레거시 시스템에서 웹훅을 보내주면 받아서 DB 업데이트하면 끝. 이러면 되는 줄 알았거든요.
// 초기 버전 - 너무 순진했다
@PostMapping("/webhook/contract-change")
public ResponseEntity<Void> handleContractChange(@RequestBody ContractChangeEvent event) {
Contract contract = contractRepository.findByExternalId(event.getContractId());
contract.update(event);
contractRepository.save(contract);
return ResponseEntity.ok().build();
}근데 운영 시작한 지 한 달도 안 돼서 고객센터 연락이 오기 시작했습니다.
“계약 연장했는데 앱에서는 만료 예정이라고 계속 떠요.”
원인을 찾아보니…
로그를 까보니 원인은 생각보다 다양했습니다.
케이스 1: 네트워크 타임아웃
2025-10-15 14:23:45 [ERROR] Webhook timeout from external system
Connection timeout after 30000ms
Contract ID: 12345
Current Status: ACTIVE → 실제로는 RENEWED 되었어야 함레거시 시스템이 웹훅을 보내다가 타임아웃이 나면? 우리는 못 받습니다. 그러면 레거시 쪽에서는 계약이 연장됐는데, 우리 쪽은 여전히 만료 예정인 상태로 남아있는 거죠.
케이스 2: 배포 중 요청 유실
2025-10-15 03:15:23 [INFO] Starting deployment...
2025-10-15 03:15:45 [WARN] Incoming webhook rejected - server shutting down
2025-10-15 03:16:12 [INFO] Deployment completed하필 저희 서버가 배 포 중이면? 요청은 유실됩니다. 로드밸런서가 있어도 완벽하지 않았어요. 배포는 주로 새벽에 하는데, 레거시 시스템도 새벽 배치로 계약을 처리하다 보니 타이밍이 겹쳤던 겁니다.
케이스 3: 동시성 문제
// 스레드 1: 계약 상태 변경 (ACTIVE → RENEWED)
contract.setStatus(Status.RENEWED);
contractRepository.save(contract); // 2025-10-15 14:23:45.123
// 스레드 2: 거의 동시에 다른 정보 변경 (연락처 업데이트)
contract.setPhoneNumber("010-1234-5678");
contractRepository.save(contract); // 2025-10-15 14:23:45.156
// 결과: 상태는 ACTIVE로 되돌아가고, 연락처만 변경됨 😱두 개의 웹훅이 거의 동시에 들어오면 레이스 컨디션이 발생합니다. JPA의 더티 체킹 때문에 나중에 저장된 엔티티가 이전 값을 덮어쓰는 거죠.
솔직히 처음엔 “그냥 다시 API 호출하면 되지 않나요?”라고 가볍게 생각했습니다. 그런데 고객센터에서 계속 같은 문의가 들어오더군요. 결국엔 개발자가 수동으로 DB 들어가서 수정해주는 일이 잦아졌습니다.
이건 아니다 싶었죠. 시스템을 고쳐야 했습니다.
전략 1: 멱등성(Idempotency) - 두 번 처리해도 괜찮게
가장 먼저 한 일은 “재시도는 언제나 일어날 수 있다”는 사실을 받아들이는 것이었습니다.
네트워크 오류가 발생하면 클라이언트는 재시도를 합니다. 당연한 거죠. 문제는 같은 요청이 두 번 처리되어 데이터가 꼬이면 안 된다는 겁니다.
멱등성이란?
간단히 말하면, “같은 요청을 여러 번 보내도 결과가 동일한 것”입니다.
1번 실행: A → B
2번 실행: A → B (B → B가 아님!)
3번 실행: A → B예를 들어볼까요?
// ❌ 멱등하지 않음 - 실행할 때마다 증가
account.balance += 1000;
// ✅ 멱등함 - 여러 번 실행해도 같은 결과
account.balance = 5000;DB의 Unique Key로 해결
가장 확실한 방법은 DB 유니크 제약조건을 활용하는 것이었습니다. 외부 시스템에서 넘어오는 고유 ID를 별도 테이블에 기록해서 중복 처리를 막았습니다.
@Entity
@Table(
name = "event_history",
indexes = @Index(name = "idx_event_id", columnList = "eventId", unique = true)
)
public class EventHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String eventId; // 외부 시스템의 이벤트 고유 ID
private LocalDateTime processedAt;
}@Service
@RequiredArgsConstructor
public class ContractSyncService {
private final EventHistoryRepository eventHistoryRepository;
private final ContractRepository contractRepository;
@Transactional
public void handleWebhook(ContractChangeEvent event) {
// 이미 처리된 이벤트인지 확인
if (eventHistoryRepository.existsByEventId(event.getId())) {
log.info("Already processed event: {}", event.getId());
return; // 멱등성 보장: 이미 처리된 요청은 무시
}
try {
// 비즈니스 로직 처리
Contract contract = contractRepository.findByExternalId(event.getContractId())
.orElseThrow(() -> new EntityNotFoundException("Contract not found"));
contract.update(event);
contractRepository.save(contract);
// 처리 이력 저장 (다음번에 중복 체크용)
eventHistoryRepository.save(EventHistory.builder()
.eventId(event.getId())
.processedAt(LocalDateTime.now())
.build());
log.info("Successfully processed event: {}", event.getId());
} catch (Exception e) {
log.error("Failed to process event: {}", event.getId(), e);
throw e; // 트랜잭션 롤백
}
}
}실제로 테스트해보기
정말 멱등한지 테스트 코드로 확인했습니다.
@Test
void 같은_이벤트를_두번_처리해도_한번만_적용된다() {
// Given
ContractChangeEvent event = ContractChangeEvent.builder()
.id("event-12345")
.contractId("contract-001")
.status(ContractStatus.RENEWED)
.build();
// When - 같은 이벤트를 2번 처리
contractSyncService.handleWebhook(event);
contractSyncService.handleWebhook(event); // 중복 요청
// Then - 처리 이력은 1건만
List<EventHistory> histories = eventHistoryRepository.findAll();
assertThat(histories).hasSize(1);
// 계약 상태도 정확히 1번만 업데이트됨
Contract contract = contractRepository.findByExternalId("contract-001").get();
assertThat(contract.getStatus()).isEqualTo(ContractStatus.RENEWED);
}사실 처음엔 “이렇게까지 해야 하나?” 싶었는데요. 막상 적용하고 나니 마음이 편하더군요. 분산 환경에서는 멱등성 확보가 정말 기본이라고 느꼈습니다.
주의할 점
이벤트 이력 테이블이 무한정 늘어나면? 정기적으로 정리해줘야 합니다.
@Scheduled(cron = "0 0 4 * * *") // 매일 새벽 4시
public void cleanupOldEventHistory() {
LocalDateTime oneMonthAgo = LocalDateTime.now().minusMonths(1);
int deleted = eventHistoryRepository.deleteByProcessedAtBefore(oneMonthAgo);
log.info("Cleaned up {} old event histories", deleted);
}1개월 지난 이벤트는 충분히 안전하다고 판단하고 삭제했습니다. 실제로는 일주일 지난 이벤트도 재처리될 일은 거의 없지만, 여유를 두고 한 달로 설정했습니다.
전략 2: 보정 스케줄러 - 새벽에 조용히 고쳐주기
멱등성을 확보해도 문제는 남았습니다. 아예 요청이 유실되는 경우죠. 웹훅 서버가 죽었거나, 네트워크가 완전히 단절된 상황이라면 실시간 동기화는 실패할 수밖에 없습니다.
그래서 생각한 게 “최후의 보루”였습니다. 실시간 동기화가 실패해도 어쨌든 맞춰주는 장치가 필요했죠.
Eventual Consistency - 결국에는 맞으면 된다
분산 시스템에는 CAP 정리라는 게 있습니다.
- Consistency (일관성): 모든 노드가 같은 데이터를 본다
- Availability (가용성): 모든 요청이 응답을 받는다
- Partition tolerance (분할 내성): 네트워크가 끊겨도 동작한다
이 중 3개를 다 만족시킬 수는 없습니다. 항상 2개만 선택할 수 있죠.
저희는 AP를 선택했습니다. 가용성과 분할 내성을 챙기고, 일관성은 Eventual Consistency(최종적 일관성)로 타협하는 거죠.
즉, “지금 당장은 안 맞을 수 있지만, 결국에는 맞춰질 거다”라는 전략입니다.
매일 새벽 돌리는 Self-Healing
매일 새벽 3시에 양쪽 시스템의 데이터를 비교하고 맞추는 배치를 돌렸습니다.
@Component
@Slf4j
@RequiredArgsConstructor
public class ContractReconciliationScheduler {
private final ExternalSystemClient externalClient;
private final ContractRepository contractRepository;
private final SlackNotifier slackNotifier;
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시
public void reconcileContracts() {
log.info("=== 계약 데이터 보정 배치 시작 ===");
LocalDate yesterday = LocalDate.now().minusDays(1);
int fixedCount = 0;
int totalChecked = 0;
try {
// 어제 변경된 계약 건들 조회 (외부 시스템)
List<Contract> externalContracts = externalClient.findChangedContracts(yesterday);
totalChecked = externalContracts.size();
log.info("외부 시스템에서 {} 건 조회 완료", totalChecked);
for (Contract external : externalContracts) {
Contract local = contractRepository
.findByExternalId(external.getExternalId())
.orElse(null);
if (local == null) {
// 우리 쪽에 아예 없는 계약 → 새로 생성
contractRepository.save(external);
fixedCount++;
log.warn("Missing contract created: {}", external.getExternalId());
continue;
}
// 데이터 비교
if (!local.isSameAs(external)) {
log.warn("Data mismatch! id: {}", external.getExternalId());
log.warn("Local: {}", local);
log.warn("External: {}", external);
// 강제 동기화
local.sync(external);
contractRepository.save(local);
fixedCount++;
}
}
log.info("=== 계약 데이터 보정 배치 완료 ===");
log.info("체크: {} 건, 수정: {} 건", totalChecked, fixedCount);
// 수정된 건이 있으면 슬랙 알림
if (fixedCount > 0) {
slackNotifier.send(String.format(
"⚠️ 계약 데이터 불일치 %d건 자동 보정 완료\n체크: %d건, 수정: %d건",
fixedCount, totalChecked, fixedCount
));
}
} catch (Exception e) {
log.error("계약 데이터 보정 배치 실패", e);
slackNotifier.send("🚨 계약 데이터 보정 배치 실패: " + e.getMessage());
}
}
}팀원의 질문과 대답
처음에 팀원이 물어봤습니다.
“이거 매일 전체 데이터 비교하면 부하가 크지 않나요?”
맞는 말이었습니다. 처음엔 전체 계약을 다 비교하려고 했거든요. 약 10만 건 정도 되는데, 하나씩 비교하면 시간도 오래 걸리고 DB 부하도 심할 것 같았습니다.
그래서 전략을 수정했습니다.
- 전날 변경된 건만 체크 - 99% 케이스 커버
- 주 1회 전체 비교 (일요일 새벽) - 혹시 모를 오래된 불일치 체크
@Scheduled(cron = "0 0 3 * * SUN") // 매주 일요일 새벽 3시
public void reconcileAllContracts() {
log.info("=== 전체 계약 데이터 보정 (주간) ===");
// 최근 1개월 계약 전체 체크
LocalDate oneMonthAgo = LocalDate.now().minusMonths(1);
List<Contract> allContracts = externalClient.findContractsAfter(oneMonthAgo);
// ... 동일한 로직
}실제로 돌려보니 전날 변경 건만 체크하는 건 5분 내외로 끝났고, 주간 전체 체크도 30분 정도면 충분했습니다. 새벽 시간대라 부하는 거의 문제가 안 됐어요.
운영 결과
이 스케줄러를 도입한 후 데이터 불일치 문의가 거의 사라졌습니다.
실제 통계를 뽑아봤습니다:
[2025년 10월 운영 통계]
총 체크: 약 30만 건 (일 1만 건 × 30일)
수정: 23건 (0.008%)
주요 원인:
- 네트워크 타임아웃: 12건
- 배포 중 유실: 7건
- 기타: 4건실시간 동기화가 실패해도 다음 날 아침이면 알아서 맞춰져 있으니까요. 개발자가 수동으로 DB 고치는 일도 없어졌고요.
시스템이 스스로 치유(Self-Healing)하도록 만드는 것, 이게 운영 효율에 정말 큰 도움이 됐습니다.
Trade-off
물론 완벽하진 않습니다.
장점:
- 유실된 데이터 자동 복구
- 개발자 수동 작업 제거
- 데이터 정합성 신뢰도 향상
단점:
- 최대 24시간 지연 발생 가능
- 외부 API 호출 비용 (하루 1만 건)
- 배치 로직 유지보수 필요
저희 서비스는 B2B 특성상 24시간 지연은 크게 문제가 안 됐습니다. 대부분 사용자는 다음 날 확인하거든요. 만약 실시간성이 중요한 서비스라면 보정 주기를 1시간마다로 줄이는 것도 방법입니다.
전략 3: 낙관적 업데이트 - 사용자는 기다리지 않게
데이터 정합성도 중요하지만, 사용자 경험도 무시할 수 없었습니다. 동기화 때문에 로딩이 3초씩 걸리면 사용자는 답답해하거든요.
그래서 프론트엔드 팀과 협의해서 낙관적 업데이트(Optimistic Update)를 도입했습니다.
기존 방식 vs 낙관적 업데이트
기존 방식 (비관적 업데이트):
사용자 클릭
→ 로딩 표시
→ 서버 요청
→ 외부 API 호출 (2초)
→ DB 저장
→ 응답 받음
→ 화면 갱신
총 소요 시간: 약 3초낙관적 업데이트:
사용자 클릭
→ 즉시 화면 갱신 (0.1초)
→ (백그라운드) 서버 요청
→ (백그라운드) 처리 완료
사용자 체감 시간: 0.1초
실제 처리 시간: 3초 (백그라운드)프론트엔드 구현 예시
// React + React Query 예시
const useUpdateContract = () => {
const queryClient = useQueryClient();
return useMutation(
(data) => api.updateContract(data),
{
// 낙관적 업데이트: 서버 응답 전에 먼저 UI 변경
onMutate: async (newData) => {
// 기존 쿼리 취소
await queryClient.cancelQueries(['contract', newData.id]);
// 이전 값 백업 (실패 시 롤백용)
const previousData = queryClient.getQueryData(['contract', newData.id]);
// 즉시 UI 업데이트 (서버 응답 대기 X)
queryClient.setQueryData(['contract', newData.id], (old) => ({
...old,
...newData,
status: 'RENEWED' // 사용자는 즉시 "연장 완료" 보임
}));
return { previousData };
},
// 실패 시 롤백
onError: (err, newData, context) => {
queryClient.setQueryData(
['contract', newData.id],
context.previousData
);
toast.error('계약 연장에 실패했습니다. 다시 시도해주세요.');
},
// 성공 시 서버 데이터로 재확인
onSuccess: (data) => {
queryClient.invalidateQueries(['contract', data.id]);
}
}
);
};백엔드는 비동기 처리
서버는 요청을 받으면 즉시 응답하고, 실제 처리는 비동기로 진행합니다.
@RestController
@RequiredArgsConstructor
public class ContractController {
private final ContractUpdateQueue updateQueue;
@PutMapping("/api/contracts/{id}/renew")
public ResponseEntity<ContractResponse> renewContract(
@PathVariable Long id,
@RequestBody ContractRenewRequest request
) {
// 큐에 넣고 즉시 응답 (0.05초)
updateQueue.enqueue(ContractUpdateTask.builder()
.contractId(id)
.action(UpdateAction.RENEW)
.requestData(request)
.build());
// 낙관적 응답: "처리 중"이지만 성공할 거라 가정
return ResponseEntity.ok(ContractResponse.builder()
.id(id)
.status(ContractStatus.RENEWED)
.message("계약 연장이 진행 중입니다")
.build());
}
}@Component
@Slf4j
public class ContractUpdateWorker {
@Async("contractUpdateExecutor")
public void processUpdate(ContractUpdateTask task) {
try {
// 실제 외부 API 호출 및 DB 저장 (2~3초 소요)
externalClient.renewContract(task.getContractId());
Contract contract = contractRepository.findById(task.getContractId()).get();
contract.renew();
contractRepository.save(contract);
log.info("Contract renewed successfully: {}", task.getContractId());
} catch (Exception e) {
log.error("Failed to renew contract: {}", task.getContractId(), e);
// 실패 시 사용자에게 푸시 알림 (또는 앱 내 알림)
notificationService.sendFailureNotification(
task.getContractId(),
"계약 연장 처리에 실패했습니다. 고객센터로 문의해주세요."
);
}
}
}물론 이건 데이터 정합성을 포기하는 게 아닙니다. “사용자가 느끼는 정합성”과 “실제 데이터 정합성”의 시점을 분리한 거죠. 백엔드에서는 큐를 통해 비동기로 확실하게 처리해주면 됩니다.
실제 효과
사용자 경험이 확실히 개선됐습니다.
Before:
- 계약 연장 버튼 클릭
- "처리 중..." 3초 대기 😰
- "완료!" 메시지
After:
- 계약 연장 버튼 클릭
- 즉시 "완료!" 화면 갱신 😊
- (백그라운드) 처리 진행고객 만족도 조사 결과 “앱이 느리다”는 응답이 30%에서 5%로 감소했습니다.
추가로 고려한 것들
1. 모니터링과 알림
데이터 불일치가 발생하면 즉시 알아야 합니다.
@Component
public class DataConsistencyMonitor {
@Scheduled(fixedDelay = 300000) // 5분마다
public void checkConsistency() {
int mismatchCount = contractRepository.countMismatchedContracts();
if (mismatchCount > 10) { // 임계값
slackNotifier.send(
"🚨 계약 데이터 불일치 급증: " + mismatchCount + "건"
);
}
}
}2. 재시도 정책
외부 API 호출 실패 시 재시도 로직을 추가했습니다.
@Retryable(
value = {ExternalApiException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public Contract syncFromExternalSystem(String externalId) {
return externalClient.getContract(externalId);
}3. 데이터 버전 관리
동시성 문제를 완벽히 해결하기 위해 낙관적 락을 도입했습니다.
@Entity
public class Contract {
@Id
private Long id;
@Version
private Long version; // JPA 낙관적 락
// ... 다른 필드들
}이러면 동시에 수정하려 할 때 OptimisticLockException이 발생하고, 재시도 로직이 작동합니다.
결론
솔직히 말하면, 이 모든 게 “웹훅만 믿으면 되겠지”라는 안일한 생각에서 시작됐습니다.
모놀리식에서는 트랜잭션 하나면 끝나던 게, 분산 환경에서는 이렇게 복잡해지더군요.
분산 환경에서 완벽한 실시간 동기화는 사실상 불가능합니다. 아니, 정확히 말하면 “보장할 수 없습니다”. 네트워크는 언제든 끊길 수 있고, 서버는 언제든 재시작될 수 있으니까요.
그러면 어떻게 해야 할까요?
저희 팀이 내린 결론은 “결국에는 맞아떨어지게 만들자(Eventual Consistency)”였습니다.
- 멱등성으로 중복을 막고
- 보정 스케줄러로 유실을 복구하고
- 낙관적 업데이트로 사용자 경험을 챙기는
이 세 가지 전략을 조합하니까 운영이 정말 편해졌습니다. 고객센터 문의도 줄었고, 개발자가 새벽에 DB 수정하는 일도 없어졌습니다.