안드로이드 앱에서 Retrofit과 OkHttp를 사용하여 네트워크 통신을 구축할 때 가장 흔하게 마주치는 문제 중 하나가 바로 공통 헤더의 처리예요. 인증 토큰이나 API 키, 또는 클라이언트 식별 정보와 같은 값들은 거의 모든 요청에 포함되어야 하는데 이를 각 인터페이스 메서드마다 직접 선언하면 코드의 가독성이 떨어지고 중복이 발생하기 마련이거든요. 특히 프로젝트 규모가 커질수록 이러한 반복적인 코드는 유지보수를 어렵게 만드는 요인이 되기도 하더라고요.

class AuthInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        val newRequest = originalRequest.newBuilder()
            .header("Authorization", "Bearer ${getAuthToken()}")
            .header("Content-Type", "application/json")
            .build()
        return chain.proceed(newRequest)
    }

    private fun getAuthToken(): String {
        // 실제 환경에서는 로컬 저장소 등에서 토큰을 가져옴
        return "your_token_here"
    }
}

val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(AuthInterceptor())
    .build()

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .client(okHttpClient)
    .addConverterFactory(GsonConverterFactory.create())
    .build()

이 문제를 해결하는 가장 효과적인 방법은 OkHttp의 Interceptor 기능을 활용하는 것이에요. Interceptor는 말 그대로 네트워크 요청이 서버로 출발하기 전이나 응답이 돌아온 직후에 가로채서 특정 로직을 수행하는 도구거든요. 이를 통해 공통 헤더를 추가하는 과정을 단 한 번의 설정으로 모든 API에 적용할 수 있게 돼요. 예를 들어 인증 토큰이 유효한지 확인하고 문제가 있을 때만 갱신하는 로직을 이 단계에서 처리하면 비즈니스 로직과 네트워크 통신 로직을 깔끔하게 분리할 수 있네요.

구현 방식은 OkHttpClient를 빌드할 때 interceptor 메서드를 사용하여 커스텀 인터셉터를 등록하는 형태예요. 이때 ApplicationInterceptor와 NetworkInterceptor의 차이를 인지하고 적절한 위치에 배치하는 것이 중요한데, 보통 공통 헤더 추가나 로깅 같은 작업은 ApplicationInterceptor 단계에서 처리하면 충분히 의도한 대로 동작해요. 이렇게 설정된 클라이언트를 Retrofit builder에 넘겨주기만 하면 되거든요. 이 방식을 사용하면 코드의 중복을 획기적으로 줄일 수 있고, 나중에 헤더 구조가 변경되더라도 인터셉터 클래스 한 곳만 수정하면 모든 통신 로직에 반영된다는 장점이 있어요. 실무에서 확장성을 고려한다면 단순히 개별 요청에 파라미터를 추가하는 것보다 훨씬 체계적이고 안정적인 구조라고 볼 수 있네요.

Jetpack Compose 2025년 12월 버전이 안정화되면서 핵심 모듈은 1.10, Material 3는 1.4로 업데이트되었네요. 프로젝트에 바로 적용하려면 compose-bom 버전을 2025.12.00으로 올리는 것만으로도 모든 개선 사항을 받을 수 있겠네요.

implementation(platform("androidx.compose:compose-bom:2025.12.00"))

이 구문을 build.gradle에 추가하면 되거든요. 이번 업데이트에서 가장 눈에 띄는 건 지연 미리 가져오기에서 일시중지 가능한 컴포지션이 기본 활성화되었다는 점이에요. 이전에는 복잡한 컴포지션이 시작되면 완료될 때까지 메인 스레드를 차단해서 UI가 멈출 수 있었는데, 이제 런타임이 작업을 필요할 때 일시중지하고 다음 프레임에서 이어받을 수 있게 되었네요. 덕분에 UI 워크로드가 많을 때 버벅거림 현상이 크게 줄어드는 것을 확인할 수 있더라고요.

내부 스크롤 벤치마크 결과에서도 Compose가 기존 뷰와 동일한 성능을 보여주니 안심하고 쓰셔도 괜찮아요.

