BACKEND

서로 다른 두 시스템 DB의 데이터 정합성 맞추기: Application 수준에서 보상 트랜잭션 적용하여 eventual consistency 적용한 실무 기록

콧차뇽 2026. 5. 5.

 

"Spring Boot와 Kong Gateway 간 DB 분산 트랜잭션 구현하기

 

 

분산 트랜잭션이 필요한 상황에 놓이다


<상황>

  • 사내 인증 서비스(Authentication)의 DB와 Kong-Gateway의 DB, 서로 다른 두 시스템의 DB에 같은 비즈니스 사실을 반영해야 합니다. 그래서 두 DB는 데이터 정합성이 맞아야 합니다. 어느 한 쪽만 성공하고 다른 쪽이 실패한다면 orphan 데이터가 발생합니다.
  • 같은 비즈니스 사실이 반영되는 각 서비스의 DB 테이블명은 아래와 같습니다.
서비스 DB Kong-gateway DB
group consumers
application oauth2_credentials
token oauth2_tokens

 

 

<문제>

  • Kong API 호출(HTTP 통신)은 외부 시스템이므로 DB의 트랜잭션 범위에 포함되지 않습니다.
  • 전형적인 실패 케이스
    1. 서비스 DB commit 성공 → Kong API 호출 실패 → 서비스 DB에는 Group이 있는데 Kong DB에는 consumers가 없음
    2. Kong API 호출 성공 → 서비스 DB commit 실패 → Kong DB에는 consumers가 있는데 서비스 DB에는 Group이 없음
    3. 네트워크 단절로 Kong 응답을 못 받음 → 실제로는 성공/실패인지 모름
  • 분산 트랜잭션(2PC,XA) 못 쓰는 환경(Kong이 외부 시스템)이라 eventual consistency로 풀 수밖에 없습니다.

 

선택한 해결책 - 보상 트랜잭션


"한 단계 실패 시 이미 성공한 단계를 반대 작업으로 되돌린다" 는 패턴.

 

왜 이 방식을 선택했는가?

  • 분산 트랜잭션(2PC,XA) 기법을 못 쓰는 환경(Kong이 외부 시스템)이라 Saga 패턴(보상 트랜잭션)을 활용한 eventual consistency로 이 문제를 해결하기로 했습니다.
  • 장점: 메인 비즈니스 로직과 외부 API 호출 로직을 분리(Decoupling)
  • 단점: 서비스간 느슨한 결합으로, 보상 트랜잭션이 실패하면 데이터의 일관성이 깨지므로 모니터링/수동 운영이 필요
  • 보상 트랜잭션
    • 리소스 생성 관련(Create)
      • ‘Group 생성 실패’에 대한 보상 → Kong consumers 삭제
      • ‘Application 생성 실패’에 대한 보상 → Kong oauth2_credential 삭제
      • ‘Token 생성 실패’에 대한 보상 → Kong oauth2_token 삭제
    • 리소스 삭제 관련(Delete)
      • ‘Group 삭제 실패’에 대한 보상 → Kong consumers 재생성
      • ‘Application 삭제 실패’에 대한 보상 → Kong oauth2_credential 재생성
      • ‘Token 삭제 실패’에 대한 보상 → Kong oauth2_token 재생성

 

‘실패 발생 시점’ 분기와 각 케이스별로 어떻게 리소스가 보호되는가?

  실패 발생 시점 결과
1 서비스 DB flush에서 제약 위반 트랜잭션 롤백, Kong API 호출 안 함, 이벤트 발행 안 함 → 일관성 유지
2 Kong API 호출 후 4xx/5xx 응답 트랜잭션 롤백, DB 변경 되감김, 이벤트 발행 안 함 → 일관성 유지
3 Kong 성공 후 publishEvent도 성공, commit 직전/중 실패(제약, 인프라) 트랜잭션 롤백 → AFTER_ROLLBACK 발화 → Kong API 보상 호출 → 일관성 유지(보상 성공 시)
4 보상 트랜잭션도 실패 일관성 깨짐 → 로그/알람으로 운영 개입 신호 발생

 

4번 ‘보상 트랜잭션도 실패’ 케이스가 이 패턴의 한계입니다. 보상 트랜잭션도 실패하면 일관성이 깨집니다. 그래서 사람이 수동으로 DB 데이터를 정리하는 등 모니터링/수동 운영이 필요합니다.

 

이 패턴의 한계

  • 보상 트랜잭션을 시도하는게 At-most-once(최대 1번)입니다. 그래서 보상 트랜잭션이 실패하면 데이터의 일관성이 깨지게 됩니다.
  • 그러므로, 보상 트랜잭션이 실패하면 운영 이슈로 수동 정리가 필요합니다  그래서 실패 로그가 명확해야 합니다.
    • 로그 메시지에 아래와 같은 식별 키 필요
      • KONG_CONSUMER_ORPHAN_ROW_CREATED ← ‘Group 생성 실패’에 대한 보상 실패
      • KONG_CONSUMER_ROW_DELETED ← ‘Group 삭제 실패’에 대한 보상 실패
      • KONG_APPLICATION_ORPHAN_ROW_CREATED ← ‘Application 생성 실패’에 대한 보상 실패
      • KONG_APPLICATION_ROW_DELETED ← ‘Application 삭제 실패’에 대한 보상 실패
      • KONG_TOKEN_ORPHAN_ROW_CREATED ← ‘Token 생성 실패’에 대한 보상 실패
      • KONG_TOKEN_ROW_DELETED ← ‘Token 삭제 실패’에 대한 보상 실패

 

