자바 디자인 패턴 — 코드로 설명할 수 있어야 하는 패턴들
"Spring이 내부적으로 어떤 패턴들을 쓰고 있는지 설명할 수 있나요?" 면접에서 이 질문을 받았을 때, 패턴 이름만 나열하는 것과 코드로 설명하는 것은 완전히 다른 인상을 준다. 디자인 패턴은 이름을 아는 게 아니라 코드로 보여줄 수 있어야 진짜 아는 것이다.
▸ 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은 컨테이너가 인스턴스를 관리하는 것이지, 클래스 자체가 Singleton으로 설계된 게 아닙니다"**라고 답할 수 있어야 한다.
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 프록시를 사용한다.
자주 나오는 면접 질문이 하나 있다.
"같은 클래스 내부에서
@Transactional메서드를 호출하면 트랜잭션이 적용될까?"
답은 **"아니요"**다. 내부 호출은 프록시를 거치지 않고 this로 직접 호출하기 때문이다. 프록시 패턴의 구조를 이해하면 자연스럽게 답할 수 있는 질문이다.
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 |
면접에서 "디자인 패턴 아는 거 말해보세요"라고 하면 이름만 나열하지 말고, **"BeanFactory가 Factory Method, JdbcTemplate이 Template Method, @Transactional이 Proxy 패턴입니다"**처럼 프레임워크 연결점과 함께 답하자.
마무리
디자인 패턴은 암기 대상이 아니라 설계 도구다. "이 패턴이 어떤 문제를 해결하고, 어디에 쓰이는지" 코드로 보여줄 수 있으면 충분하다.
다음 글에서는 테스트 코드를 다룬다. JUnit 5와 Mockito로 자바 테스트를 작성하는 방법, 그리고 왜 테스트가 코드 품질의 핵심인지 정리해보겠다.