800===Dev Docs and License/Clean Code

대형 메서드 분리 기법 - 복잡한 코드를 정복하는 리팩토링 전략 🛠️

블로글러 2024. 12. 6. 00:27

코드를 작성하다 보면 어느새 수백 줄의 거대한 메서드를 마주하게 되는 경험, 다들 한 번쯤 해보셨을 겁니다. 이런 '대형 메서드'는 마치 정리되지 않은 서랍장과 같아요. 😱 필요한 것을 찾기 위해 모든 내용물을 뒤져야 하고, 새로운 것을 추가하기도 어렵죠.

여러분이 일상에서 정리정돈을 하는 것처럼 코드도 정리가 필요합니다.

  • 큰 메서드는 작은 메서드들로 분리하여 각각의 역할을 명확히 합니다
  • 각 메서드는 하나의 일만 수행하도록 만들어 코드의 가독성을 높입니다
  • 이러한 과정을 '메서드 분리 리팩토링'이라고 합니다

왜 필요한가?

대형 메서드가 일으키는 문제들은 다음과 같습니다:

  1. 이해하기 어려움 📚: 한 메서드에 너무 많은 로직이 섞여 있어 코드의 흐름을 파악하기 힘듭니다.
  2. 유지보수 어려움 🔧: 작은 변경 사항도 전체 메서드에 영향을 줄 수 있어 수정이 부담스럽습니다.
  3. 테스트 복잡성 🧪: 큰 메서드는 테스트 케이스 작성이 어렵고, 테스트 범위가 넓어 버그 발견이 어렵습니다.
  4. 코드 재사용성 저하 ♻️: 기능이 하나의 큰 메서드에 묶여 있어 다른 곳에서 재사용하기 어렵습니다.
  5. 단일 책임 원칙 위반 ⚠️: 하나의 메서드가 여러 책임을 가지게 되어 객체지향 설계 원칙에 위배됩니다.

기본 원리

대형 메서드 분리의 핵심 원리를 알아볼까요?

단일 책임 원칙 (SRP)

메서드는 단 하나의 책임만 가져야 합니다. 여러 일을 하는 메서드는 각각의 책임에 맞게 분리해야 합니다.

// 책임이 혼합된 메서드
public void processOrder(Order order) {
    // 주문 검증, 가격 계산, 재고 업데이트, 결제 처리, 알림 발송 등
    // 모든 로직이 한 메서드에 혼합되어 있음
}

응집도와 결합도

좋은 메서드는 높은 응집도(cohesion)와 낮은 결합도(coupling)를 가집니다. 즉, 관련된 코드는 함께 있고(응집도), 다른 부분과의 의존성은 최소화(결합도)해야 합니다.

대형 메서드 분리 기법

1. 메서드 추출 (Extract Method) 🔍

가장 기본적이고 강력한 리팩토링 기법입니다. 함께 동작하는 코드 조각을 찾아 별도의 메서드로 분리합니다.

// 리팩토링 전: 하나의 큰 메서드
public double calculatePrice(Order order) {
    // 기본 가격 계산
    double basePrice = 0;
    for (Item item : order.getItems()) {
        basePrice += item.getQuantity() * item.getUnitPrice();
    }

    // 할인 계산
    double discount = 0;
    if (order.getCustomer().isVIP()) {
        discount = basePrice * 0.1;
    } else if (order.getItems().size() > 5) {
        discount = basePrice * 0.05;
    }

    // 배송비 계산
    double shippingCost = 0;
    if (basePrice > 50000) {
        shippingCost = 0;
    } else {
        shippingCost = 2500;
    }

    return basePrice - discount + shippingCost;
}

// 리팩토링 후: 여러 작은 메서드로 분리
public double calculatePrice(Order order) {
    double basePrice = calculateBasePrice(order);
    double discount = calculateDiscount(order, basePrice);
    double shippingCost = calculateShippingCost(basePrice);

    return basePrice - discount + shippingCost;
}

private double calculateBasePrice(Order order) {
    double basePrice = 0;
    for (Item item : order.getItems()) {
        basePrice += item.getQuantity() * item.getUnitPrice();
    }
    return basePrice;
}

private double calculateDiscount(Order order, double basePrice) {
    if (order.getCustomer().isVIP()) {
        return basePrice * 0.1;
    } else if (order.getItems().size() > 5) {
        return basePrice * 0.05;
    }
    return 0;
}

