안녕하세요. 자바 스프링 백엔드 개발자 stark입니다!
실무에서 개발한 코드를 보면 비즈니스 로직이 엄청 긴 경우가 있습니다. 이 경우 내가 이렇게 열심히 작성했구나! 이런 뿌듯함을 느낄 수는 있지만 '근데 이거 뭘 작성한 거지?' 이런 생각이 들기도 합니다.
만약 작성한 코드 중간에 비즈니스가 추가되어 수정해야 하거나 개발 중에 잠깐 쉬다 와서 흐름을 잃었다면 '대체 내가 위에 어떤 비즈니스 코드를 작성했지?' 이런 생각이 들면서 모든 코드를 한 줄씩 다 천천히 읽어가며 이해해야 하는 상황이 발생합니다. 심지어 실무에선 내가 작성한 코드가 아닌데 수정해야 하는 경우도 정말 많기에 코드를 작성할 때부터 이해하기 쉽게 작성할 필요성이 있습니다.
그럼 어떻게 해야 우리가 작성한 코드가 읽기 쉬워질까요? 여러 가지 방법이 있겠지만 저는 그중에서도 으뜸이라고 생각되는 Extract Method (메서드 추출) 기법을 추천드리고 싶습니다.
메서드 추출(Extract Method)이 무엇인가?
메서드 추출(Extract Method)은 복잡한 코드 블록을 별도의 메서드로 분리하여 코드의 가독성을 높이는 리팩터링 기법입니다. 예를 들어 긴 메서드나 반복되는 코드, 다양한 책임이 혼합되어 있는 경우, 이를 적절히 추출하여 의미 있는 이름을 가진 메서드로 만드는 방식입니다.
메서드 추출을 적용할 때는 다음과 같은 사항들을 고려하면 더욱 효과적입니다.
- 기능 분리 가능 여부 확인: 메서드로 분리할 코드 블록이 하나의 명확한 책임을 수행하는지 점검합니다.
- 명확한 메서드 이름 지정: 메서드 이름이 해당 코드의 기능을 명확히 설명하도록 합니다.
- 의미 있는 추출: 코드 블록을 메서드로 추출할 때는, 그 메서드가 코드 전체 흐름에서 가치를 더할 수 있도록 만들어야 합니다.
- 중복 제거: 동일한 로직이 여러 번 사용되는 경우, 메서드 추출을 통해 중복을 줄입니다.
- 리팩터링 후 검증: 리팩터링 후 코드의 동작이 기존과 동일한지 확인하는 것도 필수입니다.
메서드 추출이 적용된 예시코드를 살펴봅시다.
public class Calculator {
public int calculateSum(int a, int b) {
validateInput(a, b); // 입력값 검증
return a + b;
}
private void validateInput(int a, int b) {
if (a < 0 || b < 0) {
throw new IllegalArgumentException("입력값은 음수일 수 없습니다.");
}
}
}
위 코드에서 validateInput 메서드는 별도로 분리되어 입력값 검증을 담당합니다. 덕분에 calculateSum 메서드는 더 읽기 쉽게 변했고, 입력 검증 로직을 재사용할 수 있는 여지도 생겼습니다.
메서드 추출의 장점은 다음과 같습니다.
- 코드 가독성 향상: 긴 코드나 복잡한 비즈니스 로직을 작은 단위의 메서드로 나누면 전체적인 코드의 흐름이 명확해지고, 각 메서드의 역할이 한눈에 들어옵니다. 메서드 이름만 봐도 어떤 일을 하는지 알 수 있으니 코드를 읽고 이해하기가 훨씬 쉬워집니다.
- 단일 책임 원칙을 따른다: 코드의 각 메서드가 하나의 명확한 책임만 가지도록 하면, 수정이나 유지보수가 쉬워집니다. 한 메서드가 너무 많은 일을 하는 대신, 각 메서드가 하나의 일을 담당하게 만들면 코드 변경 시 예상치 못한 문제를 줄일 수 있습니다.
- 중복 코드 제거: 같은 기능이 여러 곳에 반복된다면 메서드 추출을 통해 하나의 메서드로 만들어 재사용할 수 있습니다. 이러면 동일한 로직을 변경할 때 한 곳만 수정하면 되기 때문에 실수를 줄이고 유지보수 부담도 줄어듭니다.
- 디버깅이 편리해진다: 특정 로직에 문제가 생겼을 때 메서드 단위로 확인할 수 있어 빠르게 문제를 찾고 해결할 수 있습니다.
- 코드 유지보수 비용 절감: 코드가 잘 읽히면, 새로운 개발자가 프로젝트에 들어올 때나 오래된 코드를 다시 볼 때 적응하는 데 걸리는 시간이 줄어듭니다. 이 말인즉슨, 코드 수정을 해야 할 때도 특정 메서드만 다루면 되니 유지보수가 쉽다는 것입니다.
간단한 예시만 봐서는 메서드 추출의 장점이 크게 와닿지 않을 수 있습니다. 하지만 메서드 추출은 특히 비즈니스 로직이 복잡한 실무에서 그 효과가 두드러집니다. 예를 들어 주문 처리와 같은 여러 단계의 작업을 다루는 경우, 각 단계를 메서드로 분리해 두면 코드가 훨씬 깔끔해지고 유지보수도 한결 수월해집니다.
주문 처리: 리팩터링 전의 긴 비즈니스 로직
비즈니스 로직은 복잡한 도메인 규칙과 다양한 외부 시스템 호출을 포함할 때 매우 길어질 수 있습니다. 다음은 리팩터링 전의 100줄 이상의 긴 주문 처리 로직을 가진 예시 코드입니다.
@Service
@RequiredArgsConstructor
public class OrderProcessingService {
private final OrderPort orderPort;
private final PaymentPort paymentPort;
private final InventoryPort inventoryPort;
private final NotificationPort notificationPort;
public Order processOrder(OrderRequest request) {
// Step 1: 주문 검증
if (request == null || request.getItems() == null || request.getItems().isEmpty()) {
throw new IllegalArgumentException("유효하지 않은 주문 요청입니다.");
}
if (request.getTotalAmount() <= 0) {
throw new IllegalArgumentException("총 주문 금액이 올바르지 않습니다.");
}
// Step 2: 재고 확인 및 예약
for (OrderItem item : request.getItems()) {
boolean available = inventoryPort.checkStock(item.getProductId(), item.getQuantity());
if (!available) {
throw new OutOfStockException("상품 " + item.getProductId() + "의 재고가 부족합니다.");
}
}
// Step 3: 재고 예약
for (OrderItem item : request.getItems()) {
inventoryPort.reserveStock(item.getProductId(), item.getQuantity());
}
// Step 4: 결제 처리
boolean paymentSuccess = paymentPort.processPayment(request.getPaymentDetails(), request.getTotalAmount());
if (!paymentSuccess) {
throw new PaymentFailedException("결제에 실패했습니다.");
}
// Step 5: 주문 생성
Order order = new Order(request.getCustomerId(), request.getItems(), request.getTotalAmount());
order.changeStatus(OrderStatus.PENDING);
orderPort.save(order);
// Step 6: 주문 확인 및 알림 전송
notificationPort.sendOrderConfirmation(order);
// Step 7: 재고 확정
for (OrderItem item : request.getItems()) {
inventoryPort.finalizeStock(item.getProductId(), item.getQuantity());
}
// Step 8: 주문 상태 업데이트
order.changeStatus(OrderStatus.COMPLETED);
orderPort.save(order);
return order;
}
}
언뜻 보면 평상시에도 작성하는 아무런 문제가 없는 정상적인 코드입니다. 단순히 비즈니스 작업 순서대로 코드가 길게 나열되어 있을 뿐입니다. 그러나 아래쪽 비즈니스를 읽던 중 위에 어떤 비즈니스가 있었는지 생각해 봅시다.
혹시 위의 비즈니스 흐름이 기억이 나시나요? 만약 기억이 나신다면 다행이지만 대부분은 놓치는 부분이 있을 것입니다. 만약 하나라도 흐름을 놓치고 로직을 추가 작성한다면 어떻게 될까요? 이런 경우에는 중복된 비즈니스나 잘못된 로직을 작성할 수도 있습니다.
주문 처리: 메서드 추출로 리팩터링 된 코드
이제 위의 비즈니스 코드를 메서드 추출로 개선해 봅시다.
@Service
@RequiredArgsConstructor
public class OrderProcessingService {
private final OrderPort orderPort;
private final PaymentPort paymentPort;
private final InventoryPort inventoryPort;
private final NotificationPort notificationPort;
public Order processOrder(OrderRequest request) {
validateOrderRequest(request); // 주문 요청 검증
reserveInventory(request); // 재고 확인 및 예약
processPayment(request); // 결제 처리
Order order = createAndSaveOrder(request); // 주문 생성 및 저장
notifyCustomer(order); // 고객에게 주문 확인 알림 전송
finalizeInventory(request); // 재고 확정
updateOrderStatus(order, OrderStatus.COMPLETED); // 주문 상태 업데이트
return order;
}
private void validateOrderRequest(OrderRequest request) {
// 주문 요청의 유효성 검증
if (request == null || request.getItems() == null || request.getItems().isEmpty()) {
throw new IllegalArgumentException("유효하지 않은 주문 요청입니다.");
}
if (request.getTotalAmount() <= 0) {
throw new IllegalArgumentException("총 주문 금액이 올바르지 않습니다.");
}
}
private void reserveInventory(OrderRequest request) {
// 재고 확인 및 예약
for (OrderItem item : request.getItems()) {
boolean available = inventoryPort.checkStock(item.getProductId(), item.getQuantity());
if (!available) {
throw new OutOfStockException("상품 " + item.getProductId() + "의 재고가 부족합니다.");
}
inventoryPort.reserveStock(item.getProductId(), item.getQuantity());
}
}
private void processPayment(OrderRequest request) {
// 결제 처리
boolean paymentSuccess = paymentPort.processPayment(request.getPaymentDetails(), request.getTotalAmount());
if (!paymentSuccess) {
throw new PaymentFailedException("결제에 실패했습니다.");
}
}
private Order createAndSaveOrder(OrderRequest request) {
// 주문 생성 및 저장
Order order = new Order(request.getCustomerId(), request.getItems(), request.getTotalAmount());
order.changeStatus(OrderStatus.PENDING);
return orderPort.save(order);
}
private void notifyCustomer(Order order) {
// 고객에게 주문 확인 알림 전송
notificationPort.sendOrderConfirmation(order);
}
private void finalizeInventory(OrderRequest request) {
// 재고 확정
for (OrderItem item : request.getItems()) {
inventoryPort.finalizeStock(item.getProductId(), item.getQuantity());
}
}
private void updateOrderStatus(Order order, OrderStatus status) {
// 주문 상태 업데이트
order.changeStatus(status);
orderPort.save(order);
}
}
코드가 정말 깔끔해졌습니다. 개발자마다 보는 시점이 다를 수 있어, 하단에 많은 메서드가 추가된 것처럼 보이지만 오히려 코드가 복잡해졌다고 느낄 수도 있습니다. 그러나 메서드 추출에서 가장 중요한 점은 '주문 작업'을 담당하는 메인 메서드의 변화입니다.
하단의 추출된 메서드들은 각각의 역할을 충실히 수행하며, 우리는 최상단의 processOrder() 메서드만 집중하면 됩니다. 주문 메서드 내부의 코드가 매우 간결해졌고, 코드의 각 줄이 명확한 역할을 가지게 되어 메인 로직을 한 줄씩 읽는 것만으로도 어떤 작업이 이루어지고 있는지 쉽게 파악할 수 있습니다.
이러한 변화는 메서드 추출을 '추상화'의 일종으로 볼 수 있습니다. 긴 메서드를 역할별로 나누고, 각 메서드의 이름을 통해 각 역할을 추상화했습니다. 덕분에 기존 코드에서는 한 줄씩 세세히 읽으며 "위에서 어떤 작업을 했지?"라고 되짚어봐야 했던 것과 달리, 이제는 한 줄만으로도 이해가 되는 집중된 코드를 만들 수 있었습니다.
결과적으로 전체 코드의 흐름을 이해하기 쉬워지고, 추후 로직을 수정할 때도 코드의 흐름을 파악해 어디에 추가하거나 수정해야 할지를 쉽게 알 수 있게 됩니다.
리팩터링 된 전후 코드 비교
마지막으로 표를 통해 메서드 추출로 리팩터링 하기 이전과 이후의 코드를 비교해 봅시다.
항목 | 리팩터링 전 (긴 코드) | 리팩터링 후 (메서드 추출) |
코드 길이 (processOrder) |
약 70줄 | 약 15줄 |
가독성 | ❌ 낮음: 긴 코드 블록으로 인해 한눈에 이해하기 어려움 | ✔️ 높음: 메서드별로 분리되어 각 단계의 역할이 명확하게 드러남 |
코드 이해 시간 | ❌ 오래 걸림: 각 단계의 역할을 파악하기 위해 코드 전체를 읽어야 함 | ✔️ 짧음: 메서드 이름만 보고도 코드의 흐름과 역할을 쉽게 파악 가능 |
유지보수성 | ❌ 낮음: 특정 로직 수정 시 전체 메서드에 영향을 줄 가능성 있음 | ✔️ 높음: 각 메서드가 독립적으로 관리되므로 특정 로직 수정이 용이 |
테스트 용이성 | ❌ 어려움: 전체 메서드를 테스트해야 함 | ✔️ 쉬움: 각 메서드 단위로 테스트 가능 |
중복 코드 | ❌ 존재할 가능성 높음: 여러 단계에서 코드 중복 발생 가능 | ✔️ 중복 제거됨: 메서드 추출로 코드 중복 최소화 |
단일 책임 원칙 준수 | ❌ 미흡: 하나의 메서드가 여러 책임을 수행함 | ✔️ 잘 준수됨: 각 메서드가 하나의 책임을 담당함 |
재사용성 | ❌ 낮음: 특정 로직을 재사용하기 어려움 | ✔️ 높음: 메서드가 독립적으로 분리되어 다른 곳에서 재사용 가능 |
마무리하며
메서드 추출을 통한 리팩터링은 코드의 가독성을 높이고 유지보수성을 향상시키는 중요한 기법입니다. 이를 통해 코드의 각 부분이 명확한 역할을 갖게 되어, 개발자는 보다 직관적으로 로직을 이해하고 수정할 수 있습니다. 결국, 코드의 품질을 높이고 팀 전체의 생산성과 협업 효율성을 개선하는 데 큰 도움이 됩니다.
기존 코드를 한번 읽어보고 메서드 추출로 리팩터링에 도전해 보시면 어떨까요? 처음에는 "굳이 이걸 분리해야 하나?" 싶겠지만, 막상 하고 나면 "이렇게 간단해질 줄이야!" 하고 놀라실 겁니다. 작은 메서드 하나로 코드의 흐름이 깔끔해지고, 나중에 코드를 다시 보더라도 "내가 왜 이렇게 코드를 작성했지?"라는 상황을 피할 수 있습니다.
저는 가끔 리팩터링 하며 작은 성취감과 함께 커피 한 잔의 여유를 가진답니다. 그러니 여러분도 한번 시도해 보세요!
'Spring > Spring에서 Java 활용하기' 카테고리의 다른 글
[Spring] 의존성과 결합도 제대로 알기 (2) | 2024.11.15 |
---|---|
[Java] Enum NPE 문제 빠르게 해결하기 (feat. equals, switch, AttributeConverter) (0) | 2024.11.03 |
[Spring] synchronized를 사용한 동시성 문제 해결방법 (8) | 2024.06.07 |
[Spring] StackTrace 상세분석 (예외처리) (0) | 2024.03.30 |
[Spring] 자바 리플렉션과 생성자 주입의 관계 (1) | 2023.11.19 |