Devin.KR
로그인

자바 최신 문법 - record sealed 패턴 매칭 텍스트 블록 가상 스레드 (자바 고급 21단원)

개발자 조회 1

이 단원에서 배우는 것

20단원까지 오면서 주문·재고 도메인의 값 객체를 계속 class 로 만들었다. 필드 세 개짜리 DTO 하나에 생성자, 게터, equals, hashCode, toString 까지 60줄이 붙었다. 결제 결과를 나타내는 타입도 상수 String 이나 enum + 부가 필드로 어정쩡하게 표현했다. 이번 단원에서 배우는 문법들은 이미 하던 일을 짧게 쓰는 설탕이 아니라, 컴파일러에게 더 많은 것을 알려 주는 수단이다. 알려 주면 컴파일러가 검증해 준다. 마지막으로 Java 21의 가상 스레드가 17~18단원의 동시성 설계를 어떻게 바꾸는지 실측한다.

  • recordsealed 로 "가능한 값의 범위"를 타입으로 못박는다
  • 레코드 패턴과 switch 패턴 매칭으로 분기에서 default 를 없앤다 (Java 21 정식)
  • 가상 스레드가 유리한 작업과 오히려 손해인 작업을 구분하고 핀 문제를 안다 (Java 21)

버전 표시를 명확히 해 둔다. record·sealed·텍스트 블록·instanceof 패턴은 Java 17에서 이미 정식이다. switch 패턴 매칭·레코드 패턴·가상 스레드는 Java 21부터 정식이다. 17에서 21 문법을 쓰면 컴파일이 안 되므로 팀의 목표 버전을 먼저 확인해야 한다.

왜 필요한가

결제 결과를 처리하는 코드를 생각해 보자. 승인이면 주문을 확정하고, 거절이면 사유를 보여 주고, 통신 실패면 재시도해야 한다. 지금까지의 자바로 쓰면 대개 이런 모양이 된다.

if ("APPROVED".equals(res.getStatus())) {
    confirm(res.getTxId());
} else if ("DECLINED".equals(res.getStatus())) {
    showReason(res.getReason());
} else if ("FAILED".equals(res.getStatus())) {
    retryAfter(res.getRetryAfterSec());
}

문제가 셋이다. 첫째, res 는 세 경우의 필드를 전부 가지고 있어서 승인인데도 getReason() 을 호출할 수 있고 null 이 튀어나온다. 둘째, 상태 문자열에 오타가 나도 컴파일러가 모른다. 셋째, 나중에 "부분 승인"이 추가됐을 때 if 사슬을 고쳐야 한다는 사실을 아무도 알려 주지 않는다. 배포하고 나서 부분 승인 건이 조용히 무시되는 것으로 알게 된다.

sealed 와 패턴 매칭은 이 셋을 전부 컴파일 에러로 바꾼다. 문법이 짧아지는 것은 부수 효과이고, 본질은 런타임 버그를 컴파일 타임으로 옮기는 것이다.

문법과 예제

1. record — 값을 나르는 타입

record OrderLine(String sku, int qty, long unitPrice) {
    OrderLine {                                  // 컴팩트 생성자: 불변식을 여기서 지킨다
        if (qty <= 0) throw new IllegalArgumentException("수량은 1 이상: " + qty);
        if (unitPrice < 0) throw new IllegalArgumentException("단가 음수: " + unitPrice);
    }
    long amount() { return qty * unitPrice; }    // 파생값은 메서드로
}

이 한 줄이 private final 필드 3개, 전체 생성자, sku()·qty()·unitPrice() 접근자, equals/hashCode/toString 을 만든다. 게터 이름이 getSku() 가 아니라 sku() 라는 점만 유의하면 된다. 컴팩트 생성자는 파라미터 목록도 필드 대입도 쓰지 않는다. 검증과 정규화만 적고 나머지는 컴파일러가 채운다.

record 를 쓸 곳과 안 쓸 곳의 기준은 명확하다. "이 객체는 값인가, 정체성이 있는가" 다. 주문 라인, API 응답 DTO, 좌표, 이벤트 페이로드는 값이다. 반면 JPA 엔티티는 식별자로 정체성을 갖고 상태가 변하며 기본 생성자를 요구하므로 record 로 만들 수 없다.

주의할 함정 하나. record 는 얕은 불변이다.

