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(", "));
    }
}

이 코드는 얼핏 보면 잘 돌아가는 것 같습니다. 하지만 치명적인 단점이 있습니다.

  1. 가독성: 전체 흐름을 보려면 저 긴 switch 문을 다 읽어야 합니다.
  2. 유지보수: LogAttribute에 새로운 타입이 추가되면, 이곳의 switch 문도 반드시 수정해야 합니다. (깜빡하면 버그!)
  3. 가짜 유연성: 반복문과 분기가 섞여 있어 구조만 복잡할 뿐, 실질적인 확장성은 낮습니다.

이 문제를 해결하기 위한 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이 너무 비대해질 수 있습니다.

마지막은 타입(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 값이 매핑되었는지 컴파일 타임에 체크하기 어렵습니다. (이는 단위 테스트로 보완해야 합니다.)

💡 결론: 무엇을 선택해야 할까?

  • 간단하고 타입이 적다? 👉 if 문으로 풀어서 쓰세요 (Loop Unrolling).
  • 타입과 로직이 뗄 수 없는 관계다? 👉 Enum 내부 구현 (Strategy Pattern).
  • Enum은 여러 곳에서 쓰이고, 로직은 이 서비스만의 것이다? 👉 매핑 튜플(Mapping Tuple) 방식을 강력 추천합니다.

results matching ""

    No results matching ""