Dev Study/Backend

[Java & SpringBoot] Spring REST API설계 방법 - ResponseEntity build하기

parkhh98 2026. 5. 31. 01:49

 

Spring MVC에서 REST API로 전환할 때 가장 먼저 마주치는 질문은 단순하다. "뷰 이름 대신 뭘 반환하지?" 이 글은 ResponseEntityREST 설계 원칙을 중심으로, 컨트롤러 코드가 어떻게 달라지는지 직접 확인한다.

 


REST가 아닌 기존 MVC 컨트롤러

전통적인 Spring MVC 컨트롤러는 서버가 화면까지 만들어서 보내주는 방식이다.

// 기존 MVC 방식
@Controller
public class ArticleController {

    @GetMapping("/articles")
    public String list(Model model) {
        model.addAttribute("articles", articleService.getAll());
        return "article/list";  // JSP 뷰 이름 반환
    }

    @PostMapping("/articles")
    public String write(@ModelAttribute Article article) {
        articleService.save(article);
        return "redirect:/articles";
    }
}

이 구조의 한계는 백엔드와 프론트엔드가 강하게 결합된다는 점이다. JSP가 없으면 동작하지 않고, Vue나 React 같은 SPA 프레임워크와 연결하려면 구조 자체를 바꿔야 한다.

Insight: MVC 방식은 서버가 뷰 렌더링까지 책임진다. REST로 전환한다는 것은 백엔드가 데이터 제공자 역할만 하도록 책임을 분리하는 것이다.

 


REST 전환 — 달라지는 것과 달라지지 않는 것

REST로 전환할 때 실제로 바뀌는 부분은 생각보다 좁다.

pom.xml — 의존성 변화

기존 MVC REST API
tomcat-embed-jasper (JSP 컴파일러) 삭제
jakarta.servlet.jsp.jstl (JSTL) 삭제
없음 springdoc-openapi-starter-webmvc-ui (Swagger)

 

JSP 관련 의존성 3개가 빠지고 Swagger 1개가 들어온다. WebConfig, DBConfig 같은 설정 파일은 변경이 없다. REST 전환 자체에 별도 설정이 필요한 게 아니라, @RestController 어노테이션 하나로 Spring이 Jackson을 통해 자동으로 JSON을 처리한다. Jackson은 spring-boot-starter-web에 이미 포함되어 있다.

컨트롤러 어노테이션

// @Controller → @RestController 로 교체
// @RestController = @Controller + @ResponseBody

@RestController          // JSON 반환
@RequestMapping("/api")
public class ArticleRestController {
    ...
}

@RestController@Controller@ResponseBody를 합친 것이다. 뷰를 렌더링하지 않고 반환값을 JSON으로 직접 직렬화해서 응답 body에 넣는다.

URL 설계 — HTTP 메서드가 행위를 표현

기존 MVC에서는 URL에 행위를 담았다. REST에서는 URL은 자원만, 행위는 HTTP 메서드로 표현한다.

기존 MVC REST API 역할
GET /articles/list GET /api/articles 목록 조회
GET /articles/detail?id=5 GET /api/articles/5 상세 조회
POST /articles/write POST /api/articles 등록
POST /articles/modify PUT /api/articles/5 수정
GET /articles/delete?id=5 DELETE /api/articles/5 삭제
Insight: REST 설계의 핵심은 URL이 명사(자원)이고, 동사(행위)는 HTTP 메서드가 담당한다는 것이다. /delete 같은 동사형 URL은 REST 원칙에 어긋난다.

 


ResponseEntity — HTTP 응답을 직접 조립하다

REST 컨트롤러에서 반환하는 ResponseEntity<T>는 HTTP 응답의 세 요소를 직접 제어할 수 있는 래퍼 객체다.

ResponseEntity<T>
    ├── status   (HTTP 상태코드)   예: 200, 201, 404
    ├── headers  (HTTP 헤더)       예: Content-Type, Location
    └── body     (실제 데이터)     예: Article 객체, List<Article>, null