private double calculateShippingCost(double basePrice) {
    return basePrice > 50000 ? 0 : 2500;
}

2. 임시 변수를 메서드로 교체 (Replace Temp with Query) 🔄

계산 결과를 임시 변수에 저장하는 대신, 해당 계산을 수행하는 메서드를 만들어 호출합니다.

// 리팩토링 전: 임시 변수 사용
public double calculateTotal(Order order) {
    double basePrice = order.getQuantity() * order.getUnitPrice();
    double discountFactor = 0.98;

    if (basePrice > 1000) {
        discountFactor = 0.95;
    }

    return basePrice * discountFactor;
}

// 리팩토링 후: 임시 변수를 메서드로 교체
public double calculateTotal(Order order) {
    return getBasePrice(order) * getDiscountFactor(order);
}

private double getBasePrice(Order order) {
    return order.getQuantity() * order.getUnitPrice();
}

private double getDiscountFactor(Order order) {
    return getBasePrice(order) > 1000 ? 0.95 : 0.98;
}

3. 조건문 분해 (Decompose Conditional) 🧩

복잡한 조건문을 이해하기 쉬운 메서드로 분리합니다.

// 리팩토링 전: 복잡한 조건문
public double calculateCharge(Date date, int quantity) {
    double charge;
    if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
        charge = quantity * winterRate + winterServiceCharge;
    } else {
        charge = quantity * summerRate;
    }
    return charge;
}

// 리팩토링 후: 조건문 분해
public double calculateCharge(Date date, int quantity) {
    if (isWinter(date)) {
        return calculateWinterCharge(quantity);
    } else {
        return calculateSummerCharge(quantity);
    }
}

private boolean isWinter(Date date) {
    return date.before(SUMMER_START) || date.after(SUMMER_END);
}

private double calculateWinterCharge(int quantity) {
    return quantity * winterRate + winterServiceCharge;
}

private double calculateSummerCharge(int quantity) {
    return quantity * summerRate;
}

4. 조건문을 다형성으로 교체 (Replace Conditional with Polymorphism) 🔀

타입에 따라 다른 동작을 하는 조건문을 클래스 계층 구조와 다형성을 이용해 교체합니다.

// 리팩토링 전: 조건문 사용
public class Employee {
    public static final int ENGINEER = 0;
    public static final int MANAGER = 1;
    public static final int SALESPERSON = 2;

    private int type;

    public double calculatePay() {
        switch (type) {
            case ENGINEER:
                return calculateEngineerPay();
            case MANAGER:
                return calculateManagerPay();
            case SALESPERSON:
                return calculateSalespersonPay();
            default:
                throw new RuntimeException("Unknown employee type");
        }
    }

    private double calculateEngineerPay() { /* ... */ }
    private double calculateManagerPay() { /* ... */ }
    private double calculateSalespersonPay() { /* ... */ }
}

// 리팩토링 후: 다형성 사용
public abstract class Employee {
    public abstract double calculatePay();
}

public class Engineer extends Employee {
    @Override
    public double calculatePay() {
        // 엔지니어 급여 계산 로직
    }
}

public class Manager extends Employee {
    @Override
    public double calculatePay() {
        // 매니저 급여 계산 로직
    }
}

public class Salesperson extends Employee {
    @Override
    public double calculatePay() {
        // 영업사원 급여 계산 로직
    }
}

실제 예제

실제 프로젝트에서 대형 메서드를 분리하는 과정을 살펴보겠습니다.

사용자 인증 서비스 리팩토링

다음은 사용자 인증을 처리하는 대형 메서드입니다:

// 리팩토링 전: 모든 인증 로직이 하나의 메서드에 있음
public class AuthenticationService {
    public AuthResult authenticate(String username, String password, String ipAddress) {
        // 1. 입력 유효성 검사
        if (username == null || username.trim().isEmpty()) {
            logger.warn("인증 실패: 사용자명이 비어 있음");
            return new AuthResult(false, "사용자명이 필요합니다.");
        }

        if (password == null || password.isEmpty()) {
            logger.warn("인증 실패: 비밀번호가 비어 있음");
            return new AuthResult(false, "비밀번호가 필요합니다.");
        }

        // 2. 사용자 조회
        User user = userRepository.findByUsername(username);
        if (user == null) {
            logger.warn("인증 실패: 사용자 {} 없음", username);
            return new AuthResult(false, "사용자명 또는 비밀번호가 잘못되었습니다.");
        }

        // 3. 계정 상태 확인
        if (!user.isActive()) {
            logger.warn("인증 실패: 사용자 {} 계정 비활성화됨", username);
            return new AuthResult(false, "계정이 비활성화되었습니다.");
        }

        // 4. 계정 잠금 확인
        if (user.isLocked()) {
            if (user.getLockExpirationTime().after(new Date())) {
                logger.warn("인증 실패: 사용자 {} 계정 잠김", username);
                return new AuthResult(false, "계정이 잠겼습니다. 나중에 다시 시도하세요.");
            } else {
                user.setLocked(false);
                user.setLoginAttempts(0);
                userRepository.save(user);
            }
        }

        // 5. 비밀번호 확인
        if (!passwordEncoder.matches(password, user.getPassword())) {
            logger.warn("인증 실패: 사용자 {} 잘못된 비밀번호", username);

            // 로그인 시도 증가 및 계정 잠금 처리
            user.setLoginAttempts(user.getLoginAttempts() + 1);
            if (user.getLoginAttempts() >= 5) {
                user.setLocked(true);
                Calendar cal = Calendar.getInstance();
                cal.add(Calendar.MINUTE, 30);
                user.setLockExpirationTime(cal.getTime());
                logger.warn("사용자 {} 계정 잠김", username);
            }
            userRepository.save(user);

            return new AuthResult(false, "사용자명 또는 비밀번호가 잘못되었습니다.");
        }

        // 6. 로그인 성공 처리
        user.setLoginAttempts(0);
        user.setLastLoginDate(new Date());
        user.setLastLoginIp(ipAddress);
        userRepository.save(user);

        // 7. 로그인 이벤트 기록
        LoginEvent event = new LoginEvent();
        event.setUsername(username);
        event.setIpAddress(ipAddress);
        event.setTimestamp(new Date());
        event.setSuccess(true);
        eventRepository.save(event);

        // 8. 보안 검사 - 의심스러운 로그인 확인
        if (!ipAddress.equals(user.getLastLoginIp())) {
            // 위치 변경 감지, 이메일 알림 발송
            notificationService.sendSecurityAlert(user.getEmail(), ipAddress, new Date());
            logger.info("사용자 {} 위치 변경 감지, 보안 알림 발송됨", username);
        }

        // 9. JWT 토큰 생성
        String token = jwtService.generateToken(user);

        logger.info("사용자 {} 로그인 성공", username);
        return new AuthResult(true, "로그인 성공", token);
    }
}

기본 사용법

이제 이 대형 메서드를 리팩토링해 보겠습니다.

  1. 첫 번째 단계로 책임 영역을 파악합니다:

    • 입력 유효성 검사
    • 사용자 조회
    • 계정 상태 확인
    • 계정 잠금 확인 및 처리
    • 비밀번호 확인
    • 로그인 성공 처리
    • 로그인 이벤트 기록
    • 보안 검사
    • JWT 토큰 생성
  2. 각 책임을 별도의 메서드로 추출합니다:

// 리팩토링 후: 여러 메서드로 분리됨
public class AuthenticationService {
    public AuthResult authenticate(String username, String password, String ipAddress) {
        // 입력 유효성 검사
        AuthResult validationResult = validateInput(username, password);
        if (!validationResult.isSuccess()) {
            return validationResult;
        }

        // 사용자 조회
        User user = findUser(username);
        if (user == null) {
            return createFailureResult("사용자명 또는 비밀번호가 잘못되었습니다.");
        }

        // 계정 상태 확인
        AuthResult accountStatusResult = checkAccountStatus(user);
        if (!accountStatusResult.isSuccess()) {
            return accountStatusResult;
        }

        // 비밀번호 확인 및 계정 잠금 처리
        AuthResult passwordResult = verifyPassword(user, password);
        if (!passwordResult.isSuccess()) {
            return passwordResult;
        }

        // 로그인 성공 처리
        updateSuccessfulLogin(user, ipAddress);

        // 로그인 이벤트 기록
        recordLoginEvent(username, ipAddress, true);

        // 보안 검사
        checkSecurity(user, ipAddress);

        // JWT 토큰 생성
        String token = generateToken(user);

        logger.info("사용자 {} 로그인 성공", username);
        return new AuthResult(true, "로그인 성공", token);
    }

