Kotlin 접근 제어를 다루는 방법

코틀린을 배우기 위해서 인프런에서 강의를 구매하고 코틀린과 친해지고 기본기를 다지기 위해서 공부하는 중이다. 글 내용은 접근 제어를 다루는 방법이고 최태현님의 자바 개발자를 위한 코틀린 입문(Java to Kotlin Starter Guide) 강의에 소금을 조금 친 내용이다

Kotlin의 접근 제어는(Visibility Modifiers)는 Java와 유사하지만, package의 역할이나 protected, internal 등의 동작 방식에서 중요한 차이가 있다.

가시성 제어의 기본 개념

  • 가시성 제어 (Visibility Control): 어떤 선언(클래스, 함수, 프로퍼티 등)이 어디서 접근 가능한지를 정의하는 규칙이다
  • Kotlin의 Package: Kotlin에서 package는 Java와 달리 오직 이름 공간(namespace) 관리용으로만 사용된다. 즉, 이름 충돌을 방지하는 목적으로 사용되며, 접근 제어에는 직접적인 영향을 미치지 않는다.
  • Kotlin의 기본 접근 지시어: public (Java의 기본은 default)

Java와 Kotlin의 접근 제어자 비교

접근 제어자Java 범위Kotlin 범위
public모든 곳에서 접근 가능모든 곳에서 접근 가능 (기본값)
protected같은 패키지 또는 하위 클래스에서 접근 가능선언된 클래스 또는 하위 클래스에서만 접근 가능
default같은 패키지 내에거만 접근 가능없음
internal없음같은 모듈 내에서만 접근 가능
private선언된 클래스 내에서만 접근 가능선언된 클래스 내에서만 접근 가능

Kotlin 접근 제어자의 특징

  • protected의 차이: Java의 protected는 같은 패키지에서도 접근이 가능하지만, Kotlin의 protected는 오직 클래스 내부와 해당 클래스를 상속받는 하위 클래스에서만 접근할 수 있다. 이는 Kotlin에서 package가 접근 제어에 사용되지 않기 때문이다.
  • internal의 등장: internal은 Kotlin에 새로 추가된 접근 제어자로 같은 모듈(module) 내에서만 접근 가능하다
    • 모듈: 한 번에 함께 컴파일되는 단위 (예: Gradle 서브 프로젝트, Maven 아티팩트, IntelliJ IDEA 모듈 등)를 의미한다
    • internal 멤버는 해당 모듈 외부(다른 모듈)에서는 접근할 수 없다

Namespace 예시

Kotlin의 package는 이름 충돌을 피하기 위한 논리적인 분류로 사용된다

// 서로 다른 패키지의 같은 이름 클래스
package com.app.util

class StringUtils { 
    fun doSomething() =
        println("App StringUtils")
}


package com.lib.util

class StringUtils { 
    fun doSomething() =
        println("Lib StringUtils")
}


fun main() {
    // 사용 시 구분 (별칭을 사용하여 이름 충돌 회피)
    import com.app.util.StringUtils as AppStringUtils
    import com.lib.util.StringUtils as LibStringUtils

    AppStringUtils().doSomething() // 출력 App StringUtils
    LibStringUtils().doSomething() // 출력 Lib StringUtils
}

코틀린 파일의 최상위(Top-Level) 선언 접근 제어

Kotlin에서는 .kt 파일에 클래스 없어도 변수, 함수 등을 직접 선언할 수 있다. 이를 최상위(Top-Level) 선언이라고 하며, 이들의 접근 제어도 가능하다

  • public (기본값): 어디서든 접근 가능하다
  • protected: 파일 최상위에는 사용 불가능하다. protected는 클래스 멤버에만 적용되며, 상속 관계를 통해 접근을 제어하기 때문이다. 최상위 선언은 어떤 클래스의 멤버도 아니다.
  • internal: 같은 모듈 내에서만 접근 가능하다
  • private: 같은 파일 내에서만 접근 가능하다 (Java의 private static과 유사하게 파일 스코프로 제한된다)
// StringUtil.kt 파일 내용
private val privateVal = 10 // 이 파일 내에서만 접근 가능

internal val internalVal = 20 / 같은 모듈 내에서만 접근 가능

val publicVal = // public (기본값) 어디서든 접근 가능

private fun privateFunc() =
    println("private function") // 이 파일 내에서만 호출 가능

internal fun internalFunc() =
    println("internal function") // 같은 모듈 내에서만 호출 가능

fun publicFunc() =
    println("public function") // public (기본값) 어디서든 호출 가능

// 클래스 정의
class MyClass() {
    // ...
}

다양한 구성요소의 접근 제어

생성자 접근 제어

  • 클래스 생성자에 접근 지시어를 적용하려면 constructor 키워드를 명시적으로 작성해야 한다
// internal 생성자: 같은 모듈 내에서만 이 클래스의 인스턴스 생성 가능
class Bus internal constructor(
    val price: Int
)