구현 매커니즘: @TransactionalEventListener를 활용한 사후 처리


구현 방식: 메인 로직 수행 중 에러 발생 시, AFTER_ROLLBACK 리스너가 동작하여 Kong API에 생성되었던 자원을 삭제하거나 무효화하는 로직을 호출.

 

서비스 DB 리소스 생성 + Kong 시스템 호출 + 이벤트 발행

@Transactional
@Service
@RequiredArgsConstructor
public class GroupService {

    private final GroupRepository groupRepository;
    private final KongApiClient kongApiClient;
    private final ApplicationEventPublisher eventPublisher;

    public Group createGroup(...) {
        groupRepository.save(group);
        groupRepository.flush();  // (1) DB 변경을 SQL로 즉시 확정

        apiResponse = kongApiClient.createConsumer(...);  // (2) Kong API 호출
        ConsumerEvent.Created event = ConsumerEvent.Created.from(apiResponse)
        
        eventPublisher.publishEvent(event); // (3) 보상용 이벤트 등록
    }
}

 

보상 트랜잭션 처리(트랜잭션 롤백 발생 시)

@Service
@RequiredArgsConstructor
public class KongConsumerEventListener {

    private final KongApiClient kongApiClient;

    // 생성 실패 시 
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void handleConsumerCreatedEvent(ConsumerEvent.Created event) {
        if (event != null) {
            try {
                kongApiClient.remove(event.consumerId(), event.id());
            } catch (RuntimeException e) {
                log.error("[KONG_CONSUMER_ORPHAN_ROW_CREATED] consumerId: {}, consumerName: {}",
                          event.groupId(), event.groupName(), e);
            }
        }
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void handleConsumerDeleteEvent(ConsumerEvent.Deleted event) {
        if (event != null) {
            try {
                kongApiClient.create(event.id(), event.consumerId(), event.applicationName());
            } catch (RuntimeException e) {
                log.error("[KONG_CONSUMER_ROW_DELETED] consumerId: {}, consumerName: {}",
                          event.groupId(), event.groupName(), e);
            }
        }
    }
}

 

구현 시 고려했던 사항


1. 작업 순서 고정: 서비스 DB에 ROW 생성 → 외부 시스템 호출 → 보상 트랜잭션 이벤트 발행

  • 별도의 제약이 없다면 서비스 DB에 먼저 ROW를 만들고, 그 값을 바탕으로 외부 시스템 API 호출해서 리소스를 만드는 순서를 강제했습니다.
    • 별도의 제약이 있는 경우: 예를 들어 외부 시스템 API 결과를 받아서 그 값으로 서비스 DB의 ROW를 만드는 경우, 서비스 DB에 ROW를 먼저 생성할 수 없습니다. 그래서 이런 경우는 무조건 ‘외부 시스템 호출 → 서비스 DB에 ROW 생성’ 순서가 강제되어야 합니다.

2. DELETE 멱등성 처리

  • 보상 트랜잭션에서 DELETE의 의미는 "없게 만들어라"입니다. 즉 삭제 요청 시, 삭제를 요청한 리소스의 실제 존재 여부에 관계없이 삭제처리가 되었다면 성공으로 봐야 보상 실패/성공 로그가 정확해집니다.
    • Kong Delete API를 호출했는데 이미 해당 리소스가 지워져서 서버로부터 404 응답을 받는 경우, 보상 관점에선 "이미 목표 상태 도달"인 것이므로 예외가 아닙니다.
    • 그래서 별도로 삭제 API 호출 시, 404 응답을 받는 경우 try-catch 문에서 무시하도록 처리했습니다.
public void remove(UUID consumerId) {
    try {
        restClient.delete()
                  .uri(kongRemoveConsumerUrl.replace("{consumer_id}", consumerId.toString()))
                  .retrieve()
                  .toBodilessEntity();
    } catch (ResourceNotFoundException ignored) {
        // KONG DELETE API의 404 에러 멱등성 처리를 위한 try-catch
    }
}

 

 

부록


'서비스 DB에 반영 → 외부 시스템 호출' 순서가 구조적으로 더 안전한 이유

  • 보상 트랜잭션 유무와 별개로 '서비스 DB 반영 → 외부 시스템 호출' 순서가 구조적으로 더 안전합니다."
  • 이유: "서비스 DB 롤백은 신뢰할 수 있고 공짜지만, 외부 시스템 보상은 best-effort이므로 신뢰할 수 없기 때문입니다"

 

<1. 롤백의 비대칭성>

  • 서비스 DB에 반영하는게 먼저면, "서비스 DB에 반영 성공, 외부 시스템 호출 실패"만 케어하면 되고, 이건 서비스 DB 롤백 한 번이면 끝납니다.
  • 외부 시스템 호출하는게 먼저면, "외부 시스템 호출 성공, 서비스 DB에 반영 실패"를 케어해야 하는데, 이건 외부 시스템 API를 또 호출해야 해서 새 실패 국면이 열리게 됩니다.

<2. 제약 위반의 조기 감지>

  • 서비스 DB에 반영하는게 먼저면, unique/FK/NOT NULL 같은 제약으로 인한 에러가 외부 시스템을 호출하기 전에 먼저 발생합니다. 그러면 비용이 비싼 외부 시스템 호출 자체를 안할 수 있습니다.
  • 외부 시스템 호출하는게 먼저면, 외부 시스템 호출 성공으로 외부에 리소스가 생성되는 부수효과 만들고 나서, 서비스 DB 제약에 걸려 ‘이미 만든 외부 시스템 리소스’를 지우러 가야합니다.

댓글