EAT-SSU 앱을 운영하면서 이상한 버그를 자주 만났다. 로컬에서는 아무 문제 없이 잘 돌아가는데, 배포하고 나면 간헐적으로 이상한 현상이 발생했다.
더 이상한 건, 에뮬레이터에서 기능 자체는 정상적으로 작동한다는 점이었다. 서버에는 데이터가 잘 저장되는데 사용자 화면에는 에러가 뜨거나, 갑자기 로그아웃되는 식이었다.
원인을 찾아보니 전부 Race Condition 때문이었다.
이 글에서는 실제로 겪었던 두 가지 사례와 해결 과정을 공유한다.
두 문제 모두 비슷한 특징이 있었다.
결국 타이밍이 문제였고, 이건 동시성 문제라는 신호였다.
여러 작업이 동시에 실행될 때, 실행 순서에 따라 결과가 달라지는 현상을 말한다.
쉽게 비유하자면:
두 명이 동시에 같은 은행 계좌에서 돈을 인출하려고 할 때,
누가 먼저 처리되느냐에 따라 잔액이 달라지는 상황
각 코드는 문제없는데, 동시에 실행되면 사고가 나는 것이다.
모바일 앱은 기본적으로 비동기 환경이다.
이 과정에서 공유 자원이 생긴다:
UiState)이 자원들에 동시 접근 + 순서 보장 없음이 겹치면 Race Condition이 발생한다.
내가 겪은 두 문제도 정확히 이랬다.
EAT-SSU의 토큰 정책은 이렇다:
배포 후, 하루에 한 번씩 갑자기 로그아웃되는 현상이 간헐적으로 발생했다.
서버 문제도 아니고, 토큰 정책 오류도 아니었다. 추적해보니 토큰 재발급 로직의 동시성 문제였다.
Access Token이 만료된 시점에 여러 API 요청이 동시에 날아가면:
1️⃣ 여러 요청이 동시에 401 응답을 받음
2️⃣ 각 요청이 같은 Refresh Token으로 재발급 요청
3️⃣ 첫 번째 요청이 Refresh Token을 사용해서 성공
4️⃣ 두 번째 요청은 이미 사용된 Refresh Token으로 시도
5️⃣ 서버에서 401 반환 → 강제 로그아웃
먼저 DEV 모드 환경의 토큰 만료 기간을 짧게 변경해달라고 요청한다.

