Skip to main content
Summary

REST API는 데이터를 주고받는 약속이다

지난 장에서는 컨트롤러가 웹 요청의 입구라는 점을 배웠습니다. 이번에는 화면을 직접 보여 주는 방식이 아니라 JSON 데이터를 주고받는 API를 만들어 보겠습니다.

JSON은 데이터를 표현하는 가벼운 형식입니다. 자바 객체의 값을 다른 프로그램이 읽기 쉬운 모양으로 포장해서 보내는 것과 비슷합니다.

{
  "id": 1,
  "title": "첫 번째 글",
  "content": "REST API를 만들고 있습니다."
}

브라우저 화면, React 같은 프론트엔드, 모바일 앱, 다른 서버는 이 JSON을 받아 자신에게 필요한 방식으로 화면을 구성합니다. 즉, REST API는 "화면"보다 "데이터"를 중심에 둔 서버 기능입니다.

REST API 만들기AI 생성 이미지

자원, HTTP 메서드, 상태 코드

REST API를 설계할 때는 세 가지를 먼저 봅니다.

첫째, 자원입니다. 자원은 서버가 다루는 대상입니다. 게시판에서는 게시글이 자원입니다. 보통 URL에는 동사보다 명사를 사용합니다.

/posts        게시글 목록 또는 게시글 생성
/posts/1      1번 게시글

둘째, HTTP 메서드입니다. 같은 URL이라도 메서드에 따라 의미가 달라집니다.

HTTP 메서드의미예시
GET조회GET /posts
POST생성POST /posts
PUT 또는 PATCH수정PATCH /posts/1
DELETE삭제DELETE /posts/1

셋째, 상태 코드입니다. 상태 코드는 서버가 요청을 처리한 결과를 숫자로 알려 주는 신호입니다.

상태 코드의미
200 OK요청 성공
201 Created새 자원 생성 성공
204 No Content성공했지만 응답 본문 없음
400 Bad Request요청 값이 잘못됨
404 Not Found요청한 자원을 찾을 수 없음
500 Internal Server Error서버 내부 오류

초보자일 때는 모든 상태 코드를 외우려 하지 않아도 됩니다. 먼저 200, 201, 204, 400, 404의 의미를 익히면 API를 읽고 만들기 훨씬 쉬워집니다.

게시글 API 설계하기

이번 장에서는 데이터베이스 없이 메모리에 게시글을 저장합니다. 서버를 재시작하면 데이터는 사라집니다. 진짜 데이터베이스 저장은 다음 장에서 다룹니다.

우리가 만들 API는 다음과 같습니다.

기능메서드URL응답 상태
게시글 목록 조회GET/api/posts200
게시글 단건 조회GET/api/posts/{id}200 또는 404
게시글 작성POST/api/posts201
게시글 삭제DELETE/api/posts/{id}204 또는 404

URL 앞에 /api를 붙인 이유는 화면용 URL과 데이터용 URL을 구분하기 위해서입니다. 예를 들어 /posts는 HTML 화면, /api/posts는 JSON API로 나눌 수 있습니다.

Entity를 바로 노출하지 않고 DTO를 사용하기

API를 만들 때 자주 나오는 말이 DTO입니다. DTO는 Data Transfer Object의 줄임말입니다. 이름은 어렵지만 뜻은 단순합니다. 계층 사이에서 데이터를 옮기기 위한 전용 객체입니다.

게시글의 실제 객체를 Post라고 하겠습니다. 이 객체는 나중에 데이터베이스와 연결될 수 있습니다. 그런데 API 응답에 Post를 그대로 내보내면 문제가 생길 수 있습니다.

예를 들어 Post에 작성자 비밀번호, 내부 상태값, 삭제 여부 같은 필드가 추가되었다고 생각해 봅시다. 이 객체를 그대로 응답하면 외부에 보여 주면 안 되는 값까지 노출될 수 있습니다. 또 내부 구조를 바꾸면 API 응답 모양도 같이 흔들립니다.

그래서 보통 다음처럼 나눕니다.

Post              내부에서 사용하는 게시글 객체
PostCreateRequest 게시글 생성 요청 JSON을 받는 객체
PostResponse      게시글 응답 JSON을 만드는 객체

비유하면 Post는 주방 안의 재료와 조리 도구까지 포함한 작업 공간이고, DTO는 손님에게 내보내는 완성된 접시입니다. 손님에게 주방 전체를 보여 줄 필요는 없습니다.

게시글 도메인 객체 만들기

먼저 domain 패키지에 Post 클래스를 만듭니다.

package com.example.board.domain;

public class Post {

    private Long id;
    private String title;
    private String content;

    public Post(Long id, String title, String content) {
        this.id = id;
        this.title = title;
        this.content = content;
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public String getContent() {
        return content;
    }
}

아직 수정 기능은 만들지 않으므로 setter는 넣지 않았습니다. 값을 바꾸는 메서드는 필요할 때만 추가하는 편이 좋습니다.

요청 DTO와 응답 DTO 만들기

이제 dto 패키지를 만들고 요청과 응답에 사용할 객체를 작성합니다.

package com.example.board.dto;

public class PostCreateRequest {

