코드 중복을 줄이는 세 가지 효율적인 방법

소프트웨어 개발 과정에서 코드 중복은 유지보수성을 저해하고 버그 발생 가능성을 높이는 주된 원인 중 하나입니다. 동일한 기능을 여러 곳에서 구현하거나, 유사한 코드를 복사하여 붙여넣기 할 경우, 한 곳을 수정하고 다른 곳을 놓치거나, 미묘한 차이를 간과하여 오류를 유발하기 쉽습니다. 따라서 반복적인 코드를 제거하고 추상화하는 것은 견고하고 확장 가능한 시스템을 구축하는 데 필수적입니다.

이 글에서는 비즈니스 로직에서 흔히 접하는 세 가지 시나리오를 통해 자바의 고급 기능, 디자인 패턴, 그리고 유틸리티를 활용하여 코드 중복을 효과적으로 제거하는 방법을 소개합니다.

1. 템플릿 메서드 패턴과 팩토리 패턴 활용하여 if-else 및 중복 코드 제거

장바구니 주문 기능 개발을 가정해 봅시다. 사용자의 유형에 따라 결제 로직이 달라집니다:

  • 일반 사용자: 상품 가격의 10% 운송료 부과, 상품 할인 없음.
  • VIP 사용자: 상품 가격의 10% 운송료 부과. 동일 상품 2개 초과 구매 시 3개째부터 할인 적용.
  • 내부 사용자: 운송료 면제, 상품 할인 없음.

목표는 사용자 유형별 장바구니 처리 로직을 구현하는 것입니다. 입력은 상품 ID와 수량을 담은 Map 객체이며, 출력은 장바구니 정보를 담은 ShoppingCart 객체입니다.

초기 코드: 높은 중복도와 if-else 문

각 사용자 유형별로 별도의 클래스를 구현하면 다음과 같이 많은 중복 코드가 발생합니다.


// 장바구니 정보
@Data
public class ShoppingCart {
    private List<CartItem> items = new ArrayList<>();
    private BigDecimal totalDiscount;
    private BigDecimal totalItemPrice;
    private BigDecimal totalDeliveryPrice;
    private BigDecimal finalPaymentPrice;
}

// 장바구니 내 상품
@Data
public class CartItem {
    private long id;
    private int quantity;
    private BigDecimal unitPrice;
    private BigDecimal itemDiscount;
    private BigDecimal deliveryCharge;
}

// 일반 사용자 장바구니 처리
public class StandardCustomerCartProcessor {
    public ShoppingCart process(long customerId, Map<Long, Integer> productQuantities) {
        ShoppingCart cart = new ShoppingCart();
        List<CartItem> itemList = new ArrayList<>();
        productQuantities.entrySet().forEach(entry -> {
            CartItem item = new CartItem();
            item.setId(entry.getKey());
            item.setUnitPrice(ExternalDataService.getProductPrice(entry.getKey()));
            item.setQuantity(entry.getValue());
            itemList.add(item);
        });
        cart.setItems(itemList);

        itemList.forEach(item -> {
            // 운송료: 상품 총액의 10%
            item.setDeliveryCharge(item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())).multiply(new BigDecimal("0.1")));
            // 할인 없음
            item.setItemDiscount(BigDecimal.ZERO);
        });

        // 총계 계산 로직 (모든 사용자 유형에서 동일)
        cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add));
        cart.setTotalDeliveryPrice(cart.getItems().stream().map(CartItem::getDeliveryCharge).reduce(BigDecimal.ZERO, BigDecimal::add));
        cart.setTotalDiscount(cart.getItems().stream().map(CartItem::getItemDiscount).reduce(BigDecimal.ZERO, BigDecimal::add));
        cart.setFinalPaymentPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount()));
        return cart;
    }
}

VIP 사용자 및 내부 사용자 장바구니 로직도 위와 유사하게 대부분의 코드가 중복됩니다. 특히 if-else 문을 사용하여 사용자 유형별로 다른 프로세서를 호출하는 부분도 문제입니다.


