공공부하자개발 · 영어 학습 노트
자바
중급객체지향과 코어 라이브러리0/9 완료
  • 01상속, 다형성, 오버라이딩
  • 02추상 클래스 vs 인터페이스
  • 03예외 처리
  • 04java.lang 심화
  • 05컬렉션 프레임워크 딥다이브
  • 06메서드 활용 패턴 (중급)
  • 07Object 메서드와 비교
  • 08java.time 실무 날짜 계산
  • 09HTTP 와 JSON 기초
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 중급 › 06 / 9

메서드 활용 패턴 (중급)

섹션 6진행 0 / 9
1왜 배우는가2핵심 원리3패턴 모음4자주 하는 실수 (Tip)5연습 문제6정리‹ 이전다음 ›

3. 패턴 모음

각 패턴의 코드는 java-src/intermediate/06_method_patterns/에서 실행해 볼 수 있습니다. Money, Order, Report, Team 등이 보조 클래스입니다.

패턴 1: 정적 팩토리 of — 값을 감싸기

생성자를 private으로 막고 of로만 만들게 하면, 나중에 캐싱을 넣거나 하위 타입을 돌려주도록 바꿔도 호출 코드는 그대로입니다. 가장 기본적인 정적 팩토리입니다. List.of, LocalDate.of, Map.of가 모두 이 이름을 씁니다.

java
public final class Money {
    private final long amount;
    private final String currency;

    private Money(long amount, String currency) {          // 생성자는 숨김
        if (amount < 0) throw new IllegalArgumentException("음수 금액: " + amount);
        this.amount = amount;
        this.currency = currency;
    }

    public static Money of(long amount) { return new Money(amount, "KRW"); }
}

System.out.println(Money.of(1000));      // 출력: 1,000원

주의: 생성자가 private이면 상속이 불가능합니다. 값 객체는 대개 final이므로 문제 없지만, 상속을 열어 둘 클래스라면 protected 생성자를 따로 둡니다.

패턴 2: 정적 팩토리 from — 다른 타입에서 변환

문자열, DTO, 다른 도메인 객체에서 변환해 만들 때는 from을 씁니다. 파싱·정규화 로직이 클래스 안에 모이므로 "12,500원"을 해석하는 코드가 여기저기 흩어지지 않습니다. Date.from(instant), EnumSet.copyOf가 같은 부류입니다.

java
public static Money from(String text) {
    String digits = text.replace(",", "").replace("원", "").trim();
    return of(Long.parseLong(digits));
}

System.out.println(Money.from("12,500원"));      // 출력: 12,500원

주의: from은 실패할 수 있습니다("abc"). NumberFormatException을 그대로 전파할지, 패턴 12의 Result로 감쌀지는 호출자가 복구 가능한지에 따라 정합니다.

패턴 3: valueOf + 캐싱 — 같은 값이면 같은 인스턴스

불변 객체는 값이 같으면 인스턴스를 공유해도 안전합니다. Integer.valueOf(127)이 항상 같은 객체를 돌려주는 것처럼, 캐시 Map을 두고 computeIfAbsent로 없을 때만 만듭니다. 자주 쓰는 값이 반복 생성되는 것을 막습니다.

java
private static final Map<Long, Money> CACHE = new HashMap<>();

public static Money valueOf(long amount) {
    return CACHE.computeIfAbsent(amount, Money::of);      // 없을 때만 of 호출
}

System.out.println(Money.valueOf(100) == Money.valueOf(100));   // 출력: true   (같은 객체)
System.out.println(Money.of(100) == Money.of(100));             // 출력: false  (매번 새 객체)

주의: 캐시는 불변 객체에만 씁니다. 가변 객체를 공유하면 한 곳의 변경이 모든 곳에 퍼집니다. 또 캐시가 무한히 커지면 메모리 누수이므로 실무에서는 크기 제한이 필요합니다.

패턴 4: 이름 있는 생성 — 의도를 이름에 담기

new Money(2)로는 2원인지 2달러인지 알 수 없습니다. won, dollars, ZERO처럼 이름이 곧 문서인 팩토리를 두면 호출 코드가 읽힙니다. 같은 시그니처(long 하나)를 받는 팩토리를 여러 개 둘 수 있다는 것이 생성자와의 결정적 차이입니다.

