[1부] 넌 전혀 RESTful한 API를 설계하고 있지 않아

황제연·2024년 12월 30일
post-thumbnail

서론

백엔드에서 개발하는 서버는 REST API서버입니다.
FE(클라이언트)에서 요청하는 데이터를 BE(서버)에서 응답하는 형태의 서버입니다.

과거 몇번의 프로젝트를 하며, JSON으로 응답하는 REST API서버를 개발했지만
과연 내가 제대로 된 REST API서버를 만들고 있는것인가? 라는 의문을 가졌습니다.

이번 프로젝트를 정리하면서 내 방식은 전혀 RESTful하지 않다는 것을 깨달았고,
표준에 맞는 RESTful API서버로 재설계하면서 학습한 부분을 정리했습니다

REST API 디자인 - URI 설계

REST API 설계 시 가장 중요한 항목은 다음 2가지입니다
1. URI는 정보의 자원을 표현한다
2. 자원의 행위는 HTTP Method로 표현한다

URI는 정보의 자원을 표현한다

URI는 정보의 자원을 표현합니다. 즉, URI는 정보의 행위를 표현하지 않습니다.

@PostMapping("admin/contest/create")

프로젝트에서 사용했던 URI입니다.
create라는 정보의 행위, 즉 동사를 사용했습니다.

오류를 바로 잡기 위해 다음과 같이 수정했습니다

@PostMapping("/contests")

이제 contests(='행사들')라는 정보의 자원만을 표시합니다!

자원의 행위는 HTTP Method로 표현한다

자원의 행위는 HTTP Method(GET, POST, PUT, PATCH, DELETE)로 표현합니다.
URI는 정보의 자원을 표현하며, 정보의 행위를 표현하지 않습니다,

@DeleteMapping("admin/contest/{contest}/delete")

프로젝트 개발당시 사용했던 URI입니다. delete라는 상태를 URI에 표현했습니다.

@DeleteMapping("/contests/{contest}")

이제 HTTP Method로만 자원의 행위를 표현합니다

기타 Rules

그 외에도 일반적으로 다음과 같은 규칙을 따릅니다.
1. 마지막에 /를 포함하지 않는다
2. _(underbar) 대신 -(dash)를 사용한다
3. 소문자를 사용한다

해당 규칙들은 프로젝트 개발당시 준수하고 있었습니다!

자원의 관계 표현

자원의 연관관계가 존재할 수 있습니다.
만약 has(소유)의 개념을 갖고 있다면, A has B 형태로 표현할 수 있습니다

@GetMapping("/contests/{contest}/favorites")

위와 같이 Contest has favorites 형태로 두 자원의 관계를 표현할 수 있습니다!

자원의 집합 표현

Collections와 Document개념을 활용해서 자원의 집합을 표현합니다

  • Collections: 객체들의 집합을 의미한다
  • Document: 한 객체를 의미한다
    Collection은 여러 Document를 포함합니다.
@PostMapping("/contests/{contest}/event")

위 URI의 Collection은 Contest고 Document는 event입니다!

@GetMapping("/contests/{contest}/favorites")

위와 같은 URI는 Contest와 favorites라는 두 Collection을 가지고 있습니다
해석하면 favorites Collection의 모든 Document를 조회한다는 의미입니다.

정리하면 자원의 집합을 작성할 때 Collection과 Documnet를 구분짓고,
Collection은 복수형, Documetn는 단수형으로 작성합니다

REST API 디자인 - HTTP 상태 코드

이어서 HTTP 상태코드에 대해 정리했습니다
RESTful한 API는 리소스의 접근만 잘 설계하는 것이 아니라 응답도 잘 표현해아합니다.

과거의 HTTP 상태 코드

프로젝트의 HTTP 응답 상태코드는 다음에 한정되었습니다

  • 200
  • 201
  • 403
  • 404
    사용하는 상태코드 유형도 적고, 상황에 맞지 않는 HTTP 상태코드도 사용하고 있었습니다.

한정된 상태코드를 사용하는 것은 그 의미를 제대로 전달할 수 없습니다
따라서 이것은 RESTful 한 API라고 할 수 없습니다.

개선된 HTTP 상태 코드

상황에 맞는 상태코드를 사용해서 그 의미를 제대로 전달하기 위해
프로젝트의 응답 상태코드를 재설계했습니다

개선된 이후 사용하는 HTTP 응답 상태코드는 다음과 같습니다

2xx