record Order(String id, List<OrderLine> lines) {
    Order {
        lines = List.copyOf(lines);   // 방어적 복사를 직접 해야 한다
    }
}

이 줄이 없으면 호출자가 넘긴 ArrayList 를 밖에서 계속 수정할 수 있어, 불변인 줄 알았던 객체의 내용이 바뀐다. List.copyOf 는 불변 복사본을 만들고, 이미 불변이면 그대로 돌려주므로 비용도 거의 없다. 다만 null 원소가 있으면 예외를 던진다는 점은 기억해 둔다.

2. sealed — 가능한 경우를 봉인한다

/** 결제 결과는 이 셋뿐이다. 컴파일러가 그것을 안다 */
sealed interface PaymentResult
        permits PaymentResult.Approved, PaymentResult.Declined, PaymentResult.Failed {

    record Approved(String txId, long amount) implements PaymentResult {}
    record Declined(String reason) implements PaymentResult {}
    record Failed(Exception cause, int retryAfterSec) implements PaymentResult {}
}

이제 승인 결과에는 reason 필드가 아예 존재하지 않는다. 앞에서 말한 첫 번째 문제가 사라진다. permits 는 같은 모듈(또는 이름 없는 모듈이면 같은 패키지) 안의 타입만 지정할 수 있다. 위처럼 중첩 타입으로 두면 permits 절을 생략해도 컴파일러가 알아서 추론한다.

3. 패턴 매칭 — Java 21

static String describe(PaymentResult r) {
    return switch (r) {                                    // sealed 라서 default 가 필요 없다
        case PaymentResult.Approved(String tx, long amt) when amt >= 1_000_000 ->
                "고액 승인 " + tx + " (" + amt + "원)";
        case PaymentResult.Approved(String tx, long amt) ->
                "승인 " + tx + " (" + amt + "원)";
        case PaymentResult.Declined d ->
                "거절: " + d.reason();
        case PaymentResult.Failed(Exception e, int after) ->
                "실패(" + e.getMessage() + "), " + after + "초 후 재시도";
    };
}
승인 TX-1 (12900원)
고액 승인 TX-2 (3000000원)
거절: 한도 초과
실패(read timed out), 3초 후 재시도

세 가지가 동시에 일어나고 있다.

  • 레코드 패턴: Approved(String tx, long amt) 는 타입 확인과 분해를 한 번에 한다. a.txId() 를 따로 부를 필요가 없다.
  • when 가드: 같은 타입 안에서 조건으로 다시 가른다. 위에서부터 검사하므로 더 좁은 조건을 먼저 써야 한다. 순서를 바꾸면 도달 불가라고 컴파일 에러가 난다.
  • 망라성 검사: sealed 이므로 세 경우를 다 적으면 default 가 필요 없다. 그리고 나중에 PartiallyApprovedpermits 에 추가하면 switch 가 컴파일 에러가 된다. 앞에서 말한 세 번째 문제가 이렇게 해결된다.

여기서 default 를 습관적으로 붙이면 이 이점이 통째로 사라진다. 새 타입이 추가돼도 조용히 default 로 흘러가 버린다. sealed + switch 조합에서는 default 를 쓰지 않는 것이 규칙이다.

null 처리도 달라졌다. 기존 switch 는 대상이 null 이면 무조건 NullPointerException 이었지만, 패턴 switch 에서는 case null -> ... 을 명시적으로 쓸 수 있다. 안 쓰면 여전히 NPE다. case null, default -> 처럼 묶어 쓰는 형태도 가능하다.

4. 텍스트 블록

String body = """
        {
          "sku": "%s",
          "qty": %d
        }""".formatted(line.sku(), line.qty());
{
  "sku": "SKU-1",
  "qty": 3
}

