Spring Boot 3.5.3 & Java 17 마이그레이션 개발 (ing…)

2025, Sep 21    

📌 버전 업그레이드 요약

항목BeforeAfter
Java1.817
Spring Boot2.7.183.5.3
Spring Cloud2021.0.92025.0.0
Maven3.6.13.9.11
SLF4J1.7.362.0.17

작업 배경

  • 노션에 내용을 너무 이것저것 대충 모았더니, 정리가 안되어서 chatGpt에게 1차 정리를 요청했습니다..하..
  • 그래도 마이그레이션을 하게 된 배경을 정리하면
    • 오래된 숙제였기도 하고
    • 고객사 요청이기도 했습니다.
    • 개인적으로는 제품에 spring ai를 개발해볼수 있는 환경이 된듯하여 기대되고
    • 또 fatjar 속도 개선이 되었다는 블로그를 보니 더더욱 완성품이 기대됩니다 😆

범위 및 일정

  • 범위는 현재 제품군 전체에 대한 적용입니다.
  • 순차적용되고 있기도하고, 다른 스프린트와 병행해서 진행하기 떄문에 시간 산정이 어려운 점이 아쉽긴하네요. 🥲

🔧 공통 적용(모든 어플리케이션)

1. 네임스페이스 변경

  • javax.*jakarta.*

    (annotation, persistence, validation, servlet 전부 변경)

2. 테스트 코드

  • JUnit 5 적용 (Spring Boot 3.x 기본)
  • @MockBean은 deprecated 경고 → 현행 유지 (추후 리팩토링 고려)
    • 현행 유지하게 된 이유는 테스트-코드-수행-속도-개선 와 관련이 있는데요.
    • 성능 이슈로 asciidoc multipage로 하면서 MockBean으로 컨피그 분리했는데, deprecated되어서 warning발생하는 것으로
    • 좀 더 고민이 필요하다.

3. Hibernate 6 호환성 대응

  • EmbeddedId 접근 방식 변경 → @Query로 명시적 쿼리 작성
    • 즉, JpoId 클래서에 extends받아서 부모.jpoid.id로 조회하는건 쿼리메소드로 지원하지 않는다.

        // ❌ Hibernate 6에서 작동하지 않음
        List<Entity> findAllByEmbeddedIdParentIdChildId(String childId);
      
    • 해결: @Query 어노테이션 사용

        // ✅ Hibernate 6 호환 방식
        @Query("SELECT e FROM Entity e WHERE e.embeddedId.parentId.childId = :childId")
        List<Entity> findAllByChildId(@Param("childId") String childId);
      
  • @Embeddable + @MappedSuperclass 동시 사용 불가 → 별도 클래스 구조로 변경
    • as-is

        // ❌ Hibernate 6에서 오류 발생
        @Embeddable
        @EqualsAndHashCode(callSuper = true)
        public class EntityId extends BaseId {
            // ...
        }
              
      
    • 해결: Embed 구조로 전환

        // ✅ Hibernate 6 호환 방식
        @Embeddable
        @EqualsAndHashCode
        public class EntityId implements JsonSerializable {
            @Embedded
            private BaseId baseId;
              
            @Override
            public String toString() {
                return toJsonWithoutPretty();
            }
        }
              
      
  • Criteria API 사용 시 경로 명시 필요

      // 기존
      root.get(FaqAgentSettingJpoId_.FAQ_AGENT_SETTING_ID).get(field).in(values);
        
      // 변경
      root.get(FaqAgentSettingJpoId_.FAQ_AGENT_SETTING_ID)
         .get(FaqAgentSettingJpoId_.PARTITION_ID)
         .get(field)
         .in(values);
        
    

4. CrudRepository.deleteById() 동작 변화

  • 2.x: 존재하지 않는 ID → EmptyResultDataAccessException
  • 3.x: 존재하지 않는 ID → 아무 동작 없이 성공 (200 OK)
  • 해결: 커스텀 JpaRepository 구현체로 예외 던지도록 수정

5. Validation 동작 변화

  • 3.x에서는 @RequestParam + @Range 등 제약이 자동 적용
  • 2.x에서는 @Validated를 붙여야만 동작

    → API 테스트 시 결과 차이가 발생할 수 있음

