전략·템플릿·default 메서드를 다른 도구로 다시 구현해 본다. 구현체 집합이 닫혀 있으면 enum, 단계가 가볍고 상태가 없으면 함수 주입, 규칙이 늘어나면 인터페이스 조합이 더 맞을 때가 있다. 어느 쪽을 고를지 판단하는 근거를 함께 적었다.
예제 1의 게이트웨이처럼 구현체가 외부에서 추가될 수 있으면 클래스가 맞다. 반대로 배송 방식처럼 컴파일 시점에 목록이 닫혀 있고 이름으로 조회(valueOf)해야 하면, enum이 인터페이스를 구현하게 하는 편이 짧고 안전하다. 인터페이스 타입으로 받으므로 람다 구현체와 섞어 쓸 수도 있다.
interface ShippingPolicy {
long fee(long amount);
}
enum Shipping implements ShippingPolicy {
STANDARD { public long fee(long amount) { return amount >= 50_000 ? 0 : 3_000; } },
EXPRESS { public long fee(long amount) { return 5_000; } },
PICKUP { public long fee(long amount) { return 0; } }
}
public class Main {
static String quote(ShippingPolicy policy, long amount) {
return amount + "원 주문 → 배송비 " + policy.fee(amount) + "원";
}
public static void main(String[] args) {
for (Shipping s : Shipping.values()) {
System.out.println(s + ": " + quote(s, 30_000));
}
System.out.println(quote(Shipping.valueOf("STANDARD"), 80_000)); // 이름으로 조회
System.out.println(quote(amount -> 1_000, 0)); // 람다도 같은 자리에
// 출력:
// STANDARD: 30000원 주문 → 배송비 3000원
// EXPRESS: 30000원 주문 → 배송비 5000원
// PICKUP: 30000원 주문 → 배송비 0원
// 80000원 주문 → 배송비 0원
// 0원 주문 → 배송비 1000원
}
}예제 2의 DataExporter는 단계가 세 개(header, row, footer)이고 단계끼리 상태를 공유하지 않는다. 이런 경우 추상 클래스 대신 단계를 생성자로 주입하면 하위 클래스 없이 정적 팩토리 하나로 끝난다. 단계가 많아지고 서로 중간 상태를 주고받아야 하면 다시 추상 클래스가 낫다.
import java.util.List;
import java.util.function.Function;
record Order(String id, String product, int qty) {}
class Exporter {
private final String header;
private final Function<Order, String> row;
private final String footer;
Exporter(String header, Function<Order, String> row, String footer) {
this.header = header;
this.row = row;
this.footer = footer;
}
String export(List<Order> orders) { // 골격은 그대로
StringBuilder sb = new StringBuilder(header);
for (Order o : orders) sb.append('\n').append(row.apply(o));
return sb.append(footer).toString();
}
static Exporter csv() {
return new Exporter("id,product,qty", o -> o.id() + "," + o.product() + "," + o.qty(), "");
}
static Exporter markdown() {
return new Exporter("| id | product | qty |\n|---|---|---|",
o -> "| " + o.id() + " | " + o.product() + " | " + o.qty() + " |", "");
}
}
public class Main {
public static void main(String[] args) {
var orders = List.of(new Order("A1", "키보드", 2), new Order("A2", "마우스", 1));
System.out.println(Exporter.csv().export(orders));
System.out.println(Exporter.markdown().export(orders));
// 출력:
// id,product,qty
// A1,키보드,2
// A2,마우스,1
// | id | product | qty |
// |---|---|---|
// | A1 | 키보드 | 2 |
// | A2 | 마우스 | 1 |
}
}규칙 하나가 Rule 인터페이스 하나이고, 검증기는 규칙 목록을 순서대로 적용해 실패 메시지를 모은다. 규칙이 추가되어도 검증기는 바뀌지 않는다(개방-폐쇄). 규칙마다 클래스를 만드는 대신 Rule.of(메시지, 조건) 정적 팩토리로 익명 구현체를 만들어 선언이 짧다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
record SignUp(String email, String password, int age) {}
interface Rule {
boolean passes(SignUp s);
String message();
static Rule of(String message, Predicate<SignUp> condition) {
return new Rule() {
public boolean passes(SignUp s) { return condition.test(s); }
public String message() { return message; }
};
}
}
class SignUpValidator {
private final List<Rule> rules = new ArrayList<>();
SignUpValidator add(Rule rule) {
rules.add(rule);
return this;
}
List<String> validate(SignUp s) {
List<String> errors = new ArrayList<>();
for (Rule r : rules) if (!r.passes(s)) errors.add(r.message());
return errors;
}
}
public class Main {
public static void main(String[] args) {
SignUpValidator validator = new SignUpValidator()
.add(Rule.of("이메일 형식 오류", s -> s.email().contains("@")))
.add(Rule.of("비밀번호 8자 이상", s -> s.password().length() >= 8))
.add(Rule.of("14세 이상", s -> s.age() >= 14));
System.out.println(validator.validate(new SignUp("[email protected]", "12345678", 20)));
System.out.println(validator.validate(new SignUp("bad", "123", 10)));
System.out.println(validator.validate(new SignUp("[email protected]", "short", 30)));
// 출력:
// []
// [이메일 형식 오류, 비밀번호 8자 이상, 14세 이상]
// [비밀번호 8자 이상]
}
}예제 3은 두 인터페이스의 default가 정면 충돌해 오버라이딩을 강제하는 경우였다. 충돌이 아닌 경우도 있다. (1) 부모 클래스에 같은 메서드가 있으면 클래스가 이긴다. (2) 한 인터페이스가 다른 인터페이스를 상속해 재정의하면 더 구체적인 쪽이 이긴다. 이 두 규칙 덕분에 기존 클래스에 default 메서드를 가진 인터페이스를 추가해도 동작이 바뀌지 않는다.
interface Greeter {
default String greet() { return "Greeter"; }
}
interface PoliteGreeter extends Greeter {
@Override
default String greet() { return "PoliteGreeter"; } // 더 구체적인 인터페이스
}
class Base {
public String greet() { return "Base"; }
}
class A extends Base implements Greeter {} // 규칙 1: 클래스 > 인터페이스
class B implements Greeter, PoliteGreeter {} // 규칙 2: 하위 인터페이스 > 상위 → 충돌 아님
class C implements Greeter {
@Override
public String greet() { return "C:" + Greeter.super.greet(); } // 명시적으로 default 재사용
}
public class Main {
public static void main(String[] args) {
System.out.println(new A().greet()); // 출력: Base
System.out.println(new B().greet()); // 출력: PoliteGreeter
System.out.println(new C().greet()); // 출력: C:Greeter
Greeter g = new A();
System.out.println(g.greet()); // 출력: Base (참조 타입과 무관, 실제 객체 기준)
}
}default 메서드 두 개가 같은 포맷 로직을 쓴다면 그 로직을 어디에 둘까. 추상 클래스로 옮기면 구현체가 상속 한 칸을 소비한다. JDK 9부터는 인터페이스 안에 private 메서드를 둘 수 있어, 구현체에는 보이지 않는 공통 로직을 default 메서드끼리만 공유할 수 있다. static 팩토리까지 인터페이스에 두면 사용자는 인터페이스 이름 하나만 알면 된다.
interface PriceFormatter {
long price();
default String formatted() { return format(price()); }
default String withTax() { return format(price() * 110 / 100); }
private String format(long v) { return String.format("%,d원", v); } // 구현체에서 호출 불가
static PriceFormatter of(long price) { return () -> price; }
}
class Product implements PriceFormatter {
private final String name;
private final long price;
Product(String name, long price) { this.name = name; this.price = price; }
@Override public long price() { return price; }
// format(...) 을 여기서 호출하면 컴파일 오류: private in interface
String label() { return name + " " + formatted(); }
}
public class Main {
public static void main(String[] args) {
PriceFormatter p = PriceFormatter.of(12_900);
System.out.println(p.formatted() + " / 세금 포함 " + p.withTax()); // 출력: 12,900원 / 세금 포함 14,190원
System.out.println(new Product("키보드", 89_000).label()); // 출력: 키보드 89,000원
}
}