C-2: 구조 패턴 — 객체를 어떻게 조립하나, 어댑터·데코레이터·퍼사드·프록시
목차 26
안녕하세요, 홍순구 튜터입니다. 지난 시간엔 카테고리 C의 문을 열며 생성 패턴 셋을 손에 넣었죠. 팩토리로 흩어진 new를 한곳에 모으고, 빌더로 인자 지옥을 조립식으로 풀고, 싱글톤은 배우되 왜 안티패턴이 됐는지까지 봤습니다. 거기서 우리가 익힌 건 "객체를 어떻게 만드나"였어요.
오늘은 한 발 더 나갑니다. 객체는 이미 만들어졌어요. 이제 이미 만들어진 객체들을 어떻게 엮느냐가 문제입니다. 서로 모양이 안 맞는 객체를 어떻게 연결할지, 기능을 어떻게 덧붙일지, 복잡하게 얽힌 객체 무리를 어떻게 단순하게 쓸지. 이걸 다루는 게 구조 패턴(structural pattern)입니다.
카테고리 C — 디자인 패턴, 검증된 해법의 카탈로그
C-1 생성 패턴 "객체를 어떻게 만드나" (지난 시간)
팩토리 · 빌더 · 싱글톤(+안티패턴)
C-2 구조 패턴 "객체를 어떻게 조립하나" ← 오늘 여기
어댑터 · 데코레이터 · 퍼사드 · 프록시
C-3 행위 패턴 "객체가 어떻게 협력하나" (다음 시간)
오늘 만날 네 패턴 중 셋(어댑터·데코레이터·프록시)은 사실 구조가 똑같습니다. 한 객체를 다른 객체로 감싸요. 그런데 왜 감싸는지, 그 의도가 셋 다 다릅니다. 거기에 더해, A-4에서 "외부 라이브러리를 경계에서 감싸자"고 했던 이야기와 B-2에서 "상속보다 컴포지션"이라고 했던 이야기가 오늘 패턴이라는 이름을 달고 다시 돌아옵니다.
💡 오늘 수업의 핵심 — "안 맞는 건 어댑터로 끼워 맞추고, 기능은 데코레이터로 겹겹이 덧입히고, 복잡한 내부는 퍼사드로 가리고, 접근은 프록시로 제어한다 — 그리고 셋은 다 '감싸기'지만 의도가 다르다"
🎯 학습 목표
- 구조 패턴이 "객체를 어떻게 조립하나"를 푸는 갈래임을 이해하고, 어댑터·데코레이터·프록시가 '다 감싸지만 의도가 다른 삼형제'이며 퍼사드는 '여럿을 묶는' 갈래임을 지도로 잡는다.
- 어댑터로 안 맞는 외부 인터페이스를 우리 약속에 끼워 맞추고(A-4 경계 회수), 데코레이터로 기능 조합 폭발을 겹겹이 위임으로 푼다(B-2 컴포지션 회수).
- 퍼사드로 복잡한 절차를 단순한 창구로 가리고, 프록시로 접근을 가로채 무거운 작업의 시점을 제어한다. 그리고 패턴마다 "언제 안 쓰나"의 경계를 함께 쥔다.
Step 1: "객체를 어떻게 조립하나" — 구조 패턴의 지도
생성 패턴이 "객체를 어떻게 태어나게 하나"였다면, 구조 패턴은 그렇게 태어난 객체들을 어떻게 조립해 더 큰 구조를 만드나를 다룹니다. 핵심은 조립을 하되 유연함을 잃지 않는 거예요. 객체를 단단히 묶어 버리면 나중에 하나만 바꾸려 해도 사방이 함께 깨지니까요.
오늘 걷는 네 패턴을 먼저 지도에 펼쳐 봅시다.
구조 패턴 — "객체를 어떻게 조립하나"
어댑터 안 맞는 인터페이스를 우리 모양으로 바꿔 끼운다 (변환)
데코레이터 같은 모양을 유지한 채 기능을 겹겹이 덧입힌다 (기능 추가)
프록시 모양은 그대로 두고 접근의 시점·권한을 제어한다 (접근 제어)
퍼사드 복잡하게 얽힌 여러 객체를 단순한 창구 하나로 가린다 (단순화)
여기서 오늘의 가장 중요한 통찰을 미리 심어 두겠습니다. 어댑터·데코레이터·프록시는 셋 다 "한 객체를 다른 객체로 감싼다"는 점에서 구조가 똑같습니다. 감싸는 바깥 객체가 안쪽 객체에게 일을 넘기는(위임하는) 모습이 셋 다 닮았어요. 그런데 왜 감싸는지가 다릅니다. 어댑터는 모양을 바꾸려고, 데코레이터는 기능을 더하려고, 프록시는 접근을 통제하려고 감싸요.
퍼사드만 결이 다릅니다. 퍼사드는 한 객체를 감싸는 게 아니라 여러 객체를 묶어 단순한 입구 하나로 가립니다. 이 "감싸기 삼형제"와 퍼사드의 차이는 오늘 마지막 Step에서 한 표로 정리할 거예요. 지금은 "구조가 비슷해 보여도 의도로 갈린다"만 머릿속에 담고 첫 패턴으로 들어갑시다.
💡 한 줄 정리
구조 패턴은 이미 만들어진 객체를 조립하는 법이다. 어댑터·데코레이터·프록시는 "한 객체를 감싼다"는 구조가 같고 의도(변환·기능 추가·접근 제어)로 갈리며, 퍼사드는 "여럿을 묶어 단순화"한다.
🙋 학생 질문 — "튜터님, 다 감싸는 거면 그냥 하나로 묶어서 가르치면 안 되나요?"
구조가 비슷한 건 맞아요. 그래서 처음 배우면 "이게 어댑터야 데코레이터야?" 하고 헷갈리기도 합니다. 하지만 패턴의 정체는 코드 모양이 아니라 의도로 정해집니다.
같은 "감싸기"라도, 모양이 안 맞아서 바꾸려고 감쌌으면 어댑터, 모양은 그대로 두고 기능을 얹으려고 감쌌으면 데코레이터, 접근을 가로채려고 감쌌으면 프록시예요. 코드 리뷰에서 "이거 어댑터죠?"라고 말할 때, 상대는 그 한마디로 "아, 모양을 맞추려는 거구나"를 알아듣습니다. 그래서 의도별로 이름을 나눠 두는 게 공통 어휘로서 값을 합니다. 셋을 묶어 버리면 그 의도의 구분이 사라져요.
Step 2: "외부 API가 바뀌면 우리 코드 전체를 고쳐야 해요" — 어댑터
첫 패턴은 어댑터(adapter)입니다. 이름 그대로예요. 우리가 흔히 쓰는 110V·220V 변환 어댑터, 그 개념 그대로 코드로 옮긴 겁니다. 모양이 안 맞는 두 인터페이스 사이에 변환기를 끼워 넣는 거죠.
A-4에서 우리가 했던 이야기를 기억하시나요? "외부 라이브러리를 직접 쓰지 말고 경계에서 감싸라"고 했죠. 그때 감쌌던 그 일이 사실은 GoF 어댑터 패턴입니다. 오늘 그걸 패턴이라는 이름으로 정식화해 봅시다.
상황은 이래요. 우리는 문자(SMS)를 보내려고 외부 라이브러리를 하나 들여왔습니다. 그런데 이 라이브러리는 우리가 부르고 싶은 모양과 메서드 이름이 다릅니다.
// 우리가 고칠 수 없는 외부 SMS 라이브러리 (structural.adapter.ThirdPartySms)
public class ThirdPartySms {
public String dispatch(String phoneNumber, String body) {
// ... 실제 전송 후 결과 코드 "OK" 반환 ...
return "OK";
}
}
우리는 send(받는이, 내용)으로 부르고 싶은데, 이쪽은 dispatch(전화번호, 본문)이라고 부르고 결과 코드를 돌려줍니다. 메서드 이름도, 반환값도 우리 스타일이 아니에요. 그리고 중요한 건, 이 라이브러리 코드는 우리가 고칠 수 없다는 점입니다. 남이 만든 거니까요.
이걸 그냥 쓰면 어떻게 될까요? 호출부가 외부 시그니처에 직접 묶입니다.
// Before — 호출부가 외부 dispatch 시그니처에 직접 묶인다 (structural.adapter.SmsClientBefore)
public class SmsClientBefore {
private final ThirdPartySms sms;
public SmsClientBefore(ThirdPartySms sms) {
this.sms = sms;
}
public void notifyUser(String to, String text) {
sms.dispatch(to, text);
}
}
dispatch라는 외부 메서드 이름이 우리 코드 한가운데에 그대로 드러나 있어요. 만약 이 외부 라이브러리를 다른 회사 제품으로 갈아끼우거나, 라이브러리가 업데이트되며 시그니처가 바뀌면, 이 dispatch를 부르는 모든 곳을 찾아 고쳐야 합니다. 외부의 사정이 우리 코드로 새어 들어온 거죠.
해법은 우리 약속(인터페이스)을 먼저 정하고, 외부를 그 약속 모양으로 바꿔 끼우는 어댑터입니다. 먼저 우리가 기대하는 약속부터요.
// 우리 코드가 기대하는 약속(타깃) (structural.adapter.MessageSender)
public interface MessageSender {
void send(String to, String text);
}
그리고 외부 라이브러리를 이 약속 모양으로 변환하는 어댑터를 둡니다.
// After — 어댑터가 우리 send 를 외부 dispatch 로 변환·위임한다 (structural.adapter.SmsAdapter)
public class SmsAdapter implements MessageSender {
private final ThirdPartySms sms;
public SmsAdapter(ThirdPartySms sms) {
this.sms = sms;
}
@Override
public void send(String to, String text) {
sms.dispatch(to, text);
}
}
SmsAdapter는 우리 MessageSender 약속을 구현하면서, 안에 ThirdPartySms를 품고 있습니다. 우리 send(to, text)가 들어오면 외부 dispatch(to, text)로 넘겨요. 변환하고 위임하는 거죠.
Before — 호출부가 외부 시그니처(dispatch)를 직접 안다
SmsClientBefore ──▶ ThirdPartySms.dispatch(번호, 본문)
(외부 사정이 우리 코드로 샌다)
After — 어댑터가 우리 약속(send)을 외부 모양으로 바꿔 끼운다
클라이언트 ──▶ MessageSender.send(받는이, 내용)
│
▼
SmsAdapter ──▶ ThirdPartySms.dispatch(번호, 본문)
(외부를 아는 곳은 어댑터 한 곳뿐)
이제 우리 애플리케이션 전체는 MessageSender라는 약속에만 의존합니다. 그 뒤에 외부 SMS가 있든, 나중에 이메일로 바뀌든, 테스트용 가짜가 끼든 호출부는 모릅니다. 외부 라이브러리가 바뀌어도 고칠 곳은 어댑터 안 한 곳뿐이에요. A-4에서 "경계를 감싸라"고 한 게 바로 이 그림이었습니다.
⚠️ 언제 안 쓰나. 어댑터는 "내가 통제할 수 없는 모양"을 맞출 때 쓰는 도구입니다. 만약 그 인터페이스를 우리가 직접 고칠 수 있다면, 어댑터를 끼우지 말고 그냥 인터페이스를 바로잡는 게 낫습니다. 멀쩡히 우리가 바꿀 수 있는 코드에 어댑터를 한 겹 두르는 건, 변환할 것도 없는데 변환기만 늘리는 군더더기예요. 어댑터는 외부 라이브러리·레거시처럼 "건드릴 수 없는 경계"에서 빛납니다.
💡 한 줄 정리
어댑터는 우리가 고칠 수 없는 외부의 모양을, 우리 약속(인터페이스)에 맞게 변환해 끼운다. 외부를 아는 곳이 어댑터 한 곳으로 좁아져, 외부가 바뀌어도 호출부는 안전하다. 단, 우리가 통제할 수 있는 인터페이스라면 어댑터 없이 바로잡는 게 낫다.
🙋 학생 질문 — "A-4의 경계 감싸기랑 어댑터 패턴이랑 같은 거예요?"
거의 같은 이야기를, 다른 높이에서 본 겁니다. A-4에서는 "외부 코드를 직접 쓰지 말고 우리 코드로 한 겹 감싸 격리하라"는 원칙으로 다뤘어요. 오늘은 그 감싸는 방식 중 "외부의 인터페이스를 우리 인터페이스 모양으로 변환한다"는 구체적인 형태에 어댑터라는 이름을 붙인 겁니다.
원칙(경계를 감싸라)이 먼저고, 그 원칙을 실현하는 검증된 형태 중 하나가 패턴(어댑터)인 거죠. 그래서 "왜 감싸나"를 모르고 어댑터만 외우면 패턴을 위한 패턴이 되기 쉽고, A-4의 "외부 변화로부터 우리를 지킨다"는 이유를 알면 어댑터를 언제 꺼낼지가 보입니다.
Step 3: "기능 조합마다 클래스가 폭발해요" — 데코레이터
두 번째는 데코레이터(decorator)입니다. 같은 모양(인터페이스)을 유지한 채로 기능을 겹겹이 덧입히는 패턴이에요. B-2에서 "상속보다 컴포지션"을 이야기하며 위임으로 기능을 얹는 모습을 봤는데, 그게 패턴으로 굳으면 바로 데코레이터입니다.
카페 음료로 가 봅시다. 에스프레소가 기본이고, 우유나 시럽 같은 토핑을 더할 수 있어요. 토핑마다 값이 붙습니다. 이걸 상속이나 조합 클래스로 풀려고 하면 무슨 일이 벌어질까요?
// Before — 조합마다 전용 클래스를 만든다 (structural.decorator.explosion)
public class EspressoWithMilk implements Beverage {
@Override
public int cost() {
return 3000 + 500;
}
@Override
public String description() {
return "에스프레소 + 우유";
}
}
"에스프레소 + 우유"를 위한 클래스를 하나 만들었어요. 그럼 "에스프레소 + 우유 + 시럽"은요? 또 다른 클래스가 필요합니다.
// Before — 또 다른 조합을 위한 또 다른 클래스 (structural.decorator.explosion)
public class EspressoWithMilkAndSyrup implements Beverage {
@Override
public int cost() {
return 3000 + 500 + 300;
}
@Override
public String description() {
return "에스프레소 + 우유 + 시럽";
}
}
문제가 보이시죠? 토핑이 N종이면 조합은 2의 N제곱으로 불어납니다. 우유·시럽·휘핑·샷 추가… 토핑 넷이면 조합만 열여섯 개고, 클래스도 그만큼 늘어나요. 게다가 3000이라는 기본값과 500이라는 우유값이 여러 클래스에 복제되어, 우유값이 바뀌면 우유가 든 모든 클래스를 일일이 고쳐야 합니다. 클래스 폭발이에요.
데코레이터는 이 폭발을 겹겹이 감싸기로 없앱니다. 먼저 음료의 약속과 기본 음료입니다.
// 음료의 약속(컴포넌트) (structural.decorator.Beverage)
public interface Beverage {
int cost();
String description();
}
// 구체 컴포넌트 — 아무것도 더하지 않은 기본 음료 (structural.decorator.Espresso)
public class Espresso implements Beverage {
@Override
public int cost() {
return 3000;
}
@Override
public String description() {
return "에스프레소";
}
}
이제 핵심인 추상 데코레이터입니다. 자신도 Beverage이면서, 또 다른 Beverage 하나를 품어요.
// 추상 데코레이터 — Beverage 를 필드로 감싸고 기본 동작은 안쪽에 위임 (structural.decorator.BeverageDecorator)
public abstract class BeverageDecorator implements Beverage {
protected final Beverage base;
protected BeverageDecorator(Beverage base) {
this.base = base;
}
@Override
public int cost() {
return base.cost();
}
@Override
public String description() {
return base.description();
}
}
여기가 데코레이터의 심장입니다. BeverageDecorator는 Beverage를 구현하면서(같은 모양), 동시에 Beverage 하나를 필드로 품어요(has-a). 자신도 음료고, 안에 음료를 가지고 있으니, 데코레이터가 데코레이터를 감쌀 수 있습니다. 겹겹이 쌓이는 거죠. 기본 동작은 그냥 안쪽 base에 넘깁니다.
구체 데코레이터는 이 위임 결과에 자기 몫만 더하면 됩니다.
// 구체 데코레이터 — 감싼 음료 값에 500, 설명에 " + 우유" (structural.decorator.MilkDecorator)
public class MilkDecorator extends BeverageDecorator {
public MilkDecorator(Beverage base) {
super(base);
}
@Override
public int cost() {
return base.cost() + 500;
}
@Override
public String description() {
return base.description() + " + 우유";
}
}
MilkDecorator는 "우유 한 가지"만 압니다. 안쪽이 에스프레소든, 시럽까지 더해진 음료든 상관 안 해요. base에게 값을 묻고, 거기에 자기 몫 500만 얹습니다. 시럽 데코레이터(SyrupDecorator)도 똑같은 구조로 300을 얹고요.
이제 조합이 자유롭습니다.
Beverage beverage = new SyrupDecorator(new MilkDecorator(new Espresso()));
// cost() = 3800, description() = "에스프레소 + 우유 + 시럽"
SyrupDecorator( MilkDecorator( Espresso ) )
└ +시럽 300 ┘ └ +우유 500 ┘ └ 3000 ┘
= 3800
바깥 데코레이터가 안쪽에 값을 묻고, 자기 몫만 더한다 (겹겹이)
토핑이 아무리 늘어도 새 클래스는 토핑 하나당 딱 하나면 됩니다. 조합은 감싸는 순서로 만들어요. 클래스 폭발이 사라졌습니다. 이 변환이 행동을 바꾸지 않았다는 건, 조합 클래스(Before)와 데코레이터(After)가 같은 값·설명을 내는지 코드베이스 DecoratorTest에서 확인해 뒀습니다.
사실 여러분은 이 패턴을 이미 매일 쓰고 있었어요. 자바 표준 입출력이 통째로 데코레이터로 설계돼 있거든요.
// 자바 표준 라이브러리 — 스트림을 겹겹이 감싸는 데코레이터
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
InputStreamReader가 바이트 스트림을 문자로 바꿔 주고, 그걸 BufferedReader가 다시 감싸 버퍼링 기능을 얹습니다. 우리 카페 음료와 똑같은 구조예요. 안쪽을 감싸고 기능을 한 겹 더하는 거죠.
여기서 잠깐 파이썬을 보면, 같은 의도가 아예 언어 문법으로 들어와 있습니다.
# 파이썬은 함수를 감싸는 @데코레이터 문법이 표준이다
def with_milk(make_beverage):
def wrapped():
cost, desc = make_beverage()
return cost + 500, desc + " + 우유"
return wrapped
@with_syrup
@with_milk
def espresso():
return 3000, "에스프레소"
# espresso() = (3800, "에스프레소 + 우유 + 시럽")
파이썬의 @데코레이터는 함수를 한 겹 감싸 기능을 더하는 문법입니다. 의도는 GoF 데코레이터와 같아요. "안쪽을 감싸 책임을 더한다." 다만 자바는 객체(클래스) 수준에서, 파이썬은 함수 수준에서 동작합니다. 둘을 똑같다고 외우진 마시고, "패러다임이 바뀌면 같은 의도라도 모습이 달라진다"의 한 예로 결을 비교해 두세요.
⚠️ 언제 안 쓰나. 데코레이터는 기능 조합이 여러 가지로 늘어날 때 빛납니다. 토핑이 딱 하나뿐이고 앞으로 늘 일이 없다면, 겹겹이 감싸는 구조는 과해요. 그냥 필드 하나 두는 게 낫습니다. 또 데코레이터를 깊게 쌓으면 "이 객체가 지금 몇 겹인지" 디버깅이 어려워질 수 있어요. 조합의 자유가 정말로 필요한지 먼저 묻고 꺼내세요.
💡 한 줄 정리
데코레이터는 같은 인터페이스를 구현하면서 그 인터페이스 객체를 품어, 기능을 겹겹이 덧입힌다. 조합마다 클래스를 만드는 폭발을 "감싸는 순서"로 푼다. B-2의 컴포지션이 패턴으로 굳은 모습이고, 자바 입출력 스트림이 바로 이 구조다.
🙋 학생 질문 — "상속으로 우유 음료, 시럽 음료를 만들면 안 되나요?"
만들 수는 있어요. 그런데 상속으로 가면 바로 클래스 폭발에 부딪힙니다. "우유 음료"를 상속으로 만들고 "시럽 음료"도 상속으로 만들면, "우유와 시럽을 둘 다 더한 음료"는요? 자바는 클래스를 둘 동시에 상속할 수 없으니, 결국 "우유시럽음료" 같은 전용 클래스를 또 만들어야 합니다. 조합마다 클래스가 늘어나죠.
데코레이터는 상속(is-a) 대신 컴포지션(has-a)으로 이 문제를 풉니다. 음료를 "물려받는" 게 아니라 음료를 "품어요". 품은 것에 자기 몫만 더하니, 무엇을 품든 상관없이 겹겹이 쌓입니다. B-2에서 "상속보다 컴포지션"이라고 한 이유가 여기서 코드로 드러나는 거예요.
Step 4: "이 서브시스템 쓰는 법이 너무 복잡해요" — 퍼사드
세 번째는 퍼사드(facade)입니다. 퍼사드는 프랑스어로 건물의 정면, 즉 겉면을 뜻해요. 안쪽이 아무리 복잡해도 밖에서는 깔끔한 정면 하나만 보이게 하는 겁니다. 복잡하게 얽힌 여러 객체를 단순한 창구 하나 뒤로 가리는 패턴이죠.
주문 처리를 예로 들어 봅시다. 상품을 하나 주문하려면 세 가지 일이 순서대로 일어나야 해요. 재고를 확인하고, 결제를 하고, 배송을 겁니다. 각각 별도의 서비스고요.
// 서브시스템 셋 — 각자 자기 일만 한다
public class InventoryService {
public boolean inStock(String itemId) { /* 재고 확인 */ }
}
public class PaymentService {
public String charge(String itemId, int amount) { /* 결제 → 결제 ID */ }
}
public class ShippingService {
public String ship(String itemId, String address) { /* 배송 → 송장 번호 */ }
}
이걸 그냥 쓰면, 주문을 거는 쪽이 이 세 서비스의 호출 순서와 조율을 직접 짊어집니다.
// Before — 호출부가 세 서브시스템의 순서·조율을 직접 안다 (structural.facade.OrderClientBefore)
public OrderResult placeOrder(String itemId, int amount, String address) {
if (!inventory.inStock(itemId)) {
throw new IllegalStateException("품절된 상품입니다: " + itemId);
}
String paymentId = payment.charge(itemId, amount);
String trackingNumber = shipping.ship(itemId, address);
return new OrderResult(paymentId, trackingNumber);
}
이 코드가 화면 하나에만 있으면 그나마 괜찮아요. 문제는 주문을 거는 곳이 여럿이라는 겁니다. 상품 상세 화면, 장바구니 화면, 빠른 주문 버튼… 주문이 일어나는 곳마다 "재고 보고 → 결제하고 → 배송 부른다"는 이 절차가 복제됩니다. 그리고 만약 순서 사이에 "쿠폰 적용" 단계가 끼면? 복제된 모든 곳을 찾아 고쳐야 해요. 호출부가 "주문하고 싶다"가 아니라 "재고를 보고, 결제를 하고, 배송을 부른다"는 내부 절차까지 알아 버린 게 문제입니다.
퍼사드는 이 절차를 한곳으로 모아 단순한 창구 하나로 가립니다.
// After — 퍼사드가 세 서브시스템을 묶어 "주문하기" 창구 하나로 (structural.facade.OrderFacade)
public class OrderFacade {
private final InventoryService inventory;
private final PaymentService payment;
private final ShippingService shipping;
// 생성자에서 세 서브시스템을 받아 둔다 (생략)
public OrderResult placeOrder(String itemId, int amount, String address) {
if (!inventory.inStock(itemId)) {
throw new IllegalStateException("품절된 상품입니다: " + itemId);
}
String paymentId = payment.charge(itemId, amount);
String trackingNumber = shipping.ship(itemId, address);
return new OrderResult(paymentId, trackingNumber);
}
}
코드를 보면 placeOrder의 몸통은 Before와 거의 같아 보입니다. 여기서 핵심을 놓치면 안 돼요. 퍼사드의 가치는 코드를 짧게 만드는 게 아니라, 이 절차가 살 집을 한 곳으로 정해 주는 것입니다.
이제 주문을 거는 모든 화면은 세 서비스를 알 필요 없이 OrderFacade 하나만 들고 placeOrder()를 부르면 됩니다. 재고·결제·배송의 순서는 퍼사드 안에 숨고, 중간에 쿠폰 단계가 끼어도 고칠 곳은 퍼사드 한 곳뿐이에요. 호출부는 "주문하고 싶다"만 말하면 됩니다. 내부가 얼마나 복잡한지는 정면 뒤로 가려졌어요.
⚠️ 언제 안 쓰나. 퍼사드는 뒤에 가릴 복잡함이 정말 있을 때 값을 합니다. 서브시스템이 하나뿐이거나 호출이 한 줄로 끝난다면, 퍼사드는 아무것도 가리지 못하면서 객체만 하나 더 늘리는 군더더기예요. "감출 절차가 있는가, 이 입구를 여러 곳이 함께 쓰는가"를 먼저 물으세요. 단순한 호출을 굳이 퍼사드로 한 겹 싸는 건 과잉입니다.
💡 한 줄 정리
퍼사드는 복잡하게 얽힌 여러 객체를 묶어, 단순한 창구 하나로 가린다. 호출부는 내부 절차를 모른 채 입구 하나만 부르고, 절차가 바뀌어도 고칠 곳은 퍼사드 한 곳이다. 단, 가릴 복잡함이 없으면 과잉이다.
🙋 학생 질문 — "퍼사드도 감싸는 거면 어댑터랑 뭐가 달라요?"
좋은 질문이고, 오늘 가장 헷갈리기 쉬운 지점이에요. 둘 다 "감싼다"는 느낌은 있지만, 무엇을 몇 개 감싸고, 왜 감싸는지가 다릅니다.
어댑터는 한 객체를 감싸서 그 모양(인터페이스)을 바꿉니다. 목적은 변환이에요. 안 맞는 걸 맞추는 거죠. 퍼사드는 여러 객체를 묶어서 그 복잡함을 가립니다. 목적은 단순화예요. 모양을 바꾸려는 게 아니라, 여럿을 상대하는 번거로움을 입구 하나로 줄이는 겁니다.
한 줄로 정리하면, 어댑터는 "모양이 안 맞아서" 하나를 감싸고, 퍼사드는 "쓰기 복잡해서" 여럿을 묶습니다. 다음 Step의 프록시까지 보고 나면, 마지막에 이 셋을 한 표로 나란히 비교할 거예요.
Step 5: "대리인을 세운다" — 프록시
네 번째이자 마지막 패턴은 프록시(proxy)입니다. 프록시는 대리인이라는 뜻이에요. 진짜 객체 앞에 대리인을 하나 세워서, 진짜에게 가는 접근을 가로채는 패턴입니다. 모양(인터페이스)은 진짜와 똑같이 두되, 그 앞에서 "언제 진짜를 부를지, 누가 부를 수 있는지"를 통제하는 거죠.
가장 흔한 쓰임은 지연 로딩(lazy loading)입니다. 무거운 객체를 정말 필요한 순간까지 만들지 않고 미루는 거예요. 이미지 갤러리로 봅시다. 이미지는 만들어지는 순간 디스크에서 읽어 들이는 무거운 작업을 합니다.
// 진짜 이미지 — 만들어지는 순간 무거운 로딩을 한다 (structural.proxy.RealImage)
public class RealImage implements Image {
private final String filename;
public RealImage(String filename) {
this.filename = filename;
loadFromDisk(); // 생성과 동시에 무거운 로딩
}
@Override
public String display() {
return "표시: " + filename;
}
// loadFromDisk() — 디스크 읽기 시뮬레이션 (생략)
}
갤러리를 만들 때 이미지를 전부 즉시 만들면 어떻게 될까요?
// Before — 갤러리를 만들 때 모든 이미지를 즉시 로딩한다 (structural.proxy.GalleryBefore)
public GalleryBefore(List<String> filenames) {
for (String filename : filenames) {
images.add(new RealImage(filename)); // 목록에 담는 순간 전부 로딩
}
}
화면에는 한 장만 보여 줄 텐데, 목록에 담는 순간 백 장이면 백 장을 전부 디스크에서 읽습니다. 안 볼 이미지까지 미리 다 로딩해서 시작이 한참 느려져요.
프록시는 진짜 이미지 앞에 대리인을 세웁니다. 대리인은 파일 이름만 들고 있다가, 정말 화면에 표시할 때 비로소 진짜를 만들어요.
// After — 대리인(프록시). 표시할 때 비로소 진짜를 만든다 (structural.proxy.ProxyImage)
public class ProxyImage implements Image {
private final String filename;
private RealImage realImage;
public ProxyImage(String filename) {
this.filename = filename;
}
@Override
public String display() {
if (realImage == null) {
realImage = new RealImage(filename); // 첫 표시 때 한 번만 로딩
}
return realImage.display();
}
}
ProxyImage는 Image 약속을 진짜와 똑같이 구현합니다. 그래서 호출부는 이게 진짜인지 대리인인지 몰라요. 생성만으로는 디스크 로딩이 일어나지 않습니다. display()가 처음 불릴 때 비로소 RealImage를 한 번 만들고, 그다음부터는 만들어 둔 걸 재사용해요.
Before — 갤러리를 만들 때 전부 즉시 로딩
new Gallery([a,b,c]) ──▶ a 로딩 · b 로딩 · c 로딩 (안 볼 것까지 다)
After — 프록시가 "볼 때" 로딩
new ProxyImage("a") ──▶ (아직 로딩 안 함)
proxy.display() ──▶ 이때 RealImage("a") 한 번 로딩
proxy.display() 다시 ──▶ 만들어 둔 것 재사용 (추가 로딩 없음)
이 "생성만으로는 로딩 안 함 → 첫 표시 때 한 번 → 재호출 때 추가 로딩 없음"의 동작은 코드베이스 ProxyTest에서 로딩 횟수를 세어 확인해 뒀습니다.
지연 로딩 말고도 프록시의 쓰임은 여럿입니다. 진짜에게 접근하기 전에 권한을 검사하는 접근 제어 프록시(권한 없으면 진짜를 아예 안 부름), 결과를 기억해 두는 캐싱 프록시 등이 같은 구조예요. 공통점은 "진짜 앞에 대리인을 세워, 진짜로 가는 접근을 가로채 무언가를 한다"입니다.
이 지점에서 앞으로 배울 프레임워크 이야기를 딱 한 줄만 심어 둘게요. 스프링의 트랜잭션이나 로깅 같은 기능이 우리 코드를 직접 고치지 않고도 슬쩍 끼어드는데, 그 바탕이 바로 이 프록시 원리입니다. 진짜 객체 앞에 대리인을 세워 호출을 가로채는 거죠. 다만 그 본격적인 적용은 후속 spring-boot에서 다루고, 오늘은 "프록시가 그 뿌리"라는 것만 알아 두면 충분합니다.
⚠️ 언제 안 쓰나. 프록시는 호출 사이에 대리인을 한 겹 끼우는 거라, 공짜가 아닙니다. 모든 호출이 대리인을 거치니 약간의 간접 비용이 들고, "지금 부른 게 진짜인지 대리인인지"가 한눈에 안 보여 디버깅이 헷갈릴 수 있어요. 지연 로딩이나 접근 제어처럼 대리인이 분명히 할 일이 있을 때만 세우세요. 그냥 진짜를 직접 쓰면 되는데 프록시를 두는 건 간접 단계만 늘리는 셈입니다.
💡 한 줄 정리
프록시는 진짜 객체와 같은 모양의 대리인을 앞에 세워, 진짜로 가는 접근을 가로챈다. 지연 로딩·접근 제어·캐싱이 대표적이다. 모양은 그대로, 접근의 시점·권한만 제어한다. 단, 대리인이 할 일이 없으면 간접 비용만 늘린다.
🙋 학생 질문 — "프록시도 같은 모양으로 감싸는 거면 데코레이터랑 똑같아 보여요."
구조는 정말 닮았어요. 둘 다 같은 인터페이스를 구현하고, 안에 진짜 객체를 품고, 호출을 위임합니다. 차이는 역시 의도예요.
데코레이터는 진짜에게 일을 넘기고 그 결과에 기능을 더합니다. 값을 더하고, 설명을 덧붙이고. 목적이 "기능 추가"죠. 프록시는 진짜에게 넘기긴 하되, 넘길지 말지·언제 넘길지를 통제합니다. 아직 안 만들었으면 지금 만들고, 권한이 없으면 아예 안 넘기고. 목적이 "접근 제어"예요.
데코레이터는 "더 해 주는" 쪽이고, 프록시는 "막거나 미루는" 쪽이라고 기억하면 편합니다. 코드가 비슷해도 왜 감쌌는지로 이름이 갈린다는 걸, 이제 세 패턴에서 거듭 봤죠? 다음 Step에서 이 삼형제를 한 표로 정리하겠습니다.
Step 6: "다 감싸는데 무엇이 다른가" — 구조 패턴 종합과 그 한계
네 패턴을 모두 걸었습니다. 이제 오늘 첫머리에 심어 둔 약속을 지킬 차례예요. 어댑터·데코레이터·프록시는 "한 객체를 감싼다"는 구조가 똑같다고 했죠. 셋을 나란히 놓고, 퍼사드까지 더해 의도로 갈라 봅시다.
| 패턴 | 무엇을 감싸나 | 모양(인터페이스)은 | 왜 감싸나 (의도) |
|---|---|---|---|
| 어댑터 | 한 객체 | 바꾼다 | 안 맞는 모양을 우리 모양으로 변환 |
| 데코레이터 | 한 객체 | 그대로 둔다 | 같은 모양에 기능을 더한다 |
| 프록시 | 한 객체 | 그대로 둔다 | 모양은 같되 접근을 제어한다 |
| 퍼사드 | 여러 객체 | 새 입구를 연다 | 복잡한 여럿을 단순한 창구로 가린다 |
이 표가 오늘의 핵심입니다. 코드만 보면 넷 다 "안에 객체를 품고 위임한다"가 닮았어요. 하지만 어댑터는 모양이 안 맞아서, 데코레이터는 기능을 더하려고, 프록시는 접근을 통제하려고 감쌉니다. 퍼사드만 결이 달라 여럿을 묶어 단순화하고요. 패턴의 정체는 코드 모양이 아니라 의도로 정해진다는 걸, 네 번 거듭 본 셈입니다.
같은 "감싸기"라도 의도로 갈린다
어댑터 [ 안 맞는 외부 ] → 모양을 바꿔 끼움
데코레이터 [ 같은 음료 ] → 기능을 한 겹 더함
프록시 [ 진짜 객체 ] → 접근을 가로채 제어함
퍼사드 [ 여러 서브시스템 ] → 단순한 창구 하나로 묶음
그리고 이 과목이 패턴마다 거듭 다는 경계를 구조 패턴에도 똑같이 답니다.
⚠️ 구조 패턴도 도구지 목적이 아니다. 네 패턴 모두 "감싸기"라, 잘못 쓰면 코드에 의미 없는 껍데기만 한 겹씩 늘어납니다. 변환할 게 없는데 어댑터를, 조합이 늘 일 없는데 데코레이터를, 가릴 복잡함이 없는데 퍼사드를, 할 일 없는 대리인으로 프록시를 두는 건 전부 과잉이에요. 패턴은 "이런 문제가 있을 때 꺼내는 해법"이지, 미리 깔아 두는 장식이 아닙니다. "지금 이걸 감싸서 푸는 문제가 분명히 있는가"를 먼저 묻고 꺼내세요. 그 질문을 던질 줄 아는 게 미들의 눈입니다.
다음 시간 예고로 가는 다리
구조 패턴으로 "객체를 어떻게 조립하나"를 봤으니, 다음 시간(C-3)에는 행위 패턴 — "객체가 어떻게 협력하나"로 넘어갑니다. 거대한 if/switch를 갈아 끼우는 알고리즘으로 풀어내는 전략 패턴을 만날 텐데, B-1의 OCP("확장에는 열고 수정에는 닫는다")가 거기서 패턴으로 돌아옵니다. 상태 변화를 구독자에게 알리는 옵저버도 함께 보고요. 오늘이 "객체를 어떻게 엮나"였다면, 다음엔 "엮은 객체들이 어떻게 일을 주고받나"로 시야가 넓어집니다.
💡 한 줄 정리
어댑터·데코레이터·프록시는 "한 객체를 감싼다"는 구조가 같고, 변환·기능 추가·접근 제어라는 의도로 갈린다. 퍼사드는 여럿을 묶어 단순화한다. 패턴은 코드 모양이 아니라 의도로 정해지며, 감쌀 이유가 없으면 어느 것도 쓰지 않는 게 옳다.
🙋 학생 질문 — "실무에서 이 넷 중 뭘 제일 자주 써요?"
체감상 어댑터와 퍼사드를 가장 자주 만납니다. 외부 라이브러리나 API를 들여오는 일이 워낙 흔하니 어댑터로 경계를 감싸는 일이 많고, 복잡한 내부를 단순한 서비스 창구로 묶는 퍼사드도 자연스럽게 자주 나와요. 사실 우리가 "서비스 클래스"라고 부르며 만드는 것 상당수가 퍼사드의 성격을 띱니다.
데코레이터는 자바 입출력 스트림처럼 라이브러리 안에서 이미 쓰고 있는 걸 알아보는 일이 많고, 프록시는 직접 손으로 짜기보다 프레임워크가 뒤에서 대신 세워 주는 경우가 흔합니다. 그러니 "내가 새로 다 짠다"보다 "이미 깔린 구조에서 이 패턴을 알아보는 눈"이 먼저예요. 이름과 의도를 알아 두면, 남의 코드를 읽을 때 "아, 이건 프록시구나" 하고 구조가 한눈에 잡힙니다.
마무리
오늘은 구조 패턴 넷으로 "이미 만들어진 객체를 어떻게 조립하나"를 익혔습니다. 핵심은 네 패턴의 이름이 아니라, 비슷한 구조를 의도로 가르는 눈이에요.
오늘 배운 핵심 세 가지
💡 하나 — 감싸기 삼형제는 의도로 갈린다. 어댑터·데코레이터·프록시는 "한 객체를 감싼다"는 구조가 똑같다. 어댑터는 안 맞는 모양을 변환하려고, 데코레이터는 같은 모양에 기능을 더하려고, 프록시는 접근을 제어하려고 감싼다. 코드 모양이 아니라 왜 감쌌는지가 패턴의 정체를 정한다.
💡 둘 — 어댑터와 데코레이터는 앞에서 배운 원리의 회수다. 어댑터는 A-4의 "외부를 경계에서 감싸라"가 패턴으로 정식화된 모습이고, 데코레이터는 B-2의 "상속보다 컴포지션"이 코드로 굳은 모습이다. 패턴은 원리 위에 선다.
💡 셋 — 퍼사드는 여럿을 묶어 단순화하고, 넷 다 과하면 껍데기다. 퍼사드는 한 객체가 아니라 여러 객체를 묶어 단순한 창구로 가린다. 그리고 네 패턴 모두, 감쌀 이유가 없는데 두르면 의미 없는 한 겹만 늘리는 과잉이다. "지금 이걸 감싸서 푸는 문제가 분명히 있는가"가 먼저다.
다음 시간 예고
다음 시간(C-3)에는 디자인 패턴의 마지막 갈래인 행위 패턴으로 갑니다. 알고리즘을 통째로 갈아 끼우는 전략(B-1의 OCP가 패턴으로 돌아옵니다), 상태 변화를 구독자에게 알리는 옵저버, 거대한 조건문을 다형성으로 푸는 상태 패턴을 만나요. 객체를 만들고(C-1) 조립했으니(C-2), 이제 그 객체들이 어떻게 협력하며 일을 주고받는지를 볼 차례입니다.
과제
오늘 배운 네 패턴을 직접 손으로 적용해 봅니다. 각 Before 코드를 더 나은 구조로 고쳐 보세요. 정답은 예시답안에서 확인할 수 있어요.
[기초] 안 맞는 외부 결제 모듈을 어댑터로 끼우기
아래 LegacyPayGateway는 우리가 고칠 수 없는 외부 결제 모듈입니다. 우리가 부르고 싶은 모양과 메서드 이름이 달라요.
// 우리가 고칠 수 없는 외부 결제 모듈
public class LegacyPayGateway {
// 금액(원)을 받아 결제하고 승인 번호를 돌려준다
public String requestPayment(long amountInWon) {
return "APPROVED-" + amountInWon;
}
}
우리 코드는 PaymentProcessor라는 약속(메서드 String pay(int amount))에만 의존하고 싶습니다. LegacyPayGateway를 이 약속 모양으로 바꿔 끼우는 어댑터 LegacyPayAdapter를 만들어, 우리 pay(amount)가 외부 requestPayment(amount)로 변환·위임되게 고쳐 보세요. 그런 다음, 외부 모듈을 다른 회사 제품으로 갈아끼울 때 고칠 곳이 어댑터 하나뿐인지 확인해 보세요.
[응용] 조합 폭발하는 알림을 데코레이터로
아래는 알림을 보내는 기본 동작에 "로그 남기기"와 "전송 시각 기록"을 더하고 싶은 상황입니다. 그런데 조합마다 전용 클래스를 만들고 있어요.
// Before — 조합마다 전용 클래스 (폭발)
public class PlainNotifier implements Notifier {
public String notify(String msg) { return "[알림] " + msg; }
}
public class LoggingNotifier implements Notifier { // 로그 + 알림
public String notify(String msg) { return "[로그]" + "[알림] " + msg; }
}
public class LoggingTimestampNotifier implements Notifier { // 로그 + 시각 + 알림
public String notify(String msg) { return "[로그][시각]" + "[알림] " + msg; }
}
Notifier 인터페이스(메서드 String notify(String msg))와 기본 구현 PlainNotifier만 두고, "로그 남기기"와 "시각 기록"을 각각 데코레이터로 만들어 보세요. 그래서 new TimestampDecorator(new LoggingDecorator(new PlainNotifier()))처럼 필요한 기능만 겹쳐 쌓을 수 있게 고치고, 조합마다 클래스를 만들지 않아도 되는지 확인해 보세요.
[심화] 복잡한 가입 절차를 퍼사드로, 무거운 객체를 프록시로
아래 회원 가입 화면은 세 서비스(중복 확인·계정 생성·환영 메일)를 직접 순서대로 부르고 있습니다.
// Before — 가입 화면이 세 서비스의 순서를 직접 안다
public class SignUpScreen {
// 세 서비스를 필드로 들고 있다 (생략)
public void signUp(String email, String password) {
if (duplicateChecker.isDuplicate(email)) {
throw new IllegalStateException("이미 가입된 이메일입니다");
}
accountCreator.create(email, password);
welcomeMailer.send(email);
}
}
이 절차를 SignUpFacade.signUp(email, password) 하나로 묶어, 가입을 거는 화면이 세 서비스를 몰라도 되게 고쳐 보세요. 추가로, 환영 메일 발송 객체가 만들어질 때 무거운 템플릿 로딩을 한다고 할 때, 그 객체를 프록시로 감싸 "실제로 메일을 보낼 때"까지 로딩을 미루는 구조를 한번 구상해 보세요. (코드까지 다 짜지 않아도, 어디에 프록시를 끼울지 설명만으로 충분합니다.)
생각해볼 주제
1. 어댑터인가 데코레이터인가 — 같은 "감싸기"를 무엇으로 부를지는 누가 정하나?
오늘 우리는 어댑터·데코레이터·프록시가 코드 구조는 거의 같고 의도로 갈린다고 배웠다. 그런데 실무에서 어떤 클래스를 만들었을 때, 그게 "외부 모양을 바꾸면서 동시에 기능도 조금 더하는" 애매한 경우가 있다. 이때 이걸 어댑터라 불러야 할까, 데코레이터라 불러야 할까? 패턴의 이름이 코드가 아니라 의도로 정해진다면, 그 의도는 누가 어떻게 정하는 걸까? 이름표 붙이기에 집착하는 것과 구조를 이해하는 것의 차이를 생각해 보자.
2. 퍼사드는 좋은 단순화인가, 복잡함을 숨긴 것인가?
퍼사드는 복잡한 내부를 단순한 창구 뒤로 가린다. 호출부는 편해진다. 그런데 "가린다"는 건 양날의 칼이다. 내부에서 무슨 일이 일어나는지 호출부가 모르게 되니, 문제가 생겼을 때 추적이 어려워질 수도 있다. 단순한 입구를 주는 것과, 알아야 할 복잡함마저 숨겨 버리는 것 사이의 경계는 어디일까? 좋은 퍼사드와 "그냥 떠넘긴 퍼사드"를 가르는 기준을 고민해 보자.
3. 패턴이 언어에 흡수되면, 그건 더 이상 패턴인가?
파이썬에서는 데코레이터가 @ 문법으로 언어에 들어와 있다. 자바에서 객체를 겹겹이 감싸 짜야 했던 걸, 파이썬은 함수 한 겹으로 표현한다. 어떤 시각에서는 "GoF 패턴 상당수가 사실 언어가 부족해서 생긴 우회로"라고 말한다. 그렇다면 언어가 그 기능을 문법으로 흡수해 버리면, 그 패턴은 사라지는 걸까, 아니면 모습만 바뀌어 여전히 거기 있는 걸까? 패턴을 "언어를 초월한 영원한 진리"로 보는 시각과 "그 시대 언어의 한계가 남긴 흔적"으로 보는 시각, 둘 사이에서 생각해 보자.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안입니다. 정답이 하나뿐인 건 아니에요. 어댑터 메서드 이름이나 데코레이터를 쌓는 순서는 "이렇게도 할 수 있다"는 한 갈래일 뿐, 안 맞는 게 끼워 맞춰지고 기능이 겹겹이 쌓이면 다른 모습도 좋습니다. 중요한 건 "왜 이렇게 감쌌는가", 그 의도입니다.
과제 예시답안
🎯 [과제 1 예시답안] 안 맞는 외부 결제 모듈을 어댑터로 끼우기
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 우리 약속 도입 | 호출부가 의존할 인터페이스(PaymentProcessor)를 먼저 두었는가 |
★★★ |
| 변환·위임 | 어댑터의 pay가 외부 requestPayment로 넘기는가 |
★★★ |
| 경계 좁히기 | 외부 시그니처를 아는 곳이 어댑터 하나로 줄었는가 | ★★☆ |
| 다형성 | 호출부가 PaymentProcessor 타입에만 의존하는가 |
★★☆ |
풀이 예시
❌ Before — 우리가 고칠 수 없는 외부 결제 모듈. 메서드 이름도 인자 타입도 우리 스타일이 아니다.
public class LegacyPayGateway {
public String requestPayment(long amountInWon) {
return "APPROVED-" + amountInWon;
}
}
✅ After — 먼저 우리가 기대하는 약속을 정한다.
public interface PaymentProcessor {
String pay(int amount);
}
그리고 외부 모듈을 이 약속 모양으로 바꿔 끼우는 어댑터를 둔다.
public class LegacyPayAdapter implements PaymentProcessor {
private final LegacyPayGateway gateway;
public LegacyPayAdapter(LegacyPayGateway gateway) {
this.gateway = gateway;
}
@Override
public String pay(int amount) {
return gateway.requestPayment(amount);
}
}
이제 우리 코드는 PaymentProcessor 약속에만 기댑니다. pay(1000)을 부르면 어댑터가 외부 requestPayment(1000)으로 넘겨 "APPROVED-1000"을 돌려줘요. 외부 결제사를 다른 회사 제품으로 갈아끼우면, 새 어댑터 하나만 만들면 됩니다. 호출부는 한 글자도 바뀌지 않아요.
💡 튜터의 한마디 — 어댑터의 핵심은 "외부를 아는 코드를 한 곳에 가둔다"입니다. requestPayment라는 외부 이름이 우리 코드 곳곳에 퍼지지 않고 어댑터 안에만 있으니, 외부가 바뀔 때 흔들리는 범위가 그 한 곳으로 좁아져요. A-4에서 "경계를 감싸라"고 한 게 바로 이 모습입니다.
🎯 [과제 2 예시답안] 조합 폭발하는 알림을 데코레이터로
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 같은 인터페이스 | 데코레이터가 Notifier를 구현하면서 Notifier를 품는가 |
★★★ |
| 위임 + 자기 몫 | 안쪽에 위임하고 자기 태그만 덧붙이는가 | ★★★ |
| 폭발 제거 | 조합마다 클래스를 만들지 않고 겹쳐 쌓는가 | ★★☆ |
| 순서 이해 | 감싸는 순서가 태그 순서를 정한다는 걸 아는가 | ★★☆ |
풀이 예시
❌ Before — 조합마다 전용 클래스를 만든다. 로그와 시각을 더했을 뿐인데 클래스가 통째로 하나 생긴다.
public class LoggingTimestampNotifier implements Notifier {
@Override
public String notify(String msg) {
return "[로그][시각][알림] " + msg;
}
}
✅ After — 약속과 기본 구현을 두고, 추상 데코레이터가 Notifier를 품는다.
public interface Notifier {
String notify(String msg);
}
public abstract class NotifierDecorator implements Notifier {
protected final Notifier base;
protected NotifierDecorator(Notifier base) {
this.base = base;
}
@Override
public String notify(String msg) {
return base.notify(msg);
}
}
구체 데코레이터는 안쪽에 위임하고 자기 태그만 앞에 덧붙인다.
public class LoggingDecorator extends NotifierDecorator {
public LoggingDecorator(Notifier base) {
super(base);
}
@Override
public String notify(String msg) {
return "[로그]" + base.notify(msg);
}
}
시각 데코레이터(TimestampDecorator)도 똑같은 구조로 "[시각]"을 앞에 붙입니다. 이제 필요한 기능만 겹쳐 쌓아요.
Notifier notifier = new LoggingDecorator(new TimestampDecorator(new PlainNotifier()));
// notifier.notify("안녕") → "[로그][시각][알림] 안녕"
전용 클래스 LoggingTimestampNotifier와 똑같은 문자열이 나오지만, 클래스를 새로 만들지 않았어요. 로그만 필요하면 new LoggingDecorator(new PlainNotifier()), 시각만 필요하면 시각 데코레이터 하나만 감싸면 됩니다.
💡 튜터의 한마디 — 감싸는 순서가 태그 순서를 정합니다. LoggingDecorator가 바깥이라 "[로그]"가 맨 앞에 붙었어요. 순서를 바꾸면 결과도 바뀝니다. 이 "순서로 조합을 만든다"가 데코레이터의 힘이자, 깊게 쌓였을 때 헷갈리는 지점이기도 해요. 그래서 조합이 정말 여러 가지로 필요할 때만 꺼냅니다.
🎯 [과제 3 예시답안] 복잡한 가입 절차를 퍼사드로, 무거운 객체를 프록시로
채점 포인트
| 항목 | 보는 것 | 배점 |
|---|---|---|
| 절차 모으기 | 세 서비스 호출을 퍼사드 한 메서드로 묶었는가 | ★★★ |
| 호출부 단순화 | 가입 화면이 세 서비스를 모른 채 퍼사드만 부르는가 | ★★★ |
| 예외 보존 | 중복이면 계정 생성·메일로 진행하지 않는가 | ★★☆ |
| 프록시 위치 | 무거운 로딩을 미룰 곳에 프록시를 구상했는가 | ★★☆ |
풀이 예시
❌ Before — 가입 화면이 세 서비스의 호출 순서를 직접 짊어진다. 가입을 거는 화면이 여럿이면 이 절차가 곳곳에 복제된다.
public class SignUpScreenBefore {
// 세 서비스를 필드로 들고 있다 (생성자 생략)
public void signUp(String email, String password) {
if (duplicateChecker.isDuplicate(email)) {
throw new IllegalStateException("이미 가입된 이메일입니다");
}
accountCreator.create(email, password);
welcomeMailer.send(email);
}
}
✅ After — 같은 절차를 퍼사드 한 창구로 모은다.
public class SignUpFacade {
private final DuplicateChecker duplicateChecker;
private final AccountCreator accountCreator;
private final WelcomeMailer welcomeMailer;
// 생성자에서 세 서비스를 받아 둔다 (생략)
public void signUp(String email, String password) {
if (duplicateChecker.isDuplicate(email)) {
throw new IllegalStateException("이미 가입된 이메일입니다");
}
accountCreator.create(email, password);
welcomeMailer.send(email);
}
}
이제 가입을 거는 모든 화면은 SignUpFacade 하나만 들고 signUp()을 부르면 됩니다. 세 서비스의 존재도, 호출 순서도 몰라요. 중간에 "약관 동의 기록" 단계가 끼어도 고칠 곳은 퍼사드 한 곳뿐입니다. 중복 이메일이면 예외를 던지고 계정 생성·메일 발송으로 넘어가지 않는 동작도 그대로 보존돼요.
프록시는 어디에 끼울까 — 환영 메일 객체(WelcomeMailer)가 만들어질 때 무거운 메일 템플릿 로딩을 한다고 합시다. 그런데 가입 화면을 띄울 때마다 이 객체를 미리 만들면, 실제 메일을 보내기도 전에 템플릿부터 읽어 들여 느려집니다.
여기에 프록시를 끼웁니다. WelcomeMailer와 같은 약속(인터페이스)을 구현한 대리인을 세워, 진짜 객체와 템플릿 로딩을 send()가 처음 불리는 순간까지 미루는 거죠. 가입은 됐지만 메일을 아직 안 보낸 상태라면 템플릿도 로딩되지 않습니다. 교안의 이미지 지연 로딩과 똑같은 구조예요.
💡 튜터의 한마디 — 퍼사드와 프록시를 한 과제에 같이 둔 건, 둘이 자주 짝을 이루기 때문이에요. 퍼사드로 절차를 단순한 창구로 묶고, 그 안에서 무거운 객체는 프록시로 지연시킵니다. "복잡함을 가리는" 퍼사드와 "시점을 제어하는" 프록시가 한 흐름에서 협력하는 거죠. 다만 프록시는 정말 무거운 로딩이 있을 때만 끼우세요. 가벼운 객체에 대리인을 세우면 간접 단계만 늘어납니다.
생각해볼 주제 예시답안
🤔 [생각해볼 주제 1] 어댑터인가 데코레이터인가 — 그 의도는 누가 정하나?
문제 상황 요약
어댑터·데코레이터·프록시는 코드 구조가 거의 같고 의도로 갈린다고 배웠습니다. 그런데 어떤 클래스가 "외부 모양을 바꾸면서 동시에 기능도 조금 더하는" 애매한 경우가 있어요. 이걸 어댑터라 불러야 할까요, 데코레이터라 불러야 할까요? 의도는 누가 어떻게 정하는 걸까요?
튜터의 가이드 및 해설
핵심은 이름표가 코드의 속성이 아니라, 우리가 그 코드에 부여한 목적이라는 데 있습니다. 같은 "감싸기" 코드라도, 그 클래스를 만든 사람이 "안 맞는 걸 맞추려고" 만들었으면 어댑터고, "기능을 더하려고" 만들었으면 데코레이터예요.
그래서 애매한 경우의 답은 "주된 의도가 무엇인가"로 정해집니다. 외부 모양을 우리 모양으로 맞추는 게 주목적이고 기능 추가는 곁다리라면, 그건 약간의 변환을 겸한 어댑터예요. 반대로 모양은 그대로 두고 기능을 얹는 게 주목적이면 데코레이터고요.
더 중요한 건, 이름표 붙이기 자체에 너무 집착하지 않는 것입니다. 패턴 이름은 팀이 서로 빠르게 소통하려는 공통 어휘지, 모든 클래스에 정확한 분류표를 달아야 하는 시험이 아니에요. 코드 리뷰에서 "이건 외부를 우리 인터페이스로 맞추는 거예요"라고 의도를 말할 수 있으면, 그게 어댑터든 무엇이든 이미 충분히 전달된 겁니다. 구조를 이해하는 게 먼저고, 이름은 그 이해를 나누는 도구일 뿐입니다.
🎯 면접에선 이렇게 나온다
"구조가 같아도 패턴은 의도로 갈립니다. 같은 감싸기라도 모양을 맞추려 했으면 어댑터, 기능을 더하려 했으면 데코레이터예요. 애매하면 '주된 목적이 뭔지'로 정하고, 이름표 자체에 집착하기보다 '이 클래스가 왜 존재하는지'를 동료에게 전달하는 걸 우선합니다. 패턴 이름은 소통 도구지 분류 시험이 아니니까요."
💡 실무에선
리뷰에서 "이거 어댑터예요 데코레이터예요?"로 다투기보다, "이게 무슨 문제를 풀려고 감싼 건지"를 먼저 합의하세요. 의도가 분명하면 이름은 자연히 따라옵니다.
🤔 [생각해볼 주제 2] 퍼사드는 좋은 단순화인가, 복잡함을 숨긴 것인가?
문제 상황 요약
퍼사드는 복잡한 내부를 단순한 창구 뒤로 가립니다. 호출부는 편해지죠. 그런데 "가린다"는 건 양날의 칼이에요. 내부에서 무슨 일이 일어나는지 호출부가 모르게 되니, 문제가 생겼을 때 추적이 어려워질 수도 있습니다. 좋은 단순화와 "그냥 숨긴 것"의 경계는 어디일까요?
튜터의 가이드 및 해설
가르는 기준은 호출부가 알 필요 없는 걸 가렸는가, 알아야 할 걸 가렸는가입니다. 좋은 퍼사드는 "재고 확인 → 결제 → 배송"처럼 호출부가 굳이 신경 쓸 필요 없는 내부 절차를 가립니다. 그 순서는 주문 로직의 사정이지, 주문 버튼을 누르는 화면이 알 일이 아니니까요. 이건 건강한 단순화예요.
문제가 되는 건 알아야 할 것까지 숨길 때입니다. 예를 들어 퍼사드가 결제 실패를 조용히 삼켜 버리고 호출부에 아무것도 알리지 않으면, 호출부는 "주문이 됐겠거니" 착각합니다. 가려야 할 건 "어떻게(how)"고, 가리면 안 되는 건 "무슨 일이 일어났나(what)"예요. 좋은 퍼사드는 내부 절차의 복잡함은 감추되, 결과와 실패는 분명히 드러냅니다.
또 하나의 신호는 추적 가능성이에요. 좋은 퍼사드는 안에서 무슨 일이 있었는지 로그나 예외로 따라갈 길을 남깁니다. 반대로 "그냥 떠넘긴 퍼사드"는 안이 깜깜해서, 문제가 생기면 어디서 터졌는지 알 수가 없어요. 단순한 입구를 주되 안을 들여다볼 창은 남겨 두는 것, 그게 경계입니다.
🎯 면접에선 이렇게 나온다
"퍼사드는 '어떻게'는 가리되 '무슨 일이 일어났나'는 가리면 안 됩니다. 내부 호출 순서 같은 절차는 숨겨도 좋지만, 결과와 실패는 호출부에 분명히 드러내야 해요. 그리고 안에서 무슨 일이 있었는지 로그나 예외로 추적할 길을 남기는지가, 좋은 퍼사드와 그냥 복잡함을 떠넘긴 퍼사드를 가릅니다."
💡 실무에선
퍼사드를 만들 때 "이 안에서 실패하면 호출부가 알 수 있나?"를 꼭 물으세요. 결과를 반환하고 예외를 전파하면 단순화고, 조용히 삼키면 은폐입니다.
🤔 [생각해볼 주제 3] 패턴이 언어에 흡수되면, 그건 더 이상 패턴인가?
문제 상황 요약
파이썬에서는 데코레이터가 @ 문법으로 언어에 들어와 있습니다. 자바에서 객체를 겹겹이 감싸 짜야 했던 걸, 파이썬은 함수 한 겹으로 표현해요. "GoF 패턴 상당수가 사실 언어가 부족해서 생긴 우회로"라는 시각도 있습니다. 언어가 그 기능을 문법으로 흡수하면, 그 패턴은 사라지는 걸까요?
튜터의 가이드 및 해설
답은 패턴이 푸는 문제와 그 문제를 푸는 형태를 분리하는 데 있습니다. 데코레이터가 푸는 문제는 "기존 것을 건드리지 않고 기능을 겹겹이 더하기"예요. 이 문제는 언어를 가리지 않고 늘 존재합니다. 자바에선 같은 인터페이스를 구현한 클래스로 객체를 감싸 풀고, 파이썬에선 @데코레이터 함수 한 겹으로 풀어요. 문제는 같고, 형태만 달라진 겁니다.
그러니 언어가 패턴을 문법으로 흡수했다고 패턴이 사라지는 게 아니라, 모습만 바뀌어 여전히 거기 있는 거예요. 파이썬 개발자도 여전히 "이 함수를 데코레이트한다"고 말합니다. 개념과 어휘는 살아 있고, 다만 직접 손으로 클래스를 쌓을 필요가 없어진 거죠.
여기서 더 깊은 통찰은, "패턴을 영원한 진리로 신성시할 필요도, 그 시대 언어의 흔적이라고 깎아내릴 필요도 없다"는 균형입니다. GoF 패턴은 한편으로는 객체로 협력을 설계하는 검증된 지혜고, 다른 한편으로는 그 시절 언어의 한계를 메운 우회로이기도 해요. 둘 다 맞습니다.
중요한 건 "내가 지금 쓰는 언어에서 이 문제가 문법으로 이미 풀려 있나, 아니면 패턴으로 직접 풀어야 하나"를 묻는 눈이에요. 그 눈이 패턴을 외우는 사람과 패턴의 본질을 아는 사람을 가릅니다.
🎯 면접에선 이렇게 나온다
"패턴이 푸는 문제는 언어를 안 가리지만, 푸는 형태는 언어에 따라 달라집니다. 파이썬은 데코레이터를
@문법으로 흡수했지만, '기능을 겹겹이 더한다'는 개념과 어휘는 그대로 살아 있어요. 그래서 패턴이 사라진 게 아니라 모습만 바뀐 겁니다. 저는 패턴을 신성시하지도, 언어 한계의 흔적이라고 깎아내리지도 않고, '이 언어에선 이게 문법으로 풀리나'를 묻는 도구로 봅니다."
💡 실무에선
새 언어를 배울 때 "내가 자바에서 패턴으로 풀던 걸, 이 언어는 문법으로 풀어 주나?"를 물어보세요. 파이썬 @, 코틀린 위임처럼 언어에 녹은 패턴을 알아보면 코드가 훨씬 간결해집니다.