실제 네트워크에 전송되는 형태

ResponseEntity는 Java 코드 안에서만 존재하는 객체다. 네트워크에 나갈 때 Spring이 HTTP 텍스트로 변환한다.

// Java 코드
return ResponseEntity.ok(articles);

// 실제 전송되는 HTTP 응답 텍스트
HTTP/1.1 200 OK\r\n
Content-Type: application/json\r\n
\r\n
[{"id":1,"title":"첫 번째 글","author":"kim",...}]

헤더 각 줄은 \r\n으로 구분되고, 헤더 끝에 빈 줄(\r\n\r\n)이 오고, 그 아래가 body다. HTTP는 1990년대에 설계된 텍스트 프로토콜이라 구조 자체가 줄 단위 파싱 방식이다. 브라우저나 axios가 이 텍스트를 받아 파싱해서 개발자에게 맵처럼 보여주는 것이다.

ResponseEntity 생성 방법

// 방법 1 - 생성자
new ResponseEntity<>(body, HttpStatus.OK)
new ResponseEntity<>(HttpStatus.NO_CONTENT)   // body 없을 때

// 방법 2 - 정적 메서드
ResponseEntity.ok(body)                                      // 200
ResponseEntity.status(HttpStatus.NOT_FOUND).build()          // body 없음
ResponseEntity.status(HttpStatus.BAD_REQUEST).body("msg")    // body 있음
ResponseEntity.noContent().build()                           // 204

제네릭 <T> vs <?> 선택 기준

메서드 시그니처의 반환 타입에서 어떤 제네릭을 쓸지는 상황에 따라 다르다.

// <?> — 성공/실패 시 body 타입이 다를 때
public ResponseEntity<?> list() {
    List<Article> list = articleService.getAll();
    if (list.isEmpty()) {
        return new ResponseEntity<Void>(HttpStatus.NO_CONTENT);   // Void
    }
    return new ResponseEntity<List<Article>>(list, HttpStatus.OK); // List<Article>
}

// <Article> — 실패해도 body 타입이 바뀌지 않을 때
public ResponseEntity<Article> detail(@PathVariable int id) {
    Article article = articleService.findById(id);
    if (article != null) {
        return ResponseEntity.ok(article);
    }
    return ResponseEntity.status(HttpStatus.NOT_FOUND).build();  // body=null, 타입은 여전히 Article
}

.build()는 "body를 null로 채운 ResponseEntity<Article>"을 만든다. 타입이 Void로 바뀌는 게 아니다. 그래서 <Article>을 그대로 유지할 수 있다.

반면 new ResponseEntity<Void>(...)처럼 명시적으로 Void 타입을 선언하면 List<Article>과 타입이 달라지므로 <?>가 필요해진다. 결론적으로 list 메서드도 new ResponseEntity<Void> 대신 .build()를 사용하면 <List<Article>>으로 명확하게 선언할 수 있다.

Insight: <?>는 타입 정보가 사라져서 읽는 사람이 "뭐가 오는 거지?" 하게 만든다. body 타입이 항상 동일하다면 구체적인 타입을 명시하는 것이 의도 전달에 낫다.

 


파라미터 어노테이션 3가지

REST 컨트롤러에서 데이터를 받는 방법은 출처에 따라 세 가지로 나뉜다.

@PathVariable — URL 경로에서

@GetMapping("/articles/{id}")
public ResponseEntity<Article> detail(@PathVariable("id") int id) {
    // GET /api/articles/5 → id = 5
}

@ModelAttribute — 쿼리스트링 / form-data에서

@GetMapping("/articles")
public ResponseEntity<?> list(@ModelAttribute SearchFilter filter) {
    // GET /api/articles?key=title&word=스프링
    // → filter.key = "title", filter.word = "스프링"
}

