Notice
Recent Posts
Recent Comments
Link
«   2026/07   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

DevYGwan

Keycloak과 DB를 한 트랜잭션처럼 — 외부 인증 서버 연동에 보상 트랜잭션(SAGA) 적용기 본문

카테고리 없음

Keycloak과 DB를 한 트랜잭션처럼 — 외부 인증 서버 연동에 보상 트랜잭션(SAGA) 적용기

YGwan 2026. 6. 20. 14:03

 사내 B2B 플랫폼에 "여러 사용자를 한 번에 추가하는 API"를 만들면서 마주친 분산 데이터 정합성 문제와, 이를 경량 보상 트랜잭션으로 푼 의사결정 과정을 정리하려고 합니다. 저희는 사용자를 관리할 때 크게 2곳에서 관리합니다. 인증만을 관리하는 keycloak 서버 & 유저의 개인 정보를 갖고 있는 DB(Postgres)에서 관리합니다. 사용자 생성은 Keycloak(인증 서버) 과 우리 서비스 DB(Postgres) 두 곳에 일어납니다. 두개의 서버는 하나의 트랜잭션을 공유할 수 없어 특정 과정에서 문제가 생기면, 데이터 정합성에 문제가 생기는 경우가 발생합니다. 예를 들어, Keycloak 생성은 성공했는데 DB에 생성이 안되게 되면, 인증 서버에 고아(orphan) 계정이 남게 되는 경우가 생길 수 있습니다. 따라서 이러한 정합성 문제를 해결하기 위해 SAGA의 보상 트랜잭션 개념으로 풀되, DB가 1개뿐인 우리 상황에 맞춰 Spring 트랜잭션 동기화(afterCompletion) 로 "DB 커밋 실패 시 Keycloak 생성을 되돌리는" 경량 구조를 택했습니다.

 


1. 문제 상황

 관리자가 사용자 목록을 한 번에 등록하는 기능. 사용자 1명은 두 시스템에 만들어집니다.

1. Keycloak      : 인증 계정 생성 (인증에 필요한 유저 정보)
2. Service DB    : UserEntity 저장 (우리 서비스에 필요한 유저 정보)
  • 비즈니스적으로는 이 둘이 하나처럼 처리돼야 데이터 정합성이 맞춰지기 때문에, 한쪽만 만들어지거나 한쪽만 삭제되면 안됩니다.

 

왜 @Transactional 하나로 안 되는가

@Transactional은 우리 DB(Postgres) 트랜잭션만 관장합니다. Keycloak은 REST API로 호출하는 완전히 별개의 시스템이라 같은 트랜잭션에 참여하지 못합니다. 그래서 다음이 발생합니다.

1. Keycloak에 계정 생성 성공   (외부 호출, 트랜잭션 밖에서 이미 확정됨)
2. DB에 UserEntity 저장 시도
3. DB commit 단계에서 실패      (예: email unique 제약 위반)
4. DB 트랜잭션 롤백 → 우리 DB엔 사용자 없음
5. 그러나 Keycloak엔 계정이 그대로 남음

결과는 Keycloak에 orphan(고아) 계정이 생기게 됩니다. 우리 DB엔 매칭되는 row가 없는데 인증 서버엔 로그인 가능한 계정이 떠 있는, 두 저장소의 불일치 상태가 됩니다.

까다로운 지점: DB의 unique 제약 위반은 flush/commit 시점, 즉 서비스 메서드 본문이 리턴된 뒤 터집니다.
그래서 단순히 메서드 안의 단순 try/catch로는 이 케이스를 잡을 수 없다.

 

따라서 이러한 문제를 해결하기 위해 분산 환경에서의 트랜잭션 처리를 통해 이러한 데이터 정합성을 해결하려고 합니다.

 


2. 개념 정리 — SAGA 패턴과 보상 트랜잭션

 

※ 분산 트랜잭션 문제

모놀리식에서는 단일 DB가 ACID로 원자성을 보장합니다. 하지만 작업이 여러 시스템(DB, 외부 API…)에 걸치면 하나의 ACID 트랜잭션으로 묶을 수 없습니다. 트랜잭션이란 하나의 데이터베이스의 상태를 변화시키기 위해 수행하는 ‘작업의 논리적 단위’이기 때문입니다. 따라서 여러 DB를 사용할 경우 하나가 실패해도 이미 처리된 작업이 남아 정합성이 깨집니다.

이건 단순히 MSA만의 얘기가 아닙니다. 단일 서비스라도 외부 API(여기선 Keycloak)를 끼는 순간, 여러 서버로 관리되는 서비스에서 겪은 것과 본질적으로 같은 분산 트랜잭션 문제가 생깁니다. 규모만 다를 뿐 구조는 동일하기 때문에 분산 트랜잭션 문제가 발생할 수 있습니다. (실제로 외부 API 자체가 별도의 서버를 호출하는거기 때문에)

 

 크게 분산 트랜잭션 문제를 해결하는 방식은 2PC(Two-Phase Commit)과 SAGA 패턴이 있습니다. 저희는 이중 SAGA 패턴을 사용해서 이러한 문제를 해결했습니다.

 

