Theme:

Spring에서 @Transactional을 붙이면 프록시가 생성된다는데, 그 프록시는 어떤 패턴으로 동작하는 걸까?

디자인 패턴 — 실무에서 자주 만나는 7가지

패턴의 구조를 이해하고 있으면 설계 선택의 이유를 설명할 수 있게 돼요. 실무에서 자주 쓰이는 패턴 7가지를 골라서, 왜 쓰는지 / 어디에 쓰이는지 / 코드로는 어떻게 생겼는지를 정리합니다.

디자인 패턴이란

1994년에 GoF(Gang of Four)라 불리는 네 명의 저자가 쓴 Design Patterns: Elements of Reusable Object-Oriented Software에서 23개의 패턴을 정의했습니다. 소프트웨어 설계에서 반복적으로 나타나는 문제를 해결하기 위한 재사용 가능한 템플릿 같은 거예요. "이 문제는 이런 구조로 풀면 잘 풀리더라"를 체계화해놓은 셈이죠.

GoF 패턴은 목적에 따라 세 가지로 분류됩니다.

분류설명대표 패턴
생성(Creational)객체를 어떻게 만들 것인가싱글톤, 팩토리 메서드, 빌더, 추상 팩토리, 프로토타입
** 구조(Structural)**클래스나 객체를 어떻게 조합할 것인가프록시, 어댑터, 데코레이터, 퍼사드, 컴포지트
** 행동(Behavioral)**객체 간 책임 분배와 알고리즘을 어떻게 할 것인가전략, 옵저버, 템플릿 메서드, 커맨드, 상태

23개 전부를 달달 외울 필요는 없어요. 실무와 밀접한 몇 가지만 제대로 이해하면 충분한데, 지금부터 하나씩 살펴보겠습니다.


1. 싱글톤 (Singleton)

인스턴스를 딱 하나만 만들고, 전역에서 접근할 수 있게 하는 패턴.

DB 커넥션 풀이나 로깅 객체처럼 애플리케이션 전체에서 하나만 있으면 되는 경우에 씁니다. 무분별하게 객체를 생성하면 메모리 낭비가 되니까, 하나만 만들어서 공유하자는 의도예요.

가장 안전한 구현: enum

JAVA
public enum Singleton {
    INSTANCE;

    public void doSomething() {
        System.out.println("싱글톤 동작");
    }
}

Joshua Bloch가 Effective Java에서 권장한 방식입니다. JVM이 enum 인스턴스의 유일성을 보장해주고, 직렬화나 리플렉션 공격에도 안전해요. 코드가 너무 짧아서 허무할 정도인데, 그게 장점이에요.

Bill Pugh 방식 (LazyHolder)

JAVA
public class Singleton {
    private Singleton() {}

    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

Holder 클래스는 getInstance()가 호출될 때 처음 로딩되기 때문에 lazy initialization이 자연스럽게 됩니다. synchronized 같은 락이 필요 없어서 성능도 좋고, thread-safe도 보장돼요. 실무에서 enum 방식이 부담스러울 때(상속이 필요하다든지) 이 방식을 많이 씁니다.

Spring과의 관계

Spring의 빈은 기본적으로 싱글톤 스코프로 관리됩니다. 그런데 GoF 싱글톤과는 약간 달라요. GoF 싱글톤은 클래스 레벨에서 인스턴스가 하나지만, Spring 싱글톤은 IoC 컨테이너 내에서 하나입니다. 같은 클래스를 다른 이름으로 빈 등록하면 인스턴스가 두 개 생길 수 있어요. "Spring 빈이 싱글톤이라는데, GoF 싱글톤과 같은 건가요?"라는 질문에는 이 차이를 설명하면 됩니다.


2. 팩토리 메서드 (Factory Method)

객체 생성을 서브클래스에 위임하는 패턴.

직접 new로 객체를 만들면 구현체에 강하게 결합됩니다. 팩토리 메서드는 객체 생성 로직을 별도 메서드로 분리해서, 어떤 구현체를 만들지를 서브클래스가 결정하도록 해요.

JAVA
// 추상 팩토리 클래스
public abstract class NotificationFactory {