쿼리스트링을 객체로 자동 바인딩한다. Stateless 원칙과 무관하다. Stateless는 서버가 클라이언트 상태를 저장하냐의 문제이고, @ModelAttribute는 요청 파라미터를 어떻게 받냐의 문제다. 요청은 매번 새로 오는 것이라 충돌하지 않는다.

@RequestBody — 요청 body의 JSON에서

@PutMapping("/articles/{id}")
public ResponseEntity<Void> update(
        @PathVariable("id") int id,
        @RequestBody Article article) {
    // PUT /api/articles/5
    // body: {"title":"수정제목","content":"수정내용"}
    article.setId(id);  // URL의 id를 article에 세팅
    articleService.update(article);
    return new ResponseEntity<>(HttpStatus.OK);
}

URL에 id가 있는데 body에도 id를 또 보내는 것은 중복이다. URL로 "어떤 자원인지", body로 "어떤 내용으로 바꿀지"를 분리하는 것이 REST 스타일이다. 그래서 body에서 받은 article에는 id가 없고, URL에서 받은 id를 직접 세팅한다.

어노테이션 데이터 출처 Content-Type 주요 사용처
@PathVariable URL 경로 {id} - 특정 자원 식별
@ModelAttribute 쿼리스트링, form-data application/x-www-form-urlencoded 검색 조건, 폼 등록
@RequestBody 요청 body application/json JSON 등록, 수정
Insight: REST에서 데이터 등록/수정은 @RequestBody(JSON)가 표준이다. @ModelAttribute는 HTML form 방식이라 Vue 같은 SPA와 연결할 때 어색하다.

 


Stateless — REST가 세션을 쓰지 않는 이유

REST의 핵심 제약 중 하나는 Stateless(무상태)다. 서버가 클라이언트의 상태를 저장하지 않는다는 원칙이다.

// 기존 MVC — 서버가 상태를 기억
session.setAttribute("loginUser", user.getName());
return "redirect:/articles";

// REST — 서버는 상태를 기억하지 않음
return ResponseEntity.ok(article);  // 요청마다 독립적으로 처리

게시글 등록 후 새로 생성된 id를 클라이언트에게 알려줄 때도 세션이 아닌 응답 body나 Location 헤더를 사용한다.

// 방법 1 - body에 등록된 객체 통째로 반환 (실무에서 주로 사용)
@PostMapping("/articles")
public ResponseEntity<Article> write(@RequestBody Article article) {
    articleService.save(article);  // DB insert 후 article.id 채워짐 (useGeneratedKeys)
    return ResponseEntity.status(HttpStatus.CREATED).body(article);
}

// 방법 2 - Location 헤더로 새 자원 URL 전달 (REST 교과서적 방법)
@PostMapping("/articles")
public ResponseEntity<Void> write(@RequestBody Article article) {
    articleService.save(article);
    URI location = URI.create("/api/articles/" + article.getId());
    return ResponseEntity.created(location).build();
    // 응답 헤더: Location: /api/articles/5
}

방법 2는 REST 표준에 더 가깝지만 클라이언트가 Location 헤더를 보고 다시 GET 요청을 날려야 해서 번거롭다. 실무에서는 방법 1을 더 많이 쓴다.

useGeneratedKeys="true"는 MyBatis 설정으로, DB가 생성한 PK를 insert 완료 후 Java 객체에 자동으로 채워준다. 그래서 save() 호출 전에는 id가 0이었다가 호출 후에는 실제 id값이 세팅된다.

Insight: Stateless는 "서버가 이전 요청을 기억하지 않는다"는 의미다. 세션은 서버가 상태를 기억하는 방식이므로 REST 원칙에 어긋난다. 필요한 정보는 매 요청마다 클라이언트가 직접 보내거나 응답으로 받아야 한다.

 


Swagger — 뷰 없는 API의 문서화

뷰가 없으니 API 명세 문서가 필요하다. springdoc-openapi 라이브러리를 추가하면 /swagger-ui/index.html에서 자동으로 테스트 가능한 문서가 생성된다.

