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 안에 상수를 넣는 방식이 가장 깔끔해 보이지만, 바이트코드 관점에서는 약간의 오버헤드가 생긴다.
- 정적 내부 클래스(Companion) 생성
- 코틀린 컴파일러는 companion object 를
SampleActivity.Companion 같은 정적 내부 클래스로 만든다. - Companion 인스턴스 생성
상수를 사용할 때는 SampleActivity.COMPANION_CONST 로 접근할 수 있지만,public static final SampleActivity.Companion Companion = new Companion();
Companion 이 다른 곳에서 한 번이라도 쓰이면 이 내부 클래스와 인스턴스가 그대로 남는다. - 보통 다음과 같은 필드가 함께 생성된다.
- 불필요한 클래스/객체가 남을 수 있음
- 상수 값 자체는 인라인되더라도,
Companion 이 사용되는 순간 추가적인 클래스와 객체 가 반드시 필요해진다.
R8 입장에서도 참조가 남아 있으면 제거하기 어렵다.
그래서 굳이 클래스에 속할 필요가 없는 상수라면 companion object 보다는 다른 방법을 쓰는 편이 깔끔하다.
6. 최상위 레벨(top-level) 상수를 쓸 때의 이점
최상위 레벨 상수를 사용하면 구조가 단순해지고 최적화에도 유리하다.
- 불필요한 클래스가 통째로 제거될 수 있다
- 상수 값은 호출 지점에 인라인되고,
상수를 담고 있던 클래스는 실제로 참조되지 않으면
R8이 통째로 없앨 수 있다. - 코드 구조가 단순해진다
- companion object 를 억지로 만들 필요가 없기 때문에
파일 하나에 설정 값만 모아두는 식의 구성이 자연스럽다. - Java 코드에서도 무리 없이 사용 가능하다
- 자바 코드에서 사용할 때는 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. 안드로이드에서 자주 쓰는 상수 패턴
실제 안드로이드 앱에서 상수를 정리할 때는 다음과 같이 나누면 관리가 편하다.
- 네트워크 / API 관련
// NetworkConstants.kt const val API_BASE_URL = "https://example.com/api" const val TIMEOUT_MS = 5_000L - Intent Extra / Bundle Key
// Keys.kt const val EXTRA_USER_ID = "extra_user_id" const val EXTRA_ARTICLE_ID = "extra_article_id" - 로그 태그
// LogTags.kt const val TAG_AUTH = "Auth" const val TAG_NETWORK = "Network" - 사용자에게 직접 노출되는 문자열
- 이런 문자열은 상수보다는 strings.xml 에 두는 편이 낫다.
다국어, 디자인 수정, 접근성 등을 고려하면 리소스로 관리하는 쪽이 훨씬 유연하다. - 상태 값이 많을 때
- 여러 상태를 문자열 상수로만 관리하면 타입 안전성이 떨어진다.
이 경우에는 enum class, sealed class, @StringDef 등을 활용해
가능한 값의 범위를 타입으로 제한하는 편이 안전하다.
10. 정리
- companion object 안의 상수는 가급적 줄인다.
내부적으로 Companion 클래스와 인스턴스가 생기기 때문에
구조가 불필요하게 복잡해진다. - 기본값은 최상위 레벨 상수로 생각한다.
단순 설정 값이나 공통 키 값은 전용 파일을 만들어
최상위 const val 로 두는 패턴이 가장 단순하다. - 관련된 상수를 묶어야 한다면 object로 계층화한다.
의미 단위로 묶고 싶을 때만 object 를 사용하고,
나머지는 파일 단위로 적당히 나누면 된다. - 프로덕션 빌드에서는 R8을 꼭 켠다.
minifyEnabled = true 로 설정하면 사용되지 않는 클래스와 필드를 많이 줄일 수 있고,
APK 크기와 메모리 사용량 모두에 도움이 된다.
결국 "상수를 어디에 둘 것인가"의 기준은 복잡하지 않다.
- 특별한 이유가 없으면 최상위 레벨 상수
- 의미 있는 묶음이 필요하면 object 또는 별도 파일
- 클래스 내부에서만 쓰고 정말 그 클래스에 속하는 값이라면 클래스 내부에 한정적으로 선언
이 정도 원칙만 정해두면, 프로젝트가 커져도 상수 관리가 훨씬 수월해진다.
'안드로이드' 카테고리의 다른 글
| 안드로이드 테스트 UnitTest, UI Test (1) | 2025.12.12 |
|---|---|
| Compose에서 MVI 아키텍처 적용하기 (0) | 2025.11.29 |
| Dagger의 @Binds vs @Provides, @Qualifier vs @Named, @Lazy<T> vs @Provider<T> (0) | 2022.08.29 |
| Sealed Class를 이용한 Retrofit 결과 처리 (0) | 2022.06.22 |
| 안드로이드 테스팅 (0) | 2022.05.28 |