Android 개발/Kotlin

Kotlin 컨텍스트 파라미터, 어느 값이 주입됐는지 눈에 보이게 만든 실전 일지

stackD 2026. 9. 22. 18:00
반응형

 

얼마 전에 코틀린(Kotlin) 2.3.20 으로 올린 다음날 아침, 멀쩡하던 결제 함수 한 줄에 빨간 줄이 떠 있었습니다. ambiguous 라는데, 어제까진 아무 문제 없던 코드였거든요.

 

그날 로그를 뒤지다가 결국 이 자리까지 왔어요. 함수 시그니처에 컨텍스트가 두 개 붙어 있는데, 호출부만 봐서는 어느 값이 들어갔는지 도무지 안 보이더라고요. 6개월 뒤의 제가 이 셋째 줄 log 를 보고 "이 로거 어디서 왔지?" 하고 물으면 답을 못 할 것 같았습니다. 그래서 2.4 명시적 컨텍스트 인자로 정리한 과정을 남겨둡니다.

 

컨텍스트 리시버 시절의 '보이지 않는 주입' 불편

예전에 컨텍스트 리시버(context receivers)를 쓸 때는 참 편했어요. 리시버 스코프가 암묵적으로 열려서 bar() 를 그냥 바로 호출하면 됐거든요. 근데 그 편함이 딱 발목을 잡더라고요. 어떤 인스턴스가 열려 있는지 호출부에 흔적이 하나도 안 남습니다. DSL 짤 때는 간결한데, 디버깅할 땐 스택을 거꾸로 파고들어야 했어요.

 

코틀린 컨텍스트 파라미터는 이 지점을 뒤집습니다. 이름을 강제하는 방식이에요.

 

interface UserService {
  fun log(message: String)
  fun findUserById(id: Int): String
}

context(users: UserService)      // 이름: 타입 으로 선언
fun outputMessage(message: String) {
  users.log("Log: $message")     // users. 로 명시 접근
}

 

users.log(...) 처럼 이름으로 접근해야 하니 타이핑은 늘어납니다. 대신 값의 출처가 시그니처에 박히는 거예요. 편의를 조금 반납하고 유지보수성을 산 맞바꿈이라고 봅니다.

 

한 가지 오해는 짚고 가야겠습니다. 이게 Koin 이나 Hilt 같은 의존성 주입 프레임워크를 대체하는 물건은 아니에요. 로거·트랜잭션·코루틴 스코프처럼 여기저기 깔려 흐르는 값을 함수 시그니처 수준에서 넘기는 보완재라고 보시면 되겠습니다.

 

 

2.3.20 오버로드 규칙 변경이 만든 ambiguous 에러

제 빨간 줄의 정체는 여기 있었습니다.

 

컨텍스트를 해소하는 방식은 타입 기반 스코프 검색이에요. 쉽게 설명하자면, 컴파일러가 각 파라미터마다 스코프 안에 맞는 후보 값이 몇 개인지 개수를 세서 결정합니다. 후보가 딱 하나면 그걸 넣고, 두 개 이상이면 "누굴 넣어야 할지 모르겠다"며 손을 들어버리는 구조예요.

 

2.3.20 에서 오버로드 해소 규칙이 바뀌면서, 컨텍스트 파라미터만 다른 오버로드를 호출할 때 갑자기 모호 판정이 나기 시작했습니다. 제 코드가 딱 그 경우였어요. 사람 눈엔 문맥으로 뻔히 구분되던 두 로거가, 컴파일러 입장에선 그냥 같은 타입 후보 두 개였던 거죠. 멀쩡하던 코드가 마이너 업데이트 후에 회귀로 터진 셈이었습니다.

 

 

Kotlin 2.4 explicit context arguments로 주입값 드러내기

코틀린 2.4.0 은 2026년 6월에 나왔습니다. 여기서 호출부에 컨텍스트 스코프를 명시적으로 열어 어떤 값이 주입되는지 드러낼 수 있게 됐어요.

 

// 어느 로거가 스코프에 열렸는지 호출부에 남는다
with(primary) {
  charge()   // UserService 컨텍스트가 스코프에서 해소됨
}

 

여기서 짚어둘 게 하나 있어요. with(primary)처럼 스코프를 열어 후보를 못 박는 방식과, users.log(...)처럼 호출부에서 이름으로 값을 직접 명시 인자로 지정하는 방식은 결이 조금 다릅니다. 앞은 스코프 단위로 후보를 확정하는 거고, 뒤는 호출 지점마다 이름으로 값을 못 박는 거예요.

 

