Skip to content
Published on

모던 Java 완전 정복: Java 17/21 핵심 문법 가이드

Authors

모던 Java 완전 정복: Java 17/21 핵심 문법 가이드

Java는 꾸준히 진화하고 있습니다. Java 21 LTS는 Project Loom(Virtual Threads), Pattern Matching, Record, Sealed Classes 등 혁신적인 기능을 포함합니다. 이 가이드에서는 현업에서 반드시 알아야 할 모던 Java 문법을 완전 정복합니다.


1. Java 21 핵심 신기능 (LTS 기준)

1.1 Record 클래스 (Java 16+)

Record는 불변(immutable) 데이터 클래스를 간결하게 선언하는 방법입니다. 컴파일러가 자동으로 생성자, getter, equals, hashCode, toString을 생성합니다.

// 기존 방식 (boilerplate 가득)
public class PersonOld {
    private final String name;
    private final int age;

    public PersonOld(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) { /* ... */ }
    @Override
    public int hashCode() { /* ... */ }
    @Override
    public String toString() { /* ... */ }
}

// Record 방식 (한 줄!)
public record Person(String name, int age) {}

// 사용
Person p = new Person("Alice", 30);
System.out.println(p.name()); // "Alice"  (getter 이름은 필드명 그대로)
System.out.println(p);        // Person[name=Alice, age=30]

// 커스텀 생성자로 유효성 검사
public record Person(String name, int age) {
    public Person {
        if (name == null || name.isBlank()) throw new IllegalArgumentException("name cannot be blank");
        if (age < 0 || age > 150) throw new IllegalArgumentException("invalid age: " + age);
    }

    // 추가 메서드 정의 가능
    public String greeting() {
        return "Hello, I'm " + name + " and I'm " + age + " years old.";
    }
}

// Record는 인터페이스 구현 가능
public record Point(double x, double y) implements Comparable<Point> {
    @Override
    public int compareTo(Point other) {
        return Double.compare(this.distance(), other.distance());
    }

    private double distance() {
        return Math.sqrt(x * x + y * y);
    }
}

Record의 제약사항:

  • 상속 불가 (implicitly final)
  • 필드는 모두 private final
  • 인스턴스 필드 추가 불가 (static 필드는 가능)

1.2 Sealed Classes (Java 17+)

Sealed Class는 어떤 클래스/인터페이스가 해당 타입을 상속/구현할 수 있는지 명시적으로 제한합니다. 이를 통해 타입 계층을 폐쇄적으로 설계할 수 있습니다.

// Shape는 오직 Circle, Rectangle, Triangle만 상속 가능
public sealed class Shape permits Circle, Rectangle, Triangle {}

public final class Circle extends Shape {
    private final double radius;
    public Circle(double radius) { this.radius = radius; }
    public double radius() { return radius; }
}

public final class Rectangle extends Shape {
    private final double width, height;
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
    public double width() { return width; }
    public double height() { return height; }
}

public non-sealed class Triangle extends Shape {
    // non-sealed: Triangle을 상속한 클래스는 제한 없음
    private final double base, height;
    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }
}

// 인터페이스에도 sealed 적용 가능
public sealed interface Result<T> permits Result.Success, Result.Failure {
    record Success<T>(T value) implements Result<T> {}
    record Failure<T>(String error) implements Result<T> {}
}

1.3 Pattern Matching for instanceof (Java 16+)

기존 instanceof 후 명시적 캐스팅 없이 바로 사용 가능합니다.

// 기존 방식
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

// 모던 방식 (Pattern Matching)
if (obj instanceof String s) {
    System.out.println(s.length()); // s는 String으로 자동 캐스팅
}

// 조건과 결합
if (obj instanceof String s && s.length() > 5) {
    System.out.println("Long string: " + s);
}

// 부정
if (!(obj instanceof String s)) {
    System.out.println("Not a string");
    return;
}
// 이 시점부터 s 사용 가능
System.out.println(s.toUpperCase());

1.4 Pattern Matching for switch (Java 21)

switch 표현식에서 타입 패턴 매칭을 지원합니다. Sealed Class와 결합하면 강력합니다.

// 타입 기반 switch
public double calculateArea(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t -> 0.5 * t.base() * t.height();
        // sealed class이므로 default 불필요 (모든 케이스 커버)
    };
}