java
public static final Money ZERO = of(0);
public static Money won(long amount) { return of(amount); }
public static Money dollars(double usd) { return new Money(Math.round(usd * 1350), "KRW"); }

System.out.println(Money.won(500));       // 출력: 500원
System.out.println(Money.dollars(2));     // 출력: 2,700원
System.out.println(Money.ZERO);           // 출력: 0원

주의: 정적 팩토리가 많아지면 "어떻게 만드는지" 찾기 어려워집니다. Javadoc이나 클래스 상단 주석에 팩토리 목록을 적어 둡니다.

패턴 5: 빌더 — 필수/선택 인자 분리

필수 인자(customer, 상품 1개 이상)는 빌더 생성자와 build()에서 강제하고, 선택 인자(address, coupon, gift)는 기본값을 두고 필요할 때만 호출합니다. 생성 결과는 불변입니다. 매개변수가 많고 대부분 선택 사항인 설정·요청 객체에 씁니다.

java
public final class Order {
    private final String customer;
    private final List<String> items;
    private final String address, coupon;
    private final boolean gift;

    private Order(Builder b) { ...b의 값을 복사... }

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

    public static class Builder {
        private final String customer;                       // 필수
        private final List<String> items = new ArrayList<>();
        private String address = "기본 배송지";               // 선택 + 기본값
        private String coupon = null;
        private boolean gift = false;

        private Builder(String customer) {
            if (customer == null || customer.isBlank()) throw new IllegalArgumentException("customer 필수");
            this.customer = customer;
        }
        public Builder item(String name) { items.add(name); return this; }
        public Builder address(String address) { this.address = address; return this; }
        public Builder coupon(String coupon) { this.coupon = coupon; return this; }
        public Builder gift(boolean gift) { this.gift = gift; return this; }
        public Order build() {
            if (items.isEmpty()) throw new IllegalStateException("상품이 없습니다");
            return new Order(this);
        }
    }
}

Order o1 = Order.builder("Kim").item("노트북").build();
Order o2 = Order.builder("Lee").item("펜").item("노트").address("부산").coupon("WELCOME").gift(true).build();
System.out.println(o1);     // 출력: Kim [노트북] → 기본 배송지
System.out.println(o2);     // 출력: Lee [펜, 노트] → 부산 쿠폰:WELCOME [선물]
try {
    Order.builder("Park").build();
} catch (IllegalStateException e) {
    System.out.println(e.getMessage());     // 출력: 상품이 없습니다
}

주의: 매개변수가 3개 이하이거나 모두 필수면 빌더는 과합니다. record나 정적 팩토리로 충분합니다.

패턴 6: 메서드 체이닝 — return this

각 메서드가 자기 자신을 돌려주면 호출을 .으로 이어 붙일 수 있습니다. 설정을 여러 개 쌓아 마지막에 결과를 뽑는 객체(쿼리 빌더, StringBuilder, HTTP 요청 빌더)에 어울립니다. 마지막 메서드(toSql)만 다른 타입을 돌려줍니다.

java
public class Query {
    private String table;
    private final List<String> conditions = new ArrayList<>();
    private String orderBy;
    private int limit = -1;

    public Query from(String table) { this.table = table; return this; }
    public Query where(String cond) { conditions.add(cond); return this; }
    public Query orderBy(String col) { this.orderBy = col; return this; }
    public Query limit(int n) { this.limit = n; return this; }

    public String toSql() {
        StringBuilder sb = new StringBuilder("SELECT * FROM " + table);
        if (!conditions.isEmpty()) sb.append(" WHERE ").append(String.join(" AND ", conditions));
        if (orderBy != null) sb.append(" ORDER BY ").append(orderBy);
        if (limit > 0) sb.append(" LIMIT ").append(limit);
        return sb.toString();
    }
}

String sql = new Query().from("users").where("age > 20").where("active = 1").orderBy("name").limit(10).toSql();
System.out.println(sql);
// 출력: SELECT * FROM users WHERE age > 20 AND active = 1 ORDER BY name LIMIT 10

