들어가며
소셜 로그인에서 회원가입과 로그인은 사실 하나의 로직으로 처리됩니다. 소셜 계정으로 처음 로그인하면 회원을 생성하고, 이미 있으면 그대로 로그인시키죠. 그런데 이 "조회 후 없으면 생성" 로직에는 동시성 문제가 숨어 있었습니다. PLIMAP 백엔드에서 코드 리뷰 중 발견되어 수정한 레이스 컨디션을 정리합니다.
문제 상황
social_account 테이블에는 uk_social_account_provider_subject UNIQUE (provider, provider_subject) 제약이 걸려 있습니다. 같은 소셜 계정으로 같은 사용자를 두 번 이상 만들지 못하게 막는, 당연히 있어야 하는 제약입니다.
그런데 기존 로직은 "조회 후 삽입(check-then-insert)" 방식이었습니다.
// 문제가 되는 흐름 (의사 코드)
SocialAccount account = repository.findByProviderAndSubject(provider, subject);
if (account == null) {
account = repository.save(new SocialAccount(...)); // 여기서 동시에 두 요청이 들어오면?
}
같은 소셜 계정으로 동시에 두 개의 로그인 요청이 들어오면(예: 사용자가 실수로 로그인 버튼을 두 번 클릭), 두 요청 모두 "계정 없음"으로 조회한 뒤 둘 다 생성을 시도합니다. 둘 중 하나는 UNIQUE 제약을 위반하며 실패합니다.
왜 같은 트랜잭션에서 재시도하면 안 되는가
PostgreSQL은 제약 위반이 발생하면 해당 트랜잭션 전체가 abort 상태가 됩니다. 즉 같은 트랜잭션 안에서 DataIntegrityViolationException을 catch하고 바로 재조회를 시도해도, 트랜잭션 자체가 이미 죽어있어서 재조회 쿼리도 실패합니다.
해결 방법
회원/소셜계정 생성 로직을 createMemberWithSocialAccount로 분리하고, TransactionTemplate + PROPAGATION_REQUIRES_NEW로 별도 트랜잭션에서 실행하도록 바꿨습니다.
// CustomOAuthService.java (개념 요약)
public Member createMemberWithSocialAccount(String provider, String subject, ...) {
try {
return transactionTemplate.execute(status -> {
// REQUIRES_NEW: 부모 트랜잭션과 독립된 새 트랜잭션에서 실행
return saveNewMemberAndSocialAccount(provider, subject, ...);
});
} catch (DataIntegrityViolationException e) {
// 이 트랜잭션은 이미 별도로 격리되어 있으므로,
// 바깥 트랜잭션에 영향을 주지 않고 안전하게 재조회할 수 있다
return repository.findByProviderAndSubject(provider, subject)
.orElseThrow(() -> e); // 재조회도 실패하면 원래 예외를 그대로 던진다
}
}
REQUIRES_NEW로 격리했기 때문에, 생성이 실패해도 그 실패는 새로 만든 트랜잭션 안에서만 abort되고 끝납니다. 바깥(원래) 트랜잭션은 영향을 받지 않으므로, catch 블록에서 안전하게 재조회 쿼리를 실행할 수 있습니다. 이미 다른 요청이 먼저 만들어놓은 계정을 찾아서 그 계정으로 로그인을 이어가면 됩니다.
검증
./gradlew build로 컴파일과 테스트를 통과시켜 회귀가 없는지 확인했습니다.
정리
- "조회 후 삽입" 패턴은 단일 요청 기준으로는 자연스러워 보이지만, 동시 요청에는 항상 레이스 컨디션을 남깁니다.
- 유니크 제약을 방어선으로 두고, 위반 시 예외를 잡아 재조회하는 방식은 흔한 해법이지만, PostgreSQL에서는 같은 트랜잭션 안에서 시도하면 트랜잭션 abort 때문에 재조회조차 실패한다는 점을 놓치기 쉽습니다.
PROPAGATION_REQUIRES_NEW로 실패할 수 있는 작업을 격리하면, 실패가 나머지 트랜잭션에 전파되지 않아 안전하게 복구 로직을 실행할 수 있습니다.