2PC(Two-Phase Commit)란

  • 2PC는 코디네이터(트랜잭션 매니저) 한 명과 참여자(리소스 매니저, 보통 DB들) 여럿으로 구성됩니다. 이름처럼 커밋을 두 단계로 쪼갭니다.
  • 2PC는 이름 그대로 두 단계로 커밋합니다.
    • 투표 단계(Prepare) : 코디네이터가 모든 참여자에게 "커밋 가능?"을 묻고, 각 참여자는 트랜잭션을 열어둔 채 가부를 답한다.
    • 커밋 단계(Commit) : 전원이 "가능"이면 코디네이터가 커밋 명령을, 단 하나라도 "불가"면 롤백 명령을 보낸다.

문제는 이 구조의 비용이다.

- 구현·운영 복잡
- 성능 부담 (가장 느린 참여자의 투표까지 락을 유지)
- 장애 시 블로킹 (coordinator 다운 → 전원 대기)
- 모든 시스템이 XA를 지원하지 않음 ← Keycloak이 정확히 이 경우
  • 참여자가 늘어날수록 이 비용은 가파르게 커집니다.
  • 가장 느린 참여자의 투표까지 락을 유지하기 때문에 낮은 가용성과 낮은 확장성을 가집니다.
  • 하지만 코디네이터가 모든 참여자의 트랜잭션을 관리하기 때문에 강한 일관성을 가집니다.
  • 그래서 높은 트래픽과 확장을 전제하는 시스템일수록 2PC를 피하고 SAGA로 간다.

 


SAGA = 로컬 트랜잭션 + 보상 트랜잭션

  • SAGA는 여러 작업을 하나의 큰 트랜잭션처럼 묶는 대신, 각각의 작업을 작은 로컬 트랜잭션들의 흐름으로 나눕니다.
  • 그리고 중간에 실패하면 이미 성공한 작업에 대해 보상 트랜잭션을 실행합니다.
  • 따라서 강한 일관성을 포기하고 최종 일관성(eventual consistency) 을 택해 높은 가용성과 확장성을 가지게 합니다.
  • 작동 방식
    • 각 단계는 자기 시스템의 로컬 트랜잭션으로 수행한다.
    • 중간에 실패하면, 성공한 단계들을 보상 트랜잭션(compensating transaction) 으로 되돌린다.
    • 핵심은 보상이 DB rollback이 아니라 비즈니스적 역작업이라는 점이다 (이미 commit된 건 rollback 불가).

 

정방향 action & 보상 action 예시

정방향 Action 보상 Action
주문 생성 주문 취소
결제 승인 결제 취소
재고 차감 재고 복구
Keycloak 계정 생성 Keycloak 계정 삭제

 

두 가지 조율 방식

구분 Choreography Orchestration
작동 방식 각 서비스가 이벤트를 주고받으며 자율 실행 중앙 Orchestrator가 흐름·보상을 지휘
결합도 중앙 제어자 없음 → 단일 장애점 없음, 결합도 낮음 흐름이 한곳에 모여 상태 추적 쉬움 -> 오케스트러에 의존
디버깅 어려움 쉬움
복잡도 서비스가 적을 땐 단순하지만, 많아질수록 어려움 흐름을 한곳에서 관리하기 때문에 서비스가 많을 때 유리함

 

저희는 Orchestration을 택했습니다.
따라서 UserService가 "Keycloak 생성 → User DB 저장 → 실패 시 Keycloak 삭제"를 직접 지휘합니다.

 


의사결정 — 풀 SAGA를 가지 않은 이유

 실무에서는 멱등성 + 상태 테이블 + 재처리 스케줄러를 한 세트로 가져가는 SAGA를 권장합니다. 강력하지만 그만큼 복잡하고 무겁죠. 저희 상황을 냉정히 보면 이 처리가 다 필요하진 않았습니다.

 

상태 테이블이  필수적이지 않은 이유
상태 테이블은 "이 SAGA가 지금 어느 단계까지 갔나"를 외부에 영속화해, 중간에 죽어도 다시 주워서 이어가기 위한 겁니다. 그런데 우리 단계는 사실상 외부 1 + DB 1뿐이고, 흐름이 단일 트랜잭션 안에서 동기로 끝납니다. 즉 DB 커밋 성공/실패 그 자체가 이미 완벽한 상태 표현이에요. 커밋됐으면 끝난 것, 안 됐으면 보상한 것이기 때문에 별도 상태 머신을 둘 만큼 단계가 많지도, 비동기로 흩어지지도 않았습니다.

