디자인 패턴 핵심 정리 시리즈 3편. 구조 패턴 6가지(Adapter·Decorator·Proxy·Composite·Facade·Flyweight)를 220V 변환 어댑터·옷 한 겹·비서·폴더 트리·안내 데스크·공통 부품 공유 비유로 풀어가며, 각 패턴의 위반·준수 코드와 자주 빠지는 함정 6가지를 처음 보는 사람도 따라올 수 있게 친절하게 풀어쓴 3편.
이 글은 디자인 패턴 핵심 정리 시리즈의 세 번째 편입니다. 1편에서 SOLID 원칙을, 2편에서 생성 패턴(객체를 어떻게 만드느냐)을 다뤘는데, 이번 3편은 한 단계 더 나아가서 "객체를 어떻게 조합해 더 큰 구조를 만드느냐"에 관한 이야기예요.
구조 패턴은 GoF 23가지 디자인 패턴 중 7가지에 속하는데, 이 글에서는 Adapter · Decorator · Proxy · Composite · Facade · Flyweight 여섯 가지를 정리합니다. 이름은 낯설지만 다 일상에서 본 적 있는 모양이에요. 220V·110V 어댑터, 옷 한 겹 더 입기, 비서가 대신 응대해주는 사장실 — 모두 구조 패턴의 사촌입니다.
왜 구조 패턴이 처음엔 어렵게 느껴질까요
이유는 세 가지예요.
첫째, 이름이 비슷한 데 비해 목적이 다 다릅니다. Adapter와 Decorator는 둘 다 "객체를 감싼다(wrapping)"고 표현하는데 풀려는 문제가 달라요. Proxy도 감싸요. Facade도 어떻게 보면 감싸기. 다 비슷해 보이는데 각자의 자리가 따로 있습니다.
둘째, Composite와 Flyweight 같은 패턴은 일상에서 잘 안 보입니다. "트리 구조 위에 통일된 인터페이스"라거나 "공유 가능한 상태 분리" 같은 표현이 추상적이라 첫 만남에서 이게 뭔지 잘 안 잡혀요.
셋째, 언제 써야 하는지가 코드 신호로 잘 안 보입니다. "기존 시스템과 새 라이브러리 인터페이스가 안 맞아요"라거나 "총알 10만 개를 메모리에 못 담아요" 같은 구체적인 신호를 만나기 전까지는 패턴이 와닿지 않습니다.
해결법은 한 가지예요. 여섯 가지 패턴을 일상 비유로 한 줄씩 묶어두고, 각 패턴이 풀려는 신호(어댑터 = 인터페이스 불일치, 데코레이터 = 토핑 조합, 프록시 = 지연 로딩 또는 보안)를 익히면 머리에 정리됩니다. 이 글이 그 비유와 신호를 따라 여섯 가지를 차례로 풀어 갑니다.
여섯 가지 구조 패턴을 한 줄로 묶기
본격 설명에 들어가기 전에 여섯 가지 구조 패턴을 일상 비유로 한 줄씩 묶어 두고 시작할게요. 시험 직전에 이 표만 다시 봐도 70%는 떠오릅니다.
| 패턴 | 비유 | 핵심 |
|---|---|---|
| Adapter | "220V·110V 변환 어댑터" | 호환 안 되는 인터페이스를 변환 |
| Decorator | "옷 한 겹 더 입기" | 런타임에 기능 동적 추가 |
| Proxy | "비서가 대리 응대" | 실제 객체 접근을 제어·중재 |
| Composite | "폴더-파일 통일 처리" | 개별/복합 객체를 같은 인터페이스로 |
| Facade | "안내 데스크" | 복잡한 내부를 단순한 창구로 |
| Flyweight | "공통 부품 공유" | 메모리 절약을 위한 상태 분리 |
여섯 가지 모두 "객체와 클래스를 어떻게 조합해 더 큰 구조를 만드는가"에 관한 답이에요. 자세한 사양은 Refactoring.Guru의 구조 패턴 가이드에서도 같이 보면 좋아요.
Adapter — 220V·110V 변환 어댑터
"호환되지 않는 인터페이스를 가진 클래스들이 함께 작동할 수 있도록 변환한다."
회사 비유로 — 해외 출장 가서 110V 콘센트에 220V 기기를 꽂는 어댑터예요. 기기를 다시 만들지 않고도 어댑터 하나로 두 다른 표준을 연결합니다. 서드파티 라이브러리 통합·레거시 시스템 마이그레이션에 가장 많이 쓰입니다.
문제 상황 — 알림 서비스 교체
// 기존 시스템 — NotificationService 인터페이스에 의존
public interface NotificationService {
void send(String to, String subject, String body);
}
public class EmailNotificationService implements NotificationService {
@Override
public void send(String to, String subject, String body) {
System.out.println("Sending email to: " + to);
System.out.println("Subject: " + subject);
System.out.println("Body: " + body);
}
}
public class Client {
public static void main(String[] args) {
NotificationService emailService = new EmailNotificationService();
emailService.send(
"customer@example.com",
"Order Confirmation",
"Your order has been received."
);
}
}
새로운 서드파티 알림 서비스 도입
// 비용 절감을 위해 외부 알림 라이브러리를 도입하려 함
// 그런데 이 라이브러리는 인터페이스가 다름!
public class ExternalMailLibrary {
// 메서드명도 파라미터명도 다름
public void sendEmail(String recipient, String title, String content) {
System.out.println("Sending email via external library to " + recipient);
System.out.println("Title: " + title);
System.out.println("Content: " + content);
}
}
// 문제 — ExternalMailLibrary는 NotificationService를 구현하지 않음
// 클라이언트 코드에서 직접 교체 불가능
해결 — Adapter 클래스 생성
// ExternalMailAdapter — NotificationService(클라이언트 기대)를 구현하면서
// 내부적으로는 ExternalMailLibrary를 호출
public class ExternalMailAdapter implements NotificationService {
private ExternalMailLibrary externalLibrary;
public ExternalMailAdapter(ExternalMailLibrary externalLibrary) {
this.externalLibrary = externalLibrary;
}
// 클라이언트가 기대하는 인터페이스 구현
@Override
public void send(String to, String subject, String body) {
// 파라미터 이름·메서드명 변환 — 이게 어댑터의 핵심
externalLibrary.sendEmail(to, subject, body);
}
}
// 클라이언트 — 단 한 줄만 변경
public class Client {
public static void main(String[] args) {
// 기존: new EmailNotificationService();
// 변경: 어댑터로 외부 라이브러리 주입
NotificationService emailService =
new ExternalMailAdapter(new ExternalMailLibrary());
// send() 호출 코드는 전혀 변경 없음
emailService.send(
"customer@example.com",
"Welcome!",
"This is a welcome email sent via external library."
);
}
}
어댑터 패턴 참여자
| 역할 | 설명 | 예시에서 |
|---|---|---|
| Client | 기존 인터페이스를 사용하는 코드 | Client 클래스 |
| Target | 클라이언트가 기대하는 인터페이스 | NotificationService |
| Adaptee | 호환되지 않는 기존 클래스 | ExternalMailLibrary |
| Adapter | Target을 구현하고 Adaptee를 감싸는 클래스 | ExternalMailAdapter |
여기서 시험 함정이 하나 있어요. Adapter와 단순 Wrapper를 헷갈리는 면접 단골 질문이 있습니다. 모든 Wrapper가 Adapter는 아니에요. Adapter의 핵심은 "인터페이스 변환"입니다. 인터페이스가 이미 호환되는데 기능만 추가하고 싶다면 Adapter가 아니라 Decorator를 써야 합니다.
> 한 줄 정리 — Adapter는 "안 맞는 인터페이스를 끼워 맞춘다". 220V·110V 변환 어댑터 비유.
Decorator — 옷 한 겹 더 입기
"런타임에 객체에 새로운 기능을 동적으로 추가한다. 기존 클래스를 수정하지 않고."
회사 비유로 — 옷 한 겹 더 입기예요. 기본 셔츠 위에 가디건을 입고, 그 위에 코트를 입고, 그 위에 또 머플러를 두르는 식. 각 겹이 자기 기능을 더하면서도 "옷"이라는 인터페이스는 같습니다.
클래스 폭발 문제 (상속만 쓸 때)
// 나쁜 예 — 상속으로 피자 토핑 구현 시 클래스 폭발
public class BasicPizza {
public String getDescription() { return "Basic Pizza"; }
public double getCost() { return 5.0; }
}
public class CheesePizza extends BasicPizza {
public String getDescription() { return super.getDescription() + ", Cheese"; }
public double getCost() { return super.getCost() + 1.0; }
}
public class CheeseOlivePizza extends CheesePizza {
public String getDescription() { return super.getDescription() + ", Olives"; }
public double getCost() { return super.getCost() + 0.50; }
}
// 토핑이 3개면 2^3 = 8개 클래스
// 토핑이 10개라면? 1024개 → 관리 불가
여기서 시험 함정이 하나 있어요. 상속만 쓰면 조합이 늘어날수록 클래스가 기하급수적으로 폭발해요. 토핑 N개에 대해 모든 조합을 클래스로 만들면 2^N개 — 토핑 10개면 1024개 클래스가 필요합니다. 신호가 보이는 순간 Decorator를 꺼낼 때입니다.
Decorator 패턴 구현
// 공통 인터페이스 — 기본 피자와 데코레이터 모두 구현
public interface Pizza {
String getDescription();
double getCost();
}
// Component (기본 피자)
public class BasicPizza implements Pizza {
@Override public String getDescription() { return "Basic Pizza"; }
@Override public double getCost() { return 5.0; }
}
// 추상 데코레이터 — 모든 토핑 데코레이터의 베이스
// Pizza 인터페이스를 구현하면서 Pizza 객체를 필드로 가짐
public abstract class PizzaDecorator implements Pizza {
protected Pizza decoratedPizza;
public PizzaDecorator(Pizza pizza) {
this.decoratedPizza = pizza;
}
@Override
public String getDescription() {
return decoratedPizza.getDescription();
}
@Override
public double getCost() {
return decoratedPizza.getCost();
}
}
// 치즈 데코레이터
public class CheeseDecorator extends PizzaDecorator {
public CheeseDecorator(Pizza pizza) { super(pizza); }
@Override
public String getDescription() {
return super.getDescription() + ", Cheese";
}
@Override
public double getCost() {
return super.getCost() + 1.0;
}
}
// 올리브 데코레이터
public class OliveDecorator extends PizzaDecorator {
public OliveDecorator(Pizza pizza) { super(pizza); }
@Override
public String getDescription() {
return super.getDescription() + ", Olives";
}
@Override
public double getCost() {
return super.getCost() + 0.5;
}
}
// 버섯 데코레이터
public class MushroomDecorator extends PizzaDecorator {
public MushroomDecorator(Pizza pizza) { super(pizza); }
@Override
public String getDescription() {
return super.getDescription() + ", Mushroom";
}
@Override
public double getCost() {
return super.getCost() + 2.0;
}
}
// 클라이언트 — 원하는 조합을 런타임에 동적 구성
public class PizzaApp {
public static void main(String[] args) {
Pizza pizza = new BasicPizza();
// "Basic Pizza - $5.0"
pizza = new CheeseDecorator(pizza);
// "Basic Pizza, Cheese - $6.0"
pizza = new OliveDecorator(pizza);
// "Basic Pizza, Cheese, Olives - $6.5"
pizza = new MushroomDecorator(pizza);
// "Basic Pizza, Cheese, Olives, Mushroom - $8.5"
// 토핑 3개 → 클래스 3개(+ 기본 + 추상 데코)만 있으면 모든 조합 가능!
}
}
Decorator가 지키는 SOLID 원칙
- OCP — 새 토핑 추가 시 기존 코드 수정 없이 새 데코레이터 클래스만 추가
- SRP — 각 데코레이터는 하나의 기능(한 가지 토핑)만 담당
- DIP — 클라이언트는
Pizza인터페이스에 의존
표준 라이브러리에서도 자주 보여요. Java I/O의 BufferedInputStream이 InputStream을 감싸고, 다시 DataInputStream이 그 위를 감싸는 구조 — 그게 바로 Decorator입니다.
> 한 줄 정리 — Decorator는 "기능을 한 겹 한 겹 입혀 가는" 패턴. OCP를 가장 깔끔하게 푸는 방식.
Proxy — 비서가 대리 응대
"다른 객체에 대한 접근을 제어하는 대리 객체를 제공한다."
회사 비유로 — 사장실 앞 비서예요. 외부 사람은 사장님(실제 객체)에게 바로 못 들어가고 비서(프록시)를 거쳐요. 비서는 일정 확인·신원 확인·메시지 전달 같은 부가 작업을 합니다. 본질은 "접근 제어".
프록시의 주요 용도가 네 가지 있어요. 지연 로딩(Lazy Loading)·캐싱(Caching)·접근 제어(Access Control)·로깅(Logging).
무거운 객체의 즉시 로딩 문제
public interface Image {
void display();
}
// 실제 이미지 — 생성자에서 즉시 디스크 로드 (무거운 작업)
public class RealImage implements Image {
private String fileName;
public RealImage(String fileName) {
this.fileName = fileName;
loadImageFromDisk(); // 비용 큰 작업
}
private void loadImageFromDisk() {
System.out.println("Loading image from disk: " + fileName);
}
@Override
public void display() {
System.out.println("Displaying " + fileName);
}
}
// 문제 있는 클라이언트
public class ProblemClient {
public static void main(String[] args) {
// 객체 생성 시 즉시 디스크 로드!
Image image1 = new RealImage("dog.png");
Image image2 = new RealImage("cat.png");
image1.display();
// image2는 표시 안 함 — 그래도 디스크에서 이미 로드됨 (자원 낭비)
}
}
Proxy로 지연 로딩
// ProxyImage — 실제 로딩을 display() 호출 시까지 지연
public class ProxyImage implements Image {
private String fileName;
private RealImage realImage; // null로 시작 (지연 초기화)
public ProxyImage(String fileName) {
this.fileName = fileName;
// 생성자에서 RealImage 만들지 않음 — 가볍고 빠른 프록시만 생성
}
@Override
public void display() {
// 처음 호출될 때만 RealImage 생성
if (realImage == null) {
realImage = new RealImage(fileName);
}
// 이후 호출은 캐시된 객체 사용
realImage.display();
}
}
// 개선된 클라이언트
public class ImprovedClient {
public static void main(String[] args) {
Image image1 = new ProxyImage("dog.png"); // 로딩 없음
Image image2 = new ProxyImage("cat.png"); // 로딩 없음
image1.display(); // 여기서 디스크 로드 발생
image1.display(); // 두 번째 호출 — 캐시된 객체 사용
// image2는 display() 안 호출 → 실제 로딩 안 됨 (자원 절약)
}
}
Proxy의 4가지 종류
// 1. Virtual Proxy (가상 프록시) — 지연 초기화
// 위의 ProxyImage가 대표 예시
// 2. Protection Proxy (보호 프록시) — 접근 제어
public class SecureImage implements Image {
private RealImage realImage;
private String userRole;
public SecureImage(String fileName, String userRole) {
this.realImage = new RealImage(fileName);
this.userRole = userRole;
}
@Override
public void display() {
if ("ADMIN".equals(userRole) || "USER".equals(userRole)) {
realImage.display();
} else {
System.out.println("Access Denied: Insufficient permissions");
}
}
}
// 3. Remote Proxy (원격 프록시) — 원격 객체에 대한 로컬 대리자
// RMI(Remote Method Invocation)의 스텁이 대표 예
// 4. Logging Proxy (로깅 프록시) — 메서드 호출 기록
public class LoggingImage implements Image {
private RealImage realImage;
public LoggingImage(String fileName) {
this.realImage = new RealImage(fileName);
}
@Override
public void display() {
System.out.println("[LOG] display() called at " + System.currentTimeMillis());
realImage.display();
System.out.println("[LOG] display() completed");
}
}
여기서 정말 중요한 시험 함정 — Decorator와 Proxy를 구분하는 면접 단골 질문이 있어요. 둘 다 객체를 감싸는 wrapping 방식이지만 목적이 달라요. Decorator는 기능 추가(피자 토핑처럼)고, Proxy는 접근 제어(지연 로딩·보안·로깅)예요. Decorator는 여러 겹 중첩이 자연스럽지만 Proxy는 보통 한 겹입니다.
멀티스레드에서 Proxy의 함정
// 스레드 안전하지 않은 Lazy Loading
public void display() {
if (realImage == null) { // 두 스레드가 동시에 통과 가능
realImage = new RealImage(fileName); // 두 번 생성 위험!
}
realImage.display();
}
// 스레드 안전 — synchronized 또는 Double-Checked Locking
public synchronized void display() {
if (realImage == null) {
realImage = new RealImage(fileName);
}
realImage.display();
}
여기서 시험 함정이 하나 있어요. 싱글톤 챕터에서 본 Double-Checked Locking 문제가 그대로 재현됩니다. Lazy Initialization을 쓸 때마다 멀티스레드 안전성을 다시 한번 체크해야 해요.
> 한 줄 정리 — Proxy는 "실제 객체 앞에 비서를 두는" 패턴. 지연 로딩·캐싱·보안·로깅이 4대 용도.
Composite — 폴더-파일 통일 처리
"개별 객체(Leaf)와 복합 객체(Composite)를 클라이언트가 동일하게 다룰 수 있도록 한다."
회사 비유로 — 폴더와 파일을 같은 방식으로 다루기예요. 폴더 안에 파일이 있고, 폴더 안에 또 폴더가 있고, 그 안에 또 파일이 있고... 트리 구조인데도 클라이언트는 "이건 파일이야 폴더야?"를 신경 쓸 필요 없이 showDetails() 같은 공통 메서드만 호출합니다.
조직도, UI 위젯 계층, 파일 시스템 — 모두 Composite 패턴의 모양이에요.
Composite 없을 때 — 파일과 폴더가 따로
// 문제 — 파일과 폴더가 별도 클래스, Folder가 File만 포함 가능
public class File {
private String name;
public File(String name) { this.name = name; }
public void showDetails() { System.out.println(name); }
}
public class Folder {
private String name;
private List<File> files = new ArrayList<>(); // File만 포함 가능!
public Folder(String name) { this.name = name; }
public void addFile(File file) { files.add(file); }
public void showDetails() {
System.out.println("Folder: " + name);
for (File file : files) {
file.showDetails();
}
}
// 폴더 안에 폴더를 못 넣음 → 파일 시스템 구조 불가능!
}
Composite 패턴 구현
// 공통 인터페이스 — File과 Folder 모두 구현
public interface FileSystemComponent {
void showDetails();
}
// Leaf (개별 객체) — 파일
public class File implements FileSystemComponent {
private String name;
public File(String name) {
this.name = name;
}
@Override
public void showDetails() {
System.out.println(" File: " + name);
}
}
// Composite (복합 객체) — 폴더
// List<FileSystemComponent>이라 File과 Folder를 모두 포함 가능
public class Folder implements FileSystemComponent {
private String name;
private List<FileSystemComponent> components = new ArrayList<>();
public Folder(String name) {
this.name = name;
}
// File도, Folder도 추가 가능 (모두 FileSystemComponent를 구현)
public void addComponent(FileSystemComponent component) {
components.add(component);
}
@Override
public void showDetails() {
System.out.println("Folder: " + name);
// 재귀적으로 자식 컴포넌트의 showDetails() 호출
for (FileSystemComponent component : components) {
component.showDetails();
}
}
}
// 클라이언트
public class FileSystemApp {
public static void main(String[] args) {
File file1 = new File("resume.pdf");
File file2 = new File("photo.jpg");
File file3 = new File("notes.txt");
File file4 = new File("code.java");
Folder documentsFolder = new Folder("Documents");
documentsFolder.addComponent(file1);
documentsFolder.addComponent(file2);
Folder projectFolder = new Folder("Project");
projectFolder.addComponent(file3);
projectFolder.addComponent(file4);
// 루트 폴더 — 파일과 폴더 모두 포함
Folder rootFolder = new Folder("Root");
rootFolder.addComponent(documentsFolder); // 폴더 추가 (재귀!)
rootFolder.addComponent(projectFolder);
rootFolder.addComponent(new File("readme.txt"));
// 한 번의 호출로 전체 트리 출력
rootFolder.showDetails();
// Folder: Root
// Folder: Documents
// File: resume.pdf
// File: photo.jpg
// Folder: Project
// File: notes.txt
// File: code.java
// File: readme.txt
// 클라이언트는 "파일이냐 폴더냐" 신경 쓸 필요 없음
FileSystemComponent any = documentsFolder;
any.showDetails(); // 동일하게 동작
}
}
Composite의 장점
- 통일성 — 클라이언트가 단일/복합 객체를 동일하게 처리
- 확장성 — 새 컴포넌트 타입(예: Shortcut, SymLink) 추가가 쉬움
- 재귀성 — 트리 전체를 재귀적으로 처리하는 연산이 자연스러움
여기서 시험 함정이 하나 있어요. 매우 깊은 트리에서 재귀가 스택 오버플로우를 일으킬 수 있어요. 폴더 깊이가 예측 불가능하게 깊어진다면 재귀 대신 반복문(스택·큐) 방식의 순회로 다시 짜야 합니다.
> 한 줄 정리 — Composite은 "폴더와 파일을 같은 인터페이스로" 다루는 패턴. 트리 구조 + 공통 인터페이스가 핵심.
Facade — 안내 데스크
"복잡한 하위 시스템에 단순화된 인터페이스를 제공한다."
회사 비유로 — 건물 1층의 안내 데스크예요. 외부에서 온 손님은 7층 회계팀, 9층 인사팀, 11층 마케팅팀을 알 필요 없이 안내 데스크에 "주문 처리 부탁드려요"라고 한 번만 말하면 됩니다. 안내 데스크가 내부적으로 여러 부서를 조율해요.
마이크로서비스 아키텍처의 API Gateway가 Facade의 가장 유명한 실제 사례입니다. 클라이언트는 게이트웨이 한 곳에 요청을 보내고, 게이트웨이가 여러 마이크로서비스를 조율해요.
Facade 없을 때 — 클라이언트가 모든 서비스를 직접 호출
public class UserService {
public void getUserDetails() {
System.out.println("Fetching user details...");
}
}
public class OrderService {
public void getOrderDetails(String orderId) {
System.out.println("Fetching order details for order: " + orderId);
}
}
public class PaymentService {
public void processPayment() {
System.out.println("Processing payment...");
}
}
// 문제 클라이언트
public class ProblematicClient {
public static void main(String[] args) {
UserService userService = new UserService();
OrderService orderService = new OrderService();
PaymentService paymentService = new PaymentService();
// 클라이언트가 호출 순서·방식·의존성을 모두 알아야 함
userService.getUserDetails();
orderService.getOrderDetails("ORD-123");
paymentService.processPayment();
// 강한 결합 + 클라이언트 복잡성 폭발
}
}
Facade로 단순화
// APIGateway (Facade) — 여러 서비스를 하나의 인터페이스로 통합
public class APIGateway {
// 내부 서비스들은 클라이언트에게 숨겨짐
private UserService userService;
private OrderService orderService;
private PaymentService paymentService;
public APIGateway() {
this.userService = new UserService();
this.orderService = new OrderService();
this.paymentService = new PaymentService();
}
// 클라이언트를 위한 단순화된 메서드
public String getFullOrderDetails(String userId, String orderId, String paymentId) {
System.out.println("=== Processing order for user " + userId + " ===");
userService.getUserDetails();
orderService.getOrderDetails(orderId);
paymentService.processPayment();
return "Order details for " + orderId;
}
}
// 단순화된 클라이언트
public class CleanClient {
public static void main(String[] args) {
APIGateway apiGateway = new APIGateway();
// 단 한 번의 메서드 호출로 복잡한 작업 수행
String details = apiGateway.getFullOrderDetails("USER-1", "ORD-123", "PAY-456");
// 1. 클라이언트 코드 단순
// 2. 내부 서비스 구조 변경에 영향 없음
// 3. 새 서비스 추가 시 클라이언트 수정 불필요
// 4. 원격 호출이라면 네트워크 호출 횟수도 감소
}
}
Facade vs Adapter
| 항목 | Facade | Adapter |
|---|---|---|
| 목적 | 복잡성 숨기기, 단순화 | 인터페이스 호환성 해결 |
| 인터페이스 | 새 단순한 인터페이스 정의 | 기존 인터페이스에 맞춰 변환 |
| 대상 | 여러 서브시스템 | 하나의 incompatible 클래스 |
| 비유 | 안내 데스크 | 220V 변환 어댑터 |
여기서 정말 중요한 시험 함정 — Facade가 너무 많은 일을 떠맡으면 "God Object" 안티패턴이 됩니다. Facade는 단순히 여러 서비스의 호출을 조율하는 역할만 해야 해요. 비즈니스 로직은 Facade가 아니라 하위 서비스 클래스에 있어야 합니다. "Facade가 1000줄짜리가 됐다" 싶으면 그건 Facade가 God Object로 변질된 신호예요.
> 한 줄 정리 — Facade는 "복잡한 내부를 숨기고 단순한 창구만 제공"하는 패턴. API Gateway가 대표 예.
Flyweight — 공통 부품 공유
"수많은 유사 객체들의 메모리 사용량을 최적화한다."
회사 비유로 — 공통 부품 공유예요. 슈팅 게임에서 화면에 총알 10만 개가 날아다닌다고 생각해 봅시다. 각 총알마다 이미지(5KB)를 따로 저장하면 500MB 메모리가 필요해요. 그런데 사실 같은 색깔 총알들은 이미지가 똑같죠. 같은 이미지를 한 번만 메모리에 두고 모든 총알이 그걸 참조하면 메모리가 확 줄어요.
핵심은 객체 상태를 두 가지로 분리하는 거예요.
- 내재적(Intrinsic) 상태 — 여러 객체가 공유 가능한 공통 상태 (총알의 이미지·색상)
- 외재적(Extrinsic) 상태 — 각 객체마다 고유한 상태 (총알의 위치·속도)
Flyweight 없을 때 — 메모리 폭발
// 문제 — 총알마다 이미지(5KB)를 저장하면 10만 개 = 500MB
public class Bullet {
private String color; // 내재적: 공유 가능
private int x; // 외재적: 각 총알 고유
private int y; // 외재적: 각 총알 고유
private int velocity; // 외재적: 각 총알 고유
// private Image image; // 5KB 이미지 — 공유 가능한데 각각 저장!
public Bullet(String color, int x, int y, int velocity) {
this.color = color;
this.x = x;
this.y = y;
this.velocity = velocity;
// 이미지를 여기서 로드하면 10만 개 * 5KB = 500MB
}
}
Flyweight 패턴 구현
// 플라이웨이트 객체 — 내재적 상태만 (공유)
public class BulletType {
private final String color; // 내재적 (공유)
// private final Image image; // 내재적 (공유)
public BulletType(String color) {
this.color = color;
System.out.println("Creating BulletType for color: " + color);
// 이미지 로딩도 여기서 한 번만
}
public String getColor() { return color; }
}
// 플라이웨이트 팩토리 — 중복 생성 방지
public class BulletTypeFactory {
private static Map<String, BulletType> bulletTypes = new HashMap<>();
public static BulletType getBulletType(String color) {
// 이미 있으면 재사용
if (!bulletTypes.containsKey(color)) {
bulletTypes.put(color, new BulletType(color));
}
return bulletTypes.get(color); // 기존 객체 반환 (공유)
}
public static int getCreatedTypesCount() {
return bulletTypes.size();
}
}
// 총알 — 외재적 상태만 저장, 내재적은 BulletType 참조
public class Bullet {
// 외재적
private int x;
private int y;
private int velocity;
// 내재적 — BulletType 객체를 참조 (공유)
private BulletType type;
public Bullet(int x, int y, int velocity, String color) {
this.x = x;
this.y = y;
this.velocity = velocity;
// 직접 new BulletType()을 안 함 — 팩토리에 요청
this.type = BulletTypeFactory.getBulletType(color);
}
public void display() {
System.out.println("Bullet at (" + x + "," + y +
") velocity=" + velocity +
" color=" + type.getColor());
}
}
// 클라이언트
public class Game {
public static void main(String[] args) {
List<Bullet> bullets = new ArrayList<>();
for (int i = 0; i < 5; i++) {
bullets.add(new Bullet(i * 10, i * 12, 5, "red"));
}
for (int i = 0; i < 5; i++) {
bullets.add(new Bullet(i * 10, i * 12, 7, "green"));
}
// 10개 총알을 만들었지만 BulletType은 2개만 생성됨!
System.out.println("BulletType objects created: " +
BulletTypeFactory.getCreatedTypesCount()); // 2
}
}
메모리 절약 효과
| 방식 | 총알 수 | 단위 메모리 | 총 메모리 |
|---|---|---|---|
| Flyweight 없음 | 100,000 | 5KB | 500MB |
| Flyweight 적용 | 100,000 | 8B (위치) + 5KB × 2 (공유) | 약 0.8MB |
| 절약 | 약 600배 감소 |
여기서 정말 중요한 시험 함정 — 공유되는 내재적 상태는 반드시 불변(immutable)이어야 합니다. 한 BulletType의 색을 바꾸면 그걸 공유하는 10만 개 총알이 모두 영향을 받아요. final 키워드 + setter 제거로 불변성을 강제해야 합니다.
// 올바른 BulletType — 불변
public class BulletType {
private final String color; // final → 변경 불가
// setter 없음
public BulletType(String color) {
this.color = color;
}
public String getColor() { return color; }
// 절대로 setColor() 추가 X
}
지도 앱의 같은 모양 아이콘 수천 개, 텍스트 에디터의 같은 폰트 글자 수천 개 — 모두 Flyweight 패턴으로 메모리를 절약합니다.
> 한 줄 정리 — Flyweight는 "내재적 상태를 한 번만 저장하고 공유"하는 메모리 최적화 패턴. 불변성이 필수.
여섯 가지 구조 패턴 종합 비교표
여섯 가지를 한 표로 정리하면 이래요. 시험 직전·코드 리뷰 직전에 이 표만 다시 봐도 머리에서 정리됩니다.
| 패턴 | 목적 | 핵심 아이디어 | 대표 예시 | 관련 원칙 |
|---|---|---|---|---|
| Adapter | 인터페이스 호환성 | Wrapping으로 변환 | 외부 라이브러리 통합 | OCP |
| Decorator | 런타임 기능 추가 | 객체 중첩 wrapping | 피자 토핑·Java I/O | OCP·SRP |
| Proxy | 접근 제어·지연 로딩 | 대리 객체 제공 | 이미지 지연 로딩 | SRP |
| Composite | 개별/복합 균일 처리 | 트리 + 공통 인터페이스 | 파일 시스템 | OCP |
| Facade | 복잡성 숨기기 | 단순 인터페이스 제공 | API Gateway | DIP |
| Flyweight | 메모리 최적화 | 공유 상태 분리 | 게임 총알·지도 아이콘 | - |
여섯 가지 패턴은 어떻게 연결되나
서로 따로 노는 게 아니라 의외의 곳에서 만나요.
- Adapter + Decorator — 둘 다 wrapping이지만 목적이 다름. Adapter는 인터페이스 변환, Decorator는 기능 추가
- Proxy + Decorator — 둘 다 wrapping이지만 목적이 다름. Proxy는 접근 제어, Decorator는 기능 추가
- Composite + Iterator — 트리 구조 위에 Iterator로 순회 (4편에서 나옵니다)
- Facade + Singleton — Facade를 싱글톤으로 만드는 경우 흔함
- Flyweight + Factory — Flyweight 객체 관리에 팩토리가 필수 (위 BulletTypeFactory)
> 한 줄 정리 — 여섯 가지 구조 패턴은 "객체를 어떻게 조합해 더 큰 구조를 만드느냐"의 답. 자주 조합돼서 같이 나타난다.
구조 패턴에서 자주 빠지는 함정 6가지
1. Adapter와 단순 Wrapper 혼동
모든 wrapping이 Adapter는 아니에요. Adapter의 핵심은 인터페이스 변환입니다. 인터페이스가 호환되는데 기능만 추가하고 싶다면 Decorator가 정답.
2. Decorator의 무리한 중첩
토핑이 너무 많이 쌓이면 디버깅이 어려워져요. 또 데코레이터의 순서가 결과에 영향을 줄 수 있으니 호출 순서를 신중히 설계해야 합니다.
3. Proxy의 멀티스레드 함정
Lazy Loading을 쓸 때 if (instance == null) 체크가 스레드 안전하지 않아요. synchronized 또는 Double-Checked Locking + volatile 적용. 싱글톤에서 본 그 함정이 그대로 재현됩니다.
4. Composite의 깊은 재귀
트리가 너무 깊으면 재귀 호출이 스택 오버플로우를 일으켜요. 깊이가 예측 불가능하다면 반복문 기반 순회로 다시 짜야 합니다.
5. Facade가 God Object로 변질
Facade가 너무 많은 일을 직접 처리하면 모든 비즈니스 로직이 그 안에 모여요. Facade는 라우팅·조율만, 비즈니스 로직은 하위 서비스에. 1000줄짜리 Facade는 분리 신호.
6. Flyweight 공유 상태 변경
내재적 상태를 변경하면 그걸 공유하는 모든 객체가 영향을 받아요. final + setter 제거로 불변성 강제. 외재적 상태와 내재적 상태의 구분을 명확히.
구조 패턴 코드 리뷰 체크리스트 — 실전용
Adapter 신호
- 외부 라이브러리·레거시 시스템의 인터페이스가 우리 코드와 안 맞는가?
- 호환을 위해 클라이언트 코드를 수정해야 하는 상황인가?
- 인터페이스 변환만 필요하고 기능 추가는 아닌가?
Decorator 신호
- 기능 조합으로 클래스가 폭발적으로 늘어나는가? (2^N)
- 런타임에 기능을 동적으로 추가해야 하는가?
- 기존 클래스를 수정하지 않고 기능을 더하고 싶은가?
Proxy 신호
- 객체 생성 비용이 큰데 항상 필요한 건 아닌가? (Lazy Loading)
- 객체 접근에 보안·권한 체크가 필요한가?
- 메서드 호출을 로깅·모니터링해야 하는가?
Composite 신호
- 트리 구조를 다루는가? (파일 시스템·조직도·UI)
- 개별 객체와 복합 객체를 동일하게 처리하고 싶은가?
- 재귀적 연산이 필요한가?
Facade 신호
- 클라이언트가 여러 서브시스템을 직접 호출해야 하는가?
- 서브시스템 간 호출 순서·의존성이 복잡한가?
- 외부에 단순한 단일 진입점을 제공하고 싶은가?
Flyweight 신호
- 같은 모양의 객체를 수천·수만 개 생성하는가?
- 메모리 사용량이 문제가 되는가?
- 객체 상태를 공유 가능한 부분과 고유한 부분으로 나눌 수 있는가?
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 구조 패턴 3편의 핵심입니다. 시험 직전·코드 리뷰 직전에 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- 구조 패턴 6가지 — Adapter · Decorator · Proxy · Composite · Facade · Flyweight
- 모두 "객체를 어떻게 조합해 큰 구조를 만드느냐"에 관한 답
- Adapter — 안 맞는 인터페이스를 끼워 맞춤. 220V·110V 변환 어댑터 비유
- Adapter ≠ 단순 Wrapper — 인터페이스 변환이 핵심
- Decorator — 런타임에 기능 한 겹씩 추가. 옷 한 겹 더 입기 비유
- Decorator는 OCP를 가장 깔끔하게 푸는 패턴. Java I/O 클래스가 대표 예
- 상속만 쓰면 조합으로 클래스 폭발 (2^N) — Decorator 신호
- Proxy — 실제 객체 앞에 비서. 사장실 비서 비유
- Proxy 4대 용도 — 지연 로딩·캐싱·접근 제어·로깅
- Proxy vs Decorator — 둘 다 wrapping. 목적이 다름 (접근 제어 vs 기능 추가)
- Composite — 폴더와 파일을 같은 인터페이스로. 트리 구조 + 공통 인터페이스
- Composite의 깊은 재귀는 스택 오버플로우 위험 — 반복문 순회 고려
- Facade — 복잡한 내부 숨기고 단순 창구만. 안내 데스크 비유
- Facade의 대표 실제 사례 — API Gateway (마이크로서비스 아키텍처)
- Facade가 비즈니스 로직까지 흡수하면 God Object 안티패턴
- Flyweight — 공통 상태 공유로 메모리 절약. 게임 총알·지도 아이콘 비유
- Flyweight 핵심 — 내재적(공유) vs 외재적(고유) 상태 분리
- 내재적 상태는 반드시 불변(
final+ setter 제거) — 공유라서 변경 시 다 영향받음 - 6가지 패턴은 자주 조합 — Flyweight + Factory, Composite + Iterator 등
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — SOLID 원칙
- 2편 — 생성 패턴 (Singleton·Factory·Builder 등)
- 3편 — 구조 패턴 (Adapter·Decorator·Proxy 등) (현재 글)
- 4편 — 행위 패턴 (Observer·Strategy·Command 등)
- 5편 — OOP 원칙
- 6편 — 패턴 조합 실전 프로젝트 (완)