CS: 코드 중첩(Nesting) 문제와 리팩토링

hyeppy·2025년 11월 16일

CS

목록 보기
9/14
post-thumbnail

여러 프로젝트를 진행하면서 예외 처리 방식에 대한 고민이 깊어졌다. 특히 회원가입이나 주문 검증 같은 기능을 구현할 때마다 if-else문이 반복적으로 중첩되었고, 코드를 작성할 때는 문제 없다고 생각했지만 며칠 후 다시 보면 흐름을 파악하기 어려웠다. 단순히 조건을 검사하는 코드임에도 불구하고, 중첩이 깊어질수록 가독성이 급격히 떨어지는 것이 체감됐다.
실무에서는 이런 코드 중첩 문제를 어떻게 해결하고, 어떤 예외 처리 방식을 사용하는지 궁금해 관련 자료를 찾아보던 중 코딩애플의 "더러운 if문 예쁘게 바꾸기"라는 영상을 접하게 되었다. 이 영상을 통해 중첩된 조건문을 개선하는 여러 기법을 학습할 수 있었고, 이를 정리하여 나중에 비슷한 상황에 마주쳤을 때 참고할 수 있도록 문서화하기로 했다.


1. Nesting이란?

Nesting(중첩)은 조건문이나 반복문이 여러 단계로 깊게 들어가 있는 구조를 의미한다. if문이 중첩되면 코드가 왼쪽에서 오른쪽으로 삼각형 모양을 그리며 들여쓰기가 깊어지는 형태가 된다.

if (나이 > 19) {
    if (간경화 == false) {
        System.out.println("음주 가능");
        if (수술 == true) {
            System.out.println("음주 아마 불가능");
        }
    }
} else {
    System.out.println("음주 불가능");
}

Nesting의 문제점

Linux Kernel coding style guide에서는 "3번 이상 nesting된 코드는 좋지 않은 코드"라고 명시하고 있다. 그 이유는 다음과 같다.

  1. 가독성 저하: 코드를 읽을 때 각 조건의 관계를 파악하기 어렵다
  2. 유지보수 어려움: 새로운 조건을 추가하거나 수정할 때 실수하기 쉽다
  3. 테스트 복잡도 증가: 모든 경로를 테스트하려면 조건의 조합이 기하급수적으로 늘어난다
  4. 인지 부하: 개발자가 동시에 여러 단계의 조건을 머릿속에 유지해야 한다

2. 코드 중첩의 문제점

실제 회원가입 기능을 구현한다고 가정하자. 다음과 같은 검증 과정이 필요하다.

  1. 아이디가 중복되지 않았는지
  2. 아이디 길이가 적절한지 (예: 4~20자)
  3. 비밀번호 길이가 적절한지 (예: 8자 이상)
  4. 비밀번호에 특수문자가 포함되었는지
  5. 이메일 형식이 올바른지

이를 중첩된 if문으로 작성하면:

public class SignupService {
    public void signup(String username, String password, String email) {
        if (!isDuplicate(username)) {
            if (isValidUsernameLength(username)) {
                if (isValidPasswordLength(password)) {
                    if (hasSpecialChar(password)) {
                        if (isValidEmail(email)) {
                            // 회원가입 처리
                            createUser(username, password, email);
                            System.out.println("가입 성공");
                        } else {
                            throw new IllegalArgumentException("이메일 형식이 올바르지 않습니다");
                        }
                    } else {
                        throw new IllegalArgumentException("비밀번호에 특수문자가 필요합니다");
                    }
                } else {
                    throw new IllegalArgumentException("비밀번호가 너무 짧습니다");
                }
            } else {
                throw new IllegalArgumentException("아이디 길이가 적절하지 않습니다");
            }
        } else {
            throw new IllegalArgumentException("아이디가 중복됩니다");
        }
    }
}

위의 코드에서 실제로 사용된 것은 if-else문밖에 없지만, 5단계로 중첩되어 있어 가독성이 매우 떨어진다. 하지만 실제 로직에서는 이보다 더 많은 검증 과정이 필요할 수 있고, if-else 외의 비즈니스 로직이 추가될 가능성이 높다.


3. 리팩토링 기법

3-1. AND/OR 연산자로 축약

여러 조건을 논리 연산자로 묶어 평탄화하는 방법이다.

public void signup(String username, String password, String email) {
    if (!isDuplicate(username) &&
        isValidUsernameLength(username) &&
        isValidPasswordLength(password) &&
        hasSpecialChar(password) &&
        isValidEmail(email)) {
        createUser(username, password, email);
        System.out.println("가입 성공");
    } else {
        throw new IllegalArgumentException("입력값이 올바르지 않습니다");
    }
}

장점:

  • 중첩이 제거되어 코드가 평탄해짐
  • 모든 조건을 한눈에 볼 수 있음

