개요
어제 작성한 클린 코드 1에 이어 클린 코드를 작성하기 위한 나머지 원칙들에 대해 배우고 프로젝트 리팩토링에 적용해 보는 과정을 가질 것이다.
클린 코드를 작성하기 위한 원칙
7. 클래스의 최소화
클래스 역시 함수와 마찬가지로 간결하게 작성하는 것이 중요하다. 함수는 물리적 크기를 측정했다면 클래스는 몇 개의 역할 또는 책임을 갖는지를 척도로 활용하며, 단일 책임 원칙(SRP)에 따라 1가지 책임만을 가져야 한다.
요약 글만으로는 이해하기 해당 부분에 대해 좀 더 찾아봤다.
클래스 최소화의 핵심 원칙
- 단일 책임 원칙(SRP, Single Responsibility Principle)
- 각 클래스는 하나의 명확한 목적을 가져야 한다.
- 여러 역할을 수행하는 "만능 클래스"를 만들지 않도록 주의합니다.
- 예) 데이터 저장소와 비즈니스 로직을 동일한 클래스에 포함하지 않고, 이를 분리한다.
- 불필요한 클래스 제거
- 코드가 동작하는 데 필요 없거나, 단순히 로직을 포장하기 위해 생성된 클래스를 피한다.
- 지나치게 세분화된 클래스를 만들면 오히려 복잡성을 증가시킨다.
- 유틸리티 클래스를 신중히 사용
- 유틸리티 클래스는 상태를 가지지 않으며, 순수한 도구 메서드만 포함해야 한다.
- 하지만, 객체지향적인 설계에서 유틸리티 클래스 대신 책임을 가진 객체를 선호하는 것이 일반적이다.
- 클래스 계층을 얕게 유지
- 클래스 계층 구조를 지나치게 깊게 설계하면 코드 이해가 어려워진다.
- 적절한 추상화를 사용하여 계층을 단순화한다.
- DRY 원칙 준수
- 비슷한 기능의 클래스를 중복해서 생성하지 말고, 공통 기능을 재사용하거나 적절히 통합한다.
클래스 최소화 필요성
- 복잡성 감소
- 클래스가 많으면 코드베이스가 복잡해지고, 이해하거나 디버깅하는 데 시간이 많이 걸림
- 불필요한 클래스는 클래스 간 의존성도 증가시키므로, 시스템의 유지보수를 어렵게 만든다.
- 가독성 및 유지보수성 향상
- 적절한 수의 클래스는 코드의 가독성을 높이고, 각 클래스의 역할이 명확해져 유지보수도 쉬워진다.
- 메모리 사용 최적화
- 불필요한 클래스가 많으면 애플리케이션의 메모리 사용량이 증가할 수 있다.
유의점
- 너무 큰 클래스(Monolithic Class) 방지
- 모든 기능을 하나의 클래스에 몰아 넣으면 단일 책임 원칙에 위배된다.
- 적절한 추상화
- 각 클래스가 지나치게 많은 책임을 가지지 않도록 주의해야 한다.
- 잘 설계된 인터페이스나 추상 클래스를 통해 클래스를 명확히 분리한다.
- 읽기 쉽고 유지보수 가능한 구조 유지
- 클래스가 많으면 가독성이 떨어질 수 있지만, 역할이 분명하고 잘 조직된 클래스 구조는 코드를 이해하기 쉽게 만든다.
기존에 만든 OrderService 클래스를 위 법칙에 맞게 리팩토링 한다면 기존의 서비스 클래스 코드를 역할에 따라 서브 서비스 클래스를 만들어서 사용하는 방법이 있을 수 있다.
7. 클래스 분리(역할 별)
- OrderManagementService: 주문 생성 및 상태 변경 관리
- OrderValidationService: 주문 검증 로직
- OrderCancellationService: 주문 취소 로직
@RequiredArgsConstructor
@Service
public class OrderService {
private final OrderManagementService orderManagementService;
private final OrderCancellationService cancellationService;
public OrderPayment createOrder(OrderForCreate orderForCreate) {
return orderManagementService.createOrder(orderForCreate);
}
public String updateOrderState(OrderForUpdate orderForUpdate) {
return orderManagementService.updateOrderState(orderForUpdate);
}
public String cancelOrder(String orderId, String userId) {
return cancellationService.cancelOrder(orderId, userId);
}
}
- 기존 서비스 코드는 서브 서비스 클래스를 통합하여 호출하는 역할로 변경하였다.
@Service
@RequiredArgsConstructor
public class OrderValidationService {
private final OrderOutputPort orderOutputPort;
private final UserOutputPort userOutputPort;
private final ProductService productService;
private final ShopService shopService;
public void validateUserIdAndRole(String userStringId, OrderType orderType) {
User user = userOutputPort.findByUserStringId(userStringId)
.orElseThrow(() -> new UserIdInvalidException(userStringId));
Role role = user.getRole();
if (orderType == OrderType.OFFLINE && role == Role.CUSTOMER) {
throw new UnauthorizedAccessException();
}
}
public void validateProductList(List<OrderProductForCreate> productList) {
productService.validateProductsAndGetProductList(productList);
}
public void validateShop(String shopUuid) {
shopService.validateShopUuid(shopUuid);
}
public Order validateOrderUuidAndGetOrder(String orderId) {
return orderOutputPort.findByOrderUuid(orderId)
.orElseThrow(() -> new OrderUuidInvalidException(orderId));
}
public void validateUpdateOrderState(OrderState orderState, String orderUuid) {
if (orderState.equals(OrderState.CANCELED)) {
throw new OrderStateChangedException(orderUuid);
}
}
public Order validateOrderUuidAndGetNotDeletedOrder(String orderUuid) {
Order order = validateOrderUuidAndGetOrder(orderUuid);
if (order.getIsDeleted()) {
throw new OrderDeletedException(orderUuid);
}
return order;
}
public void validateOrderOwnership(String userId, String orderUserId) {
if (orderUserId.equals(userId)) {
return;
}
throw new UnauthorizedAccessException();
}
public void validateOrderBelongToUser(Order order, String userStringId) {
if (!order.isSameReviewer(userStringId)) {
throw new OrderMismatchReviewerException(userStringId);
}
}
}
- Order에 필요한 모든 검증 메서드를 검증 서비스 클래스로 만들고 필요한 곳에서 호출하도록 변경했다.
@Service
@RequiredArgsConstructor
public class OrderManagementService {
private final OrderOutputPort orderOutputPort;
private final PaymentService paymentService;
private final OrderValidationService validationService;
public OrderPayment createOrder(OrderForCreate orderForCreate) {
validationService.validateUserIdAndRole(orderForCreate.userId(), orderForCreate.orderType());
validationService.validateProductList(orderForCreate.productList());
validationService.validateShop(orderForCreate.shopId());
String orderId = orderOutputPort.saveOrder(orderForCreate);
String paymentId = paymentService.createPayment(orderId);
return new OrderPayment(orderId, paymentId);
}
public String updateOrderState(OrderForUpdate orderForUpdate) {
validationService.validateOrderUuidAndGetOrder(orderForUpdate.orderId());
validationService.validateUpdateOrderState(orderForUpdate.orderState(), orderForUpdate.orderId());
return orderOutputPort.updateOrderState(orderForUpdate);
}
}
- Order 생성 및 상태 업데이트를 위한 서비스 클래스로 분리했다.
@Service
@RequiredArgsConstructor
public class OrderCancellationService {
private final OrderOutputPort orderOutputPort;
private final PaymentService paymentService;
private final OrderValidationService validationService;
public String cancelOrder(String orderId, String userId) {
Order order = validationService.validateOrderUuidAndGetOrder(orderId);
paymentService.validateOrderCheckCanceled(order);
validationService.validateOrderOwnership(userId, orderId);
LocalDateTime orderTime = order.getCreatedAt();
LocalDateTime currentTime = LocalDateTime.now();
Duration duration = Duration.between(orderTime, currentTime);
if (duration.toMinutes() > 5) {
throw new OrderCancellationTimeExceededException(orderId);
}
return orderOutputPort.cancelOrder(order, userId);
}
}
- Order 취소를 위한 서비스 클래스로 분리했다.
분리를 통해 SRP를 준수하고 가독성과 재사용성, 테스트 적인 측면에서 기존의 코드보다 좋아진 거 같지만 클래스가 늘어남에 따라 생긴 클래스 관리와 서비스 간 호출이 많아지는 것 등의 문제도 발생할 것 같다.
8. 클래스의 응집도
응집도(Cohesion)는 클래스나 모듈 내 구성 요소(메서드, 필드 등)가 얼마나 밀접하게 관련되어 있는지를 나타내는 소프트웨어 설계 원칙이다.
- 높은 응집도: 클래스가 하나의 명확한 책임이나 목적을 가지고, 관련된 작업만을 수행함.
- 낮은 응집도: 클래스가 서로 관련 없는 작업을 수행하거나, 여러 책임을 혼합하여 갖고 있음.
높은 응집도는 코드의 가독성, 유지보수성, 재사용성을 높여준다.
위에서 클래스의 최소화에 따라 분리한 코드를 응집도 측면에서 평가한 결과 각각의 장, 단점이 있었다.
응집도 측면의 장점
- 높은 응집도:
- 클래스는 주문 생성 및 상태 업데이트라는 명확한 도메인 작업에만 초점을 맞추고 있어, 높은 응집도를 가지고 있다고 볼 수 있다.
- 책임 분리:
- 주요 책임을 외부 서비스에 위임함으로써 클래스 자체가 불필요하게 커지지 않았고, 메서드들이 단일 도메인과 관련된 작업만 수행하고 있다.
- 가독성:
- 클래스의 메서드들이 논리적으로 연결되어 있으며, 읽는 사람이 "주문 관리"라는 맥락에서 쉽게 이해할 수 있다.
응집도 측면의 단점
- 검증 로직 중복 가능성:
- validationService에서 여러 검증 로직을 제공하는데, 일부 검증 로직이 다른 서비스에서도 필요하다면, 과도한 호출이 발생할 수 있다.
- 역할 분리가 과도해질 위험:
- 클래스가 너무 검증 서비스나 외부 컴포넌트에 의존하면, 실제 책임이 분산되어 클래스 자체의 응집도가 감소할 가능성이 있다.
클래스의 최소화와 응집도는 상충되는 면이 있어서 적절한 균형점을 찾아서 리팩토링하는 과정이 필요할 것 같다. 아직 클린 코드에 대한 경험이 많지 않아 어렵게 느껴진다.
9. 변경하기 쉬운 클래스
요구사항은 수시로 변하기 때문에, 변경하기 쉬운 클래스를 만드는 것이 중요하다.
변경하기 쉬운 클래스는 기본적으로 단일 책임 원칙을 지켜야 하며, 구현체보다 추상화에 의존해야 하며 다형성이 핵심이다.
위에서 리팩토링한 클래스는 어느 정도 변경하기 쉬운 클래스의 기준을 충족하면서도 개선점이 있다고 한다.
- 응집도를 높이기 위해 도메인 모델에 책임을 일부 위임해 더 단순화 하거나
- 새로운 요구사항 추가에 따른 확장성을 높이기 위해 비즈니스 규칙과 조율 책임을 분리하는 것
10. 설계 품질을 높여주는 4가지 규칙
- 모든 테스트를 실행하라: 테스트가 쉬운 코드를 작성하다 보면 SRP를 준수하고, 더 낮은 결합도를 갖는 설계를 얻을 수 있다
- 중복을 제거하라: 깔끔한 시스템을 만들기 위해 단 몇 줄이라도 중복을 제거해야 한다.
- 프로그래머의 의도를 표현하라: 좋은 이름, 작은 클래스와 메소드의 크기, 표준 명칭, 단위 테스트 작성 등을 통해 이를 달성할 수 있다.
- 클래스와 메소드의 수를 최소로 줄여라: 클래스와 메소드를 작게 유지함으로써 시스템 크기 역시 작게 유지할 수 있다.
2~4의 작업은 리팩토링 과정에 해당하며, 모든 테스트케이스를 작성한 후 코드와 클래스를 정리하면 안전하다.
11. 디미터 법칙
디미터의 법칙은 어떤 모듈이 호출하는 객체의 속사정을 몰라야 한다는 것이다. 그렇기에 객체는 자료를 숨기고 함수를 공개해야 한다. 만약 자료를 그대로 노출하면 내부 구조가 드러나 결합도가 높아지게 된다.
디미터 법칙 위반 예제
public class OrderService {
public String getCustomerCity(Order order) {
// Order -> Customer -> Address -> City (체인을 따라간다)
return order.getCustomer().getAddress().getCity();
}
}
- 위 코드처럼 체이닝 메서드를 사용할 경우 객체 간의 의존성이 높아진다는 문제가 발생한다.
- 위의 경우 Address의 구조 변경 시 OrderService 코드도 변경되야 한다는 문제가 있다.
디미터 법칙의 핵심 규칙
- 다음과 같은 객체들과만 상호작용해야 한다:
- 객체 자신
- 메서드의 매개변수
- 객체가 직접 생성한 인스턴스
- 클래스 필드(직접 포함된 객체)
- 피해야 할 상황:
- 체인을 따라 여러 객체에 접근 (e.g., a.getB().getC().getD())
- 객체 내부 구현에 의존
디미터 법칙의 현실적인 고려
- 필요 이상의 캡슐화는 피하기:
- 디미터 법칙을 무조건적으로 적용하면 메서드의 수가 불필요하게 늘어날 수 있다.
- 지나친 캡슐화로 인해 오히려 코드가 복잡해질 수도 있으니 적절히 균형을 맞추는 것이 중요하다.
- 도메인 모델 활용:
- 디미터 법칙을 잘 지키는 설계는 도메인 모델의 역할을 강화하는 데 도움이 된다.
- 객체의 책임을 명확히 나누고, 데이터 대신 행동을 공유하도록 유도한다.
현실적인 적용 예제
public class Order {
private Customer customer;
public String getCustomerCity() {
return customer.getCity(); // Customer 객체의 내부 구조를 캡슐화
}
}
// OrderService
public class OrderService {
public String getOrderCustomerCity(Order order) {
return order.getCustomerCity(); // 체인 호출 없이 간단하게 접근
}
}
- 고객 주소 정보를 Order 객체의 get 메서드를 통해 가져오는 것으로 OrderService는 필요한 정보를 얻을 수 있고, Customer에서 City가 변경 되더라도 Order만 변경하면 되므로 변경을 최소화 할 수 있다.
정리
프로젝트에서 유사 MSA 아키텍처를 사용하게 되면서 Clean Code에 대한 필요성을 느껴 배우게 되었는데, 클린 코드를 작성하기 위한 원칙에서 서로 상충되는 부분도 있고 연계되는 부분도 있어서 적절한 균형점을 찾는 것이 어려웠다.
클린 코드의 법칙을 한 단계씩 따르며 리팩토링을 하면서 내 코드의 문제점도 파악할 수 있었고, 어떻게 하면 더 좋은 코드를 작성할 수 있을지 고민할 수 있었다.
개발에는 정답은 없지만 클린코드의 개념을 항상 기억하고 코드를 만들 때 적절하게 적용하는 과정을 통해 좀 더 나은 코드를 작성할 수 있게 되어야 할 거 같다.
참고
https://mangkyu.tistory.com/132
https://yozm.wishket.com/magazine/detail/2415/
https://www.nextree.io/basic-of-clean-code/
https://www.samsungsds.com/kr/insights/cleancode-0823.html
chatGPT
'자바 심화 > TIL' 카테고리의 다른 글
MSA - 기초 2 (1) | 2024.11.22 |
---|---|
MSA - 기초 1 (1) | 2024.11.21 |
클린 코드 1 (2) | 2024.11.19 |
페이지네이션 Offset vs Cursor (2) | 2024.11.18 |
결제 기능 구현 (0) | 2024.11.16 |