Java package-private 클래스를 Kotlin 테스트 코드에서 접근하려니 생성자 호출이 안 되어 reflection을 써야 했습니다. 왜 이런 문제가 발생할까요?
Java와 Kotlin 접근 제어자 비교
Java
- public: 전체
- protected: 상속 + 같은 패키지
- (default): 같은 패키지
- private: 같은 클래스
Kotlin
- public: 전체
- internal: 같은 모듈
- protected: 상속 관계만
- private: 같은 클래스 (클래스 멤버) / 같은 파일 (최상위 선언)
핵심 차이
| Java | Kotlin | |
|---|---|---|
| 캡슐화 단위 | 패키지 | 모듈 |
| 테스트 접근 | 같은 패키지 필요 | 같은 모듈이면 OK |
| package-private | 있음 | 없음 (internal로 대체) |
| protected | 상속 + 같은 패키지 | 상속만 |
Kotlin의 설계 철학
1. 패키지는 캡슐화 경계가 아니다
// 원래 라이브러리 (example-lib.jar)
package com.example.core;
class InternalClass { // package-private
void sensitiveMethod() { }
}
// 악의적 사용자 코드 (다른 프로젝트)
package com.example.core; // 같은 패키지명 사용
public class Hacker {
void access() {
new InternalClass(); // package-private인데 접근됨
}
}
패키지는 코드 조직화 수단일 뿐, 실제 캡슐화는 컴파일/배포 단위인 모듈에서 이루어져야 합니다.
2. 모듈이 진짜 경계다
┌─────────────────────────┐
│ core.jar (모듈) │ ← 배포 단위
│ │
│ internal class 계산기 │ ← 모듈 내부만
│ public class API │ ← 외부 노출
└─────────────────────────┘
JAR 파일이 실제 경계이며, internal은 이 현실을 반영합니다.
3. 테스트 친화적 설계
module-core/ (하나의 모듈)
├── src/main/kotlin/ ← internal 정의
└── src/test/kotlin/ ← internal 접근 가능
main과 test는 같은 모듈이므로 internal 클래스에 자연스럽게 접근 가능합니다.
반면 Java는 같은 모듈이라도 package-private 클래스를 다른 패키지의 테스트에서 접근하려면 public으로 변경하거나 같은 패키지에 테스트를 작성해야 합니다.
Java-Kotlin 혼용 시 테스트 코드 문제
문제 상황
케이스 1: 기본 생성자 (정상 동작)
// Java: src/main/java/com/example/Calculator.java
class Calculator { // package-private
Calculator() {}
}
// Kotlin: src/test/kotlin/com/example/CalculatorTest.kt
class CalculatorTest {
@Test
fun test() {
val calc = Calculator() // 정상 동작
}
}
케이스 2: 파라미터 생성자 (문제 발생)
// Java: private 필드 + package-private 생성자
class Calculator {
private BigDecimal value;
Calculator() {} // 기본 생성자
Calculator(BigDecimal value) {
this.value = value;
}
}
// Kotlin 테스트
class CalculatorTest {
@Test
fun test() {
// IllegalAccessError 발생
val calc = Calculator(BigDecimal.ONE)
// Reflection으로 우회 (기본 생성자 사용)
val calc = Calculator::class.java
.getDeclaredConstructor()
.newInstance()
val field = Calculator::class.java
.getDeclaredField("value")
field.isAccessible = true
field.set(calc, BigDecimal.ONE)
}
}
원인 분석
JVM 바이트코드 레벨에서 package-private은 접근 제어 플래그가 없는 특수한 상태:
ACC_PUBLIC(0x0001)ACC_PRIVATE(0x0002)- 플래그 없음 = package-private
private 필드 + package-private 생성자 조합에서 Kotlin 컴파일러가 이를 제대로 처리하지 못합니다.
해결 방법
1. Java로 테스트 작성
// src/test/java/로 테스트 이동 (가장 확실)
2. public 생성자로 변경
public Calculator(BigDecimal value) {
this.value = value;
}
3. 테스트용 Factory 메서드
class Calculator {
// 테스트를 위한 static 메서드
public static Calculator createForTest(BigDecimal value) {
return new Calculator(value);
}
}
4. Kotlin으로 완전 마이그레이션
internal class Calculator(
private val value: BigDecimal
)
Kotlin 접근 제어자 설계의 트레이드오프
얻은 것
- 명확한 모듈 경계
- 테스트 접근 용이
- 단순한 접근 제어자
잃은 것
- 패키지 단위 세밀한 캡슐화
- 같은 모듈 내 자동완성 노이즈
- 기존 Java 구조와의 호환성
권장 프로젝트 구조
project/
├── module-api/ ← 외부 노출
├── module-domain/ ← 도메인 로직
├── module-core/ ← 핵심 로직
└── module-infra/ ← 인프라
캡슐화가 필요하면 패키지가 아닌 모듈로 분리합니다.
모듈 크기 선택
| 크기 | 장점 | 단점 |
|---|---|---|
| 작게 많이 | 캡슐화 명확 | 설정 복잡 |
| 크게 적게 | 단순함 | internal 노출 범위 넓음 |
정리
Kotlin은 패키지를 조직화 수단으로, 모듈을 캡슐화 경계로 봅니다. 이는 현대적인 빌드 시스템과 멀티모듈 구조에 더 적합한 설계입니다.
하지만 Java와 혼용 시:
- package-private 처리에 주의 필요
- protected 의미가 다름을 인지
- 테스트 코드 작성 시 제약 발생 가능
마이그레이션 전략:
- 기존 Java는 package-private 유지
- 신규 모듈은 Kotlin + internal
- 필요시 모듈 분리