Skip to content

souzip/souzip-android

Repository files navigation

📱 sou.zip (수집) - Android

Group 2085663402

해외여행 기념품 공유 및 추천 서비스 sou.zip for android

sou.zip 서비스의 안드로이드 애플리케이션 공식 저장소입니다.

Play store

🛠 환경 및 기술 스택 (Tech Stack)

  • 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

🔨 모듈 구조

image

🔨 Navigation 구조

그림01

💯 핵심 문제해결 경험

문제1: 토큰 자동 갱신 메커니즘과 동시성 제어

💡 Situation (상황)

  • RESTful API 통신 시 Access Token 만료로 인한 401 에러 발생
  • 사용자가 여러 화면에서 동시에 API 호출 시, 다수의 요청이 거의 동시에 401을 받는 상황이 발생
  • 이때 각 요청마다 Refresh Token으로 토큰 갱신을 시도하면 다음 문제 발생
    • Race Condition: 여러 스레드가 동시에 토큰 갱신 API를 호출하여 서버에 부하
    • 토큰 불일치: Thread A가 갱신한 새 토큰을 Thread B가 모르는 상태에서 또 갱신 시도
    • 무한 루프: 토큰 갱신 API 자체가 401을 반환하면 재귀적 갱신 시도

💡 Task (과제)

  • OkHttp의 Authenticator를 활용하여 다음 요구사항을 만족하는 토큰 자동 갱신 로직 구현
    1. 멀티 스레드 환경에서도 토큰 갱신 API가 단 1회만 실행되도록 보장하는 스레드 Safety한 로직 구현.
    2. 갱신 완료 후 대기 중이던 다른 요청들은 새 토큰으로 재시도하는 흐름 구축

💡 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 제거)

문제 2: 이미지 업로드 최적화 (EXIF 회전 보정 + 압축)

💡 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 처리 방식에 관계없이 모든 디바이스에서 올바른 방향으로 이미지가 표시되도록 보장

  • 사용자 경험: 업로드 체감 시간과 뷰 이미지 로드 시간 단축


About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

No packages published

Languages