Java

Clean Code 실전 - 레거시 코드를 리팩토링하는 7가지 패턴

백엔드 개발자 김승원 2026. 4. 13. 14:01

들어가며

"이 코드 누가 짠 거야?" 레거시 프로젝트를 인수인계 받으면 가장 먼저 드는 생각입니다. 그런데 git blame을 해보면 3개월 전의 내가 짠 코드일 때도 있습니다. 깨끗한 코드를 작성하는 것은 단순히 미적 감각의 문제가 아닙니다. 코드의 가독성은 곧 유지보수 비용이고, 유지보수 비용은 곧 팀의 생산성입니다.

이 글에서는 실무에서 자주 만나는 7가지 안티패턴과 그 리팩토링 방법을 Before/After 코드로 보여드리겠습니다. 모든 예제는 Java/Spring 기반이며, 내일 당장 여러분의 코드베이스에 적용할 수 있는 실용적인 패턴들입니다.

패턴 1: God Class 분해 - 단일 책임 원칙(SRP)

하나의 클래스가 너무 많은 책임을 지고 있으면 변경 이유가 많아지고, 테스트가 어려워집니다.

Before: God Class

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final UserRepository userRepository;
    private final PaymentGateway paymentGateway;
    private final EmailSender emailSender;
    private final SmsSender smsSender;
    private final SlackNotifier slackNotifier;
    private final InventoryRepository inventoryRepository;

    // 주문 생성 (비즈니스 로직)
    public OrderResponse createOrder(OrderRequest request) {
        // 사용자 검증
        User user = userRepository.findById(request.getUserId())
            .orElseThrow(() -> new RuntimeException("사용자 없음"));

        // 재고 확인 및 차감
        for (OrderItemRequest item : request.getItems()) {
            Inventory inv = inventoryRepository.findByProductId(item.getProductId());
            if (inv.getQuantity() < item.getQuantity()) {
                throw new RuntimeException("재고 부족");
            }
            inv.decrease(item.getQuantity());
            inventoryRepository.save(inv);
        }

        // 결제 처리
        PaymentResult result = paymentGateway.charge(
            user.getPaymentMethod(), calculateTotal(request));
        if (!result.isSuccess()) {
            throw new RuntimeException("결제 실패");
        }

        // 주문 저장
        Order order = Order.create(user, request.getItems());
        orderRepository.save(order);

        // 이메일 알림
        emailSender.send(user.getEmail(), "주문 완료", 
            buildEmailBody(order));

        // SMS 알림
        smsSender.send(user.getPhone(), "주문이 완료되었습니다.");

        // Slack 알림 (운영팀)
        slackNotifier.notify("#orders", "새 주문: " + order.getId());

        return OrderResponse.from(order);
    }

    // ... 200줄 이상의 private 메서드들
}

After: 책임 분리

// 1. 주문 서비스 - 오케스트레이션만 담당
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderValidator orderValidator;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final OrderRepository orderRepository;
    private final OrderNotifier orderNotifier;

    @Transactional
    public OrderResponse createOrder(OrderRequest request) {
        // 각 책임을 위임
        User user = orderValidator.validateUser(request.getUserId());
        inventoryService.reserve(request.getItems());
        paymentService.charge(user, request.calculateTotal());

        Order order = Order.create(user, request.getItems());
        orderRepository.save(order);

        orderNotifier.notifyOrderCreated(order, user);

        return OrderResponse.from(order);
    }
}

// 2. 재고 서비스 - 재고 관련 책임
@Service
@RequiredArgsConstructor
public class InventoryService {

    private final InventoryRepository inventoryRepository;

    @Transactional
    public void reserve(List<OrderItemRequest> items) {
        items.forEach(item -> {
            Inventory inv = inventoryRepository
                .findByProductIdWithLock(item.getProductId())
                .orElseThrow(() -> new ProductNotFoundException(
                    item.getProductId()));
            inv.reserve(item.getQuantity());
        });
    }
}

// 3. 알림 서비스 - 알림 관련 책임 (비동기)
@Service
@RequiredArgsConstructor
public class OrderNotifier {

    private final EmailSender emailSender;
    private final SmsSender smsSender;
    private final SlackNotifier slackNotifier;

    @Async
    @EventListener
    public void notifyOrderCreated(Order order, User user) {
        emailSender.send(user.getEmail(), "주문 완료", 
            buildEmailBody(order));
        smsSender.send(user.getPhone(), "주문이 완료되었습니다.");
        slackNotifier.notify("#orders", "새 주문: " + order.getId());
    }
}