    private String title;
    private String content;

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }
}

PostCreateRequest는 클라이언트가 보내는 JSON을 받을 그릇입니다.

{
  "title": "첫 번째 글",
  "content": "JSON으로 글을 작성합니다."
}

JSON의 title, content가 자바 객체의 필드 이름과 같으면 Spring이 값을 자동으로 담아 줍니다. 이때 내부적으로는 Jackson이라는 JSON 변환 라이브러리가 주로 사용됩니다. 지금은 이름만 알아 두어도 충분합니다.

다음은 응답 DTO입니다.

package com.example.board.dto;

import com.example.board.domain.Post;

public class PostResponse {

    private Long id;
    private String title;
    private String content;

    public PostResponse(Long id, String title, String content) {
        this.id = id;
        this.title = title;
        this.content = content;
    }

    public static PostResponse from(Post post) {
        return new PostResponse(
                post.getId(),
                post.getTitle(),
                post.getContent()
        );
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public String getContent() {
        return content;
    }
}

from() 메서드는 Post를 PostResponse로 바꾸는 변환 메서드입니다. 이런 변환 코드를 DTO 안에 두면 컨트롤러가 깔끔해집니다.

메모리 저장소 만들기

다음은 게시글을 임시로 저장할 리포지토리입니다. repository 패키지에 작성합니다.

package com.example.board.repository;

import com.example.board.domain.Post;
import org.springframework.stereotype.Repository;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@Repository
public class PostRepository {

    private final List<Post> posts = new ArrayList<>();
    private long nextId = 1L;

    public Post save(String title, String content) {
        Post post = new Post(nextId++, title, content);
        posts.add(post);
        return post;
    }

    public List<Post> findAll() {
        return posts;
    }

    public Optional<Post> findById(Long id) {
        return posts.stream()
                .filter(post -> post.getId().equals(id))
                .findFirst();
    }