    // 팩토리 메서드
    public abstract Notification createNotification();

    public void send(String message) {
        Notification notification = createNotification();
        notification.notify(message);
    }
}

// 구현체
public class EmailNotificationFactory extends NotificationFactory {
    @Override
    public Notification createNotification() {
        return new EmailNotification();
    }
}

public class SmsNotificationFactory extends NotificationFactory {
    @Override
    public Notification createNotification() {
        return new SmsNotification();
    }
}

새로운 알림 채널(카카오톡, 슬랙 등)이 추가되더라도 기존 코드를 수정하지 않고 새 팩토리 클래스만 만들면 됩니다. OCP(개방-폐쇄 원칙)를 잘 지키는 구조예요.

Spring에서의 팩토리 메서드

Spring의 BeanFactory 자체가 팩토리 패턴의 산물입니다. ApplicationContext가 빈 이름이나 타입을 받아서 적절한 객체를 생성·반환하는 구조가 정확히 팩토리 메서드 패턴에 해당해요.

JAVA
ApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class);
UserService userService = ctx.getBean(UserService.class); // 팩토리가 생성

3. 전략 (Strategy)

알고리즘을 인터페이스로 캡슐화하고, 런타임에 교체할 수 있게 하는 패턴.

if-else 분기로 알고리즘을 선택하는 대신, 각 알고리즘을 독립된 클래스로 만들어서 갈아끼울 수 있게 합니다.

JAVA
public interface SortStrategy {
    void sort(int[] data);
}

public class QuickSort implements SortStrategy {
    @Override
    public void sort(int[] data) {
        // 퀵 정렬 로직
    }
}

public class MergeSort implements SortStrategy {
    @Override
    public void sort(int[] data) {
        // 병합 정렬 로직
    }
}

public class Sorter {
    private SortStrategy strategy;

    public Sorter(SortStrategy strategy) {
        this.strategy = strategy;
    }

    public void setStrategy(SortStrategy strategy) {
        this.strategy = strategy;
    }

    public void performSort(int[] data) {
        strategy.sort(data);
    }
}

Sorter는 구체적인 정렬 알고리즘을 전혀 몰라요. 그냥 SortStrategy 인터페이스만 알고 있을 뿐입니다. 실행 시점에 setStrategy()로 바꿔 끼울 수도 있어요.

Java에서의 전략 패턴

사실 우리는 이미 전략 패턴을 쓰고 있어요. Collections.sort()Comparator를 넘기는 게 전형적인 전략 패턴입니다.

JAVA
List<String> names = Arrays.asList("Charlie", "Alice", "Bob");
Collections.sort(names, Comparator.naturalOrder());   // 전략 1
Collections.sort(names, Comparator.reverseOrder());    // 전략 2

Spring에서의 전략 패턴

Spring은 인터페이스 기반 다형성을 적극 활용합니다. 예를 들어 HandlerMapping 인터페이스의 구현체를 바꾸면 URL 매핑 전략이 바뀌어요. 실무에서는 결제 수단 선택(PaymentStrategy)이나 할인 정책 적용 같은 곳에서 전략 패턴을 흔히 적용합니다.


4. 옵저버 (Observer)

어떤 객체의 상태가 바뀌면, 그것을 관찰하고 있던 객체들에게 자동으로 알림을 보내는 패턴.

발행-구독(pub-sub) 모델이라고도 부릅니다. 이벤트가 발생했을 때 관련된 여러 처리를 수행해야 하는데, 발행자가 구독자를 직접 알 필요가 없다는 점이 핵심이에요.

JAVA
public interface EventListener {
    void onEvent(String event);
}

public class EventPublisher {
    private final List<EventListener> listeners = new ArrayList<>();

    public void subscribe(EventListener listener) {
        listeners.add(listener);
    }

    public void publish(String event) {
        for (EventListener listener : listeners) {
            listener.onEvent(event);
        }
    }
}

