Android 개발/Kotlin

수식어 안 붙인 함수가 전부 public이었다: 코틀린 공개 API 경계 코드로 못 박은 일지

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

 

모듈을 라이브러리로 떼어낸 다음 날 아침이었습니다. 슬랙에 이름도 처음 듣는 팀이 '그 함수 시그니처 바꾸면 우리 서비스 터져요'라고 올렸는데, 제 내부 헬퍼를 세 군데서 쓰고 있더라고요.

 

위키에 "내부용이니 쓰지 마세요"라고 적어둔 상태였어요. 근데 컴파일러는 그 위키를 읽지 않죠. 결국 반환 타입 하나 못 바꾸는 콘크리트 라이브러리가 돼버린 겁니다.

 

내부 헬퍼가 다른 팀 코드에 물려버린 자리

원인은 제 부주의가 아니라 언어 기본값이었습니다. 코틀린(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곳의 정확한 지도를 컴파일러가 처음으로 그려준 거예요.

 

강제하는 건 세 가지입니다.

 

  1. 공개 선언에는 가시성 수식어를 반드시 명시해야 합니다. public이든 internal이든 손으로 적어야 통과돼요.
  2. 공개 함수·프로퍼티는 반환 타입을 명시해야 합니다. 타입 추론을 막아서, 구현 바꾸다 시그니처가 슬쩍 달라지는 하위호환 사고를 막아주는 장치죠.
  3. 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이 나오고 리뷰가 마비되거든요.

 

제가 밟은 순서는 이렇습니다.

 

  1. explicitApiWarning()으로 먼저 켜서 경고만 받습니다. 빌드는 통과되니 기준선을 잡을 수 있어요.
  2. 모듈 단위로 가시성 수식어와 반환 타입을 채워 넣습니다.
  3. 다 채운 모듈부터 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는 그 경계를 처음으로 코드 안으로 끌어내렸고요.

 

문서로 부탁하던 캡슐화와 컴파일러가 강제하는 캡슐화, 둘 중 어느 쪽에 라이브러리의 미래를 걸겠냐고 묻는다면 저는 망설임 없이 후자를 택합니다. 컨벤션은 사람이 잊는 순간 무너지지만, 컴파일러는 잊는 법이 없으니까요.

 

반응형
LIST