🗂️ 기타 모듈별 마이그레이션 내역 주요 부분 정리

  • Gateway 라우팅 구조 WebFlux 기반으로 변경
  • HttpMethod, ResponseStatusException API 변경 반영
  • Elasticsearch Script API 최신화
  • Maven Wrapper 3.9.11로 업그레이드
  • jakarta.servlet.http.HttpServletRequest 적용
  • Java 17에서 URLEncoder.encode 표준 변경 반영
  • jib-maven-plugin 3.x 호환성 확인 및 설정 수정
  • LoadBalancerClient 사용 시 경고 확인 (Spring 7.0 변경 사전 안내)
  • Feign GET 파라미터 명시 (@RequestParam)
  • Hibernate Dialect 변경: MySQLDialect

🛠️ 발생한 이슈 & 해결

1. SLF4J 충돌

  • LoggerFactory is not a Logback LoggerContext 오류 발생
  • 원인: slf4j 1.7.36 → 2.0.17 업그레이드 시 구버전 JAR 잔존
  • 해결: classpath 정리 + logback 버전 동기화

2. AOP JoinPointMatch 오류

Required to bind 2 arguments, but only bound 1 (JoinPointMatch was NOT bound)

  • 원인: @Order 우선순위 너무 높음
  • 해결: @Order(Ordered.HIGHEST_PRECEDENCE)@Order(1)