주의: 체이닝 객체는 가변입니다. 여러 곳에서 같은 Query를 공유하면 조건이 섞입니다. 상속과 함께 쓰면 return this의 타입이 부모 타입이 되어 체인이 끊기는 문제도 있습니다(제네릭 self-type으로 해결, 고급 주제).

패턴 7: 템플릿 메서드 — 골격은 부모, 세부는 자식

"헤더 → 각 행 → 푸터" 순서는 모든 리포트가 같고, 각 단계의 모양만 다릅니다. 부모가 순서를 final 메서드로 고정하고 각 단계를 abstract로 비워 두면, 자식은 빈칸만 채웁니다. 런타임 디스패치 덕분에 부모의 generate가 자식의 header를 호출합니다.

java
public abstract class Report {
    public final String generate(List<String> rows) {          // 템플릿: 순서 고정
        StringBuilder sb = new StringBuilder();
        sb.append(header()).append("\n");
        for (String row : rows) sb.append(formatRow(row)).append("\n");
        if (includeFooter()) sb.append(footer(rows.size()));
        return sb.toString();
    }
    protected abstract String header();
    protected abstract String formatRow(String row);
    protected boolean includeFooter() { return true; }
    protected String footer(int count) { return "총 " + count + "건"; }
}

class MarkdownReport extends Report {
    @Override protected String header() { return "| name |\n|------|"; }
    @Override protected String formatRow(String row) { return "| " + row + " |"; }
    @Override protected String footer(int count) { return "_" + super.footer(count) + "_"; }
}

System.out.print(new MarkdownReport().generate(List.of("Kim", "Lee")));
// 출력:
// | name |
// |------|
// | Kim |
// | Lee |
// _총 2건_

주의: 템플릿 메서드는 final로 두어 자식이 순서를 깨지 못하게 합니다. 단계가 많아지면 상속 대신 전략(람다) 주입이 더 유연합니다(고급 레슨).

패턴 8: 훅(hook) 메서드 — 기본 동작을 두고 선택적으로 덮어쓰기

includeFooter()처럼 기본 구현이 있는 메서드를 훅이라 합니다. 자식은 필요한 것만 덮어쓰고 나머지는 부모 기본값을 씁니다. 모든 단계를 abstract로 만들면 자식이 전부 구현해야 하므로, "대부분 같고 가끔 다른" 단계는 훅으로 둡니다.

java
class CsvReport extends Report {
    @Override protected String header() { return "name"; }
    @Override protected String formatRow(String row) { return row; }
    @Override protected boolean includeFooter() { return false; }   // 훅으로 푸터만 끔
}

System.out.print(new CsvReport().generate(List.of("Kim", "Lee")));
// 출력:
// name
// Kim
// Lee

주의: 훅이 너무 많으면 어떤 조합이 유효한지 알기 어렵습니다. 훅은 boolean 스위치나 작은 포맷 정도로 제한합니다.

패턴 9: 오버라이딩 시 super 호출 — 부모 동작 유지하며 확장

deposit을 오버라이딩하면서 부모의 검증·합산 로직을 다시 쓰고 싶지 않습니다. super.deposit(amount)를 먼저 호출하고 추가 동작만 덧붙입니다. 부모 로직이 바뀌면 자식도 자동으로 따라갑니다. 로깅, 카운팅, 캐시 무효화처럼 "원래 일 + α"에 씁니다.

java
public class Account {
    protected long balance;
    public void deposit(long amount) {
        if (amount <= 0) throw new IllegalArgumentException("양수만 가능");
        balance += amount;
    }
    public String describe() { return "잔액 " + balance; }
}

class LoggingAccount extends Account {
    private int depositCount;
    @Override public void deposit(long amount) {
        super.deposit(amount);          // 검증 + 합산은 부모
        depositCount++;                 // 추가 동작
    }
    @Override public String describe() { return super.describe() + " (입금 " + depositCount + "회)"; }
}

LoggingAccount acc = new LoggingAccount();
acc.deposit(1000);
acc.deposit(500);
System.out.println(acc.describe());     // 출력: 잔액 1500 (입금 2회)
try {
    acc.deposit(-1);
} catch (IllegalArgumentException e) {
    System.out.println(e.getMessage());  // 출력: 양수만 가능
}

