
모듈을 라이브러리로 떼어낸 다음 날 아침이었습니다. 슬랙에 이름도 처음 듣는 팀이 '그 함수 시그니처 바꾸면 우리 서비스 터져요'라고 올렸는데, 제 내부 헬퍼를 세 군데서 쓰고 있더라고요.
위키에 "내부용이니 쓰지 마세요"라고 적어둔 상태였어요. 근데 컴파일러는 그 위키를 읽지 않죠. 결국 반환 타입 하나 못 바꾸는 콘크리트 라이브러리가 돼버린 겁니다.
내부 헬퍼가 다른 팀 코드에 물려버린 자리
원인은 제 부주의가 아니라 언어 기본값이었습니다. 코틀린(Kotlin)은 함수 앞에 가시성 수식어를 안 붙이면 전부 public으로 잡히거든요. 방금 짠 내부 헬퍼가 회사 전체가 import 할 수 있는 공개 API로 열려 있었던 셈입니다.
밖에서 호출한 팀 잘못이 아니었어요. 컴파일러가 "이거 써도 돼"라고 초록불을 켜준 거니까요.
재밌는 건 러스트(Rust)는 정반대라는 점입니다. 러스트는 기본이 private이고, 열고 싶을 때만 pub이나 pub(crate)을 명시하죠. 닫힘이 기본인 언어에선 이 사고가 애초에 잘 안 나요. 열림이 기본인 코틀린은 개발자가 의식적으로 닫아줘야 한다는 얘기입니다.

코틀린 explicit API mode가 강제하는 세 가지
그래서 켠 게 explicit API mode입니다. 코틀린 1.4.0(2020년)에 들어온 컴파일러 기능인데, 그 "묵시적 public"을 금지해줘요.
Gradle 설정은 한 줄입니다.
kotlin {
// strict = 위반 시 컴파일 에러
explicitApi()
// 경고만 받고 싶다면 이 줄로 대체
explicitApiWarning()
}
이 한 줄 넣고 빌드 돌렸더니 컴파일 에러가 200개 넘게 떴습니다. 200개가 어느 정도냐면, 몰래 공개돼 있던 내부 선언 200곳의 정확한 지도를 컴파일러가 처음으로 그려준 거예요.
강제하는 건 세 가지입니다.
- 공개 선언에는 가시성 수식어를 반드시 명시해야 합니다.
public이든internal이든 손으로 적어야 통과돼요. - 공개 함수·프로퍼티는 반환 타입을 명시해야 합니다. 타입 추론을 막아서, 구현 바꾸다 시그니처가 슬쩍 달라지는 하위호환 사고를 막아주는 장치죠.
- KDoc이 없으면 경고를 띄웁니다. 공개 API면 문서를 남기라는 압박입니다.
한 가지 오해는 풀고 가야겠어요. explicit API mode는 방패가 아니라 체크리스트입니다. 켜도 public이라고 명시하면 그 함수는 여전히 밖에서 호출돼요. 가시성을 "적게" 만드는 게 아니라 "명시적으로 선언하게" 강제하는 규칙일 뿐이거든요.