3. Validated 유효성 버그 패치?!

  • 원래는 validated가 선언되어있지 않으면, @Range@Min@Max@NotBlank 같은 Bean Validation를 선언해도 적용이 안되는 코드가 있었다면, 기존에는 오류가 나지 않았었는데요,
  • 이번에 작업하면서 그러한 잘못 선언된, validated 어노테이션없이 valid 어노테이션을 사용한 경우는 오류가 나도록 패치가 된것 같습니다.
  • 그러면서 코드 내에 버그도 잡게 된…
  • @Validated 가 없는데, 아래 @Range 가 기존 버전에서는 작동하지 않았고, spring 3 에서는 작동한 경우입니다.

      public BtalkRdoListRdo findAll(
         ...
         @Range(min = 1, max = 100) @RequestParam(defaultValue = "10", required = false) int limit,
         ...
        
    

📊 SonarQube 개선 사항

  • 소나큐브 버전 : SonarQube version: 9.9.1.69595
  • Java 16+ 기능 적용
    • java: S6201 (minor) Pattern Matching for instanceof
      • 이건 확실이 라인 수가 줄어드니 좋더군요! ㅋㅋ
      • 기존코드도 동작이 없지만, 컴파일시 타입체크 보장되고
      • 의도가 명확해서 가독성이 좋아지고
      • 캐스팅과 변수선언을 한번에 하기때문에 코드가 간결해집니다.
      • 코드
        • as-is

            if (obj instanceof Message) {
                return validateBy(((Message) obj), productType);
            } else if (obj instanceof ConversationMessengerMessage) {
                return validate(((ConversationMessengerMessage) obj).getConversationChannel(), productType);
            } else if (obj instanceof ConversationMessengerClosedMessage) {
                return validate(((ConversationMessengerClosedMessage) obj).getConversationChannel(), productType);
          
        • to-be

            if (obj instanceof Message message) {
                return validateBy(message, productType);
            } else if (obj instanceof ConversationMessengerMessage conversationMessengerMessage) {
                return validate(conversationMessengerMessage.getConversationChannel(), productType);
            } else if (obj instanceof ConversationMessengerClosedMessage conversationMessengerClosedMessage) {
                return validate(conversationMessengerClosedMessage.getConversationChannel(), productType);
            }
          
    • Java:S6204(major) Replace this usage of ‘Stream.collect(Collectors.toList())’ with ‘Stream.toList()’“(major)
      • Stream.toList() 사용 권장
      • 기존에는 Collectors.toList로 변환하던 부분에서 가변, 불변인지 분류해서
      • 가변인 경우는 기존처럼,
      • 불변인 경우는 toList를 사용하는 것으로 변경했습니다.
      • java 10+ 이후 적용가능합니다.
      구분collect(Collectors.toList())toList()
      반환 타입ArrayList (가변)List.of() (불변)
      코드 길이길음짧음
      Java 버전Java 8+Java 16+
      성능약간 느림약간 빠름
      항목BeforeAfter
      • 언제 Collectors.toList()를 사용하나?
        • 반환된 리스트를 호출자가 수정해야 하는 경우
        • 리스트에 요소를 추가/삭제해야 하는 경우
        • 리스트의 요소들을 수정해야 하는 경우
        • 런타임시에 버튼 추가/삭제
        • 조건부 필터링 후 리스트 수정
        • 사용자 상호작용에 따른 동적 변경
        • 실시간 업데이트가 필요한 경우
        • 버튼 순서 변경
      • 코드
        • as-is

            Arrays.stream(businessChannelTypes)
            .collect(Collectors.toList());
          
        • to-be

            Arrays.stream(businessChannelTypes).toList();
          
    • java:S6212 (info) Declare this local variable with “var” instead.
      • var 적용 권장—> 이건 룰 제외로 처리했습니다.
      • java 10부터 도입된 기능으로, 컴파일러가 타입을 자동으로 추론할 수 있기 때문에
      • 타입추론이 가능한 지역변수는 var로 선언해라.라는 의도입니다.
      • 하지만 크게 성능적인 이점이 있거나 그런것도 없고, info레벨인데다가. 형을 알수 없는 단점이 있어서 다른 분들과 얘기한 후 룰 제외로 정리했습니다.
    • java:S6126(major) Replace this String concatenation with Text block.
      • 원인 : 긴 문자열을 + 연산자로 연결하지말고 Java 15부터 도입된 Text Block(“"”로 시작하고 끝나는 멀티라인 문자열)을 사용
      • 코드
        • as-is
          "{\n  \"applicationId\" : \"btalk\"
          ",\n  \"messageId\" : 2034186899619840
          "ne\",\n    \"message\" : null\n  }\n}",
        
        • to-be
          """
          	{
          	  "applicationId" : "btalk",
          	  "messageId" : 2034186899619840,
          	  "channelUrl" : "d0f31e10-a9e1-4f6f-ba99-108c73ca2fdd",
          	  "type" : "text",
          	  ...
          	  }
          	}
          """,
        
    • java:S1874(minor) Remove this use of “defaultString”; it is deprecated.
      • 원인 : deprecated됨
    • java:S6208(info) Merge the previous cases into this one using comma-separated label.
      • 원인 : switch 문에서 여러 case들이 같은 동작을 수행하고 있는데, 이들을 쉼표로 구분하여 하나의 case로 병합하라는 것
      • 코드

          // Java 10 이전 (기존 방식)
          switch (value) {
              case 1:
              case 2:
              case 3:
                  System.out.println("1, 2, or 3");
                  break;
          }
                    
          // Java 10 이후 (새로운 방식)
          switch (value) {
              case 1, 2, 3:
                  System.out.println("1, 2, or 3");
                  break;
          }
        
      • 또는 enhanced switch도 가능

          switch (warningConversationMessage.getWarningMessageType()) {
                case NOT_ALLOWED_MESSAGE -> throw new NotAllowedMessageException(KakaoSystemMessageType.S5.name());
                case NO_OPERATING_TIME, NOT_ALLOWED_REQUEST ->
                        throw new GeneralMessagingException(KakaoSystemMessageType.S2.name());
                case NOT_AVAILABLE_MESSAGE -> throw new GeneralMessagingException(KakaoSystemMessageType.S1.name());
                default -> {
                }
            }
        

✅ 작업 후기 및 남은 작업

  • 작년에 spring_boot3_upgrade/ 에서 미리 준비하면서 정리했던 것이 그나마 좀 도움이 되었던 것 같습니다.
  • ui 및 api 테스트가 진행중이지만, 제가 디버깅하면서 검증해야할 비기능 요구사항도 있어서, 조금씩 해야할 것 같습니다.
    • LoadBalancer 테스트
    • Spring Cloud OpenFeign 4.x 설정 (TTL, retry, logging, HttpClient5 적용)
  • 이번 마이그레이션 스프린트에 참여하지 않은 다른 개발자에게도 전파해야할 것 같아서 잘 정리해나가볼 생각입니다.