JSON 예시, SQL, HTML 템플릿을 \"\n 없이 그대로 쓸 수 있다. 들여쓰기 규칙만 이해하면 된다. 컴파일러는 모든 줄과 닫는 """ 의 들여쓰기 중 가장 얕은 것을 기준으로 공통 여백을 잘라낸다. 위 예에서 닫는 """ 를 한 줄 내려 왼쪽 끝에 두면 잘려 나가는 여백이 0이 되어, 코드에서 준 들여쓰기가 문자열에 그대로 들어간다. SQL 로그가 이상하게 들여쓰기되어 나오는 원인이 대개 이것이다.

줄 끝 공백은 자동으로 제거된다. 일부러 남기려면 \s 를, 줄바꿈 없이 다음 줄로 이으려면 줄 끝에 \ 를 쓴다.

5. 가상 스레드 — Java 21

18단원에서 I/O 작업에는 코어 수보다 훨씬 큰 풀이 필요하다고 했다. 그런데 플랫폼 스레드는 OS 스레드와 1:1이라 개수를 늘리는 데 한계가 있다. 가상 스레드는 JVM이 관리하는 경량 스레드로, 블로킹되는 순간 캐리어 스레드에서 내려오고 다른 작업이 그 자리를 쓴다. 얼마나 차이 나는지 직접 재 본다.

import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.*;
import java.util.stream.IntStream;

public class VirtualDemo {
    public static void main(String[] args) throws Exception {
        run("플랫폼 스레드 200개", Executors.newFixedThreadPool(200));
        run("가상 스레드",        Executors.newVirtualThreadPerTaskExecutor());
    }

    static void run(String label, ExecutorService es) throws Exception {
        Instant start = Instant.now();
        try (es) {                                   // ExecutorService 가 AutoCloseable (Java 19+)
            List<Callable<String>> tasks = IntStream.range(0, 10_000)
                    .mapToObj(i -> (Callable<String>) () -> callStockApi(i))
                    .toList();
            es.invokeAll(tasks);
        }
        System.out.println(label + " : " + Duration.between(start, Instant.now()).toMillis() + "ms");
    }

    static String callStockApi(int i) throws InterruptedException {
        Thread.sleep(100);                           // 외부 API 대기를 흉내낸다
        return "SKU-" + i;
    }
}
플랫폼 스레드 200개 : 5211ms
가상 스레드 : 155ms

33배 차이다. 이유는 단순하다. 200개 풀은 10,000개 작업을 50라운드로 나눠 처리하므로 100ms × 50 = 5초가 걸린다. 가상 스레드는 만 개를 동시에 띄우고 전부 sleep 에서 내려오므로 한 라운드에 끝난다. 만 개의 플랫폼 스레드를 만들면 스택만 10GB가 필요하지만, 가상 스레드의 스택은 힙에 있고 필요한 만큼만 커진다.

중요한 것은 이 이득이 I/O 대기에서만 나온다는 점이다. CPU를 계속 쓰는 계산 작업은 가상 스레드로 바꿔도 코어 수 이상으로 빨라지지 않고, 오히려 스케줄링 오버헤드만 는다. 그리고 가상 스레드는 풀링하지 않는다. 생성 비용이 거의 없으므로 작업마다 새로 만드는 것이 정상 사용법이고, newVirtualThreadPerTaskExecutor() 라는 이름이 그 뜻이다.

실무에서 자주 틀리는 것

1. record 를 JPA 엔티티나 가변 DTO로 쓰려 한다

record 는 상속할 수 없고(암묵적 final), 필드를 추가할 수 없으며, 기본 생성자가 없다. JPA는 프록시를 만들기 위해 상속과 기본 생성자를 요구하므로(20단원) 엔티티에는 쓸 수 없다. 반면 조회 전용 프로젝션 DTO에는 record 가 최적이다. 잭슨 역직렬화도 2.12 이상이면 잘 되지만, 스프링 부트 3 미만 환경이라면 jackson-module-parameter-names 가 등록됐는지 확인해야 한다. 실무에서는 "엔티티는 클래스, API 응답과 프로젝션은 record" 정도로 선을 긋는 것이 무난하다.

2. 가상 스레드에서 synchronized 를 쓴다 (Java 21 한정)

Java 21의 가상 스레드는 synchronized 블록 안에서 블로킹되면 캐리어 스레드에 고정(pinning)되어 내려오지 못한다. 17단원의 재고 서비스처럼 락 안에서 DB나 API를 호출하면, 가상 스레드로 바꿨는데도 처리량이 그대로이거나 오히려 나빠진다. 심하면 캐리어가 전부 고정되어 멈춘 것처럼 보인다. 21에서의 대처는 synchronizedReentrantLock 으로 바꾸는 것이다. ReentrantLock 은 가상 스레드를 인식해 정상적으로 양보한다.

private final ReentrantLock lock = new ReentrantLock();

boolean reserve(String sku, int qty) {
    lock.lock();
    try { ... }
    finally { lock.unlock(); }   // finally 를 빠뜨리면 영구 락이다
}

진단은 -Djdk.tracePinnedThreads=short 로 실행해 핀 발생 지점을 찍어 보면 된다. 참고로 이 제약은 JDK 24에서 대부분 해소됐지만, 21 LTS를 쓰는 동안에는 유효한 제약이므로 반드시 확인해야 한다.

3. 가상 스레드 앞에서 커넥션 풀을 잊는다

가상 스레드는 스레드 수 제한을 없애 주지만, DB 커넥션 풀의 크기(예: HikariCP 기본 10)는 그대로다. 만 개의 가상 스레드가 동시에 커넥션을 요청하면 9,990개가 대기하다 타임아웃 난다. 스레드가 무제한이 되면 병목은 그다음 유한 자원으로 옮겨 갈 뿐이다. 18단원에서 유한 큐로 배압을 만든 것과 같은 이유로, 가상 스레드를 도입할 때는 세마포어 등으로 외부 자원 접근의 동시성을 따로 제한해야 한다.

4. sealed switch 에 default 를 넣는다

앞에서 말했지만 가장 흔한 실수라 다시 적는다. IDE가 default 를 넣으라고 권하는 경우도 있는데, sealed 타입에 대해서는 거절해야 한다. default 하나 때문에 "새 타입 추가 시 컴파일 에러"라는 이 문법의 유일한 이점이 사라진다.

5. 텍스트 블록 안에서 값을 문자열로 이어 붙인다

SQL 텍스트 블록 안에 " + userInput + " 를 넣는 순간 SQL 인젝션이 된다. 텍스트 블록은 가독성만 줄 뿐 안전성과는 무관하다. 파라미터는 여전히 바인딩 변수(?, :name)로 넘겨야 한다. 값을 끼워 넣는 것이 문자열 조립이 아니라 포맷일 때만 .formatted(...) 를 쓴다.

스스로 확인하기

  1. record Cart(String userId, List<OrderLine> lines) {} 를 만들고 new Cart("u1", myList) 로 생성한 뒤 myList.clear() 를 호출했다. 무슨 일이 일어나며 어떻게 막는가?
  2. sealed interface PaymentResultPartiallyApproved 를 추가했다. 기존 switch 문 중 (a) default 가 있는 것과 (b) 없는 것은 각각 어떻게 되는가?
  3. 외부 API를 호출하는 작업 5,000개를 가상 스레드로 바꿨는데 성능이 거의 그대로다. Java 21 환경에서 무엇부터 확인하는가?

정답

  1. cart.lines() 가 빈 리스트가 된다. record 는 참조를 그대로 보관하므로 원본 리스트가 바뀌면 record 안의 내용도 바뀐다. "record 는 불변"이라는 말은 필드 참조가 바뀌지 않는다는 뜻이지 가리키는 객체가 불변이라는 뜻이 아니다. 막으려면 컴팩트 생성자에서 lines = List.copyOf(lines); 로 방어적 복사를 한다. 접근자에서 복사본을 돌려주는 방법도 있지만 호출마다 복사 비용이 들어 생성 시 한 번 복사하는 편이 낫다.
  2. (a) default 가 있으면 컴파일은 되고 새 타입은 default 로 흘러간다. 부분 승인 건이 "알 수 없는 결과"로 조용히 처리되고, 문제는 운영에서 데이터로 발견된다. (b) default 가 없으면 컴파일 에러가 나면서 고쳐야 할 switch 를 전부 짚어 준다. 프로젝트에 switch 가 30개 흩어져 있어도 빌드 한 번으로 목록이 나온다. 이것이 sealed 를 쓰는 이유 그 자체다.
  3. 순서는 이렇다. (1) -Djdk.tracePinnedThreads=short 로 실행해 synchronized 핀이 있는지 본다. 락 안에서 I/O를 하는 코드가 있으면 ReentrantLock 으로 바꾼다. (2) DB 커넥션 풀, HTTP 클라이언트 커넥션 풀 크기를 본다. 가상 스레드가 아무리 많아도 커넥션이 10개면 동시성은 10이다. (3) 그 작업이 정말 I/O 대기인지 확인한다. JSON 파싱이나 암호화처럼 CPU를 쓰는 작업이 대부분이면 가상 스레드로 얻을 것이 없다. (4) 마지막으로 Executors.newVirtualThreadPerTaskExecutor() 대신 고정 크기 풀을 쓰고 있지는 않은지 확인한다. 가상 스레드를 풀로 감싸면 개수 제한이 그대로 살아난다.