목차
컨트롤러가 업무 절차를 떠안는다
주문을 하나 만드는 API를 보자. 요청을 받아 재고를 확인하고, 재고를 줄이고, 주문을 저장하고, 알림을 보낸다.
@PostMapping("/orders")
public OrderResponse create(@RequestBody OrderRequest req) {
Product product = productRepository.findById(req.getProductId());
if (product.getStock() < req.getQuantity()) { // 재고가 되나
throw new IllegalStateException("재고 부족");
}
product.setStock(product.getStock() - req.getQuantity()); // 재고를 줄이고
productRepository.save(product);
Order order = new Order(req.getProductId(), req.getQuantity());
orderRepository.save(order); // 주문을 저장하고
mailSender.sendOrderCreated(order); // 알림을 보낸다
return new OrderResponse(order.getId());
}돌아는 간다. 문제는 이 다음이다.
같은 주문을, 관리자 화면에서도 만들어야 한다. 밤에 도는 배치가 예약 주문을 처리할 때도 만들어야 한다. 그때마다 저 절차를 복붙한다. 그리고 “재고가 0이어도 예약은 받는다”로 규칙이 바뀌는 날, 고쳐야 할 곳이 세 군데가 된다. 하나를 빠뜨리면 입구에 따라 다르게 동작하는 시스템이 된다.
원인은 자리를 잘못 잡은 것이다. “주문을 만든다”는 업무 절차는 HTTP와 아무 상관이 없는데, HTTP를 받는 자리에 들어앉아 있다.
서비스 계층은 업무 절차를 맡는다
서비스 계층은 업무 절차를 담는 자리다. 요청을 어떻게 받았는지와 무관하게, “주문을 만든다”가 무슨 일들의 묶음인지를 여기 적는다.
컨트롤러에서 절차를 들어내면 층이 셋으로 갈린다.
각 층이 아는 게 다르다. 컨트롤러는 HTTP를, 서비스는 업무를, Repository는 저장을 안다. Repository 패턴이 서비스에서 SQL을 걷어내는 이야기였다면, 이 글은 그렇게 남은 서비스가 어디서 와서 무엇을 맡는지에 대한 이야기다.
절차를 옮기고 나면 컨트롤러는 이렇게 짧아진다.
@PostMapping("/orders")
public OrderResponse create(@RequestBody OrderRequest req) {
Long orderId = orderService.create(req.getProductId(), req.getQuantity());
return new OrderResponse(orderId);
}컨트롤러가 하는 일이 셋으로 줄었다. 요청을 받고, 서비스를 부르고, 응답을 만든다. 재고 규칙도 알림도 여기 없다.
계층마다 아는 것이 다르다
옮겨간 서비스는 이렇게 생겼다.
@Service
public class OrderService {
private final ProductRepository products;
private final OrderRepository orders;
private final MailSender mailSender;
// 생성자 생략
public Long create(Long productId, int quantity) {
Product product = products.findById(productId);
if (product.getStock() < quantity) {
throw new IllegalStateException("재고 부족");
}
product.setStock(product.getStock() - quantity);
products.save(product);
Order order = new Order(productId, quantity);
orders.save(order);
mailSender.sendOrderCreated(order);
return order.getId();
}
}코드는 아까와 거의 같은데, 딱 하나가 다르다. HttpServletRequest도, @RequestBody도, 상태 코드도 없다. 파라미터는 Long productId와 int quantity, 즉 업무의 말이다.
이 차이가 층을 가르는 기준이다.
| 층 | 아는 것 | 몰라야 하는 것 |
|---|---|---|
| 컨트롤러 | HTTP 메서드·경로·상태 코드·직렬화 | 재고 규칙 |
| 서비스 | 업무 절차와 규칙 | 요청이 HTTP로 왔는지 배치로 왔는지 |
| Repository | 데이터를 어떻게 꺼내고 넣는지 | 왜 저장하는지 |
서비스가 HTTP를 모른다는 건 규칙이 아니라 증상으로 확인하는 것이다. 서비스 코드에 HttpServletRequest나 ResponseEntity가 등장하면, 그 서비스는 이미 HTTP 없이는 못 부르는 코드가 된 것이다. 배치에서 부르려는 순간 그게 드러난다.
그리고 이 분리가 테스트에서 값을 한다. “재고가 모자라면 거부하는가”를 확인하는 데 HTTP 요청을 흉내 낼 필요가 없다. 서비스를 그냥 자바 객체로 만들어 메서드를 부르면 된다.
트랜잭션 경계가 여기다
서비스 계층에는 자리 하나가 더 있다. 어디까지가 한 업무인지를 정하는 자리다.
위 코드에서 재고 차감은 성공했는데 주문 저장이 실패하면 어떻게 되나. 재고만 줄어든 채 주문은 없는 상태가 남는다. 팔지도 않은 물건의 재고가 사라진 것이다.
둘은 같이 되거나 같이 안 돼야 한다. 그 묶음의 범위를 선언하는 게 @Transactional이다.
@Service
public class OrderService {
@Transactional // 여기부터 여기까지가 한 업무다
public Long create(Long productId, int quantity) {
// 재고 차감 + 주문 저장
// 중간에 예외가 나면 둘 다 없던 일이 된다
}
}왜 컨트롤러도 Repository도 아닌 서비스에 붙나. 한 업무가 무엇인지 아는 층이 여기뿐이기 때문이다. Repository는 “주문 하나 저장”까지만 알아서 범위를 정할 수 없고, 컨트롤러가 정하면 HTTP 요청 하나가 곧 업무 단위라고 못 박는 셈이 된다. 배치로 부르면 그 가정이 깨진다.
조회만 하는 메서드에는 @Transactional(readOnly = true)를 붙인다. 이 메서드가 아무것도 안 바꾼다는 의도가 코드에 드러나고, Spring과 Hibernate를 함께 쓰면 커밋 시점에 변경 감지를 건너뛰어 조회가 가벼워진다. readOnly는 JPA 표준이 아니라 Spring이 주는 옵션이라, 무엇이 최적화되는지는 쓰는 구현체에 달렸다.
한 업무 안의 변경들을 어떻게 모았다가 한 번에 반영하는지, 그 안쪽 이야기는 Unit of Work에 있다.
같은 절차를 여러 입구가 쓴다
절차를 서비스로 뺀 값어치가 여기서 돌아온다. 입구가 늘어도 절차는 하나다.
배치는 이렇게 부른다.
@Scheduled(cron = "0 0 2 * * *")
public void createReservedOrders() {
for (Reservation r : reservations.findDueToday()) {
orderService.create(r.getProductId(), r.getQuantity()); // 컨트롤러와 같은 절차
}
}재고 규칙이 바뀌면 OrderService.create만 고친다. API로 들어오든 배치로 들어오든 같은 규칙이 적용된다. 복붙이 없으니 어긋날 수도 없다.
이게 미들웨어와 갈리는 지점이기도 하다. 인증·로그처럼 모든 요청이 겪는 공통 처리는 요청 길목(필터)에서 하고, 업무 절차는 서비스에서 한다. 배치에는 HTTP 요청이 없으니 필터도 없지만, 업무 규칙은 그대로 필요하다.
서비스가 다시 비대해질 때
컨트롤러를 비웠더니 이번엔 서비스가 부푼다. 주문 생성 하나에 할인 계산, 등급별 적립, 쿠폰 검증까지 들어오면 메서드가 100줄이 된다. 자리만 옮긴 셈이다.
이때 밀어낼 곳은 도메인 객체다. 데이터를 들고 있는 객체가 자기 규칙을 갖게 한다.
앞서 서비스로 옮긴 코드에 이미 그런 자리가 있었다.
// 서비스가 재고를 직접 들여다보고 판단한다
if (product.getStock() < quantity) {
throw new IllegalStateException("재고 부족");
}
product.setStock(product.getStock() - quantity);이 코드는 재고 규칙을 서비스가 안다. Product를 쓰는 다른 서비스가 생기면 같은 검사를 또 적게 된다. 규칙을 객체 안으로 넣으면 이렇게 바뀐다.
public class Product {
private int stock;
public void decreaseStock(int quantity) { // 규칙이 객체 안에 있다
if (stock < quantity) {
throw new IllegalStateException("재고 부족");
}
this.stock -= quantity;
}
}// 서비스는 절차만 남는다
product.decreaseStock(quantity);서비스가 얇아졌고, 재고를 줄이는 모든 코드가 같은 검사를 거치게 됐다.
그래서 서비스가 맡을 것은 절차이지 규칙이 아니다. “무엇을 어떤 순서로 하는가”는 서비스가, “이 데이터가 지켜야 할 조건”은 도메인 객체가 맡는다. 서비스 메서드를 읽었을 때 업무 순서가 목차처럼 보이면 알맞은 두께다.
계층을 건너뛰면 무너진다
층을 갈라놓고도 자주 무너지는 자리가 둘 있다.
컨트롤러가 Repository를 직접 부르는 것. “이건 단순 조회니까”로 시작한다. 그러다 그 조회에 조건이 하나 붙고, 권한 검사가 붙는다. 그 규칙은 서비스에 없으니 다른 입구에는 적용되지 않는다.
@GetMapping("/orders/{id}")
public OrderResponse get(@PathVariable Long id) {
Order order = orderRepository.findById(id); // 서비스를 건너뛰었다
return new OrderResponse(order);
}지금 당장은 잘 돌아간다. 문제는 “내 주문만 볼 수 있다”는 규칙이 생기는 날 드러난다.
서비스가 서비스를 부르는 것. 이건 필요하면 해도 되지만, 두 서비스가 서로를 부르기 시작하면 순환이 생기고 트랜잭션 경계가 헷갈린다. 공통 로직이면 도메인 객체나 별도 컴포넌트로 내리는 쪽이 낫다.
자주 겪는 함정 하나. 같은 클래스 안에서 자기 메서드를 부르면 @Transactional이 안 걸린다. Spring이 프록시로 트랜잭션을 여는데, 내부 호출은 프록시를 거치지 않고 곧장 자기 메서드로 가기 때문이다. 트랜잭션이 열린 줄 알았는데 안 열려 있는 상태가 조용히 만들어진다. 자세한 건 Unit of Work에서 본다.
정리
| 서비스 계층이란 | 업무 절차를 담는 층. 컨트롤러(HTTP)와 Repository(저장) 사이 |
| 왜 쓰나 | 절차가 입구마다 복붙되는 걸 막는다. 규칙이 한 곳에 있다 |
| 무엇을 아나 | 업무의 말만 안다. HttpServletRequest가 등장하면 이미 샌 것 |
| 트랜잭션 | 한 업무가 무엇인지 아는 층이라 경계(@Transactional)가 여기 붙는다 |
| 두께 | 절차는 서비스가, 규칙은 도메인 객체가. 서비스는 목차처럼 읽혀야 한다 |
| 무너지는 자리 | 컨트롤러가 Repository 직접 호출 · 서비스끼리 얽히는 호출 |
업무 절차를 HTTP에서 떼어내 한 곳에 두는 것, 그게 서비스 계층이 하는 일이다.