효과: 각 클래스가 하나의 이유로만 변경됩니다. 결제 로직이 바뀌면 PaymentService만, 알림 채널이 추가되면 OrderNotifier만 수정하면 됩니다.

패턴 2: 긴 파라미터 목록 줄이기

메서드 파라미터가 4개 이상이면 가독성이 급격히 떨어집니다.

Before: 파라미터 지옥

public User createUser(String name, String email, String phone,
                       String address, String zipCode, String city,
                       String role, boolean isActive, 
                       LocalDate birthDate) {
    // ...
}

// 호출 시 - 어떤 파라미터가 뭔지 알 수 없음
createUser("김승원", "kim@email.com", "010-1234-5678",
    "강남대로 123", "06000", "서울", "ADMIN", true, 
    LocalDate.of(1990, 1, 1));

After: DTO/Builder 패턴

// 요청 DTO
public record CreateUserRequest(
    @NotBlank String name,
    @Email String email,
    @NotBlank String phone,
    @Valid Address address,
    @NotNull UserRole role,
    @NotNull LocalDate birthDate
) {
    public record Address(
        @NotBlank String street,
        @NotBlank String zipCode,
        @NotBlank String city
    ) {}
}

// 호출 시 - 명확한 구조
CreateUserRequest request = new CreateUserRequest(
    "김승원",
    "kim@email.com",
    "010-1234-5678",
    new CreateUserRequest.Address("강남대로 123", "06000", "서울"),
    UserRole.ADMIN,
    LocalDate.of(1990, 1, 1)
);
userService.createUser(request);

패턴 3: 깊은 중첩 펼치기 - Early Return

if문이 3단계 이상 중첩되면 코드 흐름을 따라가기 어렵습니다.

Before: 중첩 지옥

public OrderResponse processOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).orElse(null);
    if (order != null) {
        if (order.getStatus() == OrderStatus.PENDING) {
            User user = userRepository.findById(order.getUserId()).orElse(null);
            if (user != null) {
                if (user.isActive()) {
                    Payment payment = paymentService.getPayment(order.getPaymentId());
                    if (payment != null && payment.isCompleted()) {
                        order.complete();
                        orderRepository.save(order);
                        return OrderResponse.from(order);
                    } else {
                        throw new RuntimeException("결제 미완료");
                    }
                } else {
                    throw new RuntimeException("비활성 사용자");
                }
            } else {
                throw new RuntimeException("사용자 없음");
            }
        } else {
            throw new RuntimeException("대기 상태가 아님");
        }
    } else {
        throw new RuntimeException("주문 없음");
    }
}

After: Early Return (Guard Clause)

public OrderResponse processOrder(Long orderId) {
    Order order = orderRepository.findById(orderId)
        .orElseThrow(() -> new OrderNotFoundException(orderId));

    if (order.getStatus() != OrderStatus.PENDING) {
        throw new InvalidOrderStateException(order.getStatus());
    }

    User user = userRepository.findById(order.getUserId())
        .orElseThrow(() -> new UserNotFoundException(order.getUserId()));

    if (!user.isActive()) {
        throw new InactiveUserException(user.getId());
    }

    Payment payment = paymentService.getPayment(order.getPaymentId());
    if (payment == null || !payment.isCompleted()) {
        throw new PaymentNotCompletedException(order.getPaymentId());
    }

    order.complete();
    orderRepository.save(order);
    return OrderResponse.from(order);
}

효과: 예외 상황을 먼저 걸러내고, 핵심 로직은 메서드 마지막에 위치합니다. 들여쓰기가 1~2단계로 줄어들어 흐름이 명확해집니다.

패턴 4: 매직 넘버/문자열 제거

코드 안에 의미를 알 수 없는 숫자나 문자열이 있으면 이해하기 어렵습니다.

Before: 매직 넘버

public BigDecimal calculateShippingFee(Order order) {
    if (order.getTotalAmount().compareTo(new BigDecimal("50000")) >= 0) {
        return BigDecimal.ZERO;
    }
    if (order.getWeight() > 20.0) {
        return new BigDecimal("5000");
    }
    if (order.getRegion().equals("jeju")) {
        return new BigDecimal("3000");
    }
    return new BigDecimal("2500");
}

After: 의미 있는 상수

public class ShippingPolicy {
    private static final BigDecimal FREE_SHIPPING_THRESHOLD = 
        new BigDecimal("50000");
    private static final double HEAVY_WEIGHT_KG = 20.0;
    private static final BigDecimal HEAVY_ITEM_FEE = 
        new BigDecimal("5000");
    private static final BigDecimal JEJU_EXTRA_FEE = 
        new BigDecimal("3000");
    private static final BigDecimal STANDARD_FEE = 
        new BigDecimal("2500");
    private static final String JEJU_REGION = "jeju";

