Retrofit 'Connection Leaked' 경고, 원인부터 실전 해결 전략까지
안드로이드 및 자바 환경에서 HTTP 통신을 구현할 때, Retrofit은 이제 거의 표준처럼 여겨지는 라이브러리입니다. 강력한 타입 안정성과 간결한 API 정의는 개발자의 생산성을 극적으로 향상시켜 줍니다. 하지만 이렇게 편리한 Retrofit을 사용하다 보면, 많은 개발자가 한 번쯤은 Logcat 창에서 다음과 같은 섬뜩한 경고 메시지를 마주하게 됩니다.
WARNING: A connection to https://api.example.com/ was leaked. Did you forget to close a response body?
이 경고는 '메모리 누수(Memory Leak)'라는 단어와 닮아 있어 개발자에게 심리적인 압박감을 줍니다. "Leaked"라는 단어는 심각한 문제를 암시하는 것처럼 보입니다. 대부분의 경우, 이 경고가 즉시 앱을 중단시키는 치명적인 오류로 이어지지는 않습니다. 하지만 이를 방치하는 것은 잠재적인 성능 저하와 자원 고갈의 씨앗을 심는 것과 같습니다. 마치 집안 어딘가에서 물이 아주 조금씩 새고 있는 것을 알면서도 모른 척하는 것과 같죠. 처음에는 괜찮겠지만, 시간이 지나면 바닥이 썩고 큰 문제로 번질 수 있습니다.
이 글에서는 단순히 response.close()
를 호출하라는 단편적인 해결책을 제시하는 것을 넘어, 이 경고가 왜 발생하는지 근본적인 원인을 파헤치고, 다양한 개발 환경(콜백, 코루틴, RxJava)과 실제 상황(인터셉터, 에러 처리)에 맞춰 가장 이상적인 해결 전략을 코드와 함께 상세하게 제시하고자 합니다. 이 글을 끝까지 읽으시면, 다시는 'Connection Leaked' 경고에 당황하지 않고, 자신감 있게 자원을 관리하는 프로페셔널한 개발자로 거듭날 수 있을 것입니다.
경고의 진짜 의미: Retrofit과 OkHttp의 관계 속으로
문제를 해결하기 위해선 먼저 그 본질을 이해해야 합니다. 이 경고 메시지는 사실 Retrofit 자체의 버그가 아니라, Retrofit의 심장부에서 실제 네트워킹을 담당하는 OkHttp 라이브러리의 동작 방식과 깊은 관련이 있습니다. Retrofit은 OkHttp를 기반으로 만들어진, 일종의 아름다운 외피(Wrapper)입니다. 우리가 Retrofit 인터페이스를 정의하고 호출하면, 내부적으로는 OkHttp가 TCP/IP 소켓을 열고, HTTP 요청을 보내고, 응답을 받는 복잡한 과정을 모두 처리해 줍니다.
ResponseBody, 단순한 데이터가 아닌 '자원'입니다
우리가 서버로부터 받는 응답, 특히 response.body()
나 response.errorBody()
를 통해 얻는 ResponseBody
객체는 단순한 String이나 JSON 객체가 아닙니다. 이것은 아직 완전히 메모리에 로드되지 않은, 서버와의 연결 스트림(Stream)을 물고 있는 '자원(Resource)'에 가깝습니다. 파일 I/O에서 InputStream
이나 FileInputStream
을 다루는 것과 매우 유사하다고 생각하면 이해가 쉽습니다.
만약 서버가 수십 메가바이트(MB)짜리 거대한 JSON이나 파일을 응답으로 보낸다고 상상해 보세요. OkHttp가 이 응답을 받자마자 전부 메모리에 올려버린다면, 앱은 순식간에 OOM(Out of Memory) 오류를 내뿜으며 비정상적으로 종료될 것입니다. OkHttp는 이러한 상황을 방지하기 위해 ResponseBody
에 실제 데이터 스트림을 연결해두고, 개발자가 이 스트림을 소비(Consume)할 때 비로소 데이터를 읽어옵니다.
여기서 핵심은 '소비'입니다. ResponseBody
를 소비하는 대표적인 방법은 다음과 같습니다.
responseBody.string()
: 스트림의 모든 데이터를 읽어 String으로 변환합니다. (내부적으로 스트림을 닫습니다.)responseBody.bytes()
: 모든 데이터를 byte 배열로 변환합니다. (내부적으로 스트림을 닫습니다.)responseBody.charStream()
,responseBody.byteStream()
: 스트림을 직접 얻어와 수동으로 처리합니다. (개발자가 직접 스트림을 닫아야 합니다.)responseBody.close()
: 데이터가 필요 없을 때, 스트림을 명시적으로 닫아 연결을 해제합니다.
중요한 점은 ResponseBody
는 '일회용(One-shot)'이라는 것입니다. .string()
이나 .bytes()
를 한 번 호출하고 나면 스트림이 닫히므로, 두 번 이상 호출하면 IllegalStateException: closed
예외가 발생합니다.
OkHttp의 ConnectionPool과 '누수'의 실체
OkHttp는 성능 최적화를 위해 Connection Pool이라는 메커니즘을 사용합니다. 매번 새로운 요청마다 TCP 핸드셰이크와 같은 비싼 과정을 반복하는 대신, 한 번 사용했던 연결을 잠시 열어두고 있다가 동일한 호스트(e.g., `api.example.com`)로의 다음 요청이 들어오면 재사용하는 기술입니다.
이제 모든 조각이 맞춰집니다. 우리가 API를 호출하고 응답을 받았는데, 성공(HTTP 2xx)이 아닌 실패(HTTP 4xx, 5xx 등)가 발생했다고 가정해 봅시다. 보통 우리는 성공했을 때의 데이터, 즉 response.body()
에만 관심을 가집니다. 실패 시에는 response.errorBody()
에 담긴 에러 메시지를 파싱하거나, 혹은 그냥 무시하고 넘어가기도 합니다.
만약 우리가 ResponseBody
(성공 시)나 errorBody()
(실패 시)를 전혀 소비하지도 않고, 명시적으로 닫아주지도 않으면 어떻게 될까요? OkHttp 입장에서는 이 연결이 아직 사용 중인 것으로 간주합니다. 데이터 스트림이 열려있기 때문입니다. 따라서 이 연결을 Connection Pool에 반환하여 재사용할 수가 없습니다. 이것이 바로 'Connection Leak'의 실체입니다. 말 그대로 연결 자원이 새어 나가고 있는 것입니다.
이런 누수가 반복되면 Connection Pool에 재사용 가능한 유휴 연결이 점점 줄어들고, 결국 새로운 요청이 있을 때마다 계속해서 새로운 연결을 만들어야 하므로 성능이 저하됩니다. 극단적인 경우에는 가용 소켓 자원을 모두 소진하여 더 이상 새로운 네트워크 요청을 처리하지 못하는 상태에 이를 수도 있습니다.
상황별 해결 시나리오: 코드와 함께 보는 명쾌한 해법
원인을 알았으니, 이제 실제 코드에서 어떻게 이 문제를 해결해야 하는지 다양한 시나리오를 통해 알아보겠습니다.
1. 전통적인 방식: Call.enqueue() 콜백 사용 시
가장 고전적이고 기본적인 비동기 호출 방식입니다. onResponse
와 onFailure
콜백을 사용합니다.
문제의 코드 (Bad Practice)
많은 개발자가 HTTP 상태 코드가 성공적인 범위(200-299)에 속하는지만 확인하고, 그렇지 않은 경우 별다른 조치를 취하지 않아 누수를 발생시킵니다.
// 안티 패턴: 에러 응답 바디를 처리하지 않음
apiService.getUser("john").enqueue(object : Callback<User> {
override fun onResponse(call: Call<User>, response: Response<User>) {
if (response.isSuccessful) {
// 성공 시에는 Retrofit이 response.body()를 컨버터를 통해 객체로 변환하면서
// 내부적으로 ResponseBody를 닫아주므로 신경 쓸 필요가 없다.
val user = response.body()
// ... 유저 정보 사용
} else {
// !!! 문제 지점 !!!
// response.isSuccessful이 false인 경우 (e.g., 404 Not Found, 500 Server Error)
// response.errorBody()가 존재하지만, 이를 소비하거나 닫지 않았다.
// 이 블록이 실행되면 Connection Leak 경고가 발생한다.
Log.e("API_ERROR", "Error code: ${response.code()}")
}
}
override fun onFailure(call: Call<User>, t: Throwable) {
// onFailure는 네트워크 연결 자체 실패, 타임아웃, DNS 문제 등일 때 호출된다.
// 이 경우에는 서버로부터 응답 자체가 오지 않았으므로 ResponseBody가 존재하지 않아
// 누수 걱정을 할 필요가 없다.
Log.e("NETWORK_ERROR", "Network failure", t)
}
})
올바른 해결책 (Good Practice)
response.isSuccessful
이 false
인 else
블록에서 errorBody
를 반드시 처리(소비 또는 닫기)해야 합니다.
// 올바른 패턴: 에러 응답 바디를 반드시 닫아준다.
apiService.getUser("john").enqueue(object : Callback<User> {
override fun onResponse(call: Call<User>, response: Response<User>) {
if (response.isSuccessful) {
val user = response.body()
// ...
} else {
// 해결책: 에러 바디를 사용하든 안 하든, 반드시 자원을 해제해야 한다.
val errorMsg = response.errorBody()?.string() // .string()은 데이터를 읽고 스트림을 자동으로 닫는다.
Log.e("API_ERROR", "Error code: ${response.code()}, message: $errorMsg")
// 만약 에러 메시지 내용이 필요 없다면, 명시적으로 닫아주기만 해도 된다.
// response.errorBody()?.close() // .string()을 호출하지 않을 경우 이 방법을 사용한다.
}
}
override fun onFailure(call: Call<User>, t: Throwable) {
// ...
}
})
핵심: response.errorBody()
도 ResponseBody
객체입니다. .string()
을 호출하여 내용을 확인하는 순간 스트림이 소비되고 닫힙니다. 만약 내용이 필요 없다면 .close()
를 명시적으로 호출하여 자원을 해제해야 합니다.
2. 동기 호출: Call.execute()와 자원 관리의 정석
백그라운드 스레드에서 직접 API를 동기적으로 호출할 때는 자원 관리에 더욱 신경 써야 합니다. 이때는 Java의 try-finally
(또는 Kotlin의 use
확장 함수)가 최고의 해결책입니다.
thread {
var response: Response<User>? = null
try {
response = apiService.getUser("jane").execute() // 동기 호출
if (response.isSuccessful) {
val user = response.body()
// ... 성공 로직
} else {
val errorMsg = response.errorBody()?.string()
// ... 실패 로직
}
} catch (e: Exception) {
// IOException 등 네트워크 예외 처리
} finally {
// !!! 결정적인 부분 !!!
// 성공하든, 실패하든, 예외가 발생하든 상관없이
// response 객체 자체가 null이 아니라면 그 바디를 닫아주어야 한다.
// 하지만 isSuccessful일 때는 body()를 통해 이미 소비되었고,
// isSuccessful이 아닐 때는 errorBody()를 string()으로 소비했다.
// 만약 소비하지 않는 로직이 있다면 여기서 close()를 해야한다.
// Response 객체 자체는 close() 메소드가 없음에 유의하라. ResponseBody를 닫는 것이다.
// 그러나 execute()를 사용하는 현대적인 Kotlin 코드는 'use'를 사용하는 것이 훨씬 안전하고 간결하다.
}
}
// ===> 더 나은 Kotlin 스타일: Response.use 사용
thread {
try {
apiService.getUser("jane").execute().use { response -> // use 확장 함수가 알아서 close()를 보장한다.
if (response.isSuccessful) {
// 이 블록을 나가면 response.close()가 자동으로 호출된다.
// 하지만 response.body()를 얻는 순간 스트림은 소비된다.
val user = response.body()
println("Success: $user")
} else {
// response.errorBody() 역시 이 블록을 벗어나면 자동으로 닫히지만,
// 내용을 읽으려면 명시적으로 소비해야 한다.
val errorMsg = response.errorBody()?.string()
println("Error: ${response.code()} - $errorMsg")
}
}
} catch (e: IOException) {
println("Network error: ${e.message}")
}
}
response.use { ... }
블록은 Java의 try-with-resources
와 동일한 역할을 합니다. 이 블록이 끝나면 response
객체(정확히는 그 안의 ResponseBody)의 close()
메서드가 자동으로 호출되는 것을 보장하므로, 리소스 누수를 원천적으로 방지하는 매우 강력하고 권장되는 방법입니다.
3. 코루틴(Coroutines) 환경에서의 현대적 접근법
최신 안드로이드 개발에서는 코루틴을 사용하는 것이 대세입니다. Retrofit은 코루틴을 완벽하게 지원하며, suspend
함수를 통해 비동기 코드를 동기 코드처럼 간결하게 작성할 수 있게 해줍니다. 코루틴 환경에서는 API의 반환 타입을 어떻게 정의하느냐에 따라 대처법이 달라집니다.
Case A: 반환 타입이 `Response<T>`인 경우
API 인터페이스를 `suspend fun getUser(...): Response
// ApiService.kt
interface ApiService {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): Response<User> // 반환 타입이 Response<T>
}
// ViewModel or Repository
suspend fun fetchUser(id: String) {
try {
val response = apiService.getUser(id)
if (response.isSuccessful) {
val user = response.body()
// ... 성공
} else {
// 여기서도 마찬가지로 errorBody를 소비하거나 닫아야 한다.
val errorBody = response.errorBody()
Log.e("API_ERROR", "Code: ${response.code()}, Body: ${errorBody?.string()}")
errorBody?.close() // .string()을 썼다면 이미 닫혔지만, 안전을 위해 호출하거나 둘 중 하나만 사용.
}
} catch (e: Exception) {
// 네트워크 에러 처리
}
}
이 방식은 HTTP 상태 코드, 헤더 등 응답의 모든 정보에 접근해야 할 때 유용하지만, 매번 isSuccessful
을 체크하고 에러 바디를 수동으로 처리해야 하는 번거로움이 있습니다.
Case B: 반환 타입이 `T`인 경우 (권장)
대부분의 경우, 우리는 성공 시의 데이터 객체에만 관심이 있습니다. API를 `suspend fun getUser(...): User` 와 같이 정의하면 Retrofit이 마법을 부려줍니다.
// ApiService.kt
interface ApiService {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): User // 반환 타입이 실제 데이터 객체(T)
}
// ViewModel or Repository
suspend fun fetchUser(id: String) {
try {
// Retrofit이 알아서 성공 응답(2xx)의 body를 파싱하여 User 객체로 반환해준다.
// ResponseBody는 내부적으로 이미 소비되고 닫혔다!
val user: User = apiService.getUser(id)
// ... 성공 로직
} catch (e: Exception) {
// 실패 시 어떻게 될까?
if (e is HttpException) {
// HttpException은 HTTP 에러(2xx가 아닌 상태 코드)가 발생했을 때 던져지는 예외이다.
// 중요한 사실: Retrofit이 이 예외를 던지기 전에,
// 내부적으로 response.errorBody()를 **이미 읽고 닫아버린다.**
// 따라서 우리는 Connection Leak을 걱정할 필요가 없다!
val code = e.code()
val errorResponse = e.response()?.errorBody()?.string()
// 주의: 여기서 errorBody()를 읽으려고 하면 이미 닫혀서 읽을 수 없는 경우가 대부분이다.
// Retrofit 버전 및 어댑터 구현에 따라 다를 수 있으나, 일반적으로 HttpException을 잡는 시점에서는
// 리소스가 이미 정리되었다고 봐도 무방하다.
// 만약 에러 본문이 꼭 필요하다면 Response<T>를 반환받는 것이 더 명확하다.
Log.e("API_ERROR", "HttpException Code: $code, Error Body: $errorResponse")
} else {
// IOException 등 다른 네트워크 관련 예외 처리
Log.e("NETWORK_ERROR", "Error: ${e.message}", e)
}
}
}
결론: 코루틴을 사용하고 API 반환 타입을 `T`로 정의하면, Retrofit이 성공/실패 시의 `ResponseBody` 관리를 대부분 자동으로 처리해 줍니다. 실패는 `HttpException`으로 변환되어 `catch` 블록에서 처리하면 됩니다. 이 방식은 코드가 훨씬 간결해지고, 리소스 누수 위험도 현저히 줄어들어 가장 권장되는 현대적인 패턴입니다.
4. RxJava 사용자들을 위한 가이드
RxJava 또한 코루틴과 유사한 메커니즘을 제공합니다. RxJava3CallAdapterFactory
를 사용하면 스트림을 더욱 우아하게 처리할 수 있습니다.
- `Single<Response<T>>`: 코루틴의 `Response<T>`와 같습니다. `onSuccess`에서
response
객체를 직접 받으므로,isSuccessful
을 체크하고 실패 시 `errorBody`를 직접 닫아줘야 합니다. - `Single<T>` (권장): 코루틴의 `T`와 같습니다. 성공 시 데이터 `T`가 `onSuccess`로 바로 전달됩니다. 실패 시(non-2xx)에는 `HttpException`이 `onError`로 전달됩니다. 이 과정에서 `RxJava3CallAdapter`가 `errorBody`를 알아서 정리해주므로 누수 걱정이 없습니다.
// 권장되는 RxJava 패턴
apiService.getUser("mike") // 반환 타입: Single<User>
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
user -> {
// 성공. ResponseBody는 이미 닫혔다.
// ... 유저 정보 사용
},
error -> {
// 실패.
if (error instanceof HttpException) {
// HttpException 발생. errorBody는 이미 닫혔다.
HttpException httpException = (HttpException) error;
// ... 에러 코드(httpException.code()) 등으로 분기 처리
} else {
// IOException 등 다른 에러
}
}
);
함정을 피하는 기술: 인터셉터와 스트리밍 처리
일반적인 API 호출 외에도 연결 누수가 발생하기 쉬운 숨은 복병들이 있습니다.
가장 흔한 함정: 로깅 인터셉터에서의 누수
개발 중 API 요청/응답을 디버깅하기 위해 OkHttp의 `Interceptor`를 사용하여 로그를 남기는 경우가 많습니다. 이때 무심코 `response.body()`를 읽어버리면 심각한 문제가 발생합니다.
잘못된 로깅 인터셉터 (치명적!)
class BadLoggingInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
// !!! 아주 큰 문제 !!!
// response.body()는 스트림 기반의 일회용 객체다.
// .string()으로 내용을 읽어버리면 스트림이 닫힌다.
val responseBodyString = response.body?.string()
Log.d("API_LOG", "Response Body: $responseBodyString")
// 스트림이 닫힌 response를 그대로 반환하면,
// 이 응답을 받아 처리하려던 Retrofit의 컨버터(Gson, Moshi 등)는
// 이미 닫힌 스트림을 읽으려다 "IllegalStateException: closed" 예외를 발생시킨다!
return response
}
}
올바른 로깅 인터셉터
응답 본문을 '엿보고' 원래의 응답은 그대로 유지해야 합니다. OkHttp는 이를 위해 peekBody()
라는 매우 유용한 함수를 제공합니다.
class GoodLoggingInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
// 해결책: peekBody() 사용
// peekBody()는 원본 스트림을 닫지 않고, 버퍼에 내용을 복사해 새로운 스트림을 만든다.
// 따라서 내용을 들여다 본 후에도 원본 응답은 아무런 영향을 받지 않는다.
val responseBody = response.peekBody(Long.MAX_VALUE)
Log.d("API_LOG", "Response Body: ${responseBody.string()}")
return response
}
}
// 참고: 이미 Square에서 만든 훌륭한 로깅 인터셉터 라이브러리가 있다.
// implementation("com.squareup.okhttp3:logging-interceptor:4.x.x")
// 이걸 쓰는 것이 가장 좋다.
대용량 데이터 처리: @Streaming 어노테이션 사용 시 주의점
수백 MB, GB 단위의 파일을 다운로드할 때는 @Streaming
어노테이션을 사용합니다. 이는 Retrofit에게 응답 전체를 메모리에 버퍼링하지 말고, 스트림 형태로 바로 전달하라고 지시하는 것입니다.
이 경우, 개발자는 ResponseBody
에서 직접 byteStream()
을 얻어와 파일에 쓰는 등 수동으로 스트림을 처리해야 합니다. 당연히 스트림을 다 쓰고 나면 try-finally
나 .use
블록을 이용해 반드시 스트림을 닫아주어야 합니다. 그렇지 않으면 100% 연결 누수가 발생합니다.
근본적인 해결책: 일관성 있는 에러 처리 구조 설계
매번 if/else
와 try/catch
를 반복하는 것은 실수를 유발하기 쉽습니다. Kotlin의 확장 함수 등을 이용해 API 호출과 에러 처리를 캡슐화하는 공통 모듈을 만들면 실수를 줄이고 코드의 일관성을 높일 수 있습니다.
예를 들어, Response<T>
를 안전하게 처리하는 확장 함수를 만들 수 있습니다.
// 제네릭과 고차 함수를 이용한 안전한 API 호출 래퍼
suspend fun <T> safeApiCall(apiCall: suspend () -> Response<T>): Result<T> {
return try {
val response = apiCall()
if (response.isSuccessful) {
// body가 null일 수도 있는 경우를 대비
response.body()?.let {
Result.success(it)
} ?: Result.failure(Exception("Response body is null"))
} else {
// errorBody는 사용 후 반드시 닫는다.
val errorBody = response.errorBody()?.string()
Result.failure(Exception("API Error ${response.code()}: $errorBody"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
// 실제 사용 예시
viewModelScope.launch {
val userResult = safeApiCall { apiService.getUser("finn") } // API 호출 부분을 람다로 전달
userResult.onSuccess { user ->
// 성공 처리
}.onFailure { error ->
// 실패 처리 (네트워크 오류, API 오류 모두 여기서 처리)
}
}
이러한 래퍼 함수를 프로젝트 전반에 걸쳐 사용하면, 모든 API 호출에 대해 일관된 방식으로 자원 해제가 보장되므로, 개발자는 비즈니스 로직에만 집중할 수 있게 됩니다.
결론: '닫는 습관'이 최고의 예방책입니다
WARNING: A connection to ... was leaked
경고는 Retrofit/OkHttp가 우리에게 보내는 중요한 신호입니다. "당신이 요청한 자원은 아직 열려있습니다. 다 사용하셨으면 문을 닫아주세요!" 라는 친절한 알림인 셈입니다.
이 문제의 핵심은 ResponseBody
를 단순한 데이터 덩어리가 아닌, 파일을 다룰 때의 InputStream
처럼 소중한 '자원(Resource)'으로 인식하는 것에서부터 출발합니다. 자원은 사용 후 반드시 해제(close)해주어야 한다는 프로그래밍의 대원칙을 잊지 않는 것이 중요합니다.
오늘 우리는 이 경고의 원인이 되는 OkHttp의 Connection Pool과 ResponseBody의 동작 방식을 이해했으며, 콜백, 코루틴, RxJava 등 다양한 환경에서 발생하는 누수 시나리오와 그 해결책을 구체적인 코드로 살펴보았습니다. 또한 로깅 인터셉터와 같은 함정을 피하는 방법과, 에러 처리 로직을 구조화하여 문제를 근본적으로 예방하는 전략까지 다루었습니다.
이제 여러분은 이 경고를 더 이상 두려워할 필요가 없습니다. 오히려 이 경고를 마주칠 때마다, "아, 내가 어딘가에서 자원 관리를 깜빡했구나"라고 인지하고 코드를 점검하는 좋은 기회로 삼을 수 있을 것입니다. 올바른 자원 관리 습관은 앱의 안정성과 성능을 보장하는 탄탄한 반석이 되어줄 것입니다.
0 개의 댓글:
Post a Comment