주의: super 호출을 앞에 둘지 뒤에 둘지가 의미를 바꿉니다. 부모가 예외를 던지면 그 뒤 코드는 실행되지 않으므로, 위 예제에서 depositCount++가 super 앞에 있었다면 실패한 입금도 세게 됩니다.

패턴 10: 인터페이스 default 메서드로 기능 추가

인터페이스에 추상 메서드(area) 하나만 두고, 그것으로 만들 수 있는 편의 메서드(isLargerThan, describe)는 default로 제공합니다. 구현 클래스는 area만 쓰면 나머지를 공짜로 얻고, 원하면 덮어쓸 수 있습니다. Comparator.reversed(), Iterable.forEach()가 이 방식입니다.

java
public interface Shape {
    double area();
    default boolean isLargerThan(Shape other) { return area() > other.area(); }
    default String describe() {
        return getClass().getSimpleName() + "(" + String.format("%.1f", area()) + ")";
    }
}
record Circle(double r) implements Shape { public double area() { return Math.PI * r * r; } }
record Rect(double w, double h) implements Shape {
    public double area() { return w * h; }
    @Override public String describe() { return "직사각형 " + w + "x" + h; }   // 덮어쓰기
}

Shape c = new Circle(1);
Shape r = new Rect(2, 3);
System.out.println(c.describe());        // 출력: Circle(3.1)
System.out.println(r.describe());        // 출력: 직사각형 2.0x3.0
System.out.println(r.isLargerThan(c));   // 출력: true

주의: default 메서드는 인터페이스에 상태를 넣을 수 없으므로 반드시 다른 추상 메서드를 통해 동작해야 합니다. 두 인터페이스의 default가 충돌하면 구현 클래스가 명시적으로 골라야 합니다(Shape.super.describe()).

패턴 11: Optional 반환 — "없을 수 있는 값 하나"

찾기 메서드가 null을 돌려주면 호출자가 검사를 잊기 쉽습니다. Optional<T>를 돌려주면 "없을 수 있다"가 타입에 드러나고, map/orElse로 null 검사 없이 이어 쓸 수 있습니다. Map.get처럼 null을 돌려주는 API를 Optional.ofNullable로 감싸는 것이 가장 흔한 사용법입니다.

java
static final Map<String, String> USERS = Map.of("kim", "kim@shop.com", "lee", "lee@shop.com");

static Optional<String> findUser(String id) {
    return Optional.ofNullable(USERS.get(id));
}

System.out.println(findUser("kim").map(String::toUpperCase).orElse("없음"));   // 출력: KIM@SHOP.COM
System.out.println(findUser("zzz").map(String::toUpperCase).orElse("없음"));   // 출력: 없음

주의: Optional은 반환 타입 전용입니다. 필드, 매개변수, 컬렉션 원소로 쓰지 않습니다. optional.get()을 검사 없이 부르면 null을 돌려주는 것과 다를 바 없습니다. 고급 05 레슨에서 깊게 다룹니다.

패턴 12: Result 반환 — 실패 이유를 값으로

입력 검증처럼 "실패가 정상 흐름의 일부"이고 이유가 필요하면 예외 대신 성공/실패를 담는 타입을 돌려줍니다. sealed interface + record 두 개면 충분하고, switch 패턴 매칭으로 모든 경우를 강제로 다루게 됩니다. 예외의 스택 추적 비용도 없습니다.

java
public sealed interface Result<T> permits Result.Ok, Result.Fail {
    record Ok<T>(T value) implements Result<T> {}
    record Fail<T>(String reason) implements Result<T> {}
    static <T> Result<T> ok(T value) { return new Ok<>(value); }
    static <T> Result<T> fail(String reason) { return new Fail<>(reason); }
}

static Result<Integer> parseAge(String s) {
    int n;
    try { n = Integer.parseInt(s); }
    catch (NumberFormatException e) { return Result.fail("숫자가 아님: " + s); }
    if (n < 0 || n > 150) return Result.fail("범위 밖: " + n);
    return Result.ok(n);
}
static void printResult(Result<Integer> r) {
    switch (r) {
        case Result.Ok<Integer> ok -> System.out.println("OK " + ok.value());
        case Result.Fail<Integer> f -> System.out.println("FAIL " + f.reason());
    }
}