특히 Compose 1.9에서 도입된 CacheWindow API와 함께 사용하면 더 많은 콘텐츠를 미리 가져오면서 일시중지 가능한 컴포지션의 장점을 최대한 활용할 수 있겠네요. 프레임 준비 시간을 확보할 수 있으니 스크롤이 훨씬 부드럽게 느껴질 거예요. 이미 프로젝트에 적용해 본 분들은 성능 차이가 체감되실 테고, 아직 미적용 상태라면 이번 기회에 BOM만 업데이트해 보시는 걸 추천드려요. 최신 버전의 성능 최적화가 앱의 반응성을 한 단계 끌어올려 줄 거라고 생각해요.

출처

화면 회전이나 메모리 부족으로 앱이 죽었다 살아날 때 입력한 값이 다 날아가는 경험 모두 있으시죠? 저도 처음에 액티비티가 리크리에이트 되면서 사용자가 열심히 적어놓은 폼 데이터가 완전히 사라지는 걸 보고 당황했던 적이 많아요. 보통 뷰모델을 쓰면 상태가 유지된다고 알고 있는데, 저장소 할당 오류나 강제 종료처럼 시스템이 프로세스를 완전히 죽이는 상황에서는 뷰모델도 초기화되어버리더라고요. 그래서 공식 문서에서도 권장하는 세이브드스테이트핸들이 정말 유용한 해결책이거든요. 이 도구를 알기 전까지 번들을 직접 조작하느라 고생했던 기억이 나네요.

// build.gradle 의존성 추가
implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2")

// 뷰모델에서 세이브드스테이트핸들 활용 예시
class MyViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    private val countFlow = savedStateHandle.getStateFlow("count", 0)
    val count: StateFlow<Int> = countFlow

    fun increment() {
        savedStateHandle["count"] = countFlow.value + 1
    }
}

작동되게 하려면 별도 팩토리 등록 없이 의존성만 추가하면 돼요. 뷰모델 이십 일 이상부터는 액티비티나 프래그먼트에서 뷰모델 프로바이더로 인스턴스를 가져올 때 시스템이 자동으로 세이브드스테이트핸들을 주입해주거든요. 별도의 초기화 코드를 작성할 필요가 없으니 개발 부담이 확실히 줄어드네요. 코드를 보시면 아시겠지만, 세이브드스테이트핸들을 받아서 초기화하고 플로우 프로퍼티를 통해 데이터를 접근하면 끝이에요. 값을 넣으면 화면이 회전하거나 메모리 재할당 상황에서 시스템이 알아서 저장해두고 다시 만들어줄 때 복원해주거든요. 데이터 클래스를 직렬화할 때 구현해야 할 코드가 생기는 경우가 많은데, 세이브드스테이트핸들은 기본 타입과 시리얼라이저만 지원해도 충분해서 복잡한 변환 로직을 줄일 수 있어요. 저도 처음에 번들을 직접 다룰 때 키 값 관리가 너무 귀찮아서 고민이었는데, 이 라이브러리가 그 과정을 완전히 추상화해줘서 정말 편해졌네요. 실제 프로젝트에 적용해 보니 디버깅 시간도 크게 줄었고 사용자 경험도 훨씬 안정적이더라고요. 상태 보존에 막히신다면 세이브드스테이트핸들을 도입해보시는 걸 강력하게 추천드려요.

앱을 만들다 보면 "앱이 응답하지 않습니다" 하는 그 악명 높은 다이얼로그를 한 번쯤은 만나게 되거든요. 이게 바로 ANR(Application Not Responding)인데, 저도 처음엔 왜 뜨는지 감이 안 잡혀서 한참 헤맸어요. 핵심만 말하면 메인 스레드(UI 스레드)가 정해진 시간 안에 일을 못 끝내면 시스템이 강제로 띄우는 경고예요.

ANR이 뜨는 대표적인 기준이 몇 가지 있는데, 가장 흔한 건 사용자의 터치나 키 입력 같은 입력 이벤트를 5초 안에 처리하지 못했을 때예요. 그 밖에도 포그라운드에 액티비티가 떠 있는 상태에서 BroadcastReceiver가 onReceive를 5초 안에 못 끝내거나, 서비스가 onCreate와 onStartCommand를 제때 마치지 못하는 경우, 그리고 startForegroundService로 시작한 서비스가 5초 안에 startForeground를 호출하지 않는 경우에도 발생하더라고요.

