Spring · 웹과 REST 컨트롤러
프론트엔드가 보내는 HTTP 요청을 받아서, 알맞은 메서드로 연결하고, 그 결과를 JSON으로 돌려주는 일을 맡는 곳이 REST 컨트롤러? 입니다. 이 장에서는 요청을 받는 입구부터 응답을 내보내고 오류를 다루는 출구까지, 컨트롤러가 쓰는 애너테이션을 빠짐없이 봅니다.
- HTTP 입구 — 들어온 요청을 주소·메서드에 맞는 메서드로 연결한다
- 데이터 변환 — 주소·쿼리·본문(JSON)을 자바 객체(DTO)로 자동 변환받는다
- JSON 응답 — 반환한 객체를 Jackson이 자동으로 JSON으로 바꿔 내보낸다
- 일관된 오류 — 예외가 나도 정해진 형식으로 상태코드와 메시지를 돌려준다
이 문서는 Spring Boot 3.3.1 / Spring Framework 6 / Java 17 / Jakarta EE 10 기준입니다.
그래서 import는 모두 jakarta.* 를 씁니다(javax.* 아님). JSON 직렬화는 Jackson이 담당합니다.
실제 백엔드(study-backend)는 web/endpoint 패키지에서 컨트롤러를 두고, 포트 8084 로 동작합니다.
1. @Controller 와 @RestController
@Controller 는 "이 클래스는 웹 요청을 처리한다"는 표시입니다. 다만 @Controller 만 쓰면 반환값을
화면 이름(뷰)으로 해석합니다. JSON을 돌려주려면 메서드(또는 반환값)에 @ResponseBody 를 붙여
"반환한 값을 그대로 응답 본문으로 써라"라고 알려줘야 합니다.
@RestController? 는
@Controller 와 @ResponseBody 를 합쳐 놓은 애너테이션입니다. 그래서 REST API에서는
@RestController 하나만 붙이면 모든 메서드의 반환값이 자동으로 JSON 본문이 됩니다.
@Controller 가 "보여줄 페이지 번호"를 알려주는 안내원이라면, @RestController 는 "데이터 자체"를 봉투에 담아 그대로 건네는 직원입니다.| 구분 | @Controller | @RestController |
|---|---|---|
| 반환값 해석 | 기본은 뷰 이름 | 항상 응답 본문(JSON) |
| @ResponseBody | JSON 줄 때 직접 붙여야 함 | 이미 포함됨(생략 가능) |
| 주 용도 | 서버가 HTML 화면을 그릴 때 | JSON API 서버 |
@RequestMapping — 클래스 공통 경로
클래스 위에 @RequestMapping("/api/users") 를 붙이면 그 안의 모든 메서드 주소 앞에 이 공통 경로가 붙습니다.
메서드의 @GetMapping("/{id}") 는 실제로 /api/users/{id} 가 되는 식이죠. 공통 접두어를 한 곳에 모아두는 셈입니다.
2. HTTP 메서드 매핑
같은 주소라도 무엇을 하려는지는 HTTP 메서드로 구분합니다. 조회는 GET, 생성은 POST, 전체 수정은 PUT, 일부 수정은 PATCH, 삭제는 DELETE. 스프링은 이 각각에 맞는 짧은 애너테이션을 제공합니다.
@GetMapping·@PostMapping 같은 것들은 모두 @RequestMapping 에
메서드 종류를 미리 정해 놓은 축약형일 뿐입니다. 그래서 @RequestMapping(method = RequestMethod.GET) 라고 풀어 써도 똑같이 동작합니다.
| 축약 애너테이션 | HTTP 메서드 | 풀어 쓰면 | 흔한 용도 |
|---|---|---|---|
@GetMapping | GET | @RequestMapping(method = RequestMethod.GET) | 조회 |
@PostMapping | POST | method = RequestMethod.POST | 생성 |
@PutMapping | PUT | method = RequestMethod.PUT | 전체 수정/교체 |
@PatchMapping | PATCH | method = RequestMethod.PATCH | 일부 수정 |
@DeleteMapping | DELETE | method = RequestMethod.DELETE | 삭제 |
매핑 좁히기 속성
매핑 애너테이션에는 주소(path) 외에도, 어떤 요청만 받을지 더 좁히는 속성들이 있습니다.
| 속성 | 의미 | 예 |
|---|---|---|
path (또는 value) | 주소 패턴 | @GetMapping("/{id}") |
params | 특정 쿼리 파라미터가 있을 때만 매핑 | params = "type=admin" |
headers | 특정 헤더가 있을 때만 매핑 | headers = "X-API=v2" |
consumes | 받아들이는 요청 본문 타입(Content-Type) | consumes = "application/json" |
produces | 내보내는 응답 타입(Accept 협상) | produces = "application/json" |
3. 요청 데이터 바인딩
요청에 담긴 값(주소 일부·쿼리·본문·헤더·쿠키·파일)을 메서드 매개변수로 자동으로 꽂아 주는 것을 바인딩이라고 합니다. 어디에서 값을 가져올지를 애너테이션으로 지정합니다.
| 애너테이션 / 타입 | 어디서 가져오나 | 예 |
|---|---|---|
@PathVariable | 주소 경로의 변수 부분 {id} | /users/7 → id=7 |
@RequestParam | 쿼리스트링/폼 파라미터 ?page=2 | required, defaultValue 지정 가능 |
@RequestBody | 요청 본문(JSON)을 객체로 변환 | JSON → UserDTO (Jackson) |
@RequestHeader | 요청 헤더 값 | @RequestHeader("Authorization") |
@CookieValue | 쿠키 값 | @CookieValue("session") |
@ModelAttribute | 여러 쿼리/폼 값을 객체에 묶어 바인딩 | 검색 필터 객체로 묶기 |
@RequestPart / MultipartFile | 멀티파트 폼의 파일/파트 | 파일 업로드 |
| Pageable (스프링 데이터) | page·size·sort 쿼리를 페이징 객체로 | ?page=0&size=20&sort=name |
@RequestParam 은 기본적으로 필수(required=true) 입니다. 값이 없으면 400 오류가 나죠.
선택값으로 만들려면 required = false, 또는 빠졌을 때 기본값을 주려면
defaultValue = "1" 처럼 지정합니다(이 경우 자동으로 선택값이 됩니다).
@RequestParam 은 값 하나를 꺼낼 때, @ModelAttribute 는 여러 값을 객체 하나로 묶을 때 씁니다.
단순 타입은 생략해도 @RequestParam 으로, 복잡한 객체는 생략해도 @ModelAttribute 로 취급됩니다.
4. 응답 만들기
@RestController? 에서는 메서드가 객체를 반환하기만 하면 Jackson이 그 객체를 JSON 문자열로 직렬화해서 응답 본문에 담습니다. 별도 변환 코드가 필요 없어요.
상태코드·헤더까지 직접 다루고 싶으면 ResponseEntity 를 반환합니다. 본문·상태코드·헤더를 한꺼번에 조립할 수 있죠. 예를 들어 생성 성공이면 201, 삭제 후 본문 없음이면 204를 줄 수 있습니다.
// 1) 객체만 반환 → 200 OK + JSON 본문 (Jackson 직렬화)
@GetMapping("/{id}")
public UserDTO get(@PathVariable Long id) { ... }
// 2) ResponseEntity 로 상태코드/헤더/본문 직접 제어
@PostMapping("")
public ResponseEntity<UserDTO> create(@RequestBody UserDTO req) {
UserDTO saved = ...;
return ResponseEntity.status(HttpStatus.CREATED).body(saved); // 201
}
반대로 고정된 상태코드만 지정하면 충분할 때는 메서드(또는 예외 클래스)에 @ResponseStatus 를 붙이는 간단한 방법도 있습니다.
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 본문 없이 204
public void delete(@PathVariable Long id) { ... }자주 쓰는 HttpStatus 코드
| 코드 | 이름 | 의미 |
|---|---|---|
| 200 | OK | 정상 처리됨 |
| 201 | Created | 새 자원이 생성됨(생성 API 성공) |
| 204 | No Content | 성공했지만 돌려줄 본문 없음(삭제 등) |
| 400 | Bad Request | 요청이 잘못됨(검증 실패 등) |
| 401 | Unauthorized | 인증 안 됨(로그인 필요) |
| 403 | Forbidden | 인증은 됐지만 권한 없음 |
| 404 | Not Found | 자원을 찾을 수 없음 |
| 409 | Conflict | 충돌(중복 등록 등) |
| 500 | Internal Server Error | 서버 내부 오류 |
@RequestBody 는 들어오는 JSON을 자바 객체로(입구), @ResponseBody 는 자바 객체를 나가는 JSON으로(출구) 바꾸라는 뜻입니다.
@RestController 에는 @ResponseBody 가 이미 포함돼 있어 출구 쪽은 따로 붙이지 않아도 됩니다.
5. 예외 처리
컨트롤러마다 try-catch를 흩뿌리는 대신, 예외를 한 곳에서 모아 처리하는 것이 깔끔합니다. 이때 쓰는 것이
@RestControllerAdvice 입니다. 이것은 @ControllerAdvice 와 @ResponseBody 를 합친 것으로,
여러 컨트롤러에서 발생한 예외를 가로채 JSON 오류 응답으로 바꿔 줍니다.
그 안에서 @ExceptionHandler 로 "이 예외 종류는 이렇게 응답하라"를 정의합니다.
이것이 전역 예외 처리 패턴입니다.
@RestControllerAdvice // = @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorDTO> handleNotFound(NotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorDTO(e.getMessage())); // 404 + JSON
}
}스프링 6 / 부트 3부터는 오류 응답을 표준 형식으로 통일하는 ProblemDetail 이 들어왔습니다.
RFC 7807("Problem Details for HTTP APIs") 규격을 따라 type · title · status · detail · instance 필드를 가진
일관된 JSON을 만듭니다. @ExceptionHandler 에서 ProblemDetail 을 만들어 그대로 반환하면 됩니다.
@ExceptionHandler(NotFoundException.class)
public ProblemDetail handle(NotFoundException e) {
ProblemDetail pd = ProblemDetail
.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
pd.setTitle("자원을 찾을 수 없음");
return pd; // RFC 7807 형식 JSON 으로 응답
}6. 그 밖의 도구
| 주제 | 설명 |
|---|---|
| @CrossOrigin (CORS) | 다른 출처(도메인·포트)의 브라우저 요청을 허용. 컨트롤러/메서드에 붙여 어떤 출처를 받을지 지정한다. 프론트(5173)와 백엔드(8084)가 출처가 다를 때 관련된다. |
| 컨텐츠 협상 | consumes 로 받는 본문 타입을, produces 로 내보낼 타입을 정해 클라이언트의 Accept 헤더와 맞춘다. |
| @Valid 연계 | @RequestBody 앞에 @Valid 를 붙이면 들어온 객체를 검증한다. 검증 애너테이션은 spring-validation.html 에서 자세히 다룬다. |
7. 전체 예제 — CRUD 컨트롤러
목록·단건·생성·수정·삭제를 한 컨트롤러에 담고, 생성자 주입(@RequiredArgsConstructor)으로 의존성을 받고,
ResponseEntity 로 상태코드를 제어하며, 전역 예외 처리까지 묶은 모습입니다.
import jakarta.validation.Valid; // Jakarta EE 10 (javax 아님)
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users") // 클래스 공통 경로
@RequiredArgsConstructor // final 필드 생성자 주입
public class UserEndpoint {
private final UserService userService;
// 목록: GET /api/users?page=0&size=20
@GetMapping("")
public Page<UserDTO> list(@RequestParam(required = false) String keyword,
Pageable pageable) {
return userService.search(keyword, pageable); // 객체 반환 → JSON
}
// 단건: GET /api/users/7
@GetMapping("/{id}")
public UserDTO get(@PathVariable Long id) {
return userService.get(id);
}
// 생성: POST /api/users (본문 JSON → DTO, 검증)
@PostMapping("")
public ResponseEntity<UserDTO> create(@Valid @RequestBody UserDTO req) {
UserDTO saved = userService.create(req);
return ResponseEntity.status(HttpStatus.CREATED).body(saved); // 201
}
// 수정: PUT /api/users/7
@PutMapping("/{id}")
public UserDTO update(@PathVariable Long id,
@Valid @RequestBody UserDTO req) {
return userService.update(id, req);
}
// 삭제: DELETE /api/users/7 (본문 없이 204)
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
userService.delete(id);
}
}
// 전역 예외 처리 — 모든 컨트롤러의 예외를 한 곳에서
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ProblemDetail> handleNotFound(NotFoundException e) {
ProblemDetail pd = ProblemDetail
.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd); // 404
}
}한눈에 — 애너테이션 카탈로그
| 애너테이션 | 역할 | 붙이는 위치 |
|---|---|---|
@Controller | 웹 요청 처리 클래스(반환값 기본은 뷰) | 클래스 |
@RestController | @Controller + @ResponseBody (JSON API) | 클래스 |
@ResponseBody | 반환값을 응답 본문(JSON)으로 | 클래스/메서드 |
@RequestMapping | 주소·메서드 매핑(공통 경로/축약형의 원형) | 클래스/메서드 |
@GetMapping | GET 매핑(조회) | 메서드 |
@PostMapping | POST 매핑(생성) | 메서드 |
@PutMapping | PUT 매핑(전체 수정) | 메서드 |
@PatchMapping | PATCH 매핑(일부 수정) | 메서드 |
@DeleteMapping | DELETE 매핑(삭제) | 메서드 |
@PathVariable | 주소 경로 변수 바인딩 | 매개변수 |
@RequestParam | 쿼리/폼 파라미터(required·defaultValue) | 매개변수 |
@RequestBody | 요청 본문 JSON → 객체 | 매개변수 |
@RequestHeader | 요청 헤더 값 바인딩 | 매개변수 |
@CookieValue | 쿠키 값 바인딩 | 매개변수 |
@ModelAttribute | 여러 값을 객체로 묶어 바인딩 | 매개변수 |
@RequestPart / MultipartFile | 멀티파트 파일/파트 바인딩 | 매개변수 |
@ResponseStatus | 응답 상태코드 고정 지정 | 메서드/예외 클래스 |
@RestControllerAdvice | @ControllerAdvice + @ResponseBody (전역 예외) | 클래스 |
@ExceptionHandler | 특정 예외 종류 처리 메서드 지정 | 메서드 |
@CrossOrigin | CORS(다른 출처 요청) 허용 | 클래스/메서드 |
@Valid | 요청 객체 검증 트리거 | 매개변수 |
이 프로젝트 백엔드의 모든 HTTP 입구는 web/endpoint 패키지의 @RestController 들입니다.
DTO로 요청·응답을 주고받고, 포트 8084 에서 동작하죠.
실제 코드로 컨트롤러를 읽어 보려면 be-02 컨트롤러 로 이어집니다.
다음 단계
- 컨트롤러 동작을 떠받치는 설정·빈 → spring-config.html
- 들어온 요청 데이터 검증(@Valid) → spring-validation.html