티스토리 뷰
지금까지 자칭 'REST API'라는 것들을 구현해왔지만 REST API가 정확히 뭐야? 라고 질문한다면 제대로 답하지 못할 것이다. 그러던 와중 그런 REST API로 괜찮은가 라는 컨퍼런스 영상을 보게 되었다. 내가 지금까지 만들어온 API가 REST하지 않았다는 것을 깨닫게 되었다.
REST API의 제약조건에는 다음 6가지가 있다. API가 HTTP 프로토콜을 따른다면 대부분의 제약조건을 만족시키지만 문제는 굵은 글씨로 표시한 uniform interface 라고 한다.
- client-server
- stateless
- cache
- uniform interface
- layered system
- code-on-demand (optional)
uniform interface 또한 하나의 아키텍처 스타일로 다음의 4가지 제약조건으로 구성된다. 여기서 오늘날 대부분의 API가 지키지 않는 조건은 self-descriptive messages와 HATEOAS이다.
- identification of resources
- manipulation of resources through representations
- self-descriptive messages
- hypermedia as the engine of application state(HATEOAS)
여기서 self-descriptive messages는 "메시지는 스스로 설명해야 한다"는 것을 의미한다. 즉 응답 메시지만으로, 전달된 JSON 혹은 XML 데이터를 해석할 수 있어야 한다는 것이다. 즉 다음과 같은 JSON 데이터가 있을 때 Content-Type인 application/json-patch+json 명세를 통해 JSON 데이터를 이해할 수 있기 때문에(op
, path
각 필드는 무엇을 의미하는지 이해할 수 있음) 이 API는 self-descriptive messages를 만족한다고 할 수 있다.
HTTP/1.1 200 OK Content-Type: application/json-patch+json { "op": "remove", "path": "/a/b/c" }
다음으로 HATEOAS는 "애플리케이션의 상태는 Hyperlink를 이용해 전이되어야 한다"는 것을 의미한다. 기존 HTML과 같은 경우에는 a 태그를 통해 HATEOAS 조건을 아주 쉽게 만족시킬 수 있었던 반면, API에는 a 태그와 같은 역할을 하는 필드 등이 따로 정의되어 있지 않아 우리가 구현한 API는 대부분 HATEOAS를 만족하지 않는다. 따라서 어플리케이션 상태 전이를 위한 링크를 응답으로 클라이언트에게 전달해주면 해당 API는 HATEOAS를 만족하게 되고, 클라이언트와 서버는 보다 동적인 상호작용이 가능해진다.
이를 구현하기 위한 방법에는 JSON으로 하이퍼링크를 표현하는 방법을 정의한 명세를 활용하거나 (예를 들어 Content-Type을 application/vnd.api+json로 설정 후 JSON 데이터에 links
필드 추가), Link 또는 Location 등의 헤더로 링크를 표현하는 방법이 있다.
이렇게 REST API가 uniform interface를 만족한다면(완벽한 REST API 라면), 서버와 클라이언트가 각각 독립적으로 진화할 수 있게 된다. 즉 다른 리소스에 대한 URI를 하이퍼링크를 통해 응답으로 제공하거나, 응답 데이터를 현재 메시지만으로 해석 가능하다면, 새로운 API가 추가되거나 기존 API가 변경 혹은 삭제되더라도 클라이언트 코드는 업데이트할 필요가 없게 되는 것이다.
누군가는 앱에서는 애초에 완벽한 REST API란 불가능하다 등등 많은 의견이 있겠지만, 내가 여기서 주목한 것은 REST API의 HATEOAS 규약이다. 게시글 생성 API의 응답 값에 생성된 게시글 조회 API, 게시글 목록 조회 API 링크를 포함하는 것과 같이 모든 API마다 관련된 다른 API로의 전이를 위한 링크를 응답 값에 포함하는 것은 조금 오바이지 않나? 라는 생각이 처음에는 들었다. 그러나 페이징에서만큼은 API가 이 HATEOAS를 만족한다면 클라이언트 쪽 구현이 좀더 유연해질 것 같았다.
REST Pagination 적용 이유
조회되는 데이터셋이 매우 매우 많은 경우, 서버는 전체 데이터 리스트를 모두 조회하도록 하기 보다는 페이징 처리하여 페이지 단위로 조회할 수 있도록 한다. 이 경우 기존에는 Repository 계층에서 반환되는 Page
객체를 이해하기 쉽게 변환한 커스텀 PageResponse
를 응답으로 반환였다. 따라서 클라이언트는 응답으로 전달된 pageNumber
, totalPages
, first
등의 값을 이용해 다음 페이지 요청 URI, 마지막 페이지 요청 URI 등을 직접 유추해내야 했다.
@Getter @Builder @AllArgsConstructor public class PageResponse { private final Integer pageNumber; private final Integer pageSize; private final Integer numberOfElements; private final Integer totalPages; private final Long totalElements; private final Boolean first; private final Boolean last; private final Boolean empty; public PageResponse(Page<?> page) { this.pageNumber = page.getPageable().getPageNumber() + 1; //페이지 번호는 1부터 시작 this.pageSize = page.getPageable().getPageSize(); this.numberOfElements = page.getNumberOfElements(); this.totalPages = page.getTotalPages(); this.totalElements = page.getTotalElements(); this.first = page.isFirst(); this.last = page.isLast(); this.empty = page.isEmpty(); } }
그러나 위의 강연을 듣고 나서 생각해보니, PageResponse
뿐만 아니라 첫 페이지, 이전 페이지, 다음 페이지, 마지막 페이지에 대한 링크 또한 응답 값으로 전달하면 좀더 REST한 API가 되지 않을까? 라는 생각에서 REST한 Pagination을 구현해보기로 마음 먹었다.
현재 프로젝트에서 페이징이 적용된 API는 요청 쿼리 파라미터로 조회 기간, 페이지 인덱스, 페이지 크기 등을 전달하여 검색된 지출 리스트를 조회한다. 참고로 해당 리스트 조회 결과는 지출일을 기준으로 조회 리스트가 구분되어져 있다. 만약 아래와 같이 응답 데이터가 전달된다면 클라이언트는 paging
필드의 값을 보고 이전 페이지, 다음 페이지 등에 대한 요청 URI를 직접 생성할 것이다. 즉 아래의 응답 데이터만으로는 다음 API로의 상태 변화가 불가능하다. (응답에 하이퍼링크가 포함되어 있지 않기 때문)
기존 페이징 응답 데이터
{ "timestamp": "2024-01-07 15:04:35", "status": 200, "code": "OK", "message": "요청이 정상 처리되었습니다.", "data": { "expenditureListByDate": [ { "expenditureDate": "2023-11-17", "expenditureList": [ { "expenditureId": 3, "amount": 5000, "categoryId": 2, "type": "TRAFFIC", "title": "하루 교통비" }, { "expenditureId": 2, "amount": 4000, "categoryId": 1, "type": "FOOD", "title": "편의점 점심" } ] }, { "expenditureDate": "2023-11-15", "expenditureList": [ { "expenditureId": 1, "amount": 21000, "categoryId": 1, "type": "FOOD", "title": "점심 커피챗" } ] } ], "paging": { "pageNumber": 3, "pageSize": 3, "numberOfElements": 3, "totalPages": 5, "totalElements": 15, "first": false, "last": false, "empty": false } } }
REST Pagination 구현
Spring HATEOAS 사용하지 않는 이유
Spring은 REST API의 HATEOAS 규약을 만족하기 위한 Spring HATEOAS 라이브러리를 제공한다. 여기서 제공되는 PagedModel
을 통해 페이징 관련 링크들을 자동 생성하여 응답 바디로 전달해줄 수 있다.
implementation 'org.springframework.boot:spring-boot-starter-hateoas'
Spring HATEOAS를 사용한 경우 응답 데이터 예시
{ "timestamp": "2024-01-07 15:04:35", "status": 200, "code": "OK", "message": "요청이 정상 처리되었습니다.", "links": [ { "rel": "first", "href": "http://localhost:8080/api/expenditures?page=1&size=3" }, { "rel": "prev", "href": "http://localhost:8080/api/expenditures?page=2&size=3" }, { "rel": "next", "href": "http://localhost:8080/api/expenditures?page=4&size=3" }, { "rel": "last", "href": "http://localhost:8080/api/expenditures?page=5&size=3" } ], "data": { "expenditureListByDate": [ { "expenditureDate": "2023-11-17", "expenditureList": [ { "expenditureId": 3, "amount": 5000, "categoryId": 2, "type": "TRAFFIC", "title": "하루 교통비" }, { "expenditureId": 2, "amount": 4000, "categoryId": 1, "type": "FOOD", "title": "편의점 점심" } ] }, { "expenditureDate": "2023-11-15", "expenditureList": [ { "expenditureId": 1, "amount": 21000, "categoryId": 1, "type": "FOOD", "title": "점심 커피챗" } ] } ], "paging": { "pageNumber": 3, "pageSize": 3, "numberOfElements": 3, "totalPages": 5, "totalElements": 15, "first": false, "last": false, "empty": false } } }
그러나 이 경우 Controller의 구현 복잡성이 높아질 뿐만 아니라, 무엇보다 응답 바디에 links
필드가 추가됨으로써 응답 데이터가 복잡해진다는 것이다. (별거 아닐 수 있지만 추후 페이징 링크를 응답으로 전달해주지 않기로 결정한다면, 응답 바디에서 삭제하는 것 보다는 응답 헤더에서 삭제하는 것이 더 간단하지 않을까 생각했다. 반대로 기존 API에 대해 페이징 링크를 응답 바디에 추가하려고 한다면 기존 API를 많이 고쳐야 한다.) 따라서 Spring HATEOAS를 사용하지 않고 이를 응답의 Link 헤더로 직접 설정하여 구현하려 한다.
Link 헤더 사용
이번에 처음 알았지만 HTTP 표준에는 Link 헤더가 있다. (HTML의 <link/> 태그와 같은 용도) 실제 GitHub API에서도 REST Pagination을 위해 응답 바디 대신 Link 헤더를 사용한다. 당연히 페이징 관련 링크를 응답 바디로 전달해줄 때보다 응답 헤더를 사용하면 좀더 깔끔하게 처리가 가능해진다.
Link 엔티티 헤더 필드는 두 리소스 간의 관계, 즉 현재 요청된 리소스와 다른 리소스 간의 관계를 설명하는 수단을 제공한다. 엔티티에는 여러 개의 링크 값이 포함될 수 있으며 명세된 문법은 다음과 같다.
Link: <uri>; param1="value1"; param2="value2"
우리가 만들 Link 헤더의 값은 다음과 같다. rel
속성에는 first
, prev
, next
, last
중 하나가 가능하며, 각각 앞의 URI 값이 첫 페이지 링크, 이전 페이지 링크, 다음 페이지 링크, 마지막 페이지 링크임을 나타낸다.
Link: <http://localhost:8080/api/expenditures?page=1&size=3>; rel="first",<http://localhost:8080/api/expenditures?page=2&size=3>; rel="prev",<http://localhost:8080/api/expenditures?page=4&size=3>; rel="next",<http://localhost:8080/api/expenditures?page=5&size=3>; rel="last"
ResponseBodyAdvice 이용한 구현
Controller 계층에서 메소드의 파라미터로 HttpServletResponse
를 주입받아 직접 응답 헤더를 설정해줄 수도 있지만, 페이징 링크를 응답 헤더로 설정하는 것은 부가 로직에 가깝기 때문에 AOP를 통해 처리하는 것이 더 적절하다고 판단하였다. 여기에는 Controller 메소드 호출 후 HttpMessageConverter
에 의해 데이터가 응답 바디에 쓰여지기 전 호출되어 응답값을 조작할 수 있도록 도와주는 ResponseBodyAdvice
인터페이스를 사용할 것이다. 이는 앞선 글에서 CommonResponse
즉 공통 응답 형식을 처리하기 위해 사용했던 인터페이스이기도 하다.
현재 프로젝트에서는 페이징 처리에 따른 Page
객체가 Repository에서만 반환되고 Controller, Service 계층에서는 각각 별도의 DTO로 변환되어 반환된다. 따라서 이 Repository에서 반환된 Page
객체를 별도의 공간에 저장해두고 ResponseBodyAdvice
구현체에서 저장된 Page
객체를 가져와 응답 헤더에 설정하도록 구현할 것이다.
@RequiredArgsConstructor @Aspect @Component public class PaginationAdvice { private final PageStore pageStore; @AfterReturning(value = "execution(org.springframework.data.domain.Page com.wanted.safewallet.domain..repository..*.*(..))", returning = "page") public void processPagination(Page<?> page) { pageStore.setPage(page); //ThreadLocal에 저장 } }
위 코드는 @Aspect
어노테이션을 이용해 구현한 어드바이저로, 정의된 포인트컷에 따르면 해당 어드바이스는 모든 repository 하위 패키지 내 모든 클래스의 메소드들 중 Page
객체를 반환하는 메소드에 대해 동작한다. 즉 Repository 메소드 중 Page
객체를 반환한다면 PageStore
라는 곳에 반환된 Page
객체를 잠시 저장해둔다.
@Component public class PageStore { private final ThreadLocal<Page<?>> page = new ThreadLocal<>(); public void setPage(Page<?> page) { this.page.set(page); } public Page<?> getPage() { Page<?> storedPage = this.page.get(); this.page.remove(); return storedPage; } }
멀티 쓰레드에서도 안전하게 동작하기 위해 Repository 메소드에서 반환된 Page
객체는 쓰레드 별로 관리되는 ThreadLocal에 저장하도록 하였다. 아래의 ResponseBodyAdvice
구현체에서도 이 저장 공간에 접근하기 위해 PageStore
는 @Component
어노테이션을 통해 빈으로 등록해주었다. 또한 스프링은 쓰레드 풀(Thread Pool)을 기반으로 쓰레드를 재활용하여 사용하기 때문에 ThreadLocal에 저장해둔 Page
객체를 조회한 후에는 remove()
메소드를 호출하여 ThreadLocal을 비워주도록 하였다.
@RequiredArgsConstructor @RestControllerAdvice public class PaginationLinkHeaderAdvice implements ResponseBodyAdvice<Object> { private final PageStore pageStore; @Value("${spring.data.web.pageable.one-indexed-parameters}") private boolean oneIndexedPage; private static final String PAGE_PARAM_NAME = "page"; private static final String SIZE_PARAM_NAME = "size"; @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { return Arrays.stream(returnType.getExecutable().getParameterTypes()) .anyMatch(parameterType -> parameterType.isAssignableFrom(Pageable.class)); } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { Page<?> page = pageStore.getPage(); response.getHeaders().add(HttpHeaders.LINK, getPageLinks(page)); return body; } private String getPageLinks(Page<?> page) { if (page == null) return ""; int pageNumber = page.getNumber() + (oneIndexedPage ? 1 : 0); List<String> links = new ArrayList<>(); if (!page.isFirst()) { final String firstPageLink = getPageLink(oneIndexedPage ? 1 : 0, page.getSize()); links.add("<" + firstPageLink + ">; rel=\"first\""); } if (page.hasPrevious()) { final String prevPageLink = getPageLink(pageNumber - 1, page.getSize()); links.add("<" + prevPageLink + ">; rel=\"prev\""); } if (page.hasNext()) { final String nextPageLink = getPageLink(pageNumber + 1, page.getSize()); links.add("<" + nextPageLink + ">; rel=\"next\""); } if (!page.isLast()) { final String lastPageLink = getPageLink(page.getTotalPages() - (oneIndexedPage ? 0 : 1), page.getSize()); links.add("<" + lastPageLink + ">; rel=\"last\""); } return String.join(",", links); } private String getPageLink(int page, int size) { return ServletUriComponentsBuilder.fromCurrentRequest() .replaceQueryParam(PAGE_PARAM_NAME, page) .replaceQueryParam(SIZE_PARAM_NAME, size) .build().encode().toUriString(); } }
supports
메소드에서는 Controller 메소드들 중 파라미터로 Pageable
객체가 전달되는, 즉 페이징 요청에 대해서만 PaginationLinkHeaderAdvice
내 beforeBodyWrite
메소드가 호출되도록 boolean
값을 반환하고 있다. beforeBodyWrite
메소드에서는 앞서 PaginationAdvice
에서 PageStore
에 임시 저장해둔 Page
객체를 조회해 Link 헤더를 위한 값을 생성한다. 각각 첫 페이지 링크, 이전 페이지 링크, 다음 페이지 링크, 마지막 페이지 링크를 생성하고 ,(콤마)로 연결하여 응답의 Link 헤더로 설정해준다.
실제 페이징 요청에 대한 응답 Link 헤더

문제점
현재 프로젝트에서는 Spring RestDocs를 이용해 테스트 기반 문서화를 하고 있다. 즉 Controller 테스트를 필수로 작성해야 하는데, 여기서 사용하는 @WebMvcTest
어노테이션은 컨트롤러와 관련된 @Controller
, @ControllerAdvice
, @JsonComponent
, Converter
등을 알아서 빈으로 등록해준다. 여기서 문제는 위에서 정의한 PaginationLinkHeaderAdvice
도 빈으로 등록된다는 것인데, 이 클래스는 PageStore
클래스에 대한 의존성을 가지고 있다. 여기서 PageStore
는 컨트롤러와 관련된 클래스가 아니기 때문에 자동 빈 등록되지 않는다. 즉 @Import(PageStore.class)
어노테이션을 통해 직접 빈으로 등록해주어야 한다. 이렇게 되면 모든 -ControllerTest
마다 @Import(PageStore.class)
를 추가로 선언해줘야 했다. 그렇지 않으면 NoSuchBeanDefinitionException
예외가 미친듯이 발생하였다.
생각해보면 만약 Controller에서 Page
객체를 반환한다면 굳이 Repository에서 반환하는 Page
객체를 ThreadLocal에 저장할 필요는 없을 텐데, 현재 프로젝트에서 Controller는 Page
객체가 아닌 별도의 응답 DTO를 반환하고 있어 어쩔 수 없이 Page
객체를 ThreadLocal에 저장할 수 밖에 없었고 이를 위해 PageStore
는 싱글톤으로 스프링 컨테이너에서 관리되도록 하였다. 지금 당장은 더 좋은 방법이 떠오르진 않지만 있다면 댓글로 알려주시라...
정리
사실 페이징 관련 정보는 JSON 응답 데이터만으로 전달하는 것으로도 충분하다고 생각한다. 그러나 REST API에 더욱 부합하는 API를 구현해보고 싶었고, 페이징 관련 응답을 좀더 클라이언트 친화적이게? 전달하고 싶었다.
다만 의문이 드는 것은 Link 헤더를 전달 받은 클라이언트에서는 이걸 어떻게 파싱하지?의 문제이다. 사실 이 링크도 그냥 응답 바디에 포함하여 전달했다면 클라이언트 입장에서 이 링크를 사용하기가 훨씬 수월했을 텐데, Link 헤더로 전달하게 되면 이걸 따로 파싱해야 하지 않나? 라는 생각이 든다.(물론 Link 헤더에 대한 HTTP 명세가 있긴 하지만...) 아무튼 Pagination을 좀더 REST 하게 구현해본 경험에 만족한다. 추후 Spring HATEOAS를 통해 HATEOAS 규약을 만족하는 API를 구현해보고자 한다.
참고
https://www.matchilling.com/restful-pagination-in-spring-using-link-header/
https://www.w3.org/Protocols/9707-link-header.html
https://blog.hongminhee.org/2012/05/27/http-link-header/
https://joomn11.tistory.com/26
https://howtodoinjava.com/spring/hateoas-pagination-links/
'Spring > Spring' 카테고리의 다른 글
[Spring] 스프링에서의 SSE 구현 (with SseEmitter) (1) | 2024.09.09 |
---|---|
[Spring] AOP로 공통 응답 형식 처리하기 (0) | 2024.01.14 |
[Spring Boot] Controller - Service 계층 리팩토링 with Facade 패턴 (0) | 2023.12.21 |
[Spring Boot] 날짜 데이터를 요청 및 응답으로 주고 받는 여러 가지 방법 (0) | 2023.11.23 |
[Querydsl] Spring Boot 3.x with Gradle 설정 (1) | 2023.10.20 |
- Total
- Today
- Yesterday
- ParameterizedTest
- 템플릿 콜백 패턴
- Java
- JPA
- Transaction
- 디자인 패턴
- Front Controller
- spring
- mockito
- servlet filter
- spring aop
- SSE
- FrontController
- Gitflow
- 모두의 리눅스
- github
- junit5
- C++
- spring boot
- rest api
- 전략 패턴
- QueryDSL
- Linux
- vscode
- 단위 테스트
- Spring Security
- facade 패턴
- 서블릿 컨테이너
- Assertions
- Git
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |