AI 시대의 Spring 기술 스택 재정비: 명시성을 되찾기 위한 선택들
이 글은 Claude Opus 4.6 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
Claude Code 같은 AI 코딩 도구를 본격적으로 활용하기 시작하면서, 그동안 당연하게 쓰던 기술 스택에 대해 다시 생각하게 되었습니다. JPA의 dirty checking이 정말 편한 건지, Lombok 없이는 못 사는 건지, RestTemplate과 Retrofit이 뒤섞인 코드베이스가 과연 합리적인 건지.
결론부터 말하면, AI가 생성한 코드를 사람이 빠르게 읽고 검증하려면 “마법(magic)“보다 “명시성(explicitness)“이 훨씬 중요하다는 생각에 도달했습니다. 이 글에서는 그 관점에서 기존 Java/Spring 기술 스택의 각 영역을 하나씩 짚어봅니다.
1. Java 25 (without Lombok) — Kotlin 대신 Java를 메인으로#
Modern Java가 따라잡은 것들#
한때 Kotlin을 선택하는 이유는 명확했습니다. data class, null safety, coroutines, extension functions — Java가 제공하지 못하던 것들이 너무 매력적이었습니다. 그런데 2025년 기준으로 Java 25를 보면, 그 격차가 상당히 좁혀졌습니다.
- Records (Java 16~):
public record User(String name, String email) {}한 줄이면 Kotlin의data class가 하던 일을 합니다.equals(),hashCode(),toString()이 자동 생성되고, 기본적으로 immutable입니다. - Sealed Classes + Pattern Matching (Java 17~21): Kotlin의
sealed class+when에 대응하는 대수적 타입 모델링이 가능해졌습니다. - Virtual Threads (Java 21~): Kotlin coroutines가 풀어주던 동시성 문제를 JVM 레벨에서 해결합니다.
- Text Blocks, var, Switch Expressions: 전반적인 장황함(verbosity)이 크게 줄었습니다.
Spring Boot 4도 이 흐름에 맞춰 Java 25를 지원하면서, JSpecify 기반 null safety 어노테이션을 플랫폼 전반에 도입했습니다. Spring Framework 7에서는 기존의 @Nullable, @NonNull 등이 deprecated 되고 JSpecify 어노테이션으로 대체되었습니다. 컴파일 타임 null safety는 아직 Kotlin에 못 미치지만, IDE(IntelliJ IDEA 2025.3+)와 NullAway 같은 정적 분석 도구 수준에서는 실질적인 보호막이 됩니다.
Lombok은 이제 내려놓을 때#
Lombok의 공이 큽니다. @Data 하나로 수십 줄의 boilerplate를 없앤 건 혁명적이었습니다. 하지만 Records가 언어 표준으로 들어온 지금, Lombok을 유지해야 할 이유가 점점 줄고 있습니다.
Dan Vega가 정리한 Lombok의 문제점들이 핵심을 잘 짚습니다:
- Lombok은 표준 annotation processing이 아니라 컴파일러 내부 API(
com.sun.tools.javac)를 해킹하여 AST를 직접 조작합니다. JDK 마이너 업데이트에서도 깨질 수 있습니다. - 디버깅할 때 소스 코드에 존재하지 않는 메서드를 추적해야 합니다. “작성한 코드가 실행되는 코드가 아닌” 상황이 발생합니다.
- MapStruct, QueryDSL, JPA Buddy 등 다른 annotation processor와의 충돌이 빈번합니다.
nipafx(Nicolai Parlog)는 더 근본적인 차이를 지적합니다. Lombok의 @Data는 “코드를 생성해주는 도구"이지만, Java Records는 “이 타입은 투명한 불변 데이터 캐리어"라는 의미론적 선언입니다. 이 의미론적 보장 덕분에 Records는 pattern matching의 destructuring 대상이 될 수 있고, Lombok 클래스는 될 수 없습니다.
그래도 Kotlin이 여전히 나은 점#
공정하게 말하면, Kotlin이 아직 앞서는 영역이 있습니다:
- 컴파일 타임 null safety: Java의 JSpecify는 도구 지원이지, 언어 수준의 보장은 아닙니다.
- Extension functions: Java에서는 유틸리티 클래스로 우회해야 합니다.
- Coroutines의 구조적 동시성: Java의 Structured Concurrency는 아직 preview 상태입니다 (JDK 25, JEP 505).
- DSL 지원: 내부 DSL을 만들 때 Kotlin의 표현력이 월등합니다.
그럼에도, 두 언어를 혼용하는 비용(빌드 시간 증가, 상호운용 시 null safety 불일치, 온보딩 복잡도)을 따져보면, 대부분의 엔터프라이즈 백엔드 팀에서는 Java 단일 언어로 통일하는 편이 실익이 크다고 봅니다.
참고 자료
- Spring Boot 4.0 Release Notes — Java 25 지원, HTTP Service Client
- Spring 공식 — “Null-safe applications with Spring Boot 4” — JSpecify null safety 도입
- Dan Vega — “Modern Java: Why You Might Not Need Lombok Anymore”
- nipafx — “Why Java’s Records Are Better Than Lombok’s @Data and Kotlin’s Data Classes”
- JetBrains Blog — Spring Boot 4: Leaner, Safer Apps
2. Spring Boot 4.x — MVC + Virtual Threads#
WebFlux를 쓸 이유가 줄었습니다#
Spring Boot 4는 Spring Framework 7 위에서 동작하며, 모듈화된 auto-configuration, OpenTelemetry 통합 스타터, API Versioning 등 다양한 개선을 가져왔습니다.
그중 MVC 사용자에게 가장 의미 있는 변화는 Virtual Threads와의 자연스러운 통합입니다. spring.threads.virtual.enabled=true 한 줄이면 MVC의 동기식 프로그래밍 모델을 유지하면서도 높은 I/O 동시성을 확보할 수 있습니다. auto-configured HTTP client들도 Virtual Thread를 자동으로 활용합니다.
WebFlux가 필요했던 주된 이유가 “스레드 부족으로 인한 처리량 한계"였다면, Virtual Threads가 그 문제를 MVC 안에서 해결해줍니다. 그리고 리액티브 코드의 스택 트레이스가 얼마나 읽기 어려운지는 한번이라도 디버깅해 본 사람이라면 공감할 것입니다.
물론 수십만 RPS 이상의 극단적 시나리오에서 backpressure가 필수적이거나, R2DBC 같은 리액티브 전용 스택이 핵심인 경우라면 WebFlux가 여전히 적합합니다. 하지만 대부분의 일반적인 비즈니스 서비스에서는 MVC + Virtual Threads가 복잡도 대비 최적의 조합이라고 생각합니다.
AI 도구와의 궁합#
AI가 생성한 코드를 리뷰하는 상황을 떠올려 봅시다. 동기식 코드는 위에서 아래로 읽으면 흐름이 보입니다. 반면 Mono.flatMap(...).switchIfEmpty(...).onErrorResume(...) 체인에서 AI가 실수한 부분을 찾으려면 상당한 리액티브 프로그래밍 경험이 필요합니다. 코드의 명시성이 중요한 이유가 여기에도 있습니다.
참고 자료
- Spring Boot 4.0 Release Notes — 모듈화, Virtual Thread 지원, HTTP Service Client
- Baeldung — “Spring Boot 4 & Spring Framework 7” — AOT 개선, observability, 네이티브 이미지
- spring.io — Spring Boot 4.0.0 릴리스 공지
3. Spring Data JDBC 또는 MyBatis — JPA를 대체하는 두 가지 길#
JPA는 왜 문제인가#
JPA(Hibernate)를 오래 운영해 본 사람이라면 한번쯤 겪어봤을 것입니다:
Dirty Checking의 함정. 트랜잭션 안에서 엔티티 객체의 필드를 바꾸면, 명시적인 save() 호출 없이도 트랜잭션 커밋 시점에 UPDATE가 날아갑니다. 의도한 것이라면 편리하지만, 의도하지 않았다면 추적하기 까다로운 버그의 원인이 됩니다. Vlad Mihalcea(Hibernate 핵심 기여자)조차 “엔티티는 현재 트랜잭션이 해당 엔티티를 수정해야 하는 경우에만 필요하다"고 하며, 읽기 전용 조회에는 DTO projection을 권장합니다.
LazyInitializationException. JPA에서 가장 빈번한 예외 중 하나입니다. Thorben Janssen은 이 예외를 피하기 위해 FetchType을 EAGER로 바꾸는 것이 “또 다른 성능 문제를 야기하며 N+1 select 문제의 가장 흔한 원인"이라고 경고합니다. Open Session in View는 N+1 문제를 숨기면서 DB 커넥션을 오래 점유하는 안티패턴입니다.
jOOQ 공식 문서의 표현이 정곡을 찌릅니다: “lazy loading은 데이터가 필요 없을 때의 불필요한 로딩은 막아주지만, 데이터가 필요할 때는 N+1 문제를 만듭니다.”
AI 시대의 추가적 문제. AI가 생성한 JPA 코드에서 @Transactional 범위 안의 의도치 않은 엔티티 수정은 일반 개발자가 작성한 코드보다 발견하기 어려울 수 있습니다. SQL이 코드에 직접 드러나는 방식이라면, AI가 생성한 쿼리를 그대로 리뷰할 수 있습니다.
대안 1: Spring Data JDBC#
Spring Data JDBC는 Spring Data 프로젝트의 공식 모듈이면서도, JPA와는 근본적으로 다른 철학을 갖고 있습니다. 공식 문서가 이를 명확히 밝힙니다:
“If you load an entity, SQL statements get run. Once this is done, you have a completely loaded entity. No lazy loading or caching is done.”
“If you save an entity, it gets saved. If you do not, it does not. There is no dirty tracking and no session.”
— Spring 공식 문서, Why Spring Data JDBC?
이 철학이 가져다주는 이점은 분명합니다:
- No Session, No Proxy: 로드한 객체는 그냥 POJO입니다. 다른 스레드로 넘기든, JSON으로 직렬화하든, detached entity 에러를 걱정할 필요가 없습니다.
- Java Records와의 궁합: JPA 엔티티는 기본적으로 mutable이어야 하므로 Records와 잘 맞지 않습니다. Spring Data JDBC에서는 Records를 자연스럽게 활용할 수 있습니다.
- DDD Aggregate Root 강제: Repository는 Aggregate Root에 대해서만 존재합니다. 무분별한 엔티티 간 연관 탐색을 방지하고, bounded context를 자연스럽게 지키게 됩니다.
단점도 있습니다. 양방향 연관관계나 cascade를 자동으로 처리하지 않으므로 직접 쿼리를 작성해야 하고, first-level cache가 없어서 동일 엔티티를 반복 조회하면 매번 DB를 호출합니다.
대안 2: MyBatis#
MyBatis는 특히 한국과 일본 개발 커뮤니티에서 높은 점유율을 가지고 있고, 그 이유가 있습니다.
강점:
- XML/어노테이션 기반의 완전한 SQL 제어: 개발자가 작성한 SQL이 그대로 실행됩니다. 복잡한 동적 쿼리를
<if>,<choose>,<foreach>등의 태그로 유연하게 구성할 수 있습니다. - 레거시 스키마 대응: 정규화되지 않은 복잡한 스키마, 여러 테이블을 조인해야 하는 리포트성 쿼리 등에서 JPA보다 훨씬 자연스럽습니다.
- 결과 매핑의 유연성:
ResultMap으로 복잡한 계층 구조를 자유롭게 매핑할 수 있습니다. 조인 결과를 원하는 형태의 DTO로 직접 매핑하는 것이 직관적입니다. - 학습 곡선: SQL을 아는 개발자라면 즉시 생산성을 낼 수 있습니다.
약점:
- Spring Data 추상화 미적용: Spring Data JDBC처럼
CrudRepository인터페이스를 사용하는 패턴이 아니므로, Spring Data의 일관된 프로그래밍 모델에서 벗어납니다. - XML 관리 오버헤드: 대규모 프로젝트에서 XML mapper 파일이 늘어나면 관리가 번거로워질 수 있습니다. 어노테이션 기반으로 전환하면 이 문제를 줄일 수 있지만, 복잡한 쿼리에서는 XML이 더 읽기 편한 경우가 많습니다.
- 타입 안전성 부족: 쿼리가 문자열이므로 컴파일 타임에 오류를 잡기 어렵습니다.
Spring Data JDBC vs MyBatis — 어떤 걸 선택할까#
| 기준 | Spring Data JDBC | MyBatis |
|---|---|---|
| Spring 생태계 일관성 | Repository 인터페이스 기반, Spring Data Commons 공유 | 별도 통합, Spring Boot starter는 있음 |
| 쿼리 작성 방식 | 파생 쿼리 + @Query 어노테이션 |
XML mapper 또는 어노테이션 기반 SQL |
| 복잡한 동적 쿼리 | 상대적으로 제한적 | <if>, <choose>, <foreach> 등으로 유연 |
| DDD 지원 | Aggregate Root 패턴 내장 | 별도로 설계해야 함 |
| 레거시 스키마 대응 | 단순한 매핑에 적합 | 복잡한 스키마에서 강점 |
| Java Records 호환 | 자연스러움 | 가능하지만 추가 설정 필요 |
| 기존 팀 경험 | JPA 경험이 많은 팀에 친숙 (Spring Data 패턴) | SQL 중심 개발 경험이 많은 팀에 친숙 |
결국 둘 다 “SQL이 코드에 보인다"는 공통된 장점을 갖고 있고, JPA의 암묵적 동작들을 제거한다는 목표는 동일합니다. 새로운 마이크로서비스를 처음부터 만들 때는 Spring Data JDBC의 DDD 친화적 설계가 더 잘 맞고, 기존의 복잡한 스키마를 다루거나 동적 쿼리가 핵심인 경우에는 MyBatis가 더 현실적이라고 봅니다. 프로젝트 특성에 따라 혼용해도 무방합니다.
JPA가 여전히 합리적인 경우#
모든 상황에서 JPA를 들어내야 한다는 주장은 아닙니다. 다음과 같은 경우에는 JPA를 유지하는 것이 합리적일 수 있습니다:
- 변경할 수 없는 복잡한 레거시 스키마가 있고, 엔티티 그래프 탐색이 핵심 패턴인 경우
- Second Level Cache가 성능 전략의 핵심인 read-heavy 모놀리스
- DB 벤더 독립성이 반드시 필요한 경우
참고 자료
4. @HttpExchange — HTTP 클라이언트 통일#
파편화된 HTTP 클라이언트 정리#
오래 운영된 프로젝트에서는 HTTP 클라이언트가 뒤섞이는 경우가 흔합니다. 초기에 만든 서비스는 RestTemplate, 나중에 만든 것은 WebClient, 외부 API 연동은 Retrofit, Kotlin 서비스는 Ktor client… 각각의 설정 방식과 에러 핸들링 패턴이 다르니 코드 리뷰도 피곤해집니다.
Spring Boot 4는 이 문제에 대한 깔끔한 답을 내놓았습니다. @HttpExchange 기반의 선언적 HTTP Service Client가 프레임워크 네이티브 기능으로 들어왔습니다:
@HttpExchange("/orders")
public interface OrderClient {
@GetExchange("/{id}")
OrderDetails getOrder(@PathVariable long id);
}
인터페이스를 정의하고 @ImportHttpServices로 등록하면, Spring이 런타임에 구현체를 자동 생성합니다. URL 빌딩, JSON 직렬화/역직렬화, 에러 핸들링 모두 프레임워크가 처리합니다. HTTP service group은 기본적으로 RestClient로 구성되며, clientType 속성으로 WebClient로 전환할 수도 있습니다.
AI 시대에 좋아진 이유#
과거에 Java HttpClient를 직접 사용하기 어려웠던 이유 중 하나는, 프록시 설정, 타임아웃, SSL 인증서 같은 세부 설정을 일일이 작성해야 하는 번거로움이었습니다. 그래서 이런 것들을 편하게 추상화해주는 RestTemplate이나 Retrofit이 각광받았습니다.
지금은 그런 boilerplate를 AI가 깔끔하게 생성해줍니다. 게다가 Spring Boot 4에서는 spring.http.client.service.group.* 프로퍼티로 서비스 그룹별 base URL, timeout, SSL 등을 선언적으로 관리할 수 있고, Spring Security 7의 OAuth 지원(@ClientRegistrationId)이나 Spring Cloud 2025.1의 로드밸런싱/서킷브레이킹도 자동 적용됩니다. Feign이나 Retrofit 같은 외부 라이브러리를 유지할 이유가 줄어든 셈입니다.
초기 버전이라는 점에서 edge case 성숙도는 지켜볼 필요가 있지만, Spring 팀이 RestTemplate을 deprecated 방향으로 가고 있고 @HttpExchange에 명확히 투자하고 있으므로, 새로운 코드에서는 이쪽으로 가는 것이 자연스럽습니다.
참고 자료
- Spring 공식 블로그 — “HTTP Service Client Enhancements” — 그룹별 설정, Spring Security/Cloud 통합
- Spring 공식 블로그 — “The state of HTTP clients in Spring”
- Spring Boot 4.0 Release Notes —
@ImportHttpServices, JDK HttpClient + Virtual Thread
5. 종합 비교#
| 영역 | 기존 (일반적인 구성) | 제안 | 핵심 이유 |
|---|---|---|---|
| 언어 | Java + Kotlin 혼용, Lombok | Java 25, No Lombok | 단일 언어 통일, Records + Sealed + Pattern Matching |
| 프레임워크 | Spring Boot 2.x~3.x | Spring Boot 4.x (MVC + Virtual Threads) | 모듈화, 동기식 코드에서 높은 동시성 |
| 데이터 접근 | JPA + QueryDSL | Spring Data JDBC / MyBatis | 명시적 SQL, No Dirty Checking, Immutable Records |
| HTTP 클라이언트 | RestTemplate, Retrofit 등 혼재 | @HttpExchange + JDK HttpClient |
Spring 네이티브, 선언적, 통일된 설정 |
마치며#
이 글에서 다룬 변화들의 공통 키워드는 명시성입니다.
JPA의 dirty checking 대신 SQL이 코드에 보이는 방식. Lombok의 AST 마법 대신 Records의 언어 수준 보장. 리액티브 체인 대신 위에서 아래로 읽히는 동기식 코드. 각기 다른 HTTP 클라이언트 대신 하나의 선언적 인터페이스.
이런 변화들이 과거에는 “개발 생산성을 포기하는 것"으로 느껴졌을 수 있습니다. 하지만 AI 코딩 도구가 boilerplate 생성을 대신해주는 지금, 프레임워크의 “편의 기능"이 가져다주던 가치는 줄어들고 있는 반면, 코드를 빠르게 읽고 검증할 수 있는 능력의 가치는 올라가고 있습니다.
물론 모든 프로젝트에 일괄 적용해야 한다는 뜻은 아닙니다. JPA가 적합한 상황은 여전히 존재하고, Kotlin의 장점도 분명합니다. 중요한 건 기존의 관성이 아니라, 현재의 맥락에서 각 기술 선택의 트레이드오프를 다시 따져보는 것입니다.
새로운 서비스를 설계하거나, 기존 스택을 점검할 타이밍이라면, 한번쯤 이런 관점에서 돌아보는 것도 괜찮지 않을까요.