// Guarded Pattern (조건부 패턴)
public String classify(Object obj) {
    return switch (obj) {
        case Integer i when i < 0 -> "Negative integer: " + i;
        case Integer i when i == 0 -> "Zero";
        case Integer i -> "Positive integer: " + i;
        case String s when s.isEmpty() -> "Empty string";
        case String s -> "String: " + s;
        case null -> "Null value";
        default -> "Other: " + obj.getClass().getSimpleName();
    };
}

// Record Pattern
public String describePoint(Object obj) {
    return switch (obj) {
        case Point(double x, double y) when x == 0 && y == 0 -> "Origin";
        case Point(double x, double y) when x == 0 -> "On Y-axis at " + y;
        case Point(double x, double y) when y == 0 -> "On X-axis at " + x;
        case Point(double x, double y) -> "Point at (" + x + ", " + y + ")";
        default -> "Not a point";
    };
}

1.5 Text Blocks (Java 15+)

여러 줄 문자열을 가독성 있게 작성합니다.

// 기존 방식
String json = "{\n" +
    "  \"name\": \"Alice\",\n" +
    "  \"age\": 30\n" +
    "}";

// Text Block
String json = """
        {
          "name": "Alice",
          "age": 30
        }
        """;

// SQL 예시
String sql = """
        SELECT u.id, u.name, o.total
        FROM users u
        JOIN orders o ON u.id = o.user_id
        WHERE u.active = true
        ORDER BY o.total DESC
        """;

// HTML
String html = """
        <html>
            <body>
                <h1>Hello, World!</h1>
            </body>
        </html>
        """;

// 들여쓰기 제어: \s (trailing whitespace 유지), \ (줄바꿈 없애기)
String oneLine = """
        Hello \
        World\
        """; // "Hello World"

1.6 Virtual Threads (Java 21, Project Loom)

Virtual Thread는 JVM이 관리하는 경량 스레드입니다. OS 스레드와 1:1 매핑 대신 M:N 방식으로 수백만 개의 동시 작업이 가능합니다.

// 기존 플랫폼 스레드
Thread platformThread = new Thread(() -> {
    System.out.println("Platform thread: " + Thread.currentThread());
});
platformThread.start();

// Virtual Thread 생성
Thread virtualThread = Thread.ofVirtual()
    .name("my-virtual-thread")
    .start(() -> {
        System.out.println("Virtual thread: " + Thread.currentThread());
    });

// ExecutorService로 Virtual Thread 사용
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = new ArrayList<>();
    for (int i = 0; i < 10_000; i++) {
        int taskId = i;
        futures.add(executor.submit(() -> {
            // I/O 차단 작업도 OK - Virtual Thread는 블로킹 시 마운트 해제
            Thread.sleep(100);
            return "Task " + taskId + " completed";
        }));
    }
    // 모든 작업 완료 대기
    for (Future<String> f : futures) {
        System.out.println(f.get());
    }
}

// Spring Boot와 함께 사용 (application.properties)
// spring.threads.virtual.enabled=true

// 구조적 동시성 (Structured Concurrency, preview)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<User> user = scope.fork(() -> fetchUser(userId));
    Future<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
    scope.join().throwIfFailed();
    return new UserProfile(user.resultNow(), orders.resultNow());
}

1.7 Sequenced Collections (Java 21)

컬렉션에 순서 개념을 추가하는 새 인터페이스입니다.

// SequencedCollection: getFirst(), getLast(), addFirst(), addLast(), reversed()
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
System.out.println(list.getFirst()); // "a"
System.out.println(list.getLast());  // "c"
list.addFirst("z");                  // ["z", "a", "b", "c"]
list.addLast("x");                   // ["z", "a", "b", "c", "x"]
List<String> reversed = list.reversed(); // ["x", "c", "b", "a", "z"]

// SequencedMap: firstEntry(), lastEntry(), putFirst(), putLast(), reversed()
LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
map.put("one", 1);
map.put("two", 2);
map.put("three", 3);
System.out.println(map.firstEntry()); // one=1
System.out.println(map.lastEntry());  // three=3

2. 람다와 스트림 API 완전 정복

2.1 람다 표현식 문법

// 기본 형태
Runnable r = () -> System.out.println("Hello");
Comparator<String> comp = (a, b) -> a.compareTo(b);

// 블록 람다 (여러 줄)
Function<Integer, Integer> factorial = n -> {
    int result = 1;
    for (int i = 1; i <= n; i++) result *= i;
    return result;
};