단점:

  • 어떤 조건에서 실패했는지 구체적으로 알 수 없음
  • 사용자에게 명확한 오류 메시지를 제공할 수 없음
  • 조건이 10개 이상으로 늘어나면 여전히 가독성이 떨어짐
  • 일부 조건은 AND로, 일부는 OR로 묶어야 하는 복잡한 경우 적용이 어려움

3-2. Guard Clause (Early Return)

예외적인 상황을 먼저 처리하고 조기에 반환하는 패턴이다. Fail-Fast 원칙과 연결되는 개념으로, 문제가 발견되면 즉시 실행을 중단한다.

public void signup(String username, String password, String email) {
    if (isDuplicate(username)) {
        throw new IllegalArgumentException("아이디가 중복됩니다");
    }

    if (!isValidUsernameLength(username)) {
        throw new IllegalArgumentException("아이디 길이가 적절하지 않습니다");
    }

    if (!isValidPasswordLength(password)) {
        throw new IllegalArgumentException("비밀번호가 너무 짧습니다");
    }

    if (!hasSpecialChar(password)) {
        throw new IllegalArgumentException("비밀번호에 특수문자가 필요합니다");
    }

    if (!isValidEmail(email)) {
        throw new IllegalArgumentException("이메일 형식이 올바르지 않습니다");
    }

    // 모든 검증을 통과한 경우에만 실행
    createUser(username, password, email);
    System.out.println("가입 성공");
}

장점:

  • 가독성 향상: 각 검증 조건이 독립적으로 명확하게 보임
  • 구체적인 오류 메시지: 어떤 조건에서 실패했는지 정확히 알 수 있음
  • 수정 용이: 새로운 검증 조건을 추가하거나 순서를 변경하기 쉬움
  • 조기 종료: 첫 번째 실패 조건에서 즉시 반환되어 불필요한 검사를 하지 않음

단점:

  • 메서드에 여러 개의 return 지점이 생김 (일부 코딩 스타일에서는 비권장)
  • 예외 처리가 많아지면 코드 길이가 늘어남

3-3. 함수 추출(Extract Method)

관련 있는 검증 로직을 별도 메서드로 분리하는 방법이다.

public class SignupService {

    public void signup(String username, String password, String email) {
        validateUsername(username);
        validatePassword(password);
        validateEmail(email);

        createUser(username, password, email);
        System.out.println("가입 성공");
    }

    private void validateUsername(String username) {
        if (isDuplicate(username)) {
            throw new IllegalArgumentException("아이디가 중복됩니다");
        }
        if (!isValidUsernameLength(username)) {
            throw new IllegalArgumentException("아이디 길이가 적절하지 않습니다");
        }
    }

    private void validatePassword(String password) {
        if (!isValidPasswordLength(password)) {
            throw new IllegalArgumentException("비밀번호가 너무 짧습니다");
        }
        if (!hasSpecialChar(password)) {
            throw new IllegalArgumentException("비밀번호에 특수문자가 필요합니다");
        }
    }

    private void validateEmail(String email) {
        if (!isValidEmail(email)) {
            throw new IllegalArgumentException("이메일 형식이 올바르지 않습니다");
        }
    }
}

장점:

  • 관심사 분리: 각 검증 로직이 독립적인 메서드로 분리됨
  • 재사용성: 다른 곳에서도 같은 검증 로직을 사용할 수 있음
  • 테스트 용이: 각 검증 메서드를 독립적으로 테스트할 수 있음
  • 가독성: 메인 메서드가 간결해지고 전체 흐름을 파악하기 쉬움

단점:

  • 과도한 분리: 재사용되지 않는 로직을 억지로 분리하면 오히려 복잡도가 증가함
  • 탐색 비용: 실제 구현을 확인하려면 메서드 정의를 찾아가야 함
  • 적절한 추상화 수준 판단이 어렵다: 어디까지 분리할지 결정하기 어려울 수 있음

언제 함수로 분리해야 하는가?

검증 로직을 함수로 분리하는 목적은 재사용성도 있지만, 다음과 같은 이유도 있다:

  1. 복잡도 감소: 하나의 메서드가 너무 많은 일을 하면 이해하기 어려움
  2. 단일 책임 원칙: 각 메서드는 하나의 명확한 역할만 해야 함
  3. 추상화 수준 통일: 같은 메서드 안에서 너무 상세한 로직과 높은 수준의 로직이 섞이면 안 됨

경험 법칙:

  • 메서드가 20~30줄을 넘어가면 분리를 고려함
  • 한 메서드가 3개 이상의 다른 일을 한다면 분리함
  • "이 메서드는 무엇을 하는가?"라는 질문에 "그리고(and)"를 사용해야 한다면 분리함
  • 재사용되지 않더라도, 분리했을 때 전체 코드의 의도가 더 명확해진다면 분리함

4. Spring Boot에서의 실전 적용

Spring Boot 환경에서는 더 나은 방법들이 있다.

4-1. Bean Validation 사용

