Spring Boot 없이 10년 버틴 서비스에 Boot를 적용하기까지
이전 글에서 마이그레이션의 전체 스코프와 버전 테이블을 정리했다. 이 글에서는 PR(약 +16,000 / -14,000줄)의 안쪽에서 실제로 가장 많은 시간을 잡아먹었던 네 영역을 코드 수준에서 뜯어본다.
1. 수동 Bean vs Boot 자동 설정 — 판단의 반복
기존 서비스는 “필요한 것은 모두 직접 등록한다” 는 철학 위에 자라 있었다. DataSource, TransactionManager, RestTemplate, ObjectMapper, 보안 필터, 서블릿 매핑까지 전부 XML이나 @Bean으로 수동 등록돼 있었다.
Spring Boot는 반대다. “대부분은 관례에 맞으면 자동으로 채워 넣는다.” 문제는 이 두 철학이 한 서비스 안에서 동시에 동작하면 같은 타입의 Bean이 두 개씩 만들어진다는 것이다.
충돌이 터진 지점들
| 영역 | 기존 (수동) | Boot (자동) | 결정 |
|---|---|---|---|
| DataSource | JNDI 또는 직접 커넥션 풀 생성 | spring.datasource.* 기반 자동 구성 | 자동 구성으로 전환, 커넥션 풀 설정 이관 |
| TransactionManager | @Bean으로 직접 등록 | @ConditionalOnMissingBean으로 자동 생성 | 수동 제거 → Boot에 위임 |
| RestTemplate | 여러 곳에서 각자 생성 | Boot의 RestTemplateBuilder 제공 | @Primary로 공용 Bean 하나 유지, 나머지 정리 |
| ObjectMapper | 커스텀 직렬화 옵션 수동 등록 | Boot의 JacksonAutoConfiguration | spring.jackson.* 속성으로 이관, 수동 Bean 제거 |
| 서블릿 필터 | web.xml에 순서·패턴 지정 | FilterRegistrationBean으로 코드 등록 | web.xml 전면 제거 → Java Config 전환 |
진짜 어려운 건 “왜 이 설정이 있는가”를 추적하는 일
10년 된 서비스에서 DataSource Bean이 수동 등록돼 있으면, 그 안에 JNDI 이름, 커넥션 풀 크기, 타임아웃, 밸리데이션 쿼리가 들어있다. Boot가 자동 생성하는 DataSource는 application.properties에서 값을 읽는데, 수동 Bean에 들어있던 값과 Boot의 기본값이 다를 수 있다. 커넥션 풀 크기 하나 빠지면 운영에서 커넥션 고갈이 터질 수 있기 때문에, 수동 Bean의 옵션을 하나하나 속성 파일로 옮기고 동일한 동작을 검증해야 했다.
여기에 더해, 기존 XML에서 등록된 Bean과 Boot가 자동으로 생성하는 Bean이 이름이 같을 때는 에러 없이 하나가 다른 하나를 덮어씌우기도 했다. 이게 더 위험했다. 에러가 나면 찾기라도 쉽지, 조용히 덮어씌워지면 운영 환경에서만 다르게 동작하는 일이 생겼다.
어떤 순서로 정리했나
모든 수동 Bean을 목록화한다
XML과 Java Config에 흩어져 있는 @Bean 정의를 전부 모아서, Boot의 자동 설정과 타입이 겹치는 것을 식별했다.
각 수동 Bean이 Boot 기본값과 다른 점을 확인한다
커넥션 풀 크기, 직렬화 옵션, 타임아웃 등 실제 동작에 영향을 주는 설정값 차이를 하나하나 비교했다.
Boot 기본값이 충분한 곳은 수동 Bean을 제거한다
Boot 관례와 기존 동작이 동일하면 수동 Bean을 지우고 application.properties로 이관했다.
반드시 유지해야 하는 곳만 @Primary 또는 명시적 override로 남긴다
인증 관련 RestTemplate처럼 특수한 설정이 필요한 Bean은 @Primary를 붙여 의도를 코드로 남겼다.
@Primary
@Bean
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder,
ObjectMapper jacksonObjectMapper,
/* ... */) {
// Boot의 RestTemplateBuilder를 받되,
// 인증 서비스에 필요한 인터셉터와 타임아웃을 명시적으로 설정
}2. Hibernate 4 → 6: 두 메이저를 건너뛴 ORM 전면 재작성
Hibernate 4.1.8에서 6.5.3으로. 5를 건너뛰고 두 메이저 버전을 한 번에 뛰어넘었다. Hibernate 5에서 deprecated된 API가 6에서 완전히 삭제됐기 때문에, 점진적 마이그레이션이 아니라 사실상 ORM 코드를 새로 짜는 것에 가까웠다.
삭제·변경된 핵심 API
| Hibernate 4 (삭제됨) | Hibernate 6 (대체) | 영향 범위 |
|---|---|---|
Session.createCriteria() | JPA Criteria API 또는 JPQL | 모든 동적 쿼리 |
org.hibernate.criterion.* | jakarta.persistence.criteria.* | 조건 검색 로직 |
Query.setResultTransformer() | TupleTransformer + ResultListTransformer | 결과 매핑 |
org.hibernate.type.CustomType | org.hibernate.type.descriptor 체계 | 커스텀 타입 매핑 |
| 암묵적 타입 추론 | 명시적 타입 선언 요구 | 네이티브 쿼리 전체 |
Before / After
Before: Hibernate 4
public List<User> searchUsers(String name, String role) {
Criteria criteria = session.createCriteria(User.class);
if (name != null) {
criteria.add(Restrictions.ilike("name", name, MatchMode.ANYWHERE));
}
if (role != null) {
criteria.add(Restrictions.eq("role", role));
}
criteria.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY);
return criteria.list();
}Session.createCriteria()는 Hibernate 4에서는 표준이었고, 5에서 deprecated됐지만 동작은 했다. 6에서는 컴파일 자체가 안 됐다. 커밋 로그에 “Migration All of repository’s to CriteriaQuery from Criteria”라고 적혀 있을 정도로, 이 전환이 작업의 큰 축이었다.
Result Transformer도 사라졌다
Hibernate 4에서 자주 쓰이던 setResultTransformer()는 6에서 제거됐다. 복잡한 조인 쿼리의 결과를 DTO로 매핑하던 코드가 전부 깨졌고, 각각을 TupleTransformer나 Constructor Expression으로 재작성해야 했다.
// Hibernate 4: ResultTransformer로 DTO 매핑
query.setResultTransformer(
Transformers.aliasToBean(UserReportDto.class)
);// Hibernate 6: Constructor Expression
CriteriaQuery<UserReportDto> query = cb.createQuery(UserReportDto.class);
query.select(cb.construct(UserReportDto.class,
root.get("name"),
root.get("email"),
root.get("role")
));커스텀 타입 매핑
Hibernate 4의 CustomType과 UserType 인터페이스도 6에서 크게 바뀌었다. 암호화된 컬럼, JSON 컬럼 등 커스텀 타입 핸들러를 쓰던 코드가 전부 새 TypeDescriptor 체계에 맞춰 재작성됐다.
3. Trailing Slash 404 — 조용히 터지는 호환성 문제
마이그레이션이 끝나고 배포 직전 QA에서 특정 API 호출이 404를 리턴하는 이슈가 발견됐다. 요청 URL이 /api/users/처럼 끝에 슬래시가 붙어 있을 때만 발생했다.
원인
Spring Framework 5.3까지는 /api/users와 /api/users/를 같은 핸들러로 매칭해주는 게 기본 동작이었다. 하지만 Spring Framework 6.0(Spring Boot 3.0)부터 이 기본 동작이 제거됐다. setUseTrailingSlashMatch(true)는 deprecated 후 삭제됐고, trailing slash가 붙은 요청은 더 이상 자동으로 매칭되지 않는다.
문제는 이 서비스의 클라이언트(프론트엔드 SPA, 내부 API 호출자)가 10년간 /api/users/ 형태로 호출하고 있었다는 점이다. 서버 쪽 컨트롤러 매핑은 /api/users인데, trailing slash가 붙은 요청이 매칭되지 않으면서 조용히 404가 터졌다.
해결
Spring Framework 6.2부터 제공되는 UrlHandlerFilter를 이용해 모든 API 경로에 대해 trailing slash를 정규화하도록 필터를 등록했다.
@Bean
public UrlHandlerFilter urlHandlerFilter() {
return UrlHandlerFilter.trailingSlashHandler("/**")
.wrapRequest()
.build();
}
@Bean
public FilterRegistrationBean<UrlHandlerFilter> urlHandlerFilterBean(
UrlHandlerFilter urlHandlerFilter) {
FilterRegistrationBean<UrlHandlerFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(urlHandlerFilter);
bean.addUrlPatterns("/api/*");
return bean;
}이 필터는 /api/users/로 들어온 요청을 /api/users로 정규화해서 핸들러 매핑에 전달한다. 클라이언트 코드를 전부 고치지 않아도 기존 동작을 유지할 수 있었다.
Path Matching 전략도 함께 변경했다
Spring Boot 3의 기본 Path Matching 전략이 PathPatternParser로 바뀌었지만, 기존 코드가 AntPathMatcher 기반의 패턴(/**, {id} 등)에 의존하고 있어서 명시적으로 설정을 추가했다.
spring.mvc.pathmatch.matching-strategy=ant-path-matcher4. Spring Security 4 → 6: 보안 체인 전면 재작성
WebSecurityConfigurerAdapter 제거
Spring Security 5.7에서 deprecated되고 6.0에서 제거된 WebSecurityConfigurerAdapter는 가장 많이 쓰이는 클래스 중 하나였다. configure(HttpSecurity) 메서드를 override하던 패턴을 SecurityFilterChain Bean으로 전환해야 했다.
Before: Security 4.x
@Configuration
@EnableWebSecurity
public class SecurityConfiguration
extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.antMatchers("/api/health").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(oktaFilter,
BasicAuthenticationFilter.class);
}
}변경의 핵심
단순히 API가 바뀐 것이 아니라, 보안 설정의 사고 방식이 바뀌었다.
.and()체이닝 제거 — Lambda DSL로 전환. 각 설정 블록이 독립적인 Customizer가 됐다.antMatchers()→requestMatchers()— URL 매칭 방식이 내부적으로PathPatternRequestMatcher로 교체됐다.authorizeRequests()→authorizeHttpRequests()— 인가 처리 엔진이 교체됐다.- 필터 순서 재검증 — Okta 인증 필터 → MDC 필터 순서가 올바른지 통합 테스트로 고정했다.
인증 서비스의 보안 설정은 단순 CRUD 앱과 다르다. Okta SSO 연동, 조건부 Basic Auth, Swagger 접근 제어, 환경별 인증 on/off까지 분기가 많았기 때문에, 설정을 옮기면서 한 군데라도 빠지면 인증 우회가 가능해질 수 있었다. 모든 분기를 통합 테스트로 고정한 뒤에야 마이그레이션을 진행할 수 있었다.
5. 가장 힘들었던 건 테스트 코드였다
만 줄이 넘는 변경 중에서 가장 시간이 많이 들고 고통스러웠던 영역은 테스트 코드였다. 프로덕션 코드를 바꾸는 것보다 테스트를 바꾸고, 새로 짜고, 통과시키는 데 체감상 두 배 이상의 시간이 들었다.
왜 테스트가 그렇게 어려웠나
기존 테스트 커버리지의 빈틈
10년 된 서비스에 충분한 테스트가 있을 리 없었다. 기존에 테스트가 없던 코드는 “마이그레이션 후에도 같은 동작을 하는지” 검증할 기준선 자체가 없었다. 기준선을 먼저 만들어야 했다.
Hibernate API 변경의 연쇄 효과
프로덕션의 Criteria API를 JPA로 바꾸면, 그 코드를 테스트하던 모든 테스트도 함께 깨진다. Mock으로 Session.createCriteria()를 감싸고 있던 테스트가 JPA의 CriteriaBuilder를 Mock해야 하는 테스트로 바뀌면서, 프로덕션 변경 → 테스트 변경이 1:1이 아니라 1:N으로 번져나갔다.
Mockito 1.8 → 5.12
Mockito도 메이저가 네 번 올라갔다. Mockito.anyObject() 삭제, strict stubbing 기본 활성화, @InjectMocks 동작 변경, ArgumentMatchers 분리 등으로 기존 테스트의 stubbing 코드가 광범위하게 깨졌다.
Spring Boot 테스트 인프라 전환
web.xml 기반의 수동 설정에서 Spring Boot로 넘어가면 테스트 컨텍스트 로딩 방식 자체가 달라진다. @SpringBootTest, @WebMvcTest 같은 슬라이스 테스트를 도입하면서 기존 테스트 설정을 전면 교체해야 했다.
javax → jakarta의 파급
javax.persistence.Entity → jakarta.persistence.Entity 전환은 엔티티 클래스뿐 아니라 모든 JPA 테스트, Mock 설정, 테스트 유틸리티에 영향을 미쳤다.
PR 규모를 뜯어 보면
| 카테고리 | 비중 (추정) | 내용 |
|---|---|---|
| ORM/데이터 접근 | ~30% | Hibernate Criteria → JPA, 커스텀 타입, 네이티브 쿼리 전환 |
| 테스트 코드 | ~35% | 기존 테스트 전환 + 신규 테스트 작성 |
| 패키지 전환 | ~15% | javax.* → jakarta.* 기계적 변환 + 부수 효과 수정 |
| 설정/인프라 | ~10% | web.xml 제거, Boot 설정, 보안 필터 재구성 |
| 의존성/라이브러리 | ~10% | 내부 라이브러리 업그레이드, 호환성 조정 |
작업 순서를 어떻게 잡았나
위 네 영역이 동시에 터지기 때문에, 순서를 잘못 잡으면 원인 분리가 안 된다.
컴파일과 실행이 가능한 상태를 먼저 만든다
Java 21 + javax → jakarta 전환으로 빌드가 통과하는 기준선을 잡았다.
Hibernate/ORM 코드를 전환한다
Criteria API → JPA CriteriaQuery, 커스텀 타입 재작성, ResultTransformer 대체.
보안 체인을 재구성한다
SecurityFilterChain 전환 후, 모든 인증 분기를 통합 테스트로 고정.
Boot 설정 충돌을 정리한다
수동 Bean과 자동 Bean의 충돌을 해소하고, 불필요한 수동 설정을 제거.
trailing slash 같은 런타임 호환성을 잡는다
컴파일은 통과하지만 런타임에서만 터지는 문제를 QA 단계에서 포착하고 수정.
테스트를 새로 짠다
Spring Boot 테스트 인프라 위에서 기존 테스트를 전환하고, 커버리지가 부족했던 영역의 테스트를 새로 작성.
이 순서를 지킨 이유는 원인 분리를 위해서였다. 런타임, ORM, 보안, 배포 방식을 한 번에 바꾸면 문제가 생겼을 때 어느 레이어에서 틀어진 건지 잡기 어려워진다.
Bean 충돌, ORM 재작성, trailing slash, Security 전환 — 이 네 가지는 모두 “프레임워크 업그레이드” 한 줄에 숨어 있는 실제 작업이다. 레거시 서비스의 현대화에서 진짜 어려운 건 새 기능을 쓰는 일이 아니라, 오래된 가정과 새 관례가 부딪히는 접점을 하나하나 찾아서 판단하는 일이다.
이 기반이 정리된 이후로, 구조화 로깅을 붙이든, 비동기 문맥 전파를 정리하든, Audit Event를 재설계하든, “기본 실행 구조가 너무 낡아서 안 된다”는 문제로 돌아가지 않아도 됐다.
다음 글에서는 프레임워크 내부 구조를 정리한 뒤, 실제 운영 배포 환경을 ECS에서 컨테이너 기반 플랫폼으로 옮기며 무엇이 함께 바뀌었는지 이어서 본다.