POIFS (Poor Obfuscation Implementation File System) - MS Office 의 OLE 2 Compound document 파일 포맷을 읽고 쓰는 컴포넌트. - 모든 오피스 파일 포맷은 OLE2 방식이므로 하위 모든 컴포넌트의 기반이 된다. HSSF (Horrible SpreadSheet Format) - MS excel 97 이후로 현재까지의 엑셀파일들에 읽고 쓰기를 지원하는 컴포넌트. XSSF (XML SpreadSheet Format) - MS excel 2007 이후의 오픈 XML 파일 포맷인 *.xlsx 파일을 읽고 쓰는 컴포넌트.
음~ 잘된당~
용량을 10M 정도 넘겨봤더니, 에러 발생. 늘려보려 했는데... 실패!
Tried to allocate an array of length 132,636,469, but the maximum length for this record type is 100,000,000.
If the file is not corrupt or large, please open an issue on bugzilla to request
increasing the maximum allowable size for this record type.
As a temporary workaround, consider setting a higher override value with IOUtils.setByteArrayMaxOverride()
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번 조합!ㄱ
bootRun 을 통과하고 bootJar 를 만들어 cmd 에서 실행을 했는데 db connection 타이밍에 에러가 발생했다.
> java -jar example-0.0.1-SNAPSHOT.jar ... com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting... com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Exception during pool initialization.
java.sql.SQLNonTransientConnectionException: Could not create connection to database server. Attempted reconnect 3 times. Giving up. Caused by: com.mysql.cj.exceptions.CJCommunicationsException: Communications link failure. ... The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server. Caused by: javax.net.ssl.SSLHandshakeException: No appropriate protocol (protocol is disabled or cipher suites are inappropriate)
ㅋㅋ 아니 뭐 요점만 간단히 알려주든가, '왜' 에러 났는지를 알려줘야지 거참...
bootRun 은 되는데, jar 실행에서 db 연결이 안된다? ... ㅡㅡ;; 우선 구글을 겁나 뒤졌다. 하... 그 많은 게시물들 중 DB 연결해 놓고 jar 실행한 사람이 하나 없냐... 다들 톰캣 띄운 Hello World... 나도 헬로월드 좋아하긴 하지만... 개발군 헬창들... 저 에러메시지는 너무 광범위하고, 내 상황을 검색어로 표현하기는 더 어려웠다. 구글 검색 페이지를 넘기다넘기다 중국말까지 나오기 시작했다. 아니... 이 아이러니한 상황이 납득이가 안갔다. 이렇게 시간을 낭비할 수는 없어 docker 를 일단 돌려봤다. 엥... 도커는 또 잘됨; 최종 목적이 도커라 로컬은 그냥 넘겨도 되지만 또또... 오늘한 삽질이 아까워 그 끝을 보기로 했다.
우선 톰캣만 띄웠더니 문제없다. 바로 dataSource 연결했더니 똑같은 오류...! 아니 원래 로컬에서 DB 연결이 안됐었나; 더 해볼게 없었지만 마지막으로, 혹시나 하는 마음에 connector 버전을 바꿔보았다.
mysql-connector-java:5.1.49
당연히 같은 자리에서 오류가 발생하기는 했는데, 해결책이 나왔다!
Establishing SSL connection without server's identity verification is not recommended. According to MySQL 5.5.45+, 5.6.26+ and 5.7.6+ requirements SSL connection must be established by default if explicit option isn't set. For compliance with existing applications not using SSL the verifyServerCertificate property is set to 'false'. You need either to explicitly disable SSL by setting useSSL=false, or set useSSL=true and provide truststore for server certificate verification.
Mysql 5.5.45+, 5.6.26+, 5.7.6+ 부터는 ssl 접속이 default 라서, useSSL=false 를 명시하지 않으면 자동으로 ssl 접속을 시도한다는 것이다.
설마 이거 때문일 줄은 몰랐다. 이렇게 해결되니, 그래도 편히 눈을 감을 수 있게 됐다. 친절한 mysql connector 구버전에게 감사를 표한다.하지만 여전히 황당하기는 하다. ssl 이유라면 bootRun 으로도 되지 말았어야지. 도커에서도 되지 말았어야지...ㅜ
지금 보니 처음 발생했던 에러의 저 마지막 줄이 힌트긴 했네...
Caused by: javax.net.ssl.SSLHandshakeException: No appropriate protocol (protocol is disabled or cipher suites are inappropriate)