응답의 성공을 의미하는 상태코드입니다.

200

보통 READ 요청의 응답으로 사용합니다.

return ResponseEntity.ok(adminPageService.readDrawMemberList(contestId));

Body에 요청한 데이터를 포함해서 전달합니다

201

CREATE 요청의 응답으로 사용합니다.
생성된 자원을 URI를 제공하는 방법도 있고, Body에 담아서 제공하는 방법도 있습니다

return ResponseEntity.created(URI.create(""))  
        .body(entryCRUDService.createEntry(contestId, entryRequestDto));

프로젝트에서는 Body에 담아서 제공했습니다

202

일반적인 작업은 성공/실패를 응답하지만,
202 상태코드는 요청은 잘 받았으나 아직 서버가 작업을 완료하지 않아 나중에 알려주겠다는 의미입니다

보통 비동기 작업에서 사용하는 방식이며,
클라이언트에게 작업의 상태를 확인하는 방법을 제공해야합니다

return ResponseEntity.accepted().build();
return ResponseEntity.accepted()  
        .body(favoriteCreateResponseDto);

인증 이메일 발송과 FCM TOPIC 구독과정의 응답으로 위와같이
202 상태코드를 전달하도록 개선했습니다

상태코드를 확인할 수 있는 별도의 URI가 필요합니다
해당 내용은 2부에서 다루겠습니다!

204

리소스의 삭제 즉, DELETE요청에 응답하는 방법입니다.
요청도 유효하고 서버가 해당 리소스를 삭제했으며 더 이상 응답할 리소스가 없다는 의미입니다.

return Api.DELETE("삭제완료");

과거의 프로젝트에서 DELETE 요청에 대한 응답방식은 위와 같았습니다.
OK(200)으로 응답하고, Body에 text/plain 형태의 "삭제완료" 문자열을 담아 응답해습니다.

ResponseEntity.noContent().build()

개선한 코드는 위와같이 body없이 no-content(204)로 응답합니다

4xx

클라이언트의 잘못으로 발생하는 상태코드입니다.

400

사용자의 입력 형식에 오류가 존재할 때 사용합니다

EMAIL_LENGTH_TOO_LONG(HttpStatus.BAD_REQUEST, "이메일 ID 길이가 초과되었습니다. 확인해주세요"),

위와같이 지정된 길이를 초과하거나 타입에 맞지 않는 입력 등
약속한 형식을 벗어나는 경우 해당 예외를 발생시킵니다

401

해당 상태코드는 주로 인증에 대한 내용입니다

TEMPORARY_NOT_VALID_CODE(HttpStatus.UNAUTHORIZED, "인증된 인증코드가 아닙니다."),

위 코드와 같이 로그인/회원가입에 필요한 이메일 인증코드가 틀린 경우
401 상태코드를 사용합니다

403

401 상태코드는 인증과 관련된 내용이라면, 403은 권한과 관련된 내용입니다.

ROLE_ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 없는 사용자입니다.")

프로젝트에서 회원은 크게 3가지 역할로 분류됩니다.
이때 각 역할이 접근할 수 있는 리소스의 범위는 제한됩니다

만약 STUDENT 역할이 ADMIN이상만 볼 수 있는 리소스에 접근할 때,
403 상태코드를 사용합니다

404

요청한 자원이 존재하지 않을 때 사용합니다

MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "멤버정보를 찾을 수 없음"),

위와같이 Member가 없으면 예외가 발생하고, 404 상태코드로 응답합니다.

405

@PostMapping과 같은 애노테이션을 지정해두면 자동으로 설정됩니다.
만약 해당 애노테이션이 적용된 URI로 다른 응답 방식으로 요청하면
405 상태코드로 응답합니다.

409

클라이언트의 요청과 서버의 상태가 충돌했다는 의미인데...

FAVORITE_EXISTS(HttpStatus.CONFLICT, "이미 등록된 즐겨찾기 이벤트입니다."),

충돌의 의미가 추상적이라 중복과 관련된 내용이나 내부 로직상 불가능한 경우
409 상태코드로 응답하기로 결정했습니다.

기타

5XX나 3XX 상태코드 발생할 수 있으나 별도로 지정하지 않았습니다.
3XX 상태코드는 Etag에서 사용하지만,
API 요청의 응답으로 사용할만한 상황이 존재하지 않기 때문입니다.
5XX는 서버의 문제라서 API 서버의 응답으로 클라이언트에게 전달하지 않아야합니다.
따라서 API서버의 응답으로 설정하지 않았습니다.

