← all posts
DEV 2026.05.02 · 11 min read Intermediate

Java Util API의 공통 철학: 비교, 흐름, 부재, 패턴

Comparator의 체이닝부터 Stream 파이프라인, Optional의 null 추방, 정규표현식의 패턴 추상화까지 — Java util 패키지가 반복하는 하나의 설계 언어를 추적한다.


java.util 패키지의 네 API — Comparator, Stream, Optional, 정규표현식 — 는 서로 다른 문제를 풀지만 이상하리만큼 비슷한 문법을 공유한다. 람다를 받고, 체이닝을 허용하고, 명시적으로 의도를 선언한다. 우연일까, 아니면 이 네 API는 같은 설계 언어로 쓰여 있는 것일까?

비교의 추상화 — Comparator

정렬 기준을 코드로 표현하는 방법은 두 가지다. Comparable은 클래스 안에 “나는 이렇게 비교된다”를 새긴다. Comparator는 클래스 밖에서 “이 기준으로 비교하라”고 선언한다.

// Comparable: 클래스에 자연 순서를 고정
class Student implements Comparable<Student> {
    @Override
    public int compareTo(Student other) {
        return Integer.compare(this.score, other.score);
    }
}

// Comparator: 외부에서 기준을 주입
Comparator<Student> byScore = Comparator.comparingInt(s -> s.score);
Comparator<Student> complex = Comparator
    .comparingInt((Student s) -> s.score).reversed()
    .thenComparingInt(s -> s.age)
    .thenComparing(s -> s.name);

thenComparing 체이닝이 핵심이다. “점수 내림차순, 같으면 나이 오름차순, 같으면 이름 사전순”이라는 복잡한 정렬 명세가 한 문장의 선언으로 표현된다. 명령형 if 블록 대신 의도가 코드 표면에 드러난다.

한 가지 함정: return a - b 패턴은 정수 오버플로우를 유발한다. Integer.compare(a, b)가 항상 올바른 선택이다. null이 섞인 데이터라면 Comparator.nullsFirst 또는 nullsLast로 null 위치를 명시적으로 결정해야 한다 — 묵시적 NPE보다 명시적 선언이 낫다.

데이터 흐름의 추상화 — Stream

Stream은 컬렉션을 순회하는 방식을 바꾼다. for 루프가 “어떻게 순회할 것인가”를 기술한다면, Stream은 “무엇을 원하는가”를 기술한다.

// 명령형: 어떻게
List<String> result = new ArrayList<>();
for (Product p : products) {
    if (p.category.equals("전자") && p.price >= 100000) {
        result.add(p.name.toUpperCase());
    }
}

// 선언형: 무엇을
List<String> result = products.stream()
    .filter(p -> p.category.equals("전자"))
    .filter(p -> p.price >= 100000)
    .map(p -> p.name.toUpperCase())
    .collect(Collectors.toList());

파이프라인은 생성 → 중간 연산 → 최종 연산 세 단계로 구성된다. 중간 연산(filter, map, flatMap, sorted)은 지연 평가(lazy)다 — 최종 연산(collect, reduce, count)이 호출되기 전까지 실제로 실행되지 않는다. 이 지연이 단락 평가(short-circuit)를 가능하게 하고, limit(1).findFirst() 같은 조합이 전체 컬렉션을 스캔하지 않도록 만든다.

groupingBypartitioningBy는 Stream의 가장 강력한 최종 단계다. 고객별 총 구매액, 학년별 평균 점수, 단어 빈도 분석 — 모두 Collectors.groupingBy 한 줄로 표현된다. flatMap은 중첩 컬렉션을 평탄화하는 핵심 연산이다: List<List<T>>List<T>.

병렬 스트림의 트레이드오프

parallelStream()은 ForkJoinPool을 사용해 연산을 병렬화한다. 대량 데이터의 CPU 집약적 연산에서는 이점이 있지만, 소량 데이터나 I/O 작업, 순서 의존 연산에서는 오히려 느리다. 공유 변수 수정은 절대 금지 — collect로 결과를 모아야 한다.

부재의 추상화 — Optional

null은 Java의 가장 오래된 설계 실수 중 하나다. null을 반환하는 메서드는 호출자에게 암묵적 계약을 강요한다 — “이 값이 null일 수 있다는 걸 알아서 처리해라.” Optional은 이 계약을 타입 시스템으로 끌어올린다.

// null을 반환하는 전통 방식
String email = user != null ? user.getEmail() : null;
String upper = email != null ? email.toUpperCase() : "NO EMAIL";

// Optional 체이닝
String upper = Optional.ofNullable(user)
    .map(User::getEmail)
    .map(String::toUpperCase)
    .orElse("NO EMAIL");

map은 Optional 안의 값에 함수를 적용한다. 값이 없으면 빈 Optional을 그대로 전파한다 — null 체크가 파이프라인 안에 숨어 있다. flatMapOptional<Optional<T>>의 중첩을 방지한다. orElseGetorElse와 달리 Supplier를 받아 값이 없을 때만 평가한다 — DB 조회나 네트워크 호출 같은 비용 있는 연산에서는 orElseGet이 올바른 선택이다.

패턴의 추상화 — 정규표현식

정규표현식은 문자열 처리를 “어떤 문자를 어떻게 찾는가” 대신 “어떤 패턴인가”로 기술한다. Pattern.compile은 정규식을 컴파일된 패턴으로 캐싱하고, Matcher가 실제 매칭을 수행한다.

// 컴파일된 패턴 재사용 (성능 중요)
private static final Pattern EMAIL =
    Pattern.compile("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$");

// 캡처 그룹으로 데이터 추출
Pattern date = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher m = date.matcher("2024-12-17");
if (m.find()) {
    String year = m.group(1);   // 2024
    String month = m.group(2);  // 12
    String day = m.group(3);    // 17
}

// replaceAll로 변환
String converted = "2024-12-17"
    .replaceAll("(\\d{4})-(\\d{2})-(\\d{2})", "$3/$2/$1");
// → 17/12/2024

탐욕적(greedy) 수량자는 최대한 길게 매칭하고, 게으른(lazy) 수량자(*?, +?)는 최소한으로 매칭한다. HTML 파싱처럼 같은 패턴이 여러 번 나타날 때 .*는 마지막 닫는 태그까지 삼켜버린다 — .*?가 필요한 이유다. return a - b 오버플로우처럼, 수량자 선택도 의도하지 않은 결과를 낳는다.

정리

네 API를 관통하는 공통 언어는 하나다 — 명시적 선언, 체이닝, 지연 평가.

  • Comparator: 정렬 기준을 체이닝으로 조합한다. Integer.compare를 쓰고 null을 명시한다.
  • Stream: 파이프라인은 지연 평가다. 중간 연산은 쌓이고, 최종 연산이 실행한다. 병렬은 트레이드오프가 있다.
  • Optional: null을 타입으로 드러낸다. isPresent+get 대신 map/orElseGet으로 파이프라인을 이어간다.
  • 정규표현식: Pattern을 정적 상수로 캐싱하고, 캡처 그룹으로 추출하고, $1 역참조로 변환한다.

이 네 API를 배우는 것은 각각의 메서드를 외우는 일이 아니다. “의도를 코드 표면에 드러내라”는 Java 8 이후의 설계 언어를 익히는 일이다.