public class LoggingListener implements EventListener {
    @Override
    public void onEvent(String event) {
        System.out.println("[LOG] " + event);
    }
}

public class AlertListener implements EventListener {
    @Override
    public void onEvent(String event) {
        System.out.println("[ALERT] " + event);
    }
}

참고로 Java의 java.util.ObserverObservable은 Java 9에서 deprecated 되었습니다. 제네릭을 지원하지 않고 구조적인 한계가 있어서 사실상 쓸 이유가 없어요.

Spring의 이벤트 시스템

Spring에서는 ApplicationEvent@EventListener로 옵저버 패턴을 프레임워크 레벨에서 지원합니다. 이게 실무에서 훨씬 깔끔해요.

JAVA
// 이벤트 정의
public class OrderCompletedEvent extends ApplicationEvent {
    private final Long orderId;

    public OrderCompletedEvent(Object source, Long orderId) {
        super(source);
        this.orderId = orderId;
    }

    public Long getOrderId() { return orderId; }
}

// 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;

    public void completeOrder(Long orderId) {
        // 주문 완료 처리 ...
        eventPublisher.publishEvent(new OrderCompletedEvent(this, orderId));
    }
}

// 이벤트 구독
@Component
public class NotificationHandler {
    @EventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        System.out.println("주문 완료 알림 전송: " + event.getOrderId());
    }
}

주문 서비스는 알림 핸들러의 존재를 몰라요. 나중에 포인트 적립, 통계 갱신 같은 처리가 추가되어도 OrderService 코드는 건드릴 필요가 없습니다.


5. 프록시 (Proxy)

실제 객체에 대한 대리인(proxy)을 두어, 접근 제어·부가 기능 추가·지연 로딩 등을 수행하는 패턴.

클라이언트는 프록시를 통해 실제 객체에 접근하는데, 프록시가 실제 객체와 같은 인터페이스를 구현하기 때문에 클라이언트 입장에서는 차이를 모릅니다.

JAVA
public interface ImageLoader {
    void display();
}

public class RealImageLoader implements ImageLoader {
    private final String filename;

    public RealImageLoader(String filename) {
        this.filename = filename;
        loadFromDisk(); // 무거운 작업
    }

    private void loadFromDisk() {
        System.out.println("디스크에서 로딩 중: " + filename);
    }

    @Override
    public void display() {
        System.out.println("표시: " + filename);
    }
}

public class ProxyImageLoader implements ImageLoader {
    private final String filename;
    private RealImageLoader realImage;

    public ProxyImageLoader(String filename) {
        this.filename = filename;
    }

    @Override
    public void display() {
        if (realImage == null) {
            realImage = new RealImageLoader(filename); // 실제로 필요할 때 로딩
        }
        realImage.display();
    }
}

ProxyImageLoaderdisplay()가 호출되기 전까지는 실제 이미지를 로딩하지 않아요. 이런 게 바로 지연 로딩(Lazy Loading)입니다.

Spring AOP

Spring AOP는 프록시 패턴의 대표적인 활용입니다. @Transactional을 붙이면 Spring이 해당 빈의 프록시를 생성해서, 메서드 호출 전후에 트랜잭션 시작/커밋/롤백을 처리해요. 실제 비즈니스 로직은 건드리지 않으면서 횡단 관심사(cross-cutting concern)를 깔끔하게 분리할 수 있습니다.

JPA 지연 로딩

JPA에서 @ManyToOne(fetch = FetchType.LAZY)로 설정하면, 연관 엔티티를 프록시 객체로 채워둡니다. 실제로 그 엔티티의 필드에 접근할 때 비로소 SELECT 쿼리가 나가요. 이 프록시 객체 역시 프록시 패턴의 구현입니다. Hibernate가 런타임에 엔티티 클래스를 상속한 프록시 클래스를 동적으로 생성하는 방식으로 동작해요.


6. 템플릿 메서드 (Template Method)

