[Kotlin Spring Boot] 소셜 로그인 API 동시성 테스트하기
들어가며
동시성... 여러 명이 동시에 접근하는 것...
동기적으로 작성한 소셜 로그인 API로 동시성 테스트를 해보며 트랜잭션의 비관적 락, 낙관적 락을 공부해보려고 합니다.
지피티니의 도움을 많이 받은 글이고,, 엉성하지만
공부하면서 작성하는 글이니 자유롭게 피드백해주세요!
동시성 문제가 발생하는 부분을 어떻게 알 수 있을까?
1. 공유 자원(Shared Resource) 확인하기
동시성 문제의 대부분은 여러 스레드가 동시에 하나의 데이터를 수정하려고 할 때 발생합니다.
대표적인 공유자원은 다음과 같습니다.
- 전역 변수 또는 static 필드
- 싱글톤 객체의 상태값
- DB의 특정 레코드
- 캐시(Redis 등)의 키값
- 파일, 세션, 큐 등
2. 무상태(Stateless) vs 유상태(Stateful)
서비스 또는 메서드가 무상태(stateless) 라면, 일반적으로 동시성 문제에서 자유롭습니다. 반대로 상태(state)를 가지는 객체는 위험할 수 있습니다.
예시로 무상태한 싱글톤이 있을 수 있습니다.
@Component
class MutableCounter {
var count = 0
fun increment() { count++ }
}
이 클래스는 싱글톤으로 주입되고, count는 여러 요청에서 동시에 변경될 수 있습니다. → Race Condition 위험이 있습니다.
3. 트랜잭션 경계에서의 의심
데이터베이스 작업이 트랜잭션으로 감싸져 있다고 해서 항상 안전한 것은 아닙니다.
다음과 같이 중복 회원가입 방지 로직이 있다고 하면
@Transactional
fun register(email: String) {
if (userRepository.existsByEmail(email)) {
throw IllegalStateException("이미 가입된 이메일입니다.")
}
userRepository.save(User(email))
}
이 코드는 existsByEmail과 save 사이에 다른 스레드가 끼어들 수 있어 동시성 문제 발생 가능성이 있습니다.
그럼 이 내용을 바탕으로 소셜 로그인 코드를 보면서 동시성 문제를 찾아봅시다.
소셜 로그인 흐름
소셜 로그인 API에서 동시성 이슈는 대부분 ‘같은 사용자가 동시에 여러 번 로그인 시도할 때’ 발생합니다.
예를 들어, 회원가입 로직이 if (없으면) 저장 구조라면, 동시에 들어온 요청이 모두 '없다'고 판단하고 중복으로 저장할 수 있습니다.
토큰 발급 로직도 마찬가지입니다. 사용자의 refreshToken을 갱신하고, 오래된 것을 삭제하는 로직은 모두 shared resource를 다루기 때문에 race condition이 생기기 쉽습니다.
따라서, 조회 → 없으면 저장, 갱신 후 삭제 구조가 있을 때는 반드시 동시성 테스트를 통해 문제를 검증하고, DB 제약조건이나 트랜잭션 처리로 방어하는 것이 필요합니다.
먼저 소셜 로그인 과정부터 아주 간단하게 살펴보면 다음과 같습니다.
다음과 같은 순서로 동작합니다. 간단하게 스윽 보고 코드와 함께 봅시다
1. 사용자가 소셜 로그인 버튼 클릭
2. 서버로 로그인 요청 보내기
/login/{type}?code=xxx 형식으로 요청을 보내면, 서버에서는 type에 따라 해당 소셜 플랫폼에서 사용자 정보를 가져옵니다.
3. 사용자 정보 가져오기
서버는 받은 code를 이용해 소셜 플랫폼에 요청을 보내고, 사용자의 이메일, 프로필 이미지 등의 정보를 받아옵니다.
4. 기존 회원인지 확인
이메일로 우리 시스템에 이미 가입한 사용자가 있는지 확인합니다.
- 있다면 → 로그인 처리
- 없다면 → 새로운 사용자로 회원가입 처리
4. 토큰 발급 후 응답
로그인 또는 회원가입이 완료되면, 서버는 클라이언트에 Access Token과 Refresh Token을 포함한 응답을 내려줍니다.
어떻게 동작하는지 코드와 함께 봅쉬다
코드 살펴보기
다음과 같이 컨트롤러 코드를 작성했습니다.
@Operation(summary = "소셜 로그인 API", description = "SocialType(KAKAO, GOOGLE, NAVER 등)을 받아 로그인 또는 회원가입을 수행합니다._예림")
@ApiResponses(
// 생략
)
@GetMapping("/login/{type}")
fun signIn(
@RequestParam("code") accessCode: String, // 소셜 플랫폼에서 발급된 인증 코드
@PathVariable("type") type: SocialType, // 로그인할 소셜 플랫폼 타입 (KAKAO, NAVER, 등)
@Parameter(hidden = true) @ExtractDeviceId deviceId: String, // 클라이언트 디바이스 식별자 (헤더 등에서 추출됨)
): BaseResponse<AuthUserResponse> {
// AuthService를 통해 소셜 로그인 또는 회원가입 처리 후 결과 응답
return BaseResponse.onSuccess(
SuccessStatus.OK,
authService.signInWithSocial(accessCode, type, deviceId),
)
}
그리고 다음과 같이 컨트롤러에서 호출하는 서비스 메서드를 작성했습니다.
@Transactional
override fun signInWithSocial(
accessCode: String,
type: SocialType,
deviceId: String,
): AuthUserResponse {
// 1. 소셜 플랫폼에서 accessCode를 사용해 사용자 프로필 정보를 요청
val profile = oAuthClientComposite.getClient(type).requestProfile(accessCode = accessCode)
val email = profile.getEmail()
// ⭐️ 2. 이메일로 기존 사용자를 조회하거나, 없으면 회원가입 처리
val user = userRepository.findByEmail(email) ?: userRepository.save(
User(
email,
profileImage = s3Properties.s3.defaultProfileImageUrl, // 기본 프로필 이미지로 설정
),
)
// 3. 사용자에게 발급할 accessToken / refreshToken 생성
val (refreshToken, accessToken) = issueNewToken(email, deviceId)
// 4. 사용자 정보 + 토큰을 응답 객체로 반환
return AuthUserResponse(
email,
accessToken = accessToken,
refreshToken = refreshToken,
signUpStatus = user.signUpStatus,
)
}
oAuthClientComposite.getClient(type).requestProfile(accessCode = accessCode) 은 내부 구조가 복잡하고, 동시성 문제가 발생하는 부분은 아니라 설명을 생략하겠습니다!
🔍 OAuthClientComposite 및 OAuthClient 관련 코드가 동시성 문제가 없는 이유
1. OAuthClientComposite.getClient(type: SocialType)
clients는 생성자 주입을 통해 초기화된 불변 리스트 (List<OAuthClient>) 내부에서 상태 변경이 없고 find는 읽기 전용 작업이므로 Thread-safe합니다fun getClient(type: SocialType): OAuthClient = clients.find { it.supports(type) } ?: throw AuthException(ErrorStatus.UNSUPPORTED_SOCIAL_TYPE)
2. requestGet, parseBody 등 요청 및 파싱 로직
requestProfile에서 requestGet, parseBody 등의 함수를 호출하여 작업을 처리하는데요.
여기서 RestTemplate과 ObjectMapper는 Spring Bean으로 싱글톤으로 주입받아도 Thread-safe한 구조로 설계되어 있어 동시성 문제로부터는 안전합니다.val response = restTemplate.exchange(...) val parsed = objectMapper.readValue(...)
3번 주석에서 호출하는 issueNewToken 메서드의 내부는 다음과 같습니다!
private fun issueNewToken(email: String, deviceId: String): Pair<String, String> {
val now = Instant.now()
// 1. 현재 시각 기준으로 Access Token과 Refresh Token 생성
val accessToken = jwtProvider.createAccessToken(now, email, deviceId)
val refreshToken = jwtProvider.createRefreshToken(now, email, deviceId)
// ⭐️ 2. 사용자의 디바이스에 해당하는 Refresh Token을 DB에 저장 또는 갱신
// (이미 존재하면 update, 없으면 insert)
refreshTokenService.upsertRefreshToken(email, deviceId, refreshToken)
// 3. 하나의 사용자에게 저장 가능한 토큰 개수에 제한이 있다면
// 가장 오래된 토큰을 삭제하여 갯수를 유지
refreshTokenService.removeOldestTokenIfLimitExceeded(email)
// 4. 생성된 Refresh Token과 Access Token을 반환
return Pair(refreshToken, accessToken)
}
그럼 다음 코드에서 동시성 문제가 발생할 수 있는 지점은 무엇이 있을까요?
동시성 문제가 발생할 수 있는 지점
1. signInWithSocial의 회원가입 처리 부분
val user = userRepository.findByEmail(email) ?: userRepository.save(
User(email, profileImage = s3Properties.s3.defaultProfileImageUrl)
)
위 코드의 위험 포인트는, 다수의 요청이 동시에 같은 이메일로 들어오면, findByEmail에서는 사용자가 존재하지 않는다고 판단하지만, 그 사이 다른 요청에서 이미 save를 했을 가능성이 있습니다.
→ 결과적으로 (1) 중복된 이메일의 사용자가 생성되거나, (2) DB 제약조건 위반 예외가 발생할 수 있습니다.
그러나 이메일에 대한 DB 유니크 제약 조건은 적용되어 있으니 (2) DB 제약조건 위반 예외가 발생할 수 있겠습니다.
2. 토큰을 업데이트 및 삭제하려는 경우
issueNewToken 함수에서는 토큰이 이미 존재하면 update, 없으면 insert하는 upsertToken 메서드와 가장 오래된 토큰을 삭제하는 기능이 있습니다.
- upsert 충돌
- 두 개의 쓰레드가 거의 동시에 upsertRefreshToken()을 호출하면 race condition 발생
- DB에 이미 존재하지 않는다고 판단 → 둘 다 insert 시도 → constraint violation
- 삭제 조건 충돌
- 두 쓰레드가 동시에 refreshTokenService.removeOldestTokenIfLimitExceeded(email) 호출
- 각 쓰레드가 삭제해야 할 가장 오래된 토큰을 동일하게 판단하고 중복 삭제 시도 가능
동시성 테스트
같은 accessCode와 SocialType, deviceId로 동시에 여러 요청이 들어왔을 때
- 사용자(User)는 한 번만 생성되고
- refreshToken도 정상적으로 하나만 저장되는지를 확인
해보려고 합니다.
@Test
@DisplayName("동시에 여러 요청이 들어와도 user는 1명만 생성되어야 한다")
fun signInWithKakaoForConcurrencyTest() {
// given
val accessCode = "mock-code"
val type = SocialType.KAKAO
val deviceId = "deviceId"
val email = "test@example.com"
val newRefreshToken = "new-refresh-token"
val newAccessToken = "new-access-token"
val kakaoProfile = KakaoProfile(kakaoAccount = KakaoAccount(email = email))
val threadCount = 10
val latch = CountDownLatch(threadCount)
val executor = Executors.newFixedThreadPool(threadCount)
// when
whenever(oAuthClientComposite.getClient(SocialType.KAKAO)).thenReturn(kakaoOAuthClient)
whenever(kakaoOAuthClient.requestProfile(any())).thenReturn(kakaoProfile)
whenever(jwtProvider.createRefreshToken(any(), any(), any())).thenReturn(newRefreshToken)
whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(newAccessToken)
val futures = mutableListOf<Future<*>>()
repeat(threadCount) {
val future = executor.submit {
try {
authService.signInWithSocial(accessCode, type, deviceId)
logger.info("요청 성공")
} finally {
latch.countDown()
}
}
futures.add(future)
}
latch.await()
// 모든 Future 결과를 검사해서 예외 발생 여부 확인
futures.forEach { future ->
future.get() // 예외 발생 시 여기서 던져져 테스트 실패함
}
logger.info("모든 쓰레드 작업 완료")
}
테스트를 돌려보면,
예외가 잔뜩 발생하는 것을 확인할 수 있습니다. 또한 로그를 찍어보니 findByEmail 호출 시 에러가 발생하고 멈추는 것을 확인했습니다.
에러를 요약하면
org.springframework.dao.DataIntegrityViolationException:
could not execute statement
[Unique index or primary key violation:
"PUBLIC.CONSTRAINT_INDEX_4 ON PUBLIC.USERS(EMAIL NULLS FIRST)
VALUES ( 'test@example.com' )"]
DataIntegrityViolationException은 데이터 무결성 제약 조건을 위반했을 때 발생하는 에러입니다.
동시에 여러 쓰레드가 User(email = test@example.com)을 생성하려고 했고,
이미 생성된 유저가 있는데도 중복 저장을 시도해서 DB 제약 조건에 위배되어 실패한 것입니다.
원하던(?) 에러가 발생했습니다 ^_^
이제 해결해봅시다
비관적 락(Pessimistic Lock) vs 낙관적 락(Optimistic Lock)
이전 케어비전 블로그에도 @sugi가 잘 정리해놓았지만 다시 한 번 공부 차 올려보자면,
비관적 락 (Pessimistic Lock)이란?
다른 트랜잭션이 이 데이터를 건드릴 거 같으니까, 일단 잠가놓자
- 데이터 조회 시점에 락을 걸어버림
- 다른 트랜잭션은 해당 레코드에 접근 불가
- 예: SELECT ... FOR UPDATE
장점은 충돌 방지 확실하고, 중요한 계좌 이체, 재고 감소 등에는 유리합니다.
단점은 락 경쟁이 심해지면 대기 시간이 높아지고 데드락 발생 가능성 있습니다.
낙관적 락 (Optimistic Lock)
일단 너도 나도 자유롭게 수정은 해,,, 근데 저장할 때 바뀌었으면 실패야
- 조회 시 락 없이 동작
- 저장할 때 버전 필드를 보고 충돌 여부 판단
- 엔티티에 @Version 필드를 추가
@Entity
class User(
...
@Version
var version: Long? = null
)
장점은 락을 안 걸기 때문에 성능 우수하고, 충돌이 드물고 읽기 많은 시스템에 적합합니다.
단점은 충돌 발생 시 예외 발생 → 재시도 로직이 필요합니다.
개선해보기
1. User 생성 부분 (findByEmail() → save())
해당 코드의 문제는 두 요청이 동시에 findByEmail()을 호출 → 둘 다 사용자가 없다고 판단하고, save()가 동시에 호출되며 중복 사용자 생성 시도해 Unique Key 제약을 위반했었습니다.
이에 대한 해결 방안은 다음과 같습니다.
(1) 비관적 락
이메일을 기준으로 행 단위 락을 걸고, 누군가 잠그면 다른 트랜잭션은 기다립니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.email = :email")
fun findByEmail(email: String): User?
val user = userRepository.findByEmailForUpdate(email) ?: userRepository.save(User(...))
(2) Unique Constraint + 예외 처리 (낙관적 방식)
DB에서 email에 unique 제약을 걸고, save() 시 충돌 나면 catch 해서 이미 존재하는 사용자를 처리합니다.
try {
userRepository.save(User(...))
} catch (e: DataIntegrityViolationException) {
userRepository.findByEmail(email)!!
}
성능은 좋지만, 예외를 재시도하는 로직이 필수입니다.
Refresh Token 저장 / 삭제 (upsertRefreshToken & removeOldest...)
같은 유저가 같은 디바이스로 동시에 로그인 → upsert와 삭제가 충돌하는 문제가 있었던 부분입니다.
(1) 비관적 락
해당 사용자의 토큰 목록을 FOR UPDATE로 조회 후 upsert & 삭제를 수행합니다.
예시 쿼리는 다음과 같습니다.
SELECT * FROM refresh_token WHERE email = ? FOR UPDATE;
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT rt FROM RefreshToken rt WHERE rt.email = :email AND rt.deviceId = :deviceId")
fun findTokenForUpdate(email: String, deviceId: String): RefreshToken?
(2) 낙관적 락
RefreshToken 엔티티에 @Version 필드 추가하고 저장 시점에 버전 충돌 여부로 판단하는 방식입니다.
@Entity
class RefreshToken(
...
@Version
var version: Long? = null
)
충돌이 나면 OptimisticLockException이 발생하고, 재시도 로직을 추가해야 합니다.
어떤 방법을 선택할까?
비관적 락과 낙관적 락 중 어떤 게 더 나은가?는 상황에 맞게 선택하는 게 맞습니다
비관적 락과 낙관적 락을 비교해보자면
기준 | 비관적 락 | 낙관적 락 |
충돌 가능성 | 충돌이 자주 일어난다 (예: 같은 이메일로 로그인, 재고 감소 등) | 충돌이 드물다 (예: 대부분 읽기 위주, 가끔 갱신) |
성능 | 락을 잡고 대기하므로 성능에 불리 (동시 요청 많을수록 느려짐) | 락이 없으므로 빠름 (충돌 시 재시도 필요) |
데이터 무결성 | 절대적으로 중요할 때 (중복 저장, 금액 계산 등) | 재시도로도 해결 가능할 때 (몇 번 실패해도 무방) |
복잡도 | 단순: 트랜잭션 안에서 처리 | 복잡: @Version 필요, 재시도 로직 필요 |
사용 예 | 사용자 중복 생성 방지, 재고 감소, 결제 처리 | 게시글 좋아요 수, 읽기 많은 화면의 변경 감지 |
현재 저의 상황은,
충돌 가능성이 존재하고,
중복 저장을 피해야 하고
로직이 비교적 단순한 편이므로 (@Transactional + FOR UPDATE 구조로 끝남)
=> 비관적 락을 선택했습니다.
그럼 동시에 여러 요청이 들어와도 User는 한 번만 생성되고 RefreshToken도 중복 없이 안전하게 upsert하는 방식으로 리팩토링 해봅시다
UserRepository에 비관적 락 메서드 추가
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.email = :email")
fun findByEmail(email: String?): User?
PESSIMISTIC_WRITE는 해당 레코드에 쓰기 락을 겁니다.
→ 그럼 다른 트랜잭션이 같은 레코드를 읽거나 쓰려면 기다려야 합니다.
마치며
이번 글에서는 소셜 로그인 API의 내부 흐름을 살펴보고, 동시성 문제 발생 가능 지점과 이를 방지하기 위한 방법에 대해 정리해보았습니다.
특히 다음과 같은 상황에서 동시성 문제가 발생할 수 있다는 것을 알 수 있었습니다
- 같은 이메일로 동시에 여러 요청이 들어올 때, 사용자가 중복 생성되는 문제
- 한 유저가 여러 디바이스로 로그인하면서 토큰 저장/삭제 로직이 충돌하는 문제
이런 문제를 예방하기 위해 다음과 같은 전략이 필요했습니다!
- DB에 Unique 제약 조건을 명확히 설정하고,
- 비관적 락(Pessimistic Lock) 또는 낙관적 락(Optimistic Lock) 을 상황에 맞게 적용하며,
- 테스트 코드에서 실제 동시 요청을 시뮬레이션해 검증하는 것
소셜 로그인처럼 사용자 진입의 첫 관문이 되는 API일수록, 더 신중하게 테스트하는 게 중요한 것 같습니다. 감사합니다.