Developer.

[멋사 백엔드 19기] TIL 45일차 Rest API

📂 목차


📚 본문

Rest API

웹 서비스의 한 형태로 Representational State Transfer (REST) 아키텍처 스타일을 따르도록 구성하는 API 를 써야 한다.

REST의 주요 원칙

  1. 자원(Resource) 기반 설계
    • RESTful 서비스에서 모든 콘텐츠는 자원(Resource) 으로 표현된다.
    • 각 자원은 URI(Uniform Resource Identifier) 로 식별된다.
    • 예시: /api/users/1 → id가 1인 사용자 자원
  2. 무상태(Stateless)
    • 각 요청(Request)은 서버와 독립적으로 처리된다.
    • 서버는 클라이언트의 상태를 저장하지 않으며, 모든 요청은 필요한 정보를 스스로 포함해야 한다.
    • 이를 통해 서버 설계가 단순해지고, 확장성(Scalability) 이 높아진다.
  3. 표준 HTTP 메서드 활용
    • 자원에 대한 CRUD 동작을 HTTP 메서드로 명확히 표현한다.
      • GET: 조회(Read)
      • POST: 생성(Create), 멱등성 성질 X
      • PUT: 수정(Update)
      • PATCH: 부분 수정
      • DELETE: 삭제(Delete)
    • 이를 통해 API가 직관적이고 일관성 있게 설계된다.

멱등성은 동일 요청을 보내면 서버의 상태를 동일하게 유지하냐이다.

  1. 다양한 표현(Representation)
    • 자원은 JSON, XML 등 다양한 형태로 표현될 수 있다.
    • 클라이언트의 Accept 헤더에 따라 서버는 가장 적절한 데이터 형식으로 응답한다.
  2. 연결성(Connectivity) & HATEOAS
    • 자원들은 하이퍼링크(Hyperlink) 를 통해 서로 연결될 수 있다.
    • HATEOAS(Hypermedia As The Engine Of Application State) 를 통해 클라이언트는 API 응답 내 링크를 따라가며
      추가적인 자원에 접근할 수 있다.

Rest Controller

@Controller + @ResponseBody 어노테이션의 결합이며, viewname 을 반환하는 부분이 데이터(JSON/XML) 를 직접 응답으로 보내는 컨트롤러를 의미한다.

@RestController
@RequestMapping("/users")
public class UserController {
    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        return new User(id, "홍길동", "hong@example.com");
    }
}

이전에 Controller 어노테이션은 viewname 을 반환하고, model 에 데이터를 넣어주는 역할을 하지만, 여기서는 Model 이 안쓰이기 때문에 전달하지 않고 데이터를 그대로 HandlerAdapter 에 전달한다.

HttpMessageConverter

데이터를 전달할 때 위처럼 객체를 전달함을 볼 수 있는데, 우선 반환값으로는 다음을 전달할 수 있다.

  • String
  • DTO
  • ResponseEntity<?>

@RestController 는 객체를 반환하면 뷰를 렌더링하지 않고, HttpMessageConverter 가 그 객체를 JSON 또는 XML 형식으로 변환하여 HTTP 응답 본문(Response Body) 에 직접 기록한다.

즉, HandlerAdapter 가 컨트롤러 메서드를 실행한 뒤 반환값을 전달하면, HttpMessageConverter 가 해당 객체 타입과 Accept 헤더를 참고하여 적절한 변환기(MappingJackson2HttpMessageConverter)를 선택하고 직렬화(JSON 변환)를 수행 후 반환시키게 된다.

변환기 구현체

  • MappingJackson2HttpMessageConverter: JSON 변환
  • Jaxb2RootElementHttpMessageConverter: XML 변환
  • StringHttpMessageConverter: 문자열 처리

ResponseEntity

HTTP 응답을 객체의 형태로 추상화한 클래스이다. Spring MVC 에서 @RestController@Controller(@ResponseBody 어노테이션을 함수에 붙여야 함) 에서 사용가능하며, HTTP 스펙 까지 세밀하게 제어가 가능하다.

넣을 수 있는 정보는 다음과 같다:

  • Body

  • Headers
    • Content-Type
    • Location
    • Authorization
  • Status Code
    • 200
    • 201
    • 404

메서드들을 파헤쳐 본다.

public ResponseEntity(@Nullable T body, @Nullable MultiValueMap<String, String> headers, int rawStatus) {
    this(body, headers, HttpStatusCode.valueOf(rawStatus));
}

/**
    * Create a {@code ResponseEntity} with a body, headers, and a status code.
    * @param body the entity body
    * @param headers the entity headers
    * @param statusCode the status code
    */
public ResponseEntity(@Nullable T body, @Nullable MultiValueMap<String, String> headers, HttpStatusCode statusCode) {
    super(body, headers);
    Assert.notNull(statusCode, "HttpStatusCode must not be null");

    this.status = statusCode;
}

위가 완전체의 생성자이다. body, headers, statusCode 순으로 넣을 수 있으며, headers 에는 MultiValueMap 이 들어가는 것을 볼 수 있다. ResponseEntity 는 위 생성자 외에도 이를 builder 패턴으로 만드는 것도 허용한다. 따라서 정적 Builder 메서드들을 보자.

ResponseEntity Builder

우선 가장 기본적으로 ResponseEntity.status() 로 먼저 틀을 잡는다.

public static BodyBuilder status(HttpStatusCode status) {
    Assert.notNull(status, "HttpStatusCode must not be null");
    return new DefaultBuilder(status);
}

내부적으로는 위와 같이 되어 있으며, status 가 null 이라면 당연히 예외가 발생하도록 설계되어 있다. 마지막으로는 DefaultBuilder를 생성하면서 리턴하고 있다. DefaultBuilder 는 private static 의 nested class 로 구성하여서 우리가 굳이 볼 필요는 없지만, ResponseEntity.BodyBuilder 인터페이스를 구현하는 것을 볼 수 있다. 이를 잠깐 살펴보자.

ResponseEntity BodyBuilder

필드

  • HttpStatusCode
  • HttpHeaders

생성자

  • int statusCode
  • HttpStatusCode statusCode

관련 메서드

메서드는 전부 Builder 패턴이기에 method chaining 이 가능하다.

header(String name, String... values)        // 단일/복수 헤더 추가
headers(HttpHeaders headers)                // HttpHeaders 전체 복사
headers(Consumer<HttpHeaders> consumer)     // 람다로 헤더 커스터마이징

allow(HttpMethod... methods)        // Allow 헤더 설정
contentLength(long length)          // Content-Length
contentType(MediaType type)         // Content-Type
eTag(String tag)                    // ETag
lastModified(ZonedDateTime/Instant/long) // Last-Modified
location(URI location)              // Location 헤더
cacheControl(CacheControl cache)    // Cache-Control
varyBy(String... headers)           // Vary 헤더

최종 생성 ResponseEntity 로 반환하는 메서드는 다음과 같다:

<T> ResponseEntity<T> body(T body)  // 실제 body 포함하여 생성
<T> ResponseEntity<T> build()       // body 없이 ResponseEntity 생성

위를 토대로 ResponseEntityHTTP 상태코드와 함께 ResponseEntity 를 쉽게 생성할 수 있게 된다.

ResponseEntity 응답 생성

Status 200

  • ok(): 상태 코드 200 OKBodyBuilder 생성
  • ok(T body): 상태 코드 200 OKbody 를 포함한 ResponseEntity 생성
  • of(Optional<T> body): Optional 값이 있으면 200 OK + body, 비어있으면 404 Not Found
  • ofNullable(T body): null 이면 404 Not Found, null 이 아니면 200 OK + body

Status 201 created

반드시 생성된 자원의 위치를 헤더로 알려줘야 한다.

  • created(URI location): 201 Created 상태 + Location 헤더 설정 주로 POST 에서 새 리소스 생성 시 사용
URI location = ServletUriComponentsBuilder
    .fromCurrentRequest()
    .path("/{id}")
    .buildAndExpand(id)
    .toUri();

return ResponseEntity.created(location)
                    .body(memo);

기타 상태 코드 빌더

  • accepted() → 202 Accepted
  • noContent() → 204 No Content
  • badRequest() → 400 Bad Request
  • notFound() → 404 Not Found
  • unprocessableEntity() → 422 Unprocessable Entity
  • internalServerError() → 500 Internal Server Error

이제 위를 이용하여 CRUD 전부를 만들어보자.

ServletUriComponentsBuilder

HTTP 요청을 기반으로 URI 를 편리하게 생성할 때 사용할 수 있는 유틸리티 클래스이며, 주로 REST API 에서 새 리소스 생성(CREATED) 후에 Location 헤더 설정에 많이 쓰이게 된다.

요청 설정

  • fromCurrentRequest(): 현재 요청 URI 를 기준으로 Builder 생성
  • fromCurrentContextPath(): 컨텍스트 루트 기준
  • fromServletMapping(): 서블릿 매핑 기준

