
금요일 오후, 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 부터 들여다보시면 어떨까요?

'Android 개발 > Kotlin' 카테고리의 다른 글
| 코틀린 2.4에서 Swift 패키지를 의존성으로 바로 먹였더니 — 실측 기록 (0) | 2026.07.15 |
|---|---|
| 직렬화 라이브러리 3개를 kotlinx.serialization 한 곳으로 정리한 후기 (0) | 2026.07.14 |
| 코틀린 sealed Result 패턴으로 try/catch 좀비를 잡은 3주 (0) | 2026.07.07 |
| Kotlin 2.4 field 키워드로 ViewModel _state/state 보일러플레이트 한 줄로 줄이기 (0) | 2026.06.27 |
| 공유 UI 말고 공유 코어부터, KMP 도입 한 주 일지 (0) | 2026.06.24 |