Line(3)
[Refactoring] 반복문 속 거대한 if/switch 제거하기: ‘가짜 유연성’에서 벗어나기
오늘은 개발을 하다 보면 무의식적으로 작성하게 되는 “반복문 안의 조건 분기” 문제를 리팩토링한 과정을 공유하려 합니다. “순서가 있는 로그 메시지 생성”이라는 가상의 요구사항을 통해, 코드를 어떻게 점진적으로 개선할 수 있는지 4가지 단계를 통해 알아보겠습니다.
1. 상황: 유연해 보이지만 위험한 코드
로그를 출력하기 위한 인터페이스 Loggable과, 로그 속성을 정의하는 LogAttribute Enum이 있다고 가정해 봅시다.
public interface Loggable {
String getLogType();
String getLogLevel();
String getLogDescription();
// ... timestamp, location 등등
}
public enum LogAttribute {
LOG_TYPE, LOG_LEVEL, LOG_DESCRIPTION
}
그리고 요구사항은 다음과 같습니다.
“사용자가 원하는 속성(
Set<LogAttribute>)만 골라서 출력하되, 출력 순서는 **미리 정의된 규칙(ORDERED_ATTRIBUTES)**을 따라야 한다.”
처음엔 별생각 없이 아래와 같이 구현했습니다.
❌ Before: 반복문 안에 숨은 switch 문
public class LogService {
// 순서가 보장된 리스트
private static final List<LogAttribute> ORDERED_ATTRIBUTES = List.of(
LogAttribute.LOG_LEVEL,
LogAttribute.LOG_TYPE,
LogAttribute.LOG_DESCRIPTION
);
public String createLogMessage(Loggable loggable, Set<LogAttribute> attributesToLog) {
return ORDERED_ATTRIBUTES.stream()
.filter(attributesToLog::contains) // 사용자가 요청한 속성만 필터링
.map(attribute -> {
// 😱 문제의 구간: 반복문 내부의 분기
switch (attribute) {
case LOG_LEVEL: return loggable.getLogLevel();
case LOG_TYPE: return loggable.getLogType();
case LOG_DESCRIPTION: return loggable.getLogDescription();
default: return "";
}
})
.collect(Collectors.joining(", "));
}
}
이 코드는 얼핏 보면 잘 돌아가는 것 같습니다. 하지만 치명적인 단점이 있습니다.
- 가독성: 전체 흐름을 보려면 저 긴
switch문을 다 읽어야 합니다. - 유지보수:
LogAttribute에 새로운 타입이 추가되면, 이곳의switch문도 반드시 수정해야 합니다. (깜빡하면 버그!) - 가짜 유연성: 반복문과 분기가 섞여 있어 구조만 복잡할 뿐, 실질적인 확장성은 낮습니다.
이 문제를 해결하기 위한 4가지 리팩토링 단계를 소개합니다.
2. 해결책 1: 반복문 풀기 (Unrolling Loop)
컬렉션의 크기가 작고 고정적이라면, 굳이 반복문을 쓸 필요가 없습니다. 직관적으로 if문으로 나열하는 것이 더 명확할 수 있습니다.
public String createLogMessage(Loggable loggable, Set<LogAttribute> attributesToLog) {
List<String> messageParts = new ArrayList<>();
if (attributesToLog.contains(LogAttribute.LOG_LEVEL)) {
messageParts.add(loggable.getLogLevel());
}
if (attributesToLog.contains(LogAttribute.LOG_TYPE)) {
messageParts.add(loggable.getLogType());
}
// ... 계속 추가
return String.join(", ", messageParts);
}
- 장점: 코드가 매우 단순하고 흐름이 한눈에 보입니다.
- 단점: 속성이 늘어날수록 코드가 길어지고,
join같은 유틸리티를 직접 구현해야 하는 번거로움이 생깁니다. 또한 하나라도 빼먹는 실수를 하기 쉽습니다.
3. 해결책 2: 분기 추출 (Extract Method)
switch 문이 보기 싫다면 별도 메서드로 추출할 수 있습니다.
public String createLogMessage(Loggable loggable, Set<LogAttribute> attributesToLog) {
return ORDERED_ATTRIBUTES.stream()
.filter(attributesToLog::contains)
.map(attr -> getLogText(attr, loggable)) // 메서드 추출
.collect(Collectors.joining(", "));
}
private String getLogText(LogAttribute attribute, Loggable loggable) {
return switch (attribute) {
case LOG_LEVEL -> loggable.getLogLevel();
case LOG_TYPE -> loggable.getLogType();
default -> throw new IllegalArgumentException("Unknown attribute");
};
}
- 장점: 메인 비즈니스 로직(
createLogMessage)이 깔끔해집니다. - 단점: 근본적인 해결책은 아닙니다. 여전히
Enum정의와getLogText로직 사이의 결합도는 느슨하여, 구현 누락의 위험이 존재합니다.
4. 해결책 3: Enum에 로직 위임 (Strategy Pattern)
Java의 Enum은 강력합니다. 각 상수별로 추상 메서드를 구현하게 하여 전략 패턴을 적용할 수 있습니다.
public enum LogAttribute {
LOG_LEVEL {
@Override
public String getText(Loggable loggable) {
return loggable.getLogLevel();
}
},
LOG_TYPE {
@Override
public String getText(Loggable loggable) {
return loggable.getLogType();
}
},
// ...
;
// 추상 메서드 정의 (컴파일러가 구현을 강제함)
public abstract String getText(Loggable loggable);
}
// Service 코드
public String createLogMessage(Loggable loggable, Set<LogAttribute> attributesToLog) {
return ORDERED_ATTRIBUTES.stream()
.filter(attributesToLog::contains)
.map(attr -> attr.getText(loggable)) // 분기 제거! 다형성 활용
.collect(Collectors.joining(", "));
}
- 장점: 응집도 최강. 새로운 타입이 추가될 때
getText를 구현하지 않으면 컴파일 에러가 발생하므로 실수를 원천 차단합니다. - 단점:
LogAttribute라는 도메인 모델에 ‘로그 문자열 생성’이라는 특정 로직이 섞입니다. Enum이 너무 비대해질 수 있습니다.
5. 해결책 4: 튜플(Tuple)로 관계 명시하기 (Recommended)
마지막은 타입(Key)과 로직(Function) 을 매핑하는 튜플을 만드는 것입니다. Java 14부터 도입된 Record를 활용하면 깔끔합니다.
import java.util.function.Function;
public class LogService {
// 1. (Enum 타입, 로직 함수)를 담는 불변 객체 정의
private record AttributeMapping(
LogAttribute attribute,
Function<Loggable, String> textExtractor
) {}
// 2. 순서와 로직을 한 곳에서 정의 (테이블 형태)
private static final List<AttributeMapping> ORDERED_MAPPINGS = List.of(
new AttributeMapping(LogAttribute.LOG_LEVEL, Loggable::getLogLevel),
new AttributeMapping(LogAttribute.LOG_TYPE, Loggable::getLogType),
new AttributeMapping(LogAttribute.LOG_DESCRIPTION, Loggable::getLogDescription)
);
public String createLogMessage(Loggable loggable, Set<LogAttribute> attributesToLog) {
return ORDERED_MAPPINGS.stream()
// 3. 튜플의 Enum을 확인하여 필터링
.filter(mapping -> attributesToLog.contains(mapping.attribute()))
// 4. 튜플에 매핑된 함수(Extractor) 실행
.map(mapping -> mapping.textExtractor().apply(loggable))
.collect(Collectors.joining(", "));
}
}
- 장점:
- 관심사의 분리: Enum은 순수하게 두고, 로직은 Service 내부에
List로 정의하여 관리합니다. - 유연성:
Function<Loggable, String>인터페이스를 활용해 어떤 로직이든 유연하게 끼워 넣을 수 있습니다.
- 관심사의 분리: Enum은 순수하게 두고, 로직은 Service 내부에
- 단점: 모든 Enum 값이 매핑되었는지 컴파일 타임에 체크하기 어렵습니다. (이는 단위 테스트로 보완해야 합니다.)
💡 결론: 무엇을 선택해야 할까?
- 간단하고 타입이 적다? 👉
if문으로 풀어서 쓰세요 (Loop Unrolling). - 타입과 로직이 뗄 수 없는 관계다? 👉 Enum 내부 구현 (Strategy Pattern).
- Enum은 여러 곳에서 쓰이고, 로직은 이 서비스만의 것이다? 👉 매핑 튜플(Mapping Tuple) 방식을 강력 추천합니다.