본문 바로가기

자바 심화/TIL

클린 코드 1

개요

Java 단기심화 과정에서 첫 번째 팀 프로젝트가 마무리 되었다. 처음으로 모놀리식이 아닌 다른 아키텍처 구조(싱글 모듈 헥사고날)를 사용하다 보니 적응하는데 많은 어려움을 겪으면서 기능 개발에 많은 시간을 투자하지 못했고, 겨우 겨우 완성한 기능 코드를 리팩토링할 시간도 없었다.

 

그래서 다음 프로젝트까지 어느 정도 시간이 있기 때문에 클린 코드에 관한 글을 보면서 최대한 클린 코드의 형태를 지키도록 내가 맡은 부분을 리팩토링하는 것으로 클린 코드에 대해 배워볼 것이다.

 

얼마나 적용할 수 있을지는 모르겠지만 앞으로 꾸준하게 연습해 나가면 자연스럽게 되지 않을까 생각한다.

 

Clean Code?

클린 코드란 쉽게 이해할 수 있고, 유지보수하기 쉬우며, 확장 가능하도록 작성된 코드를 의미한다.

클린 코드는 개발자 간의 협업을 원활하게 하고, 버그를 줄이며, 코드의 품질을 높이는 데 중점을 둔다.

- 로버트 C. 마틴의 책 Clean Code에서 널리 알려짐.

 

클린 코드의 특징

  1. 가독성(Readability)
    • 코드를 읽는 사람이 코드의 목적과 동작을 쉽게 이해할 수 있어야 한다.
    • 명확하고 직관적인 변수명, 함수명, 클래스명을 사용한다.
  2. 단순성(Simplicity)
    • 불필요한 복잡성을 피하고, 문제를 해결하기 위한 가장 간단한 방법을 선택한다.
    • "적은 코드로 더 많은 일을 한다"가 아닌, "명확하고 간결한 코드를 작성한다"에 초점을 둔다.
  3. 일관성(Consistency)
    • 동일한 패턴과 스타일을 유지하여, 코드 전반의 일관성을 확보한다.
    • 예를 들어, 네이밍 규칙, 들여쓰기, 주석 스타일 등을 통일한다.
  4. 의도 표현(Intentionality)
    • 코드가 "무엇을 하는지"를 명확히 보여주며, 의도를 숨기지 않는다.
    • 주석이 아니라 코드 자체로 의도를 전달할 수 있도록 한다.
  5. 테스트 가능성(Testability)
    • 코드가 테스트하기 쉽도록 설계되어야 한다.
    • 예를 들어, 함수는 단일 책임을 가지고 독립적으로 동작할 수 있어야 한다.
  6. 확장성과 유지보수성(Extensibility and Maintainability)
    • 새로운 기능 추가 또는 변경이 기존 코드에 큰 영향을 주지 않도록 설계해야 한다.
    • 코드 변경 시 다른 부분에 버그가 발생하지 않도록 의존성을 최소화 한다.

 

클린 코드를 작성하기 위한 원칙

1. 객체 생성에도 유의미한 이름을 사용하라

  • 객체 생성자가 오버로딩 되는 경우 어떤 값으로 어떻게 생성되는지 정보가 부족할 수 있음, 이런 경우에 정적 팩토리 메서드를 사용하는 것이 보다 명확한 코드를 작성할 수 있게 한다. 단, 구현을 드러내는 이름은 피하는 게 좋다.
// 안 좋은 예시
orderService.create(request);

// 좋은 예시
OrderForCreate orderForCreate = orderCreateRequest.toDomain(userDetails.getUserStringId());
OrderPayment orderPayment =  orderService.createOrder(orderForCreate);
  • 안 좋은 예시에서는 orderService에서 무엇을 만드는지 파악하기 힘들고 입력 객체가 구체적으로 무엇인지 파악하기 어렵다.
  • 좋은 예시에서는 orderService에서 Order를 만든다는 것을 알 수 있고, 입력 객체도 Order를 만드는 데 필요한 것임을 알 수 있고, 반환 값도 명확하게 알 수 있다.

2. 함수는 하나의 역할만 해야 한다.

  • 함수는 지정된 이름 아래에서 한 단계 수준의 추상화 수준을 유지해야 하며, 이는 하나의 역할 및 기능만을 하는 것이다.
  • 또는 의미 있는 다른 함수로 추출 가능한 부분이 없다면 그것 역시 하나의 역할 및 기능만을 수행하고 있는 것이다.
