실무에서 자주 쓰는 자바 디자인 패턴 - 언제 어떻게 쓸까?
TL;DR
- 문제: 결제/알림 방식마다 if-else 분기, 코드가 계속 길어지고 새 방식 추가할 때마다 수정
- 원인: “같은 일을 하지만 방법이 다른 것들”을 분기로 처리하면 OCP (개방-폐쇄 원칙) 위반
- 해결: 실무에서 자주 쓰는 3가지 패턴 (Strategy, Factory, Template Method)
- 효과: 새 방식 추가 시 기존 코드 수정 불필요, 테스트 쉬움, 가독성 ↑
- 한계: 초기 설계 시간, 클래스 수 증가, 간단한 2~3개 분기는 오버, 팀 학습 필요
글 머리말
“이 부분 Strategy 패턴으로 바꾸면 어떨까요?”
코드 리뷰 때 팀원이 던진 한마디에 머릿속이 하얗게 됐습니다. GoF 디자인 패턴 책을 대학교 때 본 이후로 제대로 들여다본 적이 없었거든요.
그날 저녁, 구글에 “자바 디자인 패턴 실무”를 검색했습니다. 스택오버플로우와 baeldung 블로그를 뒤지면서 팀원이 제안한 Strategy 패턴을 이해하려고 했습니다.
그런데 신기하게도, 제가 그동안 짰던 코드들을 돌아보니 이미 디자인 패턴을 쓰고 있었더군요. 이름을 몰랐을 뿐입니다.
이번 포스팅에서는 실무에서 자주 마주치는 디자인 패턴 3가지를 다룹니다. 책에 나오는 이론이 아니라, 제가 어떤 상황에서 어떻게 적용했는지를 공유합니다.
Strategy 패턴 - 분기문 지옥에서 탈출하기
Strategy 패턴은 “같은 일을 하지만 방법이 다른 것들”을 인터페이스로 묶는 패턴입니다.
실무에서 흔히 보는 상황:
- 결제 방식이 여러 개 (신용카드, 계좌이체, 포인트)
- 알림 방식이 여러 개 (이메일, SMS, 푸시)
- 파일 저장소가 여러 개 (로컬, S3, FTP)
이런 경우 if-else나 switch로 분기하면 코드가 계속 길어집니다. Strategy 패턴이 이 문제를 해결합니다.
graph TB
Client[클라이언트]
Context[PaymentService]
Strategy[PaymentStrategy<br/>인터페이스]
ConcreteA[CreditCardPayment]
ConcreteB[BankTransferPayment]
ConcreteC[PointPayment]
Client --> Context
Context --> Strategy
Strategy -.-> ConcreteA
Strategy -.-> ConcreteB
Strategy -.-> ConcreteC
style Strategy fill:#e8f5e9
style ConcreteA fill:#fff3e0
style ConcreteB fill:#fff3e0
style ConcreteC fill:#fff3e0실제 겪은 문제
결제 시스템을 만들 때였습니다. 결제 수단이 신용카드, 계좌이체, 포인트 3가지였는데, 각각 처리 방식이 달랐습니다. 처음엔 이렇게 짰습니다.
// Bad - 조건문 지옥
public class PaymentService {
public void processPayment(String paymentType, PaymentRequest request) {
if ("CREDIT_CARD".equals(paymentType)) {
// 신용카드 처리
validateCardNumber(request.getCardNumber());
checkCardLimit(request.getAmount());
callCardGateway(request);
saveCardTransaction(request);
} else if ("BANK_TRANSFER".equals(paymentType)) {
// 계좌이체 처리
validateBankAccount(request.getAccountNumber());
checkDailyLimit(request.getAmount());
callBankApi(request);
saveBankTransaction(request);
} else if ("POINT".equals(paymentType)) {
// 포인트 처리
validatePointBalance(request.getUserId(), request.getAmount());
deductPoints(request);
savePointTransaction(request);
} else {
throw new IllegalArgumentException("지원하지 않는 결제 수단입니다");
}
}
}문제는 새 결제 수단이 추가될 때마다 이 메서드가 계속 길어진다는 겁니다. 카카오페이, 네이버페이까지 추가하니까 200줄이 넘어가더군요.
Strategy 패턴 적용
팀원이 제안한 Strategy 패턴을 적용했습니다.
// Good - Strategy 패턴
public interface PaymentStrategy {
void pay(PaymentRequest request);
}
public class CreditCardPayment implements PaymentStrategy {
@Override
public void pay(PaymentRequest request) {
validateCardNumber(request.getCardNumber());
checkCardLimit(request.getAmount());
callCardGateway(request);
saveCardTransaction(request);
}
}
public class BankTransferPayment implements PaymentStrategy {
@Override
public void pay(PaymentRequest request) {
validateBankAccount(request.getAccountNumber());
checkDailyLimit(request.getAmount());
callBankApi(request);
saveBankTransaction(request);
}
}
public class PointPayment implements PaymentStrategy {
@Override
public void pay(PaymentRequest request) {
validatePointBalance(request.getUserId(), request.getAmount());
deductPoints(request);
savePointTransaction(request);
}
}서비스 코드는 이렇게 바뀝니다.
public class PaymentService {
private final Map<String, PaymentStrategy> strategies;
public PaymentService() {
strategies = Map.of(
"CREDIT_CARD", new CreditCardPayment(),
"BANK_TRANSFER", new BankTransferPayment(),
"POINT", new PointPayment()
);
}
public void processPayment(String paymentType, PaymentRequest request) {
PaymentStrategy strategy = strategies.get(paymentType);
if (strategy == null) {
throw new IllegalArgumentException("지원하지 않는 결제 수단입니다");
}
strategy.pay(request);
}
}적용 후 느낀 점
장점:
- 새 결제 수단 추가가 쉬워졌습니다. 클래스 하나만 추가하면 됩니다.
- 테스트 코드 작성이 편해졌습니다. 각 결제 수단을 독립적으로 테스트할 수 있거든요.
- 코드 리뷰 시간이 줄었습니다. 변경된 클래스만 보면 되니까요.
단점:
- 클래스 개수가 늘어납니다. 결제 수단이 5개면 클래스도 5개입니다.
- 간단한 로직에 적용하면 오히려 복잡해집니다. 분기가 2-3개면 그냥 if문이 낫습니다.
Spring에서는 이렇게 씁니다:
@Component("CREDIT_CARD")
public class CreditCardPayment implements PaymentStrategy {
// ...
}
@Service
public class PaymentService {
private final Map<String, PaymentStrategy> strategies;
public PaymentService(Map<String, PaymentStrategy> strategies) {
this.strategies = strategies;
}
public void processPayment(String paymentType, PaymentRequest request) {
PaymentStrategy strategy = strategies.get(paymentType);
if (strategy == null) {
throw new IllegalArgumentException("지원하지 않는 결제 수단입니다");
}
strategy.pay(request);
}
}Spring이 자동으로 빈 이름을 키로 하는 Map을 주입해줍니다. 정말 편하더군요.
Template Method 패턴 - 중복 코드 제거하기
Template Method 패턴은 “대부분은 같은데 일부만 다른 것들”을 다룰 때 씁니다.
실무 예시:
- 주문 처리: 일반 주문과 예약 주문의 흐름은 비슷한데 배송 준비만 다름
- 파일 변환: Excel, CSV, JSON 변환의 흐름은 같은데 변환 로직만 다름
- 데이터 동기화: 외부 시스템 연동 흐름은 같은데 API 호출만 다름
전체 프로세스는 부모 클래스에서 정의하고, 달라지는 부분만 자식 클래스에서 구현합니다.
구조:
graph TB
Abstract[OrderProcessor<br/>추상 클래스]
Template["processOrder()<br/>(템플릿 메서드)"]
Hook["prepareShipping()<br/>(추상 메서드)"]
ConcreteA[NormalOrderProcessor]
ConcreteB[ReservationOrderProcessor]
Abstract --> Template
Template --> Hook
Abstract --> ConcreteA
Abstract --> ConcreteB
style Abstract fill:#e8f5e9
style Template fill:#e1f5fe
style Hook fill:#fff3e0실제 겪은 문제
주문 처리 프로세스를 구현하는 중이었습니다. 일반 주문과 예약 주문이 있었는데, 전체 흐름은 비슷한데 세부 단계만 달랐습니다.
// Bad - 중복 코드
public class OrderProcessor {
public void processNormalOrder(Order order) {
// 1. 재고 확인
checkStock(order);
// 2. 결제 처리
processPayment(order);
// 3. 배송 준비 (일반 주문은 즉시)
prepareImmediateShipping(order);
// 4. 알림 발송
sendNotification(order);
// 5. 주문 완료
completeOrder(order);
}
public void processReservationOrder(Order order) {
// 1. 재고 확인 (동일)
checkStock(order);
// 2. 결제 처리 (동일)
processPayment(order);
// 3. 배송 준비 (예약 주문은 예약일에)
scheduleShipping(order);
// 4. 알림 발송 (동일)
sendNotification(order);
// 5. 주문 완료 (동일)
completeOrder(order);
}
}코드 중복이 심각했습니다. 그리고 알림 발송 로직을 수정하려면 두 메서드를 다 고쳐야 했습니다.
Template Method 패턴 적용
공통 흐름은 부모 클래스에, 다른 부분만 자식 클래스에서 구현하도록 바꿨습니다.
// Good - Template Method 패턴
public abstract class OrderProcessor {
// 템플릿 메서드 - 전체 흐름 정의
public final void processOrder(Order order) {
checkStock(order);
processPayment(order);
// 서브 클래스에서 구현
prepareShipping(order);
sendNotification(order);
completeOrder(order);
}
// 공통 로직
private void checkStock(Order order) {
// 재고 확인 로직
}
private void processPayment(Order order) {
// 결제 처리 로직
}
private void sendNotification(Order order) {
// 알림 발송 로직
}
private void completeOrder(Order order) {
// 주문 완료 로직
}
// 서브 클래스에서 구현할 추상 메서드
protected abstract void prepareShipping(Order order);
}
public class NormalOrderProcessor extends OrderProcessor {
@Override
protected void prepareShipping(Order order) {
// 즉시 배송 준비
prepareImmediateShipping(order);
}
}
public class ReservationOrderProcessor extends OrderProcessor {
@Override
protected void prepareShipping(Order order) {
// 예약일에 배송 준비
scheduleShipping(order);
}
}적용 후 느낀 점
장점:
- 중복 코드가 확실히 줄었습니다. 알림 로직을 한 곳만 수정하면 됩니다.
- 전체 흐름이 한눈에 보입니다. 템플릿 메서드만 보면 프로세스를 이해할 수 있습니다.
단점:
- 상속을 사용합니다. 자바는 단일 상속만 가능해서 다른 클래스를 상속받을 수 없습니다.
- 흐름이 복잡해지면 이해하기 어렵습니다. 메서드 호출을 따라가다 보면 머리가 아프더군요.
실무 팁:
저는 이제 Strategy 패턴을 더 선호합니다. 상속보다는 조합이 유연하거든요.
// Composition over Inheritance
public class OrderProcessor {
private final ShippingStrategy shippingStrategy;
public OrderProcessor(ShippingStrategy shippingStrategy) {
this.shippingStrategy = shippingStrategy;
}
public void processOrder(Order order) {
checkStock(order);
processPayment(order);
shippingStrategy.prepare(order); // 전략 패턴 활용
sendNotification(order);
completeOrder(order);
}
}이렇게 하면 상속의 단점을 피할 수 있습니다.
Adapter 패턴 - 외부 API 통합하기
Adapter 패턴은 “호환되지 않는 인터페이스들을 하나로 통합”할 때 씁니다.
현실 세계의 어댑터를 생각하면 쉽습니다. 220V 콘센트에 110V 기기를 연결하려면 어댑터가 필요하죠. 코드에서도 마찬가지입니다.
실무에서 가장 많이 쓰는 경우:
- 외부 API 통합: 제조사마다 다른 API를 공통 인터페이스로 통합
- 레거시 시스템 연동: 오래된 시스템을 새 시스템에 맞춤
- 서드파티 라이브러리: 여러 라이브러리를 동일한 방식으로 사용
구조:
graph LR
Client[클라이언트]
Target[DeviceAdapter<br/>인터페이스]
AdapterA[VendorAAdapter]
AdapterB[VendorBAdapter]
AdapterC[VendorCAdapter]
ServiceA[VendorAApi]
ServiceB[VendorBClient]
ServiceC[VendorCService]
Client --> Target
Target -.-> AdapterA
Target -.-> AdapterB
Target -.-> AdapterC
AdapterA --> ServiceA
AdapterB --> ServiceB
AdapterC --> ServiceC
style Target fill:#e8f5e9
style ServiceA fill:#ffebee
style ServiceB fill:#ffebee
style ServiceC fill:#ffebee실제 겪은 문제
여러 제조사의 API를 통합해야 하는 작업이 있었습니다. 각 제조사마다 API 형식이 완전히 달랐습니다.
// Bad - 제조사별로 다른 코드
public class DeviceController {
public void controlDevice(String vendor, String command) {
if ("VendorA".equals(vendor)) {
// A사 API 호출
VendorAApi apiA = new VendorAApi();
apiA.authenticate(apiKey);
apiA.sendCommand(deviceId, command);
apiA.disconnect();
} else if ("VendorB".equals(vendor)) {
// B사 API 호출 (완전히 다른 형식)
VendorBClient clientB = new VendorBClient(apiKey);
clientB.login();
DeviceCommand cmd = clientB.createCommand(command);
clientB.execute(deviceId, cmd);
clientB.logout();
} else if ("VendorC".equals(vendor)) {
// C사 API 호출 (또 다른 형식)
VendorCService serviceC = VendorCService.getInstance();
serviceC.init(apiKey);
serviceC.control(deviceId, command, true);
}
}
}이 코드의 문제는 새 제조사가 추가될 때마다 이 메서드를 수정해야 한다는 겁니다.
Adapter 패턴 적용
각 제조사 API를 공통 인터페이스로 감쌌습니다.
// Good - Adapter 패턴
public interface DeviceAdapter {
void control(String deviceId, String command);
}
public class VendorAAdapter implements DeviceAdapter {
private final VendorAApi api;
public VendorAAdapter(VendorAApi api) {
this.api = api;
}
@Override
public void control(String deviceId, String command) {
api.authenticate(apiKey);
api.sendCommand(deviceId, command);
api.disconnect();
}
}
public class VendorBAdapter implements DeviceAdapter {
private final VendorBClient client;
public VendorBAdapter(VendorBClient client) {
this.client = client;
}
@Override
public void control(String deviceId, String command) {
client.login();
DeviceCommand cmd = client.createCommand(command);
client.execute(deviceId, cmd);
client.logout();
}
}
public class VendorCAdapter implements DeviceAdapter {
private final VendorCService service;
public VendorCAdapter(VendorCService service) {
this.service = service;
}
@Override
public void control(String deviceId, String command) {
service.init(apiKey);
service.control(deviceId, command, true);
}
}컨트롤러는 이렇게 단순해집니다.
public class DeviceController {
private final Map<String, DeviceAdapter> adapters;
public DeviceController(Map<String, DeviceAdapter> adapters) {
this.adapters = adapters;
}
public void controlDevice(String vendor, String deviceId, String command) {
DeviceAdapter adapter = adapters.get(vendor);
if (adapter == null) {
throw new IllegalArgumentException("지원하지 않는 제조사입니다");
}
adapter.control(deviceId, command);
}
}적용 후 느낀 점
장점:
- 새 제조사 추가가 쉽습니다. Adapter 클래스 하나만 만들면 됩니다.
- 각 제조사 API의 복잡한 호출 로직을 숨길 수 있습니다.
- 테스트가 편합니다. Mock Adapter를 만들어서 테스트하면 됩니다.
단점:
- Adapter 클래스가 많아집니다. 제조사가 10개면 Adapter도 10개입니다.
- 공통 인터페이스 설계가 어렵습니다. 제조사마다 기능이 다르면 인터페이스를 어떻게 정의해야 할지 고민됩니다.
실제 경험:
처음엔 공통 인터페이스를 너무 단순하게 만들었습니다.
// Bad - 너무 단순한 인터페이스
public interface DeviceAdapter {
void control(String deviceId, String command);
}그런데 제조사마다 지원하는 기능이 달랐습니다. A사는 온도 조절이 가능한데, B사는 불가능했습니다.
결국 이렇게 바꿨습니다.
// Better - 기능별 인터페이스 분리
public interface DeviceAdapter {
void control(String deviceId, String command);
boolean supports(String feature);
}
public class VendorAAdapter implements DeviceAdapter {
@Override
public boolean supports(String feature) {
return Set.of("POWER", "TEMPERATURE", "MODE").contains(feature);
}
// ...
}이렇게 하니까 클라이언트 코드에서 지원 여부를 먼저 확인할 수 있었습니다.
패턴 적용 시 주의할 점
과도한 패턴 사용은 오히려 독
간단한 CRUD 서비스에 Factory, Builder, Adapter, Strategy를 다 적용한 코드를 본 적이 있습니다. 클래스가 20개가 넘더군요.
“이 패턴이 왜 필요한가요?”라고 물었더니 “디자인 패턴을 적용하면 좋은 코드라고 알고 있습니 다”라는 답이 돌아왔습니다.
패턴 자체가 목적이 되면 안 됩니다. 필요할 때만 써야 합니다.
- 분기문이 2-3개면 그냥 if문으로 충분합니다.
- 중복 코드가 없으면 Template Method는 필요 없습니다.
- 외부 API가 하나면 Adapter도 필요 없습니다.
언제 패턴을 적용할까?
저는 이런 기준으로 판단합니다.
1. 변경이 자주 일어나는가?
- 새 결제 수단이 매달 추가된다면 → Strategy 패턴
- 코드가 한 번 짜고 끝이라면 → if문으로 충분
2. 중복 코드가 많은가?
- 비슷한 로직이 3곳 이상 반복된다면 → Template Method나 Strategy
- 중복이 없다면 → 그냥 둡니다
3. 테스트가 어려운가?
- 외부 API 때문에 테스트가 힘들다면 → Adapter 패턴
- 테스트가 쉽다면 → 그냥 둡니다
4. 다른 개발자가 이해하기 쉬운가?
- 패턴을 적용했는데 더 복잡해졌다면 → 원래대로 돌립니다
- 패턴 때문에 코드를 이해하기 어렵다면 → 잘못된 적용입 니다
패턴 선택 가이드:
| 상황 | 추천 패턴 | 이유 |
|---|---|---|
| 분기가 5개 이상 | Strategy | 각 분기를 독립 클래스로 분리 |
| 전체 흐름은 같고 일부만 다름 | Template Method | 중복 제거 및 흐름 명확화 |
| 외부 시스템/라이브러리 통합 | Adapter | 인터페이스 차이 흡수 |
| 알고리즘을 런타임에 교체 | Strategy | 유연한 교체 가능 |
| 상속보다 조합 선호 | Strategy | 단일 상속 제약 회피 |
| 공통 로직 + 변형 로직 | Template Method | 상속 구조 활용 |
적용 시점 판단 기준:
graph TD
Start[코드 작성 시작]
Q1{분기문이<br/>5개 이상?}
Q2{비슷한 흐름이<br/>3곳 이상 반복?}
Q3{외부 API<br/>통합 필요?}
Q4{런타임에<br/>교체 필요?}
Strategy[Strategy 패턴]
Template[Template Method]
Adapter[Adapter 패턴]
Simple[단순 구현<br/>if문 사용]
Start --> Q1
Q1 -->|예| Strategy
Q1 -->|아니오| Q2
Q2 -->|예| Template
Q2 -->|아니오| Q3
Q3 -->|예| Adapter
Q3 -->|아니오| Q4
Q4 -->|예| Strategy
Q4 -->|아니오| Simple
style Strategy fill:#e8f5e9
style Template fill:#e1f5fe
style Adapter fill:#fff3e0
style Simple fill:#f5f5f5리팩토링은 점진적으로
처음부터 완벽한 패턴을 적용하려고 하지 않습니다.
1단계: 일단 동작하게 만듭니다.
// 일단 if문으로 시작
if ("A".equals(type)) {
// ...
} else if ("B".equals(type)) {
// ...
}2단계: 분기가 5개 이상 늘어나면 패턴 적용을 고민합니다.
“이거 계속 늘어나겠는데?”라는 생각이 들 때가 패턴을 적용할 타이밍입니다.
3단계: 패턴을 적용하고 팀원에게 리뷰를 요청합니다.
“이렇게 바꿨는데 어떤가요?”
팀원이 이해하기 어렵다면 다시 단순하게 바꿉니다.
세 패턴의 핵심 차이
실무에서 이 세 패턴이 헷갈릴 때가 있습니다. 한눈에 정리하면 이렇습니다.
Strategy 패턴:
- “같은 목적, 다른 방법”
- 알고리즘을 교체 가능하게
- 조합(Composition) 사용
Template Method 패턴:
- “같은 흐름, 다른 일부”
- 전체 골격을 정의하고 일부만 변경
- 상속(Inheritance) 사용
Adapter 패턴:
- “다른 인터페이스를 하나로”
- 호환되지 않는 것들을 연결
- 래핑(Wrapping) 사용
실무에서는 함께 쓰기도 합니다:
// 패턴 조합 예시: Strategy + Adapter
public interface PaymentStrategy {
void pay(PaymentRequest request);
}
// 외부 결제 API를 Adapter로 감싸서 Strategy로 사용
public class ExternalPaymentAdapter implements PaymentStrategy {
private final ExternalPaymentApi api; // Adapter 패턴
@Override
public void pay(PaymentRequest request) {
// 외부 API 호출을 우리 인터페이스에 맞춤
ExternalRequest externalRequest = convertToExternal(request);
api.process(externalRequest);
}
}패턴은 단독으로만 쓰는 게 아닙니다. 실무에서는 여러 패턴을 조합해서 씁니다.
시스템 점검 체크리스트
저도 디자인 패턴을 적용할 때 이 항목들을 확인합니다. 패턴 도입을 고려한다면 참고하시면 좋을 것 같습니다.
- 분기 개수: if-else나 switch가 3개 이상인가? (2개 이하면 패턴 불필요)
- 확장 가능성: 앞으로 새로운 방식이 추가될 예정인가? (아니면 패턴 오버)
- OCP 원칙: 새 방식 추가 시 기존 코드를 수정하지 않고 추가만 하는가?
- 팀 이해도: 팀원 전체가 이 패턴을 이해하고 유지보수할 수 있는가?
- 테스트 독립성: 각 구현체를 독립적으로 테스트할 수 있는가?
마무리
디자인 패턴을 2년간 실무에 적용하면서 느낀 점이 있습니다.
패턴을 알고 있다는 것과 잘 쓴다는 것은 다릅니다.
신입 때는 “이 코드에 어떤 패턴을 적용할까?”를 고민했습니다. 지금은 “패턴이 정말 필요할까?”를 먼저 묻습니다.
저희 팀에서 가장 칭찬받는 코드는 디자인 패턴을 멋지게 적용한 코드가 아닙니다. 신입 개발자도 10분 만에 이해할 수 있는 단순한 코드입니다.
그런데 재밌는 게 있습니다. 패턴을 제대로 이해하고 나니까, if문으로 짠 코드를 보면 “여기 Strategy 쓰면 되겠네”가 바로 보이더군요. 그게 진짜 실력인 것 같습니다.
패턴을 억지로 끼워 맞추지 마세요. 단순하게 시작하고, 변경이 잦아지면 그때 적용하면 됩니다. 망치를 들었다고 모든 게 못이 되는 건 아니니까요.
참고 :
https://refactoring.guru/design-patterns
https://www.baeldung.com/design-patterns-series
https://github.com/iluwatar/java-design-patterns
GoF의 디자인 패턴 (에릭 감마 외)
Effective Java 3/E (조슈아 블로크)
