백엔드 개발자 로드맵 따라가기 7. RESTful API

박성수·2020년 11월 24일
1

1. 개요

예전에 작은 프로젝트들에서 RESTful한 시스템을 더러 개발했었다. 하지만 그 과정 속에서도 과연 내가 제대로 RESTful한 시스템을 개발한 것인가? 라는 의구심은 계속해서 들었다.

그리고 RESTful 하다는 것에 대한 이론적인거 말고 개발 당시 고려해야 하는 것이 어떤게 있고 어떻게 설계해야 하는지에 대한 고민들을 바탕으로 정리한 결과를 지금 포스팅 해보려고 한다.

2. RESTful 특징

아래 HTTP Cache 사용, Stateless 등의 특징 외에 서버-클라이언트 구조, 리소스-행위-표현 의 구조 등 여러 특징이 있지만 그런 것들은 잘 설명된 블로그가 많으니 참고하세요.

2-1. HTTP Cache 사용 가능

요즘 정적 자원(css, 이미지 등)에 대한 Cache 처리는 자동으로 지원되지만 동적 자원은 그렇지 않다.

하지만 API와 같은 동적 자원에도 간단한 Cache 기능을 사용하면서 속도를 올리고 트래픽을 줄이는 효과를 볼 수 있다.

API Cache 처리를 해주는 방법은 크게 2가지가 있다.

2-1-1. Last Modified

Last-Modified: Mon, 03 Jan 2011 17:45:57 GMT

API의 내용이 마지막으로 변경된 시간을 Response 해주는데, 이 변경된 시간을 기준으로 Cache 데이터를 사용한다는 전략이다.

하지만 이는 DB에서 변경된 시간을 관리하고 있어야 한다는 단점이 있다.

2-1-2. Etag

ETag: "15f0fff99ed5aae4edffdd6496d7131f"

Last Modified 방식과 거의 동일한데 시간 대신 Hash 값을 사용한다.

개인적인 의견으로 HTTP Cache를 사용하는 것보다 서버 측의 어플리케이션 캐시(Spring Cache 등)를 사용하는 것이 구현의 편의성, 관리성, 사용성, 성능 등의 여러면에서 더욱 효과적이라고 생각한다.

2-2. Stateless, 무상태성

"상태 정보를 가지고 있지 않다" 흔히 얘기하는 RESTful의 특징 중의 하나이다.

Stateless 하다는 것은 전통적으로 클라이언트와 서버가 서로의 존재를 인증하기 위해 가졌던 세션이나 쿠키 정보를 더이상 가지지 않는 상태를 의미한다.

그렇다면 Stateless 한 특성을 살리려면 개발자는 무엇을 해야하는가?

세션,쿠키를 대신하여 클라이언트/서버가 서로를 인증할 수 있는 수단을 HTTP에 실어 보내야 한다. 그것도 안전하고 보다 편한 방법으로!

그 방법으로 아래 두가지가 존재한다.

물론 HTTP Basic Auth, Digest Auth 등도 존재하지만 보안 취약성 등의 문제로 지금은 잘 사용하지 않는 기술이기 때문에 제외한다.

2-2-1. API Token 활용

  1. API Client가 사용자 ID,PW로 API Token을 요청한다.
  2. API 인증서버는 ID,PW를 이용하여 사용자를 인증한다.
  3. 인증된 사용자에 대해 Token을 발급한다.
  4. 이후 클라이언트는 발급된 Token을 이용하여 API를 호출한다.

위 1단계 사용자 인증 단계에서는 여러 방법을 사용할 수 있다.
대표적으로 안전성을 인정받고 현재 많은 어플리케이션에서 제공하는 방법이 제 3자 인증 방식 (OAuth2.0)이 이다.

2-2-2. OAuth2.0 활용

아래 링크에 보다 자세한 내용이 설명되어 있다.

-OAuth2.0 인증 서버 구축하기편-

3. REST API 설계

REST API 설계시 아래와 같은 점들을 고려하자.

// Non-RESTful URI
http://example.com/cotnents?lists=3&id=1

// RESTful URI
http://example/com/contents/lists/3/id/1

3-1. URI는 정보의 자원(리소스)를 나타내야 한다.

아래 예시처럼 URI에는 select, delete 등과 같은 행위에 대한 표현이 들어가면 안된다.

GET /members/select/1 (X)

GET /members/1 (O)

행위에 대한 표현은 HTTP Method(GET, POST, PUT, DELETE)로 표현해야 한다.

또한 리소스명은 동사보다는 명사를 사용하자.

