본문으로 건너뛰기
Advertisement

17.4 구조 패턴 (Decorator, Adapter, Proxy)

1. Decorator (데코레이터 패턴)

"기존 객체를 변경하지 않고 기능을 동적으로 추가" 합니다. 상속보다 유연하게 기능을 조합할 수 있습니다. 자바의 BufferedReader, Collections.unmodifiableList() 등이 대표 예시입니다.

// 커피 주문 시스템
interface Coffee {
String getDescription();
int getCost();
}

// 기본 커피
class SimpleCoffee implements Coffee {
@Override public String getDescription() { return "아메리카노"; }
@Override public int getCost() { return 3000; }
}

// 추상 데코레이터
abstract class CoffeeDecorator implements Coffee {
protected final Coffee coffee;
CoffeeDecorator(Coffee coffee) { this.coffee = coffee; }
}

// 구체 데코레이터들
class MilkDecorator extends CoffeeDecorator {
MilkDecorator(Coffee coffee) { super(coffee); }
@Override public String getDescription() { return coffee.getDescription() + " + 우유"; }
@Override public int getCost() { return coffee.getCost() + 500; }
}

class SyrupDecorator extends CoffeeDecorator {
private final String flavor;
SyrupDecorator(Coffee coffee, String flavor) {
super(coffee);
this.flavor = flavor;
}
@Override public String getDescription() { return coffee.getDescription() + " + " + flavor + "시럽"; }
@Override public int getCost() { return coffee.getCost() + 300; }
}

class WhipDecorator extends CoffeeDecorator {
WhipDecorator(Coffee coffee) { super(coffee); }
@Override public String getDescription() { return coffee.getDescription() + " + 휘핑크림"; }
@Override public int getCost() { return coffee.getCost() + 700; }
}

// 사용 - 기능을 동적으로 레이어처럼 쌓기
Coffee order1 = new SimpleCoffee();
System.out.printf("%s: %,d원%n", order1.getDescription(), order1.getCost());
// 아메리카노: 3,000원

Coffee order2 = new WhipDecorator(new MilkDecorator(new SimpleCoffee()));
System.out.printf("%s: %,d원%n", order2.getDescription(), order2.getCost());
// 아메리카노 + 우유 + 휘핑크림: 4,200원

Coffee order3 = new SyrupDecorator(
new WhipDecorator(
new MilkDecorator(new SimpleCoffee())
), "바닐라"
);
System.out.printf("%s: %,d원%n", order3.getDescription(), order3.getCost());
// 아메리카노 + 우유 + 휘핑크림 + 바닐라시럽: 4,500원

2. Adapter (어댑터 패턴)

"호환되지 않는 인터페이스들을 함께 동작하도록 변환" 합니다. 마치 해외 여행 시 전원 어댑터처럼, 기존 코드를 수정하지 않고 인터페이스를 맞춰줍니다.

// 레거시 시스템 (기존 결제 API)
class LegacyPaymentSystem {
public String doTransaction(String userId, double amount) {
return String.format("LEGACY_OK: 사용자=%s, 금액=%.0f", userId, amount);
}
public boolean checkBalance(String userId) {
return true; // 잔액 충분하다고 가정
}
}

// 새 시스템이 기대하는 인터페이스
interface ModernPaymentGateway {
PaymentResult processPayment(Payment payment);
}

record Payment(String userId, int amount, String currency) {}
record PaymentResult(boolean success, String transactionId, String message) {}

// 어댑터: LegacyPaymentSystem → ModernPaymentGateway로 변환
class LegacyPaymentAdapter implements ModernPaymentGateway {
private final LegacyPaymentSystem legacy;

LegacyPaymentAdapter(LegacyPaymentSystem legacy) {
this.legacy = legacy;
}

@Override
public PaymentResult processPayment(Payment payment) {
// 인터페이스 변환: 새 형식 → 레거시 형식
if (!legacy.checkBalance(payment.userId())) {
return new PaymentResult(false, null, "잔액 부족");
}
double amount = convertToKRW(payment.amount(), payment.currency());
String result = legacy.doTransaction(payment.userId(), amount);
String txId = "TXN_" + System.currentTimeMillis();
return new PaymentResult(true, txId, result);
}

private double convertToKRW(int amount, String currency) {
return switch (currency) {
case "USD" -> amount * 1300.0;
case "EUR" -> amount * 1420.0;
default -> amount;
};
}
}

