Java 디자인 패턴 완벽 가이드: 실무에서 자주 쓰이는 핵심 패턴 총정리
1. 디자인 패턴이란?
디자인 패턴(Design Pattern)은 소프트웨어 설계 과정에서 반복적으로 발생하는 문제에 대한 검증된 해결책이다. 1994년 GoF(Gang of Four)가 저서 Design Patterns: Elements of Reusable Object-Oriented Software에서 23가지 패턴을 체계적으로 정리한 이후, 객체지향 프로그래밍의 핵심 교양으로 자리잡았다.
디자인 패턴을 단순히 “코드 템플릿”으로 이해하면 안 된다. 패턴은 특정 맥락에서 발생하는 설계 문제를 해결하기 위한 구조적 접근법이다. 따라서 패턴을 적용하기 전에 반드시 “이 문제에 이 패턴이 적합한가?”를 먼저 고민해야 한다.
SOLID 원칙과 디자인 패턴의 관계
디자인 패턴은 SOLID 원칙을 실현하는 구체적인 방법론이라고 볼 수 있다. SOLID 원칙 각각이 어떤 패턴과 연결되는지 살펴보자.
- SRP(Single Responsibility Principle, 단일 책임 원칙): 하나의 클래스는 하나의 책임만 가져야 한다. Facade 패턴은 복잡한 서브시스템의 인터페이스를 단순화하여 각 클래스의 책임을 명확히 분리한다.
- OCP(Open-Closed Principle, 개방-폐쇄 원칙): 확장에는 열려 있고 수정에는 닫혀 있어야 한다. Strategy 패턴과 Template Method 패턴은 기존 코드를 수정하지 않고 새로운 행위를 추가할 수 있게 해준다.
- LSP(Liskov Substitution Principle, 리스코프 치환 원칙): 하위 타입은 상위 타입을 대체할 수 있어야 한다. Factory Method 패턴에서 반환되는 객체는 상위 타입으로 참조되므로 LSP를 자연스럽게 준수한다.
- ISP(Interface Segregation Principle, 인터페이스 분리 원칙): 클라이언트가 사용하지 않는 메서드에 의존하지 않아야 한다. Adapter 패턴은 불필요한 인터페이스를 감추고 필요한 부분만 노출한다.
- DIP(Dependency Inversion Principle, 의존 역전 원칙): 고수준 모듈이 저수준 모듈에 의존하지 않고, 둘 다 추상화에 의존해야 한다. Observer 패턴과 Strategy 패턴은 인터페이스를 통한 의존성 역전을 구현한다.
이처럼 디자인 패턴은 SOLID 원칙이라는 추상적인 가이드라인을 코드 레벨에서 실현하는 도구다. 원칙을 이해하면 패턴의 존재 이유를 깊이 이해할 수 있고, 패턴을 학습하면 원칙을 자연스럽게 체득할 수 있다.
2. 생성 패턴 (Creational Patterns)
생성 패턴은 객체 생성 메커니즘을 다루는 패턴으로, 객체 생성 과정을 캡슐화하여 유연성과 재사용성을 높인다.
2.1 Singleton 패턴
Singleton 패턴은 클래스의 인스턴스가 오직 하나만 존재하도록 보장하고, 전역적인 접근점을 제공하는 패턴이다. 설정 관리, 커넥션 풀, 캐시 등에서 활용된다.
방법 1: synchronized 키워드
가장 단순한 Thread-safe 구현이지만, 매번 동기화 비용이 발생하는 단점이 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SingletonSync {
private static SingletonSync instance;
private SingletonSync() {
// private 생성자로 외부 생성 차단
}
public static synchronized SingletonSync getInstance() {
if (instance == null) {
instance = new SingletonSync();
}
return instance;
}
}
이 방식은 getInstance()가 호출될 때마다 synchronized 블록에 진입해야 하므로 성능 저하가 발생한다. 인스턴스가 이미 생성된 이후에도 불필요하게 동기화가 수행되기 때문이다.
방법 2: Double-Checked Locking (DCL)
동기화 비용을 줄이기 위해 인스턴스가 null인 경우에만 synchronized 블록에 진입한다. volatile 키워드가 필수인데, 이는 명령어 재정렬(instruction reordering)로 인해 초기화가 완료되지 않은 인스턴스를 다른 스레드가 참조하는 문제를 방지하기 위함이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class SingletonDCL {
private static volatile SingletonDCL instance;
private SingletonDCL() {}
public static SingletonDCL getInstance() {
if (instance == null) { // 1차 검사 (lock 없이)
synchronized (SingletonDCL.class) {
if (instance == null) { // 2차 검사 (lock 획득 후)
instance = new SingletonDCL();
}
}
}
return instance;
}
}
volatile이 없으면 JVM의 메모리 모델상 객체 초기화 순서가 보장되지 않아, 다른 스레드가 반만 초기화된 객체를 볼 수 있다. 이는 매우 찾기 어려운 동시성 버그를 유발한다.
방법 3: Bill Pugh Singleton (정적 내부 클래스)
가장 권장되는 방식이다. JVM의 클래스 로딩 메커니즘을 활용하여 lazy initialization과 thread safety를 동시에 달성한다.
1
2
3
4
5
6
7
8
9
10
11
public class SingletonBillPugh {
private SingletonBillPugh() {}
private static class SingletonHolder {
private static final SingletonBillPugh INSTANCE = new SingletonBillPugh();
}
public static SingletonBillPugh getInstance() {
return SingletonHolder.INSTANCE;
}
}
SingletonHolder 클래스는 getInstance()가 최초로 호출될 때 비로소 JVM에 의해 로드된다. JVM은 클래스 로딩 시점에 Thread-safe하게 static 필드를 초기화하므로, 별도의 동기화 코드가 필요 없다. 이 방식이 DCL보다 간결하고 안전하며, 성능 오버헤드도 없다.
방법 4: enum Singleton
Joshua Bloch가 Effective Java에서 추천한 방식으로, Reflection 공격과 직렬화 문제까지 자동으로 방어된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public enum SingletonEnum {
INSTANCE;
private int count = 0;
public void doSomething() {
count++;
System.out.println("작업 수행 횟수: " + count);
}
}
// 사용법
// SingletonEnum.INSTANCE.doSomething();
enum은 JVM이 인스턴스 생성을 보장하므로 리플렉션으로 새 인스턴스를 만들 수 없고, Serializable을 구현해도 역직렬화 시 새 인스턴스가 만들어지지 않는다. 단, 상속이 불가능하고 lazy initialization이 되지 않는다는 제약이 있다.
실무에서의 Singleton 사용 주의점
Singleton은 강력하지만 남용하면 안 된다. 전역 상태를 만들기 때문에 테스트가 어려워지고, 클래스 간 결합도가 높아질 수 있다. Spring Framework를 사용한다면 직접 Singleton을 구현하기보다 Spring Bean의 기본 스코프(singleton)를 활용하는 것이 바람직하다. Spring IoC 컨테이너가 Bean의 생명주기를 관리해주므로, Singleton 구현의 복잡성을 프레임워크에 위임할 수 있다.
2.2 Factory Method 패턴
Factory Method 패턴은 객체 생성 로직을 서브클래스에 위임하여, 생성할 객체의 타입을 런타임에 결정할 수 있게 하는 패턴이다. new 키워드를 직접 사용하는 대신 팩토리 메서드를 통해 객체를 생성하므로, 클라이언트 코드가 구체 클래스에 의존하지 않게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 제품 인터페이스
public interface Notification {
void send(String message);
}
// 구체 제품 클래스들
public class EmailNotification implements Notification {
@Override
public void send(String message) {
System.out.println("[EMAIL] " + message);
}
}
public class SmsNotification implements Notification {
@Override
public void send(String message) {
System.out.println("[SMS] " + message);
}
}
public class PushNotification implements Notification {
@Override
public void send(String message) {
System.out.println("[PUSH] " + message);
}
}
// 팩토리 클래스
public class NotificationFactory {
public Notification createNotification(String type) {
return switch (type.toUpperCase()) {
case "EMAIL" -> new EmailNotification();
case "SMS" -> new SmsNotification();
case "PUSH" -> new PushNotification();
default -> throw new IllegalArgumentException("알 수 없는 알림 타입: " + type);
};
}
}
// 클라이언트 코드
public class NotificationService {
private final NotificationFactory factory = new NotificationFactory();
public void notify(String type, String message) {
Notification notification = factory.createNotification(type);
notification.send(message);
}
}
이 패턴의 핵심은 클라이언트 코드(NotificationService)가 구체적인 알림 클래스를 알 필요가 없다는 것이다. 새로운 알림 타입(예: Slack 알림)이 추가되더라도 팩토리 클래스만 수정하면 되므로 OCP를 만족한다.
2.3 Abstract Factory 패턴
Abstract Factory 패턴은 관련된 객체군을 일관되게 생성할 수 있는 인터페이스를 제공하는 패턴이다. Factory Method가 하나의 제품을 생성한다면, Abstract Factory는 관련된 제품 가족(family) 전체를 생성한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 추상 제품 인터페이스들
public interface Button {
void render();
}
public interface TextField {
void render();
}
// Windows 스타일 구현
public class WindowsButton implements Button {
@Override
public void render() {
System.out.println("Windows 스타일 버튼 렌더링");
}
}
public class WindowsTextField implements TextField {
@Override
public void render() {
System.out.println("Windows 스타일 텍스트필드 렌더링");
}
}
// Mac 스타일 구현
public class MacButton implements Button {
@Override
public void render() {
System.out.println("Mac 스타일 버튼 렌더링");
}
}
public class MacTextField implements TextField {
@Override
public void render() {
System.out.println("Mac 스타일 텍스트필드 렌더링");
}
}
// 추상 팩토리
public interface UIFactory {
Button createButton();
TextField createTextField();
}
// 구체 팩토리
public class WindowsUIFactory implements UIFactory {
@Override
public Button createButton() { return new WindowsButton(); }
@Override
public TextField createTextField() { return new WindowsTextField(); }
}
public class MacUIFactory implements UIFactory {
@Override
public Button createButton() { return new MacButton(); }
@Override
public TextField createTextField() { return new MacTextField(); }
}
// 클라이언트
public class Application {
private final Button button;
private final TextField textField;
public Application(UIFactory factory) {
this.button = factory.createButton();
this.textField = factory.createTextField();
}
public void renderUI() {
button.render();
textField.render();
}
}
Abstract Factory를 사용하면, OS에 따라 팩토리만 교체하면 UI 전체가 일관된 스타일로 생성된다. 실무에서는 데이터베이스 드라이버 선택, 테마 시스템, 크로스 플랫폼 라이브러리 등에서 자주 활용된다.
2.4 Builder 패턴
Builder 패턴은 복잡한 객체의 생성 과정을 단계별로 분리하여, 동일한 생성 과정에서 서로 다른 표현을 만들 수 있게 하는 패턴이다. 특히 생성자 매개변수가 많거나, 선택적 매개변수가 많은 경우에 효과적이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public class HttpRequest {
private final String url;
private final String method;
private final Map<String, String> headers;
private final String body;
private final int timeout;
private final boolean followRedirects;
private HttpRequest(Builder builder) {
this.url = builder.url;
this.method = builder.method;
this.headers = Collections.unmodifiableMap(builder.headers);
this.body = builder.body;
this.timeout = builder.timeout;
this.followRedirects = builder.followRedirects;
}
// Getter 메서드들 생략
public static class Builder {
// 필수 매개변수
private final String url;
// 선택 매개변수 (기본값 설정)
private String method = "GET";
private Map<String, String> headers = new HashMap<>();
private String body = null;
private int timeout = 30000;
private boolean followRedirects = true;
public Builder(String url) {
this.url = Objects.requireNonNull(url, "URL은 필수입니다");
}
public Builder method(String method) {
this.method = method;
return this;
}
public Builder header(String key, String value) {
this.headers.put(key, value);
return this;
}
public Builder body(String body) {
this.body = body;
return this;
}
public Builder timeout(int timeout) {
if (timeout <= 0) throw new IllegalArgumentException("타임아웃은 양수여야 합니다");
this.timeout = timeout;
return this;
}
public Builder followRedirects(boolean followRedirects) {
this.followRedirects = followRedirects;
return this;
}
public HttpRequest build() {
return new HttpRequest(this);
}
}
}
// 사용 예시
HttpRequest request = new HttpRequest.Builder("https://api.example.com/users")
.method("POST")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer token123")
.body("{\"name\": \"홍길동\"}")
.timeout(5000)
.build();
Lombok @Builder와의 비교
Lombok의 @Builder는 위와 같은 보일러플레이트 코드를 어노테이션 하나로 자동 생성해준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Builder
@Getter
public class UserDto {
private final String name;
private final String email;
@Builder.Default
private final String role = "USER";
@Builder.Default
private final boolean active = true;
}
// 사용
UserDto user = UserDto.builder()
.name("홍길동")
.email("hong@example.com")
.role("ADMIN")
.build();
Lombok @Builder는 편리하지만 한계가 있다. 첫째, 유효성 검증 로직을 추가하기 어렵다. build() 시점에 필드 간 복합 검증이 필요하다면 직접 Builder를 구현하는 것이 낫다. 둘째, 상속 구조에서의 Builder 체이닝이 자연스럽지 않다. 셋째, 컴파일 타임에 코드를 생성하므로 디버깅이 다소 불편할 수 있다. 단순한 DTO라면 Lombok을, 복잡한 도메인 객체라면 직접 구현을 권장한다.
3. 구조 패턴 (Structural Patterns)
구조 패턴은 클래스와 객체를 더 큰 구조로 조합하는 방법을 다루는 패턴이다.
3.1 Adapter 패턴
Adapter 패턴은 호환되지 않는 인터페이스를 가진 클래스들을 함께 사용할 수 있도록 변환 계층을 제공하는 패턴이다. 마치 220V 콘센트에 110V 전자제품을 연결하기 위한 변압기와 같은 역할이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 기존 시스템 (레거시 결제 모듈)
public class LegacyPaymentSystem {
public void processPayment(String accountNumber, double amount) {
System.out.println("레거시 시스템: 계좌 " + accountNumber + "에서 " + amount + "원 결제");
}
}
// 새로운 결제 인터페이스
public interface ModernPaymentGateway {
void pay(PaymentRequest request);
}
public record PaymentRequest(String userId, BigDecimal amount, String currency) {}
// Adapter: 레거시 시스템을 새로운 인터페이스로 감싸기
public class LegacyPaymentAdapter implements ModernPaymentGateway {
private final LegacyPaymentSystem legacySystem;
private final AccountRepository accountRepository;
public LegacyPaymentAdapter(LegacyPaymentSystem legacySystem,
AccountRepository accountRepository) {
this.legacySystem = legacySystem;
this.accountRepository = accountRepository;
}
@Override
public void pay(PaymentRequest request) {
// 새로운 인터페이스의 요청을 레거시 시스템에 맞게 변환
String accountNumber = accountRepository
.findAccountByUserId(request.userId())
.getAccountNumber();
double amount = request.amount().doubleValue();
legacySystem.processPayment(accountNumber, amount);
}
}
실무에서 Adapter 패턴은 외부 API 연동, 레거시 시스템 통합, 라이브러리 교체 시 가장 빈번하게 사용된다. Spring에서도 HandlerAdapter, WebMvcConfigurerAdapter(deprecated) 등에서 이 패턴을 확인할 수 있다.
3.2 Proxy 패턴
Proxy 패턴은 다른 객체에 대한 대리자 또는 대변인 역할을 하는 객체를 제공하여, 원본 객체에 대한 접근을 제어하는 패턴이다. 접근 제어, 지연 초기화, 로깅, 캐싱 등에 활용된다.
정적 Proxy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public interface UserService {
User findById(Long id);
void save(User user);
}
public class UserServiceImpl implements UserService {
@Override
public User findById(Long id) {
System.out.println("DB에서 사용자 조회: " + id);
return new User(id, "홍길동");
}
@Override
public void save(User user) {
System.out.println("DB에 사용자 저장: " + user.getName());
}
}
// 로깅 프록시
public class LoggingUserServiceProxy implements UserService {
private final UserService target;
public LoggingUserServiceProxy(UserService target) {
this.target = target;
}
@Override
public User findById(Long id) {
long start = System.currentTimeMillis();
System.out.println("[LOG] findById 호출 - id: " + id);
User result = target.findById(id);
long elapsed = System.currentTimeMillis() - start;
System.out.println("[LOG] findById 완료 - 소요시간: " + elapsed + "ms");
return result;
}
@Override
public void save(User user) {
System.out.println("[LOG] save 호출 - user: " + user.getName());
target.save(user);
System.out.println("[LOG] save 완료");
}
}
JDK Dynamic Proxy
JDK Dynamic Proxy는 런타임에 프록시 객체를 동적으로 생성한다. 인터페이스 기반으로만 동작하며, InvocationHandler를 구현하여 메서드 호출을 가로챈다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class LoggingInvocationHandler implements InvocationHandler {
private final Object target;
public LoggingInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
long start = System.currentTimeMillis();
System.out.println("[Dynamic Proxy] " + method.getName() + " 호출");
Object result = method.invoke(target, args);
long elapsed = System.currentTimeMillis() - start;
System.out.println("[Dynamic Proxy] " + method.getName() + " 완료 (" + elapsed + "ms)");
return result;
}
}
// 프록시 생성
UserService realService = new UserServiceImpl();
UserService proxyService = (UserService) Proxy.newProxyInstance(
UserService.class.getClassLoader(),
new Class[]{UserService.class},
new LoggingInvocationHandler(realService)
);
proxyService.findById(1L); // 프록시를 통한 호출
CGLIB Proxy
CGLIB은 바이트코드 조작 라이브러리로, 인터페이스 없이 클래스를 상속하여 프록시를 생성한다. Spring Framework는 인터페이스가 없는 Bean에 대해 CGLIB을 사용하여 AOP를 적용한다. Spring Boot 2.0부터는 기본적으로 CGLIB 프록시를 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CglibLoggingInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object obj, Method method, Object[] args,
MethodProxy proxy) throws Throwable {
System.out.println("[CGLIB] " + method.getName() + " 호출 시작");
Object result = proxy.invokeSuper(obj, args);
System.out.println("[CGLIB] " + method.getName() + " 호출 완료");
return result;
}
}
// CGLIB 프록시 생성
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserServiceImpl.class);
enhancer.setCallback(new CglibLoggingInterceptor());
UserServiceImpl proxy = (UserServiceImpl) enhancer.create();
proxy.findById(1L);
Spring AOP와의 연계: Spring의 @Transactional, @Cacheable, @Async 등의 어노테이션은 내부적으로 프록시 패턴을 활용한다. @Transactional이 붙은 메서드를 호출하면, 실제로는 프록시 객체가 트랜잭션을 시작하고, 원본 메서드를 실행한 뒤, 트랜잭션을 커밋하거나 롤백한다. 이것이 바로 같은 클래스 내부에서 @Transactional 메서드를 호출하면 트랜잭션이 적용되지 않는 이유이기도 하다. 내부 호출은 프록시를 거치지 않고 this를 통해 직접 호출되기 때문이다.
3.3 Decorator 패턴
Decorator 패턴은 객체에 동적으로 새로운 기능을 추가할 수 있게 하는 패턴이다. 상속 대신 조합(composition)을 사용하여 기능을 확장하므로, 런타임에 유연하게 기능을 조합할 수 있다.
Java I/O Stream이 Decorator 패턴의 대표적인 실제 사례다.
1
2
3
4
// Java I/O에서의 Decorator 패턴 활용 예시
InputStream fileStream = new FileInputStream("data.txt"); // 기본 스트림
InputStream buffered = new BufferedInputStream(fileStream); // 버퍼링 데코레이터
DataInputStream dataStream = new DataInputStream(buffered); // 데이터 타입 읽기 데코레이터
직접 Decorator 패턴을 구현해보자. 커피 주문 시스템을 예로 든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 컴포넌트 인터페이스
public interface Coffee {
String getDescription();
int getCost();
}
// 기본 구현
public class Americano implements Coffee {
@Override
public String getDescription() { return "아메리카노"; }
@Override
public int getCost() { return 4000; }
}
// 데코레이터 추상 클래스
public abstract class CoffeeDecorator implements Coffee {
protected final Coffee decoratedCoffee;
public CoffeeDecorator(Coffee coffee) {
this.decoratedCoffee = coffee;
}
@Override
public String getDescription() {
return decoratedCoffee.getDescription();
}
@Override
public int getCost() {
return decoratedCoffee.getCost();
}
}
// 구체 데코레이터들
public class ShotDecorator extends CoffeeDecorator {
public ShotDecorator(Coffee coffee) { super(coffee); }
@Override
public String getDescription() {
return decoratedCoffee.getDescription() + " + 샷추가";
}
@Override
public int getCost() {
return decoratedCoffee.getCost() + 500;
}
}
public class WhipCreamDecorator extends CoffeeDecorator {
public WhipCreamDecorator(Coffee coffee) { super(coffee); }
@Override
public String getDescription() {
return decoratedCoffee.getDescription() + " + 휘핑크림";
}
@Override
public int getCost() {
return decoratedCoffee.getCost() + 700;
}
}
// 사용 예시
Coffee order = new Americano();
order = new ShotDecorator(order); // 샷 추가
order = new WhipCreamDecorator(order); // 휘핑크림 추가
System.out.println(order.getDescription()); // 아메리카노 + 샷추가 + 휘핑크림
System.out.println(order.getCost()); // 5200
데코레이터를 자유롭게 조합할 수 있으므로, 옵션이 10가지라면 상속으로는 2^10 = 1024개의 클래스가 필요하지만, 데코레이터 패턴으로는 10개의 데코레이터 클래스만 있으면 된다.
3.4 Facade 패턴
Facade 패턴은 복잡한 서브시스템에 대한 단순화된 인터페이스를 제공하는 패턴이다. 클라이언트가 서브시스템의 내부 복잡성을 알 필요 없이, Facade를 통해 간단하게 기능을 사용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 복잡한 서브시스템 클래스들
public class InventoryService {
public boolean checkStock(String productId) {
System.out.println("재고 확인: " + productId);
return true;
}
public void reduceStock(String productId, int quantity) {
System.out.println("재고 차감: " + productId + " x " + quantity);
}
}
public class PaymentService {
public boolean processPayment(String userId, BigDecimal amount) {
System.out.println("결제 처리: " + userId + " - " + amount + "원");
return true;
}
}
public class ShippingService {
public String createShipment(String orderId, String address) {
System.out.println("배송 생성: " + orderId + " -> " + address);
return "TRACK-" + orderId;
}
}
public class NotificationService {
public void sendOrderConfirmation(String userId, String orderId) {
System.out.println("주문 확인 알림 발송: " + userId + ", 주문번호: " + orderId);
}
}
// Facade: 복잡한 주문 프로세스를 단순화
public class OrderFacade {
private final InventoryService inventoryService;
private final PaymentService paymentService;
private final ShippingService shippingService;
private final NotificationService notificationService;
public OrderFacade(InventoryService inventoryService,
PaymentService paymentService,
ShippingService shippingService,
NotificationService notificationService) {
this.inventoryService = inventoryService;
this.paymentService = paymentService;
this.shippingService = shippingService;
this.notificationService = notificationService;
}
public OrderResult placeOrder(String userId, String productId,
int quantity, String address, BigDecimal amount) {
// 1. 재고 확인
if (!inventoryService.checkStock(productId)) {
return OrderResult.failure("재고가 부족합니다");
}
// 2. 결제 처리
if (!paymentService.processPayment(userId, amount)) {
return OrderResult.failure("결제에 실패했습니다");
}
// 3. 재고 차감
inventoryService.reduceStock(productId, quantity);
// 4. 배송 생성
String trackingNumber = shippingService.createShipment(productId, address);
// 5. 알림 발송
notificationService.sendOrderConfirmation(userId, trackingNumber);
return OrderResult.success(trackingNumber);
}
}
Facade 패턴은 Spring의 Service 계층 설계와 자연스럽게 어울린다. Controller는 복잡한 비즈니스 로직을 알 필요 없이 Service(Facade)의 단순한 메서드만 호출하면 된다.
4. 행동 패턴 (Behavioral Patterns)
행동 패턴은 객체 간의 책임 분배와 알고리즘 캡슐화를 다루는 패턴이다.
4.1 Strategy 패턴
Strategy 패턴은 알고리즘을 별도의 클래스로 캡슐화하고, 런타임에 알고리즘을 교체할 수 있게 하는 패턴이다. Java의 Comparator 인터페이스가 Strategy 패턴의 대표적 사례다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 전략 인터페이스
public interface DiscountStrategy {
BigDecimal calculateDiscount(BigDecimal originalPrice);
String getDescription();
}
// 구체 전략들
public class NoDiscount implements DiscountStrategy {
@Override
public BigDecimal calculateDiscount(BigDecimal originalPrice) {
return originalPrice;
}
@Override
public String getDescription() { return "할인 없음"; }
}
public class PercentageDiscount implements DiscountStrategy {
private final int percentage;
public PercentageDiscount(int percentage) {
this.percentage = percentage;
}
@Override
public BigDecimal calculateDiscount(BigDecimal originalPrice) {
BigDecimal multiplier = BigDecimal.valueOf(100 - percentage)
.divide(BigDecimal.valueOf(100));
return originalPrice.multiply(multiplier);
}
@Override
public String getDescription() { return percentage + "% 할인"; }
}
public class FixedAmountDiscount implements DiscountStrategy {
private final BigDecimal discountAmount;
public FixedAmountDiscount(BigDecimal discountAmount) {
this.discountAmount = discountAmount;
}
@Override
public BigDecimal calculateDiscount(BigDecimal originalPrice) {
return originalPrice.subtract(discountAmount).max(BigDecimal.ZERO);
}
@Override
public String getDescription() { return discountAmount + "원 할인"; }
}
// 컨텍스트 클래스
public class PriceCalculator {
private DiscountStrategy strategy;
public PriceCalculator(DiscountStrategy strategy) {
this.strategy = strategy;
}
public void setStrategy(DiscountStrategy strategy) {
this.strategy = strategy;
}
public BigDecimal calculate(BigDecimal price) {
System.out.println("적용 정책: " + strategy.getDescription());
return strategy.calculateDiscount(price);
}
}
// 사용 예시
PriceCalculator calculator = new PriceCalculator(new NoDiscount());
BigDecimal price = BigDecimal.valueOf(50000);
calculator.setStrategy(new PercentageDiscount(20));
System.out.println("20% 할인가: " + calculator.calculate(price)); // 40000
calculator.setStrategy(new FixedAmountDiscount(BigDecimal.valueOf(7000)));
System.out.println("7000원 할인가: " + calculator.calculate(price)); // 43000
Java Comparator와 Strategy 패턴
Comparator는 Strategy 패턴의 가장 일상적인 예시다. 정렬 알고리즘(컨텍스트)은 변하지 않고, 비교 전략만 교체한다.
1
2
3
4
5
6
7
8
9
10
11
List<Product> products = getProducts();
// 가격순 전략
products.sort(Comparator.comparing(Product::getPrice));
// 이름순 전략
products.sort(Comparator.comparing(Product::getName));
// 복합 전략: 카테고리순 -> 가격순
products.sort(Comparator.comparing(Product::getCategory)
.thenComparing(Product::getPrice));
4.2 Observer 패턴
Observer 패턴은 객체의 상태 변화를 관찰하는 옵저버 목록을 관리하고, 상태가 변하면 모든 옵저버에게 자동으로 통지하는 패턴이다. 이벤트 기반 시스템의 기초이며, 발행-구독(Pub-Sub) 패턴의 근간이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 이벤트 정의
public class OrderEvent {
private final String orderId;
private final String eventType;
private final LocalDateTime timestamp;
public OrderEvent(String orderId, String eventType) {
this.orderId = orderId;
this.eventType = eventType;
this.timestamp = LocalDateTime.now();
}
// getter 생략
public String getOrderId() { return orderId; }
public String getEventType() { return eventType; }
public LocalDateTime getTimestamp() { return timestamp; }
}
// 옵저버 인터페이스
public interface OrderEventListener {
void onEvent(OrderEvent event);
}
// 구체 옵저버들
public class EmailNotifier implements OrderEventListener {
@Override
public void onEvent(OrderEvent event) {
System.out.println("[이메일] 주문 " + event.getOrderId()
+ " - " + event.getEventType() + " 알림 발송");
}
}
public class InventoryUpdater implements OrderEventListener {
@Override
public void onEvent(OrderEvent event) {
if ("ORDER_PLACED".equals(event.getEventType())) {
System.out.println("[재고] 주문 " + event.getOrderId() + " 재고 차감 처리");
}
}
}
public class AnalyticsTracker implements OrderEventListener {
@Override
public void onEvent(OrderEvent event) {
System.out.println("[분석] 이벤트 기록 - " + event.getEventType()
+ " at " + event.getTimestamp());
}
}
// Subject (발행자)
public class OrderEventPublisher {
private final List<OrderEventListener> listeners = new ArrayList<>();
public void subscribe(OrderEventListener listener) {
listeners.add(listener);
}
public void unsubscribe(OrderEventListener listener) {
listeners.remove(listener);
}
public void publish(OrderEvent event) {
for (OrderEventListener listener : listeners) {
listener.onEvent(event);
}
}
}
Spring의 ApplicationEvent 활용
Spring에서는 Observer 패턴을 ApplicationEvent와 @EventListener를 통해 더욱 깔끔하게 구현할 수 있다. 이 방식은 컴포넌트 간 결합도를 극도로 낮춰준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 이벤트 정의
public class OrderPlacedEvent extends ApplicationEvent {
private final String orderId;
private final String userId;
public OrderPlacedEvent(Object source, String orderId, String userId) {
super(source);
this.orderId = orderId;
this.userId = userId;
}
public String getOrderId() { return orderId; }
public String getUserId() { return userId; }
}
// 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void placeOrder(OrderRequest request) {
// 주문 저장 로직...
String orderId = saveOrder(request);
// 이벤트 발행 - 주문 서비스는 후속 처리를 알 필요 없다
eventPublisher.publishEvent(
new OrderPlacedEvent(this, orderId, request.getUserId())
);
}
}
// 이벤트 리스너들 (각각 독립적으로 동작)
@Component
public class OrderEventHandlers {
@EventListener
public void handleEmailNotification(OrderPlacedEvent event) {
System.out.println("주문 확인 이메일 발송: " + event.getUserId());
}
@Async
@EventListener
public void handleAnalytics(OrderPlacedEvent event) {
System.out.println("주문 분석 데이터 기록: " + event.getOrderId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleAfterCommit(OrderPlacedEvent event) {
System.out.println("트랜잭션 커밋 후 처리: " + event.getOrderId());
}
}
@TransactionalEventListener는 트랜잭션이 성공적으로 커밋된 후에만 이벤트를 처리하므로, 데이터 일관성을 보장하면서 이벤트 기반 아키텍처를 구현할 수 있다. 실무에서 매우 유용한 기능이다.
4.3 Template Method 패턴
Template Method 패턴은 알고리즘의 골격을 상위 클래스에서 정의하고, 세부 단계를 하위 클래스에서 구현하게 하는 패턴이다. “변하지 않는 부분은 상위 클래스에, 변하는 부분은 하위 클래스에” 두는 원칙을 따른다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 템플릿 메서드를 가진 추상 클래스
public abstract class DataExporter {
// 템플릿 메서드 - 알고리즘의 골격 정의
public final void export(String query) {
List<Map<String, Object>> data = fetchData(query);
List<Map<String, Object>> processedData = processData(data);
String formatted = formatData(processedData);
writeOutput(formatted);
System.out.println("데이터 내보내기 완료. 총 " + processedData.size() + "건");
}
// 공통 단계 (이미 구현됨)
protected List<Map<String, Object>> fetchData(String query) {
System.out.println("데이터 조회: " + query);
// DB 조회 로직
return List.of(Map.of("id", 1, "name", "데이터1"));
}
// Hook 메서드 - 하위 클래스에서 선택적으로 오버라이드
protected List<Map<String, Object>> processData(List<Map<String, Object>> data) {
return data; // 기본적으로는 그대로 반환
}
// 추상 메서드 - 하위 클래스에서 반드시 구현
protected abstract String formatData(List<Map<String, Object>> data);
protected abstract void writeOutput(String formattedData);
}
// CSV 내보내기
public class CsvExporter extends DataExporter {
@Override
protected String formatData(List<Map<String, Object>> data) {
StringBuilder sb = new StringBuilder();
// 헤더 추가
if (!data.isEmpty()) {
sb.append(String.join(",", data.get(0).keySet())).append("\n");
}
// 데이터 행 추가
for (Map<String, Object> row : data) {
sb.append(row.values().stream()
.map(Object::toString)
.collect(Collectors.joining(","))).append("\n");
}
return sb.toString();
}
@Override
protected void writeOutput(String formattedData) {
System.out.println("CSV 파일 저장:\n" + formattedData);
}
}
// JSON 내보내기
public class JsonExporter extends DataExporter {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected String formatData(List<Map<String, Object>> data) {
try {
return objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(data);
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON 변환 실패", e);
}
}
@Override
protected void writeOutput(String formattedData) {
System.out.println("JSON 파일 저장:\n" + formattedData);
}
}
Spring의 JdbcTemplate, RestTemplate, TransactionTemplate 등이 이 패턴의 이름에서 유래했다. 이들은 공통적인 보일러플레이트(커넥션 획득, 예외 처리, 리소스 반환 등)를 프레임워크가 처리하고, 실제 비즈니스 로직만 개발자가 콜백으로 제공하도록 설계되어 있다.
4.4 Command 패턴
Command 패턴은 요청 자체를 객체로 캡슐화하여, 요청의 매개변수화, 큐잉, 로깅, 실행 취소(undo) 등을 지원하는 패턴이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// Command 인터페이스
public interface Command {
void execute();
void undo();
}
// 수신자 (Receiver)
public class TextEditor {
private final StringBuilder content = new StringBuilder();
public void insert(int position, String text) {
content.insert(position, text);
}
public void delete(int position, int length) {
content.delete(position, position + length);
}
public String getContent() {
return content.toString();
}
}
// 구체 커맨드: 텍스트 삽입
public class InsertTextCommand implements Command {
private final TextEditor editor;
private final int position;
private final String text;
public InsertTextCommand(TextEditor editor, int position, String text) {
this.editor = editor;
this.position = position;
this.text = text;
}
@Override
public void execute() {
editor.insert(position, text);
}
@Override
public void undo() {
editor.delete(position, text.length());
}
}
// 구체 커맨드: 텍스트 삭제
public class DeleteTextCommand implements Command {
private final TextEditor editor;
private final int position;
private final int length;
private String deletedText;
public DeleteTextCommand(TextEditor editor, int position, int length) {
this.editor = editor;
this.position = position;
this.length = length;
}
@Override
public void execute() {
deletedText = editor.getContent().substring(position, position + length);
editor.delete(position, length);
}
@Override
public void undo() {
editor.insert(position, deletedText);
}
}
// Invoker: 커맨드 히스토리 관리
public class CommandManager {
private final Deque<Command> history = new ArrayDeque<>();
private final Deque<Command> redoStack = new ArrayDeque<>();
public void executeCommand(Command command) {
command.execute();
history.push(command);
redoStack.clear(); // 새 명령 실행 시 redo 스택 초기화
}
public void undo() {
if (!history.isEmpty()) {
Command command = history.pop();
command.undo();
redoStack.push(command);
}
}
public void redo() {
if (!redoStack.isEmpty()) {
Command command = redoStack.pop();
command.execute();
history.push(command);
}
}
}
// 사용 예시
TextEditor editor = new TextEditor();
CommandManager manager = new CommandManager();
manager.executeCommand(new InsertTextCommand(editor, 0, "Hello "));
manager.executeCommand(new InsertTextCommand(editor, 6, "World!"));
System.out.println(editor.getContent()); // "Hello World!"
manager.undo();
System.out.println(editor.getContent()); // "Hello "
manager.redo();
System.out.println(editor.getContent()); // "Hello World!"
Command 패턴은 실무에서 작업 큐(Task Queue), 매크로 기록, 트랜잭션 관리, 감사 로그(audit log) 등에 활용된다.
5. Spring Framework에서의 디자인 패턴 활용 사례 총정리
Spring Framework은 수많은 디자인 패턴을 내부적으로 활용하고 있다. 이를 이해하면 Spring의 동작 원리를 깊이 파악할 수 있다.
| 디자인 패턴 | Spring에서의 활용 | 설명 |
|---|---|---|
| Singleton | Bean Scope (기본값) | 모든 Spring Bean은 기본적으로 Singleton으로 관리된다 |
| Factory Method | BeanFactory, FactoryBean |
Bean 생성 로직을 팩토리에 위임한다 |
| Abstract Factory | FactoryBean 인터페이스 |
복잡한 Bean 생성 과정을 추상화한다 |
| Builder | UriComponentsBuilder, MockMvcRequestBuilders |
복잡한 객체를 단계별로 생성한다 |
| Proxy | Spring AOP, @Transactional |
CGLIB/JDK Dynamic Proxy로 횡단 관심사를 처리한다 |
| Decorator | BeanPostProcessor, HttpServletRequestWrapper |
기존 Bean에 기능을 동적으로 추가한다 |
| Adapter | HandlerAdapter, MessageListenerAdapter |
다양한 핸들러를 통일된 인터페이스로 처리한다 |
| Facade | JdbcTemplate, RestTemplate |
복잡한 JDBC/HTTP 처리를 단순화한다 |
| Strategy | Resource 인터페이스, PlatformTransactionManager |
알고리즘을 교체 가능하게 캡슐화한다 |
| Observer | ApplicationEvent, @EventListener |
이벤트 기반 통신으로 결합도를 낮춘다 |
| Template Method | JdbcTemplate, AbstractController |
공통 흐름은 고정하고 세부 로직만 위임한다 |
| Command | Spring Batch의 Tasklet |
작업 단위를 객체로 캡슐화한다 |
Spring에서 특히 중요한 패턴 조합
Spring MVC의 요청 처리 과정에서는 여러 패턴이 유기적으로 결합되어 동작한다.
- DispatcherServlet(Front Controller 패턴)이 모든 요청을 수신한다.
- HandlerMapping(Strategy 패턴)이 요청에 맞는 핸들러를 결정한다.
- HandlerAdapter(Adapter 패턴)가 핸들러를 실행한다.
- 핸들러에 적용된 AOP(Proxy 패턴)가 트랜잭션, 로깅 등을 처리한다.
- ViewResolver(Strategy 패턴)가 뷰를 결정하고 렌더링한다.
이처럼 Spring은 단일 패턴이 아니라 다양한 패턴의 조합으로 설계되어 있으며, 이것이 Spring의 유연성과 확장성의 비결이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Spring에서 Strategy 패턴 활용 예시 - 인터페이스와 구현체 교체
public interface PaymentStrategy {
PaymentResult pay(PaymentRequest request);
}
@Service("creditCardPayment")
public class CreditCardPayment implements PaymentStrategy {
@Override
public PaymentResult pay(PaymentRequest request) {
// 신용카드 결제 로직
return new PaymentResult(true, "신용카드 결제 완료");
}
}
@Service("bankTransferPayment")
public class BankTransferPayment implements PaymentStrategy {
@Override
public PaymentResult pay(PaymentRequest request) {
// 계좌이체 결제 로직
return new PaymentResult(true, "계좌이체 완료");
}
}
// Strategy를 Map으로 주입받아 런타임에 선택
@Service
@RequiredArgsConstructor
public class PaymentService {
private final Map<String, PaymentStrategy> strategies;
public PaymentResult processPayment(String method, PaymentRequest request) {
PaymentStrategy strategy = strategies.get(method + "Payment");
if (strategy == null) {
throw new IllegalArgumentException("지원하지 않는 결제 방식: " + method);
}
return strategy.pay(request);
}
}
위 코드에서 Spring은 PaymentStrategy 인터페이스를 구현한 모든 Bean을 자동으로 Map에 주입해준다. 키는 Bean 이름이므로, strategies.get("creditCardPayment")으로 해당 전략을 꺼낼 수 있다. 이 방식은 새로운 결제 수단을 추가할 때 기존 코드를 전혀 수정하지 않아도 되므로 OCP를 완벽하게 준수한다.
6. 실무 적용 가이드와 안티패턴
패턴 선택 가이드
디자인 패턴을 실무에 적용할 때는 다음과 같은 기준으로 판단한다.
객체 생성이 복잡한가?
- 매개변수가 4개 이상이면 Builder 패턴을 고려한다.
- 생성할 객체의 타입이 런타임에 결정되면 Factory 패턴을 사용한다.
- 전역적으로 하나의 인스턴스만 필요하면 Singleton을 고려하되, Spring Bean으로 대체 가능한지 먼저 확인한다.
기존 코드를 수정하지 않고 기능을 확장해야 하는가?
- 알고리즘을 교체해야 하면 Strategy 패턴을 사용한다.
- 기능을 동적으로 조합해야 하면 Decorator 패턴을 사용한다.
- 횡단 관심사를 분리해야 하면 Proxy 패턴(Spring AOP)을 사용한다.
컴포넌트 간 결합도를 낮춰야 하는가?
- 이벤트 기반 통신이 적합하면 Observer 패턴(Spring Event)을 사용한다.
- 복잡한 서브시스템을 감춰야 하면 Facade 패턴을 사용한다.
- 호환되지 않는 인터페이스를 연결해야 하면 Adapter 패턴을 사용한다.
흔한 안티패턴과 주의사항
1. 과도한 패턴 적용 (Pattern-itis)
가장 흔한 실수는 모든 곳에 패턴을 억지로 적용하는 것이다. 패턴은 복잡성을 관리하기 위한 도구인데, 불필요한 곳에 적용하면 오히려 복잡성이 증가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 안티패턴: 불필요한 Strategy 패턴
// 변하지 않는 로직에 Strategy를 적용한 과잉 설계
public interface GreetingStrategy {
String greet(String name);
}
public class KoreanGreeting implements GreetingStrategy {
@Override
public String greet(String name) {
return "안녕하세요, " + name + "님";
}
}
// 이 경우 단순히 메서드 하나로 충분하다
public String greet(String name) {
return "안녕하세요, " + name + "님";
}
단일 구현체만 존재하고, 향후 확장 가능성도 없다면 패턴을 적용할 이유가 없다. YAGNI(You Aren’t Gonna Need It) 원칙을 기억하자.
2. Singleton 남용
Singleton을 전역 변수처럼 사용하면 테스트가 극도로 어려워지고, 숨겨진 의존성(hidden dependency)이 만들어진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 안티패턴: Singleton을 직접 참조
public class OrderProcessor {
public void process(Order order) {
// 숨겨진 의존성 - 테스트 시 모킹이 어렵다
DatabaseConnection.getInstance().save(order);
EmailService.getInstance().send(order.getUserEmail());
}
}
// 올바른 방법: 의존성 주입 사용
public class OrderProcessor {
private final DatabaseConnection db;
private final EmailService emailService;
public OrderProcessor(DatabaseConnection db, EmailService emailService) {
this.db = db;
this.emailService = emailService;
}
public void process(Order order) {
db.save(order);
emailService.send(order.getUserEmail());
}
}
3. 거대한 Factory 클래스
Factory에 점점 더 많은 타입을 추가하다 보면 거대한 switch/if-else 블록이 만들어진다. 이 경우 앞서 보았던 Spring의 Map 주입 방식이나, Service Locator 패턴으로 리팩토링해야 한다.
4. 과도한 Observer 사용
이벤트가 너무 많아지면 시스템의 흐름을 추적하기 어려워진다. 이벤트 체이닝이 깊어지면 디버깅이 사실상 불가능해지는 “이벤트 스파게티”가 된다. Observer 패턴을 사용할 때는 이벤트 흐름도를 문서화하고, 이벤트 깊이를 2-3단계 이내로 제한하는 것이 좋다.
패턴 학습의 올바른 순서
디자인 패턴을 효과적으로 학습하려면 다음 순서를 권장한다.
- 먼저 SOLID 원칙을 체득한다. 패턴의 존재 이유를 이해하는 기반이 된다.
- 실무에서 자주 쓰이는 패턴부터 학습한다. Strategy, Factory, Observer, Builder, Proxy가 실무 빈도가 높다.
- Spring 코드를 읽으며 패턴이 어떻게 적용되었는지 분석한다. 추상적인 패턴 설명보다 실제 코드를 통해 훨씬 깊이 이해할 수 있다.
- 리팩토링 과정에서 패턴을 적용해본다. 처음부터 패턴을 설계하려 하지 말고, 코드의 문제점(중복, 결합도, 복잡성)을 인식한 후 그 해결책으로 패턴을 도입한다.
디자인 패턴은 목적이 아니라 수단이다. “이 패턴을 쓰고 싶다”가 아니라 “이 문제를 해결하는 데 이 패턴이 적합하다”라는 사고 흐름이 중요하다. 코드를 작성하면서 자연스럽게 패턴이 떠오르는 수준이 될 때, 비로소 디자인 패턴을 제대로 이해했다고 할 수 있다.