HATEOAS

앞선 과정들을 통해 이제 REST의 2레벨까지 도달했습니다
보통 2레벨에 머무는 경우가 많은데, REST의 레벨은 3레벨까지 있습니다

3레벨에 도달하기 위해서는 HATEOAS 원칙을 준수해야합니다

HATEOAS란?

HATEOAS(Hypermedia As The Engine Of Application State)는 REST 아키텍처의 중요한 원칙 중 하나로, 리소스에 하이퍼미디어를 도입해서 앱의 상태를 클라이언트와 서버간에 동적으로 전달할 수 있는 개념입니다.

HATEOAS 장점

HATEOAS를 활용하면 서버가 제공하는 링크의 명칭을 기반으로 설정할 수 있기 때문에
API 링크가 변경되어도 명칭만 바뀌지 않는다면, 클라이언트의 수정 작업을 줄일 수 있습니다!

또한 새로운 링크를 추가해도 클라이언트가 응답으로 온 링크를 따라가서 탐색할 수 있기 때문에 확장성이 크다는 장점이 있습니다

HATEOAS 개발

HATEOAS를 적용한 항목은 아래와 같습니다

  • QR코드 인증로직
  • Event CREATE/UPDATE
  • Entry CREATE/UPDATE
  • 비동기 로직
    비동기 로직은 2부에서 상태확인 URI를 만든 뒤 적용하겠습니다

Spring boot HATEOAS 사용설정

Spring boot는 HATEOAS를 편리하게 사용할 수 있도록 기능을 제공합니다

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

위와 같이 Gradle에 의존성을 추가하면 사용할 수 있습니다

HATEOAS 적용

List<Link> links = new ArrayList<>();  
links.add(linkTo(methodOn(StudentPageController.class)  
        .readStudentHome(token, contest.getId()))  
        .withRel("학생 Home 페이지"));  
links.add(linkTo(methodOn(StudentPageController.class)  
        .readStudentActivities(token, contest.getId()))  
        .withRel("학생 활동 페이지"));

위와 같이 Link타입의 리스트나 변수를 생성한 뒤,
Spring Context에 존재하는 개발한 컨트롤러를 이용해서 HATEOAS를 적용할 수 있습니다.

또한 Rel 설정을 통해 어떤 링크인지 설명도 추가할 수 있습니다!

HATEOAS 적용확인

QR코드 로직에 위와같이 적용된 것을 확인할 수 있습니다!

HATEOAS 한계

HATEOAS를 이번 프로젝트에서 적극적으로 활용하지는 않았습니다.
일부 안내가 필요한 기능들에 대해서 HATEOAS를 적용하였고, 나머지는 적용하지 않았습니다

HATEOAS를 적극적으로 활용하지 않은 이유는 다음과 같습니다.

추가 비용 문제

HATEOAS 생성 비용과 응답 메세지의 크기가 커져서 추가 비용이 발생합니다.
추가 비용의 발생으로 응답시간이 길어지기 때문에 HATEOAS를 적극적으로 적용하지 않았습니다

Swagger의 존재

이미 Swagger로 문서화했는데, HATEOAS와 이것을 문서화하는 HAL Explorer를 활용하는 이유과 필요성을 찾지 못했습니다.

이미 클라이언트는 Swagger 문서에 익숙하기 때문에, HATEOAS의 큰 매력을 느끼지 못했습니다.

결론

따라서 HATEOAS의 적극적인 적용은 하지 않지 않았습니다.
정말 일부분에 대해서만 적용하였고, 비동기 로직과 남은 도메인의 CREATE/UPDATE 이외에는 확장하지 않을 계획입니다.

HTTP Header 시리즈

retry-after

retry-after는 Http header에서 특별하게 설정할 수 있는 정보로
사용자의 재시도 요청까지 대기하는 시간을 의미합니다

retry-after는 언제 사용할까?

Retry-after를 사용하는 상황은 다음과 같습니다

  • 클라이언트의 요청을 차단할 때 (429)
  • 서버의 과부하 혹은 유지보수 작업때문에 일시적으로 중단하는 경우 (503)
  • 리소스 전달 지연 (202)

클라이언트의 요청을 차단할 때(429)

서버는 항상 위험에 노출되어 있습니다. 만약 클라이언트 요청의 한도를 정하지 않으면
Dos공격이나 Brute-force 공격과 같이 비정상적인 공격을 방어할 수 없습니다.

