백엔드 면접 학습 노트 목차

모던 자바·스프링 — 가상 스레드·리액티브·데이터 접근 (면접 심화)

Java 21 이후 백엔드 면접의 지형이 한 번 바뀌었다. "동시성을 어떻게 확보하나"라는 질문의 모범 답안 자체가 달라졌기 때문이다. 예전이라면 "WebFlux로 논블로킹"이 정답에 가까웠지만, 지금 그렇게만 답하면 "왜 굳이?" 라는 되물음이 온다.

이 편은 그 변화 지점을 다룬다 — 가상 스레드, pinningJEP 491, 병목이 어디로 옮겨갔는가, WebFlux를 아직 쓰는 이유, 그리고 JDBC 위에 무엇을 얹을 것인가.

면접관이 노리는 단골 함정 Top: ① "가상 스레드를 켜면 빨라진다"(→ 병목이 커넥션 풀로 옮겨갈 뿐) ② "synchronized는 여전히 pinning된다"(→ Java 24에서 제거) ③ "가상 스레드도 풀로 관리한다"(→ 풀을 만들지 않는다) ④ "WebFlux면 JPA 그대로 쓴다"(→ R2DBC엔 JPA가 없다) ⑤ "Spring Boot는 LTS를 잡고 버틴다"(→ Boot에 LTS는 없다)


1. 가상 스레드 — 무엇이 싸졌는가 (★★ 신규 단골)

나오는 질문: "가상 스레드가 뭔가요? 기존 스레드와 뭐가 다르죠?" · "왜 스레드가 싸졌다고 하나요?" · "톰캣 스레드 풀을 200으로 잡던 이유가 뭐였죠?"

전통적 스레드는 OS 스레드와 1:1이다. 스택으로 ~1MB를 잡고, 전환할 때마다 커널 모드로 넘어가는 컨텍스트 스위치가 든다. 그래서 수천 개를 띄우는 게 부담이었고, 서버는 스레드 풀 크기가 곧 동시 처리 한계였다 — 톰캣 기본 200이 그 숫자다.

가상 스레드(Project Loom, Java 21 정식)는 JVM이 관리하는 스레드다. 진짜 OS 스레드(캐리어 스레드) 위에 마운트되어 돌다가, 블로킹을 만나면 언마운트되면서 자기 스택을 힙으로 옮기고 캐리어를 놓아 준다. 그 캐리어에는 다른 가상 스레드가 올라탄다.

다이어그램 로딩 중…

핵심은 "빨라졌다"가 아니라 "기다리는 비용이 사라졌다" 이다. CPU 연산은 하나도 빨라지지 않는다. 다만 I/O를 기다리는 동안 OS 스레드를 붙잡지 않으므로, 블로킹 코드를 그대로 쓰면서 수만 동시성을 얻는다.

// 풀을 만들지 않는다 — 작업마다 새 가상 스레드를 만드는 게 정상이다
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var id : userIds) {
        executor.submit(() -> repository.find(id));   // 평범한 블로킹 코드
    }
}

Spring Boot에서는 스위치 하나다.

spring:
  threads:
    virtual:
      enabled: true      # Java 21+ 필요. 톰캣 요청 처리·@Async·스케줄러가 가상 스레드로

깊이 — 왜 "풀을 만들지 마라"인가(★). 스레드 풀의 존재 이유는 생성이 비싸서 재사용하려는 것이었다. 가상 스레드는 생성이 싸므로 재사용할 이유가 없다. 오히려 풀로 묶으면 풀 크기가 다시 동시성 상한이 되어 이점을 스스로 없앤다. 그래서 Executors.newFixedThreadPool에 가상 스레드 팩토리를 끼우는 건 안티패턴이다.

깊이 — 짝을 이루는 두 기능(★). 가상 스레드 하나만 놓고 외우면 꼬리질문에서 막힌다. 함께 설계된 기능이 둘 있다.

  • 구조적 동시성(Structured Concurrency) — 동시에 띄운 자식 작업들의 수명을 부모 블록 안에 가둔다. 블록을 벗어나면 남은 자식이 자동으로 취소되므로 고아 작업(부모가 실패했는데도 계속 도는 작업)이 구조적으로 불가능해진다. 예외도 부모로 모여 스택 트레이스가 이어진다.
try (var scope = StructuredTaskScope.open()) {
    var user  = scope.fork(() -> fetchUser(id));
    var order = scope.fork(() -> fetchOrder(id));
    scope.join();                                  // 둘 다 끝날 때까지 대기
    return combine(user.get(), order.get());
}   // 블록을 벗어나면 살아남은 자식 작업은 자동 취소
  • Scoped ValuesThreadLocal을 대신해 요청 컨텍스트를 흘려보낸다. 블록 범위에서만 살아 있고 자동으로 해제되며 불변이다. 왜 이게 가상 스레드에서 특히 중요한지는 3번에서 다시 나온다.

