자바는
“오래됐지만”
계속 젊어지는 언어다
2014년의 자바와 지금의 자바는 다른 언어에 가깝습니다. 람다·스트림·record·패턴 매칭·가상 스레드가 차례로 들어오면서 장황하던 코드가 절반 이하로 줄었습니다. 이 탭은 지금 쓰는 자바를 기준으로, 옛날 문법과 나란히 놓고 설명합니다.
자바를 실행하는 법
“설치했는데 안 돌아가요”의 90%는 JDK 버전과 빌드 도구 이야기입니다. 여기만 정리하고 넘어가면 나머지는 편합니다.
JDK · JRE · JVM — 헷갈리는 3형제
“한 번 작성하면 어디서나 돈다”가 여기서 나옵니다. 내 코드는 OS가 아니라 JVM을 향해 컴파일되고, JVM이 각 OS별로 따로 존재합니다.
어떤 버전을 깔아야 하나
| 버전 | 상태 · 무엇이 들어왔나 |
|---|---|
| 8 | 아직도 현업에 많음. 람다·스트림이 처음 들어온 버전 |
| 11 | LTS. var(도입은 Java 10), 새 HTTP 클라이언트 |
| 17 | LTS. record · sealed · switch 표현식 정식화 |
| 21 | LTS. 가상 스레드 · 패턴 매칭 정식화 — 여기가 큰 분기점 |
| 25 | 현행 LTS. 스코프 값 정식화(구조적 동시성은 아직 preview), 성능 개선. 새로 시작하면 이걸로 |
| 26 | 2026-03 · non-LTS(6개월 지원). HTTP/3 프리뷰(JEP 517), G1 처리량 개선(JEP 522),
final 의 의미 강제(JEP 500), Applet API 제거(JEP 504),
sun.misc.Unsafe 메모리 접근 기본 차단 |
첫 코드 — 예전과 지금
예전 방식 (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
./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 을 최신으로 올렸는지 먼저 확인하세요.
변수 · 타입 · var
자바의 타입은 딱 두 종류로 나뉩니다 — 기본형과 참조형. 이 구분만 잡으면 나머지는 쉽습니다.
기본형 8가지 — 값 자체를 담는다
| 타입 | 크기 | 범위 · 쓰임 |
|---|---|---|
| byte | 1B | -128 ~ 127 · 바이너리 데이터 |
| short | 2B | ±3만 · 거의 안 씁니다 |
| int | 4B | ±21억 · 정수의 기본값 |
| long | 8B | ±922경 · 돈·ID·시간 100L |
| float | 4B | 실수 7자리 1.5f |
| double | 8B | 실수 15자리 · 실수의 기본값 |
| boolean | 1byte* | true / false (0/1 아님!) — *크기는 명세에 없고 JVM 구현상 보통 1바이트 |
| char | 2B | 문자 하나 '가' (작은따옴표) |
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 var name = "김현우"; 처럼 두면 재할당이 막힙니다.
“이 변수는 여기서 정해지고 안 바뀐다”가 코드에 드러나면 읽는 속도가 확실히 빨라지고,
나중에 동시성 코드에서 사고를 줄여 줍니다.
문자열과 텍스트 블록
실무 코드의 절반은 문자열을 만들고 자르고 붙이는 일입니다. 자바의 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"로 적어야 합니다.
텍스트 블록을 쓰면 이 이중 이스케이프를 피할 수 있습니다.
조건 · 반복 · 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;
};
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 를 추가하면 -> 여기서 컴파일 에러가 나서 놓칠 수 없다 ✅
클래스 · 객체 · 생성자
자바는 모든 코드가 클래스 안에 있습니다. 클래스는 “데이터 + 그 데이터를 다루는 기능”을 한 덩어리로 묶는 도구입니다.
기본 구조
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)) { ... }
생성자가 많아질 때 — 빌더 패턴
❌ 파라미터가 늘어나면 무엇이 뭔지 모른다
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 객체가 존재한다는 것 자체가 “유효한 주문”을 보장합니다.
이게 뒤쪽 코드에서 방어 코드를 없애 주는 가장 큰 힘입니다.
상속 · 인터페이스 · 다형성
상속은 “~이다”, 인터페이스는 “~할 수 있다”입니다. 실무에서는 상속보다 인터페이스를 훨씬 많이 씁니다.
상속 — 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()); // 테스트에서는 아무것도 안 함
상속 vs 인터페이스 — 무엇을 쓸까
| 상황 | 선택 | 이유 |
|---|---|---|
| “카드결제는 결제다” | abstract class | 공통 상태(필드)와 공통 흐름을 물려줄 때 |
| “이 클래스는 전송할 수 있다” | interface | 능력만 정의. 여러 개 구현 가능 |
| 구현을 갈아끼워야 함 | interface | 테스트에서 가짜 구현으로 대체하기 쉬움 |
| 코드 재사용이 목적 | 합성(필드로 갖기) | 상속은 결합도가 너무 높아집니다 |
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 엔티티 — 기본 생성자와 가변 필드가 필요해서 안 맞습니다
- ❌ 상태가 바뀌어야 하는 객체
record OrderRes(Long id, int amount) {} 처럼 필요한 필드만 정의하세요.
record 를 어디에 선언하나 — 파일 · 중첩 · 지역
가장 많이 받는 질문입니다. “클래스처럼 파일을 따로 만드나요, 아니면 서비스 안에 넣나요?” — 답은 둘 다이고, 기준은 하나뿐입니다. 그 record 를 몇 계층이 알아야 하는가.
| 선언 위치 | 모양 | 언제 이걸 쓰나 |
|---|---|---|
| ① 별도 파일 가장 표준 |
OrderRes.javapublic 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", "현우");
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.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) { }
@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();
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 · 통째 분해
// 제네릭도 그대로 됩니다
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);
} // ← 접근자를 한 번도 안 부르고 값이 전부 풀립니다
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);
"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 패턴 매칭은 함께 쓸 때 위력이 나옵니다. 경우가 늘어나도 빠뜨린 곳을 컴파일러가 전부 알려 주기 때문에, “처리 안 한 상태가 있는지” 사람이 기억할 필요가 없습니다.
컬렉션 완전정복
자바 실무의 체감 8할이 컬렉션입니다. List · Set · Map 셋과, 각각을 언제 쓰는지만 정확히 알면 됩니다.
| 인터페이스 | 주요 구현체 | 언제 · 특징 |
|---|---|---|
| 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 클래스는 쓰지 마세요 |
| Queue | PriorityQueue | 우선순위 큐(힙). 항상 가장 작은 것이 먼저 |
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);
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로 대체하면 됩니다.
성능 감각 — 왜 자료구조를 고르나
| 작업 | ArrayList | LinkedList | HashSet / HashMap | TreeMap |
|---|---|---|---|---|
| 인덱스로 꺼내기 | 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배 빨라집니다. 이 한 가지만 기억하세요.
제네릭 — <> 안에 들어가는 것
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);
타입 파라미터 관례
| 글자 | 의미 |
|---|---|
| T | Type — 아무 타입 |
| E | Element — 컬렉션 원소 |
| K, V | Key, Value — Map |
| R | Result — 반환 타입 |
| ? | 와일드카드 — 뭔지 몰라도 되는 자리 |
경계 — 아무 타입이나 말고
// 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>) 같은 검사도 안 됩니다.
제네릭은 컴파일러에게 주는 정보라고 생각하면 정확합니다.
람다와 함수형 인터페이스
-> 기호 하나가 자바를 크게 바꿨습니다.
“동작을 값처럼 넘기는” 방법이 생겼기 때문입니다.
❌ 익명 클래스 — 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) -> Unit | forEach, ifPresent |
Function<T,R> | T 받음 → R 반환 | apply() | (T) -> R | map, computeIfAbsent |
Predicate<T> | T 받음 → boolean 반환 | test() | (T) -> Boolean | filter, removeIf, anyMatch |
Supplier<T> | 안 받음 → T 반환 | get() | () -> T | orElseGet, Stream.generate |
Runnable (덤) | 안 받음 → 반환 없음 | run() | () -> Unit | 스레드, ifPresentOrElse |
(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() // []
list.forEach 등)에 람다를 넘기면 SAM 변환으로 자바 인터페이스가 자동으로 만들어집니다 —
그래서 두 언어의 람다가 서로 그대로 오갑니다.
함수형 인터페이스란 정확히 무엇인가
한 줄로 “추상 메서드가 정확히 하나뿐인 인터페이스” 입니다.
그게 전부입니다. 특별한 종류가 따로 있는 게 아니라,
조건을 만족하면 그냥 함수형 인터페이스가 됩니다.
Runnable, Comparator, Callable 처럼
자바 1.0~5 시절부터 있던 인터페이스들도 전부 여기에 해당합니다.
왜 이런 게 필요한가
자바에는 “함수”라는 타입이 없습니다.
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~ | 인자가 그 기본형 (박싱 없음) | IntPredicateIntFunction<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 → R | map, computeIfAbsent |
Predicate<T> | test() | T → boolean | filter, removeIf, anyMatch |
Consumer<T> | accept() | T → void | forEach, ifPresent, peek |
Supplier<T> | get() | () → T | orElseGet, Stream.generate |
Runnable | run() | () → void | 스레드, ifPresentOrElse |
Callable<T> | call() | () → T + 예외 가능 | ExecutorService.submit |
Comparator<T> | compare() | T,T → int | sort, 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);
}
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); }
}
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줄 넘어가면 람다의 장점이 사라짐 | 메서드로 빼고 메서드 참조로 넘긴다 |
int[] count = {0})이나
AtomicInteger를 쓰거나, 애초에 스트림의 reduce·count로 바꾸세요.
람다 안에서 바깥 변수를 고치려는 코드는 대개 설계를 다시 볼 신호입니다.
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단 구조입니다
stream().filter(...)만 쓰고 끝내면 아무 일도 일어나지 않습니다.
그리고 스트림은 한 번만 소비됩니다 — 같은 스트림 변수를 두 번 쓰면 예외가 납니다.
중간 연산
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();
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>— 빈 리스트를 돌려주면 됩니다 - ✅ 반환 타입에만 쓰세요 — “못 찾을 수도 있음”을 알리는 용도
Objects.requireNonNull(x, "메시지")로 입구에서 막기
③ 그래도 “없을 수 있다”가 정상 흐름이면 그때 Optional.
NPE 는 Optional 로 막는 게 아니라 null 을 안 만들어서 막는 겁니다.
예외 처리 — 언제 잡고 언제 던질까
기준은 하나입니다 — 내가 이 자리에서 실제로 대처할 수 있는 것만 잡는다. 못 하면 던져 올리는 게 맞습니다.
예외 계층
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));
@RestControllerAdvice 한 곳에서 전부 받아
일관된 JSON 에러 응답으로 변환하면 됩니다 —
🍃 Spring Boot 탭 09번 전역 예외 처리에서 이어집니다.
①
catch (Exception e) { } — 빈 catch. 무슨 일이 났는지 영원히 알 수 없습니다.
②
e.printStackTrace() — 로그 시스템을 타지 않아 운영에서 안 보입니다.
log.error("메시지", e)를 쓰세요.
③
catch (Exception e) { throw new RuntimeException(e.getMessage()); } —
원인(cause)을 버리면 스택 트레이스가 끊겨 추적이 불가능합니다.
반드시 new XxxException("메시지", e)처럼 e 자체를 넘기세요.
날짜와 시간 — java.time
Date와 Calendar는 잊으세요.
Java 8부터 들어온 java.time은 불변이고 스레드 안전하며, 훨씬 읽기 쉽습니다.
타입 고르기
| 타입 | 언제 |
|---|---|
| LocalDate | 날짜만 — 생일, 정산일 |
| LocalTime | 시각만 — 영업 시작 시간 |
| LocalDateTime | 날짜+시각 (시간대 없음) — 국내 서비스 기본 |
| Instant | UTC 기준 절대 시각 — 로그·이벤트 기록 |
| ZonedDateTime | 시간대까지 — 글로벌 서비스 |
| Duration | 시간 간격 (초·분·시) |
| Period | 날짜 간격 (일·월·년) |
기본 사용
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.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 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"
치트시트와 학습 로드맵
막힐 때 찾아보는 한 장 요약과, 무엇을 어떤 순서로 익힐지 정리했습니다.
문법 한 장 요약
// ── 변수·타입 ─────────────────────────────
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, "필수");
파이썬 ↔ 자바 대조표
| Python | Java | 메모 |
|---|---|---|
| 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 / finally | try / catch / finally | 거의 동일 |
| with open(f) as x: | try (var x = ...) { } | 자동 정리 |
| None | null · Optional | |
| @decorator | @Annotation + AOP | 개념이 유사 |
| @dataclass | record | 거의 같습니다 |
| pip / uv | Gradle / Maven |
이 순서로 익히면 됩니다
연습 프로젝트 추천 순서
- ① 콘솔 To-Do — 컬렉션·클래스·예외만으로
- ② 파일 정산기 — CSV 읽어 집계 후 결과 출력 (스트림 연습)
- ③ REST API — 게시판 CRUD (Boot + JPA)
- ④ 로그인 붙이기 — JWT (Security)
- ⑤ 엑셀 다운로드 — POI (실전 도구 탭)
- ⑥ 화면 붙이기 — JavaFX 또는 프론트 연동
느린 이유도
터지는 이유도
대개 여기 있다
문법을 다 알아도 메모리와 스레드를 모르면 운영에서 막힙니다. 이 탭은 “왜 OOM 이 나는지”, “왜 가끔만 틀리는지”, 그리고 Java 21 부터 판을 바꾼 가상 스레드를 다룹니다.
JVM 구조와 메모리
“어디에 무엇이 저장되는가”만 알면 OOM·성능 문제의 절반은 짐작이 갑니다.
void example() {
int a = 10; // 스택 — 값 자체
String s = "hello"; // 스택엔 '주소', 실제 객체는 힙
List<String> list = new ArrayList<>(); // 힙
} // 메서드 끝 -> 스택은 즉시 정리, 힙 객체는 GC 가 나중에
힙은 다시 세대로 나뉩니다
| 영역 | 내용 |
|---|---|
| Eden | 새 객체가 처음 놓이는 곳. 대부분 여기서 바로 죽습니다 |
| Survivor 0/1 | Eden 에서 살아남은 것이 잠시 머무는 곳 |
| 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 # 어떤 객체가 많은지
① static 컬렉션에 계속 담기 — 절대 안 지워집니다. 캐시는 반드시 크기 제한을 두세요.
② DB 결과를 전부 메모리로 —
findAll() 이 100만 건이면 그대로 힙에 올라옵니다. 페이징·스트리밍을 쓰세요.
③ 파일·스트림을 안 닫음 —
try-with-resources를 쓰면 구조적으로 막힙니다.
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) 지연이 중요할 때 |
| Shenandoah | ZGC 와 비슷한 목표 |
| Parallel | 처리량 우선. 배치 작업에 유리 |
| Serial | 작은 컨테이너·단일 CPU |
java -XX:+UseG1GC -jar app.jar # 기본
java -XX:+UseZGC -jar app.jar
# ※ 세대형 ZGC 는 Java 23부터 기본이라 따로 켤 필요 없음
# (21~22에서만 -XX:+ZGenerational 추가, 24부터는 이 플래그가 폐기됨)
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초가 될 수 있습니다 —
그동안 모든 요청이 멈춰 있습니다.
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); // 압축 생성자에서 복사
}
}
불변 객체는 여러 스레드가 동시에 읽어도 안전합니다. 동시성 문제의 상당수가 “공유되는 가변 상태”에서 나오므로, 불변으로 만들 수 있으면 락 자체가 필요 없어집니다.
스레드 기초
웹 서버는 이미 스레드 위에서 돌고 있습니다. 직접 만들 일은 적어도 무슨 일이 벌어지는지는 알아야 합니다.
만들고 실행하기
// 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 가 몰려 있는 지점을 찾으세요.
같은 락을 여러 스레드가 기다리고 있으면 그게 병목입니다.
스레드를 직접 만들지 않는 법 — 실무에서 쓰는 형태
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 로 직접 만드는 편이 낫습니다.
동기화 · 락 · 원자성
“가끔만 틀리는 버그”의 원인은 대부분 여기 있습니다. 여러 스레드가 같은 값을 동시에 건드릴 때 무슨 일이 일어나는지 봅시다.
❌ 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 int count; count++; 는 여전히 깨집니다.
· 플래그(true/false) → volatile
· 증감·누적 → Atomic 계열
· 여러 값을 함께 → synchronized / Lock
스레드 A가 락1을 쥐고 락2를 기다리고, B가 락2를 쥐고 락1을 기다리면 둘 다 영원히 멈춥니다. 예방법은 하나 — 락을 항상 같은 순서로 잡기(예: ID 가 작은 계좌부터). 그리고 가능하면
tryLock(타임아웃)을 써서 영원히 기다리지 않게 하세요.
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 면 풀이 아니라 다운스트림이 병목입니다.
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 은
업스트림을 못 세운다”. 이 네 문장이면 깊이가 전달됩니다.
가상 스레드 — 판이 바뀐 지점 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(); }
};
}
알아두면 자유로워지는 것들
// 지금 가상 스레드 위인가? (라이브러리 분기 작성 시)
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 이상 고정' 이 반복되는 스택이 수리 대상입니다
우회 — 위에서부터 차례로 시도하세요
// ① 드라이버·런타임 업그레이드 (제일 싸고 확실)
// 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번
submit().get() 을 감으면 스레드 풀 시절로 되돌아갈 뿐입니다.
그리고 섬의 크기는 반드시 커넥션 풀 크기와 같게 — 더 크면 의미가 없고
더 작으면 커넥션을 놀립니다.
구조적 동시성 · 스코프 값 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();
}
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 처럼 새지 않습니다.
성능 튜닝 · 프로파일링
측정 없는 최적화는 대부분 헛수고입니다. 어디가 느린지 먼저 찾는 도구부터 익히세요.
측정 도구
| 도구 | 언제 |
|---|---|
| JFR (Flight Recorder) | 운영 중 상시 기록. 부하 1% 미만. 1순위 |
| async-profiler | CPU·할당 병목을 불꽃 그래프로 |
| VisualVM / JMC | GUI 로 힙·스레드 관찰 |
| 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줄보다 효과가 큰 경우가 대부분입니다.
리플렉션 · 애너테이션 · 프록시
스프링이 “마법처럼” 동작하는 원리가 여기 있습니다. 직접 쓸 일은 드물어도 알면 스프링이 안 무섭습니다.
리플렉션 — 실행 중에 클래스를 들여다보기
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번에서 자세히 다룹니다.
에러 메시지 사전 — 이게 뜨면 여기를
자바에서 만나는 예외는 놀랍도록 정해져 있습니다. 스택 트레이스는 아래에서 위로 읽으세요 — 맨 아래가 근본 원인입니다.
| 메시지 | 원인 | 해결 |
|---|---|---|
| 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로 충돌 확인 후 버전 고정 |
| UnsupportedOperationException | List.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: 가 있으면 가장 마지막 것이 근본 원인입니다.
프레임워크 내부 스택은 대부분 볼 필요가 없습니다.
운영 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
운영에서 늘 넣는 옵션
-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 입니다
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 # 목표치일 뿐, 보장은 아닙니다
기동 시간 — 오토스케일에 직결됩니다
- 트래픽이 몰려 서버를 늘릴 때 부팅이 60초면 이미 늦습니다
- CDS(클래스 데이터 공유)로 기동 20~30% 단축 —
-XX:ArchiveClassesAtExitSpring Boot 3.3+ - 스프링의 지연 초기화는 첫 요청이 느려지므로 운영에서는 권하지 않습니다
- 기동 직후 느린 것(JIT 워밍업)은 정상 — readiness 프로브로 트래픽 투입을 늦추세요
스프링은
“대신 만들어 주는”
도구다
스프링 부트가 하는 일은 결국 하나입니다 — 객체를 대신 만들어서 필요한 곳에 넣어 주고, 흔한 설정을 미리 다 해 두는 것. 이 탭은 빈 프로젝트에서 배포 가능한 REST API 까지를 순서대로 따라갑니다.
스프링 부트가 하는 일
“왜 이렇게 편한가”를 먼저 알면 나중에 안 될 때 어디를 봐야 할지 알게 됩니다.
① 객체를 대신 만들고 연결한다 (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가 자동으로 준비됩니다.
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 로 끝납니다.
요청이 흘러가는 길
프로젝트 시작하기
5분이면 도는 서버를 만들 수 있습니다.
1) 프로젝트 생성
start.spring.io 에서 만들거나, IntelliJ 의 New Project → Spring Initializr. 고를 것은 넷뿐입니다.
| 항목 | 선택 |
|---|---|
| Project | Gradle - Kotlin (또는 Groovy) |
| Language | Java |
| Spring Boot | 최신 4.1.x (3.x 는 2026-06 로 OSS 지원 종료) |
| Java | 25 권장 (Boot 4 는 17~26 지원) |
처음 담을 의존성
- Spring Web — REST API (필수)
- Lombok — 보일러플레이트 제거
- Spring Data JPA — DB
- H2 Database — 개발용 인메모리 DB
- Validation — 요청 검증
- Spring Boot DevTools — 자동 재시작
2) 폴더 구조 — 도메인형을 권합니다
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"}
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();
}
}
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 | 주입될 때마다 새로 생성 |
| request | HTTP 요청마다 |
| 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 간 직접 호출을 안 하는 규약으로 버팁니다
@Configuration 은 CGLIB 로 @Bean 호출을 가로챈다”.
이 네 문장이 나오면 면접관이 화제를 바꿉니다.
자동 설정의 원리
“마법”이 아니라 조건부 설정입니다. 원리를 알면 “왜 내 설정이 안 먹지?”를 스스로 풀 수 있습니다.
동작 방식
// 스프링 부트 내부의 자동 설정 클래스는 대략 이렇게 생겼습니다
@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'
특정 자동 설정을 끄려면:
@SpringBootApplication(exclude = SecurityAutoConfiguration.class)
설정 파일 · 프로파일
개발·운영 환경을 나누고, 비밀값을 코드에서 분리하는 방법입니다.
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); }
${DB_PASSWORD})나
AWS Secrets Manager · Vault 를 쓰고,
로컬 개발용 값은 application-local.yml을
.gitignore 에 넣으세요.
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 ← 상태 변경은 동사 허용
어디에 무엇을 끼울까 — 필터 · 인터셉터 · AOP
| 수단 | 동작 시점 | 쓸 곳 · 주의 |
|---|---|---|
| Filter | DispatcherServlet 앞 | 인증 · 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 를 쓰세요.
요청 · 응답 · 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).
프론트엔드가 성공·실패를 한 가지 방식으로 처리할 수 있습니다.
검증 — Bean Validation
잘못된 값은 컨트롤러 입구에서 막는 것이 가장 쌉니다. 서비스까지 들어오면 방어 코드가 온 사방에 생깁니다.
implementation("org.springframework.boot:spring-boot-starter-validation")
자주 쓰는 애너테이션
| 애너테이션 | 검사 내용 |
|---|---|
| @NotNull | null 이 아님 |
| @NotEmpty | null 아니고 비어 있지 않음 (문자열·컬렉션) |
| @NotBlank | 공백만 있어도 안 됨 — 문자열엔 이걸 |
| @Size(min=, max=) | 길이·크기 |
| @Min / @Max | 숫자 범위 |
| @Positive / @PositiveOrZero | 양수 / 0 이상 |
| 이메일 형식 | |
| @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 으로 바꿔 주는 것이 실무 기본입니다.
전역 예외 처리
컨트롤러마다 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)를 응답에 넣으면 사용자 문의 → 로그 검색이 즉시 됩니다.
계층 설계
각 층이 무엇을 하고 무엇을 하지 않는지가 명확해야 프로젝트가 커져도 안 무너집니다.
| 계층 | 해야 할 일 | 하면 안 되는 일 |
|---|---|---|
| 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();
}
}
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 미지원)
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 덕분입니다
스케줄러 · 비동기 · 캐시
주기 실행, 백그라운드 처리, 결과 재사용. 애너테이션 하나로 끝나는 것들입니다.
⏰ 스케줄러
@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시 */
⚡ 비동기
@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
💾 캐시
@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
파일 업로드 · 다운로드
엑셀 다운로드는 실무에서 가장 자주 요청받는 기능 중 하나입니다.
업로드
@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); // 스트리밍으로 직접 씀
}
}
테스트
테스트는 “잘 만든 증거”가 아니라 “고쳐도 안 깨진다는 보험”입니다. 리팩터링 속도가 달라집니다.
① 단위 테스트 — 가장 빠름
@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 주문_생성부터_결제까지() { ... }
}
TestRestTemplate 이 자동으로 안 들어옵니다 —
@SpringBootTest 만으로는 주입이 실패합니다.
@AutoConfigureTestRestTemplate 을 붙이거나,
아예 RestTestClient·WebTestClient 로 옮기세요.
Actuator · 모니터링
“서버가 살아 있나?”, “어디가 느리지?”를 코드 없이 알 수 있게 해 줍니다.
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/metrics | JVM·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 로 막으세요.
배포 · 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 을 반환한 뒤에
트래픽을 옮기고, 옛 인스턴스는 처리 중인 요청을 마치고 내려가게 합니다.
쿠버네티스 · 무중단 배포
대기업 배포는 거의 다 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 # ★ 기존 파드는 하나도 안 줄인다 = 무중단
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 # 급하게 줄이지 않기 (플래핑 방지)
SPRING_DATASOURCE_PASSWORD 같은 이름을 자동으로 매핑합니다.
같은 이미지를 개발·스테이징·운영에 그대로 쓰는 게 원칙입니다.
서버가 여러 대가 된 뒤의 이야기는 ⚡ 대규모 트래픽 탭에서 이어집니다.
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 요구 |
|---|---|
| Java | 17 ~ 26 (권장 25 LTS) |
| Gradle | 8.14+ 또는 9.x |
| Maven | 3.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 3 | Boot 4 |
|---|---|
starter-web | starter-webmvc |
starter-aop | starter-aspectj |
starter-oauth2-client | starter-security-oauth2-client |
starter-web-services | starter-webservices |
Could not find … 로 바로 실패하니
오히려 조용히 깨지는 것보다 낫습니다.
③ 코드에서 반드시 고쳐야 하는 것
| 주제 | Boot 3 | Boot 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 기반으로 옮기기 |
@MockBean → @MockitoBean ·
Jackson 2 → 3 · Security 6 → 7 이 전부 레시피로 있습니다.
🧰 실전 도구 탭의 도구표에 실행법을 정리해 뒀습니다.
runtimeOnly("org.springframework.boot:spring-boot-properties-migrator")
를 넣어 두세요. 기동 로그에 바뀐 키·없어진 키를 전부 찍어 줍니다.
마이그레이션이 끝나면 반드시 빼세요(운영에 남기면 기동이 느려집니다).
기다리지 않고
흘려보내는
서버
WebFlux 는 “빠른 서버”가 아니라 적은 스레드로 많은 연결을 버티는 서버입니다. 잘 쓰면 강력하지만 아무 데나 쓰면 손해입니다. 그래서 언제 쓰지 말아야 하는지부터 시작합니다.
왜 WebFlux인가 — 그리고 언제 쓰지 말아야 하나
가상 스레드가 나온 지금, 이 판단이 예전보다 훨씬 중요해졌습니다.
MVC — 요청 하나에 스레드 하나
요청 1 → [스레드 1] ──DB 대기 2초── 응답
요청 2 → [스레드 2] ──DB 대기 2초── 응답
…
요청 201 → 대기 (톰캣 기본 풀 200개 소진)
스레드는 '기다리는 동안'에도 메모리를 붙잡고 있다
WebFlux — 적은 스레드가 돌려막기
요청 1~10000 → [이벤트 루프 스레드 8개]
DB 요청을 던져 놓고 → 즉시 다른 요청 처리
결과가 오면 → 콜백으로 이어서 처리
기다리는 동안 스레드가 놀지 않는다
셋 중 무엇을 고를까 2026 기준
| 상황 | 선택 |
|---|---|
| 일반적인 CRUD API | MVC — 고민할 필요 없습니다 |
| 동시 접속은 많은데 로직은 I/O 대기 위주 | MVC + 가상 스레드 ★ 코드는 그대로, 처리량만 올라갑니다 |
| 실시간 스트리밍 · SSE · 채팅 | WebFlux |
| 수십 개 API 를 조합하는 게이트웨이 | WebFlux |
| 백프레셔가 필요한 데이터 파이프라인 | WebFlux |
| 팀에 리액티브 경험자가 없다 | MVC — 학습·디버깅 비용이 큽니다 |
implementation("org.springframework.boot:spring-boot-starter-webflux")
⚠️
starter-webmvc(Boot 3 의 starter-web) 와 함께 넣으면 MVC 가 이깁니다.
WebFlux 를 쓰려면 starter-webmvc 를 빼세요.
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()를 호출하는 코드는 대개 잘못된 신호입니다.
연산자 지도
수백 개가 있지만 실무에서 쓰는 건 20개 남짓입니다. 목적별로 묶어 두면 찾기 쉽습니다.
| 연산자 | 하는 일 | 메모 |
|---|---|---|
| ■ 변환 | ||
| map(v -> f(v)) | 값을 바꿈 (동기) | Stream 의 map 과 동일 |
| flatMap(v -> mono) | 또 다른 비동기 호출을 이어붙임 | 가장 많이 씁니다. 순서 보장 안 됨 |
| concatMap | flatMap + 순서 보장 | 순서가 중요하면 이걸로 |
| 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())
)
스레드 모델 · 스케줄러
여기가 WebFlux 에서 사고가 가장 많이 나는 곳입니다. 이벤트 루프를 막으면 서버 전체가 멈춥니다.
reactor-http-nio-*)에서
블로킹 호출을 하면 안 됩니다.
스레드가 CPU 코어 수만큼(보통 4~8개)뿐이라
한 요청이 막히면 다른 수천 개 요청이 전부 멈춥니다.
블로킹의 예: JDBC ·
RestTemplate · Thread.sleep() ·
.block() · 파일 I/O · 무거운 CPU 연산
스케줄러 종류
| 스케줄러 | 용도 |
|---|---|
| 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
컨트롤러 · 라우터 함수
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));
}
}
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()));
.block() 으로 결과를 기다리면 됩니다.
다만 WebFlux 프로젝트에서는 .block() 이 금지입니다.
Spring 6.1 부터는 동기 호출 전용
RestClient 도 있습니다
Spring 6.1+ (현행 7.x) — MVC 라면 그쪽이 더 자연스럽습니다.
백프레셔 — 감당할 만큼만 받기
생산자가 소비자보다 빠를 때 “천천히 주세요”라고 말할 수 있는 능력입니다. 리액티브의 진짜 차별점입니다.
왜 필요한가
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 를 대량 호출할 때 이 셋이 없으면
상대 서버를 다운시키거나 우리가 먼저 죽습니다.
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(); // 여러 구독자가 같은 스트림을 공유
R2DBC — WebFlux 전용 DB 접근
JDBC 는 태생이 블로킹이라 WebFlux 와 맞지 않습니다. R2DBC 는 논블로킹 DB 드라이버 표준입니다.
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();
}
}
① 연관관계 매핑·지연 로딩·더티 체킹이 없습니다 — JPA 의 편의 기능 대부분이 없습니다. 직접 조합해야 합니다.
② QueryDSL 을 못 씁니다 — 동적 쿼리는 문자열을 조립하거나
Criteria API 를 씁니다.
③ 지원 DB 가 제한적입니다 — MySQL·PostgreSQL·MSSQL·H2·Oracle. 그 외는 확인이 필요합니다.
④ 그래서 “단순 조회가 대량으로 일어나는 서비스”에 잘 맞고, 복잡한 도메인 모델이 있다면 MVC + JPA + 가상 스레드가 나은 선택인 경우가 많습니다.
리액티브 트랜잭션
@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 호출은 별개입니다.
에러 처리 · 재시도
리액티브에서는 예외가 신호로 흐르기 때문에 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()));
filter 로 일시적 오류만 골라내세요.
디버깅 · 테스트 · 함정
리액티브의 가장 큰 비용은 디버깅 난이도입니다. 도구를 알아 두면 훨씬 수월합니다.
디버깅
// 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 으로 수동 전달하는 것도 방법
contextWrite 위치가 위에 있거나 둘 중 하나입니다.
Netty 구조 — 왜 WebFlux가 빠른가
WebFlux 아래에는 항상 Netty가 깔려 있습니다. 여기를 모르면 튜닝도 장애 분석도 추측이 됩니다.
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 — 참조 카운팅이 붙은 버퍼 ★
| 종류 | 특징 |
|---|---|
| 힙 ByteBuf | JVM 힙에 할당. GC 가 치워 줌. 소켓에 쓸 때 복사 1회 발생 |
| 다이렉트 ByteBuf | 힙 밖(네이티브). 복사 없이 커널로 전달 = zero-copy. Netty 기본값 ★ |
할당자(Allocator) — 4.2 에서 기본값이 바뀌었습니다 ★
| 할당자 | 특징 |
|---|---|
| Adaptive | Netty 4.2 기본값. 실제 워크로드를 관찰해 버퍼 크기·전략을 자동 조정. pooled 보다 메모리를 적게 쓰고 가상 스레드와도 잘 맞습니다 ★ |
| Pooled | 버퍼를 재사용해 GC 압력을 줄임. 4.1 까지의 기본값.
지금은 -Dio.netty.allocator.type=pooled 로 명시해야 씁니다 |
| Unpooled | 매번 새로 할당. 느리지만 진단용으로 유용 |
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' 으로만 드러나서 원인 추적이 지옥입니다
-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-* 개수를 센다
Netty 직접 다루기
대부분은 직접 쓸 일이 없습니다. 다만 사설 프로토콜·게임 서버·프록시를 만드는 순간 이게 유일한 선택지가 됩니다.
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_BACKLOG | accept 대기 큐. 리눅스 somaxconn과 둘 중 작은 값이 적용됩니다. 4096 권장 |
| TCP_NODELAY | Nagle 끄기. 작은 패킷의 40ms 지연 제거 — 켜 두세요(true) ★ |
| SO_KEEPALIVE | 죽은 연결 감지. 다만 기본 2시간이라 앱 레벨 하트비트가 더 실용적 |
| SO_REUSEADDR | 재기동 시 Address already in use 방지 |
| CONNECT_TIMEOUT_MILLIS | 클라이언트 연결 타임아웃. 기본 30초는 너무 깁니다 → 2초 |
| WRITE_BUFFER_WATER_MARK | 느린 클라이언트 대비 송신 버퍼 한계. 넘으면 isWritable()==false |
① 사설 바이너리 프로토콜 — 금융 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(); // 밀린 것 재개
}
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 을 내리면
// 마지막 응답들이 잘려 나갑니다
이벤트 루프를 막지 마라
WebFlux 운영 장애의 절반 이상이 이 하나입니다. 코드 한 줄이 서버 전체를 세웁니다.
· 관리자는 하루 열 번 호출 → QA 에서 아무 문제 없음
· 운영 배포 → 평소 p99 40ms 였던 전체 API 가 2~8초로 튐
· 원인: 워커 4개 중 하나가 300ms 동안 멈출 때마다 그 루프에 묶인 수천 개 연결이 전부 대기. 처리량이 25% 씩 계단식으로 붕괴.
· CPU 는 30% 로 한가하고, 스레드 덤프에도 “정상”으로 보여서 3일을 헤맸습니다.
무엇이 블로킹인가
| 코드 | 왜 위험한가 |
|---|---|
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)
// ← 몇 번째 줄인지까지 정확히 찍힙니다
핸들러 안의 무거운 작업 — EventExecutorGroup
// Netty 를 직접 쓸 때, 특정 핸들러만 별도 스레드 풀에서 돌린다
private static final EventExecutorGroup BIZ =
new DefaultEventExecutorGroup(16);
ch.pipeline()
.addLast(new HttpServerCodec()) // 루프 스레드에서
.addLast(BIZ, new BusinessHandler()); // ★ 이 핸들러만 BIZ 스레드에서
// 주의 ① 스레드가 바뀌므로 ByteBuf 를 그대로 넘기면 수명 관리가 어려워집니다
// → 되도록 POJO 로 디코딩한 '뒤' 지점에 붙이세요
// 주의 ② 같은 채널의 순서는 유지되지만, 채널 간 순서는 보장되지 않습니다
jcmd <pid> Thread.print 를 2초 간격 3회 떠서
reactor-http-nio-* 가 매번 같은 스택에 있으면 그게 범인입니다.
② CPU 는 낮은데 처리량이 안 오르면 거의 항상 블로킹입니다.
③ JFR 의 Socket Read / File Read 이벤트에 루프 스레드 이름이 보이면 확정.
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_MILLIS | TCP 핸드셰이크까지 |
| responseTimeout | 요청을 보낸 뒤 응답 읽기 사이의 최대 공백 — 요청 단위로 걸리고 풀어짐 ★ |
| ReadTimeoutHandler | 패킷 사이 무응답 시간 — 커넥션 단위 (풀에서 놀 때도 남아 있음) |
Mono .timeout() | 리액티브 체인 전체 (최후 방어선) |
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 부족 또는 상대가 느림 |
PrematureCloseException | keep-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
운영 · 튜닝 종합
여기까지 오면 남는 질문은 하나입니다 — “그래서 지금도 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;
| 트랜스포트 | 차이 |
|---|---|
| NIO | JDK 기본. 어디서나 동작. 기준선 |
| epoll | 리눅스 전용. GC 압력 ↓ · 지연 5~15% 개선. SO_REUSEPORT·TCP_FASTOPEN 사용 가능 ★ |
| kqueue | 맥·BSD 용 |
| io_uring | Netty 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)
SimpleChannelInboundHandler 를 쓴다(자동 release).
② 직접 받은 ByteBuf 는 try/finally 로 release.
③ 다음 핸들러로 넘긴 버퍼는 내가 release 하지 않는다 — 소유권이 넘어갑니다.
| 기준 | WebFlux + Netty | MVC + 가상 스레드 (Java 21+) |
|---|---|---|
| 동시 연결 1만+ | ✅ 검증된 영역 | ✅ 가능 (스택 메모리만 주의) |
| DB 접근 | R2DBC 필수 — JPA 를 못 씁니다 | ✅ JPA·MyBatis 그대로 ★ |
| 기존 코드 재사용 | 전면 재작성 | ✅ 설정 한 줄, 코드 그대로 ★ |
| 스택 트레이스 | ❌ 읽기 매우 어려움 | ✅ 평소와 동일 |
| 디버거 · 프로파일러 | ❌ 흐름 추적이 어려움 | ✅ 그대로 동작 |
| 블로킹 한 줄의 대가 | 💥 서버 전체 정지 | 캐리어 스레드 하나만 (핀 되는 경우 제외) |
| 백프레셔 | ✅ 프로토콜 수준으로 내장 ★ | ❌ 직접 만들어야 함 (세마포어 등) |
| SSE · 스트리밍 · WebSocket | ✅ 가장 자연스러움 ★ | △ 가능하지만 연결당 스레드 |
| API 수십 개 병렬 조합 | ✅ zip 한 줄 | ✅ 구조적 동시성 (StructuredTaskScope — 아직 preview) · 또는 CompletableFuture |
| 메모리(연결 1만 기준) | ✅ 가장 적음 | 여유 있음 (가상 스레드 스택은 힙에) |
| 팀 학습 비용 | ❌ 매우 큼 | ✅ 거의 없음 |
그래서 언제 갈아타야 하나 ★
① 하는 일이 “DB 읽고 JSON 주기” 위주다
② JPA 의 도메인 모델이 필요하다 (연관관계·더티 체킹)
③ 팀에 리액티브를 자신 있게 읽는 사람이 2명 미만이다
④ 장애 때 스택 트레이스로 원인을 못 찾은 적이 있다
⑤ 블로킹 라이브러리를
boundedElastic 으로 감싸는 코드가 늘고 있다
— 이건 “리액티브를 흉내만 내고 비용만 낸다”는 신호입니다.
운영 반입 전 체크리스트
[ 스레드 · 블로킹 ]
□ 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 은 운영에서 꺼져 있다
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 는 그대로 두고 브로커 릴레이로 메시지만 공유가 정석 |
WebSocketHandler 가 Flux<WebSocketMessage> 를
주고받는 리액티브 API 입니다 — 백프레셔(f07)가 소켓까지 이어져
수만 동시 접속에 유리합니다. STOMP 편의 기능은 MVC 쪽이 성숙하니,
접속 수가 수천 이하라면 MVC + STOMP 조합이 개발 속도 면에서 낫습니다.
실무 난이도의
절반이
여기에 있다
스프링에서 가장 많이 막히고, 가장 많이 사고가 나는 영역입니다.
N+1, 영속성 컨텍스트, 트랜잭션 전파 —
이 셋만 확실히 잡으면 나머지는 문법입니다.
이 탭은 Spring Framework 7 · Spring Boot 4.1 기준입니다.
기술 고르기 지도
셋 다 배울 필요는 없습니다. 다만 왜 그걸 쓰는지는 알아야 합니다.
| 기술 | 강점 | 약점 | 언제 |
|---|---|---|---|
| Spring Data JPA | CRUD 를 안 짜도 됨. 객체 그래프를 그대로 다룸. 더티 체킹 | 복잡한 조회에 약함. 내부 동작을 모르면 성능 사고 | 대부분의 신규 프로젝트 기본 |
| QueryDSL | 동적 쿼리를 타입 안전하게. 컴파일 시점에 오타 검출 | Q타입 생성 설정이 번거로움 | JPA 와 함께 — 검색 조건이 많을 때 |
| MyBatis | SQL 을 그대로 씀. 복잡한 통계·레거시 DB 에 강함 | 매핑을 전부 손으로. 컴파일 검사 없음 | 레거시 DB, 튜닝된 SQL 이 필요한 곳 |
| JdbcClient | 가장 가벼움. 스프링 6.1+ 내장 (현행 7.x) | 기능이 최소한 | 간단한 조회 몇 개만 필요할 때 |
| R2DBC | 논블로킹 | 연관관계·더티 체킹 없음 | WebFlux 프로젝트 — 🌊 09번 |
JdbcClient 로 따로 빼는 식으로 섞어 씁니다.
한 프로젝트에서 여러 개를 같이 쓰는 건 전혀 이상하지 않습니다.
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
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 를 넘으면 전체 장애
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 / Or | AND / OR |
| Between | BETWEEN |
| LessThan / GreaterThanEqual | < / >= |
| IsNull / IsNotNull | IS NULL |
| In / NotIn | IN |
| Like / Containing / StartingWith | LIKE |
| OrderBy…Desc | ORDER BY |
| Top / First | LIMIT |
| Distinct | DISTINCT |
findByStatusAndAmountGreaterThanAndCreatedAtBetweenOrderByAmountDesc
같은 이름은 읽을 수도, 고칠 수도 없습니다.
조건이 3개를 넘으면 QueryDSL(09번)로 가는 게 맞습니다.
Spring Data 2026.0 — Boot 4.1 이 물고 오는 것
| 기능 | 무엇이 달라지나 |
|---|---|
| AOT 리포지토리 | 파생 쿼리(findByStatusAndAmount…)의 쿼리 생성을 빌드 시점에 끝냅니다.
기동 때 프록시를 만들며 리플렉션으로 파싱하던 비용이 사라져 기동이 빨라지고
네이티브 이미지와도 잘 맞습니다. 메서드 이름 오타도 빌드에서 걸립니다. |
| JSpecify 널 애노테이션 | API 전반이 @Nullable·@NonNull 로 표기돼
IDE·널 검사기가 NPE 가능 지점을 미리 잡아 줍니다. 코틀린에서 특히 체감이 큽니다. |
| Vector Search | 임베딩 벡터를 리포지토리로 조회하는 공통 추상화. 유사도 검색을 스토어별 API 없이 씁니다(지원 스토어에 한함). |
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번)로 관리하세요.
연관관계 매핑
JPA 에서 가장 헷갈리는 부분입니다. 규칙 세 가지만 지키면 대부분 해결됩니다.
① 모든 연관관계는
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) 로 충분합니다.
영속성 컨텍스트 — JPA 의 심장
“왜 save() 를 안 했는데 UPDATE 가 나가지?”의 답이 여기 있습니다.
엔티티의 4가지 상태
new Order()save() 했거나 findById() 로 꺼낸 것. 변경이 자동 추적됩니다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 커넥션을 붙잡습니다.
트래픽이 늘면 커넥션 고갈로 이어집니다.
엔티티의 네 가지 상태
| 상태 | 언제 | 변경이 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 배치로
flush() + clear() 로 주기적으로 비우거나,
대량 작업은 JDBC 배치·INSERT ... SELECT 로 처리하는 편이
훨씬 빠르고 안전합니다 — JPA 는 대량 처리 도구가 아닙니다.
쿼리 메서드 · 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 로 비워 주세요.
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 도 막는 가장 무난한 방법입니다.
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 폴더를 소스로 표시
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 ✅
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() // 스트림
페이징 · 정렬 · 프로젝션
목록 화면은 거의 전부 페이징입니다. 잘못 만들면 데이터가 늘수록 급격히 느려집니다.
기본 페이징
// 컨트롤러 — 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 에 인덱스가 있어야 합니다.
MyBatis 기초
SQL 을 직접 쓰고 결과를 객체에 매핑합니다. SQL 을 완전히 통제해야 할 때 강력합니다.
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 인젝션 💥
${} 는 정렬 컬럼명처럼 어쩔 수 없을 때만,
그리고 반드시 화이트리스트 검증 후에 쓰세요.
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); }
}
트랜잭션 완전정복
“왜 롤백이 안 되지?”의 원인은 거의 정해져 있습니다. 여기서 전부 정리합니다.
기본 사용
@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()));
}
스키마 관리 · 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
-- 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 으로 실제로 타는지 반드시 확인하세요.
JPA vs MyBatis — 실전 선택
둘 다 써 본 입장에서 정리한 판단 기준입니다.
| 상황 | 권장 | 이유 |
|---|---|---|
| 신규 프로젝트, 도메인 중심 | JPA + QueryDSL | CRUD 를 안 짜도 되고 객체지향 설계를 살릴 수 있음 |
| 기존 DB 가 이미 있고 스키마를 못 바꿈 | MyBatis | 복합키·비정규화 테이블에 JPA 매핑이 고통스러움 |
| 복잡한 통계·집계 리포트 | MyBatis / JdbcClient | 윈도우 함수·CTE 등 SQL 을 그대로 쓰는 게 빠름 |
| 대량 배치 처리 | JdbcTemplate 배치 | JPA 는 영속성 컨텍스트 때문에 메모리를 먹음 |
| 검색 조건이 10개 이상 | QueryDSL | 동적 쿼리를 타입 안전하게 |
| 팀에 SQL 강자가 많음 | MyBatis | 학습 비용이 낮고 통제가 쉬움 |
| WebFlux | R2DBC | JDBC 는 블로킹 — 🌊 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); }); // 처리 후 즉시 분리
}
}
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 배치 = 가장 빠름
}
}
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 로 관리
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();
}
JPA는 쉽게 시작해서
어렵게
끝난다
save()와 findById()까지는 하루면 익힙니다.
문제는 상속 구조를 매핑할 때, 동시에 같은 행을 고칠 때,
10만 건을 넣을 때 시작됩니다.
이 탭은 데이터 · JPA 탭 다음에 오는 그 이야기입니다 —
실무에서 반드시 만나지만 자료가 흩어져 있는 주제들을 한곳에 모았습니다.
상속 매핑 3전략
객체는 상속이 되는데 테이블은 안 됩니다. JPA 는 이 간극을 세 가지 방식으로 메우고, 어느 것을 고르느냐로 쿼리 성능과 제약 조건이 갈립니다.
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 (조회 성능 개선) |
복합키와 식별 관계
레거시 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 |
|---|---|
| 스타일 | 관계형 / 객체지향 |
| JPQL | o.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"}))
}
임베디드 · 값 타입
주소·기간·금액처럼 여러 컬럼이 한 덩어리로 의미를 갖는 것들입니다. 값 타입으로 묶으면 도메인이 읽히고, 검증이 한 곳에 모입니다.
@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 을 쓰세요
컨버터 · 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); }
}
}
@Id(기본키),
@Version, 그리고 enum 에 @Enumerated가 이미 붙어 있으면
그쪽이 우선합니다. 또 컨버터를 통과한 값은 JPQL 에서 DB 값 기준으로 비교되므로,
where status = :status 같은 쿼리에 주의가 필요합니다.
식별자 생성 전략
@GeneratedValue 한 줄이 배치 INSERT 가능 여부를 결정합니다.
IDENTITY 를 쓰면 Hibernate 의 배치 기능이 통째로 꺼집니다.
| 전략 | DB | 특징 |
|---|---|---|
| IDENTITY | MySQL·MariaDB | AUTO_INCREMENT. INSERT 를 즉시 실행해야 키를 안다 → 배치 불가 |
| SEQUENCE | PostgreSQL·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초
generate_statistics)에서
JDBC batches 수를 확인하세요 — 0이면 배치가 동작하지 않는 것입니다.
@UuidGenerator(style = TIME) 지원)
영속성 컨텍스트 심화
데이터 · JPA 탭에서 더티 체킹까지 봤다면, 여기서는
플러시 시점, 준영속 처리, 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)
findById(1) == findById(1)이 true입니다.
반대로 트랜잭션이 다르면 다른 인스턴스이므로,
엔티티를 ==로 비교하는 코드는 언젠가 반드시 틀립니다 —
id 로 비교하세요.
지연 로딩 내부 — 프록시
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 을 없애는 법
- 트랜잭션 안에서 DTO 로 변환해 반환 (가장 확실)
- fetch join 으로 함께 조회
- @EntityGraph 로 로딩 계획 지정
default_batch_fetch_size로 IN 절 묶음 조회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: 쿼리는 그대로 두고 '로딩 계획'만 바꾼다. 재사용에 유리
페치 전략 총정리
N+1 을 없애는 방법은 여럿인데 각각 한계가 다릅니다. 특히 컬렉션 fetch join + 페이징은 반드시 알아야 하는 함정입니다.
// ✅ 방법 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 | 하나만 가능 · 페이징 불가(메모리 처리) |
| @EntityGraph | fetch join 과 같은 제약을 받는다 |
| default_batch_fetch_size | N+1 → 2회로. 페이징과 함께 써도 안전 |
| @BatchSize | 특정 연관에만 적용 (전역 설정의 예외) |
| 2단계 조회 | 제약 없음 · 쿼리 2번 · 가장 예측 가능 |
| DTO 프로젝션 | 연관을 아예 안 탄다 — 조회 전용 API 의 정답 |
락 — 동시 수정 제어
두 사용자가 같은 행을 동시에 고치면 나중 것이 앞의 것을 덮어씁니다(lost update). 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 도 있다)
set stock = stock - :qty where stock >= :qty)가
락도 재시도도 없이 가장 빠릅니다.
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 | 자주 바뀌는 데이터 (소프트 락 사용 · 비용 있음) |
| TRANSACTIONAL | JTA 환경 — 거의 안 쓴다 |
READ_ONLY로 두면
DB 왕복이 사라지고 부작용도 없습니다.
이벤트 · 리스너 · 도메인 이벤트
엔티티가 저장·수정·삭제될 때 자동으로 끼어드는 지점입니다. 감사 필드 채우기, 이력 남기기, 도메인 이벤트 발행에 씁니다.
// ① 엔티티 콜백 — 엔티티 안에서
@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);
벌크 연산 · 대용량 처리
수만 건을 다루기 시작하면 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);
| 상황 | 선택 |
|---|---|
| 수만 건 INSERT | SEQUENCE + batch_size, 또는 JdbcTemplate.batchUpdate |
| 조건에 맞는 대량 UPDATE | 벌크 쿼리 (@Modifying) — 한 방에 |
| 전체 순회하며 가공 | 스트림 + clear() 또는 커서 페이징 |
| 도메인 규칙이 복잡한 배치 | Spring Batch — 청크·재시작·스킵을 프레임워크가 처리 |
| 단순 데이터 이관 | SQL 로 직접 — 엔티티를 만들 이유가 없다 |
em.clear() 누락으로 메모리 폭발,
② IDENTITY 전략이라 배치 INSERT 가 꺼져 있음,
③ 루프 안에서 조회(N+1 의 배치 버전) — findAllById로 묶으세요.
멀티 데이터소스 · 읽기 분리
조회 부하를 읽기 복제본으로 넘기거나, 여러 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 판단보다 먼저
데이터소스가 결정되어 라우팅이 동작하지 않습니다.
소프트 삭제 · 동적 필터
실무 대부분의 시스템은 데이터를 진짜로 지우지 않습니다.
그 순간 “모든 조회에 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 가 느리다”는 말은 대부분 “내가 쓴 방식이 느리다”입니다. 무엇이 몇 번 나가는지 숫자로 보는 방법부터 시작합니다.
보이게 만들기
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 를 쓰다 만나는 예외들을 원인과 해결로 정리했습니다. 메시지를 검색하기 전에 여기서 먼저 찾아보세요.
| 예외 · 메시지 | 원인과 해결 |
|---|---|
| LazyInitializationException could not initialize proxy — no Session | 트랜잭션 밖에서 지연 로딩 접근 → 트랜잭션 안에서 DTO 변환 · fetch join · EntityGraph |
| No default constructor for entity | 기본 생성자 없음 → protected 기본 생성자 추가 (코틀린은 no-arg 플러그인) |
| detached entity passed to persist | id 가 있는 객체를 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 끄기 · 트랜잭션 단축 · 풀 크기 조정 |
문자열 쿼리는
런타임에 터지고
QueryDSL은 컴파일에 막힌다
검색 조건이 다섯 개만 되어도 if 로 JPQL 문자열을 조립하게 됩니다.
그러면 띄어쓰기 하나로 운영에서 터집니다.
QueryDSL 은 그 쿼리를 자바 코드로 쓰게 해서,
컬럼명 오타·타입 불일치를 컴파일 단계에서 잡습니다.
왜 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);
}
}
// 이제 어디서든 주입받아 쓴다
Order 엔티티로부터 QOrder 클래스가 생성되고,
그 안에 order.status·order.totalAmount 같은
타입 있는 경로 객체가 들어 있습니다. 우리가 쓰는 것은 이 경로 객체이고,
엔티티 필드가 바뀌면 Q타입도 바뀌므로 컴파일 에러로 드러납니다.
기본 문법
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 절 서브쿼리).
동적 조건 — null 이 곧 “조건 없음”
QueryDSL 의 가장 강력한 성질입니다.
where() 에 null을 넘기면 그 조건은 무시됩니다.
이 규칙 하나로 동적 쿼리가 if 없이 표현됩니다.
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();
}
조인 총정리
QueryDSL 의 조인은 JPQL 조인 그대로입니다. 연관 기반 조인과 연관 없는 조인(theta·on), 그리고 fetch join을 구분해서 씁니다.
// ① 연관 기반 조인 — 가장 흔하다
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 프로젝션으로 (엔티티를 안 만든다)
서브쿼리
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 절 서브쿼리가 안 될 때
- 조인으로 바꿔 본다 — 대부분의 인라인 뷰는 조인으로 표현됩니다
- 쿼리를 두 번 나눈다 — 서브쿼리 결과(id 목록)를 먼저 받고 그것으로 본 쿼리
- 네이티브 쿼리 — 통계·리포트처럼 복잡하면 이쪽이 정직합니다
- DB 뷰를 만든다 — 재사용되는 복잡한 집계라면 좋은 선택
서브쿼리 성능 감각
// ❌ 상관 서브쿼리가 행마다 실행되면 느리다
.where(order.totalAmount.gt(
select(sub.totalAmount.avg()).from(sub).where(sub.member.eq(order.member))
))
// ✅ 조인 + 그룹핑으로 바꾸면 한 번에 계산된다
// (평균을 미리 구해 조인)
// exists vs in
// · exists : 첫 행을 찾으면 멈춘다 → 보통 더 빠르다
// · in : 서브쿼리 결과를 전부 만든 뒤 비교 (옵티마이저가 바꿔 주기도 한다)
// 실행 계획을 보고 판단하세요
프로젝션 — 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 로 변환해 반환하세요.
집계 · 그룹핑 · 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("메모 없음")
Expressions.stringTemplate·numberTemplate으로
function('함수명', 인자…) 형태를 씁니다.
Hibernate 6 부터는 표준 함수 상당수를 방언이 알아서 변환해 주므로
to_char·date_format 같은 DB 고유 함수만 이 방식이 필요합니다.
페이징 · 카운트 최적화
목록 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()).
벌크 연산 · 수정 · 삭제
한 건씩 더티 체킹으로 고치면 수만 건에서는 답이 없습니다. 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 와 비슷한 위치)
Spring Data 와 통합
기본 CRUD 는 Spring Data 에 맡기고, 복잡한 조회만 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 는 커스텀 리포지터리가 낫다
재사용 · 표현식 조합
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)
성능 — 무엇을 조심하나
QueryDSL 은 결국 JPQL 을 만드는 도구입니다. 느린 이유는 QueryDSL 이 아니라 만들어진 SQL에 있습니다.
| 문제 | 해결 |
|---|---|
| 목록 조회에서 N+1 | fetchJoin() · 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 를 검토하세요 —
// 사용자는 대부분 전체 건수를 필요로 하지 않습니다
실전 ① — 검색 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 타이브레이커로 페이지가 흔들리지 않게.
세 가지 모두 실무에서 사고가 났던 자리입니다.
실전 ② — 통계 · 대량 처리
리포트 화면과 배치에서 쓰는 형태입니다. 집계 · 기간 그룹핑 · 대량 스트리밍을 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(); // ★ 영속성 컨텍스트 비우기
}
}
버전 상황과 대안
QueryDSL 은 원 저장소의 릴리스가 느려진 상태입니다. 그래서 포크·대안이 등장했습니다 — 새 프로젝트라면 알고 고르는 편이 좋습니다.
| 선택지 | 상황과 특징 |
|---|---|
| querydsl 5.1.x (원본) | 여전히 가장 널리 쓰인다. jakarta 분류자 제공. 릴리스 주기가 길다 |
| OpenFeign 포크 | io.github.openfeign.querydsl — 최신 JDK·Hibernate 대응이 빠르다 |
| Spring Data JPA + Specification | 추가 의존성 없음. 표현력이 떨어지고 코드가 장황하다 |
| jOOQ | SQL 그 자체를 타입 안전하게. 통계·복잡 쿼리에 강하다. 상용 DB 는 유료 |
| Kotlin JDSL | 코틀린 프로젝트라면 코드 생성 없이 동적 쿼리 (라인 오픈소스) |
| Blaze-Persistence | JPA 확장 — 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 도 함께 검토
치트시트 · 트러블슈팅
자주 찾는 문법과, 막혔을 때 확인할 것을 한 장에 모았습니다.
자주 쓰는 문법 요약
// 조회
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() 누락 |
| 느리다 | 쿼리 로그 → 쿼리 수 → 실행 계획 순서로 |
SQL을
내가 쓰겠다는
선택
JPA 가 “객체로 생각하고 SQL 은 맡긴다”면, MyBatis 는 “SQL 을 내가 쓰고 매핑만 맡긴다”입니다. 복잡한 조회·통계·레거시 DB에서는 이쪽이 훨씬 정직하고 빠릅니다. 이 탭은 설정부터 동적 SQL · 배치 · 인터셉터 · 성능까지 실무 전 범위를 다룹니다.
언제 MyBatis인가 · 설정
JPA 와 우열을 가리는 문제가 아닙니다. “이 시스템에서 SQL 을 누가 통제해야 하는가”에 대한 답이 다를 뿐입니다.
MyBatis 가 유리한 경우
- 통계·리포트가 주 업무 — 윈도 함수·CTE·힌트를 자유롭게
- 레거시 DB 스키마 — 정규화가 안 됐거나 복합키·트리거가 많다
- DBA 가 SQL 을 검토하는 조직 — SQL 이 파일로 남는다
- 튜닝이 잦다 — 실행 계획을 보고 SQL 을 바로 고칠 수 있다
- 대량 배치 — 벌크·머지·프로시저 호출이 자연스럽다
JPA 가 유리한 경우
- 도메인 규칙이 복잡 — 객체로 상태를 관리해야 한다
- CRUD 가 대부분 — 반복 SQL 을 쓰지 않아도 된다
- DB 를 바꿀 가능성 — 방언 차이를 프레임워크가 흡수
- 변경 감지·낙관적 락 같은 기능이 필요
- 스키마를 우리가 설계한다
설정 — Spring Boot
의존성과 프로퍼티
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
// → 패키지 구조를 맞춰 두면 찾기 쉽다
매퍼 — 인터페이스와 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아래에 — 자바 폴더에 두면 빌드에서 빠진다
< 를 쓸 수 없습니다.
< 로 이스케이프하거나 <![CDATA[ ... ]]> 로 감싸세요 —
WHERE created_at < #{to} 에서 자주 겪는 문제입니다.
파라미터 — #{} 와 ${}
MyBatis 에서 가장 중요한 한 가지입니다.
#{}는 바인딩, ${}는 문자열 치환이고,
${}는 SQL 인젝션의 통로입니다.
#{}, 그 밖에는 쓰지 않는다.${} — 문자열이 그대로 박힌다
<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} 로 -->
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>) 사용 |
연관 매핑 — 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>
lazy-loading-enabled: true 설정도 필요합니다.
JPA 연관관계와의 차이
| 항목 | MyBatis |
|---|---|
| 매핑 시점 | 쿼리 결과를 읽을 때만 — 연결이 유지되지 않는다 |
| 지연 로딩 | select 속성으로 가능하지만 N+1 주의 |
| 변경 감지 | 없다 — 바꾸면 update 를 직접 호출 |
| 영속성 컨텍스트 | 없다 — 같은 행을 두 번 조회하면 다른 객체 |
| cascade | 없다 — 자식 저장·삭제를 직접 |
동적 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 < #{to} <!-- XML 안에서 < 는 < 로 -->
</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 () 이 되어 문법 오류가 나므로
빈 검사도 필수입니다.
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 재사용
같은 컬럼 목록과 조건을 여러 쿼리에 복사하다 보면
한 곳만 고치고 다른 곳을 놓치는 사고가 납니다. <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 < #{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> 로 공유하는 것이
가장 실용적인 활용입니다. 조건이 어긋나 총 건수와 실제 목록이 안 맞는 버그를 막습니다.
페이징
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);
}
}
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 < #{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 처럼 타이브레이커를 두세요.
배치 · 대량 처리
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 대로 끊어 읽습니다
useCursorFetch=true 를 URL 에 넣거나
fetchSize = Integer.MIN_VALUE 로 스트리밍 모드를 켜야 합니다.
| 작업 | 권장 방법 |
|---|---|
| 수백 건 INSERT | foreach 다중 VALUES |
| 수만~수십만 건 INSERT | ExecutorType.BATCH + 청크 flush |
| 대량 UPDATE (조건 기반) | 단일 UPDATE 문 하나로 (WHERE 조건으로) |
| 행마다 다른 값으로 UPDATE | BATCH 실행 또는 CASE WHEN · 임시 테이블 조인 |
| UPSERT | DB 문법 그대로 — ON DUPLICATE KEY·ON CONFLICT·MERGE |
| 수백만 건 조회 | ResultHandler·Cursor + fetchSize |
타입 핸들러
자바 타입과 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 : 순서를 숫자로 (권장하지 않음) -->
CodeEnum 인터페이스를 구현한 모든 enum 에 적용하세요 —
enum 마다 핸들러를 만들 필요가 없어집니다.
캐시 — 켜기 전에 읽을 것
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>
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() { /* 코드 갱신 시 호출 */ }
}
// 어디서 캐시되고 언제 비워지는지가 코드에 드러난다
인터셉터(플러그인)
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 | 쿼리 실행 전후 — 로깅·시간 측정·캐시 |
StatementHandler | SQL 문자열 조작 — 페이징·힌트 추가·테넌트 조건 |
ParameterHandler | 파라미터 가공 — 암호화 |
ResultSetHandler | 결과 가공 — 복호화·마스킹 |
등록과 순서
// 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();
}
// 나중에 등록된 것이 바깥쪽에서 실행된다 (프록시 체인)
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 힌트 측면에서도 이득입니다.
팀 컨벤션으로 정해 두세요.
JPA 와 함께 쓰기
실무에서 가장 현실적인 구성입니다. 쓰기·도메인 규칙은 JPA, 복잡한 조회·통계·배치는 MyBatis. 같은 트랜잭션에서 함께 동작하지만 주의할 점이 있습니다.
함께 쓸 때 주의점
@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·UPDATE | MyBatis(배치) 또는 JdbcTemplate |
| 레거시 테이블 접근 | MyBatis (매핑을 억지로 만들 필요 없음) |
성능 · 트러블슈팅
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 로그 (실행 시간 포함)
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 이스케이프 → < 또는 CDATA |
| IN 절이 비어 문법 오류 | <if> 로 빈 리스트 검사 |
| enum 변환 실패 | 타입 핸들러 등록 확인 (10 참고) |
| 배치가 안 빨라진다 | rewriteBatchedStatements=true(MySQL) · flush 주기 확인 |
| 커서 조회 중 세션 종료 | 커서는 트랜잭션 안에서만 유효 → @Transactional 확인 |
| OOM (대량 조회) | fetchSize·스트리밍 미적용 → ResultHandler·Cursor |
치트시트 · 선택 가이드
자주 찾는 태그와, 세 가지 데이터 접근 기술 중 무엇을 언제 쓸지에 대한 최종 정리입니다.
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>
<!-- 이스케이프 -->
< > & 또는 <![CDATA[ a < b ]]>
세 기술 최종 비교
| 기준 | JPA / QueryDSL / MyBatis |
|---|---|
| SQL 통제 | 낮음 / 중간 / 완전 |
| 타입 안전 | 중간 / 높음 / 낮음 |
| 동적 쿼리 | 어려움 / 쉬움 / 쉬움 |
| 도메인 표현 | 강함 / 강함 / 약함 |
| 복잡한 통계 | 약함 / 중간 / 강함 |
| 대량 처리 | 약함 / 중간 / 강함 |
| 학습 곡선 | 높음 / 중간 / 낮음 |
| DB 이식성 | 높음 / 높음 / 낮음 |
막는 것보다
흐름을 아는 게
먼저다
스프링 시큐리티가 어렵게 느껴지는 이유는 필터 체인이 안 보이기 때문입니다. “요청이 어느 필터를 지나 어디서 막히는지”만 잡으면 설정은 그냥 몇 줄짜리 코드가 됩니다.
전체 그림 — 필터 체인
스프링 시큐리티는 결국 서블릿 필터 묶음입니다. 요청은 컨트롤러에 닿기 전에 이 체인을 통과합니다.
Authentication 객체를 만듭니다. 커스텀 JWT 필터가 들어가는 자리입니다.핵심 객체 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 호출 |
implementation("org.springframework.boot:spring-boot-starter-security")
설정 기본형
Spring Security 7(Boot 4.0 = 7.0 · Boot 4.1 = 7.1)은 람다 DSL만 씁니다. 옛 자료를 복붙하면 컴파일부터 안 됩니다.
·
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() 를 위에 두면 아래 규칙이 전부 무시됩니다.
구체적인 것 → 넓은 것 순서로 쓰세요.
폼 로그인 · 세션
서버가 화면까지 그리는 전통적인 웹이라면 이 방식이 여전히 가장 단순하고 안전합니다.
@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
사용자 · 비밀번호
인증의 실체는 “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 — 느린 것이 목적입니다
- ✅ 로그인 실패 횟수 제한과 계정 잠금
- ✅ 로그에 비밀번호·토큰을 절대 남기지 않기
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));
}
· 시크릿은 32바이트 이상 + 환경 변수로
· Access 토큰은 짧게(15~30분), Refresh 는 길게
· 민감 정보를 페이로드에 넣지 마세요 — 누구나 디코딩할 수 있습니다(암호화 아님)
· 강제 로그아웃이 필요하면 Refresh 토큰을 서버에 저장하고 삭제
· 토큰 저장은 httpOnly 쿠키가 localStorage 보다 XSS 에 안전합니다
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());
}
}
successHandler 에서 JWT 를 만들어
프론트엔드 URL 로 리다이렉트하면서 토큰을 전달하는 방식이 일반적입니다.
기존 계정과 이메일이 같을 때 어떻게 합칠지를 미리 정해 두지 않으면 나중에 데이터가 꼬입니다.
권한 · 메서드 시큐리티
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();
}
GET /api/orders/1 이 인증만 확인하고
그 주문이 내 것인지 확인하지 않으면
아무나 남의 주문을 볼 수 있습니다.
조회 쿼리에 userId 조건을 넣는 것이 가장 확실합니다.
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()))
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 이 납니다
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) 이 원칙입니다.
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);
}
SecurityContextHolder(ThreadLocal)를 쓰지만
WebFlux 는 스레드가 계속 바뀌므로 리액티브 컨텍스트에 담습니다.
SecurityContextHolder.getContext() 를 그대로 쓰면 null 이 나옵니다.
자주 하는 실수
보안 사고는 대부분 화려한 공격이 아니라 기본을 빠뜨려서 납니다.
| 증상 · 실수 | 원인 | 해결 |
|---|---|---|
| 모든 요청이 401 | 필터가 등록 안 됨 / 순서가 틀림 | addFilterBefore(...UsernamePasswordAuthenticationFilter.class) |
| 403 인데 권한은 맞음 | ROLE_ 접두사 누락 | hasRole("ADMIN") ↔ 저장은 ROLE_ADMIN |
| 필터 예외가 안 잡힘 | @RestControllerAdvice 는 필터 밖 | AuthenticationEntryPoint 로 직접 응답 |
| CORS 는 설정했는데 계속 막힘 | preflight(OPTIONS)가 인증에 막힘 | OPTIONS 를 permitAll, .cors() 등록 확인 |
| POST 만 403 | CSRF 가 켜져 있음 | REST API 면 csrf.disable() |
| 남의 데이터가 조회됨 | 인증만 하고 소유권 검사 없음 | 쿼리에 userId 조건 추가 |
| 비밀번호가 그대로 보임 | encode() 누락 | 저장 전 passwordEncoder.encode() |
| 토큰 만료가 안 됨 | 클라이언트가 계속 보관 | Refresh 토큰을 서버에 저장 후 삭제로 무효화 |
| 로그에 토큰이 찍힘 | 요청 헤더 전체 로깅 | Authorization 헤더 마스킹 |
| h2-console 이 안 열림 | frameOptions 차단 | 개발 프로파일에서만 frameOptions.sameOrigin() |
로그인 유지 — 리프레시 토큰 · 다중 기기
“로그인 상태가 왜 자꾸 풀려요?” 와 “토큰이 털렸는데 못 막아요” 는 같은 설계에서 나옵니다. 실서비스가 쓰는 로테이션 + 기기별 세션 구조를 만듭니다.
토큰 계열(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);
}
어디에 담아 보낼 것인가
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); // 소유자 확인은 필수
}
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);
}
(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() 설정을 기본 제공합니다.
신규 서비스라면 처음부터 패스키를 함께 열어 두는 것을 권합니다.
권한 모델 — 역할로는 부족할 때
ROLE_ADMIN 하나로 시작한 프로젝트는 반드시
“이 팀 매니저는 자기 팀 문서만 수정 가능” 앞에서 무너집니다.
처음부터 자원 소유권을 넣어 설계하는 방법입니다.
역할(RBAC) → 속성(ABAC) 으로 넘어가는 지점
// 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);
}
}
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·가상 스레드로 넘길 때 컨텍스트 전파를 확인하세요
모든 요청이
먼저 지나는
한 곳
서비스가 여러 개로 쪼개지면 인증·로깅·라우팅을 각 서비스마다 만들 수 없습니다. 게이트웨이는 그 공통 관심사를 한 곳에 모으는 자리입니다. WebFlux 기반이라 적은 스레드로 많은 연결을 버팁니다.
게이트웨이가 필요한 이유
서비스가 하나뿐이라면 필요 없습니다. 여러 개가 되는 순간 이야기가 달라집니다.
게이트웨이 없이
브라우저 ──→ order-service (인증 코드 복사)
──→ user-service (인증 코드 복사)
──→ payment-service (인증 코드 복사)
문제
· 서비스마다 인증·로깅·CORS 를 중복 구현
· 프론트엔드가 주소를 전부 알아야 함
· 서비스 주소가 바뀌면 클라이언트도 배포
· 서비스가 전부 외부에 노출
게이트웨이가 있으면
브라우저 ──→ [Gateway] ──→ order-service
──→ user-service
──→ payment-service
· 인증·로깅·Rate Limit 을 한 곳에서
· 클라이언트는 게이트웨이 주소 하나만
· 내부 서비스는 외부에 노출되지 않음
· 서킷 브레이커로 장애 전파 차단
게이트웨이가 하는 일
- 라우팅 — 경로·헤더를 보고 어느 서비스로 보낼지 결정
- 인증·인가 — 토큰을 여기서 한 번만 검증
- Rate Limit — 과도한 요청 차단
- 서킷 브레이커 — 죽은 서비스로 계속 보내지 않기
- 로깅·추적 — 요청 ID 를 심어 전 구간 추적
- 응답 변환 — 헤더 추가·제거
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")
를 넣어 두면 기동 로그가 바뀐 키를 전부 알려 줍니다.
서블릿 게이트웨이 + 가상 스레드(
spring.threads.virtual.enabled=true) 조합이면
DB 조회나 외부 인증 호출 같은 블로킹 필터를 그냥 써도 됩니다 —
요청 하나가 막혀도 가상 스레드만 대기하고 캐리어 스레드는 반납됩니다.
팀이 리액티브에 익숙하지 않다면 MVC 게이트웨이가 대체로 더 안전한 선택입니다. 극한의 동시 연결 수·SSE 프록시가 필요할 때만 WebFlux 로 가세요.
라우팅 설정
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 로 현재 등록된 라우트를 확인할 수 있습니다.
Predicate 총정리
“이 요청을 이 라우트로 보낼지” 판단하는 조건들입니다.
| Predicate | 예시 | 설명 |
|---|---|---|
| Path | Path=/api/orders/** | 가장 많이 씁니다. Ant 패턴 |
| Method | Method=GET,POST | HTTP 메서드 |
| Header | Header=X-Version, v2 | 헤더 존재·값 (정규식 가능) |
| Query | Query=type, admin | 쿼리 파라미터 |
| Cookie | Cookie=session, .+ | 쿠키 |
| Host | Host=**.myapp.com | 도메인 기반 (멀티 테넌트) |
| RemoteAddr | RemoteAddr=10.0.0.0/8 | IP 대역 (내부망 전용 API) |
| After / Before / Between | After=2026-08-03T00:00+09:00[Asia/Seoul] | 시간 기반 (점검·이벤트 오픈) |
| Weight | Weight=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
필터 총정리
요청·응답을 지나가면서 바꾸는 장치입니다.
| 필터 | 하는 일 |
|---|---|
| ■ 경로 조작 | |
| 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 | 응답 헤더 추가 |
| SetRequestHostHeader | Host 헤더 변경 |
| ■ 안정성 | |
| CircuitBreaker | 장애 시 폴백 |
| Retry | 재시도 |
| RequestRateLimiter | 요청 수 제한 |
| RequestSize | 요청 크기 제한 |
| ■ 기타 | |
| Redirect=302, https://… | 리다이렉트 |
| SetStatus=401 | 상태 코드 강제 |
커스텀 필터
실무에서 가장 많이 만드는 것 — 인증 검사와 요청 추적입니다.
글로벌 필터 — 모든 요청에
@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-* 헤더는 제거하세요.
서킷 브레이커
한 서비스가 죽었을 때 전체가 같이 죽는 것을 막습니다.
3가지 상태
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));
}
}
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);
}
인증 연동 · 전체 구성
실제로 돌아가는 게이트웨이 설정 한 벌입니다.
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 리액티브 클라이언트를 쓰세요.
③ 타임아웃을 반드시 설정하세요 — 백엔드가 느려지면 게이트웨이 커넥션이 고갈됩니다.
④ 응답 본문을 통째로 읽는 필터는 메모리를 먹습니다. 꼭 필요할 때만.
MSA 인프라 — 디스커버리 · 설정 · 추적
서비스가 열 개가 되면 주소·설정·로그를 사람이 관리할 수 없습니다.
① 서비스 디스커버리 — 주소를 이름으로
주소를 고정으로 적으면
uri: http://10.0.1.24:8081
→ 서버가 늘거나 IP 가 바뀌면 게이트웨이를 다시 배포해야 합니다
→ 오토스케일과 근본적으로 맞지 않습니다
이름으로 부르면
uri: lb://order-service
→ 레지스트리에 등록된 인스턴스 목록을 받아 알아서 분산
→ 죽은 인스턴스는 목록에서 빠집니다
| 선택지 | 언제 씁니다 |
|---|---|
| Kubernetes Service | K8s 를 쓴다면 이미 있습니다. 별도 레지스트리 없이 DNS 이름으로 부르면 끝 — 요즘 기본 |
| Eureka | K8s 가 아닌 환경(온프레미스 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
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) → 재배포 없이 반영
③ 분산 추적 — 게이트웨이가 시작점
# 게이트웨이와 모든 서비스에 동일하게
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());
};
}
gRPC — MSA 내부 통신
외부 공개는 REST, 서비스끼리는 gRPC. 그 경계선을 정합니다.
REST 와 무엇이 다른가
| 항목 | REST + JSON | gRPC |
|---|---|---|
| 전송 | HTTP/1.1 (보통) | HTTP/2 — 멀티플렉싱·헤더 압축 |
| 페이로드 | 텍스트 JSON | protobuf 바이너리 — 보통 3~10배 작음 |
| 계약 | 문서·OpenAPI (선택) | .proto 가 곧 계약 ★ 코드가 생성됨 |
| 스트리밍 | SSE·WebSocket 별도 | 양방향이 기본 기능 |
| 브라우저 | 그냥 됨 | 직접 안 됨 — grpc-web + 프록시 필요 |
| 디버깅 | curl 로 즉시 | grpcurl 필요 · 눈으로 못 읽음 |
HTTP/1.1 커넥션 하나에 요청 하나 — 키프얼라이브를 써도
"앞 요청이 끝나야 다음"(head-of-line blocking)
HTTP/2 ★ 커넥션 하나 위에 스트림 수백 개를 동시에
→ 커넥션 풀 압박이 줄고, 헤더도 HPACK 으로 압축됩니다
→ 내부 홉이 많은 MSA 일수록 이득이 커집니다
.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_ARGUMENT | 400 |
| UNAUTHENTICATED / PERMISSION_DENIED | 401 / 403 |
| NOT_FOUND | 404 |
| ALREADY_EXISTS / ABORTED | 409 |
| DEADLINE_EXCEEDED | 504 — 가장 자주 봅니다 |
| RESOURCE_EXHAUSTED | 429 |
| UNAVAILABLE | 503 — 재시도해도 되는 유일한 축 |
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 는 "모르는 필드는 무시"가 기본이라
신규 필드를 추가해도 구버전 클라이언트가 죽지 않습니다.
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)
✅ 내부 서비스 간 동기 호출이 많고 스키마를 강제하고 싶을 때
✅ 대량·실시간 스트리밍(시세·로그·진행률)
✅ 팀·언어가 갈려 있을 때 — .proto 하나로 클라이언트가 생성됩니다
❌ 브라우저·외부 파트너 공개 API — REST + OpenAPI 가 압도적으로 편합니다
❌ 서비스가 2~3개뿐 — 도구·학습 비용이 이득보다 큽니다
❌ 비동기로 풀어야 할 일 — 그건 gRPC 가 아니라 Kafka 입니다(⚡ k08). 동기 호출 체인이 길어지면 장애가 그대로 전파됩니다.
장애를 번지지 않게 — 타임아웃 예산 · 격벽
MSA 에서 서비스 하나가 느려지면 게이트웨이가 먼저 죽습니다. 스레드·커넥션이 그 서비스를 기다리다 전부 묶이기 때문입니다. 막는 방법은 기다리는 시간에 예산을 매기는 것입니다.
예산을 설정으로 못박기
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 }
격벽(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");
}
- 느린 호출을 실패로 세는 것이 핵심입니다 — 장애는 보통 에러가 아니라 지연으로 옵니다
- 폴백은 “빈 값”이 아니라 의미 있는 응답으로 — 캐시된 직전 값, 축소된 기능, 명확한 안내
- 서비스별 커넥션 풀을 분리하세요. 공유 풀은 한 곳이 다 먹습니다
- 서킷이 열렸다는 사실을 메트릭·알림으로 반드시 노출하세요
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"))
실무에서
진짜 많이 쓰는
것들만
“엑셀로 뽑아 주세요”, “로그 좀 남겨 주세요”, “이 XML 좀 읽어 주세요”. 현업에서 가장 자주 받는 요청들을 복붙해서 바로 도는 코드로 정리했습니다.
Lombok — 보일러플레이트 제거
자바의 “장황함” 절반을 없애 줍니다. 다만 아무 데나 쓰면 사고가 납니다.
compileOnly("org.projectlombok:lombok") ·
annotationProcessor("org.projectlombok:lombok")
테스트에서도 쓰려면
testCompileOnly · testAnnotationProcessor 도 함께.
IDE 에는 Lombok 플러그인 + Annotation Processing 활성화가 필요합니다.
자주 쓰는 애너테이션
| 애너테이션 | 생성되는 것 |
|---|---|
| @Getter / @Setter | getter / setter |
| @RequiredArgsConstructor | final 필드 생성자 — DI 에 필수 |
| @NoArgsConstructor | 기본 생성자 (JPA 필요) |
| @AllArgsConstructor | 전체 필드 생성자 |
| @ToString | toString() |
| @EqualsAndHashCode | equals / hashCode |
| @Builder | 빌더 패턴 |
| @Slf4j | private 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();
① 엔티티에
@Data·@ToString 금지 —
연관 엔티티를 따라가며 무한 순환과 N+1이 납니다.
꼭 쓰려면 @ToString(exclude = "orders").
② 엔티티에
@Setter 금지 — 아무 데서나 상태가 바뀌면 도메인 규칙을 지킬 수 없습니다.
③
@EqualsAndHashCode 전체 필드 사용 금지 —
엔티티는 ID 기준으로만. @EqualsAndHashCode(of = "id")
생성자 3종 — 누구를 인자로 가져가나 🟢 기초
이름이 길어서 헷갈리지만 읽어 보면 뜻이 그대로 들어 있습니다.
@NoArgsConstructor(노 아그스 컨스트럭터)는 “인자 없는 생성자”,
@RequiredArgsConstructor(리콰이어드 아그스 컨스트럭터)는 “꼭 필요한 인자만 받는 생성자”,
@AllArgsConstructor(올 아그스 컨스트럭터)는 “모든 필드를 받는 생성자”입니다.
현업에서는 줄여서 노아그 · 리콰이어드 · 올아그라고 부릅니다.
핵심은 “꼭 필요한”의 기준이 final 과 @NonNull이라는 점입니다.
| 어노테이션 (읽는 법) | 인자로 받는 필드 | 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() {
}
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 와 같은 읽기 감각
@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;
}
@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);
}
⑤ @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;
}
}
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;
}
@Qualifier·@Value 는 직접 넣어야 합니다.
설정을 바꾼 뒤에는 클린 빌드(./gradlew clean build)를 해야 반영됩니다.@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 == 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() ...
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. 헷갈리면 델롬복으로 펼쳐 보면 됩니다.로깅 — SLF4J · Log4j2
운영에서 문제가 생겼을 때 남아 있는 로그가 전부입니다. 여기 투자한 시간은 반드시 돌아옵니다.
구조 — SLF4J 는 껍데기, 구현은 갈아끼움
org.slf4j.Logger// ❌ 문자열을 미리 조립하면 낭비 (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 에서 검색이 훨씬 쉽습니다
- 반복문 안에서 건마다 로그를 찍지 마세요 — 디스크가 찹니다
Jackson — JSON 다루기
스프링 부트에 이미 들어 있습니다. 직접 쓸 일도 자주 생깁니다.
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) { }
FAIL_ON_UNKNOWN_PROPERTIES 를 꺼 두세요.
상대가 필드를 하나 추가하는 순간 우리 서비스가 전부 500이 됩니다.
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 | 런타임 리플렉션 — 느리고 오류가 늦게 발견됨. 권하지 않습니다 |
record 의 정적 팩토리(from())가 더 읽기 쉽습니다.
필드가 20개 넘는 대형 DTO 가 여러 개일 때 MapStruct 의 가치가 나옵니다.
엑셀 읽기 — 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번의 스트리밍 방식으로
엑셀 쓰기 · 다운로드
“조회 결과를 엑셀로 받고 싶어요” — 이것도 단골 요청입니다.
@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();
}
}
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("비밀번호");
대용량 엑셀 · 스트리밍
수만 행 이상이면 일반 방식은 반드시 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 로 직접 지정하세요
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 가 압도적으로 빠르고 가볍습니다. 수백만 행도 메모리 걱정 없이 처리됩니다. 다만 한글이 깨지지 않게 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()));
}
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
) { }
/etc/passwd)을 읽히거나
내부망 요청을 유발할 수 있습니다.
DTD·외부 엔티티를 반드시 끄세요.
테스트 도구
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);
});
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 로 트리를 보고 버전을 하나로 고정하세요.
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);
무엇을 고를까
| 클라이언트 | 언제 |
|---|---|
| RestClient | MVC 신규 코드의 기본 |
| WebClient | WebFlux, 또는 병렬 호출이 필요할 때 |
| 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);
}
// 주입해서 그냥 메서드처럼 호출합니다
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분이 됩니다.
테스트 층 나누기 — 실무 기준
| 층 | 범위와 속도 |
|---|---|
| 단위 테스트 | 스프링 없이 순수 객체. 밀리초. 가장 많이 만드세요 |
@DataJpaTest | JPA 계층만. Testcontainers 와 조합하면 쿼리 검증에 최적 |
@WebMvcTest | 컨트롤러만. 서비스는 @MockitoBean 으로 |
| 통합 테스트 | 전체 기동. 느리므로 핵심 흐름만 (주문·결제·로그인) |
@SpringBootTest 로 만들면
빌드가 20분이 되고, 그러면 아무도 테스트를 안 돌립니다.
컨테이너는 재사용(withReuse)하고,
무거운 통합 테스트는 태그로 분리해 머지 전에만 돌리세요.
목적별 도구 찾기
“이걸 하려면 뭘 써야 하지?”를 한글로 검색하세요.
| 하고 싶은 일 | 도구 | 메모 |
|---|---|---|
| 엑셀 읽기·쓰기 | org.apache.poi:poi-ooxml | 대용량은 SXSSF — 05~07번 |
| CSV 처리 | com.opencsv:opencsv | 엑셀보다 훨씬 가벼움. BOM 주의 |
| PDF 생성 | com.openhtmltopdf · iText | HTML → PDF 가 가장 편함 |
| 로깅 | SLF4J + Logback / Log4j2 | 02번 |
| JSON | jackson-databind | 부트에 이미 포함 — 03번 |
| XML | JAXB · StAX · Jackson XML | 08번 |
| DTO 변환 | MapStruct | 04번 |
| 로컬 캐시 | 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-mail | Thymeleaf 로 템플릿 |
| 서버 렌더링 | Thymeleaf | 관리자 화면에 적합 |
| API 문서 | springdoc-openapi | /swagger-ui.html |
| 단위 테스트 | JUnit5 · Mockito · AssertJ | 09번 |
| 통합 테스트 | Testcontainers | 진짜 DB 로 검증 |
| 테스트 데이터 | Instancio · EasyRandom | 픽스처 자동 생성 |
| 범용 유틸 | commons-lang3 · Guava | 과하게 쓰진 마세요 |
| 재시도 | Spring Retry · Resilience4j | @Retryable |
| 모니터링 | Actuator + Micrometer | 🍃 15번 |
| 분산 추적 | Micrometer Tracing + Zipkin | MSA 필수 |
| 빌드 | Gradle | 10번 |
| 정적 분석 | SonarQube · SpotBugs | CI 에 연결 |
범용 유틸 — 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 → 캐시
PDF 생성 — 정산서 · 계약서 · 증명서
사내 시스템의 단골 요구입니다. 라이브러리 선택에서 라이선스가 함정이라 기능보다 먼저 봐야 합니다.
| 라이브러리 | 라이선스 | 특징 |
|---|---|---|
| OpenHTMLtoPDF | 무료 (Apache 계열) | HTML+CSS → PDF. 디자인을 HTML 로 짜는 현대적 방식 — 실무 1순위 추천 |
| Apache PDFBox | 무료 (Apache) | 저수준 — 좌표 찍어 그리기·기존 PDF 편집·텍스트 추출. 템플릿 채우기에 유용 |
| iText | AGPL — 사실상 상용 유료 | 기능은 최강. 단 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();
}
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");
}
메일 · 알림 · 스케줄 중복 방지
가입 인증 메일, 장애 슬랙 알림, 새벽 정산 배치 — 운영에 들어가면 반드시 만나는 세 가지를 한 번에 정리합니다.
메일 발송 — 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);
}
슬랙 알림 — 웹훅 한 줄
// 장애·주문 급증 같은 이벤트를 즉시 채널로
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 락 테이블로 "한 대만" 실행됨 — 나머지는 조용히 스킵
}
파싱 · 생성 — 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());
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"));
// 업로드 원본을 그대로 서빙하면 트래픽이 폭발합니다 — 업로드 시점에 축소
JSpecify — null을 타입 계약으로
자바에는 @Nullable이 스무 종류 넘게 있었습니다.
패키지가 다르면 도구가 다르게 해석했고, 그래서 아무도 믿지 않았습니다.
JSpecify 는 구글·젯브레인스·오라클·우버·스프링이 함께 만든 표준입니다.
@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) { ... }
}
@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 는 모든 위치에
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")
}
}
검사기가 잡아 주는 것
@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 (버전에 따라 기본 활성)
@NullMarked를 붙이는 것만으로
코틀린 쪽 플랫폼 타입이 사라지고 null 안전성이 경계를 넘어 이어집니다.
코틀린 가이드의 자바와 섞어 쓰기 섹션과 함께 보면 그림이 완성됩니다.
점진적 도입 — 한 번에 다 하지 않는다
권장 순서
- 의존성 추가 — 애너테이션만이라 위험이 없습니다
- 새 패키지부터
@NullMarked— 새로 만드는 코드에만 적용 - NullAway 를
warn으로 켜서 현황 파악 - 도메인·API 경계 패키지부터 정리 (효과가 가장 큽니다)
- 정리가 끝난 패키지를
AnnotatedPackages에 추가하고error로 승격 - 손대기 어려운 레거시는
@NullUnmarked로 격리
현실적인 마찰과 대처
| 문제 | 대처 |
|---|---|
| 서드파티 라이브러리에 애너테이션이 없다 | NullAway 는 미표시 코드를 신뢰합니다. 경계에서 Objects.requireNonNull |
| 필드가 나중에 주입된다 | 생성자 주입으로 바꾸거나 @Nullable + 검사 |
| Lombok 과 충돌 | 생성된 코드에는 검사가 제한적 — 롬복 설정에서 애너테이션 복사 옵션 확인 |
기존 javax.annotation.Nullable | 둘 다 인식되지만 혼용하지 말고 단계적으로 교체 |
| 테스트 코드 | 보통 검사 대상에서 제외합니다 |
-Xjsr305=strict 옵션이 하던 일을
이제 더 정밀하게 해 줍니다.
웹 없이도
돌아가는
내 프로그램
사내 도구, 데이터 변환기, 배치 실행기처럼 브라우저가 필요 없는 프로그램을 만들 때 JavaFX 만큼 빠른 선택지가 없습니다. 자바를 이미 안다면 새로 배울 것이 거의 없습니다.
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.fxml | FXML 로 화면 정의 |
| javafx.graphics | 도형·애니메이션 |
| javafx.web | 내장 브라우저(WebView) |
| javafx.media | 오디오·비디오 |
현행 최신은 JavaFX 26(2026-03)인데
--release 24 로 컴파일돼 있어 실행에 JDK 24 이상이 필요합니다 —
JDK 21 에 묶여 있는 프로젝트라면 26 으로는 못 올라갑니다.
JavaFX 버전은 JDK 버전과 같거나 낮게 맞추면 실수가 없습니다.
Swing 과 무엇이 다른가
| Swing | JavaFX | |
|---|---|---|
| 화면 정의 | 자바 코드만 | FXML + CSS |
| 스타일링 | LookAndFeel | CSS (웹과 유사) |
| 데이터 연결 | 수동 갱신 | 바인딩(자동 반영) |
| 디자인 도구 | — | Scene Builder (드래그&드롭) |
| 차트·애니메이션 | 직접 구현 | 내장 |
첫 창 만들기
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 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);
레이아웃
화면 배치는 레이아웃 컨테이너를 겹쳐서 만듭니다. 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);
컨트롤 갤러리
자주 쓰는 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 를 안 주면 빈 표에 "내용 없음"이라는 영어가 뜹니다.
사용자는 프로그램이 잘못된 줄 압니다.
왜 비었는지를 알려 주세요 —
"조회 조건을 입력하고 검색을 누르세요" 와 "조건에 맞는 결과가 없습니다"는
사용자가 할 일이 완전히 다릅니다.
이벤트 처리
람다 덕분에 아주 간단합니다.
// 버튼 클릭
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());
바인딩 실전 — 폼 하나를 통째로 묶기
모델을 프로퍼티로 만들면
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 set | bind() 걸린 프로퍼티에 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 을 쓰되,
같은 프로퍼티에 리스너까지 붙이면 갱신이 두 번 도는 일이 생깁니다.
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(); // 컨트롤러 참조가 필요하면
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;
});
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 | 투명도 |
io.github.mkpaz:atlantafx-base)를 넣으면
한 줄로 현대적인 테마가 적용됩니다.
Application.setUserAgentStylesheet(new PrimerLight().getUserAgentStylesheet());
백그라운드 작업
여기가 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<T> 를 만들고 restart() 를 호출하는 편이 깔끔합니다.
실행 파일로 배포
사용자가 자바를 설치하지 않아도 돌아가는 프로그램으로 만듭니다.
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", "");
대용량 화면 — 수십만 행을 끊김 없이
사내 도구가 느려지는 이유는 대부분 “조회 결과를 전부 화면에 올려서” 입니다. 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 를 켜면
프레임마다 어느 단계가 오래 걸리는지 찍힙니다.
“느낌상 느리다”로 최적화하면 대개 엉뚱한 곳을 고칩니다.
배포한 뒤 — 자동 업데이트 · 운영
사내 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에 — 설치 폴더에 쓰면 권한 오류가 납니다
사용자가
10만 명이
되는 순간
기능은 똑같은데 사람이 많아지면 전부 무너집니다. DB 커넥션이 마르고, 캐시가 한꺼번에 만료되고, 재고가 마이너스로 팔립니다. 대기업 면접에서 실제로 묻는 건 문법이 아니라 이 장면들을 겪어 봤는가입니다.
트래픽을 숫자로 읽는 법
“많다·느리다”로는 아무것도 못 고칩니다. 먼저 단위를 손에 익히세요.
알아야 하는 단위 네 개
| 단위 | 뜻과 감각 |
|---|---|
| RPS / TPS | 초당 요청 수. “DAU 100만·1인당 20요청·피크가 평균의 5배”면
100만×20÷86400×5 ≈ 1,150 RPS |
| 지연(Latency) | 요청 하나가 끝나기까지 걸린 시간. 평균이 아니라 p95·p99를 봅니다 |
| 동시성 | 같은 순간 처리 중인 요청 수. 스레드·커넥션이 여기에 묶입니다 |
| 처리량(Throughput) | 시간당 실제로 끝낸 일의 양. RPS 를 버텨 낸 결과 |
리틀의 법칙 — 딱 하나만 외운다면
동시 처리 건수 = 도착률(RPS) × 평균 처리시간(초)
예) 500 RPS · 평균 200ms
→ 500 × 0.2 = 100 건이 항상 서버 안에 떠 있다
→ 스레드(또는 커넥션)가 100개는 있어야 밀리지 않는다
예) 같은 500 RPS 인데 외부 API 가 느려져 2초가 됐다
→ 500 × 2 = 1,000 건
→ 톰캣 기본 스레드 200개로는 즉시 대기열 폭발
→ 요청은 큐에 쌓이고, 응답시간은 눈덩이처럼 커진다
용량 산정 — 면접에서 자주 시키는 계산
순서대로 따라가면 됩니다
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 대기 포함(블로킹) | 스레드 수에 갇힘 — 가상 스레드 검토 |
병목은 거의 항상 같은 자리
서버가 느릴 때 볼 곳은 정해져 있습니다. 이 순서대로 확인하세요.
제일 먼저 보는 설정 세 줄
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 # 커넥션 반납 안 되는 코드 잡기
vCPU × 2 + 디스크 수 근처. WAS 10대라면
10 × pool 크기가 DB 최대 접속 수를 넘지 않는지부터 확인하세요.
증상 → 범인 대조표
| 증상 | 먼저 의심할 것 |
|---|---|
| CPU 는 놀고 응답만 느림 | 대기 — DB·외부 API·락 |
| CPU 100% 고정 | 비효율 쿼리·직렬화·정규식·GC |
| 가끔 확 튀는 p99 | GC 정지·캐시 만료 몰림·배치와 겹침 |
| 메모리가 계속 증가 | 캐시 무한 증가·ThreadLocal 미정리 |
Connection is not available | 커넥션 풀 고갈 (누수 또는 긴 트랜잭션) |
| 스레드는 200 인데 처리량 낮음 | 블로킹 대기 — 가상 스레드·비동기 검토 |
캐시 전략 총정리
트래픽 대응의 가장 저렴하고 효과 큰 수단입니다. 대신 정합성을 내줍니다.
읽기 패턴 — 실무는 사실상 이것 하나
[Cache-Aside / Look-aside] ★ 90% 는 이걸 씁니다
1. 캐시 확인 → 있으면 그대로 반환 (HIT)
2. 없으면 DB 조회 (MISS)
3. 캐시에 넣고 반환
장점 캐시가 죽어도 서비스는 산다 (DB 로 흐름)
단점 MISS 시 한 번은 느리다, 정합성은 TTL 에 맡긴다
쓰기 패턴
| 방식 | 동작과 쓰임 |
|---|---|
| Write-Around | DB 만 쓰고 캐시는 지운다. 가장 안전 — 기본값으로 삼으세요 |
| Write-Through | DB·캐시 동시 갱신. 일관되지만 쓰기가 느려짐 |
| Write-Back | 캐시에 먼저 쓰고 나중에 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 인 쿼리 — 네트워크 왕복이 더 비쌉니다
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;
});
캐시가 서비스를 죽이는 세 가지
캐시를 넣어서 생긴 장애는 캐시를 안 넣었을 때보다 큽니다. 이름을 알아 두세요.
① 캐시 스탬피드 (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;
}
동시성 제어 — 재고가 마이너스가 되는 이유
서버가 여러 대가 되는 순간 synchronized 는 아무 의미가 없습니다.
무슨 일이 벌어지나
재고 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);
}
낙관적 락 — 충돌이 드물 때
@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 메서드 안에서 락을 잡으면
커밋보다 락 해제가 먼저 일어나 그 사이에 다른 요청이 옛 값을 읽습니다.
위 코드처럼 락은 바깥, 트랜잭션은 안쪽 별도 빈이 정석입니다.
멱등성 — 두 번 눌러도 한 번만
대규모에서는 같은 요청이 두 번 오는 게 정상입니다. 그걸 전제로 설계하세요.
중복은 왜 생기나
- 사용자가 결제 버튼을 두 번 눌렀다
- 응답이 늦어 클라이언트가 타임아웃으로 재시도했다 (서버는 이미 처리 완료)
- 게이트웨이·로드밸런서가 자동 재시도했다
- Kafka 컨슈머가 리밸런싱으로 같은 메시지를 다시 받았다 (at-least-once)
- 배치가 실패해 재실행됐다
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;
}
}
}
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;
Kafka — 왜 큐를 넣는가
동기 호출을 줄이면 응답은 빨라지고 장애는 번지지 않습니다.
동기 호출의 한계
[동기] 주문 API 하나에 전부 매달린 경우
주문 저장 120ms + 쿠폰 80ms + 포인트 90ms
+ 알림톡 300ms + 정산 150ms = 740ms
→ 알림톡 업체가 죽으면 "주문 자체가" 실패한다
[비동기] 이벤트를 던지고 끝
주문 저장 120ms + 발행 3ms = 123ms
쿠폰·포인트·알림·정산은 각자 구독해서 처리
→ 알림톡이 죽어도 주문은 정상, 복구되면 밀린 것부터 처리
Kafka 용어 — 이 다섯 개면 됩니다
| 용어 | 뜻 |
|---|---|
| 토픽 | 메시지가 쌓이는 이름 (예: order.created) |
| 파티션 | 토픽을 쪼갠 단위. 병렬 처리의 최소 단위이자 순서 보장의 범위 |
| 컨슈머 그룹 | 같은 그룹은 일을 나눠 갖고, 다른 그룹은 같은 걸 각자 다 받습니다 |
| 오프셋 | 어디까지 읽었는지 표시. 커밋해야 다음부터 안 읽습니다 |
| 키 | 같은 키 = 같은 파티션 = 순서 보장. 주문번호·사용자ID 를 씁니다 |
스프링에서 쓰기 — 설정
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)
# 관리 명령의 --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 버전부터 확인하세요.
Kafka 운영 — 유실 · 중복 · 순서
면접에서 가장 자주 파고드는 세 가지입니다. 보장 수준의 이름부터 외우세요.
전달 보장 세 가지
| 수준 | 의미와 현실 |
|---|---|
| at-most-once | 먼저 커밋하고 처리 → 유실 가능. 로그 수집 정도에만 |
| at-least-once | 처리하고 커밋 → 중복 가능. 실무 기본값. 중복은 k07 멱등성으로 흡수합니다 |
| exactly-once | 트랜잭션으로 정확히 한 번. Kafka 안에서만 성립하고 성능 대가가 큽니다. 외부 DB 까지는 보장 못 합니다 |
유실을 막는 설정
브로커
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;
}
순서 · 리밸런싱 · 컨슈머 랙
- 순서는 파티션 안에서만 보장됩니다. 순서가 중요한 단위(주문번호·계좌번호)를 키로 주면 같은 파티션으로 갑니다
- 리밸런싱 — 컨슈머가 들어오고 나갈 때 파티션이 재배분되며 잠깐 멈춥니다.
처리가
max.poll.interval.ms를 넘으면 쫓겨나 무한 리밸런싱에 빠집니다 - 한 건이 오래 걸린다면
max-poll-records를 줄이거나 무거운 일은 별도 스레드 풀로 넘기세요 - 컨슈머 랙(lag) = 쌓인 양 − 읽은 양. 운영에서 제일 중요한 단일 지표입니다. 계속 커지면 소비가 생산을 못 따라가는 것 — 파티션·컨슈머를 늘립니다
분산 트랜잭션 — 아웃박스와 사가
DB 커밋과 메시지 발행 사이에 서버가 죽으면? 그 틈이 실제로 사고가 됩니다.
이중 쓰기 문제 (Dual Write)
@Transactional
public void order(OrderRequest req) {
orderRepository.save(order); // ① DB 커밋
kafka.send("order.created", event); // ② 메시지 발행
}
문제 1: ① 성공 후 ② 직전에 서버가 죽으면
→ 주문은 있는데 포인트·알림이 영원히 안 감
문제 2: ② 를 먼저 하면
→ 발행됐는데 ① 이 롤백 → 있지도 않은 주문의 이벤트가 돌아다님
문제 3: @Transactional 안에서 send() 를 해도
→ 커밋 전에 이미 나가서, 롤백돼도 되돌릴 수 없음
아웃박스 패턴 — 같은 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
}
}
사가(Saga) — 롤백 대신 보상
서비스가 나뉘면 롤백이 없습니다
주문 → 결제 → 재고 차감 → 배송 요청
↑ 여기서 실패하면?
이미 끝난 "결제"를 DB 롤백으로 되돌릴 수 없습니다.
대신 반대 동작(보상 트랜잭션)을 실행합니다.
재고 차감 실패
→ 결제 취소(환불) 이벤트 발행
→ 주문 상태를 CANCELLED 로
핵심: 모든 단계마다 "그것을 되돌리는 동작"이 짝으로 있어야 합니다.
두 가지 방식
| 방식 | 특징 |
|---|---|
| 코레오그래피 (이벤트 연쇄) | 각자 이벤트를 듣고 다음을 실행. 결합도 낮음. 단계가 많아지면 전체 흐름을 아무도 모릅니다 |
| 오케스트레이션 (조정자) | 중앙 조정자가 순서를 지휘. 흐름이 한눈에 보이고 디버깅이 쉬움. 조정자가 단일 장애점 |
장애 격리 — 한 곳이 죽어도 버티기
대규모 장애는 대부분 작은 지연이 전체로 번져서 일어납니다.
장애 전파 시나리오
추천 서비스가 느려짐 (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(); // ★ 빈 화면 대신 그럴듯한 것
}
}
서킷 브레이커 상태 — 이 그림이면 충분합니다
Rate Limit — 받을 만큼만 받기
전부 받아 주다 다 같이 죽느니, 일부를 거절하고 나머지를 살리는 편이 낫습니다.
알고리즘 네 가지
| 방식 | 특징 |
|---|---|
| 고정 윈도우 | 가장 단순. 경계에서 2배가 통과하는 결함 (59초·61초에 몰림) |
| 슬라이딩 윈도우 | 경계 문제 해결. 메모리를 조금 더 씁니다 |
| 토큰 버킷 | 평소 여유분을 모아 뒀다 순간 폭주(burst)를 허용. API 제한의 표준 |
| 리키 버킷 | 일정 속도로만 흘려보냄. 뒷단 보호에 좋지만 burst 를 못 받습니다 |
어디에 걸 것인가
- 게이트웨이 — 전체 트래픽 방어 (🚪 Gateway 탭 g07)
- 애플리케이션 — 사용자별·API 별 세밀한 제한
- 중요한 건 기준(key) — IP? 사용자ID? API 키? 대개 로그인 전은 IP, 후는 사용자ID
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;
}
}
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();
}
그 밖의 부하 차단 장치
대기열 (Virtual Waiting Room)
티켓팅·수강신청·한정판 판매에서 씁니다.
1) 진입 시 Redis Sorted Set 에 (사용자, 타임스탬프) 등록
2) 내 순번 = ZRANK → "앞에 12,043명" 안내
3) 서버는 "초당 N명"씩만 통과시켜 토큰 발급
4) 통과한 사람만 실제 API 호출 가능
효과: 뒷단은 항상 감당 가능한 만큼만 받는다
사용자는 "실패" 대신 "순번"을 본다 (체감이 완전히 다름)
우아한 성능 저하 (Graceful Degradation)
- 부하가 심하면 부가 기능부터 끕니다 — 추천·실시간 재고·개인화
- 핵심 기능(로그인·결제·주문)은 끝까지 지킵니다
- 기능 스위치(feature flag)를 미리 만들어 두면 배포 없이 끌 수 있습니다
- 정적 페이지·캐시된 응답으로 임시 전환하는 경로도 준비해 두세요
데이터베이스 확장
서버는 늘리기 쉽지만 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() { ... }
대용량 테이블의 흔한 함정
깊은 페이징 — 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)
관측 가능성 — 안 보이면 못 고칩니다
대규모 시스템에서 모니터링은 기능의 일부입니다. 나중에 붙이는 게 아닙니다.
세 기둥
| 종류 | 답해 주는 질문 · 도구 |
|---|---|
| 메트릭 | “지금 몇 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);
추적 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 에서 이게 없으면 장애 분석이 사실상 불가능합니다.
부하 테스트 — 한도를 미리 알기
“몇 명까지 버팁니까?”에 답할 수 있어야 합니다. 답은 측정으로만 나옵니다.
테스트 종류 — 목적이 다릅니다
| 종류 | 알아내는 것 |
|---|---|
| 부하(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% 가 아닌데 느리다면 → 어딘가에서 '대기'하고 있는 것
전체 그림 · 실전 체크리스트
지금까지의 조각을 한 장으로. 면접 답변과 설계 리뷰에 그대로 씁니다.
대규모 트래픽 서비스의 표준 구성
[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 을 짧게, 돈 관련은 캐시로 판단하지 않음 |
마지막으로 — 순서를 지키세요
동작 원리를
설명할 수
있는가
🧬 고급 탭이 “실무에서 쓰는 동시성과 JVM”이었다면, 이 탭은 그 아래에서 실제로 무슨 일이 일어나는지를 다룹니다. 바이트코드 · JIT · 메모리 모델 · 락프리 · GC 내부 · 힙덤프 분석 · 보안 · 아키텍처 — 장애를 해결할 때와 시니어 면접에서 차이를 만드는 영역입니다.
바이트코드와 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개 이상)
// · 예외 처리·동기화가 많음
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());
}
}
클래스로더 · 동적 로딩
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 드라이버가 이 방식)
LaunchedClassLoader(Boot 3.1 이하는 LaunchedURLClassLoader) 로
BOOT-INF/lib 안의 jar 를 로딩합니다.
그래서 jar 안의 jar 를 그냥 클래스패스에 넣으면 동작하지 않습니다.
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)
happens-before 규칙
| A happens-before B | 의미 |
|---|---|
| 같은 스레드의 앞 문장 → 뒤 문장 | 프로그램 순서 |
| volatile 쓰기 → 그 변수 읽기 | 가장 많이 쓰는 규칙 |
| unlock → 같은 락의 lock | synchronized |
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)
java.util.concurrent 를 쓰고,
③ volatile 은 플래그 하나 정도에만,
④ 직접 락을 설계하기 전에 이미 있는 자료구조를 먼저 찾으세요.
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);
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 도 같은 이야기를 합니다)
false sharing · 캐시 라인
코드는 완벽한데 느린 이유. 하드웨어를 알아야 보입니다.
캐시 라인이란
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]; // ❌ 열 우선 — 매번 캐시 미스
다만 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바이트 + 필드 + 패딩 → 실제 객체 크기를 알 수 있습니다
GC 심화 — G1 · ZGC 내부
🧬 고급 a02 의 확장판. 어떤 원리로 멈춤을 줄이는지 봅니다.
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 동시 마킹 시작 시점
ZGC — 컬러 포인터와 로드 배리어
목표: 힙 크기와 무관하게 정지 시간 1ms 미만
방법
· 포인터의 사용하지 않는 비트에 "색"을 심어 상태를 표시
· 애플리케이션이 참조를 읽을 때마다 로드 배리어가 끼어들어
필요하면 그 자리에서 포인터를 고침 (동시 이동)
· 그래서 "객체를 옮기는 동안에도" 앱이 계속 돕니다
대가
· 로드 배리어 때문에 처리량이 약간 (수 %) 낮습니다
· 메모리를 조금 더 씁니다
세대별 ZGC — 대부분의 워크로드에서 비세대형보다 낫습니다
-XX:+UseZGC
※ Java 23부터 세대형이 기본. 21~22에서만 -XX:+ZGenerational 을
추가로 켜야 하고, 24부터는 이 플래그 자체가 폐기됐습니다
| GC | 고르는 기준 |
|---|---|
| G1 | 기본값. 힙 4~32GB 웹 서비스. 고민되면 G1 |
| ZGC | p99 지연이 중요하거나 힙이 큼(32GB+) |
| Parallel | 배치 — 처리량만 중요하고 멈춰도 될 때 |
| Serial | 1 vCPU 컨테이너 · 사이드카 |
| Shenandoah | ZGC 와 유사한 저지연 GC (Red Hat) |
# GC 로그 (운영에 항상 켜 두세요 — 비용이 거의 없습니다)
-Xlog:gc*,gc+heap=info:file=/log/gc.log:time,uptime,level:filecount=5,filesize=20M
# 분석: GCeasy.io 에 업로드하거나 GCViewer 로 열면
# 정지 시간 분포 · 처리량 · 승격률을 한눈에 볼 수 있습니다
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
✅ 잘 먹히는 곳 — 수백만 건 엔티티·DTO 를 메모리에 올리는 서비스, 대형 캐시, 노드가 많은 그래프·트리, 작은 객체가 쏟아지는 스트림 처리
❌ 별 차이 없는 곳 — 객체 하나가 이미 큰 경우(긴 문자열·큰 배열 중심). 헤더 4바이트는 전체 대비 비율로만 의미가 있습니다.
Unsafe·JOL·에이전트처럼 헤더 레이아웃을 직접 가정하는 코드는 깨질 수 있으니
★ 부하 테스트를 한 번 돌린 뒤에 운영에 넣으세요.
힙덤프 · 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[] 가 대부분 → 응답 버퍼·파일 통째 로딩
스레드 덤프 읽는 요령
BLOCKED 락을 기다림 → "waiting to lock <0x...>" 의 주소를 추적해
누가 그 락을 쥐고 있는지 찾습니다
WAITING 조건 대기 (정상일 수 있음)
RUNNABLE 실행 중 — 그런데 소켓 read 에서 RUNNABLE 인 경우가 많습니다
★ 스레드 덤프를 5초 간격으로 3번 떠서
"같은 자리에 계속 있는 스레드"를 찾는 것이 핵심입니다
오프힙 · 네이티브 메모리
“힙은 여유 있는데 컨테이너가 죽는” 이유.
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));
-XX:MaxMetaspaceSize=256m 로 상한을 두면
OOM 으로 빨리 드러나 원인을 찾기 쉬워집니다.
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 (개발 시)
BlockHound 를 붙이면 블로킹 호출을 런타임에 잡아 줍니다
(🌊 WebFlux 탭 f04·f12).
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 에 등록
./gradlew nativeCompile — 기동 50ms, 메모리 1/5).
직렬화 · 역직렬화 취약점
자바에서 가장 많은 원격 코드 실행(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 조회가 실행됐습니다
교훈: "신뢰할 수 없는 데이터를 파싱하는 모든 지점"이 공격면입니다.
방어
// ① 꼭 써야 한다면 필터를 거세요 (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()));
공급망 보안 · 의존성 관리
내 코드보다 가져다 쓴 라이브러리가 더 위험합니다.
의존성 검사
// 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); // 문자열 연결도 위험 요소는 아니지만
// 로그 인젝션(개행 삽입)에 주의
함수형 자바 심화
☕ 기초 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;
}; // ★ 빠짐없이 처리했는지 컴파일러가 검사합니다
}
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));
// ❌ 스트림 남용 — 오히려 읽기 어렵습니다
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.get() 대신 orElseThrow()·
map·ifPresentOrElse 를 쓰세요.
디자인 패턴 실전
이름을 아는 것보다 언제 쓰지 말아야 하는지가 중요합니다.
실무에서 값어치가 큰 것
// ① 전략 — 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 클래스들 |
| 의존성 주입 | 생성자 주입 |
성능 최적화 종합
이 탭의 내용을 실행 순서로 정리합니다.
효과 크기 순서
| 순위 | 항목 · 기대 효과 |
|---|---|
| 1 | 쿼리·인덱스·N+1 — 10~1000배 |
| 2 | 알고리즘 복잡도 — O(n²)→O(n). 수십~수백 배 |
| 3 | 캐시 — 로컬·Redis. 10~100배 |
| 4 | 병렬·비동기 — 순차 호출을 동시에. 수 배 |
| 5 | 커넥션 풀·재사용 — HTTP keep-alive 포함. 10~50% |
| 6 | 직렬화·페이로드 축소. 2~5배(네트워크 구간) |
| 7 | JVM·GC 옵션 (z06). 보통 10~30% |
| 8 | 객체 할당·캐시 라인 (z05). 핫 루프에서만 |
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배 빨라집니다
아키텍처 — 헥사고날 · DDD
규모가 커지면 패키지 구조가 곧 설계가 됩니다.
의존 방향을 한쪽으로
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) { ... }
}
// 구조를 코드로 강제하기 — 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");
// ★ 규칙을 문서가 아니라 "테스트"로 만들면 실제로 지켜집니다
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 # 요약 그래프
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)이 더 싸게 같은 목적을 달성합니다.
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~600MB | 50~120MB |
| 피크 처리량 | 기준 100% | 70~95% (PGO 없으면 낮음) |
| 빌드 시간 | 수십 초 | 3~15분 · 메모리 8GB+ |
| 이미지 크기 | JRE + jar | 단일 실행 파일 60~150MB |
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 캐시 — 릴리스별 진행 상황
| JDK | JEP | 무엇이 좋아졌나 |
|---|---|---|
| 24 | JEP 483 | AOT 클래스 로딩·링킹 — 클래스를 미리 읽고 링크해 캐시에 담습니다 (시작점) |
| 25 | JEP 514 | 명령이 한 줄로 — -XX:AOTCacheOutput 하나로 훈련 실행과 캐시 생성이 합쳐졌습니다
(전에는 record → create → use 3단계) |
| 25 | JEP 515 | 메서드 프로파일까지 캐시 — JIT 이 워밍업을 처음부터 다시 하지 않아 기동 직후의 피크 성능 도달이 빨라집니다 |
| 26 | JEP 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 이미지 빌드 단계에서 만들어 이미지에 함께 넣는 게 정석입니다)
-Ddebug=true 나 ApplicationStartup 으로 구간을 먼저 재세요.
✅ 서버리스·Lambda·Cloud Run — 콜드 스타트가 곧 비용이자 지연
✅ CLI 도구 — 실행 시간이 수백 ms 인 프로그램에 JVM 기동은 낭비
✅ 사이드카·에이전트 — 인스턴스마다 뜨므로 메모리 절감이 곱해집니다
✅ 스케일아웃이 초 단위로 일어나는 트래픽(⚡ k12 대기열 개방)
❌ 24시간 도는 일반 웹 서비스 — 기동은 하루 한 번, 처리량은 JIT 이 이깁니다
❌ 리플렉션·동적 프록시가 많은 레거시 의존성이 섞인 프로젝트
❌ CI 예산이 빠듯한 팀 — 빌드 시간이 배포 리듬을 망칩니다
중간 답: 먼저 AppCDS/AOT 캐시 → 그래도 부족하면 CRaC → 콜드 스타트가 본질이면 네이티브. (z10 의 애너테이션 프로세서 기반 설계가 어느 쪽이든 유리하게 만듭니다)
아무것도 없는 PC 에
자바 개발 환경을
처음부터 세웁니다
자바를 시작하는 사람의 절반은 문법이 아니라 설치에서 막힙니다.
“'java'은(는) 내부 또는 외부 명령이 아닙니다”, “javac 가 없다는데요”,
“Unsupported class file major version 69” — 전부 JDK 를 어디에 깔았고 무엇이 그것을 가리키는가 하나의 문제입니다.
이 탭은 어느 사이트에서 무엇을 받아, 설치 마법사의 어느 체크박스를 켜고,
환경 변수 화면의 어느 칸에 무엇을 넣고, 어떤 명령으로 확인하는지까지 한 단계도 건너뛰지 않고 적었습니다.
끝나면 빈 PC 를 받아도 30분 안에 ./gradlew bootRun 이 도는 상태로 만들 수 있습니다.
어떤 JDK 를 깔아야 하나 — 배포판과 버전 고르기
파이썬은 “파이썬을 깐다”가 끝이지만, 자바는 같은 자바를 만드는 회사가 예닐곱 곳입니다. Oracle · Temurin · Corretto · Zulu · Liberica · Microsoft · GraalVM — 이름만 보면 다른 언어 같지만 안에 든 것은 거의 같습니다. 여기서 5분만 정리하면 “뭘 받아야 하죠?”가 영원히 끝납니다.
배포판 비교표 — 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 라이선스 — 최신 릴리스는 상용 포함 무료지만 지원 종료 후 계속 쓰면 유상. 회사에서는 법무 확인 필요 ★ |
버전 — LTS 만 보면 됩니다
| 버전 | 나온 때 | 지금 어떤 위치인가 |
|---|---|---|
| 8 | 2014 | 레거시. 아직 현업에 남아 있지만 새로 고르지 않습니다 |
| 11 LTS | 2018 | 구형 서비스 유지보수용. 새 프로젝트에는 쓰지 않습니다 |
| 17 LTS | 2021 | 아직도 가장 많이 돌아가는 버전. 기존 프로젝트를 받으면 보통 이것 |
| 21 LTS | 2023 | 가상 스레드가 들어온 분기점. 라이브러리 호환이 가장 넓어 안전한 선택 ★ |
| 25 LTS | 2025-09 | 현행 LTS. 새로 시작한다면 이것 ★ |
| 26 | 2026-03 | non-LTS(6개월). 새 기능 구경용이지 실무 기본값이 아닙니다 |
JDK · JRE · JVM — 셋의 관계 🟢 기초
이름만 알면 되는 실행 파일들
| 명령 | 하는 일 · 언제 마주치나 |
|---|---|
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
C:\Program Files\Java\jre1.8.0_xxx 가 PATH 를 잡고 있는 것입니다(st12).
한 단계 더 — 회사에서는 어떻게 정하나 🟡 중급
현실적인 조합 세 가지
| 상황 | 내 PC | 서버 · 컨테이너 |
|---|---|---|
| 혼자 학습 | Temurin 25 | 없음 |
| 스타트업 · 신규 | Temurin 25 | eclipse-temurin:25-jre |
| AWS 중심 | Corretto 21 | amazoncorretto:21 |
| 레거시 유지보수 | Temurin 17 + 21 | 기존 이미지 그대로 |
| 대기업 · 조달 | 사내 배포판 | 사내 레지스트리 이미지 |
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)
윈도우 설치 — Temurin 25 LTS 를 끝까지 따라 깔기
화면에 무엇이 뜨고 어디를 눌러야 하는지 전부 적었습니다. 체크박스 두 개만 제대로 켜면 뒤에 나올 환경 변수 삽질(st03)을 통째로 건너뛸 수 있습니다. 그 두 개가 무엇인지가 이 섹션의 핵심입니다.
① 어디서 무엇을 받나 — 화면 그대로
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 기반 프로세서 면 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\
설치 확인 — 세 가지를 다 봐야 합니다 🟢 기초
# ── 반드시 "새로" 연 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
더 빠른 길 — 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
echo $env:JAVA_HOME 이 비어 있으면
st03 을 그대로 따라 직접 잡으세요. 이게 오히려 정상 경로입니다.
Oracle JDK 를 깔면 뭐가 다른가
| Temurin | Oracle JDK | |
|---|---|---|
| 받는 곳 | adoptium.net | oracle.com (계정 불필요해짐) |
| 설치 경로 | Eclipse Adoptium\ | Program Files\Java\jdk-25 |
| JAVA_HOME | 체크박스로 설정 | 안 잡아 줍니다 ★ |
| PATH | JDK 폴더 직접 추가 | C:\Program Files\Common Files\Oracle\Java\javapath 라는 간접 폴더 |
| 버전 바꾸기 | PATH 순서로 | javapath 가 끼어들어 헷갈립니다 |
| 라이선스 | 제한 없음 | NFTC — 조건 확인 필요 |
…\Oracle\Java\javapath 를 꽂아 둡니다. 그 안에는 진짜 java.exe 가 아니라
바로가기가 들어 있어서, 나중에 Temurin 을 깔아도
java -version 은 계속 Oracle 을 가리킵니다.
해결은 st03 의 PATH 순서 조정, 또는 그 항목을 지우는 것입니다.
java -version · javac -version · echo $env:JAVA_HOME
세 개가 다 나오는지 확인하면 끝입니다.JAVA_HOME 과 PATH — 손으로 잡는 법을 완전히
설치 마법사가 안 잡아 줬거나, zip 으로 풀었거나, 버전을 바꿔야 할 때는 직접 넣어야 합니다. 화면 경로부터 값 하나하나까지 적었습니다. 이 두 변수가 무엇을 하는지만 이해하면 자바 설치 문제의 8할이 사라집니다.
%JAVA_HOME%\bin 을 넣는 이유가 이것입니다 — 고칠 곳이 한 군데가 됩니다.환경 변수 화면까지 가는 길 (윈도우 11 · 10)
가장 빠른 길
Win + R → sysdm.cpl ,3 → [환경 변수] 버튼
(쉼표 앞 공백이 있어야 "고급" 탭이 바로 열립니다)
또는 검색창에
"환경 변수" 또는 "env" → "시스템 환경 변수 편집"
또는 클릭으로
설정 → 시스템 → 정보
→ (오른쪽 또는 아래) 고급 시스템 설정
→ [고급] 탭 → [환경 변수(N)...]
열리는 창은 위아래 두 칸입니다
┌ 사용자 변수 ─ 나만 적용 · 관리자 권한 불필요
└ 시스템 변수 ─ 이 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 로 닫으면 저장되지 않습니다
%JAVA_HOME%\bin 이 밑에 있으면
옛날 자바가 계속 이깁니다. 다음 그림이 그 이야기입니다.
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" }
$env:Path > C:\path-backup.txt 로 한 번 백업해 두세요.
%JAVA_HOME%\bin 을 맨 앞에.
고친 뒤에는 터미널을 새로 열고 where.exe java 로 맨 윗줄을 확인합니다.
이 세 가지면 윈도우 자바 환경 문제는 거의 끝납니다.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
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 (완전히 껐다 켜야 반영됩니다)
/mnt/c/… 를 오가면 파일 접근이 수십 배 느려집니다.
IntelliJ 도 WSL 프로젝트는 WSL JDK 로 잡아야 합니다(st07).
OS 별 경로 한눈에
| OS | JDK 가 깔리는 곳 (= 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
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 해야 새 터미널에서도 살아 있습니다.여러 JDK 를 함께 두고 갈아 끼우기
실무에서는 JDK 하나로 끝나지 않습니다. 회사 서비스는 17, 새로 만드는 것은 25, 오픈소스 기여는 21 — 이런 상황이 곧 옵니다. 환경 변수를 매번 손으로 고치는 대신, 도구에 맡기고 프로젝트가 스스로 버전을 말하게 만듭니다.
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
~/.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 밖 터미널에는 영향 없음 |
| Scoop | scoop bucket add java 후 scoop install temurin25-jdk. scoop reset temurin21-jdk 로 전환 |
| SDKMAN + WSL | WSL 안에서 개발한다면 이게 가장 깔끔합니다 |
| PowerShell 함수 | st03 의 j21/j25 함수 — 도구 없이도 충분합니다 |
가장 확실한 방법 — 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
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 툴체인 | 빌드에만 적용 | 빌드에만 적용 | 팀 전체에 강제되는 유일한 방법 ★ |
빌드 도구 — Gradle · Maven 은 “깔지 않는 것”이 정답
자바 프로젝트는 javac 를 직접 부르지 않습니다.
라이브러리를 받아 오고, 컴파일하고, 테스트하고, jar 로 묶는 일을 빌드 도구가 합니다.
그런데 재미있게도 가장 좋은 설치 방법은 “설치하지 않는 것” 입니다 — 래퍼 때문입니다.
래퍼 사용법 — 이게 전부입니다
# 프로젝트를 받으면 이 파일들이 함께 옵니다
# 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
사내망 · 프록시 — 회사에서 첫날 막히는 곳 🟡 중급
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/ |
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
./gradlew --stop 을 먼저 해 보세요 — st12 의 단골 증상입니다.
./gradlew · ./mvnw)를 씁니다.
회사에서는 gradle.properties(프록시·Nexus)와
사내 인증서 등록이 첫날 관문이고,
이상하면 ./gradlew --stop 이 첫 번째 처방입니다.IntelliJ IDEA — 설치부터 첫 프로젝트까지
자바 개발자의 압도적 다수가 IntelliJ 를 씁니다. 무료 Community 로도 콘솔 앱 · 라이브러리 · Gradle 프로젝트를 전부 할 수 있고, 스프링 부트 전용 기능이 필요할 때만 Ultimate 를 생각하면 됩니다. JDK 를 지정하는 자리가 세 군데라는 점만 처음에 잡고 가면 헤맬 일이 없습니다.
Community 와 Ultimate — 무엇이 다른가
| 기능 | Community | Ultimate |
|---|---|---|
| 자바 · Kotlin 편집 · 디버깅 | ✅ | ✅ |
| Gradle · Maven · JUnit | ✅ | ✅ |
| Git · 리팩터링 · 프로파일러 | ✅ | ✅ |
| Spring / Spring Boot 지원 | ❌ | ✅ ★ |
| JPA · SQL 콘솔 · DB 도구 | ❌ | ✅ |
| HTTP 클라이언트 (.http 파일) | ❌ | ✅ |
| JavaScript · 프런트엔드 | ❌ | ✅ |
| 가격 | 무료 | 유료 (학생 · OSS 무료) |
설치 — 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
첫 프로젝트 — 클릭 순서 그대로 🟢 기초
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/
첫 실행과 첫 확인
// 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
처음에 꼭 바꾸는 설정 — 인코딩과 자동 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 + Tab | System.out.println() 자동 완성 |
psvm + Tab | public static void main 자동 완성 |
| Ctrl + Alt + V | 결과를 변수로 뽑기 |
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 파일에 묶여 있을 때
원격 개발 — 여기선 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]
}
에디터 없이 확인만 할 때
# 남의 코드를 잠깐 보거나 서버에서 확인만 할 때는
# 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)
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
IntelliJ 쓰던 사람이 헷갈리는 것
| IntelliJ | Eclipse · STS |
|---|---|
| 프로젝트 = 창 하나 | 워크스페이스 하나에 프로젝트 여러 개 |
| 저장하면 자동 컴파일 안 함 | 저장할 때마다 자동 빌드 (기본 켜짐) |
| Shift Shift | Ctrl + Shift + R (파일) · Ctrl + Shift + T (타입) |
| Alt + Enter | Ctrl + 1 (Quick Fix) |
| Ctrl + Alt + L | Ctrl + Shift + F (포맷) |
| Ctrl + B | F3 (선언으로) |
| Ctrl + Alt + O | Ctrl + Shift + O (import 정리) |
실행 Shift + F10 | Ctrl + F11 |
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
}
]
}
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
로 띄우고 위의 attach 설정으로 붙습니다.
운영 서버에 열어 두면 안 되는 포트이니 개발·스테이징에서만 쓰세요.
그 밖의 선택지
| 도구 | 메모 |
|---|---|
| NetBeans | Apache 재단. 무료이고 Maven 지원이 좋지만 국내 자바 현장에서는 드뭅니다 |
| Neovim + jdtls | Eclipse 언어 서버를 그대로 씁니다. 설정 비용이 크지만 가볍습니다 |
| JetBrains Fleet | 가벼운 편집기 모드 ↔ 스마트 모드 전환. 아직 IDEA 를 대체하지는 않습니다 |
| 브라우저에서 | GitHub Codespaces · IntelliJ 의 Code With Me — 설치 없이 남의 환경에 붙어 봅니다 |
build.gradle.kts 와 src/ 안에 있고,
편집기는 언제든 갈아탈 수 있습니다.
IDE 전용 파일(.idea/, .project, .classpath)은
.gitignore 에 넣어 두는 것이 관례입니다.
Spring Boot 시작 준비 — start.spring.io 부터 첫 실행까지
자바를 배우는 이유의 대부분이 스프링입니다. 환경이 갖춰졌다면 서버 하나 띄우는 데 5분이면 됩니다. 여기서는 프로젝트를 받아 여는 것까지만 다룹니다 — 코드 이야기는 Spring Boot Web 탭에 있습니다.
start.spring.io 화면 — 고를 것
| 항목 | 고를 값 · 이유 |
|---|---|
| Project | Gradle - Kotlin — 요즘 표준. Groovy 도 여전히 널리 씁니다 |
| Language | Java |
| Spring Boot | SNAPSHOT · M 표시가 없는 최신 안정판을 고릅니다 ★ |
| Group | com.example — 회사 도메인을 거꾸로 (kr.co.mycorp) |
| Artifact | demo — 프로젝트 이름. 소문자·하이픈 |
| Packaging | Jar — War 는 옛 방식입니다 |
| Java | 21 또는 25 — 내 PC 에 깐 것과 맞추세요 |
처음에 담을 의존성
| 이름 | 왜 |
|---|---|
| Spring Web | REST API — 사실상 필수 |
| Spring Boot DevTools | 코드를 고치면 자동 재시작 ★ |
| Lombok | getter/setter 자동 생성 |
| Validation | 요청 값 검증 |
| Spring Data JPA + H2 | DB 연습용 (설치 불필요한 메모리 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 가 나오면 성공입니다. 서버는 떴는데 만든 주소가 없을 뿐입니다 ★
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>
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 # 브라우저 새로고침까지
.class 파일이 바뀔 때 재시작하므로,
Settings → Build → Compiler → ☑ Build project automatically 를 켜야 합니다.
(예전 버전은 Registry 의 compiler.automake.allow.when.app.running 도 함께 켜야 했습니다.)
받은 프로젝트 안에 무엇이 들어 있나 🟢 기초
폴더 하나씩
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
build.gradle.kts 를 열고,
./gradlew bootRun 후 404 가 나오면 성공입니다.인코딩 · 로케일 — 한글이 깨지는 모든 경우
한국에서 자바를 하면 언젠가 반드시 만납니다.
?????, 안녕, 占쏙옙 — 모양은 달라도 원인은 몇 개 안 됩니다.
깨지는 지점이 소스인지, 컴파일인지, 콘솔인지를 가르는 법부터 잡습니다.
윈도우 콘솔 — 코드 페이지 바꾸기
# 지금 코드 페이지 확인
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 사용
# ※ 옛날 프로그램이 깨질 수 있어 "베타" 입니다. 감수하고 켜는 사람이 많습니다
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
# ↑ 에러가 아닙니다. 다만 스크립트 출력을 파싱할 때 방해가 될 수 있습니다
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>
에디터 · 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"' _ {} \;
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
-encoding UTF-8 ·
콘솔 chcp 65001, 그리고 필요하면 JAVA_TOOL_OPTIONS.
Java 18+ 는 내부 기본값이 UTF-8 이라 예전보다 훨씬 편해졌지만
콘솔과 소스 파일은 여전히 사람이 챙겨야 합니다.만든 것을 돌리고 나눠 주기 — jar · jlink · Docker
개발 PC 에서 도는 것과 남에게 건네주는 것은 다른 문제입니다. 상대 PC 에 자바가 없을 수도 있고, 서버에는 아예 다른 버전이 있을 수도 있습니다. 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
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) | 조금 낮음 |
| 리플렉션 | 자유 | 사전 설정 필요 |
서버에 그냥 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 네이티브는 시작 속도가 곧 돈일 때만 꺼내는 카드입니다.설치 문제 사전 — 증상 → 원인 → 해결
여기까지 따라 했는데도 안 되는 경우를 모았습니다. 에러 메시지를 그대로 찾아 해당 줄을 보세요. 대부분 “무엇이 어느 자바를 가리키고 있나” 하나로 설명됩니다.
증상 → 원인 → 해결 표
| 증상 (에러 메시지) | 원인 | 해결 |
|---|---|---|
'java'은(는) 내부 또는 외부 명령…java: command not found |
PATH 에 JDK 의 bin 이 없거나, 넣고 나서 터미널을 안 닫음 |
st03 으로 %JAVA_HOME%\bin 추가 → 터미널을 전부 닫고 새로. 그래도 안 되면 where.exe java 로 후보 확인 |
javac: command not foundjava 는 되는데 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 failedunable 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 에 기본 포함된 도구입니다.
javapath · 옛 JDK 경로를 전부 뺍니다.
③ 재부팅합니다. ④ st02 를 처음부터 다시 합니다.
30분이면 끝나고, 이상한 증상을 몇 시간 뒤쫓는 것보다 훨씬 빠릅니다.
where/which -a, echo JAVA_HOME,
./gradlew --stop 셋이면 충분합니다.
그리고 무엇을 고쳤든 터미널과 IDE 를 새로 열어야 반영됩니다.자바는 지금
다시 쓰는 언어가
되었습니다
2023년 Java 21, 2025년 Java 25 를 지나며 자바는 문법이 아니라 실행 모델이 바뀌었습니다.
스레드를 아껴 쓰던 시대가 끝났고(가상 스레드), 동시 작업을 try 블록처럼 묶게 되었고(구조적 동시성),
if-instanceof 사슬이 패턴 매칭으로 접혔고, C 라이브러리를 JNI 없이 부르게 되었고(FFM),
기동 시간을 AOT 캐시로 절반으로 줄이게 되었습니다.
이 탭은 “무엇이 새로 생겼나”가 아니라 “그래서 내 코드를 어떻게 바꾸나”만 다룹니다.
이 탭을 마치면 Java 8/11 로 짠 서비스를 21/25 로 올리고, 그 위에서 새 기능을 골라 쓸 수 있게 됩니다.
왜 지금 자바가 다시 빨라졌나 — 8 → 17 → 21 → 25
“자바는 느리고 장황하다”는 인상은 대부분 Java 8 시절의 기억입니다. 그 사이 자바는 6개월마다 새 버전을 내면서 문법·런타임·기동 시간을 전부 갈아엎었습니다. 먼저 무엇이 언제 들어왔는지 지도를 그리고, 그다음 내 프로젝트를 어디에 놓을지 정합니다.
버전이 오를 때마다 사라진 “관용구”
// ── 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 → 11 | var, 표준 HttpClient, G1 기본, 컨테이너 인식 | JAXB·CORBA 제거, 모듈 경계 |
| 11 → 17 | record · sealed · switch 식 · 텍스트 블록 · ZGC | 강한 캡슐화(리플렉션 차단) |
| 17 → 21 | 가상 스레드 · 패턴 매칭 · SequencedCollection | 드라이버 핀닝 점검 |
| 21 → 25 | ScopedValue 정식 · 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+ 는 이미 기본 동작)
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 로 폭넓게 (프리뷰 금지) |
synchronized 핀닝 해소(24+),
② ScopedValue 정식(25), ③ AOT 캐시·압축 객체 헤더(24/25).
가상 스레드를 진짜로 쓸 거라면 25 가 압도적으로 편합니다.
가상 스레드 — 스레드를 아끼지 않아도 되는 시대
가상 스레드는 “빨라지는 기능”이 아니라 스레드를 자원으로 취급하지 않아도 되게 만드는 기능입니다. 그 차이를 이해하려면 스레드가 블로킹될 때 실제로 무슨 일이 벌어지는지부터 봐야 합니다. 여기서는 마운트·언마운트·캐리어라는 세 단어만 확실히 잡습니다.
만드는 법 네 가지 — 그리고 각각 언제
// ① 딱 하나 띄우고 잊기 (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();
}
한 단계 더 — 컨티뉴에이션, 마법의 정체 🔴 고급
언마운트가 실제로 하는 일
가상 스레드가 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).
Executors.newVirtualThreadPerTaskExecutor() 하나면 되고,
풀에 담지 말고 이름을 붙이는 것 두 가지만 지키면 됩니다.가상 스레드 실전 함정 — 켜고 나서 만나는 것들
가상 스레드를 켠 뒤 “오히려 느려졌다”는 신고는 거의 항상 같은 네 가지입니다 — 핀닝 · ThreadLocal 폭증 · 커넥션 풀 · 다운스트림 과부하. 네 가지 전부 증상이 아니라 원인을 재는 법이 있습니다. 그걸 먼저 손에 넣습니다.
① 핀닝 — 재는 법과 고치는 법
# ── 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
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 등) — 코드 스타일이 바뀝니다
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
# → 한 지점에 수만 개가 몰려 있으면 그곳이 진짜 병목입니다
③④ 새 병목 — 커넥션 풀과 다운스트림 🔴 고급
스레드가 공짜여도 커넥션은 공짜가 아닙니다
[켜기 전] 톰캣 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);
jcmd Thread.dump_to_file -format=json 두 도구면 됩니다.Spring Boot 에서 가상 스레드 — 설정 한 줄이 바꾸는 것
spring.threads.virtual.enabled=true 한 줄로 켜집니다. 문제는 이 한 줄이 정확히 무엇을 바꾸고
무엇은 안 바꾸는지 모른 채 켜는 것입니다. 여기서는 교체되는 빈 목록을 하나씩 확인하고,
언제 리액티브 대신 Loom 을 고를지 판단 기준을 세웁니다.
그 한 줄이 실제로 교체하는 빈들
# application.yml — Spring Boot 3.2+ (4.x 동일)
spring:
threads:
virtual:
enabled: true
| 무엇이 | 어떻게 바뀌나 |
|---|---|
| 톰캣/제티 요청 처리 | 프로토콜 핸들러의 실행자를 가상 스레드로 |
applicationTaskExecutor | SimpleAsyncTaskExecutor(가상) — @Async |
taskScheduler | SimpleAsyncTaskScheduler — @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
보통 이런 모양이 나옵니다
| 동시 사용자 | 스레드 풀 200 | 가상 스레드 |
|---|---|---|
| 200 | p99 ≈ 210ms | p99 ≈ 210ms |
| 500 | p99 ≈ 620ms | p99 ≈ 215ms |
| 1,000 | p99 ≈ 1.2s | p99 ≈ 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 와 에러율로 확인한 뒤 넓히세요.구조적 동시성 — 동시 작업을 try 블록처럼 묶기
ExecutorService 로 세 개를 동시에 던지면, 그 셋은 서로 남남이고 부모와도 남남입니다.
하나가 터져도 나머지는 계속 돌고, 요청이 취소돼도 아무도 모릅니다.
구조적 동시성은 그 셋을 하나의 단위로 묶어 다 성공하거나 다 정리되거나를 보장합니다.
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 기준 아직 프리뷰이니, 공용 모듈이 아닌 갈아엎기 쉬운 자리에서 먼저 쓰세요.스코프 값 — ThreadLocal 을 대신할 것 Java 25 정식
로그인 사용자·추적 ID·테넌트처럼 요청 하나 동안 어디서든 읽고 싶은 값을
메서드 인자로 계속 넘기는 것은 고통스럽습니다. 그래서 ThreadLocal 을 썼는데,
ThreadLocal 은 언제 지워야 할지 아무도 모르고 가상 스레드에서는 개수만큼 늘어납니다.
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());
ScopedValue.runWhere(K, v, op)·callWhere(...) 정적 메서드는
지금은 없습니다. Java 25 정식 API 는
ScopedValue.where(K, v).run(...) / .call(...) 한 가지 형태입니다.
ThreadLocal 과 정확히 무엇이 다른가
| ThreadLocal | ScopedValue | |
|---|---|---|
| 변경 | 언제든 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"); }
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 커넥션 · 트랜잭션 | 그대로 — 스프링이 관리합니다 |
| 무거운 객체 캐시 | 둘 다 ❌ — 불변 객체로 바꾸세요 |
| 요청 중 누적하는 값 | 불변이라 부적합 — 반환값으로 |
Ctx.trace() 같은 것). ③ 그 헬퍼의 내부만 ScopedValue 로 갈아끼웁니다.
이렇게 하면 호출부를 하나도 안 고치고 전환할 수 있습니다.
ScopedValue 는 “호출 범위에 붙는 불변 상수”입니다.
where(K, v).run(...) 으로 묶고 K.get() 으로 읽으며,
블록이 끝나면 자동으로 사라져 누수가 성립하지 않습니다.
Java 25 에서 정식이라 플래그 없이 바로 쓸 수 있고, 구조적 동시성의 자식에게 복사 없이 전달됩니다.
다만 프레임워크는 아직 ThreadLocal 위에 있으니 내 코드부터 옮기세요.패턴 매칭 완전판 — if-instanceof 사슬의 끝
패턴 매칭은 “이 값이 이런 모양인지 확인하고, 맞으면 그 안을 꺼내는” 문법입니다.
확인과 꺼내기가 한 번에 일어나기 때문에 형변환이 사라지고,
sealed 와 합치면 빠뜨린 경우를 컴파일러가 잡아 줍니다.
여기까지 오면 자바에서도 대수적 데이터 타입을 쓸 수 있습니다.
패턴의 네 종류 — 한눈에
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 가 컴파일 에러로 알려 줍니다.
실무 적용 — 패턴 매칭이 실제로 값을 하는 자리 🔴 고급
이벤트 처리 · 외부 응답 파싱
// ① 도메인 이벤트 디스패치 — 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 정식 |
가드 when | Java 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 사슬처럼 선형으로 늘어나지 않습니다.
case Point(int x, int y) 는 내부적으로 p.x(), p.y() 를 호출합니다.
접근자를 재정의해서 부수효과를 넣어 두었다면 분해할 때마다 그게 실행됩니다.
record 의 접근자는 순수하게 두세요.
if (!(o instanceof X x)) return; 으로 코드를 평평하게 펴고,
sealed + switch + default 없음 조합으로 빠뜨린 경우를 컴파일러에게 맡기세요.
이 조합 하나로 방문자 패턴과 enum + if 사슬이 동시에 사라집니다.record · sealed · 값 지향 설계 — 무엇을 어디에 쓸까
record 문법 자체는 ☕ Java 기초 탭에서 다뤘습니다. 여기서는 설계 판단만 봅니다 —
어디까지 record 로 만들고 어디서 클래스로 돌아가야 하는지, sealed 계층을 어떻게 자르는지,
DTO 를 경계마다 몇 개 둘 것인지. 그리고 앞으로 올 값 클래스(Valhalla)가 이 판단을 어떻게 바꾸는지.
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 요청/응답 DTO | record |
| 도메인 값 객체(금액·좌표·기간) | record |
| 이벤트 · 메시지 페이로드 | record |
| 조회 결과 프로젝션(JPQL·QueryDSL) | record |
설정 바인딩(@ConfigurationProperties) | record |
| 메서드 안 임시 묶음 | 지역 record |
| JPA 엔티티 | 클래스 |
| 상태가 변하는 도메인 객체 | 클래스 |
| 필드가 계속 늘어날 공개 API | 클래스 + 빌더 |
| 상속 계층이 필요한 것 | 클래스(또는 sealed) |
경계마다 타입을 나누기 — 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);
};
}
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.
“누구든 새 구현을 추가할 수 있어야 한다” → 그냥 인터페이스.
결제 결과·주문 상태·파싱 결과·명령은 전자이고, 플러그인·전략·리포지토리는 후자입니다.
값 클래스(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 등)
→ 이렇게 짜 두면 값 클래스가 나왔을 때
선언 한 줄만 바꿔서 성능을 얻습니다.
텍스트 블록 · 새 API — 손이 자주 가는 것들
Java 11 로 짠 코드를 25 로 옮기면 직접 만들어 쓰던 유틸이 표준 API 로 바뀌는 자리가 꽤 나옵니다.
여기서는 실제로 매일 손이 가는 것만 모았습니다 —
텍스트 블록 실전, Stream Gatherers(Java 24 정식), SequencedCollection,
HttpClient, Files·Path, 그리고 Java 25 의 새 소스 파일 문법.
텍스트 블록 — 실무에서 쓰는 세 자리
// ① 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 까지 익히면
외부 라이브러리 없이도 상당히 멀리 갑니다.FFM API — JNI 없이 C 라이브러리 부르기 Java 22 정식
예전에는 자바에서 C 함수를 부르려면 C 로 브리지 코드를 짜서 컴파일해야 했습니다(JNI).
java.lang.foreign 은 그 과정을 통째로 없앴습니다 —
자바 코드만으로 네이티브 함수를 부르고 네이티브 메모리를 다룹니다.
게다가 메모리 수명과 경계를 JVM 이 검사해 줍니다.
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(아직 인큐베이터) 쪽 문제 |
JNI 와 나란히 놓고 보기
| JNI | FFM | |
|---|---|---|
| C 코드 작성 | 필요 | 불필요 |
| 플랫폼별 빌드 | 필요 | 불필요 |
| 메모리 해제 | 수동 | Arena 가 |
| 경계 검사 | 없음 | 있음 |
| 실수했을 때 | JVM 크래시 | 대부분 예외 |
| 호출 오버헤드 | 큼 | 작음 |
| 학습 곡선 | 가파름 | 보통 |
| 기존 자산 | 많음 | 늘어나는 중 |
--enable-native-access 없이는 경고가 나옵니다.
앞으로의 릴리스에서 에러로 바뀔 예정이니,
지금 쓰는 네이티브 접근은 전부 기동 옵션에 명시해 두세요.
Arena.ofConfined() 로 감싸고, downcallHandle 로 함수를 얻어 invokeExact 로 부릅니다.
Java 24 부터는 --enable-native-access 를 반드시 붙이세요.
헤더가 크면 jextract, 작으면 손으로 — 그리고 네이티브 호출 중에는 가상 스레드가 캐리어를 놓지 못한다는 점만 기억하면 됩니다.GC · 성능 — Java 24~25 에서 새로 생긴 손잡이
GC 의 기본기(세대 구조 · 로그 읽는 법 · 힙 덤프)는 🧬 고급·동시성 탭에서 다뤘습니다. 여기서는 21 이후에 새로 생긴 것만 봅니다 — 세대별 ZGC, 압축 객체 헤더(힙 10~20% 절감), AOT 캐시(기동 시간 절반), 그리고 GraalVM native-image 와의 갈림길.
G1 이냐 ZGC 냐 — 판단 기준
| 기준 | G1 (기본) | ZGC |
|---|---|---|
| 힙 크기 | ~32GB 무난 | 수백 GB 까지 |
| 최대 멈춤 | 수십~수백 ms | 1ms 미만 |
| 처리량 | 조금 유리 | 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
압축 객체 헤더 — 공짜에 가까운 절감
# 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
기동을 줄이는 네 가지 카드 — 나란히
| 방법 | 기동 | 대가 |
|---|---|---|
| 아무것도 안 함 | 기준 | — |
| 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
-XX:AOTCache 로 기동을 30~50% 줄이고, -XX:+UseCompactObjectHeaders 로 힙을 10~20% 줄이는 것이
지금 가장 값싼 두 장의 카드입니다. GC 는 고민되면 G1, p99 가 튀면 ZGC 로 충분하고,
native-image 는 “짧게 뜨고 사라지는 워크로드”일 때만 값을 합니다.Spring AI — 자바 서비스에 LLM 붙이기
LLM 연동은 파이썬만의 일이 아닙니다. Spring AI(1.0 GA 2025-05)는
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);
}
}
② 질의 — 검색 결과를 근거로 넣기
// 방법 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 가 대신 호출
// → 결과를 모델에게 돌려줌 → 모델이 자연어로 정리 → 최종 답변
비용 · 타임아웃 · 관측
// ① 토큰 사용량을 지표로 — 비용은 여기서만 보입니다
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 ← 운영에서는 끄세요 (개인정보)
ChatModel 추상화가 있는 이유가 바로 이것입니다.
ChatClient 하나로 시작합니다.
실무 코드가 되는 지점은 세 개입니다 — .entity(record) 로 구조화 출력,
벡터 스토어 + 근거 주입(RAG), @Tool 로 내 API 연결.
그리고 운영에 올릴 때는 토큰 지표 · 타임아웃 · 재시도 상한 · 동시 호출 제한을
다른 외부 API 와 똑같이 걸어 두세요.마이그레이션 실전 — 8/11 에서 21/25 로
실패하는 마이그레이션은 대부분 순서를 섞어서 실패합니다 — JDK 를 올리면서 의존성도 올리고 문법도 바꾸면, 깨졌을 때 무엇이 범인인지 알 수 없습니다. 여기서는 한 단계씩 되돌릴 수 있는 순서와, 각 단계에서 실제로 만나는 벽, 그리고 기계가 대신해 주는 부분을 정리합니다.
단계 2~3 에서 만나는 벽 — 제거된 것들
| 사라진 것 | 버전 · 대안 |
|---|---|
| JAXB · JAX-WS | 11 — jakarta.xml.bind 를 의존성으로 추가 |
| CORBA · Java EE 모듈 | 11 — 별도 라이브러리 |
| Nashorn (JS 엔진) | 15 — GraalJS |
| Applet · RMI Activation | 17/21 — 대안 없음 |
| Security Manager | 24 영구 비활성 — 컨테이너 격리로 대체 |
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 로의 변환, 패턴 매칭 도입 —
설계 판단이 필요해서 사람이 해야 합니다.
의존성 호환 — 2026년 기준 현실 🔴 고급
먼저 확인해야 할 것들
| 라이브러리 | 메모 |
|---|---|
| Gradle | 먼저 올려야 합니다 — 옛 버전은 새 JDK 를 인식조차 못 합니다 |
| Lombok | JDK 를 가장 심하게 탑니다 — 최신으로. 안 되면 record 로 줄이세요 |
| ByteBuddy / ASM | Mockito·Hibernate·스프링 AOP 가 이걸 씁니다. 새 클래스 파일 버전 지원 필요 |
| Mockito | 인라인 목 · --add-opens 이슈. 최신으로 |
| Jackson | record·sealed 지원. -parameters 컴파일 옵션 권장 |
| Hibernate / JPA | 6.x 이상 + jakarta. 5.x 는 javax 라 함께 못 씁니다 |
| Jacoco · 커버리지 | 새 클래스 파일 버전 지원이 늦는 편입니다 |
| 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 정식 |
| SequencedCollection | 21 정식 |
| FFM API | 22 정식 |
| Stream Gatherers | 24 정식 |
| ScopedValue | 25 정식 |
| 모듈 import · 간결한 소스 | 25 정식 |
| 압축 객체 헤더 | 25 정식 (24 실험) |
| AOT 캐시 | 24 정식 (25 간소화) |
| 구조적 동시성 | 프리뷰 — --enable-preview |
| 원시 타입 패턴 | 프리뷰 |
| 값 클래스(Valhalla) | 아직 준비 중 |
java.util.concurrent·CompletableFuture 를 먼저 다지고,
부하와 지표를 다룰 거라면 ⚡ 대규모 트래픽 탭의 병목 지도·관측·부하 테스트로 이어 보세요.
jdeprscan·jdeps --jdk-internals 두 개면 시작할 수 있습니다.
그리고 롤백 기준을 숫자로 미리 못 박아 두세요 — 그게 있어야 과감하게 올릴 수 있습니다.운영에서
남는 것은
로그뿐입니다
장애가 났을 때 디버거를 붙일 수는 없습니다. 그때 남아 있는 것은 로그가 전부입니다. 그런데 자바 로깅은 처음 보면 이상합니다 — 왜 SLF4J 와 Logback 을 둘 다 넣나, 왜 의존성 하나 잘못 넣으면 로그가 통째로 사라지나, 왜 로거가 원격 코드 실행 취약점이 됐나. 이 탭은 파사드 + 구현이라는 두 겹 구조에서 출발해 구현체 선택 · 설정 · 비동기 · JSON · MDC · 성능 · 보안까지 한 번에 잇습니다. 이 탭을 마치면 로그를 "찍는" 것이 아니라 "설계"할 수 있게 됩니다.
자바 로깅의 역사 — 왜 두 겹이 됐나
다른 언어는 대개 로거가 하나입니다. 파이썬은 logging, Go 는 log/slog.
자바만 파사드(SLF4J) + 구현(Logback·Log4j2) 두 겹입니다.
이유는 기술이 아니라 역사에 있습니다 — 그리고 그 역사를 알면 오늘의 의존성 문제가 전부 설명됩니다.
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 은 동기화된 PrintStream 입니다.
요청 스레드 200개가 동시에 찍으면 전부 같은 락을 기다립니다.
"로그를 지웠더니 TPS 가 올랐다"는 이야기의 절반이 여기서 나옵니다.
네 세대 요약 — 지금 무엇이 남았나
| 세대 | 대표 | 지금 위치 |
|---|---|---|
| 1세대 | System.out | 테스트 코드에만 |
| 2세대 | Log4j 1.x | 2015년 수명 종료 |
| 2세대 | commons-logging | 낡은 라이브러리에 잔존 |
| 3세대 | java.util.logging | JDK 내장 · 도구류에서 |
| 4세대 | SLF4J (파사드) | 사실상 표준 |
| 4세대 | Logback | Spring Boot 기본값 |
| 4세대 | Log4j2 | 대안 · 비동기가 강점 |
reload4j 라는 보안 패치 포크를 따로 냈습니다.
신규 프로젝트가 고를 이유는 전혀 없습니다.
한 단계 더 — 파사드 + 구현이라는 두 겹 🟡 중급
라이브러리 입장에서 생각해 보기
// 내가 오픈소스 라이브러리를 만든다고 합시다.
// 로그를 남기고 싶은데, 쓰는 쪽이 어떤 로거를 쓸지 모릅니다.
// ❌ 구현을 직접 의존하면 — 사용자에게 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]
// → 실행은 되지만 '어느 쪽이 이겼는지'는 클래스패스 순서 나름
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 가 이긴 뒤에 나왔습니다.
구현체 비교 — Logback · Log4j2 그리고 나머지
고를 것은 사실상 두 개입니다. 나머지는 "이미 들어와 있어서 알아야 하는 것"에 가깝습니다. 아래 표는 절대 점수가 아니라 상대 위치입니다 — 숫자를 지어내지 않았습니다.
채택 정도 — 숫자 대신 상대 위치로
| 구현체 | 위치 | 고르는 상황 |
|---|---|---|
| Logback | 사실상 표준 | Spring Boot 면 그냥 기본값 |
| Log4j2 | 널리 씀 | 초당 수십만 줄 · 비동기 |
java.util.logging | 내장이라 늘 있음 | 고르는 게 아니라 만남 |
| tinylog | 특정 상황에서만 | 의존성 최소화 · 안드로이드 |
| Log4j 1.x | 신규 채택 없음 | 없음 — 걷어낼 대상 |
reload4j | 임시 방편 | Log4j 1.x 를 못 걷어낼 때 |
./gradlew dependencies ·
mvn dependency:tree 로 이미 뭐가 들어와 있는지부터.
2026년 8월 기준 대략의 인상은 위 표와 같지만, 시점이 지나면 바뀝니다.
고르는 기준 — 세 문장으로
✅ Logback 그대로
application.yml 로 끝내고 싶다 ·
고민하기 싫다.→ 아무것도 안 하면 이미 Logback 입니다.
🔁 Log4j2 로 교체
→ 교체 비용은 의존성 정리 + 설정 파일 다시 쓰기 정도입니다.
고를 때 실제로 던지는 질문 세 개
① 지금 초당 몇 줄을 찍고 있나?
→ 모르면 먼저 재세요. 대부분 '생각보다 적게' 찍습니다.
② 로깅이 p99 지연에 잡히나?
→ 비동기 전환은 여기 답이 '예'일 때만 의미가 있습니다.
③ 팀이 XML 설정을 관리할 수 있나?
→ 못 하면 Logback + application.yml 이 정답입니다.
이렇게도 쓸 수 있다 — 나머지 셋의 자리 🟡 중급
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")
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 가 같아서 코드 수정 없이 바뀝니다)
SLF4J 사용법 — {} 하나가 만드는 차이
log.debug("id=" + id) 와 log.debug("id={}", id) 는 결과가 같아 보입니다.
레벨이 꺼져 있을 때 완전히 달라집니다 — 그리고 운영에서 DEBUG 는 대개 꺼져 있습니다.
{} 는 검사를 앞으로 당깁니다.기본기 다섯 줄
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(...);
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() 에 맡기는 편이 안전합니다.
의존성 지옥 풀기 — 바인딩 하나 · 브리지 여럿
로깅에서 가장 자주 나는 사고는 코드 문제가 아니라 클래스패스 문제입니다. "로그가 안 나온다" · "두 번 찍힌다" · "라이브러리 로그만 안 보인다" — 전부 이 절의 이야기입니다.
증상으로 원인 찾기
| 증상 | 원인 |
|---|---|
| 로그가 하나도 안 나옴 | 바인딩 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 와 운영 서버의 클래스패스 순서가 다르면
'어느 쪽이 이기는지'가 환경마다 달라집니다. ★
Defaulting to no-operation (NOP) logger implementation 은
예외가 아니라 경고입니다. 애플리케이션은 멀쩡히 뜨고,
로그만 조용히 사라집니다. 장애 나서야 알게 됩니다.
기동 로그의 SLF4J 줄을 CI 에서 검사하는 팀도 있습니다.
브리지 네 개 — 이름 규칙만 알면 됩니다
| 아티팩트 | 하는 일 |
|---|---|
jul-to-slf4j | java.util.logging → SLF4J |
jcl-over-slf4j | commons-logging → SLF4J |
log4j-over-slf4j | Log4j 1.x → SLF4J |
log4j-to-slf4j | Log4j2 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-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>
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)가 실제로 찍히는지 눈으로 확인한다.
Logback 설정 — appender · 롤링 · 프로파일
Spring Boot 는 application.yml 만으로도 꽤 갑니다. 그러다 어느 순간
패턴을 바꾸고 싶고 · 파일을 나누고 싶고 · 프로파일마다 다르게 하고 싶어집니다.
그때 필요한 것이 logback-spring.xml 한 장입니다.
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 | 줄번호·메서드·클래스 — 매우 느림 |
실무에선 이렇게 — 설정이 안 먹을 때 🔴 실무
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"/>
additivity 입니다.
Logback 의 로거는 부모에게도 전달합니다 —
com.myapp.Order 로거에 appender 를 붙이면
그 appender + root 의 appender 양쪽으로 나갑니다.
따로 빼려면 additivity="false" 를 반드시 붙이세요.
Log4j2 와 비동기 로깅 — Disruptor 가 뭘 바꿨나
Logback 에도 AsyncAppender 가 있습니다. 그런데 Log4j2 의 AsyncLogger 는
다른 층에서, 다른 자료구조로 비동기를 합니다. 그 차이가 처리량 차이의 정체입니다.
AsyncAppender 와 AsyncLogger 는 다른 층입니다
| Logback AsyncAppender | Log4j2 AsyncLogger | |
|---|---|---|
| 비동기 지점 | appender 앞 | logger 호출 직후 |
| 자료구조 | ArrayBlockingQueue | LMAX 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>
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 에서만 켜세요.
직접 재는 법
// 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
구조적 로깅 — 문장 대신 필드로 찍기
로그를 사람이 grep 하던 시대에는 문장이 좋았습니다.
수집기에 넣고 검색·집계하는 시대에는 필드가 좋습니다.
바꾸는 데 필요한 것은 인코더 하나와 필드 이름 규약입니다.
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>
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.id | MDC |
| 무슨 일 | event.action + 도메인 값 | 코드 |
| 실패 | error.type error.stack_trace | 인코더 |
이름 규칙 세 개
① 소문자 + 점(.) 계층 user.id, http.response.status_code
② 같은 뜻은 같은 이름 userId / user_id / uid 를 섞지 않기
③ 값의 타입을 고정 orderId 는 항상 숫자, 항상 문자열 중 하나
바꿀 때는 '한동안 둘 다' 를 찍습니다
· 새 필드를 추가 → 대시보드를 옮김 → 옛 필드를 제거
· 이름을 바로 바꾸면 그날의 대시보드가 통째로 빕니다
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 .
MDC — 요청 ID 를 로그마다 자동으로
장애 분석의 첫 단계는 언제나 같습니다 — "그 요청이 남긴 줄만 모아 보기".
매번 traceId 를 인자로 넘길 수는 없으니, 스레드에 붙여 두고 패턴이 알아서 찍게 합니다.
그게 MDC 이고, 스레드가 바뀌는 순간 새 나갑니다.
필터에서 심고 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 라면 필드 검색
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 를 검토하세요.
Spring Boot 로깅 — 재시작 없이 레벨 바꾸기
운영 중에 "이 부분만 DEBUG 로 보고 싶다"가 생깁니다.
배포하고 재시작하면 그 사이 증상이 사라집니다.
Actuator 의 loggers 엔드포인트는 이 문제를 한 번의 POST 로 끝냅니다.
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 # 바인딩 값 전부
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'
성능과 비용 — 얼마나 찍을지를 설계합니다
로그를 많이 찍으면 진단은 쉬워지고 지연·비용은 나빠집니다. 반대도 마찬가지입니다. 정답이 하나가 아니라 상충이므로, "어디에 놓을지"를 의식적으로 정해야 합니다.
먼저 재세요 — 볼륨 계산
# ① 지금 초당 몇 줄을 찍고 있나
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
샘플링 — 다 찍지 않고 살리기
// ① 요청 단위 샘플링 — 뽑힌 요청만 통째로 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 | 켬 | 재시도 성공 · 폴백 · 임계치 근접 |
| 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;
}
// 이 한 줄만으로 성공률 · 지연 분포 · 실패 사유 분포가 전부 나옵니다.
로깅이 지연·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)과 이벤트 이름이면 위치는 충분히 좁혀집니다.
Log4Shell 의 교훈 — 로거가 공격 통로가 되던 날
2021년 12월, 로그 한 줄이 원격 코드 실행이 됐습니다. 원인은 어려운 버그가 아니라 "로그 메시지를 데이터가 아니라 명령으로 해석했다"는 설계였습니다. 이 절은 그때 무슨 일이 있었는지보다 지금 내 프로젝트에서 무엇을 확인해야 하는지에 초점을 둡니다.
무슨 일이었나 — 5분 요약
| 항목 | 내용 |
|---|---|
| 식별자 | CVE-2021-44228 · CVSS 10.0 (최고 등급) |
| 영향 범위 | log4j-core 2.0-beta9 ~ 2.14.1 |
| 조건 | 공격자가 넣은 문자열이 로그 메시지에 들어가기만 하면 |
| 2.15.0 | JNDI 를 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.x | JMSAppender 를 설정했을 때만 (CVE-2021-4104) |
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"
실무에선 이렇게 — 다음 Log4Shell 을 대비하는 구조 🔴 실무
빌드에 스캔을 붙이기
// 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 .
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 한 줄로 올릴 수 있게 |
| 로거도 아웃바운드 | 앱 서버의 바깥 방향 네트워크 차단 |
${env:HOSTNAME} · ${sys:user.dir} 같은 Lookup 은
설정 파일(log4j2.xml)에서 경로나 호스트명을 끼워 넣으려고 만든 것입니다.
문제는 그 해석이 메시지 문자열에도 적용됐다는 점, 그리고 JNDI 라는 "이름으로 아무 객체나 가져오는" 표준 API 가
Lookup 목록에 있었다는 점입니다. 둘 다 각각은 합리적이었고, 합쳐지자 폭발했습니다.
보안 사고는 대개 이렇게 정상 기능 두 개의 교차점에서 납니다.
예외 로깅 — 한 곳에서 한 번, 스택은 필요한 만큼만
예외 로그는 장애 분석의 주식인데, 실무 로그를 열어 보면 같은 스택이 세 번 찍혀 있거나 메시지만 있고 스택이 없거나, 스택이 200줄이라 정작 원인 줄을 못 찾습니다. 규칙은 셋입니다 — 어디서 잡나 · 무엇을 남기나 · 얼마나 남기나.
기본기 — 스택이 사라지는 다섯 가지 방법
// ✅ 유일한 정답 — 예외를 '마지막 인자'로
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 · 검증 실패 | 없음 |
| 흐름 제어용 예외 | 생성조차 안 함 |
error.stack_trace 필드 하나로 들어가 이 문제가 사라집니다(LOG 07).
새벽 세 시에
알람이 울려도
당황하지 않기
장애 당번 첫날에 필요한 것은 JVM 내부 이론이 아닙니다. 무엇을 먼저 보고, 어떤 명령을 치고, 언제 롤백을 누를지입니다. 이 탭은 신입부터 3년차까지가 내일 아침 운영 당번을 서도 될 만큼 실무 절차만 모았습니다 — 첫 5분 런북, 스레드 덤프·힙 덤프·GC 로그 읽는 법, 커넥션 풀과 타임아웃 값을 정하는 계산, 알람 10개, 무중단 배포, 프로파일링, 그리고 포스트모템까지. 이론은 한 줄로 다른 탭에 넘기고, 여기서는 복사해서 바로 치는 명령과 실제 설정값, 판단 기준에 집중합니다. 이 탭을 마치면 "어디부터 봐야 하지?"가 "이 순서로 보면 된다"로 바뀝니다.
장애 첫 5분 런북 — 분류하고, 보고, 멈춘다
장애 대응에서 가장 비싼 실수는 원인부터 찾으려는 것입니다. 첫 5분의 목표는 원인이 아니라 증상 분류 → 최근 변경 확인 → 출혈을 멈출지 결정입니다. 이 순서를 몸에 익혀 두면 알람이 울렸을 때 손이 먼저 움직입니다.
증상 세 갈래 — 무엇을 먼저 보나
| 증상 | 먼저 볼 지표 | 첫 명령 · 화면 |
|---|---|---|
| 느림 | p99 지연 · 스레드 busy · Hikari pending · GC 시간 | jcmd 1 Thread.print 3회 (OPS 02) |
| 에러 | 5xx 비율 · 상태코드 분포 · 의존 서비스 에러율 | 최근 10분 에러 로그 종류별 개수 |
| 죽음 | 재시작 횟수 · 컨테이너 메모리 · 헬스체크 | kubectl describe pod 의 Last State |
| 일부만 | 인스턴스별 · 엔드포인트별 분해 | 한 대만 이상하면 그 한 대를 빼기 |
첫 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 .
한 단계 더 — 5분을 쪼개 쓰기 🟡 중급
롤백 판단 기준 — 셋 중 하나면 누릅니다
| 조건 | 판단 |
|---|---|
| 증상 시작이 배포 직후 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
실무에선 이렇게 — 말로 상황을 공유하는 법 🔴 실무
상황 선언 · 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분 안에 한 명 더 부르기 | "조금만 더 보고" 버티기 |
스레드 덤프 읽기 — "지금 다들 뭐 하고 있나"를 찍은 사진
"서버가 느리다"의 절반 이상은 스레드 덤프 세 장이면 원인이 보입니다. 덤프는 모든 스레드가 그 순간 서 있는 코드 줄을 찍은 사진입니다. 한 장은 우연일 수 있으니 5초 간격으로 세 장 떠서, 계속 같은 곳에 몰려 있는 스레드를 찾습니다. 스레드 상태와 락의 원리는 🧬 고급 탭 ADV 04~05에 있고, 여기서는 읽는 법만 다룹니다.
덤프 뜨기 — 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
eclipse-temurin:25-jre 같은 이미지라면
kill -3 이 유일한 길입니다. 운영 이미지를 만들 때 jcmd 를 쓸 수 있게 할지
미리 정해 두세요(JDK 이미지 또는 jlink 에 jdk.jcmd 모듈 포함).
상태 여섯 개 — 덤프에서의 뜻
| 상태 | 덤프에서의 뜻 | 많으면 |
|---|---|---|
| RUNNABLE | CPU 를 쓰거나 소켓 · 파일 I/O 대기 | 맨 위 프레임이 read 면 외부가 느림 |
| BLOCKED | synchronized 입구 대기 | 락 경합 — 쥔 쪽을 찾기 |
| WAITING | park · wait() 무기한 | 풀 대기면 자원 고갈 |
| TIMED_WAITING | sleep · 시간 제한 대기 | 대개 유휴 스레드 — 정상. 단 맨 위가 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
한 단계 더 — 세 장을 겹쳐 보는 법 🟡 중급
맨 위 프레임 집계 — 위 히트맵을 만드는 명령
# 각 스레드의 '상태 다음 첫 번째 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
가상 스레드를 쓴다면 (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 은 대부분 해소
실무에선 이렇게 — 자주 만나는 패턴 세 가지 🔴 실무
① 데드락 — 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:...)
→ 커넥션을 쥔 스레드들이 무엇을 하는지(트랜잭션 안 외부 호출?)가 진짜 원인
힙 덤프와 OOM — 메시지가 이미 칸 이름을 알려 줍니다
OutOfMemoryError 는 한 종류가 아닙니다. 어느 메모리 칸이 찼는지가 메시지에 그대로 찍히고,
칸마다 원인과 처방이 완전히 다릅니다. 그리고 힙이 원인이라면 죽는 순간의 힙 덤프가 유일한 증거입니다 —
그래서 덤프 옵션은 장애 전에 켜 둬야 합니다. 힙 구조와 MAT 의 원리는 🔬 전문가 탭 DEEP 07에 있습니다.
OOM 다섯 종류 — 메시지별 첫 조치
| 메시지 | 흔한 원인 | 첫 조치 |
|---|---|---|
Java heap space | 누수 · 한 번에 너무 많이 로드(전체 조회 · 엑셀) | 힙 덤프 분석 |
GC overhead limit exceeded | 힙이 거의 꽉 찬 채 GC 만 반복 | 위와 같음 — 사실상 같은 병 |
Metaspace | 클래스 동적 생성 누수(프록시 · 스크립트 엔진 · 핫 리로드) | jcmd 1 VM.metaspace |
unable to create native thread | 스레드 무한 생성 · ulimit -u · pids 제한 | 스레드 덤프로 풀 이름별 개수 |
Direct buffer memory | Netty · NIO 버퍼 해제 누락 | -XX:NativeMemoryTracking |
| OOMKilled (137) | 힙 외 메모리 합이 컨테이너 한도 초과 | Xmx 비율 낮추기 (OPS 04) |
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 | 힙 밖 메모리를 칸별로 보기(약간의 비용) |
한 단계 더 — 누수인지 그냥 많이 쓰는 건지 🟡 중급
살아 있는 프로세스에서 덤프 뜨기
# ① 먼저 가볍게 — 클래스별 개수 · 크기 상위 (몇 초)
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분 간격으로 두 번 떠서 비교하면
# '계속 늘어나는 클래스'가 바로 보입니다
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
| # | 화면 | 무엇을 보나 |
|---|---|---|
| 1 | Leak Suspects | "한 객체가 힙의 72%를 잡고 있다" — 첫 가설 |
| 2 | Dominator Tree | Retained Heap 내림차순 맨 위 몇 개 |
| 3 | Path to GC Roots | weak/soft 제외 — 누가 놓아주지 않나 |
| 4 | Histogram | 개수가 비정상인 우리 클래스 |
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() |
| 리스너 · 콜백 등록만 하고 해제 안 함 | 등록 해제 · 약한 참조 |
HeapDumpOnOutOfMemoryError 와
살아남는 덤프 경로는 미리 켜 두고, 누수 판단은 GC 직후 힙의 바닥 추세로,
분석은 MAT 의 Leak Suspects → Dominator Tree(Retained) → Path to GC Roots 순서로 합니다.
덤프가 없으면 OOMKilled — 힙이 아니라 컨테이너 한도 문제입니다.GC 로그 해석과 튜닝 — 플래그보다 로그 한 줄이 먼저
GC 튜닝의 90%는 힙 크기를 맞게 주는 것이고, 나머지 10%를 판단하려면 GC 로그가 있어야 합니다. 로그 없이 플래그를 바꾸는 것은 증상 없이 약을 바꾸는 것과 같습니다. G1 · ZGC 의 내부 동작은 🔬 전문가 탭 DEEP 06, 운영 JVM 튜닝 전반은 🧬 고급 탭 ADV 13을 보세요.
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 멈춤 | 수~수십 ms | p99 이 목표(200ms) 근처 |
| Pause Full | 0회 | 한 번이라도 나오면 조사 |
| GC 후 힙(→ 뒤 숫자) | 평평 | 계속 오름 → OPS 03 누수 |
| GC 시간 비율 | < 5% | > 10% — 처리량이 깎이는 중 |
| Humongous | 가끔 | 잦으면 리전 크기 · 코드 확인 |
| ZGC Allocation Stall | 0 | 나오면 힙이 작다는 뜻 |
# 로그를 잴 때 쓰는 한 줄 — 멈춤 시간 상위 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
한 단계 더 — G1 이냐 ZGC 냐 🟡 중급
켜는 법 · 버전별 차이
# 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 웹 API | G1 | 멈춤 수십 ms 로 충분 |
| 힙 16 GB+ · p99 < 10 ms | ZGC | 멈춤이 힙 크기와 무관 |
| 야간 배치 · 처리량만 | Parallel | 멈춤 길어도 총 시간 짧음 |
| CPU 1개 · 힙 수백 MB | Serial | GC 스레드 여럿이 오히려 손해 |
실무에선 이렇게 — 컨테이너 메모리 잡기 🔴 실무
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 GiB | 65~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 를 명시
MaxRAMPercentage 를 65~75%로,
CPU 는 2개 이상을 주거나 GC 를 명시하세요.커넥션 풀 고갈 — 풀을 늘리기 전에 '쥔 시간'을 보세요
자바 웹 서비스 장애에서 가장 자주 보는 로그 한 줄이 Connection is not available, request timed out 입니다.
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])))
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-timeout | 30000 | 2000~5000 — 빨리 실패해 서킷 · 폴백이 일하게 |
maximum-pool-size | 10 | 계산값. 보통 10~30 |
max-lifetime | 1800000 | 인프라 유휴 끊김보다 30초 이상 짧게 |
leak-detection-threshold | 0(끔) | 정상 최장 트랜잭션의 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)
Previously reported leaked connection ... was returned 가 따라 찍힙니다.
실무에선 이렇게 — 쥔 시간을 줄이는 코드 🔴 실무
❌ 트랜잭션 안에서 외부 호출
@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);
// 저장 실패 시: 예외를 던지고 재시도 큐로 → 재시도 때 같은 키로 조회만 됨
// 또는 '결제 요청됨' 상태를 먼저 커밋해 두고 결과를 나중에 반영 (상태 기계)
그 밖에 커넥션을 오래 쥐는 코드
| 패턴 | 처방 |
|---|---|
open-in-view: true(기본값) | false — 뷰 렌더링 · 직렬화 동안 쥐지 않게 |
| 트랜잭션 안의 대용량 루프 · 파일 생성 | 청크로 나눠 짧은 트랜잭션 여러 개 |
REQUIRES_NEW 중첩 | 스레드 하나가 커넥션 2개 → 풀 교착 |
@Transactional 안 Thread.sleep · 락 대기 | 트랜잭션 밖에서 대기 |
REQUIRES_NEW 가 11번째를 기다리면 아무도 못 끝납니다.
HikariCP 권장식 풀 ≥ 스레드 수 × (한 스레드의 최대 동시 커넥션 − 1) + 1 을 지키거나 중첩을 없애세요.
connection-timeout 은 30초 기본값을 3초 안팎으로 줄입니다.슬로우 쿼리 실전 추적 — 가장 느린 것 말고 '총합이 큰 것'
"느린 쿼리 찾아 주세요"라는 말을 들으면 보통 한 번에 4초 걸리는 리포트 쿼리를 찾습니다. 그런데 서비스를 실제로 무겁게 만드는 것은 대개 3ms 짜리가 5만 번 도는 쿼리입니다. 이 섹션은 DB · 앱 · 테스트 세 곳에서 쿼리를 잡는 법을 다룹니다. N+1 을 고치는 JPA 기법(fetch join · batch size)은 🧩 JPA 심화 탭에 있습니다.
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 | 바인딩 값 · 호출 위치 확인 |
| ORM | Hibernate 통계 · 슬로우 로그 | 세션당 쿼리 수 |
| 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-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
@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 temporary | MySQL 정렬 · 임시 테이블 — ORDER BY 인덱스 |
Nested Loop + 바깥 행 많음 | 조인 순서 · 조인 컬럼 인덱스 확인 |
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에서 예산과 함께 정합니다.
타임아웃 · 재시도 · 서킷 — 값을 '계산'해서 정하기
"타임아웃은 몇 초로 하죠?"에 "3초요"라고 감으로 답하면 장애 때 두 가지가 터집니다. 하위 호출 타임아웃의 합이 상위보다 길어서 사용자는 이미 떠났는데 서버만 일하거나, 재시도가 곱해져서 아픈 서비스를 더 때리는 것입니다. 서킷 브레이커의 원리는 ⚡ 대규모 탭 SCALE 11에 있고, 여기서는 숫자를 정하는 법만 다룹니다.
타임아웃 여섯 종류 — 전부 따로 정합니다
| 종류 | 뜻 | 보통 값 |
|---|---|---|
| 연결 | TCP 연결 맺기까지 | 사내 200~500 ms · 외부 1 s |
| 읽기(응답) | 응답 바이트를 기다리는 최대 시간 | 상대 p99 × 2~3 |
| 풀 획득 | Hikari · HTTP 커넥션 풀 대기 | 1~3 s (OPS 05) |
| 쿼리 | SQL 한 문장 | OLTP 1~3 s |
| 트랜잭션 | @Transactional(timeout=) | 쿼리 상한의 합 + 여유 |
| 전체 호출 | 재시도 포함 총 시간 | 상위 예산에서 역산 |
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) { ... }
실무에선 이렇게 — 재시도가 장애를 키우지 않게 🔴 실무
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 응답 | 하지 않음 — 다시 해도 같음 |
지표 대시보드 — RED · USE 와 꼭 걸어야 할 알람 10개
대시보드에 그래프 80개를 띄워 두면 장애 때 아무것도 안 보입니다. 필요한 것은 "사용자가 아픈가"(RED)와 "자원이 아픈가"(USE)를 한 화면에서 보는 것, 그리고 사람을 깨울 알람 10개입니다. 관측 가능성의 개념(지표 · 로그 · 트레이스)은 ⚡ 대규모 탭 SCALE 14, Actuator 기초는 🍃 Spring Boot 탭 BOOT 15에 있습니다.
켜기 — 의존성 두 줄 + 설정
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 |
| Errors | 5xx 비율 | 위 지표의 status=~"5.." |
| Duration | p99 지연 | ..._seconds_bucket |
| Utilization | CPU · 힙 | 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
한 단계 더 — 꼭 걸어야 할 알람 10개 🟡 중급
| # | 알람 | 조건 (예시값) | 임계값 근거 | 등급 |
|---|---|---|---|---|
| 1 | 5xx 비율 | > 1% 가 5분 지속 | SLO 99.9% 의 에러 예산을 10배 속도로 태우는 수준 | 호출 |
| 2 | p99 지연 | > SLO × 2 가 10분 | SLO 가 500 ms 면 1 s. 순간 튐은 for 로 거름 | 호출 |
| 3 | 트래픽 급감 | 지난주 같은 시각의 50% 미만 | 조용한 장애(앞단 차단 · DNS) 는 에러율로 안 잡힘 | 호출 |
| 4 | 인스턴스 다운 | up == 0 1분 | 스크레이프 실패 = 프로세스 · 네트워크 이상 | 호출 |
| 5 | 재시작 반복 | 15분에 2회 초과 | OOMKilled · liveness 실패의 신호 | 호출 |
| 6 | GC 후 힙 | > 85% 가 15분 | 누수 조기 경보 — OOM 몇 시간 전 (OPS 03) | 티켓 |
| 7 | GC 시간 비율 | > 10% (5분 평균) | 처리량이 깎이기 시작하는 지점 | 티켓 |
| 8 | Hikari 대기 | 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개가 울리면 아무것도 안 읽힘 |
curl -s localhost:8081/actuator/prometheus | grep -E "^(hikaricp|jvm_gc|tomcat_threads)" | cut -d'{' -f1 | sort -u
로 지금 앱이 내보내는 이름을 먼저 보세요.
for: 와 런북 링크를 붙이고,
울렸는데 할 일이 없던 알람은 지웁니다.무중단 배포 — 끄는 순서와 DB 호환이 전부
배포할 때마다 5xx 가 몇십 건씩 튄다면 원인은 둘 중 하나입니다. 파드를 끄는 순서가 틀렸거나, 새 코드와 옛 코드가 같은 DB 를 동시에 쓰는 몇 분을 생각하지 않은 것입니다. 쿠버네티스 · Docker 배포 기초는 🍃 Spring Boot 탭 BOOT 16~17에 있고, 여기서는 그 두 가지만 정확히 맞춥니다.
앱 쪽 — 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 # ★ 외부 의존성 절대 넣지 않기
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 체크
✅ liveness 는 자기 자신만
배포 중 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 비교
ApplicationRunner 에서 풀을 미리 채우고, 준비가 끝난 뒤에만
AvailabilityChangeEvent.publish(ctx, ReadinessState.ACCEPTING_TRAFFIC) 하는 방식도 씁니다.
실무에선 이렇게 — DB 마이그레이션은 expand-contract 🔴 실무
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 |
spring.jpa.hibernate.ddl-auto 는 운영에서 반드시
validate 또는 none 입니다 — 스키마는 Flyway 만 바꿉니다.
설정 · 비밀 관리 — "어느 값이 이겼나"와 "어디에 남았나"
설정 사고는 두 가지입니다. 운영에 개발 설정이 올라간 것(우선순위를 몰라서),
그리고 비밀번호가 Git 이나 로그에 남은 것(경계를 몰라서). 둘 다 코드 리뷰로는 잘 안 보이고 장애나 보안 사고로 드러납니다.
프로파일 · application.yml 기초는 🍃 Spring Boot 탭 BOOT 05에 있습니다.
운영에서 설정을 넣는 표준 방식
# 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"
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 | 기동 실패 — 배포가 멈춤 |
| 코드에서 읽을 때 검사 | 첫 요청에서야 터짐 — 최악 |
실무에선 이렇게 — 비밀이 새는 네 군데 🔴 실무
❌ 흔히 새는 자리
// ① 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-auto | validate · none |
show-values · show-details | when-authorized 이하 |
| 비밀을 담은 record · DTO | toString 재정의 |
| 관리 포트(8081) 외부 노출 | 없음 |
| 비밀 회전 주기 · 담당 | 문서로 존재 |
/actuator/env/키 로 확인합니다.
설정은 @ConfigurationProperties + @Validated 로 틀리면 뜨지 않게,
비밀은 Git · 로그 · 예외 · 이미지 네 곳에 남지 않게. 새면 회전이 먼저입니다.CPU 프로파일링 — JFR 과 플레임 그래프로 핫스팟 찾기
"CPU 가 90% 인데 왜죠?"에 스레드 덤프는 약한 답만 줍니다 — 사진 몇 장으로는 짧게 자주 도는 코드가 안 잡힙니다. 프로파일러는 1초에 수십~수백 번 스택을 표본 추출해서 "CPU 시간이 어느 메서드에 갔나"를 비율로 보여 줍니다. 운영에서 켜도 되는 두 도구, JFR과 async-profiler를 다룹니다. JIT · 성능 최적화 이론은 🧬 고급 탭 ADV 10 · 🔬 전문가 탭 DEEP 15에 있습니다.
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.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
| 모드 | 이럴 때 |
|---|---|
cpu | CPU 사용률이 높다 |
wall | 느린데 CPU 는 한가 — I/O · 락 대기 |
alloc | GC 가 잦다 · 할당률이 높다 |
lock | BLOCKED 가 많다 (OPS 02) |
한 단계 더 — 플레임 그래프 읽는 규칙 🟡 중급
다섯 가지 규칙
| # | 규칙 |
|---|---|
| 1 | 폭 = 비율. 높이(깊이)는 중요하지 않음 |
| 2 | 위가 평평하고 넓은 칸이 CPU 를 직접 쓰는 곳(self time) |
| 3 | 그 아래로 내려가며 우리 패키지 중 가장 위 칸을 찾음 — 고칠 곳 |
| 4 | 좌우 순서는 이름순 — 시간 흐름이 아님 |
| 5 | 클릭하면 확대, 검색(Ctrl+F)으로 패키지 하이라이트 |
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 | 짧게 — 부담이 더 큼 |
| 결과 파일 | 클래스 · 메서드 이름 포함 — 사내 보관 |
asprof -e cpu 30초로 플레임 그래프, 느린데 CPU 가 낮으면 -e wall.
JFR 은 항상 켜 두고 장애 때 JFR.dump 로 직전 기록을 꺼냅니다.
읽을 때는 폭이 넓은 지붕 → 그 아래 우리 코드, 고친 뒤에는 같은 조건으로 다시 잽니다.코드 리뷰 체크리스트 — 장애를 PR 에서 막기
이 탭 앞의 열한 섹션에 나온 장애는 거의 전부 코드 몇 줄에서 시작했습니다. 리뷰에서 "변수명이 어색해요"보다 먼저 볼 것은 운영에서 터지는 여섯 갈래입니다. 각 갈래를 나쁜 예 · 좋은 예로 정리했습니다 — 리뷰 코멘트에 그대로 붙여 쓸 수 있게.
갈래 ①② — 동시성 · 트랜잭션 경계 🟡 중급
❌ 싱글턴 빈의 가변 필드 · 검사 후 행동 · 자기 호출
@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 만 남기면 어떨까요?"
✅ "[질문] 이 외부 호출의 읽기 타임아웃은 어디서 정해지나요?"
포스트모템 쓰기 — 같은 장애를 두 번 겪지 않는 법
장애는 복구로 끝나지 않습니다. 왜 일어났고, 왜 늦게 알았고, 무엇을 바꿔서 다음엔 막을지를 글로 남겨야 같은 장애가 반복되지 않습니다. 포스트모템의 목적은 범인을 찾는 것이 아니라 시스템의 빈칸을 찾는 것입니다. 이 섹션은 바로 쓸 수 있는 템플릿과, 쓰면서 흔히 빠지는 함정을 다룹니다.
포스트모템 템플릿 — 복사해서 쓰기
# [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일 | 액션 아이템 |
비난 없는 문장으로 쓰기
❌ 사람을 주어로
✅ 시스템을 주어로
한 단계 더 — 5 Whys 의 한계와 기여 요인 트리 🟡 중급
좋은 액션 아이템 · 나쁜 액션 아이템
| ❌ 흔히 쓰는 것 | ✅ 이렇게 바꾸기 |
|---|---|
| "앞으로 더 주의한다" | 사람의 주의 대신 장치: CI 검사 · 알람 · 기본값 |
| "모니터링 강화" | "hikaricp_connections_pending > 0 3분 알람 추가" |
| 담당 · 기한 없음 | 이름 한 명 + 날짜 + 티켓 번호 |
| "시스템 전면 개편" 하나 | 2주 안에 끝나는 크기로 쪼개기 |
| 예방만 10개 | 예방 · 감지 · 완화 유형을 골고루 |
5 Whys 를 쓸 때 지킬 것
① "왜"마다 답이 여러 개면 전부 적고 갈래를 나눕니다
(한 줄만 따라가면 나머지 원인이 버려짐)
② 답이 '사람'에 닿으면 질문을 바꿉니다
"왜 실수했나" → "왜 그 실수가 막히지 않았나"
③ 답마다 증거(지표 · 로그 · 커밋)를 붙입니다
증거 없는 답은 가설 칸에 따로 둡니다
④ "운이 좋았던 점"도 적습니다
새벽이라 트래픽이 적었다 → 낮이었다면? → 그것도 액션 후보
실무에선 이렇게 — 리뷰 회의 30분과 그 뒤 🔴 실무
회의 진행 순서 (30분)
| 시간 | 내용 |
|---|---|
| 5분 | 요약 · 영향 낭독 (문서는 회의 전에 공유) |
| 10분 | 타임라인 함께 보며 빈칸 · 놀란 점 표시 |
| 10분 | 기여 요인 트리 · 액션 아이템 합의 |
| 5분 | 담당 · 기한 확정, 공유 범위 결정 |
포스트모템이 쌓이면 하는 일
분기마다 포스트모템을 모아 '유형'으로 셉니다
유형 건수 대표 액션
커넥션 풀 · 타임아웃 5 기본 타임아웃 템플릿화 (OPS 07)
배포 · 스키마 호환 3 expand-contract 체크 (OPS 09)
메모리 · OOM 2 힙 비율 표준값 (OPS 04)
설정 실수 2 @Validated 의무화 (OPS 10)
→ 같은 유형이 3번 넘으면 개별 수정이 아니라
'팀 표준'(템플릿 · 린트 · 기본 설정)으로 올립니다
Stack 은
이제 그만,
Deque 로 쌓는다
자바에서 스택을 만들 때 아직도 new Stack<>() 을 쓰는 코드가 많습니다.
하지만 2026년 현업 코드는 거의 전부 Deque<E> st = new ArrayDeque<>() 한 줄로 스택과 큐를 함께 처리합니다.
이 탭은 자료구조 이론이 아니라 자바 API 를 어떻게 고르고 쓰는지를 다룹니다.
Stack 이 왜 밀려났는지(순회 순서까지 실제 출력으로 확인합니다), ArrayDeque 의 원형 배열이 JDK 25 소스에서 어떻게 생겼는지,
PriorityQueue 의 함정, 그리고 가상 스레드 시대의 BlockingQueue 생산자-소비자까지 순서대로 봅니다.
모든 예제는 JDK 25.0.1 에서 직접 돌려 출력 주석을 맞췄습니다.
한 장 지도 — 무엇으로 쌓고 무엇으로 줄 세우나
자바에는 스택·큐 구실을 하는 클래스가 열다섯 개가 넘습니다. 1996년부터 하나씩 쌓였기 때문입니다. 그런데 새 코드에서 실제로 고르는 것은 다섯 개 안팎입니다. 먼저 지도를 펴 놓고, 나머지 섹션에서 하나씩 뜯어 봅니다. 컬렉션 전체의 기초(List·Map·Set 고르기)는 Java 기초 탭 JAVA 08에 있으니 여기서는 반복하지 않습니다.
| 하려는 일 | 2026 권장 (선언 타입 = 구현) | 쓰지 말 것 | 한 줄 이유 |
|---|---|---|---|
| 스택 (LIFO) | Deque<E> = new ArrayDeque<>() | Stack, Vector | Stack 은 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 · ConcurrentLinkedDeque | Stack (동기화 = 안전, 이라는 착각) | 메서드 하나하나만 원자적이라 "비었나 보고 꺼내기"는 깨집니다 (SQ 10) |
| 지연 실행 · 재시도 | DelayQueue · ScheduledExecutorService | Thread.sleep 루프 | 준비된 것만 꺼내 줍니다 (SQ 12) |
| 비동기 스트림 버퍼 | SubmissionPublisher · Reactor Sinks | 무한 버퍼 | 배압(backpressure)이 없으면 결국 OOM (SQ 07) |
선언은 인터페이스, 생성은 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
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) — 표준 라이브러리에 링 버퍼 클래스는 없습니다 |
스택 — Stack 이 사장된 진짜 이유와 ArrayDeque 관용구
"Stack 은 synchronized 라서 느리다"가 흔한 설명이지만, 실제로 더 큰 이유는 설계입니다.
Stack 은 Vector 를 상속한 탓에 스택이 아닌 일(중간 삽입·인덱스 수정)을 허락하고,
순회 순서가 바닥→꼭대기라 디버깅 출력부터 헷갈립니다. 실제 출력으로 확인합니다.
실제 출력으로 확인 — 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." |
관용구 — push · pop · peek 과 First 계열의 대응 🟢 기초
| 스택 동작 | Deque 스택 메서드 | 같은 뜻의 First 메서드 | 비었을 때 | 옛 Stack |
|---|---|---|---|---|
| 위에 쌓기 | push(e) | addFirst(e) | — | push(e) |
| 위에서 꺼내기 | pop() | removeFirst() | NoSuchElementException | pop() → EmptyStackException |
| 꺼내되 비면 null | poll() | pollFirst() | null | 없음 |
| 위를 보기만 | peek() | peekFirst() | null | peek() → EmptyStackException |
| 위를 보되 비면 예외 | element() | getFirst() | NoSuchElementException | — |
| 비었나 | isEmpty() | — | — | empty() (이름도 다름) |
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
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 만. 순회는 꼭대기부터 나오고, 중간 접근은 애초에 불가능합니다 — 그게 장점입니다.큐(FIFO) — 예외형과 특수값형, 그리고 큐가 아닌 큐
Queue 인터페이스는 같은 일을 하는 메서드를 두 벌 가지고 있습니다.
실패를 예외로 알리는 add·remove·element 와, 특수값(false·null)으로 알리는 offer·poll·peek 입니다.
어느 쪽을 쓰느냐가 코드의 의도를 드러냅니다. 그리고 ArrayDeque 가 null 을 거부하는 이유도 바로 여기에 있습니다.
두 벌의 차이 — 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
}
}
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 금지. 스레드 안전 아님 |
| LinkedList | O(1) | O(1) | 복잡도는 같지만 원소마다 노드 객체 할당 — 실측 수 배 느림 (SQ 09) |
ArrayList (remove(0)) | 분할 상환 O(1) | O(n) | 큐로 쓰면 안 됨 |
| PriorityQueue | O(log n) | O(log n) | FIFO 가 아니라 우선순위 순 (SQ 05) |
| ArrayBlockingQueue | O(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
무엇이 새로 생겼고 무엇이 원래 있었나
| 메서드 | Deque | List (ArrayList 등) |
|---|---|---|
addFirst/addLast | Java 6 부터 | Java 21 신규 |
getFirst/getLast | Java 6 부터 | Java 21 신규 |
removeFirst/removeLast | Java 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) 입니다.덱의 속 — ArrayDeque 원형 배열을 JDK 25 소스로 뜯어 보기
ArrayDeque 는 배열 한 장 + 인덱스 두 개(head, tail)가 전부입니다. 원소를 옮기지 않고 인덱스만 돌리기 때문에 앞뒤 어느 쪽 연산도 O(1) 입니다. 아래 그림의 상태값은 상상이 아니라 JDK 25.0.1 에서 리플렉션으로 내부 필드를 직접 찍은 값입니다.
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 로 '뒤로' 감긴다
}
}
--add-opens 는 학습용입니다. 실무 코드에서 JDK 내부를 열지 마세요.
늘어나는 규칙 — 작을 땐 2배, 64 부터는 1.5배 🟡 중급
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 8 | Java 11 이후 (25 포함) |
|---|---|---|
| 기본 배열 길이 | 16 | 17 (16 + 빈 칸 1) |
| 용량 규칙 | 항상 2의 거듭제곱 | 아무 길이나 |
| 인덱스 감기 | (i + 1) & (len - 1) 비트마스크 | inc/dec 비교 분기 |
| 늘리는 법 | 꽉 차면 항상 2배 | 64 미만 약 2배, 이상 1.5배 |
| 꽉 찬 판정 | 넣은 뒤 head == tail | 넣은 뒤 head == tail (같음) |
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 · removeIf | O(n) | 중간 삭제가 필요하면 덱이 맞는 도구가 아닐 수 있습니다 |
인덱스 접근 get(i) | 없음 | 의도적으로 제공하지 않습니다 |
new ArrayDeque<>() 로 갈아 끼우는 것이 메모리를 돌려받는 유일한 방법입니다(clear() 는 칸만 비웁니다).
우선순위 큐 — PriorityQueue 는 정렬된 목록이 아니다
PriorityQueue 는 배열에 저장한 이진 최소 힙입니다. poll() 은 항상 가장 작은 것을 꺼내지만,
배열 자체는 정렬돼 있지 않습니다. 그래서 출력해 보면 엉뚱한 순서가 나오고, 원소를 넣은 뒤에 바꾸면 힙이 조용히 깨집니다.
힙 이론은 CS 가이드에 맡기고, 여기서는 자바 API 가 어떻게 동작하고 어디서 사고가 나는지만 봅니다.
기본 사용 · 최대 힙 · 다중 키 — 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 — 항상
가장 흔한 사고 — 넣은 뒤에 값을 바꾸면 힙이 깨진다 🟡 중급
필드를 바꾸고 끝 — 힙은 모른다
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)으로 두고
// "바꾸기" = 새 원소를 넣고 옛 원소는 꺼낼 때 버리기 (아래 다익스트라)
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]
다익스트라 — 지연 삭제 관용구
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 · poll | O(log n) | siftUp / siftDown |
peek · size | O(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 — 원본 순회는 정렬이 아님 |
스레드 안전 큐 — 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) |
| SynchronousQueue | 0 | 손에서 손으로 | — | 버퍼 없이 바로 건네기. newCachedThreadPool 의 큐 |
| LinkedTransferQueue | 없음 | transfer | CAS | "받는 사람이 가져갈 때까지" 기다리는 넣기가 필요할 때 |
완전한 예제 — 가상 스레드 + 독약 (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 실측 🔴 고급
실측 코드 — 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 로 |
CallerRunsPolicy 는 제출한 스레드가 직접 실행하게 만들어 생산 속도를 늦추는, 가장 손쉬운 배압입니다.
비동기 · 스트림 — 버퍼에 상한을 두는 큐들
비동기 코드에서도 큐는 사라지지 않습니다. 이름이 버퍼로 바뀔 뿐입니다. 빠른 생산자와 느린 소비자 사이에는 반드시 어딘가에 쌓이는 곳이 생기고,
그 크기를 정하지 않으면 메모리가 정합니다(OOM). JDK 표준 Flow API, Reactor 의 Sinks, Spring 의 작업 큐를 차례로 봅니다.
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 (실행마다 달라질 수 있음 — 무엇이 버려졌는지에 따라)
}
}
}
합계 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 | 브로커 디스크 | 보존 기간 · 큐 길이 정책 (서버 가이드) |
가상 스레드 시대의 상한 — 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() (추정값) |
queue-capacity, Reactor 의 버퍼 크기, 가상 스레드의 Semaphore 중 어딘가에 상한을 반드시 두세요.알고리즘 패턴 — 자바다운 코드로 쓰는 여섯 가지
코딩 테스트와 실무에서 스택·큐·덱이 나오는 패턴은 거의 정해져 있습니다. 개념 설명은 CS 가이드에 있으니 여기서는 자바에서 어떤 타입·메서드로 쓰는 게 관용구인지, 그리고 자주 틀리는 한 줄에 집중합니다. 모든 결과는 실제 실행값입니다.
① 괄호 검사 — 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
⑥ 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 해도 그대로
removeLast(). 덱이라서 "깊이 제한 있는 스택"이 자연스럽게 됩니다.
| 패턴 | 타입 | 쓰는 메서드 | 자주 틀리는 곳 |
|---|---|---|---|
| 괄호 · 수식 · 경로 정규화 | Deque 스택 | push · pop · isEmpty | 끝난 뒤 isEmpty() 검사 빠뜨림 |
| 다음 큰/작은 수 | Deque 스택 (인덱스) | peek · pop · push | 값을 담아 거리 계산 불가 |
| BFS · 레벨 순회 | Deque 큐 | offer · poll | 꺼낼 때 방문 표시 |
| 창 최댓값/최솟값 | Deque 덱 (인덱스) | peekFirst/Last · pollFirst/Last | < 와 <= 혼동(중복 값) |
| 0-1 BFS | Deque 덱 | offerFirst · offerLast | 방문 배열을 쓰면 틀림 — dist 비교로 |
| DFS 반복 버전 | Deque 스택 | push · pop | 자식 넣는 순서(SQ 12) |
성능과 메모리 — 박싱, 노드, 그리고 믿을 수 없는 벤치마크
"ArrayDeque 가 LinkedList 보다 빠르다"는 맞습니다. 그런데 얼마나, 왜인지는 측정해야 압니다. 이 섹션의 수치는 이 PC(12 스레드)·JDK 25·단일 스레드에서 간이 측정한 예시입니다(JMH 아님). 크기보다 순서와 이유를 보세요.
측정 코드 — 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;
}
스택 측정에서 일어난 일 — 숫자를 믿지 말아야 할 때
| 스택 (깊이 1,000 유지) | push+pop 2천만 번 | 해석 |
|---|---|---|
int[] 직접 | 약 18 ms | 박싱도 객체도 없음 |
ArrayList Last | 약 85 ms | 참조 배열 |
ArrayDeque | 약 100 ms | 참조 배열 + 원형 인덱스 |
Stack (지역 변수) | 약 110~130 ms | JIT 이 락을 상당 부분 걷어냄 |
Stack (다른 스레드에 노출) | 약 200 ms | 락이 살아남음 — 약 2배 |
LinkedList | 약 13 ms (?) | push 직후 pop → 노드가 탈출하지 않아 할당 자체가 제거됨. 실제 상황이 아님 |
원소 하나의 무게 — Deque<Integer> 는 int 의 다섯 배 🟡 중급
원시 타입 스택 — 수백만 개면 직접 만든다
/** 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; }
}
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번 |
new Node(할당 + 나중에 GC),
노드가 힙에 흩어져 다음 원소를 읽을 때마다 캐시 미스. ArrayDeque 는 참조가 배열에 연속으로 놓여 CPU 가 미리 읽어 둡니다.
Deque<Integer> 는 원소당 20바이트입니다. 수백만 개가 아니면 박싱은 신경 쓰지 말고, 벤치는 JMH 로.흔한 실수 모음 — 컴파일은 되는데 틀리는 여덟 가지
스택·큐 버그는 대부분 예외 없이 조용히 틀립니다. 아래는 코드 리뷰에서 실제로 자주 보는 것들이고, 출력은 모두 JDK 25 에서 직접 돌린 결과입니다. 특히 ③ 과 ④ 는 경력자도 처음 보면 놀랍니다.
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()
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) | 늘 false | List.copyOf 로 바꿔 비교 |
| PriorityQueue 출력·순회 | 정렬 안 된 순서 (SQ 05) | stream().sorted() 또는 poll |
무한 LinkedBlockingQueue() | 밀리면 OOM · max 스레드 무의미 | 용량 지정 (SQ 06) |
| 깊은 재귀로 트리 순회 | StackOverflowError | 명시적 스택 (SQ 12) |
다른 언어와 비교 — 같은 일을 무엇으로 하나
자바 개발자가 다른 언어로 넘어갈 때 가장 헷갈리는 것은 이름은 같은데 운명이 다른 타입입니다.
자바의 Stack 은 버려졌지만 C# 의 Stack<T> 는 지금도 정답이고, 자바의 PriorityQueue 는 최소 힙이지만 C++ 과 Rust 는 최대 힙이 기본입니다.
| 언어 | 스택 | 큐 · 덱 | 우선순위 큐 (기본 방향) | 스레드 간 큐 | 비동기 채널 |
|---|---|---|---|---|---|
| Java 25 | ArrayDeque push/pop | ArrayDeque | PriorityQueue (최소) | ArrayBlockingQueue | SubmissionPublisher · Reactor Sinks |
| Kotlin | ArrayDeque · MutableList addLast/removeLast | kotlin.collections.ArrayDeque | java.util.PriorityQueue (최소) | java.util.concurrent 그대로 | 코루틴 Channel · Flow |
| Python | list append/pop | collections.deque | heapq (최소, 함수형) | queue.Queue | asyncio.Queue |
| JS / TS | Array push/pop | 표준 없음 (shift() 는 O(n)) — 직접 구현 | 표준 없음 | — (워커 간 메시지) | 이벤트 루프 · Streams |
| C# | Stack<T> (권장!) | Queue<T> (덱은 없음) | PriorityQueue<TElement,TPriority> (최소) | BlockingCollection · ConcurrentQueue | System.Threading.Channels |
| C++ | std::stack (기본 deque 위) | std::queue · std::deque | std::priority_queue (최대) | 표준 없음 (직접 · 라이브러리) | — |
| Rust | Vec push/pop | VecDeque | BinaryHeap (최대) | std::sync::mpsc | tokio::sync::mpsc |
| Go | 슬라이스 append | 슬라이스 · container/list | container/heap (인터페이스 구현) | 버퍼 있는 채널 | 채널 (같은 것) |
자바 → 다른 언어로 갈 때 틀리는 것
| 자바 습관 | 다른 언어에서 |
|---|---|
| "Stack 은 레거시" | C# Stack<T>, C++ std::stack 은 지금도 표준 답 |
| PriorityQueue = 최소 힙 | C++·Rust 는 최대 힙. 최소가 필요하면 greater<> · Reverse |
poll() 이 null | Python 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 가 같은 일을 하는 라이브러리 구현입니다.)
옮겨 쓰기 — 자바 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)));
실무 사례 — 최근 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();
}
}
drainTo 는 원소를 하나씩 poll 하는 것보다 락을 덜 잡습니다.
③ 슬라이딩 윈도우 레이트 리미터 — 타임스탬프 덱 🟡 중급
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개 | 버스트 허용량을 따로 조절 |
RateLimiter)으로 바꾸세요. 덱 방식은 limit 이 작고 정확해야 할 때(로그인 시도 5회/분) 맞습니다.
System.nanoTime() 을 씁니다. currentTimeMillis() 는 NTP 보정으로 뒤로 갈 수 있어 창 계산이 깨집니다.
서버가 여러 대면 이 리미터는 서버별입니다 — 전체 한도는 Redis 같은 외부 저장소가 필요합니다.
④ 재시도 큐 — DelayQueue 와 지수 백오프 🟡 중급
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 | 재시작해도 재시도가 살아남아야 할 때 (메모리 큐는 사라짐) |
⑤ 재귀를 명시적 스택으로 — 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 로 스택 늘리기 | 임시방편. 스레드마다 늘어나고, 입력이 더 커지면 또 터짐 |
| 후위 순회(자식 먼저) | 상태 플래그를 같이 넣거나 스택 두 개 — 전위보다 까다롭습니다 |
children.reversed() 뷰가 인덱스 루프를 대신합니다. 작은 트리로 두 결과가 [1, 2, 4, 5, 3] 으로 같은지 확인했습니다.
StackOverflowError 입니다.
⑥ 한눈에 — 사례별 타입 고르기 🟢 기초
| 사례 | 타입 | 핵심 한 줄 |
|---|---|---|
| 작업 큐 (스레드 간) | ArrayBlockingQueue(n) + 독약 | 유한 + 소비자 수만큼 독약 (SQ 06) |
| 최근 N개 | ArrayDeque + 크기 검사 | pollFirst 후 addLast, 내보낼 땐 복사 |
| 이벤트 버퍼 | ArrayBlockingQueue + drainTo | offer 로 막지 않고, 묶어서 쓰기 |
| 레이트 리미터 | ArrayDeque<Long> (작은 limit) | 앞에서 만료, 뒤에서 기록 |
| 재시도 | DelayQueue · ScheduledExecutorService | 지수 백오프 + jitter, 영속성 필요하면 DB |
| 스케줄러 · 타이머 | PriorityQueue (실행 시각 순) · ScheduledThreadPoolExecutor | 직접 만들기보다 표준 실행기 |
| 깊은 트리 순회 | ArrayDeque 스택 | 자식은 거꾸로 push |
| undo / redo | ArrayDeque 두 개 | 깊이 제한은 removeLast (SQ 08) |
구조는
규모를 따라
한 칸씩 자란다
파일이 스무 개일 때는 아무렇게나 둬도 찾을 수 있습니다. 문제는 파일이 삼백 개, 사람이 다섯 명, 실행 파일이 세 개가 될 때입니다. 이 탭은 자바 프로젝트를 어떤 폴더·패키지·모듈로 나누는지, 그리고 언제 다음 단계로 넘어가야 하는지를 짧게 정리합니다. Maven 표준 레이아웃에서 시작해 Spring Boot 기능별 패키지, Gradle 멀티 모듈과 build-logic, 테스트 픽스처 공유, JavaFX 앱과 라이브러리 구조까지 보고, 마지막에 구조 규칙을 ArchUnit 테스트로 강제하는 법으로 끝냅니다. 순수 자바 예제(package-private 경계, JPMS exports)는 JDK 25.0.1 에서 직접 컴파일해 오류 메시지를 옮겼습니다.
왜 구조인가 — 표준 레이아웃과 규모별 진화
구조는 "그 코드 어디 있어요?"를 아무도 묻지 않게 만드는 약속입니다. 자바는 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 스크립트가 전부 표준 경로를 가정합니다. 얻는 것이 없습니다.
다음 단계로 넘어가는 신호 🟢 기초
| 지금 | 이런 일이 생기면 | 다음 단계 |
|---|---|---|
| 단일 패키지 | 한 폴더에 파일 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/
# 배치가 생기면 그때 멀티 모듈로 쪼갭니다
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/ # 정말 공통인 것만 (예외 · 설정)
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).
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 하지 않는다
헥사고날 · 클린을 과하지 않게
포트·어댑터의 원리, 도메인과 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);
}
}
OrderRepository extends JpaRepository 를 응용 계층이 직접 쓰는 타협을 많이 합니다.
| 비교 | 기능별 평평 (STR 02) | 포트 · 어댑터 |
|---|---|---|
| 기능 하나의 파일 수 | 3~5개 (컨트롤러 · 서비스 · 리포지토리 · 엔티티 · DTO) | 7~10개 (+ 포트 · 어댑터 · JPA 엔티티 · 매퍼) |
| 도메인 단위 테스트 | 엔티티에 JPA 애너테이션이 붙어 있어도 가능은 함 | 순수 자바 — 스프링 컨텍스트 없이 밀리초 |
| 입구가 여러 개 (REST · Kafka · 배치) | 서비스를 그대로 재사용 — 대개 충분 | 유스케이스 재사용이 명시적 |
| 외부 시스템 교체 | 서비스 코드 여기저기를 고침 | 어댑터 하나만 교체 |
| 어울리는 곳 | CRUD · 관리자 화면 · 작은 서비스 | 결제 · 정산 · 주문처럼 규칙과 외부 연동이 많은 핵심 기능 |
OrderUseCase 인터페이스와 OrderService 구현이 1:1 로만 존재
③ 매퍼가 필드를 그대로 옮기기만 함. 셋 다면 그 기능은 STR 02 의 평평한 구조로 되돌려도 잃는 것이 없습니다.
멀티 모듈 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() }
실행 모듈 — 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)
}
자주 걸리는 함정 세 가지
@SpringBootApplication 이 있는 패키지 아래만 스캔하고
JPA 리포지토리·엔티티도 그 기준으로 찾습니다. com.shop.api.ApiApplication 이면 com.shop.infra 의 리포지토리를 못 찾습니다.
→ 실행 모듈의 Application 은 공통 최상위 패키지 com.shop 에 둡니다.
bootJar 가 생기고 일반 jar 가 꺼져
다른 모듈이 의존할 수 없게 됩니다. Boot 플러그인은 실행 모듈에만.
subprojects { } — 예전 방식입니다.
모듈이 무엇을 받는지 그 모듈 파일만 봐서는 알 수 없고 구성 캐시와도 잘 안 맞습니다.
Gradle 공식 문서도 convention plugin(build-logic) 을 권합니다.
| 모듈 | 의존해도 되는 것 | 금지 |
|---|---|---|
| common | JDK 만 | 다른 모든 모듈 — 아래로 가장 많이 깔리는 모듈이라 바뀌면 전부 다시 빌드 |
| core-domain | common | infra-* · app-* · spring-boot-starter-webmvc |
| infra-db | core-domain · common · JPA | app-* · 다른 infra-* |
| app-* | core-domain · common · infra-* (가능하면 runtimeOnly) | 다른 app-* — 공유할 것이 있으면 core 로 내립니다 |
build-logic 의 convention plugin, 버전은 libs.versions.toml 한 곳에 둡니다. (Maven 이면 부모 POM 의 <modules> 와 <dependencyManagement> 가 같은 역할입니다.)설정 · 리소스 구조 — 프로필 · 프로퍼티 클래스 · 시크릿
설정 파일은 코드보다 환경마다 다르게 바뀌는 것을 담습니다. 그래서 "공통 기본값 + 환경별 차이"로 나누고, 비밀번호는 저장소 밖에서 들어오게 하는 것이 구조의 전부입니다.
리소스 폴더
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
common/config/ 로 올립니다.
config/ 패키지에 프로퍼티 클래스 50개를 모아 두면 계층별 패키지와 같은 문제가 생깁니다.
❌ 비밀번호가 저장소에
# 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 은 차이만, 비밀은 환경 변수·시크릿 파일로. 프로퍼티 클래스는 쓰는 기능 패키지 옆에 둡니다.테스트 구조 — 단위 · 슬라이스 · 통합과 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 로 만듦 | 그 클래스가 있는 모듈 · 같은 패키지 |
@WebMvcTest | MVC · 지정 컨트롤러 · JSON | app-api |
@DataJpaTest | JPA · 리포지토리 · DataSource | infra-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 이름이 다르니, 실제 좌표는 카탈로그 한 곳에서만 관리합니다.
통합 테스트를 따로 돌리기 🟡 중급
// 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 로 한 번만 만듭니다.한 저장소에 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 가 바뀌면 셋 다 대상 |
모노레포냐 멀티 레포냐 🟡 중급
| 기준 | 한 저장소 (모노레포) | 저장소 여러 개 |
|---|---|---|
| 도메인 규칙 공유 | 소스 그대로 — 한 커밋으로 셋 다 고침 | 공유 라이브러리를 배포 · 버전 올리기 · 셋 다 업그레이드 |
| 배포 | 앱별로 따로 가능 (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 에는 그 앱이 쓰는 것만
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/ 한 폴더에 몰아 두면 경로 문자열이 화면마다 늘고, 모듈 캡슐화에도 걸리기 쉽습니다.
| 무엇 | 어디에 |
|---|---|
| FXML · 화면별 CSS | src/main/resources/ 아래 컨트롤러와 같은 패키지 경로 |
| 앱 전체 CSS · 아이콘 · 폰트 | 최상위 패키지 경로 (com/example/desk/app.css) |
| 사용자 설정 · 로컬 DB 파일 | jar 안이 아니라 사용자 홈 (%APPDATA% · ~/.config) — 설치 폴더는 쓰기 금지일 수 있음 |
| 설치본 아이콘 · 라이선스 | src/main/packaging/ 같은 별도 폴더 — 런타임 리소스와 섞지 않음 |
opens … to javafx.fxml 한 줄입니다.라이브러리 구조 — 공개 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
internal 자체가 경고판이 되어야 합니다. 문서에도 "internal 은 호환 대상 아님"을 적습니다.
tasks.jar { manifest { attributes("Automatic-Module-Name" to "com.acme.pay") } }.
안 넣으면 jar 파일 이름에서 추측한 이름이 사용자 module-info 에 박힙니다.
멀티 모듈 라이브러리의 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 무엇이든 | 패치도 가능 — 약속 대상이 아님 |
한 장 요약 — 규모 · 유형별 권장 구조와 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개 이상 |
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(규칙)로 현재 위반을 기록해 두고 새 위반만 막습니다.
Spring Modulith — 기능별 패키지를 "모듈"로 검사
class ModularityTest {
static final ApplicationModules modules =
ApplicationModules.of(ShopApplication.class);
@Test
void 모듈_구조_검증() {
modules.verify(); // 순환 · 다른 모듈 내부 접근이면 실패
}
@Test
void 문서_생성() {
new Documenter(modules).writeDocumentation(); // 모듈 관계도 생성
}
}
@ApplicationModuleListener)로 이끌어
나중에 서비스로 떼어낼 여지를 남깁니다.
버전은 Boot 4 짝인 Modulith 2.x 를 BOM(spring-modulith-bom)으로 맞춥니다.
| 강제 수단 | 무엇을 막나 | 비용 |
|---|---|---|
| package-private | 같은 패키지 밖의 접근 | 0 — 하위 패키지를 만들면 사라짐 |
| Gradle 모듈 의존 선언 | 선언 안 한 모듈 import (컴파일 실패) | 모듈 분할 비용 |
| ArchUnit | 패키지 · 이름 · 계층 · 순환 규칙 무엇이든 | 테스트 한 파일 · 규칙 유지 |
| Spring Modulith | 기능 모듈 사이 순환 · 내부 접근 | 관례(패키지 모양)를 따라야 함 |
| JPMS exports | 모듈패스 사용자의 내부 접근 | 스프링 앱에는 실익이 적음 (DEEP 17) |