알고리즘의 골격을 상위 클래스에서 정의하고, 구체적인 단계는 하위 클래스에서 구현하도록 하는 패턴.

전체적인 흐름은 같은데 세부 구현만 다른 경우에 유용합니다. "큰 틀은 내가 잡아놓을 테니, 세부 구현은 너희가 알아서 채워라"는 느낌이에요.

JAVA
public abstract class DataProcessor {

    // 템플릿 메서드 — 전체 흐름을 정의
    public final void process() {
        readData();
        processData();
        writeData();
    }

    protected abstract void readData();
    protected abstract void processData();
    protected abstract void writeData();
}

public class CsvDataProcessor extends DataProcessor {
    @Override
    protected void readData() {
        System.out.println("CSV 파일 읽기");
    }

    @Override
    protected void processData() {
        System.out.println("CSV 데이터 가공");
    }

    @Override
    protected void writeData() {
        System.out.println("CSV 결과 저장");
    }
}

public class JsonDataProcessor extends DataProcessor {
    @Override
    protected void readData() {
        System.out.println("JSON 파일 읽기");
    }

    @Override
    protected void processData() {
        System.out.println("JSON 데이터 파싱");
    }

    @Override
    protected void writeData() {
        System.out.println("JSON 결과 저장");
    }
}

process() 메서드가 final인 게 포인트입니다. 하위 클래스가 전체 흐름을 바꾸지 못하게 하면서, 각 단계의 세부 구현만 오버라이드하도록 강제해요.

Spring에서의 템플릿 메서드

Spring의 JdbcTemplate이 대표적입니다. 커넥션 획득 → SQL 실행 → 결과 매핑 → 자원 해제라는 전체 흐름은 JdbcTemplate이 잡아놓고, 개발자는 SQL과 결과 매핑 로직만 작성하면 돼요.

JAVA
List<User> users = jdbcTemplate.query(
    "SELECT id, name FROM users",
    (rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name"))
);

이름에 "Template"이 들어가 있는 것만 봐도 이 패턴을 적용했음을 노골적으로 드러내고 있죠. RestTemplate, RedisTemplate 등도 같은 맥락입니다.


7. 빌더 (Builder)

복잡한 객체의 생성 과정을 단계별로 분리하는 패턴.

생성자 파라미터가 많아지면 어떤 값이 어느 필드에 들어가는지 헷갈립니다. 빌더 패턴은 메서드 체이닝으로 가독성 있게 객체를 생성할 수 있게 해줘요.

JAVA
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 HttpRequest(Builder builder) {
        this.url = builder.url;
        this.method = builder.method;
        this.headers = builder.headers;
        this.body = builder.body;
        this.timeout = builder.timeout;
    }

    public static class Builder {
        private final String url;            // 필수
        private String method = "GET";       // 기본값
        private Map<String, String> headers = new HashMap<>();
        private String body;
        private int timeout = 3000;

        public Builder(String url) {
            this.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) {
            this.timeout = timeout;
            return this;
        }

        public HttpRequest build() {
            return new HttpRequest(this);
        }
    }
}

사용할 때는 이렇게 씁니다.

JAVA
HttpRequest request = new HttpRequest.Builder("https://api.example.com/users")
        .method("POST")
        .header("Content-Type", "application/json")
        .body("{\"name\": \"홍길동\"}")
        .timeout(5000)
        .build();

어떤 필드에 뭘 넣는지가 명확하고, 필수 파라미터와 선택 파라미터 구분도 자연스러워요.

Lombok @Builder

실무에서 매번 Builder 클래스를 직접 작성하는 건 번거롭습니다. Lombok의 @Builder를 쓰면 컴파일 타임에 빌더 코드를 자동 생성해줘요.

JAVA
@Builder
@Getter
public class UserDto {
    private final String name;
    private final String email;
    private final int age;
}

// 사용
UserDto user = UserDto.builder()
        .name("홍길동")
        .email("hong@example.com")
        .age(28)
        .build();

