💡개요
오늘은 자바에서 사용되는 디자인 패턴인 전략 패턴에 대해 정리해 보자.
📕 전략 패턴
자바에서 전략 패턴이란, 실행 시점에 적절한 전략을 선택하여 유연하게 변경하거나 추가할 수 있도록 하는 디자인 패턴이다.
전략 패턴에서 각 기능을 담당하는 개별 전략은 단순히 전략이라기보다는 하나의 알고리즘으로 이해하는 것이 더 직관적이다.
다음은 전략 패턴을 도입했을 때, 클라이언트가 의존하는 전략과 관련된 다이어그램이다.
전략 패턴을 적용하면, 클라이언트는 Context만을 의존하게 되어 구체적인 전략(알고리즘) 구현에 대한 결합도를 낮출 수 있다.
이를 통해 새로운 전략을 쉽게 추가하거나 변경할 수 있으며, 코드의 유연성과 확장성이 향상된다.
🚀 전략 패턴 적용
다음은 전략 패턴 예시 코드이다.
첫 번째로 사용할 전략을 인터페이스로 추상화해야 한다.
interface Strategy {
void runStrategy();
}
그다음, 위에서 선언한 전략 인터페이스를 구현한 구현체 클래스를 만든다.
이 구현체는 우리가 적용할 알고리즘이 포함된다.
public class StrategyA implements Strategy {
@Override
public void runStrategy() {
System.out.println("A 전략 실행");
}
}
public class StrategyB implements Strategy {
@Override
public void runStrategy() {
System.out.println("B 전략 실행");
}
}
그리고 위 전략 구현체를 보관하고 실행할 Context 클래스를 생성한다.
public class Context {
Strategy strategy;
void setStrategy(Strategy strategy) {
this.strategy = strategy;
}
void runStrategy() {
strategy.runStrategy();
}
}
이제부턴 Context에 원하는 전략을 동적으로 설정하고 실행할 수 있다.
Context context = new Context();
context.setStrategy(new StrategyA()); // Context에 A 전략 적용
context.runStrategy();
context.setStrategy(new StrategyB()); // Context에 B 전략 적용
context.runStrategy();
클라이언트 코드 실행 결과는 다음과 같다.
[결과]
A 전략 실행
B 전략 실행
이처럼 전략 패턴을 사용하면 실행 시점에 원하는 전략을 동적으로 변경할 수 있어 기능을 더욱 유연하게 관리할 수 있다.
🚀 여러 개의 전략
위 예시처럼 낱개의 전략을 적용할 뿐만 아니라, 여러 전략을 사용할 수도 있다.
다음은 사용자가 회원가입 시, 입력한 비밀번호의 정합성을 판별하는 로직을 전략 패턴을 사용지 않고! 구현한 코드이다.
public class Password {
private static final int PASSWORD_MIN_LENGTH = 8;
private static final int PASSWORD_MAX_LENGTH = 20;
private static final Pattern alphabetPattern = Pattern.compile("[a-zA-Z]");
private static final Pattern numberPattern = Pattern.compile("\\\\d");
private static final Pattern specialCharPattern = Pattern.compile("[!@?]");
private void validate(final String password) {
// 비밀번호가 null인지 검증
if (password == null) {
throw new RestApiException(PASSWORD_CANNOT_BE_NULL);
}
// 비밀번호에 공백이 포함되었는지 검증
if (password.contains(" ")) {
throw new RestApiException(PASSWORD_CANNOT_CONTAINS_BLANK);
}
// 비밀번호 길이 검증
if (password.length() < PASSWORD_MIN_LENGTH || password.length() > PASSWORD_MAX_LENGTH) {
throw new RestApiException(INVALID_PASSWORD_LENGTH);
}
// 비밀번호 형식 검증 (알파벳, 숫자, 특수문자 포함 여부)
if (!(alphabetPattern.matcher(password).find()
&& numberPattern.matcher(password).find()
&& specialCharPattern.matcher(password).find())) {
throw new RestApiException(INVALID_PASSWORD_FORMAT);
}
}
}
대부분의 플랫폼에서는 회원가입 시 비밀번호에 대한 여러 가지 제한 사항을 두고 있다.
예를 들어, 비밀번호의 최소/최대 길이나 필수로 포함해야 하는 문자(알파벳, 숫자, 특수문자 등) 같은 조건들이 있다.
위 Password 클래스는 이러한 검증 로직을 포함하고 있지만, 비밀번호 객체 자체에 너무 많은 역할이 부여되어 있다는 문제점이 있다.
물론 비즈니스적으로 볼 때 비밀번호의 정합성을 판별하는 것은 Password 클래스 내부에서 진행하는 것이 맞다.
하지만 위 코드는 지저분해도 너무 지저분하다.
특히 저 validate() 라는 메서드에 너무 많은 역할이 집중되어 있다.
저런 메서드가 유지보수를 어렵게 만드는 악마가 되곤 한다..
다음은 위 문제를 해결하기 위해 비밀번호 정합성을 전략 패턴을 사용해 구현한 코드이다.
interface PasswordValidationStrategy {
void validate(final String password);
}
사용할 전략 메서드는 정합성 검증이기 때문에 validate() 라는 메서드명을 사용했다.
PasswordNullStrategy
비밀번호에 null 입력 여부를 판별하는 전략이다.
class PasswordNullStrategy implements PasswordValidationStrategy {
/**
* 비밀번호가 null 인지 검증한다.
* @param password null 여부를 검증할 비밀번호
*/
@Override
public void validate(final String password) {
if (password == null)
throw new RestApiException(PASSWORD_CANNOT_BE_NULL);
}
}
PasswordBlankStrategy
비밀번호에 공백이 포함되었는지 확인하는 전략이다.
class PasswordBlankStrategy implements PasswordValidationStrategy {
/**
* 비밀번호에 공백이 포함되어 있는지 검증한다.
* @param password 공백 포함 여부를 검증할 비밀번호
*/
@Override
public void validate(final String password) {
if (password.contains(" "))
throw new RestApiException(PASSWORD_CANNOT_CONTAINS_BLANK);
}
}
PasswordFormatStrategy
비밀번호에 최소 1개의 알파벳, 숫자, 특수문자(!, ?)가 포함되어 있는지 확인하는 전략이다.
class PasswordFormatStrategy implements PasswordValidationStrategy {
private static final Pattern alphabetPattern = Pattern.compile("[a-zA-Z]"),
numberPattern = Pattern.compile("\\\\d"),
specialCharPattern = Pattern.compile("[!@?]");
/**
* 비밀번호에 알파벳, 숫자, 특수문자가 모두 포함되어 있는지 검증한다.
* @param password 포맷을 검증할 비밀번호
*/
@Override
public void validate(final String password) {
if (!(alphabetPattern.matcher(password).find()
&& numberPattern.matcher(password).find()
&& specialCharPattern.matcher(password).find()))
throw new RestApiException(INVALID_PASSWORD_FORMAT);
}
}
PasswordLengthStrategy
비밀번호 길이가 기준에 맞는지 판별하는 전략이다.
class PasswordLengthStrategy implements PasswordValidationStrategy {
private static final int PASSWORD_MIN_LENGTH = 8, PASSWORD_MAX_LENGTH = 20;
/**
* 비밀번호의 길이가 PASSWORD_MIN_LENGTH 이상 PASSWORD_MAX_LENGTH 이하인지 검증한다.
* @param password 길이 검증할 비밀번호
*/
@Override
public void validate(final String password) {
if (password.length() < PASSWORD_MIN_LENGTH || password.length() > PASSWORD_MAX_LENGTH)
throw new RestApiException(INVALID_PASSWORD_LENGTH);
}
}
위의 전략 구현체를 적용하기 위해 Password 클래스에 List<PasswordValidationStrategy> 타입의 리스트를 생성하여 전략을 보관한다. (참고로 여기선 위 코드처럼 Password에서 직접 관리하는 것 보다 Context 객체를 따로 생성하여 관리하는 것이 전략 패턴의 정석이다..!)
모든 전략 구현체는 PasswordValidationStrategy 인터페이스를 상속받았기 때문에, 다형성을 활용하여 해당 리스트에 저장할 수 있다.
이제 리스트에 저장된 구현체들을 stream().forEach() 문법을 사용해 validate() 메서드를 훨씬 간결하게 표현할 수 있게 되었다.
@Embeddable
@NoArgsConstructor
public class Password {
private static final List<PasswordValidationStrategy> passwordValidationStrategies = List.of(
new PasswordNullStrategy(),
new PasswordBlankStrategy(),
new PasswordLengthStrategy(),
new PasswordFormatStrategy()
);
...
@Column(name = "PASSWORD")
private String password;
public Password(final String password) {
validate(password);
this.password = password;
}
private void validate(String password) {
passwordValidationStrategies.stream().forEach(strategy -> strategy.validate(password));
}
}
뿐만 아니라, 이후에 비밀번호 최대 길이를 25자로 늘리거나, 사용 가능한 특수 문자에 !, @, ?뿐만 아니라 #, $까지 추가해야 한다는 요구사항이 생길 수도 있다.
이 경우, 기존에 전략 패턴을 적용하지 않았다면, 클라이언트 코드에 해당하는 Password 클래스를 직접 수정해야 한다.
public class Password {
...
// 20 -> 25
private static final int PASSWORD_MAX_LENGTH = 25;
...
// !@? -> !@?#$
private static final Pattern specialCharPattern = Pattern.compile("[!@?#$]");
private void validate(final String password) {
...
}
}
아니면 비밀번호 길이 제한 판별이 필요 없기 때문에 제거하라는 요구사항이 전달된 경우, 이 경우도 Password 클래스 코드 수정이 불가피하다.
public class Password {
// 길이 제한 관련 상수 제거
...
private void validate(final String password) {
...
// 비밀번호에 공백이 포함되었는지 검증
if (password.contains(" ")) {
throw new RestApiException(PASSWORD_CANNOT_CONTAINS_BLANK);
}
// 비밀번호 길이 검증 코드 제거
// 비밀번호 형식 검증 (알파벳, 숫자, 특수문자 포함 여부)
if (!(alphabetPattern.matcher(password).find()
&& numberPattern.matcher(password).find()
&& specialCharPattern.matcher(password).find())) {
throw new RestApiException(INVALID_PASSWORD_FORMAT);
}
}
}
하지만 전략 패턴을 사용할 경우 Password 코드의 변경을 최소화할 수 있다.
비밀번호 최대 제한 길이를 25로 변경할 경우 PasswordLengthStrategy.PASSWORD_MAX_LENGTH 만 수정하면 된다.
class PasswordLengthStrategy implements PasswordValidationStrategy {
private static final int PASSWORD_MIN_LENGTH = 8, PASSWORD_MAX_LENGTH = 25; // 20 -> 25
/**
* 비밀번호의 길이가 PASSWORD_MIN_LENGTH 이상 PASSWORD_MAX_LENGTH 이하인지 검증한다.
* @param password 길이 검증할 비밀번호
*/
@Override
public void validate(final String password) {
if (password.length() < PASSWORD_MIN_LENGTH || password.length() > PASSWORD_MAX_LENGTH)
throw new RestApiException(INVALID_PASSWORD_LENGTH);
}
}
사용 가능한 특수문자 추가도 PasswordFormatStrategy.specialCharPattern 만 수정하면 된다.
class PasswordFormatStrategy implements PasswordValidationStrategy {
private static final Pattern alphabetPattern = Pattern.compile("[a-zA-Z]"),
numberPattern = Pattern.compile("\\\\d"),
specialCharPattern = Pattern.compile("[!@?#$]"); // !@? -> !@?#$
/**
* 비밀번호에 알파벳, 숫자, 특수문자가 모두 포함되어 있는지 검증한다.
* @param password 포맷을 검증할 비밀번호
*/
@Override
public void validate(final String password) {
if (!(alphabetPattern.matcher(password).find()
&& numberPattern.matcher(password).find()
&& specialCharPattern.matcher(password).find()))
throw new RestApiException(INVALID_PASSWORD_FORMAT);
}
}
이처럼 전략 패턴을 도입하면 코드 수정 범위를 최소화할 수 있기 때문에, 유지보수 측면에서 많은 장점을 얻을 수 있다.
하지만 그 만큼 전략을 수행하는 클래스가 많아지기 때문에 관리가 어렵다는 단점 또한 존재한다..
'개발 일기' 카테고리의 다른 글
[개발 일기] 2025.02.28 - @Profiles, @ActiveProfiles (1) | 2025.02.28 |
---|---|
[개발 일기] 2025.02.26 - Dangling quantifier '+’ 에러 (0) | 2025.02.26 |
[개발 일기] 2025.02.24 - == 연산자 vs Objects.isNull() (0) | 2025.02.24 |
[개발 일기] 2025.02.23 - @Builder를 사용하면 생성자가 private이라도 외부에서 접근 가능한 이유? (0) | 2025.02.23 |
[개발 일기] 2025.02.22 - JPA Entity 기본생성자 (0) | 2025.02.22 |