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 의미가 다름을 인지
  • 테스트 코드 작성 시 제약 발생 가능

마이그레이션 전략:

  1. 기존 Java는 package-private 유지
  2. 신규 모듈은 Kotlin + internal
  3. 필요시 모듈 분리