// 타입 추론
List<String> names = List.of("Charlie", "Alice", "Bob");
names.sort((a, b) -> a.compareTo(b));
// 또는 메서드 참조
names.sort(String::compareTo);

2.2 함수형 인터페이스

// Function<T, R>: T -> R
Function<String, Integer> strLen = String::length;
Function<Integer, Integer> doubler = x -> x * 2;
Function<String, Integer> lenThenDouble = strLen.andThen(doubler);
System.out.println(lenThenDouble.apply("Hello")); // 10

// BiFunction<T, U, R>: T, U -> R
BiFunction<String, Integer, String> repeat = (s, n) -> s.repeat(n);
System.out.println(repeat.apply("Java", 3)); // "JavaJavaJava"

// Predicate<T>: T -> boolean
Predicate<String> isLong = s -> s.length() > 5;
Predicate<String> startsWithA = s -> s.startsWith("A");
Predicate<String> combined = isLong.and(startsWithA);
Predicate<String> either = isLong.or(startsWithA);
Predicate<String> notLong = isLong.negate();

// Supplier<T>: () -> T
Supplier<LocalDate> today = LocalDate::now;
Supplier<List<String>> listFactory = ArrayList::new;

// Consumer<T>: T -> void
Consumer<String> printer = System.out::println;
Consumer<String> logger = s -> log.info("Processing: {}", s);
Consumer<String> both = printer.andThen(logger);

// UnaryOperator<T>: T -> T (Function의 특수화)
UnaryOperator<String> toUpper = String::toUpperCase;
UnaryOperator<Integer> increment = x -> x + 1;

// BinaryOperator<T>: T, T -> T
BinaryOperator<Integer> add = Integer::sum;
BinaryOperator<String> concat = String::concat;

2.3 메서드 참조 (4가지 유형)

// 1. 정적 메서드 참조: ClassName::staticMethod
Function<String, Integer> parseInt = Integer::parseInt;
Comparator<String> comp = String::compareTo; // 아니, 이건 인스턴스 메서드

// 2. 특정 인스턴스의 메서드 참조: instance::instanceMethod
String prefix = "Hello, ";
Function<String, String> greeter = prefix::concat;
System.out.println(greeter.apply("World")); // "Hello, World"

// 3. 임의 인스턴스의 메서드 참조: ClassName::instanceMethod
Function<String, String> upper = String::toUpperCase;
Comparator<String> byLength = Comparator.comparingInt(String::length);

// 4. 생성자 참조: ClassName::new
Supplier<ArrayList<String>> listCreator = ArrayList::new;
Function<String, StringBuilder> sbCreator = StringBuilder::new;
BiFunction<String, Integer, String> repeater = String::new; // 아님, 예시용

2.4 Stream API 핵심 연산

List<Employee> employees = List.of(
    new Employee("Alice", "Engineering", 90_000),
    new Employee("Bob", "Engineering", 80_000),
    new Employee("Charlie", "Marketing", 60_000),
    new Employee("Diana", "Marketing", 70_000),
    new Employee("Eve", "Engineering", 95_000)
);

// filter + map + collect
List<String> highEarnerNames = employees.stream()
    .filter(e -> e.salary() > 75_000)
    .map(Employee::name)
    .sorted()
    .collect(Collectors.toList());
// Java 16+: .toList() (불변 리스트)

// flatMap: Stream<Stream<T>> -> Stream<T>
List<List<Integer>> nested = List.of(List.of(1, 2, 3), List.of(4, 5), List.of(6));
List<Integer> flat = nested.stream()
    .flatMap(Collection::stream)
    .collect(Collectors.toList());
// [1, 2, 3, 4, 5, 6]

// reduce
int totalSalary = employees.stream()
    .mapToInt(Employee::salary)
    .sum(); // 또는 reduce(0, Integer::sum)

OptionalDouble avgSalary = employees.stream()
    .mapToInt(Employee::salary)
    .average();

// groupingBy
Map<String, List<Employee>> byDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::department));

Map<String, Double> avgSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.averagingInt(Employee::salary)
    ));

Map<String, Long> countByDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::department, Collectors.counting()));

// partitioningBy
Map<Boolean, List<Employee>> partition = employees.stream()
    .collect(Collectors.partitioningBy(e -> e.salary() > 75_000));
// true -> 고연봉자, false -> 나머지

// joining
String names = employees.stream()
    .map(Employee::name)
    .collect(Collectors.joining(", ", "[", "]"));
// "[Alice, Bob, Charlie, Diana, Eve]"

// toMap
Map<String, Integer> nameSalaryMap = employees.stream()
    .collect(Collectors.toMap(Employee::name, Employee::salary));

// findFirst, findAny, anyMatch, allMatch, noneMatch
Optional<Employee> first = employees.stream()
    .filter(e -> e.department().equals("Engineering"))
    .findFirst();

boolean anyHigh = employees.stream().anyMatch(e -> e.salary() > 100_000);
boolean allAbove50k = employees.stream().allMatch(e -> e.salary() > 50_000);

2.5 Optional

// Optional 생성
Optional<String> opt1 = Optional.of("value");      // null 불가
Optional<String> opt2 = Optional.ofNullable(null); // null 허용 -> empty
Optional<String> opt3 = Optional.empty();

// 값 추출
String val1 = opt1.get();                          // NoSuchElementException 위험
String val2 = opt1.orElse("default");              // 기본값 (항상 평가됨)
String val3 = opt1.orElseGet(() -> computeDefault()); // lazy 평가
String val4 = opt1.orElseThrow(() -> new NotFoundException("not found"));

// 변환
Optional<Integer> length = opt1.map(String::length);
Optional<String> upper = opt1.map(String::toUpperCase);

// flatMap: Optional<Optional<T>> 방지
Optional<Address> address = Optional.of(user)
    .flatMap(u -> Optional.ofNullable(u.getAddress()));

// 조건부 처리
opt1.ifPresent(System.out::println);
opt1.ifPresentOrElse(
    v -> System.out.println("Found: " + v),
    () -> System.out.println("Not found")
);

// filter
Optional<String> longVal = opt1.filter(s -> s.length() > 3);

// 안티패턴: Optional.get() without isPresent() 체크
// 안티패턴: 메서드 파라미터로 Optional 사용 (권장하지 않음)
// 권장: 반환값에만 Optional 사용

2.6 병렬 스트림 주의사항

// 병렬 스트림 - CPU 바운드 작업에 적합
List<Integer> numbers = IntStream.rangeClosed(1, 1_000_000)
    .boxed().collect(Collectors.toList());

long sum = numbers.parallelStream()
    .mapToLong(Integer::longValue)
    .sum(); // 안전: 상태 없는 연산

// 주의: 공유 상태 변경 (스레드 불안전)
List<Integer> results = new ArrayList<>();
numbers.parallelStream()
    .filter(n -> n % 2 == 0)
    .forEach(results::add); // 위험! ArrayList는 thread-safe 하지 않음

// 올바른 방법: collect 사용
List<Integer> safeResults = numbers.parallelStream()
    .filter(n -> n % 2 == 0)
    .collect(Collectors.toList()); // thread-safe

// I/O 바운드 작업에는 Virtual Threads가 더 적합
// 병렬 스트림은 ForkJoinPool.commonPool() 사용 -> 풀 고갈 주의

3. 제네릭 심화

3.1 와일드카드와 PECS 원칙

PECS: Producer Extends, Consumer Super

// 공변(Covariant): extends - 읽기(Producer)에 사용
public double sumList(List<? extends Number> list) {
    double sum = 0;
    for (Number n : list) {
        sum += n.doubleValue(); // 읽기 OK
    }
    // list.add(1.0); // 컴파일 에러! 쓰기 불가
    return sum;
}
// 호출: sumList(List<Integer>), sumList(List<Double>) 모두 가능

// 반공변(Contravariant): super - 쓰기(Consumer)에 사용
public void addNumbers(List<? super Integer> list) {
    list.add(1);
    list.add(2); // 쓰기 OK
    // Integer n = list.get(0); // 컴파일 에러! 타입 보장 불가 (Object로만 읽기)
    Object obj = list.get(0); // OK
}
// 호출: addNumbers(List<Integer>), addNumbers(List<Number>), addNumbers(List<Object>) 가능

// 복사 예시 (PECS 완전 적용)
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (T item : src) {  // src는 Producer -> extends
        dest.add(item);   // dest는 Consumer -> super
    }
}

// 비한정 와일드카드: ? (모든 타입)
public void printList(List<?> list) {
    for (Object o : list) {
        System.out.println(o);
    }
}

3.2 제네릭 메서드와 타입 소거