편하긴 한데, @Builder를 무분별하게 쓰면 필수 값 검증이 빠지기 쉽다는 점은 주의해야 합니다.


SOLID 원칙과의 연결

디자인 패턴이 지향하는 방향을 원칙 레벨에서 정리한 게 SOLID입니다. 각 패턴이 어떤 원칙과 연결되는지 알아두면 더 깊이 이해할 수 있어요.

원칙핵심관련 패턴
SRP (단일 책임)클래스는 하나의 변경 이유만 가져야 한다옵저버 — 발행과 구독의 책임 분리
OCP (개방-폐쇄)확장에는 열려 있고, 수정에는 닫혀 있어야 한다팩토리 메서드, 전략 — 새 구현체 추가 시 기존 코드 무수정
LSP (리스코프 치환)하위 타입은 상위 타입을 대체할 수 있어야 한다프록시 — 실제 객체와 프록시를 교체 가능
ISP (인터페이스 분리)사용하지 않는 인터페이스에 의존하지 않아야 한다전략 — 필요한 알고리즘 인터페이스만 의존
DIP (의존성 역전)구체화가 아닌 추상화에 의존해야 한다팩토리 메서드, 전략, 템플릿 메서드

주의할 점

싱글톤의 단점은?

싱글톤은 전역 상태를 만들기 때문에 ** 테스트가 어려워집니다 **. 단위 테스트에서 목(mock) 객체로 교체하기 힘들고, 테스트 간에 상태가 공유되어 순서 의존적인 테스트가 생길 수 있어요. 멀티스레드 환경에서 상태를 가진 싱글톤은 동기화 이슈도 신경 써야 합니다. 이래서 Spring은 IoC 컨테이너를 통해 싱글톤을 관리하고, 테스트 시에는 DI를 통해 mock으로 교체할 수 있게 한 거예요.

패턴 남용은 왜 문제인가?

패턴을 적용하면 일반적으로 추상화 계층이 늘어납니다. 단순한 문제에 패턴을 억지로 적용하면 코드가 오히려 복잡해지고, 유지보수 비용이 올라가요. 문제의 복잡도가 패턴 도입의 복잡도보다 낮을 때가 바로 패턴을 쓰지 않아야 할 때입니다. 패턴은 도구지 규칙이 아니에요.

MVC 패턴은 GoF 패턴인가?

MVC(Model-View-Controller)는 GoF 23개 패턴에 포함되지 않습니다. MVC는 아키텍처 패턴에 가깝고, 내부적으로 옵저버(Model → View 알림), 전략(Controller가 View의 입력 처리 전략), 컴포지트(View 계층 구조) 등 여러 GoF 패턴을 조합해서 만든 복합 패턴이에요. Spring MVC에서 DispatcherServlet이 Controller를 호출하고, View를 결정하는 구조가 바로 이 패턴의 구현입니다.


마무리

디자인 패턴을 공부할 때 흔히 하는 실수가 패턴의 UML 다이어그램이나 구조를 외우는 데 집중하는 거예요. 중요한 건 "이 패턴을 왜 선택했고, 어떤 트레이드오프가 있는지"를 설명할 수 있느냐입니다. Spring을 쓰고 있다면 이미 이 패턴들을 간접적으로 쓰고 있는 셈이니까, 프레임워크 내부 구조와 연결 지어서 설명하면 훨씬 설득력 있는 답변이 됩니다.

파생 개념

  • **SOLID 원칙 **: 디자인 패턴의 근간이 되는 객체지향 설계 원칙 다섯 가지.
  • [제어의 역전(IoC)과 의존성 주입(DI)](/개발/백엔드/스프링/스프링부트/제어의 역전(IoC) 과 의존성_주입(DI)): 팩토리 메서드, 전략 패턴 등의 기반이 되는 Spring 핵심 개념.
  • ** 리팩터링 **: 기존 코드의 동작을 유지하면서 내부 구조를 개선하는 과정. 디자인 패턴은 리팩터링의 목표 구조가 되기도 합니다.
댓글 로딩 중...