재처리 스케줄러가 필수적이지 않은 이유
재처리 스케줄러는 상태 테이블에 "멈춰있는 SAGA"가 쌓인다는 전제에서 그걸 주기적으로 긁어 재시도하는 장치입니다. 위에서 상태 테이블을 안 두기로 한 순간, 재처리할 대상 자체가 없습니다. 우리는 실패하면 그 자리에서 afterCompletion이 동기로 보상하거나, 보상마저 실패하면 알람으로 사람에게 넘깁니다. "나중에 배치가 주워서 처리"할 큐가 존재하지 않기 때문에 이러한 재처리 스케줄러도 필수적이지 않았습니다.

멱등성만은 남긴 이유 (대조점)
반면 멱등성은 풀세트 중 유일하게 우리도 가져갔습니다. 보상(삭제)이 재시도되거나, 이미 없는 계정을 또 지우려 할 수 있고, 같은 이메일이 중복 생성되면 안되기 때문입니다.

 

결론적으로, "DB는 진짜 트랜잭션 1개로 묶고, 트랜잭션 밖의 Keycloak만 보상한다." 의 정책으로 데이터를 관리했습니다. 즉 DB 커밋 여부를 단일 진실 기준(source of truth)으로 삼는 경량 보상 & 신뢰성 높은 리소스 하나를 커밋 기준으로 두고 나머지를 맞추는 식으로 데이터 정합성을 맞췄습니다.
가장 중요하게 생각한 것은, 과설계(over-engineering)를 피하면서 정합성 문제를 해결하는 것이였습니다.

 

※ 처리 방식

 


※ 구현

Orchestration — 보상 등록 후 정방향 진행

  • UserService가 흐름을 지휘합니다.
  • 트랜잭션 시작 직후 보상을 먼저 등록하고, 만든 Keycloak id를 저장해 이후에 보상 트랜잭션을 위해 관리합니다.
  • 이때 keycloak 유저를 먼저 추가하고 이후에 유저를 추가합니다.
@Transactional(isolation = Isolation.REPEATABLE_READ, timeout = 30)
fun createUsers(customerId: String, users: List<CreateUserInput>): List<UserEntity> {
    val createdKeycloakUserIds = mutableListOf<String>()
    keycloakService.registerKeycloakCompensation(createdKeycloakUserIds) // afterCompletion 콜백 등록

    val entities = users.map { input ->
        id ← keycloak에 유저 추가
        createdIds.추가(id)
        entity ← DB에 유저 추가
    }
    return userRepository.saveAll(entities) // 단일 배치 insert
}
  • 각 유저는 Keycloak에 한 명씩 생성 (Keycloak에 배치 API가 없음)
  • DB는 일괄 저장(batch insert)으로 한 번에 처리
  • 정책은 All-or-nothing : 한 entry라도 실패하면 트랜잭션 전체 롤백

 

보상 등록 — afterCompletion 트랜잭션 동기화

  • 여기가 SAGA 패턴에서 가장 중요한 보상 처리 관련 코드입니다.
  • 보상을 트랜잭션 커밋 결과에 묶어서 관리합니다.
// Keycloak과 DB는 트랜잭션을 공유할 수 없는 별개 시스템이라, 보상 트랜잭션으로 되돌린다.
fun registerKeycloakCompensation(keycloakUserIds: List<String>) {
    TransactionSynchronizationManager.registerSynchronization(
        object : TransactionSynchronization {
            override fun afterCompletion(status: Int) {
                if (status != TransactionSynchronization.STATUS_COMMITTED) {
                    keycloakUserIds.forEach { deleteUser(keycloakUserId = it) }
                }
            }
        },
    )
}
  • afterCompletion은 트랜잭션이 커밋이든 롤백이든 끝난 뒤 호출.
  • STATUS_COMMITTED가 아닐 때만 보상(삭제)을 실행 → 정상 커밋 시엔 아무작업도 X.
  • keycloakUserIds를 콜백 안에서 lazy하게 읽으므로, 등록 이후 추가된 id까지 전부 보상 가능.

 

※ 왜 inline try/catch가 아니라 afterCompletion인가

실패 케이스 inline try/catch  afterCompletion
루프 중간 Keycloak 호출 실패 잡힘 잡힘 (롤백으로 처리됨)
saveAll commit 시 DB 제약 위반 못 잡음 (메서드 리턴 후 발생) 잡힘
  • DB 제약 위반이 메서드 본문 밖(커밋 시점)에서 터지기 때문에, 트랜잭션 생명주기 훅인 afterCompletion만이 두 케이스를 모두 일관되게 처리할 수 있습니다.

 

