"객체 생성은 비싸니 피해야 한다."가 아니다. 특히 요즘의 JVM에서는 별다른 일을 하지 않는 작은 객체를 생성하고 회수하는 일이 크게 부담이 되지도 않는다. 그러나, 불필요한게 있어서 좋을게 없다.
가장 불필요한 객체 생성이 일어나는 시점은 오토박싱으로 예를 들 수 있다.
다음 코드를 보자.
package items.item6;
public class Main {
public static void main(String[] args) {
long start = System.currentTimeMillis();
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
System.out.println("sum = " + sum);
long end = System.currentTimeMillis();
System.out.println("total time = " + (end - start) + "ms");
}
}
끔찍한 일이 일어나고 있다. 객체가 어디서 생성되는지 보이는가?
sum은 Long 박싱 타입인데, i는 long 기본 타입이다. 이때 sum = sum + i를 이행하는 과정에서 i의 오토박싱이 일어난다. 즉, 이 루프를 돌면서 Long 인스턴스가 2의 31승개가 만들어진다.
이때, sum의 타입을 long으로만 바꿔주면 이 코드의 실행 속도는 2.2초에서 0.6초로 줄어든다.
가끔 정적 메서드, 정적 필드만을 담은 클래스를 만들고 싶을 때가 있다. 대표적인게 유틸클래스.
물론, 객체 지향적으로 사고하지 않는 이들이 종종 남용하는 방식이라 좋아보이진 않지만 유용할 때가 분명 있다.
근데, 내가 이 부분에서 빠뜨린 부분이 있었다.
나는 인스턴스화를 막기 위해 추상 클래스로 선언하면 아무 문제 없을 줄 알았는데, 그게 아니었다.
추상클래스로 만드는 것으로는 인스턴스화를 막을 수 없다.
추상클래스로 만들어도, 하위 클래스를 만들어 인스턴스화하면 그만이다.
다음 코드를 보자.
AbstractClass
package items.item4;
public abstract class AbstractClass {
public static void hello() {
System.out.println("Hello Static Class");
}
}
ChildClass
package items.item4;
public class ChildClass extends AbstractClass {
public void childMethod() {
System.out.println("child method");
}
}
이 하위클래스가 추상 클래스를 상속받았다.
Main
package items.item4;
public class Main {
public static void main(String[] args) {
ChildClass childClass = new ChildClass();
childClass.hello();
}
}
하위클래스로 인스턴스를 생성하면, 결국 원하는만큼 인스턴스를 생성할 수가 있게 된다. 어차피 자식 인스턴스를 만들면 부모 인스턴스는 자연스럽게 생성자가 호출되니까 인스턴스도 계속해서 만들어질 수 있다.
결론
그래서, 이 인스턴스화를 막는 방법은 추상 클래스가 정답이 아니다. 인스턴스화를 막으려면 private 생성자를 만들면 된다.
명시적으로 생성자를 하나라도 만들면 컴파일러는 우리 대신 생성자를 만들지 않기 때문에 private 생성자만 존재하게 된다. 그리고 private 생성자만 있는 경우, 절대로 하위 클래스가 존재할 수 없다. 상속받는 클래스는 명시적이든 묵시적이든 상위 클래스의 생성자를 호출하는데 호출할 수 있는 생성자가 없으니 말이다.
그래서, 자식 클래스를 만들 수도 없고 생성자를 통해 인스턴스를 만들수도 없는 private 생성자를 사용하자.
일반적으로 사용하는 HTML Form을 통한 파일 업로드를 이해하려면 먼저 폼을 전송하는 다음 두 가지 방식의 차이를 이해해야 한다.
HTML 폼 전송 방식
application/x-www-form-urlencoded
multipart/form-data
application/x-www-form-urlencoded 방식은 HTML 폼 데이터를 서버로 전송하는 가장 기본적인 방법이다.
Form 태그에 별도의 enctype 옵션이 없으면 웹 브라우저는 요청 HTTP 메시지 헤더에 다음 내용을 추가한다.
`Content-Type: application/x-www-form-urlencoded`
그리고 폼에 입력한 전송할 항목을 HTTP Body에 문자로 username=kim&age=20과 같이 &로 구분해서 전송한다.
파일을 업로드 하려면, 파일은 문자가 아니라 바이너리 데이터를 전송해야 한다. 문자를 전송하는 이 방식으로 파일을 전송하기는 어렵다. 그리고 또 한가지 문제가 더 있는데, 보통 폼을 전송할 때 파일만 전송하는 것이 아니라는 점이다.
다음 예를 보자.
- 이름
- 나이
- 첨부파일
여기에는 이름과 나이도 전송하고 첨부파일도 함께 전송한다. 문제는 이름과 나이는 문자로 전송하고, 첨부 파일은 바이너리로 전송해야 한다는 점이다. 여기에서 문제가 발생한다. 문자와 바이너리를 동시에 전송해야 하는 상황.
이 문제를 해결하기 위해 HTTP는 multipart/form-data라는 전송 방식을 제공한다.
이 방식을 사용하려면 Form 태그에 별도의 enctype="multipart/form-data"를 지정해야 한다.
multipart/form-data 방식은 다른 종류의 여러 파일과 폼의 내용을 함께 전송할 수 있다. (그래서 이름이 multi part이다.)
폼의 입력 결과로 생성된 HTTP 메시지를 보면 각각의 전송 항목이 구분되어 있다.
위 사진을 보면, boundary=-----XXX라고 표시가 되어 있다. 즉, -----XXX이부분이 항목단위로 구분자가 되는 것이다.
그리고 그 구분자별로, Content-Disposition이라는 항목별 헤더가 추가되어 있고 여기에 부가 정보가 있다. username, age, file1이 각각 분리되어 있고, 폼의 일반 데이터는 각 항목별로 문자가 한칸 띄고 전송되고, 파일의 경우 파일 이름과 Content-Type이 추가되고 바이너리 데이터가 한칸 띄고 전송된다.
mulitipart/form-data는 이렇게 각각의 항목을 구분해서, 한번에 전송하는 것이다.
Part
multipart/form-data는 application/x-www-form-urlencoded와 비교해서 매우 복잡하고 각각의 부분(Part)로 나뉘어져 있다. 그렇다면 이렇게 복잡한 HTTP 메시지를 서버에서 어떻게 사용할 수 있을까?
서블릿 파일 업로드 1
역시 마찬가지로, 스프링의 도움을 받기 전에 태초의 세계부터 시작해보자. 결국 스프링이 태어나기 전에도 이 파일을 업로드하는 기능은 있었다. 그때 서블릿은 어떻게 처리를 했을까?
간단한 폼을 보여주는 HTML 파일이다. 여기서 한번 체크하고 넘어갈 부분은 form 태그의 enctype="multipart/form-data" 이 부분이다. 이렇게 직접 명시를 해줘야 브라우저에서 폼에 데이터를 넣어 전송할 때 서버에서 multipart/form-data로 받아온다.
또 하나 해줄 설정이 있는데, 브라우저(클라이언트)에서 서버로 POST 요청이 들어올 때 로그를 자세하게 보고싶다.
application.properties
logging.level.org.apache.coyote.http11=trace
이 옵션을 설정해서 HTTP 요청 메시지를 로그로 확인할 수 있다.
이제 브라우저에 다음과 같이 접속해보면,
이렇게 보여진다. 여기서 아무 상품명과 간단한 PNG 파일을 넣어 제출해보자.
그럼 결과로그가 엄청 많이 찍히는데 제일 윗 부분에 이 부분을 보자.
HTTP 요청 메시지에서 Content-Type은 multipart/form-data이다. 그리고 boundary로 ----XXX 이렇게 나와있다.
그리고 그 구분자로 각각의 데이터가 넘어오고 있다. 위에 부분은 상품명에 대한 데이터, 아래 부분은 파일이다. 그리고 문자가 막 이상하게 나오는데 이건 인코딩 과정에서 이렇게 보이는거고 바이너리 데이터가 넘어오고 있다는 것이다.
그리고 하단에 찍힌 로그를 보자.
우리가 직접 찍은 로그인데, 여기에 parts 부분에 두개의 데이터가 넘어오고 있는게 보이는가? 이게 각각의 파트이다. 아마 상품명에 관련된 Part, 파일과 관련된 Part이겠지. 이렇게 데이터를 받아올 수 있다.
큰 파일을 무제한 업로드하게 둘 수는 없으므로 서버에서 업로드 사이즈를 제한할 수도 있다. 사이즈가 넘으면 SizeLimitExceededException 예외가 발생한다.
max-file-size: 파일 하나의 최대 사이즈 (기본 1MB)
max-request-size: 멀티파트 요청 하나에 여러 파일을 업로드할 수 있는데, 그 전체 합 (기본 10MB)
멀티파트 끄기 옵션
spring.servlet.multipart.enabled=false
멀티파트는 일반적인 폼 요청인 application/x-www-form-urlencoded보다 훨씬 복잡하다. 그래서 이렇게 옵션을 끄면 서블릿 컨테이너는 멀티파트와 관련된 처리를 하지 않는다. 실제로 그런지 결과를 확인해보자.
아무런 데이터도 받지 못했다.
그리고 이 옵션은 기본이 켜져있는 `true`이다. 이 옵션을 키면 스프링 부트는 서블릿 컨테이너에게 멀티파트 데이터를 처리하라고 설정한다. 그리고 또 재미있는 부분이 있다. 이 로그를 좀 더 자세히 보면 이렇게 되어 있다.
HttpServletRequest 객체가 RequestFacade로 되어 있다. 그리고 이게 StandardMultipartHttpServletRequest가 되는데 이게 어떻게 된 것이냐?
스프링은 멀티파트 옵션을 키면 DispatcherServlet에서 멀티파트 리졸버(MultipartResolver)를 실행한다. 멀티파트 리졸버는 멀티파트 요청인 경우, 서블릿 컨테이너가 전달하는 일반적인 HttpServletRequest를 MultipartHttpServletRequest로 변환해서 반환한다. MultipartHttpServletRequest는 HttpServletRequest의 자식 인터페이스이고, 멀티파트와 관련된 추가 기능을 제공한다.
아래 사진은 DispatcherServlet의 일부분인 doDispatch 메서드의 일부이다.
여기서 checkMultipart 메서드에 request를 넘기고 있다. 저 메서드 안으로 들어가보면,
이렇게 멀티파트 요청인지 체크를 하는 부분이 있고 그게 맞다면, 스프링이 제공하는 기본 멀티파트 리졸버는 MultipartHttpServletRequest 인터페이스를 구현한 StandardMultipartHttpServletRequest를 반환한다. 그래서 request라는 로그를 찍어봤을 때, RequestFacade가 찍히고 이게 StandardMultipartHttpServletRequest이 녀석이 되는것이다. 이제 컨트롤러에서 HttpServletRequest 대신에 MultipartHttpServletRequest를 주입받을 수 있는데, 이걸 사용하면 멀티파트와 관련된 여러가지 처리를 편리하게 할 수 있다. 아래 코드처럼 말이다.
package cwchoiit.fileupload.controller;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
@Slf4j
@Controller
@RequestMapping("/servlet/v2")
public class ServletUploadControllerV2 {
@Value("${file.dir}")
private String fileDir;
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@PostMapping("/upload")
public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
log.info("request = {}", request);
String itemName = request.getParameter("itemName");
log.info("itemName = {}", itemName);
Collection<Part> parts = request.getParts();
log.info("parts = {}", parts);
for (Part part : parts) {
log.info("=== PART ===");
log.info("name = {}", part.getName());
Collection<String> headerNames = part.getHeaderNames();
for (String headerName : headerNames) {
log.info("header {}:{}", headerName, part.getHeader(headerName));
}
// 파일 이름
log.info("submittedFileName = {}", part.getSubmittedFileName());
// 데이터 크기
log.info("size = {}", part.getSize());
// 데이터 읽기
InputStream inputStream = part.getInputStream();
String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("body={}", body);
// 파일 저장
if (StringUtils.hasText(part.getSubmittedFileName())) {
String fullPath = fileDir + part.getSubmittedFileName();
log.info("파일 저장 fullPath = {}", fullPath);
part.write(fullPath);
}
}
return "upload-form";
}
}
우리가 application.properties 파일에 지정한 파일 경로 값을 가져오기 위해 스프링이 제공하는 @Value 애노테이션을 활용해서 이렇게 가져왔다. (물론, @Value보다 더 좋은 방법인 외부 설정을 객체로 변환하여 빈으로 등록해서 가져오는 방식이 더 좋다)
@Value("${file.dir}")
private String fileDir;
멀티파트 형식은 전송 데이터를 하나하나 각각 부분(Part)으로 나누어 전송한다. 그래서 parts에는 이렇게 나누어진 데이터가 각각 담긴다.
서블릿이 제공하는 Part는 멀티파트 형식을 편리하게 읽을 수 있는 다양한 메서드를 제공한다.
Part 주요 메서드
part.getSubmittedFileName(): 클라이언트가 전달한 파일명
part.getInputStream(): Part의 전송 데이터를 읽을 수 있다.
part.write(...): Part를 통해 전송된 데이터를 저장할 수 있다.
실제로 파일 업로드를 해서 결과 로그를 찍어보자.
이렇게 Part로부터 정보를 가져온 모습을 볼 수 있다. 그리고 중요한 로그인 파일 저장한 경로에 대해 출력하는 로그는 다음과 같다.
실제로 해당 경로에 파일이 저장이 됐을 것이다.
서블릿이 제공하는 Part는 편하기는 하지만, HttpServletRequest를 사용해야 하고, 추가로 파일 부분만 구분하려면 여러가지 코드를 넣어야 한다. 이번에는 스프링이 이 부분을 얼마나 편리하게 제공하는지 확인해보자.
스프링과 파일 업로드
스프링은 MultipartFile이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원한다.
@PostMapping("/items/new"): 폼의 데이터를 데이터베이스에 저장하고, 폼을 보여주는 화면으로 리다이렉트 한다.
@GetMapping("/items/{id}"): 상품을 보여준다.
@GetMapping("/images/{fileName}"): <img> 태그로 이미지를 조회할 때 사용한다. UrlResource로 이미지를 읽어서, @ResponseBody로 이미지 바이너리를 반환한다.
@GetMapping("/attach/{itemId}"): 파일을 다운로드할 때 사용한다. 예제를 더 단순화 할 수 잇지만, 파일 다운로드 시 권한 체크같은 복잡한 상황까지 가정한다 생각하고 이미지 id를 요청하도록 했다. 파일 다운로드시에는 고객이 업로드한 파일 이름으로 다운로드 하는 게 좋다. 이때는 Content-Disposition 헤더에 attachment; filename="업로드 파일명"값을 주면 된다. 그리고 이렇게 헤더에 값을 넣어줘야 고객이 이 URL로 요청을 하면 파일을 다운로드 받을 수 있게 된다.
스프링이 제공하는 @RequestParam을 사용하면 data를 처음부터 Integer 타입의 숫자로 가져올 수 있다. 이건 그냥 되는게 아니라 스프링이 중간에서 타입을 변환해주었기 때문이다! 이러한 예는 @ModelAttribute, @PathVariable도 마찬가지다.
스프링이 제공하는 이 타입 변환 기능은 개발자가 원한다면 새로운 타입을 만들어서 변환하고 싶으면 변환할 수 있다.
Converter 인터페이스
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source);
}
이처럼 스프링은 확장 가능한 컨버터 인터페이스를 제공한다. 개발자는 스프링에 추가적인 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해서 등록하면 된다. 이 컨버터 인터페이스는 모든 타입에 적용할 수 있다. 필요하면 X → Y 타입으로 변환하는 컨버터 인터페이스를 만들고, 또 Y → X 타입으로 변환하는 컨버터 인터페이스를 만들어서 등록하면 된다.
참고로, 과거에는 PropertyEditor라는 것으로 타입을 변환했다. PropertyEditor는 동시성 문제가 있어서 타입을 변환할 때 마다 객체를 계속 생성해야 하는 단점이 있다. 지금은 Converter의 등장으로 해당 문제들이 해결되었고, 기능 확장이 필요하면 Converter를 사용하면 된다.
실제 코드를 통해서 타입 컨버터를 이해해보자.
타입 컨버터 - Converter
일단, 가장 단순한 형태부터 시작해보자. 직접 우리가 컨버터를 만들고 등록을 해서 자유롭게 사용해보는 것이다.
문자를 숫자로 변환하는 컨버터를 만들어보자.
StringToIntegerConverter
package cwchoiit.converter.controller.converter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
@Slf4j
public class StringToIntegerConverter implements Converter<String, Integer> {
@Override
public Integer convert(String source) {
log.info("convert source = {}", source);
return Integer.valueOf(source);
}
}
Converter<S, T>를 구현한다. 여기서 패키지를 반드시 `org.springframework.core.convert.converter`로 사용해야 한다.
이 인터페이스는 convert()라는 메서드를 구현해야 한다. 이건 단순하게 Source를 Target 타입으로 변환하는 메서드이다.
문자를 숫자로 변환했다면, 숫자를 문자로 변환하는 컨버터도 만들어보자.
IntegerToStringConverter
package cwchoiit.converter.controller.converter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
@Slf4j
public class IntegerToStringConverter implements Converter<Integer, String> {
@Override
public String convert(Integer source) {
log.info("convert source = {}", source);
return String.valueOf(source);
}
}
굉장히 간단하게 구현할 수 있다. 이건 그냥 맛보기니까 이렇게 하면 된다는 정도만 이해하고 실제로 뭐랄까.. 진짜 의미있는 컨버터를 만들어야 이걸 왜 배우고 있지?에 대한 의문이 해소될 것이다. 그래서 어떤 애플리케이션이 사용자의 IP와 Port정보를 받아서 문자열로 변환하고 반대도 변환이 가능한 그런 기능이 필요하다고 가정해보자.
그럼 먼저, 변환할 수 있도록 변환 대상 객체가 필요하다.
package cwchoiit.converter.type;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@Getter
@EqualsAndHashCode
public class IpPort {
private String ip;
private int port;
public IpPort(String ip, int port) {
this.ip = ip;
this.port = port;
}
}
위 클래스는, `127.0.0.1:8080`과 같은 ip, port 정보를 이 객체로 변환해주기 위해 필요한 객체이다.
StringToIpPortConverter
package cwchoiit.converter.controller.converter;
import cwchoiit.converter.type.IpPort;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
@Slf4j
public class StringToIpPortConverter implements Converter<String, IpPort> {
@Override
public IpPort convert(String source) {
log.info("convert source = {}", source);
String[] split = source.split(":");
String ip = split[0];
int port = Integer.parseInt(split[1]);
return new IpPort(ip, port);
}
}
컨버터를 등록할 때는 StringToIntegerConverter와 같은 타입 컨버터를 명확하게 알아야 한다. 반면에 컨버터를 사용하는 입장에서는 타입 컨버터를 전혀 몰라도 된다. 타입 컨버터들은 모두 컨버전 서비스 내부에 숨어서 제공된다. 따라서 타입 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다. 물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다.
이 부분에서 인터페이스 분리 원칙 - ISP(Interface Segregation Principle)이 또 나오게 된다. 인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다. 무슨 말이냐면,
DefaultConversionService는 다음 두 인터페이스를 구현했다.
ConversionService: 컨버터 사용에 초점
ConversionRegistry: 컨버터 등록에 초점
이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다. 특히 컨버터를 사용하는 클라이언트는 ConversionService만 의존하면 되므로, 컨버터를 어떻게 등록하고 관리하는지 전혀 몰라도 된다. 결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게 된다. 이렇게 인터페이스를 분리하는 것을 ISP라고 한다.
스프링은 내부에서 ConversionService를 사용해서 타입을 변환한다. 예를 들어서, 앞서 살펴본 @RequestParam 같은 곳에서 이 기능을 사용해서 타입을 변환한다. 이제 컨버전 서비스를 스프링에 적용해보자.
스프링 컨버터에 만든 커스텀 컨버터 적용하기
스프링은 내부에서 ConversionService를 제공한다. 우리는 WebMvcConfigurer가 제공하는 addFormatters()를 사용해서 추가하고 싶은 컨버터를 추가하면 된다. 이렇게 하면 스프링은 내부에서 사용하는 ConversionService에 컨버터를 추가한다.
두번째 경로인 `/convert-ip`에 ipPort 라는 쿼리파라미터로 이렇게 넣어보자.
우리가 등록한 것을 잘 사용하고 있다. 근데 문자를 숫자로 변환하는 기능은 원래도 스프링이 제공했는데 우리가 등록한것을 사용하고 있다. 그 이유는 언제나 우리가 직접 등록한게 먼저이기 때문이다. 만약, StringToIntegerConverter를 등록한것을 빼버리면 원래 기존의 스프링이 가지고 있는 기본 컨버터가 동작할 것이다.
뷰 템플릿에 컨버터 적용하기
컨버터는 뷰 템플릿과도 상호작용한다. 한번 확인해보자!
ConverterViewController
package cwchoiit.converter.controller;
import cwchoiit.converter.IpPort;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ConverterViewController {
@GetMapping("/converter-view")
public String converterView(Model model) {
model.addAttribute("number", 10000);
model.addAttribute("ipPort", new IpPort("127.0.0.1", "8080"));
return "converter-view";
}
}
우선, 뷰를 보여주는 컨트롤러 하나를 새로 만들고, 다음과 같이 모델 객체에 number, ipPort를 넘겨주자.
number는 숫자 타입으로 넘어가고, ipPort는 IpPort라는 객체타입으로 넘어간다.
DefaultFormattingConversionService는 Formatter는 물론, Converter도 등록하고 사용할 수 있다.
그 이유는 DefaultFormattingConversionService는 ConversionService관련 기능을 상속받기 때문이다. 또한 ConversionService가 제공하는 convert를 사용해서 컨버터 또는 포맷터를 사용할 수 있다.
DefaultFormattingConversionService는 다음과 같은 상속 구조를 가지고 있다.
FormattingConversionService를 상속받는다. 그리고 이 FormattingConversionService는 GenericConversionService와 FormatterRegistry를 상속받는다. 그 위로 더 올라가면, ConversionService와 ConversionRegistry가 있다. 그렇기 때문에 등록은 물론 사용도 가능하게 되는 것이다.
참고로, 스프링 부트는 DefaultFormattingConversionService를 상속받은WebConversionService를 내부에서 사용한다.
포맷터 적용하기
이제 이 포맷터를 웹 애플리케이션에 적용해보자. 스프링이 제공하는 WebMvcConfigurer에서 작업하면 된다.
주의할 점은, 만약 컨버터로 문자를 숫자로, 숫자를 문자로 변환하는 컨버터를 등록하면 우선순위 때문에 포맷터가 적용되지 않는다. 우선순위는 컨버터가 포맷터보다 우선순위가 높기 때문에 숫자를 문자로, 문자를 숫자로 변환하는 우리가 만든 포맷터는 컨버터에 의해 적용되지 않는다. 그래서 나같은 경우 아예 해당 컨버터들은 등록하지 않고 지워버렸다.
그럼 이제 실행해보자. 이전에 사용했던 뷰 템플릿을 보여주는 컨트롤러이다.
@GetMapping("/converter-view")
public String converterView(Model model) {
model.addAttribute("number", 10000);
model.addAttribute("ipPort", new IpPort("127.0.0.1", 8080));
return "converter-view";
}
컨버터를 적용한 `number`가 "10,000" 이렇게 출력됐음을 알 수 있다. 포맷터가 적용된 것이다.
뷰 템플릿 뿐 아니라, @RequestParam과 같은 녀석들도 모두 적용된다. 아래 경로에 한번 테스트해보자.
분명 `form.setNumber(10000);`으로 값을 넣었지만, th:field를 사용해서 값을 출력해보니 `10,000`이렇게 보여진다. 포맷터가 잘 적용된 모습이다. LocalDateTime도 마찬가지.
[Submit]버튼을 클릭해서 POST 요청을 날려보면, 다음과 같이 보여진다.
@ModelAttribute도 포맷터가 잘 적용된 모습이다. 이렇게 각 객체의 필드별로 포맷을 자유자재로 정의할수도 있다. 애노테이션을 활용해서!
정리를 하자면
컨버터와 포맷터를 알아보았다. 컨버터를 사용하든, 포맷터를 사용하든 등록 방법은 달라도 사용할 때는 컨버전서비스를 통해서 일관성있게 사용할 수 있었다. 포맷터는 여기서 더 나아가서 애노테이션 기반으로도 포맷을 지정할 수가 있었다. 이로 인해 조금 더 편하고 유연하게 적용을 할 수 있었다.
주의할 점은, 메시지 컨버터(HttpMessageConverter)에는 컨버전 서비스가 적용되지 않는다. 특히 객체를 JSON으로 변환할 때 메시지 컨버터를 사용하면서 이 부분을 많이 오해하는데, HttpMessageConverter의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다. 예를 들어 JSON을 객체로 변환하는 메시지 컨버터는 내부에서 Jackson과 같은 라이브러리를 사용한다. 객체를 JSON으로 변환한다면 그 결과는 이 라이브러리에 달린 것이다. 따라서 JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야 한다. 결과적으로 이것은 컨버전 서비스와 전혀 관계가 없다.
컨버전 서비스는 @RequestParam, @ModelAttribute, @PathVariable, 뷰 템플릿 등에서 사용할 수 있다.
이것도 결론부터 말하자면, @ExceptionHandler, @ControllerAdvice로 API 예외 처리를 하면 된다. 근데 이 것들을 사용하기 앞서, API 예외 처리를 해 온 역사를 하나씩 알아보면서 이 두개의 애노테이션이 어떻게 끝판왕이 될 수 있는지를 좀 이해해보자.
API 예외 처리 - 시작
저번 포스팅에서는 뷰(오류 페이지 화면)예외 처리에 대해 알아보았다. 스프링 부트를 사용해서 아주 간단하게 오류 페이지 관련된 HTML 파일만 만들면 됐었다. API는 어떻게 처리해야 할까?
API는 생각할 내용이 더 많다. 오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정의하고, JSON으로 데이터를 내려주어야 한다.
이 역시 서블릿이 처리하는 예외 처리 방식부터 한번 시작해보자.
WebServerCustomizer
package cwchoiit.exception;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> {
@Override
public void customize(ConfigurableServletWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
}
}
이 코드를 다시 살려서, 등록해 놓으면 WAS에 예외가 전달되거나, response.sendError(...)가 호출되면 위 코드에서 등록한 예외 페이지 경로가 호출된다. 이제 API 관련 컨트롤러 하나를 만들자.
ApiExceptionController
package cwchoiit.exception.api;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("ex");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
단순히 회원을 조회하는 기능을 하나 만들고, 예외 테스트를 위해 URL에 전달된 `id`값이 `ex`라면 예외가 발생하도록 코드를 심어두었다.
Postman으로 테스트해보자.
HTTP Header에 Accept가 application/json인 것을 꼭 확인하자.
정상 케이스
정상 호출의 경우 위 사진과 같이 결과가 잘 출력된다. 이번엔 예외 케이스로 호출해보자.
예외 케이스
API를 요청했는데, 정상의 경우 API로 JSON 형식으로 데이터가 정상 반환된다. 그런데 오류가 발생하면 우리가 미리 만들어둔 오류 페이지 HTML이 반환된다. 이것은 기대하는 바가 아니다. 클라이언트는 정상 요청이든, 오류 요청이든 JSON이 반환되기를 기대한다. 웹 브라우저가 아닌 이상 HTML을 직접 받아서 할 수 있는 것은 별로 없다.
왜 이런 일이 발생하냐면, 서블릿 오류 처리 방식으로 WebServerCustomizer를 등록했고, 거기서 500에러인 경우`/error-page/500`을 재호출하게 정의했다. 그리고 이 경로는 우리가 이전에 만든 이 컨트롤러의 경로이다.
package cwchoiit.exception.servlet;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Controller
public class ErrorPageController {
//RequestDispatcher 상수로 정의되어 있음
public static final String ERROR_EXCEPTION = "javax.servlet.error.exception";
public static final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_type";
public static final String ERROR_MESSAGE = "javax.servlet.error.message";
public static final String ERROR_REQUEST_URI = "javax.servlet.error.request_uri";
public static final String ERROR_SERVLET_NAME = "javax.servlet.error.servlet_name";
public static final String ERROR_STATUS_CODE = "javax.servlet.error.status_code";
@RequestMapping("/error-page/404")
public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 404");
printErrorInfo(request);
return "error-page/404";
}
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 500");
printErrorInfo(request);
return "error-page/500";
}
private void printErrorInfo(HttpServletRequest request) {
log.info("ERROR_EXCEPTION: {}", request.getAttribute(ERROR_EXCEPTION));
log.info("ERROR_EXCEPTION_TYPE: {}", request.getAttribute(ERROR_EXCEPTION_TYPE));
log.info("ERROR_MESSAGE: {}", request.getAttribute(ERROR_MESSAGE));
log.info("ERROR_REQUEST_URI: {}", request.getAttribute(ERROR_REQUEST_URI));
log.info("ERROR_SERVLET_NAME: {}", request.getAttribute(ERROR_SERVLET_NAME));
log.info("ERROR_STATUS_CODE: {}", request.getAttribute(ERROR_STATUS_CODE));
log.info("dispatchType={}", request.getDispatcherType());
}
}
그런데 이 컨트롤러의 경우 `error-page/500`은 HTML 뷰를 반환한다. 그래서 저런 결과가 나온것이다.
문제를 해결하려면 오류 페이지 컨트롤러도 JSON 응답을 할 수 있도록 수정해야 한다.
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(
HttpServletRequest request, HttpServletResponse response) {
log.info("API errorPage 500");
Map<String, Object> result = new HashMap<>();
Exception ex = (Exception) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
result.put("status", request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE));
result.put("message", ex.getMessage());
Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
컨트롤러의 같은 경로를 처리하지만, produces = MediaType.APPLICATION_JSON_VALUE를 추가했다. 이건 어떤거냐면, 요청할 때 헤더에 Accept를 application/json으로 요청할 때 호출이 된다. 클라이언트가 내가 받을 응답은 JSON 형태인것만 이해할 수 있다라고 서버에 요청할 때 던져주면 그것을 처리하는 컨트롤러가 호출되는 것이다.
그래서 이대로 다시 실행해보면 아래와 같이 JSON 결과를 얻는다.
응답 데이터를 위해 Map을 만들고 status, message키에 값을 할당했다. Jackson 라이브러리는 Map을 JSON 구조로 변환할 수가 있다. 근데 ResponseEntity를 사용해서 응답하기 때문에 메시지 컨버터가 동작해서 클라이언트에 JSON이 반환된다.
이렇게 헤더에 Accept로 어떤 값을 주냐에 따라 컨트롤러에 produces를 지정해서 그때 그때 적절한 호출이 가능하다. 참고로 Accept에 `*/*`로 넣어 호출하면 그냥 아무것도 없는 기존에 원래 있던 경로가 호출되서 다시 HTML 화면이 보일 것이다. 왜냐하면, 둘 중 하나는 아예 딱 지정해서 APPLICATION/JSON만 가능한 경로이기 때문에 그 외 Accept는 호출되지가 않는다. 그래서 더 넓은 범위의 처리 가능한 경로를 찾기 때문에 produces가 없는 HTML 화면을 반환하는 메서드가 실행되는 것이다.
이렇게 가장 원시적인 방법으로 API 예외를 처리할 수가 있는데 솔직히 너무 불편하다. 어떤 에러일때 어떤 경로로 호출할 지 정의하는 WebServerCustomizer부터 시작해서 오류 화면을 반환해야 하는 경우와 JSON을 반환해야 하는 경우를 나눠서 처리하는 과정 등 여간 불편한게 아니다. 역시 이럴땐 스프링 부트가 도와주기 마련이다. 스프링 부트가 도와주는 방법을 하나씩 알아보자.
API 예외 처리 - 스프링 부트 기본 오류 처리
이제 스프링 부트가 기본으로 해주는 오류 처리를 한번 알아보자. 우선 서블릿 방식의 예외 처리를 위한 WebServerCustomizer를 다시 주석처리 하자. 아래처럼.
//@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> {
@Override
public void customize(ConfigurableServletWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
}
}
이렇게 해두면 아무런 커스터마이징 코드가 없기 때문에 스프링 부트가 기본으로 해주는 오류 처리를 사용하게 된다. 그리고 그게 이전에 배웠던 BasicErrorController였다. 그래서 이 컨트롤러의 기본 경로는 `/error`였고 `src/main/resources/templates/error` 안에 404.html, 4xx.html만 만들어두면 저 컨트롤러가 알아서 호출해주는 아주 강력한 기능이었다. 스프링 부트는 API도 기본 오류 처리를 저 BasicErrorController가 해주는데 한번 보자.
BasicErrorController 일부
...
@RequestMapping(
produces = {"text/html"}
)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
HttpStatus status = this.getStatus(request);
Map<String, Object> model = Collections.unmodifiableMap(this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
response.setStatus(status.value());
ModelAndView modelAndView = this.resolveErrorView(request, response, status, model);
return modelAndView != null ? modelAndView : new ModelAndView("error", model);
}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
HttpStatus status = this.getStatus(request);
if (status == HttpStatus.NO_CONTENT) {
return new ResponseEntity(status);
} else {
Map<String, Object> body = this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.ALL));
return new ResponseEntity(body, status);
}
}
...
여기보면, errorHtml과 error라는 두 개의 메서드가 있다. 에러가 발생해서 이 컨트롤러를 호출하면 HTML을 호출해야 하는 경우 저 errorHtml 메서드가 실행되는거고, API 예외를 처리하는 경우 저 error 메서드가 실행되는 것이다. 보면 errorHtml 메서드에 produces로 "text/html" 이라고 되어 있다. 그럼 다시 한번 아까 예외 테스트를 할 때 호출했던 경로로 호출해보면 어떻게 될까?
바로 이렇게 보여진다. 이게 스프링 부트의 기본 오류 처리이다. 이게 저 BasicErrorController의 error() 메서드가 반환하는 값이 되는 것이고. 그리고 여기 보여지는 값도 오류 화면 페이지에서 막 이것저것 보여줄 수 있게 설정한 것 처럼 더 추가할 수 있다.
위와 같이 쭉 뭐가 더 중요한 정보들이 나온다. 이건 사용하면 안된다고 했다. 다시 돌려 놓자.
스프링 부트가 기본으로 제공하는 BasicErrorController를 사용하면 뭐 다른것을 하지 않아도 이렇게 API 예외 처리도 가능하다. 그런데 HTML 파일을 반환하는 경우는 아주아주 유용하고 거의 이 기능 그대로를 가져다가 사용해서 그냥 templates/error 경로에 404, 4xx, 500.html 파일만 만들면 되지만 API는 조금 다르다. API는 공통으로 예외를 하나로 처리할 수 있는 경우가 거의 없고 회원 조회, 상품 등록, 상품 삭제 등등 각각의 API는 스펙이 천차만별이다. 그래서 각각의 API 마다마다 세밀한 스펙 정의와 함께 에러가 발생한 경우 그 에러 스펙 역시도 굉장히 가지각색이다.
그래서 API 예외 처리를 BasicErrorController로 하기에는 무리가 있고, 이 기능을 사용하지 않는다. 대신에 아주 매우 강력한 기능이 있다. 그 기능을 사용할건데 그 기능을 사용하기 위해 발전해 온 역사 하나하나를 살펴보자.
API 예외 처리 - HandlerExceptionResolver 시작
예외가 발생해서 서블릿을 넘어 WAS까지 예외가 전달된 경우, HTTP 상태 코드가 500으로 처리된다. 당연한 것이 WAS 입장에서는 어떤 예외가 발생했는지는 모르지만, 서버가 이 에러를 처리하지 못했으니 결국 나까지 에러가 올라왔구나 싶어서 서버의 내부 문제라고 판단하고 500으로 모든 에러를 처리해 버린다.
그러나, 발생하는 예외에 따라 400, 404 등 다른 상태코드로 처리하고 싶다. 뿐만 아니라 오류 메시지, 형식등을 API마다 다르게 처리하고 싶다.
상태 코드 변환
예를 들어, IllegalArgumentException을 처리하지 못해서 컨트롤러 밖으로 넘어가는 일이 발생하면 HTTP 상태 코드를 400으로 처리하고 싶다. 어떻게 해야 할까? 우선, 이 에러를 던질 수 있어야 한다. 컨트롤러를 살짝 수정해보자.
ApiExceptionController
package cwchoiit.exception.api;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("ex");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력값");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
예외 테스트 용 컨트롤러에서 PathVariable이 `bad`로 넘어온 경우 IllegalArgumentException을 터트린다.
이 상태에서 그냥 바로 실행해보면 이렇게 500 에러로 발생한다.
내가 원하는건 500에러가 아니라 사용자가 입력값을 잘못 넣었다고 말해주고 싶다. 400에러를 던지고 싶다.
이럴때 사용할 수 있는 것 중 하나가 HandlerExceptionResolver이다.
이 HandlerExceptionResolver를 사용하면, 다음 흐름으로 진행된다.
컨트롤러에서 예외가 발생한다.
예외가 DispatcherServlet까지 올라온다.
이 예외를 처리하기 위한 HandlerExceptionResolver를 찾는다.
찾았다면 해당 HandlerExceptionResolver에서 처리하는 예외 처리를 수행한다.
예외를 처리했으니 WAS에는 정상 응답으로 반환된다.
참고로, 이 HandlerExceptionResolver를 사용해도 인터셉터의 postHandle은 호출되지 않는다. 이 postHandle은 컨트롤러 레이어에서 예외가 발생하면 그냥 무조건 호출이 안되는 메서드이다.
사실 말로만 하면 무슨말인지 백퍼센트 감이 오지 않는다. 직접 사용해보자.
MyHandlerExceptionResolver
package cwchoiit.exception.resolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import java.io.IOException;
@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
try {
if (ex instanceof IllegalArgumentException) {
log.info("IllegalArgumentException resolver to 400");
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
return new ModelAndView();
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
HandlerExceptionResolver를 구현하는 클래스를 만든다.
여기서 구현해야 하는 메서드인 resolveException을 구현한다.
이 메서드 안에서 올라온 예외 타입마다 처리하는 방식을 분개한다. (한 클래스에서 해도 되고 클래스를 여러개 나누어서 모두 등록해도 상관은 없다)
만약, 올라온 예외 타입이 IllegalArgumentException인 경우, response.sendError(400, 예외 메시지)를 호출하여 WAS에게 전달한다.
비어있는 ModelAndView 객체를 반환한다.
WAS에게 response 객체 안에 400에러를 담아서 전달했으니 WAS는 이제 내부적으로 이 400에러를 처리할 수 있는 설정을 찾을 것이다. 지금 우리는 WebServerCustomizer와 같이 따로 커스텀하여 구현한 예외 처리 방식이 없기 때문에 스프링 부트가 기본으로 제공하는 BasicErrorController가 처리하게 된다. 그리고 이때, 요청 헤더의 Accept가 APPLICATION/JSON이므로 API 예외 처리 방식으로 원하는 상태코드와 메시지가 나가게 되는 것이다.
이 메서드는 반환 할 수 있는 방식이 3가지이다.
new ModelAndView(): 빈 ModelAndView 객체를 반환하면, API 예외 처리 방식으로 진행된다.
꽉 찬 ModelAndView(): 실제로 Model과 View를 꽉 채워서 반환하면 우리가 원하는 뷰를 반환한다. 예를 들면 4xx.html을 반환하게 할 수 있다.
null: null을 반환하는 경우, 이 HandlerExceptionResolver가 해결할 수 없는 예외라 판단하고 WAS까지 예외가 올라간다. 그래서 기존 예외가 그대로 던져진다.
WebMvcConfigurer를 구현하는 구현체에서 extendHandlerExceptionResolvers 메서드를 구현한다. 여기서 원하는 HandlerExceptionResolver를 추가하면 된다.
이렇게 한 후 실행해보면, 원하는 상태코드로 반환이 가능해진다.
API 예외 처리 - HandlerExceptionResolver 활용
그런데, 보면 알겠지만 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 `/error`를 호출하는 과정은 생각해보면 너무 비효율적이며 복잡하다. HandlerExceptionResolver를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 여기에서 문제를 깔끔하게 해결할 수 있다.
커스텀 예외 하나를 추가해보자.
UserException
package cwchoiit.exception.exception;
public class UserException extends RuntimeException {
public UserException() {
super();
}
public UserException(String message) {
super(message);
}
public UserException(String message, Throwable cause) {
super(message, cause);
}
public UserException(Throwable cause) {
super(cause);
}
protected UserException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
그래서 이제 이 예외도 하나 컨트롤러에서 추가해보자.
ApiExceptionController
package cwchoiit.exception.api;
import cwchoiit.exception.exception.UserException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("ex");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
이제 PathVariable로 `user-ex`가 들어오는 경우 방금 새로 만든 UserException이 호출되도록 했다.
이렇게 해 두고 HandlerExceptionResolver를 어떻게 사용할 수 있냐면, 다음과 같이 작성해보자.
UserHandlerExceptionResolver
package cwchoiit.exception.resolver;
import com.fasterxml.jackson.databind.ObjectMapper;
import cwchoiit.exception.exception.UserException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
private static final ObjectMapper objectMapper = new ObjectMapper();
@Override
public ModelAndView resolveException(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
try {
if (ex instanceof UserException) {
log.info("UserException resolver to 400");
String accept = request.getHeader("accept");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
if (accept != null && accept.contains("application/json")) {
Map<String, Object> errorResults = new HashMap<>();
errorResults.put("message", ex.getMessage());
errorResults.put("ex", ex.getClass());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(errorResults));
return new ModelAndView();
} else {
return new ModelAndView("error/500");
}
}
} catch (IOException e) {
log.error("Error while resolving exception", e);
}
return null;
}
}
던져진 예외가 UserException인 경우를 체크한다.
요청 헤더의 Accept값에 따라, 오류 페이지를 보여줄지 API 예외로 JSON 응답을 할지를 선택한다.
JSON의 경우:
응답 상태 코드를 400으로 지정한다.
JSON으로 처리해야 하는 경우, 응답 메시지로 `message`, `ex`정보를 담는다. 그리고 Content-Type, CharacterEncoding 정보를 각각 `application/json`, `UTF-8`로 지정한다.
getWriter()를 사용해서 그냥 여기서 응답 결과를 다 만들어서 WAS에게 전달한다.
빈 ModelAndView()를 반환한다.
오류 페이지를 보여주는 경우:
그냥 ModelAndView로 `error/500` 페이지를 호출하면 된다. (만약, 400으로 던지고 싶다면 `error/400` 또는 `error/4xx` 던지면 된다)
이렇게 이 HandlerExceptionResolver 안에서 응답 처리를 모두 다 해서 WAS에게 전달을 해주면, WAS는 만들어져있는 그 응답 처리 결과를 그대로 내보낸다. 그러면 sendError(...)와 달리, 다시 필터부터 서블릿으로 거슬러 올라가는 그 행위를 하지 않아도 된다.
응답 결과는 우리가 원하는대로 잘 나온다. 그리고 이게 정말 다시 거슬러 올라가서 예외를 처리하는 컨트롤러인 BasicErrorController를 재호출하는 과정이 없는지 보자. 예전에 로그를 찍는 인터셉터를 등록했으니까 그 결과를 보면 된다.
afterCompletion에서 예외가 발생한 경우 예외를 로그로 출력했는데 그 로그가 출력되지 않았다. 즉, 예외를 그냥 HandlerExceptionResolver에서 먹어버리고 WAS에게 어떻게 응답을 내보낼지 모든것을 다 정의해서 전달한 것이다.
이렇게 활용해서 비효율적인 서버 내부적인 재호출을 막을 순 있다. 그러나, 일단 이 방식은 사용하지 않을 것이다. 영원히. 왜냐하면 이것 또한 너무 귀찮다. 요청 헤더에서 Accept 값을 찾고, 인코딩 정보, Content-Type 등등 getWriter()로 응답 메시지 처리 등 귀찮다. 이럴때 역시 스프링이 도와준다. 지금부터 스프링이 제공하는 ExceptionResolver를 알아보자.
API 예외 처리 - 스프링이 제공하는 ExceptionResolver 1
스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같다.
HandlerExceptionResolverComposite에 다음 순서로 등록
ExceptionHandlerExceptionResolver
ResponseStatusExceptionResolver
DefaultHandlerExceptionResolver → 우선 순위가 가장 낮다.
우선순위가 가장 높은 ExceptionHandlerExceptionResolver는 @ExceptionHandler를 처리하는 녀석이다. 가장 중요하고 거의 모든 에러 처리를 이것으로 할 것이기 때문에 제일 마지막에 알아보겠다.
ResponseStatusExceptionResolver는 Http 상태 코드, 원인을 지정해서 사용하는 @ResponseStatus 애노테이션을 처리한다.
결국 그 메서드는 그냥 우리 했던거랑 똑같이 response.sendError(...) 호출하는 것 뿐이다. 즉, 이 방식도 WAS까지 갔다가 다시 서버 내부적으로 재호출하는 코드인 것.
직접 HandlerExceptionResolver를 구현해보니까, 스프링이라는 위대하고 거대한 프레임워크가 만든 코드가 이해가 되는 것이다. 그래서 이 과정이 굉장히 소중하다고 생각하는데.. 여튼, 이 ResponseStatusExceptionResolver가 또 재밌는게 있다. 메시지 기능을 사용할 수가 있다. 아래 처럼 `reason`값에 메시지 코드를 넣어보자.
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}
결국 MessageSource 객체로 메시지가 있는지 확인해서 있으면 사용하고 없으면 기본값 그대로 사용하는 코드 그냥 호출할 뿐이다.
아무튼 예외 객체에 @ResponseStatus 애노테이션을 달아서 사용하는 이런 방식이 ResponseStatusExceptionResolver이다.
근데 이건 우리가 만든 예외는 가능한데 라이브러리 내부에 있는 예외 객체나, 기존에 자바가 가지고 있는 예외 객체에는 우리가 코드를 수정할 수가 없다. 그러니까 @ResponseStatus 애노테이션을 달 수가 없다는 것이다. 그런데 그 예외를 사용하고 싶을 때가 있다. 그런 경우에 어떤게 가능하냐면, ResponseStatusException 예외를 던져도 ResponseStatusExceptionResolver가 처리해준다. 다음 처럼.
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
DefaultHandlerExceptionResolver
이 녀석은 스프링 내부에서 발생하는 스프링 예외를 해결한다. 대표적인 게, 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다. 그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다. HTTP에서는 이런 경우는 400에러로 내보내야 맞다. DefaultHandlerExceptionResolver는 이것을 500오류가 아니라 400 오류로 변경해준다.
아래와 같이 컨트롤러에 경로 하나를 추가해보자.
@GetMapping("/api/default-handler-ex")
public String defaultHandlerEx(@RequestParam Integer data) {
return "OK";
}
이 상태에서 `data`라는 쿼리 파라미터에 숫자를 넣으면 아무런 문제가 안되는데 문자를 넣는 경우, 이제 TypeMismatchException이 발생하게 된다.
그래서 호출을 해보면, 이렇게 에러가 발생하는데 400으로 반환된다. 이것을 DefaultHandlerExceptionResolver이 녀석이 해준 것이다. 이 코드도 내부로 들어가보면, 이렇게 무수히 많은 스프링 내부적 오류를 처리하는 코드가 있다.
...
@Nullable
protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {
try {
if (ex instanceof ErrorResponse errorResponse) {
ModelAndView mav = null;
if (ex instanceof HttpRequestMethodNotSupportedException theEx) {
mav = this.handleHttpRequestMethodNotSupported(theEx, request, response, handler);
} else if (ex instanceof HttpMediaTypeNotSupportedException theEx) {
mav = this.handleHttpMediaTypeNotSupported(theEx, request, response, handler);
} else if (ex instanceof HttpMediaTypeNotAcceptableException theEx) {
mav = this.handleHttpMediaTypeNotAcceptable(theEx, request, response, handler);
} else if (ex instanceof MissingPathVariableException theEx) {
mav = this.handleMissingPathVariable(theEx, request, response, handler);
} else if (ex instanceof MissingServletRequestParameterException theEx) {
mav = this.handleMissingServletRequestParameter(theEx, request, response, handler);
} else if (ex instanceof MissingServletRequestPartException theEx) {
mav = this.handleMissingServletRequestPartException(theEx, request, response, handler);
} else if (ex instanceof ServletRequestBindingException theEx) {
mav = this.handleServletRequestBindingException(theEx, request, response, handler);
} else if (ex instanceof MethodArgumentNotValidException theEx) {
mav = this.handleMethodArgumentNotValidException(theEx, request, response, handler);
} else if (ex instanceof HandlerMethodValidationException theEx) {
mav = this.handleHandlerMethodValidationException(theEx, request, response, handler);
} else if (ex instanceof NoHandlerFoundException theEx) {
mav = this.handleNoHandlerFoundException(theEx, request, response, handler);
} else if (ex instanceof NoResourceFoundException theEx) {
mav = this.handleNoResourceFoundException(theEx, request, response, handler);
} else if (ex instanceof AsyncRequestTimeoutException theEx) {
mav = this.handleAsyncRequestTimeoutException(theEx, request, response, handler);
}
return mav != null ? mav : this.handleErrorResponse(errorResponse, request, response, handler);
}
if (ex instanceof ConversionNotSupportedException theEx) {
return this.handleConversionNotSupported(theEx, request, response, handler);
}
if (ex instanceof TypeMismatchException theEx) {
return this.handleTypeMismatch(theEx, request, response, handler);
}
if (ex instanceof HttpMessageNotReadableException theEx) {
return this.handleHttpMessageNotReadable(theEx, request, response, handler);
}
if (ex instanceof HttpMessageNotWritableException theEx) {
return this.handleHttpMessageNotWritable(theEx, request, response, handler);
}
if (ex instanceof MethodValidationException theEx) {
return this.handleMethodValidationException(theEx, request, response, handler);
}
if (ex instanceof BindException theEx) {
return this.handleBindException(theEx, request, response, handler);
}
if (ex instanceof AsyncRequestNotUsableException) {
return this.handleAsyncRequestNotUsableException((AsyncRequestNotUsableException)ex, request, response, handler);
}
} catch (Exception var19) {
Exception handlerEx = var19;
if (this.logger.isWarnEnabled()) {
this.logger.warn("Failure while trying to resolve exception [" + ex.getClass().getName() + "]", handlerEx);
}
}
return null;
}
...
이 중에 딱 이 부분을 보자.
if (ex instanceof TypeMismatchException theEx) {
return this.handleTypeMismatch(theEx, request, response, handler);
}
보면, handleTypeMismatch 라는 메서드를 실행한다. 들어가보면 이렇게 되어 있다.
마찬가지로 ModelAndView를 반환하는 우리가 직접 해봤던 HandlerExceptionResolver랑 똑같이 생긴 것이다.
거기다가 sendError(400)을 호출해서, WAS에게 400에러임을 알리고 WAS는 처리할 수 있는 녀석을 찾아 재호출할 뿐이다.
빈 ModelAndView()를 반환해서 API 예외임을 명시하고 있다.
아무것도 커스텀 하지 않았다면 BasicErrorController를 호출해서 상태코드 400을 적용하고, 스프링의 기본 오류 메시지를 출력할 것이다.
이렇게 하나씩 알아봤는데, 지금까지 한 내용은 결국 HandlerExceptionResolver를 사용하는 방식이다. 근데 이 방식의 경우, 직접 구현해봐서 알겠지만 response 객체에 직접 데이터를 넣어야 했다. 서블릿 코드 짜듯이 말이다. 이건 일단 너무 불편하고, ModelAndView를 반환하는 것부터 약간 이상하다. API 예외 인데 왜 ModelAndView를 반환하나? 그래서 실질적으로 HandlerExceptionResolver를 사용해서 API 예외 처리를 하기엔 좀 별로다.
이런 불편함을 한방에 해결하는 혁신적인 기능을 스프링이 제공한다. @ExceptionHandler와 @ControllerAdvice. 이것을 드디어 알아보자.
API 예외 처리 - @ExceptionHandler
HTML 화면 오류 vs API 오류
웹 브라우저에 HTML 화면을 제공할 땐 오류가 발생하면 BasicErrorController를 사용하는 게 편하다. 그냥 단순히 5xx, 4xx 관련된 오류 화면을 `src/main/resources/templates/error` 경로에 만들면 되니까. 그 이후는 BasicErrorController가 알아서 다 해준다.
그런데, API는 각 시스템 마다 응답의 모양도 다르고, 스펙도 모두 다르다. 매우 세밀한 제어가 필요한데 공통의 모양을 처리하는 건 적합하지 않다. 그래서 BasicErrorController가 처리해주는 API 예외도 원하는 모양이 되지 않고, 그렇다고 HandlerExceptionResolver를 직접 구현하는건 매우 불편하고 귀찮다. 그리고 이건 모든 개발자들이 동시에 느낀 현상일 것이다. 그렇기 때문에 끝판왕이 등장했으니까.
스프링은 이 API 예외 처리 문제를 해결하기 위해, @ExceptionHandler라는 애노테이션을 사용하는 매우 강력하고 편리한 예외 처리 기능을 제공한다. 그리고 이 애노테이션을 처리해주는 ExceptionHandlerExceptionResolver가 있다. 스프링은 이 ExceptionHandlerExceptionResolver를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver중에 우선순위도 제일 높다. 실무에서는 API 예외 처리는 대부분 이 기능을 사용한다.
이제 사용해보자. 우선 예외 응답을 위한 객체를 하나 만들자.
ErrorResult
package cwchoiit.exception.exhandler;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
새로운 컨트롤러 하나도 만들자.
ApiExceptionV2Controller
package cwchoiit.exception.api;
import cwchoiit.exception.exception.UserException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/v2")
public class ApiExceptionV2Controller {
@GetMapping("/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("ex");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
이런 컨트롤러가 있을 때, 만약 `/api/v2/members/bad`로 요청한 누군가에게 적절하게 API 예외 응답을 보여주려면 어떻게 하면 되냐? 아래 코드를 보자.
package cwchoiit.exception.api;
import cwchoiit.exception.exception.UserException;
import cwchoiit.exception.exhandler.ErrorResult;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/v2")
public class ApiExceptionV2Controller {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalArgumentExceptionHandler(IllegalArgumentException ex) {
log.error("Illegal argument", ex);
return new ErrorResult("Illegal argument", ex.getMessage());
}
@GetMapping("/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("ex");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
@ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 참고로 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalArgumentExceptionHandler(IllegalArgumentException ex) {
log.error("Illegal argument", ex);
return new ErrorResult("Illegal argument", ex.getMessage());
}
그러니까, 이렇게 만들어두면 이 컨트롤러에서 발생한 IllegalArgumentException과 그 하위 예외는 모두 얘가 처리해버리는 것이다.
이번엔 UserException을 잡는 @ExceptionHandler를 만들어보자.
@ExceptionHandler
public ResponseEntity<ErrorResult> userExceptionHandler(UserException ex) {
log.error("UserException", ex);
ErrorResult errResult = new ErrorResult("UserException", ex.getMessage());
return new ResponseEntity<>(errResult, HttpStatus.BAD_REQUEST);
}
이번엔 생김새가 좀 다르다. @ExceptionHandler에 어떤 예외 클래스인지 정의하지 않아도 된다. 파라미터에 넣은 예외로 그 부분을 대체할 수 있다.
그리고 반환 타입을 ResponseEntity로 지정하면, 리턴할 때 내가 원하는 상태코드를 직접 넣을 수 있다. @ResponseStatus를 사용하지 않아도 된다.
둘 중 어떤 생김새로 만들어도 무방하다는 얘기이다.
이번엔 Exception이라는 가장 상위의 예외를 처리하는 @ExceptionHandler를 만들어보자.
@ExceptionHandler
public ResponseEntity<ErrorResult> exceptionHandler(Exception ex) {
log.error("Exception", ex);
ErrorResult errResult = new ErrorResult("Exception", ex.getMessage());
return new ResponseEntity<>(errResult, HttpStatus.INTERNAL_SERVER_ERROR);
}
이렇게 만들어 두면, 자세하게 명시한 IllegalArgumentException, UserException을 제외한 예외가 발생했을 때 이 @ExceptionHandler가 호출된다.
그러니까 RuntimeException이 발생하면 이 녀석이 호출되는 것이다.
그리고 또 이 방식의 좋은점은, response.sendError(...)를 호출하는 것처럼 WAS까지 왔다가 다시 서버 내부적으로 호출이 한번 더 일어나는 그런 방식이 아니라 여기서 그냥 바로 끝난다는 점이다. 그래서 실행 흐름을 보자.
실행흐름
컨트롤러 호출 결과 IllegalArgumentException 예외가 발생했다.
예외가 발생했으므로 ExceptionResolver가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.
ExceptionHandlerExceptionResolver는 해당 컨트롤러에 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 있는지 확인한다.
해당 메서드가 있으므로 실행한다. @RestController이므로 이 메서드 또한 @ResponseBody가 적용된다. 따라서 HTTP 컨버터가 사용되고 응답이 JSON으로 반환된다.
그리고 이 @ExceptionHandler를 사용해서, 뷰 처리도 가능하긴 하다. 다음 코드를 참고해보자. (물론, 이건 그냥 가능하다는 것이지 그냥 오류 화면 처리는 BasicErrorController를 이용하는게 제일 좋다.)
@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
log.info("exception e", e);
return new ModelAndView("error/500");
}
최종 코드를 한번 보자.
package cwchoiit.exception.api;
import cwchoiit.exception.exception.UserException;
import cwchoiit.exception.exhandler.ErrorResult;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/v2")
public class ApiExceptionV2Controller {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalArgumentExceptionHandler(IllegalArgumentException ex) {
log.error("Illegal argument", ex);
return new ErrorResult("Illegal argument", ex.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExceptionHandler(UserException ex) {
log.error("UserException", ex);
ErrorResult errResult = new ErrorResult("UserException", ex.getMessage());
return new ResponseEntity<>(errResult, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler
public ResponseEntity<ErrorResult> exceptionHandler(Exception ex) {
log.error("Exception", ex);
ErrorResult errResult = new ErrorResult("Exception", ex.getMessage());
return new ResponseEntity<>(errResult, HttpStatus.INTERNAL_SERVER_ERROR);
}
@GetMapping("/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("ex");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
이렇게 해당 컨트롤러에서 처리하고 싶은 예외를 @ExceptionHandler로 등록만 해놓으면 끝인것이다. 너무 편리하다. 근데 한가지 아쉬운 점이 보인다. 이 컨트롤러 뿐 아니라 모든 컨트롤러에 적용하고 싶다. 그럴때 바로 @ControllerAdvice를 사용하면 된다.
API 예외 처리 - @ControllerAdvice
@ExceptionHandler를 사용해서 예외를 깔끔하게 처리할 수 있었다. 근데 불편한 점이 두가지 있다.
정상 처리 코드와 예외 처리 코드가 섞여있다.
여러 컨트롤러에서 동시에 사용하고 싶다.
이런 불편함을 이 @ControllerAdvice가 해결해준다.
ExControllerAdvice
package cwchoiit.exception.exhandler.advice;
import cwchoiit.exception.exception.UserException;
import cwchoiit.exception.exhandler.ErrorResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalArgumentExceptionHandler(IllegalArgumentException ex) {
log.error("Illegal argument", ex);
return new ErrorResult("Illegal argument", ex.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExceptionHandler(UserException ex) {
log.error("UserException", ex);
ErrorResult errResult = new ErrorResult("UserException", ex.getMessage());
return new ResponseEntity<>(errResult, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler
public ResponseEntity<ErrorResult> exceptionHandler(Exception ex) {
log.error("Exception", ex);
ErrorResult errResult = new ErrorResult("Exception", ex.getMessage());
return new ResponseEntity<>(errResult, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@RestControllerAdvice 애노테이션이 달린 클래스 하나를 만든다. @RestControllerAdvice이거는 @ControllerAdvice + @ResponseBody가 합쳐진 애노테이션이다. 즉, REST API를 위한 ControllerAdvice라고 생각하면 된다.
아까 컨트롤러에서 작성했던 @ExceptionHandler 메서드들을 모두 여기로 이동시킨다.
그리고 아까 컨트롤러의 모습은 이렇게 변경됐다.
ApiExceptionV2Controller
package cwchoiit.exception.api;
import cwchoiit.exception.exception.UserException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/v2")
public class ApiExceptionV2Controller {
@GetMapping("/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("ex");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
완전 깔끔하게 컨트롤러와 예외 처리가 분리됐다. 그러나 동작은 기존과 동일하게 동작한다.
@ControllerAdvice
대상으로 지정한 여러 컨트롤러에 @ExceptionHandler 기능을 부여해주는 역할을 한다.
대상을 지정하지 않으면 모든 컨트롤러에 적용된다 (글로벌 적용)
대상 컨트롤러 지정 방법
// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}
// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class,AbstractController.class})
public class ExampleAdvice3 {}
정리를 하자면
길고 긴 시간 끝에 결국 API 예외 처리하는 방법을 완벽하게 정리했다. @ExceptionHandler, @ControllerAdvice를 통해서 예외 처리를 깔끔하고 완벽하게 할 수 있었다.
결론을 먼저 말하겠다. 스프링 부트를 사용한다면 이 방식을 사용하지 말자. 구식이기도 하며 더 불편하다. 그렇지만, 모든 개념은 다 태초부터 이해해야 왜 지금 현재 사용하는 기능이 나타났고 이 기능이 어떤 불편함을 해결했는지를 이해할 수 있기 때문에 이 서블릿 예외 처리부터 시작할 뿐이다.
스프링을 사용해서 예외 처리를 편리하게 다루기 앞서, 서블릿 컨테이너는 예외 처리를 어떻게 하는지부터 알아보자.
서블릿은 다음 2가지 방식으로 예외 처리를 지원한다.
Exception
response.sendError(HTTP 상태 코드, 오류 메시지)
Exception
자바의 메인 메서드를 직접 실행하는 경우, main 이라는 이름의 스레드가 실행된다. 실행 도중에 예외를 잡지 못하고 처음 실행한 main() 메서드를 넘어서 예외가 던져지면, 예외 정보를 남기고 해당 스레드는 종료된다.
웹 애플리케이션은 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행된다. 애플리케이션에서 예외가 발생했는데 어디선가 try - catch로 예외를 잡아서 처리하면 아무 문제가 없다. 그런데 만약 애플리케이션에서 예외를 잡지 못하고 서블릿 밖으로까지 예외가 전달되면 어떻게 동작할까?
WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
결국 톰캣같은 WAS까지 예외가 전달된다. WAS는 예외가 올라오면 어떻게 처리해야 할까? 한번 테스트 해보자.
먼저 스프링 부트가 제공하는 기본 예외 페이지가 있는데 이건 꺼두자.
application.properties
server.error.whitelabel.enabled=false
ServletExController
package cwchoiit.exception.servlet;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import java.io.IOException;
@Slf4j
@Controller
public class ServletExController {
@GetMapping("/error-ex")
public void errorEx() {
throw new RuntimeException("예외 발생");
}
}
실행해보면 다음처럼 Tomcat이 기본으로 제공하는 오류 화면을 볼 수 있다.
Exception의 경우, 서버 내부에서 처리할 수 없는 오류가 발생한 것으로 생각해서 HTTP 상태 코드를 500으로 반환한다.
이번에는 아무 경로나 호출해보자. (http://localhost:8080/nopage)
Tomcat이 기본으로 제공하는 404 오류 화면을 볼 수 있다.
response.sendError(HTTP 상태코드, 오류 메시지)
오류가 발생했을 때, HttpServletResponse가 제공하는 sendError라는 메서드를 사용해도 된다. 이것을 호출한다고 당장 예외가 발생하는 것은 아니다. 그러나 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다. 이 메서드를 사용하면 HTTP 상태코드와 오류 메시지도 추가할 수 있다.
ServletExController
package cwchoiit.exception.servlet;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import java.io.IOException;
@Slf4j
@Controller
public class ServletExController {
@GetMapping("/error-ex")
public void errorEx() {
throw new RuntimeException("예외 발생");
}
@GetMapping("/error-404")
public void error404(HttpServletResponse response) throws IOException {
response.sendError(404, "404 오류");
}
@GetMapping("/error-500")
public void error500(HttpServletResponse response) throws IOException {
response.sendError(500);
}
}
이번에는 sendError 메서드를 사용해서 서블릿 컨테이너에게 오류가 발생했다고 알리는 Path 두 개를 만들었다.
sendError를 호출하면, response 내부에는 오류가 발생했다는 상태를 저장해둔다. 그리고 서블릿 컨테이너는 고객에게 응답 전에 response에 sendError()가 호출되었는지 확인한다. 그리고 호출되었다면 설정한 오류코드에 맞추어 기본 오류 페이지를 보여준다.
마찬가지로 해당 경로로 요청을 날려보면, 다음 사진처럼 Tomcat이 제공하는 오류 페이지를 볼 수 있을것이다.
그러나, 서블릿 컨테이너가 제공하는 기본 예외 처리 화면은 사용자가 보기에 많이 불편하다. 그래서 의미있는 오류 화면을 제공해보자.
서블릿 예외 처리 - 오류 화면 제공
서블릿 컨테이너가 제공하는 기본 예외 처리 화면은 고객 친화적이지 않다. 서블릿이 제공하는 오류 화면을 커스텀할 수 있는 기능을 사용해보자. 서블릿은 Exception이 발생해서 서블릿 밖으로 전달되거나 또는 response.sendError()가 호출되었을 때, 각각의 상황에 맞춘 오류 처리 기능을 제공한다.
@GetMapping("/error-ex")
public void errorEx() {
throw new RuntimeException("예외 발생");
}
이제는 Tomcat이 기본으로 제공하는 못생긴 화면이 아닌 개발자가 직접 만든 오류 화면으로 보여진다. (물론 이것도 못생긴 것 같다..)
위에서 잠깐 얘기했지만 이 오류 페이지가 보여지는 작동 원리는 Exception이든 sendError()이든 WAS까지 올라오면 예외 커스텀 처리를 했는지 보고, 했다면 그 경로에 대한 컨트롤러를 재호출하는 한번 왔다가 다시 가는 이런 방식이다. 아래에서 좀 더 자세히 말해보자.
서블릿 예외 처리 - 오류 페이지 작동 원리
서블릿은 Exception이 발생해서 서블릿 밖으로 전달되거나, response.sendError()가 호출되었을 때, 설정된 오류 페이지를 찾는다.
예를 들어서, RuntimeException 예외가 WAS까지 전달이 되면, WAS는 오류 페이지 정보를 확인한다. 확인해보니 RuntimeException의 오류 페이지로 `/error-page/500`이 지정되어 있다. WAS는 오류 페이지를 출력하기 위해 `/error-page/500`을 다시 요청한다.
오류 페이지 요청 흐름
WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View
기존에 만들어 둔 에러 페이지에 대한 컨트롤러에 여러 오류 정보를 출력하는 메서드 printErrorInfo()를 만들었다.
해당 메서드를 각 오류 페이지를 보여주는 경로에 추가해서 확인해보자.
500 에러 화면 호출 시
2024-09-07T13:14:20.932+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : ERROR_EXCEPTION TYPE: class java.lang.RuntimeException
2024-09-07T13:14:20.932+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : ERROR_EXCEPTION MESSAGE: Request processing failed: java.lang.RuntimeException: 예외 발생
2024-09-07T13:14:20.933+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : ERROR_REQUEST_URI: /error-ex
2024-09-07T13:14:20.933+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : ERROR_SERVLET_NAME: dispatcherServlet
2024-09-07T13:14:20.933+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : ERROR_STATUS_CODE: 500
2024-09-07T13:14:20.933+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : dispatcherType: ERROR
2024-09-07T13:14:20.933+09:00 INFO 99797 --- [nio-8080-exec-1] c.exception.servlet.ErrorPageController : errorPage 500
404 에러 화면 호출 시
2024-09-07T13:15:03.975+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : ERROR_EXCEPTION TYPE: null
2024-09-07T13:15:03.975+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : ERROR_EXCEPTION MESSAGE: 404 오류
2024-09-07T13:15:03.975+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : ERROR_REQUEST_URI: /error-404
2024-09-07T13:15:03.975+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : ERROR_SERVLET_NAME: dispatcherServlet
2024-09-07T13:15:03.975+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : ERROR_STATUS_CODE: 404
2024-09-07T13:15:03.976+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : dispatcherType: ERROR
2024-09-07T13:15:03.976+09:00 INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController : errorPage 404
이런식으로, 에러를 받은 서블릿이 해당 에러에 대한 정보를 request에 담아서 에러 화면을 출력하는 컨트롤러를 호출한다.
서블릿 예외 처리 - 필터
그럼 서블릿이 예외 처리를 하는 원리를 이해하면 이런 문제가 있어 보인다.
최초에 사용자가 요청을 해서 필터를 거쳤는데 컨트롤러 호출 시 에러가 발생해서 다시 이게 올라오면 WAS 내부에서 다시 한번 에러를 처리하는 컨트롤러를 호출하기 위해 필터 - 서블릿 - 인터셉터 - 컨트롤러를 재호출할텐데 그럼 필터나 인터셉터가 두번이나 호출될 것 같다. 맞다. 그래서 만약 로그인 체크 관련 필터나 인터셉터가 적용됐다면 그 체크를 두번이나 할 것이다. 서블릿은 이런 문제를 해결하기 위해 DispatcherType이라는 추가 정보를 제공한다.
DispatcherType
필터는 이런 경우를 위해서 dispatcherType 이라는 옵션을 제공한다. 아까 위에서 서블릿이 에러 정보와 관련된 내용들을 request에 담아서 에러 처리 컨트롤러를 재호출한다고 했는데, 그때 찍었던 로그 중 이런 부분이 있었다.
필터를 사용하려면 필터를 등록했어야 했다. 여기서 유심히 볼 부분은 setDispatcherTypes()이다. 지금 보면 REQUEST, ERROR 타입을 추가해줬다. 즉, 저 두개의 타입일 때 이 필터가 적용될 것이라는 의미이다. 그럼 여기서 ERROR일땐 필터를 적용하기 싫다면? 빼버리면 된다. 그리고 기본값이 REQUEST만 있는 형태이다.
이 상태에서 다시 요청을 해보자. 다음과 같이 로그가 찍힐 것이다.
보면 최초에 내가 브라우저를 통해 요청한 것은 DispatcherType이 REQUEST라고 찍혔다.
그렇게 요청에 대한 응답이 돌아왔는데 여기서 response.sendError(404, "404 오류")가 저장된 상태라 WAS는 해당 정보를 보고 이 에러를 처리할 컨트롤러를 찾고 그 컨트롤러에 요청을 내부적으로 다시 한번 더 한다.
그래서 실제로 그렇게 요청을 더 했다는 것을 DispatcherType이 ERROR로 찍힌 필터의 로그가 한번 더 찍힌것을 볼 수 있다. 이렇듯 만약, 적용할 DispatcherType에 ERROR도 추가하면 WAS가 내부적으로 오류 처리 페이지를 호출할때도 필터가 적용된다.
정리를 하자면,
그래서, 오류 페이지 경로도 필터를 적용할 것이 아니면 기본값 그대로를 사용하면 된다. 그게 아니라 오류 페이지 요청 전용 필터를 적용하고 싶으면 DispatcherType.ERROR만 적용해도 된다.
그럼 필터는 이렇게 알아봤는데 인터셉터는 어떻게 동작할까?
서블릿 예외 처리 - 인터셉터
인터셉터도 마찬가지로 두번 호출이 된다. 그리고 이 인터셉터도 역시 개발자가 원하면 두번 다 호출시킬수도 아닐수도 있다.
인터셉터를 등록하기 위해 WebMvcConfigurer를 구현한다. 해당 인터페이스의 메서드인 addInterceptors를 사용해 등록하자.
여기서 인터셉터를 등록할때 이 인터셉터는 필터와 달리 DispatcherType을 지정하는 건 없다. 대신, 오류 페이지를 보여주는 컨트롤러의 경로를 제외시켜버리면 된다. 위 excludePathPatterns(..., "/error-page/**")에서 한 것처럼 말이다.
이 또한 마찬가지로, 에러 페이지 호출 컨트롤러에도 인터셉터를 적용하고자 하면 제외 경로에서 빼버리면 될 것이다.
그래서 만약, 저 "/error-page/**"을 빼고 인터셉터를 등록한 후 `/error-page/ex`를 브라우저에서 호출한다면,
브라우저에선 이렇게 에러 화면이 보일 것이고, 로그는 다음과 같이 보일 것이다.
첫번째 사용자 요청은 DispatcherType이 REQUEST이다.
에러가 발생했다. 에러가 발생하면 인터셉터는 postHandle이 호출되지 않는다고 했다. 그래서 호출되지 않았고 afterCompletion만 호출된 모습이다. 그리고 그 메서드 안에서는 에러가 있는 경우 에러를 출력하기 때문에 첫번째 에러가 찍혔다.
인터셉터에서 WAS까지 에러가 올라왔다. WAS에서 에러를 출력했다. 이게 두번째 에러 메시지다. 그리고 WAS는 에러를 처리하는 오류 페이지 정보를 확인하고 해당 컨트롤러를 내부적으로 재호출한다.
그때 인터셉터는 DispatcherType이 ERROR이다. 그리고 해당 오류 페이지를 화면에 출력한다. 해당 오류 페이지 처리를 하는 컨트롤러 경로에 오류 메시지를 찍는 메서드가 있었다. 그 메서드 때문에 세번째 에러 메시지가 찍힌 모습이다.
정리를 하자면,
필터든 인터셉터든 서블릿이 처리하는 예외 동작은 WAS까지 예외가 올라온 다음 WAS에서 해당 예외를 처리할 수 있는 커스텀 경로가 있는지 확인하고 있다면, 해당 경로로 재호출을 서버 내부적으로 실행한다. 여기서 필터와 인터셉터가 또 호출되는 것을 막고자 한다면 필터는 DispatcherType을 지정할 때 ERROR는 빼면 된다. 그리고 이게 기본값이라고 했다. 반면, 인터셉터는 DispatcherType을 지정하는 메서드는 따로없다. 대신 오류 페이지를 처리하는 컨트롤러에 대한 경로를 제외시키면 된다.
그래서 서블릿은 이렇게 예외를 처리하곤 한다. 근데 생각해보자. 불편하다. 어떤게 불편하냐면 new ErrorPage()로 특정 에러에 대한 오류 처리를 위한 경로를 직접 재정의 하는것과 그 경로에 대한 컨트롤러를 만드는 것이 불편하다. 이것이 자동으로 이루어지면 좋을것 같다. 그것을 스프링 부트가 해준다. 이제 스프링 부트를 사용할 때 오류 페이지를 처리하는 방법을 알아보자.
스프링 부트 - 오류 페이지 시작
이제 위에 한 것들은 전부 잊자! 왜냐하면 스프링 부트는 마법처럼 모든것을 다 자동으로 해주기 때문에!
일단, 잊기 전에 ㅋㅋㅋ 지금까지 예외 처리를 하기 위해 어떤 과정을 거쳤는지 다시 복기해보자.
WebServerCustomizer를 만들어서 예외 종류에 따라 new ErrorPage()를 추가했다.
예외 처리용 컨트롤러(ErrorPageController)를 만들었다.
스프링 부트가 이 두가지를 모두 자동으로 해준다.
ErrorPage를 자동으로 등록한다. 이때, `/error`라는 경로로 기본 오류 페이지를 설정한다.
그래서 서블릿 밖으로 예외가 발생하거나, response.sendError(...)가 호출되면 모든 오류는 `/error`를 호출하게 된다.
BasicErrorController라는 스프링 컨트롤러를 자동으로 등록한다. 이 컨트롤러가 이제 `/error`를 매핑해서 처리하는 컨트롤러가 될 것이다.
이제 위에서 한 것들은 다 싹 다 지워버리고 우리는 딱 뭐만 하면 되냐면, 오류 페이지만 등록하면 된다.
"페이지 어디에 등록해요?" → `src/main/resources/templates/error` 경로에 만들면 된다.
그리고 또 기가막힌게 404에러가 발생했을 때 내가 404.html이라는 파일이 있으면 해당 오류 페이지를 불러오겠지만,
따로 404.html을 정의한 것은 없고 4xx.html 이라는 파일만 있으면 이 4xx.html 파일을 불러온다.
그래서 404, 400, 403 뭐 이런 페이지를 하나씩 일일이 다 만들어도 되고? 귀찮고 공통으로 묶어도 될 것 같다 싶으면 4xx.html 파일을 만들면 되는것이다.
오류 정보에 대한 내용들이 이렇게 보여진다. 이건 스프링 부트가 자동으로 만들고 등록해주는 BasicErrorController가 정보를 넘겨준 것이다. 근데 보면, null이 많다. 왜 그러냐면, 기본적으로 이런 내용들을 사용자에게 노출하는건 안 좋은 행위이다. 개발자가 아닌 사람들은 무슨말인지도 모를거니와 해커들이 이런 내용을 보면 오히려 역이용해서 나쁜짓을 할 수 있기 때문에 말이다. 그래서 기본은 이 값을 다 지워버리지만 굳이 굳이 노출시킬수도 있다.
이런 내용이 나오면, 해커들은 "아 이 라이브러리 사용하는구나? 이 라이브러리는 이런 취약점이 있으니까 이런점을 공격해야겠다." 뭐 이런식으로 말이다. 아무튼 이런 짓은 하지말고 이게 가능하다는 점만 이해하고 넘어가자.
모든 에러는 서버의 로그로 관리되어야 한다. 사용자나 외부로 노출시키면 안된다.
정리
이제 스프링 부트를 사용하면, 공통 오류 페이지를 아주아주 쉽게 만들어 낼 수 있다. 서블릿이 처리하는 과정을 이해하고 나서 보니 "아 이런 과정들이 자동으로 되어 있구나."를 이해할 수 있게 됐다. 그리구 혹시 모르지 않나? 스프링 사용 못하는 환경에서 개발을 할 수도..? 그럴땐 이 포스팅에서 배운 서블릿을 활용한 오류 페이지 처리를 하면 된다.
애노테이션을 만들었으면 이 애노테이션이 어떤 것을 할지 정의해줘야 한다. 그러기 위해 HandlerMethodArgumentResolver를 구현한다.
supportsParameter(MethodParameter methodParameter): 이 메서드에서 `true`를 반환해야만 resolveArgument가 실행된다. 이 메서드에서 우선 1.애노테이션이 @Login인지 확인한다.2. 애노테이션이 달린 파라미터의 타입이 Member인지 확인한다. 이 두개를 모두 만족하면 resolveArgument 메서드를 실행한다.
resolveArgument(): 여기서는 해당 파라미터에 어떤 값을 넣어줄 지 결정한다. 우리가 원하는 건 세션에 있는 로그인 한 사용자를 파라미터에 넣으려고 한다. 그래서 파라미터로 제공되는 NativeWebRequest를 HttpServletRequest로 캐스팅하여 세션을 가져와서 세션에 로그인 한 유저를 저장할 때 사용했던 키를 통해 로그인 한 유저를 반환한다. 없다면 null을 반환하면 된다.
로그에 대한 내용을 담은 글이었는데, 여기에 추가적으로 하나 더 아주 유용한 기능을 기록해보려 한다.
서버를 운영하던 중 사용자의 요청이 어디서부터 시작해서 어디서 끝나는지 로그가 무수히 찍히는 상황에서는 인지하기가 쉽지가 않다.
어떤 작업을 했고 어떤 과정을 거쳤는지, 에러가 났다면 어디서 시작해서 어떤 에러가 발생했는지 로그 자체는 남지만 과정의 흐름을 이해하기가 쉽지 않았다. 그래서 요청부터 응답까지의 사용자 흐름을 한눈에 파악할 수 있는 좋은 기능인 Logback MDC를 사용하는 방법을 기록하려고 한다.
우선, 요즘 대부분의 Slf4j 구현체는 Logback을 사용한다. 물론 log4j를 사용할수도 있다.
나는 Logback을 사용할거고 여기서 제공하는 MDC 기능을 사용하겠다. 쉽게 말해 사용자의 요청부터 응답까지의 로그를 하나의 흐름으로 확인하는 방법이다.
logback-spring.xml 파일 작성
우선, logback 설정 파일을 좀 수정해줘야 한다.
logback-spring.xml vs logback.xml
스프링 부트에서 로그 설정 파일을 정의할 때, logback.xml 또는 logback-spring.xml 파일을 사용할 수 있다. 차이점은:
logback-spring.xml은 스프링 부트만의 확장 기능을 사용할 수 있다. 예를 들어, profile 기반 설정 등을 지원한다.
logback.xml은 순수 Logback 설정 파일로, 스프링 부트의 확장 기능은 사용할 수 없다.
따라서, 스프링 부트를 사용 중이라면, logback-spring.xml 파일을 사용하는게 더 유연한 설정을 할 수 있다.
이 파일은 어디에 이미 있는게 아니다. 그래서 필요하다면 개발자가 직접 생성해서 만들어야 한다. 그 위치는 `src/main/resources`이다. 여기에 이 파일을 만들어두면 스프링 부트가 자동으로 인식하여 시작될 때 이 로그 설정을 적용한다.
우선, pattern 부분을 수정해야 한다. 내가 커스텀해서 사용자의 요청부터 응답까지의 한 과정을 동일한 식별자로 남길 수 있는 부분을 추가해야 한다. 나는 [%X{identifier}] 라고 값을 넣었다.
%X{key}는 Logback에서 로그 출력 시 MDC에 저장된 특정 키의 값을 참조하는데 사용된다.
그래서 MDC.put("key", value)로 저장한 값을 참조하게 된다.
각 패키지 별로 로그 레벨을 설정할 수 있다. `root`는 기본으로 info로 설정하고 그 내부적으로 필요한 패키지만 레벨을 수정하는게 좋을것이다. `root`부터 debug로 설정하면 별별 로그가 다 찍힐테니까.
내가 만드는 프로젝트의 패키지의 루트는 `hello`이다. 그래서 <logger>를 추가적으로 만들어서 `hello`패키지의 로그 레벨을 debug로 설정했다.
`additivity="false"`는 해당 Logger 에서 처리한 로그가 상위 Logger 에 전파되지 않도록 설정하는 기능이다. 이게 왜 필요하냐면, `hello`는 결국 `root` 하위 패키지이다. 그래서 저렇게 `root`와 `hello`에 대한 <logger>를 만들고 이 속성을 추가하지 않으면 두 개의 logger가 같은 로그를 찍는다. 그게 싫으니 `hello`레벨에서 찍은 로그는 `root`에서는 찍지 않아도 된다는 의미이다.
HandlerInterceptor로 요청마다 식별자 설정
스프링에서 사용할 수 있는 인터셉터를 사용해서 요청이 들어올 때 서블릿에 도착하기 전 고유 식별자를 만들어 모든 흐름의 로그에 같은 식별자를 남기자.
모든 요청에 대한 응답이 끝나고 사용자에게 돌아가기 전에 호출되는 afterCompletion() 메서드에서 만들어진 MDC 값을 초기화한다.
여기서 이런 의문이 든다면 굉장히 좋은 의문이다.
"요청이 동시에 두개가 들어왔을 때 요청A는 일찍 끝나서 MDC.clear()가 호출됐을 때 요청B는 계속 처리중이라 중간에 `identifer`의 값이 날라가는 것 아닌가?"
→ MDC(Mapped Diagnostic Context)는 스레드 로컬 기반으로 동작한다. 즉, MDC에 저장된 데이터는 각 요청마다 개별 스레드에 저장되므로, 한 요청에서 MDC.clear()가 호출되더라도 다른 요청의 MDC 데이터에는 영향을 미치지 않는다. 그래서 안심해도 된다.
이렇게 인터셉터를 만들었으면, 이제 인터셉터를 등록하면 된다.
WebMvcConfigurer에서 인터셉터 등록
package hello.login;
import hello.login.web.interceptor.LoggingInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoggingInterceptor());
}
}
하나의 요청에는 언제나 동일한 식별자가 기록됨을 확인
[2024-09-04 12:29:06] [http-nio-8080-exec-1] [8fd24009-(CLIENT-IP)-0:0:0:0:0:0:0:1] DEBUG c.g.controller.HealthzController - [healthz] Health Check
[2024-09-04 12:29:06] [http-nio-8080-exec-1] [8fd24009-(CLIENT-IP)-0:0:0:0:0:0:0:1] DEBUG c.gemmachat.service.HealthzService - [healthz] Wow condition is good 🎉
사용자가 요청을 했고 그 요청이 처리하는 과정이 계속해서 같은 식별자인 `8fd24009-(CLIENT-IP)-0:0:0:0:0:0:0:1`로 기록되고 있음을 확인할 수 있다. 나는 내가 내 서버에 요청했기 때문에 0:0:0:0:0:0:0:1(localhost)로 찍히고 있다.
이렇게 설정해두면, 요청부터 응답까지의 모든 과정을 동일한 식별자로 기록해서 한눈에 한 요청이 어떤 작업부터 어떤 흐름으로 무엇을 했는지 보기가 편하고 에러가 발생하더라도 어디서 출발해서 어떤 부분에서 에러를 마주했고 그 에러가 무엇인지 로그를 통해 트래킹하는게 쉬워진다.
보너스. 서블릿 필터로 적용
"어? 저는 스프링 인터셉터 싫은데요? 서블릿 필터로는 안되나요?" → 됩니다! (굳이? 라는 생각이 들지만)
Filter를 구현한다. 동일하게 구현하면 된다. 다만, Filter는 doFilter 메서드 안에서 MDC.clear()도 응답을 사용자에게 내보내기 전에 처리해줘야 한다.
WebConfig
package hello.login;
import hello.login.web.filter.LogFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean<LogFilter> logFilter() {
FilterRegistrationBean<LogFilter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LogFilter());
filterFilterRegistrationBean.setOrder(1);
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
}
서블릿 필터를 등록한다.
실행결과
[2024-09-04 15:59:48] [http-nio-8080-exec-3] [bed864ac-f818-4ef3-a2fc-8fd249a06da2-(CLIENT-IP)-0:0:0:0:0:0:0:1] INFO hello.login.web.item.ItemController - HI
이전 포스팅에서 세션을 활용해 로그인 관련 기능을 적용했다. 그런데 한가지 문제가 남아있다. 로그인 기능 자체는 잘 적용했지만 로그인 되지 않은 사용자가 특정 URI만 알고 있다면 해당 URI로 직접 요청해서 바로 들어갈 수 있다. 로그인 된 사용자만 볼 수 있는 화면도 그렇게 들어갈 수 있는 상태이다. 이것을 해결하려면 필터 기능을 사용해서 요청이 컨트롤러에 닿기 전 먼저 로그인 됐는지 확인을 하면 된다. 그리고 그때 필터라는 기능을 사용할 수 있다.
서블릿 필터는 서블릿이 지원하는 수문장이다. 필터의 특성은 다음과 같다.
필터 흐름
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
필터를 적용하면, 필터가 호출된 다음 서블릿이 호출된다. 그래서 모든 고객의 요청 로그를 남기는 요구사항이 있다면 필터를 적용하는 방법을 고려할 수 있다. 참고로 필터는 특정 URL 패턴에 적용할 수 있다. `/*` 이라고 하면 모든 요청에 필터가 적용된다. 참고로 스프링을 사용한다면 여기서 말하는 서블릿은 디스패처 서블릿이라고 생각하면 된다.
필터 제한
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출X) //비 로그인 사용자
필터에서 적절하지 않은 요청이라고 판단하면, 거기에서 끝을 낼 수 있다. 그래서 로그인 여부를 체크하기에 딱 좋다.
이 부분이 제일 중요하다. 다음 필터가 있으면 다음 필터를 호출하고, 필터가 없으면 서블릿을 호출한다. 만약 이 로직을 호출하지 않으면 다음 단계로 진행되지 않는다.
서블릿을 호출하고 모든 작업이 다 끝나면 다시 이 필터로 돌아온다. 응답은 역순이다. 그래서 WAS를 거쳐 다시 고객한테로 응답이 돌아가는 방식이다. 그러면 이 필터로 돌아왔을 때 WAS로 응답이 돌아가기 전 filterChain.doFilter(request, response) 이후에 있는 코드들이 실행된다. 여기서는 finally 구문이 실행될 것이다.
필터를 이렇게 만들면 끝난게 아니다. 필터를 만들고 필터를 등록해야 한다.
WebConfig
package hello.login;
import hello.login.web.filter.LogFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean<LogFilter> logFilter() {
FilterRegistrationBean<LogFilter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LogFilter());
filterFilterRegistrationBean.setOrder(1);
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
}
필터를 등록하는 방법은 여러가지가 있지만, 스프링 부트를 사용한다면 FilterRegistrationBean을 사용해서 등록하면 된다.
setFilter(new LogFilter()); → 등록할 필터를 지정한다.
setOrder(1) → 필터는 체인으로 동작하기 때문에 순서가 필요하다. 낮을수록 먼저 동작한다.
addUrlPatterns("/*") → 필터를 적용할 URL 패턴을 지정한다. 한번에 여러 패턴을 지정할 수 있다.
이 상태로 우리의 서비스에 요청을 날려보면 로그가 이렇게 남게 된다.
2024-09-04 11:27:50.577 INFO 74694 --- [nio-8080-exec-7] hello.login.web.filter.LogFilter : REQUEST [5cde931b-fc7e-4a08-a9a8-5f841529a891][/items/add]
2024-09-04 11:27:50.608 INFO 74694 --- [nio-8080-exec-7] hello.login.web.item.ItemController : errors=org.springframework.validation.BeanPropertyBindingResult: 3 errors
Field error in object 'item' on field 'price': rejected value [null]; codes [NotNull.item.price,NotNull.price,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.price,price]; arguments []; default message [price]]; default message [must not be null]
Field error in object 'item' on field 'quantity': rejected value [null]; codes [NotNull.item.quantity,NotNull.quantity,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.quantity,quantity]; arguments []; default message [quantity]]; default message [must not be null]
Field error in object 'item' on field 'itemName': rejected value []; codes [NotBlank.item.itemName,NotBlank.itemName,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.itemName,itemName]; arguments []; default message [itemName]]; default message [must not be blank]
2024-09-04 11:27:50.613 INFO 74694 --- [nio-8080-exec-7] hello.login.web.filter.LogFilter : RESPONSE [5cde931b-fc7e-4a08-a9a8-5f841529a891][/items/add]
참고로, 실무에서 HTTP 요청 시 같은 요청의 로그에 모두 같은 식별자를 자동으로 남기는 방법은 `logback mdc`로 검색해보자.
서블릿 필터 - 인증 체크
이제 인증 관련 필터를 만들어보자. 위에서 만든 로그 필터는 필터의 동작을 알아보기 위한 맛보기였다.
미인증 사용자는 로그인 화면으로 리다이렉트 한다. 그런데 로그인 이후에 다시 홈으로 이동해버리면 원하는 경로를 다시 찾아가야하는 불편함이 있다. 예를 들어, 상품 관리 화면을 보려고 들어갔다가 로그인 화면으로 이동하면 로그인 이후에 다시 상품 관리 화면으로 들어가는 것이 좋다. 이런 부분이 개발자 입장에서는 좀 귀찮을 수 있어도 사용자 입장으로 보면 편리한 기능이다. 이런 기능을 위해 현재 요청한 경로인 requestURI를 `/login`에 쿼리 파라미터로 함께 전달한다. `/login` 컨트롤러에서 로그인 성공 시 해당 경로로 이동하는 기능을 추가로 개발해주면 된다.
return;
여기가 중요하다. 필터는 더는 진행하지 않는다. 이후 필터는 물론, 서블릿, 컨트롤러가 더는 호출되지 않는다. 앞서 redirect를 사용했기 때문에 redirect가 응답으로 적용되고 요청이 끝난다.
이제 이 필터를 등록하자.
package hello.login;
import hello.login.web.filter.LogFilter;
import hello.login.web.filter.LoginCheckFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean<LogFilter> logFilter() {
FilterRegistrationBean<LogFilter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LogFilter());
filterFilterRegistrationBean.setOrder(1);
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
//@Bean
public FilterRegistrationBean<LoginCheckFilter> loginCheckFilter() {
FilterRegistrationBean<LoginCheckFilter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LoginCheckFilter());
filterFilterRegistrationBean.setOrder(2);
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
}
위에서 먼저 등록한 LogFilter와 같이 등록한다.
RedirectURL 처리
@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
BindingResult bindingResult,
@RequestParam(defaultValue = "/") String redirectURL,
HttpServletRequest req) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 세션이 있으면 세션 반환, 없으면 신규 세션을 생성
HttpSession session = req.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:" + redirectURL;
}
위에서 설명한 대로 사용자가 로그인에 성공하면 원래 진입하려고 했던 경로로 다시 보내주면 사용자 입장에서는 아주 편리한 기능이다. 그래서 로그인 처리 컨트롤러에서 @RequestParam을 이용해서 아까 쿼리 파라미터로 넘긴 `redirectURL`을 받는다. 없을 수도 있으니 defaultValue는 `/`로 지정한다.
그리고 마지막에 return "redirect:" + redirectURL; 이렇게 리다이렉트한다.
이렇게 필터와 로그인 처리를 해두고 실행해보자. 이제 특정 URI를 알고 있어도 로그인하지 않았다면 로그인 화면으로 리다이렉트 될 것이다. 필터가 잘 동작하고 있는것이다. 필터를 사용해도 충분히 원하는 기능을 구현할 수 있지만 스프링이 제공하는 인터셉터라는 기능이 있다. 아주아주 강력한 기능이고 스프링을 사용한다면 이 인터셉터를 사용하면 더 좋을 수 있다. 이 인터셉터에 대해 알아보자.
인터셉터
스프링에서 제공하는 인터셉터도 서블릿 필터와 같이 웹과 관련된 공통 관심 사항을 효과적으로 해결할 수 있는 기술이다.
서블릿 필터가 서블릿이 제공하는 기술이라면, 스프링 인터셉터는 스프링 MVC가 제공하는 기술이다. 둘다 웹과 관련된 공통 관심사항을 처리하지만, 적용되는 순서와 범위 그리고 사용방법이 다르다.
스프링 인터셉터의 흐름
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에 존재한다.
스프링 인터셉터는 스프링 MVC가 제공하는 기능이기 때문에 결국 디스패처 서블릿 이후에 등장하게 된다. 스프링 MVC의 시작점이 디스패처 서블릿이라고 생각해보면 이해가 될 것이다.
스프링 인터셉터에도 URL 패턴을 적용할 수 있는데 서블릿 URL 패턴과는 다르고, 매우 정밀하게 설정할 수 있다.
스프링 인터셉터의 제한
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터(적절하지 않은 요청이라 판단, 컨트롤러 호출X) // 비 로그인 사용자
인터셉터에서 적절하지 않은 요청이라고 판단하면 거기에서 끝을 낼 수 있다. 그래서 로그인 여부를 체크하기에 딱 알맞다.
스프링 인터셉터를 사용하려면 HandlerInterceptor 인터페이스를 구현하면 된다.
preHandle: 컨트롤러 호출 전에 실행되는 메서드
postHandle: 컨트롤러 호출 후 실행되는 메서드
afterCompletion: 요청 완료 이후에 실행되는 메서드
서블릿 필터의 경우, 단순히 request, response만 제공했지만, 인터셉터는 어떤 컨트롤러(handler)가 호출되는지 호출 정보도 받을 수 있고 어떤 modelAndView가 반환되는지도 알 수 있다.
스프링 인터셉터 정상 호출 흐름
preHandle: 컨트롤러 호출 전에 호출된다. (더 정확히는 핸들러 어댑터 호출 전에 호출된다.) 중요한 건 이 preHandle의 반환값이 true이면 다음으로 진행하고, false이면 더는 진행하지 않는다. false인 경우 나머지 인터셉터는 물론이고 핸들러 어댑터도 호출되지 않는다.
postHandle: 컨트롤러 호출 후에 호출된다.
afterCompletion: 뷰가 렌더링 된 이후에 호출된다.
스프링 인터셉터 예외 상황
preHandle: 컨트롤러 호출 전에 호출된다.
postHandle: 컨트롤러에서 예외가 발생하면 postHandle은 호출되지 않는다.
afterCompletion: 항상 호출된다. 예외가 발생하면 파라미터로 받는 `ex`에 예외를 받아 어떤 예외가 발생했는지 로그를 출력할 수 있다.
afterCompletion은 예외가 발생하던 발생하지 않던 호출되기 때문에 예외와 무관하게 공통 처리가 필요한 부분은 여기서 처리하면 된다. 그리고 이 메서드는 예외 정보(ex)도 받는다.
정리를 하자면
인터셉터는 스프링 MVC 구조에 특화된 필터 기능을 제공한다고 이해하면 된다. 스프링 MVC를 사용하고, 특별히 필터를 꼭 사용해야 하는 상황이 아니라면 인터셉터를 사용하는 것이 더 편리하다.
request.setAttribute("uuid", uuid); → 서블릿 필터의 경우 doFilter 메서드 안에서 모든것을 처리하기 때문에 상관없지만 인터셉터의 경우 호출 시점이 분리되어 있기 때문에 preHandle에서 지정한 값을 postHandle, afterCompletion에서 사용하려면 어딘가에 담아두어야 한다. 인터페이스도 싱글톤처럼 사용되기 때문에 멤버변수를 사용하면 멀티스레드 환경에서 위험하다. 따라서 request에 담는다.
return true; → true를 반환해야만 다음 인터셉터 또는 컨트롤러로 넘어간다.
위에서 말했지만, 어떤 컨트롤러를 호출할지 이미 preHandle 메서드가 호출되는 시점은 알고 있기 때문에 컨트롤러 정보도 알 수 있고 컨트롤러 정보를 통해 어떤 작업을 할 수 있음을 보여주기 위해 다음 코드를 작성했다.
정말 신기하게도, 어떤 컨트롤러의 어떤 메서드를 호출하는지에 대한 정보도 출력되는 것을 볼 수 있다.
postHandle에서는 만약 뷰를 렌더링한다면, Model 정보와 View 정보도 출력한다.
스프링의 URL 경로
인터셉터 등록할 때 경로를 지정했었는데, 서블릿 기술이 제공하는 URL 경로와 완전히 다르다. 더욱 자세하고 세밀하게 설정할 수 있다.
? 한 문자 일치
* 경로(/) 안에서 0개 이상의 문자 일치
** 경로 끝까지 0개 이상의 경로(/) 일치
{spring} 경로(/)와 일치하고 spring이라는 변수로 캡처
{spring:[a-z]+} matches the regexp [a-z]+ as a path variable named "spring" {spring:[a-z]+} regexp [a-z]+ 와 일치하고, "spring" 경로 변수로 캡처
{*spring} 경로가 끝날 때 까지 0개 이상의 경로(/)와 일치하고 spring이라는 변수로 캡처
/pages/t?st.html — matches /pages/test.html, /pages/tXst.html but not /pages/
toast.html
/resources/*.png — matches all .png files in the resources directory
/resources/** — matches all files underneath the /resources/ path, including /
resources/image.png and /resources/css/spring.css
/resources/{*path} — matches all files underneath the /resources/ path and
captures their relative path in a variable named "path"; /resources/image.png
will match with "path" → "/image.png", and /resources/css/spring.css will match
with "path" → "/css/spring.css"
/resources/{filename:\\w+}.dat will match /resources/spring.dat and assign the
value "spring" to the filename variable
참조 링크
스프링 인터셉터 - 인증 체크
이제 인터셉터를 활용해서 인증을 체크해보자! 이걸 사용하면 서블릿 필터 생각도 안날것이다.
두번째 인터셉터를 등록한다. 일단 모든 경로에 이 인터셉터를 적용한다음 excludePathPatterns로 인증이 필요없는 경우를 걸러버리면 끝이다. 너무 편리하다.
정리를 하자면
서블릿 필터를 사용하거나 스프링 MVC의 인터셉터를 사용해서 인증 처리를 공통적으로 할 수 있다. 그러나, 둘 다 사용해본 입장에서 인터셉터를 두고 필터를 사용할 필요가 없다는 게 느껴진다. 이렇게 인터셉터를 알아보았다. 다음 포스팅에서는 ArgumentResolver를 사용해서 세션에 있는 로그인 한 유저를 굉장히 간편하게 가져올 수 있는 방법을 알아보자. 이것을 배우면 세션에 있는 로그인 한 유저를 간편하게 가져오는 방법 뿐만 아니라, 컨트롤러에서 공통 작업이 필요한 경우 전부 다 응용할 수 있다.
package hello.login.domain.login;
import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
public Member login(String loginId, String password) {
return memberRepository.findByLoginId(loginId)
.filter(member -> member.getPassword().equals(password))
.orElse(null);
}
}
로그인 시도를 하는 메서드인 login(String loginId, String password)에서는 MemberRepository를 통해 loginId로 유저를 찾고 찾은 유저의 패스워드가 전달받은 password와 일치하다면 그 유저를 반환한다. 찾지 못했다면 null을 반환한다.
Stream API를 사용했다. Stream은 이제 자바 코드를 작성하면서 거의 필수불가결한 요소이다.
LoginController
package hello.login.web.login;
import hello.login.domain.login.LoginService;
import hello.login.domain.member.Member;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginForm") LoginForm loginForm) {
return "login/loginForm";
}
@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// TODO: 로그인 성공 처리
return "redirect:/";
}
}
로그인 폼을 보여주는 GetMapping과 로그인 정보를 입력하고 요청하는 PostMapping 두 가지가 있다.
마찬가지로 저번 시간에 배운 Bean Validation을 통해 데이터 검증 처리를 한다. 검증에 실패하면 다시 로그인 화면으로 사용자를 보낸다.
검증에 성공하면, 로그인 서비스를 통해 유저를 찾는다. 만약 찾은 유저가 null이라면, BindingResult에 로그인 실패 에러를 ObjectError로 담아 다시 로그인 폼을 보여준다.
찾은 유저가 있다면 로그인 성공 처리를 해줘야 한다. 그 부분은 아직 남겨두었다.
그리고 홈 화면으로 리다이렉트 시킨다.
LoginForm
package hello.login.web.login;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
@Data
public class LoginForm {
@NotEmpty
private String loginId;
@NotEmpty
private String password;
}
여기까지가 이제 가장 기본적으로 필요한 구성들이다. 이제 하나씩 로그인 처리를 알아가보자!
쿠키를 사용하여 로그인 처리하기
결론부터 말하면 이 방식은 사용하면 안된다. 하지만 이 과정을 먼저 진행해보면서 왜 사용하면 안되고 어떤 방식이 이를 해결하는지 이해해보자. 쿠키는 브라우저와 서버간 상태를 유지할 수 있는 방법 중 하나이다. HTTP는 기본이 Stateless이다. 즉, 상태를 저장하지 않는다는 의미이다. 그러면 사용자가 로그인을 했을때 이 사용자가 누구인지 서버가 인증/인가를 하려면 어딘가 사용자가 제공한 정보를 보관해야 한다. 그 방법이 쿠키라는 것이다.
쿠키의 방식
그래서 서버에서 로그인에 성공하면 응답을 돌려줄 때 HTTP 응답에 쿠키를 담아 전달한다. 그럼 사용자는 브라우저에 해당 URL 관련된 모든 사이트에 쿠키를 보관하여 어떤 요청을 할 때마다 서버에 이 쿠키를 같이 담아 요청한다.
로그인 시도
사용자가 로그인한다.
로그인에 성공하면 서버는 쿠키를 만들어 사용자에게 응답에 전달한다.
전달받은 쿠키를 브라우저에 저장한다.
이후 클라이언트가 서버에 쿠키를 지속적으로 전달
저장된 쿠키를 동일한 사이트에 요청을 할 때 지속적으로 서버에 쿠키를 전송한다.
서버에서는 받은 쿠키를 통해 사용자를 인증/인가처리 한다.
쿠키의 종류
영속 쿠키: 만료 날짜를 입력하면 그게 영속 쿠키이고, 해당 날짜까지 유지가 된다.
세션 쿠키: 만료 날짜를 생략하면 그게 세션 쿠키이고, 브라우저 종료시 까지만 유지된다.
참고로, 세션 쿠키는 HTTP 세션이 아니다. 세션 쿠키는 쿠키 종류이고 이름이다.
쿠키를 사용해 로그인 처리
그럼 이제 로그인 성공 시 쿠키를 생성해서 응답에 같이 내보내자.
@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
BindingResult bindingResult,
HttpServletResponse res) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
res.addCookie(idCookie); // 쿠키에 시간을 주지 않으면 브라우저 종료 시 쿠키가 삭제 = 세션 쿠키 (세션 쿠키는 HTTP 세션이랑 다른 개념)
return "redirect:/";
}
로그인에 성공한 다음, 쿠키를 new Cookie()로 생성한다.
쿠키는 Key/Value 한 쌍이다. 그래서 Key는 `memberId`로, Value는 로그인 한 유저의 ID를 넣었다. (id는 loginId와 다른 것)
굉장히 간단하게 로그인과 로그아웃 기능을 쿠키를 사용해 구현해봤다. 그러나, 이 쿠키를 사용한 방식은 심각한 보안 문제가 있다. 그래서 쿠키를 사용해서 절대로 로그인 기능을 구현하지 않는다. 그 이유가 뭘까?
쿠키의 보안 문제
쿠키값은 임의로 변경을 할 수 있다.
클라이언트가 쿠키를 강제로 변경이 가능하고 변경하면 다른 사용자가 된다. 예) `memberId=1` → `memberId=2`로 변경
쿠키에 보관된 정보는 훔쳐갈 수 있다.
memberId도 굉장히 민감한 정보로 그대로 노출되어 있지만, 더 민감한 정보를 담았다면? (예: 주민등록번호, 신용카드 정보)그대로 훔쳐갈 수 있다.
헤커가 쿠키를 한번 훔쳐가면 그 쿠키로 지속적으로 악의적인 요청을 할 수 있다.
서버에서 원하는 시점에 해당 쿠키를 강제로 끊어버릴 수 없기 때문에 브라우저가 종료되기 전 또는 쿠키의 시간이 만료되기 전까지 모든짓을 다 할 수 있다.
대안은 이렇다.
쿠키엔 중요한 값을 노출시키면 안된다. 사용자 별 예측 불가능한 임의의 토큰값을 노출하고 서버에서 토큰과 사용자 ID를 매핑해서 인식한다. 그리고 서버에서 토큰을 관리한다.
토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능해야 한다.
해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게 유지하게 하고, 해킹이 의심되는 경우 서버에서 토큰을 강제로 제거해서 토큰 무효화를 할 수 있어야 한다.
세션을 사용하여 로그인 처리하기
위 쿠키의 문제를 한방에 해결하는 방법이 바로 이 '세션'이다. 그리고 실제로 웹 애플리케이션 서비스들은 대부분 이 세션을 사용한다.
세션을 사용해서 어떻게 위 문제를 해결한다는 걸까?
세션을 사용한 흐름
사용자가 로그인을 한다.
로그인 정보를 서버에서 받는다.
로그인 정보를 통해 서버에서는 사용자를 찾는다.
사용자를 찾은 경우, 그 누구도 이해할 수 없는 랜덤문자열을 생성해서 세션 저장소에 해당 회원 정보와 같이 저장한다.
이 상태에서 사용자에게 응답에 쿠키로 방금 위에서 생성한 그 누구도 이해할 수 없는 랜덤문자열을 전송한다.
그럼 이제 클라이언트는 쿠키로 저장하고 있는 값이 이 랜덤문자열이 된다.
사용자는 이제 해당 사이트에 어떤 요청을 하더라도 쿠키에 저장된 랜덤문자열을 서버로 전송한다.
서버에서는 해당 문자열을 받아 세션 저장소에서 해당 문자열을 찾는다.
찾았다면 그 값과 매핑된 회원 정보를 통해 현재 사용자가 누구인지 인증/인가한다.
이렇게 로그인을 구현하면 위 쿠키를 사용했을 때 문제점들이 모두 해결된다.
쿠키 값을 변조 가능하다 → 예상 불가능한 임의의 랜덤문자열이기 때문에 변조가 불가능하다. 하더라도 그 값은 유효하지 않다.
쿠키에 보관하는 정보는 클라이언트 해킹 시 털릴 가능성이 있다 → 털린 값은 랜덤문자열이기 때문에 민감한 정보가 하나도 없다.
쿠키 탈취 후 원하는만큼 계속해서 재사용 할 가능성이 있다 → 해커가 이 토큰을 훔쳐가더라도 유효 시간을 짧게 설정하여 지속적으로 재사용 가능하지 못하도록 막고, 해킹 의심시에는 서버에서 언제든지 이 토큰을 삭제해버릴 수 있다.
이게 바로 세션이라는 방식을 사용한 로그인 처리 기법이다. 이제 세션을 사용해서 로그인 처리할 수 있도록 코드를 변경해보자.
세션 사용하여 로그인 처리 - 세션 직접 만들기
당연히 세션을 지원한다. 그러나, 한번 직접 만들어보는 것부터 해보자. 세션의 기능은 딱 3가지가 필요하다.
세션 생성
임의의 랜덤 값 생성
세션 저장소에 값 저장
해당 랜덤 값을 응답 쿠키로 생성해서 클라이언트에 전달
세션 조회
클라이언트가 요청 시 전달한 쿠키에 있는 랜덤값을 통해 세션 저장소에서 보관한 값 조회
세션 만료
클라이언트가 요청 시 전달하는 쿠키의 값을 세션 저장소에서 삭제
SessionManager
package hello.login.web.session;
import org.springframework.stereotype.Component;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class SessionManager {
public static final String SESSION_COOKIE_NAME = "mySessionId";
private final Map<String, Object> sessionStore = new ConcurrentHashMap<>();
public void createSession(Object value, HttpServletResponse response) {
// 세션 ID 생성
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId, value);
// 쿠키 생성
Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(mySessionCookie);
}
public Object getSession(HttpServletRequest request) {
Cookie findCookie = Arrays
.stream(request.getCookies())
.filter(cookie -> SESSION_COOKIE_NAME.equals(cookie.getName()))
.findFirst()
.orElse(null);
if (findCookie == null) {
return null;
}
// findCookie.getValue() => UUID
return sessionStore.get(findCookie.getValue());
}
public void expireSession(HttpServletRequest request) {
Arrays
.stream(request.getCookies())
.filter(cookie -> SESSION_COOKIE_NAME.equals(cookie.getName()))
.findFirst()
.ifPresent(cookie -> sessionStore.remove(cookie.getValue()));
}
}
세션 생성
자바에서 기본으로 제공하는 UUID를 사용해서 랜덤값을 생성한다.
해당 UUID값과 매핑될 값(멤버 객체)을 세션 보관소에 저장한다.
사용자에게 쿠키로 UUID값을 전달하기 위해 쿠키를 만든다.
응답에 해당 쿠키를 추가한다.
세션 조회
요청 헤더에서 쿠키를 가져와서 쿠키의 이름을 통해 UUID값을 찾는다.
찾았으면 해당값으로 세션 보관소에서 찾아 반환한다. 여기서 반환되는 값은 매핑된 값(멤버 객체)이다.
못찾았으면 null을 반환한다.
세션 만료
요청 헤더에서 쿠키를 가져와서 쿠키의 이름을 통해 서버에서 세션으로 사용되는 쿠키 객체를 찾는다.
찾았다면 해당 쿠키로부터 값(UUID)을 가져와서 세션 저장소에서 날린다.
매우 간단하게, 직접 세션을 만들어보았다. 만든 세션 저장소가 제대로 작동하는지 테스트 코드를 짜보자.
SessionManagerTest
package hello.login.web.session;
import hello.login.domain.member.Member;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.*;
class SessionManagerTest {
SessionManager sessionManager = new SessionManager();
@Test
void sessionTest() {
// 세션 생성
MockHttpServletResponse response = new MockHttpServletResponse();
Member member = new Member();
sessionManager.createSession(member, response);
// 요청 헤더에 쿠키 저장
MockHttpServletRequest req = new MockHttpServletRequest();
req.setCookies(response.getCookies());
// 사용자 요청으로부터 세션 조회 -> 해당 세션은 위에서 만든 멤버와 일치해야 한다.
Object value = sessionManager.getSession(req);
assertThat(value).isEqualTo(member);
// 세션 만료
sessionManager.expireSession(req);
Object expiredValue = sessionManager.getSession(req);
assertThat(expiredValue).isNull();
}
}
실제로 사용자가 요청을 하고 그에 대한 응답을 하고 있다고 가정해보자.
먼저 로그인을 사용자가 했다고 하면 해당 사용자와 세션 ID를 한 쌍으로 세션 저장소에 저장해야 한다.
동일한 사용자가 새로운 요청을 했을 때, 해당 요청에는 세션 정보가 들어있고 그 세션 정보를 통해 조회한 사용자는 동일한 사용자여야 한다.
여기서는 테스트 코드를 작성할 때 실제로 요청과 응답을 주고 받는게 아니기 때문에 HttpServletRequest, HttpServletResponse를 사용할 수 없다. 그 대신 테스트를 위해 만들어져 있는 MockHttpServletRequest, MockHttpServletResponse를 사용할 수 있다.
세션 사용하여 로그인 처리 - 직접 만든 세션 적용
이제 세션을 적용해보자. 기존의 쿠키로 로그인하는 방식 관련 컨트롤러는 싹 다 날려버려도 된다. 아니면 그냥 코드를 아래처럼 수정하자.
@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
BindingResult bindingResult,
HttpServletResponse res) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
sessionManager.createSession(loginMember, res);
return "redirect:/";
}
다른 부분들은 전부 동일하고 로그인 성공 처리 부분에서 단지 SessionManager의 createSession()을 호출해주면 끝이다. 이 메서드 안에서 세션ID를 만들고 그 ID와 넘겨받는 멤버 객체가 한 쌍으로 세션 저장소에 보관도 되고, 응답에 쿠키로 세션ID도 추가한다.
@PostMapping("/logout")
public String logout(HttpServletRequest req) {
sessionManager.expireSession(req);
return "redirect:/";
}
로그아웃의 경우도 그냥 expireSession()을 호출하면 된다.
이제 HomeController도 세션을 적용하자.
SessionHomeController
package hello.login.web;
import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import hello.login.web.session.SessionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import javax.servlet.http.HttpServletRequest;
@Slf4j
@Controller
@RequiredArgsConstructor
public class SessionHomeController {
private final SessionManager sessionManager;
@GetMapping("/")
public String home(HttpServletRequest req, Model model) {
Member member = (Member) sessionManager.getSession(req);
if (member == null) {
return "home";
}
model.addAttribute("member", member);
return "loginHome";
}
}
이제 더 이상 @CookieValue는 필요없다. 그저 SessionManager의 getSession()을 호출하면 된다. 이 메서드를 호출하면 요청 헤더에 담긴 세션ID를 통해 세션 저장소에서 이 세션ID와 한 쌍을 이루는 멤버 객체를 반환할 것이다.
찾지 못했다면 로그인되지 않은것으로 간주한다.
찾았다면 로그인 된 화면으로 이동시킨다.
이렇게 직접 만든 세션도 잘 적용해보았다. 그러나, 이번을 계기로 다시는 직접 만들지 않아도 된다. 훨씬 더 잘 만들어져 있는 제공된 기능을 사용하면 된다.
서블릿 HTTP 세션사용하기
이제 직접 만드는 세션이 아니라 제공되는 좋은 기능을 사용하면 된다. 서블릿은 세션을 위해 HttpSession이라는 기능을 제공한다.
이 녀석을 통해 세션을 생성하면 다음과 같은 쿠키를 생성한다. `JSESSIONID`. 이게 이제 쿠키의 이름이고 그 값으로는 똑같이 알 수 없는 랜덤값을 넣는다. 그럼 서블릿 세션도 내부적으로 그 랜덤값을 통해 우리가 실질적으로 매핑한 값(멤버 객체)를 찾아서 로그인 여부를 확인하게 된다.
우선, 이 서블릿 세션을 사용할 땐 세션의 키가 필요하다. 뭐 아무렇게나 지어도 상관없다. 그래서 나는 인터페이스를 만들어서 상수를 정의하려고 한다.
그리고 이제, 로그인과 로그아웃을 이 서블릿 세션을 사용해서 처리할거니까 로그인 컨트롤러를 수정해주자.
HttpServletSessionLoginController
package hello.login.web.login;
import hello.login.domain.login.LoginService;
import hello.login.domain.member.Member;
import hello.login.web.session.SessionConst;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
@Slf4j
@Controller
@RequiredArgsConstructor
public class HttpServletSessionLoginController {
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginForm") LoginForm loginForm) {
return "login/loginForm";
}
@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
BindingResult bindingResult,
HttpServletRequest req) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 세션이 있으면 세션 반환, 없으면 신규 세션을 생성
HttpSession session = req.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
@PostMapping("/logout")
public String logout(HttpServletRequest req) {
// getSession(false): 새로 만드는 게 아니라 기존에 있는 세션을 가져와야 한다.
HttpSession session = req.getSession(false);
if (session != null) {
session.invalidate(); // 전부 무효화
}
return "redirect:/";
}
}
이게 전체 코드이고 코드를 나눠서 보자. 먼저 로그인 할 때이다.
@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
BindingResult bindingResult,
HttpServletRequest req) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 세션이 있으면 세션 반환, 없으면 신규 세션을 생성
HttpSession session = req.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
역시나 다 동일한데 로그인 성공 시 세션을 생성하는 부분만 변경해준다. HttpServletRequest에는 getSession()이라는 메서드가 있고 그 녀석을 통해 객체를 얻으면 HttpSession 타입의 객체가 있다.
그 객체에 setAttribute()를 호출해서 원하는 키에 저장할 값(멤버 객체)를 추가하면 된다.
@PostMapping("/logout")
public String logout(HttpServletRequest req) {
// getSession(false): 새로 만드는 게 아니라 기존에 있는 세션을 가져와야 한다.
HttpSession session = req.getSession(false);
if (session != null) {
session.invalidate(); // 전부 무효화
}
return "redirect:/";
}
이번엔 로그아웃이다. 마찬가지로 getSession()을 통해 HttpSession 객체를 가져온다.
getSession(true) → 세션을 가져오는데 없으면 새로 만들어서 가져오는 것이고 이게 기본값이다.
getSession(false)→ 세션을 가져오는데 없으면 null을 반환한다.
HttpSession이 null이 아니라면 invalidate()을 호출해서 세션의 데이터를 무효화한다.
이번엔 HomeController를 HttpSession을 사용하도록 수정해보자.
HttpServletSessionHomeController
package hello.login.web;
import hello.login.domain.member.Member;
import hello.login.web.session.SessionConst;
import hello.login.web.session.SessionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
@Slf4j
@Controller
@RequiredArgsConstructor
public class HttpServletSessionHomeController {
@GetMapping("/")
public String home(HttpServletRequest req, Model model) {
HttpSession session = req.getSession(false);
if (session == null) {
return "home";
}
Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
if (loginMember == null) {
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
}
마찬가지로 HttpSession 객체를 가져온다. 여기서도 없으면 새로 만드는 게 아니라 기존에 있다면 가져오고 없으면 null 처리를 한다. 왜 새로 만들지 않냐면, 당연하게도 로그인을 했으면 당연히 세션이 저장된 상태일 것이다. 그럼 조회했을 때 null일 수 없다.
가져온 HttpSession 객체로부터 getAttribute()를 호출해서 아까 저장한 키/값을 키를 통해 가져온다.
가져온 값(멤버 객체)이 없다면 home 화면으로, 가져온 값(멤버 객체)이 있다면 loginHome 화면으로 이동한다.
이 상태로 실행을 다시 해보면 잘 동작할 것이다.
근데, 여기서 더 나아가서 스프링이 조금 더 도와준다. 이 HttpServletSessionHomeController를 보면 HttpServletRequest 받아서 세션 꺼내고 이런 작업이 너무 귀찮으니까 딱 이렇게 바꿔버릴 수 있다.
@GetMapping("/")
public String homeWithSpring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member member,
Model model) {
if (member == null) {
return "home";
}
model.addAttribute("member", member);
return "loginHome";
}
@SessionAttribute 애노테이션을 스프링이 지원하는데 여기서 바로 키값으로 내가 원하는 해당 키와 매핑된 값(멤버 객체)을 꺼내올 수 있다. `required = false`는 당연히 있어야 한다. 로그인을 하지 않았을 수 있으니까.
그리고, 이렇게 서블릿 HTTP 세션을 사용할 때 최초 로그인 시에는 URL에 이런게 붙는다.
그러니까, 쿠키에 있는 JSESSIONID가 URL에 붙는 경우를 볼 수 있는데(최초 딱 한번만), 그 이유가 뭐냐면 서버 입장에서는 브라우저가 쿠키를 지원하는지 지원하지 않는지 최초에는 알 수가 없다. 이후에는 쿠키가 넘어오는지 아닌지를 통해 자연스럽게 파악이 된다고 해도 딱 최초에는 모른다. 그래서 그런 경우에 URL에 있는 이 JSESSIONID를 통해 세션을 유지할 수 있게 해주는 것인데, 쿠키를 지원하지 않는 브라우저는 거의 없다고 봐야하고 커스텀 설정을 통해 그런 작업을 했다면 그건 배제하자. URL에 이런 정보가 들어가봐야 좋을 거 하나 없기 때문에. 그래서 이 값을 없애는 방법은 다음과 같이 application.properties 파일에 이 한 줄을 추가한다.
server.servlet.session.tracking-modes=cookie
세션의 타임아웃
이제 이 세션의 타임아웃을 알아볼 차례다.
세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate()이 호출되는 경우에 삭제된다. 그런데 대부분의 사용자는 로그아웃을 누르고 나가지 않고 그냥 웹 브라우저를 종료한다. 문제는 HTTP는 Stateless이기 때문에 요청 후 응답을 내보내면 더이상 뭐 서버는 알 수 있는게 없다. 그래서 사용자가 브라우저를 종료했는지, 아닌지 알 수 없다. 그러면 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다. 이런 경우에 남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있다.
세션과 관련된 쿠키(JSESSIONID)를 탈취 당했을 때, 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수 있다.
세션은 기본적으로 메모리에 생성된다.(물론, DB에 올릴수도 있다) 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우에만 생성해서 사용해야 한다. 10만명의 사용자가 로그인하면 10만개의 세션이 생성되는 것이다.
세션의 종료 시점
세션의 종료 시점은 생성 시점으로부터 30분 정도 잡으면 될까? 근데 그러면 30분이 지나면 세션이 삭제되기 때문에 사용자가 사이트를 열심히 돌아다니다가 중간에 갑자기 뚝 세션이 삭제되면 로그인을 해야하는 번거로움이 발생할 것이다. 그래서 대부분의 경우 사용자의 마지막 요청 시간으로부터 30분을 종료 시점으로 설정한다. 그러니까 사용하다가 사용자가 요청을 하면 요청한 시점부터 다시 30분을 늘리는 것이다. HttpSession은 이 방식을 사용한다. 그러니까 우리는 따로 작업해줄게 없다.
근데, 당연히 이 설정값은 변경이 가능하다. 기본이 30분이고 다음과 같이 변경할 수 있다.
application.properties
server.servlet.session.timeout=60s
이러면 60초가 타임 아웃이다. 이건 글로벌 설정이다.
근데, 특정 세션별로 보안이 매우 중요해서 세션 타임 아웃을 달리 설정해야 하는 경우도 있다. 특정 세션의 타임 아웃을 바꾸려면
세션을 만들어서 다음 코드를 호출하면 된다.
session.setMaxInactiveInterval(1800); //1800초
정리하자면,
서블릿의 HttpSession이 제공하는 타임아웃 기능 덕분에 세션을 안전하고 편리하게 관리하고 사용할 수 있다. 실무에서 주의할 점은 세션에는 최소한의 데이터만 보관해야 한다는 점이다. 메모리 사용량을 고려해야 하기 때문이다.
참고로, 세션에는 이러한 메서드들이 있다. 참고하면 좋을 것 같다.
SessionInfoController
package hello.login.web.session;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Date;
@Slf4j
@RestController
@RequestMapping("/session")
public class SessionInfoController {
@GetMapping("/info")
public String sessionInfo(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return "session is null";
}
session.getAttributeNames()
.asIterator()
.forEachRemaining(name -> log.info("session name = {}, value = {}", name, session.getAttribute(name)));
log.info("sessionId = {}", session.getId()); // 세션 ID
log.info("getMaxInactiveInterval = {}", session.getMaxInactiveInterval()); // 세션의 유효시간 예)1800초=30분
log.info("creationTime = {}", new Date(session.getCreationTime())); // 세션 생성 일시
log.info("lastAccessTime = {}", new Date(session.getLastAccessedTime())); // 세션과 연결된 사용자가 최근에 서버에 접근한 시간
log.info("isNew = {}", session.isNew()); // 새로 생성된 세션인지
return "done";
}
}
정리
이제 세션을 사용해 로그인 기능도 구현해보았다. 다음 포스팅에는 필터와 인터셉터를 사용해서 로그인 안 된 사용자를 걸러내는 방법을 알아보자.