📂 목차
📚 본문
Rest API
웹 서비스의 한 형태로 Representational State Transfer (REST) 아키텍처 스타일을 따르도록 구성하는 API 를 써야 한다.
REST의 주요 원칙
- 자원(Resource) 기반 설계
- RESTful 서비스에서 모든 콘텐츠는 자원(Resource) 으로 표현된다.
- 각 자원은 URI(Uniform Resource Identifier) 로 식별된다.
- 예시:
/api/users/1→ id가 1인 사용자 자원
- 무상태(Stateless)
- 각 요청(Request)은 서버와 독립적으로 처리된다.
- 서버는 클라이언트의 상태를 저장하지 않으며, 모든 요청은 필요한 정보를 스스로 포함해야 한다.
- 이를 통해 서버 설계가 단순해지고, 확장성(Scalability) 이 높아진다.
- 표준 HTTP 메서드 활용
- 자원에 대한 CRUD 동작을 HTTP 메서드로 명확히 표현한다.
GET: 조회(Read)POST: 생성(Create), 멱등성 성질 XPUT: 수정(Update)PATCH: 부분 수정DELETE: 삭제(Delete)
- 이를 통해 API가 직관적이고 일관성 있게 설계된다.
- 자원에 대한 CRUD 동작을 HTTP 메서드로 명확히 표현한다.
멱등성은 동일 요청을 보내면 서버의 상태를 동일하게 유지하냐이다.
- 다양한 표현(Representation)
- 자원은 JSON, XML 등 다양한 형태로 표현될 수 있다.
- 클라이언트의
Accept헤더에 따라 서버는 가장 적절한 데이터 형식으로 응답한다.
- 연결성(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
데이터를 전달할 때 위처럼 객체를 전달함을 볼 수 있는데, 우선 반환값으로는 다음을 전달할 수 있다.
StringDTOResponseEntity<?>
@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-TypeLocationAuthorization
- 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
필드
HttpStatusCodeHttpHeaders
생성자
int statusCodeHttpStatusCode 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 생성위를 토대로 ResponseEntity 는 HTTP 상태코드와 함께 ResponseEntity 를 쉽게 생성할 수 있게 된다.
ResponseEntity 응답 생성
Status 200
ok(): 상태 코드200 OK로BodyBuilder생성ok(T body): 상태 코드200 OK와body를 포함한ResponseEntity생성of(Optional<T> body):Optional값이 있으면200 OK+body, 비어있으면404 Not FoundofNullable(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 AcceptednoContent()→ 204 No ContentbadRequest()→ 400 Bad RequestnotFound()→ 404 Not FoundunprocessableEntity()→ 422 Unprocessable EntityinternalServerError()→ 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() 함수로 간단히 세팅할 수 있다. 상태코드만 세팅 되기 때문에 body 와 header 는 자유롭게 넣어주어야 한다.
@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 등): 자체 캐시가 없거나 비활성화됨(직접 구현해야 함)
다음 장에서 이를 더 자세히 다루자.