이 스코프 블록이 저한텐 진짜 반가웠습니다. 로깅 스코프를 적용할 때 어떤 로거가 주입됐는지 호출부에 그대로 남으니까, 리뷰어가 스택을 역추적할 필요가 없어지더라고요. 같은 타입 컨텍스트가 중첩된 트랜잭션 스코프에서도 명시적으로 스코프를 열어 후보를 못 박으면 아까 그 모호 에러가 바로 풀립니다.

 

다만 여기 함정이 하나 있습니다. 이런 명시적 컨텍스트 인자 지정과 callable reference 는 여전히 Experimental 상태예요. -Xexplicit-context-arguments 플래그를 켜야 쓸 수 있고, 기본 활성화는 코틀린 2.5 예정으로 알려져 있어요. 프로덕션에 전면 도입하기엔 아직 이른 것 같습니다.

 

 

Context Parameters Stable 승격과 Experimental 공존 구분

"2.4에서 컨텍스트 파라미터가 Stable 됐다"는 소식만 보고 안심하면 딱 제가 밟은 함정을 밟습니다.

 

정확히는 이렇게 갈립니다.

 

  1. 컨텍스트 파라미터 선언과 암묵 전달 → Stable 승격됐습니다.
  2. 명시적 컨텍스트 인자, callable reference → 여전히 Experimental, 플래그 뒤에 있습니다.

 

"Stable 승격"이랑 "내가 쓰려는 그 기능이 Stable"은 다른 이야기입니다. 제가 정작 필요했던 with(primary) { charge() } 형태의 명시적 스코프 지정은 아직 실험 딱지가 붙어 있었던 거예요.

 

날짜도 하나 짚어둘게요. 인용하실 때 2.4.0(2026년 6월)과 2.3.0(2025년 12월 16일)을 헷갈리기 쉬운데, "2025년 12월 Stable" 이라고 적힌 글이 보이면 그건 2.3.0 얘기라 다시 확인하시는 게 안전합니다.

 

 

컨텍스트 리시버 deprecated 마이그레이션과 팀 컨벤션

컨텍스트 리시버는 이제 deprecated 됐습니다. context(Foo) 형태의 기존 코드는 곧 경고를 뱉을 거예요.

 

옮기는 것 자체는 다행히 도구가 도와줍니다. 인텔리J 아이디어(IntelliJ IDEA) 2025.1 이상이면 "Replace context receivers with context parameters" 퀵픽스로 한 파일 안의 리시버를 통째로 자동 전환해 줍니다. 저도 함수 단위는 거의 이걸로 넘겼어요. 프로젝트 전체를 한 번에 훑고 싶으면 Run Inspection by Name 에서 "Migrate from context receivers to context parameters" 를 돌리면 되고요.

 

문제는 클래스에 붙어 있던 리시버였습니다. 이건 직접 대응물이 없어서 손으로 재설계해야 하거든요. 견적을 만만하게 잡았다가 반나절을 통째로 썼습니다. 클래스 단위로 컨텍스트 리시버를 쓰고 계셨다면 마이그레이션 비용을 넉넉히 잡으시는 편이 좋습니다.

 

컨벤션도 하나 제안하고 싶어요. 명시 인자를 모든 함수에 강제하면 코드가 장황해집니다. 같은 타입 컨텍스트가 둘 이상 겹치는 자리에서만 명시를 강제하고, 인자 한두 개짜리 작은 함수는 그냥 일반 파라미터로 두는 게 훨씬 읽기 좋았어요. 2.2 Beta → 2.3.20 규칙 변경 → 2.4 일부 Stable 로 버전마다 손볼 데가 생기니, 초기 채택 코드는 버전업 계획과 묶어서 관리하는 게 마음이 편합니다.

 

결국 제가 겪은 "보이지 않는 주입" 불편은 컴파일러가 후보 개수를 세는 방식에서 나온 거였고, 2.4 명시적 인자가 그 값을 호출부에 다시 드러내 줬습니다. 편의를 조금 반납하는 대신, 반년 뒤의 제가 셋째 줄 log 를 보고 "이 컨텍스트는 여기서 왔다"고 바로 답할 수 있는 코드를 남기기로 했어요. 그거 하나면 그날 아침의 빨간 줄값은 했다고 봅니다.

 

반응형
LIST