구조적 동시성은 Java 26 기준 아직 프리뷰라 버전별로 API 시그니처가 다르다는 점은 알고 있어야 한다.

꼬리질문: "가상 스레드를 쓰면 CPU 바운드 작업도 빨라지나요?" → 아니다. I/O 대기 비용만 없앤다. CPU 바운드는 코어 수가 한계이므로 오히려 플랫폼 스레드 + 적정 풀이 맞다. / "톰캣 스레드 풀 설정은 이제 의미 없나요?" → 가상 스레드를 켜면 요청 처리 스레드에 대한 풀 설정이 무의미해진다(SimpleAsyncTaskExecutor가 풀 관련 속성을 무시한다).

함정: ❌ "가상 스레드 = 비동기" → 아니다. 동기 블로킹 코드를 그대로 쓰기 위한 기능이다. 코드는 여전히 위에서 아래로 읽힌다.


2. pinning과 JEP 491 — 버전을 아는지 가르는 질문 (★★ 심화)

나오는 질문: "가상 스레드에서 synchronized를 쓰면 왜 문제가 되나요?" · "그 문제는 지금도 그런가요?" · "ReentrantLock으로 바꾸라는 조언을 들었는데 왜죠?"

이건 어느 버전 기준으로 공부했는지가 그대로 드러나는 질문이다.

Java 21~23의 상황. 가상 스레드가 언마운트하려면 자기 상태를 안전하게 힙으로 옮길 수 있어야 한다. 그런데 synchronized모니터 소유권이 캐리어(OS 스레드)의 정체성에 묶여 있었다. 그 안에서 블로킹하면 소유권을 옮길 방법이 없으니 캐리어에 **고정(pinning)**된다. 고정된 캐리어는 블로킹이 끝날 때까지 다른 가상 스레드를 받지 못한다.

// 라이브러리 내부에 흔한 패턴 — 내가 쓴 코드가 아니어도 걸린다
synchronized (pool) {
    return dataSource.getConnection();   // 여기서 블로킹 → 21~23에서는 캐리어에 고정
}

문제는 이게 내 코드의 문제가 아니었다는 점이다. JDBC 드라이버·커넥션 풀·로깅 프레임워크 내부에 synchronized가 널려 있어서, 내가 안 썼는데도 부하가 조금만 올라가면 캐리어가 말라붙었다. 그래서 당시 처방이 ReentrantLock으로 바꾸는 것이었다 — 모니터가 아니라 일반 객체 상태로 락을 표현하므로 언마운트가 가능하다.

당시에는 이걸로 관측했다.

