반응형
전략 패턴을 알아보자
1. 전략 패턴이란?
전략 패턴이란?
- 전략 패턴은 알고리즘 군을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 하는 디자인 패턴이다. 이를 통해 알고리즘을 사용하는 클라이언트 코드에 영향을 주지 않고 알고리즘을 독립적으로 변경할 수 있다.
전략 패턴의 구성 요소
- Strategy 인터페이스
- 알고리즘을 정의하는 공통 인터페이스다.
- 특정 작업을 수행하기 위한 메서드를 선언한다.
- Concrete Strategy
- Strategy 인터페이스를 구현하는 구체적인 알고리즘 클래스다.
- 다양한 알고리즘이나 행동을 구현한다.
- Context
- Strategy 객체를 가지고 있으며, 클라이언트로부터 요청을 받아 Strategy에 작업을 위임한다.
- 전략을 설정하고 실행하는 역할을 한다.
전략 패턴의 작동 방식
- 클라이언트가 Context 객체를 생성하고 사용할 Strategy를 설정한다.
- Context는 클라이언트의 요청에 따라 Strategy의 메서드를 호출하여 작업을 수행한다.
- 알고리즘을 변경하고 싶다면 다른 Concrete Strategy를 설정하면 된다.
2. 전략 패턴을 사용하지 않은 경우
먼저, 전략 패턴을 사용하지 않고 비행기 티켓 가격을 계산하는 간단한 시스템을 구현해 보자.
// PriceCalculator 클래스: 가격 계산을 담당
public class PriceCalculator {
public double calculatePrice(String ticketType, double basePrice) {
if (ticketType.equals("성인")) {
return basePrice;
} else if (ticketType.equals("학생")) {
return basePrice * 0.8; // 20% 할인
} else if (ticketType.equals("어린이")) {
return basePrice * 0.5; // 50% 할인
} else if (ticketType.equals("시니어")) {
return basePrice * 0.7; // 30% 할인
} else {
throw new IllegalArgumentException("알 수 없는 티켓 유형입니다.");
}
}
}
// TicketApp 클래스: 메인 애플리케이션
public class TicketApp {
public static void main(String[] args) {
PriceCalculator calculator = new PriceCalculator();
double adultPrice = calculator.calculatePrice("성인", 100.0);
double studentPrice = calculator.calculatePrice("학생", 100.0);
double childPrice = calculator.calculatePrice("어린이", 100.0);
double seniorPrice = calculator.calculatePrice("시니어", 100.0);
System.out.println("성인 티켓 가격: " + adultPrice);
System.out.println("학생 티켓 가격: " + studentPrice);
System.out.println("어린이 티켓 가격: " + childPrice);
System.out.println("시니어 티켓 가격: " + seniorPrice);
}
}
실행 결과
성인 티켓 가격: 100.0
학생 티켓 가격: 80.0
어린이 티켓 가격: 50.0
시니어 티켓 가격: 70.0
문제점
- 조건문의 증가: 새로운 티켓 유형이 추가될 때마다 calculatePrice 메서드에 조건문을 추가해야 한다.
- 확장성의 한계: 알고리즘을 변경하거나 새로운 할인 정책을 추가하려면 기존 코드를 수정해야 한다.
- 유지보수 어려움: 조건문이 많아질수록 코드의 가독성이 떨어지고 유지보수가 어려워진다.
- 개방-폐쇄 원칙 위반: 새로운 기능 추가 시 기존 코드를 수정해야 하므로 OCP(Open-Closed Principle)를 위반한다.
3. 전략 패턴을 적용한 코드
전략 패턴을 적용하여 가격 계산 시스템을 다시 구현해 보자.
1. Strategy 인터페이스 작성
- 모든 가격 계산 전략은 PriceStrategy 인터페이스를 구현해야 합니다.
- calculatePrice(double basePrice) 메서드를 통해 가격을 계산합니다.
// PriceStrategy 인터페이스: 가격 계산 전략을 정의
public interface PriceStrategy {
double calculatePrice(double basePrice);
}
2. Concrete Strategy 클래스 작성
- 성인 요금 전략: 성인 요금은 할인 없이 기본요금을 그대로 반환한다.
// AdultPriceStrategy 클래스: 성인 요금 계산
public class AdultPriceStrategy implements PriceStrategy {
@Override
public double calculatePrice(double basePrice) {
return basePrice;
}
}
- 학생 요금 전략: 학생 요금은 기본요금에서 20%를 할인한다.
// StudentPriceStrategy 클래스: 학생 요금 계산
public class StudentPriceStrategy implements PriceStrategy {
@Override
public double calculatePrice(double basePrice) {
return basePrice * 0.8; // 20% 할인
}
}
- 어린이 요금 전략: 어린이 요금은 기본요금에서 50%를 할인한다.
// ChildPriceStrategy 클래스: 어린이 요금 계산
public class ChildPriceStrategy implements PriceStrategy {
@Override
public double calculatePrice(double basePrice) {
return basePrice * 0.5; // 50% 할인
}
}
- 시니어 요금 전략: 시니어 요금은 기본요금에서 30%를 할인한다.
// SeniorPriceStrategy 클래스: 시니어 요금 계산
public class SeniorPriceStrategy implements PriceStrategy {
@Override
public double calculatePrice(double basePrice) {
return basePrice * 0.7; // 30% 할인
}
}
3. Context 클래스 작성
- PriceStrategy 객체를 멤버 변수로 가지고 있으며, changePriceStrategy()를 통해 전략을 설정한다.
- calculate() 메서드는 현재 설정된 전략의 calculatePrice() 메서드를 호출한다.
- 만약 전략이 설정되지 않은 상태에서 calculate()를 호출하면 예외를 발생시킨다.
// PriceCalculator 클래스: 가격 계산을 담당 (Context)
public class PriceCalculator {
private PriceStrategy priceStrategy;
// 전략을 설정하는 메서드
public void changePriceStrategy(PriceStrategy priceStrategy) {
this.priceStrategy = priceStrategy;
}
// 가격을 계산하는 메서드
public double calculate(double basePrice) {
if (priceStrategy == null) {
throw new IllegalStateException("PriceStrategy가 설정되지 않았습니다.");
}
return priceStrategy.calculatePrice(basePrice);
}
}
4. Client 클래스
- PriceCalculator 객체를 생성하고, 각 티켓 유형에 맞는 전략을 changePriceStrategy() 메서드를 통해 설정하여 가격을 계산한다.
- 전략(strategy)을 동적으로 변경하면서 같은 PriceCalculator 객체를 사용할 수 있다.
// TicketApp 클래스: 메인 애플리케이션 (Client)
public class TicketApp {
public static void main(String[] args) {
PriceCalculator calculator = new PriceCalculator();
// 성인 요금 계산
calculator.changePriceStrategy(new AdultPriceStrategy());
double adultPrice = calculator.calculate(100.0);
// 학생 요금 계산
calculator.changePriceStrategy(new StudentPriceStrategy());
double studentPrice = calculator.calculate(100.0);
// 어린이 요금 계산
calculator.changePriceStrategy(new ChildPriceStrategy());
double childPrice = calculator.calculate(100.0);
// 시니어 요금 계산
calculator.changePriceStrategy(new SeniorPriceStrategy());
double seniorPrice = calculator.calculate(100.0);
System.out.println("성인 티켓 가격: " + adultPrice);
System.out.println("학생 티켓 가격: " + studentPrice);
System.out.println("어린이 티켓 가격: " + childPrice);
System.out.println("시니어 티켓 가격: " + seniorPrice);
}
}
실행 결과
성인 티켓 가격: 100.0
학생 티켓 가격: 80.0
어린이 티켓 가격: 50.0
시니어 티켓 가격: 70.0
4. 전략 패턴의 장점
전략 패턴을 적용함으로써 다음과 같은 장점을 얻을 수 있다.
1. 유연한 알고리즘 변경
- 실행 중에 알고리즘을 동적으로 변경할 수 있다.
- 클라이언트는 원하는 전략을 선택하여 사용할 수 있다.
코드 예시
- 다양한 요구 사항 대응: 사용자나 상황에 따라 다른 알고리즘을 쉽게 적용할 수 있다.
- 동적 변경 가능: 실행 중에도 전략을 변경할 수 있어 유연성이 높다.
PriceCalculator calculator = new PriceCalculator();
// 전략 변경 전: 성인 요금
calculator.setPriceStrategy(new AdultPriceStrategy());
double price = calculator.calculate(100.0);
System.out.println("가격: " + price); // 출력: 가격: 100.0
// 전략 변경 후: 학생 요금
calculator.setPriceStrategy(new StudentPriceStrategy());
price = calculator.calculate(100.0);
System.out.println("가격: " + price); // 출력: 가격: 80.0
2. 코드 중복 감소
- 공통된 부분은 Context에서 관리하고, 변하는 부분만 Strategy로 분리한다.
- 각 전략 클래스는 자신만의 알고리즘에 집중할 수 있다.
코드 예시
- 가독성 향상: 코드가 명확하게 분리되어 읽기 쉽다.
- 유지보수 용이: 변경 사항이 있을 때 해당 전략 클래스만 수정하면 된다.
// PriceCalculator 클래스는 공통 로직을 관리
public class PriceCalculator {
// ...
public double calculate(double basePrice) {
// 공통 검증 로직 추가 가능
if (basePrice < 0) {
throw new IllegalArgumentException("가격은 음수일 수 없습니다.");
}
return priceStrategy.calculatePrice(basePrice);
}
}
3. 개방-폐쇄 원칙(OCP) 준수
- 기존 코드를 수정하지 않고도 새로운 전략을 추가할 수 있다.
- 클래스의 확장에는 열려 있고 수정에는 닫혀 있다.
코드 예시
- 확장성 향상: 새로운 기능 추가 시 기존 코드를 수정할 필요가 없다.
- 안정성 보장: 기존 코드에 영향을 주지 않아 버그 발생 가능성이 낮아진다.
// 새로운 전략 추가: 특별 할인 요금 전략
public class SpecialDiscountStrategy implements PriceStrategy {
@Override
public double calculatePrice(double basePrice) {
return basePrice * 0.6; // 40% 할인
}
}
// 기존 코드 수정 없이 새로운 전략 사용
calculator.setPriceStrategy(new SpecialDiscountStrategy());
double specialPrice = calculator.calculate(100.0);
System.out.println("특별 할인 티켓 가격: " + specialPrice); // 출력: 특별 할인 티켓 가격: 60.0
4. 의존성 역전 원칙 준수
- 고수준 모듈(PriceCalculator)이 저수준 모듈(Concrete Strategy)에 의존하지 않고, 추상화(PriceStrategy)에 의존한다.
- 의존성 역전 원칙(DIP, Dependency Inversion Principle)을 준수한다.
코드 예시
- 유지보수성 향상: 모듈 간 의존성이 줄어들어 수정 시 영향 범위가 줄어든다.
- 테스트 용이성: 인터페이스를 통해 의존성을 주입받으므로, 모의 객체(Mock)를 사용한 단위 테스트가 가능하다.
// 전략 변경이 필요한 경우
public class FlexiblePriceCalculator {
private PriceStrategy priceStrategy;
// 생성자 주입
public FlexiblePriceCalculator(PriceStrategy priceStrategy) {
this.priceStrategy = priceStrategy;
}
// 전략 변경 메서드 사용
public void changePriceStrategy(PriceStrategy newStrategy) {
this.priceStrategy = newStrategy;
}
public double calculatePrice(double basePrice) {
return priceStrategy.calculatePrice(basePrice);
}
}
5. 주의사항 : 전략 필드에 final을 사용하면 전략 패턴의 구현 방식과 사용에 있어 약간의 차이가 생긴다.
- 전략 필드에 final 키워드를 사용해서 선언해 두어도 전략 패턴은 여전히 적용된다. 전략 인터페이스(PriceStrategy)와 다양한 구체적인 전략 클래스(implements PriceStrategy)를 정의하고, 이를 컨텍스트 클래스(ImmutablePriceCalculator)에서 사용하는 전략 패턴의 기본 구조는 그대로 유지된다. 다만 전략 필드에 final을 사용하면 컨텍스트 객체의 전략이 생성 시점에 결정되고 final이기 때문에 이후에 변경되지 않는다는 점만 다르다고 보면 된다.
// 불변 컨텍스트 클래스
public class ImmutablePriceCalculator {
private final PriceStrategy priceStrategy;
public ImmutablePriceCalculator(PriceStrategy priceStrategy) {
this.priceStrategy = priceStrategy;
}
public double calculatePrice(double basePrice) {
return priceStrategy.calculatePrice(basePrice);
}
}
- 조금 더 자세히 알아보자면 final을 사용할 때와 사용하지 않을 때의 주요 차이점은 런타임에 전략을 변경하는 방식이다. final을 사용하지 않으면 setter 메서드, change 메서드 등을 통해 동일한 컨텍스트 객체(FlexiblePriceCalculator) 내에서 전략을 변경할 수 있지만, ImmutablePriceCalculator처럼 전략 필드에 final을 사용하면 새로운 전략을 적용하기 위해서는 완전히 새로운 컨텍스트 객체(ImmutablePriceCalculator)를 생성해야 한다. 즉, final을 사용해도 전략 패턴은 적용되지만, 전략 변경의 유연성과 객체의 불변성 사이의 트레이드오프가 발생한다.
5. 스프링에서의 전략 패턴 활용
스프링 프레임워크에서는 전략 패턴이 다양한 곳에서 활용됩니다. 대표적인 예로는 Validator 인터페이스와 Resource 추상화를 들 수 있다.
1. Validator 인터페이스
- 스프링은 입력 값 검증을 위해 Validator 인터페이스를 제공한다.
- 다양한 검증 로직을 가진 Validator 구현체를 만들어 사용할 수 있다.
예시 코드
- Validator 인터페이스를 구현한 UserValidator를 통해 입력 값 검증 전략을 분리한다.
- @Component 어노테이션을 사용하여 Validator 구현체들을 빈으로 등록하고, 각 빈에는 @Qualifier로 식별자를 지정한다.
- 컨트롤러에서는 @Autowired와 @Qualifier를 사용하여 필요한 Validator를 주입받는다.
- 각 요청 처리 메서드에서 필요한 Validator를 사용하여 검증을 수행한다.
@Component("userValidator")
public class UserValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return User.class.equals(clazz);
}
@Override
public void validate(Object target, Errors errors) {
User user = (User) target;
// 검증 로직 구현
if (user.getName() == null || user.getName().isEmpty()) {
errors.rejectValue("name", "name.empty", "이름은 필수 입력 항목입니다.");
}
}
}
@Component("adminValidator")
public class AdminValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Admin.class.equals(clazz);
}
@Override
public void validate(Object target, Errors errors) {
Admin admin = (Admin) target;
// 검증 로직 구현
if (admin.getRole() == null || !admin.getRole().equals("ADMIN")) {
errors.rejectValue("role", "role.invalid", "유효한 관리자 권한이 필요합니다.");
}
}
}
@Component("guestValidator")
public class GuestValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Guest.class.equals(clazz);
}
@Override
public void validate(Object target, Errors errors) {
Guest guest = (Guest) target;
// 검증 로직 구현
if (guest.getPurpose() == null || guest.getPurpose().isEmpty()) {
errors.rejectValue("purpose", "purpose.empty", "방문 목적을 입력해주세요.");
}
}
}
- 컨트롤러에서 Validator 주입 및 사용
@Controller
public class UserController {
private final Validator userValidator;
private final Validator adminValidator;
private final Validator guestValidator;
// 여러 Validator를 주입받음
@Autowired
public UserController(@Qualifier("userValidator") Validator userValidator,
@Qualifier("adminValidator") Validator adminValidator,
@Qualifier("guestValidator") Validator guestValidator) {
this.userValidator = userValidator;
this.adminValidator = adminValidator;
this.guestValidator = guestValidator;
}
@PostMapping("/users")
public String createUser(@ModelAttribute User user, BindingResult result) {
// UserValidator를 사용하여 검증
userValidator.validate(user, result);
if (result.hasErrors()) {
return "userForm";
}
// 사용자 생성 로직
return "redirect:/users";
}
@PostMapping("/admins")
public String createAdmin(@ModelAttribute Admin admin, BindingResult result) {
// AdminValidator를 사용하여 검증
adminValidator.validate(admin, result);
if (result.hasErrors()) {
return "adminForm";
}
// 관리자 생성 로직
return "redirect:/admins";
}
@PostMapping("/guests")
public String createGuest(@ModelAttribute Guest guest, BindingResult result) {
// GuestValidator를 사용하여 검증
guestValidator.validate(guest, result);
if (result.hasErrors()) {
return "guestForm";
}
// 게스트 생성 로직
return "redirect:/guests";
}
}
2. Resource 추상화
- 스프링 프레임워크에서는 다양한 소스의 리소스를 추상화하기 위해 Resource 인터페이스를 제공한다. 이를 통해 파일 시스템, 클래스패스, URL 등 여러 위치에 있는 리소스를 동일한 방식으로 처리할 수 있다. 이는 전략 패턴의 적용 예시로 볼 수 있다.
Resource 인터페이스와 구현체
- Resource 인터페이스: 리소스를 추상화한 인터페이스로, 리소스에 접근하기 위한 공통 메서드를 제공한다.
- 구현체들
- FileSystemResource: 파일 시스템의 리소스를 나타낸다.
- ClassPathResource: 클래스패스에 있는 리소스를 나타낸다.
- UrlResource: URL로 접근 가능한 리소스를 나타낸다.
- 그 외에도 ByteArrayResource, InputStreamResource 등이 있다.
예시 코드
- Resource 인터페이스와 그 구현체들을 사용하여 리소스를 로드하고 처리하는 예제를 확인해 보자.
- loadResource(Resource resource) 메서드는 Resource 인터페이스를 인자로 받아, 내부적으로 InputStream을 통해 리소스를 읽어 들인다.
- Resource의 구체적인 구현체에 따라 리소스의 위치나 접근 방식이 다르지만, loadResource 메서드는 Resource 인터페이스만 사용하므로 구체적인 구현에 의존하지 않는다.
- FileSystemResource, ClassPathResource, UrlResource 등 다양한 Resource 구현체를 생성하여 loadResource 메서드에 전달함으로써, 서로 다른 리소스 위치에서 데이터를 읽어올 수 있다.
public class ResourceExample {
public static void main(String[] args) throws IOException {
// 파일 시스템에서 리소스 로드
Resource fileResource = new FileSystemResource("path/to/file.txt");
loadResource(fileResource);
// 클래스패스에서 리소스 로드
Resource classPathResource = new ClassPathResource("data/file.txt");
loadResource(classPathResource);
// URL로 리소스 로드
Resource urlResource = new UrlResource("https://www.example.com/data.txt");
loadResource(urlResource);
}
public static void loadResource(Resource resource) throws IOException {
if (resource.exists()) {
try (InputStream is = resource.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
System.out.println("=== " + resource.getDescription() + " ===");
String line;
while ((line = reader.readLine()) != null) {
// 리소스 처리 로직
System.out.println(line);
}
}
} else {
System.out.println("리소스가 존재하지 않습니다: " + resource.getDescription());
}
}
}
실행 결과 예시
- 실제 파일이나 URL이 유효하다면, 각 리소스의 내용을 출력한다.
=== file [path/to/file.txt] ===
파일 시스템의 리소스 내용
=== class path resource [data/file.txt] ===
클래스패스의 리소스 내용
=== URL [https://www.example.com/data.txt] ===
URL 리소스의 내용
6. 결론
전략 패턴은 알고리즘 군을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 함으로써, 시스템의 유연성과 확장성을 높여주는 디자인 패턴이다.
- 유연한 알고리즘 변경: 실행 중에도 알고리즘을 변경할 수 있어 다양한 요구 사항에 대응할 수 있다.
- 코드 중복 감소: 공통 로직과 변하는 부분을 분리하여 코드의 가독성과 유지보수성이 향상된다.
- 개방-폐쇄 원칙 준수: 기존 코드를 수정하지 않고 새로운 전략을 추가할 수 있어 확장성이 높다.
- 의존성 역전 원칙 준수: 고수준 모듈이 추상화에 의존하여 모듈 간 결합도가 낮아진다.
반응형
'JAVA' 카테고리의 다른 글
Java 클래스 상속의 자유도와 주의점 (1) | 2024.10.27 |
---|---|
Java Stream 제대로 이해하기 (0) | 2024.10.26 |
커맨드 패턴(Command Pattern)이란? (4) | 2024.10.04 |
[Java] List를 Optional로 처리할 때 고려해야 할 사항 (1) | 2024.10.01 |
[Java] 자바 생성자의 초기화 방법 (1) | 2024.09.21 |