// 제네릭 메서드
public static <T extends Comparable<T>> T max(T a, T b) {
    return a.compareTo(b) >= 0 ? a : b;
}

// 재귀 타입 바운드
public static <T extends Comparable<T>> T findMax(List<T> list) {
    return list.stream().max(Comparator.naturalOrder()).orElseThrow();
}

// 타입 소거 (Type Erasure)
// 컴파일 후 List<String>은 List가 됨 (런타임에 타입 정보 없음)
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
System.out.println(strings.getClass() == integers.getClass()); // true!

// instanceof에 제네릭 타입 사용 불가
// if (obj instanceof List<String>) {} // 컴파일 에러

// 올바른 방법
if (obj instanceof List<?> list && !list.isEmpty() && list.get(0) instanceof String) {
    List<String> stringList = (List<String>) list; // unchecked warning
}

// 제네릭 배열 생성 불가
// T[] arr = new T[10]; // 컴파일 에러
// 해결책:
@SuppressWarnings("unchecked")
T[] arr = (T[]) new Object[10];
// 또는 리스트 사용
List<T> list = new ArrayList<>();

4. 컬렉션 프레임워크

4.1 불변 컬렉션 (Java 9+)

// 불변 List (null 불가, 순서 유지)
List<String> immutableList = List.of("a", "b", "c");
// immutableList.add("d"); // UnsupportedOperationException

// 불변 Set
Set<Integer> immutableSet = Set.of(1, 2, 3, 4, 5);
// Set.of는 순서 미보장, 중복 불가

// 불변 Map
Map<String, Integer> immutableMap = Map.of(
    "one", 1,
    "two", 2,
    "three", 3
);

// 10개 이상: Map.ofEntries
Map<String, Integer> bigMap = Map.ofEntries(
    Map.entry("a", 1),
    Map.entry("b", 2),
    Map.entry("c", 3)
    // ...
);

// 복사본 (불변)
List<String> original = new ArrayList<>(List.of("a", "b"));
List<String> copy = List.copyOf(original);
Map<String, Integer> mapCopy = Map.copyOf(immutableMap);

4.2 Map 구현체 선택 기준

// HashMap: 순서 없음, O(1) 평균 (일반적인 경우)
Map<String, Integer> hashMap = new HashMap<>();

// LinkedHashMap: 삽입 순서 유지, O(1) 평균
Map<String, Integer> linkedMap = new LinkedHashMap<>();

// TreeMap: 키 기준 정렬, O(log n)
Map<String, Integer> treeMap = new TreeMap<>();
Map<String, Integer> reverseMap = new TreeMap<>(Comparator.reverseOrder());

// ConcurrentHashMap: 스레드 안전, 세그먼트 잠금
ConcurrentHashMap<String, Integer> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.computeIfAbsent("key", k -> 0);
concurrentMap.merge("key", 1, Integer::sum); // 원자적 카운터

// EnumMap: Enum 키, 매우 빠름
EnumMap<DayOfWeek, String> schedule = new EnumMap<>(DayOfWeek.class);

4.3 List/Deque 구현체

// ArrayList: 랜덤 접근 O(1), 삽입/삭제 O(n)
List<String> arrayList = new ArrayList<>();

// LinkedList: 삽입/삭제 O(1), 랜덤 접근 O(n) (실제로는 캐시 미스로 느림)
// 권장하지 않음 - ArrayDeque가 대부분의 경우 더 빠름

// ArrayDeque: 양방향 큐, 스택/큐 역할 모두 가능, 빠름
Deque<String> deque = new ArrayDeque<>();
deque.push("first");      // 스택: 앞에 추가
deque.offerLast("last");  // 큐: 뒤에 추가
String top = deque.peek(); // 첫 번째 확인 (제거 없음)
String removed = deque.poll(); // 첫 번째 제거

// CopyOnWriteArrayList: 읽기 많고 쓰기 적은 경우, 스레드 안전
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>();

5. 예외 처리 패턴

5.1 Checked vs Unchecked Exception

// Checked Exception: 컴파일러가 처리 강제 (IOException, SQLException 등)
public String readFile(String path) throws IOException {
    return Files.readString(Path.of(path));
}

// Unchecked Exception: RuntimeException 하위 (처리 선택)
public int divide(int a, int b) {
    if (b == 0) throw new ArithmeticException("Division by zero");
    return a / b;
}

// 커스텀 예외 계층 설계
public class AppException extends RuntimeException {
    private final String errorCode;

