자바 디자인 패턴 — 코드로 설명할 수 있어야 하는 패턴들
Spring의
BeanFactory는 Factory Method,JdbcTemplate은 Template Method,@Transactional은 Proxy 패턴으로 동작한다. 프레임워크를 쓰면서 패턴을 모르면, 내부에서 문제가 생겼을 때 원인을 추적할 수가 없다.
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
디자인 패턴을 왜 알아야 하나
디자인 패턴은 "이런 상황에서는 이렇게 설계하면 좋다"는 ** 검증된 해법 모음 **이에요. GoF가 정리한 23개 중 실무에서 자주 쓰는 8~10개만 확실히 알면 충분합니다.
- ** 공통 언어 **: "여기 Strategy 패턴 적용하죠"라고 하면 팀원 모두가 같은 구조를 떠올려요
- ** 프레임워크 이해 **: Spring, JDK 내부가 패턴 덩어리입니다. 패턴을 모르면 설계 의도를 이해할 수 없어요
이 글에서는 면접에서 자주 나오는 8가지 패턴을 코드와 Spring 적용 사례로 정리합니다.
Singleton — 인스턴스는 하나만
개념
애플리케이션 전체에서 ** 인스턴스를 하나만** 만들어 공유하는 패턴입니다. 설정 객체, 커넥션 풀, 로깅 같은 곳에서 써요.
전통적인 방식과 문제점
public class AppConfig {
private static AppConfig instance;
private AppConfig() {} // 외부 생성 차단
public static synchronized AppConfig getInstance() {
if (instance == null) instance = new AppConfig();
return instance;
}
}
이 방식에는 synchronized 성능 저하, 리플렉션으로 private 생성자 뚫림, 직렬화 시 새 인스턴스 생성 등의 문제가 있습니다.
enum Singleton — Effective Java의 결론
public enum AppConfig {
INSTANCE; // 유일한 인스턴스
private String dbUrl;
public String getDbUrl() {
return dbUrl;
}
public void setDbUrl(String dbUrl) {
this.dbUrl = dbUrl;
}
}
// 사용
AppConfig.INSTANCE.setDbUrl("jdbc:mysql://localhost:3306/mydb");
String url = AppConfig.INSTANCE.getDbUrl();
enum이 최선인 이유는 간단해요.
- JVM이 ** 인스턴스 유일성을 보장 **합니다
- 리플렉션으로 새 인스턴스를 만들 수 없어요 (JVM이 차단)
- 직렬화/역직렬화해도 같은 인스턴스를 반환합니다
- 코드가 짧아요
Spring Bean과의 차이
Spring의 기본 스코프가 Singleton이라서 혼동하기 쉬운데, ** 완전히 다른 메커니즘 **입니다.
| 구분 | GoF Singleton | Spring Singleton Bean |
|---|---|---|
| 범위 | JVM 전체에서 1개 | Spring 컨테이너 당 1개 |
| 구현 | 직접 private 생성자 + static | 컨테이너가 관리 |
| 테스트 | 어렵다 (전역 상태) | 쉽다 (DI로 Mock 주입 가능) |
Spring Bean의 Singleton 스코프와 GoF Singleton 패턴은 ** 완전히 다른 메커니즘 **이에요. Spring은 컨테이너가 인스턴스를 하나만 생성해서 관리하는 것이지, 클래스 자체가 private 생성자로 Singleton으로 설계된 것이 아닙니다. 그래서 테스트에서 Mock을 주입할 수 있는 거예요.
Builder — 복잡한 객체를 단계적으로
개념
생성자 파라미터가 많을 때, ** 메서드 체이닝으로 필요한 값만 설정 **하며 객체를 만드는 패턴입니다.
문제 상황 — 생성자 지옥
// 파라미터가 많아지면 어떤 값이 뭔지 알기 어렵다
User user = new User("홍길동", "hong@email.com", 25, "서울", "010-1234-5678", true);
어떤 값이 나이이고 어떤 값이 전화번호인지 보기만 해서는 알 수 없어요.
Builder 패턴 구현
public class User {
private final String name;
private final String email;
private final int age;
private final String city;
private User(Builder builder) {
this.name = builder.name;
this.email = builder.email;
this.age = builder.age;
this.city = builder.city;
}
public static class Builder {
private final String name; // 필수
private final String email; // 필수
private int age = 0; // 선택 — 기본값
private String city = ""; // 선택 — 기본값
public Builder(String name, String email) {
this.name = name;
this.email = email;
}
public Builder age(int age) { this.age = age; return this; }
public Builder city(String city) { this.city = city; return this; }
public User build() { return new User(this); }
}
}
// 필요한 값만 골라서 설정 — 코드 자체가 설명이 된다
User user = new User.Builder("홍길동", "hong@email.com")
.age(25)
.city("서울")
.build();
Lombok @Builder
실무에서는 이 보일러플레이트를 Lombok이 생성해줍니다.
@Builder
@Getter
public class User {
private final String name;
private final String email;
@Builder.Default
private int age = 0;
@Builder.Default
private boolean active = true;
}
User user = User.builder()
.name("홍길동")
.email("hong@email.com")
.age(25)
.build();
Lombok @Builder는 편리하지만, 내부적으로 어떤 코드가 생성되는지 이해해야 디버깅이 가능해요. Builder 패턴의 핵심은 ** 불변 객체 + 가독성 있는 생성 **입니다.
Factory Method — 생성 로직을 분리하라
개념
객체를 직접 new로 만들지 않고, ** 생성을 담당하는 메서드(또는 클래스)에 위임 **하는 패턴이에요. 어떤 구현체를 만들지는 팩토리가 결정합니다.
코드로 보기
// 인터페이스
public interface Notification {
void send(String message);
}
// 구현체들 (EmailNotification, SmsNotification, PushNotification 등)
// 팩토리 — 타입에 따라 적절한 구현체 생성
public class NotificationFactory {
public static Notification create(String type) {
return switch (type) {
case "email" -> new EmailNotification();
case "sms" -> new SmsNotification();
case "push" -> new PushNotification();
default -> throw new IllegalArgumentException("알 수 없는 타입: " + type);
};
}
}
// 사용하는 쪽은 구현체를 몰라도 된다
Notification noti = NotificationFactory.create("email");
noti.send("가입을 환영합니다!");
Spring에서의 Factory
Spring의 BeanFactory가 바로 이 패턴이에요. @Autowired로 주입받는 빈은 컨테이너(팩토리)가 생성한 것이고, ApplicationContext도 BeanFactory를 확장한 것입니다. Spring 컨테이너 자체가 거대한 팩토리 예요.
Strategy — 알고리즘을 갈아끼우기
개념
동일한 문제를 해결하는 여러 알고리즘을 인터페이스로 분리 하고, 런타임에 교체할 수 있게 하는 패턴입니다.
코드로 보기
// 전략 인터페이스 — 함수형 인터페이스로 선언
@FunctionalInterface
public interface DiscountStrategy {
int applyDiscount(int price);
}
// 컨텍스트 — 전략을 주입받아 사용
public class PaymentService {
private DiscountStrategy strategy;
public void setStrategy(DiscountStrategy strategy) {
this.strategy = strategy;
}
public int calculatePrice(int originalPrice) {
return strategy.applyDiscount(originalPrice);
}
}
PaymentService service = new PaymentService();
// 런타임에 전략 교체 — 클래스로 구현체를 만들 수도 있고
service.setStrategy(new FixedDiscount(3000)); // 3000원 고정 할인
System.out.println(service.calculatePrice(10000)); // 7000
// 람다로 바로 전략을 넘길 수도 있다 (@FunctionalInterface이므로)
service.setStrategy(price -> (int) (price * 0.9)); // 10% 할인
System.out.println(service.calculatePrice(10000)); // 9000
JDK에서의 Strategy — Comparator
Comparator가 Strategy 패턴의 교과서적인 예입니다.
List<String> names = Arrays.asList("Charlie", "Alice", "Bob");
// 전략 1: 알파벳 순
names.sort(Comparator.naturalOrder());
// 전략 2: 길이 순
names.sort(Comparator.comparingInt(String::length));
// 전략 3: 역순
names.sort(Comparator.reverseOrder());
정렬 알고리즘은 그대로인데, **비교 전략만 갈아끼우는 거예요 **. 이것이 Strategy 패턴의 핵심입니다.
Observer — 이벤트가 발생하면 알려줘
개념
어떤 객체의 상태가 변하면, 이를 ** 구독하고 있는 객체들에게 자동으로 알리는** 패턴이에요. 발행-구독(Pub-Sub) 구조의 기본이 됩니다.
코드로 보기
// 옵저버 인터페이스
public interface EventListener {
void onEvent(String eventType, String data);
}
// 이벤트 발행자 — 구독/해제/발행을 관리
public class EventPublisher {
private final Map<String, List<EventListener>> listeners = new HashMap<>();
public void subscribe(String eventType, EventListener listener) {
listeners.computeIfAbsent(eventType, k -> new ArrayList<>()).add(listener);
}
public void publish(String eventType, String data) {
listeners.getOrDefault(eventType, Collections.emptyList())
.forEach(listener -> listener.onEvent(eventType, data));
}
}
EventPublisher publisher = new EventPublisher();
// 구독 등록 — 람다로 간결하게
publisher.subscribe("주문완료", (type, data) ->
System.out.println("[이메일] " + type + ": " + data));
publisher.subscribe("주문완료", (type, data) ->
System.out.println("[로그] " + type + ": " + data));
// 이벤트 발생 → 구독자들에게 자동 알림
publisher.publish("주문완료", "주문번호 #1234");
// [이메일] 주문완료: 주문번호 #1234
// [로그] 주문완료: 주문번호 #1234
Spring ApplicationEvent
Spring에서는 Observer 패턴을 프레임워크 레벨에서 지원해요.
// 1. 이벤트 정의
public record OrderCompletedEvent(String orderId) {}
// 2. 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher publisher;
public void completeOrder(String orderId) {
// 주문 처리 로직...
publisher.publishEvent(new OrderCompletedEvent(orderId));
}
}
// 3. 이벤트 수신 — @EventListener만 붙이면 자동 구독
@Component
public class NotificationHandler {
@EventListener
public void handle(OrderCompletedEvent event) {
System.out.println("알림 전송: 주문 " + event.orderId() + " 완료");
}
}
ApplicationEventPublisher가 발행자, @EventListener가 옵저버입니다. 직접 구독/해제를 관리할 필요 없이 Spring이 연결해줘요.
Template Method — 뼈대는 고정, 세부는 위임
개념
알고리즘의 ** 전체 흐름(뼈대)은 부모 클래스가 정의 **하고, 특정 단계의 구현은 서브클래스에 맡기는 패턴입니다.
코드로 보기
public abstract class DataProcessor {
// 템플릿 메서드 — final로 전체 흐름을 고정
public final void process() {
readData();
processData(); // 이 부분만 서브클래스가 구현
writeResult();
}
private void readData() { System.out.println("데이터를 읽는다"); }
protected abstract void processData(); // 서브클래스에 위임
private void writeResult() { System.out.println("결과를 저장한다"); }
}
public class CsvProcessor extends DataProcessor {
@Override
protected void processData() {
System.out.println("CSV 형식으로 데이터를 변환한다");
}
}
new CsvProcessor().process();
// 데이터를 읽는다 → CSV 형식으로 데이터를 변환한다 → 결과를 저장한다
process()가 final이므로 전체 흐름은 바꿀 수 없고, ** 변하는 부분만 서브클래스가 채웁니다 **.
Spring의 JdbcTemplate
Spring의 JdbcTemplate이 이 패턴을 적용한 대표 사례예요.
// JdbcTemplate 사용 예시
String sql = "SELECT name, age FROM users WHERE id = ?";
User user = jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
// 이 부분만 우리가 구현 — ResultSet에서 객체로 매핑
User u = new User();
u.setName(rs.getString("name"));
u.setAge(rs.getInt("age"));
return u;
}, userId);
커넥션 획득, PreparedStatement 생성, 예외 처리, 리소스 정리 같은 ** 반복 작업은 JdbcTemplate이 담당 **하고, 우리는 "ResultSet에서 객체를 어떻게 만들지"만 구현하면 됩니다.
Proxy — 대신 처리해주는 대리인
개념
실제 객체 대신 ** 대리 객체(프록시)가 요청을 받아서 **, 부가 기능(로깅, 인증, 트랜잭션 등)을 수행한 후 실제 객체에 위임하는 패턴입니다.
정적 프록시의 한계
가장 단순한 프록시는 같은 인터페이스를 구현하고, 내부에서 실제 객체를 호출하면서 앞뒤로 로깅 등을 끼워넣는 것입니다. 하지만 메서드가 100개면 프록시도 100개 메서드를 일일이 작성해야 해요.
JDK Dynamic Proxy
이 문제를 해결하기 위해, 인터페이스 기반으로 ** 런타임에 프록시를 자동 생성 **합니다.
public class LoggingHandler implements InvocationHandler {
private final Object target;
public LoggingHandler(Object target) { this.target = target; }
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("[로그] " + method.getName() + " 호출");
Object result = method.invoke(target, args); // 실제 객체에 위임
System.out.println("[로그] " + method.getName() + " 완료");
return result;
}
}
UserService real = new UserServiceImpl();
UserService proxy = (UserService) Proxy.newProxyInstance(
UserService.class.getClassLoader(),
new Class[]{UserService.class},
new LoggingHandler(real)
);
proxy.findById(1L); // 프록시가 로깅 후 실제 객체에 위임
JDK Dynamic Proxy vs CGLIB
| 구분 | JDK Dynamic Proxy | CGLIB |
|---|---|---|
| 조건 | 인터페이스 필수 | 인터페이스 없어도 가능 |
| 방식 | 인터페이스 구현체를 런타임 생성 | 대상 클래스를 상속한 서브클래스 생성 |
| 제약 | 인터페이스에 정의된 메서드만 프록시 | final 클래스/메서드는 프록시 불가 |
Spring AOP와의 관계
Spring AOP의 @Transactional, @Cacheable, @Async가 전부 프록시로 동작합니다. 실제로는 대상 클래스의 프록시 객체가 빈으로 등록되어, 메서드 호출 시 프록시가 트랜잭션 시작/커밋/롤백을 처리해요. Spring Boot는 기본적으로 CGLIB 프록시 를 사용합니다.
Proxy 패턴을 이해하면 다음 상황이 왜 문제인지 바로 알 수 있어요. 같은 클래스 내부에서 @Transactional 메서드를 호출하면 프록시를 거치지 않고 this로 직접 호출 하기 때문에 트랜잭션이 적용되지 않습니다. 이것이 Spring AOP에서 가장 흔한 함정이에요.
Decorator — 기능을 겹겹이 감싸기
개념
기존 객체를 래핑하여 기능을 동적으로 추가 하는 패턴입니다. 상속 없이 기능을 확장할 수 있어요.
코드로 보기
public interface Coffee {
String getDescription();
int getCost();
}
public class BasicCoffee implements Coffee {
public String getDescription() { return "아메리카노"; }
public int getCost() { return 3000; }
}
// 데코레이터 — 감싸는 대상을 생성자로 받는다
public class MilkDecorator implements Coffee {
private final Coffee coffee;
public MilkDecorator(Coffee coffee) { this.coffee = coffee; }
public String getDescription() { return coffee.getDescription() + " + 우유"; }
public int getCost() { return coffee.getCost() + 500; }
}
public class ShotDecorator implements Coffee {
private final Coffee coffee;
public ShotDecorator(Coffee coffee) { this.coffee = coffee; }
public String getDescription() { return coffee.getDescription() + " + 샷 추가"; }
public int getCost() { return coffee.getCost() + 500; }
}
// 기능을 겹겹이 감싸기
Coffee coffee = new ShotDecorator(new MilkDecorator(new BasicCoffee()));
System.out.println(coffee.getDescription()); // 아메리카노 + 우유 + 샷 추가
System.out.println(coffee.getCost()); // 4000
JDK에서의 Decorator — InputStream
Java I/O가 대표적인 예입니다. FileInputStream에 버퍼링과 압축 해제를 조합하여 확장해요.
InputStream in = new GZIPInputStream( // 3. 압축 해제
new BufferedInputStream( // 2. 버퍼링
new FileInputStream("data.gz") // 1. 파일 읽기
)
);
Proxy vs Decorator
둘 다 래핑 구조지만 **목적이 다릅니다 **.
| 구분 | Proxy | Decorator |
|---|---|---|
| 목적 | 접근 제어, 부가 기능 (로깅, 트랜잭션) | 기능 확장, 조합 |
| 감싸는 대상 | 보통 하나의 고정된 대상 | 여러 번 중첩 가능 |
| 대상 인지 | 클라이언트가 프록시인 줄 모른다 | 명시적으로 래핑한다 |
패턴 비교 테이블
| 패턴 | 분류 | 핵심 키워드 | Spring 적용 위치 |
|---|---|---|---|
| Singleton | 생성 | 인스턴스 하나 | Bean 스코프 (개념적 유사) |
| Builder | 생성 | 메서드 체이닝, 불변 객체 | — (Lombok이 대체) |
| Factory Method | 생성 | 생성 로직 위임 | BeanFactory, ApplicationContext |
| Strategy | 행위 | 알고리즘 교체 | Comparator, HandlerMapping |
| Observer | 행위 | 이벤트 알림 | ApplicationEvent, @EventListener |
| Template Method | 행위 | 뼈대 고정 + 세부 위임 | JdbcTemplate, RestTemplate |
| Proxy | 구조 | 대리 호출 + 부가 기능 | AOP, @Transactional, @Cacheable |
| Decorator | 구조 | 기능 중첩 확장 | InputStream 계열, ServerHttpRequestDecorator |
주의할 점
패턴을 위한 패턴을 만들지 마라
간단한 if-else로 충분한 곳에 Strategy 패턴을 적용하면 오히려 코드가 복잡해져요. 패턴은 "이 구조가 앞으로 변경될 가능성이 있는가?" 를 기준으로 판단해야 합니다. 변경 가능성이 없으면 단순한 코드가 더 나아요.
Proxy의 self-invocation 함정
Spring AOP 프록시는 외부에서 호출될 때만 동작합니다. this.method()는 프록시를 거치지 않으므로 @Transactional, @Cacheable, @Async 모두 적용되지 않아요. 해결법은 클래스를 분리하거나, AopContext.currentProxy()를 사용하는 것입니다.
Singleton은 테스트를 어렵게 만든다
GoF Singleton은 전역 상태를 만들기 때문에 단위 테스트에서 격리가 안 됩니다. 실무에서는 Singleton을 직접 구현하기보다 Spring 컨테이너의 Bean 스코프 로 관리하는 것이 테스트 가능성 면에서 훨씬 낫습니다.
디자인 패턴은 암기 대상이 아니라 설계 도구 입니다. "이 패턴이 어떤 문제를 해결하고, 어디에 쓰이는지" 코드로 보여줄 수 있으면 충분해요.