@GetMapping("wrong-approach")
public ShoppingCart handleOrder(@RequestParam("customerId") int customerId) {
    String customerType = ExternalDataService.getCustomerType(customerId);
    Map<Long, Integer> productQuantities = getCustomerProductSelection(); // 가정
    
    if ("Standard".equals(customerType)) {
        return new StandardCustomerCartProcessor().process(customerId, productQuantities);
    }
    if ("Vip".equals(customerType)) {
        return new VipCustomerCartProcessor().process(customerId, productQuantities);
    }
    if ("Internal".equals(customerType)) {
        return new InternalCustomerCartProcessor().process(customerId, productQuantities);
    }
    return null;
}

해결책: 템플릿 메서드 패턴과 팩토리 패턴

공통된 로직은 추상 클래스에 정의하고, 사용자 유형별로 달라지는 부분만 추상 메서드로 남겨둡니다. 이는 템플릿 메서드 패턴의 핵심입니다.


public abstract class AbstractCartProcessor {
    public ShoppingCart process(long customerId, Map<Long, Integer> productQuantities) {
        ShoppingCart cart = new ShoppingCart();

        List<CartItem> itemList = new ArrayList<>();
        productQuantities.entrySet().forEach(entry -> {
            CartItem item = new CartItem();
            item.setId(entry.getKey());
            item.setUnitPrice(ExternalDataService.getProductPrice(entry.getKey()));
            item.setQuantity(entry.getValue());
            itemList.add(item);
        });
        cart.setItems(itemList);

        // 각 상품의 할인 및 운송료는 하위 클래스에서 처리
        itemList.forEach(item -> {
            calculateItemDiscount(customerId, item);
            calculateDeliveryCharge(customerId, item);
        });

        // 총계 계산 로직 (공통)
        cart.setTotalItemPrice(cart.getItems().stream().map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add));
        cart.setTotalDeliveryPrice(cart.getItems().stream().map(CartItem::getDeliveryCharge).reduce(BigDecimal.ZERO, BigDecimal::add));
        cart.setTotalDiscount(cart.getItems().stream().map(CartItem::getItemDiscount).reduce(BigDecimal.ZERO, BigDecimal::add));
        cart.setFinalPaymentPrice(cart.getTotalItemPrice().add(cart.getTotalDeliveryPrice()).subtract(cart.getTotalDiscount()));
        return cart;
    }

    protected abstract void calculateItemDiscount(long customerId, CartItem item);
    protected abstract void calculateDeliveryCharge(long customerId, CartItem item);
}

이제 구체적인 프로세서들은 이 추상 클래스를 상속받아 필요한 메서드만 구현하면 됩니다.


@Service("StandardCustomerCartProcessor")
public class StandardCustomerCartProcessor extends AbstractCartProcessor {
    @Override
    protected void calculateItemDiscount(long customerId, CartItem item) {
        item.setItemDiscount(BigDecimal.ZERO);
    }

    @Override
    protected void calculateDeliveryCharge(long customerId, CartItem item) {
        item.setDeliveryCharge(item.getUnitPrice()
                .multiply(BigDecimal.valueOf(item.getQuantity()))
                .multiply(new BigDecimal("0.1")));
    }
}

@Service("VipCustomerCartProcessor")
public class VipCustomerCartProcessor extends StandardCustomerCartProcessor { // VIP는 기본 배송료는 동일하므로 상속 활용
    @Override
    protected void calculateItemDiscount(long customerId, CartItem item) {
        if (item.getQuantity() > 2) {
            // VIP 할인 로직
            item.setItemDiscount(item.getUnitPrice()
                    .multiply(BigDecimal.valueOf(100 - ExternalDataService.getVipDiscountRate(customerId)).divide(new BigDecimal("100")))
                    .multiply(BigDecimal.valueOf(item.getQuantity() - 2)));
        } else {
            item.setItemDiscount(BigDecimal.ZERO);
        }
    }
}

@Service("InternalCustomerCartProcessor")
public class InternalCustomerCartProcessor extends AbstractCartProcessor {
    @Override
    protected void calculateItemDiscount(long customerId, CartItem item) {
        item.setItemDiscount(BigDecimal.ZERO);
    }

    @Override
    protected void calculateDeliveryCharge(long customerId, CartItem item) {
        item.setDeliveryCharge(BigDecimal.ZERO); // 내부 사용자 운송료 면제
    }
}

if-else 문을 제거하기 위해 팩토리 패턴을 적용할 수 있습니다. 여기서는 Spring의 IoC 컨테이너를 활용하여 동적으로 빈을 주입받는 방법을 사용합니다.


@Autowired
private ApplicationContext applicationContext;

