예전에 작은 프로젝트들에서 RESTful한 시스템을 더러 개발했었다. 하지만 그 과정 속에서도 과연 내가 제대로 RESTful한 시스템을 개발한 것인가? 라는 의구심은 계속해서 들었다.
그리고 RESTful 하다는 것에 대한 이론적인거 말고 개발 당시 고려해야 하는 것이 어떤게 있고 어떻게 설계해야 하는지에 대한 고민들을 바탕으로 정리한 결과를 지금 포스팅 해보려고 한다.
아래 HTTP Cache 사용, Stateless 등의 특징 외에 서버-클라이언트 구조, 리소스-행위-표현 의 구조 등 여러 특징이 있지만 그런 것들은 잘 설명된 블로그가 많으니 참고하세요.
요즘 정적 자원(css, 이미지 등)에 대한 Cache 처리는 자동으로 지원되지만 동적 자원은 그렇지 않다.
하지만 API와 같은 동적 자원에도 간단한 Cache 기능을 사용하면서 속도를 올리고 트래픽을 줄이는 효과를 볼 수 있다.
API Cache 처리를 해주는 방법은 크게 2가지가 있다.
Last-Modified: Mon, 03 Jan 2011 17:45:57 GMT
API의 내용이 마지막으로 변경된 시간을 Response 해주는데, 이 변경된 시간을 기준으로 Cache 데이터를 사용한다는 전략이다.
하지만 이는 DB에서 변경된 시간을 관리하고 있어야 한다는 단점이 있다.
ETag: "15f0fff99ed5aae4edffdd6496d7131f"
Last Modified 방식과 거의 동일한데 시간 대신 Hash 값을 사용한다.
개인적인 의견으로 HTTP Cache를 사용하는 것보다 서버 측의 어플리케이션 캐시(Spring Cache 등)를 사용하는 것이 구현의 편의성, 관리성, 사용성, 성능 등의 여러면에서 더욱 효과적이라고 생각한다.
"상태 정보를 가지고 있지 않다" 흔히 얘기하는 RESTful의 특징 중의 하나이다.
Stateless 하다는 것은 전통적으로 클라이언트와 서버가 서로의 존재를 인증하기 위해 가졌던 세션이나 쿠키 정보를 더이상 가지지 않는 상태를 의미한다.
그렇다면 Stateless 한 특성을 살리려면 개발자는 무엇을 해야하는가?
세션,쿠키를 대신하여 클라이언트/서버가 서로를 인증할 수 있는 수단을 HTTP에 실어 보내야 한다. 그것도 안전하고 보다 편한 방법으로!
그 방법으로 아래 두가지가 존재한다.
물론 HTTP Basic Auth, Digest Auth 등도 존재하지만 보안 취약성 등의 문제로 지금은 잘 사용하지 않는 기술이기 때문에 제외한다.
위 1단계 사용자 인증 단계에서는 여러 방법을 사용할 수 있다.
대표적으로 안전성을 인정받고 현재 많은 어플리케이션에서 제공하는 방법이 제 3자 인증 방식 (OAuth2.0)이 이다.
아래 링크에 보다 자세한 내용이 설명되어 있다.
REST API 설계시 아래와 같은 점들을 고려하자.
// Non-RESTful URI
http://example.com/cotnents?lists=3&id=1
// RESTful URI
http://example/com/contents/lists/3/id/1
아래 예시처럼 URI에는 select, delete 등과 같은 행위에 대한 표현이 들어가면 안된다.
GET /members/select/1 (X)
GET /members/1 (O)
행위에 대한 표현은 HTTP Method(GET, POST, PUT, DELETE)로 표현해야 한다.
또한 리소스명은 동사보다는 명사를 사용하자.
분명한 URI의 사용으로 혼동을 주지 않도록 한다.
GET /animals/lion/ (X)
GET /animals/lion (O)
긴 URI를 하이픈을 사용해 가독성을 높일 수 있다.
가독성이 좋지 않다.
대소문자를 구별하기 때문이다.
파일 확장자는 Accept Header에 포함시킨다.
GET /animals/lion/1/photo.jpg (X)
GET /animals/lion/1/photo Accept: image/jpg (O)
GET /users/{userid}/devices (소유 'has'의 관계를 표현할 때)
// 관계명이 복잡하다면 서브 리소스에 명시적으로 표현할 수 있다. likes와 같이
GET /users/{userid}/likes/devices
Document는 하나의 객체, Collection은 Document 들의 집합을 의미하며 URI에서 표현할 수 있다.
GET /sports/soccer
GET /sports/soccer/players/17
sports와 players는 collection, soccer와 17은 document가 되며, collection은 복수, document는 단수로 나타내어 직관적인 URI 설계가 가능해진다.
클라이언트의 요청으로 부터 적절한 응답 코드를 돌려줘야 한다.
상태코드 | 설명 |
---|---|
200-OK | 클라이언트의 요청을 정상 수행함 |
201-Created | 클라이언트의 리소스 생성 요청을 성공적으로 수행함 |
301-Moved Permanently | 클라이언트가 요청한 리소스가 변경되었을 때(변경된 URL은 Location 헤더에 나타냄) |
400-Bad Request | 클라이언트의 요청이 부적절함 |
401-Unauthorized | 권한이 없는 클라이언트가 보호된 리소스에 요청을 수행했을 때 |
404-Not Found | 클라이언트가 요청한 리소스가 존재하지 않음 |
500-Internal Server Error | 서버 내부에 오류가 조재함 |
위의 RESTful API 설계 원칙을 지키면서 간단한 CRUD REST API 예제 코드를 작성해보았다.
@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));
}
@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));
}
@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));
}
@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));
}
HandlerInterceptor는 클라이언트의 요청들에 대해 실제 작업이 진행되기 전/후 처리를 수행하는 역할을 한다.
예를 들어 모든 API를 수행하기 전 로그인 여부를 먼저 확인해야 한다면 각 API 마다 로그인 여부를 점검하는 로직을 작성하는 것이 아니라 로그인 관련 HandlerInterceptor 를 구현한 후, 전/후 처리를 수행하면 중복되는 코드를 없애고 일관성 있고 확장성 있는 코드를 작성할 수 있다.
또한 용도별로 여러 인터셉터를 두어서 각 인터셉터들 간의 우선 순위를 조정함으로써 좀 더 유연한 작업 처리가 가능하도록 로직을 작성할 수도 있다.
그 외에도 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);
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
/**
* order() 메소드를 통해 인터셉터의 우선 순위를 줄 수 있고, 숫자가 낮을 수록 우선 순위가 높다.
* 만약 별도의 우선 순위 설정이 없다면 등록순으로 실행된다.
**/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserInterceptor())
.addPathPatterns("/**") // 특정 URI 포함
.excludePathPatterns("/boards") // 특정 URI 제외
.order(1);
}
}