Race Condition 해결기

유진·3일 전

Android

목록 보기
17/17
post-thumbnail

들어가며

EAT-SSU 앱을 운영하면서 이상한 버그를 자주 만났다. 로컬에서는 아무 문제 없이 잘 돌아가는데, 배포하고 나면 간헐적으로 이상한 현상이 발생했다.

더 이상한 건, 에뮬레이터에서 기능 자체는 정상적으로 작동한다는 점이었다. 서버에는 데이터가 잘 저장되는데 사용자 화면에는 에러가 뜨거나, 갑자기 로그아웃되는 식이었다.

원인을 찾아보니 전부 Race Condition 때문이었다.

이 글에서는 실제로 겪었던 두 가지 사례와 해결 과정을 공유한다.


문제를 찾기 어려웠던 이유

두 문제 모두 비슷한 특징이 있었다.

  • ✅ 기능 자체는 성공함
  • ✅ 서버 에러도 아님
  • ❌ 로컬 환경에서는 거의 재현 안 됨
  • ❌ 실제 서비스 환경에서만 가끔 발생

결국 타이밍이 문제였고, 이건 동시성 문제라는 신호였다.


Race Condition이 뭔가?

여러 작업이 동시에 실행될 때, 실행 순서에 따라 결과가 달라지는 현상을 말한다.

쉽게 비유하자면:

두 명이 동시에 같은 은행 계좌에서 돈을 인출하려고 할 때,
누가 먼저 처리되느냐에 따라 잔액이 달라지는 상황

각 코드는 문제없는데, 동시에 실행되면 사고가 나는 것이다.

모바일 앱에서 자주 발생하는 이유

모바일 앱은 기본적으로 비동기 환경이다.

  • 네트워크 요청은 여러 개가 동시에 날아가고
  • Coroutine/Thread가 병렬로 실행되고
  • UI 상태는 비동기 결과로 업데이트된다

이 과정에서 공유 자원이 생긴다:

  • Access Token / Refresh Token
  • UI 상태 (UiState)
  • 로컬 저장소 (DataStore 등)

이 자원들에 동시 접근 + 순서 보장 없음이 겹치면 Race Condition이 발생한다.

Race Condition의 무서운 점

  • 항상 발생하지 않는다 (간헐적)
  • 로컬에서는 잘 안 보인다
  • QA나 실제 사용자 환경에서만 가끔 나타난다
  • 로그 없이는 원인 파악이 정말 어렵다

내가 겪은 두 문제도 정확히 이랬다.


사례 1: 토큰 재발급으로 인한 간헐적 로그아웃

문제 상황

EAT-SSU의 토큰 정책은 이렇다:

  • Access Token: 1일
  • Refresh Token: 7일

배포 후, 하루에 한 번씩 갑자기 로그아웃되는 현상이 간헐적으로 발생했다.

서버 문제도 아니고, 토큰 정책 오류도 아니었다. 추적해보니 토큰 재발급 로직의 동시성 문제였다.

어떻게 발생했나?

Access Token이 만료된 시점에 여러 API 요청이 동시에 날아가면:

1️⃣ 여러 요청이 동시에 401 응답을 받음
2️⃣ 각 요청이 같은 Refresh Token으로 재발급 요청
3️⃣ 첫 번째 요청이 Refresh Token을 사용해서 성공
4️⃣ 두 번째 요청은 이미 사용된 Refresh Token으로 시도
5️⃣ 서버에서 401 반환 → 강제 로그아웃

해결 방법: Mutex로 동시성 제어

핵심은 간단했다.

Refresh Token 재발급은 딱 한 번만 일어나야 한다

Mutex를 사용해서 해결했다:

  • 첫 번째 401 요청만 재발급 수행
  • 나머지 요청들은 Lock 대기
  • 재발급 완료 후, 갱신된 토큰을 사용

구조 개선