보상 실패는 전용 예외로 가시화

  • 보상(삭제)마저 실패하면 keycloak에 orphan이 확정되게 됩니다.
  • 이건 반드시 사람이 알아야 하는 상황이라 에러를 발생시켜 알림을 발생시킵니다.
  • 해당 알림이 발생하면, 개발자가 수동으로 이를 처리해 문제를 해결합니다.
fun deleteUser(keycloakUserId: String) {
    try {
        keycloakClient.realm(realm).users().delete(keycloakUserId).use { }
    } catch (e: Exception) {
        throw KeycloakUserDeleteException(keycloakUserId = keycloakUserId, cause = e)
    }
}
  • alert = true + debugInfo로 어떤 계정이 orphan인지 즉시 식별 가능하다.
  • 보상 자체가 실패해도 시스템이 조용히 깨지는 게 아니라, 알람이 뜨고 정리 정보가 남는다.

 

※ 멱등성 — SAGA에서 빠지면 안 되는 것

  보상과 각 단계는 재시도·중복 호출에도 안전해야 합니다.

  • 보상(삭제) 단계의 멱등성: 이미 없는 Keycloak 유저를 삭제 시도해도 치명적이지 않아야 합니다.

 생성 단계의 멱등성/충돌 방지: 이메일은 우리 DB의 unique 제약 + Keycloak username 유니크로 이중 보호됩니다. 따라서 같은 이메일 중복 생성이 조용히 통과하지 못한다. SAGA의 "각 단계 멱등 + unique key" 원칙을, DB 제약과 Keycloak의 유니크 username으로 충족시켰습니다.

 

이렇게 SAGA의 Orchestration 과 afterCompletion을 통한 보상 처리로 데이터 정합성 문제를 해결했습니다.

 


※ 남은 한계와 다음 단계

지금 구현은 의도적으로 best-effort를 맞추기 위한 보상입니다. 따라서 다음과 같은 한계가 있습니다.

  • 보상 삭제가 (네트워크 등으로) 실패하면 keycloak에 orphan이 남고, 예외로 알람만 뜨고 자동 재시도는 없습니다.
  • 한번이라도 실패하면, 개발자가 직접 알림을 확인해 별도의 처리를 해야합니다.
  • 상태 테이블 + 실패건 재 처리 스케쥴러 + 실패시 DLQ & Outbox 패턴 등은 구현되어 있지 않습니다.

이렇게 한 이유는 이런걸 처리하기 위한 추가적인 서비스 구축까지 할 정도로 해당 기능이 정밀하게 관리될 필요가 없었기 때문입니다. 엄청나게 큰 장애도 아니고, 문제 상황 또한 빠르게 처리될 필요 없고 직접 처리도 그렇게 어려운 것이 아니기 때문에 추가적인 인프라 구축 + 코드 처리 보단 간단하게 처리하고 이후에 다른 서비스에서 비슷한 처리가 계속해서 반복되어 모니터링이 필요하다면 이러한 기능들을 단계적으로 확장하기도 용이하기 때문입니다. 이후에,  완전 보장이 필요해지면(사용자 생성량이 폭증하거나 SLA가 빡세지면) 풀 SAGA 쪽으로 단계적으로 확장할 수 있습니다.

1단계 (현재) : @Transactional + afterCompletion 보상 + 알람
2단계        : Saga 상태 테이블 + 실패 건 재처리 스케줄러 (retry_count 상한)
3단계        : 서비스 분리 시 이벤트 기반 + Outbox 패턴

지금 구조는 이 확장 경로를 막지 않습니다. —> 보상 로직이 이미 한 곳에 모여 있어, 상태 저장만 끼워 넣으면 됩니다.

 

※ 회고 — 이 작업에서 배운 것

  1. 분산 트랜잭션은 MSA만의 문제라고 생각했는데, 단일 서비스라도 외부 API 같은 것을 끼는 순간 같은 문제가 생길 수 있다.
  2. 패턴은 통째로 가져오는 게 아니라 필요한 만큼 쓴다. 풀 SAGA의 화려한 구성요소(상태 테이블, Outbox, 이벤트)를 다 넣었으면 over-engineering이었다. 우리 제약(DB 1개, 외부 1개, 동기 흐름)에선 보상 + 멱등성 + 알람이면 충분했다.
  3. 트랜잭션 경계와 예외 발생 시점을 정확히 알아야 한다. DB 제약 위반은 commit 시점에 터진다를 몰랐다면 inline catch로 짜고 버그를 만들었을 것이다. afterCompletion을 선택한 근거가 여기에 있다.
  4. 실패를 숨기지 말고 드러내라. 보상 실패를 전용 예외 + 알람으로 만든 덕분에, orphan이 생겨도 언젠가 들키는 게 아니라 즉시 알게 된다.