B2B 서비스에서 새로운 고객사가 들어오거나 기존 기능을 수정 및 이슈 픽스 해야하는 상황일 때 복잡한 if-else 레거시가 유지보수 효율성 문제를 자주 발생시켰다. enum 방식의 전략 패턴을 도입했고, 각 로직에는 단위테스트를 쉽게 도입할 수 있었다. 결과적으로 회귀테스트 비용이 제로로 떨어졌고, 나중에 팀원이 동일한 작업을 진행하면서 추가 작업이 쉬워졌음을 느꼈다고 공유해주었다.
이 글에 사용된 코드는 실제 프로덕션 코드가 아닌, 핵심 구조만 재구성한 예시입니다.
유지보수 관점에서 if-else의 문제점
모든 if-else 구조가 나쁜 것은 아니다. 때론 간결하고 단순한 코드 방식이 더욱 유지보수에 유리한 경우가 있다.
HR B2B 서비스를 막 론칭하고 새로운 고객사들이 계약을 시작할 때 해당 고객사의 요청 사항이 우리 플랫폼 기능으로 흡수될 수 있을지 판단해야 하는 경우가 많았다. 이러한 상황이 반복되다보니 기존 레거시 코드들을 확장하는 작업을 자주하게 되었다.
특히 휴가/근태 설정과 관련된 기존 코드들은 if-else로 복잡한 분기문을 갖고 있으며 테스트코드도 없었기 때문에 다음과 같은 고질적인 문제를 직접 경험할 수 있었다:
- 신입으로 입사해서 코드를 파악할 때 복잡성 때문에 진입 장벽이 높았음
- 코드들이 서로 뭉쳐있어서 수정이나 확장시 기존 코드들을 건드릴 수밖에 없어 사이드 이펙트의 두려움이 컸음
- 이슈 픽스나 새로운 기능을 패치하는 경우 회귀 테스트 비용이 컸고, 기능 크기에 비해 PR 리뷰에 상당한 시간을 소요함
- 시간이 지날 수록 코드뭉치가 점점 불어나서 앞의 문제점이 더욱 커지는 상황이 발생
다음은 기존 if-else 방식을 사용하는 휴가계산과 관련된 코드 스니펫을 가져왔다:
public class LeaveCalculationService {
public int calculate(LeaveContext context) {
String policy = context.getRule().getPolicyType();
if ("STANDARD".equals(policy)) {
return context.getTotalMinutes();
} else if ("PRORATED".equals(policy)) {
int base = context.getTotalMinutes() - context.getAdjustmentMinutes();
String roundingMethod = context.getRule().getRoundingMethod();
if ("CEIL".equals(roundingMethod)) {
return (int) Math.ceil((double) base / context.getUnitMinutes()) * context.getUnitMinutes();
} else if ("FLOOR".equals(roundingMethod)) {
return (base / context.getUnitMinutes()) * context.getUnitMinutes();
} else {
throw new IllegalStateException("지원하지 않는 roundingMethod: " + roundingMethod);
}
} else if ("CAPPED".equals(policy)) {
int base = context.getTotalMinutes() - context.getAdjustmentMinutes();
int limit = context.getRule().getDaysLimit() * context.getUnitMinutes();
return Math.min(base, limit);
} else {
throw new IllegalStateException("지원하지 않는 policy: " + policy);
}
}
}
휴가 계산 정책이 STANDARD, PRORATED, CAPPED이냐에 따라 각각 서로 다른 로직이 동작되어야 한다.
enum을 이용한 전략패턴
복잡한 if-else를 해결하는 패턴들은 다음과 같다:
- Strategy Pattern
- Chain of Responsibility Pattern
- Command Pattern
여기 주어진 상황에 가장 적절한 솔루션은 **전략패턴(Strategy Pattern)**으로 판단되었다.
왜 전략패턴이 가장 적절할까?
위 세가지 패턴 중 무엇을 선택할지는 다음과 같은 판단 기준을 적용해볼 수 있다:

위 3가지 패턴에서 불필요하거나 과한 역할을 갖는 패턴을 소거하는 방식으로 결정해본다.
먼저 Chain of Responsibility는 이름에서 유추할 수 있듯이 연쇄적인 로직처리에 유용하다. 로직간 순서를 바꾸거나 일부를 생략해서 제공할 수 있는 유연성도 갖고 있다. 이 패턴은 현재 문제점에 적용하면 오히려 복잡성을 증가시킨다.
Command 패턴은 로직들을 직렬화해서 큐에 담거나 지연 실행, undo 등에 유용하기 때문에 이것 또한 현재 패턴에 맞지 않다. 휴가 계산은 옵션에 맞는 Processor를 동적으로 디스패치하는게 필요하고 Processor 자체는 심플하기 때문에 전략 패턴에 적절하다.
전략 패턴을 어떻게 구현하는가?
전략 패턴의 핵심은 유사한 알고리즘을 묶고 이를 동적 환경에서 디스패치를 할 수 있어야 한다. 자바 스프링 환경에서 Spring Bean을 이용한 방식, 인터페이스, enum 등을 활용해서 구현할 수 있다.
현재 구현된 방식을 기준으로 Enum을 사용하여 옵션값(STANDARD, PRORATED, CAPPED)을 제공하고 있다. 로직이 하나의 enum에 응집된다는 장점이 있는데 반대로 복잡성이 증가할 수 있다는 단점이 있다. 휴가 계산에 사용되는 각개의 로직은 복잡성이 낮아 여러 클래스로 분리하는건 불필요하다고 판단되어 enum 자체에 추상 메서드를 선언하고 각 옵션에 이를 오버라이드 하는 방식을 채택했다. 구현된 결과는 다음과 같다.
public enum LeavePolicyType {
STANDARD("기본 연차") {
@Override
public int calculate(LeaveContext context) {
return context.getTotalMinutes();
}
},
PRORATED("비례 연차") {
@Override
public int calculate(LeaveContext context) {
int base = context.getTotalMinutes() - context.getAdjustmentMinutes();
return context.getRule().getRoundingMethod().apply(base, context.getUnitMinutes());
}
},
CAPPED("한도 연차") {
@Override
public int calculate(LeaveContext context) {
int base = context.getTotalMinutes() - context.getAdjustmentMinutes();
int limit = context.getRule().getDaysLimit() * context.getUnitMinutes();
return Math.min(base, limit);
}
};
private final String displayName;
LeavePolicyType(String displayName) {
this.displayName = displayName;
}
public abstract int calculate(LeaveContext context);
public String getDisplayName() {
return displayName;
}
}
displayName 필드로 DB, 프론트와의 분리
DB에 저장하거나 프론트에 전달하는 값은 enum 코드를 이용했다. 대신 비즈니스 맥락은 displayName과 같은 별도의 필드에 저장하여 활용했다. 이를 통해 비즈니스 맥락이 바뀌어도 전달되는 코드를 유지하고 필드값만 바꾸는 방식으로 양쪽의 의존성을 최소화 했다.
전략 패턴 도입으로 유지보수성과 테스트 용이성 향상
각 enum 상수가 계산 로직의 책임을 갖게되니 테스트 코드도 간결해졌다. 하나의 메서드가 1개의 책임만 가지니 테스트 범위도 좁아진 것이다. 예를 들어, 다음과 같이 테스트 코드를 작성할 수 있었다.
@Test
@DisplayName("기본 연차 정책은 조정 없이 전체 시간을 그대로 반환한다")
void shouldReturnTotalMinutesWhenPolicyIsStandard() {
// given
LeaveContext context = LeaveContext.of(480, 0, null);
// when
int result = LeavePolicyType.STANDARD.calculate(context);
// then
assertThat(result).isEqualTo(480);
}
@Test
@DisplayName("한도 연차 정책은 부여 시간이 한도를 초과하면 한도값을 반환한다")
void shouldReturnLimitWhenTotalExceedsCap() {
// given
LeaveContext context = LeaveContext.of(480, 0, rule(3, 60)); // 3일 * 60분 = 180분 한도
// when
int result = LeavePolicyType.CAPPED.calculate(context);
// then
assertThat(result).isEqualTo(180);
}
각 정책을 독립적으로 테스트할 수 있어서 테스트 범위가 좁고 명확했다. if-else 방식에서는 모든 분기를 하나의 메서드에서 커버해야 했지만, enum 방식에서는 정책별로 격리된 단위 테스트가 가능했다.
실제로, 새로운 고객사가 계약을 하고 새로운 옵션을 요구했을 때는 다른 팀원이 작업을 하게 되었다. 이때 옵션 추가가 굉장히 쉬웠으며 테스트 코드까지 같이 작성해주었다.
해당 PR을 리뷰할때 이전 처럼 큰 덩어리를 볼 때의 인지과부하가 덜하다는 것을 느꼈고, 이미 테스트가 있어서 회귀테스트 비용도 제로에 가까웠다. 결과적으로 리뷰가 너무 쉬워졌다.
실무에서 언제 전략패턴을 사용하면 좋은가?
전략패턴은 다른 패턴에 비해 단순하고 명료하다. 동적으로 어떤 로직이 사용될 수 있을지 쉽게 결정되는 경우, 그리고 연쇄적인 로직을 필요로 하지 않는 경우 채택할 수 있다.