JAVA

Java 클린 코드 실천 가이드: 네이밍부터 코드 리뷰 체크리스트까지

IT Lab 2026. 3. 2. 20:00

Java 17 기준으로 네이밍, 메서드 분리, 매직 넘버 제거를 실무 관점에서 정리하고, 바로 쓰는 코드 리뷰 체크리스트까지 제공합니다.

도입 (문제 상황)

기능은 잘 동작하는데, 시간이 지나면 본인도 코드를 읽기 어려워지는 경험이 있으실 거예요. 특히 변수명은 애매하고, 메서드는 길어지고, 숫자는 여기저기 박혀 있으면 수정이 작은데도 자신감이 떨어집니다. 이 글에서는 “지금 바로 적용 가능한” 클린 코드 습관을 Java 관점에서 정리해 봅니다.

핵심 개념: Java 클린 코드가 중요한 이유(네이밍/분리/상수화)

클린 코드의 3요소(네이밍, 메서드 분리, 매직 넘버 제거)가 변경 비용을 낮추는 흐름도

클린 코드는 “예쁜 코드”가 아니라 변경 비용을 낮추는 코드입니다. 실무에서 대부분의 비용은 신규 개발보다 수정과 확장에서 발생하고, 그때 발목을 잡는 게 보통 아래 3가지예요.

Java 네이밍: 읽는 사람이 추론하지 않게 만들기

좋은 이름은 주석을 대체합니다. data, info, temp 같은 단어는 “의미를 담는 듯하지만 아무것도 말하지 않는” 대표 케이스예요.

  • 의도를 드러내는 동사/명사 조합을 쓰세요: calculateTotalPrice(), findActiveUsers()
  • 단위/범위/조건을 이름에 포함하세요: timeoutMillis, maxRetryCount, isActive
  • boolean은 is/has/can/should로 시작하면 읽기 흐름이 좋아져요: isExpired, hasPermission

메서드 분리: 길이보다 “변경 이유”를 기준으로 쪼개기

메서드가 길어지는 진짜 문제는 줄 수가 아니라 서로 다른 이유로 변경되는 코드가 섞이는 것입니다.
예를 들어 “검증”, “계산”, “영속화”, “로그/메트릭”이 한 메서드에 섞이면, 요구사항 하나 바뀔 때마다 전체를 건드리게 됩니다.

  • 한 메서드는 가능하면 **한 가지 책임(한 가지 변경 이유)**만 가지도록 분리해 보세요.
  • 분리된 메서드는 이름 자체가 문서가 됩니다. (코드를 “접었다 펼쳤다” 하는 효과)

매직 넘버 제거: 숫자를 “정책”으로 승격시키기

코드에 박힌 3, 60_000, 0.1 같은 값은 시간이 지나면 의미가 사라집니다. 숫자는 빠르게 복제되고, 서로 다른 곳에서 조금씩 변형되면서 버그가 생겨요.

  • 의미 있는 이름의 static final 상수로 올리세요.
  • 값이 환경마다 달라질 수 있으면 상수보다 **설정(예: properties)**로 빼는 게 맞습니다.
  • 금액/시간처럼 단위가 중요한 값은 Duration, BigDecimal 같은 타입으로 “의미를 타입에 실어” 실수를 줄이세요.

선택 가이드: 언제 무엇을 쓰면 좋을까요?

아래 표는 실무에서 자주 고민하는 “상수화 vs 설정화 vs 타입화”를 빠르게 정리한 기준입니다.

상황 추천 이유
변하지 않는 규칙(예: 최대 길이, 고정 상태 코드) static final 상수 코드에서 즉시 의미를 드러내고 변경 추적이 쉬움
배포 환경마다 달라지는 값(예: 타임아웃, 리트라이 횟수) 설정(properties/env) 운영/스테이징/로컬 차이를 코드 변경 없이 관리
단위/정밀도가 중요한 값(시간, 금액, 비율) Duration, BigDecimal 등 타입 사용 “ms vs s”, “double 오차” 같은 실수를 구조적으로 방지

코드 예제: 네이밍/메서드 분리/매직 넘버 제거 + 리뷰 체크리스트

아래 코드는 “주문 처리”를 예로, 나쁜 코드에서 좋은 코드로 정리한 한 파일 실행 예제입니다. (Java 17)

import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;

public class CleanCodeGuideDemo {

    public static void main(String[] args) {
        var service = new OrderService(new FakePaymentClient());

        var order = new Order("ORD-1001", new BigDecimal("120.00"), true);
        var result = service.processOrder(order);

        System.out.println("paymentId=" + result.paymentId());
        System.out.println("paidAt=" + result.paidAt());
    }

    // --- Domain ---
    record Order(String orderId, BigDecimal totalAmount, boolean vipCustomer) {
        Order {
            Objects.requireNonNull(orderId);
            Objects.requireNonNull(totalAmount);
        }
    }

