Java Util API의 공통 철학: 비교, 흐름, 부재, 패턴
Comparator의 체이닝부터 Stream 파이프라인, Optional의 null 추방, 정규표현식의 패턴 추상화까지 — Java util 패키지가 반복하는 하나의 설계 언어를 추적한다.
- 01 Java String은 왜 불변인가
- 02 Java 수 타입의 설계 철학 — 왜 세 가지가 필요한가
- 03 Java 배열과 Arrays 클래스, 무엇을 알아야 하는가
- 04 Java Generics는 왜 타입을 지운가
- 05 Java Enum은 단순한 상수 묶음이 아니다
- 06 Java 예외는 어떻게 설계해야 하는가
- 07 Java Collections Framework, 하나의 철학으로 읽는다
- 08 Java 함수형 인터페이스는 왜 이렇게 설계됐을까
- 09 Modern Java의 불변 설계 — Record, Switch, Sealed Class
- 10 Java Util API의 공통 철학: 비교, 흐름, 부재, 패턴
- 11 Java 8 Time API는 왜 기존 Date를 버렸는가
- 12 Java IO는 왜 이렇게 복잡한가
- 13 Java Reflection은 어떻게 프레임워크를 만드는가
- 14 Java 동시성은 왜 이렇게 설계됐을까
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() 같은 조합이 전체 컬렉션을 스캔하지 않도록 만든다.
groupingBy와 partitioningBy는 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 체크가 파이프라인 안에 숨어 있다. flatMap은 Optional<Optional<T>>의 중첩을 방지한다. orElseGet은 orElse와 달리 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 이후의 설계 언어를 익히는 일이다.