    public AppException(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public AppException(String errorCode, String message, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    public String getErrorCode() { return errorCode; }
}

public class ResourceNotFoundException extends AppException {
    public ResourceNotFoundException(String resourceType, Object id) {
        super("RESOURCE_NOT_FOUND",
              resourceType + " not found with id: " + id);
    }
}

public class ValidationException extends AppException {
    private final Map<String, String> fieldErrors;

    public ValidationException(Map<String, String> fieldErrors) {
        super("VALIDATION_FAILED", "Validation failed");
        this.fieldErrors = Map.copyOf(fieldErrors);
    }

    public Map<String, String> getFieldErrors() { return fieldErrors; }
}

5.2 try-with-resources & Multi-catch

// try-with-resources (AutoCloseable 구현체)
public String readAndClose(String path) throws IOException {
    try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
        return reader.lines().collect(Collectors.joining("\n"));
    } // reader.close() 자동 호출
}

// 여러 리소스 (역순으로 닫힘)
public void copyFile(String src, String dest) throws IOException {
    try (
        InputStream in = new FileInputStream(src);
        OutputStream out = new FileOutputStream(dest)
    ) {
        in.transferTo(out);
    }
}

// Multi-catch
public void process(String input) {
    try {
        int value = Integer.parseInt(input);
        String result = riskyOperation(value);
        System.out.println(result);
    } catch (NumberFormatException | IllegalArgumentException e) {
        System.err.println("Invalid input: " + e.getMessage());
    } catch (RuntimeException e) {
        log.error("Unexpected error", e);
        throw e; // 재발생
    }
}

// Effective Java: 예외를 삼키지 말 것
// Bad:
try { riskyOperation(); } catch (Exception e) { /* 무시 */ }

// Good:
try { riskyOperation(); } catch (Exception e) {
    log.error("Operation failed", e);
    throw new AppException("OPERATION_FAILED", "Failed to complete operation", e);
}

6. 동시성 (Concurrency)

6.1 CompletableFuture

// 비동기 작업 체인
CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> fetchUserId())          // 비동기 시작
    .thenApplyAsync(id -> fetchUser(id))       // 변환 (새 스레드)
    .thenApply(user -> user.getName())         // 변환 (같은 스레드)
    .exceptionally(ex -> "Unknown User");      // 예외 처리

// thenCompose: flatMap 역할 (비동기 체인)
CompletableFuture<Order> orderFuture = CompletableFuture
    .supplyAsync(() -> fetchUser(userId))
    .thenCompose(user -> CompletableFuture.supplyAsync(() -> fetchLatestOrder(user)));

// thenCombine: 두 비동기 결과 합치기
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() -> fetchOrders(id));

CompletableFuture<UserProfile> profileFuture = userFuture.thenCombine(
    ordersFuture,
    (user, orders) -> new UserProfile(user, orders)
);

// allOf: 모두 완료까지 대기
CompletableFuture<Void> allDone = CompletableFuture.allOf(
    task1, task2, task3
);
allDone.thenRun(() -> System.out.println("All done!"));

// anyOf: 하나라도 완료
CompletableFuture<Object> firstDone = CompletableFuture.anyOf(task1, task2, task3);

// 타임아웃
CompletableFuture<String> withTimeout = future
    .orTimeout(5, TimeUnit.SECONDS)
    .completeOnTimeout("default", 3, TimeUnit.SECONDS);

// 커스텀 Executor 지정
Executor executor = Executors.newFixedThreadPool(10);
CompletableFuture.supplyAsync(() -> heavyTask(), executor);

6.2 Virtual Threads와 구조적 동시성

// Virtual Thread 활용 - I/O 집약적 작업
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<CompletableFuture<String>> tasks = urls.stream()
        .map(url -> CompletableFuture.supplyAsync(() -> httpGet(url), executor))
        .collect(Collectors.toList());

    List<String> results = tasks.stream()
        .map(CompletableFuture::join)
        .collect(Collectors.toList());
}

// ReentrantLock
ReentrantLock lock = new ReentrantLock(true); // fair lock
lock.lock();
try {
    // critical section
} finally {
    lock.unlock(); // finally에서 반드시 해제
}

// tryLock으로 타임아웃
if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        // critical section
    } finally {
        lock.unlock();
    }
} else {
    // 잠금 획득 실패 처리
}

