CORS(Cross-Origin Resource Sharing)는 웹 브라우저에서 특정 사이트(A.com) 로부터 다른 사이트(B.com) 로의 요청 및 접근을 가능하게 하는 프로토콜이다. 보안 상 최신 브라우저는 기본적으로 타 사이트로의 요청을 제한하고 있다. SPA 프레임워크의 사용 등으로 frontend 와 backend 의 분리가 일반적이 된 요즘, 서버로의 API 요청시 프로토콜/도메인/포트 등이 다르다면, CORS 설정이 올바르지 않다면 브라우저 콘솔에서 관련 error 를 마주하게 된다.
Access to XMLHttpRequest at 'http://localhost:8080/account/signin' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
기본적인 CORS 요청들을 단순(simple) 요청이라 할 때, application/json 타입의 요청이거나 커스텀 헤더를 전송하는 등의 요청은 사전(preflight) 요청이라고 한다. 이 경우는 웹 브라우저에서 OPTIONS 메소드로 preflight(사전전달) 요청이 먼저 수행하고, 서버에서 허용 범주를 응답 받은 후에 실제 GET/POST 등의 요청을 보내게 된다.
CORS 서버 설정
서버 개발자는 요청을 허용할 범주(Access-Control-Allow-* header/method/origin) 를 설정하여 응답 헤더에 보낼 수 있도록 세팅하면 된다. Spring Boot 의 경우, WebMvcConfigurer 를 구현하고 addCorsMappings 메소드를 오버라이드 하여 아래와 같이 설정할 수 있다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000", "http://localhost:8081")
.exposedHeaders("Authorization");
}
}
exposedHeaders 메소드의 경우 브라우저에서 클라이언트가 접근 가능한 응답 헤더 목록을 설정한다. (Authorization 은 token 등을 이용할 때 많이 사용) cors mapping 만 설정했을 때는 GET, HEAD, POST 의 메소드에 대해서, 모든 출처의 요청에 대해 허용하는 것이 기본값이다. 위와 같이 Spring 에서 설정했을 경우, 아래와 같은 응답 헤더를 보내주고 CORS 는 성공하게 된다.
Spring Boot 에서 모든 원본 Http request 와 response 에 대한 log 를 기록하려고 한다.
요청 → Filter → Interceptor → AOP → Interceptor → Filter → 응답 의 처리 순서로 볼 때, 가장 원시적인 요청 본문과 응답 본문을 캐치할 수 있는 부분은 Filter 였다. 요청을 받자마자 Filter 에서 본문을 로깅하고, 응답 직전에 본문을 로깅하고... 하지만 이거슨 순수한 나만의 착각?
Request / Response body 로깅 목표와... 결과
비정상 요청 파악 및 비정상 응답 파악하기.
최대한 원시 본문을 유지할 것. (의미를 찾지못함;)
요청 본문과 응답 본문의 로깅을 각각 처리. (막상 분리하고 나니 굳이...)
POST 메소드만 처리.
form-urlencoded 와 application/json 모두 처리.
로깅 위치는 Filter 와 Interceptor 의 preHandle 과 afterCompletion 를 적절히 사용.
1. request / response 본문(body) 읽기
request 와 response 의 본문(body) 를 로깅하기 위해 HttpServletRequest 의 getReader() 나 getInputStream() 등을 통해서 읽어낼 경우, 다음 단계에서 이 본문들은 비어있는 상태가 된다. 인터셉터에서 request 본문을 읽는다면 컨트롤러에 파라미터가 전달되지 않을 것이고, 인터셉터에서 response 본문을 읽는다면 클라이언트에 내용이 빈 상태로 응답될 수 있다. 이를 방지하기 위해 사용하는 방법이 여러가지 있는데 난 그 중에서 ContentCachingRequestWrapper / ContentCachingResponseWrapper 클래스를 선택했다.
ContentCachingRequestWrapper 는 getReader() 나 getInputStream() 등을 통해서 읽은 요청 본문을 캐시하고 byte array 로 이를 읽을 수 있게 하는 HttpServletRequest 래퍼이다. ContentCachingResponseWrapper의 경우 Body를 돌려주기 위해 copyBodyToResponse method를 호출해줘야 한다.
Filter 에서 ContentCachingRequestWrapper / ContentCachingResponseWrapper 래퍼로 request 와 response 를 캐시하여, 재사용이 가능하도록 하였다. 밑에 메소드 3개는 각각 헤더, 요청 본문, 응답 본문 가져오기.
이렇게 ContentCaching* 클래스로 간단하게 요청/응답 본문을 로깅할 수 있었다. 심플한건 좋았지만, 내가 원했던 방법은 이렇게 filter 에 전부 때려박는 것은 아니었다. 요청 본문은 preHandle 에서 로깅하고 싶었는데, 제약 조건이 많았다. preHandle 에서 ContentCachingRequestWrapper 로는 json 을 처리하지 못했다. 하지만 form-urlencoded 요청의 경우, application.properties 에서 logging.level.org.springframework.web=debug 설정이 되어 있다면, 인터셉터 preHandle 에서도 요청 본문 로깅이 가능했다. Exception 발생시 응답 본문은 null 이 발생했으나, 이 경우 상태 코드로 응답 예측이 가능하니 큰 문제는 없었다. multipart/form-data 요청은 용량 차지하지 않게 별도로 알아서 잘 처리하시고... (난 그냥 본문 날려버렸음.)
굳이 Spring 에서 처리하긴 했지만, 로깅을 커스텀 하는 것은 쉽지 않았다. 어떤 서비스에 어떤 목적으로 어디에 로깅을 하느냐 또 그 로깅을 어떻게 활용할 것인가가 중요한 것 같다.
API 개발시 어떠한 처리 결과에도 동일한 포맷의 응답(response) 을 리턴하는 것은 중요하다. 아래는 가장 간단한 예이다.
{
"code": 200,
"message": "ok",
"result": {}
}
code / message 로 미리 정의된 응답 코드와 메시지를 확인할 수 있고, 정상적으로 처리가 되었을 경우 result 에서 결과를 확인할 수 있다. 디테일이 필요하다면 더 다양한 코드에 상세한 설명을 넣으면 되고, 보안을 강화한다면 메시지를 없앨 수도 있다. 이러한 공통적인 포맷을 Spring 에서 구현하는 방법을 살펴 보았다.
1. whitelabel 에러 페이지
Spring Boot 는 기본적으로 text/html 타입의 요청에 대해 whitelabel 이라는 오류 페이지를 제공한다. 기존 톰캣의 보라색 에러 페이지 보다는 괜찮아 보일 수 있다.
브라우저에서 접속했을 때에도 에러 페이지를 커스텀 하려면 properties 파일에서 whitelabel 페이지를 비활성화 하고 에러 페이지를 재정의해야 한다.
위 속성은 DefaultErrorAttributes 에서 정의되는데 속성만 추가하려면 application.properties 에서 server.error 옵션들을 수정하던지 DefaultErrorAttributes 를 상속하여 커스텀 할 수도 있다.
# message 항목 보이기
server.error.include-message=always
@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
Map<String, Object> result = super.getErrorAttributes(webRequest, options);
result.put("description", "abcdefg");
return result;
}
}
3. AbstractErrorController 상속하여 재정의
basicErrorController 처럼 ErrorController 인터페이스를 구현한 AbstractErrorController 를 상속하여 에러 응답을 재정의할 수 있다.
@RestController
@RequestMapping("${server.error.path:${error.path:/error}}")
public class CustomErrorController extends AbstractErrorController {
public CustomErrorController(ErrorAttributes errorAttributes) {
super(errorAttributes);
}
@RequestMapping
public ResponseEntity<ErrorMessageEntity> customError(HttpServletRequest request) {
HttpStatus httpStatus = getStatus(request);
CustomResponseEntity customResponseEntity = new CustomResponseEntity();
customResponseEntity.setCode(httpStatus.value());
customResponseEntity.setMessgae(httpStatus.getReasonPhrase());
customResponseEntity.setResult(null);
return new ResponseEntity<>(customResponseEntity, httpStatus);
}
}
@Entity
public class CustomResponseEntity {
private Integer code;
private String messgae;
@JsonInclude(JsonInclude.Include.NON_NULL)
private String result;
...
}
기본 에러 경로인 /error (properties 파일: server.error.path) 는 basicErrorController 에서 매핑을 시도하므로, ErrorController 인터페이스를 구현하지 않고 /error 컨트롤러를 사용한다면 런타임 에러가 발생한다.
위와 같이 AbstractErrorController 를 상속하면 모든 HTTP 상태 코드에 대한 응답을 커스텀 할 수 있다. 여기에 Spring 5+ 를 사용 중이라면 RuntimeException 을 상속하는 ResponseStatusException 를 이용해 어디에서든 원하는 에러를 발생시킬 수 있다.
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid path");
특정 상황에 특정 에러 코드와 메시지로 응답하고 싶었는데 2번째 파라미터를 응답 엔티티에 넣는 것이 쉽지 않았다.
5. Custom Exception 생성
ResponseStatusException 을 대신하여 메시지도 응답할 수 있도록 CustomException 을 생성한다.
@Component
public class CustomException extends RuntimeException {
private ErrorCode errorCode;
public CustomException(ErrorCode errorCode) {
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return this.errorCode;
}
}
에러 메시지를 enum 으로 정리한다.
public enum ErrorCode {
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "Expired Token"),
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "Invalid Token"),
SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"),
...
;
private final HttpStatus status;
private final String message;
ErrorCode(HttpStatus status, String message) {
this.status = status;
this.message = message;
}
public HttpStatus getStatus() {
return this.status;
}
public String getMessage() {
return this.message;
}
}
컨트롤러 전역에서 에러를 핸들링할 수 있도록 @ControllerAdvice, @ExceptionHandler 를 작성한다.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<CustomResponseEntity> myCustomException(CustomException ex) {
CustomResponseEntity customResponseEntity = new CustomResponseEntity();
ErrorCode errorCode = ex.getErrorCode();
customResponseEntity.setCode = errorCode.getStatus().value();
customResponseEntity.setMessage = errorCode.getMessage();
return new ResponseEntity<>(customResponseEntity, errorCode.getStatus());
}
}
위 5번 GlobalExceptionHandler 에 아래 NoHandlerFoundException 추가하여 NoHandlerFoundException 예외를 처리한다.
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<DefaultMessageEntity> handle404(NoHandlerFoundException ex) {
CustomResponseEntity customResponseEntity = new CustomResponseEntity();
customResponseEntity.setCode = HttpStatus.NOT_FOUND.value();
customResponseEntity.setMessage = HttpStatus.NOT_FOUND.getReasonPhrase()
return new ResponseEntity<>(customResponseEntity, HttpStatus.OK);
}
CustomException(EXPIRED_TOKEN) 이라는 에러를 발생시키면, @ExceptionHandler 에서 해당 예외를 처리하는 예제이다. CustomException 을 제외한 예외들은 3번에서 작성한 CustomErrorController 에 의해 처리될 것이다. 3번 5번 6번 조합!ㄱ
데이터베이스에서 replication 은 데이터베이스간의 데이터 복제를 의미한다. 하나의 데이터베이스에서 읽기와 쓰기 작업을 동시에 진행할 경우 병목 현상이 발생할 확률이 높다. 이를 방지하기 위해 흔히 Master(Write) 와 Slave(Read) 로 나누어, Master 에서 변경된 데이터를 Slave 로 복제하는 것을 replication 이라고 한다.
Java 어플리케이션에서 분기 처리하는 방법은 커넥션 풀(DataSource) 을 Master 와 Slave 로 나누어 주고, JDBC 의 Connection.setReadOnly 메소드나 Spring 의 @Transactional 를 사용하는 것이다.
정상적으로 생성됐다면 이 snippets 들을 불러올 템플릿 asciidoc 파일을 생성한다. (/src/main/asciidoc/)
= Document Title
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toc-title: API 명세서
:toclevels: 4
[[api]]
== Book Api
api 에 관련된 설명을 이곳에 적습니다..
include::{snippets}/book/curl-request.adoc[]
include::{snippets}/book/http-request.adoc[]
include::{snippets}/book/path-parameters.adoc[]
include::{snippets}/book/http-response.adoc[]
include::{snippets}/book/response-fields.adoc[]