    public BigDecimal calculateShippingFee(Order order) {
        if (isFreeShipping(order)) {
            return BigDecimal.ZERO;
        }
        if (isHeavyItem(order)) {
            return HEAVY_ITEM_FEE;
        }
        if (isJejuRegion(order)) {
            return JEJU_EXTRA_FEE;
        }
        return STANDARD_FEE;
    }

    private boolean isFreeShipping(Order order) {
        return order.getTotalAmount()
            .compareTo(FREE_SHIPPING_THRESHOLD) >= 0;
    }

    private boolean isHeavyItem(Order order) {
        return order.getWeight() > HEAVY_WEIGHT_KG;
    }

    private boolean isJejuRegion(Order order) {
        return JEJU_REGION.equals(order.getRegion());
    }
}

패턴 5: Optional 제대로 쓰기

Optional은 null을 다루는 도구이지만, 잘못 사용하면 오히려 코드가 지저분해집니다.

Before: Optional 안티패턴들

// 안티패턴 1: isPresent() + get()
Optional<User> userOpt = userRepository.findById(id);
if (userOpt.isPresent()) {
    User user = userOpt.get();
    return UserResponse.from(user);
} else {
    throw new UserNotFoundException(id);
}

// 안티패턴 2: Optional을 파라미터로 사용
public void updateUser(Long id, Optional<String> name, 
                       Optional<String> email) {
    // Optional은 반환 타입용이지, 파라미터용이 아닙니다
}

// 안티패턴 3: Optional.of(null 가능 값)
Optional<String> opt = Optional.of(maybeNull);  // NPE 위험!

// 안티패턴 4: Optional을 필드로 사용
@Entity
public class User {
    private Optional<String> nickname;  // 직렬화 문제 발생
}

After: Optional 올바른 사용법

// 패턴 1: orElseThrow - 존재하지 않으면 예외
User user = userRepository.findById(id)
    .orElseThrow(() -> new UserNotFoundException(id));

// 패턴 2: map + orElse - 변환 후 기본값
String displayName = userRepository.findById(id)
    .map(User::getNickname)
    .orElse("익명 사용자");

// 패턴 3: filter - 조건부 처리
userRepository.findByEmail(email)
    .filter(User::isActive)
    .orElseThrow(() -> new InactiveUserException(email));

// 패턴 4: ifPresent - 값이 있을 때만 실행
userRepository.findById(id)
    .ifPresent(user -> notificationService.sendWelcome(user));

// 패턴 5: or - 대체 소스에서 조회
User user = userCache.findById(id)
    .or(() -> userRepository.findById(id))
    .orElseThrow(() -> new UserNotFoundException(id));

// 패턴 6: stream - 스트림과 결합
List<UserResponse> responses = userIds.stream()
    .map(userRepository::findById)
    .flatMap(Optional::stream)  // 존재하는 것만 필터링
    .map(UserResponse::from)
    .toList();

패턴 6: Enum 활용한 전략 패턴

if-else 또는 switch문으로 분기가 많아지면 Enum에 전략을 위임할 수 있습니다.

Before: 분기문 지옥

public BigDecimal calculateDiscount(String memberGrade, BigDecimal amount) {
    if ("BRONZE".equals(memberGrade)) {
        return amount.multiply(new BigDecimal("0.01"));
    } else if ("SILVER".equals(memberGrade)) {
        return amount.multiply(new BigDecimal("0.03"));
    } else if ("GOLD".equals(memberGrade)) {
        BigDecimal discount = amount.multiply(new BigDecimal("0.05"));
        BigDecimal maxDiscount = new BigDecimal("10000");
        return discount.compareTo(maxDiscount) > 0 ? maxDiscount : discount;
    } else if ("PLATINUM".equals(memberGrade)) {
        BigDecimal discount = amount.multiply(new BigDecimal("0.10"));
        BigDecimal maxDiscount = new BigDecimal("50000");
        return discount.compareTo(maxDiscount) > 0 ? maxDiscount : discount;
    } else {
        return BigDecimal.ZERO;
    }
}

After: Enum 전략 패턴

@Getter
@RequiredArgsConstructor
public enum MemberGrade {