printResult(parseAge("30"));     // 출력: OK 30
printResult(parseAge("abc"));    // 출력: FAIL 숫자가 아님: abc
printResult(parseAge("-5"));     // 출력: FAIL 범위 밖: -5

주의: 호출자가 복구할 수 없는 상황(DB 연결 끊김, 버그)은 여전히 예외가 맞습니다. Result는 "사용자 입력이 틀렸다" 같은 예상된 실패에 씁니다.

패턴 13: 불변 객체의 with-메서드

불변 객체는 setter가 없습니다. 값을 바꾸고 싶으면 바뀐 복사본을 돌려주는 withXxx 메서드를 둡니다. 원본은 그대로이므로 여러 곳에서 공유해도 안전하고, plus/times처럼 연산도 새 객체를 돌려줍니다. LocalDate.withYear, String.replace가 이 방식입니다.

java
public Money withAmount(long newAmount) { return new Money(newAmount, currency); }
public Money plus(Money other) { return new Money(amount + other.amount, currency); }
public Money times(int n) { return new Money(amount * n, currency); }

Money m = Money.of(1000);
Money m2 = m.withAmount(2000);
System.out.println(m + " " + m2);            // 출력: 1,000원 2,000원   (m은 그대로)
System.out.println(m.plus(m2).times(3));     // 출력: 9,000원

주의: m.plus(m2);처럼 반환값을 버리면 아무 일도 일어나지 않습니다(초급 패턴 4의 String과 같은 함정). record라면 withXxx를 직접 써야 합니다(자동 생성 안 됨).

패턴 14: equals / hashCode 표준 구현

값 객체는 "같은 값이면 같은 객체"여야 합니다. equals는 자기 자신 검사 → instanceof 패턴 매칭 → 필드 비교 순으로, hashCode는 Objects.hash로 같은 필드를 넣어 만듭니다. 둘은 반드시 함께 구현합니다.

java
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Money m)) return false;        // null도 여기서 false
    return amount == m.amount && currency.equals(m.currency);
}
@Override
public int hashCode() { return Objects.hash(amount, currency); }

System.out.println(Money.of(100).equals(Money.of(100)));                         // 출력: true
Set<Money> set = new HashSet<>(List.of(Money.of(100), Money.of(100), Money.of(200)));
System.out.println(set.size());                                                  // 출력: 2

주의: equals(Money o)처럼 매개변수 타입을 바꾸면 오버라이딩이 아니라 오버로딩이 되어 HashSet이 무시합니다. 반드시 equals(Object o)이고 @Override를 붙여 컴파일러가 잡게 합니다.

패턴 15: toString — 로그와 디버깅을 위해

toString을 구현하지 않으면 Money@1b6d3586이 찍힙니다. 문자열 연결, println, 컬렉션 출력, 디버거, 로그 모두 toString을 부르므로 값 객체는 반드시 구현합니다. 사람이 읽을 형식으로, 핵심 필드를 담습니다.

java
@Override
public String toString() {
    return String.format("%,d%s", amount, currency.equals("KRW") ? "원" : currency);
}

System.out.println("합계: " + Money.of(1234567));        // 출력: 합계: 1,234,567원
System.out.println(List.of(Money.of(1), Money.of(2)));   // 출력: [1원, 2원]

주의: toString을 파싱해서 값을 꺼내는 코드를 쓰지 마세요. 표시용이며 형식이 바뀔 수 있습니다. 값이 필요하면 접근자(amount())를 씁니다.

패턴 16: compareTo — 자연 순서 정의

Comparable<Money>를 구현하면 TreeSet, Collections.sort, Stream.sorted()가 자동으로 정렬합니다. 숫자 필드는 a - b 대신 Long.compare(a, b)를 씁니다(오버플로우 방지). 여러 필드면 첫 필드가 같을 때 다음 필드로 넘어갑니다.

java
public final class Money implements Comparable<Money> {
    @Override
    public int compareTo(Money o) { return Long.compare(amount, o.amount); }
}