원인은 결국 하나로 모이더라고요. 무거운 작업을 메인 스레드에서 돌린 거죠. 네트워크 요청이나 큰 파일 읽기, 무거운 DB 쿼리, 비트맵 디코딩 같은 걸 UI 스레드에서 하면 화면이 잠깐 멈추고, 그 사이 사용자가 터치하면 5초 카운트가 시작되는 거예요. 저도 예전에 리스트 데이터를 메인에서 파싱하다가 딱 이걸 겪었어요.

해결은 무조건 무거운 일은 백그라운드로 넘기는 거예요. 코틀린이면 코루틴을 쓰는 게 제일 깔끔한데, 아래처럼 무거운 부분만 IO 디스패처로 넘기고 결과 반영만 메인에서 하면 됩니다.

lifecycleScope.launch {
    val data = withContext(Dispatchers.IO) {
        repository.loadFromNetwork()   // 무거운 작업은 IO 스레드에서
    }
    textView.text = data               // 결과 반영은 다시 메인 스레드에서
}

이렇게 withContext(Dispatchers.IO)로 무거운 부분만 IO 스레드로 넘기고, 결과 반영만 메인에서 하면 화면이 멈추지 않아요. 자바라면 예전엔 AsyncTask를 많이 썼는데 지금은 지원 종료(deprecated)됐으니, ExecutorService에 Handler를 조합하거나 WorkManager를 쓰는 게 더 나아요.

마지막으로 팁 하나 더. 실제 배포한 앱에서 발생한 ANR은 Play Console의 Android vitals에서 스택트레이스를 확인할 수 있으니, 어느 지점에서 메인 스레드가 막혔는지 거기서 꼭 들여다보세요. 로컬에서 재현이 안 될 때 특히 도움이 되거든요.

출처: Android Developers - Diagnose and fix ANRs (https://developer.android.com/topic/performance/anrs/diagnose-and-fix-anrs)

targetSdk를 33으로 올리고 나서 앱 알림이 하나도 안 뜬다는 제보를 받은 적이 있거든요. 코드는 그대로고 NotificationManager로 notify를 호출하는데도 화면에 아무것도 안 나타나더라고요. 예외도 안 던지고 로그도 조용해서 한참 헤맸는데, 알고 보니 원인은 꽤 단순했어요.

- 왜 안 뜨나

Android 13(API 33)부터 알림이 런타임 권한으로 바뀌었어요. POST_NOTIFICATIONS라는 권한이 새로 생겼고, 예전처럼 설치만 하면 알림이 켜져 있는 게 아니라 기본값이 꺼짐이에요. 사용자가 직접 허용해 주기 전까지는 notify를 아무리 불러도 시스템이 조용히 무시합니다. 에러가 안 나니까 더 찾기 어려운 거죠.

- 해결 방법

먼저 매니페스트에 권한을 선언해 줍니다.

<manifest ...>
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>

선언만 해서는 아무 일도 안 일어나요. 런타임에 사용자한테 직접 물어봐야 하거든요. 요즘은 ActivityResultContracts.RequestPermission을 쓰는 게 표준이에요.

private val requestPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
    if (isGranted) {
        // 알림을 보낼 수 있음
    } else {
        // 거부됨 - 알림 없이 동작하도록 처리
    }
}

private fun askNotificationPermission() {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return

    val granted = ContextCompat.checkSelfPermission(
        this, Manifest.permission.POST_NOTIFICATIONS
    ) == PackageManager.PERMISSION_GRANTED

    if (granted) return

    if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) {
        // 왜 알림이 필요한지 설명하는 UI를 먼저 보여주고 요청
    } else {
        requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
    }
}

- 주의할 점

targetSdk를 32 이하로 두면 시스템이 알아서 권한 대화상자를 띄워주긴 해요. 다만 앱이 첫 알림 채널을 만들 때 뜨는 방식이라 타이밍이 좀 애매하더라고요. 어차피 구글 플레이 정책상 targetSdk는 계속 올려야 하니까, 그냥 33 이상으로 맞추고 직접 요청하는 쪽이 마음 편한 것 같아요.

