@Controller와 @RequestMapping 완전 정복: URL 매핑 전략 총정리
@Controller, @RestController, @RequestMapping의 모든 속성과 매핑 전략을 예제 코드와 함께 정리하고, 클래스·메서드 레벨 매핑 조합, 파라미터 바인딩, 반환값 처리까지 다룹니다.
지난 글에서 HandlerMapping이 URL을 탐색하고 HandlerAdapter가 실행을 위임하는 내부 구조를 살펴봤습니다. 이번 글에서는 개발자가 직접 작성하는 @Controller와 @RequestMapping의 모든 기능을 체계적으로 정리합니다. 매핑 조건부터 파라미터 바인딩, 반환값 처리까지 한 번에 짚겠습니다.
@Controller vs @RestController
@Controller는 @Component를 포함하므로 Spring 컴포넌트 스캔 대상이 됩니다. 기본적으로 메서드 반환값을 뷰 이름으로 해석합니다.
@Controller
public class PageController {
@GetMapping("/home")
public String home(Model model) {
model.addAttribute("message", "안녕하세요!");
return "home"; // → templates/home.html (Thymeleaf)
}
}
@RestController는 @Controller와 @ResponseBody를 합친 합성 어노테이션입니다. 모든 메서드의 반환값이 HttpMessageConverter를 거쳐 응답 바디에 직접 쓰입니다.
// @RestController = @Controller + @ResponseBody
@RestController
public class ApiController {
@GetMapping("/api/status")
public Map<String, String> status() {
return Map.of("status", "ok"); // → JSON 자동 직렬화
}
}
같은 컨트롤러에서 뷰와 JSON 응답을 혼용해야 하면 @Controller를 쓰되 특정 메서드에 @ResponseBody를 붙입니다.
@RequestMapping 속성 완전 정리
path / value: URL 패턴
@RequestMapping(path = "/api/users") // 단일 경로
@RequestMapping(value = {"/api/users", "/api/members"}) // 복수 경로
// 경로 변수
@GetMapping("/orders/{orderId}/items/{itemId}")
public OrderItem getItem(@PathVariable Long orderId,
@PathVariable Long itemId) { ... }
// 와일드카드 (spring-web 6.0+: PathPatternParser 사용)
@GetMapping("/files/**") // 0개 이상의 경로 세그먼트
@GetMapping("/doc/*.html") // 단일 세그먼트 와일드카드
클래스 레벨 @RequestMapping과 메서드 레벨이 합쳐집니다.
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
@GetMapping("/{id}") // → /api/v1/users/{id}
@PostMapping // → /api/v1/users
@DeleteMapping("/{id}") // → /api/v1/users/{id}
}
method: HTTP 메서드
// 전통적인 방식
@RequestMapping(path = "/users", method = RequestMethod.GET)
// 단축 어노테이션 (실무 권장)
@GetMapping("/users")
@PostMapping("/users")
@PutMapping("/users/{id}")
@PatchMapping("/users/{id}")
@DeleteMapping("/users/{id}")
params: 쿼리 파라미터 조건
특정 파라미터가 있거나 없을 때만 매핑합니다. API 버전 관리나 피처 플래그에 활용합니다.
// type 파라미터가 premium인 요청만 처리
@GetMapping(value = "/content", params = "type=premium")
public Content premiumContent() { ... }
// debug 파라미터가 없는 요청만 처리
@GetMapping(value = "/content", params = "!debug")
public Content normalContent() { ... }
headers: 요청 헤더 조건
@GetMapping(value = "/api/users",
headers = "X-API-Version=2")
public Page<UserDtoV2> listV2(Pageable pageable) { ... }
consumes: 요청 Content-Type
@PostMapping(value = "/upload",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public String handleUpload(@RequestParam MultipartFile file) { ... }
@PostMapping(value = "/api/users",
consumes = MediaType.APPLICATION_JSON_VALUE)
public UserDto createFromJson(@RequestBody CreateUserRequest req) { ... }
produces: 응답 Accept 헤더 조건
@GetMapping(value = "/report",
produces = MediaType.APPLICATION_PDF_VALUE)
public byte[] generatePdf() { ... }
@GetMapping(value = "/report",
produces = MediaType.APPLICATION_JSON_VALUE)
public ReportDto getReportJson() { ... }
같은 URL에 produces만 다른 두 메서드를 등록하면 클라이언트의 Accept 헤더에 따라 다른 메서드가 호출됩니다. 컨텐츠 협상(Content Negotiation)이라고 합니다.
파라미터 바인딩
@PathVariable: 경로 변수
@GetMapping("/users/{id}")
public UserDto getUser(@PathVariable Long id) { ... }
// 경로 변수 이름과 파라미터 이름이 다를 때
@GetMapping("/users/{userId}")
public UserDto getUser(@PathVariable("userId") Long id) { ... }
// 선택적 경로 변수 (Spring 6.1+)
@GetMapping({"/users", "/users/{id}"})
public UserDto getUser(@PathVariable(required = false) Long id) { ... }
@RequestParam: 쿼리 파라미터
// GET /search?keyword=spring&page=1&size=20
@GetMapping("/search")
public Page<Post> search(
@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) { ... }
// 여러 값: /filter?tag=java&tag=spring
@GetMapping("/filter")
public List<Post> filter(
@RequestParam List<String> tag) { ... }
@RequestBody: 요청 바디
@PostMapping("/users")
public ResponseEntity<UserDto> create(
@RequestBody @Valid CreateUserRequest request) {
UserDto created = userService.create(request);
URI location = URI.create("/api/v1/users/" + created.getId());
return ResponseEntity.created(location).body(created);
}
@Valid를 함께 쓰면 CreateUserRequest의 Bean Validation 어노테이션(@NotNull, @Size 등)이 자동으로 검증됩니다.
@RequestHeader: 헤더 값
@GetMapping("/secured")
public String secured(
@RequestHeader("Authorization") String authHeader,
@RequestHeader(value = "X-Client-Version",
required = false) String clientVersion) {
...
}
@ModelAttribute: 폼 데이터
@PostMapping("/login")
public String login(@ModelAttribute LoginForm form,
BindingResult result,
HttpSession session) {
if (result.hasErrors()) return "login";
session.setAttribute("user", authenticate(form));
return "redirect:/home";
}
반환값 처리
ResponseEntity: 상태 코드 + 헤더 + 바디
@GetMapping("/users/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/users")
public ResponseEntity<UserDto> create(@RequestBody @Valid CreateUserRequest req) {
UserDto saved = userService.create(req);
return ResponseEntity
.created(URI.create("/api/v1/users/" + saved.getId()))
.body(saved);
}
@DeleteMapping("/users/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
userService.delete(id);
}
@ResponseStatus: 고정 상태 코드
반환값이 없거나 항상 같은 상태 코드를 반환할 때 사용합니다. ResponseEntity보다 간결합니다.
@PostMapping("/notify")
@ResponseStatus(HttpStatus.ACCEPTED)
public void notify(@RequestBody NotificationRequest req) {
notificationService.sendAsync(req);
}
URL 패턴 매칭 우선순위
Spring MVC는 복수의 패턴이 매칭될 때 다음 순서로 우선순위를 결정합니다.
1. 정확한 경로: /users/profile
2. 접두사 와일드카드: /users/{id}
3. 더블 와일드카드: /users/**
4. 기본 패턴: /**
패턴이 같은 수준이면 더 구체적인(변수가 적고 리터럴이 많은) 패턴이 우선합니다. @AntPathMatcher 대신 Spring 5.3+의 PathPatternParser를 사용하면 성능이 향상됩니다.
# Spring Boot 기본값: PathPatternParser 사용
spring:
mvc:
pathmatch:
matching-strategy: path-pattern-parser
실전 팁: 컨트롤러 설계 원칙
// ❌ 컨트롤러에 비즈니스 로직이 있는 경우
@GetMapping("/orders/{id}/total")
public BigDecimal getTotal(@PathVariable Long id) {
Order order = orderRepository.findById(id).orElseThrow();
return order.getItems().stream()
.map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQty())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
// ✅ 컨트롤러는 요청 파싱과 응답 조립만
@GetMapping("/orders/{id}/total")
public ResponseEntity<OrderTotalResponse> getTotal(
@PathVariable Long id) {
BigDecimal total = orderService.calculateTotal(id);
return ResponseEntity.ok(new OrderTotalResponse(id, total));
}
컨트롤러의 책임은 HTTP 요청을 파싱하고, 서비스를 호출하고, 응답을 조립하는 것에 한정합니다. 비즈니스 로직은 서비스 계층으로 위임합니다.
핵심 정리
@RestController는@Controller + @ResponseBody. 반환값이 자동으로 JSON 직렬화됩니다.@RequestMapping의path,method,params,headers,consumes,produces속성을 조합해 매핑 조건을 세밀하게 제어합니다.- 실무에서는
@GetMapping,@PostMapping등 단축 어노테이션을 사용합니다. - 파라미터 바인딩:
@PathVariable(경로),@RequestParam(쿼리),@RequestBody(바디),@RequestHeader(헤더). - 상태 코드 제어:
ResponseEntity(동적),@ResponseStatus(고정). - 컨트롤러는 HTTP 파싱·조립 전담. 비즈니스 로직은 서비스 계층에.
지난 글: HandlerMapping과 HandlerAdapter 심화: 요청이 컨트롤러를 찾는 방법
읽어주셔서 감사합니다. 😊