internal로도 안 막히는 진짜 내부 API 두 종류
밖에서 호출되던 자리를 뜯어보니 성격이 두 가지로 갈리더군요.
1. 실수로 public이 된 유틸
그냥 수식어를 안 붙여서 열려버린 헬퍼들입니다. 이건 explicit API mode로 잡아서 internal로 닫으면 깔끔하게 해결돼요.
2. 기술상 public이 불가피한데 개념상 내부인 API
인라인 함수나 리플렉션 대상이 여기 속합니다. internal로 막으면 컴파일이 안 되거나 동작이 깨지는데, 그렇다고 남이 쓰라고 만든 것도 아니죠.
문제는 internal이 "이 모듈 안에서만"이라는 제어라는 점입니다. A 모듈 함수를 B 모듈이 써야 하는데 외부 소비자에겐 감추고 싶을 때, 모듈 경계를 넘는 순간 internal은 무력해져요.
그래서 쓰는 절충안이 @InternalApi 같은 마커 애노테이션입니다. public으로 열어두되 "이건 내부용"이라고 표식을 달고 문서·린트로 관리하는 방식이에요. "public인데 쓰지 마"라는 모순이긴 한데, 완전한 캡슐화가 불가능한 현실에선 이 우회로가 최선일 때가 많습니다.
warning으로 시작해 strict까지 미는 마이그레이션
레거시 대형 모듈에 strict를 바로 켜는 건 추천드리기 어려울 것 같습니다. 에러 수백 개가 한꺼번에 쏟아지면서 거대 PR이 나오고 리뷰가 마비되거든요.
제가 밟은 순서는 이렇습니다.
explicitApiWarning()으로 먼저 켜서 경고만 받습니다. 빌드는 통과되니 기준선을 잡을 수 있어요.- 모듈 단위로 가시성 수식어와 반환 타입을 채워 넣습니다.
- 다 채운 모듈부터 strict로 전환합니다.
설정이 모듈 단위라는 게 여기서 큰 도움이 됐어요. 경계가 제일 무너진 모듈부터 격리해서 적용할 수 있으니까요. 그리고 프로덕션 소스에만 걸고 테스트 소스는 제외하는 게 기본입니다.
개인적으로는 이 기능, 앱 코드 전체에 무작정 켜는 건 별로라고 봅니다. 타입 추론의 간결함을 중시하는 팀엔 반환 타입 명시가 퇴보로 느껴질 수 있어요. 라이브러리 모듈, 그러니까 남이 갖다 쓰는 코드에만 선별 적용하는 게 훨씬 나았습니다.
공개 API 변경을 CI에서 걸러내는 BCV 장치
explicit API mode가 "무엇이 public이냐"를 코드 작성 시점에 강제한다면, 그게 언제 바뀌었냐는 다른 도구가 봅니다. Binary Compatibility Validator(BCV)예요.
BCV는 api/ 디렉터리에 .api 덤프 파일을 생성해둡니다. 그리고 PR에서 공개 API 표면이 바뀌면 그 덤프의 diff로 잡아내죠. 리뷰어가 "이거 의도한 변경 맞아?"를 머지 전에 확인하게 되는 구조입니다.
여기에 nonPublicMarkers 설정으로 아까 그 @InternalApi를 등록해두면, 기술상 public이지만 개념상 내부인 API를 덤프 표면에서 빼줘요. 결국 explicit API mode에 BCV, 마커 애노테이션 이 세 개가 맞물려야 공개 API 경계가 실전에서 굴러가더라고요.

결국 경계는 누가 지키는가
explicit API mode가 만병통치약은 아닙니다. 이미 배포돼서 밖에서 쓰이던 함수는 public으로 명시하면 그대로 노출되고, deprecation 사이클 없이는 못 닫아요. 도구가 못 푸는 영역이 분명히 있습니다.
그럼에도 저는 이 도구를, 정확히는 이 마찰을 반깁니다. 위키에 적어둔 "쓰지 마세요"는 신뢰라는 허공에 떠 있던 경계였어요. explicit API mode는 그 경계를 처음으로 코드 안으로 끌어내렸고요.
문서로 부탁하던 캡슐화와 컴파일러가 강제하는 캡슐화, 둘 중 어느 쪽에 라이브러리의 미래를 걸겠냐고 묻는다면 저는 망설임 없이 후자를 택합니다. 컨벤션은 사람이 잊는 순간 무너지지만, 컴파일러는 잊는 법이 없으니까요.
'Android 개발 > Kotlin' 카테고리의 다른 글
| Hilt Koin 비교, 6년차가 프로젝트마다 갈라 쓰는 실전 기준 (0) | 2026.10.01 |
|---|---|
| 값이 밀리고 튀던 Flow, buffer와 conflate가 각각 살린 것 (0) | 2026.09.29 |
| Kotlin 컨텍스트 파라미터, 어느 값이 주입됐는지 눈에 보이게 만든 실전 일지 (0) | 2026.09.22 |
| Kotlin context parameters로 Logger·DB 인자 줄줄이 걷어낸 실전 일지 (0) | 2026.09.15 |
| 코틀린 2.4 백킹 필드로 `_uiState` 두 줄을 한 줄로 정리했어요 (0) | 2026.09.04 |