@GetMapping("right-approach")
public ShoppingCart handleOrderRefactored(@RequestParam("customerId") int customerId) {
    String customerType = ExternalDataService.getCustomerType(customerId);
    Map<Long, Integer> productQuantities = getCustomerProductSelection(); // 가정

    // 사용자 유형에 따라 적절한 프로세서 빈을 동적으로 가져옴
    AbstractCartProcessor processor = (AbstractCartProcessor) applicationContext.getBean(customerType + "CustomerCartProcessor");
    return processor.process(customerId, productQuantities);
}

이러한 접근 방식은 새로운 사용자 유형이 추가되어도 기존 코드를 수정할 필요 없이 새로운 프로세서 클래스만 추가하면 되므로, 개방/폐쇄 원칙을 준수하며 코드의 확장성과 유지보수성을 크게 향상시킵니다.

2. 어노테이션과 리플렉션을 이용한 중복 코드 제거

은행 API와 같은 외부 시스템 연동 시, 파라미터 직렬화 방식이 JSON이 아닌 특정 형식(고정 길이 문자열, 특정 패딩 규칙 등)을 요구하는 경우가 있습니다. 각 API 호출마다 이러한 직렬화 로직을 수동으로 구현하면 많은 중복 코드가 발생하고 오류에 취약해집니다.

  • 모든 파라미터를 고정 길이 데이터로 만들고 순서대로 연결합니다.
  • 길이가 부족한 경우 패딩 처리를 합니다:
    • 문자열: 언더스코어(_)로 우측 패딩 (좌측 정렬)
    • 숫자: 0으로 좌측 패딩 (우측 정렬)
    • 통화: 소수점 두 자리(센트 단위)로 내림 처리 후 숫자 타입으로 0 좌측 패딩
  • 모든 파라미터에 대한 MD5 서명을 추가합니다.

초기 코드: 상세한 하드코딩과 중복

각 API 호출 메서드에서 파라미터 포맷팅 로직을 하드코딩하면 다음과 같습니다.


public class BankApiConnector {

    // 사용자 생성 API 호출
    public static String createUser(String name, String identityNum, String phone, int age) throws IOException {
        StringBuilder dataBuilder = new StringBuilder();
        // 문자열 좌측 정렬, 남는 공간은 '_'로 채움
        dataBuilder.append(String.format("%-10s", name).replace(' ', '_'));
        dataBuilder.append(String.format("%-18s", identityNum).replace(' ', '_'));
        // 숫자 우측 정렬, 남는 공간은 '0'으로 채움
        dataBuilder.append(String.format("%05d", age));
        dataBuilder.append(String.format("%-11s", phone).replace(' ', '_'));
        // MD5 서명 추가
        dataBuilder.append(DigestUtils.md2Hex(dataBuilder.toString()));

        return Request.Post("http://localhost:45678/bank/createAccount")
                .bodyString(dataBuilder.toString(), ContentType.APPLICATION_JSON)
                .execute().returnContent().asString();
    }

    // 결제 API 호출
    public static String processPayment(long userId, BigDecimal amount) throws IOException {
        StringBuilder dataBuilder = new StringBuilder();
        // 숫자 우측 정렬, '0' 좌측 패딩
        dataBuilder.append(String.format("%020d", userId));
        // 금액을 센트 단위로 변환 후 우측 정렬, '0' 좌측 패딩
        dataBuilder.append(String.format("%010d", amount.setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue()));
        // MD5 서명 추가
        dataBuilder.append(DigestUtils.md2Hex(dataBuilder.toString()));

        return Request.Post("http://localhost:45678/bank/makePayment")
                .bodyString(dataBuilder.toString(), ContentType.APPLICATION_JSON)
                .execute().returnContent().asString();
    }
}

이 코드는 다음과 같은 문제점을 안고 있습니다.

  • 각 데이터 타입별 포맷팅 로직이 반복됩니다.
  • 파라미터 조합, 서명, 요청 전송 로직이 모든 API 메서드에서 반복됩니다.
  • API 문서와 코드 간의 불일치 가능성이 높습니다.

해결책: 어노테이션과 리플렉션

어노테이션을 사용하여 API 및 파라미터의 메타데이터를 정의하고, 리플렉션을 통해 이 메타데이터를 읽어 공통 처리 로직을 구현합니다.