이때 적용되는 개념이 Rate Limiting입니다.
해당 개념을 적용해서 요청의 한도를 초과할 경우,
429 상태코드와 함께 Retry-after속성을 헤더에 포함하고 서버의 자원을 방어할 수 있습니다.

Rate Limting 개념은 서킷 브레이커와 함께 운영환경 장애 대응전략을 정리할 때
자세히 다루겠습니다.

서버의 과부하/유지보수 작업때문에 일시적으로 중단하는 경우 (503)

서버의 과부하가 발생하거나 유지보수를 위해 일시적으로 서버를 중단해야할 경우,
503 상태코드와 함께 Retry-after속성을 헤더에 포함할 수 있습니다.

현재 서버에서는 해당 기능에 대해 개발하지 않았기 때문에,
간단하게 활용상황만 정리했습니다.

리소스 전달 지연 (202)

비동기 작업을 처리하는 경우 202 상태코드를 전달합니다.
이때, Retry-after와 Location 속성을 추가해서 polling 대상과 주기를 명시하면
클라이언트의 Polling 주기를 서버 중심으로 관리할 수 있을 것입니다!

해당 내용은 2부에서 HATEOAS 적용과 함께 정리하겠습니다.

Etag와 Cache-control

크기가 큰 응답에 대해 매번 같은 요청을 한다면,
매번 느린 응답시간과 많은 네트워크 비용이 발생합니다.

이 문제를 해결하기 위해 HTTP의 헤더 설정을 통해 캐싱 전략을 사용할 수 있습니다

Etag

Etag는 특정 버전의 리소스를 식별하는 HTTP 응답헤더의 속성입니다.

동작방식

Etag는 데이터가 변경되면 그 값도 함께 변경됩니다.
이 특징을 활용해서 최신 데이터의 변경사항을 확인할 수 있습니다

먼저 처음 데이터를 요청할 때, 응답 헤더로 Etag를 받습니다
사용자는 Etag값을 받아 요청헤더 If-None-Match에 담아서 요청 데이터의 업데이트 여부를 확인합니다

만약 업데이트 되지 않았다면 304 (Not Modified) 상태코드를 반환합니다.
업데이트 되었다면 200(OK) 상태코드와 함께 새로운 Etag를 반환합니다

Etag 사용하기

Etag를 활용하면, 업데이트되지 않았으면 body나 요청 파일을 전달하지 않기 때문에
네트워크 비용을 절약할 수 있습니다!

네트워크 비용을 절약하기 위해, 프로젝트에서 Etag를 활용하기로 결정했습니다

@Configuration  
public class EtagHeaderFilter {  
  
  
    @Bean  
    public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagHeaderFilter(){  
        FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean =  
                new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());  
        filterRegistrationBean.setName("etagHeaderFilterForReadCache");  
        filterRegistrationBean.addUrlPatterns("/*");  
        filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST);  
        filterRegistrationBean.addInitParameter("cache-control", "no-cache");  
  
  
        filterRegistrationBean.setFilter(new ShallowEtagHeaderFilter() {  
            @Override  
            protected void doFilterInternal(HttpServletRequest request,  
                                            HttpServletResponse response, FilterChain filterChain)  
                    throws ServletException, IOException {  
                super.doFilterInternal(request, response, filterChain);  
  
                if (request.getMethod().equalsIgnoreCase("GET")) {  
                    response.setHeader("Cache-Control", "private, max-age=3600, must-revalidate");  
                }  
            }  
        });  
        return filterRegistrationBean;  
    }  
  
}

Spring은 Etag를 쉽게 사용할 수 있도록, ShallowEtagHeaderFilter를 제공합니다
이 필터를 구현해서 Bean으로 등록하면 원하는 형태로 활용할 수 있습니다.

기능 테스트

Etag 적용 전

Etag를 적용하기 전에는 요청을 반복해도 200 OK로 응답합니다.

Etag 적용 후


Etag를 적용한 후에는 304로 응답합니다!

응답 데이터크기 비교

Admin page의 home화면 조회 시 발생하는 데이터 크기로 비교했습니다.
1000개의 이벤트 데이터를 별도로 만들어서 테스트했습니다.

ETAG 적용 전

적용 전에는 응답데이터 크기가 278.65KB입니다.

ETAG 적용 후

적용 후에는 응답데이터 크기가 369B입니다!