그리고 사용자가 한 번 거부하면 앱에서 다이얼로그를 다시 띄울 수가 없어요. 알림 채널 전체가 막혀버리거든요. 그래서 요청 타이밍이 정말 중요한데, 앱을 켜자마자 냅다 물어보기보다는 알림이 왜 필요한지 사용자가 납득할 만한 순간에 물어보는 게 좋아요. shouldShowRequestPermissionRationale이 true를 리턴하면 "설명을 좀 해주고 요청하라"는 신호니까 이걸 활용하면 됩니다.

마지막으로 헷갈리기 쉬운 부분 하나. 포그라운드 서비스는 이 권한이 없어도 시작할 수 있어요. 대신 서비스에 붙는 알림 자체는 권한이 없으면 사용자한테 안 보입니다. 서비스는 도는데 알림만 안 보인다면 이걸 의심해 보세요. 미디어 세션 관련 알림이나 통화 스타일(CallStyle) 알림은 예외라서 권한 없이도 표시된다는 것도 알아두면 좋고요.

저도 처음엔 "알림이 왜 안 뜨지" 하면서 채널 설정만 계속 뒤졌는데, 결국 권한 한 줄이었어요. 13 이상 기기에서 알림이 조용하다면 제일 먼저 여기부터 확인해 보시길.

출처: Notification runtime permission - Android Developers (https://developer.android.com/develop/ui/views/notifications/notification-permission)

2023년 8월 31일부터 플레이 스토어에 등록하기 위해서는 앱의 Target API를 33이상으로 해야 한다.

Android 13에 맞춰서 사용하던 권한을 변경했는데, Album에서 권한이 허용되지 않았다고 얼럿이 뜬다.
yanzhenjie:album는 최종 업데이트가 2018년이고, 내부적으로 권한을 체크해서 얼럿을 띄우는데, 한 개발자가 Android 13에 맞춰서 업데이트를 해놨다(Thanks)

라이브러리 참조방법
https://jitpack.io/#hisetu/Album/android_13-SNAPSHOT

 

JitPack | Publish JVM and Android libraries

JitPack makes it easy to release your Java or Android library. Publish straight from GitHub or Bitbucket.

jitpack.io

깃헙주소
https://github.com/hisetu/Album 

 

GitHub - hisetu/Album: :watermelon: Album and Gallery for Android platform.

:watermelon: Album and Gallery for Android platform. - GitHub - hisetu/Album: :watermelon: Album and Gallery for Android platform.

github.com

 

페이지 이동 시 도메인을 확인하여 webView.removeJavascriptInterface(SCRIPT_NAME); 을 호출.

 

상황에 따라 handler를 이용하거나 runOnUiThread사용

 

 

----------------------- 추가 --------------------

위의 방법은 HTTPS를 사용하거나, 특정 몇개의 도메인만 사용하는 경우에만 허용됨.

사이트 내부에서 다른 외부 사이트의 이미지들을 로딩하는 경우에는 사용할 수 없는 방법으로, 이 경우에는 prompt를 이용하여 비슷하게 구현가능

1. 서버에서 HTTPS 사용.

 

2-1. 서버에서 HTTPS사용 불가 시 manifest파일의 application태그에 android:usesCleartextTraffic="true" 속성 추가

2-2. 서버에서 HTTPS사용 불가 시 resource/xml폴더에 임의의 xml 파일 생성 후 다음과 같은 내용을 입력 후 manifest파일의 application태그에 android:networkSecurityConfig="@xml/추가한 파일명" 속성추가

<network-security-config>
<domain-config cleartextTrafficPermitted="true">
    <domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>

 

예전엔 SqliteOpenHelper 상속받아서 썼었는데, 테이블/쿼리 관련해서 간단히 선언만하면 자동으로 구현소스를 만들어 주는게 있다.


나중에 필요할때 써먹어야겠다. (그동안 만들어 놨던 클래스 안녕 ㅠㅠ)


https://codelabs.developers.google.com/codelabs/android-room-with-a-view/#0

a태그에 download 속성을 추가한다. 값은 임의 지정가능
<a href="https://www.xpressengine.com/layouts/xe_v4/img/bi-lg.png&quot; download="image">image_down</a>

+ Recent posts