// ReadWriteLock: 다수 읽기, 단일 쓰기
ReadWriteLock rwLock = new ReentrantReadWriteLock();
Map<String, String> cache = new HashMap<>();

public String get(String key) {
    rwLock.readLock().lock();
    try {
        return cache.get(key);
    } finally {
        rwLock.readLock().unlock();
    }
}

public void put(String key, String value) {
    rwLock.writeLock().lock();
    try {
        cache.put(key, value);
    } finally {
        rwLock.writeLock().unlock();
    }
}

7. 모던 Java 패턴

7.1 Builder Pattern

// 수동 Builder
public class HttpRequest {
    private final String url;
    private final String method;
    private final Map<String, String> headers;
    private final String body;
    private final int timeoutMs;

    private HttpRequest(Builder builder) {
        this.url = builder.url;
        this.method = builder.method;
        this.headers = Map.copyOf(builder.headers);
        this.body = builder.body;
        this.timeoutMs = builder.timeoutMs;
    }

    public static Builder builder(String url) {
        return new Builder(url);
    }

    public static class Builder {
        private final String url;
        private String method = "GET";
        private Map<String, String> headers = new HashMap<>();
        private String body;
        private int timeoutMs = 5000;

        private Builder(String url) { this.url = url; }

        public Builder method(String method) { this.method = method; return this; }
        public Builder header(String key, String value) { headers.put(key, value); return this; }
        public Builder body(String body) { this.body = body; return this; }
        public Builder timeout(int ms) { this.timeoutMs = ms; return this; }
        public HttpRequest build() { return new HttpRequest(this); }
    }
}

// 사용
HttpRequest request = HttpRequest.builder("https://api.example.com/users")
    .method("POST")
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer token123")
    .body("{\"name\": \"Alice\"}")
    .timeout(3000)
    .build();

7.2 Sealed Class + Pattern Matching으로 ADT 구현

// Result 타입 (Either와 유사)
public sealed interface Result<T> permits Result.Ok, Result.Err {

    record Ok<T>(T value) implements Result<T> {}
    record Err<T>(String message, Throwable cause) implements Result<T> {
        public Err(String message) { this(message, null); }
    }

    static <T> Result<T> ok(T value) { return new Ok<>(value); }
    static <T> Result<T> err(String message) { return new Err<>(message); }
    static <T> Result<T> of(Supplier<T> supplier) {
        try {
            return ok(supplier.get());
        } catch (Exception e) {
            return err(e.getMessage());
        }
    }

    default <U> Result<U> map(Function<T, U> mapper) {
        return switch (this) {
            case Ok<T> ok -> Result.ok(mapper.apply(ok.value()));
            case Err<T> err -> Result.err(err.message());
        };
    }

    default T getOrElse(T defaultValue) {
        return switch (this) {
            case Ok<T> ok -> ok.value();
            case Err<T> ignored -> defaultValue;
        };
    }
}

// 사용 예시
Result<Integer> result = Result.of(() -> Integer.parseInt("123"));
Result<String> mapped = result.map(n -> "Number: " + n);
String output = mapped.getOrElse("Parse failed");
System.out.println(output); // "Number: 123"

// Command 패턴 (ADT)
public sealed interface Command permits Command.CreateUser, Command.DeleteUser, Command.UpdateEmail {
    record CreateUser(String name, String email) implements Command {}
    record DeleteUser(String userId) implements Command {}
    record UpdateEmail(String userId, String newEmail) implements Command {}
}

public void handle(Command command) {
    switch (command) {
        case Command.CreateUser(String name, String email) ->
            userService.create(name, email);
        case Command.DeleteUser(String userId) ->
            userService.delete(userId);
        case Command.UpdateEmail(String userId, String newEmail) ->
            userService.updateEmail(userId, newEmail);
    }
}

7.3 Spring Boot와의 연계

// Record를 DTO로 사용
public record CreateUserRequest(
    @NotBlank String name,
    @Email String email,
    @Min(0) @Max(150) int age
) {}

public record UserResponse(
    String id,
    String name,
    String email,
    LocalDateTime createdAt
) {
    public static UserResponse from(User user) {
        return new UserResponse(
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getCreatedAt()
        );
    }
}

// Controller
@RestController
@RequestMapping("/api/users")
public class UserController {

    @PostMapping
    public ResponseEntity<UserResponse> createUser(
            @RequestBody @Valid CreateUserRequest request) {
        User user = userService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(UserResponse.from(user));
    }
}