Refresh Token 재발급은 딱 한 번만 일어나야 한다
Mutex를 사용해서 해결했다:
동시성 해결과 함께 전체 인증 구조도 정리했다:
로그아웃 시 원인도 명확히 알 수 있게 MISSING_REFRESH_TOKEN, REFRESH_TOKEN_EXPIRED 등을 로깅하도록 개선했다.
return runBlocking {
mutex.withLock {
val currentAccessToken = getAccessTokenUseCase()
val requestAuthHeader = response.request.header("Authorization")
// 이미 다른 요청이 토큰을 재발급/저장한 경우, 저장된 토큰으로만 재시도
if (!requestAuthHeader.isNullOrBlank() && requestAuthHeader != "Bearer $currentAccessToken") {
Timber.d("TokenAuthenticator → token already refreshed by another call; retrying with stored token")
return@withLock response.request.newBuilder()
.header("Authorization", "Bearer $currentAccessToken")
.build()
}
Timber.d("TokenAuthenticator → attempting token reissue")
when (val result = reissueAndStoreTokenUseCase()) {
is ReissueAndStoreResult.Success -> response.request.newBuilder()
.header("Authorization", "Bearer ${result.accessToken}")
.build()
is ReissueAndStoreResult.MissingRefreshToken -> {
Timber.e("TokenAuthenticator → refreshToken is blank; forcing logout")
logoutUseCase()
TokenEventBus.notifyTokenExpired(LogoutReason.MISSING_REFRESH_TOKEN)
null
}
is ReissueAndStoreResult.RefreshInvalid -> {
Timber.e(
"TokenAuthenticator → refresh invalid: code=${result.responseCode}, message=${result.message}"
)
logoutUseCase()
TokenEventBus.notifyTokenExpired(LogoutReason.REFRESH_TOKEN_EXPIRED)
null
}
is ReissueAndStoreResult.TransientFailure -> {
Timber.w(
result.throwable,
"TokenAuthenticator → transient reissue failure: code=${result.responseCode}, message=${result.message}"
)
null
}
}
}
| 항목 | As-Is | To-Be |
|---|---|---|
| 동시성 제어 | ❌ 없음 | ✅ Mutex 적용 |
| 재발급 요청 | 여러 요청이 동시에 시도 | 1회만 |
| 결과 | 일부 실패 → 로그아웃 | 모든 요청 성공 |
QA에서 이런 제보가 들어왔다:


"리뷰 작성이 성공했는데 네트워크 오류 다이얼로그가 떠요"
실제로 서버에는 리뷰가 잘 저장되어 있었다. 하지만 사용자는 에러를 보게 되니 서비스 신뢰도가 떨어지는 상황이었다.
리뷰 작성 플로우는 이렇게 작동했다:
postReview API 호출saveS3 이미지 업로드문제는 두 작업 모두에서 UiState.Success를 설정하고 있었다는 점이었다.
@HiltViewModel
class UploadReviewViewModel @Inject constructor(
private val writeReviewUseCase: WriteReviewUseCase,
private val getImageUrlUseCase: GetImageUrlUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState<Unit>>(UiState.Init)
val uiState = _uiState.asStateFlow()
private val _uiEvent: MutableSharedFlow<UiEvent> = MutableSharedFlow()
val uiEvent = _uiEvent.asSharedFlow()
fun postReview(menuId: Long, reviewData: WriteReviewRequest) {
viewModelScope.launch {
_uiState.value = UiState.Loading
val success = writeReviewUseCase(menuId, reviewData)
if (!success) {
_uiState.value = UiState.Error
_uiEvent.emit(UiEvent.ShowToast("리뷰 작성에 실패하였습니다."))
return@launch
}
_uiState.value = UiState.Success(Unit)
_uiEvent.emit(UiEvent.ShowToast("리뷰가 작성되었습니다."))
}
}
suspend fun saveS3(file: File): String? {
_uiState.value = UiState.Loading
val url = getImageUrlUseCase(file)
if (url == null) {
_uiState.value = UiState.Error
_uiEvent.emit(UiEvent.ShowToast("이미지 업로드에 실패하였습니다."))
return null
}
// _uiState.value = UiState.Success(Unit) 여기를 삭제했더니 race condition을 막을 수 있었다
return url
}
}
문제 발생 시나리오:
1️⃣ postReview 성공 → UiState.Success 설정
2️⃣ Activity가 성공으로 인식 → finish() 호출
3️⃣ ViewModelScope 종료
4️⃣ 아직 끝나지 않은 saveS3가 IOException 발생
5️⃣ 네트워크 오류로 오인 → 에러 다이얼로그 표시
기능은 성공했지만, 상태 관리 경쟁 때문에 사용자 경험이 망가진 것이다.
해결 전략은 명확했다:
UiState.Success를 단 한 곳에서만 관리finish() 호출 시점 보장즉, UI 상태는 전체 작업 흐름을 대표해야 한다는 원칙을 적용했다.
두 문제 모두 이런 공통점이 있었다:
Race Condition은 특정 기술의 문제가 아니라 설계의 문제였다.
이번 경험을 통해 확실히 배운 게 있다:
1. 실제 서비스에서는 동시성 문제가 반드시 발생한다
2. 해결의 핵심은 구조
3. "운 좋게 잘 되는 코드"는 언젠가 문제를 만든다
이번 리팩토링은 단순한 버그 수정이 아니라, EAT-SSU 전체를 더 견고하게 만드는 계기가 되었다.
배포 전 QA가 꼭 필요하다는 사실을 다시 한번 상기하며 마무리한다
윤소양 고마워요~~ QA해주는 최고의 PM~~