    private AuthResult validateInput(String username, String password) {
        if (username == null || username.trim().isEmpty()) {
            logger.warn("인증 실패: 사용자명이 비어 있음");
            return new AuthResult(false, "사용자명이 필요합니다.");
        }

        if (password == null || password.isEmpty()) {
            logger.warn("인증 실패: 비밀번호가 비어 있음");
            return new AuthResult(false, "비밀번호가 필요합니다.");
        }

        return new AuthResult(true, "");
    }

    private User findUser(String username) {
        return userRepository.findByUsername(username);
    }

    private AuthResult checkAccountStatus(User user) {
        if (!user.isActive()) {
            logger.warn("인증 실패: 사용자 {} 계정 비활성화됨", user.getUsername());
            return new AuthResult(false, "계정이 비활성화되었습니다.");
        }

        if (user.isLocked()) {
            return handleLockedAccount(user);
        }

        return new AuthResult(true, "");
    }

    private AuthResult handleLockedAccount(User user) {
        if (user.getLockExpirationTime().after(new Date())) {
            logger.warn("인증 실패: 사용자 {} 계정 잠김", user.getUsername());
            return new AuthResult(false, "계정이 잠겼습니다. 나중에 다시 시도하세요.");
        } else {
            unlockAccount(user);
            return new AuthResult(true, "");
        }
    }

    private void unlockAccount(User user) {
        user.setLocked(false);
        user.setLoginAttempts(0);
        userRepository.save(user);
    }

    // 나머지 메서드들도 계속 분리...
}

다음은 리팩토링 전후의 차이점을 표로 정리한 것입니다:

항목 리팩토링 전 리팩토링 후
메서드 수 1개 (authenticate) 10개 이상의 작은 메서드들
메서드 길이 약 100줄 주 메서드 25줄, 나머지 5-15줄
가독성 낮음 (모든 로직이 혼합) 높음 (각 책임이 분리됨)
테스트 용이성 어려움 (전체를 한번에 테스트) 쉬움 (각 메서드를 독립적으로 테스트 가능)
재사용성 낮음 높음 (분리된 메서드를 다른 곳에서 재사용 가능)
유지보수성 어려움 용이함 (각 책임 영역 변경이 독립적)

주의사항 및 팁 💡

⚠️ 이것만은 주의하세요!

  1. 과도한 메서드 분리

    • 너무 작은 메서드들로 과도하게 분리하면 오히려 코드 이해가 어려워질 수 있습니다.
    • 의미 있는 단위로 분리하세요.
  2. 메서드 이름 선택

    • 추출한 메서드의 이름은 그 기능을 명확히 표현해야 합니다.
    • "doSomething"과 같은 모호한 이름은 피하세요.
  3. 성능 고려

    • 메서드 호출이 많아지면 약간의 성능 저하가 있을 수 있습니다.
    • 대부분의 경우 가독성과 유지보수성 향상이 더 중요합니다.

💡 꿀팁

  • IDE의 리팩토링 도구를 활용하세요. 대부분의 IDE는 자동 메서드 추출 기능을 제공합니다.
  • "Extract Till You Drop" 원칙을 고려하세요. 메서드가 한 가지 일만 할 때까지 계속 추출합니다.
  • 같이 변경되는 코드는 같은 메서드에 두고, 독립적으로 변경되는 코드는 분리하세요.
  • 주석이 필요한 코드 블록은 좋은 메서드 추출 후보입니다.
  • 분기문(if-else, switch)은 각 분기를 별도 메서드로 추출하는 것을 고려하세요.

마치며

지금까지 대형 메서드 분리 기법에 대해 알아보았습니다. 복잡한 메서드를 작고 명확한 단위로 분리하는 것은 코드 품질을 크게 향상시키는 중요한 리팩토링 기법입니다. 처음에는 시간이 더 소요될 수 있지만, 장기적으로는 유지보수 시간과 버그 발생 가능성을 크게 줄일 수 있습니다. 🚀

여러분의 코드에서 이러한 기법을 적용해 보시고, 더 깔끔하고 이해하기 쉬운 코드를 만들어 보세요!

혹시 궁금한 점이 있으시거나, 실제 적용 과정에서 어려움을 겪고 계신다면 댓글로 남겨주세요. 함께 해결책을 찾아보겠습니다! 😊

참고 자료 🔖


#코드리팩토링 #대형메서드분리 #클린코드 #코드품질

728x90