해외여행 기념품 공유 및 추천 서비스 sou.zip for android
sou.zip 서비스의 안드로이드 애플리케이션 공식 저장소입니다.
- Language: Kotlin
- Min SDK: API 26 (Android 8.0)
- Build System: Gradle
- UI: Jetpack Compose
- Navigation: Jetpack Navigation 3
- Architecture: Android App Architecture
- Dependency Injection: Hilt
- State Management: Orbit MVI
- Network: Retrofit, OkHttp
- JSON: Kotlinx Serialization
- Map SDK: Mapbox Android SDK
- Image Loading: Coil
- Data: DataStore, Keystore
- OAuth: Kakao, Google
💡 Situation (상황)
- RESTful API 통신 시 Access Token 만료로 인한 401 에러 발생
- 사용자가 여러 화면에서 동시에 API 호출 시, 다수의 요청이 거의 동시에 401을 받는 상황이 발생
- 이때 각 요청마다 Refresh Token으로 토큰 갱신을 시도하면 다음 문제 발생
- Race Condition: 여러 스레드가 동시에 토큰 갱신 API를 호출하여 서버에 부하
- 토큰 불일치: Thread A가 갱신한 새 토큰을 Thread B가 모르는 상태에서 또 갱신 시도
- 무한 루프: 토큰 갱신 API 자체가 401을 반환하면 재귀적 갱신 시도
💡 Task (과제)
- OkHttp의 Authenticator를 활용하여 다음 요구사항을 만족하는 토큰 자동 갱신 로직 구현
- 멀티 스레드 환경에서도 토큰 갱신 API가 단 1회만 실행되도록 보장하는 스레드 Safety한 로직 구현.
- 갱신 완료 후 대기 중이던 다른 요청들은 새 토큰으로 재시도하는 흐름 구축
💡 Action (해결 과정)
✅ 1) Authenticator와 동기화 블록을 활용한 제어
-
해결책 1: ViewModel/Repository 레벨에서 API 호출마다 Retry 로직
-
한계
- 모든 API 호출마다 보일러플레이트 추가
- 동시에 여러 ViewModel이 API 호출 시 각각 토큰 갱신 시도 → Race Condition 미해결
- 데이터 레이어에 인증 로직이 섞임 → 관심사 분리 미흡
-
해결책 2: OkHttp Authenticator
-
OkHttp Authenticator 분석
- HTTP 401 응답 수신 시 authenticate() 메서드 자동 호출
- 반환값이 null 아니면 해당 Request로 재시도, null 이면 요청 실패 처리
- 중요: Authenticator는 각 실패한 요청마다 별도 스레드에서 실행됨 → 동시성 문제 발생 가능
-
Synchronized 블록
- Kotlin의 synchronized 블록을 사용하여 임계 영역을 설정. 한 번에 하나의 스레드만 토큰 갱신 로직에 진입하도록 강제하여 Race Condition 원천 차단
-
단점
- OkHttp 스레드 블로킹 → 블로킹 되는 시간 최적화 필요 → 캐시 도입의 배경
✅ 2) 메모리 캐시 적용
-
문제
- 기존에는 DataStore로 토큰 및 UserId 등 서버 요청과 관련된 value 들 저장하는 상태 → 데이터의 일관성과 영속성 보장
- 그러나 DataStore는 비동기(Flow) API를 제공하기 때문에, 매 네트워크 요청마다 토큰을 조회하면 I/O 병목을 유발할 수 있음
- 특히 Authenticator의 동기화 블록(
synchronized) 내부에서 DataStore I/O가 발생할 경우, 대기 중인 다른 서버 요청들의 지연 시간이 늘어나는 문제
-
해결
- UserMemoryCache 이름의 메모리캐시 도입, AccessToken을 메모리에 로드
- 앱 실행 중 빈번하게 접근하는 AccessToken을 volatile 변수로 관리
@Singleton class UserMemoryCache @Inject constructor() { @Volatile private var cachedUserId: String? = null @Volatile private var cachedAccessToken: String? = null ...
-
영향
- Authenticator 동기화 블록 내에서의 토큰 조회 속도를 Disk I/O 대비 단축시켰고 결과적으로 동시성 처리 성능을 최적화
✅ 3) 무한 루프 방지를 위한 로직
- 문제: 토큰 갱신 API 자체가 실패하거나 재시도한 요청이 또다시 401을 받을 경우, 무한 재귀 호출에 빠질 위험 존재.
- 해결: 요청 헤더에 재시도 마킹(
X-Token-Retry)을 확인하고, 갱신 API 경로(token/refresh)에 대한 401 응답은 무시하도록 예외 처리하여 시스템 안정성 확보.
💡 Result (결과)
- 동시성 안전성 확보: 3개 ViewModel에서 동시 API 호출 시 토큰 갱신 1회만 실행 확인 (로그 분석)
- 네트워크 효율성: 토큰 갱신 중복 호출 제거로 서버 부하 감소
- 사용자 경험 개선: 401 에러 발생 시 자동 갱신 후 재시도로 사용자는 인지 불가
- 메모리 vs 디스크 동기화: 메모리 캐시로 평균 응답 시간 단축 (디스크 I/O 제거)
💡 Situation (상황)
- 기념품 업로드 시 사용자가 고해상도 원본 사진을 선택할 경우, 10MB 이상의 대용량 파일로 인해 업로드 시간 지연 및 UI 버벅임 발생
- 일부 디바이스(삼성 갤럭시 등)에서 촬영한 사진의 EXIF 회전 정보가 비트맵 디코딩 시 반영되지 않아 이미지가 90도 회전되어 표시되는 정합성 문제 확인
💡 Task (과제)
- 원본 해상도를 유지할 필요 없는 썸네일 및 표시용 이미지를 위해 메모리 효율적인 리사이징 파이프라인 구축
- 디바이스별로 파편화된 EXIF 메타데이터를 읽어 이미지 방향 오류해결
💡 Action (해결 과정)
✅ 1) EXIF 회전 보정 및 정규화
- 문제: BitmapFactory 는 이미지의 픽셀 데이터만 디코딩할 뿐, 메타데이터에 기록된 회전 정보(Orientation Tag)는 무시하여 업로드된 이미지가 UI에선 회전해서 보임
- 해결: ExifInterface를 활용해 원본 파일의 회전 각도를 추출하고, Matrix 연산을 통해 비트맵을 정방향으로 회전시켜 저장하는 전처리 로직 구현
✅ 2) 비트맵 다운샘플링과 압축을 통한 최적화
- 메모리 최적화:
inJustDecodeBounds = true옵션으로 메모리 로드 없이 이미지의 크기만 먼저 읽어온 뒤, 목표 해상도에 맞춰 적절한 샘플링 사이즈를 계산- 이미지를 1/2 또는 1/4 크기로 다운 샘플링하여 메모리 점유율을 80% 이상 절감 + 서버 전송 비용 감소
- 네트워크 효율화: 서버 전송 전 클라이언트에서 JPEG 포맷으로 압축 수행
- 클라이언트의 CPU 연산 비용이 발생하지만, 네트워크 전송 시간 단축이 사용자 경험에 더 큰 이득이라 판단하여 클라이언트 처리 방식 채택
✅ 3) 이미지 처리 파이프라인 구현
private suspend fun processImage(context: Context, uri: Uri): File? {
return try {
// 1. EXIF 정보 읽기 (회전 각도 추출)
val exif = ExifInterface(inputStream)
val orientation = exif.getAttributeInt(...)
// 2. 비트맵 샘플링 로드
// inJustDecodeBounds로 크기만 확인 후 sampleSize 계산하여 비트맵 로드
val sampledBitmap = loadSampledBitmap(context, uri, MAX_WIDTH, MAX_HEIGHT)
// 3. EXIF 기반 회전 변환 (Matrix 연산)
val rotatedBitmap = rotateBitmapIfNeeded(sampledBitmap, orientation)
// 4. JPEG 압축 및 임시 파일 저장 (품질 80%)
val tempFile = createTempImageFile(context)
FileOutputStream(tempFile).use { out ->
rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
}
tempFile
} catch (e: Exception) { ... }
}💡 Result (결과)
-
파일 크기 감소: 평균 6MB → 1MB (80% 이상 절감), 네트워크 전송 시간 LTE 기준 2.5초 → 1초 미만으로 감소
-
회전 정합성: 제조사별 상이한 EXIF 처리 방식에 관계없이 모든 디바이스에서 올바른 방향으로 이미지가 표시되도록 보장
-
사용자 경험: 업로드 체감 시간과 뷰 이미지 로드 시간 단축