java -Djdk.tracePinnedThreads=full -jar app.jar
Thread[#28,ForkJoinPool-1-worker-2,5,CarrierThreads]
    java.base/java.lang.VirtualThread$VThreadContinuation.onPinned
    app//com.zaxxer.hikari.pool.HikariPool.getConnection  <== monitors:1
                                                              ^^^^^^^^^^
                                          모니터를 쥔 채 블로킹했다는 표시

여기에 남의 라이브러리 이름이 찍히면 손쓸 방법이 없었다. JEP 491이 판을 바꾼 이유가 이것이다 — 생태계 전체가 바뀌기를 기다리는 대신 JVM 한 곳에서 해결했다.

Java 24의 JEP 491이 이 문제를 없앴다. JVM 모니터 구현을 다시 써서, 모니터 소유권을 캐리어가 아니라 가상 스레드 자신의 정체성으로 추적한다. 이제 synchronized 블록 안에서 언마운트되고, Object.wait()로 대기했다가 모니터를 다시 잡아도 캐리어를 붙잡지 않는다.

그래서 "synchronizedReentrantLock 리팩터링"은 더 이상 pinning 회피 목적으로는 필요 없다. tryLock·Condition·공정성 같은 기능이 필요할 때만 쓴다.

깊이 — 그래도 남는 pinning 셋(★). 여기까지 답하면 확실히 구분된다.

남은 pinning이유실무 체감
네이티브 프레임(JNI·FFM)네이티브 코드가 raw 스택 포인터를 쥘 수 있어 안전한 언마운트 불가암호화 프로바이더·특수 I/O 라이브러리
클래스 로딩·정적 초기화클래스 초기화 중에는 언마운트 불가. 우회법 없음기동 직후 한 번, 운영 중엔 드묾
리눅스 로컬 파일 I/OJDK에 프로덕션 수준 io_uring 통합이 아직 없음대량 파일 읽기·쓰기 배치

즉 "웹 요청 → DB·HTTP 호출"이 대부분인 서버에서 pinning은 사실상 사라졌다.

꼬리질문: "그럼 Java 21에서 가상 스레드를 쓰면 안 되나요?" → 쓸 수 있지만, -Djdk.tracePinnedThreads=full로 pinning을 관측하며 라이브러리를 골라야 했다. 24 이상이면 그 부담이 없다. / "ReentrantLock으로 다 바꿔 둔 코드는 되돌려야 하나요?" → 아니다. 동작에 문제가 없으니 굳이 되돌릴 필요는 없고, 새로 쓸 때 굳이 안 바꿔도 된다가 요점이다.

함정: ❌ "가상 스레드에서는 synchronized를 쓰면 안 된다" → Java 23 이하 기준의 옛 조언이다. 버전을 함께 말해야 정확한 답이 된다.


3. 켰는데 왜 안 빨라지죠 — 병목의 이동 (★★★ 실무 최다)

나오는 질문: "가상 스레드를 켰는데 처리량이 기대만큼 안 오릅니다. 어디를 보시겠어요?" · "그럼 커넥션 풀을 키우면 되나요?"

이 질문이 가상 스레드 주제의 진짜 본론이다. 실제 측정이 상황을 정확히 보여준다 — 동시 사용자 500명·60초 JDBC 워크로드에서,

지표플랫폼 스레드가상 스레드
처리량624 req/s1,539 req/s (+147%)
에러율37%37% (동일)

처리량은 크게 올랐는데 에러율은 한 톨도 안 줄었다. 원인은 커넥션 풀 포화다. 스레드 모델의 천장을 없앴을 뿐, 병목을 없앤 게 아니라 옮긴 것이다.

다이어그램 로딩 중…

깊이 — 스레드 풀은 사실 브레이크였다(★★). 예전에 "톰캣 풀 200"은 단순한 성능 설정이 아니라 암묵적인 동시 요청 상한이었다. DB에 동시에 갈 수 있는 요청도 200건이 최대였다는 뜻이다. 가상 스레드는 그 브레이크를 없앴으므로, 이제 자원마다 상한을 직접 걸어야 한다.

// 스레드 풀이 대신 해 주던 "동시 실행 상한"을 자원 앞에 명시적으로 건다
private static final Semaphore DB_PERMITS = new Semaphore(50);

String findUser(String id) throws InterruptedException {
    DB_PERMITS.acquire();
    try {
        return repository.find(id);
    } finally {
        DB_PERMITS.release();
    }
}

"커넥션 풀을 키우면 되나요?"에 대한 답은 아니오다. 풀 크기의 적정값은 애플리케이션이 아니라 DB가 정한다 — 커넥션이 많아지면 DB 쪽 컨텍스트 스위칭·락 경합으로 오히려 느려진다. 시작점은 흔히 (코어 수 × 2) + 디스크 수 수준의 작은 값이고, 인스턴스당 50을 넘기면 병목이 DB로 옮겨갈 뿐이다. 대신 connectionTimeout을 3~5초로 짧게 잡아야 한다 — 기본 30초면 5,000개가 줄 선 상태에서 장애가 30초 뒤에야 드러나고, 그 사이 서킷브레이커가 제 역할을 못 한다.

깊이 — ThreadLocal이 조용히 손해가 된다(★). 플랫폼 스레드는 풀에서 재사용되니 ThreadLocal 캐시도 스레드 수만큼만 만들어진다. 가상 스레드는 요청마다 만들고 버리므로 그 캐시가 요청마다 다시 만들어진다. 같은 60초 워크로드에서 초기화 횟수가 200회 → 443,267회로 약 2,200배가 된 측정이 있다. 예외도 경고도 없이 GC 압력만 늘어서, 일부러 찾아보기 전에는 안 보인다. Java 25에서 정식화된 **Scoped Values**가 그 대안이다.

꼬리질문: "가상 스레드 전환 전에 무엇을 먼저 점검하겠어요?" → ① 무거운 객체를 담은 ThreadLocal ② 커넥션 풀·외부 API rate limit 등 다운스트림 상한 ③ (Java 23 이하라면) 라이브러리의 synchronized pinning. / "MSA에서 가상 스레드를 켜면 하위 서비스에 어떤 영향이 있나요?" → 상류의 브레이크가 풀려 하위 서비스로 가는 요청량이 급증한다. 벌크헤드·rate limit이 없으면 장애가 하류로 전파된다.

함정: ❌ "가상 스레드를 켰으니 커넥션 풀도 크게" → 정확히 반대다. 풀 크기는 DB 능력에 묶이고, 늘어난 건 대기 줄뿐이다.


4. WebFlux를 아직 쓰나요 — 비교 질문 (★★ 단골)

나오는 질문: "WebFlux가상 스레드 중 무엇을 고르시겠어요? 이유는?" · "리액티브의 장점이 뭔가요?" · "그럼 WebFlux는 이제 안 쓰나요?"

"죽었다"가 아니라 "기본 선택지에서 내려왔다"가 정확한 답이다.

리액티브의 원래 매력은 적은 스레드로 많은 I/O 동시성이었다. 그 대가로 Mono/Flux 체인, 어려운 스택 트레이스, 한 줄만 블로킹해도 무너지는 규율을 받아들여야 했다. 가상 스레드가 그 이득을 대가 없이 주면서 교환식이 깨졌다.

벤치마크도 이를 뒷받침한다 — DB·HTTP 바운드 워크로드에서 둘의 처리량은 통계적으로 거의 같고(5~8% 이내), 가상 스레드가 코드 단순함과 디버깅에서 이긴다. 같은 기능을 두 방식으로 써 보면 그 차이가 바로 보인다.

// WebFlux — 값이 아니라 파이프라인을 조립한다
@GetMapping("/orders/{id}")
public Mono<OrderView> get(@PathVariable Long id) {
    return orderRepo.findById(id)
        .flatMap(o -> userClient.fetch(o.userId())        // 중첩되기 시작한다
            .map(u -> new OrderView(o, u)))
        .switchIfEmpty(Mono.error(new NotFoundException()));
}

// 가상 스레드 — 평범한 블로킹 코드 그대로
@GetMapping("/orders/{id}")
public OrderView get(@PathVariable Long id) {
    Order o = orderRepo.findById(id).orElseThrow(NotFoundException::new);
    User u = userClient.fetch(o.userId());
    return new OrderView(o, u);
}

두 번째는 디버거로 한 줄씩 따라갈 수 있고 스택 트레이스에 호출 경로가 그대로 남는다. 처리량이 비슷하다면 이 차이가 곧 유지보수 비용이다.

그래도 WebFlux가 맞는 경우는 분명히 있다.

상황왜 리액티브인가
스트리밍·SSE·WebSocket"요청 하나 → 응답 하나"가 아니라 흐름이 본질
백프레셔가 실제로 필요생산자가 소비자보다 빠를 때 request(n)으로 흐름을 거꾸로 제어
외부 API를 대량 합성여러 호출을 병렬로 엮고 타임아웃·재시도를 조합하는 게 게이트웨이의 본업
이미 리액티브인 시스템스택 전체가 논블로킹으로 짜여 있고 R2DBC·리액티브 Kafka를 이미 쓴다

깊이 — 백프레셔는 가상 스레드가 대체하지 못한다(★). 가상 스레드는 동시성 비용 문제를 풀었지, 흐름 제어 문제를 푼 게 아니다. 생산자가 소비자보다 빠를 때 "N개만 더 보내라"고 역방향으로 요구하는 메커니즘은 리액티브 스트림 규격에만 있다. 그래서 대용량 스트림 파이프라인에서는 여전히 리액티브가 답이다. 반대로 "평범한 CRUD API인데 동시성이 필요해서" 라면 그건 이제 오버엔지니어링으로 본다.

꼬리질문: "가상 스레드가 있으니 WebFlux를 걷어내야 할까요?" → 이미 잘 도는 시스템을 굳이 되돌릴 이유는 없다. 신규 선택의 기본값이 바뀐 것이지, 마이그레이션을 강제하는 변화가 아니다. / "둘을 한 앱에서 섞을 수 있나요?" → 가능하지만 권하지 않는다. 특히 데이터 접근 계층이 갈리면(아래 5번) 복잡도가 급격히 는다.

함정: ❌ "고동시성 = 리액티브" → 옛 공식이다. ❌ "리액티브는 CPU를 더 잘 쓴다" → 아니다. 스레드 자원 효율의 문제였고, 처리 속도 자체가 빨라지는 게 아니다.


5. 리액티브를 택하면 JPA를 잃는다 — R2DBC (★ 심화)

나오는 질문: "WebFlux를 쓰면 DB는 어떻게 접근하나요?" · "R2DBC를 써 보셨나요? JDBC와 뭐가 다르죠?"

"끝까지 논블로킹"이라는 전제는 DB에서 가장 비싸게 청구된다. JDBC는 태생이 블로킹 API라 리액티브 파이프라인에 넣을 수 없다. 그래서 R2DBC(Reactive Relational Database Connectivity)가 나왔다 — 관계형 DB를 논블로킹으로 접근하는 별도 스펙이고, 행을 Publisher로 흘려보낸다.

문제는 R2DBC가 "JDBC의 리액티브 버전"이 아니라 다른 생태계라는 점이다.

잃는 것구체적으로
JPA/Hibernate 전체R2DBC에는 JPA 구현이 없다. 영속성 컨텍스트·변경 감지·지연 로딩·연관관계 매핑이 사라지고, Spring Data R2DBC의 얕은 매핑을 쓰거나 손으로 매핑한다
마이그레이션 도구의 매끄러움Flyway·Liquibase가 JDBC 기반이라 별도 설정·우회가 필요하다
혼용의 편의한 앱에서 JPA와 R2DBC를 같이 쓰면 자동 설정이 서로를 비활성화해 명시적으로 풀어야 한다
익숙한 트랜잭션 감각트랜잭션이 스레드가 아니라 리액터 컨텍스트를 타고 흐른다. @Transactional의 동작 방식 자체가 다르다

반환 타입이 바뀌면 그 위가 전부 따라 바뀐다는 것도 비용이다.

// JDBC + JPA — 값을 반환한다
public User find(Long id) {
    return userRepository.findById(id).orElseThrow(NotFoundException::new);
}

// R2DBC — 값이 아니라 "값이 올 파이프라인"을 반환한다
public Mono<User> find(Long id) {
    return userRepository.findById(id)
        .switchIfEmpty(Mono.error(new NotFoundException()));
}

UserMono<User>가 되면 그 위의 서비스·컨트롤러가 전부 바뀐다 — 일부만 리액티브로 만들 수 없다. 그리고 표의 마지막 줄이 특히 위험하다. 전통 방식에서는 트랜잭션이 ThreadLocal에 담겨 "이 스레드가 곧 이 트랜잭션"이었는데, 리액티브에서는 한 요청이 여러 스레드를 옮겨 다니므로 그 전제가 성립하지 않는다. 체인 중간에서 block()을 부르거나 따로 subscribe()하면 그 작업은 트랜잭션 밖에서 돈다 — 컴파일도 되고 예외도 안 나서, 롤백이 안 되는 걸 운영에서야 알게 되는 유형의 사고다.

깊이 — 이게 WebFlux 채택률이 꺾인 진짜 이유다(★★). 흔히 "Mono/Flux가 어려워서"라고 말하지만, 팀을 실제로 되돌린 건 대개 데이터 계층이었다. N+1을 설명할 줄 아는 개발자가 영속성 컨텍스트를 통째로 포기하는 선택을 하려면 그만한 이득이 있어야 하는데, 가상 스레드가 그 이득을 대신 주면서 근거가 사라졌다.

꼬리질문: "그럼 R2DBC는 언제 쓰나요?" → 스트리밍이 본질이고 스택 전체가 이미 논블로킹이며, ORM 없이 살 수 있는 단순한 매핑일 때. / "WebFlux에서 JDBC를 그냥 쓰면 안 되나요?" → 이벤트 루프 스레드가 블로킹되어 전체 처리량이 폭락한다. 꼭 해야 하면 boundedElastic 스케줄러로 격리해야 하는데, 그 시점에 리액티브를 쓰는 의미가 크게 준다.

함정: ❌ "WebFlux + JPA" → 성립하지 않는 조합이다. 면접에서 이렇게 답하면 리액티브를 실제로 안 써 봤다는 게 바로 드러난다.


6. JDBC 위에 무엇을 얹나 — 데이터 접근 지도 (★ 단골 확장)

나오는 질문: "JDBC는 요즘도 쓰나요?" · "JPA·MyBatis·jOOQ 중에 뭘 고르시겠어요?" · "읽기 성능이 중요한 API라면요?"

"안 쓴다"가 아니라 "직접 호출하지 않는다"가 정확한 답이다. JPA/Hibernate·MyBatis·JdbcTemplate·jOOQ·Spring Data JDBC가 전부 그 아래에서 JDBC를 호출한다. 커넥션 풀(HikariCP)도 JDBC 스펙 위에 있다. 그래서 N+1이든 커넥션 고갈이든, 사고가 나면 원인은 늘 한 층 아래에 있다.

다이어그램 로딩 중…
도구성격강점
JPA/Hibernate객체 중심 ORM영속성 컨텍스트·변경 감지·생산성러닝커브·N+1·복잡 통계 쿼리에 약함
MyBatisSQL 매퍼SQL 직접 제어·동적 쿼리DB 종속·매퍼 보일러플레이트
JdbcTemplate얇은 래퍼SQL 직접 + 보일러플레이트 제거매핑을 직접 씀
jOOQ타입 세이프 SQL DSL스키마에서 코드를 생성해 컬럼명·타입 오류를 컴파일 때 잡음·DB 고유 문법 표현 가능코드 생성 단계 필요·상용 라이선스(일부 DB)
// jOOQ: 오타난 컬럼이나 타입 불일치가 컴파일 단계에서 걸린다
List<OrderSummary> rows = dsl
    .select(ORDERS.USER_ID, count(), sum(ORDERS.AMOUNT))
    .from(ORDERS)
    .where(ORDERS.CREATED_AT.ge(from))
    .groupBy(ORDERS.USER_ID)
    .having(count().gt(3))
    .fetchInto(OrderSummary.class);

깊이 — 하나만 고르는 문제가 아니다(★★). 요즘 답으로 좋은 평가를 받는 건 경로별 분리다.

  • 쓰기 경로 → JPA/Hibernate. 영속성 컨텍스트·변경 감지·엔티티 매핑이 실제로 값을 한다.
  • 읽기 경로 → jOOQ나 JdbcTemplate. 통계·리포트·다중 조인처럼 쿼리 모양과 성능을 직접 통제해야 하는 곳에서는, ORM이 만든 SQL을 뒤에서 고치는 것보다 처음부터 쓰는 게 싸다.

대부분의 서비스가 읽기 9 : 쓰기 1이라는 점을 생각하면 자연스러운 분리이고, CQRS라는 이름을 붙이지 않아도 성립한다. 밑바닥이 같은 JDBC라 커넥션 풀·트랜잭션 경계 이야기는 그대로 적용된다는 점까지 덧붙이면 좋다.

꼬리질문: "jOOQ의 타입 안전성QueryDSL과 뭐가 다른가요?" → 둘 다 컴파일 타임 검증을 주지만, QueryDSL은 JPA 엔티티에서, jOOQ는 DB 스키마에서 코드를 생성한다. 즉 jOOQ는 엔티티 없이 SQL 자체를 타입 안전하게 쓰는 쪽이다. / "그냥 네이티브 쿼리를 쓰면 안 되나요?" → 되지만 문자열이라 컬럼명 오타·타입 불일치가 런타임에야 드러난다.

함정: ❌ "JPA를 쓰면 SQL을 몰라도 된다" → 오히려 SQL 이해가 성능(N+1·페치 전략)의 전제다. ❌ "JDBC는 레거시" → 모든 상위 도구의 토대다.


7. 버전 질문 — Java와 Spring Boot의 현재 (★ 마무리 단골)

나오는 질문: "최근에 쓴 Java 버전이 어떻게 되나요?" · "Spring Boot 버전 업그레이드를 해 보셨나요?" · "왜 그 버전을 골랐죠?"

Java. LTS는 2년 주기다.

버전위치
21 LTS (2023-09)현재 가장 널리 쓰이는 주류. 가상 스레드가 여기서 정식화
25 LTS (2025-09)신규 프로젝트 기본값. Scoped Values 정식·Compact Object Headers 프로덕션·AOT 캐시 완성
26 (2026-03)non-LTS. 다음 LTS의 예고편
17 LTS (2021-09)아직 많지만 감소 중

Spring Boot — LTS가 없다. 6개월마다 마이너가 나오고 오픈소스 지원은 12~13개월이다. "LTS를 잡고 몇 년 버틴다"는 전략 자체가 없어서, 올라가지 않는 것이 곧 부채다.

버전출시OSS 지원 종료
4.12026-062027-07
4.02025-112026-12
3.52025-052026-06 (종료)

Spring Boot 4는 Spring Framework 7 + Jakarta EE 11 기반이고, 베이스라인은 여전히 Java 17이다(Java 25 1급 지원). 2.7 → 3.0 때의 javaxjakarta 같은 전면 개명은 없다. 대신 성격이 다른 변화가 왔다.

  • 모듈화 — jar가 잘게 쪼개져, 예전에 묻어 들어오던 자동 설정이 안 딸려온다. 업그레이드에서 "되던 게 안 되는" 대부분의 원인이고, 해법은 의존성을 명시적으로 선언하는 것이다.
  • API 버저닝@GetMapping(value = "/products", version = "2")처럼 프레임워크 기능이 됐다. 경로를 갈라 컨트롤러를 복사할 필요가 없다.
  • HTTP Service Client@HttpExchange 인터페이스 + @ImportHttpServices로 선언형 REST 클라이언트를 만든다.
  • JSpecify 널 안전성@NullMarked를 걸면 기본이 non-null이 되고 널 가능한 것만 @Nullable로 표시한다.
  • @Retryable·@ConcurrencyLimit 내장 — 재시도·동시 실행 제한이 외부 라이브러리 없이 들어왔다.
  • Spring Data AOT — 리포지토리 쿼리 메서드를 빌드 시점에 실제 소스 코드로 생성해, 기동할 때 리플렉션으로 쿼리를 파싱하지 않게 한다(기동 시간·메모리 동시 절감). 프리뷰 기능이라 spring.aot.repositories.enabled=true로 직접 켜야 하고, 현재 JPA·MongoDB만 지원한다.
  • 4.1의 lazy JDBC connectionspring.datasource.connection-fetch=lazy를 주면 LazyConnectionDataSourceProxy로 감싸, 트랜잭션 시작이 아니라 첫 JDBC 문장 시점에 커넥션을 빌린다. 3번에서 본 커넥션 점유 시간 문제에 직접 듣는 변화다.

깊이 — 업그레이드에서 실제로 부딪히는 건 모듈화다(★★). 버전 숫자를 외우는 것보다 이 경험을 말할 수 있는지가 갈린다.

3.x 에서 잘 되던 앱을 4.x 로 올렸더니
  Flyway 마이그레이션이 안 돈다 / H2 콘솔이 안 뜬다 / 특정 Actuator 엔드포인트가 없다
       ↓
버그가 아니라, 다른 스타터에 묻어 오던 자동 설정이 이제 안 딸려오는 것
java -jar app.jar --debug                    # 조건 평가 리포트
curl localhost:8080/actuator/conditions      # 실행 중인 앱에서

리포트의 Negative matches@ConditionalOnClass 실패로 찍히면 답은 하나다 — 해당 스타터를 명시적으로 선언한다(spring-boot-starter-flyway 등). "자동 설정이 왜 안 붙었는지 리포트로 확인했다" 까지 답하면 Boot의 동작 원리를 아는 사람으로 읽힌다.

꼬리질문: "3.x에서 4.x로 올린다면 순서를 어떻게 잡겠어요?" → ① 3.5 최신 패치까지 올려 deprecation 경고를 0으로 ② Java 17 이상 확인 ③ 4.x로 올리며 사라진 암묵적 의존성을 조건 평가 리포트(--debug·/actuator/conditions)로 하나씩 채운다. / "왜 21이 아니라 25인가요?" → LTS 잔여 기간이 길고, Scoped Values·Compact Object Headers·AOT 캐시가 정식이라 운영 이점이 있다.

함정: ❌ "Spring Boot도 LTS를 쓰면 된다" → Boot에 LTS는 없다. ❌ "Boot 4는 Java 21 이상 필수" → 베이스라인은 17이다.


흔한 오답·함정 정리

  • 가상 스레드를 켜면 빨라진다기다리는 비용만 사라진다. 병목은 커넥션 풀·rate limit으로 옮겨간다.
  • 가상 스레드에서 synchronized 금지 → Java 23 이하 기준의 옛 조언. 24의 JEP 491에서 제거됐다.
  • 가상 스레드도 풀로 관리 → 풀을 만들면 그 크기가 다시 상한이 된다. 작업마다 새로 만든다.
  • 가상 스레드 = 비동기 → 아니다. 동기 블로킹 코드를 그대로 쓰기 위한 기능이다.
  • CPU 바운드도 가상 스레드로 → 이득 없다. 코어 수가 한계다.
  • WebFlux + JPA → 성립하지 않는다. 리액티브를 택하면 R2DBC이고, JPA는 없다.
  • 고동시성 = 리액티브 → 옛 공식. 지금 기본값은 가상 스레드다.
  • JDBC는 레거시 → 모든 상위 도구의 토대다. "직접 호출하지 않을 뿐"이다.
  • Spring Boot LTS → 존재하지 않는다. 마이너 12~13개월 지원이 전부다.

한국 면접 단골 Q&A (답변 골격)

질문핵심 답
가상 스레드JVM 관리 스레드·블로킹 시 캐리어에서 언마운트·수만 동시성
기존 스레드와 차이OS 스레드 1:1(~1MB·커널 전환) vs 힙에 스택 보관·생성 저렴
왜 풀을 안 만드나생성이 싸서 재사용 이유 없음·풀 크기가 다시 상한이 됨
pinning모니터 소유권이 캐리어에 묶여 언마운트 불가(21~23)
JEP 491Java 24·모니터 소유권을 가상 스레드 정체성으로 추적 → 제거
남은 pinning네이티브 프레임·클래스 초기화·리눅스 파일 I/O
켰는데 안 빨라짐병목 이동 — 커넥션 풀 포화(처리량 +147%, 에러율 동일)
풀을 키우면?아니오. 크기는 DB가 정함 → connectionTimeout 단축·Semaphore
ThreadLocal 문제요청마다 재생성(측정상 약 2,200배) → Scoped Values
WebFlux vs 가상 스레드I/O 동시성만이면 가상 스레드·스트리밍/백프레셔면 리액티브
WebFlux가 남는 곳SSE/WebSocket·백프레셔·API 대량 합성·기존 리액티브
R2DBC논블로킹 DB 스펙·JPA 없음·Flyway 우회·트랜잭션이 컨텍스트를 탐
JDBC는 쓰나직접 호출만 안 함. JPA·MyBatis·jOOQ 전부 그 위
JPA vs jOOQ쓰기는 JPA·읽기는 jOOQ/JdbcTemplate 경로별 분리
jOOQ vs QueryDSLjOOQ는 DB 스키마 기반·QueryDSL은 JPA 엔티티 기반
Java 버전21 주류·25 신규 기본·26 non-LTS·17 감소
Spring Boot 버전LTS 없음·6개월 주기·12~13개월 지원. 4.x = Framework 7
Boot 4 핵심모듈화·API 버저닝·HTTP Service Client·JSpecify·Data AOT
Boot 3→4 업그레이드3.5 경고 0 → Java 17+ → 조건 리포트로 사라진 의존성 채우기

꼬리질문 대비 (상 난이도)

  • "가상 스레드 전환 전 체크리스트를 말해 보세요" → ① 무거운 객체를 담은 ThreadLocal ② 커넥션 풀·외부 API rate limit 등 다운스트림 상한 ③ Java 23 이하라면 라이브러리 pinning ④ 관측 지표(풀 대기 시간·큐 길이) 확보.
  • "MSA에서 가상 스레드를 켜면 하위 서비스는요?" → 상류 브레이크가 풀려 하위로 가는 요청량이 급증한다. 벌크헤드·서킷브레이커·rate limit이 없으면 장애가 하류로 전파된다.
  • "pinning이 실제로 일어나는지 어떻게 확인하나요?"-Djdk.tracePinnedThreads, JFR의 관련 이벤트, 캐리어 스레드 수 대비 처리량 정체 관측.
  • "가상 스레드 환경에서 트랜잭션 경계는 어떻게 잡나요?" → 원칙은 같지만 커넥션 점유 시간이 더 중요해진다. 트랜잭션 안에서 외부 API를 호출하지 않는 것이 핵심이고, Boot 4.1의 lazy connection이 이를 구조적으로 돕는다.
  • "리액티브에서 블로킹 호출 한 줄이 왜 치명적인가요?" → 이벤트 루프 스레드는 코어 수 정도로 소수다. 하나가 멈추면 그 위의 모든 요청이 멈추고, 몇 개 겹치면 처리량이 폭락한다.
  • "Compact Object Headers가 뭔가요?" → 객체 헤더를 두 워드에서 한 워드로 줄여(12→8바이트) 힙 사용량과 캐시 지역성을 개선한다. Java 25에서 프로덕션 옵션이 됐다.
  • "기동 속도를 줄이는 방법은?" → ① Spring lazy-initialization(첫 요청이 느려지는 대가) ② Spring Data AOT ③ AOT 캐시(Project Leyden — 로드·링크된 클래스와 메서드 프로파일을 저장, 코드 변경 없이 최대 42% 단축) ④ GraalVM 네이티브 이미지(가장 빠르지만 닫힌 세계 가정·리플렉션 힌트·빌드 시간의 값).

한 줄 요약가상 스레드(21)는 블로킹 시 캐리어에서 언마운트해 스레드 비용을 없앤다(풀을 만들지 않는다·CPU 바운드엔 무의미). synchronized pinning은 Java 24 JEP 491에서 제거됐고 네이티브 프레임·클래스 초기화·리눅스 파일 I/O만 남는다. 진짜 요점은 병목의 이동 — 처리량 +147%에도 에러율이 그대로였던 건 커넥션 풀 포화 때문이고, 답은 풀 확대가 아니라 connectionTimeout 단축·Semaphore 상한·ThreadLocal 재검토(Scoped Values)다. WebFlux는 죽지 않았지만 기본값에서 내려왔다 — 스트리밍·백프레셔·API 합성에 남고, 택하는 순간 R2DBC라 JPA를 잃는다. JDBC는 레거시가 아니라 JPA·MyBatis·jOOQ 전부의 토대이고, 요즘은 쓰기 JPA·읽기 jOOQ/JdbcTemplate 경로별 분리가 좋은 답이다. 버전은 Java 21 주류 / 25 신규 기본, Spring Boot는 LTS가 없고(6개월·12~13개월 지원) 현재 줄기는 Framework 7 기반 Boot 4(모듈화·API 버저닝·HTTP Service Client·JSpecify·Data AOT·4.1 lazy JDBC connection)다.

출처

Spring·JPA — IoC/DI·AOP·영속성 컨텍스트·N+1API·REST 설계 — 멱등성·상태코드·버저닝·GraphQL