
Controller, Service, Repository
Spring Boot로 REST API를 만들다 보면 자연스럽게 다음과 같은 구조를 사용하게 된다.
Controller
→ Service
→ Repository
→ Database
처음에는 단순히 “Spring에서는 이렇게 폴더를 나누는구나”라고 생각하기 쉽다.
하지만 이 구조는 파일을 보기 좋게 정리하기 위한 규칙이 아니라, 각 객체의 책임을 분리하여 변경과 테스트에 유리한 프로그램을 만들기 위한 설계 방식이다.
이번 글에서는 MVC가 무엇인지부터 시작해 Spring MVC가 HTTP 요청을 처리하는 실제 과정, @Controller와 @RestController의 차이, 그리고 간단한 상품 조회 API 예제까지 정리해 본다.
1. MVC 패턴이란?

MVC는 애플리케이션을 다음 세 가지 역할로 나누는 설계 패턴이다.
M: Model
V: View
C: Controller
구성 요소역할
| 구성 요소 | 역할 |
| Model | 애플리케이션의 데이터와 비즈니스 상태를 관리 |
| View | 사용자에게 보여 줄 결과를 표현 |
| Controller | 사용자의 요청을 받고 Model과 View를 연결 |
Spring MVC는 Spring Framework에서 제공하는 웹 애플리케이션 개발 모듈이며, MVC 패턴을 기반으로 HTTP 요청을 처리하고 데이터를 전달하며 화면을 렌더링한다. Spring Boot 웹 애플리케이션도 내부적으로 Spring MVC를 사용한다.
전체적인 흐름은 다음과 같다.
사용자 요청
→ Controller가 요청을 받음
→ Model의 상태 또는 데이터를 처리
→ View가 결과를 표현
→ 사용자에게 응답
예를 들어 사용자가 상품 상세 페이지를 요청했다고 가정해 보자.
GET /products/10
Controller는 URL에 포함된 상품 번호 10을 받는다. 이후 상품 데이터를 조회하고, 조회된 정보를 View에 전달한다. View는 받은 데이터를 HTML로 표현하여 사용자에게 보여 준다.
Model은 단순한 데이터 객체가 아니다
MVC에서 Model은 넓은 의미로 사용된다.
Model에는 다음과 같은 요소가 포함될 수 있다.
- 도메인 객체
- 애플리케이션의 상태
- 비즈니스 규칙
- 데이터베이스에서 조회한 데이터
- Controller가 View로 전달하는 데이터
Spring MVC에는 실제로 Model이라는 객체도 존재한다.
@GetMapping("/products/{id}")
public String productDetail(
@PathVariable Long id,
Model model
) {
Product product = productService.findById(id);
model.addAttribute("product", product);
return "product-detail";
}
이 코드에서 Model은 Controller의 처리 결과를 View로 전달하는 저장소 역할을 한다.
key: "product"
value: 조회된 Product 객체
다만 MVC 패턴의 Model 전체가 Spring의 Model 객체 하나만을 의미하는 것은 아니다. Service에서 수행하는 비즈니스 로직과 도메인 데이터 역시 넓은 의미의 Model 영역에 포함된다.
2. Spring Boot와 Spring MVC의 관계
Spring MVC와 Spring Boot는 같은 개념이 아니다.
Spring MVC는 웹 요청을 처리하는 프레임워크이고, Spring Boot는 Spring MVC를 쉽게 구성하고 실행하도록 도와주는 실행 플랫폼에 가깝다.
구분Spring MVCSpring Boot
| 구분 | Spring MVC | Spring Boot |
| 역할 | HTTP 요청 처리 구조 제공 | Spring 애플리케이션 설정과 실행 자동화 |
| 핵심 기능 | Controller, DispatcherServlet, ViewResolver | 자동 설정, Starter, 내장 서버 |
| 서버 | 별도 설정이 필요할 수 있음 | 내장 Tomcat 자동 구성 |
| 설정 | 직접 설정할 요소가 많음 | Auto-Configuration으로 대부분 자동 설정 |
Spring Boot는 내부적으로 Spring MVC를 그대로 사용한다. spring-boot-starter-web을 추가하면 DispatcherServlet, Jackson, Spring MVC와 내장 Tomcat 등이 자동으로 구성된다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
}
이 의존성 하나를 추가하면 일반적으로 다음 요소가 함께 준비된다.
Spring MVC
Jackson
DispatcherServlet
내장 Tomcat
기본 예외 처리
JSON 직렬화·역직렬화
즉, Spring Boot가 새로운 MVC 구조를 만드는 것이 아니라 기존 Spring MVC를 쉽게 시작할 수 있도록 감싸 주는 것이다.
3. Spring MVC 요청 처리 원리
Spring MVC의 중심에는 DispatcherServlet이 있다.
DispatcherServlet은 모든 HTTP 요청을 가장 먼저 받는 Front Controller다.
Client
→ DispatcherServlet
→ 적절한 Controller 탐색
→ Controller 실행
→ 결과 변환
→ Client 응답
Spring MVC에서는 각 Controller가 요청을 직접 기다리는 것이 아니다. 모든 요청이 먼저 DispatcherServlet으로 들어오고, DispatcherServlet이 요청에 적합한 Controller를 찾아 실행한다.
자료에서는 DispatcherServlet이 단일 진입점으로 요청을 받은 뒤 HandlerMapping과 HandlerAdapter를 이용해 Controller를 실행하고, 결과를 가공하여 클라이언트에 반환하는 구조로 설명한다.
DispatcherServlet
DispatcherServlet은 요청 처리 전체 흐름을 제어한다.
주요 역할은 다음과 같다.
요청 수신
→ 요청을 처리할 Handler 탐색
→ Handler 실행 요청
→ 실행 결과 수신
→ View 또는 응답 데이터 변환
→ HTTP 응답
DispatcherServlet이 비즈니스 로직을 직접 수행하지는 않는다.
요청과 Controller 사이를 연결하고 결과가 올바른 방식으로 반환되도록 조정하는 역할을 한다.
HandlerMapping
HandlerMapping은 요청 URL과 HTTP Method를 분석해 어떤 Controller 메서드가 요청을 처리해야 하는지 찾는다.
@GetMapping("/products/{id}")
public ProductResponse getProduct(
@PathVariable Long id
) {
// ...
}
다음 요청이 들어왔다고 가정해 보자.
GET /products/10
HandlerMapping은 다음 정보를 이용해 메서드를 찾는다.
HTTP Method: GET
Request URI: /products/10
그리고 /products/{id}와 연결된 Controller 메서드를 반환한다.
HandlerAdapter
Controller마다 실행 방식과 파라미터 구조가 다를 수 있다.
HandlerAdapter는 선택된 Controller 메서드를 실제로 실행할 수 있도록 요청 데이터를 메서드 파라미터에 맞게 변환한다.
@GetMapping("/products/{id}")
public ProductResponse getProduct(
@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean detail
) {
// ...
}
HandlerAdapter는 요청의 다음 정보를 분석한다.
/products/10
→ id = 10
?detail=true
→ detail = true
이렇게 변환된 값을 Controller 메서드에 전달한다.
Controller 실행
Controller는 요청 데이터를 전달받고 필요한 Service를 호출한다.
@GetMapping("/products/{id}")
public ProductResponse getProduct(
@PathVariable Long id
) {
return productService.findById(id);
}
Controller는 직접 데이터베이스를 조회하지 않고 Service에 작업을 위임하는 것이 일반적이다.
결과 반환
Controller의 반환 방식에 따라 이후 처리가 달라진다.
@Controller
→ ViewResolver가 화면을 찾음
@RestController
→ HttpMessageConverter가 객체를 JSON으로 변환
이 차이가 Spring MVC 기반 웹 페이지와 REST API를 이해하는 핵심이다.
4. @Controller와 @RestController의 차이
@Controller
@Controller는 HTML 화면을 반환하는 전통적인 MVC 방식에서 사용한다.
@Controller
public class ProductPageController {
@GetMapping("/products/{id}")
public String productPage(
@PathVariable Long id,
Model model
) {
Product product = productService.findById(id);
model.addAttribute("product", product);
return "product-detail";
}
}
반환된 "product-detail"은 HTML 내용 자체가 아니다.
View의 논리적인 이름이다.
Controller 반환값
→ "product-detail"
ViewResolver 처리
→ templates/product-detail.html
최종 결과
→ HTML 응답
Controller는 실제 View 파일의 정확한 경로나 View 기술을 직접 알 필요가 없다.
@RestController
@RestController는 화면이 아니라 데이터를 반환하는 REST API에서 사용한다.
@RestController
public class ProductApiController {
@GetMapping("/api/products/{id}")
public ProductResponse getProduct(
@PathVariable Long id
) {
return productService.findResponseById(id);
}
}
@RestController는 다음 두 어노테이션을 결합한 것이다.
@RestController
= @Controller
+ @ResponseBody
Controller가 반환한 Java 객체는 HttpMessageConverter를 통해 JSON으로 변환된다.
ProductResponse 객체
→ Jackson
→ JSON
→ HTTP Response Body
예를 들어 다음 객체가 반환되었다고 하자.
new ProductResponse(
1L,
"무선 마우스",
15_000
);
클라이언트에는 다음과 같은 JSON이 전달된다.
{
"id": 1,
"name": "무선 마우스",
"price": 15000
}
Spring MVC에서 View를 반환하는 경우에는 ViewResolver가 동작하고, REST API에서 객체를 반환하는 경우에는 HttpMessageConverter가 JSON 변환을 담당한다.
5. Controller, Service, Repository를 나누는 이유
실제 Spring Boot 프로젝트에서는 MVC만 이야기하기보다 다음과 같이 계층을 더 세분화한다.
Controller
→ Service
→ Repository
→ Database
이 구조를 Layered Architecture, 즉 계층형 아키텍처라고 부른다.
MVC와 계층형 아키텍처는 완전히 같은 개념은 아니다.
MVC
→ 사용자 요청과 화면 표현을 분리하는 패턴
Controller–Service–Repository
→ 애플리케이션 내부의 책임을 계층별로 분리하는 구조
Spring Boot 웹 애플리케이션에서는 두 개념을 함께 사용하는 경우가 많다.
Controller 계층
Controller는 HTTP와 관련된 책임을 담당한다.
- URL과 HTTP Method 매핑
- Path Variable과 Query Parameter 수신
- JSON 요청 본문 바인딩
- 요청 데이터의 형식 검증
- Service 호출
- HTTP 상태 코드와 응답 데이터 반환
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(
ProductService productService
) {
this.productService = productService;
}
}
Controller가 가격 계산, 재고 검증이나 DB 조회까지 직접 담당하면 책임이 지나치게 많아진다.
Service 계층
Service는 애플리케이션의 비즈니스 규칙을 처리한다.
- 가격과 할인 계산
- 주문 가능 여부 판단
- 재고 확인
- 중복 데이터 검사
- 여러 Repository 작업 조합
- 트랜잭션 관리
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(
ProductRepository productRepository
) {
this.productRepository = productRepository;
}
}
자료에서는 Service가 Controller와 Repository 사이에서 비즈니스 로직, 검증, 계산, 데이터 가공과 트랜잭션을 담당한다고 설명한다.
Repository 계층
Repository는 데이터 저장소와의 통신을 담당한다.
- 데이터 저장
- 데이터 조회
- 데이터 수정
- 데이터 삭제
- JPA, JDBC, MyBatis와 같은 접근 기술 추상화
public interface ProductRepository {
Optional<Product> findById(Long id);
List<Product> findAll();
}
Repository를 분리하면 데이터 저장 방식이 변경되어도 Service에 미치는 영향을 줄일 수 있다.
Memory Repository
→ 테스트 또는 학습
JPA Repository
→ 관계형 데이터베이스
외부 API Repository
→ 외부 서비스 조회
Service는 구체적인 저장 방식보다 Repository 인터페이스에 의존할 수 있다.
6. 간단한 MVC 계층 구조 예제
상품 ID를 입력받아 상품 정보를 반환하는 REST API를 만들어 보자.
이번 예제에서는 MVC 계층의 역할을 명확하게 보기 위해 실제 DB 대신 메모리 Repository를 사용한다.
전체 구조는 다음과 같다.
GET /api/products/1
↓
ProductController
↓
ProductService
↓
ProductRepository
↓
MemoryProductRepository
↓
ProductResponse JSON
도메인 객체
상품의 핵심 상태를 표현한다.
public class Product {
private final Long id;
private final String name;
private final int price;
public Product(
Long id,
String name,
int price
) {
if (id == null || id <= 0) {
throw new IllegalArgumentException(
"상품 ID는 양수여야 합니다."
);
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException(
"상품명은 필수입니다."
);
}
if (price < 0) {
throw new IllegalArgumentException(
"상품 가격은 0 이상이어야 합니다."
);
}
this.id = id;
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public int getPrice() {
return price;
}
}
Product는 단순히 값을 저장하는 객체가 아니라, 상품이 유효한 상태를 유지하도록 기본 규칙을 보호한다.
Repository 인터페이스
Service가 구체적인 저장 방식에 의존하지 않도록 인터페이스를 만든다.
public interface ProductRepository {
Optional<Product> findById(Long id);
}
메모리 Repository 구현체
@Repository
public class MemoryProductRepository
implements ProductRepository {
private final Map<Long, Product> products =
new HashMap<>();
public MemoryProductRepository() {
products.put(
1L,
new Product(1L, "무선 마우스", 15_000)
);
products.put(
2L,
new Product(2L, "기계식 키보드", 89_000)
);
}
@Override
public Optional<Product> findById(Long id) {
return Optional.ofNullable(products.get(id));
}
}
Repository는 상품을 어디에서 가져오는지만 담당한다.
상품이 존재하지 않을 때 어떤 메시지를 보여 줄지, 어떤 응답 DTO를 만들지는 Repository의 책임이 아니다.
응답 DTO
도메인 객체를 API에 그대로 노출하지 않고 응답 전용 DTO를 만든다.
public record ProductResponse(
Long id,
String name,
int price
) {
public static ProductResponse from(
Product product
) {
return new ProductResponse(
product.getId(),
product.getName(),
product.getPrice()
);
}
}
DTO는 클라이언트에 필요한 데이터 구조를 표현한다.
Product
→ 애플리케이션 내부의 도메인 객체
ProductResponse
→ 외부에 공개되는 API 응답 구조
Service
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(
ProductRepository productRepository
) {
this.productRepository = productRepository;
}
public ProductResponse findById(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(
() -> new NoSuchElementException(
"상품을 찾을 수 없습니다. id=" + id
)
);
return ProductResponse.from(product);
}
}
Service는 다음 두 가지 작업을 수행한다.
Repository에서 상품 조회
→ 상품이 없으면 예외 발생
Product 도메인 객체
→ ProductResponse DTO로 변환
Controller
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(
ProductService productService
) {
this.productService = productService;
}
@GetMapping("/{id}")
public ProductResponse findProduct(
@PathVariable Long id
) {
return productService.findById(id);
}
}
Controller는 요청 값을 받고 Service를 호출한 뒤 결과를 반환한다.
비즈니스 판단이나 저장소 접근은 하지 않는다.
예외 처리
상품이 없을 때는 500 오류보다 404 응답이 적절하다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
NoSuchElementException exception
) {
ErrorResponse response = new ErrorResponse(
"PRODUCT_NOT_FOUND",
exception.getMessage()
);
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(response);
}
}
public record ErrorResponse(
String code,
String message
) {
}
정상 요청
GET /api/products/1
{
"id": 1,
"name": "무선 마우스",
"price": 15000
}
존재하지 않는 상품 요청
GET /api/products/100
{
"code": "PRODUCT_NOT_FOUND",
"message": "상품을 찾을 수 없습니다. id=100"
}
7. 예제의 실제 요청 처리 과정
앞에서 만든 API에 다음 요청이 들어왔다고 가정해 보자.
GET /api/products/1
Spring MVC 내부에서는 다음 과정이 진행된다.
1. 클라이언트가 GET /api/products/1 요청
2. 내장 Tomcat이 요청 수신
3. DispatcherServlet으로 요청 전달
4. HandlerMapping이 ProductController.findProduct() 탐색
5. HandlerAdapter가 경로의 "1"을 Long id로 변환
6. Controller가 ProductService.findById(1L) 호출
7. Service가 ProductRepository.findById(1L) 호출
8. Repository가 Product 객체 반환
9. Service가 ProductResponse로 변환
10. Controller가 ProductResponse 반환
11. HttpMessageConverter가 객체를 JSON으로 직렬화
12. 클라이언트에게 200 OK와 JSON 응답
이 흐름에서 각 객체는 자신의 책임만 수행한다.
DispatcherServlet
→ 전체 요청 흐름 조정
Controller
→ HTTP 요청과 응답
Service
→ 애플리케이션의 비즈니스 흐름
Repository
→ 데이터 접근
HttpMessageConverter
→ Java 객체와 JSON 변환
8. MVC 구조를 적용할 때 주의할 점
Controller에 비즈니스 로직을 넣지 않는다
다음 코드는 Controller가 너무 많은 일을 담당한다.
@GetMapping("/{id}")
public ProductResponse getProduct(
@PathVariable Long id
) {
Product product = productRepository.findById(id)
.orElseThrow();
int discountedPrice =
product.getPrice() * 90 / 100;
return new ProductResponse(
product.getId(),
product.getName(),
discountedPrice
);
}
Controller가 Repository를 직접 조회하고 할인 가격까지 계산한다.
이 로직은 Service로 이동하는 것이 적절하다.
Controller
→ 요청을 받는 역할
Service
→ 할인 규칙을 적용하는 역할
Repository
→ 상품을 조회하는 역할
Service를 단순 전달 계층으로만 만들지 않는다
반대로 Service가 Controller의 호출을 그대로 Repository에 전달하기만 한다면 계층을 나눈 의미가 작아질 수 있다.
public Product findById(Long id) {
return repository.findById(id).orElseThrow();
}
단순 CRUD에서도 Service는 다음 책임을 담당할 수 있다.
- 조회 실패 시 도메인에 맞는 예외 변환
- Entity와 DTO 변환
- 권한과 상태 검사
- 트랜잭션 경계 설정
- 여러 Repository 작업 조합
DTO와 Entity를 구분한다
JPA Entity를 Controller에서 바로 반환하는 것은 피하는 것이 좋다.
@GetMapping("/{id}")
public ProductEntity getProduct(
@PathVariable Long id
) {
return productRepository.findById(id)
.orElseThrow();
}
Entity를 직접 노출하면 다음 문제가 생길 수 있다.
- 데이터베이스용 필드가 API에 노출됨
- 민감한 정보가 반환될 수 있음
- Entity 변경이 API 응답 변경으로 이어짐
- 연관관계 직렬화 중 순환 참조 발생 가능
- 지연 로딩 과정에서 오류나 불필요한 쿼리 발생 가능
따라서 API 요청과 응답에는 DTO를 사용하는 것이 안전하다.
계층은 단방향으로 의존하게 만든다
일반적인 의존 방향은 다음과 같다.
Controller
→ Service
→ Repository
Repository가 Service를 호출하거나 Service가 Controller를 호출하면 계층의 책임이 뒤섞인다.
권장하지 않는 구조
Repository → Service
Service → Controller
상위 계층은 하위 계층을 사용할 수 있지만, 하위 계층이 상위 계층에 의존하지 않도록 설계하는 것이 좋다.
전체 흐름 요약
Spring MVC
→ HTTP 요청을 처리하는 웹 프레임워크
DispatcherServlet
→ 모든 요청의 단일 진입점
HandlerMapping
→ 요청을 처리할 Controller 탐색
HandlerAdapter
→ 요청 값을 파라미터로 변환하고 Controller 실행
Controller
→ HTTP 요청·응답 처리
Service
→ 비즈니스 로직 처리
Repository
→ 데이터 저장소 접근
ViewResolver
→ 논리적 View 이름을 실제 View로 변환
HttpMessageConverter
→ Java 객체와 JSON 변환
핵심 정리
MVC의 목적
Model
→ 데이터와 애플리케이션 상태
View
→ 결과 표현
Controller
→ 요청을 받아 Model과 View 연결
Spring MVC의 중심
Client
→ DispatcherServlet
→ HandlerMapping
→ HandlerAdapter
→ Controller
→ View 또는 JSON 응답
웹 페이지와 REST API의 차이
@Controller
→ 논리적 View 이름 반환
→ ViewResolver
→ HTML
@RestController
→ Java 객체 반환
→ HttpMessageConverter
→ JSON
계층별 책임
Controller
→ HTTP
Service
→ Business
Repository
→ Data
MVC 구조의 핵심은 클래스를 많이 만드는 것이 아니다.
변경 이유가 서로 다른 코드를 서로 다른 객체와 계층으로 분리하는 것이 핵심이다.
Controller는 HTTP 요청 형식이 바뀔 때 변경되고, Service는 비즈니스 규칙이 바뀔 때 변경되며, Repository는 데이터 저장 기술이 바뀔 때 변경된다. 이렇게 변경 이유를 분리하면 각 계층을 독립적으로 이해하고 테스트하기 쉬워진다.
복습
- MVC에서 Model은 Spring의 Model 객체만을 의미하는가?
- Spring Boot와 Spring MVC는 어떤 관계인가?
- DispatcherServlet이 Front Controller라고 불리는 이유는 무엇인가?
- HandlerMapping과 HandlerAdapter는 각각 어떤 역할을 하는가?
- @Controller와 @RestController의 반환값은 어떻게 처리되는가?
- ViewResolver와 HttpMessageConverter의 차이는 무엇인가?
- Controller가 Repository를 직접 호출하면 어떤 문제가 생길 수 있는가?
- Service 계층에는 어떤 로직을 작성해야 하는가?
- JPA Entity를 응답 객체로 직접 반환하지 않는 것이 좋은 이유는 무엇인가?
- Controller → Service → Repository 의존 방향을 지켜야 하는 이유는 무엇인가?
'개발 > Spring' 카테고리의 다른 글
| [Spring] Swagger UI와 OpenAPI 문서화 정리 (0) | 2026.07.31 |
|---|---|
| [Spring] Spring Boot JPA 동작 원리와 CRUD 정리 (0) | 2026.07.31 |
| [Spring] DTO는 무엇일까? (0) | 2025.09.08 |
| [Spring] Spring Boot란? (0) | 2025.07.07 |
| [Spring] Spring이란 (2) | 2025.06.23 |