Spring MVC 아키텍처와 요청 처리 과정 완벽 가이드
1. MVC 패턴이란?
MVC(Model-View-Controller) 패턴은 소프트웨어 설계에서 가장 널리 사용되는 아키텍처 패턴 중 하나로, 애플리케이션을 세 가지 핵심 컴포넌트로 분리하여 관심사의 분리(Separation of Concerns)를 달성하는 것을 목표로 한다.
Model (모델)
모델은 애플리케이션의 데이터와 비즈니스 로직을 담당한다. 데이터베이스와의 상호작용, 데이터 검증, 비즈니스 규칙 적용 등이 모두 모델 계층에서 이루어진다. Spring에서는 주로 @Entity, @Service, @Repository 등의 어노테이션이 붙은 클래스들이 모델 역할을 수행한다. 모델은 뷰나 컨트롤러에 대해 전혀 알지 못하며, 오직 데이터의 상태와 그 상태를 변경하는 로직에만 집중한다.
View (뷰)
뷰는 사용자에게 보여지는 화면, 즉 프레젠테이션 계층을 담당한다. 모델이 가진 데이터를 사용자가 이해할 수 있는 형태로 표현하는 역할을 한다. Spring MVC에서는 JSP, Thymeleaf, Mustache 같은 템플릿 엔진이 뷰의 역할을 수행하며, REST API 환경에서는 JSON이나 XML 형태의 응답이 뷰에 해당한다.
Controller (컨트롤러)
컨트롤러는 모델과 뷰 사이의 중재자 역할을 한다. 사용자의 요청을 받아 적절한 모델을 호출하고, 그 결과를 적절한 뷰에 전달하는 흐름을 제어한다. Spring에서는 @Controller 또는 @RestController 어노테이션이 붙은 클래스가 컨트롤러 역할을 수행한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Model 예시
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Builder
public Member(String name, String email) {
this.name = name;
this.email = email;
}
}
// Controller 예시
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@GetMapping("/members/{id}")
public String getMember(@PathVariable Long id, Model model) {
MemberResponse member = memberService.findById(id);
model.addAttribute("member", member);
return "member/detail"; // View 이름 반환
}
}
MVC 패턴을 적용하면 각 계층이 독립적으로 개발 및 테스트 가능해지고, 코드의 재사용성이 높아지며, 유지보수가 훨씬 수월해진다. 예를 들어 화면 디자인을 변경해야 할 때 뷰만 수정하면 되고, 비즈니스 로직이 바뀌어도 컨트롤러나 뷰에 미치는 영향을 최소화할 수 있다.
2. Spring MVC의 전체 아키텍처
Spring MVC는 Java 기반 웹 애플리케이션 개발을 위한 프레임워크로, 전통적인 MVC 패턴을 웹 환경에 맞게 확장한 구조를 가지고 있다. Spring MVC의 핵심은 Front Controller 패턴을 구현한 DispatcherServlet이다.
전체 아키텍처를 구성하는 주요 컴포넌트는 다음과 같다.
- DispatcherServlet: 모든 HTTP 요청의 진입점. Front Controller 역할을 수행한다.
- HandlerMapping: 요청 URL을 처리할 핸들러(컨트롤러)를 찾는 역할을 한다.
- HandlerAdapter: 찾아낸 핸들러를 실제로 실행하는 어댑터이다.
- Handler (Controller): 실제 비즈니스 로직을 호출하고 결과를 반환하는 컨트롤러이다.
- ViewResolver: 논리적 뷰 이름을 실제 뷰 객체로 변환한다.
- View: 최종 응답을 렌더링한다.
- HttpMessageConverter: 요청/응답 본문을 Java 객체로 변환하거나 그 반대를 수행한다.
이 컴포넌트들이 유기적으로 협력하여 클라이언트의 요청을 처리하고 응답을 생성한다. Spring MVC의 가장 큰 장점은 이 모든 과정이 선언적(어노테이션 기반)으로 설정 가능하며, 각 컴포넌트를 필요에 따라 교체하거나 커스터마이징할 수 있는 높은 확장성에 있다.
3. DispatcherServlet의 역할과 동작 원리
DispatcherServlet은 Spring MVC의 핵심 컴포넌트로, Front Controller 패턴을 구현한다. Front Controller 패턴은 모든 요청을 하나의 진입점에서 받아 중앙에서 처리 흐름을 제어하는 디자인 패턴이다. 이를 통해 공통 로직(인증, 로깅, 인코딩 등)을 중앙에서 일괄 처리할 수 있다.
DispatcherServlet의 계층 구조
DispatcherServlet은 다음과 같은 상속 구조를 가진다.
1
2
3
4
5
GenericServlet
└── HttpServlet
└── HttpServletBean
└── FrameworkServlet
└── DispatcherServlet
HttpServletBean은 서블릿의 init-param을 Spring Bean 프로퍼티로 바인딩하고, FrameworkServlet은 WebApplicationContext를 초기화하며, DispatcherServlet은 실질적인 요청 디스패치 로직을 담고 있다.
DispatcherServlet 초기화 과정
Spring Boot에서는 DispatcherServlet이 자동으로 등록되지만, 내부적으로는 DispatcherServletAutoConfiguration에 의해 설정된다. 초기화 시 onRefresh() 메서드가 호출되면서 다음과 같은 전략 객체들을 초기화한다.
1
2
3
4
5
6
7
8
9
10
11
12
// DispatcherServlet 내부 초기화 메서드 (실제 Spring 소스코드 기반)
protected void initStrategies(ApplicationContext context) {
initMultipartResolver(context); // 멀티파트 파일 업로드 처리
initLocaleResolver(context); // 로케일(지역 정보) 처리
initThemeResolver(context); // 테마 처리
initHandlerMappings(context); // 핸들러 매핑 초기화
initHandlerAdapters(context); // 핸들러 어댑터 초기화
initHandlerExceptionResolvers(context); // 예외 리졸버 초기화
initRequestToViewNameTranslator(context);
initViewResolvers(context); // 뷰 리졸버 초기화
initFlashMapManager(context); // FlashMap 매니저 초기화
}
doDispatch 메서드 - 핵심 처리 흐름
DispatcherServlet의 핵심 로직은 doDispatch() 메서드에 있다. 이 메서드가 실제로 요청을 받아 핸들러를 찾고, 실행하고, 뷰를 렌더링하는 전체 흐름을 관장한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// DispatcherServlet의 doDispatch 메서드 흐름 (간략화)
protected void doDispatch(HttpServletRequest request,
HttpServletResponse response) throws Exception {
HandlerExecutionChain mappedHandler = null;
ModelAndView mv = null;
try {
// 1. 요청에 맞는 핸들러(컨트롤러) 조회
mappedHandler = getHandler(request);
if (mappedHandler == null) {
noHandlerFound(request, response);
return;
}
// 2. 핸들러를 실행할 수 있는 어댑터 조회
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
// 3. 인터셉터의 preHandle 호출
if (!mappedHandler.applyPreHandle(request, response)) {
return;
}
// 4. 핸들러(컨트롤러) 실행
mv = ha.handle(request, response, mappedHandler.getHandler());
// 5. 인터셉터의 postHandle 호출
mappedHandler.applyPostHandle(request, response, mv);
} catch (Exception ex) {
// 예외 처리
processHandlerException(request, response, mappedHandler, ex);
}
// 6. 뷰 렌더링
processDispatchResult(request, response, mappedHandler, mv, null);
}
이처럼 DispatcherServlet이 요청의 전체 생명주기를 관리하므로, 개발자는 비즈니스 로직에만 집중할 수 있다. 공통 관심사는 필터, 인터셉터, AOP 등을 통해 분리하면 된다.
4. 요청 처리 흐름 상세 분석
클라이언트의 HTTP 요청이 Spring MVC 애플리케이션에 도달했을 때 거치는 전체 흐름을 단계별로 상세히 살펴보자.
4.1 HandlerMapping
HandlerMapping은 HTTP 요청을 처리할 핸들러(컨트롤러 메서드)를 찾아주는 인터페이스이다. Spring MVC에는 여러 HandlerMapping 구현체가 존재하며, 가장 많이 사용되는 것은 RequestMappingHandlerMapping이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// RequestMappingHandlerMapping은 @RequestMapping 어노테이션을 기반으로 핸들러를 매핑한다.
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
@GetMapping
public List<ProductResponse> getProducts() {
// GET /api/v1/products 요청이 이 메서드에 매핑됨
return productService.findAll();
}
@GetMapping("/{id}")
public ProductResponse getProduct(@PathVariable Long id) {
// GET /api/v1/products/123 요청이 이 메서드에 매핑됨
return productService.findById(id);
}
}
HandlerMapping의 주요 구현체들은 아래와 같다.
- RequestMappingHandlerMapping:
@RequestMapping및 관련 어노테이션(@GetMapping,@PostMapping등)을 스캔하여 매핑 정보를 구성한다. 가장 우선순위가 높고 가장 널리 사용된다. - BeanNameUrlHandlerMapping: Bean 이름이 URL 패턴인 핸들러를 매핑한다. 레거시 방식이다.
- SimpleUrlHandlerMapping: URL 패턴과 핸들러를 명시적으로 설정할 수 있다.
핸들러를 찾을 때는 등록된 HandlerMapping 구현체들을 우선순위 순서대로 순회하면서, 해당 요청을 처리할 수 있는 핸들러를 가장 먼저 찾은 HandlerMapping의 결과를 사용한다.
4.2 HandlerAdapter
HandlerAdapter는 HandlerMapping이 찾아준 핸들러를 실제로 실행하는 역할을 한다. 어댑터 패턴을 적용하여 다양한 형태의 핸들러를 일관된 방식으로 호출할 수 있게 해준다.
1
2
3
4
5
6
7
8
// HandlerAdapter 인터페이스
public interface HandlerAdapter {
boolean supports(Object handler);
ModelAndView handle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception;
long getLastModified(HttpServletRequest request, Object handler);
}
가장 많이 사용되는 구현체는 RequestMappingHandlerAdapter로, @RequestMapping 어노테이션이 달린 컨트롤러 메서드를 실행한다. 이 어댑터는 내부적으로 ArgumentResolver를 이용하여 메서드 파라미터를 바인딩하고, ReturnValueHandler를 이용하여 반환값을 처리한다.
4.3 ViewResolver
ViewResolver는 컨트롤러가 반환한 논리적 뷰 이름(String)을 실제 View 객체로 변환하는 역할을 한다. REST API에서는 @RestController나 @ResponseBody를 사용하므로 ViewResolver를 거치지 않지만, 서버 사이드 렌더링 시에는 반드시 필요하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Thymeleaf ViewResolver 설정 예시 (application.yml)
// spring:
// thymeleaf:
// prefix: classpath:/templates/
// suffix: .html
@Controller
public class PageController {
@GetMapping("/home")
public String home(Model model) {
model.addAttribute("message", "환영합니다");
return "home"; // -> /templates/home.html 파일이 렌더링됨
}
}
주요 ViewResolver 구현체로는 ThymeleafViewResolver, InternalResourceViewResolver(JSP), ContentNegotiatingViewResolver 등이 있다.
4.4 HttpMessageConverter
HttpMessageConverter는 HTTP 요청 본문(body)을 Java 객체로 변환하거나, Java 객체를 HTTP 응답 본문으로 변환하는 역할을 한다. REST API 개발에서 핵심적인 컴포넌트이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("/api/members")
public class MemberApiController {
@PostMapping
public ResponseEntity<MemberResponse> create(
@RequestBody MemberCreateRequest request) {
// @RequestBody: HttpMessageConverter가 JSON -> MemberCreateRequest 변환
MemberResponse response = memberService.create(request);
// 반환값도 HttpMessageConverter가 MemberResponse -> JSON 변환
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
Spring Boot는 기본적으로 MappingJackson2HttpMessageConverter(JSON), StringHttpMessageConverter(문자열), ByteArrayHttpMessageConverter(바이트 배열) 등을 자동으로 등록한다. Content-Type 헤더와 Accept 헤더를 기반으로 적절한 컨버터가 선택된다.
커스텀 HttpMessageConverter를 등록하고 싶다면 WebMvcConfigurer를 구현하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
// 커스텀 컨버터 추가
converters.add(new MappingJackson2HttpMessageConverter(customObjectMapper()));
}
private ObjectMapper customObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
}
5. @Controller vs @RestController 차이
Spring MVC에서 컨트롤러를 정의할 때 사용하는 두 어노테이션의 차이를 정확히 이해하는 것이 중요하다.
@Controller
@Controller는 전통적인 Spring MVC 컨트롤러를 정의할 때 사용한다. 메서드의 반환값이 뷰 이름으로 해석되어 ViewResolver를 통해 실제 뷰(HTML, JSP 등)가 렌더링된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Controller
@RequestMapping("/members")
public class MemberViewController {
private final MemberService memberService;
public MemberViewController(MemberService memberService) {
this.memberService = memberService;
}
@GetMapping
public String list(Model model) {
List<MemberResponse> members = memberService.findAll();
model.addAttribute("members", members);
return "member/list"; // 뷰 이름 반환 -> ViewResolver가 처리
}
@GetMapping("/{id}")
public String detail(@PathVariable Long id, Model model) {
MemberResponse member = memberService.findById(id);
model.addAttribute("member", member);
return "member/detail";
}
// @Controller에서도 JSON 응답을 보내려면 @ResponseBody를 붙인다
@GetMapping("/api/check-email")
@ResponseBody
public Map<String, Boolean> checkEmail(@RequestParam String email) {
boolean exists = memberService.existsByEmail(email);
return Map.of("exists", exists);
}
}
@RestController
@RestController는 @Controller + @ResponseBody를 합친 어노테이션이다. 모든 메서드의 반환값이 HTTP 응답 본문으로 직접 변환된다. REST API를 개발할 때 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@RestController // @Controller + @ResponseBody
@RequestMapping("/api/v1/members")
public class MemberApiController {
private final MemberService memberService;
public MemberApiController(MemberService memberService) {
this.memberService = memberService;
}
@GetMapping
public List<MemberResponse> getMembers() {
// 반환값이 HttpMessageConverter에 의해 JSON으로 직접 변환됨
return memberService.findAll();
}
@GetMapping("/{id}")
public MemberResponse getMember(@PathVariable Long id) {
return memberService.findById(id);
}
@PostMapping
public ResponseEntity<MemberResponse> createMember(
@Valid @RequestBody MemberCreateRequest request) {
MemberResponse created = memberService.create(request);
URI location = URI.create("/api/v1/members/" + created.getId());
return ResponseEntity.created(location).body(created);
}
}
핵심 차이를 정리하면, @Controller는 뷰 이름을 반환하여 서버 사이드 렌더링에 적합하고, @RestController는 객체를 직접 반환하여 REST API에 적합하다. 현대 웹 개발에서는 프론트엔드와 백엔드를 분리하는 추세이므로, @RestController를 더 자주 사용하게 된다.
6. 매핑 어노테이션
Spring MVC는 HTTP 요청을 컨트롤러 메서드에 매핑하기 위한 다양한 어노테이션을 제공한다.
@RequestMapping
모든 매핑 어노테이션의 기반이 되는 어노테이션으로, HTTP 메서드, URL 패턴, 컨텐츠 타입 등 다양한 조건을 지정할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@RestController
@RequestMapping(value = "/api/v1/orders", produces = MediaType.APPLICATION_JSON_VALUE)
public class OrderController {
// 특정 HTTP 메서드를 지정
@RequestMapping(method = RequestMethod.GET)
public List<OrderResponse> getOrders() {
return orderService.findAll();
}
// 여러 URL 패턴을 동시에 매핑
@RequestMapping(value = {"/{id}", "/detail/{id}"}, method = RequestMethod.GET)
public OrderResponse getOrder(@PathVariable Long id) {
return orderService.findById(id);
}
// Content-Type 조건 추가
@RequestMapping(
value = "/upload",
method = RequestMethod.POST,
consumes = MediaType.MULTIPART_FORM_DATA_VALUE
)
public OrderResponse uploadOrder(@RequestParam MultipartFile file) {
return orderService.processFile(file);
}
}
축약형 매핑 어노테이션
Spring 4.3부터는 HTTP 메서드별 축약형 어노테이션을 제공한다. 실무에서는 이 축약형을 더 많이 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@RestController
@RequestMapping("/api/v1/articles")
@RequiredArgsConstructor
public class ArticleController {
private final ArticleService articleService;
@GetMapping
public PageResponse<ArticleResponse> getArticles(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return articleService.findAll(page, size);
}
@GetMapping("/{id}")
public ArticleResponse getArticle(@PathVariable Long id) {
return articleService.findById(id);
}
@PostMapping
public ResponseEntity<ArticleResponse> createArticle(
@Valid @RequestBody ArticleCreateRequest request) {
ArticleResponse created = articleService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PutMapping("/{id}")
public ArticleResponse updateArticle(
@PathVariable Long id,
@Valid @RequestBody ArticleUpdateRequest request) {
return articleService.update(id, request);
}
@PatchMapping("/{id}/status")
public ArticleResponse updateStatus(
@PathVariable Long id,
@RequestBody ArticleStatusRequest request) {
return articleService.updateStatus(id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteArticle(@PathVariable Long id) {
articleService.delete(id);
return ResponseEntity.noContent().build();
}
}
@PutMapping은 리소스의 전체 교체, @PatchMapping은 부분 수정에 사용하는 것이 RESTful 관례이다. @DeleteMapping은 삭제 후 보통 204 No Content를 반환한다.
7. 파라미터 바인딩
Spring MVC는 HTTP 요청의 다양한 부분을 컨트롤러 메서드의 파라미터에 자동으로 바인딩해주는 기능을 제공한다.
@RequestParam
쿼리 파라미터나 폼 데이터를 메서드 파라미터에 바인딩한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@GetMapping("/search")
public List<ProductResponse> search(
@RequestParam String keyword,
@RequestParam(required = false, defaultValue = "price") String sortBy,
@RequestParam(required = false) Integer minPrice,
@RequestParam(required = false) Integer maxPrice,
@RequestParam(defaultValue = "0") int page) {
// GET /search?keyword=노트북&sortBy=price&minPrice=500000&page=0
return productService.search(keyword, sortBy, minPrice, maxPrice, page);
}
// 같은 이름의 파라미터가 여러 개일 때 List로 받기
@GetMapping("/filter")
public List<ProductResponse> filter(
@RequestParam List<String> category) {
// GET /filter?category=전자기기&category=가전
return productService.filterByCategories(category);
}
@PathVariable
URL 경로의 변수 부분을 메서드 파라미터에 바인딩한다. RESTful API 설계에서 리소스 식별자를 전달할 때 주로 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@GetMapping("/users/{userId}/orders/{orderId}")
public OrderResponse getUserOrder(
@PathVariable Long userId,
@PathVariable Long orderId) {
// GET /users/42/orders/100
return orderService.findByUserAndOrder(userId, orderId);
}
// 파라미터 이름이 경로 변수 이름과 다를 때
@GetMapping("/categories/{category-name}/products")
public List<ProductResponse> getProducts(
@PathVariable("category-name") String categoryName) {
return productService.findByCategory(categoryName);
}
@RequestBody
HTTP 요청 본문(body)을 Java 객체로 역직렬화한다. 주로 JSON 데이터를 받을 때 사용하며, HttpMessageConverter가 변환을 담당한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// DTO 정의
@Getter
@NoArgsConstructor
public class MemberCreateRequest {
@NotBlank(message = "이름은 필수입니다")
private String name;
@Email(message = "올바른 이메일 형식이 아닙니다")
@NotBlank(message = "이메일은 필수입니다")
private String email;
@NotBlank(message = "비밀번호는 필수입니다")
@Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하입니다")
private String password;
}
// 컨트롤러에서 사용
@PostMapping("/members")
public ResponseEntity<MemberResponse> create(
@Valid @RequestBody MemberCreateRequest request) {
// JSON 본문이 MemberCreateRequest 객체로 자동 변환됨
MemberResponse response = memberService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@ModelAttribute
HTML 폼 데이터나 쿼리 파라미터를 객체에 바인딩할 때 사용한다. @RequestBody와 달리 폼 데이터(application/x-www-form-urlencoded)를 처리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Getter
@Setter
public class MemberSearchCondition {
private String name;
private String email;
private Integer minAge;
private Integer maxAge;
private String sortBy;
}
@GetMapping("/members/search")
public List<MemberResponse> search(@ModelAttribute MemberSearchCondition condition) {
// GET /members/search?name=홍길동&minAge=20&maxAge=30&sortBy=name
// 쿼리 파라미터가 MemberSearchCondition 객체의 필드에 자동 바인딩됨
return memberService.search(condition);
}
// 폼 전송 처리
@PostMapping("/members/register")
public String register(@ModelAttribute MemberRegisterForm form,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "member/register";
}
memberService.register(form);
return "redirect:/members";
}
@ModelAttribute는 생략 가능하며, 단순 타입이 아닌 객체 파라미터에는 자동으로 @ModelAttribute가 적용된다. 한편 @RequestBody는 JSON 요청 본문을 처리하고 @ModelAttribute는 폼/쿼리 파라미터를 처리한다는 점에서 용도가 명확히 구분된다.
8. Filter vs Interceptor vs AOP 비교
Spring 애플리케이션에서 공통 관심사(Cross-Cutting Concerns)를 처리하는 세 가지 메커니즘을 비교해보자.
Servlet Filter
Servlet Filter는 Java Servlet 스펙에 정의된 컴포넌트로, DispatcherServlet 앞단에서 요청/응답을 가로챈다. Spring의 영역 바깥, 즉 서블릿 컨테이너 레벨에서 동작한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
@Order(1)
public class RequestLoggingFilter implements Filter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
String method = httpRequest.getMethod();
long startTime = System.currentTimeMillis();
log.info("[REQUEST] {} {} 시작", method, requestURI);
try {
chain.doFilter(request, response); // 다음 필터 또는 서블릿으로 전달
} finally {
long duration = System.currentTimeMillis() - startTime;
HttpServletResponse httpResponse = (HttpServletResponse) response;
log.info("[RESPONSE] {} {} - {} ({}ms)",
method, requestURI, httpResponse.getStatus(), duration);
}
}
}
특정 URL 패턴에만 필터를 적용하고 싶다면 FilterRegistrationBean을 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<RequestLoggingFilter> loggingFilter() {
FilterRegistrationBean<RequestLoggingFilter> registration =
new FilterRegistrationBean<>();
registration.setFilter(new RequestLoggingFilter());
registration.addUrlPatterns("/api/*");
registration.setOrder(1);
return registration;
}
}
HandlerInterceptor
HandlerInterceptor는 Spring MVC에서 제공하는 컴포넌트로, DispatcherServlet과 Controller 사이에서 동작한다. Spring Bean에 접근할 수 있으며, 어떤 핸들러(컨트롤러)가 요청을 처리하는지 알 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@Component
@RequiredArgsConstructor
public class AuthenticationInterceptor implements HandlerInterceptor {
private final JwtTokenProvider tokenProvider;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 핸들러 메서드 정보에 접근 가능
if (handler instanceof HandlerMethod handlerMethod) {
// 커스텀 어노테이션 확인
LoginRequired loginRequired =
handlerMethod.getMethodAnnotation(LoginRequired.class);
if (loginRequired != null) {
String token = extractToken(request);
if (token == null || !tokenProvider.validate(token)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"error\":\"인증이 필요합니다\"}");
return false; // false를 반환하면 요청 처리를 중단
}
// 인증된 사용자 정보를 request에 저장
Long memberId = tokenProvider.getMemberId(token);
request.setAttribute("memberId", memberId);
}
}
return true; // true를 반환하면 다음 단계로 진행
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) throws Exception {
// 컨트롤러 실행 후, 뷰 렌더링 전에 호출
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
// 뷰 렌더링까지 완료된 후에 호출 (예외 발생 여부와 관계없이)
}
private String extractToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
return null;
}
}
인터셉터를 등록하려면 WebMvcConfigurer를 구현해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final AuthenticationInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**") // 적용할 URL 패턴
.excludePathPatterns("/api/auth/**") // 제외할 URL 패턴
.excludePathPatterns("/api/public/**")
.order(1);
}
}
AOP와의 차이점
Spring AOP는 메서드 실행 레벨에서 동작하며, 특정 패턴의 메서드 호출 전후에 로직을 삽입할 수 있다. Filter와 Interceptor가 HTTP 요청/응답 수준에서 동작하는 것과 달리, AOP는 서비스 계층 등 어떤 Spring Bean의 메서드에도 적용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Aspect
@Component
@Slf4j
public class ExecutionTimeAspect {
@Around("@annotation(com.example.annotation.MeasureExecutionTime)")
public Object measureTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
String methodName = joinPoint.getSignature().toShortString();
try {
Object result = joinPoint.proceed();
return result;
} finally {
long duration = System.currentTimeMillis() - start;
log.info("[성능 측정] {} - 실행 시간: {}ms", methodName, duration);
}
}
}
세 가지 메커니즘의 실행 순서와 적용 범위를 비교하면 다음과 같다.
| 구분 | Filter | Interceptor | AOP |
|---|---|---|---|
| 스펙 | Servlet 스펙 | Spring MVC 스펙 | Spring 스펙 |
| 동작 위치 | DispatcherServlet 외부 | DispatcherServlet 내부 | 메서드 레벨 |
| Spring Bean 접근 | 제한적 | 가능 | 가능 |
| 핸들러 정보 접근 | 불가 | 가능 | 가능 |
| 주요 용도 | 인코딩, CORS, 보안 | 인증/인가, 로깅 | 트랜잭션, 로깅, 캐싱 |
| 실행 순서 | 1번째 | 2번째 | 3번째 |
요청 흐름: Filter → DispatcherServlet → Interceptor(preHandle) → AOP → Controller → AOP → Interceptor(postHandle) → View 렌더링 → Interceptor(afterCompletion) → Filter
9. 예외 처리 전략
Spring MVC에서 예외를 체계적으로 처리하는 것은 안정적인 API 개발의 핵심이다.
@ExceptionHandler
특정 컨트롤러 내에서 발생하는 예외를 처리하는 메서드를 정의한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/api/members")
public class MemberController {
@GetMapping("/{id}")
public MemberResponse getMember(@PathVariable Long id) {
return memberService.findById(id);
// MemberNotFoundException이 발생할 수 있음
}
// 이 컨트롤러 내에서 발생하는 MemberNotFoundException만 처리
@ExceptionHandler(MemberNotFoundException.class)
public ResponseEntity<ErrorResponse> handleMemberNotFound(
MemberNotFoundException e) {
ErrorResponse error = new ErrorResponse("MEMBER_NOT_FOUND", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
}
@ControllerAdvice / @RestControllerAdvice
애플리케이션 전역에서 발생하는 예외를 한 곳에서 처리할 수 있다. @RestControllerAdvice는 @ControllerAdvice + @ResponseBody이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 커스텀 비즈니스 예외 처리
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(
BusinessException e) {
log.warn("비즈니스 예외 발생: {}", e.getMessage());
ErrorResponse error = new ErrorResponse(e.getErrorCode(), e.getMessage());
return ResponseEntity.status(e.getHttpStatus()).body(error);
}
// 엔티티를 찾을 수 없을 때
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleEntityNotFound(
EntityNotFoundException e) {
log.warn("엔티티 조회 실패: {}", e.getMessage());
ErrorResponse error = new ErrorResponse("ENTITY_NOT_FOUND", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
// Validation 실패 시
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException e) {
List<FieldErrorDetail> fieldErrors = e.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new FieldErrorDetail(
error.getField(),
error.getDefaultMessage(),
error.getRejectedValue()))
.toList();
ErrorResponse error = new ErrorResponse(
"VALIDATION_ERROR", "입력값이 올바르지 않습니다", fieldErrors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
// 잘못된 파라미터 타입
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleTypeMismatch(
MethodArgumentTypeMismatchException e) {
String message = String.format("파라미터 '%s'의 값 '%s'이(가) 올바르지 않습니다",
e.getName(), e.getValue());
ErrorResponse error = new ErrorResponse("TYPE_MISMATCH", message);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
// 그 외 모든 예외 (최후의 방어선)
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
log.error("예상치 못한 예외 발생", e);
ErrorResponse error = new ErrorResponse(
"INTERNAL_ERROR", "서버 내부 오류가 발생했습니다");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
에러 응답 DTO도 함께 정의한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Getter
@AllArgsConstructor
public class ErrorResponse {
private String code;
private String message;
private List<FieldErrorDetail> fieldErrors;
public ErrorResponse(String code, String message) {
this(code, message, List.of());
}
}
@Getter
@AllArgsConstructor
public class FieldErrorDetail {
private String field;
private String message;
private Object rejectedValue;
}
ResponseEntityExceptionHandler
Spring MVC가 기본적으로 발생시키는 예외들을 일괄적으로 처리하기 위한 베이스 클래스이다. 이 클래스를 상속받으면 NoHandlerFoundException, HttpRequestMethodNotSupportedException 등 프레임워크 수준의 예외를 커스터마이징할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
HttpRequestMethodNotSupportedException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
String message = String.format("지원하지 않는 HTTP 메서드입니다: %s", ex.getMethod());
ErrorResponse error = new ErrorResponse("METHOD_NOT_ALLOWED", message);
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(error);
}
@Override
protected ResponseEntity<Object> handleHttpMediaTypeNotSupported(
HttpMediaTypeNotSupportedException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
ErrorResponse error = new ErrorResponse(
"UNSUPPORTED_MEDIA_TYPE",
"지원하지 않는 Content-Type입니다: " + ex.getContentType());
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(error);
}
}
10. ResponseEntity와 응답 처리
ResponseEntity는 HTTP 응답의 상태 코드, 헤더, 본문을 모두 제어할 수 있게 해주는 클래스이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
// 상태 코드와 본문을 함께 반환
@PostMapping
public ResponseEntity<ProductResponse> create(
@Valid @RequestBody ProductCreateRequest request) {
ProductResponse product = productService.create(request);
URI location = URI.create("/api/v1/products/" + product.getId());
return ResponseEntity
.created(location) // 201 Created + Location 헤더
.body(product);
}
// 조건에 따라 다른 응답 반환
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> get(@PathVariable Long id) {
return productService.findOptionalById(id)
.map(ResponseEntity::ok) // 200 OK
.orElse(ResponseEntity.notFound().build()); // 404 Not Found
}
// 커스텀 헤더 추가
@GetMapping("/export")
public ResponseEntity<byte[]> export() {
byte[] data = productService.exportToCsv();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=products.csv")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(data.length)
.body(data);
}
// 삭제 후 No Content 반환
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
productService.delete(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
// 페이징 응답
@GetMapping
public ResponseEntity<PageResponse<ProductResponse>> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
PageResponse<ProductResponse> result = productService.findAll(page, size);
return ResponseEntity.ok(result);
}
}
표준화된 응답 포맷을 사용하는 것이 실무에서 권장된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Getter
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success;
private T data;
private String message;
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, data, null);
}
public static <T> ApiResponse<T> ok(T data, String message) {
return new ApiResponse<>(true, data, message);
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, null, message);
}
}
11. Validation (@Valid, @Validated, BindingResult)
입력값 검증은 애플리케이션의 안정성과 보안을 위해 필수적이다. Spring MVC는 Bean Validation(JSR-380)을 기반으로 강력한 검증 기능을 제공한다.
@Valid를 이용한 기본 검증
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Getter
@NoArgsConstructor
public class ProductCreateRequest {
@NotBlank(message = "상품명은 필수입니다")
@Size(max = 100, message = "상품명은 100자 이내로 입력해주세요")
private String name;
@NotNull(message = "가격은 필수입니다")
@Min(value = 0, message = "가격은 0 이상이어야 합니다")
@Max(value = 100_000_000, message = "가격은 1억 이하여야 합니다")
private Integer price;
@NotBlank(message = "카테고리는 필수입니다")
private String category;
@Size(max = 1000, message = "설명은 1000자 이내로 입력해주세요")
private String description;
@Pattern(regexp = "^[A-Z]{2}-\\d{4}$",
message = "상품코드는 'XX-0000' 형식이어야 합니다")
private String productCode;
}
@PostMapping("/products")
public ResponseEntity<ProductResponse> create(
@Valid @RequestBody ProductCreateRequest request) {
// @Valid에 의해 자동 검증, 실패 시 MethodArgumentNotValidException 발생
return ResponseEntity.status(HttpStatus.CREATED)
.body(productService.create(request));
}
@Validated와 그룹 검증
@Validated는 Spring에서 제공하는 어노테이션으로, 검증 그룹을 지정할 수 있다는 점에서 @Valid와 차이가 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 검증 그룹 인터페이스 정의
public interface OnCreate {}
public interface OnUpdate {}
@Getter
@NoArgsConstructor
public class MemberRequest {
@Null(groups = OnCreate.class, message = "생성 시 ID를 지정할 수 없습니다")
@NotNull(groups = OnUpdate.class, message = "수정 시 ID는 필수입니다")
private Long id;
@NotBlank(groups = {OnCreate.class, OnUpdate.class},
message = "이름은 필수입니다")
private String name;
@NotBlank(groups = OnCreate.class, message = "비밀번호는 필수입니다")
private String password;
}
@PostMapping("/members")
public ResponseEntity<MemberResponse> create(
@Validated(OnCreate.class) @RequestBody MemberRequest request) {
// OnCreate 그룹에 해당하는 검증만 수행
return ResponseEntity.status(HttpStatus.CREATED)
.body(memberService.create(request));
}
@PutMapping("/members/{id}")
public MemberResponse update(
@PathVariable Long id,
@Validated(OnUpdate.class) @RequestBody MemberRequest request) {
// OnUpdate 그룹에 해당하는 검증만 수행
return memberService.update(id, request);
}
BindingResult로 검증 결과 직접 처리
BindingResult를 파라미터에 추가하면 검증 실패 시 예외가 발생하지 않고, 에러 정보를 직접 처리할 수 있다. 주로 서버 사이드 렌더링(SSR) 방식에서 폼 데이터를 처리할 때 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@PostMapping("/register")
public String register(@Valid @ModelAttribute MemberRegisterForm form,
BindingResult bindingResult,
Model model) {
// 검증 실패 시 예외를 던지지 않고 BindingResult에 에러가 담김
if (bindingResult.hasErrors()) {
// 필드 에러 순회
bindingResult.getFieldErrors().forEach(error -> {
log.warn("필드 '{}' 검증 실패: {} (입력값: {})",
error.getField(),
error.getDefaultMessage(),
error.getRejectedValue());
});
return "member/register"; // 에러가 있으면 폼 페이지로 다시 돌아감
}
memberService.register(form);
return "redirect:/login";
}
커스텀 Validator
복합적인 검증 로직이 필요하면 커스텀 어노테이션과 Validator를 만들 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 커스텀 어노테이션 정의
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "이미 사용 중인 이메일입니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Validator 구현
@Component
@RequiredArgsConstructor
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final MemberRepository memberRepository;
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null || email.isBlank()) {
return true; // null 검증은 @NotBlank에 위임
}
return !memberRepository.existsByEmail(email);
}
}
// DTO에서 사용
public class SignUpRequest {
@NotBlank
@Email
@UniqueEmail
private String email;
}
12. Spring MVC에서의 비동기 처리
Spring MVC는 서블릿 3.0의 비동기 처리 기능을 활용하여 비동기 요청 처리를 지원한다. 이를 통해 서블릿 스레드를 장시간 점유하지 않고도 오래 걸리는 작업을 처리할 수 있다.
DeferredResult
DeferredResult는 별도의 스레드에서 결과를 설정할 수 있게 해주는 객체이다. 이벤트 기반 아키텍처나 외부 시스템의 콜백을 기다려야 할 때 유용하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
// 대기 중인 클라이언트를 저장하는 큐
private final Queue<DeferredResult<NotificationResponse>> waitingClients =
new ConcurrentLinkedQueue<>();
@GetMapping("/subscribe")
public DeferredResult<NotificationResponse> subscribe() {
// 타임아웃 30초, 타임아웃 시 기본 응답
DeferredResult<NotificationResponse> result =
new DeferredResult<>(30000L,
new NotificationResponse("timeout", "연결 시간 초과"));
result.onTimeout(() -> waitingClients.remove(result));
result.onCompletion(() -> waitingClients.remove(result));
waitingClients.add(result);
return result; // 서블릿 스레드는 즉시 반환됨
}
// 알림 발행 시 대기 중인 모든 클라이언트에게 전달
@PostMapping("/publish")
public ResponseEntity<Void> publish(@RequestBody NotificationRequest request) {
NotificationResponse notification =
new NotificationResponse("message", request.getContent());
for (DeferredResult<NotificationResponse> client : waitingClients) {
client.setResult(notification); // 대기 중인 클라이언트에게 응답 전송
}
return ResponseEntity.ok().build();
}
}
CompletableFuture (Callable)
CompletableFuture를 반환하면 Spring이 비동기적으로 처리하고, 결과가 준비되면 응답을 보낸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@RestController
@RequestMapping("/api/reports")
@RequiredArgsConstructor
public class ReportController {
private final ReportService reportService;
private final AsyncTaskExecutor taskExecutor;
// Callable을 사용한 비동기 처리
@GetMapping("/summary")
public Callable<ReportResponse> getSummary() {
// 서블릿 스레드에서 즉시 반환, 별도 스레드에서 처리
return () -> reportService.generateSummary();
}
// CompletableFuture를 사용한 비동기 처리
@GetMapping("/detail/{id}")
public CompletableFuture<ReportResponse> getDetail(@PathVariable Long id) {
return CompletableFuture.supplyAsync(
() -> reportService.generateDetail(id), taskExecutor);
}
// 여러 비동기 작업을 조합
@GetMapping("/combined/{id}")
public CompletableFuture<CombinedReportResponse> getCombined(
@PathVariable Long id) {
CompletableFuture<SalesData> salesFuture =
CompletableFuture.supplyAsync(
() -> reportService.getSalesData(id), taskExecutor);
CompletableFuture<UserData> userFuture =
CompletableFuture.supplyAsync(
() -> reportService.getUserData(id), taskExecutor);
// 두 비동기 작업이 모두 완료되면 결합
return salesFuture.thenCombine(userFuture,
(sales, users) -> new CombinedReportResponse(sales, users));
}
}
비동기 처리를 활성화하려면 설정이 필요하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
@Bean(name = "taskExecutor")
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
비동기 처리는 외부 API 호출, 파일 처리, 리포트 생성 등 I/O 바운드 작업에서 서블릿 스레드 풀의 고갈을 방지하는 데 매우 효과적이다. 다만 모든 요청을 비동기로 처리할 필요는 없으며, 빠르게 완료되는 단순 CRUD 작업은 동기 처리가 오히려 효율적이다.
13. 실무 Best Practices
계층별 책임 분리
컨트롤러에 비즈니스 로직을 작성하지 않고, 서비스 계층에 위임하는 것이 핵심이다. 컨트롤러는 요청 파라미터 바인딩과 응답 변환만 담당해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 좋은 예: 컨트롤러는 얇게 유지
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody OrderCreateRequest request,
@AuthenticationPrincipal LoginMember loginMember) {
OrderResponse response = orderService.create(loginMember.getId(), request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
// 나쁜 예: 컨트롤러에 비즈니스 로직이 섞여있음
@PostMapping
public ResponseEntity<OrderResponse> createOrder(...) {
// 이런 로직은 서비스 계층에 있어야 한다
Member member = memberRepository.findById(loginMember.getId()).orElseThrow();
Product product = productRepository.findById(request.getProductId()).orElseThrow();
if (product.getStock() < request.getQuantity()) {
throw new InsufficientStockException();
}
// ...
}
요청/응답 DTO 분리
엔티티를 API 응답으로 직접 노출하면 안 된다. 내부 구조 변경이 API 스펙에 영향을 미치고, 순환 참조나 불필요한 정보 노출 문제가 발생할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 요청 DTO
@Getter
@NoArgsConstructor
public class OrderCreateRequest {
@NotNull private Long productId;
@Positive private int quantity;
@NotBlank private String shippingAddress;
}
// 응답 DTO
@Getter
@Builder
public class OrderResponse {
private Long id;
private String productName;
private int quantity;
private int totalPrice;
private String status;
private LocalDateTime orderedAt;
public static OrderResponse from(Order order) {
return OrderResponse.builder()
.id(order.getId())
.productName(order.getProduct().getName())
.quantity(order.getQuantity())
.totalPrice(order.getTotalPrice())
.status(order.getStatus().name())
.orderedAt(order.getCreatedAt())
.build();
}
}
API 버전 관리
API가 변경될 때 기존 클라이언트에 영향을 주지 않기 위해 버전을 관리하는 것이 좋다.
1
2
3
4
5
6
7
8
// URL 경로 기반 버전 관리 (가장 일반적)
@RestController
@RequestMapping("/api/v1/members")
public class MemberV1Controller { }
@RestController
@RequestMapping("/api/v2/members")
public class MemberV2Controller { }
일관된 응답 형식 유지
성공과 실패 모두 동일한 구조의 응답을 반환하면 클라이언트 측에서 처리하기가 수월하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Getter
public class ApiResult<T> {
private final boolean success;
private final T data;
private final ErrorInfo error;
private ApiResult(boolean success, T data, ErrorInfo error) {
this.success = success;
this.data = data;
this.error = error;
}
public static <T> ApiResult<T> success(T data) {
return new ApiResult<>(true, data, null);
}
public static ApiResult<?> error(String code, String message) {
return new ApiResult<>(false, null, new ErrorInfo(code, message));
}
@Getter
@AllArgsConstructor
static class ErrorInfo {
private String code;
private String message;
}
}
로깅 전략
요청과 응답을 적절히 로깅하되, 민감 정보(비밀번호, 카드 번호 등)는 마스킹 처리해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Component
public class ApiLoggingInterceptor implements HandlerInterceptor {
private static final Logger log =
LoggerFactory.getLogger(ApiLoggingInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String requestId = UUID.randomUUID().toString().substring(0, 8);
request.setAttribute("requestId", requestId);
request.setAttribute("startTime", System.currentTimeMillis());
log.info("[{}] {} {} - Client: {}",
requestId,
request.getMethod(),
request.getRequestURI(),
request.getRemoteAddr());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
String requestId = (String) request.getAttribute("requestId");
long startTime = (long) request.getAttribute("startTime");
long duration = System.currentTimeMillis() - startTime;
log.info("[{}] {} {} - Status: {} ({}ms)",
requestId,
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
duration);
}
}
정리
Spring MVC 아키텍처를 깊이 이해하면 단순히 동작하는 코드를 넘어서, 유지보수하기 쉽고 확장 가능한 애플리케이션을 설계할 수 있다. DispatcherServlet을 중심으로 한 요청 처리 흐름, Filter와 Interceptor의 적절한 활용, 체계적인 예외 처리, 그리고 깔끔한 계층 분리는 실무에서 안정적인 백엔드 시스템을 구축하기 위한 기본기이다. 특히 요청/응답 DTO를 분리하고, 일관된 에러 응답 형식을 갖추며, 적절한 검증 로직을 배치하는 것이 품질 높은 API 개발의 핵심이라 할 수 있다.