먼저, API 및 필드 정의를 위한 커스텀 어노테이션을 생성합니다.


// API 레벨 어노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
public @interface BankApiEndpoint {
    String description() default "";
    String urlPath() default "";
}

// 필드 레벨 어노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@Documented
public @interface ApiFieldSpec {
    int order() default -1;
    int length() default -1;
    String type() default ""; // "S" for String, "N" for Number, "M" for Money
}

이제 API 요청 객체에 이 어노테이션을 적용하여 메타데이터를 추가합니다.


@BankApiEndpoint(urlPath = "/bank/createAccount", description = "사용자 계정 생성")
@Data
public class CreateUserRequest {
    @ApiFieldSpec(order = 1, type = "S", length = 10)
    private String userName;
    @ApiFieldSpec(order = 2, type = "S", length = 18)
    private String identityNumber;
    @ApiFieldSpec(order = 4, type = "S", length = 11)
    private String phoneNumber;
    @ApiFieldSpec(order = 3, type = "N", length = 5)
    private int userAge;
}

@BankApiEndpoint(urlPath = "/bank/makePayment", description = "결제 요청")
@Data
public class PaymentRequest {
    @ApiFieldSpec(order = 1, type = "N", length = 20)
    private long customerId;
    @ApiFieldSpec(order = 2, type = "M", length = 10)
    private BigDecimal transactionAmount;
}

모든 API 요청을 처리할 수 있는 범용 ApiCallHandler 메서드를 구현합니다. 이 메서드는 리플렉션을 사용하여 어노테이션 정보를 읽고 파라미터를 동적으로 구성합니다.


private static String invokeBankApi(Object requestObject) throws IOException {
    BankApiEndpoint apiEndpoint = requestObject.getClass().getAnnotation(BankApiEndpoint.class);
    if (apiEndpoint == null) {
        throw new IllegalArgumentException("Missing BankApiEndpoint annotation on " + requestObject.getClass().getName());
    }

    StringBuilder dataBuilder = new StringBuilder();
    Arrays.stream(requestObject.getClass().getDeclaredFields())
            .filter(field -> field.isAnnotationPresent(ApiFieldSpec.class))
            .sorted(Comparator.comparingInt(f -> f.getAnnotation(ApiFieldSpec.class).order()))
            .peek(field -> field.setAccessible(true)) // private 필드 접근 허용
            .forEach(field -> {
                ApiFieldSpec fieldSpec = field.getAnnotation(ApiFieldSpec.class);
                Object fieldValue;
                try {
                    fieldValue = field.get(requestObject);
                } catch (IllegalAccessException e) {
                    throw new RuntimeException("Failed to access field " + field.getName(), e);
                }

                String formattedValue;
                switch (fieldSpec.type()) {
                    case "S": // 문자열 (좌측 정렬, '_' 우측 패딩)
                        formattedValue = String.format("%-" + fieldSpec.length() + "s", fieldValue.toString()).replace(' ', '_');
                        break;
                    case "N": // 숫자 (우측 정렬, '0' 좌측 패딩)
                        formattedValue = String.format("%" + fieldSpec.length() + "s", fieldValue.toString()).replace(' ', '0');
                        break;
                    case "M": // 통화 (센트 단위 변환 후 숫자 형식, '0' 좌측 패딩)
                        if (!(fieldValue instanceof BigDecimal)) {
                            throw new IllegalArgumentException("Money type field must be BigDecimal: " + field.getName());
                        }
                        long cents = ((BigDecimal) fieldValue).setScale(2, RoundingMode.DOWN).multiply(new BigDecimal("100")).longValue();
                        formattedValue = String.format("%0" + fieldSpec.length() + "d", cents);
                        break;
                    default:
                        throw new IllegalArgumentException("Unknown field type: " + fieldSpec.type());
                }
                dataBuilder.append(formattedValue);
            });

    String payload = dataBuilder.toString();
    String signature = DigestUtils.md2Hex(payload); // 서명 생성
    payload += signature;

    // 실제 API 호출 로직
    String fullUrl = "http://localhost:45678" + apiEndpoint.urlPath();
    return Request.Post(fullUrl)
            .bodyString(payload, ContentType.APPLICATION_JSON)
            .execute().returnContent().asString();
}

이제 BankApiConnector의 각 메서드는 파라미터 객체를 생성하고 invokeBankApi를 호출하는 간단한 역할만 수행합니다.