@Configuration
public class ApiDocsConfig {
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Article API")
                        .description("게시글 REST API")
                        .version("v1.0.0")
                        .license(new License()
                                .name("Example Corp")
                                .url("https://example.com")));
    }
}

위 코드는 빌더 패턴(메서드 체이닝)을 사용한다. 각 메서드가 return this로 자기 자신을 반환하기 때문에 .으로 계속 이어붙일 수 있다. 중간 변수를 만들지 않아도 되는 것이 핵심이다.

// 체이닝 없이 작성하면
License license = new License();
license.name("Example Corp");
license.url("https://example.com");

Info info = new Info();
info.title("Article API");
info.license(license);   // 완성된 license 객체를 주입

OpenAPI api = new OpenAPI();
api.info(info);
return api;

// 체이닝 버전 — 완성되는 순간 바로 인자로 전달
return new OpenAPI()
        .info(new Info()
                .title("Article API")
                .license(new License().name("Example Corp").url("...")));

컨트롤러 메서드에도 Swagger 어노테이션을 붙여 문서를 보강할 수 있다.

@Tag(name = "Article API", description = "게시글 CRUD")
@RestController
public class ArticleRestController {

    @Operation(summary = "게시글 목록 조회", description = "검색 조건에 따른 필터링 지원")
    @GetMapping("/articles")
    public ResponseEntity<?> list(...) { ... }

    @Hidden  // Swagger UI에서 숨김 — 미완성이거나 내부용 API
    @DeleteMapping("/articles/{id}")
    public ResponseEntity<Void> delete(...) { ... }
}
Insight: @Hidden은 API가 동작하지 않는 게 아니라 문서에만 안 보이는 것이다. 아직 완성되지 않은 API나 외부에 노출하고 싶지 않은 관리용 API에 붙인다.

 


FAQ

Q. 파일 업로드도 @RequestBody로 받을 수 있나?

안 된다. 파일은 바이너리 데이터라 JSON으로 표현이 불가능하다. 파일 업로드에는 @RequestParam("file") MultipartFile file을 쓰거나, 파일과 JSON 객체를 함께 받아야 한다면 @RequestPart를 사용한다. @RequestPartmultipart/form-data 요청에서 JSON 객체와 파일을 동시에 받을 수 있어 REST에서 더 자연스럽다.

 

Q. DELETE 성공 시 200 OK와 204 No Content 중 어느 것이 맞나?

REST 표준에서는 204 No Content가 더 적합하다. 삭제 완료 후 돌려줄 데이터가 없기 때문이다. 200 OK에 "success" 문자열을 body에 넣는 방식은 상태코드로 이미 결과를 표현하고 있는데 문자열이 중복 정보가 된다. ResponseEntity.noContent().build()로 204를 반환하는 것이 깔끔하다.

 

Q. UserController는 왜 REST로 안 바꿨나?

로그인/회원가입은 세션 기반 인증 방식을 유지하고 있기 때문이다. REST 원칙을 엄격히 적용한다면 JWT 토큰 방식으로 교체해야 하지만, 학습 단계에서는 Board CRUD만 REST로 전환하고 인증은 기존 방식을 유지한 것이다. 실무에서도 시스템 일부만 REST로 전환하는 경우는 흔하다.

 


마무리

REST 전환의 핵심은 설정이 아니라 어노테이션과 반환 방식의 변화다. @RestController로 JSON을 반환하고, HTTP 메서드로 CRUD를 표현하고, ResponseEntity로 상태코드와 데이터를 함께 조립하는 것이 전부다.

Service와 Mapper 코드는 기존 MVC와 완전히 동일하다. 달라지는 건 "서비스 결과를 어떻게 응답하느냐"뿐이다. 백엔드는 데이터만 내려주고, 화면 구성은 Vue 같은 프론트엔드가 담당하는 분리된 구조로 가는 첫 걸음이 여기서 시작된다.

 


 

Environment: Windows 11, JDK 17, Spring Boot 3.4.x, IntelliJ IDEA