JPA 완벽 마스터
JPA의 핵심 개념과 실전 활용법
학습 항목
이미지 로딩 중...
Java 실전 프로젝트 설계 패턴 완벽 가이드
실무에서 자주 사용되는 Java 설계 패턴을 실전 예제와 함께 학습합니다. 각 패턴의 사용 시나리오, 구현 방법, 그리고 실무 적용 팁까지 초급 개발자도 쉽게 이해할 수 있도록 구성했습니다.
목차
- 싱글톤 패턴 - 애플리케이션 전역에서 단 하나의 인스턴스만 관리
- 팩토리 패턴 - 객체 생성 로직을 캡슐화하여 유연성 확보
- 전략 패턴 - 알고리즘을 동적으로 교체 가능하게 설계
- 옵저버 패턴 - 이벤트 기반 통신으로 느슨한 결합 구현
- 데코레이터 패턴 - 객체에 동적으로 기능 추가하기
- 빌더 패턴 - 복잡한 객체를 단계적으로 생성하기
- 어댑터 패턴 - 호환되지 않는 인터페이스를 연결하기
- 템플릿 메서드 패턴 - 알고리즘 구조를 정의하고 세부 단계는 서브클래스에 위임
1. 싱글톤 패턴 - 애플리케이션 전역에서 단 하나의 인스턴스만 관리
시작하며
여러분이 데이터베이스 연결 관리자를 만들 때 이런 상황을 겪어본 적 있나요? 애플리케이션 여러 곳에서 DB 커넥션 객체를 만들다 보니 수십 개의 연결이 동시에 생성되어 리소스가 낭비되고, 심지어 연결 풀이 고갈되는 문제가 발생합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 설정 관리자, 로거, 캐시 매니저 같은 객체들은 애플리케이션 전역에서 하나만 존재해야 하는데, 이를 제대로 관리하지 않으면 메모리 낭비와 동기화 문제가 생깁니다.
바로 이럴 때 필요한 것이 싱글톤 패턴입니다. 클래스의 인스턴스가 딱 하나만 생성되도록 보장하고, 어디서든 동일한 인스턴스에 접근할 수 있게 해줍니다.
개요
간단히 말해서, 싱글톤 패턴은 클래스의 인스턴스를 오직 하나만 생성하고, 전역적으로 접근할 수 있는 지점을 제공하는 디자인 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하자면, 애플리케이션 설정, 데이터베이스 연결 풀, 로깅 시스템 같은 리소스는 여러 개 만들 필요가 없을 뿐만 아니라, 여러 개 만들면 오히려 문제가 됩니다.
예를 들어, 애플리케이션 설정 객체가 여러 개 생기면 각각 다른 설정값을 가질 수 있어 일관성이 깨집니다. 전통적인 방법으로는 개발자가 "한 번만 생성하자"고 약속하고 조심스럽게 코드를 작성했다면, 싱글톤 패턴을 사용하면 언어 차원에서 이를 강제하여 실수를 방지할 수 있습니다.
이 패턴의 핵심 특징은 첫째, 생성자를 private으로 만들어 외부에서 인스턴스를 생성하지 못하게 하고, 둘째, static 메서드를 통해 인스턴스에 접근하도록 하며, 셋째, 멀티스레드 환경에서도 안전하게 동작하도록 동기화 처리를 한다는 점입니다. 이러한 특징들이 중요한 이유는 실무에서 대부분의 애플리케이션이 멀티스레드로 동작하기 때문입니다.
코드 예제
public class DatabaseConnection {
// 유일한 인스턴스를 저장할 static 변수
private static volatile DatabaseConnection instance;
private Connection connection;
// 외부에서 인스턴스 생성을 막기 위한 private 생성자
private DatabaseConnection() {
// DB 연결 초기화 작업
this.connection = createConnection();
}
// Double-Checked Locking으로 스레드 안전성 보장
public static DatabaseConnection getInstance() {
if (instance == null) {
synchronized (DatabaseConnection.class) {
if (instance == null) {
instance = new DatabaseConnection();
}
}
}
return instance;
}
public Connection getConnection() {
return this.connection;
}
}
설명
이것이 하는 일: 싱글톤 패턴은 애플리케이션이 실행되는 동안 특정 클래스의 인스턴스를 딱 하나만 만들고, 모든 곳에서 동일한 인스턴스를 공유하도록 합니다. 첫 번째로, private 생성자가 핵심입니다.
일반적으로 클래스는 new 키워드로 인스턴스를 생성하지만, 생성자를 private으로 선언하면 외부에서 인스턴스를 만들 수 없게 됩니다. 이렇게 하는 이유는 인스턴스 생성을 클래스 내부에서만 통제하기 위함입니다.
그 다음으로, getInstance() 정적 메서드가 실행되면서 인스턴스 생성을 관리합니다. 내부에서 먼저 instance == null을 체크하여 인스턴스가 없을 때만 생성하고, 이미 있으면 기존 인스턴스를 반환합니다.
Double-Checked Locking 기법을 사용하여 첫 번째 null 체크는 동기화 없이 수행하고, 두 번째 체크는 synchronized 블록 안에서 수행합니다. 마지막으로, volatile 키워드가 인스턴스 변수에 적용되어 멀티스레드 환경에서 메모리 가시성 문제를 해결합니다.
volatile을 사용하면 한 스레드에서 변경한 값을 다른 스레드에서 즉시 볼 수 있어, 인스턴스가 완전히 초기화되기 전에 다른 스레드가 접근하는 것을 방지합니다. 여러분이 이 코드를 사용하면 데이터베이스 연결이 애플리케이션 전체에서 하나만 유지되어 리소스를 절약할 수 있고, 연결 상태를 일관되게 관리할 수 있으며, 멀티스레드 환경에서도 안전하게 동작하는 이점을 얻을 수 있습니다.
실전 팁
💡 Enum을 사용한 싱글톤이 가장 안전합니다. public enum DatabaseConnection { INSTANCE; }처럼 작성하면 직렬화와 리플렉션 공격에도 안전하며, 코드도 훨씬 간결해집니다.
💡 Spring 같은 프레임워크를 사용한다면 직접 싱글톤을 구현하지 마세요. 스프링의 기본 빈 스코프가 싱글톤이므로 @Component나 @Service만 붙이면 자동으로 싱글톤으로 관리됩니다.
💡 싱글톤은 테스트하기 어렵다는 단점이 있습니다. 의존성 주입(DI)을 활용하여 싱글톤 인스턴스를 주입받도록 설계하면 테스트 시 Mock 객체로 교체할 수 있습니다.
💡 초기화가 오래 걸리는 싱글톤이라면 Lazy Initialization 대신 Eager Initialization을 고려하세요. private static final DatabaseConnection instance = new DatabaseConnection();처럼 클래스 로딩 시점에 생성하면 동기화 오버헤드가 없습니다.
💡 싱글톤을 너무 많이 사용하면 전역 상태가 늘어나 코드 간 결합도가 높아집니다. 정말 전역적으로 하나만 필요한 경우에만 사용하고, 대부분의 경우 의존성 주입을 통한 일반 객체 사용을 권장합니다.
2. 팩토리 패턴 - 객체 생성 로직을 캡슐화하여 유연성 확보
시작하며
여러분이 결제 시스템을 개발할 때 이런 상황을 겪어본 적 있나요? 카드 결제, 계좌이체, 페이팔, 카카오페이 등 다양한 결제 수단을 지원해야 하는데, 클라이언트 코드 곳곳에서 new CreditCardPayment(), new BankTransferPayment() 같은 생성 코드가 산재되어 있어 새로운 결제 수단을 추가할 때마다 여러 곳을 수정해야 합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 객체 생성 로직이 비즈니스 로직과 섞여 있으면 코드가 복잡해지고, 새로운 타입을 추가하거나 생성 로직을 변경할 때 수많은 곳을 찾아다니며 수정해야 합니다.
이는 유지보수를 어렵게 만들고 버그 발생 가능성을 높입니다. 바로 이럴 때 필요한 것이 팩토리 패턴입니다.
객체 생성 로직을 별도의 팩토리 클래스로 분리하여, 클라이언트는 어떤 객체를 만들지만 결정하고 실제 생성은 팩토리에 위임합니다.
개요
간단히 말해서, 팩토리 패턴은 객체 생성 로직을 별도의 팩토리 클래스에 캡슐화하여, 클라이언트가 구체적인 클래스를 알지 못해도 객체를 생성할 수 있게 하는 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하자면, 객체 생성이 복잡하거나 조건에 따라 다른 타입의 객체를 만들어야 할 때 코드가 지저분해집니다.
결제 시스템에서 사용자가 선택한 결제 수단에 따라 다른 결제 객체를 생성해야 하는 경우, 이 로직이 여러 곳에 흩어져 있으면 관리가 어렵습니다. 전통적인 방법으로는 클라이언트 코드에서 직접 new 키워드로 객체를 생성하고 조건문으로 분기 처리했다면, 팩토리 패턴을 사용하면 생성 로직을 한 곳에 모아 관리할 수 있고, 새로운 타입 추가 시 팩토리만 수정하면 됩니다.
이 패턴의 핵심 특징은 첫째, 객체 생성과 사용을 분리하여 Single Responsibility Principle을 지키고, 둘째, 공통 인터페이스를 구현한 다양한 클래스를 생성할 수 있으며, 셋째, Open/Closed Principle을 따라 확장에는 열려있고 수정에는 닫혀있다는 점입니다. 이러한 특징들이 중요한 이유는 실무에서 요구사항은 계속 변경되고 새로운 타입이 추가되기 때문입니다.
코드 예제
// 공통 인터페이스 정의
interface Payment {
void processPayment(double amount);
}
// 구체적인 결제 방식 구현
class CreditCardPayment implements Payment {
public void processPayment(double amount) {
System.out.println("카드로 " + amount + "원 결제");
}
}
class BankTransferPayment implements Payment {
public void processPayment(double amount) {
System.out.println("계좌이체로 " + amount + "원 결제");
}
}
// 팩토리 클래스: 객체 생성 로직을 캡슐화
class PaymentFactory {
public static Payment createPayment(String type) {
// 결제 타입에 따라 적절한 객체 생성
switch (type.toLowerCase()) {
case "card":
return new CreditCardPayment();
case "bank":
return new BankTransferPayment();
default:
throw new IllegalArgumentException("지원하지 않는 결제 방식: " + type);
}
}
}
// 클라이언트 코드: 구체적인 클래스를 몰라도 사용 가능
Payment payment = PaymentFactory.createPayment("card");
payment.processPayment(10000);
설명
이것이 하는 일: 팩토리 패턴은 복잡한 객체 생성 로직을 팩토리 클래스에 위임하여, 클라이언트 코드를 간결하게 만들고 새로운 객체 타입 추가를 쉽게 합니다. 첫 번째로, 공통 인터페이스인 Payment를 정의하여 모든 결제 방식이 따라야 할 계약을 명시합니다.
이렇게 하는 이유는 클라이언트가 구체적인 클래스가 아닌 인터페이스에 의존하도록 하여, 실제 구현체가 무엇인지 몰라도 사용할 수 있게 하기 위함입니다. 그 다음으로, PaymentFactory.createPayment() 메서드가 실행되면서 전달받은 타입 문자열에 따라 적절한 구체 클래스를 생성합니다.
내부에서는 switch문으로 타입을 판단하고, 각 케이스마다 해당하는 구체 클래스를 인스턴스화합니다. 만약 지원하지 않는 타입이 들어오면 예외를 던져 잘못된 사용을 방지합니다.
마지막으로, 클라이언트 코드는 팩토리를 통해 받은 Payment 인터페이스 타입의 객체로 작업합니다. 클라이언트는 CreditCardPayment나 BankTransferPayment 같은 구체 클래스를 전혀 알 필요가 없고, 그저 processPayment() 메서드를 호출하면 됩니다.
이렇게 하면 새로운 결제 수단을 추가할 때 클라이언트 코드는 전혀 수정할 필요가 없습니다. 여러분이 이 코드를 사용하면 객체 생성 로직이 한 곳에 집중되어 관리가 쉬워지고, 새로운 결제 수단 추가 시 팩토리 클래스만 수정하면 되며, 클라이언트 코드가 구체 클래스에 의존하지 않아 결합도가 낮아지는 이점을 얻을 수 있습니다.
실전 팁
💡 팩토리 메서드를 static으로 만들면 팩토리 인스턴스를 만들 필요 없이 바로 사용할 수 있어 편리합니다. 하지만 테스트나 확장성을 고려한다면 인스턴스 메서드로 만들고 인터페이스를 구현하는 것이 좋습니다.
💡 switch문 대신 Map을 활용하면 더 깔끔합니다. Map<String, Supplier<Payment>> factoryMap을 만들어 타입별 생성자를 등록하고, factoryMap.get(type).get()으로 객체를 생성하세요. 새로운 타입 추가가 더 직관적입니다.
💡 팩토리에서 복잡한 초기화가 필요하다면 Builder 패턴과 함께 사용하세요. 팩토리가 빌더를 반환하고, 빌더로 세부 설정을 한 뒤 최종 객체를 생성하는 방식으로 조합하면 강력합니다.
💡 Spring을 사용한다면 @Component와 @Qualifier로 팩토리 패턴을 구현할 수 있습니다. Map으로 빈들을 주입받아 타입에 따라 반환하면, 새로운 구현체는 빈으로만 등록하면 자동으로 팩토리에 포함됩니다.
💡 팩토리가 너무 복잡해진다면 Abstract Factory 패턴으로 진화시키세요. 관련된 객체들을 함께 생성해야 할 때(예: Windows UI와 Mac UI를 각각 다른 팩토리로), 팩토리 자체를 추상화하면 더 체계적으로 관리할 수 있습니다.
3. 전략 패턴 - 알고리즘을 동적으로 교체 가능하게 설계
시작하며
여러분이 전자상거래 시스템에서 할인 정책을 구현할 때 이런 상황을 겪어본 적 있나요? 신규 회원 10% 할인, VIP 회원 20% 할인, 시즌 특가 30% 할인 등 다양한 할인 정책이 있는데, 조건문으로 분기 처리하다 보니 코드가 수백 줄로 길어지고, 새로운 할인 정책을 추가할 때마다 기존 코드를 수정해야 하는 상황이 발생합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. if-else나 switch문이 중첩되면서 코드 복잡도가 기하급수적으로 증가하고, 한 곳에 모든 알고리즘이 몰려있어 테스트도 어렵습니다.
게다가 새로운 정책 추가나 기존 정책 수정 시 다른 정책에 영향을 줄 위험이 있습니다. 바로 이럴 때 필요한 것이 전략 패턴입니다.
각 알고리즘을 별도의 클래스로 캡슐화하고, 실행 시점에 원하는 알고리즘을 선택하여 사용할 수 있게 합니다.
개요
간단히 말해서, 전략 패턴은 비슷한 계열의 알고리즘들을 각각 별도의 클래스로 캡슐화하고, 이들을 상호 교환 가능하게 만들어 실행 중에 알고리즘을 선택할 수 있게 하는 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하자면, 같은 작업을 여러 방식으로 수행해야 할 때가 많습니다.
데이터 압축을 ZIP, RAR, 7Z로 할 수 있고, 정렬을 QuickSort, MergeSort로 할 수 있듯이, 상황에 따라 최적의 알고리즘을 선택해야 합니다. 예를 들어, 주문 금액에 따라 다른 할인 정책을 적용하는 경우, 각 정책을 독립적으로 관리하고 필요할 때 교체할 수 있어야 합니다.
전통적인 방법으로는 거대한 조건문으로 모든 경우를 처리했다면, 전략 패턴을 사용하면 각 알고리즘을 독립된 클래스로 분리하여 추가, 수정, 삭제가 자유롭고, 코드 재사용성도 높아집니다. 이 패턴의 핵심 특징은 첫째, 알고리즘을 사용하는 클라이언트와 알고리즘 구현을 분리하여 독립적으로 변경할 수 있고, 둘째, 조건문을 다형성으로 대체하여 코드가 깔끔해지며, 셋째, 런타임에 전략을 동적으로 교체할 수 있다는 점입니다.
이러한 특징들이 중요한 이유는 비즈니스 로직이 복잡해질수록 유연성과 확장성이 생산성에 직결되기 때문입니다.
코드 예제
// 전략 인터페이스: 모든 할인 정책의 공통 계약
interface DiscountStrategy {
double applyDiscount(double price);
}
// 구체적인 전략 구현들
class NewCustomerDiscount implements DiscountStrategy {
public double applyDiscount(double price) {
// 신규 회원 10% 할인
return price * 0.9;
}
}
class VIPDiscount implements DiscountStrategy {
public double applyDiscount(double price) {
// VIP 회원 20% 할인
return price * 0.8;
}
}
// Context: 전략을 사용하는 클래스
class ShoppingCart {
private DiscountStrategy discountStrategy;
// 전략을 동적으로 설정 가능
public void setDiscountStrategy(DiscountStrategy strategy) {
this.discountStrategy = strategy;
}
public double checkout(double price) {
// 설정된 전략을 사용하여 할인 적용
if (discountStrategy != null) {
return discountStrategy.applyDiscount(price);
}
return price;
}
}
// 사용 예시: 실행 중에 전략 변경 가능
ShoppingCart cart = new ShoppingCart();
cart.setDiscountStrategy(new VIPDiscount());
double finalPrice = cart.checkout(10000); // 8000원
설명
이것이 하는 일: 전략 패턴은 다양한 알고리즘을 각각 독립적인 클래스로 만들고, 클라이언트가 필요에 따라 알고리즘을 선택하고 교체할 수 있게 하여 코드의 유연성을 극대화합니다. 첫 번째로, DiscountStrategy 인터페이스가 모든 할인 정책의 공통 계약을 정의합니다.
이렇게 하는 이유는 다양한 할인 정책들이 동일한 메서드 시그니처를 갖게 하여, 클라이언트 코드가 구체적인 전략을 몰라도 일관된 방식으로 사용할 수 있게 하기 위함입니다. 모든 전략이 applyDiscount(double price) 메서드를 구현하므로, 어떤 전략을 사용하든 호출 방법은 동일합니다.
그 다음으로, 각 구체적인 전략 클래스(NewCustomerDiscount, VIPDiscount)가 인터페이스를 구현하면서 자신만의 알고리즘을 제공합니다. 각 클래스는 독립적으로 존재하므로, 한 전략을 수정해도 다른 전략에 영향을 주지 않습니다.
예를 들어, VIP 할인율을 20%에서 25%로 변경해도 신규 회원 할인 로직은 전혀 건드리지 않습니다. 마지막으로, ShoppingCart 클래스는 전략 객체를 필드로 가지고 있다가, checkout() 메서드 실행 시 해당 전략의 applyDiscount()를 호출합니다.
중요한 점은 setDiscountStrategy() 메서드를 통해 언제든지 전략을 변경할 수 있다는 것입니다. 같은 장바구니 객체로 처음에는 일반 할인을 적용하다가, 사용자가 VIP로 업그레이드되면 즉시 VIP 할인으로 전환할 수 있습니다.
여러분이 이 코드를 사용하면 새로운 할인 정책 추가 시 기존 코드를 전혀 수정하지 않고 새 클래스만 만들면 되고, 각 전략을 독립적으로 테스트할 수 있으며, 복잡한 조건문이 사라져 코드 가독성이 크게 향상되는 이점을 얻을 수 있습니다.
실전 팁
💡 전략 객체가 상태를 가지지 않는다면(stateless) 싱글톤으로 관리하거나 Enum으로 구현하세요. 매번 새로운 인스턴스를 만들 필요 없이 재사용하면 메모리 효율이 좋아집니다.
💡 람다 표현식을 활용하면 간단한 전략은 별도 클래스 없이 인라인으로 정의할 수 있습니다. cart.setDiscountStrategy(price -> price * 0.9)처럼 작성하면 코드가 더 간결해집니다.
💡 전략이 많아지면 Factory 패턴과 조합하세요. DiscountStrategyFactory.getStrategy("VIP")처럼 팩토리에서 전략을 가져오면, 전략 선택 로직까지 캡슐화되어 클라이언트 코드가 더 깔끔해집니다.
💡 Spring에서는 전략들을 빈으로 등록하고 Map으로 주입받아 사용하세요. Map<String, DiscountStrategy> 타입으로 주입받으면, 빈 이름으로 전략을 선택할 수 있어 설정 파일로 전략을 제어할 수 있습니다.
💡 전략이 복잡한 초기화를 필요로 한다면 생성자 주입을 활용하세요. 전략 객체 생성 시 필요한 의존성을 주입받도록 하면, 전략 자체가 더 복잡한 비즈니스 로직을 수행할 수 있습니다.
4. 옵저버 패턴 - 이벤트 기반 통신으로 느슨한 결합 구현
시작하며
여러분이 뉴스 구독 시스템을 만들 때 이런 상황을 겪어본 적 있나요? 새로운 뉴스가 등록되면 이메일 서비스, SMS 서비스, 푸시 알림 서비스 등 여러 서비스에 알려야 하는데, 뉴스 등록 코드 안에서 각 서비스의 메서드를 직접 호출하다 보니 새로운 알림 수단을 추가할 때마다 뉴스 등록 코드를 수정해야 합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 한 객체의 상태 변화가 여러 객체에 영향을 줘야 하는데, 직접 호출 방식으로 구현하면 결합도가 높아져 유지보수가 어렵습니다.
뉴스 등록 로직은 알림 전송 로직과 분리되어야 하는데, 서로 강하게 의존하게 되면 코드 변경이 연쇄적으로 발생합니다. 바로 이럴 때 필요한 것이 옵저버 패턴입니다.
주체(Subject)와 관찰자(Observer)를 분리하여, 주체의 상태가 변경되면 등록된 모든 관찰자에게 자동으로 알림이 가도록 합니다.
개요
간단히 말해서, 옵저버 패턴은 한 객체의 상태 변화를 관심 있는 다른 객체들에게 자동으로 알려주는 일대다 의존 관계를 정의하는 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하자면, 이벤트 기반 시스템에서는 이벤트 발생자와 이벤트 처리자를 분리해야 합니다.
사용자 정보가 업데이트되면 캐시 갱신, 로그 기록, 이메일 발송 등 여러 작업이 수행되어야 하는데, 이들을 모두 하나의 메서드에 넣으면 Single Responsibility Principle을 위반합니다. 예를 들어, 주문 완료 시 재고 차감, 결제 처리, 배송 준비, 알림 발송 등을 각각 독립적인 옵저버로 만들면 관심사가 깔끔하게 분리됩니다.
전통적인 방법으로는 상태 변경 후 관련된 모든 객체의 메서드를 직접 호출했다면, 옵저버 패턴을 사용하면 주체는 관찰자가 누구인지, 무엇을 하는지 몰라도 되고, 새로운 관찰자를 추가하거나 제거하는 것이 자유롭습니다. 이 패턴의 핵심 특징은 첫째, 주체와 관찰자 사이의 결합도가 낮아 서로 독립적으로 변경 가능하고, 둘째, 런타임에 관찰자를 동적으로 등록/해제할 수 있으며, 셋째, 브로드캐스트 방식으로 여러 객체에 동시에 알림을 보낼 수 있다는 점입니다.
이러한 특징들이 중요한 이유는 현대 애플리케이션은 대부분 이벤트 기반으로 동작하기 때문입니다.
코드 예제
import java.util.*;
// 옵저버 인터페이스: 모든 관찰자가 구현해야 할 계약
interface Observer {
void update(String news);
}
// 주체 인터페이스: 관찰자 관리 기능 정의
interface Subject {
void attach(Observer observer);
void detach(Observer observer);
void notifyObservers();
}
// 구체적인 주체: 뉴스 피드
class NewsFeed implements Subject {
private List<Observer> observers = new ArrayList<>();
private String latestNews;
public void attach(Observer observer) {
observers.add(observer);
}
public void detach(Observer observer) {
observers.remove(observer);
}
// 모든 등록된 관찰자에게 알림
public void notifyObservers() {
for (Observer observer : observers) {
observer.update(latestNews);
}
}
// 뉴스 등록: 상태 변경 후 자동으로 알림
public void addNews(String news) {
this.latestNews = news;
notifyObservers();
}
}
// 구체적인 관찰자들
class EmailNotifier implements Observer {
public void update(String news) {
System.out.println("이메일 발송: " + news);
}
}
class SMSNotifier implements Observer {
public void update(String news) {
System.out.println("SMS 발송: " + news);
}
}
// 사용 예시
NewsFeed feed = new NewsFeed();
feed.attach(new EmailNotifier());
feed.attach(new SMSNotifier());
feed.addNews("새로운 기사가 등록되었습니다"); // 모든 옵저버에게 알림
설명
이것이 하는 일: 옵저버 패턴은 주체 객체가 자신의 상태 변화를 직접 알리지 않고, 등록된 관찰자들에게 자동으로 통지하는 구조를 만들어 이벤트 기반 프로그래밍을 가능하게 합니다. 첫 번째로, Observer 인터페이스가 모든 관찰자가 구현해야 할 update() 메서드를 정의합니다.
이렇게 하는 이유는 주체가 구체적인 관찰자 클래스를 알 필요 없이, 인터페이스 타입으로만 관찰자를 관리할 수 있게 하기 위함입니다. 주체는 "누가 관찰하는지"는 모르고 "어떻게 알릴지"만 알면 됩니다.
그 다음으로, NewsFeed 클래스는 관찰자 목록을 내부적으로 관리하면서 attach()와 detach() 메서드로 관찰자를 등록하고 제거합니다. 중요한 것은 addNews() 메서드가 호출되면, 내부 상태(latestNews)를 업데이트한 후 자동으로 notifyObservers()를 호출한다는 점입니다.
이렇게 하면 상태 변경과 알림이 항상 함께 일어나 일관성이 보장됩니다. 마지막으로, notifyObservers() 메서드가 실행될 때 등록된 모든 관찰자의 update() 메서드를 순회하며 호출합니다.
각 관찰자는 자신만의 방식으로 알림을 처리합니다. EmailNotifier는 이메일을 보내고, SMSNotifier는 SMS를 보내는 식으로, 주체는 각 관찰자가 무엇을 하는지 전혀 모르지만 모두에게 알림이 전달됩니다.
여러분이 이 코드를 사용하면 새로운 알림 수단(예: 푸시 알림)을 추가할 때 NewsFeed 코드를 전혀 수정하지 않고 새 옵저버 클래스만 만들어 등록하면 되고, 관찰자를 런타임에 자유롭게 추가/제거할 수 있으며, 주체와 관찰자가 독립적으로 진화할 수 있는 이점을 얻을 수 있습니다.
실전 팁
💡 Java의 java.util.Observable과 Observer는 deprecated되었으니 사용하지 마세요. 대신 직접 구현하거나 RxJava, Spring Events 같은 라이브러리를 활용하세요.
💡 관찰자가 많아지면 비동기 처리를 고려하세요. CompletableFuture나 스레드 풀을 사용하여 각 관찰자를 병렬로 실행하면, 한 관찰자의 느린 처리가 다른 관찰자에게 영향을 주지 않습니다.
💡 옵저버 패턴은 메모리 누수의 원인이 될 수 있습니다. 관찰자가 더 이상 필요 없으면 반드시 detach()로 등록을 해제하세요. 특히 GUI 컴포넌트나 Activity가 관찰자인 경우 주의해야 합니다.
💡 Spring을 사용한다면 @EventListener와 ApplicationEventPublisher로 더 간단하게 구현할 수 있습니다. 이벤트 객체를 발행하면 해당 이벤트를 리스닝하는 모든 메서드가 자동으로 호출됩니다.
💡 Push 방식과 Pull 방식을 구분하세요. 현재 코드는 Push 방식으로 주체가 데이터를 전달하지만, Pull 방식으로 관찰자가 주체로부터 필요한 데이터를 가져오게 할 수도 있습니다. Pull 방식은 관찰자가 원하는 정보만 선택적으로 가져올 수 있어 유연합니다.
5. 데코레이터 패턴 - 객체에 동적으로 기능 추가하기
시작하며
여러분이 커피 주문 시스템을 만들 때 이런 상황을 겪어본 적 있나요? 기본 커피에 우유, 시럽, 휘핑크림 등 다양한 옵션을 추가할 수 있는데, 모든 조합을 클래스로 만들다 보니 CoffeeWithMilk, CoffeeWithMilkAndSyrup, CoffeeWithMilkAndSyrupAndWhippedCream 같은 클래스가 수십 개 생기고, 새로운 옵션을 추가할 때마다 클래스 개수가 기하급수적으로 늘어납니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 상속으로 기능을 확장하면 조합 폭발 문제가 발생하고, 클래스 계층이 복잡해져 관리가 어렵습니다.
게다가 컴파일 타임에 기능이 고정되어 런타임에 동적으로 기능을 추가하거나 제거할 수 없습니다. 바로 이럴 때 필요한 것이 데코레이터 패턴입니다.
기본 객체를 여러 데코레이터로 감싸서 기능을 동적으로 추가하고, 필요에 따라 데코레이터를 조합하여 원하는 기능을 만들어냅니다.
개요
간단히 말해서, 데코레이터 패턴은 객체에 추가적인 기능을 동적으로 덧붙이는 패턴으로, 상속 대신 조합을 사용하여 기능을 확장합니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하자면, 기본 기능에 선택적인 부가 기능을 조합해야 하는 경우가 많습니다.
파일 입출력에서 기본 스트림에 버퍼링, 압축, 암호화 등을 선택적으로 적용하거나, UI 컴포넌트에 스크롤, 테두리, 그림자 효과를 조합하는 경우가 대표적입니다. 예를 들어, 기본 텍스트 메시지에 암호화, 압축, Base64 인코딩을 순차적으로 적용해야 한다면, 각 기능을 독립된 데코레이터로 만들어 조합할 수 있습니다.
전통적인 방법으로는 모든 조합에 대해 서브클래스를 만들었다면, 데코레이터 패턴을 사용하면 각 기능을 독립된 데코레이터로 만들고 필요한 만큼 중첩하여 사용할 수 있습니다. 이 패턴의 핵심 특징은 첫째, 상속 없이 기능을 확장할 수 있어 Open/Closed Principle을 지키고, 둘째, 런타임에 동적으로 기능을 추가하거나 제거할 수 있으며, 셋째, 단일 책임 원칙을 지키면서 여러 기능을 조합할 수 있다는 점입니다.
이러한 특징들이 중요한 이유는 요구사항에 따라 기능 조합이 자주 변경되는 실무 환경에서 유연성을 제공하기 때문입니다.
코드 예제
// 기본 인터페이스: 커피의 공통 계약
interface Coffee {
String getDescription();
double getCost();
}
// 기본 구현: 심플 커피
class SimpleCoffee implements Coffee {
public String getDescription() {
return "심플 커피";
}
public double getCost() {
return 2000;
}
}
// 데코레이터 추상 클래스: 모든 데코레이터의 기반
abstract class CoffeeDecorator implements Coffee {
protected Coffee decoratedCoffee;
public CoffeeDecorator(Coffee coffee) {
this.decoratedCoffee = coffee;
}
}
// 구체적인 데코레이터들
class MilkDecorator extends CoffeeDecorator {
public MilkDecorator(Coffee coffee) {
super(coffee);
}
public String getDescription() {
return decoratedCoffee.getDescription() + ", 우유";
}
public double getCost() {
return decoratedCoffee.getCost() + 500;
}
}
class SyrupDecorator extends CoffeeDecorator {
public SyrupDecorator(Coffee coffee) {
super(coffee);
}
public String getDescription() {
return decoratedCoffee.getDescription() + ", 시럽";
}
public double getCost() {
return decoratedCoffee.getCost() + 300;
}
}
// 사용 예시: 데코레이터를 중첩하여 기능 조합
Coffee coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SyrupDecorator(coffee);
System.out.println(coffee.getDescription() + " = " + coffee.getCost() + "원");
// 출력: 심플 커피, 우유, 시럽 = 2800원
설명
이것이 하는 일: 데코레이터 패턴은 기본 객체를 여러 겹의 래퍼로 감싸면서 각 래퍼가 자신의 기능을 추가하여, 최종적으로 모든 기능이 조합된 객체를 만들어냅니다. 첫 번째로, Coffee 인터페이스가 기본 객체와 데코레이터 모두가 구현해야 할 공통 계약을 정의합니다.
이렇게 하는 이유는 데코레이터가 감싼 객체와 동일한 인터페이스를 제공하여, 클라이언트가 기본 객체를 사용하든 데코레이터로 감싼 객체를 사용하든 동일한 방식으로 다룰 수 있게 하기 위함입니다. 이것이 바로 투명성(transparency)입니다.
그 다음으로, CoffeeDecorator 추상 클래스는 감쌀 대상 객체(decoratedCoffee)를 필드로 가지고 있습니다. 모든 구체적인 데코레이터는 이 추상 클래스를 상속받아, 생성자에서 감쌀 객체를 받고, 메서드 내부에서는 감싼 객체의 메서드를 먼저 호출한 후 자신의 기능을 추가합니다.
예를 들어, MilkDecorator의 getCost()는 decoratedCoffee.getCost()로 기존 가격을 가져온 후 우유 가격 500원을 더합니다. 마지막으로, 클라이언트 코드에서는 기본 객체를 생성하고, 그것을 데코레이터로 계속 감싸면서 기능을 추가합니다.
new SyrupDecorator(new MilkDecorator(new SimpleCoffee()))처럼 중첩된 구조가 만들어지며, 최종적으로 coffee.getCost()를 호출하면 가장 바깥쪽 데코레이터부터 시작해서 안쪽으로 차례로 실행되며 각 층의 기능이 더해집니다. 여러분이 이 코드를 사용하면 새로운 옵션(예: 휘핑크림)을 추가할 때 기존 코드를 수정하지 않고 새 데코레이터만 만들면 되고, 런타임에 원하는 조합을 자유롭게 구성할 수 있으며, 각 기능이 독립된 클래스로 분리되어 테스트와 유지보수가 쉬워지는 이점을 얻을 수 있습니다.
실전 팁
💡 Java의 I/O 스트림이 대표적인 데코레이터 패턴 예시입니다. new BufferedReader(new InputStreamReader(new FileInputStream("file.txt")))처럼 여러 데코레이터를 조합하여 사용하는 구조를 참고하세요.
💡 데코레이터가 너무 많이 중첩되면 디버깅이 어렵습니다. 스택 트레이스가 깊어지고 어떤 데코레이터가 적용되었는지 추적하기 힘들 수 있으니, 빌더 패턴과 조합하여 명시적으로 구성하는 것도 좋은 방법입니다.
💡 데코레이터 순서가 중요할 수 있습니다. 압축 후 암호화와 암호화 후 압축은 결과가 다를 수 있으니, 데코레이터 적용 순서를 문서화하고 테스트하세요.
💡 성능이 중요하다면 데코레이터 체인의 깊이를 모니터링하세요. 매번 메서드 호출이 여러 층을 거치므로, 체인이 너무 길면 오버헤드가 발생할 수 있습니다. 필요시 플라이웨이트 패턴과 조합하여 데코레이터를 재사용하세요.
💡 타입 체킹이 필요하다면 각 데코레이터에 고유한 메서드를 추가하지 마세요. 그러면 데코레이터의 투명성이 깨집니다. 대신 Visitor 패턴이나 타입별로 다른 인터페이스를 분리하는 방법을 고려하세요.
6. 빌더 패턴 - 복잡한 객체를 단계적으로 생성하기
시작하며
여러분이 사용자 프로필 객체를 만들 때 이런 상황을 겪어본 적 있나요? 이름, 이메일, 전화번호, 주소, 생년월일, 프로필 사진 등 수십 개의 필드가 있는데, 생성자 파라미터가 너무 많아져서 어떤 순서로 값을 넣어야 하는지 헷갈리고, 선택적인 필드 때문에 여러 개의 오버로딩된 생성자를 만들다 보니 코드가 지저분해집니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 설정 객체나 DTO처럼 많은 속성을 가진 객체를 생성할 때, 텔레스코핑 생성자 패턴(매개변수가 점점 늘어나는 생성자들)이나 자바빈즈 패턴(setter 남발)은 각각 가독성과 불변성 문제를 일으킵니다.
게다가 필수 필드와 선택 필드를 구분하기 어렵고, 객체 생성 중간에 일관성이 깨질 수 있습니다. 바로 이럴 때 필요한 것이 빌더 패턴입니다.
복잡한 객체 생성 과정을 단계별로 나누고, 가독성 좋은 메서드 체이닝으로 객체를 구성하며, 최종적으로 완전한 객체를 반환합니다.
개요
간단히 말해서, 빌더 패턴은 복잡한 객체의 생성 과정과 표현 방법을 분리하여, 동일한 생성 절차로 서로 다른 표현 결과를 만들 수 있게 하는 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하자면, 많은 매개변수를 가진 객체를 생성할 때 코드 가독성과 유지보수성이 매우 중요합니다.
HTTP 요청 객체를 만들 때 URL, 헤더, 바디, 타임아웃, 재시도 정책 등 많은 설정이 필요한데, 이를 생성자로 받으면 new HttpRequest(url, null, body, null, 3000, null, true, false, ...)처럼 null이 많이 섞여 무엇이 무엇인지 알 수 없습니다. 예를 들어, 복잡한 쿼리 객체를 만들 때 WHERE 절, JOIN 절, ORDER BY 절을 선택적으로 추가하면서도 최종적으로 유효한 쿼리를 보장해야 합니다.
전통적인 방법으로는 수많은 오버로딩된 생성자를 만들거나 setter를 사용했다면, 빌더 패턴을 사용하면 명확한 메서드 이름으로 각 속성을 설정하고, 메서드 체이닝으로 코드를 읽기 쉽게 만들며, 불변 객체를 생성할 수 있습니다. 이 패턴의 핵심 특징은 첫째, 가독성 높은 코드로 복잡한 객체를 생성할 수 있고, 둘째, 생성 과정에서 유효성 검증을 집중적으로 수행할 수 있으며, 셋째, 불변 객체를 만들면서도 편리한 생성 방식을 제공한다는 점입니다.
이러한 특징들이 중요한 이유는 복잡한 도메인 모델을 다루는 현대 애플리케이션에서 객체의 일관성과 안정성이 필수적이기 때문입니다.
코드 예제
// 복잡한 객체: 사용자 프로필
public class UserProfile {
// 필수 필드
private final String username;
private final String email;
// 선택 필드
private final String phoneNumber;
private final String address;
private final int age;
// private 생성자: 빌더를 통해서만 생성 가능
private UserProfile(Builder builder) {
this.username = builder.username;
this.email = builder.email;
this.phoneNumber = builder.phoneNumber;
this.address = builder.address;
this.age = builder.age;
}
// 정적 내부 클래스: 빌더
public static class Builder {
// 필수 필드는 생성자에서 받음
private final String username;
private final String email;
// 선택 필드는 기본값으로 초기화
private String phoneNumber = "";
private String address = "";
private int age = 0;
public Builder(String username, String email) {
this.username = username;
this.email = email;
}
// 각 필드에 대한 설정 메서드 (메서드 체이닝을 위해 this 반환)
public Builder phoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
return this;
}
public Builder address(String address) {
this.address = address;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
// 최종 객체 생성 (유효성 검증도 여기서)
public UserProfile build() {
// 유효성 검증
if (age < 0 || age > 150) {
throw new IllegalStateException("유효하지 않은 나이: " + age);
}
return new UserProfile(this);
}
}
}
// 사용 예시: 가독성 높은 객체 생성
UserProfile profile = new UserProfile.Builder("john", "john@example.com")
.phoneNumber("010-1234-5678")
.address("서울시 강남구")
.age(30)
.build();
설명
이것이 하는 일: 빌더 패턴은 객체 생성을 빌더 객체에 위임하여, 필수 필드는 생성자로 받고 선택 필드는 메서드로 설정한 뒤, 최종적으로 유효성을 검증하고 불변 객체를 반환합니다. 첫 번째로, UserProfile 클래스의 생성자를 private으로 만들어 외부에서 직접 생성하지 못하게 합니다.
이렇게 하는 이유는 객체 생성을 빌더를 통해서만 가능하게 하여, 생성 과정을 통제하고 불완전한 객체가 만들어지는 것을 방지하기 위함입니다. 모든 필드를 final로 선언하여 불변성도 보장합니다.
그 다음으로, 정적 내부 클래스인 Builder가 실제 객체 생성 작업을 담당합니다. 빌더의 생성자는 필수 필드만 받아 반드시 설정되도록 강제하고, 선택 필드는 별도의 메서드로 제공합니다.
각 설정 메서드는 return this;를 통해 빌더 자신을 반환하므로, .phoneNumber().address().age()처럼 메서드를 연쇄적으로 호출할 수 있습니다. 이를 메서드 체이닝 또는 플루언트 인터페이스라고 합니다.
마지막으로, build() 메서드가 호출되면 먼저 유효성 검증을 수행하고, 문제가 없으면 빌더 자신을 생성자에 전달하여 UserProfile 객체를 생성합니다. 유효성 검증을 build() 시점에 하면, 설정이 모두 완료된 후 한 번에 검증할 수 있어 효율적이고, 불완전한 객체가 생성되는 것을 막을 수 있습니다.
여러분이 이 코드를 사용하면 객체 생성 코드가 자기 문서화되어 읽기 쉬워지고, 필수 필드와 선택 필드를 명확히 구분할 수 있으며, 불변 객체의 장점(스레드 안전성, 예측 가능성)을 누리면서도 편리하게 객체를 생성하고, 생성 시점에 유효성을 철저히 검증할 수 있는 이점을 얻을 수 있습니다.
실전 팁
💡 Lombok의 @Builder 어노테이션을 사용하면 빌더 패턴 코드를 자동 생성할 수 있습니다. 보일러플레이트 코드를 줄이고 실수를 방지할 수 있어 실무에서 매우 유용합니다.
💡 필수 필드가 많다면 Step Builder 패턴을 고려하세요. 각 필수 필드를 설정해야만 다음 단계로 넘어갈 수 있도록 인터페이스를 설계하면, 컴파일 타임에 필수 필드 누락을 방지할 수 있습니다.
💡 빌더를 재사용할 수 있게 만들면 테스트에 유용합니다. toBuilder() 메서드를 제공하여 기존 객체를 기반으로 일부만 변경한 새 객체를 만들 수 있게 하면, 테스트 픽스처 관리가 편해집니다.
💡 빌더가 너무 비대해지면 분리를 고려하세요. 기본 빌더와 고급 빌더를 나누거나, 관련된 필드들을 그룹화한 서브 빌더를 만들어 책임을 분산시킬 수 있습니다.
💡 Jackson, Gson 같은 JSON 라이브러리와 함께 사용할 때는 기본 생성자나 @JsonCreator를 추가해야 할 수 있습니다. 빌더 패턴과 직렬화 라이브러리의 호환성을 미리 확인하세요.
7. 어댑터 패턴 - 호환되지 않는 인터페이스를 연결하기
시작하며
여러분이 레거시 결제 시스템과 새로운 결제 시스템을 통합해야 할 때 이런 상황을 겪어본 적 있나요? 레거시 시스템은 processOldPayment(String cardNumber, double amount) 메서드를 사용하는데, 새 시스템은 PaymentRequest 객체를 받는 executePayment(PaymentRequest request) 메서드를 사용하여 인터페이스가 완전히 달라서 직접 연결할 수 없습니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 서드파티 라이브러리를 통합하거나, 레거시 코드와 신규 코드를 연동하거나, 외부 API를 사용할 때 인터페이스가 맞지 않는 경우가 많습니다.
이때 기존 코드를 수정하면 사이드 이펙트가 발생할 위험이 크고, 서드파티 라이브러리는 아예 수정이 불가능합니다. 바로 이럴 때 필요한 것이 어댑터 패턴입니다.
호환되지 않는 두 인터페이스 사이에 어댑터를 두어, 한쪽 인터페이스를 다른 쪽이 기대하는 형태로 변환해줍니다.
개요
간단히 말해서, 어댑터 패턴은 한 클래스의 인터페이스를 클라이언트가 기대하는 다른 인터페이스로 변환하여, 인터페이스 불일치로 인해 함께 동작할 수 없는 클래스들이 협력할 수 있게 하는 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하자면, 시스템을 개발하다 보면 서로 다른 시기에 만들어진 컴포넌트들을 통합해야 하는 경우가 많습니다.
새로운 로깅 라이브러리로 교체하고 싶지만 기존 코드가 다른 로깅 인터페이스를 사용하고 있거나, AWS SDK를 사용하다가 Azure SDK로 전환해야 하는데 전체 코드를 고칠 수 없는 경우가 대표적입니다. 예를 들어, 기존 시스템이 XML로 데이터를 주고받는데 새 API는 JSON만 지원한다면, 어댑터가 둘 사이를 중재할 수 있습니다.
전통적인 방법으로는 래퍼 클래스를 임시로 만들어 사용했다면, 어댑터 패턴은 이를 체계화하여 명확한 역할과 책임을 부여하고, 클라이언트 코드 수정 없이 다른 구현체로 교체할 수 있게 합니다. 이 패턴의 핵심 특징은 첫째, 기존 코드를 수정하지 않고 새로운 인터페이스에 적응시킬 수 있어 Open/Closed Principle을 지키고, 둘째, 서로 다른 인터페이스 간의 번역자 역할을 하며, 셋째, 클라이언트와 실제 구현체를 분리하여 결합도를 낮춘다는 점입니다.
이러한 특징들이 중요한 이유는 실무에서는 항상 기존 시스템을 유지하면서 새로운 기술을 도입해야 하기 때문입니다.
코드 예제
// 기존 레거시 시스템 (수정 불가능)
class LegacyPaymentSystem {
public void processOldPayment(String cardNumber, double amount) {
System.out.println("레거시 시스템으로 결제: " + cardNumber + ", " + amount + "원");
}
}
// 새로운 결제 시스템이 기대하는 인터페이스
interface ModernPaymentGateway {
void executePayment(PaymentRequest request);
}
// 새 시스템의 요청 객체
class PaymentRequest {
private String cardNumber;
private double amount;
public PaymentRequest(String cardNumber, double amount) {
this.cardNumber = cardNumber;
this.amount = amount;
}
public String getCardNumber() { return cardNumber; }
public double getAmount() { return amount; }
}
// 어댑터: 레거시 시스템을 새 인터페이스에 맞춤
class PaymentAdapter implements ModernPaymentGateway {
private LegacyPaymentSystem legacySystem;
public PaymentAdapter(LegacyPaymentSystem legacySystem) {
this.legacySystem = legacySystem;
}
// 새 인터페이스 호출을 레거시 호출로 변환
public void executePayment(PaymentRequest request) {
// PaymentRequest 객체를 레거시 형식으로 변환
legacySystem.processOldPayment(
request.getCardNumber(),
request.getAmount()
);
}
}
// 사용 예시: 클라이언트는 새 인터페이스만 알면 됨
ModernPaymentGateway gateway = new PaymentAdapter(new LegacyPaymentSystem());
gateway.executePayment(new PaymentRequest("1234-5678", 50000));
설명
이것이 하는 일: 어댑터 패턴은 클라이언트가 기대하는 인터페이스를 구현하면서, 내부적으로는 호환되지 않는 다른 인터페이스를 호출하여 둘 사이를 중재합니다. 첫 번째로, LegacyPaymentSystem은 수정할 수 없는 기존 시스템입니다.
이렇게 수정 불가능한 코드가 있는 이유는 서드파티 라이브러리이거나, 다른 팀이 관리하거나, 수정 시 광범위한 영향이 우려되기 때문입니다. 이 시스템은 자신만의 인터페이스(processOldPayment)를 가지고 있어 직접 사용하기 어렵습니다.
그 다음으로, ModernPaymentGateway 인터페이스는 새로운 시스템이 기대하는 표준 인터페이스입니다. 클라이언트 코드는 이 인터페이스에 의존하도록 작성되어 있어, 모든 결제 구현체가 이 인터페이스를 따라야 합니다.
여기서 문제는 레거시 시스템이 이 인터페이스를 구현하지 않는다는 점입니다. 마지막으로, PaymentAdapter가 두 인터페이스 사이의 다리 역할을 합니다.
어댑터는 ModernPaymentGateway 인터페이스를 구현하여 클라이언트가 기대하는 형태를 제공하면서, 내부적으로는 LegacyPaymentSystem 객체를 가지고 있다가 호출을 변환합니다. executePayment()가 호출되면 PaymentRequest 객체에서 필요한 정보를 추출하여 레거시 시스템의 processOldPayment()에 전달합니다.
여러분이 이 코드를 사용하면 레거시 시스템을 수정하지 않고도 새 인터페이스로 사용할 수 있고, 나중에 레거시 시스템을 완전히 새 시스템으로 교체할 때 어댑터만 변경하면 클라이언트 코드는 그대로 유지되며, 여러 외부 시스템을 일관된 인터페이스로 통합하여 관리할 수 있는 이점을 얻을 수 있습니다.
실전 팁
💡 클래스 어댑터와 객체 어댑터를 구분하세요. 위 예제는 객체 어댑터(어댑티를 필드로 가짐)이고, 클래스 어댑터는 다중 상속으로 구현하는데 Java는 다중 상속을 지원하지 않으므로 객체 어댑터가 일반적입니다.
💡 양방향 어댑터가 필요하다면 두 인터페이스를 모두 구현하세요. 한 객체가 두 인터페이스를 모두 제공하여, 어느 쪽에서든 사용할 수 있게 만들 수 있습니다.
💡 어댑터가 복잡한 변환 로직을 가진다면 별도의 변환기(Converter) 클래스로 분리하세요. 어댑터는 인터페이스 변환에만 집중하고, 데이터 변환 로직은 다른 클래스가 담당하면 단일 책임 원칙을 지킬 수 있습니다.
💡 Spring Integration이나 Apache Camel 같은 프레임워크는 강력한 어댑터 기능을 제공합니다. 다양한 프로토콜과 데이터 형식 간 변환이 필요하다면 이런 프레임워크 활용을 고려하세요.
💡 어댑터 패턴과 프록시 패턴을 혼동하지 마세요. 어댑터는 인터페이스를 변환하는 것이 목적이고, 프록시는 접근 제어나 부가 기능 추가가 목적입니다. 목적에 따라 적절한 패턴을 선택하세요.
8. 템플릿 메서드 패턴 - 알고리즘 구조를 정의하고 세부 단계는 서브클래스에 위임
시작하며
여러분이 데이터 처리 파이프라인을 만들 때 이런 상황을 겪어본 적 있나요? CSV 파일 처리, JSON 파일 처리, XML 파일 처리 등 여러 데이터 소스를 다루는데, 모두 "파일 열기 → 데이터 읽기 → 검증 → 변환 → 저장 → 파일 닫기"라는 동일한 흐름을 따르지만, 각 단계의 구체적인 구현은 파일 형식마다 다릅니다.
이 공통 흐름을 여러 곳에서 중복 작성하다 보니 유지보수가 어렵습니다. 이런 문제는 실제 개발 현장에서 자주 발생합니다.
알고리즘의 전체 구조는 동일한데 일부 단계만 다른 경우, 코드 중복이 발생하고 흐름을 변경할 때 여러 곳을 수정해야 합니다. 게다가 각 구현체가 필수 단계를 빠뜨릴 위험도 있습니다.
바로 이럴 때 필요한 것이 템플릿 메서드 패턴입니다. 알고리즘의 골격을 상위 클래스에 정의하고, 구체적인 단계들은 추상 메서드로 남겨두어 하위 클래스가 구현하도록 합니다.
개요
간단히 말해서, 템플릿 메서드 패턴은 알고리즘의 구조를 상위 클래스에서 정의하되, 알고리즘의 각 단계 중 일부를 하위 클래스에서 구현할 수 있도록 하는 패턴입니다. 왜 이 패턴이 필요한지 실무 관점에서 설명하자면, 비즈니스 로직이 복잡해질수록 공통 흐름과 변경 가능한 부분을 명확히 분리하는 것이 중요합니다.
웹 애플리케이션의 요청 처리 과정(인증 → 권한 확인 → 비즈니스 로직 실행 → 응답 생성)이나, 게임의 턴 진행(입력 받기 → 검증 → 상태 업데이트 → 렌더링)처럼 정해진 순서가 있는 작업이 대표적입니다. 예를 들어, 다양한 리포트 생성 시스템에서 "데이터 수집 → 분석 → 포맷팅 → 저장"이라는 공통 흐름을 유지하면서, 각 리포트 타입별로 분석과 포맷팅 방법만 다르게 구현할 수 있습니다.
전통적인 방법으로는 각 구현체가 전체 흐름을 자체적으로 구현했다면, 템플릿 메서드 패턴을 사용하면 공통 흐름은 한 곳에서 관리하고 변경 지점만 하위 클래스에 위임하여 코드 재사용성과 일관성을 높일 수 있습니다. 이 패턴의 핵심 특징은 첫째, 알고리즘의 불변 부분을 상위 클래스에서 한 번만 구현하여 중복을 제거하고, 둘째, 훅(Hook) 메서드를 통해 선택적인 단계를 제공할 수 있으며, 셋째, 할리우드 원칙("우리가 연락할게요")을 따라 상위 클래스가 하위 클래스를 호출한다는 점입니다.
이러한 특징들이 중요한 이유는 복잡한 프로세스를 체계적으로 관리하고 확장하기 위함입니다.
코드 예제
// 추상 클래스: 데이터 처리 템플릿
abstract class DataProcessor {
// 템플릿 메서드: 알고리즘의 골격을 정의
public final void process() {
// 변경되지 않는 공통 흐름
openFile();
String data = readData();
if (isValid(data)) {
String transformed = transform(data);
saveData(transformed);
}
closeFile();
// Hook 메서드: 선택적으로 구현 가능
afterProcess();
}
// 추상 메서드: 하위 클래스가 반드시 구현
protected abstract void openFile();
protected abstract String readData();
protected abstract String transform(String data);
protected abstract void saveData(String data);
protected abstract void closeFile();
// 구체 메서드: 공통 검증 로직
protected boolean isValid(String data) {
return data != null && !data.isEmpty();
}
// Hook 메서드: 하위 클래스가 필요시 오버라이드
protected void afterProcess() {
// 기본 구현은 아무것도 하지 않음
}
}
// 구체 클래스: CSV 처리기
class CsvProcessor extends DataProcessor {
protected void openFile() {
System.out.println("CSV 파일 열기");
}
protected String readData() {
System.out.println("CSV 데이터 읽기");
return "csv,data,here";
}
protected String transform(String data) {
System.out.println("CSV를 JSON으로 변환");
return "{\"data\": \"" + data + "\"}";
}
protected void saveData(String data) {
System.out.println("변환된 데이터 저장: " + data);
}
protected void closeFile() {
System.out.println("CSV 파일 닫기");
}
// Hook 메서드 오버라이드
protected void afterProcess() {
System.out.println("CSV 처리 완료 로그 기록");
}
}
// 사용 예시
DataProcessor processor = new CsvProcessor();
processor.process(); // 템플릿 메서드가 전체 흐름 제어
설명
이것이 하는 일: 템플릿 메서드 패턴은 상위 클래스의 process() 같은 템플릿 메서드가 알고리즘의 전체 흐름을 제어하고, 각 단계는 추상 메서드나 훅 메서드로 위임하여 하위 클래스가 커스터마이징하도록 합니다. 첫 번째로, process() 템플릿 메서드가 final로 선언되어 하위 클래스가 오버라이드할 수 없게 합니다.
이렇게 하는 이유는 알고리즘의 전체 구조를 상위 클래스가 완전히 통제하여, 필수 단계가 빠지거나 순서가 바뀌는 것을 방지하기 위함입니다. 템플릿 메서드 안에서는 openFile() → readData() → isValid() → transform() → saveData() → closeFile() → afterProcess()라는 정해진 순서로 메서드들을 호출합니다.
그 다음으로, openFile(), readData() 같은 추상 메서드들은 하위 클래스가 반드시 구현해야 하는 필수 단계입니다. 각 단계는 독립적인 책임을 가지며, 하위 클래스는 자신의 특성에 맞게 각 단계를 구현합니다.
CsvProcessor는 CSV 파일을 다루는 방식으로 구현하고, 다른 프로세서는 XML이나 JSON 방식으로 구현할 수 있습니다. 마지막으로, isValid()와 afterProcess() 같은 메서드는 서로 다른 목적을 가집니다.
isValid()는 모든 프로세서가 공통으로 사용하는 검증 로직이므로 상위 클래스에 구체적으로 구현되어 있고, afterProcess()는 Hook 메서드로 기본 구현은 비어있지만 하위 클래스가 필요시 오버라이드하여 추가 작업을 수행할 수 있습니다. 이렇게 하면 선택적인 기능을 유연하게 확장할 수 있습니다.
여러분이 이 코드를 사용하면 공통 처리 흐름이 한 곳에 집중되어 변경 시 한 곳만 수정하면 되고, 새로운 데이터 형식을 지원할 때 전체 흐름을 다시 구현할 필요 없이 필요한 단계만 구현하면 되며, 알고리즘의 일관성이 보장되어 실수로 중요한 단계를 빠뜨리는 일이 없는 이점을 얻을 수 있습니다.
실전 팁
💡 템플릿 메서드는 반드시 final로 선언하여 하위 클래스가 흐름을 변경하지 못하게 하세요. 이것이 패턴의 핵심입니다.
💡 추상 메서드와 Hook 메서드를 구분하세요. 추상 메서드는 필수 구현 단계이고, Hook 메서드는 선택적 확장 지점입니다. Hook 메서드에는 비어있거나 기본 동작을 제공하는 구현을 넣으세요.
💡 템플릿 메서드 패턴은 상속을 사용하므로 결합도가 높아질 수 있습니다. 단계가 많거나 복잡하다면 전략 패턴과 조합하여 각 단계를 독립된 전략 객체로 만드는 것도 고려하세요.
💡 Spring의 JdbcTemplate, RestTemplate 같은 클래스들이 템플릿 메서드 패턴을 활용합니다. 연결 관리, 예외 처리 같은 공통 작업은 프레임워크가 처리하고, 실제 쿼리나 데이터 처리 로직만 개발자가 제공하는 구조입니다.
💡 콜백(Callback) 방식으로 구현하면 상속 없이도 비슷한 효과를 낼 수 있습니다. 템플릿 메서드 대신 고차 함수나 함수형 인터페이스를 사용하여 각 단계를 파라미터로 받으면, 더 유연한 설계가 가능합니다.