Spring Boot 버전업 중 알게된 Java 버전별 캡슐화 정책 강화
자바 모듈 시스템의 변화로 인한 직렬화 문제를 분석하면서 알게된 내용을 공유합니다.
1. 들어가며
안녕하세요. 딜리버리 프로덕트에서 간선 서비스를 개발하고 있는 고산하입니다.
지난 상반기, 컬리에서는 전사적으로 AWS MSK의 버전업이 진행되었는데요. 이에 따라 Kafka 클라이언트와의 호환성을 유지하기 위해서 Spring Boot의 버전업이 필요하게 되었습니다.
이번 포스팅에서는 Spring Boot 버전업 과정에서 마주했던 문제를 해결하면서 알게 된 내용에 대해 공유드리고자 합니다.
제가 담당한 프로젝트의 기존 스팩과 업그레이드 대상 버전은 다음과 같습니다:
- Spring Boot: 2.5.3 → 3.2.4
- Java: 11 → 17
- Kafka clients: 2.7.2 → 3.0.16
- Gson: 2.8.5 → 2.11.0
- …
모든 버전업을 마무리하고 나서 주요 기능에 대한 테스트를 진행하였고, 로그인 과정에서 문제가 발생하는것을 확인할 수 있었습니다.
com.google.gson.JsonIOException: Failed making field 'java.time.LocalDateTime#date' accessible; either increase its visibility or write a custom TypeAdapter for its declaring type.
...
Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make field private final java.time.LocalDate java.time.LocalDateTime.date accessible: module java.base does not "opens java.time" to unnamed module @7ee7980d
at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:354)
at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:297)
문제 지점을 디버깅을 통해 확인한 결과, 로그인 시 생성된 ValidToken 객체를 Redis에 직렬화하여 저장하는 과정에서 에러가 발생한 것을 알 수 있었습니다.
로그를 살펴보니, 직렬화 과정에서 리플렉션을 사용하여 LocalDateTime의 date 필드에 접근하려 했으나 java.time 패키지가 외부 모듈에 공개되어 있지 않아 문제가 발생한 것으로 파악되었습니다.
에러 메시지의 상단을 보면 필드의 가시성을 높이거나 Custom TypeAdapter
를 등록하여 문제를 해결하라는 두 가지 방법을 제안하고 있습니다.
Failed making field 'java.time.LocalDateTime#date' accessible; either increase its visibility or write a custom TypeAdapter for its declaring type.
제안된 두 가지 방법 중 필드의 가시성을 높이는 것은 캡슐화 및 보안성 측면에서 바람직하지 않기 때문에, LocalDateTime에 대한 Custom TypeAdapter를 작성하였습니다. 이후 로그인 과정을 다시 테스트해본 결과 문제가 해결된 것을 확인할 수 있었습니다.
1.1 마치며(?)
지금까지 Gson의 InaccessibleObjectException 에러에 대한 해결 방법을 알아보았습니다.
그러나 현상이 해결되었으니 끝인 걸까요?
문제는 해결되었지만 에러의 근본적인 원인을 이해하지 못한 상태였고, Gson 라이브러리의 직렬화 내부 동작이 에러와 어떤 연관이 있는지, Custom TypeAdapter를 등록하는 것이 왜 문제 해결에 효과적이었는지에 대한 궁금증이 남아있었습니다.
이러한 호기심을 풀기 위해 Gson의 내부 직렬화 동작 과정을 분석해 보기로 했습니다.
2. 직렬화 동작 과정
에러 메시지의 Stack Trace를 따라가며 문제가 발생한 지점을 위주로 살펴보았습니다.
주요 부분을 추려보면 다음과 같습니다.
- com.google.gson.internal.reflect.ReflectionHelper.makeAccessible(ReflectionHelper.java:76)
- com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.getBoundFields(ReflectiveTypeAdapterFactory.java:388)
- com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.create(ReflectiveTypeAdapterFactory.java:161)
- com.google.gson.Gson.getAdapter(Gson.java:628)
2.1 TypeAdapter 찾기
Gson.getAdapter()
메서드는 객체의 타입에 맞는 TypeAdapter를 선택하거나 생성하는 역할을 합니다.
public <T> TypeAdapter<T> getAdapter(TypeToken<T> type) {
TypeAdapter<?> cached = typeTokenCache.get(type);
if (cached != null) {
return (TypeAdapter<T>) cached;
}
// 적절한 TypeAdapter를 찾기 위해 등록된 TypeAdapterFactory 리스트 순회
for (TypeAdapterFactory factory : factories) {
TypeAdapter<T> adapter = factory.create(this, type);
if (adapter != null) {
typeTokenCache.put(type, adapter); // 캐시 저장
return adapter;
}
}
// ...
}
동작 원리:
- Gson은 먼저 캐시(typeTokenCache)에서 요청된 타입의 TypeAdapter를 찾습니다.
- 캐시에 없는 경우, 등록된
TypeAdapterFactory
리스트를 순회하며 요청된 타입에 적합한 TypeAdapter를 생성하거나 반환합니다. - 생성된 TypeAdapter는 캐시에 저장되어 동일한 타입에 대한 후속 요청 시 재사용됩니다.
2.2 ReflectiveTypeAdapterFactory
ReflectiveTypeAdapterFactory
는 Gson에 등록된 여러 TypeAdapterFactory
중 하나로, 커스텀 TypeAdapter가 등록되지 않은 타입에 대해 기본적으로 동작합니다.
public final class ReflectiveTypeAdapterFactory implements TypeAdapterFactory {
//...
private FieldsData getBoundFields(Gson context, TypeToken<?> type, Class<?> raw, boolean blockInaccessible, boolean isRecord) {
for (Field field : fields) {
//...
if (!blockInaccessible && accessor == null) {
ReflectionHelper.makeAccessible(field);
}
//...
}
}
}
동작 원리:
- ReflectiveTypeAdapterFactory는 리플렉션을 사용하여 객체의 필드에 접근하고, 이를 JSON으로 변환하는 역할을 합니다.
- 이 과정에서 접근 제한이 있는 필드에 대해
ReflectionHelper.makeAccessible()
메서드를 호출하여 접근 권한을 설정합니다.
public class ReflectionHelper {
public static void makeAccessible(AccessibleObject object) throws JsonIOException {
try {
object.setAccessible(true);
} catch (Exception exception) {
//...
}
}
}
AccessibleObject.setAccessible(true)
를 호출하여, private, protected 등 접근 제한이 설정된 필드에도 접근을 허용합니다.
Gson은 이러한 리플렉션을 통한 접근을 활용하여 객체의 모든 필드를 JSON 직렬화에 포함할 수 있게 됩니다.
그러나 이러한 직렬화 과정은 자바 11에서는 정상적으로 동작했지만, 자바 17로 버전업한 이후 부터는 ReflectionHelper.makeAccessible()
호출시 InaccessibleObjectException이 발생했습니다.
private 필드에 리플랙션 기반 접근을 시도하면서 문제가 발생한 것을 확인할 수 있었는데요.
Unable to make field private final java.time.LocalDate java.time.LocalDateTime.date accessible: module java.base does not "opens java.time" to unnamed module
이 에러 로그를 통해, 자바의 모듈 시스템이 특정 패키지에 대한 외부 접근을 허용하지 않도록 설정되었다는 것을 알 수 있었습니다. 자바 11에서는 이러한 접근이 가능했지만, 자바 17로 버전업한 후에는 모듈 시스템의 강화로 인해 접근이 불가능해졌다는 것을 추측할 수 있었습니다.
따라서 자바 11과 17 사이에 모듈 시스템, 그리고 리플렉션 접근과 관련된 변화가 이번 에러의 원인일 수 있겠다는 생각이 들었고, 문제의 근본적인 원인을 이해하기 위해 자바 9에서 17까지의 모듈 시스템 변화에 대해 알아보기로 했습니다.
3. 자바 9부터 자바 17까지의 모듈 시스템 변화
자바의 모듈 시스템은 자바 9(프로젝트 Jigsaw)에서 처음 도입되었으며, 이후 버전에서 점진적으로 강화되어 왔습니다.
3.1 자바 9: 모듈 시스템 도입
자바 9에서는 모듈 시스템을 도입하여 JDK 내부 요소에 대한 접근을 제한함으로써 보안성과 유지 보수성을 개선하고자 하였습니다.
모듈 시스템은 강력한 캡슐화를 제공하여, 모듈 외부의 코드가 해당 모듈이 공개한 패키지의 public 및 protected 요소에만 접근이 가능하도록 합니다. 이러한 강력한 캡슐화는 컴파일 타임과 런타임 모두에 적용되며, 리플렉션을 통한 접근에도 동일하게 적용됩니다.
하지만 자바 9에서는 이전 버전과의 호환성을 위해, JDK 8에 존재하던 내부 요소들에 대해서는 런타임 시 강력한 캡슐화를 적용하지 않습니다. 이를 완화된 강력한 캡슐화(relaxed strong encapsulation)라고 합니다.
따라서 클래스패스에 있는 라이브러리나 애플리케이션 코드는 여전히 java.*
패키지의 비공개 요소와 sun.*
등의 내부 패키지에 리플렉션으로 접근이 가능하며,
이는 --illegal-access=permit
옵션이 기본값으로 설정되어 있기 때문입니다.
3.2 자바 16: 디폴트 옵션 변경
자바 16에서는 JEP 396을 통해 강력한 캡슐화가 기본 동작으로 설정되었습니다.
이에 따라 --illegal-access
의 기본 옵션이 deny로 변경되었고, JDK 내부 패키지에 대한 리플렉션 접근이 기본적으로 차단되었습니다.
하지만 여전히 sun.misc.Unsafe
와 같은 중요한 내부 API는 사용할 수 있었으며, 필요에 따라 --add-opens
옵션을 사용하여 특정 패키지를 열어줄 수 있었습니다.
이를 통해 개발자들은 특정 패키지에 대한 리플렉션 접근을 명시적으로 허용할 수 있었습니다.
3.3 자바 17: JEP 403 적용 및 강력한 캡슐화
자바 17에서는 JEP 403이 적용되면서 JDK 내부 요소에 대한 강력한 캡슐화가 더욱 강화되었습니다.
이는 내부 요소들에 대한 접근을 엄격하게 제한함으로써 보안을 강화하고, JDK 내부 구현에 의존하는 외부 코드를 줄이기 위함이었습니다.
Strongly encapsulate all internal elements of the JDK, except for critical internal APIs such as sun.misc.Unsafe. It will no longer be possible to relax the strong encapsulation of internal elements via a single command-line option, as was possible in JDK9 through JDK16.
JEP 403에서 제안된 주요 변경 사항은 다음과 같습니다:
- 내부 API의 접근 제한:
setAccessible(true)
를 통한 리플렉션 기반의 접근을 차단합니다. --illegal-access
옵션 제거: 자바 16까지 사용되던 –illegal-access 명령줄 스위치는 더 이상 지원되지 않습니다.--add-opens
제한 강화: 시스템 모듈에 대한 패키지 오픈을 제한하여 보안을 강화합니다.
이로 인해 내부 API에 대한 리플렉션 접근이 완전히 차단되었으며, --add-opens
옵션도 시스템 모듈에 대해서는 제한되었습니다.
즉, java.base
모듈의 패키지에 대한 리플렉션 접근이 불가능해졌습니다.
이러한 변화로 인해 Gson이 LocalDateTime 클래스의 private final 필드에 접근하려고 시도하면 InaccessibleObjectException이 발생하게 된 것이었습니다. Gson의 기본 직렬화 방식은 ReflectiveTypeAdapterFactory를 통한 리플렉션을 사용하기 때문에, 자바 17에서는 이러한 접근이 모듈 시스템의 강화로 인해 더 이상 허용되지 않았던 것입니다.
4. Custom TypeAdapter가 어떻게 문제를 해결한걸까
자바 모듈 시스템의 변화로 인해, Gson과 같이 리플렉션을 사용하여 내부 구현에 의존적으로 설계된 라이브러리들은 수정이 불가피해졌습니다. 특히, Gson이 private 필드에 리플렉션으로 접근하려고 시도하면서 발생한 이번 문제는, 모듈 시스템이 강화된 자바 17 환경에서 근본적으로 해결이 어려워 보였습니다.
실제로 Gson 프로젝트의 이슈를 확인해 본 결과, 내부 패키지의 객체를 직렬화하는 문제에 대해 해결이 어렵다는 의견이 있었습니다:
This is due to some historical baggage where Gson will happily serialize any object inside the platform package (java.*). Ideally Gson would throw an error when you did this, but alas, we can't make that change. Also Gson's built-in support of java.util.Date is also historical baggage we cannot remove or change.
이러한 이유로 인해, 초반부에서 확인했던 에러 메시지에서 Custom TypeAdapter를 등록해 문제를 해결하라는 제안이 나온 것이었습니다. Custom TypeAdapter를 등록함으로써 Gson이 기본적으로 사용하는 ReflectiveTypeAdapterFactory 대신, 등록한 Custom TypeAdapter를 사용하도록 할 수 있었습니다.
아래는 구현한 LocalDateTimeAdapter
의 예시입니다:
public class LocalDateTimeAdapter extends TypeAdapter<LocalDateTime> {
private static final DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
@Override
public void write(JsonWriter jsonWriter, LocalDateTime localDateTime) throws IOException {
jsonWriter.value(localDateTime.format(formatter));
}
@Override
public LocalDateTime read(JsonReader jsonReader) throws IOException {
return LocalDateTime.parse(jsonReader.nextString(), formatter);
}
}
커스텀 TypeAdapter를 등록함으로써, Gson이 TypeAdapterFactory 리스트를 순회할 때 정의한 커스텀 TypeAdapter가 ReflectiveTypeAdapterFactory보다 우선순위를 갖게 됩니다. 이를 통해 ReflectiveTypeAdapterFactory에서 발생하는 리플렉션 접근 문제를 우회하고, LocalDateTime 객체를 성공적으로 직렬화 및 역직렬화할 수 있었습니다.
결론적으로, private 필드를 가진 클래스 타입에 대해 직접 Custom TypeAdapter를 구현함으로서 Gson의 리플렉션 제한을 효과적으로 회피하고, 안정적인 직렬화 및 역직렬화를 달성할 수 있었습니다.
5. 마치며
지금까지 Spring Boot 버전업 과정에서 발생한 Gson 직렬화 문제를 통해 자바 모듈 시스템의 변화와 캡슐화 정책 강화에 대해 살펴보았습니다.
현상만 해결하고 넘어갔다면 알 수 없었을 내용이지만, 문제의 근본적인 원인에 대한 탐구를 통해서 Gson의 직렬화 동작 원리와 자바 모듈 시스템의 변화에 대해 깊이 이해할 수 있었습니다.
이처럼 문제의 본질을 이해하고 그 과정에서 얻은 통찰은 앞으로 비슷한 상황에 직면했을 때 더 나은 대응을 가능하게 해줄 것이라고 생각합니다.
'왜'에 집중하고 문제의 근본적인 원인을 탐구해가는 과정을 통해서 더 많은 것을 배울 수 있었던 경험이었습니다.