OIDC 기반 SSO 로그인 개발 중 발생한 JIT 프로비저닝 경합 문제
멀티테넌트 SaaS에 외부 IdP 기반 SSO 로그인을 붙이는 작업을 하고 있었습니다.
브라우저에서는 Authorization Code + PKCE 흐름으로 로그인을 시작하고, 백엔드에서는 authorization code를 토큰으로 교환한 뒤 JWKS를 이용해 ID Token의 서명을 검증하는 일반적인 방식을 사용했습니다. 이어서 issuer, audience와 테넌트 관련 클레임을 확인해 우리 서비스가 사용할 수 있는 사용자 정보로 변환했습니다.
IdP에 대해 조금 이야기해보면 IdP가 보장하는 것은 외부 사용자의 신원이고 인증이 끝났다고 애플리케이션 로그인까지 끝나는것이 아닙니다. 저의 경우에는 이 사용자가 어느 테넌트에 속하는지, 어떤 로컬 계정과 연결되는지를 별도로 관리해야 했습니다. 따라서, 아직 연결된 계정이 없다면 최초 로그인 시 로컬 사용자를 생성하는 JIT(Just-In-Time) 프로비저닝도 필요했습니다.
당시 구현하던 전체 흐름은 다음과 같았습니다.
여기 중에 문제가 발생한 곳은 토큰 검증이 아니라, 외부 신원을 내부 사용자로 연결하는 마지막 JIT 프로비저닝 단계였습니다.
유니크 제약을 걸고 재조회로 접근#
로컬 사용자는 (tenant, external_member_id) 조합으로 식별했습니다.
같은 외부 사용자 ID라도 테넌트가 다르면 서로 다른 사용자로 취급해야 했기 때문에, 두 컬럼에는 복합 유니크 제약을 걸었습니다.
신경 쓰였던 것은 동일한 사용자의 첫 로그인 요청이 거의 동시에 두 번 들어오는 경우였습니다.
두 요청이 모두 로컬 사용자가 없다는 결과를 받으면, 둘 다 같은 키로 INSERT를 시도할 수 있습니다. 중복 row 자체는 유니크 제약이 막아 주기 때문에 두번째 요청은 제약 위반을 잡은 뒤, 먼저 생성된 사용자를 다시 조회하면 된다고 생각했습니다.
서비스 코드는 다음과 같았습니다.
@Service
@RequiredArgsConstructor
public class AuthUserService {
private final LocalUserRepository localUserRepository;
@Transactional
public UserResponse resolveOrProvision(AuthPrincipal principal) {
Instant now = Instant.now();
LocalUser user = localUserRepository
.findByTenantAndExternalMemberId(
principal.tenant(),
principal.externalMemberId()
)
.map(existing -> {
existing.touch(now);
return existing;
})
.orElseGet(() -> provision(principal, now));
return toResponse(user, principal);
}
private LocalUser provision(
AuthPrincipal principal,
Instant now
) {
try {
return localUserRepository.saveAndFlush(
new LocalUser(
principal.externalMemberId(),
principal.accountId(),
principal.tenant(),
now
)
);
} catch (DataIntegrityViolationException raceLost) {
return localUserRepository
.findByTenantAndExternalMemberId(
principal.tenant(),
principal.externalMemberId()
)
.orElseThrow(() -> raceLost);
}
}
// toResponse(...) 생략
}saveAndFlush를 쓴 이유도 분명했습니다.
단순히 save만 호출하면 INSERT와 유니크 제약 검사가 트랜잭션 종료 시점까지 딜레이 될수 있었기 떄문에 provision() 안에서 예외를 잡을 수 없습니다.
따라서, 즉시 flush해서 유니크 제약 위반을 그 자리에서 받고, catch 블록에서 재조회하려는 의도였습니다.
요새 LLM이 테스트 환경을 잘 만들어주기도 하니까 Testcontainers로 실제 MariaDB를 띄운 통합 테스트도 작성했습니다.
- 최초 로그인 시 사용자 생성
- 재로그인 시 중복 생성 방지
- 같은 외부 사용자 ID라도 테넌트가 다르면 별도 생성
- 잘못된 테넌트와 클레임 거부
다만 해당 테스트는 단일 스레드만 고려했었고, 테스트 시나리오에 유니크 제약 위반을 체크하지 못했었습니다.
QA환경에서의 검증#
기능이 어느 정도 완성된 뒤, dev 환경의 실제 IdP를 연결해 브라우저 E2E를 실행했습니다.
브라우저에서 PKCE 로그인을 시작하고, IdP의 호스티드 로그인 페이지를 거쳐 callback으로 돌아왔습니다. 백엔드의 토큰 교환과 JWKS 검증, 테넌트 검증까지는 정상이었습니다.
그런데 로그인 직후 호출한 첫 /me 요청이 500으로 끝났습니다.
로그의 시작은 예상했던 유니크 제약 위반이었습니다.
SQL Error: 1062, SQLState: 23000
(conn=15) Duplicate entry 'tenant-a-7161'
for key 'uk_app_user_tenant_member'tenant-a의 외부 사용자 7161을 생성하려 했지만, 같은 값이 이미 존재한다는 의미였습니다.
다른 요청이 사용자를 먼저 생성했다는 뜻이므로 여기까지는 의도한 상황이었습니다. DataIntegrityViolationException을 잡고 다시 조회하면 로그인이 정상적으로 끝나야 했습니다.
하지만 바로 다음 로그가 예상과 달랐습니다.
HHH000099: an assertion failure occurred
(this may indicate a bug in Hibernate,
but is more likely due to unsafe use of the session):
org.hibernate.AssertionFailure:
null id in com.example.auth.entity.LocalUser entry
(don't flush the Session after an exception occurs)스택트레이스의 시작은 다음과 같았습니다.
org.hibernate.AssertionFailure:
null id in com.example.auth.entity.LocalUser entry
(don't flush the Session after an exception occurs)
at org.hibernate.event.internal
.DefaultFlushEntityEventListener.checkId(...:86)
at org.hibernate.event.internal
.DefaultFlushEntityEventListener.getValues(...:182)
at org.hibernate.event.internal
.DefaultFlushEntityEventListener.onFlushEntity(...:141)유니크 제약 위반은 예상대로 DataIntegrityViolationException으로 변환됐고, catch에서도 잡혔습니다.
실패한 것은 그다음 재조회였습니다.
단순한 SELECT를 호출했을 뿐인데, Hibernate는 왜 null id와 flush를 이야기하고 있는지 처음에는 이해가 잘 되지 않았습니다.
예외 이후 flush 로 인한 오류(Hibernate)#
don't flush the Session after an exception occurs
예외가 발생한 뒤에는 해당 Session을 다시 flush하지 말라는 의미입니다.
localUserRepository.findByTenantAndExternalMemberId(...);그런데 코드에서는 catch 블록에서 명시적으로 flush를 호출하지 않았고 재조회 메서드 하나만 호출했습니다.
여기서 Hibernate의 AUTO flush mode가 개입합니다.
Hibernate는 쿼리를 실행하기 전에, persistence context에 남아 있는 변경 사항이 해당 쿼리 결과에 영향을 줄 수 있다고 판단하면 먼저 flush합니다. 따라서 코드에서 SELECT만 호출한 상황에서도 실제로는 SELECT 앞에 auto-flush가 들어갈 수 있습니다.
문제는 바로 직전 saveAndFlush에서 저장에 실패한 LocalUser였습니다.
Hibernate는 IDENTITY 전략 엔티티를 저장할 때 INSERT를 실행하고, DB가 생성한 ID를 엔티티에 반영합니다. 그러나 이번 INSERT는 유니크 제약 위반으로 실패했기때문에 DB에서 생성된 ID를 받지 못해서 해당 엔티티의 ID는 여전히 null이었습니다.
더 중요한 점은 INSERT가 실패했다고 해서 persistence context가 예외 이전 상태로 되돌아가는 것은 아니라는 것이었습니다.
결국 실제 흐름은 다음과 같았습니다.
saveAndFlush()가 INSERT를 실행한다.- DB가 유니크 제약 위반을 반환한다.
- ID를 받지 못한 엔티티가 persistence context에 남는다.
- 애플리케이션은 예외를 잡고 재조회한다.
- SELECT 실행 전에 auto-flush가 발생한다.
- Hibernate가 id가 없는 엔티티를 다시 검사한다.
AssertionFailure가 발생한다.
명시적으로 flush를 다시 호출한 것은 아니었지만 같은 세션에서 SELECT를 실행한 것이 결과적으로 실패한 엔티티를 다시 flush 경로를 타게 만들었습니다.
Hibernate 소스코드로 명확한 원인 찾아보기#
로그만으로도 어느 정도 설명은 됐지만, 실제 구현에서도 같은 경로인지 확인하고 싶었습니다.
로컬 Gradle 캐시에 받아져 있던 Hibernate ORM 6.6.18.Final sources jar에서 다음 문구를 검색했습니다.
don't flush the Session after an exception occurs이 문구는 DefaultFlushEntityEventListener#checkId 한 곳에서 나왔습니다.
if (id == null) {
throw new AssertionFailure(
"null id in " + persister.getEntityName()
+ " entry "
+ "(don't flush the Session after an exception occurs)"
);
}재조회 SELECT가 auto-flush로 이어지는 경로도 확인했습니다.
SessionImpl#autoFlushIfRequired는 트랜잭션이 진행 중인 경우 쿼리 실행 전에 flush 여부를 판단합니다.
if (!isTransactionInProgress()) {
return false;
}반대로 말하면, 이번 코드처럼 같은 트랜잭션 안에서 재조회하면 auto-flush 대상이 될 수 있습니다.
저장 경로에서는 실제 INSERT를 실행하기 전에 엔티티가 persistence context에 등록됩니다. 따라서 IDENTITY 전략을 이용할 때에는 DB에서 ID를 받아야 하므로 이 시점의 ID는 아직 null일 수 있습니다.
persistenceContext.addEntry(
entity,
Status.SAVING,
null,
null,
id,
...
);INSERT가 정상적으로 끝나면 ID가 채워지지만 INSERT가 예외로 끝났을 때, 방금 등록한 엔트리를 원래 상태로 되돌려 주는 보상 동작을 기대해서는 안 됩니다.
Hibernate가 예외 메시지에서 말하는 사용 규칙은 오히려 반대로 예외가 발생한 Session은 계속 사용하지 말아야 한다 고 명시되어있었습니다.
예외를 잡았다고 트랜잭션까지 복구된 것은 아니었다#
처음에는 EntityManager.clear()를 호출하거나, 조회의 flush mode를 바꾸면 해결할 수 있지 않을까 생각했습니다.
하지만 persistence context만 비우는 것으로는 충분하지 않았습니다.
Spring Data Repository의 트랜잭션 경계를 통과한 런타임 예외는 현재 트랜잭션을 rollback-only로 표시할 수 있습니다. 애플리케이션 바깥으로 예외가 최종 전파되지 않더라도, 이미 rollback-only가 된 트랜잭션은 정상적으로 커밋할 수 없습니다.
즉, 같은 트랜잭션에서 복구를 시도하면 두 가지 문제가 겹칩니다.
애플리케이션에서 예외를 catch했다는 것은 제어 흐름을 되찾았다는 의미로 쓰이고(그렇게 개발자들이 이해하지만) 세션과 트랜잭션이 예외 이전의 정상 상태로 돌아갔다는 뜻은 아닙니다.
따라서 재조회 쿼리를 작성하는것뿐만 아니라 재조회를 어느 트랜잭션에서 실행하는지도 잘 확인해야합니다.
생성과 복구를 다른 트랜잭션에 배치#
해결 방법은 생성 시도와 복구 재조회를 서로 다른 트랜잭션으로 나누는 것이었습니다.
기존 resolveOrProvision()의 @Transactional을 제거하고, 첫 번째 생성 시도와 실패 후 재조회를 각각 TransactionTemplate으로 감쌌습니다.
@Service
@RequiredArgsConstructor
public class AuthUserService {
private final LocalUserRepository localUserRepository;
private final TransactionTemplate transactionTemplate;
public UserResponse resolveOrProvision(AuthPrincipal principal) {
Instant now = Instant.now();
try {
return transactionTemplate.execute(
status -> resolveOrCreate(principal, now)
);
} catch (DataIntegrityViolationException raceLost) {
return transactionTemplate.execute(status ->
localUserRepository
.findByTenantAndExternalMemberId(
principal.tenant(),
principal.externalMemberId()
)
.map(user -> toResponse(user, principal))
.orElseThrow(() -> raceLost)
);
}
}
private UserResponse resolveOrCreate(
AuthPrincipal principal,
Instant now
) {
LocalUser user = localUserRepository
.findByTenantAndExternalMemberId(
principal.tenant(),
principal.externalMemberId()
)
.map(existing -> {
existing.touch(now);
return existing;
})
.orElseGet(() -> localUserRepository.saveAndFlush(
new LocalUser(
principal.externalMemberId(),
principal.accountId(),
principal.tenant(),
now
)
));
return toResponse(user, principal);
}
// toResponse(...) 생략
}중요한 것은 두 execute가 같은 트랜잭션 안에서 실행되지 않는다는 점입니다.
첫 번째 execute에서 유니크 제약 위반이 발생하면 해당 트랜잭션은 롤백됩니다. 그 트랜잭션이 사용하던 persistence context도 함께 종료됩니다.
이후 catch 블록에서 두 번째 execute를 호출하면, 새로운 트랜잭션과 새로운 persistence context가 열립니다.
복구 재조회에는 실패한 엔티티가 남아 있지 않기 때문에 기존 트랜잭션의 rollback-only 상태도 이어받지 않습니다.
@Transactional(propagation = REQUIRES_NEW)를 붙인 별도 메서드로도 구현할 수 있습니다.
다만 같은 클래스 안에서 메서드를 직접 호출하면 Spring의 트랜잭션 프록시를 거치지 않는 self-invocation 문제가 있습니다. REQUIRES_NEW를 사용하려면 복구 로직을 별도 빈으로 분리하는 편이 안전합니다.
이번에는 트랜잭션 경계가 코드에 직접 드러나고, 프록시 호출 여부에 영향을 받지 않도록 TransactionTemplate을 선택했습니다.
LLM-하네스을 써서 꼼꼼한 테스트 환경을 만들어보자#
수정 후 실제 IdP 로그인 플로우를 다시 실행했습니다.
PKCE 로그인과 callback, 토큰 검증을 거쳐 /me가 정상적으로 200을 반환했습니다.
하지만 이것만으로는 충분하지 않았습니다.
기존 통합 테스트는 모두 단일 스레드였고, 유니크 제약 위반 이후의 복구 경로를 검증하지 않았습니다. 수정 전 코드도 테스트 22건을 모두 통과했던 만큼, E2E 한 번이 성공했다고 회귀 방지가 된 것은 아니었습니다.
그래서 프로덕션 코드와 분리된 하네스를 만들었습니다.
환경은 다음과 같았습니다.
- Java 17
- Spring Boot 3.5.3
- Hibernate ORM 6.6.18.Final
- H2 in-memory
- 기존 방식과 수정 방식 각각 20회 반복
두 스레드가 모두 최초 조회에서 사용자가 없다는 결과를 받은 뒤 INSERT로 넘어가도록 CountDownLatch로 타이밍을 맞췄습니다.
경합이 우연히 발생하기를 기다리지 않고, 반드시 두 요청이 같은 분기를 통과하도록 만든 테스트였고 결과는 아래와 같습니다.
| 구현 | 실행 결과 |
|---|---|
| 같은 트랜잭션에서 예외를 잡고 재조회 | 20/20 AssertionFailure |
| 실패한 트랜잭션 종료 후 새 트랜잭션에서 재조회 | 20/20 정상 복구 |
기존 방식에서는 프로덕션 로그와 같은 오류가 발생하는것도 확인할 수 있었습니다.
AssertionFailure:
null id in ... entry
(don't flush the Session after an exception occurs)스택도 DefaultFlushEntityEventListener#checkId에서 시작해 auto-flush 경로로 이어졌습니다.
수정한 방식에서는 한 요청이 사용자를 생성하고, 다른 요청이 새 트랜잭션에서 같은 사용자를 조회했습니다. 두 요청은 최종적으로 동일한 사용자 ID를 반환했습니다.
하네스는 H2를 사용했기 때문에 MariaDB의 락 동작이나 실제 트래픽에서의 경합 확률까지 재현한 것은 아니지만, 실패 지점은 SQL dialect이 아니라 Hibernate의 persistence context와 flush 과정에 있었기 떄문에 DB가 달라도 같은 AssertionFailure 경로가 재현된다는 점은 확인할 수 있었습니다.
또 다른 문제.. 왜 근데 2번 들어왔지 ?#
정확히 밝혀내지 못한 부분도 있습니다.
처음 500이 발생한 E2E에서 실제로 무엇이 같은 /me 요청을 거의 동시에 두 번 만들었는지는 특정하지 못했습니다. (요새 EKS 환경이 조금 불안정하긴합니다)
프론트엔드의 이중 호출일 수도 있고, 개발 모드의 마운트 과정이나 토큰 갱신과 초기 조회가 겹쳤을 수도 있습니다. 다만 MariaDB에 유니크 제약 위반이 기록됐기 때문에, 서로 다른 두 요청이 같은 사용자를 생성하려 했다는 사실 자체는 확인할 수 있었습니다.
문제를 다시 돌아보며#
이번 문제는 OIDC 프로토콜 자체에서 발생한 것은 아니었습니다.
Authorization Code + PKCE 흐름은 정상적으로 끝났고, 토큰 교환과 JWKS 기반 서명 검증도 통과했습니다. issuer, audience와 테넌트 관련 클레임도 유효했습니다.
문제는 인증된 외부 사용자를 내부 도메인의 사용자로 연결하는 지점에 있었기에 SSO를 구현하면서 실제로 결정해야 했던 것은 토큰 검증만이 아니었습니다.
- 외부 식별자와 내부 사용자를 어떤 키로 연결할 것인가
- 멀티테넌트 환경에서 사용자 식별 범위를 어디까지 둘 것인가
- 최초 로그인 프로비저닝을 어떻게 멱등하게 만들 것인가
- 동시에 들어온 최초 로그인 요청을 어떻게 정상 응답으로 복구할 것인가
- DB 제약 위반 이후의 세션과 트랜잭션을 어떻게 다룰 것인가
유니크 제약은 중복 사용자가 생성되는 것을 막아 줬습니다.
하지만 경합에서 진 요청을 정상적인 로그인 응답으로 돌려보내는 것까지 해결해 주지는 않았습니다.
이번에 잘못 가정했던 것은 다음과 같습니다.
예외를 catch했다
→ 예외 상황이 끝났다
→ 같은 트랜잭션을 계속 사용해도 된다실제로는 애플리케이션의 제어 흐름만 되찾았을 뿐이었습니다.
예외를 catch했다
→ 호출 흐름은 계속할 수 있다
→ persistence context와 트랜잭션까지 복구된 것은 아니다경합에서 진 요청이 먼저 생성된 사용자를 재조회하는 전략 자체는 유효했습니다.
다만 그 재조회는 실패한 INSERT와 같은 트랜잭션이 아니라, 새로운 트랜잭션과 새로운 persistence context에서 실행돼야 했습니다.
IdP는 사용자의 외부 신원을 확인해 줍니다. 그 신원을 내부 사용자와 안전하게 연결하고, 최초 로그인 경합과 실패한 트랜잭션까지 처리하는 것은 애플리케이션의 책임입니다.
이번 이슈는 로그인 callback 이후의 작은 예외 처리에서 시작됐지만, SSO의 경계가 어디까지인지 다시 보게 만들었습니다.
토큰을 검증했다고 로그인이 끝나는 것이 아니라, 그 사용자가 우리 서비스 안에서 일관된 하나의 계정으로 연결되고 실패한 요청까지 정상적으로 복구하는 과정까지 챙겨보려합니다.
긴글 읽어주셔서 감사합니다.