- 스프링부트 관련 잡다한 기술 이야기
"Spring Boot DevTools 클래스로더 이슈"라는 제목으로 글을 작성하였는데, 막상 글을 다 작성한 후 다시 읽어보니 너무 잡다한 내용이 되었다. 그래서, 이 글의 부제는 "스프링부트 관련 잡다한 기술 이야기"라고 정하였다.
회사의 소스코드는 보안상 이유로 전혀 포함하지 않았습니다. 일반적인 기술 관련 내용만 작성해서 공유합니다. 회사 업무 관련 내용은 팀원들에게만 따로 공유합니다.
신규로 맡게 된 애플리케이션은 스프링 부트 2.X 버전으로 구축되어 있다. 해당 시스템에서 몇 가지 이슈가 있는데 그중에서 필자를 제일 답답했던 점은 로컬 개발환경에서 @Cacheable 어노테이션이 정상적으로 작동하지 않는다는 사실이다. 캐시 구현체로부터 가져온 데이터를 역직렬 화하는 과정에서 Cast Exception 이 발생한다. 캐시 구현체는 Redis를 사용하고 있었다. 실서비스에서는 오류 없이 정상적으로 서비스 중이다. 기존 개발자들은 캐시 기능을 끄고 개발을 하고 있었다. 필자가 직감적으로 처음에 생각했던 추측은 두 가지였는데, 전부 정답은 아니었다.
필자의 직감 (정답은 아니었다.)
우리가 모르는 서버에서 직렬화 데이터를 주기적으로 넣어주고 있는데, 최신 버전의 클래스 정보와 맞지 않는 데이터를 넣어주고 있어서 그런 걸까? 예를 들어서, 클래스 Path 가 다르거나, serialVersionUID 가 다르거나...
필자의 직감은 정답은 아니었다. 원인이 뭘까? 찾아보자...
스프링은 캐시 추상화 기능을 제공한다. @Cacheable, @CachePut, @CacheEvit 등의 어노테이션을 선언하면 캐싱을 심플하게 구현할 수 있다. 캐시 Provider를 선택하기만 하면, 비즈니스 로직은 변경 없이 관심사 분리 원칙에 의해서 캐싱을 구현할 수 있다. 스프링에서 제공하는 대표적인 AOP 기능이다. 제공하는 Provider 리스트는 아래 링크를 확인하길 바란다.
필자는 스프링부트 환경에서는 캐시 프로바이더로 EhCache, Redis, Hazelcast를 사용해본 경험이 있는데, 이 글에서 다루고 있는 이슈는 Redis에 대한 내용이다. 스프링 캐싱 추상화에 대해서는 나중에 각 잡고 글을 쓰겠다.
일반적으로 직렬화(直列化) 또는 시리얼라이제이션(serialization)은 컴퓨터 과학의 데이터 스토리지 문맥에서 데이터 구조나 오브젝트 상태를 동일하거나 다른 컴퓨터 환경에 저장(이를테면 파일이나 메모리 버퍼에서, 또는 네트워크 연결 링크 간 전송)하고 나중에 재구성할 수 있는 포맷으로 변환하는 과정이다. [2] 자바 시스템 내부에서 사용되는 Object 또는 Data를 외부의 자바 시스템에서도 사용할 수 있도록 byte 형태로 데이터를 변환하는 기술을 의미하는데, JVM(Java Virtual Machine 이하 JVM)의 메모리에 상주(힙 또는 스택)되어 있는 객체 데이터를 바이트 형태로 변환하는 기술이다. [3] 역직렬화는 반대로 byte로 변환된 Data를 원래대로 Object나 Data로 변환하는 기술을 역직렬화(Deserialize)라고 부른다. 직렬화된 바이트 형태의 데이터를 객체로 변환해서 JVM으로 상주시키는 형태이다. 직렬화 관련해서는 우아한 형제들의 블로그 글을 참고하자.
http://woowabros.github.io/experience/2017/10/17/java-serialize.html
해당 글에서 설명하는 역직렬화 조건은 아래와 같다.
동일한 serialVersionUID를 가지고 있어야 한다.
직렬화 대상이 된 객체의 클래스가 클래스 패스에 존재해야 한다.
이번 이슈를 찾아가는 과정에서, 필자가 직감적으로 추측했던 내용이 바로 이 부분이다. 하지만, 원인은 아니었다.
도대체 무엇인 문제인가??
다행히도 같이 일하는 팀원이 작은 힌트를 주었는데, 해당 팀원이 생각하는 원인은 클래스로더 이슈, 스프링 프레임워크의 버그, 또는 Intellij 의 버그인 것 같다는 의견이었는데 팀원도 나도 확신은 없었다.
Java Compiler, JVM, 클래스 로더 등에 대해서 아주 간단하게만 알아보자. 필자도 까먹어서 기억이 잘 나지 않지만, 필자의 허접한 그림을 먼저 감상하길 바란다.
자바 컴파일러는 JVM 이 이해할 수 있도록 자바 바이트 코드로 변환한다. 우리가 잘 알고 있는 javac 가 바로 자바 컴파일러 이다. 컴파일러에 의해 생성된 바이트코드는 JVM 의 클래스로더에 의해서 동적으로 로드된다. 클래스 로드가 포함되어있는 JVM 은 자바 바이트 코드를 실행하는 역할을 하는데 아래와 같이 구성된다.
자바 인터프리터(interpreter)
클래스 로더(class loader)
JIT 컴파일러(Just-In-Time compiler)
가비지 컬렉터(garbage collector)
https://www.baeldung.com/java-classloaders
자바 클래스로더(Java Classloader)는 자바 클래스를 자바 가상 머신(JVM)으로 동적 로드하는 자바 런타임 환경(JRE)의 일부이다. [1]
스프링 데이터 레디스 프로젝트는 스프링 애플리케이션 환경에서 레디스 연동을 위한 컨피그 구성을 제공하여, 심플하게 레디스 연동 시스템을 구축할 수 있는 기술이다.
https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/
스프링 데이터 레디스 코드 중에서 RedisCacheManager Bean을 생성하는 코드를 확인해보자.
determineConfiguration(resourceLoader.getClassLoader()) 구문을 디버깅해보자.
AppClassLoader 가 주입되는 것을 확인할 수 있다. AppClassLoader는 애플리케이션 클래스로더인데, 자바 classpath에 명시된 경로에서 클래스를 로딩한다. 개발자가 애플리케이션 구동을 위해 직접 작성한 대부분의 클래스는 이 애플리케이션 클래스로더에 의해 로딩된다. [4]
스프링부트 devtools 는 애플리케이션 개발을 위해 유용한 기능을 포함하고 있는 모듈이다. 대표적인 기능 중 하나는, 개발 중 코드 변경 시 애플리케이션 재시작 없이 자동으로 리로드 할 수 있다. 필자는 예전에 스프링 MVC 개발 중 유용하게 사용한 경험이 있다.
디펜던시를 추가하고, Properties에서 spring.devtools.livereload.enabled=true 설정하고 IntelliJ에서 체크박스를 선택하면 된다. 자세한 내용은 생략한다.
https://www.baeldung.com/spring-boot-devtools
잡다하게 관련 기술을 나열하였다. 정리해보면 아래와 같다.
JVM 클래스 로드
스프링 캐싱 추상화
직렬화, 역직렬화
Spring Data Redis
Spring Boot Devtools
Spring Boot DevTools를 추가한 후 RadisCacheManager 빈 생성 로직을 다시 디버깅해보자.
클래스 로더가 변경되었다. Spring Boot DevTools 디펜던시에서는 RestartClassLoader를 사용한다. 위에서 DevTools 가 없는 상황에서는 AppClassLoader를 사용했는데, DevTools의 특징으로 인해서 AppClassLoader를 쓰지 않고, RestartClassLoader를 사용하도록 구현이 된 것이다. 이 상황은 문제가 없다. 관련해서 스프링 개발 팀에서 스프링부트 2.0.0을 개발하면서 한차례 논의를 해서 수정 작업을 한 것으로 보인다.
https://github.com/spring-projects/spring-boot/issues/11822
https://github.com/spring-projects/spring-boot/pull/11825
개인적인 생각이지만, Pivotal 개발자들은 정말 똑똑한 것 같다. 어쨌든 Spring Boot DevTools와 Spring Data Redis를 같이 사용할 때 문제가 없도록 이미 개발이 되어있는 상황이었다. Spring Boot DevTools 가이드에서도 가이드가 되어있다.
해당 레퍼런스 문서를 보면, 클래스 로더에 대한 내용이 있다.
필자가 영어를 잘 못하지만, 읽어보면
"불행하게도 서드파티 라이브러리는 Context 클래스로더 고려 없이 역직렬화를 한다. 문제가 발생한다면, 라이브러리 저자에게 수정 요청을 해야 한다."라고 아주 친절하게 안내하고 있다.
간단하게 정리하면 이렇다.
Pivotal에서는 Spring Boot DevTools와 Spring Data Redis를 사용할 때 발생하는 역직렬화 문제를 이미 수정해서 배포하였다.
필자의 회사 코드에서는, RedisCacheManager를 커스터마이징 해서 사용 중이었는데, 클래스로더 관련 로직이 포함되어 있지 않았다. 즉, Spring Boot DevTools 가 문제의 발단이 되기는 했지만, 실제로 역직렬화를 제대로 못하는 이슈는 RedisCacheManager에서 클래스 로더를 제대로 지정하지 못하는 문제 때문이었다. 스프링부트에서 제공하는 AutoConfiguration 은 매우 심플하고 간결하지만, 개발자의 의지에 의해서 재정의할 때는 반드시 주의가 필요하다. 필자 같은 평범한 개발자보다, 상대적으로 Pivotal의 개발자들이 훨씬 더 뛰어나고 똑똑하다고 생각한다. 우리는, Pivotal 개발자가 개발한 AutoConfiguration 구문을 재정의할 때는 반드시 해당 로직을 먼저 검토한 이후, 재정의를 해야 한다. 아무 생각 없이 재정의하면 예상하지 못한 이슈, 우리가 알기 어려운 부분을 놓칠 수 있다. 스프링 부트가 편하고 좋지만, 커스터마이징을 하는 경우에는 각별히 주의가 필요하다. 잘 알고 써야 한다. 정말 중요한 내용이다. 자, 다시 코드로 돌아가서 보자. 회사의 코드를 공개할 수는 없지만, Pivotal 개발자가 작성한 코드를 보자.
@ConditionalOnMissingBean 어노테이션은 CacheManager 타입의 Bean(빈) 이 없는 경우에만 사용한다는 것을 의미한다. 만약, 우리가 개발한 코드에서 CacheManager를 정의한다면, 스프링 부트에서 제공하는
"public RedisCacheManager cacheManager"는 실행되지 않을 것이다. 즉, Pivotal에서 구현한 클래스로더 관련 이슈 해결사항을 별도로 구현해야 한다는 의미다. 예를 들어서, RedisConfig 라는 클래스를 아래와 같이 만들었다. @Configuration 을 선언하고, @Bean 어노테이션을 사용해서 CacheManager 타입의 빈을 만든다. 빈의 이름은 cacheManager 가 될 것이다.
자.. 이렇게 개발하고 역직렬화를 수행하면 아래와 같이... 에러 메시지를 내뿜는다.
해결책은 3가지이다.
1. Spring Boot DevTools 를 사용하지 않는다.
2. CacheManager 를 재정의 하지 않고, 스프링 부트 AutoConfiguration 에서 제공하는 기능을 그대로 사용한다.
3. CacheManager 를 재정의할 때 클래스로더 관련 로직을 추가한다.
개인적으로 1번 또는 2번 방법을 추천한다. 만약 3번 방법으로 하고 싶다면 아래와 같이 코드를 작성하면 된다.
참고로, 회사에서는 Spring Boot DevTools를 사용하지 않는 방법으로 갈 예정이다.
https://github.com/sieunkr/spring-data/tree/master/spring-data-redis-devtools-error
주저리주저리 작성하다 보니 내용이 너무 난잡해졌다. 마음 같아서는 아주 상세하게 작성하고 싶었지만 글 쓰는 것이 쉽지가 않다. 글의 제목은 "Spring Boot DevTools 클래스로더 이슈"이지만, 사실 스프링부트 AutoConfiguration에 대한 내용이 더 중요한 것 같다. 스프링 부트에서 제공하는 Autoconfiguration을 재정의할 때는, 반드시 기존 코드를 확인한 후 꼼꼼하게 재정의하도록 해야겠다.
[2] 직렬화 https://ko.wikipedia.org/wiki/%EC%A7%81%EB%A0%AC%ED%99%94
[3] 자바 직렬화, 그것이 알고싶다. 훑어보기편 http://woowabros.github.io/experience/2017/10/17/java-serialize.html
[4] Java ClassLoader 훑어보기 https://homoefficio.github.io/2018/10/13/Java-%ED%81%B4%EB%9E%98%EC%8A%A4%EB%A1%9C%EB%8D%94-%ED%9B%91%EC%96%B4%EB%B3%B4%EA%B8%B0/