// 사용: 새 코드는 ModernPaymentGateway 인터페이스만 사용
ModernPaymentGateway gateway = new LegacyPaymentAdapter(new LegacyPaymentSystem());
Payment payment = new Payment("user123", 100, "USD");
PaymentResult result = gateway.processPayment(payment);
System.out.println(result); // PaymentResult[success=true, ...]

3. Proxy (프록시 패턴)

"대리인 객체를 통해 실제 객체에 대한 접근을 제어" 합니다. 지연 초기화, 캐싱, 로깅, 접근 제어 등에 활용됩니다.

// 이미지 로딩 시스템 (지연 초기화 프록시)
interface Image {
void display();
int getWidth();
int getHeight();
}

// 실제 객체: 생성 비용이 큰 고해상도 이미지
class RealImage implements Image {
private final String filename;
private final int width, height;

RealImage(String filename) {
this.filename = filename;
System.out.println("💾 [비용 큰 작업] 이미지 로딩: " + filename);
// 실제로는 디스크에서 읽기, 리사이징 등 수행
this.width = 1920;
this.height = 1080;
}

@Override public void display() { System.out.println("🖼️ 표시: " + filename); }
@Override public int getWidth() { return width; }
@Override public int getHeight() { return height; }
}

// 프록시: 실제 객체 대신 서비스, 필요할 때만 실제 객체 생성
class LazyImageProxy implements Image {
private final String filename;
private RealImage realImage; // 실제 이미지는 필요할 때만 생성

LazyImageProxy(String filename) {
this.filename = filename;
System.out.println("📋 프록시 생성 (이미지 아직 로드 안 됨): " + filename);
}

@Override
public void display() {
if (realImage == null) {
realImage = new RealImage(filename); // 최초 사용 시에만 로드
}
realImage.display();
}

@Override
public int getWidth() {
if (realImage == null) realImage = new RealImage(filename);
return realImage.getWidth();
}

@Override
public int getHeight() {
if (realImage == null) realImage = new RealImage(filename);
return realImage.getHeight();
}
}

// 캐싱 프록시
interface UserRepository {
String findById(int id);
}

class DatabaseUserRepository implements UserRepository {
@Override
public String findById(int id) {
System.out.println("🗄️ DB 쿼리: SELECT * FROM users WHERE id=" + id);
return "User_" + id; // 실제로는 DB에서 조회
}
}

class CachedUserRepositoryProxy implements UserRepository {
private final UserRepository real;
private final Map<Integer, String> cache = new HashMap<>();

CachedUserRepositoryProxy(UserRepository real) { this.real = real; }

@Override
public String findById(int id) {
if (cache.containsKey(id)) {
System.out.println("⚡ 캐시 히트: id=" + id);
return cache.get(id);
}
String user = real.findById(id);
cache.put(id, user);
return user;
}
}

// 사용
UserRepository repo = new CachedUserRepositoryProxy(new DatabaseUserRepository());
repo.findById(1); // DB 쿼리
repo.findById(1); // 캐시 히트 (DB 쿼리 없음)
repo.findById(2); // DB 쿼리
repo.findById(2); // 캐시 히트

고수 팁

구조 패턴의 실무 활용:

  • Decorator: Spring Security의 필터 체인, Java I/O 스트림 체이닝이 대표 예시입니다.

    // Java I/O의 데코레이터 패턴
    BufferedReader reader = new BufferedReader( // BufferedReader가 데코레이터
    new InputStreamReader( // InputStreamReader도 데코레이터
    new FileInputStream("file.txt") // 실제 스트림
    )
    );
  • Adapter: 서드파티 라이브러리의 인터페이스를 우리 코드에 맞게 감쌀 때, 레거시 API를 현대적 인터페이스로 변환할 때 활용합니다.

  • Proxy: Spring AOP(@Transactional, @Cacheable, @Aspect)가 프록시 패턴으로 구현됩니다. JDK 동적 프록시 또는 CGLIB을 사용합니다.

Advertisement