    BRONZE("브론즈", new BigDecimal("0.01"), null) {
        @Override
        public BigDecimal calculateDiscount(BigDecimal amount) {
            return amount.multiply(discountRate);
        }
    },
    SILVER("실버", new BigDecimal("0.03"), null) {
        @Override
        public BigDecimal calculateDiscount(BigDecimal amount) {
            return amount.multiply(discountRate);
        }
    },
    GOLD("골드", new BigDecimal("0.05"), new BigDecimal("10000")) {
        @Override
        public BigDecimal calculateDiscount(BigDecimal amount) {
            BigDecimal discount = amount.multiply(discountRate);
            return maxDiscount != null && discount.compareTo(maxDiscount) > 0 
                ? maxDiscount : discount;
        }
    },
    PLATINUM("플래티넘", new BigDecimal("0.10"), new BigDecimal("50000")) {
        @Override
        public BigDecimal calculateDiscount(BigDecimal amount) {
            BigDecimal discount = amount.multiply(discountRate);
            return maxDiscount != null && discount.compareTo(maxDiscount) > 0 
                ? maxDiscount : discount;
        }
    };

    private final String displayName;
    protected final BigDecimal discountRate;
    protected final BigDecimal maxDiscount;

    public abstract BigDecimal calculateDiscount(BigDecimal amount);
}

// 사용 코드 - 분기문이 완전히 사라짐
public BigDecimal calculateDiscount(MemberGrade grade, BigDecimal amount) {
    return grade.calculateDiscount(amount);
}

// 더 깔끔한 방법: 함수형 인터페이스 활용
@RequiredArgsConstructor
public enum MemberGradeV2 {

    BRONZE("브론즈", amount -> amount.multiply(new BigDecimal("0.01"))),
    SILVER("실버", amount -> amount.multiply(new BigDecimal("0.03"))),
    GOLD("골드", amount -> {
        BigDecimal discount = amount.multiply(new BigDecimal("0.05"));
        return discount.min(new BigDecimal("10000"));
    }),
    PLATINUM("플래티넘", amount -> {
        BigDecimal discount = amount.multiply(new BigDecimal("0.10"));
        return discount.min(new BigDecimal("50000"));
    });

    private final String displayName;
    private final UnaryOperator<BigDecimal> discountStrategy;

    public BigDecimal calculateDiscount(BigDecimal amount) {
        return discountStrategy.apply(amount);
    }
}

효과: 새로운 등급이 추가되면 Enum에 항목 하나만 추가하면 됩니다. if-else 분기를 찾아다닐 필요가 없고, 컴파일 타임에 누락을 방지할 수 있습니다.

패턴 7: 테스트 가능한 코드로 변환

테스트가 어려운 코드는 대부분 설계가 잘못된 것입니다. 의존성을 주입 가능하게 만들면 자연스럽게 테스트하기 쉬운 코드가 됩니다.

Before: 테스트 불가능한 코드

@Service
public class ReportService {

    public Report generateDailyReport() {
        // 문제 1: 현재 시간에 직접 의존 → 특정 날짜 테스트 불가
        LocalDate today = LocalDate.now();

        // 문제 2: static 메서드 호출 → Mock 불가
        List<Order> orders = OrderRepository.findByDate(today);

        // 문제 3: new로 직접 생성 → 대체 불가
        ExcelExporter exporter = new ExcelExporter();
        byte[] excel = exporter.export(orders);

        // 문제 4: 외부 API 직접 호출 → 테스트 시 실제 호출됨
        HttpClient client = HttpClient.newHttpClient();
        client.send(HttpRequest.newBuilder()
            .uri(URI.create("https://api.slack.com/notify"))
            .POST(BodyPublishers.ofByteArray(excel))
            .build(), BodyHandlers.ofString());

        return new Report(today, orders.size());
    }
}

After: 테스트 가능한 코드

// 시간을 인터페이스로 추상화
public interface TimeProvider {
    LocalDate today();
    LocalDateTime now();
}

@Component
public class SystemTimeProvider implements TimeProvider {
    @Override
    public LocalDate today() { return LocalDate.now(); }
    @Override
    public LocalDateTime now() { return LocalDateTime.now(); }
}

// 내보내기 인터페이스
public interface ReportExporter {
    byte[] export(List<Order> orders);
}

@Component
public class ExcelReportExporter implements ReportExporter {
    @Override
    public byte[] export(List<Order> orders) { /* ... */ }
}

// 알림 인터페이스
public interface ReportNotifier {
    void notify(byte[] report);
}

@Component
public class SlackReportNotifier implements ReportNotifier {
    @Override
    public void notify(byte[] report) { /* Slack API 호출 */ }
}