TreeSet<Money> sorted = new TreeSet<>(List.of(Money.of(300), Money.of(100), Money.of(200)));
System.out.println(sorted);                                      // 출력: [100원, 200원, 300원]
System.out.println(sorted.first().compareTo(sorted.last()) < 0);  // 출력: true

주의: TreeSet은 equals가 아니라 compareTo == 0으로 중복을 판단합니다. compareTo가 통화를 무시하면 100원과 100달러가 같은 것으로 취급됩니다. equals와 일관되게 만드세요.

패턴 17: 유틸 클래스 — private 생성자 + static

Math, Collections, Objects처럼 상태 없이 static 메서드만 모은 클래스입니다. 실수로 new Strings()를 못 하게 생성자를 private으로 막고, 리플렉션까지 막으려면 안에서 예외를 던집니다. final로 상속도 막습니다.

java
public final class Strings {
    private Strings() { throw new AssertionError("인스턴스화 금지"); }

    public static boolean isBlank(String s) { return s == null || s.isBlank(); }
    public static String abbreviate(String s, int max) {
        if (s == null || s.length() <= max) return s;
        return s.substring(0, max - 3) + "...";
    }
    public static String capitalize(String s) {
        if (isBlank(s)) return s;
        return Character.toUpperCase(s.charAt(0)) + s.substring(1);
    }
}

System.out.println(Strings.capitalize("java"));                // 출력: Java
System.out.println(Strings.abbreviate("Hello, World!", 8));    // 출력: Hello...
System.out.println(Strings.isBlank("  "));                     // 출력: true

주의: 유틸 클래스에 static 필드로 상태를 넣기 시작하면 전역 변수가 됩니다. 상태가 필요하면 일반 클래스로 바꾸세요.

패턴 18: 방어적 복사 — 받은 컬렉션은 복사한다

생성자가 받은 리스트를 그대로 필드에 넣으면 바깥에서 그 리스트를 바꿀 때 내 객체도 바뀝니다(별칭). new ArrayList<>(initial)로 복사하면 그 순간 연결이 끊깁니다. 컬렉션, 배열, Date 같은 가변 객체를 받을 때는 항상 복사합니다.

java
public class Team {
    private final List<String> members;
    public Team(Collection<String> initial) {
        this.members = new ArrayList<>(initial);        // 복사
    }
}

List<String> src = new ArrayList<>(List.of("Kim", "Lee"));
Team team = new Team(src);
src.add("HACK");                                        // 바깥에서 원본 변경
System.out.println(team.members());                     // 출력: [Kim, Lee]

주의: 얕은 복사입니다. 리스트 안의 원소가 가변 객체면 원소 자체는 공유됩니다. 원소도 불변(String, record, Money)으로 만드는 것이 근본 해결책입니다.

패턴 19: 불변 반환 — 내 컬렉션을 바깥에 노출하지 않는다

getter가 내부 리스트를 그대로 돌려주면 바깥에서 add/clear로 내 상태를 망가뜨립니다. Collections.unmodifiableList는 읽기 전용 뷰를 돌려주고, 수정 시도는 예외로 막습니다. 내부 변경(team.add)은 뷰에도 반영됩니다.

java
public void add(String name) { members.add(name); }
public List<String> members() { return Collections.unmodifiableList(members); }

try {
    team.members().add("Park");
} catch (UnsupportedOperationException e) {
    System.out.println("수정 불가");                  // 출력: 수정 불가
}
team.add("Park");                                   // 정해진 경로로만 변경
System.out.println(team.members());                 // 출력: [Kim, Lee, Park]

주의: 스냅샷이 필요하면(반환 후 내부가 바뀌어도 그대로여야 함) List.copyOf(members)를 씁니다. 매번 복사하므로 큰 리스트를 자주 호출할 때는 비용을 고려합니다.

패턴 20: 빈 컬렉션 vs null

"결과 없음"을 null로 돌려주면 호출자는 if (list != null)을 매번 써야 하고, 잊으면 NPE입니다. 빈 컬렉션을 돌려주면 for문이 0번 돌고 size()가 0이라 아무 검사가 필요 없습니다. 컬렉션·배열을 돌려주는 메서드는 절대 null을 돌려주지 않습니다.