Path 변수/쿼리 추가

  • path("/subpath"): URI 뒤에 경로 추가
  • queryParm("page", 1): 쿼리 파라미터 추가

URI 객체 생성

  • buildAndExpand(Object... uriVariables): {id} 같은 Path 변수 치환
  • toUri(): java.net.URI 객체 반환
CREATE REST

201 상태 코드를 가지며, ResponseEntity.created() 함수로 간단히 세팅할 수 있다. 상태코드만 세팅 되기 때문에 bodyheader 는 자유롭게 넣어주어야 한다.

@PostMapping(consumes = "text/plain",
             produces = "application/json")
@Transactional
public ResponseEntity<Memo> createMemo(@RequestBody String content) {
    if (content.isBlank())
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    long idx = memos.size();
    var memo = memos.put(idx, new Memo(idx, content));

    URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand("id", idx)
            .toUri();

    return ResponseEntity.created(location)
            .body(memo);
}

여기서 PostMapping 에는 들어올 content-type 을 지정해주어야 String 으로 알맞게 mapping 이 될 수 있겠다. 만약 application/json 으로 받으려면 DTO 를 따로 정의해주고 Body 에는 구조적 데이터 형태를 지키면서 요청을 보내야 한다.

location 을 넣음으로써 self-descriptive 성질을 충족한다고 볼 수 있다.
create 에는 RESTful 을 위해 이렇게 자기 설명을 해주는 헤더가 필요하고,
이를 통해 새로 생성된 리소스의 URI 가 어디있는지 알려주어야 한다.

READ REST

Mutiple READ

@GetMapping(produces = "application/json")
@Transactional(readOnly = true)
public ResponseEntity<List<Memo>> getMemos() {
    List<Memo> lst = Arrays.asList(memos.values().toArray(new Memo[0]));

    return ResponseEntity.ok()
            .header("Content-Type", "application/json")
            .header("X-Total-Count", String.valueOf(lst.size()))
            .body(lst);
}

Single READ

@GetMapping(path = "/{id}",
            produces = "application/json")
@Transactional(readOnly = true)
public ResponseEntity<Memo> getMemos(@PathVariable Long id) {
    if (id == null || !memos.containsKey(id))
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                                .header("Content-Type", "application/json")
                                .build();
    return ResponseEntity.ok().header("Content-Type", "application/json")
                            .body(memos.get(id));
}
UPDATE REST

업데이트는 좀 복잡하다. create + read 의 구현을 좀 가져와서 사용하면 되겠다.

@PutMapping(path = "/{id}",
            consumes = "text/plain",
            produces = "application/json")
@Transactional
public ResponseEntity<Memo> updateMemo(@PathVariable Long id,
                                        @RequestBody String content) {
    if (id == null || content.isBlank() || !memos.containsKey(id))
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                             .header("Content-Type", "application/json")
                             .build();

    var memo = new Memo(id, content);
    memos.put(id, memo);

    URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                                              .path("/{id}")
                                              .buildAndExpand("id", id)
                                              .toUri();

    return ResponseEntity.ok()
                         .header("Content-Type", "application/json")
                         .header("Location", location.toString())
                         .body(memo);
}
DELETE REST
@DeleteMapping(path = "/{id}")
@Transactional
public ResponseEntity<?> deleteMemo(@PathVariable Long id) {
    if (id == null || !memos.containsKey(id))
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                                .header("Content-Type", "application/json")
                                .build();

    memos.remove(id);

    return ResponseEntity.noContent().build(); // 204 Status
}

이렇게 하면 REST 의 제약조건들을 대부분 만족하지만 HATEOAS 가 없게 된다.

Spring HATEOAS

스프링에서는 REST 를 지키기 위해 Spring HATEOAS 를 제공한다. 이는 REST 원칙을 엄격하게 지키기 위함이며, 단순한 DTO 나 객체 반환만으로는 링크 정보 제공이 불가하기 때문에 Spring HATEOAS 에서 제공해주는 EntityModel 으로 한 번 더 감싸서 반환시켜주는게 원칙이다.

implementation 'org.springframework.boot:spring-boot-starter-hateoas'

위를 추가하면 다음이 자동으로 추가된다:

  • spring-hateoas
  • spring-web
  • spring-context
  • Jackson 모듈

데이터

class Memo {
    private Long id;
    private String content;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    public Memo(Long id, String content, LocalDateTime createdAt, LocalDateTime updatedAt) {
        this.id = id;
        this.content = content;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }

    // getters and setters
    public Long getId() { return id; }
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

class MemoRequest {
    private String content;
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
}

위를 토대로 작성해보자.

CREATE

@PostMapping
@Transactional
public ResponseEntity<EntityModel<Memo>> createMemo(@RequestBody MemoRequest request) {
    // 새 메모 생성
    long id = memos.size();
    Memo memo = new Memo(id, request.getContent(), LocalDateTime.now(), LocalDateTime.now());
    memos.put(id, memo);

    // HATEOAS 링크 생성
    EntityModel<Memo> resource = EntityModel.of(memo);
    resource.add(WebMvcLinkBuilder.linkTo(
            WebMvcLinkBuilder.methodOn(MemoController.class).getMemo(id)
    ).withSelfRel());
    resource.add(WebMvcLinkBuilder.linkTo(
            WebMvcLinkBuilder.methodOn(MemoController.class).updateMemo(id, null)
    ).withRel("update"));
    resource.add(WebMvcLinkBuilder.linkTo(
            WebMvcLinkBuilder.methodOn(MemoController.class).deleteMemo(id)
    ).withRel("delete"));

    // HTTP 201 Created와 Location 헤더 반환
    URI location = WebMvcLinkBuilder.linkTo(
            WebMvcLinkBuilder.methodOn(MemoController.class).getMemo(id)
    ).toUri();

    return ResponseEntity.created(location).body(resource);
}

READ 단건

@GetMapping("/{id}")
public ResponseEntity<EntityModel<User>> getUser(@PathVariable Long id) {
    User user = userService.findById(id);

    if (user == null) return ResponseEntity.notFound().build();

    EntityModel<User> resource = EntityModel.of(user);
    resource.add(WebMvcLinkBuilder.linkTo(
        WebMvcLinkBuilder.methodOn(UserController.class).getUser(id)
    ).withSelfRel());
    resource.add(WebMvcLinkBuilder.linkTo(
        WebMvcLinkBuilder.methodOn(UserController.class).updateUser(id, null)
    ).withRel("update"));

    return ResponseEntity.ok(resource);
}

READ 다건

@GetMapping
public ResponseEntity<List<EntityModel<Memo>>> getAllMemos(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size) {

    List<Memo> allMemos = new ArrayList<>(memos.values());
    int start = page * size;
    int end = Math.min(start + size, allMemos.size());

    List<Memo> pagedMemos = allMemos.subList(start, end);

    List<EntityModel<Memo>> resources = new ArrayList<>();
    for (Memo memo : pagedMemos) {
        EntityModel<Memo> resource = EntityModel.of(memo);
        resource.add(WebMvcLinkBuilder.linkTo(
                WebMvcLinkBuilder.methodOn(MemoController.class).getMemo(memo.getId())
        ).withSelfRel());
        resources.add(resource);
    }

    return ResponseEntity.ok()
            .header("X-Total-Count", String.valueOf(allMemos.size()))
            .body(resources);
}

UPDATE

@PutMapping("/{id}")
public ResponseEntity<EntityModel<Memo>> updateMemo(
        @PathVariable Long id,
        @RequestBody MemoRequest request) {

    Memo memo = memos.get(id);
    if (memo == null) return ResponseEntity.notFound().build();

    memo.setContent(request.getContent());
    memo.setUpdatedAt(LocalDateTime.now());

    EntityModel<Memo> resource = EntityModel.of(memo);
    resource.add(WebMvcLinkBuilder.linkTo(
            WebMvcLinkBuilder.methodOn(MemoController.class).getMemo(id)
    ).withSelfRel());
    resource.add(WebMvcLinkBuilder.linkTo(
            WebMvcLinkBuilder.methodOn(MemoController.class).deleteMemo(id)
    ).withRel("delete"));

    return ResponseEntity.ok(resource);
}

DELETE

@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteMemo(@PathVariable Long id) {
    Memo removed = memos.remove(id);
    if (removed == null) return ResponseEntity.notFound().build();
    return ResponseEntity.noContent().build();
}

코드가 너무 많아서 AI 도움을 받아서 정확한 부분은 아직 검증은 못했다.
하지만 다음 메서드를 통해 보일러플레이트 코드를 조금 줄일 수 있을 것이다.

private EntityModel<Memo> wrapping(Memo memo) {
    EntityModel<Memo> resource = EntityModel.of(memo);

    var memoCtrl = WebMvcLinkBuilder.methodOn(MemoController.class);

    resource.add(WebMvcLinkBuilder.linkTo(memoCtrl.getMemo(memo.id())).withSelfRel());
    resource.add(WebMvcLinkBuilder.linkTo(memoCtrl.updateMemo(memo.id(), null)).withSelfRel());
    resource.add(WebMvcLinkBuilder.linkTo(memoCtrl.deleteMemo(memo.id())).withSelfRel());
    resource.add(WebMvcLinkBuilder.linkTo(memoCtrl.getMemos()).withSelfRel());
    return resource;
}

HTTP 헤더

헤더에는 다양한 값을 넣을 수 있는데:

  • Cache-Control: 클라이언트 / 중간 프록시가 캐싱할 수 있는지, 얼마나 오래 캐시할지 지정
  • ETag: 리소스의 고유 식별자(보통 해시값), 변경 시 새 값으로 갱신
  • Last-Modified: 리소스 최종 수정 시각
  • Expires: 캐시 만료 기간
Cache 관련 헤더

HTTP는 리소스를 효율적으로 전송하기 위해 캐싱(Caching) 메커니즘을 제공한다. 이를 제어하는 핵심 수단은 HTTP 헤더(Header) 이며, 서버와 클라이언트가 리소스의 유효성, 만료 시점, 재검증 정책 등을 협의할 수 있다.

Cache-Control

캐시 정책을 가장 세밀하게 제어할 수 있는 핵심 헤더로써, HTTP/1.1 이후의 모든 캐싱 로직은 이 헤더를 기준으로 동작한다.

Cache-Control: , =, ...

디렉티브라는게 있는데, 디렉티브에는 다음 내용들이 들어갈 수 있다.

캐싱 가능 여부

  • no-store: 요청/응답 모두 캐시에 저장하지 않음
  • no-cache: 저장은 가능하지만, 재사용 전 서버 검증이 필요함 (ETag / Last-Modified)
  • public: 모든 캐시(공유 캐시 포함) 에서 저장 및 재사용 가능
  • private: 사용자 전용 캐시(브라우저 등) 에서만 저장 가능

유효 기간

  • max-age=초: 캐시된 리소스의 유효시간(초 단위)
  • s-maxage=초: 공유 캐시(프록시, CDN 등)에서의 유효 시간
  • max-stale=초: 만료된 캐시라도 지정된 시간 안이면 사용 허용
  • min-fresh=초: 앞으로 최소 초 이내에 새로고침해야 사용 가능

재검증 정책

  • must-revalidate: 만료 후 반드시 원서버에 검증 후 사용
  • proxy-revalidate: 중간 프록시 캐시만 재검증 강제

기타

  • immutable: 리소스가 절대 바뀌지 않음을 명시(브라우저 재검증 생략 가능)
  • stale-while-revalidate=초: 만료 후에도 지정 시간 동안 캐시 사용하며 백그라운드 갱신
  • stale-if-error=초: 서버 오류 발생 시, 지정 시간 내 캐시 사용 허용

  • Last-Modified: 서버 -> 클라이언트 로의 리소스 최종 수정 시각 제공
  • If-Modified-Since: 클라이언트 -> 서버 로의 이전 응답의 수정 시각을 보냄(서버가 비교 후 반환)

ETag & If-None-Match

리소스의 고유 식별자(Entity Tag) 를 기반으로 더 정밀하게 캐시 재검증을 수행한다.

  • ETag: 서버 -> 클라이언트, 리소스 버전 식별자(보통 해시값)
  • If-None-Match: 클라이언트 -> 서버, 이전에 받은 ETag 를 전달, 일치 시 304 응답
    • 만약 다르면 새 콘텐츠와 함께 200 OK + 새로운 ETag 반환

Vary

캐시 구분 기준을 지정하는 헤더이며, 같은 URL 이라도 요청 헤더 값에 따라 다른 응답을 캐시해야 할 때 사용한다.

Vary: Accept-Encoding, User-Agent

하지만 캐시는 터미널 자체에서는 없고 캐시 스토리지 기능이 있는 유저에게만 캐싱이 가능하다.

  • 웹 브라우저
  • CDN(Cloudflare, Akamai, etc.): 서버 응답 헤더를 기준으로 전 세계 캐시 서버에 저장
  • Reverse Proxy(nginx, Varnish): 서버 앞단에서 HTTP 캐시를 수행
  • API Gateway / Load Balancer: 응답 헤더 기반으로 캐싱 가능 (AWS API, Gateway, Nginx reverse cache)
  • HTTP 클라이언트 (Postman 등): 자체 캐시가 없거나 비활성화됨(직접 구현해야 함)

다음 장에서 이를 더 자세히 다루자.