Android 개발/Kotlin

@JvmField 어디 붙이지? Kotlin 2.4 use-site target 정리

stackD 2026. 7. 16. 18:00
반응형

 

금요일 오후, Gson 으로 파싱한 객체 필드가 전부 null 로 찍히는 걸 보고 멘붕이 왔습니다. 범인은 빠뜨린 @field: 네 글자였어요.

 

데이터 클래스에 @SerializedName("user_name") 만 깔끔하게 박아두고 "이러면 알아서 되겠지" 했던 게 화근이었습니다. 컴파일러는 그 어노테이션을 생성자 파라미터(param) 에만 붙여놨고, Gson 은 필드(field) 만 스캔하니 둘이 영원히 만날 일이 없었던 거지요. 코드를 다 뒤져도 잘못된 부분이 안 보이니까 더 답답하더라고요.

 

이런 use-site target 사고는 코틀린(Kotlin) 으로 자바 라이브러리를 붙여 쓸 때 한 번씩 다 겪는 통과의례인 것 같습니다. 그날 야근의 복기 겸, Kotlin 2.4 에서 안정화된 @all 까지 한 번에 정리해봅니다.

 

Kotlin use-site target이 왜 필요한가

val name: String 한 줄은 컴파일러 입장에서는 세 가지 산출물이 됩니다. 백킹 필드 하나, getter 메서드 하나, 생성자 파라미터 하나예요. 자바에서 보면 이게 다 따로 노는 요소입니다.

 

문제는 @SerializedName, @Inject, @Autowired 같은 어노테이션을 그냥 프로퍼티에 붙이면 컴파일러가 "어디다 박을지" 정해야 한다는 점입니다. 라이브러리마다 스캔하는 자리가 달라서, 같은 어노테이션도 자리를 잘못 잡으면 그냥 무시당해버리는 경우가 많아요.

 

자리 지정 문법이 바로 @field:, @get:, @param: 같은 use-site target 입니다.

 

 

@field @get @param 세 형제는 정확히 뭐가 다를까

세 개를 한 줄로 정리하자면 이렇습니다.

 

  • @field: 는 백킹 필드에 어노테이션을 박습니다. Jackson, Gson, Room 처럼 자바 필드를 리플렉션으로 스캔하는 라이브러리에 필요해요.
  • @get: 은 자동 생성된 getter 메서드에 박힙니다. JPA 의 기본 접근 전략이 getter 기반이라 @get:Id, @get:GeneratedValue 같은 형태로 자주 보입니다.
  • @param: 은 생성자 파라미터에 박힙니다. Dagger·Hilt 의 @Named 같은 Qualifier, Spring 의 생성자 주입 어노테이션이 이 자리에 갑니다.

 

@property: 도 있긴 한데 이건 코틀린 리플렉션 전용입니다. Spring·Hibernate 같은 자바 리플렉션 기반 라이브러리에는 아예 보이지 않으니, 그쪽 작업에서는 빼고 생각하셔도 됩니다. 이 외에 @set:, @setparam:, @receiver:, @delegate: 도 있는데 만나는 빈도가 워낙 낮아 필요할 때 찾아보시면 됩니다.

 

Gson Room Hilt에서 자리 잘못 잡아 터졌던 사고 3가지

제가 직접 굴렀거나 팀 슬랙에서 본 패턴 세 가지만 짧게 정리합니다.

 

1. Gson / Room — @field: 누락

 

// ❌ 동작 안 함: @SerializedName 이 param 에만 붙음
data class User(
    @SerializedName("user_name") val name: String
)

// ⭕ Gson 이 필드를 스캔하므로 @field: 명시
data class User(
    @field:SerializedName("user_name") val name: String
)

 

Room 의 @PrimaryKey, @ColumnInfo 도 같은 함정입니다. 생성자 프로퍼티에 그냥 박으면 컴파일은 통과하는데 런타임에 조용히 무시당하는 경우가 많거든요. 처음 만나면 진짜 원인 찾기가 어렵습니다.

 

2. JPA — getter 기반이라 @get: 필요

 

@Entity
class Post(
    @get:Id @get:GeneratedValue val id: Long = 0,
    @get:Column(nullable = false) val title: String = ""
)

 

JPA 가 Lazy Loading 을 걸려면 getter 를 가로채야 합니다. @field:Id 로 박으면 프록시 생성이 깨지는 경우가 생기니, JPA 쪽은 @get: 으로 통일해두시는 게 안전합니다.

 

3. Hilt / Dagger Qualifier — @param: 명시

 

생성자 주입에서 @Named("io") 같은 Qualifier 를 그냥 박으면 멀티바인딩이 꼬일 수 있어요. @param:Named("io") 로 자리를 못 박아두면 충돌을 피하기 좋습니다.

 

 

Kotlin 2.4 default target 규칙 변화와 마이그레이션 주의점

