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의 설계 철학

패키지는 캡슐화 경계가 아니다

// 원래 라이브러리 (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인데 접근됨
    }
}

패키지는 코드 조직화 수단일 뿐, 실제 캡슐화는 컴파일/배포 단위인 모듈에서 이루어져야 합니다.

모듈이 진짜 경계다

┌─────────────────────────┐
│  core.jar (모듈)         │ ← 배포 단위
│                         │
│  internal class 계산기    │ ← 모듈 내부만
│  public class API       │ ← 외부 노출
└─────────────────────────┘

JAR 파일이 실제 경계이며, internal은 이 현실을 반영합니다.

테스트 친화적 설계

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 컴파일러가 이를 제대로 처리하지 못합니다.

해결 방법

Java로 테스트 작성

// src/test/java/로 테스트 이동 (가장 확실)

public 생성자로 변경

public Calculator(BigDecimal value) {
    this.value = value;
}

테스트용 Factory 메서드

class Calculator {
    // 테스트를 위한 static 메서드
    public static Calculator createForTest(BigDecimal value) {
        return new Calculator(value);
    }
}

Kotlin으로 완전 마이그레이션

internal class Calculator(
    private val value: BigDecimal
)

Kotlin 접근 제어자 설계의 트레이드오프

얻은 것

  • 명확한 모듈 경계
  • 테스트 접근 용이
  • 단순한 접근 제어자

잃은 것

  • 패키지 단위 세밀한 캡슐화
  • 같은 모듈 내 자동완성 노이즈
  • 기존 Java 구조와의 호환성

권장 프로젝트 구조

project/
├── module-api/       ← 외부 노출
├── module-domain/    ← 도메인 로직
├── module-core/      ← 핵심 로직
└── module-infra/     ← 인프라

캡슐화가 필요하면 패키지가 아닌 모듈로 분리합니다.

모듈 크기 선택

크기 장점 단점
작게 많이 캡슐화 명확 설정 복잡
크게 적게 단순함 internal 노출 범위 넓음

실무에서는 기존 Java는 package-private 유지하고, 신규 모듈은 Kotlin + internal로 작성하면 됩니다.