2026-03-15

Java 8에서 25까지 — 단계적 마이그레이션으로 보일러플레이트 900줄 제거한 이야기

출발점: Java 8 + Spring Boot 2.x

에이블테라퓨틱스에 합류했을 때 기존 시스템은 Java 8 + Spring Boot 2.x 기반이었다. Java 8은 2019년에 공식 지원이 종료됐고, Spring Boot 2.x도 EOL에 가까웠다. 보안 패치가 더 이상 나오지 않는 상태에서 헬스케어 플랫폼을 운영하는 건 리스크였다.

하지만 더 큰 문제는 코드 품질이었다. Java 8 시절의 관행대로 작성된 VO(Value Object) 클래스들이 있었는데:

// Before: 전형적인 Java 8 스타일 VO
public class UserResponse {
    private String id;
    private String name;
    private String email;
    private String phone;
    private String role;
    private LocalDateTime createdAt;
 
    public UserResponse() {}
 
    public UserResponse(String id, String name, String email,
                        String phone, String role, LocalDateTime createdAt) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.phone = phone;
        this.role = role;
        this.createdAt = createdAt;
    }
 
    // getter 6개, setter 6개, equals, hashCode, toString...
    // 한 클래스에 60~80줄
}

이런 클래스가 수십 개. 거기에 다중 상속 구조로 VO끼리 extends하고 있어서 필드가 어디서 오는지 추적하기도 어려웠다.

마이그레이션 전략: 한 번에 하지 않기

"Java 25로 한 번에 올리자"는 위험한 접근이다. 호환성 이슈가 어디서 터질지 모르기 때문이다. 단계적으로 진행했다:

1단계: Java 8 → 17 (LTS)

가장 큰 변화가 여기서 일어났다:

  • javax → jakarta 네임스페이스 변경 준비 (아직 Spring Boot 2.x라서 실제 전환은 아님)

  • 텍스트 블록, switch 표현식 같은 문법 적용

  • record 클래스 도입 시작 — 새로 만드는 DTO부터 record로

// After: Java 17+ record
public record UserResponse(
    String id,
    String name,
    String email,
    String phone,
    String role,
    LocalDateTime createdAt
) {}
// 끝. 6줄. getter, equals, hashCode, toString 자동 생성.

2단계: Spring Boot 2.x → 3.5 (Java 17 → 21)

이게 가장 큰 고비였다. Spring Boot 3.x부터 Jakarta EE 전환이 필수다:

// Before
import javax.persistence.*;
import javax.validation.constraints.*;
import javax.servlet.http.*;
 
// After
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import jakarta.servlet.http.*;

단순 패키지명 변경이지만 영향 범위가 넓다. IDE의 전체 치환 기능으로 처리하되, 서드파티 라이브러리가 jakarta를 지원하는지 하나하나 확인해야 했다.

보안 설정도 완전히 바뀌었다:

// Before: WebSecurityConfigurerAdapter 상속 (deprecated)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated();
    }
}
 
// After: SecurityFilterChain 빈 등록
@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .build();
    }
}

3단계: Java 21 → 25

여기서는 주로 최신 문법 적용:

  • 패턴 매칭 강화 — instanceof 후 바로 캐스팅

  • sealed classes — 도메인 모델 타입 안전성

  • virtual threads — 검토 중 (아직 적용 전)

VO → record 전환: 900줄 제거

가장 임팩트가 컸던 작업이다. 기존 VO 클래스들을 전수 조사했다:

  • Request DTO: 15개 → 전부 record로 전환

  • Response DTO: 22개 → 전부 record로 전환

  • 다중 상속 구조: 공통 필드를 composition으로 변경

Before: 평균 60줄/클래스 × 37개 = ~2,220줄
After: 평균 8줄/클래스 × 37개 = ~296줄
제거된 보일러플레이트: ~1,900줄 (그 중 명확히 측정 가능한 것만 900줄 이상)

record는 불변(immutable)이니까 setter로 인한 사이드 이펙트도 원천 차단된다.

겪은 이슈들

Jackson 직렬화 문제

record와 Jackson(JSON 라이브러리) 조합에서 기본 생성자가 없어서 역직렬화가 안 되는 경우가 있었다. Spring Boot 3.x에서는 jackson-module-parameter-names가 기본 포함되어 해결되지만, 커스텀 직렬화가 필요한 경우 @JsonCreator를 명시해야 했다.

서드파티 라이브러리 호환성

Java 17+에서 리플렉션 제한이 강화되면서 일부 라이브러리가 InaccessibleObjectException을 던졌다. --add-opens JVM 옵션으로 임시 대응 후, 라이브러리 업데이트로 근본 해결했다.

결과

항목BeforeAfter
Java 버전8 (EOL)25 (최신)
Spring Boot2.x (EOL 근접)3.5.x
DTO 보일러플레이트~2,220줄~296줄
보안 설정deprecated API현행 API

마이그레이션은 한 번에 하면 안 된다. 단계별로 올리면서 각 단계에서 테스트를 통과시키고, 동료에게 리뷰를 받아야 한다. 특히 Spring Boot 2→3 전환은 Jakarta EE 네임스페이스 변경 때문에 영향 범위가 크니까, 충분한 테스트 기간을 잡아야 한다.

관련 글

벡터 유사도 기반