3-2. URI 마지막 문자로 슬래시(/)를 사용하지 않는다.

분명한 URI의 사용으로 혼동을 주지 않도록 한다.

GET /animals/lion/ (X)

GET /animals/lion (O)

3-3. 하이픈(-)은 URI 가독성을 높이는데 사용한다.

긴 URI를 하이픈을 사용해 가독성을 높일 수 있다.

3-4. 언더바(_)는 URI에 사용하지 않는다.

가독성이 좋지 않다.

3-5. URI에는 소문자가 적합하다.

대소문자를 구별하기 때문이다.

3-6. 파일 확장자는 URI에 포함하지 마라.

파일 확장자는 Accept Header에 포함시킨다.

GET /animals/lion/1/photo.jpg (X)

GET /animals/lion/1/photo Accept: image/jpg (O)

3-7. 리소스 간의 관계를 표현하는 방법

 GET /users/{userid}/devices (소유 'has'의 관계를 표현할 때)
 
 // 관계명이 복잡하다면 서브 리소스에 명시적으로 표현할 수 있다. likes와 같이
 GET /users/{userid}/likes/devices

3-8. 자원을 표현하는 Collection과 Document

Document는 하나의 객체, Collection은 Document 들의 집합을 의미하며 URI에서 표현할 수 있다.

GET /sports/soccer

GET /sports/soccer/players/17

sports와 players는 collection, soccer와 17은 document가 되며, collection은 복수, document는 단수로 나타내어 직관적인 URI 설계가 가능해진다.

3-9. HTTP 응답 코드

클라이언트의 요청으로 부터 적절한 응답 코드를 돌려줘야 한다.

상태코드설명
200-OK클라이언트의 요청을 정상 수행함
201-Created클라이언트의 리소스 생성 요청을 성공적으로 수행함
301-Moved Permanently클라이언트가 요청한 리소스가 변경되었을 때(변경된 URL은 Location 헤더에 나타냄)
400-Bad Request클라이언트의 요청이 부적절함
401-Unauthorized권한이 없는 클라이언트가 보호된 리소스에 요청을 수행했을 때
404-Not Found클라이언트가 요청한 리소스가 존재하지 않음
500-Internal Server Error서버 내부에 오류가 조재함

4. SpringBoot + RESTful API

4-1. HTTP Method 구현

위의 RESTful API 설계 원칙을 지키면서 간단한 CRUD REST API 예제 코드를 작성해보았다.

  1. URI 는 자원을 나타낼 수 있어야 한다.
  2. URI 에는 insert/delete 와 같은 행위와 관련된 용어가 들어가면 안된다. (행위는 HTTP Method로 구분)
  3. URI 에서 / 는 계층 구조를 나타낸다.
  4. URI 에서 복수는 Collection 을 단수는 Document 를 의미하도록 작성하는 것이 좋다.
  5. 리턴 값은 단순 Object 값보다는 상태 코드, 메세지 등의 정보를 같이 담아서 리턴하는 것이 좋다.

POST (CREATE)

@PostMapping("/users")
public ResponseEntity<DefaultResponse<Object>> insertUserByName(@RequestBody MemberDTO memberDTO,
                                                            HttpServletRequest request){
    MemberDTO model = null;
    try {
        model = memberDTOServiceDao.save(memberDTO);
    }  catch (Exception e) {
        log.error(e.getMessage());
    }
    return ResponseEntity.ok().body(responseHandler.createResponse(model, request));
}

GET (READ)

@GetMapping("/users")
public ResponseEntity<DefaultResponse<Object>> selectUserByAll(HttpServletRequest request) {
    List<MemberDTO> memberList = null;
    try {
        memberList = memberDTOServiceDao.findAll();
    } catch (Exception e) {
        log.error(e.getMessage());
    }
    return ResponseEntity.ok().body(responseHandler.createResponse(memberList, request));
}

@GetMapping("/users/{id}")
public ResponseEntity<DefaultResponse<Object>> selectUserById(@PathVariable String id,
                                                                HttpServletRequest request){
    MemberDTO member = null;
    try {
        member = memberDTOServiceDao.findById(id).get();
    } catch (Exception e) {
        log.error(e.getMessage());
    }
    return ResponseEntity.ok().body(responseHandler.createResponse(member, request));
}

PUT (UPDATE)