크기 차이가 약 278.28KB입니다.
데이터 크기는 약 755.5배 줄였고,
데이터 전송비용이 $0.01/GB당 발생한다면 전송 한 번당 약 $0.0027를 절약합니다.

만약 응답 데이터의 크기가 더 커진다면, 그 차이가 더 커질 것이고
네트워크 비용도 더 절약할 수 있습니다!

Cache-control

해당 속성은 캐시의 생명주기 및 관리 방법을 설정하는 응답헤더입니다.
해당 속성에서 캐시 유형, 캐시 무효화, 캐시 유효시간 등 다양한 설정을 할 수 있습니다

Private

클라이언트에서 저장되는 캐시를 private 캐시라고 합니다.
public 캐시는 반대로 프록시 캐시 서버의 캐시를 의미합니다.

사용자의 웹 브라우저에서 활용되는 것을 목적으로 개발했기 때문에 private 설정을 추가했습니다

캐시 유효시간

max-age로 캐시의 최대 유효시간을 설정할 수 있습니다

max-age=3600

위와같이 지정하면 1시간동안 유효한 캐시로 설정할 수 있습니다

캐시 무효화

웹 브라우저의 경우 최적화를 위해 임의로 캐싱하며, 캐시 유효시간이 지나기전까지
서버에 다시 요청하지 않고 유지하는 문제가 발생합니다

캐시 무효화 설정을 추가하면 해당 문제를 해결할 수 있습니다

no-cache

데이터를 캐시해도 되지만 항상 서버에 검증하고 사용한다는 설정입니다
서버로부터 304 응답을 받아야 캐시에서 사용하며, 아닐 경우 서버에 요청해서 데이터를 응답받습니다

filterRegistrationBean.addInitParameter("cache-control", "no-cache"); 

최신 데이터의 요청에 대해서 초기 설정으로 no-cache를 설정했습니다

이후, Etag를 포함해서 304 응답이 아닌 경우 서버에서 데이터를 응답받습니다
304인 경우 캐시에서 데이터를 불러옵니다!

must-revalidate

캐시 만료 후 최초 조회 시, 반드시 서버에서 검증한다는 설정입니다.
만약 유효시간 내에 있다면, 캐시를 사용합니다

해당 설정은 프록시 캐시 서버를 사용할 때, 필요한 설정입니다.
만약 프록시 캐시 - API서버 간 연결이 끊길 경우,
프록시 캐시는 업데이트되기 전의 데이터를 사용자에게 전달합니다.

사용자는 업데이트 되기 전의 데이터를 확인하기 때문에, 서비스의 치명적인 문제를 일으킬 수 있습니다

must-revalidate설정은 위와같은 상황에서 504 Gateway Timeout 상태코드를 반환합니다.
따라서 업데이트 전의 데이터를 전달하는 문제도 발생하지 않고,
사용자는 문제를 확인하고 별도의 재수정 로직을 거칠 수 있습니다!

현재 프로젝트는 Nginx를 사용하는 리버스 프록시 구조로 되어있기 때문에 해당 설정을 추가했습니다

no-store

민감한 데이터가 존재하기 때문에 캐싱하면 안된다는 의미입니다

@GetMapping("/admin/events/{eventId}/qrcode")  
public ResponseEntity<QrcodeCreateResponseDto> createQrcode(  
        @RequestHeader(value = "Authorization", required = false) String token,  
        @PathVariable(name = "eventId") Long id){  
  
    QrcodeCreateResponseDto qrcodeCreateResponseDto  
            = qrcodeService.createQrcode(  
                    token.replace("Bearer","").trim(), id);  
  
    return ResponseEntity.ok()  
            .header("Cache-Control", "no-store")  
            .body(qrcodeCreateResponseDto);  
}

QR코드 생성 로직은 만료시간 암호화를 위한 키가 전달되기 때문에,
절대 캐싱하지 말라고 no-store 설정을 추가했습니다

마무리

이번 정리를 통해 REST API에 대해 깊이 있게 배울 수 있었습니다

특히 똑같은 데이터를 반복하는 경우 캐싱 전략을 어떻게 세울 수 있을지와
상태코드 및 URI명 에 대해 많은 고민을 했는데,
이번 정리를 통해 해당 고민을 어떻게 해결할지 깊게 배웠습니다.

아직 DTO와 PATCH, 비동기 상태 확인과 페이징 전략이 남아있습니다.
해당 글은 2부에서 이어서 작성하겠습니다!

참고

profile
Software Developer

0개의 댓글