스프링 부트를 위한 디자인 패턴 입문
출처: https://www.youtube.com/watch?v=Mq4MwPXWarI · 어라운드 허브 스튜디오 - Around Hub Studio · 11:43 · 2021-06-13
요지
- 디자인 패턴은 반복해서 발생하는 소프트웨어 설계 문제에 재사용할 수 있는 해결 구조다.
- 패턴은 그대로 복제해야 하는 정답이나 알고리즘이 아니라, 프로젝트의 문맥에 맞게 조정하는 설계 지침이다.
- 공통된 패턴 이름은 개발자 사이의 의사소통을 돕고 코드 구조의 이해와 재사용, 변경을 쉽게 만든다.
- GoF의 23개 디자인 패턴은 목적에 따라 생성, 구조, 행동 패턴으로 나뉜다.
- 스프링을 이해할 때는 특히 싱글톤을 알아야 하지만, 스프링 컨테이너가 관리하는 싱글톤은 전통적인 구현과 차이가 있다.
- 모든 패턴을 암기하기보다 각 패턴이 해결하는 문제를 파악하고 설계 시점에 적절한 패턴을 찾아 적용하는 편이 효과적이다.
개요
디자인 패턴에서 ‘디자인’은 시각적 장식이 아니라 소프트웨어의 구조를 정하는 설계를 뜻한다. 디자인 패턴은 개발 과정에서 반복적으로 마주치는 문제와 그 문제를 해결하는 검증된 구조를 이름 붙여 정리한 것이다.
패턴을 익히면 특정 구현 코드뿐 아니라 코드 뒤에 있는 설계 의도를 읽을 수 있다. 이는 여러 개발자가 협업하고, 시간이 지나 기능을 추가하거나 구조를 변경해야 하는 스프링 부트 애플리케이션에서 특히 중요하다. 다만 패턴은 절대적인 정답이 아니므로 문제의 규모와 변경 가능성, 사용 언어와 프레임워크의 특성에 맞춰 적용해야 한다.
배경 / 사전 지식
디자인 패턴은 알고리즘과 구별해야 한다. 알고리즘이 주어진 입력을 처리해 결과를 만드는 구체적인 절차라면, 디자인 패턴은 객체와 클래스의 책임 및 관계를 조직하는 상위 수준의 설계안이다. 따라서 같은 패턴도 언어와 프로젝트에 따라 구현이 달라질 수 있다.
GoF(Gang of Four)는 에리히 감마, 리처드 헬름, 랠프 존슨, 존 블리시디스가 정리한 23개 객체지향 디자인 패턴을 가리킨다. 이 패턴들은 다음 세 범주로 분류된다.
- 생성 패턴: 객체를 언제, 어디서, 어떤 방식으로 만들지 다룬다.
- 구조 패턴: 클래스와 객체를 조합해 더 큰 구조를 만드는 방법을 다룬다.
- 행동 패턴: 객체 사이의 책임 분배와 메시지 교환 방식을 다룬다.
예제를 이해하려면 클래스, 인터페이스, 객체 생성, 의존성 같은 기본적인 객체지향 개념을 알고 있어야 한다. 결합도는 한 구성요소가 다른 구성요소의 구체적인 구현에 의존하는 정도이며, 느슨한 결합은 구현 교체와 테스트를 쉽게 만든다.
핵심 개념
패턴은 설계 어휘다
‘싱글톤’, ‘팩토리’, ‘전략’처럼 합의된 이름을 사용하면 긴 구조 설명을 반복하지 않고도 설계 의도를 전달할 수 있다. 다른 개발자가 코드를 처음 접했을 때도 어떤 책임이 어디에 있고 어떤 변경을 예상한 구조인지 더 빨리 파악할 수 있다.
생성 패턴
생성 패턴은 객체 생성 방식이 프로그램 전체에 미치는 영향을 줄인다. GoF 생성 패턴에는 추상 팩토리, 빌더, 팩토리 메서드, 프로토타입, 싱글톤이 있다.
싱글톤은 한 클래스의 인스턴스를 하나만 두고 여러 곳에서 참조하게 하는 패턴이다. 스프링에서는 개발자가 정적 필드와 생성 제어를 직접 구현하기보다 컨테이너가 기본적으로 빈(bean)을 싱글톤 스코프로 관리한다. 이때 ‘하나’의 범위는 JVM 전체가 아니라 일반적으로 하나의 스프링 ApplicationContext 안이다.
구조 패턴
구조 패턴은 클래스나 객체를 조합해 확장 가능한 구조를 만든다. 어댑터, 브리지, 컴포지트, 데코레이터, 퍼사드, 플라이웨이트, 프록시가 여기에 속한다.
예를 들어 어댑터는 호환되지 않는 인터페이스 사이를 연결하고, 데코레이터는 기존 객체를 수정하지 않고 부가 기능을 감싼다. 퍼사드는 복잡한 하위 시스템 앞에 단순한 진입점을 제공한다. 이런 구조는 기존 코드의 변경을 최소화하면서 기능을 덧붙이는 데 유용하다.
행동 패턴
행동 패턴은 객체들이 협력하는 방법과 책임을 나누는 방법을 정의한다. 책임 연쇄, 커맨드, 인터프리터, 반복자, 중재자, 메멘토, 옵서버, 상태, 전략, 템플릿 메서드, 방문자가 포함된다.
전략 패턴은 교체 가능한 알고리즘을 동일한 인터페이스 뒤에 배치한다. 상태 패턴은 객체의 내부 상태에 따라 행동이 달라지는 경우 상태별 로직을 객체로 분리한다. 두 패턴 모두 거대한 조건문을 줄이고 결합도를 낮추는 데 도움이 된다.
장점과 비용
디자인 패턴은 공통 어휘를 제공하고, 구조 파악과 코드 재사용을 돕고, 변경의 영향을 제한할 수 있다. 반면 패턴을 고려한 초기 설계에는 학습과 논의, 구현 시간이 든다. 불필요한 추상화는 클래스 수와 간접 호출을 늘려 오히려 이해를 방해할 수 있다.
작동 원리
디자인 패턴은 다음 순서로 적용한다.
- 바뀔 가능성이 있는 부분과 반복되는 문제를 찾는다.
- 객체별 책임과 서로 의존하는 지점을 확인한다.
- 생성, 구조, 행동 중 문제의 성격과 가까운 패턴 후보를 고른다.
- 후보 패턴이 해결하려는 문제와 현재 문맥이 실제로 일치하는지 검토한다.
- 패턴의 역할과 관계는 유지하되 언어와 프레임워크에 맞게 구현을 조정한다.
- 테스트를 통해 구현 교체와 기능 추가가 쉬워졌는지 확인한다.
- 복잡성 증가가 얻는 이점보다 크다면 더 단순한 설계로 되돌린다.
예를 들어 결제 방식이 계속 추가된다면 조건문에 구현을 쌓기보다 공통 PaymentStrategy 인터페이스를 정의할 수 있다. 호출자는 구체적인 결제 클래스가 아니라 인터페이스에 의존하고, 스프링 컨테이너가 필요한 구현을 주입한다. 새 결제 방식은 기존 호출 코드를 크게 바꾸지 않고 전략 구현 하나를 추가해 확장할 수 있다.
코드 예시
다음 코드는 별도 라이브러리 없이 Java 17 이상에서 실행할 수 있는 간단한 전략 패턴 예제다.
import java.util.Map;
interface DiscountStrategy {
int apply(int price);
}
final class RegularDiscount implements DiscountStrategy {
@Override
public int apply(int price) {
return price;
}
}
final class VipDiscount implements DiscountStrategy {
@Override
public int apply(int price) {
return (int) (price * 0.8);
}
}
final class PriceCalculator {
private final Map<String, DiscountStrategy> strategies;
PriceCalculator(Map<String, DiscountStrategy> strategies) {
this.strategies = Map.copyOf(strategies);
}
int calculate(String customerType, int price) {
DiscountStrategy strategy = strategies.get(customerType);
if (strategy == null) {
throw new IllegalArgumentException("Unknown customer type: " + customerType);
}
return strategy.apply(price);
}
}
public class Main {
public static void main(String[] args) {
PriceCalculator calculator = new PriceCalculator(Map.of(
"regular", new RegularDiscount(),
"vip", new VipDiscount()
));
System.out.println(calculator.calculate("regular", 10_000)); // 10000
System.out.println(calculator.calculate("vip", 10_000)); // 8000
}
}
PriceCalculator는 할인 계산의 구체적인 규칙을 알지 못하고 DiscountStrategy 계약에만 의존한다. 할인 정책을 추가할 때 계산기 내부의 조건문을 수정하는 대신 새 전략 클래스를 등록할 수 있다. 스프링 부트에서는 각 구현을 빈으로 등록하고 Map<String, DiscountStrategy> 또는 생성자 주입을 이용해 같은 구조를 구성할 수 있다.
스프링 빈은 기본적으로 싱글톤이므로 위 전략 구현이 상태를 갖지 않는 객체라면 여러 요청에서 안전하게 공유할 수 있다. 반대로 요청마다 변하는 값을 싱글톤 빈의 필드에 저장하면 동시성 문제가 생길 수 있으므로 입력값은 메서드 인자로 전달하고 빈은 가능하면 무상태로 설계해야 한다.
함정·실수
- 패턴을 정답처럼 적용하기: 패턴의 전제와 현재 문제가 맞지 않으면 불필요한 계층만 늘어난다. 먼저 해결할 변화 지점이 실제로 존재하는지 확인한다.
- 이름만 패턴에서 가져오기: 클래스에
Factory나Strategy라는 이름을 붙였다고 해당 패턴이 되는 것은 아니다. 객체의 책임과 관계가 패턴의 의도에 부합해야 한다. - 전통적인 싱글톤과 스프링 싱글톤을 동일시하기: 스프링의 싱글톤은 컨테이너가 관리하며
ApplicationContext별로 생성된다. 직접 구현한 정적 싱글톤을 섞으면 테스트와 의존성 관리가 어려워질 수 있다. - 싱글톤 빈에 변경 가능한 상태 저장하기: 여러 요청과 스레드가 같은 빈을 사용할 수 있어 데이터 경합과 사용자 간 정보 누출이 발생할 수 있다.
- 미래의 모든 요구를 미리 추상화하기: 예상만으로 확장 지점을 만들면 초기 비용과 유지보수 부담이 커진다. 실제 변경 압력이 확인된 부분부터 리팩터링한다.
- 패턴을 그대로 복사하기: GoF 패턴은 객체지향 설계의 골격이다. Java와 스프링의 인터페이스, 빈 수명 주기, 의존성 주입 방식에 맞게 조정해야 한다.
베스트 프랙티스
- 패턴 이름보다 해결하려는 문제와 적용 결과를 먼저 설명한다.
- 구현 클래스보다 인터페이스에 의존하고, 의존성은 생성자를 통해 명시적으로 주입한다.
- 스프링에서는 직접 싱글톤 객체를 만들기보다 컨테이너의 빈 관리 기능을 활용한다.
- 싱글톤 스코프 빈은 가능한 한 무상태이며 불변으로 설계한다.
- 전략이나 팩토리를 도입할 때 새 구현 추가가 기존 코드 수정 없이 가능한지 테스트한다.
- 패턴 적용 전후의 복잡성을 비교하고, 단순한 조건문이나 조합만으로 충분하다면 더 작은 설계를 선택한다.
- 23개 패턴을 모두 암기하려 하지 말고 이름, 목적, 대표적인 적용 상황을 익힌 뒤 필요할 때 세부 구현을 찾아본다.
- 코드 리뷰에서는 사용한 패턴뿐 아니라 선택 이유, 예상 변경점, 고려한 대안을 함께 기록한다.
참고
- GoF의 23가지 디자인 패턴
- 스프링 부트에서 활용되는 싱글톤 패턴
- 영상에서 추가 학습 대상으로 언급한 느슨한 결합(loose coupling)
- 영상은 개별 패턴의 상세 구현을 다루지 않으며, 필요한 패턴을 설계 시점에 찾아 심화 학습할 것을 권한다.