동시성 해결과 함께 전체 인증 구조도 정리했다:

  • TokenAuthenticator: 401 처리 전담
  • TokenInterceptor: Authorization 헤더 주입만
  • ReissueAndStoreTokenUseCase: 재발급 + 저장 로직 분리
  • TokenEventBus: 로그아웃 이벤트 관리

로그아웃 시 원인도 명확히 알 수 있게 MISSING_REFRESH_TOKEN, REFRESH_TOKEN_EXPIRED 등을 로깅하도록 개선했다.

결과

항목As-IsTo-Be
동시성 제어❌ 없음✅ Mutex 적용
재발급 요청여러 요청이 동시에 시도1회만
결과일부 실패 → 로그아웃모든 요청 성공
  • ✅ 간헐적 로그아웃 완전 제거
  • ✅ 재발급 네트워크 요청 수 N → 1
  • ✅ 코드 가독성과 유지보수성 향상

사례 2: 리뷰 작성 후 뜨는 이상한 에러

문제 상황

QA에서 이런 제보가 들어왔다:

"리뷰 작성이 성공했는데 네트워크 오류 다이얼로그가 떠요"

실제로 서버에는 리뷰가 잘 저장되어 있었다. 하지만 사용자는 에러를 보게 되니 서비스 신뢰도가 떨어지는 상황이었다.

원인 분석

리뷰 작성 플로우는 이렇게 작동했다:

  1. postReview API 호출
  2. saveS3 이미지 업로드
  3. 두 작업 모두 비동기로 처리

문제는 두 작업 모두에서 UiState.Success를 설정하고 있었다는 점이었다.

문제 발생 시나리오:

1️⃣ postReview 성공 → UiState.Success 설정
2️⃣ Activity가 성공으로 인식 → finish() 호출
3️⃣ ViewModelScope 종료
4️⃣ 아직 끝나지 않은 saveS3IOException 발생
5️⃣ 네트워크 오류로 오인 → 에러 다이얼로그 표시

기능은 성공했지만, 상태 관리 경쟁 때문에 사용자 경험이 망가진 것이다.

해결 방법: UI 성공 상태 단일화

해결 전략은 명확했다:

  • UiState.Success단 한 곳에서만 관리
  • 모든 비동기 작업이 완료된 후에만 성공 처리
  • finish() 호출 시점 보장

즉, UI 상태는 전체 작업 흐름을 대표해야 한다는 원칙을 적용했다.

결과

  • ✅ 잘못된 네트워크 오류 다이얼로그 제거
  • ✅ 리뷰 작성 UX 정상화
  • ✅ ViewModel 상태 흐름 단순화

두 사례의 공통점

두 문제 모두 이런 공통점이 있었다:

  • 기능 로직 자체는 정상
  • "동시에 실행될 수 있다"는 가정을 못함
  • 네트워크, 인증, UI 상태 어디서든 발생 가능

Race Condition은 특정 기술의 문제가 아니라 설계의 문제였다.


배운 점

이번 경험을 통해 확실히 배운 게 있다:

1. 실제 서비스에서는 동시성 문제가 반드시 발생한다

  • 로컬에서 괜찮다고 안심하면 안 됨
  • 특히 인증, 상태 관리, 네트워크 경계에서 자주 발생

2. 해결의 핵심은 구조

  • 단일 책임: 하나의 일만 하도록
  • 단일 진입점: 한 곳에서만 관리
  • 명시적인 동기화: Mutex 같은 도구 활용

3. "운 좋게 잘 되는 코드"는 언젠가 문제를 만든다

이번 리팩토링은 단순한 버그 수정이 아니라, EAT-SSU 전체를 더 견고하게 만드는 계기가 되었다.


마치며

Race Condition은 찾기도 어렵고 재현하기도 어려운 문제다. 하지만 한 번 겪고 나면 패턴이 보이고, 예방할 수 있게 된다.

"로컬에선 되는데..."라는 말을 하게 된다면, 한 번쯤 동시성 문제를 의심해보자.

profile
안드로이드... 좋아하세요?

0개의 댓글