자바 백엔드 입문 보강편. 회사 코드는 왜 죄다 Controller·Service·Repository 셋으로 쪼개져 있을까 — 처음 보면 와닿지 않는 서비스 레이어 분리의 이유를 식당 비유로 풀고, fat controller의 문제·@Service의 정체·인터페이스 여부·의존 방향까지 정리한 학습 노트.
이 글은 자바 백엔드 입문 시리즈의 보강편이에요. 28편 @RestController에서 요청을 받아 JSON을 돌려주는 법을, 45편 @Entity·Repository에서 DB에 붙는 법을 다뤘죠. 그 사이에 거의 항상 끼어 있는 한 겹이 있어요. 서비스 레이어(Service Layer)예요. 회사 코드를 처음 열어 보면 클래스가 죄다 ~Controller·~Service·~Repository 셋으로 갈라져 있는데, 입문자에게 "왜 굳이 셋으로 쪼개?" 가 잘 안 와닿아요. 이 글이 그 이유를 풀어 가요.
왜 서비스 레이어로 나누라는 게 처음엔 와닿지 않을까
솔직히 말하면, 컨트롤러 하나에 다 때려 넣어도 코드는 돌아가요. 요청 받고, 값 검증하고, DB에서 꺼내고, 계산하고, 저장하고, 응답까지 — 한 메서드 안에서 다 할 수 있어요. 그런데 실무 코드는 예외 없이 셋으로 쪼개져 있죠. 그러니 "동작은 하는데 왜 굳이 번거롭게 나누지?" 라는 의문이 드는 게 당연해요.
이유는 한 단어예요 — 역할 분리. 한 사람이 주문도 받고 요리도 하고 재료도 관리하면 작은 분식집은 돌아가지만, 손님이 늘면 무너져요. 그래서 식당은 일을 나눠요.
비유로 보면 딱 떨어져요. 레이어드 아키텍처는 식당의 분업이에요.
- 홀 직원(Controller) — 손님 주문을 받아 주방에 전달하고, 완성된 음식을 다시 손님에게 내요. 요리는 안 해요.
- 주방장(Service) — 실제로 요리를 해요. 레시피(비즈니스 로직)가 여기 다 있어요.
- 창고 담당(Repository) — 재료를 꺼내 오고 채워 넣어요. 요리는 안 하고 재료 입출고만.
서비스 레이어는 이 중 주방장 자리예요. 음식의 맛을 책임지는, 가장 중요한 한 겹이죠.
세 레이어의 역할 한 줄씩
| 레이어 | 한 줄 역할 | 대표 어노테이션 |
|---|---|---|
| Controller | HTTP 요청·응답만 담당 (받고 넘기고 돌려줌) | @RestController |
| Service | 비즈니스 로직 — "무엇을 어떻게 처리할지" | @Service |
| Repository | DB 접근 — 저장·조회만 | @Repository |
핵심은 "각 레이어는 자기 일만 한다" 예요. 컨트롤러는 계산하지 않고, 서비스는 HTTP를 모르고, 리포지토리는 비즈니스 규칙을 모르죠. 이 경계가 흐릿해지는 순간 코드가 엉키기 시작해요.
컨트롤러에 다 넣으면 생기는 일
말로만 하면 안 와닿으니, 다 때려 넣은 컨트롤러를 한번 볼게요. 흔히 fat controller(비대한 컨트롤러)라고 불러요.
@RestController
public class OrderController {
private final OrderRepository orderRepository;
private final PointRepository pointRepository;
@PostMapping("/orders")
@Transactional
public OrderResponse create(@RequestBody OrderRequest req) {
// 1. 검증
if (req.amount() <= 0) throw new IllegalArgumentException("금액 오류");
// 2. 포인트 차감 로직
Point point = pointRepository.findByUserId(req.userId());
if (point.getBalance() < req.amount()) throw new IllegalStateException("잔액 부족");
point.setBalance(point.getBalance() - req.amount());
// 3. 주문 저장
Order order = orderRepository.save(new Order(req.userId(), req.amount()));
// 4. 응답 변환
return new OrderResponse(order.getId(), point.getBalance());
}
}
동작은 해요. 그런데 여기서 시험 함정이 하나 있어요 — 이 코드는 "동작하는 것"과 "유지보수되는 것"이 전혀 다른 문제라는 걸 보여줘요. 무엇이 문제냐면,
- 재사용이 안 돼요. 똑같은 "포인트 차감 주문" 로직을 배치 작업에서도 써야 하면? 컨트롤러 안에 있으니 HTTP 없이는 못 불러요.
- 테스트가 어려워요. 비즈니스 로직만 단위 테스트하고 싶은데, 컨트롤러라 매번 HTTP 요청을 흉내 내야 해요.
- 트랜잭션 경계가 애매해요.
@Transactional을 컨트롤러에 붙이는 건 권장되지 않아요. 트랜잭션은 비즈니스 한 덩어리를 묶는 건데, 그 덩어리는 서비스에 있어야 자연스럽거든요.
서비스 레이어가 하는 진짜 일
위 로직에서 검증·포인트 차감·주문 저장 묶음을 빼내 서비스로 옮기면 이렇게 돼요.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PointRepository pointRepository;
@Transactional
public Order placeOrder(Long userId, int amount) {
if (amount <= 0) throw new IllegalArgumentException("금액 오류");
Point point = pointRepository.findByUserId(userId);
point.use(amount); // 잔액 부족이면 내부에서 예외
return orderRepository.save(new Order(userId, amount));
}
}
이제 컨트롤러는 "요청 받아서 서비스에 넘기고, 결과를 응답으로 변환" 만 해요. 진짜 일은 서비스가 하죠.
@RestController
public class OrderController {
private final OrderService orderService;
@PostMapping("/orders")
public OrderResponse create(@RequestBody OrderRequest req) {
Order order = orderService.placeOrder(req.userId(), req.amount());
return new OrderResponse(order.getId());
}
}
서비스 레이어가 맡는 건 결국 세 가지예요 — 비즈니스 규칙(잔액이 부족하면 막는다), 여러 리포지토리의 조합(포인트와 주문을 함께 다룬다), 트랜잭션 경계(이 묶음은 한 덩어리로 성공하거나 실패한다). 43편 @Transactional에서 "트랜잭션은 서비스 레이어에 붙이는 게 표준" 이라고 한 게 바로 이래서예요.
"비즈니스 로직은 어디에 둘까?" 고민될 땐 한 가지만 떠올리면 돼요 — HTTP 없이도 호출할 수 있어야 하는 코드면 서비스 레이어. 컨트롤러는 HTTP 통역사일 뿐, 판단은 서비스가 합니다.
@Service는 그냥 @Component인데 왜 따로 있나
여기서 헷갈리는 분들 많아요. @Service를 뜯어 보면 사실 @Component예요. 기능이 똑같아요 — 둘 다 18편에서 본 컴포넌트 스캔 대상으로 Bean이 됩니다. 그럼 왜 이름이 따로 있을까요.
답은 의미 전달이에요. @Service라고 적는 순간 "이 클래스는 비즈니스 로직 담당이다" 가 코드만 봐도 읽혀요. 사람에게도, 도구에게도요. 이런 걸 스테레오타입(stereotype) 어노테이션이라고 불러요 — @Controller·@Service·@Repository가 다 같은 식구죠. 기능은 @Component와 같지만 역할을 이름표로 박아 둔 거예요. @Repository는 거기에 더해 DB 예외를 Spring 공통 예외로 바꿔 주는 부가 기능까지 살짝 얹혀 있어요.
인터페이스를 꼭 만들어야 하나
옛날 스프링 책을 보면 서비스를 항상 인터페이스 + 구현 클래스 두 개로 만들어요(OrderService 인터페이스 + OrderServiceImpl 클래스). 정석이긴 한데, 잠깐 — 이게 요즘도 무조건 맞느냐 하면 그렇진 않아요.
- 인터페이스를 두는 게 좋은 경우 — 같은 기능을 여러 방식으로 구현할 일이 예상될 때. 예를 들어 결제를 카드·계좌이체 두 구현으로 갈아 끼워야 하면 인터페이스가 빛나요.
- 클래스 하나로 충분한 경우 — 구현이 어차피 하나뿐이면
OrderServiceImpl같은 껍데기 인터페이스는 파일만 늘리는 형식이 되기도 해요. 그래서 요즘 실무에서는 구현체가 하나면 클래스 단독으로 두는 흐름도 많아요.
정답은 "구현이 갈릴 가능성이 보이면 인터페이스, 아니면 클래스" 예요. 처음부터 모든 서비스에 인터페이스를 강제할 필요는 없어요.
의존 방향 — 위에서 아래로만
마지막으로 가장 중요한 규칙 하나. 의존은 항상 위에서 아래로만 흘러요.
Controller → Service → Repository
컨트롤러는 서비스를 알지만, 서비스는 컨트롤러를 몰라요. 서비스는 리포지토리를 알지만, 리포지토리는 서비스를 몰라요. 이 방향이 거꾸로 흐르거나 서로를 부르기 시작하면(15편 의존성 주입에서 본 순환 의존) 구조가 무너져요. 화살표가 한 방향으로만 가는지 — 그게 레이어드 아키텍처가 건강한지 보는 가장 빠른 체크예요.
자주 막히는 함정
서비스가 HttpServletRequest를 받아요. 서비스에 HTTP 냄새가 나면 경계가 무너진 신호예요. HTTP 관련 처리는 컨트롤러에서 끝내고, 서비스에는 순수한 값만 넘기세요.
컨트롤러끼리 서로 호출해요. 공통 로직이 필요하면 컨트롤러를 부르지 말고 서비스로 내려서 둘 다 그 서비스를 쓰면 돼요.
리포지토리에 비즈니스 규칙이 들어가요. "잔액이 부족하면 막는다" 같은 판단은 리포지토리가 아니라 서비스(또는 도메인 객체) 일이에요. 리포지토리는 저장·조회만.
시험·면접 직전 압축 노트 — 서비스 레이어
- 레이어드 아키텍처 = Controller · Service · Repository 3계층 분업
- 비유 — 홀 직원(Controller) · 주방장(Service) · 창고 담당(Repository)
- Controller = HTTP 받고 넘기고 응답 변환 (계산 X)
- Service = 비즈니스 로직의 집 (규칙·여러 Repository 조합·트랜잭션 경계)
- Repository = DB 저장·조회만 (비즈니스 규칙 X)
- fat controller(다 때려 넣기) 문제 = 재사용 불가 · 테스트 어려움 · 트랜잭션 경계 애매
- 비즈니스 로직 위치 판단 = HTTP 없이도 호출돼야 하면 서비스 레이어
@Service는 사실상@Component— 기능 동일, 의미(역할) 전달용 스테레오타입- 스테레오타입 =
@Controller·@Service·@Repository(같은 식구) @Repository는 DB 예외를 Spring 공통 예외로 변환하는 부가 기능 있음- 인터페이스+구현 = 정석이지만, 구현체 하나뿐이면 클래스 단독도 실무에서 흔함
- 인터페이스 둘 기준 = 구현이 갈릴 가능성이 보일 때
@Transactional은 컨트롤러가 아니라 서비스 레이어에 붙이는 게 표준- 의존 방향 = Controller → Service → Repository 한 방향만
- 역방향·상호 호출 = 순환 의존 위험 (구조 붕괴 신호)
- 서비스가
HttpServletRequest를 받으면 경계 무너진 신호
공식 문서: Spring — Stereotype Annotations에서 @Service·@Repository의 정의를 확인할 수 있어요.
시리즈 다른 편
같이 읽으면 좋은 글: