프레임워크 · Spring · 웹 · REST 컨트롤러

Spring · 웹과 REST 컨트롤러

프론트엔드가 보내는 HTTP 요청을 받아서, 알맞은 메서드로 연결하고, 그 결과를 JSON으로 돌려주는 일을 맡는 곳이 REST 컨트롤러? 입니다. 이 장에서는 요청을 받는 입구부터 응답을 내보내고 오류를 다루는 출구까지, 컨트롤러가 쓰는 애너테이션을 빠짐없이 봅니다.

컨트롤러는 호텔 프런트 데스크와 같아요. 손님(요청)이 오면 용건을 듣고(주소·메서드로 분류), 필요한 서류를 받아(요청 데이터 바인딩), 담당 부서(서비스)에 넘기고, 처리 결과를 정리해 손님에게 건넵니다(JSON 응답). 문제가 생기면 정해진 양식으로 안내장(오류 응답)을 줍니다.
왜 / 어디에 쓰나
  • 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)
@ResponseBodyJSON 줄 때 직접 붙여야 함이미 포함됨(생략 가능)
주 용도서버가 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 메서드풀어 쓰면흔한 용도
@GetMappingGET@RequestMapping(method = RequestMethod.GET)조회
@PostMappingPOSTmethod = RequestMethod.POST생성
@PutMappingPUTmethod = RequestMethod.PUT전체 수정/교체
@PatchMappingPATCHmethod = RequestMethod.PATCH일부 수정
@DeleteMappingDELETEmethod = 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=2required, 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 와 defaultValue

@RequestParam 은 기본적으로 필수(required=true) 입니다. 값이 없으면 400 오류가 나죠. 선택값으로 만들려면 required = false, 또는 빠졌을 때 기본값을 주려면 defaultValue = "1" 처럼 지정합니다(이 경우 자동으로 선택값이 됩니다).

@RequestParam vs @ModelAttribute

@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 코드

코드이름의미
200OK정상 처리됨
201Created새 자원이 생성됨(생성 API 성공)
204No Content성공했지만 돌려줄 본문 없음(삭제 등)
400Bad Request요청이 잘못됨(검증 실패 등)
401Unauthorized인증 안 됨(로그인 필요)
403Forbidden인증은 됐지만 권한 없음
404Not Found자원을 찾을 수 없음
409Conflict충돌(중복 등록 등)
500Internal Server Error서버 내부 오류
@ResponseBody / @RequestBody 정리

@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의 ProblemDetail (RFC 7807)

스프링 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주소·메서드 매핑(공통 경로/축약형의 원형)클래스/메서드
@GetMappingGET 매핑(조회)메서드
@PostMappingPOST 매핑(생성)메서드
@PutMappingPUT 매핑(전체 수정)메서드
@PatchMappingPATCH 매핑(일부 수정)메서드
@DeleteMappingDELETE 매핑(삭제)메서드
@PathVariable주소 경로 변수 바인딩매개변수
@RequestParam쿼리/폼 파라미터(required·defaultValue)매개변수
@RequestBody요청 본문 JSON → 객체매개변수
@RequestHeader요청 헤더 값 바인딩매개변수
@CookieValue쿠키 값 바인딩매개변수
@ModelAttribute여러 값을 객체로 묶어 바인딩매개변수
@RequestPart / MultipartFile멀티파트 파일/파트 바인딩매개변수
@ResponseStatus응답 상태코드 고정 지정메서드/예외 클래스
@RestControllerAdvice@ControllerAdvice + @ResponseBody (전역 예외)클래스
@ExceptionHandler특정 예외 종류 처리 메서드 지정메서드
@CrossOriginCORS(다른 출처 요청) 허용클래스/메서드
@Valid요청 객체 검증 트리거매개변수
이 프로젝트와의 관계

이 프로젝트 백엔드의 모든 HTTP 입구는 web/endpoint 패키지의 @RestController 들입니다. DTO로 요청·응답을 주고받고, 포트 8084 에서 동작하죠. 실제 코드로 컨트롤러를 읽어 보려면 be-02 컨트롤러 로 이어집니다.

다음 단계