// 리팩토링된 서비스 - 모든 의존성이 주입 가능
@Service
@RequiredArgsConstructor
public class ReportService {

    private final OrderRepository orderRepository;
    private final TimeProvider timeProvider;
    private final ReportExporter reportExporter;
    private final ReportNotifier reportNotifier;

    public Report generateDailyReport() {
        LocalDate today = timeProvider.today();
        List<Order> orders = orderRepository.findByDate(today);
        byte[] exported = reportExporter.export(orders);
        reportNotifier.notify(exported);
        return new Report(today, orders.size());
    }
}

// 테스트 코드
@ExtendWith(MockitoExtension.class)
class ReportServiceTest {

    @Mock private OrderRepository orderRepository;
    @Mock private TimeProvider timeProvider;
    @Mock private ReportExporter reportExporter;
    @Mock private ReportNotifier reportNotifier;
    @InjectMocks private ReportService reportService;

    @Test
    void 일일_리포트를_생성한다() {
        // given
        LocalDate fixedDate = LocalDate.of(2025, 6, 15);
        given(timeProvider.today()).willReturn(fixedDate);

        List<Order> orders = List.of(
            Order.builder().id(1L).build(),
            Order.builder().id(2L).build()
        );
        given(orderRepository.findByDate(fixedDate)).willReturn(orders);
        given(reportExporter.export(orders)).willReturn(new byte[]{1, 2, 3});

        // when
        Report report = reportService.generateDailyReport();

        // then
        assertThat(report.getDate()).isEqualTo(fixedDate);
        assertThat(report.getOrderCount()).isEqualTo(2);
        verify(reportNotifier).notify(any());
    }

    @Test
    void 주문이_없으면_빈_리포트를_반환한다() {
        // given
        LocalDate fixedDate = LocalDate.of(2025, 1, 1);
        given(timeProvider.today()).willReturn(fixedDate);
        given(orderRepository.findByDate(fixedDate))
            .willReturn(Collections.emptyList());
        given(reportExporter.export(anyList())).willReturn(new byte[0]);

        // when
        Report report = reportService.generateDailyReport();

        // then
        assertThat(report.getOrderCount()).isZero();
    }
}

리팩토링 체크리스트

코드 리뷰나 리팩토링 시 참고할 수 있는 체크리스트입니다.

항목 질문 관련 패턴
클래스 크기 이 클래스가 변경되는 이유가 2가지 이상인가? 패턴 1: God Class 분해
메서드 시그니처 파라미터가 4개 이상인가? 패턴 2: 파라미터 줄이기
들여쓰기 3단계 이상 중첩되는가? 패턴 3: Early Return
숫자/문자열 의미를 알 수 없는 리터럴이 있는가? 패턴 4: 매직 넘버 제거
null 처리 Optional을 올바르게 사용하고 있는가? 패턴 5: Optional 활용
분기 로직 if-else가 3개 이상 연속되는가? 패턴 6: Enum 전략 패턴
테스트 가능성 이 메서드를 단위 테스트할 수 있는가? 패턴 7: 의존성 주입

마치며

리팩토링은 한 번에 모든 것을 고치는 것이 아닙니다. 보이스카우트 규칙(캠프장을 떠날 때 왔을 때보다 깨끗하게)처럼, 코드를 수정할 때마다 조금씩 개선하는 것이 핵심입니다.

  • 패턴 1 (God Class 분해): 하나의 클래스에 하나의 책임만 부여하세요. 서비스 클래스가 200줄이 넘는다면 분리 시점입니다.
  • 패턴 2 (파라미터 줄이기): DTO와 Record를 적극 활용하세요. Java 17+의 Record는 이 용도에 완벽합니다.
  • 패턴 3 (Early Return): 예외 상황을 먼저 처리하고, 핵심 로직을 메서드 끝에 두세요.
  • 패턴 4 (매직 넘버 제거): 모든 리터럴에 이름을 붙이세요. "왜 50000인가?"라는 질문이 나오면 안 됩니다.
  • 패턴 5 (Optional): orElseThrow, map, filter를 적극 사용하고, isPresent()+get() 조합은 피하세요.
  • 패턴 6 (Enum 전략): 타입별 분기 로직은 Enum에 위임하세요. 새로운 타입 추가 시 수정 범위가 최소화됩니다.
  • 패턴 7 (테스트 가능성): new, static, 현재 시간 직접 참조를 인터페이스로 추상화하세요.

이 7가지 패턴만 습관적으로 적용해도, 6개월 뒤의 나 자신과 팀 동료들에게 감사 인사를 받을 수 있을 것입니다.