    record PaymentResult(String paymentId, Instant paidAt) {}

    // --- Service (refactored) ---
    static class OrderService {
        // 매직 넘버 제거: 정책을 상수/타입으로 승격
        private static final BigDecimal VIP_DISCOUNT_RATE = new BigDecimal("0.10"); // 10%
        private static final BigDecimal ZERO = BigDecimal.ZERO;

        // ms 같은 단위를 숫자로 들고 있지 말고 타입으로 표현
        private static final Duration PAYMENT_TIMEOUT = Duration.ofSeconds(2);

        private final PaymentClient paymentClient;

        OrderService(PaymentClient paymentClient) {
            this.paymentClient = paymentClient;
        }

        public PaymentResult processOrder(Order order) {
            validate(order);

            BigDecimal finalAmount = calculateFinalAmount(order);

            // 메서드 분리: 외부 연동(변경 이유가 큼)을 별도 메서드로 격리
            String paymentId = requestPayment(order.orderId(), finalAmount);

            return new PaymentResult(paymentId, Instant.now());
        }

        private void validate(Order order) {
            if (order.totalAmount().compareTo(ZERO) <= 0) {
                throw new IllegalArgumentException("totalAmount must be positive");
            }
            if (order.orderId().isBlank()) {
                throw new IllegalArgumentException("orderId must not be blank");
            }
        }

        private BigDecimal calculateFinalAmount(Order order) {
            // 네이밍: 무엇을 하는지 드러나는 이름
            if (!order.vipCustomer()) {
                return order.totalAmount();
            }
            return applyDiscount(order.totalAmount(), VIP_DISCOUNT_RATE);
        }

        private BigDecimal applyDiscount(BigDecimal amount, BigDecimal discountRate) {
            // BigDecimal은 double 대신 String/정수 기반 생성 권장
            BigDecimal discount = amount.multiply(discountRate);
            return amount.subtract(discount);
        }

        private String requestPayment(String orderId, BigDecimal amount) {
            // “외부 호출 + 타임아웃 정책” 같은 변경 포인트를 한 군데로 모음
            return paymentClient.pay(orderId, amount, PAYMENT_TIMEOUT);
        }
    }

    // --- External dependency boundary ---
    interface PaymentClient {
        String pay(String orderId, BigDecimal amount, Duration timeout);
    }

    static class FakePaymentClient implements PaymentClient {
        @Override
        public String pay(String orderId, BigDecimal amount, Duration timeout) {
            // 데모용: 실제로는 HTTP 클라이언트/SDK 호출 등이 들어감
            return "PAY-" + orderId + "-" + amount;
        }
    }
}
flowchart TD
  A["OrderService.processOrder"] --> B["validate"]
  A --> C["calculateFinalAmount"]
  C --> D["applyDiscount"]
  A --> E["requestPayment"]
  E --> F["PaymentClient.pay"]
  A --> G["return PaymentResult"]

한 메서드 안에서 “검증/계산/외부 연동”이 섞이지 않도록 흐름을 분리한 구조입니다.

실무 팁

💡 실무에서는
네이밍은 “도메인 용어 사전”이 있으면 난이도가 확 내려갑니다. 예를 들어 “사용 중”을 active로 할지 enabled로 할지 팀마다 다르게 쓰기 시작하면 검색도 어렵고 중복 개념이 늘어요. 코드 리뷰에서 “이 단어는 우리 서비스에서 어떤 의미인가요?”를 한 번만 합의해 두면, 이후 네이밍 논쟁이 크게 줄어듭니다.

💡 실무에서는
코드 리뷰 체크리스트를 PR 템플릿에 박아 두는 게 가장 효과적입니다. 아래 항목을 그대로 붙여 넣고, 리뷰어가 아니라 작성자가 먼저 체크해 보세요.

  • 이름이 의도를 드러내나요? (data, temp, handle 같은 포괄어 남발은 없는지)
  • 메서드가 두 가지 이상 이유로 변경될 여지가 있나요? (검증/계산/IO 혼재)
  • 매직 넘버가 있나요? 있다면 상수/설정/타입 중 무엇이 맞나요?
  • 예외 메시지가 문제 원인을 설명하나요? (로그 없이도 원인 추적 가능하게)
  • 테스트하기 어려운 구조인가요? (외부 연동이 인터페이스 뒤로 빠져 있는지)

핵심 요약

좋은 네이밍은 추론 비용을 줄이고, 메서드 분리는 변경 이유를 분리합니다.
매직 넘버 제거는 정책을 코드에 드러내고 실수를 줄입니다.
체크리스트를 PR에 붙이면 클린 코드가 “습관”이 됩니다.

다음 글: #38 Java 코딩 컨벤션 정리