1. 규격이 자주 바뀌는 상황에서의 설계 문제
소프트웨어 개발을 하다 보면 요구사항이나 규격이 자주 변경되는 상황을 마주하게 된다. 특히 제품의 특성에 따라 다양한 기능이 추가되거나 변경될 때, 코드를 어떻게 구조화할지가 중요한 문제가 된다. 예를 들어 전자상거래 시스템에서 상품(Product)마다 할인 정책, 배송 방식, 리뷰 기능 등이 다르게 적용되어야 하는 경우를 생각해볼 수 있다.
1-1. 초기 접근 방식: 상속과 인터페이스 구현
처음에는 공통 슈퍼클래스를 만들고, 각 특성에 해당하는 인터페이스를 정의한 후, 구체적인 클래스에서 이를 구현하는 방식을 떠올릴 수 있다. 기본적인 구조는 다음과 같다.
// 공통 슈퍼클래스
abstract class Product {
protected String name;
protected double price;
public Product(String name, double price) {
this.name = name;
this.price = price;
}
public String getName() {
return name;
}
public double getPrice() {
return price;
}
}
// 기능별 인터페이스들
interface Discountable {
double applyDiscount(double percentage);
}
interface Shippable {
double calculateShipping(String destination);
}
interface Reviewable {
void addReview(String review);
void showReviews();
}
이제 Book 클래스를 만든다고 가정하면, Product를 상속받고 필요한 인터페이스들을 구현하는 방식으로 작성할 수 있다.
class Book extends Product implements Discountable, Reviewable {
private List<String> reviews = new ArrayList<>();
public Book(String name, double price) {
super(name, price);
}
@Override
public double applyDiscount(double percentage) {
return price * (1 - percentage / 100);
}
@Override
public void addReview(String review) {
reviews.add(review);
}
@Override
public void showReviews() {
System.out.println("Reviews for " + name + ": " + reviews);
}
}
class Electronics extends Product implements Discountable, Shippable, Reviewable {
private List<String> reviews = new ArrayList<>();
public Electronics(String name, double price) {
super(name, price);
}
@Override
public double applyDiscount(double percentage) {
return price * (1 - percentage / 100);
}
@Override
public double calculateShipping(String destination) {
return 3000;
}
@Override
public void addReview(String review) {
reviews.add(review);
}
@Override
public void showReviews() {
System.out.println("Reviews: " + reviews);
}
}
1-2. 문제점: 코드 중복
위 방식의 가장 큰 문제는 코드 중복이 심하게 발생한다는 점이다. Book 클래스와 Electronics 클래스를 보면 applyDiscount 메서드의 구현이 동일하고, 리뷰 관련 코드도 거의 같다. 만약 할인 로직이 변경되거나 새로운 제품 클래스가 추가된다면, 모든 클래스에서 동일한 코드를 수정하거나 복사해야 하는 상황이 발생한다. 이는 유지보수를 매우 어렵게 만들며, 실수로 일부 클래스에서만 수정을 누락하는 버그를 발생시킬 수 있다.
2. 전략 패턴으로 해결하기
2-1. 전략 패턴의 핵심 아이디어
전략 패턴(Strategy Pattern)은 이러한 문제를 해결하기 위한 디자인 패턴이다. 핵심 아이디어는 "알고리즘을 캡슐화하고 교환 가능하게 만드는 것"이다. 각 클래스가 인터페이스를 직접 구현하는 대신, 인터페이스의 구현체(행동을 정의한 클래스)를 별도로 만들고, 이를 조합(Composition)하여 사용하는 방식이다.
구조를 다시 설계하면 다음과 같다.
// 공통 슈퍼클래스는 그대로 유지
abstract class Product {
protected String name;
protected double price;
public Product(String name, double price) {
this.name = name;
this.price = price;
}
}
// 기능 인터페이스 (전략 인터페이스)
interface DiscountStrategy {
double calculate(double price, double percentage);
}
interface ShippingStrategy {
double calculate(String destination);
}
// 인터페이스 구현체들 (구체적인 전략)
class PercentageDiscount implements DiscountStrategy {
@Override
public double calculate(double price, double percentage) {
return price * (1 - percentage / 100);
}
}
class FixedDiscount implements DiscountStrategy {
@Override
public double calculate(double price, double percentage) {
return price - percentage;
}
}
class StandardShipping implements ShippingStrategy {
@Override
public double calculate(String destination) {
return destination.equals("서울") ? 3000 : 5000;
}
}
class ExpressShipping implements ShippingStrategy {
@Override
public double calculate(String destination) {
return destination.equals("서울") ? 5000 : 8000;
}
}
2-2. 전략을 조합하는 방식
이제 Product를 상속받는 클래스는 행동을 직접 구현하는 대신, 전략 객체를 조합하여 사용한다.
class Book extends Product {
private DiscountStrategy discountStrategy;
private ShippingStrategy shippingStrategy;
public Book(String name, double price,
DiscountStrategy discountStrategy,
ShippingStrategy shippingStrategy) {
super(name, price);
this.discountStrategy = discountStrategy;
this.shippingStrategy = shippingStrategy;
}
public double applyDiscount(double percentage) {
return discountStrategy.calculate(price, percentage);
}
public double calculateShipping(String destination) {
return shippingStrategy.calculate(destination);
}
}
class Electronics extends Product {
private DiscountStrategy discountStrategy;
private ShippingStrategy shippingStrategy;
public Electronics(String name, double price,
DiscountStrategy discountStrategy,
ShippingStrategy shippingStrategy) {
super(name, price);
this.discountStrategy = discountStrategy;
this.shippingStrategy = shippingStrategy;
}
public double applyDiscount(double percentage) {
return discountStrategy.calculate(price, percentage);
}
public double calculateShipping(String destination) {
return shippingStrategy.calculate(destination);
}
}
사용 예시는 다음과 같다.
public static void main(String[] args) {
// 책은 퍼센트 할인과 일반 배송 적용
Book book = new Book("자바의 정석", 30000,
new PercentageDiscount(),
new StandardShipping());
System.out.println("책 할인가: " + book.applyDiscount(10));
System.out.println("책 배송비: " + book.calculateShipping("서울"));
// 전자제품은 고정 할인과 빠른 배송 적용
Electronics laptop = new Electronics("노트북", 1500000,
new FixedDiscount(),
new ExpressShipping());
System.out.println("노트북 할인가: " + laptop.applyDiscount(50000));
System.out.println("노트북 배송비: " + laptop.calculateShipping("부산"));
}
2-3. 전략 패턴의 장점
이 방식의 가장 큰 장점은 코드 중복이 제거된다는 것이다. PercentageDiscount는 한 번만 작성하면 Book, Electronics, 그리고 앞으로 추가될 모든 제품 클래스에서 재사용할 수 있다. 새로운 할인 정책이나 배송 방식이 추가되어도 새로운 전략 클래스만 작성하면 되며, 기존 코드를 수정할 필요가 없다. 이는 개방-폐쇄 원칙(Open-Closed Principle)을 따르는 좋은 설계라고 할 수 있다.
3. 런타임에 전략 변경하기
전략 패턴의 또 다른 강력한 기능은 프로그램 실행 중에 전략을 동적으로 변경할 수 있다는 점이다. 생성자에서만 전략을 설정하는 것이 아니라, setter 메서드를 제공하면 언제든지 전략을 교체할 수 있다.
class Book extends Product {
private DiscountStrategy discountStrategy;
private ShippingStrategy shippingStrategy;
public Book(String name, double price,
DiscountStrategy discountStrategy,
ShippingStrategy shippingStrategy) {
super(name, price);
this.discountStrategy = discountStrategy;
this.shippingStrategy = shippingStrategy;
}
// 전략을 변경할 수 있는 setter 메서드
public void setDiscountStrategy(DiscountStrategy discountStrategy) {
this.discountStrategy = discountStrategy;
}
public void setShippingStrategy(ShippingStrategy shippingStrategy) {
this.shippingStrategy = shippingStrategy;
}
public double applyDiscount(double percentage) {
return discountStrategy.calculate(price, percentage);
}
public double calculateShipping(String destination) {
return shippingStrategy.calculate(destination);
}
}
실행 중에 전략을 변경하는 예시를 보면 다음과 같다.
public static void main(String[] args) {
Book book = new Book("자바의 정석", 30000,
new PercentageDiscount(),
new StandardShipping());
// 처음에는 퍼센트 할인 적용
System.out.println("할인가: " + book.applyDiscount(10)); // 27000원
// 프로모션 기간에는 고정 금액 할인으로 변경
book.setDiscountStrategy(new FixedDiscount());
System.out.println("할인가: " + book.applyDiscount(5000)); // 25000원
// 급한 배송이 필요할 때는 빠른 배송으로 변경
book.setShippingStrategy(new ExpressShipping());
System.out.println("배송비: " + book.calculateShipping("서울")); // 5000원
}
같은 Book 객체인데 할인 방식과 배송 방식이 실행 중에 변경된다. 이것이 전략 패턴의 핵심이며, 이를 통해 매우 유연한 설계를 할 수 있다.
4. 실전 예제: Java Comparator와 전략 패턴
전략 패턴은 이론적인 개념이 아니라 자바 표준 라이브러리에서도 활발히 사용되고 있다. 가장 대표적인 예가 바로 Comparator 인터페이스이다.
4-1. Comparator는 정렬 전략이다
리스트를 정렬할 때 sort() 메서드는 어떻게 정렬할지 알지 못한다. 대신 Comparator를 전략으로 받아서 비교 알고리즘을 위임한다.
List<String> names = Arrays.asList("홍길동", "김철수", "이영희");
// Comparator = Strategy 인터페이스
interface Comparator<T> {
int compare(T o1, T o2);
}
// 구체적인 전략 1: 길이순 정렬
class LengthComparator implements Comparator<String> {
@Override
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
}
// 구체적인 전략 2: 역순 정렬
class ReverseComparator implements Comparator<String> {
@Override
public int compare(String s1, String s2) {
return s2.compareTo(s1);
}
}
// 전략 주입
names.sort(new LengthComparator());
System.out.println(names); // [김철수, 홍길동, 이영희]
// 전략 변경
names.sort(new ReverseComparator());
System.out.println(names); // [홍길동, 이영희, 김철수]
4-2. 익명 클래스와 람다 표현식
매번 클래스를 만들기 번거롭다면 익명 클래스나 람다 표현식을 사용할 수 있다. 이는 전략을 즉석에서 만들어 주입하는 방식이다.
List<String> names = Arrays.asList("홍길동", "김철수", "이영희");
// 익명 클래스로 전략 생성
names.sort(new Comparator<String>() {
@Override
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
});
// 람다 표현식으로 더 간단하게 (Java 8 이상)
names.sort((s1, s2) -> s1.length() - s2.length());
// 메서드 레퍼런스로 더욱 간단하게
names.sort(Comparator.comparing(String::length));
4-3. 복잡한 객체 정렬 예제
실무에서는 단순 문자열이 아니라 복잡한 객체를 정렬하는 경우가 많다. 다음은 학생 객체를 다양한 기준으로 정렬하는 예제이다.
class Student {
String name;
int score;
int age;
public Student(String name, int score, int age) {
this.name = name;
this.score = score;
this.age = age;
}
@Override
public String toString() {
return name + "(" + score + "점, " + age + "세)";
}
}
public static void main(String[] args) {
List<Student> students = Arrays.asList(
new Student("홍길동", 85, 20),
new Student("김철수", 92, 19),
new Student("이영희", 78, 21),
new Student("박민수", 92, 20)
);
// 전략 1: 점수순 정렬
students.sort((s1, s2) -> s1.score - s2.score);
System.out.println("점수순: " + students);
// 전략 2: 이름순 정렬
students.sort((s1, s2) -> s1.name.compareTo(s2.name));
System.out.println("이름순: " + students);
// 전략 3: 점수 내림차순, 같으면 나이순
students.sort((s1, s2) -> {
if (s1.score != s2.score) {
return s2.score - s1.score; // 점수 내림차순
}
return s1.age - s2.age; // 나이 오름차순
});
System.out.println("점수 내림차순(같으면 나이순): " + students);
}
4-4. 전략 패턴 구조 매핑
Comparator 예제를 전략 패턴의 구조와 매핑하면 다음과 같다.
- Context: List.sort() 메서드 - 정렬을 수행하는 주체
- Strategy 인터페이스: Comparator<T> - 비교 알고리즘을 정의
- ConcreteStrategy: LengthComparator, 람다 표현식 등 - 구체적인 비교 로직
- 전략 주입: sort(Comparator) - 메서드 파라미터로 전략을 받음
5. 전략 패턴 추가 고려사항
5-1. null 체크와 기본 전략
전략 객체가 null일 수 있는 상황을 대비해야 한다. null 체크를 하거나 기본 전략을 제공하는 것이 좋다.
class Book extends Product {
private DiscountStrategy discountStrategy;
public Book(String name, double price, DiscountStrategy discountStrategy) {
super(name, price);
// 기본 전략 제공
this.discountStrategy = discountStrategy != null
? discountStrategy
: new NoDiscount();
}
public double applyDiscount(double percentage) {
// null 체크
if (discountStrategy == null) {
return price; // 할인 없음
}
return discountStrategy.calculate(price, percentage);
}
}
// 기본 전략
class NoDiscount implements DiscountStrategy {
@Override
public double calculate(double price, double percentage) {
return price; // 할인 안함
}
}
5-2. 전략 패턴의 실무 활용 사례
전략 패턴은 다양한 실무 상황에서 활용된다.
// 결제 시스템
interface PaymentStrategy {
boolean pay(int amount);
}
class CreditCardPayment implements PaymentStrategy {
private String cardNumber;
@Override
public boolean pay(int amount) {
System.out.println("신용카드로 " + amount + "원 결제");
return true;
}
}
class KakaoPayment implements PaymentStrategy {
@Override
public boolean pay(int amount) {
System.out.println("카카오페이로 " + amount + "원 결제");
return true;
}
}
class ShoppingCart {
private PaymentStrategy paymentStrategy;
public void checkout(int amount) {
paymentStrategy.pay(amount);
}
public void setPaymentStrategy(PaymentStrategy paymentStrategy) {
this.paymentStrategy = paymentStrategy;
}
}
// 사용
ShoppingCart cart = new ShoppingCart();
cart.setPaymentStrategy(new CreditCardPayment());
cart.checkout(50000);
// 결제 수단 변경
cart.setPaymentStrategy(new KakaoPayment());
cart.checkout(30000);
파일 압축 시스템도 전략 패턴으로 구현할 수 있다.
interface CompressionStrategy {
void compress(String filePath);
}
class ZipCompression implements CompressionStrategy {
@Override
public void compress(String filePath) {
System.out.println(filePath + "을 ZIP으로 압축");
}
}
class RarCompression implements CompressionStrategy {
@Override
public void compress(String filePath) {
System.out.println(filePath + "을 RAR로 압축");
}
}
class FileManager {
private CompressionStrategy compressionStrategy;
public void setCompressionStrategy(CompressionStrategy strategy) {
this.compressionStrategy = strategy;
}
public void compressFile(String filePath) {
compressionStrategy.compress(filePath);
}
}
5-3. State 패턴과의 차이점
전략 패턴과 유사하게 보이는 패턴으로 State 패턴이 있다. 두 패턴의 차이를 이해하는 것이 중요하다.
// 전략 패턴: 클라이언트가 전략을 선택하고 변경
Book book = new Book("자바의 정석", 30000, new PercentageDiscount());
book.setDiscountStrategy(new FixedDiscount()); // 클라이언트가 변경
// State 패턴: 객체의 상태에 따라 자동으로 행동이 변경
Order order = new Order();
order.process(); // 주문 상태에 따라 내부적으로 행동이 자동 변경
// 예: 결제대기 -> 배송준비 -> 배송중 -> 배송완료
전략 패턴은 클라이언트가 명시적으로 알고리즘을 선택하는 반면, State 패턴은 객체의 내부 상태에 따라 행동이 자동으로 변경된다는 차이가 있다.
5-4. 전략 패턴의 단점
전략 패턴에도 단점이 있다. 우선 클라이언트가 사용 가능한 전략들을 알고 있어야 한다는 점이다. 어떤 상황에서 어떤 전략을 사용해야 하는지 클라이언트가 판단해야 하므로, 전략이 많아질수록 복잡도가 증가한다. 또한 전략 객체가 많아지면 관리해야 할 클래스가 늘어난다는 부담도 있다. 간단한 알고리즘의 경우 전략 패턴을 적용하는 것이 오히려 오버엔지니어링이 될 수 있으므로, 상황에 맞게 적용하는 것이 중요하다.
6. 정리
전략 패턴은 알고리즘을 캡슐화하고 교환 가능하게 만드는 디자인 패턴이다. 상속보다 조합(Composition)을 활용하여 코드 중복을 줄이고, 런타임에 행동을 동적으로 변경할 수 있는 유연한 구조를 만들 수 있다. Java의 Comparator는 전략 패턴의 대표적인 예이며, 결제 시스템, 파일 압축, 정렬 알고리즘 등 다양한 실무 상황에서 활용할 수 있다. 다만 모든 상황에 적합한 것은 아니므로, 요구사항의 복잡도와 변경 가능성을 고려하여 적절히 적용하는 것이 중요하다.
'공부일기.. > Java' 카테고리의 다른 글
| [Java] String 정리 (1) | 2025.12.16 |
|---|---|
| [java] 예외 처리와 트랜잭션 롤백 (PSA (Portable Service Abstraction)) (0) | 2025.10.29 |
| [java] Enum 추상 메서드 활용과 이해 (0) | 2025.10.03 |
| [코테] JAVA 백준 알고리즘 시작하기 - 입출력 가이드 !!성능최적화!! (0) | 2025.09.29 |
| [java] 자바 파일 처리 기본기 가이드 - 주니어 개발자 필수 FILE/FILES (0) | 2025.09.22 |