여기서부터가 2.4 의 핵심입니다. 생성자 프로퍼티에 어노테이션을 타깃 없이 붙였을 때 컴파일러가 어디다 박는지 가 버전 따라 달라졌습니다.

 

  • Kotlin 2.1 이하: 기본 타깃이 param 단독
  • Kotlin 2.2: 새 동작이 옵트인 단계 — -Xannotation-default-target=param-property 플래그를 켜야 param + property(또는 field) 로 확장되고, 동시에 마이그레이션 경고가 떨어집니다
  • Kotlin 2.4: 새 default 가 stable 로 승격되어 별도 플래그 없이도 param + property(또는 field) 가 기본 동작

 

언뜻 보면 "더 많은 곳에 박아주니 좋은 거 아닌가" 싶은데, 막상 굴려보면 사고가 나는 일이 있습니다. 대표적인 게 Bean Validation 인데요. @NotNull 이 param 에만 붙던 시절에는 검증이 한 번 돌았는데, 새 default 에서는 field 에도 같이 붙어서 동일한 검증이 두 번 실행될 수 있어요. Spring 쪽에서 -Xannotation-default-target=param-property 옵션을 권장하게 된 배경이기도 합니다.

 

이전 동작을 그대로 유지하고 싶다면 컴파일 옵션을 깔아두시면 됩니다.

 

// build.gradle.kts
kotlin {
    compilerOptions {
        freeCompilerArgs.add("-Xannotation-default-target=first-only")
    }
}

 

마이그레이션 직후에 ./gradlew compileKotlin 한 번 돌려서 "Annotation will be applied to multiple targets" 경고가 어디서 나는지 확인해두시면 좋습니다. 경고가 떨어진 자리는 IDE 가 "타깃을 명시하라" 는 퀵픽스를 띄워주니까 거기서부터 손보면 됩니다.

 

 

@all 메타 타깃과 @JvmField의 진짜 역할

@all: (Kotlin 2.4 안정화)

 

@field:, @get:, @param: 을 세 줄 늘어놓던 경우 한 줄로 줄일 수 있게 됐어요.

 

data class Product(
    @all:Deprecated("Legacy field") val code: String
)

 

다만 위임 프로퍼티(by lazy, by Delegates.observable 등) 에는 적용되지 않는다는 제약이 있습니다. 그리고 라이브러리가 "특정 자리에만 어노테이션이 있어야" 동작하는 경우엔 @all: 이 오히려 충돌을 만들 수 있어요. Bean Validation 중복 검증이 정확히 이 케이스라, 저는 @all: 을 "정말 모든 자리에 일관되게 박혀야 의미 있을 때" 만 쓰고 있습니다.

 

@JvmField 는 use-site target 이 아닙니다

 

이건 자주 헷갈리는 부분인데요. @JvmField 는 어노테이션을 어디 박을지 지시하는 게 아니라, getter/setter 생성을 막고 자바의 public 필드로 직접 노출시키는 코드 생성 지시자입니다. @field: 와는 완전히 다른 개념이에요.

 

class MainActivity : AppCompatActivity() {
    companion object {
        const val TAG = "MainActivity"
    }
}

 

const val TAG 로 선언하면 자바에서도 MainActivity.TAG 로 바로 접근할 수 있으며, 컴파일 타임 상수로 취급되어 인라인됩니다. 다만 @JvmField 자체에도 제약이 있어요. private, open, override, const 와는 같이 못 쓰고, 위임 프로퍼티에도 못 붙입니다. const val 은 이미 public static final 로 컴파일되니까 @JvmField 가 따로 필요 없고요.

 

개인적으로는 컴패니언 오브젝트 상수에 한정해서만 쓰고, 일반 프로퍼티 성능 최적화 용도로는 거의 안 씁니다. getter 호출 오버헤드는 JIT 이 다 펴주니까 굳이 가독성을 깎으면서 박을 이유가 없더라고요.

 

라이브러리 스캔 방식으로 1초 만에 use-site target 잡기

어디에 박아야 할지 헷갈릴 때 저는 딱 한 가지만 떠올립니다. "이 라이브러리가 자바 리플렉션으로 무엇을 스캔하지?"

 

  • 필드를 스캔 → @field: (Jackson, Gson, Room, Moshi 일부)
  • 메서드(getter) 를 스캔 → @get: (JPA, Hibernate)
  • 생성자 인자만 본다 → @param: (Dagger·Hilt Qualifier, Spring 생성자 주입)
  • 모든 자리에 일관되게 박아야 의미 있을 때 → @all:
  • 코틀린 리플렉션 전용 → @property:

 

여기에 @field:, @get:, @param: 세 개를 IDE 라이브 템플릿으로 등록해두면 작성 속도가 확실히 빨라집니다. 인텔리J(IntelliJ) 의 Settings > Editor > Live Templates 에서 Kotlin 그룹에 추가해두시면 fld, get, prm 약어로 바로 튀어나오게 만들 수 있어요. 저는 이 세 개를 등록한 뒤로 Gson null 사고를 다시 안 만들고 있습니다.

 

그날 야근의 교훈은 한 문장이었어요. 어노테이션이 무시당하는 게 아니라, 제가 다른 자리에 박아둔 거였다는 사실입니다. 다음에 또 라이브러리가 내가 쓴 어노테이션을 못 본 척 한다면, 코드를 의심하기 전에 use-site target 부터 들여다보시면 어떨까요?

 

 

반응형
LIST