본문 바로가기

안드로이드

안드로이드로이드에서 Constants를 효율적으로 사용하려 어떻게 해야할까?

반응형

1. 서론

Android/Kotlin 프로젝트를 진행하다 보면 상수를 선언할 때 보통 두 가지 방식을 고민하게 된다.

  • 최상위 레벨(top-level)에 두는 상수
  • 클래스의 companion object 안에 두는 상수

둘 다 코드에서 쓰기에는 큰 차이가 없어 보이지만,
바이트코드 관점이나 R8 최적화 단계까지 내려가면 결과가 달라진다.

이 글에서는 Kotlin 상수가 컴파일되면서 어떻게 변하는지,
그리고 어떤 방식이 실제로 더 효율적인지를 정리한다.

 

2. Kotlin에서 상수 선언하기

코틀린에서 상수를 만들 때는 const val 키워드를 사용한다.

const val MAX_RETRY_COUNT = 3

val 과 const val 의 차이는 다음과 같다.

  • val
    • 한 번 할당하면 다시 바뀌지 않는 읽기 전용 참조
    • 값이 런타임에 계산될 수도 있다.
  • const val
    • 컴파일 시점에 값이 결정되는 상수
    • 기본형 타입과 String 에만 사용 가능
    • 최상위 레벨, object, companion object 안에만 선언할 수 있다.
    • 컴파일 시점에 인라인(inline) 될 수 있다.

가능하면 단순한 설정 값이나 키 값은 const val 로 선언하는 편이 더 효율적이다.

 

3. Kotlin 바이트코드와 D8/R8 간단 정리

상수가 실제로 어떻게 처리되는지 이해하려면 Kotlin → JVM 바이트코드 → D8/R8 흐름을 한 번 짚고 가는 편이 좋다.

  • Kotlin 바이트코드
    • Kotlin 코드는 먼저 JVM 바이트코드(.class) 로 컴파일된다.
    • Android Studio의 Show Kotlin Bytecode → Decompile 메뉴로 중간 결과를 확인할 수 있다.
  • D8
    • JVM 바이트코드를 Android(.dex) 포맷으로 변환하는 도구이다.
    • 예전에는 dx 를 사용했지만 이제는 D8이 기본이다.
  • R8
    • 코드 축소와 최적화, 난독화를 담당하는 도구이다.
    • 쓰이지 않는 클래스·메서드·필드를 제거하는 tree shaking 도 같이 수행한다.
    • Gradle에서 minifyEnabled true 로 활성화한다.
buildTypes {
    release {
        minifyEnabled true
        // ...
    }
}

4. 예제 코드와 디컴파일 결과

(1) 예제 코드

// 최상위 레벨 상수
const val TOP_LEVEL_CONST = "MY_TOP_LEVEL_CONST"

class SampleActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        println(TOP_LEVEL_CONST)
        println(COMPANION_CONST)
    }

    companion object {
        const val COMPANION_CONST = "MY_COMPANION_CONST"
    }
}

(2) 디컴파일된 자바 코드(요약)

// SampleActivity.java
public final class SampleActivity extends ComponentActivity {
    public static final String COMPANION_CONST = "MY_COMPANION_CONST";

    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        System.out.println("MY_TOP_LEVEL_CONST");
        System.out.println("MY_COMPANION_CONST");
    }
}

// SampleActivityKt.java
public final class SampleActivityKt {
   public static final String TOP_LEVEL_CONST = "MY_TOP_LEVEL_CONST";
}

여기서 눈여겨볼 부분은 다음과 같다.

  • 코드에서는 TOP_LEVEL_CONST 와 COMPANION_CONST 를 사용하지만, 실제 자바 코드에서는 문자열 리터럴이 그대로 인라인 된다.
  • 최상위 상수를 담기 위한 SampleActivityKt 클래스가 하나 생기지만, 클래스 자체를 직접 참조하지 않으면 R8이 제거할 수 있다.
  • companion object 안의 const val 은 바깥 클래스의 static final 필드로 노출된다.

 

5. companion object 내부 상수가 미묘하게 손해를 보는 이유

코드만 보면 companion object 안에 상수를 넣는 방식이 가장 깔끔해 보이지만, 바이트코드 관점에서는 약간의 오버헤드가 생긴다.

  1. 정적 내부 클래스(Companion) 생성
  2. 코틀린 컴파일러는 companion object 를
    SampleActivity.Companion 같은 정적 내부 클래스로 만든다.
  3. Companion 인스턴스 생성
    public static final SampleActivity.Companion Companion = new Companion();
    상수를 사용할 때는 SampleActivity.COMPANION_CONST 로 접근할 수 있지만,
    Companion 이 다른 곳에서 한 번이라도 쓰이면 이 내부 클래스와 인스턴스가 그대로 남는다.
  4. 보통 다음과 같은 필드가 함께 생성된다.
  5. 불필요한 클래스/객체가 남을 수 있음
  6. 상수 값 자체는 인라인되더라도,
    Companion 이 사용되는 순간 추가적인 클래스와 객체 가 반드시 필요해진다.
    R8 입장에서도 참조가 남아 있으면 제거하기 어렵다.

그래서 굳이 클래스에 속할 필요가 없는 상수라면 companion object 보다는 다른 방법을 쓰는 편이 깔끔하다.