@PutMapping("/users/{id}")
public ResponseEntity<DefaultResponse<Object>> updateUserById(@PathVariable String id,
                                                                    @RequestBody MemberDTO oldMember,
                                                                    HttpServletRequest request){
    MemberDTO updateMember = null;
    try {
        MemberDTO newMember = MemberDTO.builder()
                .id(id)
                .name(oldMember.getName())
                .age(oldMember.getAge())
                .teamId(oldMember.getTeamId())
                .build();
        updateMember = memberDTOServiceDao.update(newMember);
    } catch (Exception e) {
        log.error(e.getMessage());
    }
    return ResponseEntity.ok().body(responseHandler.createResponse(updateMember,request));
}

DELETE

@DeleteMapping("/users/{id}")
public ResponseEntity<DefaultResponse<Object>> deleteUserById(@PathVariable String id,
                                                                HttpServletRequest request){
    boolean isDelete = false;
    try {
        memberDTOServiceDao.deleteById(id);
        isDelete = true;
    } catch (Exception e) {
        log.error(e.getMessage());
    }
    return ResponseEntity.ok().body(responseHandler.createResponse(isDelete,request));
}

4-2. HandlerInterceptor

HandlerInterceptor는 클라이언트의 요청들에 대해 실제 작업이 진행되기 전/후 처리를 수행하는 역할을 한다.

예를 들어 모든 API를 수행하기 전 로그인 여부를 먼저 확인해야 한다면 각 API 마다 로그인 여부를 점검하는 로직을 작성하는 것이 아니라 로그인 관련 HandlerInterceptor 를 구현한 후, 전/후 처리를 수행하면 중복되는 코드를 없애고 일관성 있고 확장성 있는 코드를 작성할 수 있다.

또한 용도별로 여러 인터셉터를 두어서 각 인터셉터들 간의 우선 순위를 조정함으로써 좀 더 유연한 작업 처리가 가능하도록 로직을 작성할 수도 있다.

그 외에도 HandlerInterceptor를 사용할 경우 가지는 이점이 상당한만큼 규모가 큰 프로젝트일수록 인터셉터의 사용은 필수적이라고 할 수 있겠다.

4-2-1. HandlerInterceptor 작성

@Log4j2
@Component
public class UserInterceptor implements HandlerInterceptor {

    /**
     * Controller 요청을 전달하기 전처리 구간
     * 
     * DELETE Method 수행시 특정 사용자에 대한 삭제는 차단하려는 로직
     **/
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String method = request.getMethod();
        if (method.equals("DELETE")) {
            log.warn("USER DELETE 수행");
            if (request.getRequestURI().substring(request.getRequestURI().lastIndexOf("/")+1).equals("31")) {
                log.warn("User 31 cannot be deleted.");
                return false;
            }
        }
        return true;
    }

    /**
     * Controller 가 요청을 처리하고 난 후처리 구간 (클라이언트에게 요청 결과를 응답하기 전)
     * 
     * User 정보에 변화(수정,삽입,삭제)가 있으면 적용된 캐쉬 값을 갱신하려는 로직
     **/
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        ApplicationContext ctx = ApplicationContextProvider.getApplicationContext();
        CacheManager cacheManager = (CacheManager) ctx.getBean("myCacheManager");
        String method = request.getMethod();
        
        if (!method.equals("GET")) {
            log.warn("USER {} 작업 수행!!!",method);
            try {
                Cache users = cacheManager.getCache("userList");
                users.clear();
                log.info("Refresh to userList of Cache!!");
            } catch (Exception e) {
                log.error(e.getMessage());
            }
        }
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String method = request.getMethod();
        String requestURI = request.getRequestURI();
        log.info("Handler 처리 완료!!! {} ;; {}",method, requestURI);
    }
}

4-2-2. HandlerInterceptor 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {
    /**
     * order() 메소드를 통해 인터셉터의 우선 순위를 줄 수 있고, 숫자가 낮을 수록 우선 순위가 높다.
     * 만약 별도의 우선 순위 설정이 없다면 등록순으로 실행된다.
     **/
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserInterceptor())
                .addPathPatterns("/**") // 특정 URI 포함
                .excludePathPatterns("/boards") // 특정 URI 제외
                .order(1); 
    }
}

<참고사이트>
https://meetup.toast.com/posts/92

https://bbirec.medium.com/http-%EC%BA%90%EC%89%AC%EB%A1%9C-api-%EC%86%8D%EB%8F%84-%EC%98%AC%EB%A6%AC%EA%B8%B0-2effb1bfab12

https://bcho.tistory.com/955

profile
Java 백엔드 개발자입니다. 제가 생각하는 개발자로서 가져야하는 업무적인 기본 소양과 현업에서 가지는 고민들을 같이 공유하고 소통하려고 합니다.

0개의 댓글