Spring의 @Valid와 Bean Validation 어노테이션을 활용하면 선언적으로 검증할 수 있다.

public class SignupRequest {

    @NotBlank(message = "아이디는 필수입니다")
    @Size(min = 4, max = 20, message = "아이디는 4~20자여야 합니다")
    private String username;

    @NotBlank(message = "비밀번호는 필수입니다")
    @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다")
    @Pattern(regexp = ".*[!@#$%^&*()].*", message = "비밀번호에 특수문자가 필요합니다")
    private String password;

    @NotBlank(message = "이메일은 필수입니다")
    @Email(message = "이메일 형식이 올바르지 않습니다")
    private String email;

    // getter, setter
}

@RestController
public class SignupController {

    @PostMapping("/signup")
    public ResponseEntity<String> signup(@Valid @RequestBody SignupRequest request) {
        // 여기 도달했다면 모든 검증을 통과한 것
        // 중복 확인 같은 비즈니스 검증만 수행
        if (userService.isDuplicate(request.getUsername())) {
            throw new DuplicateUsernameException("아이디가 중복됩니다");
        }

        userService.createUser(request);
        return ResponseEntity.ok("가입 성공");
    }
}

장점:

  • 검증 로직이 도메인 객체에 선언적으로 표현됨
  • 중복 코드가 줄어듦
  • Spring이 자동으로 검증하고 오류 메시지를 생성됨
  • 비즈니스 로직과 입력 검증이 분리됨

4-2. Custom Validator 구현

복잡한 검증 로직은 Custom Validator로 구현할 수 있다.

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueUsernameValidator.class)
public @interface UniqueUsername {
    String message() default "아이디가 이미 존재합니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> {

    @Autowired
    private UserRepository userRepository;

    @Override
    public boolean isValid(String username, ConstraintValidatorContext context) {
        if (username == null) {
            return true; // @NotBlank가 처리
        }
        return !userRepository.existsByUsername(username);
    }
}

// 사용
public class SignupRequest {
    @UniqueUsername
    @Size(min = 4, max = 20)
    private String username;
    // ...
}

4-3. 전략 패턴(Strategy Pattern) 활용

조건 분기가 복잡하고 자주 변경되는 경우, 전략 패턴을 사용할 수 있다.

public interface ValidationStrategy {
    void validate(SignupRequest request);
}

@Component
public class UsernameValidationStrategy implements ValidationStrategy {

    @Autowired
    private UserRepository userRepository;

    @Override
    public void validate(SignupRequest request) {
        if (userRepository.existsByUsername(request.getUsername())) {
            throw new IllegalArgumentException("아이디가 중복됩니다");
        }
        if (request.getUsername().length() < 4 || request.getUsername().length() > 20) {
            throw new IllegalArgumentException("아이디 길이가 적절하지 않습니다");
        }
    }
}

@Component
public class PasswordValidationStrategy implements ValidationStrategy {
    @Override
    public void validate(SignupRequest request) {
        if (request.getPassword().length() < 8) {
            throw new IllegalArgumentException("비밀번호가 너무 짧습니다");
        }
        if (!request.getPassword().matches(".*[!@#$%^&*()].*")) {
            throw new IllegalArgumentException("비밀번호에 특수문자가 필요합니다");
        }
    }
}

@Service
public class SignupService {

    private final List<ValidationStrategy> strategies;

    public SignupService(List<ValidationStrategy> strategies) {
        this.strategies = strategies;
    }

    public void signup(SignupRequest request) {
        // 모든 검증 전략 실행
        for (ValidationStrategy strategy : strategies) {
            strategy.validate(request);
        }

        createUser(request);
    }
}

장점:

  • 새로운 검증 규칙을 추가할 때 기존 코드를 수정하지 않아도 됨 (OCP 원칙)
  • 각 검증 로직이 완전히 독립적임
  • 검증 순서를 쉽게 변경할 수 있음

5. 두 가지 코딩의 종류

코딩은 크게 두 가지 목적으로 나뉜다.

5-1. 기능 구현을 위한 코딩

  • 사용자가 요구한 기능을 동작하게 만드는 것
  • "일단 되게 만들기"
  • 빠른 프로토타이핑, MVP(Minimum Viable Product) 개발에 적합
  • 단기적 관점

5-2. 코드 관리를 위한 코딩

  • 코드의 유지보수성, 확장성, 가독성을 높이는 것
  • "좋은 코드로 만들기"
  • 장기적으로 운영될 제품에 필수적
  • Refactoring, Design Pattern, Clean Code 원칙 적용
  • 장기적 관점

6. 정리

  1. Nesting은 3단계 이하로 유지하라
  2. Guard Clause를 활용하여 예외 상황을 먼저 처리하라
  3. 관련 로직은 함수로 추출하되, 과도한 분리는 피하라
  4. Spring의 Validation 기능을 적극 활용하라

profile
Backend

0개의 댓글