java
public List<String> membersStartingWith(char c) {
    List<String> out = new ArrayList<>();
    for (String m : members) if (m.charAt(0) == c) out.add(m);
    return out;                                        // 없으면 빈 리스트
}

System.out.println(team.membersStartingWith('K'));         // 출력: [Kim]
System.out.println(team.membersStartingWith('Z').size());  // 출력: 0   (null이었다면 NPE)

주의: 빈 컬렉션 생성 비용이 걱정되면 List.of()/Collections.emptyList()를 돌려줍니다. 불변 싱글턴이라 비용이 없습니다.

패턴 21: 인터페이스 타입으로 받기 — Collection, List

매개변수를 ArrayList<String>으로 선언하면 Set이나 List.of()의 결과를 못 넘깁니다. 메서드가 실제로 필요한 능력만 요구하는 가장 넓은 인터페이스(Collection → Iterable)로 받으면 호출자가 자유로워집니다. 반환은 반대로 구체적인 것이 편할 수 있지만, 대개 List/Set/Map 인터페이스로 돌려줍니다.

java
public int addAll(Collection<String> names) {      // ArrayList가 아니라 Collection
    int before = members.size();
    members.addAll(names);
    return members.size() - before;
}

System.out.println(team.addAll(Set.of("Choi")));        // 출력: 1
System.out.println(team.addAll(List.of("A", "B")));     // 출력: 2
System.out.println(team.members().size());              // 출력: 6

주의: 순서가 중요하면 List, 중복 불가가 중요하면 Set으로 좁혀 받아 의도를 드러냅니다. "무조건 Collection"이 아니라 "필요한 만큼만 넓게"입니다.

패턴 22: 실무 예제 — Money 계산

앞의 패턴들을 합치면 Money는 정적 팩토리로 만들고, with-메서드로 계산하고, compareTo로 비교하는 완전한 값 객체가 됩니다. 실무에서 금액을 long이나 double로 들고 다니면 통화·반올림·음수 검증이 흩어지는데, 값 객체는 그것을 한 곳에 모읍니다.

java
Money price = Money.from("15,000원");
Money total = price.times(3).plus(Money.won(2500));
System.out.println(total);                                              // 출력: 47,500원
System.out.println(total.compareTo(Money.of(50_000)) < 0 ? "5만원 미만" : "5만원 이상");
// 출력: 5만원 미만

주의: 실제 금융 코드는 double을 절대 쓰지 않습니다. 원 단위 long 또는 BigDecimal을 씁니다. dollars(double)는 예제용 단순화입니다.

패턴 23: 실무 예제 — 조건에 따라 빌더 조합

빌더는 변수에 담아 두고 조건에 따라 메서드를 골라 부른 뒤 build()할 수 있습니다. 생성자였다면 new Order(a, b, cond ? c : null, ...)처럼 읽기 어려운 삼항 연산자가 늘어났을 것입니다.

java
Order.Builder b = Order.builder("Choi").item("키보드");
if (total.amount() > 40_000) b.coupon("OVER40K");        // 조건부 설정
System.out.println(b.build());
// 출력: Choi [키보드] → 기본 배송지 쿠폰:OVER40K

주의: 같은 빌더로 build()를 두 번 호출하면 내부 리스트를 공유할 수 있습니다. 예제의 Order는 List.copyOf로 복사하므로 안전하지만, 빌더를 직접 만들 때 확인해야 합니다.

패턴 24: 실무 예제 — Validator 체인

검증 규칙을 체이닝으로 쌓고 마지막에 errors()로 모든 위반을 한 번에 받습니다. 첫 실패에서 멈추는 예외 방식과 달리 모든 오류를 사용자에게 한 번에 보여 줄 수 있습니다. 정적 팩토리(of), 체이닝(return this), 불변 반환(List.copyOf)이 함께 쓰입니다.

java
public class Validator {
    private final String field, value;
    private final List<String> errors = new ArrayList<>();
    private Validator(String field, String value) { this.field = field; this.value = value; }

    public static Validator of(String field, String value) { return new Validator(field, value); }
    public Validator notBlank() {
        if (value == null || value.isBlank()) errors.add(field + ": 필수");
        return this;
    }
    public Validator maxLength(int n) {
        if (value != null && value.length() > n) errors.add(field + ": " + n + "자 이하");
        return this;
    }
    public Validator matches(String regex, String desc) {
        if (value != null && !value.matches(regex)) errors.add(field + ": " + desc);
        return this;
    }
    public List<String> errors() { return List.copyOf(errors); }
    public boolean isValid() { return errors.isEmpty(); }
}