// Sealed class로 API 응답 통일
public sealed interface ApiResponse<T> permits ApiResponse.Success, ApiResponse.Error {
    record Success<T>(T data, String message) implements ApiResponse<T> {}
    record Error<T>(String code, String message, List<String> details) implements ApiResponse<T> {}
}

8. 퀴즈

퀴즈 1: Record의 특징으로 올바르지 않은 것은?

보기:

  • A) 컴파일러가 equals, hashCode, toString을 자동 생성한다
  • B) 인스턴스 필드를 추가로 선언할 수 있다
  • C) 인터페이스를 구현할 수 있다
  • D) 다른 클래스를 상속받을 수 없다

정답: B

설명: Record는 헤더에 정의된 컴포넌트 외에 인스턴스 필드를 추가로 선언할 수 없습니다. static 필드는 가능합니다. Record는 java.lang.Record를 암묵적으로 상속하므로 다른 클래스를 상속받을 수 없으며, 인터페이스는 구현 가능합니다.

퀴즈 2: PECS 원칙에서 다음 코드의 올바른 와일드카드 유형은? public void addToList(List<___> list, T element)

보기:

  • A) extends T
  • B) super T
  • C) ?
  • D) 와일드카드 불필요

정답: B (super T)

설명: PECS(Producer Extends, Consumer Super)에서 list가 element를 받아들이는 Consumer 역할을 하므로 ? super T를 사용합니다. ? extends T는 읽기(Producer)에 사용합니다. List<? super T>는 T 타입 또는 T의 상위 타입 리스트를 받을 수 있어 T 요소를 안전하게 추가할 수 있습니다.

퀴즈 3: Virtual Thread에 대한 설명으로 올바른 것은?

보기:

  • A) Virtual Thread는 OS 스레드와 1:1 매핑된다
  • B) CPU 집약적 작업에 특히 효과적이다
  • C) 블로킹 I/O 시 캐리어 스레드에서 마운트 해제된다
  • D) synchronized 블록에서도 항상 최적의 성능을 발휘한다

정답: C

설명: Virtual Thread는 블로킹 I/O 발생 시 OS 스레드(캐리어 스레드)에서 언마운트되어 다른 Virtual Thread가 해당 OS 스레드를 사용할 수 있습니다. CPU 집약적 작업보다 I/O 집약적 작업에 적합하며, synchronized 블록에서는 pinning이 발생할 수 있어 성능 저하가 있을 수 있습니다(ReentrantLock 권장).

퀴즈 4: 다음 Stream 코드의 결과는? List.of(1, 2, 3, 4, 5).stream().filter(n -> n % 2 == 0).map(n -> n * n).reduce(0, Integer::sum)

보기:

  • A) 20
  • B) 29
  • C) 25
  • D) 4

정답: A (20)

설명: filter로 짝수 선별 [2, 4], map으로 제곱 [4, 16], reduce로 합산 4 + 16 = 20입니다.

퀴즈 5: Sealed Class에서 permits에 나열된 하위 클래스가 가질 수 없는 특징은?

보기:

  • A) final로 선언되어 더 이상 상속 불가
  • B) sealed로 선언되어 자신의 하위 타입을 제한
  • C) non-sealed로 선언되어 자유롭게 상속 허용
  • D) abstract로 선언되어 직접 인스턴스화 불가

정답: D

설명: Sealed Class의 직접 하위 클래스는 반드시 final, sealed, 또는 non-sealed 중 하나로 선언되어야 합니다. abstract만으로는 선언할 수 없습니다(abstract는 sealed나 final과 함께 사용 가능하지만 단독으로 permits 조건을 충족하지 않습니다). Java 컴파일러는 Sealed 계층의 모든 하위 클래스를 알고 있어야 하기 때문입니다.


마무리

모던 Java는 단순한 문법 개선을 넘어 함수형 프로그래밍, 타입 안전성, 고성능 동시성을 언어 차원에서 지원합니다. Java 21 LTS를 기반으로:

  • Record + Sealed Class + Pattern Matching: 타입 안전한 도메인 모델
  • Stream API + Optional: 선언적 데이터 처리
  • Virtual Threads: 고성능 I/O 동시성
  • CompletableFuture: 비동기 파이프라인

이 기능들을 적절히 조합하면 간결하고 안전하며 고성능인 Java 코드를 작성할 수 있습니다.