// 사용자 생성 메서드
public static String createUser(String name, String identityNum, String phone, int age) throws IOException {
    CreateUserRequest request = new CreateUserRequest();
    request.setUserName(name);
    request.setIdentityNumber(identityNum);
    request.setUserAge(age);
    request.setPhoneNumber(phone);
    return invokeBankApi(request);
}

// 결제 메서드
public static String processPayment(long userId, BigDecimal amount) throws IOException {
    PaymentRequest request = new PaymentRequest();
    request.setCustomerId(userId);
    request.setTransactionAmount(amount);
    return invokeBankApi(request);
}

이 패턴은 복잡한 데이터 처리 규칙을 어노테이션으로 분리하고, 리플렉션으로 이를 동적으로 적용함으로써, 모든 API 연동 로직을 단일화하고 코드 중복을 완전히 제거합니다.

3. 속성 복사 유틸리티를 활용한 중복 제거

다층 아키텍처 시스템에서는 계층 간의 느슨한 결합을 위해 각 계층별로 독립적인 데이터 전송 객체(DTO), 도메인 객체(DO), 뷰 객체(VO)를 사용합니다. 이들 객체 간에 속성을 수동으로 복사하는 작업은 매우 지루하고 오류 발생 가능성이 높습니다.

수동 복사의 문제점

수십 또는 수백 개의 필드를 가진 객체 간의 변환은 다음과 같은 문제를 야기합니다.


OrderInputDTO orderInput = new OrderInputDTO(); // 가정된 입력 DTO
OrderDataEntity orderEntity = new OrderDataEntity();

orderEntity.setReceivedDate(orderInput.getReceivedDate());
orderEntity.setShippingAddress(orderInput.getShippingAddress());
orderEntity.setShippingAddressId(orderInput.getShippingAddressId());
orderEntity.setCanBeCancelled(orderInput.isCanBeCancelled());
orderEntity.setHasComplaint(orderInput.isCanBeReviewed()); // 필드명 오류 또는 오매핑
orderEntity.setCanBeReviewed(orderInput.isHasComplaint()); // 필드명 오류 또는 오매핑
orderEntity.setDiscountAmount(orderInput.getDiscountAmount());
orderEntity.setDiscountCouponId(orderInput.getDiscountCouponId());
orderEntity.setCreationTimestamp(orderInput.getCreationTimestamp());
// ... 수많은 반복적인 set/get 호출 ...
orderEntity.setDeliveryPersonContact(orderEntity.getDeliveryPersonContact()); // 자기 자신에게 복사 오류

위 코드에서 볼 수 있듯이, 필드명이 유사하거나 많을수록 오타, 잘못된 소스-대상 매핑, 또는 자기 자신에게 값을 할당하는 실수가 발생하기 쉽습니다. 이러한 버그는 발견하기 어려워 시스템에 심각한 문제를 초래할 수 있습니다.

해결책: 속성 복사 유틸리티

Apache Commons BeanUtils, Spring BeanUtils, 또는 MapStruct와 같은 빈 매핑 라이브러리를 사용하면 이러한 문제를 해결할 수 있습니다. 이들 도구는 객체 간의 동일한 이름의 속성을 자동으로 복사해 줍니다.


// 예: Spring의 BeanUtils 활용
import org.springframework.beans.BeanUtils;

OrderInputDTO orderInput = new OrderInputDTO();
// orderInput 필드 설정 (생략)

OrderDataEntity orderEntity = new OrderDataEntity();
// "id" 필드는 복사하지 않도록 예외 처리
BeanUtils.copyProperties(orderInput, orderEntity, "id", "creationTimestamp"); 

// MapStruct와 같은 컴파일 타임 매퍼 사용
// OrderMapper.INSTANCE.toEntity(orderInput);

이처럼 유틸리티를 사용하면 수동 복사로 인한 오류를 방지하고 코드를 간결하게 유지할 수 있습니다. 복사 제외할 필드를 지정하거나, 필요에 따라 커스터마이징된 매핑 로직을 구현할 수도 있습니다. 복잡한 매핑이 필요한 경우 MapStruct와 같은 컴파일 타임 매퍼가 런타임 오버헤드 없이 강력한 기능을 제공합니다.

태그: java Design Patterns Template Method Factory Pattern Annotations

10월 6일 12:25에 게시됨