6. 최상위 레벨(top-level) 상수를 쓸 때의 이점

최상위 레벨 상수를 사용하면 구조가 단순해지고 최적화에도 유리하다.

  1. 불필요한 클래스가 통째로 제거될 수 있다
  2. 상수 값은 호출 지점에 인라인되고,
    상수를 담고 있던 클래스는 실제로 참조되지 않으면
    R8이 통째로 없앨 수 있다.
  3. 코드 구조가 단순해진다
  4. companion object 를 억지로 만들 필요가 없기 때문에
    파일 하나에 설정 값만 모아두는 식의 구성이 자연스럽다.
  5. Java 코드에서도 무리 없이 사용 가능하다
  6. 자바 코드에서 사용할 때는 SomeKt.MY_CONST 와 같은 형태로 접근한다.
    필요하다면 @JvmField 등을 활용해 노출 방식을 좀 더 제어할 수도 있다.

 

7. 여러 클래스에서 상수를 공유하는 패턴

여러 곳에서 공유해야 하는 상수라면, 전용 파일을 만들어 최상위 레벨 상수로 관리하는 방식이 가장 무난하다.

// Constants.kt
package com.example.shared

const val API_BASE_URL = "https://example.com/api"
const val DEFAULT_PAGE_SIZE = 20
const val LOG_TAG = "MyApp"

필요한 곳에서 다음처럼 가져다 쓰면 된다.

import com.example.shared.API_BASE_URL

이 방식의 특징은 다음과 같다.

  • 상수는 호출 지점에 인라인된다.
  • Constants.kt 에서 만들어진 ConstantsKt 클래스는 다른 코드에서 직접 참조하지 않으면 R8이 제거할 수 있다.
  • 도메인별로 NetworkConstants, AnalyticsConstants 처럼 파일을 쪼개도 관리가 수월하다.

 

8. object로 상수를 그룹화하기

상수를 조금 더 구조적으로 묶고 싶다면 object 를 사용할 수 있다.

object AppInfo {
    const val APP_NAME = "MyApp"

    object Versions {
        const val API_LEVEL = 33
        const val MIN_SUPPORTED_VERSION = 21
    }
}

사용 코드는 다음과 같다.

println(AppInfo.APP_NAME)
println(AppInfo.Versions.API_LEVEL)

이 방식은 의미 단위로 상수를 묶을 수 있다는 점이 장점이다.
다만 object 는 싱글톤 인스턴스를 하나 생성하므로,
정말로 계층 구조가 필요할 때만 사용하는 편이 좋다.
단순 키 값만 모아두는 용도라면 굳이 object 가 없어도 된다.


9. 안드로이드에서 자주 쓰는 상수 패턴

실제 안드로이드 앱에서 상수를 정리할 때는 다음과 같이 나누면 관리가 편하다.

  1. 네트워크 / API 관련
    // NetworkConstants.kt
    const val API_BASE_URL = "https://example.com/api"
    const val TIMEOUT_MS = 5_000L
  2. Intent Extra / Bundle Key
    // Keys.kt
    const val EXTRA_USER_ID = "extra_user_id"
    const val EXTRA_ARTICLE_ID = "extra_article_id"
  3. 로그 태그
    // LogTags.kt
    const val TAG_AUTH = "Auth"
    const val TAG_NETWORK = "Network"
  4. 사용자에게 직접 노출되는 문자열
  5. 이런 문자열은 상수보다는 strings.xml 에 두는 편이 낫다.
    다국어, 디자인 수정, 접근성 등을 고려하면 리소스로 관리하는 쪽이 훨씬 유연하다.
  6. 상태 값이 많을 때
  7. 여러 상태를 문자열 상수로만 관리하면 타입 안전성이 떨어진다.
    이 경우에는 enum class, sealed class, @StringDef 등을 활용해
    가능한 값의 범위를 타입으로 제한하는 편이 안전하다.

 

10. 정리

  1. companion object 안의 상수는 가급적 줄인다.
    내부적으로 Companion 클래스와 인스턴스가 생기기 때문에
    구조가 불필요하게 복잡해진다.
  2. 기본값은 최상위 레벨 상수로 생각한다.
    단순 설정 값이나 공통 키 값은 전용 파일을 만들어
    최상위 const val 로 두는 패턴이 가장 단순하다.
  3. 관련된 상수를 묶어야 한다면 object로 계층화한다.
    의미 단위로 묶고 싶을 때만 object 를 사용하고,
    나머지는 파일 단위로 적당히 나누면 된다.
  4. 프로덕션 빌드에서는 R8을 꼭 켠다.
    minifyEnabled = true 로 설정하면 사용되지 않는 클래스와 필드를 많이 줄일 수 있고,
    APK 크기와 메모리 사용량 모두에 도움이 된다.

결국 "상수를 어디에 둘 것인가"의 기준은 복잡하지 않다.

  • 특별한 이유가 없으면 최상위 레벨 상수
  • 의미 있는 묶음이 필요하면 object 또는 별도 파일
  • 클래스 내부에서만 쓰고 정말 그 클래스에 속하는 값이라면 클래스 내부에 한정적으로 선언

이 정도 원칙만 정해두면, 프로젝트가 커져도 상수 관리가 훨씬 수월해진다.

반응형