// 안 좋은 예시
private void createOrderWithProducts(OrderForCreate orderForCreate, OrderEntity orderEntity) {
        List<OrderProductEntity> orderProductEntities = orderForCreate.productList().stream()
                .map(orderProductForCreate -> {
                    ProductEntity productEntity = productRepository.findByProductUuid(orderProductForCreate.productId())
                            .orElseThrow(() -> new ProductIdInvalidException(orderProductForCreate.productId()));

                    return OrderProductEntity.from(orderForCreate, orderEntity, productEntity);
                })
                .collect(Collectors.toList());

        orderProductRepository.saveAll(orderProductEntities);
    }
    

// 개선
// 함수의 역할 분리
private List<OrderProductEntity> createOrderProducts(OrderForCreate orderForCreate, OrderEntity orderEntity) {
    return orderForCreate.productList().stream()
            .map(orderProductForCreate -> {
                 ProductEntity productEntity = validateAndFindProduct(orderProductForCreate.productId());
                 return OrderProductEntity.from(orderForCreate, orderEntity, productEntity);
            })
            .collect(Collectors.toList());
}

private ProductEntity validateAndFindProduct(String productUuid) {
    return productRepository.findByProductUuid(productUuid)
            .orElseThrow(() -> new ProductIdInvalidException(productUuid));
}

private void saveOrderProducts(List<OrderProductEntity> orderProductEntities) {
    orderProductRepository.saveAll(orderProductEntities);
}
    
// 개선된 함수의 호출
private void createOrderWithProducts(OrderForCreate orderForCreate, OrderEntity orderEntity) {
    List<OrderProductEntity> orderProductEntities = createOrderProducts(orderForCreate, orderEntity);
    saveOrderProducts(orderProductEntities);
}
  • 기존에 사용하던 메서드는 함수는 orderForCreate.productList() 를 순회하며 OrderEntity를 생성하고 생성된 OrderProductEntity를 DB에 저장한다. 이는 하나의 역할만 해야 된다는 2번째 원칙에 맞지 않으며 그로 인해 가독성도 좋지 못한 모습이다.
  • 우선 2개의 역할을 수행하는 기존 메서드를 생성과 저장 2개의 별도의 메서드로 분리하고 호출하도록 변경하고, 그 후 생성을 하는 메서드에서 검증 부분을 별도의 메서드로 분리해 1개의 메서드를 총 4개의 메서드로 분리해 각각 하나의 역할만 수행하도록 했다.

 

3. 명령과 조회를 분리하라 

  • 함수는 뭔가를 수행하거나 조회하거나 하나의 역할만을 해야 한다. 두 개의 역할을 동시에 하면 이상한 함수가 탄생하게 된다.
public class OrderCommandController {

    private final OrderService orderService;

    @ResponseStatus(HttpStatus.CREATED)
    @PostMapping
    public OrderDetailResponse createOrder(@RequestBody OrderCreateRequest orderCreateRequest,
                                           @AuthenticationPrincipal UserDetailsImpl userDetails) {
        
        OrderForCreate orderForCreate = orderCreateRequest.toDomain(userDetails.getUserStringId());
        OrderPayment orderPayment =  orderService.createOrder(orderForCreate);

        return OrderDetailResponse.from(orderPayment);
    }
    
public class OrderQueryController {

    private final OrderPersistenceAdapter orderPersistenceAdapter;
    private final ShopPersistenceAdapter shopPersistenceAdapter;

    @ResponseStatus(HttpStatus.OK)
    @GetMapping("/{orderId}")
    public OrderResponse findOrder(@PathVariable(value = "orderId") String orderId,
                                   @AuthenticationPrincipal UserDetailsImpl userDetails) {

        TotalOrder totalOrder = orderPersistenceAdapter.findByOrderIdAndUserId(orderId, userDetails.getUserStringId())
                .orElseThrow(() -> new OrderNotFoundException(orderId));

        return OrderResponse.from(totalOrder);
    }
  • 프로젝트에서 Contorller를 Command와 Query로 나눠 Query 컨트롤러에서 조회만 수행하도록 만들어 Controller 단에서 명령과 조회를 분리하도록 설계해 사용했다.

 

4. 오류코드 보다는 예외를 활용하자

