비즈니스 로직과 트랜잭션의 자리
@PostMapping("/order")
public String order(...) {
// 재고 확인 (mapper 직접 호출)
Item i = itemMapper.findById(itemId);
if (i.getStock() < quantity) throw new ...;
// 주문 저장
orderMapper.insert(order);
// 재고 감소
itemMapper.decreaseStock(itemId, quantity);
// 결제 처리
paymentApi.charge(amount);
// 알림 발송
notificationService.send(...);
return "redirect:/order/list";
}
한 메서드에 5 가지 책임. 테스트 불가능, 재사용 불가능, 트랜잭션 처리 모호.
Controller(종업원)에게 주문서를 받아 실제 요리(비즈니스 로직)를 책임지는 메인 셰프.
@Service
public class OrderService {
@Autowired private ItemMapper itemMapper;
@Autowired private OrderMapper orderMapper;
@Autowired private PaymentApi paymentApi;
public Long order(int itemId, int quantity, int userId) {
// 다음 슬라이드에서 4 단계 처리
}
}
한 주문을 처리하려면 세 부품이 필요합니다 — 상품 / 주문 / 결제.
public Long order(int itemId, int quantity, int userId) {
// ① 재고 확인 (비즈니스 규칙)
Item item = itemMapper.findById(itemId);
if (item.getStock() < quantity) {
throw new OutOfStockException();
}
// ② 주문 저장
Order order = new Order(itemId, quantity, userId);
orderMapper.insert(order);
// ③ 재고 감소
itemMapper.decreaseStock(itemId, quantity);
// ④ 결제
paymentApi.charge(order.getAmount());
return order.getId();
}
@PostMapping("/order")
public String order(@RequestParam int itemId,
@RequestParam int quantity,
HttpSession session) {
Member u = (Member) session.getAttribute("loginUser");
Long orderId = service.order(itemId, quantity, u.getId());
return "redirect:/order/" + orderId;
}
👉 컨트롤러는 「세션에서 사용자 가져옴 → 서비스 호출 → 응답」 만. 단 한 줄짜리 위임.
@Service
public class TransferService {
@Transactional
public void transfer(int from, int to, int amount) {
accountMapper.withdraw(from, amount); // 출금
accountMapper.deposit(to, amount); // 입금
}
}
두 곳 중에 — 왜 Service 인가?
@Service
public class MemberService {
public void register(Member m) {
save(m); // ⚠️ 여기서 호출하면
}
@Transactional
public void save(Member m) {
// ⚠️ 트랜잭션이 적용 안 됨!
}
}
Spring AOP 의 프록시 메커니즘 때문. Self-invocation 우회 — 다른 빈에서 호출 또는 클래스 분리.
// 인터페이스 (선택적)
public interface MemberService {
Member find(int id);
void register(Member m);
}
@Service
public class MemberServiceImpl implements MemberService {
@Override
public Member find(int id) { ... }
@Override
public void register(Member m) { ... }
}
👉 옛 컨벤션: 인터페이스 + Impl. 본 과정은 단순화 — 구현 클래스만.
인터페이스는 다른 구현이 필요할 때 (테스트 mock 등) 추가.
| 계층 | 분량 | 이유 |
|---|---|---|
| Controller | 얇게 (Thin) | HTTP 처리만 |
| Service | 두툼하게 (Fat) | 비즈니스 로직 모음 |
| Mapper | 얇게 | SQL 만 |
👉 Service 가 두툼해야 — 도메인 변경 시 한 곳만 수정.
public class OutOfStockException extends RuntimeException {
public OutOfStockException(String msg) { super(msg); }
}
@Service
public class OrderService {
public Long order(int itemId, int qty, int userId) {
Item i = itemMapper.findById(itemId);
if (i.getStock() < qty) {
throw new OutOfStockException("재고 부족");
}
// ...
}
}
// Controller 또는 @ControllerAdvice 에서
@ExceptionHandler(OutOfStockException.class)
public String handleOutOfStock(...) { ... }
| 실수 | 증상 |
|---|---|
| @Service 빠뜨림 | NoSuchBeanDefinitionException |
| Service 가 너무 얇음 | Controller 가 비대해짐 |
| @Transactional 을 Mapper 에 | 여러 Mapper 호출 시 트랜잭션 안 묶임 |
| Self-invocation | 트랜잭션 적용 안 됨 |
| Service 가 Controller 호출 | 의존 방향 위배. 절대 X |
Service 가 무엇인지는 알지만 어디까지가 Service 의 일인지 흐릿.
5 가지 책임을 외운다. @Transactional 의 자리가 Service 인 이유를 안다.