OrderService가 PaymentGateway 인터페이스만 의존하고, 구현체를 갈아 끼워도 서비스 코드가 바뀌지 않음을 보여준다.
record PaymentResult(boolean success, String transactionId) {}
interface PaymentGateway {
PaymentResult pay(long amount);
default boolean supports(long amount) { return amount > 0; }
default String name() { return getClass().getSimpleName(); }
}
class CardGateway implements PaymentGateway {
@Override
public PaymentResult pay(long amount) {
return new PaymentResult(true, "CARD-" + amount);
}
}
class BankTransferGateway implements PaymentGateway {
@Override
public PaymentResult pay(long amount) {
return new PaymentResult(true, "BANK-" + amount);
}
@Override
public boolean supports(long amount) { return amount >= 10_000; } // 1만원 미만 이체 불가
}
class OrderService {
private final PaymentGateway gateway; // 인터페이스 타입만 의존
OrderService(PaymentGateway gateway) { this.gateway = gateway; }
String placeOrder(String orderId, long amount) {
if (!gateway.supports(amount)) {
return orderId + " 실패: " + gateway.name() + "는 " + amount + "원 결제 불가";
}
PaymentResult r = gateway.pay(amount);
return orderId + " 결제 " + (r.success() ? "성공" : "실패") + " tx=" + r.transactionId();
}
}
public class Main {
public static void main(String[] args) {
System.out.println(new OrderService(new CardGateway()).placeOrder("O-1", 5_000));
System.out.println(new OrderService(new BankTransferGateway()).placeOrder("O-2", 5_000));
System.out.println(new OrderService(new BankTransferGateway()).placeOrder("O-3", 50_000));
// 람다로 즉석 구현체: 테스트용 "항상 성공" 게이트웨이
System.out.println(new OrderService(amount -> new PaymentResult(true, "FAKE")).placeOrder("O-4", 1));
// 출력:
// O-1 결제 성공 tx=CARD-5000
// O-2 실패: BankTransferGateway는 5000원 결제 불가
// O-3 결제 성공 tx=BANK-50000
// O-4 결제 성공 tx=FAKE
}
}내보내기 흐름은 DataExporter.export()에 고정하고, CSV와 JSON은 각 단계만 다르게 구현한다.
import java.util.List;
import java.util.stream.Collectors;
record Order(String id, String product, int qty) {}
abstract class DataExporter {
public final String export(List<Order> orders) { // 템플릿 메서드: 순서 고정
StringBuilder sb = new StringBuilder();
sb.append(header());
for (Order o : orders) sb.append('\n').append(row(o));
sb.append(footer());
return sb.toString();
}
protected abstract String header();
protected abstract String row(Order o);
protected String footer() { return ""; } // 훅 메서드: 기본은 없음
}
class CsvExporter extends DataExporter {
@Override protected String header() { return "id,product,qty"; }
@Override protected String row(Order o) { return o.id() + "," + o.product() + "," + o.qty(); }
}
class JsonExporter extends DataExporter {
@Override protected String header() { return "["; }
@Override protected String row(Order o) {
return " {\"id\":\"%s\",\"product\":\"%s\",\"qty\":%d}".formatted(o.id(), o.product(), o.qty());
}
@Override protected String footer() { return "\n]"; }
}
public class Main {
public static void main(String[] args) {
var orders = List.of(new Order("A1", "키보드", 2), new Order("A2", "마우스", 1));
System.out.println(new CsvExporter().export(orders));
System.out.println(new JsonExporter().export(orders));
// 출력:
// id,product,qty
// A1,키보드,2
// A2,마우스,1
// [
// {"id":"A1","product":"키보드","qty":2}
// {"id":"A2","product":"마우스","qty":1}
// ]
}
}한 클래스가 두 인터페이스를 구현하며, 같은 시그니처의 default 메서드를 명시적으로 선택하는 방법을 보여준다.
interface Loggable {
default String describe() { return "Loggable"; }
}
interface Auditable {
default String describe() { return "Auditable"; }
default String auditTag() { return "[AUDIT]"; }
}
class Transaction implements Loggable, Auditable {
private final long amount;
Transaction(long amount) { this.amount = amount; }
@Override
public String describe() { // 두 default가 충돌 → 반드시 오버라이딩
return Loggable.super.describe() + "+" + Auditable.super.describe() + " " + amount + "원";
}
}
public class Main {
public static void main(String[] args) {
Transaction t = new Transaction(30_000);
System.out.println(t.auditTag() + " " + t.describe());
// 출력: [AUDIT] Loggable+Auditable 30000원
Loggable l = t; // 업캐스팅: 어느 인터페이스 타입으로 봐도 Transaction의 describe가 실행
System.out.println(l.describe());
// 출력: Loggable+Auditable 30000원
}
}계약은 인터페이스로, 공통 로직(재시도, 로그)은 추상 클래스로, 채널별 차이는 구체 클래스로 나누는 실무 구조다.
import java.util.List;
interface Notifier {
boolean send(String to, String message);
}
abstract class RetryingNotifier implements Notifier {
private final int maxAttempts;
protected RetryingNotifier(int maxAttempts) { this.maxAttempts = maxAttempts; }
@Override
public final boolean send(String to, String message) { // 공통: 재시도 골격
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
if (doSend(to, message)) {
System.out.println(channel() + " 발송 성공 (" + attempt + "회차) → " + to);
return true;
}
System.out.println(channel() + " 발송 실패 (" + attempt + "회차)");
}
return false;
}
protected abstract boolean doSend(String to, String message);
protected abstract String channel();
}
class EmailNotifier extends RetryingNotifier {
EmailNotifier() { super(1); }
@Override protected boolean doSend(String to, String msg) { return to.contains("@"); }
@Override protected String channel() { return "EMAIL"; }
}
class SmsNotifier extends RetryingNotifier {
private int calls = 0;
SmsNotifier() { super(3); }
@Override protected boolean doSend(String to, String msg) { return ++calls >= 2; } // 첫 시도는 실패하는 척
@Override protected String channel() { return "SMS"; }
}
public class Main {
public static void main(String[] args) {
List<Notifier> notifiers = List.of(new EmailNotifier(), new SmsNotifier());
for (Notifier n : notifiers) {
n.send(n instanceof EmailNotifier ? "[email protected]" : "010-1234-5678", "주문이 완료되었습니다");
}
// 출력:
// EMAIL 발송 성공 (1회차) → [email protected]
// SMS 발송 실패 (1회차)
// SMS 발송 성공 (2회차) → 010-1234-5678
}
}메서드 하나짜리 전략은 람다로 넘기고, default 메서드로 정책을 합성(compose)하는 방법을 보여준다.
@FunctionalInterface
interface DiscountPolicy {
long apply(long price);
default DiscountPolicy andThen(DiscountPolicy next) { // 정책 합성
return price -> next.apply(this.apply(price));
}
static DiscountPolicy none() { return price -> price; }
}
public class Main {
public static void main(String[] args) {
DiscountPolicy tenPercent = price -> price * 90 / 100;
DiscountPolicy coupon5000 = price -> Math.max(0, price - 5_000);
long price = 50_000;
System.out.println(DiscountPolicy.none().apply(price)); // 출력: 50000
System.out.println(tenPercent.apply(price)); // 출력: 45000
System.out.println(tenPercent.andThen(coupon5000).apply(price)); // 출력: 40000
System.out.println(coupon5000.andThen(tenPercent).apply(price)); // 출력: 40500 (순서에 따라 다름)
}
}