// protected 생성자: 선언된 클래스 및 하위 클래스에서만 이 클래스의 인스턴스 생성 가능
// 주의: Final 클래스에서 protected 생성자는 사실상 private과 동일
class Car protected constructor(val price: Int) {
    // 이 클래스는 기본적으로 final이므로 상속 불가능
    // 따라서 이 protected는 실질적으로 private처럼 동작한다
    // 컴파일러 경고: 'protected' visibility is effectively 'private' in a final class
}

// 해결 방법
// 클래스를 open으로 선언하여 상속 허용
open class OpenCar protected constructor(
    val price: Int
)

// 생성자를 private으로 명시적으로 변경
class PrivateCar private constructor(
    val price: Int
)

Java Util 클래스 설계 패턴과 Kotlin의 해법

  • Java에서는 유틸리티 클래스 (예: StringUtils)를 정적 메서드만 제공하고 인스턴스화를 막기 위해 abstract 클래스에 private 생성자를 함께 사용했다. 이는 언어적 제약 때문에 발생하는 방어적 설계 패턴이다
// Java의 StringUtils 패턴
public abstract class StringUtils { // 직접 인스턴스화 방지
    private StringUtils() { // 상속을 통한 인스턴스화 우회 방지

    }

    public static boolean isDirectoryPath(String path) {
        return path.endWith("/");
    }
}

Kotlin에서는 이러한 제약이 없으므로 더 직관적인 방법을 제공한다

  • 최상위 함수(Top-level Function): Kotlin은 파일 최상위에 함수를 직접 선언할 수 있어, 별도의 클래스 없이도 유틸리티 함수를 제공할 수 있다
// StringUtil.kt
fun isDirectoryPath(path: String): Boolean {
    return path.endWith("/")
}
  • object 선언: 싱글턴 패턴을 위한 object 선언을 통해 정적 메서드와 유사하게 동작하는 유틸리티 객체를 만들 수도 있다
object StringUtils {
    fun isDirectoryPath(path: String): Boolean {
        return path.endWith("/")
    }
}

// 사용: StringUtils.isDirectoryPath("/")
  • Kotlin은 언어 차원에서 제공하는 도구(최상위 함수, object)를 통해 Java의 방어적 패턴 없이도 유틸리티 기능을 안전하고 자연스럽게 구현할 수 있다

프로퍼티 가시성 제어

Kotlin에서는 프로퍼티 전체뿐만 아니라, getter와 setter를 개별적으로 제어할 수 있어 유연한 캡슐화를 구현할 수 있다

  • 프토퍼티 전체 가시성 제어: 프로프티 선언 앞에 접근 지시어를 붙인다. 이는 해당 프로퍼티의 getter와 setter (var인 경우) 모두에 적용된다
class Bar(
    internal val name: String, // getter만 internal
    private var owner: String // getter, setter 모두 private
)
  • Getter / Setter 개별 제어: var 프로퍼티의 경우 set 또는 get 키워드 접근 지시어를 붙여 개별적으로 가시성을 설정할 수 있다
class Bar(_price: Int) {
    var price = _price
        private set // getter는 public (기본값), setter만 private
}

class User(
    val id: String, // public val: 읽기 전용, 모든 곳에서 접근 가능
    private var password: String // private var: 완전 비공개 (클래스 내부만 접근 가능)
    _balance: Int
) {
    var balance = _balance
        private set // 읽기는 public, 수정은 클래스 내부에서만 가능 (캡슐화)

    fun deposit(amount: Int) {
        balance += amount // 내부에서만 balance 수정 가능
    }
}

Java와 Kotlin을 함께 사용할 때 주의점

  • internal 멤버 접근: Kotlin의 internal 멤버는 컴파일 시 바이트코드 상 public으로 표시된다. 따라서 Java 코드에서는 Kotlin 모듈의 internal 클래스, 함수, 프로퍼티에 접근할 수 있다. 아는 Kotlin의 internal 가시성 제어가 Java 코드에는 적용되지 않는다는 점을 의미하므로, 혼용 프로젝트 시 주의해야 한다
    • 예시: 상위 모듈이 Java이고 하위 모듈이 Kotlin일 때, 하위 Kotlin 모듈의 internal 멤버는 상위 Java 모듈에서 접근 가능하다
  • protected 멤버 접근: Kotlin의 protected와 Java의 protected는 동작 방식이 다르다. Java의 protected는 같은 패키지에서도 접근 가능하지만, Kotlin의 protected는 오직 상속 관계에서만 접근 가능하다
    • 따라서 같은 패키지에 있는 Java 코드는 Kotlin 클래스의 protected 멤버에 접근할 수 있지만, 같은 패키지에 있는 다른 Kotlin 코드는 접근할 수 없다. 이 부분도 혼용 프로젝트에서 의도치 않은 접근 을 허용할 수 있으므로 유의해야 한다

Kotlin의 접근 제어는 Java보다 더 세밀하고 안전한 방식으로 캡슐화를 지원하며, package의 순수한 namespace 역할 분리를 통해 더욱 명확한 코드 구조를 가능하게 한다. 혼용 프로젝트 시에는 Java와의 미묘한 차이점을 이해하고 적절히 활용하는 것이 중요하다

출처 – 인프런 강의 중 자바 개발자를 위한 코틀린 입문(Java to Kotlin Starter Guide)