    public boolean deleteById(Long id) {
        return posts.removeIf(post -> post.getId().equals(id));
    }
}

@Repository는 이 클래스가 저장소 역할을 한다는 표시입니다. 동시에 스프링이 이 객체를 Bean으로 등록할 수 있게 해 줍니다.

Optional<Post>는 "게시글이 있을 수도 있고 없을 수도 있다"는 뜻입니다. /api/posts/999처럼 없는 글을 조회할 수 있기 때문에 이 표현이 자연스럽습니다.

이번 예제의 저장소는 학습용으로 단순하게 만들었습니다. 여러 사람이 동시에 요청하는 실제 서비스에서는 지금처럼 ArrayList와 nextId만으로 처리하지 않고, 데이터베이스와 트랜잭션을 사용해 더 안전하게 저장합니다. 그 내용은 다음 단계에서 이어서 다룹니다.

서비스에서 비즈니스 흐름 정리하기

컨트롤러가 모든 일을 직접 하면 코드가 금방 복잡해집니다. 컨트롤러는 요청과 응답을 담당하고, 실제 기능 흐름은 서비스에 둡니다.

service 패키지에 PostService를 만듭니다.

package com.example.board.service;

import com.example.board.domain.Post;
import com.example.board.repository.PostRepository;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class PostService {

    private final PostRepository postRepository;

    public PostService(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    public Post create(String title, String content) {
        return postRepository.save(title, content);
    }

    public List<Post> findAll() {
        return postRepository.findAll();
    }

    public Optional<Post> findById(Long id) {
        return postRepository.findById(id);
    }

    public boolean delete(Long id) {
        return postRepository.deleteById(id);
    }
}

PostService는 PostRepository를 직접 new로 만들지 않습니다. 생성자를 통해 받습니다. 이전에 배운 DI 방식입니다. 스프링이 PostRepository Bean을 찾아 PostService에 넣어 줍니다.

REST 컨트롤러 만들기

이제 API의 입구인 컨트롤러를 작성합니다. controller 패키지에 PostApiController를 만듭니다.

package com.example.board.controller;

import com.example.board.domain.Post;
import com.example.board.dto.PostCreateRequest;
import com.example.board.dto.PostResponse;
import com.example.board.service.PostService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/posts")
public class PostApiController {

    private final PostService postService;

    public PostApiController(PostService postService) {
        this.postService = postService;
    }

    @GetMapping
    public List<PostResponse> findAll() {
        return postService.findAll().stream()
                .map(PostResponse::from)
                .toList();
    }

    @GetMapping("/{id}")
    public ResponseEntity<PostResponse> findById(@PathVariable Long id) {
        return postService.findById(id)
                .map(post -> ResponseEntity.ok(PostResponse.from(post)))
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<PostResponse> create(@RequestBody PostCreateRequest request) {
        Post post = postService.create(request.getTitle(), request.getContent());
        PostResponse response = PostResponse.from(post);

        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(response);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        boolean deleted = postService.delete(id);

        if (!deleted) {
            return ResponseEntity.notFound().build();
        }

        return ResponseEntity.noContent().build();
    }
}

여기서 중요한 부분을 하나씩 보겠습니다.

@RestController는 @Controller와 @ResponseBody가 합쳐진 애너테이션입니다. 메서드가 반환한 객체를 HTML 뷰 이름으로 해석하지 않고, HTTP 응답 본문에 JSON으로 담아 보냅니다.

@RequestBody는 요청 본문에 들어 있는 JSON을 자바 객체로 바꿔 줍니다.

@PostMapping
public ResponseEntity<PostResponse> create(@RequestBody PostCreateRequest request) {
    ...
}

클라이언트가 JSON을 보내면 Spring은 PostCreateRequest 객체를 만들고 title, content 값을 채웁니다.

ResponseEntity는 응답 상태 코드와 응답 본문을 함께 다룰 때 사용합니다. 단순히 객체만 반환하면 대부분 200 OK가 됩니다. 하지만 게시글 생성은 201 Created가 더 정확합니다. 그래서 ResponseEntity.status(HttpStatus.CREATED)를 사용했습니다.

삭제에 성공했을 때는 응답 본문을 보낼 필요가 없습니다. 이때는 204 No Content를 사용합니다.

HTTP Client로 테스트하기

IntelliJ IDEA를 사용한다면 프로젝트 안에 requests.http 파일을 만들고 다음 내용을 작성할 수 있습니다.

### 게시글 작성
POST http://localhost:8080/api/posts
Content-Type: application/json

{
  "title": "첫 번째 글",
  "content": "HTTP Client로 작성한 글입니다."
}

### 게시글 목록 조회
GET http://localhost:8080/api/posts

### 게시글 단건 조회
GET http://localhost:8080/api/posts/1

### 게시글 삭제
DELETE http://localhost:8080/api/posts/1

각 요청 위에 있는 실행 버튼을 누르면 서버로 요청이 전송됩니다. 서버가 실행 중이어야 합니다.

Postman을 사용한다면 다음 순서로 테스트합니다.

  1. 메서드를 POST로 선택합니다.

  2. URL에 http://localhost:8080/api/posts를 입력합니다.

  3. Body 탭에서 raw와 JSON을 선택합니다.

  4. 아래 JSON을 입력하고 Send를 누릅니다.

{
  "title": "Postman 테스트",
  "content": "Postman으로 JSON 요청을 보냅니다."
}

정상이라면 상태 코드는 201 Created이고 응답 본문은 다음과 비슷합니다.

{
  "id": 1,
  "title": "Postman 테스트",
  "content": "Postman으로 JSON 요청을 보냅니다."
}

이후 GET http://localhost:8080/api/posts를 보내면 작성한 글이 배열로 조회됩니다.

[
  {
    "id": 1,
    "title": "Postman 테스트",
    "content": "Postman으로 JSON 요청을 보냅니다."
  }
]

자주 만나는 오류

404 Not Found가 나온다면 URL을 먼저 확인합니다. /api/posts로 만들었는데 /posts로 요청하거나, /api/posts/999처럼 존재하지 않는 게시글을 조회하면 404가 나올 수 있습니다.

405 Method Not Allowed가 나온다면 URL은 맞지만 HTTP 메서드가 맞지 않을 가능성이 큽니다. 예를 들어 POST /api/posts로 보내야 하는 요청을 PUT /api/posts로 보내면 서버가 "이 URL은 알지만, 이 메서드는 허용하지 않는다"는 뜻으로 405를 응답할 수 있습니다.

415 Unsupported Media Type이 나온다면 요청 헤더에 Content-Type: application/json이 있는지 확인합니다. 서버는 JSON을 기대하는데 클라이언트가 본문 형식을 알려 주지 않으면 변환에 실패할 수 있습니다.

JSON 값이 null로 들어온다면 JSON 필드 이름과 DTO 필드 이름이 같은지 확인합니다. 예를 들어 DTO는 title인데 JSON에서 postTitle로 보내면 자동으로 매칭되지 않습니다.

서버를 재시작했더니 글이 사라졌다면 정상입니다. 이번 장의 저장소는 메모리의 ArrayList입니다. 메모리는 프로그램이 종료되면 비워집니다. 다음 장에서 데이터베이스를 연결하면 서버를 다시 켜도 데이터가 유지됩니다.

정리

REST API는 자원을 URL로 표현하고, HTTP 메서드로 행동을 구분하며, 상태 코드로 처리 결과를 알려 줍니다.

Spring에서는 @RestController로 JSON 응답을 만들고, @RequestBody로 JSON 요청을 자바 객체로 받을 수 있습니다. 응답 상태를 세밀하게 다루고 싶을 때는 ResponseEntity를 사용합니다.

또한 내부 객체를 그대로 외부에 노출하지 않고 DTO를 사용하면 API 응답 모양을 안정적으로 관리할 수 있습니다. 이번 장에서 만든 게시글 API는 아직 메모리에만 데이터를 저장합니다. 다음 장에서는 이 흐름에 데이터베이스와 JPA를 연결해 실제로 데이터가 남는 API로 발전시켜 보겠습니다.

책 목록

초보자를 위한 자바 스프링 사용법

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기