  • 오류코드를 반환하면 그에 따른 분기가 일어나게 되고, 또 분기가 필요한 경우 중첩되기 마련이다.

5. 여러 예외가 발생하는 경우 Wrapper 클래스로 감싸자

  • 외부 라이브러리를 이용하면 다양한 예외 클래스를 마주하게 된다. 그리고 이러한 예외들을 처리하려면 상당히 번거로워 진다.
  • 이런 상황에서 Wrapper 클래스를 이용해 감싸면 효율적으로 예외 처리를 할 수 있다.
public class GlobalExceptionHandler {

    @ExceptionHandler(ConstraintException.class)
    public ResponseEntity<Error> exceptionHandle(ConstraintException e) {
        log.error("constraintExceptionHandle", e);

        return ApiResponse.error(HttpStatus.BAD_REQUEST, e.getMessage());
    }

    @ExceptionHandler(InvalidValueException.class)
    public ResponseEntity<Error> exceptionHandle(InvalidValueException e) {
        log.error("invalidValueExceptionHandle", e);

        return ApiResponse.error(HttpStatus.BAD_REQUEST, e.getMessage());
    }

    @ExceptionHandler(ForbiddenException.class)
    public ResponseEntity<Error> exceptionHandle(ForbiddenException e) {
        log.error("forbiddenExceptionHandle", e);

        return ApiResponse.error(HttpStatus.FORBIDDEN, e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ResponseEntity<Error> exceptionHandle(MethodArgumentNotValidException e) {
        log.error("methodArgumentNotValidExceptionHandle", e);

        String errorMessage = e.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(fieldError -> fieldError.getField() + ":" + fieldError.getDefaultMessage())
            .collect(Collectors.joining(", "));

        return ApiResponse.error(HttpStatus.BAD_REQUEST, errorMessage);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Error> exceptionHandle(Exception e) {
        log.error("exceptionHandle", e);

        return ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage());
    }

    @ExceptionHandler(RequestValidationException.class)
    public ResponseEntity<Error> exceptionHandle(RequestValidationException e) {
        log.error("RequestValidationExceptionHandle", e);

        return ApiResponse.error(HttpStatus.BAD_REQUEST, e.getMessage());
    }

    @ExceptionHandler(DeletedException.class)
    public ResponseEntity<Error> exceptionHandle(DeletedException e) {
        log.error("deletedExceptionHandle", e);

        return ApiResponse.error(HttpStatus.NOT_FOUND, e.getMessage());
    }

    @ExceptionHandler(DataNotFoundException.class)
    public ResponseEntity<Error> exceptionHandle(DataNotFoundException e) {
        log.error("dataNotFoundExceptionHandle", e);

        return ApiResponse.error(HttpStatus.NOT_FOUND, e.getMessage());
    }
}
  • 예외를 처리하기 위해 GlobalExceptionHandler를 설정하고 각 예외마다 그에 맞는 Wrapper 클래스를 활용해 예외를 처리하도록 했다.
// 오류보다 예외를 사용
public Order validateOrderUuidAndGetOrder(String orderId) {
    return orderOutPutPort.findByOrderUuid(orderId)
            .orElseThrow(() -> new OrderUuidInvalidException(orderId));
}


// Order관련 예외 처리용 WrapperClass
public class OrderUuidInvalidException extends InvalidValueException {

    public OrderUuidInvalidException(String orderUuid) {
        super(String.format(ORDER_UUID_INVALID.getMessage(), orderUuid));
    }
}


// 관련 오류 메시지 enum class
public enum OrderErrorMessage {

    USER_ID_INVALID("유효하지 않은 유저 식별자 입니다. : %s"),
    ORDER_PRODUCT_ID_INVALID("유효하지 않은 주문 상품 식별자 입니다. : %s"),
    ORDER_ID_INVALID("유효하지 않은 주문 식별자 입니다. : %s"),
    ORDER_CANCELLATION_TIME_EXCEEDED("주문 : %s는 주문 후 5분이 지나 취소할 수 없습니다."),
    ORDER_STATE_CANNOT_DB_CHANGED("주문 : %s 의 주문 상태를 변경할 수 없습니다."),
    ORDER_STATE_INVALID("유효하지 않은 주문 상태 입니다."),
    ORDER_UUID_INVALID("유효하지 않은 주문 식별자 입니다. : %s"),
    ORDER_DELETED("삭제된 주문 식별자 입니다. : %s"),
    UNAUTHORIZED_ACCESS("유효하지 않은 권한의 유저 입니다."),
    ORDER_MISMATCH_REVIEWER("주문자 정보가 일치하지 않습니다.: %s");

    private final String message;
}

 

 

6. 테스트 코드의 작성

  • TDD의 핵심 규칙 3가지
    1. 실패하는 단위 테스트를 작성하기 전까지 실제 코드를 작성하지 않는다.
    2. 컴파일은 실패하지 않으면서 실행이 실패하는 정도로면 단위 테스트를 작성한다.
    3. 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.
  • 실제 코드를 변경하면 잠재적인 버그가 발생할 수 있음을 내포하며, 테스트 코드가 있으면 변경된 코드를 검증함으로써 이를 해결할 수 있다.
    • 테스트 코드를 작성할 때 준수해야 할 사항
      1. 1개의 테스트 함수에 대해 assert를 최소화
      2. 1개의 테스트 함수는 1가지 개념 만을 테스트하라
  • First 규칙
    1. Fast: 테스트는 빠르게 동작하여 자주 돌릴 수 있어야 한다.
    2. Independent: 각각의 테스트는 독립적이며 서로 의존해서는 안된다.
    3. Repeatable: 어느 환경에서도 반복 가능해야 한다.
    4. Self-Validating: 테스트는 성공 또는 실패로 bool 값으로 결과를 내어 자체적으로 검증되어야 한다.
    5. Timely: 테스트는 적시에 즉, 테스트하려는 실제 코드를 구현하기 직전에 구현해야 한다.
@Test
@DisplayName("[가게 수정 실패 테스트] 존재하지 않은 가게 식별자인 경우 예외를 발생한다.")
public void shopUpdate_FailureTest_GivenInvalidShopUuid() {
    // Given
    String invalidUuid = "InvalidUuid";
    ShopForUpdate shopForUpdate = new ShopForUpdate(
        invalidUuid, TEST_SHOP_CATEGORY_ID, TEST_SHOP_NAME, TEST_USER_ID);

    shopOutputPortFindByIdReturnEmptyOptional();

    // When & Then
    Assertions.assertAll(
        () -> assertThatThrownBy(() -> shopService.updateShop(shopForUpdate))
            .isInstanceOf(ShopIdInvalidException.class)
            .hasMessage(String.format(SHOP_ID_INVALID.getMessage(), invalidUuid))
    );
}
  • 단위 테스트 코드를 TDD에 따라 작성하지 못하고 서비스 코드 작성 이후에 작성하였다, 추후 개선이 필요하다고 생각한다.

 

정리

클린 코드를 작성하기 위한 원칙은 작성한 내용 외에도 더 있지만 한 번에 모든 내용을 다 배우는 것보단 차근 차근 단계를 밟아가는 것이 좀 더 낫다고 생각하기 때문에 나머지 5개의 원칙에 대해서는 내일 다루도록 하고 오늘 배운 내용을 정리한다.

  1. 다양한 관점에서 볼 때 클린 코드를 지키는 것이 개발할 때도 그 이후에 리팩토링을 할 때도 더 좋기 때문에 클린 코드 원칙을 준수하면서 개발을 진행할 수 있도록 연습이 필요하다.
  2. 함수를 작성할 때 여러 개로 나누기 보단 하나의 함수가 여러 가지 역할을 하도록 만들었는데 클린 코드의 원칙에 맞지 않으며, 가독성과 확장성 측면에서도 좋지 않기 때문에 1개의 역할만 수행하도록 만들어야 된다.
  3. 테스트 코드에 대한 어려움을 해소하기 위해 쉬운 단위 테스트 코드를 작성하는 습관을 들일 필요성을 느꼈다.
  4. 시간이 날 때마다 내가 개발한 코드를 클린 코드에 맞게 리팩토링하며 클린 코드에 대해 적응해야 겠다.

'자바 심화 > TIL' 카테고리의 다른 글

MSA - 기초 1  (1) 2024.11.21
클린 코드 -2  (1) 2024.11.20
페이지네이션 Offset vs Cursor  (2) 2024.11.18
결제 기능 구현  (0) 2024.11.16
PostgreSQL 기초  (1) 2024.11.14