Validator v1 = Validator.of("email", "kim@shop.com").notBlank().maxLength(50).matches(".+@.+\\..+", "이메일 형식");
Validator v2 = Validator.of("email", "").notBlank().matches(".+@.+\\..+", "이메일 형식");
Validator v3 = Validator.of("nick", "이름이너무깁니다정말로").notBlank().maxLength(8);
System.out.println(v1.isValid() + " " + v1.errors());   // 출력: true []
System.out.println(v2.isValid() + " " + v2.errors());   // 출력: false [email: 필수, email: 이메일 형식]
System.out.println(v3.isValid() + " " + v3.errors());   // 출력: false [nick: 8자 이하]

주의: v2에서 빈 문자열이 "필수"와 "형식" 두 오류를 냅니다. 첫 오류 이후 규칙을 건너뛰고 싶다면 notBlank 실패 시 플래그를 세우는 식으로 확장합니다.

패턴 25: 정적 팩토리로 구현 클래스 숨기기

반환 타입을 인터페이스(Shape)로 두면 실제로 Circle을 만들지 Rect를 만들지 호출자가 몰라도 됩니다. 나중에 구현 클래스를 바꾸거나 추가해도 호출 코드는 그대로입니다. List.of()가 ImmutableCollections.ListN을 숨기는 것과 같은 원리입니다.

java
static Shape unitShape(String kind) {
    return switch (kind) {
        case "circle" -> new Circle(1);
        case "rect" -> new Rect(1, 1);
        default -> throw new IllegalArgumentException(kind);
    };
}

System.out.println(unitShape("circle").describe());   // 출력: Circle(3.1)
System.out.println(unitShape("rect").describe());     // 출력: 직사각형 1.0x1.0

주의: 문자열로 종류를 고르는 것은 오타에 약합니다. 실무에서는 enum이나 sealed 타입을 인자로 받습니다.

패턴 모음
  • 패턴 1: 정적 팩토리 of — 값을 감싸기
  • 패턴 2: 정적 팩토리 from — 다른 타입에서 변환
  • 패턴 3: valueOf + 캐싱 — 같은 값이면 같은 인스턴스
  • 패턴 4: 이름 있는 생성 — 의도를 이름에 담기
  • 패턴 5: 빌더 — 필수/선택 인자 분리
  • 패턴 6: 메서드 체이닝 — return this
  • 패턴 7: 템플릿 메서드 — 골격은 부모, 세부는 자식
  • 패턴 8: 훅(hook) 메서드 — 기본 동작을 두고 선택적으로 덮어쓰기
  • 패턴 9: 오버라이딩 시 super 호출 — 부모 동작 유지하며 확장
  • 패턴 10: 인터페이스 default 메서드로 기능 추가
  • 패턴 11: Optional 반환 — "없을 수 있는 값 하나"
  • 패턴 12: Result 반환 — 실패 이유를 값으로
  • 패턴 13: 불변 객체의 with-메서드
  • 패턴 14: equals / hashCode 표준 구현
  • 패턴 15: toString — 로그와 디버깅을 위해
  • 패턴 16: compareTo — 자연 순서 정의
  • 패턴 17: 유틸 클래스 — private 생성자 + static
  • 패턴 18: 방어적 복사 — 받은 컬렉션은 복사한다
  • 패턴 19: 불변 반환 — 내 컬렉션을 바깥에 노출하지 않는다
  • 패턴 20: 빈 컬렉션 vs null
  • 패턴 21: 인터페이스 타입으로 받기 — Collection, List
  • 패턴 22: 실무 예제 — Money 계산
  • 패턴 23: 실무 예제 — 조건에 따라 빌더 조합
  • 패턴 24: 실무 예제 — Validator 체인
  • 패턴 25: 정적 팩토리로 구현 클래스 숨기기
이전 섹션2 핵심 원리3 / 6다음 섹션4 자주 하는 실수 (Tip)