"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의 트랜잭션 범위에 포함되지 않습니다.
- 전형적인 실패 케이스
- 서비스 DB commit 성공 → Kong API 호출 실패 → 서비스 DB에는 Group이 있는데 Kong DB에는 consumers가 없음
- Kong API 호출 성공 → 서비스 DB commit 실패 → Kong DB에는 consumers가 있는데 서비스 DB에는 Group이 없음
- 네트워크 단절로 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 재생성
- 리소스 생성 관련(Create)
‘실패 발생 시점’ 분기와 각 케이스별로 어떻게 리소스가 보호되는가?
| 실패 발생 시점 | 결과 | |
| 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 제약에 걸려 ‘이미 만든 외부 시스템 리소스’를 지우러 가야합니다.
댓글