728x90
반응형
SMALL

"객체 생성은 비싸니 피해야 한다."가 아니다. 특히 요즘의 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");
    }
}
  • 끔찍한 일이 일어나고 있다. 객체가 어디서 생성되는지 보이는가?
  • sumLong 박싱 타입인데, ilong 기본 타입이다. 이때 sum = sum + i를 이행하는 과정에서 i의 오토박싱이 일어난다. 즉, 이 루프를 돌면서 Long 인스턴스가 2의 31승개가 만들어진다.

이때, sum의 타입을 long으로만 바꿔주면 이 코드의 실행 속도는 2.2초에서 0.6초로 줄어든다.

그래서, 이 오토박싱도 불필요한 객체 생성이 될 수 있다. 

 

불필요한 객체 생성은 피하자.

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

가끔 정적 메서드, 정적 필드만을 담은 클래스를 만들고 싶을 때가 있다. 대표적인게 유틸클래스.

물론, 객체 지향적으로 사고하지 않는 이들이 종종 남용하는 방식이라 좋아보이진 않지만 유용할 때가 분명 있다.

 

근데, 내가 이 부분에서 빠뜨린 부분이 있었다.

나는 인스턴스화를 막기 위해 추상 클래스로 선언하면 아무 문제 없을 줄 알았는데, 그게 아니었다.

 

추상클래스로 만드는 것으로는 인스턴스화를 막을 수 없다.

추상클래스로 만들어도, 하위 클래스를 만들어 인스턴스화하면 그만이다.

 

다음 코드를 보자. 

 

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 생성자를 사용하자.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

파일 업로드 소개

일반적으로 사용하는 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-dataapplication/x-www-form-urlencoded와 비교해서 매우 복잡하고 각각의 부분(Part)로 나뉘어져 있다. 그렇다면 이렇게 복잡한 HTTP 메시지를 서버에서 어떻게 사용할 수 있을까? 

 

서블릿 파일 업로드 1

역시 마찬가지로, 스프링의 도움을 받기 전에 태초의 세계부터 시작해보자. 결국 스프링이 태어나기 전에도 이 파일을 업로드하는 기능은 있었다. 그때 서블릿은 어떻게 처리를 했을까? 

 

ServletUploadControllerV1

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.stereotype.Controller;
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.util.Collection;

@Slf4j
@Controller
@RequestMapping("/servlet/v1")
public class ServletUploadControllerV1 {

    @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);

        return "upload-form";
    }
}
  • 두개의 간단한 경로를 만든다. 하나는 폼을 보여주는 GET, 하나는 폼을 통해 전달되는 POST 요청이다.
  • POST 요청을 처리하는 부분에서, request.getParts() 부분을 유심히 보자. 이 Part가 위에서 말한 Part이다. 위에서 `---XXX` 이 구분자로 각각의 데이터를 구분지어 받는다고 했는데 그 하나하나가 이 Part이다.

src/main/resources/templates/upload-form.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2>상품 등록 폼</h2>
    </div>

    <h4 class="mb-3">상품 입력</h4>

    <form th:action method="post" enctype="multipart/form-data">
        <ul>
            <li>상품명 <input type="text" name="itemName"></li>
            <li>파일<input type="file" name="file" ></li>
        </ul>
        <input type="submit"/>
    </form>

</div> <!-- /container -->
</body>
</html>
  • 간단한 폼을 보여주는 HTML 파일이다. 여기서 한번 체크하고 넘어갈 부분은 form 태그의 enctype="multipart/form-data" 이 부분이다. 이렇게 직접 명시를 해줘야 브라우저에서 폼에 데이터를 넣어 전송할 때 서버에서 multipart/form-data로 받아온다.

 

또 하나 해줄 설정이 있는데, 브라우저(클라이언트)에서 서버로 POST 요청이 들어올 때 로그를 자세하게 보고싶다. 

application.properties

 logging.level.org.apache.coyote.http11=trace

이 옵션을 설정해서 HTTP 요청 메시지를 로그로 확인할 수 있다.

 

이제 브라우저에 다음과 같이 접속해보면,

이렇게 보여진다. 여기서 아무 상품명과 간단한 PNG 파일을 넣어 제출해보자.

그럼 결과로그가 엄청 많이 찍히는데 제일 윗 부분에 이 부분을 보자.

HTTP 요청 메시지에서 Content-Typemultipart/form-data이다. 그리고 boundary----XXX 이렇게 나와있다.

그리고 그 구분자로 각각의 데이터가 넘어오고 있다. 위에 부분은 상품명에 대한 데이터, 아래 부분은 파일이다. 그리고 문자가 막 이상하게 나오는데 이건 인코딩 과정에서 이렇게 보이는거고 바이너리 데이터가 넘어오고 있다는 것이다.

 

그리고 하단에 찍힌 로그를 보자.

우리가 직접 찍은 로그인데, 여기에 parts 부분에 두개의 데이터가 넘어오고 있는게 보이는가? 이게 각각의 파트이다. 아마 상품명에 관련된 Part, 파일과 관련된 Part이겠지. 이렇게 데이터를 받아올 수 있다. 

 

멀티파트 사용 옵션 - 업로드 사이즈 제한

application.properties

 spring.servlet.multipart.max-file-size=1MB
 spring.servlet.multipart.max-request-size=10MB

큰 파일을 무제한 업로드하게 둘 수는 없으므로 서버에서 업로드 사이즈를 제한할 수도 있다. 사이즈가 넘으면 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)를 실행한다. 멀티파트 리졸버는 멀티파트 요청인 경우, 서블릿 컨테이너가 전달하는 일반적인 HttpServletRequestMultipartHttpServletRequest로 변환해서 반환한다. MultipartHttpServletRequestHttpServletRequest의 자식 인터페이스이고, 멀티파트와 관련된 추가 기능을 제공한다.

 

아래 사진은 DispatcherServlet의 일부분인 doDispatch 메서드의 일부이다.

여기서 checkMultipart 메서드에 request를 넘기고 있다. 저 메서드 안으로 들어가보면,

이렇게 멀티파트 요청인지 체크를 하는 부분이 있고 그게 맞다면, 스프링이 제공하는 기본 멀티파트 리졸버는 MultipartHttpServletRequest 인터페이스를 구현한 StandardMultipartHttpServletRequest를 반환한다. 그래서 request라는 로그를 찍어봤을 때, RequestFacade가 찍히고 이게 StandardMultipartHttpServletRequest이 녀석이 되는것이다. 이제 컨트롤러에서 HttpServletRequest 대신에 MultipartHttpServletRequest를 주입받을 수 있는데, 이걸 사용하면 멀티파트와 관련된 여러가지 처리를 편리하게 할 수 있다. 아래 코드처럼 말이다.

@PostMapping("/upload")
public String saveFileV1(MultipartHttpServletRequest request) throws ServletException, IOException {
    log.info("request = {}", request);

    request.getFile("filename");

    String itemName = request.getParameter("itemName");
    log.info("itemName = {}", itemName);

    Collection<Part> parts = request.getParts();
    log.info("parts = {}", parts);

    return "upload-form";
}
  • request.getFile()과 같은 메서드는 MultipartHttpServletRequest에서 사용할 수 있게 만들어준 메서드이다.

그런데, 이후에 얘기할 MultipartFile 이라는 것을 사용하면 훨씬 더 편리하게 사용할 수 있기 때문에 MultipartHttpServletRequest를 잘 사용하지는 않는다. 그냥 이런 과정이 있다고 알아두면 좋을것 같다.

 

서블릿 파일 업로드 2

서블릿이 제공하는 Part에 대해 알아보고 실제 파일도 서버에 업로드 해보자.

먼저, 파일을 업로드 하려면 실제 파일이 저장되는 경로가 필요하다. 

 

원하는 경로를 지정해서 실제로 폴더를 하나 만들어두자. 그리고 application.properties 파일에 `file.dir`이라는 키로 저장해두자. 난 이런 경로로 선택했다.

application.properties

file.dir=/Users/choichiwon/Spring/fileupload/src/main/resources/static/upload/

주의! 경로 마지막에 `/` 붙여주자!

 

ServletUploadControllerV2

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이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원한다.

 

SpringUploadController

package cwchoiit.fileupload.controller;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;

@Slf4j
@Controller
@RequestMapping("/spring")
public class SpringUploadController {

    @Value("${file.dir}")
    private String fileDir;

    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }

    @PostMapping("/upload")
    public String saveFile(@RequestParam String itemName,
                           @RequestParam MultipartFile file,
                           HttpServletRequest request) throws IOException {
        log.info("request = {}", request);
        log.info("itemName = {}", itemName);
        log.info("file = {}", file);

        if (!file.isEmpty()) {
            String fullPath = fileDir + file.getOriginalFilename();
            log.info("파일 저장 fullPath = {}", fullPath);
            file.transferTo(new File(fullPath));
        }
        return "upload-form";
    }
}
  • POST 요청을 처리하는 메서드를 유심히 봐보자.
  • 코드를 보면 스프링 답게 정말 필요한 부분의 코드만 작성하면 된다.
  • @RequestParam을 통해 itemName, file을 아주 편리하게 가져올 수 있다.
  • HttpServletRequest는 받을 필요도 없지만, 로그로 찍어보기 위해 받아봤다.
  • MultipartFile@RequestParam 뿐 아니라 @ModelAttribute에서도 동일하게 사용할 수 있다.

@ModelAttribute 사용 예시

@Data
static class Payload {
    private String itemName;
    private MultipartFile file;
}


@PostMapping("/upload")
public String saveFile(@ModelAttribute Payload payload,
                       HttpServletRequest request) throws IOException {
    log.info("payload = {}", payload);
    log.info("request = {}", request);
    log.info("itemName = {}", payload.getItemName());
    log.info("file = {}", payload.getFile());

    if (!payload.getFile().isEmpty()) {
        String fullPath = fileDir + payload.getFile().getOriginalFilename();
        log.info("파일 저장 fullPath = {}", fullPath);
        payload.getFile().transferTo(new File(fullPath));
    }
    return "upload-form";
}

 

 

MultipartFile 주요 메서드

  • file.getOriginalFileName(): 업로드 파일 명
  • file.transferTo(...): 파일 저장

실제로 실행을 해보자. 파일이 잘 업로드 됐음을 알 수 있게 된다.

 

예제로 구현하는 파일 업로드, 다운로드

이제 이런 건 또 현실 세계와 비슷한 작업을 해보면서 느껴보는게 중요하다. 요구사항은 이렇다.

  • 상품 등록 시
    • 상품 이름
    • 첨부 파일 하나
    • 이미지 파일 여러개
  • 첨부파일을 업로드, 다운로드 할 수 있다.
  • 업로드한 이미지를 웹 브라우저에서 확인할 수 있다.

 

이러한 요구사항을 가지고 한번 예제를 만들어보자. 우선 상품 객체가 될 Item이다.

Item

package cwchoiit.fileupload.domain;

import lombok.Data;

import java.util.List;

@Data
public class Item {

    private Long id;
    private String itemName;
    private UploadFile attachFile;
    private List<UploadFile> imageFiles;
}

UploadFile - 업로드 파일 정보 보관

package cwchoiit.fileupload.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UploadFile {

    private String uploadFileName;
    private String storeFileName;
}
  • uploadFileName: 고객이 업로드한 파일의 실제 이름
  • storeFileName: 서버에 저장된 파일의 이름(여러 고객들 중 같은 이름의 파일을 등록한 경우, 중복을 피하기 위함)

ItemRepository

package cwchoiit.fileupload.domain;

import org.springframework.stereotype.Repository;

import java.util.HashMap;
import java.util.Map;

@Repository
public class ItemRepository {

    private final Map<Long, Item> store = new HashMap<>();
    private long sequence = 0L;

    public Item save(Item item) {
        item.setId(++sequence);
        store.put(item.getId(), item);
        return item;
    }

    public Item findById(long id) {
        return store.get(id);
    }
}

 

이렇게 만들면, 상품과 상품에서 사용될 이미지와 첨부파일 관련 객체를 만든 것이고, 그 상품을 데이터베이스에 저장할 레포지토리까지 만들었다. 물론, 여기서는 실제 데이터베이스를 사용하지는 않고 메모리에 저장하는 코드로 작성했다.

 

중요한 부분은, 실제 애플리케이션에서도 파일을 데이터베이스에 저장하지는 않는다는 점이다. 실제 애플리케이션에서도 파일이나 이미지는 S3나 다른 파일 보관 서비스를 사용하고, 그 보관된 파일을 찾아갈 수 있는 파일명이라던가 경로정도만 데이터베이스에 저장한다. 

 

FileStore - 파일 저장과 관련된 업무 처리

package cwchoiit.fileupload.file;

import cwchoiit.fileupload.domain.UploadFile;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Component
public class FileStore {

    @Value("${file.dir}")
    private String fileDir;

    public String getFullPath(String fileName) {
        return fileDir + fileName;
    }

    public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
        List<UploadFile> uploadFiles = new ArrayList<>();
        for (MultipartFile multipartFile : multipartFiles) {
            if (!multipartFile.isEmpty()) {
                uploadFiles.add(storeFile(multipartFile));
            }
        }
        return uploadFiles;
    }

    public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
        if (multipartFile.isEmpty()) {
            return null;
        }

        String originalFilename = multipartFile.getOriginalFilename();
        String storeFileName = createStoreFileName(originalFilename);

        multipartFile.transferTo(new File(getFullPath(storeFileName)));

        return new UploadFile(originalFilename, storeFileName);
    }

    private String createStoreFileName(String originalFilename) {
        String ext = extractExt(originalFilename);
        String uuid = UUID.randomUUID().toString();
        return uuid + "." + ext;
    }

    private String extractExt(String originalFilename) {
        int pos = originalFilename.lastIndexOf(".");
        return originalFilename.substring(pos + 1);
    }
}
  • 멀티파트 파일을 서버에 저장하는 코드이다.
  • createStoreFileName(): 서버 내부에서 관리하는 파일명은 유일한 이름을 생성하는 UUID를 사용해서 충돌을 방지한다. 
  • extractExt(): 확장자를 별도로 추출해서, 서버 내부에서 관리하는 파일명에도 붙여준다. 예를 들어, 고객이 `a.png`라는 파일명으로 업로드를 하면 `51041c62-86e4-4274-801d-614a7d994edb.png`와 같이 저장한다.
  • getFullPath(): 파일을 저장할 경로 + 파일명을 반환하는 메서드.
  • storeFile(): 단일 파일을 저장한다. 
  • storeFiles(): 복수의 파일을 저장한다.

이제 앞단에서 전송하는 데이터를 운송하는 DTO 객체 ItemForm이다.

ItemForm

package cwchoiit.fileupload.controller;

import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@Data
public class ItemForm {

    private Long id;
    private String itemName;
    private MultipartFile attachFile;
    private List<MultipartFile> imageFiles;
}
  • 이미지를 다중으로 업로드하기 위해, List<MultipartFile> 타입을 선언한다. @ModelAttribute, @RequestParam으로 이렇게 다중 멀티파트 파일을 받을 수 있다.

ItemController

package cwchoiit.fileupload.controller;

import cwchoiit.fileupload.domain.Item;
import cwchoiit.fileupload.domain.ItemRepository;
import cwchoiit.fileupload.domain.UploadFile;
import cwchoiit.fileupload.file.FileStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.util.UriUtils;

import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.util.List;

@Slf4j
@Controller
@RequiredArgsConstructor
public class ItemController {

    private final ItemRepository itemRepository;
    private final FileStore fileStore;

    @GetMapping("/items/new")
    public String newItem(@ModelAttribute ItemForm form) {
        return "item-form";
    }

    @PostMapping("/items/new")
    public String saveItem(@ModelAttribute ItemForm form, RedirectAttributes redirectAttributes) throws IOException {
        UploadFile attachFile = fileStore.storeFile(form.getAttachFile());
        List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles());

        Item item = new Item();
        item.setItemName(form.getItemName());
        item.setAttachFile(attachFile);
        item.setImageFiles(storeImageFiles);
        itemRepository.save(item);

        redirectAttributes.addAttribute("itemId", item.getId());
        return "redirect:/items/{itemId}";
    }

    @GetMapping("/items/{id}")
    public String item(@PathVariable Long id, Model model) {
        Item item = itemRepository.findById(id);
        model.addAttribute("item", item);
        return "item-view";
    }

    @ResponseBody
    @GetMapping("/images/{fileName}")
    public Resource downloadImage(@PathVariable String fileName) throws MalformedURLException {
        return new UrlResource("file:" + fileStore.getFullPath(fileName));
    }

    @GetMapping("/attach/{itemId}")
    public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
        Item item = itemRepository.findById(itemId);
        String storeFileName = item.getAttachFile().getStoreFileName();
        String uploadFileName = item.getAttachFile().getUploadFileName();

        UrlResource urlResource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));

        log.info("uploadFileName = {}", uploadFileName);
        String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
        String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";

        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
                .body(urlResource);
    }
}
  • @GetMapping("/items/new"): 등록폼을 보여준다.
  • @PostMapping("/items/new"): 폼의 데이터를 데이터베이스에 저장하고, 폼을 보여주는 화면으로 리다이렉트 한다.
  • @GetMapping("/items/{id}"): 상품을 보여준다.
  • @GetMapping("/images/{fileName}"): <img> 태그로 이미지를 조회할 때 사용한다. UrlResource로 이미지를 읽어서, @ResponseBody로 이미지 바이너리를 반환한다.
  • @GetMapping("/attach/{itemId}"): 파일을 다운로드할 때 사용한다. 예제를 더 단순화 할 수 잇지만, 파일 다운로드 시 권한 체크같은 복잡한 상황까지 가정한다 생각하고 이미지 id를 요청하도록 했다. 파일 다운로드시에는 고객이 업로드한 파일 이름으로 다운로드 하는 게 좋다. 이때는 Content-Disposition 헤더에 attachment; filename="업로드 파일명"값을 주면 된다. 그리고 이렇게 헤더에 값을 넣어줘야 고객이 이 URL로 요청을 하면 파일을 다운로드 받을 수 있게 된다.

item-form.html - 등록폼 뷰

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2>상품 등록</h2>
    </div>

    <form th:action method="post" enctype="multipart/form-data">
        <ul>
            <li>상품명 <input type="text" name="itemName"></li>
            <li>첨부파일<input type="file" name="attachFile" ></li>
            <li>이미지 파일들<input type="file" multiple="multiple" name="imageFiles" ></li>
        </ul>
        <input type="submit"/>
    </form>

</div> <!-- /container -->
</body>
</html>
  • 다중 파일을 업로드하려면 <input> 태그에 multiple="multiple" 옵션을 넣어주면 된다. 
  • 그리고 이렇게 사용되는 <input> 태그에 사용자가 여러 이미지를 넣어 POST 요청을 날리면 List<MultipartFile> 타입으로 자동으로 들어간다.

item-view.html - 조회 뷰

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2>상품 조회</h2>
    </div>

    상품명: <span th:text="${item.itemName}">상품명</span><br/>
    첨부파일: <a th:if="${item.attachFile}" th:href="|/attach/${item.id}|" th:text="${item.getAttachFile().getUploadFileName()}" /><br/>
    <img th:each="imageFile : ${item.imageFiles}" th:src="|/images/${imageFile.getStoreFileName()}|" width="300" height="300"/>

</div> <!-- /container -->
</body>
</html>
  • 여기보면, <img> 태그에 th:src"|/images/${imageFile.getStoreFileName()}|" 이렇게 받고 있다. 컨트롤러에서 만든 이미지를 서버에서 불러오는 경로이다. UrlResource를 사용해서.

실행해보면, 하나의 첨부파일을 다운로드 및 업로드 하고 여러 이미지 파일을 한번에 업로드 및 조회할 수 있다.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

스프링 타입 컨버터 소개

문자를 숫자로 변환하거나, 반대로 숫자를 문자로 변환해야 하는 것처럼 애플리케이션을 개발하다 보면 타입을 변환해야 하는 경우가 상당히 많다. 다음 예를 보자.

package cwchoiit.converter.controller;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class ConverterController {

    @GetMapping("/v1")
    public String hello(HttpServletRequest request) {
        String data = request.getParameter("data");
        Integer i = Integer.valueOf(data);
        log.debug("i : {}", i);
        return "ok";
    }
}
  • HTTP 요청 파라미터는 모두 문자로 처리된다. 따라서 request.getParameter("data");를 통해 가져온 데이터도 당연히 문자로 받게 된다. 이 값을 다른 타입으로 변환해서 사용하고 싶으면 다음과 같이 숫자 타입으로 변환하는 과정을 거쳐야 한다. 
  • Integer.valueOf(data);

이번엔 스프링 MVC가 제공하는 @RequestParam을 사용해보자.

@GetMapping("/v2")
public String hello2(@RequestParam Integer data) {
    log.debug("i : {}", data);
    return "ok";
}
  • 스프링이 제공하는 @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()라는 메서드를 구현해야 한다. 이건 단순하게 SourceTarget 타입으로 변환하는 메서드이다.

문자를 숫자로 변환했다면, 숫자를 문자로 변환하는 컨버터도 만들어보자.

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);
    }
}
  • `127.0.0.1:8080`과 같은 문자열을 IpPort 객체로 변환해주는 컨버터이다.

IpPortToStringConverter

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 IpPortToStringConverter implements Converter<IpPort, String> {

    @Override
    public String convert(IpPort source) {
        log.info("convert source = {}", source);
        return source.getIp() + ":" + source.getPort();
    }
}
  • 반대로 IpPort 객체로 `127.0.0.1:8080`과 같은 문자열로 변환해주는 컨버터이다.

 

이제 이렇게 만들어 둔 컨버터들을 등록하고 관리하면서 편리하게 변환 기능을 제공하는 역할을 하는 무언가가 필요하다. 이제 그것에 대해 알아보자. 

 

컨버전 서비스 - ConversionService

타입 컨버터들을 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공하는데 이게 바로 컨버전 서비스이다.

ConversionService 인터페이스

package org.springframework.core.convert;

import org.springframework.lang.Nullable;

public interface ConversionService {
    boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType);

    boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType);

    @Nullable
    <T> T convert(@Nullable Object source, Class<T> targetType);

    @Nullable
    default Object convert(@Nullable Object source, TypeDescriptor targetType) {
        return this.convert(source, TypeDescriptor.forObject(source), targetType);
    }

    @Nullable
    Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
}

컨버전 서비스 인터페이스는 단순히 컨버팅이 가능한가? 확인하는 기능과, 컨버팅 기능을 제공한다.

 

ConversionServiceTest  - 컨비전 서비스 테스트

package cwchoiit.converter.converter;

import cwchoiit.converter.controller.converter.IntegerToStringConverter;
import cwchoiit.converter.controller.converter.IpPortToStringConverter;
import cwchoiit.converter.controller.converter.StringToIntegerConverter;
import cwchoiit.converter.controller.converter.StringToIpPortConverter;
import cwchoiit.converter.type.IpPort;
import org.junit.jupiter.api.Test;
import org.springframework.core.convert.support.DefaultConversionService;

import static org.assertj.core.api.Assertions.*;

public class ConversionServiceTest {

    @Test
    void conversionService() {
        DefaultConversionService defaultConversionService = new DefaultConversionService();
        defaultConversionService.addConverter(new StringToIntegerConverter());
        defaultConversionService.addConverter(new IntegerToStringConverter());
        defaultConversionService.addConverter(new IpPortToStringConverter());
        defaultConversionService.addConverter(new StringToIpPortConverter());

        assertThat(defaultConversionService.convert("10", Integer.class)).isEqualTo(10);
        assertThat(defaultConversionService.convert(10, String.class)).isEqualTo("10");

        IpPort ipPort = defaultConversionService.convert("127.0.0.1:8080", IpPort.class);
        assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));

        String stringIpPort = defaultConversionService.convert(new IpPort("127.0.0.1", 8080), String.class);
        assertThat(stringIpPort).isEqualTo("127.0.0.1:8080");
    }
}
  • DefaultConversionServiceConversionService 인터페이스를 구현한 구현체다. 그리고 추가적으로 컨버터를 등록하는 기능도 제공한다.

등록과 사용의 분리

 

등록

defaultConversionService.addConverter(new StringToIntegerConverter());

사용

defaultConversionService.convert("10", Integer.class)

 

컨버터를 등록할 때는 StringToIntegerConverter와 같은 타입 컨버터를 명확하게 알아야 한다. 반면에 컨버터를 사용하는 입장에서는 타입 컨버터를 전혀 몰라도 된다. 타입 컨버터들은 모두 컨버전 서비스 내부에 숨어서 제공된다. 따라서 타입 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다. 물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다. 

 

이 부분에서 인터페이스 분리 원칙 - ISP(Interface Segregation Principle)이 또 나오게 된다. 인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다. 무슨 말이냐면,

 

DefaultConversionService는 다음 두 인터페이스를 구현했다.

  • ConversionService: 컨버터 사용에 초점
  • ConversionRegistry: 컨버터 등록에 초점

이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다. 특히 컨버터를 사용하는 클라이언트는 ConversionService만 의존하면 되므로, 컨버터를 어떻게 등록하고 관리하는지 전혀 몰라도 된다. 결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게 된다. 이렇게 인터페이스를 분리하는 것을 ISP라고 한다. 

 

스프링은 내부에서 ConversionService를 사용해서 타입을 변환한다. 예를 들어서, 앞서 살펴본 @RequestParam 같은 곳에서 이 기능을 사용해서 타입을 변환한다. 이제 컨버전 서비스를 스프링에 적용해보자.

 

스프링 컨버터에 만든 커스텀 컨버터 적용하기

스프링은 내부에서 ConversionService를 제공한다. 우리는 WebMvcConfigurer가 제공하는 addFormatters()를 사용해서 추가하고 싶은 컨버터를 추가하면 된다. 이렇게 하면 스프링은 내부에서 사용하는 ConversionService에 컨버터를 추가한다.

 

WebConfig

package cwchoiit.converter;

import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new StringToIntegerConverter());
        registry.addConverter(new IntegerToStringConverter());
        registry.addConverter(new StringToIpPortConverter());
        registry.addConverter(new IpPortToStringConverter());
    }
}

 

등록한 컨버터가 잘 동작하는지 확인해보자.

ConverterController

package cwchoiit.converter.controller;

import cwchoiit.converter.IpPort;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class ConverterController {

    @GetMapping("/convert")
    public String convert(@RequestParam Integer data) {
        log.info("data: {}", data);
        return "ok";
    }

    @GetMapping("/convert-ip")
    public String convertIp(@RequestParam IpPort ipPort) {
        log.info("ipPort: {}", ipPort);
        return "ok";
    }
}
  • 첫번째 경로인 `/convert`data라는 쿼리파라미터를 넣어보자.

 

  • 두번째 경로인 `/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는 숫자 타입으로 넘어가고, ipPortIpPort라는 객체타입으로 넘어간다.

 

뷰 템플릿을 만들자!

src/main/resources/templates/converter-view.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<ul>
    <li>${number}: <span th:text="${number}" ></span></li>
    <li>${{number}}: <span th:text="${{number}}" ></span></li>
    <li>${ipPort}: <span th:text="${ipPort}" ></span></li> <!--{} 하나면 그냥 변수 표현식-->
    <li>${{ipPort}}: <span th:text="${{ipPort}}" ></span></li> <!-- {{}} 두개면 컨버전 서비스 적용-->
</ul>

</body>
</html>
  • Thymeleaf를 사용하면, 이렇게 `${}` 하나만 있는것과 `${{}}` 두개가 있는게 있는데, 하나만 사용하면 그냥 변수를 담는 변수 표현식이고 두개를 사용하면 컨버전 서비스를 적용하는 것이다. 이 결과는 어떻게 나올까?

  • number의 경우, 하나를 사용하나 두 개를 사용하나 동일하게 10000으로 찍혔다. 일단 두 개를 사용한 경우 컨버터가 적용이 된 것이다. 하나를 사용해도 결국 숫자는 뷰 템플릿에서는 무조건 문자로 표현된다. 
  • ipPort의 경우, 하나를 사용했더니 그냥 IpPorttoString() 메서드가 그대로 호출된 모습이다. 말 그대로 변수 표현식에 객체를 넘겼으니 객체가 찍히는게 맞다. 근데 두개를 넘겼더니 문자로 바꿔주는 컨버터가 적용된 모습을 확인할 수 있다.

 

이렇게 변수도 컨버터를 이용할 수 있고, 폼에도 컨버터를 적용할 수가 있다.

컨트롤러에 폼을 사용하는 경로를 추가해서 확인해보자.

package cwchoiit.converter.controller;

import cwchoiit.converter.IpPort;
import lombok.Data;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class ConverterViewController {

    ...

    @GetMapping("/converter/edit")
    public String converterForm(Model model) {
        IpPort ipPort = new IpPort("127.0.0.1", "8080");
        Form form = new Form(ipPort);
        model.addAttribute("form", form);
        return "converter-form";
    }

    @PostMapping("/converter/edit")
    public String converterSubmit(@ModelAttribute("form") Form form, Model model) {
        IpPort ipPort = form.getIpPort();
        model.addAttribute("ipPort", ipPort);
        return "converter-view";
    }

    @Data
    static class Form {
        private IpPort ipPort;

        public Form(IpPort ipPort) {
            this.ipPort = ipPort;
        }
    }
}
  • 가장 간단한 형태의 GET, POST 두 개를 만들었다. GET에는 모델에 `form`Form 객체를 담아서 화면에 보여주고, POST에선 폼에 담긴 데이터를 @ModelAttribute가 받는다.

이제 뷰 템플릿을 만들어보자.

src/main/resources/templates/converter-form.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<form th:object="${form}" th:method="post">
    th:field <input type="text" th:field="*{ipPort}"><br/> <!--th:field 이 녀석은 컨버터를 자동 적용해준다.-->
    th:value <input type="text" th:value="*{ipPort}">(보여주기 용도)<br/>
    <input type="submit"/>
</form>

</body>
</html>

 

실행을 해보면, 우선 다음 화면을 보자.

  • `th:field`의 경우 컨버터가 적용이 됐다. 이 `th:field`는 무조건 컨버터 적용을 할 수 있으면 해주는 좋은 녀석이다.
  • `th:value`의 경우 객체 그대로가 출력된다. 그래서 만약, 컨버터 적용을 하고 싶지 않다면 이 녀석을 사용하면 된다.

 

이것을 `Submit`을 통해 POST 요청을 날려보면 어떻게 될까? 마찬가지로 컨버터가 적용된다.

왜냐하면, @RequestParam과 같이 @ModelAttribute도 컨버터가 자동으로 적용되기 때문이다.

 

포맷터 만들기

컨버터는 입력과 출력 타입에 제한이 없는, 범용 타입 변환 기능을 제공한다.

이번에는 일반적인 웹 애플리케이션 환경을 생각해보자. 불린 타입을 숫자로 바꾸는 것 같은 범용 기능보다는 개발자 입장에서는 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 상황이 대부분이다.

 

웹 애플리케이션에서 객체를 문자로, 문자를 객체로 변환하는 예

  • 화면에 숫자를 출력해야 하는데, Integer → String 출력 시점에 숫자 1000 → "1,000" 이렇게 단위에 쉼표를 넣어서 출력하거나 또는 "1,000" → 1000 이라는 숫자로 변경해야 한다. 
  • 날짜 객체를 문자인 "2024-09-11 10:50:11"과 같이 출력하거나 그 반대의 상황

Locale

여기에 추가로 날짜 숫자의 표현 방법은 Locale 현지화 정보가 사용될 수 있다.

 

 

이렇게 객체를 특정한 포맷에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능이 바로 포맷터이다.

Converter vs Formatter

  • Converter는 범용 (객체 → 객체)
  • Formatter는 문자에 특화(객체 → 문자, 문자 → 객체) + 현지화(Locale)

Formatter 인터페이스

package org.springframework.format;

public interface Formatter<T> extends Printer<T>, Parser<T> {
}
package org.springframework.format;

import java.util.Locale;

@FunctionalInterface
public interface Printer<T> {
    String print(T object, Locale locale);
}
package org.springframework.format;

import java.text.ParseException;
import java.util.Locale;

@FunctionalInterface
public interface Parser<T> {
    T parse(String text, Locale locale) throws ParseException;
}
  • String print(T object, Locale locale): 객체를 문자로 변경한다.
  • T parse(String text, Locale locale): 문자를 객체로 변환한다.

숫자 1000을 문자 "1,000"으로 그러니까 1000단위로 쉼표가 들어가는 포맷을 적용해보자. 그리고 그 반대도 처리해주는 포맷터를 만들어보자.

MyNumberFormatter

package cwchoiit.converter.formatter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.format.Formatter;

import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Locale;

@Slf4j
public class MyNumberFormatter implements Formatter<Number> {

    @Override
    public Number parse(String text, Locale locale) throws ParseException {
        log.info("text = {}, locale = {}", text, locale);
        NumberFormat format = NumberFormat.getInstance(locale);
        return format.parse(text);
    }

    @Override
    public String print(Number object, Locale locale) {
        log.info("object = {}, locale = {}", object, locale);
        NumberFormat instance = NumberFormat.getInstance(locale);
        return instance.format(object);
    }
}
  • "1,000"처럼 숫자 중간의 쉼표를 적용하려면 자바가 기본으로 제공하는 NumberFormat 객체를 사용하면 된다. 이 객체는 Locale 정보를 활용해서 나라별로 다른 숫자 포맷을 만들어준다.
  • parse()를 사용해서 문자를 숫자로 변환한다. 참고로 Number 타입은 Integer, Long과 같은 숫자 타입의 부모 클래스이다.

잘 동작하는지 테스트 코드를 작성해보자.

package cwchoiit.converter.formatter;

import org.junit.jupiter.api.Test;

import java.text.ParseException;
import java.util.Locale;

import static org.assertj.core.api.Assertions.*;

class MyNumberFormatterTest {

    MyNumberFormatter formatter = new MyNumberFormatter();

    @Test
    void parse() throws ParseException {
        Number result = formatter.parse("1,000", Locale.KOREA);
        assertThat(result).isEqualTo(1000L);

        Number result2 = formatter.parse("1,000,000", Locale.KOREA);
        assertThat(result2).isEqualTo(1_000_000L);
    }

    @Test
    void print() {
        String result = formatter.print(1000, Locale.KOREA);
        assertThat(result).isEqualTo("1,000");
    }
}

 

 

이제 이 만든 포맷터를 등록해서 여기저기서 쉽게 사용할 수 있도록 해보자.

포맷터를 지원하는 컨버전 서비스

package cwchoiit.converter.formatter;

import cwchoiit.converter.controller.converter.IpPortToStringConverter;
import cwchoiit.converter.controller.converter.StringToIpPortConverter;
import cwchoiit.converter.type.IpPort;
import org.junit.jupiter.api.Test;
import org.springframework.format.support.DefaultFormattingConversionService;

import static org.assertj.core.api.Assertions.*;

public class FormattingConversionServiceTest {
    
    @Test
    void formattingConversionService() {
        DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();

        conversionService.addConverter(new StringToIpPortConverter());
        conversionService.addConverter(new IpPortToStringConverter());

        conversionService.addFormatter(new MyNumberFormatter());

        // 컨버터 사용
        IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
        assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));

        // 포맷터 사용
        assertThat(conversionService.convert(1000, String.class)).isEqualTo("1,000");
        assertThat(conversionService.convert("1,000", Integer.class)).isEqualTo(1000L);
    }
}
  • DefaultFormattingConversionServiceFormatter는 물론, Converter도 등록하고 사용할 수 있다.
  • 그 이유는 DefaultFormattingConversionServiceConversionService관련 기능을 상속받기 때문이다. 또한 ConversionService가 제공하는 convert를 사용해서 컨버터 또는 포맷터를 사용할 수 있다.

DefaultFormattingConversionService는 다음과 같은 상속 구조를 가지고 있다.

  • FormattingConversionService를 상속받는다. 그리고 이 FormattingConversionServiceGenericConversionServiceFormatterRegistry를 상속받는다. 그 위로 더 올라가면, ConversionServiceConversionRegistry가 있다. 그렇기 때문에 등록은 물론 사용도 가능하게 되는 것이다. 

 

참고로, 스프링 부트는 DefaultFormattingConversionService를 상속받은 WebConversionService를 내부에서 사용한다. 

 

포맷터 적용하기

이제 이 포맷터를 웹 애플리케이션에 적용해보자. 스프링이 제공하는 WebMvcConfigurer에서 작업하면 된다.

WebConfig

package cwchoiit.converter;

import cwchoiit.converter.controller.converter.IpPortToStringConverter;
import cwchoiit.converter.controller.converter.StringToIpPortConverter;
import cwchoiit.converter.formatter.MyNumberFormatter;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new IpPortToStringConverter());
        registry.addConverter(new StringToIpPortConverter());

        registry.addFormatter(new MyNumberFormatter());
    }
}
  • 주의할 점은, 만약 컨버터로 문자를 숫자로, 숫자를 문자로 변환하는 컨버터를 등록하면 우선순위 때문에 포맷터가 적용되지 않는다. 우선순위는 컨버터가 포맷터보다 우선순위가 높기 때문에 숫자를 문자로, 문자를 숫자로 변환하는 우리가 만든 포맷터는 컨버터에 의해 적용되지 않는다. 그래서 나같은 경우 아예 해당 컨버터들은 등록하지 않고 지워버렸다.

 

그럼 이제 실행해보자. 이전에 사용했던 뷰 템플릿을 보여주는 컨트롤러이다.

@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과 같은 녀석들도 모두 적용된다. 아래 경로에 한번 테스트해보자.

@GetMapping("/v2")
public String hello2(@RequestParam Integer data) {
    log.debug("i : {}", data);
    return "ok";
}

보면, 쿼리 파라미터로 "1,000"을 넘겼고 그 값을 숫자 1000으로 변환한 로그가 찍힌 모습이다.

 

스프링이 제공하는 기본 포맷터

이 부분이 제일 중요하다. 사실 스프링은 자바에서 기본으로 제공하는 타입들에 대해 수 많은 포맷터를 기본으로 제공한다.

그런데, 포맷터는 기본 형식이 지정이 되어 있다. 그래서 객체의 각 필드마다 다른 형식으로 포맷을 지정하기가 어렵다. 스프링은 이런 문제를 해결하기 위해 애노테이션 기반으로 원하는 형식을 지정해서 사용할 수 있는 매우 유용한 포맷터 두 가지를 기본으로 제공한다.

 

  • @NumberFormat: 숫자 관련 형식 지정 포맷터 사용
  • @DateTimeFormat: 날짜 관련 형식 지정 포맷터 사용

무슨말인지는 예제를 통해 알아보자.

FormatterController

package cwchoiit.converter.controller;

import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.NumberFormat;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

import java.time.LocalDateTime;

@Controller
public class FormatterController {

    @GetMapping("/formatter/edit")
    public String formatterForm(Model model) {
        Form form = new Form();
        form.setNumber(10000);
        form.setLocalDateTime(LocalDateTime.now());
        model.addAttribute("form", form);
        return "formatter-form";
    }

    @PostMapping("/formatter/edit")
    public String formatterEdit(@ModelAttribute Form form) {
        return "formatter-view";
    }

    @Data
    static class Form {
        @NumberFormat(pattern = "###,###")
        private Integer number;

        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private LocalDateTime localDateTime;
    }
}
  • 저기보면, 정적 내부 클래스로 Form이 있다. 그리고 각 필드에 @NumberFormat, @DateTimeFormat 애노테이션이 달려있고 그 패턴을 지정해두었다. 이렇게 되면 이제 이 객체를 사용할 때 저 형식으로 포맷이 적용되는 것이다.

테스트 하기 위해 뷰 템플릿을 만들자.

formatter-form.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<form th:object="${form}" th:method="post">
    number <input type="text" th:field="*{number}"><br/>
    localDateTime <input type="text" th:field="*{localDateTime}"><br/>
    <input type="submit"/>
</form>

</body>
</html>

formatter-view.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<ul>
    <li>${form.number}: <span th:text="${form.number}" ></span></li>
    <li>${{form.number}}: <span th:text="${{form.number}}" ></span></li>
    <li>${form.localDateTime}: <span th:text="${form.localDateTime}" ></span></li>
    <li>${{form.localDateTime}}: <span th:text="${{form.localDateTime}}" ></span></li>
</ul>

</body>
</html>

 

실행해보자.

  • 분명 `form.setNumber(10000);`으로 값을 넣었지만, th:field를 사용해서 값을 출력해보니 `10,000`이렇게 보여진다. 포맷터가 잘 적용된 모습이다. LocalDateTime도 마찬가지.

[Submit]버튼을 클릭해서 POST 요청을 날려보면, 다음과 같이 보여진다.

@ModelAttribute도 포맷터가 잘 적용된 모습이다. 이렇게 각 객체의 필드별로 포맷을 자유자재로 정의할수도 있다. 애노테이션을 활용해서! 

 

정리를 하자면

컨버터와 포맷터를 알아보았다. 컨버터를 사용하든, 포맷터를 사용하든 등록 방법은 달라도 사용할 때는 컨버전서비스를 통해서 일관성있게 사용할 수 있었다. 포맷터는 여기서 더 나아가서 애노테이션 기반으로도 포맷을 지정할 수가 있었다. 이로 인해 조금 더 편하고 유연하게 적용을 할 수 있었다. 

 

주의할 점은, 메시지 컨버터(HttpMessageConverter)에는 컨버전 서비스가 적용되지 않는다. 특히 객체를 JSON으로 변환할 때 메시지 컨버터를 사용하면서 이 부분을 많이 오해하는데, HttpMessageConverter의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다. 예를 들어 JSON을 객체로 변환하는 메시지 컨버터는 내부에서 Jackson과 같은 라이브러리를 사용한다. 객체를 JSON으로 변환한다면 그 결과는 이 라이브러리에 달린 것이다. 따라서 JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야 한다. 결과적으로 이것은 컨버전 서비스와 전혀 관계가 없다.

 

컨버전 서비스는 @RequestParam, @ModelAttribute, @PathVariable, 뷰 템플릿 등에서 사용할 수 있다.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

이것도 결론부터 말하자면, @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 HeaderAcceptapplication/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를 추가했다. 이건 어떤거냐면, 요청할 때 헤더에 Acceptapplication/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);
    }
}

...

여기보면, errorHtmlerror라는 두 개의 메서드가 있다. 에러가 발생해서 이 컨트롤러를 호출하면 HTML을 호출해야 하는 경우 저 errorHtml 메서드가 실행되는거고, API 예외를 처리하는 경우 저 error 메서드가 실행되는 것이다. 보면 errorHtml 메서드에 produces"text/html" 이라고 되어 있다. 그럼 다시 한번 아까 예외 테스트를 할 때 호출했던 경로로 호출해보면 어떻게 될까?

바로 이렇게 보여진다. 이게 스프링 부트의 기본 오류 처리이다. 이게 저 BasicErrorControllererror() 메서드가 반환하는 값이 되는 것이고. 그리고 여기 보여지는 값도 오류 화면 페이지에서 막 이것저것 보여줄 수 있게 설정한 것 처럼 더 추가할 수 있다.

application.properties

server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=always
server.error.include-binding-errors=always

 

이것들을 추가한 다음 다시 호출해보면, 

위와 같이 쭉 뭐가 더 중요한 정보들이 나온다. 이건 사용하면 안된다고 했다. 다시 돌려 놓자.

 

스프링 부트가 기본으로 제공하는 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가 처리하게 된다. 그리고 이때, 요청 헤더의 AcceptAPPLICATION/JSON이므로 API 예외 처리 방식으로 원하는 상태코드와 메시지가 나가게 되는 것이다.

 

이 메서드는 반환 할 수 있는 방식이 3가지이다.

  • new ModelAndView(): 빈 ModelAndView 객체를 반환하면, API 예외 처리 방식으로 진행된다.
  • 꽉 찬 ModelAndView(): 실제로 ModelView를 꽉 채워서 반환하면 우리가 원하는 뷰를 반환한다. 예를 들면 4xx.html을 반환하게 할 수 있다. 
  • null: null을 반환하는 경우, 이 HandlerExceptionResolver가 해결할 수 없는 예외라 판단하고 WAS까지 예외가 올라간다. 그래서 기존 예외가 그대로 던져진다.

HandlerExceptionResolver를 이제 등록만 하면 된다.

WebConfig

package cwchoiit.exception;

import cwchoiit.exception.interceptor.LogInterceptor;
import cwchoiit.exception.resolver.MyHandlerExceptionResolver;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "*.ico", "/error", "/error-page/**");
    }

    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }
}
  • 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(...)와 달리, 다시 필터부터 서블릿으로 거슬러 올라가는 그 행위를 하지 않아도 된다.

 

이제 이 HandlerExceptionResolver를 또 등록해야 한다.

package cwchoiit.exception;

import cwchoiit.exception.interceptor.LogInterceptor;
import cwchoiit.exception.resolver.MyHandlerExceptionResolver;
import cwchoiit.exception.resolver.UserHandlerExceptionResolver;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "*.ico", "/error", "/error-page/**");
    }

    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
        resolvers.add(new UserHandlerExceptionResolver());
    }
}

이렇게 하고 실행해보자.

응답 결과는 우리가 원하는대로 잘 나온다. 그리고 이게 정말 다시 거슬러 올라가서 예외를 처리하는 컨트롤러인 BasicErrorController를 재호출하는 과정이 없는지 보자. 예전에 로그를 찍는 인터셉터를 등록했으니까 그 결과를 보면 된다.

afterCompletion에서 예외가 발생한 경우 예외를 로그로 출력했는데 그 로그가 출력되지 않았다. 즉, 예외를 그냥 HandlerExceptionResolver에서 먹어버리고 WAS에게 어떻게 응답을 내보낼지 모든것을 다 정의해서 전달한 것이다.

 

이렇게 활용해서 비효율적인 서버 내부적인 재호출을 막을 순 있다. 그러나, 일단 이 방식은 사용하지 않을 것이다. 영원히. 왜냐하면 이것 또한 너무 귀찮다. 요청 헤더에서 Accept 값을 찾고, 인코딩 정보, Content-Type 등등 getWriter()로 응답 메시지 처리 등 귀찮다. 이럴때 역시 스프링이 도와준다. 지금부터 스프링이 제공하는 ExceptionResolver를 알아보자.

 

API 예외 처리 - 스프링이 제공하는 ExceptionResolver 1

스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같다.

HandlerExceptionResolverComposite에 다음 순서로 등록

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver → 우선 순위가 가장 낮다.

 

우선순위가 가장 높은 ExceptionHandlerExceptionResolver@ExceptionHandler를 처리하는 녀석이다. 가장 중요하고 거의 모든 에러 처리를 이것으로 할 것이기 때문에 제일 마지막에 알아보겠다. 

 

 

ResponseStatusExceptionResolver는 Http 상태 코드, 원인을 지정해서 사용하는 @ResponseStatus 애노테이션을 처리한다.

@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "못찾음")

 

DefaultHandlerExceptionResolver는 스프링 내부 기본 예외를 처리한다.

 

ResponseStatusExceptionResolver

이 녀석은 다음 두 가지 경우를 처리한다.

  • @ResponseStatus가 달려있는 예외
  • ResponseStatusException 예외

다음과 같은 예외 하나를 만들어보자.

package cwchoiit.exception.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {

}

이 예외를 컨트롤러 밖으로 던져버리면 ResponseStatusExceptionResolver가 해당 예외의 애노테이션을 확인해서 오류 코드를 400으로 변경하고 메시지도 담는다. 

 

테스트를 위해 컨트롤러에 아래 경로 하나를 추가하자.

@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
    throw new BadRequestException();
}

이렇게 간단하게 응답 코드와 메시지를 출력할 수 있다. 아 참고로, 메시지를 출력하려면, application.properties에서 이 코드 추가해야 한다.

server.error.include-message=always

 

근데, 이게 뭐 별게 아니다. 직접 ResponseStatusExceptionResolver에 들어가보면, 이런 코드가 있다.

@Nullable
protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {
    try {
        ...
        ResponseStatus status = (ResponseStatus)AnnotatedElementUtils.findMergedAnnotation(ex.getClass(), ResponseStatus.class);
        if (status != null) {
            return this.resolveResponseStatus(status, request, response, handler, ex);
        }
		...
    } catch (Exception var8) {
        ...
    }
    return null;
}
  • 여기서 지금 반환 타입 ModelAndView이다. 우리가 직접 해봤던 그 HandlerExceptionResolver랑 똑같은 짓을 하고 있는거다.
  • 그리고 AnnotatedElementUtils.findMergedAnnotation(...)이걸 사용해서 ResponseStatus 애노테이션이 달려있는지 체크를 해서, 있으면 그 안에 정보들을 가져올 뿐이다. 
  • 그리고 호출하는 메서드가 resolveResponseStatus(...)이다. 
protected ModelAndView resolveResponseStatus(ResponseStatus responseStatus, HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) throws Exception {
    int statusCode = responseStatus.code().value();
    String reason = responseStatus.reason();
    return this.applyStatusAndReason(statusCode, reason, response);
}
  • 여기 보면, 그 ResponseStatus 애노테이션에 속성값으로, `code`, `reason`이 있었고 거기에 넣은 값 가져오는 코드이다.
  • 그리고 applyStatusAndReason(...)을 호출한다.
protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response) throws IOException {
    if (!StringUtils.hasLength(reason)) {
        response.sendError(statusCode);
    } else {
        String resolvedReason = this.messageSource != null ? this.messageSource.getMessage(reason, (Object[])null, reason, LocaleContextHolder.getLocale()) : reason;
        response.sendError(statusCode, resolvedReason);
    }

    return new ModelAndView();
}
  • 결국 그 메서드는 그냥 우리 했던거랑 똑같이 response.sendError(...) 호출하는 것 뿐이다. 즉, 이 방식도 WAS까지 갔다가 다시 서버 내부적으로 재호출하는 코드인 것.

 

직접 HandlerExceptionResolver를 구현해보니까, 스프링이라는 위대하고 거대한 프레임워크가 만든 코드가 이해가 되는 것이다. 그래서 이 과정이 굉장히 소중하다고 생각하는데.. 여튼, 이 ResponseStatusExceptionResolver가 또 재밌는게 있다. 메시지 기능을 사용할 수가 있다. 아래 처럼 `reason`값에 메시지 코드를 넣어보자.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {

}

messages.properties

error.bad=오우 에러가 났어요!

 

 

이렇게 적용을 하면, 메시지 사용을 할 수 있다.

 

이것도 사실 뭐 별게 아니다. 위 코드보면 이런 부분이 있다.

String resolvedReason = this.messageSource != null ? this.messageSource.getMessage(reason, (Object[])null, reason, LocaleContextHolder.getLocale()) : reason;

결국 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 라는 메서드를 실행한다. 들어가보면 이렇게 되어 있다.

protected ModelAndView handleTypeMismatch(TypeMismatchException ex, HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {
    response.sendError(400);
    return new ModelAndView();
}
  • 마찬가지로 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으로 반환된다.
  • @ResponseStatus(HttpStatus.BAD_REQUEST)를 지정했으므로 400으로 반환된다.

 

그리고 이 @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를 통해서 예외 처리를 깔끔하고 완벽하게 할 수 있었다. 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

 

서블릿 예외 처리

결론을 먼저 말하겠다. 스프링 부트를 사용한다면 이 방식을 사용하지 말자. 구식이기도 하며 더 불편하다. 그렇지만, 모든 개념은 다 태초부터 이해해야 왜 지금 현재 사용하는 기능이 나타났고 이 기능이 어떤 불편함을 해결했는지를 이해할 수 있기 때문에 이 서블릿 예외 처리부터 시작할 뿐이다. 

 

스프링을 사용해서 예외 처리를 편리하게 다루기 앞서, 서블릿 컨테이너는 예외 처리를 어떻게 하는지부터 알아보자.

서블릿은 다음 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 흐름

WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러 (response.sendError())

sendError를 호출하면, response 내부에는 오류가 발생했다는 상태를 저장해둔다. 그리고 서블릿 컨테이너는 고객에게 응답 전에 responsesendError()가 호출되었는지 확인한다. 그리고 호출되었다면 설정한 오류코드에 맞추어 기본 오류 페이지를 보여준다.

 

마찬가지로 해당 경로로 요청을 날려보면, 다음 사진처럼 Tomcat이 제공하는 오류 페이지를 볼 수 있을것이다.

그러나, 서블릿 컨테이너가 제공하는 기본 예외 처리 화면은 사용자가 보기에 많이 불편하다. 그래서 의미있는 오류 화면을 제공해보자.

 

서블릿 예외 처리 - 오류 화면 제공

서블릿 컨테이너가 제공하는 기본 예외 처리 화면은 고객 친화적이지 않다. 서블릿이 제공하는 오류 화면을 커스텀할 수 있는 기능을 사용해보자. 서블릿은 Exception이 발생해서 서블릿 밖으로 전달되거나 또는 response.sendError()가 호출되었을 때, 각각의 상황에 맞춘 오류 처리 기능을 제공한다.

 

과거에는 web.xml이라는 파일에 다음과 같이 오류 화면을 등록했다.

 <web-app>
     <error-page>
     <error-code>404</error-code>
     <location>/error-page/404.html</location>
     </error-page>
     <error-page>
     <error-code>500</error-code>
     <location>/error-page/500.html</location>
     </error-page>
     <error-page>
     <exception-type>java.lang.RuntimeException</exception-type>
     <location>/error-page/500.html</location>
     </error-page>
 </web-app>

 

지금은 스프링 부트를 통해서 서블릿 컨테이너를 실행하기 때문에, 스프링 부트가 제공하는 기능을 사용해서 서블릿 오류 페이지 커스텀 등록하면 된다. 

 

서블릿 오류 페이지 커스텀 등록

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);
    }
}
  • WebServerFactoryCustomizer<ConfigurableServletWebServerFactory>를 상속받는 클래스가 필요하다.
  • 이 인터페이스가 구현해야 할 메서드인 customize()를 구현한다.
  • response.sendError(404) 코드가 입력되면 이제 errorPage404를 호출한다. ("/error-page/404" 경로를 재 호출)
  • response.sendError(500) 코드가 입력되면 이제 errorPage500을 호출한다.("/error-page/500" 경로를 재 호출) 
  • RuntimeException 또는 그 자식 타입의 예외가 호출되면 errorPageEx를 호출한다. ("/error-page/500" 경로를 재 호출)  

그러니까 스프링 부트와 서블릿 오류 페이지를 이용해서 에러 화면 처리를 하려면 이렇게 하면 된다. 결국, sendError()이던 Exception이든 발생해서 WAS까지 올라오면, 서블릿 컨테이너가 위에 커스텀 한 경로의 컨트롤러를 재호출한다.

 

위 경로의 컨트롤러를 재호출한다는 의미는, 해당 컨트롤러가 있어야 한다는 말이다.

ErrorPageController

package cwchoiit.exception.servlet;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Slf4j
@Controller
@RequestMapping("/error-page")
public class ErrorPageController {

    @RequestMapping("/404")
    public String error404(HttpServletRequest request, HttpServletResponse response) {
        log.info("errorPage 404");
        return "error-page/404";
    }

    @RequestMapping("/500")
    public String error500(HttpServletRequest request, HttpServletResponse response) {
        log.info("errorPage 500");
        return "error-page/500";
    }
}

 

오류 처리 View

src/main/resources/templates/404.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>

<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>404 오류 화면</h2>
    </div>

    <div>
        <p>오류 화면 입니다.</p>
    </div>

    <hr class="my-4">

</div> <!-- /container -->

</body>
</html>

 

src/main/resources/templates/500.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>

<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>500 오류 화면</h2>
    </div>

    <div>
        <p>오류 화면 입니다.</p>
    </div>

    <hr class="my-4">

</div> <!-- /container -->

</body>
</html>

 

이렇게 화면과 에러 처리 컨트롤러를 만들고 나서 다시 아까 만든 이 녀석을 호출해보자.

@GetMapping("/error-ex")
public void errorEx() {
    throw new RuntimeException("예외 발생");
}

이제는 Tomcat이 기본으로 제공하는 못생긴 화면이 아닌 개발자가 직접 만든 오류 화면으로 보여진다. (물론 이것도 못생긴 것 같다..)

위에서 잠깐 얘기했지만 이 오류 페이지가 보여지는 작동 원리는 Exception이든 sendError()이든 WAS까지 올라오면 예외 커스텀 처리를 했는지 보고, 했다면 그 경로에 대한 컨트롤러를 재호출하는 한번 왔다가 다시 가는 이런 방식이다. 아래에서 좀 더 자세히 말해보자.

 

서블릿 예외 처리 - 오류 페이지 작동 원리

서블릿은 Exception이 발생해서 서블릿 밖으로 전달되거나, response.sendError()가 호출되었을 때, 설정된 오류 페이지를 찾는다.

 

예외 발생 흐름

WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)

 

sendError 흐름

WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(response.sendError())


WAS는 해당 예외를 처리하는 오류 페이지 정보를 확인한다.

`new ErrorPage(RuntimeException.class, "/error-page/500")`

 

예를 들어서, RuntimeException 예외가 WAS까지 전달이 되면, WAS는 오류 페이지 정보를 확인한다. 확인해보니 RuntimeException의 오류 페이지로 `/error-page/500`이 지정되어 있다. WAS는 오류 페이지를 출력하기 위해 `/error-page/500`을 다시 요청한다. 

 

오류 페이지 요청 흐름

WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View

 

예외 발생과 오류 페이지 요청 흐름

1. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
2. WAS `/error-page/500` 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View

 

중요한 점은 웹 브라우저(클라이언트)는 서버 내부에서 이런 일이 일어나는지 전혀 모른다는 점이다. 오직 서버 내부에서 오류 페이지를 찾기 위해 추가적인 호출을 한다.

 

정리를 하자면,

  1. 예외가 발생하거나 response.sendError()메서드를 호출해서 WAS까지 전파된다.
  2. WAS는 오류 페이지 경로를 찾아서 내부에서 오류 페이지를 호출한다. 이때 오류 페이지 경로로 필터, 서블릿, 인터셉터, 컨트롤러가 모두 다시 호출된다.

오류 정보 추가

WAS는 오류 페이지를 단순히 다시 요청만 하는 것이 아니라, 오류 정보를 requestattribute에 추가해서 넘겨준다. 필요하면 오류 페이지에서 이렇게 전달된 오류 정보를 사용할 수 있다.

 

그래서 실제로 어떤 오류 정보를 넘겨주는지 찍어보자.

ErrorPageController

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.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Slf4j
@Controller
@RequestMapping("/error-page")
public class ErrorPageController {

    @RequestMapping("/404")
    public String error404(HttpServletRequest request, HttpServletResponse response) {
        printErrorInfo(request);
        log.info("errorPage 404");
        return "error-page/404";
    }

    @RequestMapping("/500")
    public String error500(HttpServletRequest request, HttpServletResponse response) {
        printErrorInfo(request);
        log.info("errorPage 500");
        return "error-page/500";
    }

    private void printErrorInfo(HttpServletRequest request) {
        log.info("ERROR_EXCEPTION: {}", request.getAttribute(RequestDispatcher.ERROR_EXCEPTION));
        log.info("ERROR_EXCEPTION TYPE: {}", request.getAttribute(RequestDispatcher.ERROR_EXCEPTION_TYPE));
        log.info("ERROR_EXCEPTION MESSAGE: {}", request.getAttribute(RequestDispatcher.ERROR_MESSAGE));
        log.info("ERROR_REQUEST_URI: {}", request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI));
        log.info("ERROR_SERVLET_NAME: {}", request.getAttribute(RequestDispatcher.ERROR_SERVLET_NAME));
        log.info("ERROR_STATUS_CODE: {}", request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE));
        log.info("dispatcherType: {}", request.getDispatcherType());
    }
}
  • 기존에 만들어 둔 에러 페이지에 대한 컨트롤러에 여러 오류 정보를 출력하는 메서드 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에 담아서 에러 처리 컨트롤러를 재호출한다고 했는데, 그때 찍었던 로그 중 이런 부분이 있었다.

log.info("dispatcherType: {}", request.getDispatcherType());

그리고 출력을 해보면 오류 페이지에서 dispatcherType: ERROR라고 나오는 것을 확인할 수 있다.

2024-09-07T13:15:03.976+09:00  INFO 99797 --- [nio-8080-exec-2] c.exception.servlet.ErrorPageController  : dispatcherType: ERROR

 

고객이 처음 요청할 땐 이 값은 REQUEST이다. 이렇게 서블릿 스펙은 실제 고객이 요청한 것인지, 서버가 내부적으로 오류 페이지를 요청하는 것인지 DispatcherType으로 구분할 수 있는 방법을 제공한다.

package jakarta.servlet;

public enum DispatcherType {
    FORWARD,
    INCLUDE,
    REQUEST,
    ASYNC,
    ERROR;

    private DispatcherType() {
    }
}
  • FORWARD: MVC에서 배웠던 서블릿에서 다른 서블릿이나 JSP를 호출할 때
  • INCLUDE: 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때
  • REQUEST: 클라이언트 요청
  • ASYNC: 서블릿 비동기 호출
  • ERROR: 오류 요청

그래서 필터에는 DispatcherTypeERROR라면 필터를 적용하지 않는 어떤 방법이 있지 않을까? 맞다. 그리고 이게 Default이다.

필터와 DispatcherType

LogFilter

package cwchoiit.exception.servlet;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.util.UUID;

@Slf4j
public class LogFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("LogFilter init");
    }

    @Override
    public void doFilter(ServletRequest servletRequest,
                         ServletResponse servletResponse,
                         FilterChain filterChain) throws IOException, ServletException {
        log.info("LogFilter doFilter");

        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        String uuid = UUID.randomUUID().toString();

        try {
            log.info("REQUEST: [{}][{}][{}]", uuid, request.getDispatcherType(), request.getRequestURI());
            filterChain.doFilter(request, response);
        } catch (Exception e) {
            throw new ServletException(e);
        } finally {
            log.info("RESPONSE: [{}][{}][{}]", uuid, request.getDispatcherType(), request.getRequestURI());
        }
    }

    @Override
    public void destroy() {
        log.info("LogFilter destroy");
    }
}

예전에 로그인 포스팅때 사용했던 LogFilter를 가져와서 DispatcherType을 로그로 찍는것만 추가했다.

실제로 고객이 요청이 들어올땐 어떤게 찍히고, 서블릿이 내부적으로 오류 페이지를 호출할 땐 어떻게 찍히는지 보기 위함이다.

 

WebConfig

package cwchoiit.exception;

import cwchoiit.exception.servlet.LogFilter;
import jakarta.servlet.DispatcherType;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public FilterRegistrationBean<LogFilter> filterRegistrationBean() {
        FilterRegistrationBean<LogFilter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");
        filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);

        return filterRegistrationBean;
    }
}

필터를 사용하려면 필터를 등록했어야 했다. 여기서 유심히 볼 부분은 setDispatcherTypes()이다. 지금 보면 REQUEST, ERROR 타입을 추가해줬다. 즉, 저 두개의 타입일 때 이 필터가 적용될 것이라는 의미이다. 그럼 여기서 ERROR일땐 필터를 적용하기 싫다면? 빼버리면 된다. 그리고 기본값이 REQUEST만 있는 형태이다.

 

이 상태에서 다시 요청을 해보자. 다음과 같이 로그가 찍힐 것이다.

  • 보면 최초에 내가 브라우저를 통해 요청한 것은 DispatcherTypeREQUEST라고 찍혔다.
  • 그렇게 요청에 대한 응답이 돌아왔는데 여기서 response.sendError(404, "404 오류")가 저장된 상태라 WAS는 해당 정보를 보고 이 에러를 처리할 컨트롤러를 찾고 그 컨트롤러에 요청을 내부적으로 다시 한번 더 한다.
  • 그래서 실제로 그렇게 요청을 더 했다는 것을 DispatcherTypeERROR로 찍힌 필터의 로그가 한번 더 찍힌것을 볼 수 있다. 이렇듯 만약, 적용할 DispatcherType에 ERROR도 추가하면 WAS가 내부적으로 오류 처리 페이지를 호출할때도 필터가 적용된다.

정리를 하자면,

그래서, 오류 페이지 경로도 필터를 적용할 것이 아니면 기본값 그대로를 사용하면 된다. 그게 아니라 오류 페이지 요청 전용 필터를 적용하고 싶으면 DispatcherType.ERROR만 적용해도 된다. 

 

그럼 필터는 이렇게 알아봤는데 인터셉터는 어떻게 동작할까?

서블릿 예외 처리 - 인터셉터

인터셉터도 마찬가지로 두번 호출이 된다. 그리고 이 인터셉터도 역시 개발자가 원하면 두번 다 호출시킬수도 아닐수도 있다.

LogInterceptor

package cwchoiit.exception.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import java.util.UUID;

@Slf4j
public class LogInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String uuid = UUID.randomUUID().toString();
        request.setAttribute("uuid", uuid);

        // @RequestMapping: HandlerMethod
        // 정적 리소스: ResourceHttpRequestHandler
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;
        }

        log.info("REQUEST [{}][{}][{}][{}]", uuid, request.getDispatcherType(), request.getRequestURI(), handler);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("postHandle [{}]", modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String uuid = (String) request.getAttribute("uuid");
        String requestURI = request.getRequestURI();
        log.info("RESPONSE [{}][{}][{}][{}]", uuid, request.getDispatcherType(), requestURI, handler);

        if (ex != null) {
            log.error("afterCompletion err", ex);
        }
    }
}

마찬가지로, 로그인 포스팅때 사용했던 LogInterceptor를 가져왔다. 그리고 로그 출력 시 DispatcherType을 추가하는 것만 달라졌다.

이 인터셉터도 사용하기 위해 등록해야 한다.

WebConfig

package cwchoiit.exception;

import cwchoiit.exception.interceptor.LogInterceptor;
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 LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "*.ico", "/error", "/error-page/**");
    }
}

인터셉터를 등록하기 위해 WebMvcConfigurer를 구현한다. 해당 인터페이스의 메서드인 addInterceptors를 사용해 등록하자.

여기서 인터셉터를 등록할때 이 인터셉터는 필터와 달리 DispatcherType을 지정하는 건 없다. 대신, 오류 페이지를 보여주는 컨트롤러의 경로를 제외시켜버리면 된다. 위 excludePathPatterns(..., "/error-page/**")에서 한 것처럼 말이다.

 

이 또한 마찬가지로, 에러 페이지 호출 컨트롤러에도 인터셉터를 적용하고자 하면 제외 경로에서 빼버리면 될 것이다. 

그래서 만약, 저 "/error-page/**"을 빼고 인터셉터를 등록한 후 `/error-page/ex`를 브라우저에서 호출한다면,

브라우저에선 이렇게 에러 화면이 보일 것이고, 로그는 다음과 같이 보일 것이다.

  • 첫번째 사용자 요청은 DispatcherTypeREQUEST이다.
  • 에러가 발생했다. 에러가 발생하면 인터셉터는 postHandle이 호출되지 않는다고 했다. 그래서 호출되지 않았고 afterCompletion만 호출된 모습이다. 그리고 그 메서드 안에서는 에러가 있는 경우 에러를 출력하기 때문에 첫번째 에러가 찍혔다.
  • 인터셉터에서 WAS까지 에러가 올라왔다. WAS에서 에러를 출력했다. 이게 두번째 에러 메시지다. 그리고 WAS는 에러를 처리하는 오류 페이지 정보를 확인하고 해당 컨트롤러를 내부적으로 재호출한다. 
  • 그때 인터셉터는 DispatcherTypeERROR이다. 그리고 해당 오류 페이지를 화면에 출력한다. 해당 오류 페이지 처리를 하는 컨트롤러 경로에 오류 메시지를 찍는 메서드가 있었다. 그 메서드 때문에 세번째 에러 메시지가 찍힌 모습이다.

정리를 하자면,

필터든 인터셉터든 서블릿이 처리하는 예외 동작은 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 파일을 만들면 되는것이다.

 

src/main/resources/templates/error/4xx.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>

<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>4xx 오류 화면 스프링 부트 제공</h2>
    </div>

    <div>
        <p>오류 화면 입니다.</p>
    </div>

    <hr class="my-4">

</div> <!-- /container -->

</body>
</html>

src/main/resources/templates/error/404.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>

<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>404 오류 화면 스프링 부트 제공</h2>
    </div>

    <div>
        <p>오류 화면 입니다.</p>
    </div>

    <hr class="my-4">

</div> <!-- /container -->

</body>
</html>

 

src/main/resources/templates/error/500.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>

<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>500 오류 화면 스프링 부트 제공</h2>
    </div>

    <div>
        <p>오류 화면 입니다.</p>
    </div>

    <hr class="my-4">

</div> <!-- /container -->

</body>
</html>

 

이제 위에서 서블릿 예외 처리를 위해 사용했던 모든 코드들을 다 지우고 그냥 딱 이 세 개 파일만 만들고 실행해보자.

`/error-ex` 경로로 진입해보자.

내가 만든 500.html 파일이 노출된다. 와우!? 이러니 스프링 부트가 나에겐 최애일 수 밖에 없다.

그리고 여기서 조금만 더 나아가서 추가적인 기능도 사용할 수가 있는데, BasicErrorController는 다음 정보를 Model에 담아서 View에 전달한다. 뷰 템플릿은 이 값을 활용할수도 있다.

* timestamp: Fri Feb 05 00:00:00 KST 2021
* status: 400
* error: Bad Request
* exception: org.springframework.validation.BindException * trace: 예외 trace
* message: Validation failed for object='data'. Error count: 1
* errors: Errors(BindingResult)
* path: 클라이언트 요청 경로 (`/hello`)

 

그래서 예시로 한번 500.html 파일을 아래와 같이 수정해보자.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>

<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>500 오류 화면 스프링 부트 제공</h2>
    </div>

    <div>
        <p>오류 화면 입니다.</p>
    </div>

    <ul>
        <li>오류 정보</li>
        <ul>
            <li th:text="|timestamp: ${timestamp}|"></li>
            <li th:text="|path: ${path}|"></li>
            <li th:text="|status: ${status}|"></li>
            <li th:text="|message: ${message}|"></li>
            <li th:text="|error: ${error}|"></li>
            <li th:text="|exception: ${exception}|"></li>
            <li th:text="|errors: ${errors}|"></li>
            <li th:text="|trace: ${trace}|"></li>
        </ul>
        </li>
    </ul>

    <hr class="my-4">

</div> <!-- /container -->

</body>
</html>

이러고 다시 실행해보면 이렇게 보일 것이다.

오류 정보에 대한 내용들이 이렇게 보여진다. 이건 스프링 부트가 자동으로 만들고 등록해주는 BasicErrorController가 정보를 넘겨준 것이다. 근데 보면, null이 많다. 왜 그러냐면, 기본적으로 이런 내용들을 사용자에게 노출하는건 안 좋은 행위이다. 개발자가 아닌 사람들은 무슨말인지도 모를거니와 해커들이 이런 내용을 보면 오히려 역이용해서 나쁜짓을 할 수 있기 때문에 말이다. 그래서 기본은 이 값을 다 지워버리지만 굳이 굳이 노출시킬수도 있다.

 

application.properties

server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=always
server.error.include-binding-errors=always

이렇게 속성을 다 나올수 있게 해주면 된다. 그리고 다시 보면 이렇게 다 보인다.

이런 내용이 나오면, 해커들은 "아 이 라이브러리 사용하는구나? 이 라이브러리는 이런 취약점이 있으니까 이런점을 공격해야겠다." 뭐 이런식으로 말이다. 아무튼 이런 짓은 하지말고 이게 가능하다는 점만 이해하고 넘어가자. 

 

모든 에러는 서버의 로그로 관리되어야 한다. 사용자나 외부로 노출시키면 안된다. 

 

정리

이제 스프링 부트를 사용하면, 공통 오류 페이지를 아주아주 쉽게 만들어 낼 수 있다. 서블릿이 처리하는 과정을 이해하고 나서 보니 "아 이런 과정들이 자동으로 되어 있구나."를 이해할 수 있게 됐다. 그리구 혹시 모르지 않나? 스프링 사용 못하는 환경에서 개발을 할 수도..? 그럴땐 이 포스팅에서 배운 서블릿을 활용한 오류 페이지 처리를 하면 된다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

ArgumentResolver 활용

ArgumentResolver를 사용해서, 세션에 있는 로그인 한 유저를 굉장히 간단하게 가져오는 방법을 소개한다.

 

다음 코드를 보자.

@GetMapping("/")
public String home(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member member,
                             Model model) {
    if (member == null) {
        return "home";
    }

    model.addAttribute("member", member);
    return "loginHome";
}
  • 웹 애플리케이션의 홈 경로이다. 세션에 저장된 로그인 한 유저를 찾기 위해 @SessionAttribute를 사용해서 가져오는 모습이다. 이것이 나쁘다는 게 아니라 이 코드가 아래처럼 바뀔 수 있다.
@GetMapping("/")
public String homeWithArgumentResolver(@Login Member loginMember, Model model) {
    if (loginMember == null) {
        return "home";
    }

    model.addAttribute("member", loginMember);
    return "loginHome";
}
  • @Login 이라는 애노테이션을 개발자가 직접 만들고 이 애노테이션이 달린 파라미터에 로그인 한 유저를 자동으로 넣어주는 것이다.

 

ArgumentResolver는 말 그대로 파라미터의 애노테이션을 사용해서 어떤 처리를 해주겠다는 의미이다. 저렇게 사용하면 로그인 한 유저를 가져오려고 할때마다 꽤나 편리하게 사용할 수 있을 것 같다. 

 

ArgumentResolver를 위한 애노테이션 생성

package hello.login.web.argumentresolver;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
  • @Target(ElementType.PARAMETER): 파라미터에만 사용되는 애노테이션으로 정의
  • @Retention(RetentionPolicy.RUNTIME): 런타임까지도 애노테이션 정보가 남아 있음을 의미

ArgumentResolver를 위한 HandlerMethodArgumentResolver 구현

LoginMemberArgumentResolver

package hello.login.web.argumentresolver;

import hello.login.domain.member.Member;
import hello.login.web.session.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Optional;

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        log.info("[supportsParameter] 실행");

        boolean hasLoginAnnotation = methodParameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType = Member.class.isAssignableFrom(methodParameter.getParameterType());

        return hasLoginAnnotation && hasMemberType;
    }

    @Override
    public Object resolveArgument(MethodParameter methodParameter,
                                  ModelAndViewContainer modelAndViewContainer,
                                  NativeWebRequest nativeWebRequest,
                                  WebDataBinderFactory webDataBinderFactory) throws Exception {
        log.info("[resolveArgument] 실행");

        HttpServletRequest req = nativeWebRequest.getNativeRequest(HttpServletRequest.class);
        if (req == null) {
            return null;
        }

        HttpSession session = req.getSession(false);
        if (session == null) {
            return null;
        }

        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}
  • 애노테이션을 만들었으면 이 애노테이션이 어떤 것을 할지 정의해줘야 한다. 그러기 위해 HandlerMethodArgumentResolver를 구현한다.
  • supportsParameter(MethodParameter methodParameter): 이 메서드에서 `true`를 반환해야만 resolveArgument가 실행된다. 이 메서드에서 우선 1.애노테이션이 @Login인지 확인한다. 2. 애노테이션이 달린 파라미터의 타입이 Member인지 확인한다. 이 두개를 모두 만족하면 resolveArgument 메서드를 실행한다.
  • resolveArgument(): 여기서는 해당 파라미터에 어떤 값을 넣어줄 지 결정한다. 우리가 원하는 건 세션에 있는 로그인 한 사용자를 파라미터에 넣으려고 한다. 그래서 파라미터로 제공되는 NativeWebRequestHttpServletRequest로 캐스팅하여 세션을 가져와서 세션에 로그인 한 유저를 저장할 때 사용했던 키를 통해 로그인 한 유저를 반환한다. 없다면 null을 반환하면 된다.

ArgumentResolver를 위한 WebMvcConfigurer 설정

package hello.login;

import hello.login.web.argumentresolver.LoginMemberArgumentResolver;
import hello.login.web.interceptor.LoggingInterceptor;
import hello.login.web.interceptor.LoginCheckInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;


@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }
}
  • WebMvcConfigurer가 가지고 있는 메서드 중 addArgumentResolvers를 구현한다. 여기서 만든 LoginMemberArgumentResolver를 추가하면 된다.

 

ArgumentResolver 테스트

@GetMapping("/")
public String homeWithArgumentResolver(@Login Member loginMember, Model model) {
    if (loginMember == null) {
        return "home";
    }

    model.addAttribute("member", loginMember);
    return "loginHome";
}
  • 이제 로그인이 된 경우 이 홈 경로로 들어오면 loginMember에 로그인 한 유저가 들어온다.

 

스프링 MVC에서는 이렇게 간단하지만 편리한 기능을 만들 수 있다.

728x90
반응형
LIST
728x90
반응형
SMALL

이전 포스팅 중 이런 포스팅이 있었다.

 

로깅에 대하여

서버를 운영중이던 개발중이던 로그를 남기는 건 필수적 요소이다.근데 이게 한번 제대로 팍 이해하고 넘어가지 않으면 그놈이 그놈같고 이게 뭔 차인가 싶으니 제대로 딱 정리 한번 하기로 마

cwchoiit.tistory.com

로그에 대한 내용을 담은 글이었는데, 여기에 추가적으로 하나 더 아주 유용한 기능을 기록해보려 한다.

서버를 운영하던 중 사용자의 요청이 어디서부터 시작해서 어디서 끝나는지 로그가 무수히 찍히는 상황에서는 인지하기가 쉽지가 않다.

어떤 작업을 했고 어떤 과정을 거쳤는지, 에러가 났다면 어디서 시작해서 어떤 에러가 발생했는지 로그 자체는 남지만 과정의 흐름을 이해하기가 쉽지 않았다. 그래서 요청부터 응답까지의 사용자 흐름을 한눈에 파악할 수 있는 좋은 기능인 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`이다. 여기에 이 파일을 만들어두면 스프링 부트가 자동으로 인식하여 시작될 때 이 로그 설정을 적용한다. 

 

src/main/resources/logback-spring.xml

<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>[%d{yyyy-MM-dd HH:mm:ss}] [%thread] [%X{identifier}] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="info">
        <appender-ref ref="CONSOLE" />
    </root>

    <!-- 특정 패키지 로그 레벨 설정 -->
    <logger name="hello" level="debug" additivity="false">
        <appender-ref ref="CONSOLE" />
    </logger>
</configuration>
  • 우선, 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로 요청마다 식별자 설정

스프링에서 사용할 수 있는 인터셉터를 사용해서 요청이 들어올 때 서블릿에 도착하기 전 고유 식별자를 만들어 모든 흐름의 로그에 같은 식별자를 남기자. 

 

LoggingInterceptor

package hello.login.web.interceptor;

import org.slf4j.MDC;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;

public class LoggingInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String uuid = UUID.randomUUID().toString().substring(0, 8);
        String ipAddress = request.getHeader("X-Forwarded-For");
        if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getRemoteAddr();
        }

        MDC.put("identifier", uuid + "-(CLIENT-IP)-" + ipAddress);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        MDC.clear();
    }
}
  • UUID를 만들어 절대 중복될 수 없는 고유값을 생성한다.
  • 요청 헤더에 있는 `X-Forwarded-For` 값을 통해 요청한 사용자의 IP를 받아온다. 이 경우 헤더에 이 값이 없을 수 있다. 그럴 경우에는 HttpServletRequest 객체가 가지고 있는 getRemoteAddr() 메서드를 통해 값을 추가한다.
  • MDC.put("identifier", uuid + "-(CLIENT-IP)-" + ipAddress)
    • `identifier`라는 키로 고유값을 매핑한다.
  • 모든 요청에 대한 응답이 끝나고 사용자에게 돌아가기 전에 호출되는 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)로 찍히고 있다.

 

이렇게 설정해두면, 요청부터 응답까지의 모든 과정을 동일한 식별자로 기록해서 한눈에 한 요청이 어떤 작업부터 어떤 흐름으로 무엇을 했는지 보기가 편하고 에러가 발생하더라도 어디서 출발해서 어떤 부분에서 에러를 마주했고 그 에러가 무엇인지 로그를 통해 트래킹하는게 쉬워진다. 

 

 

보너스. 서블릿 필터로 적용

"어? 저는 스프링 인터셉터 싫은데요? 서블릿 필터로는 안되나요?" → 됩니다! (굳이? 라는 생각이 들지만)

 

LogFilter

package hello.login.web.filter;

import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;

@Slf4j
public class LogFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("LogFilter init");
    }

    @Override
    public void doFilter(ServletRequest servletRequest,
                         ServletResponse servletResponse,
                         FilterChain filterChain) throws IOException, ServletException {
        log.info("LogFilter doFilter");

        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        String uuid = UUID.randomUUID().toString();

        String ipAddress = request.getHeader("X-Forwarded-For");
        if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getRemoteAddr();
        }

        MDC.put("identifier", uuid + "-(CLIENT-IP)-" + ipAddress);

        try {
            filterChain.doFilter(request, response);
        } catch (Exception e) {
            throw new ServletException(e);
        } finally {
            MDC.clear();
        }
    }

    @Override
    public void destroy() {
        log.info("LogFilter destroy");
    }
}
  • Filter를 구현한다. 동일하게 구현하면 된다. 다만, FilterdoFilter 메서드 안에서 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

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

서블릿 필터

이전 포스팅에서 세션을 활용해 로그인 관련 기능을 적용했다. 그런데 한가지 문제가 남아있다. 로그인 기능 자체는 잘 적용했지만 로그인 되지 않은 사용자가 특정 URI만 알고 있다면 해당 URI로 직접 요청해서 바로 들어갈 수 있다. 로그인 된 사용자만 볼 수 있는 화면도 그렇게 들어갈 수 있는 상태이다. 이것을 해결하려면 필터 기능을 사용해서 요청이 컨트롤러에 닿기 전 먼저 로그인 됐는지 확인을 하면 된다. 그리고 그때 필터라는 기능을 사용할 수 있다.

 

서블릿 필터는 서블릿이 지원하는 수문장이다. 필터의 특성은 다음과 같다.

 

필터 흐름

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러

 

필터를 적용하면, 필터가 호출된 다음 서블릿이 호출된다. 그래서 모든 고객의 요청 로그를 남기는 요구사항이 있다면 필터를 적용하는 방법을 고려할 수 있다. 참고로 필터는 특정 URL 패턴에 적용할 수 있다. `/*` 이라고 하면 모든 요청에 필터가 적용된다. 참고로 스프링을 사용한다면 여기서 말하는 서블릿은 디스패처 서블릿이라고 생각하면 된다.

 

필터 제한

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출X) //비 로그인 사용자

필터에서 적절하지 않은 요청이라고 판단하면, 거기에서 끝을 낼 수 있다. 그래서 로그인 여부를 체크하기에 딱 좋다.

 

필터 체인

HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러

필터는 체인으로 구성될 수 있다. 중간에 필터를 자유롭게 추가할 수 있다. 예를 들어서 로그를 남기는 필터를 먼저 적용하고, 다음에 로그인 여부를 체크하는 필터를 만들 수 있다.

 

필터 인터페이스

package javax.servlet;

import java.io.IOException;

public interface Filter {
    default void init(FilterConfig filterConfig) throws ServletException {
    }

    void doFilter(ServletRequest var1, ServletResponse var2, FilterChain var3) throws IOException, ServletException;

    default void destroy() {
    }
}
  • 필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고 관리한다.
  • init(): 필터 초기화 메서드. 서블릿 컨테이너가 생성될 때 호출된다.
  • doFilter(): 고객의 요청이 들어올 때마다 해당 메서드가 실행된다. 필터의 로직을 구현하는 부분
  • destroy(): 필터 종료 메서드. 서블릿 컨테이너가 종료될 때 호출된다.

필터에 대한 개념을 좀 더 이해하기 위해 요청 로그를 남기는 서블릿 필터를 만들어보자.

서블릿 필터 - 요청 로그

package hello.login.web.filter;

import lombok.extern.slf4j.Slf4j;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;

@Slf4j
public class LogFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("LogFilter init");
    }

    @Override
    public void doFilter(ServletRequest servletRequest,
                         ServletResponse servletResponse,
                         FilterChain filterChain) throws IOException, ServletException {
        log.info("LogFilter doFilter");

        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        String requestURI = request.getRequestURI();
        String uuid = UUID.randomUUID().toString();

        try {
            log.info("REQUEST [{}][{}]", uuid, requestURI);
            filterChain.doFilter(request, response);
        } catch (Exception e) {
            throw new ServletException(e);
        } finally {
            log.info("RESPONSE [{}][{}]", uuid, requestURI);
        }

    }

    @Override
    public void destroy() {
        log.info("LogFilter destroy");
    }
}
  • 필터를 사용하려면 필터 인터페이스를 구현해야 하므로 Filter를 구현한다.
  • doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
    • HTTP 요청이 들어오면 doFilter가 호출된다.
    • ServletRequest, ServletResponse는 HTTP 요청이 아닌 경우까지 고려해서 만든 인터페이스이다. HTTP를 사용하면 HttpServletRequest, HttpServletResponse로 다운 캐스팅하면 된다. 
  • HTTP 요청을 구분하기 위해 요청당 임의의 랜덤값인 UUID를 생성한다.
  • log.info("REQUEST [{}][{}]", uuid, requestURI); 필터에서 요청 로그를 출력한다.
  • chain.doFilter(request, response):
    • 이 부분이 제일 중요하다. 다음 필터가 있으면 다음 필터를 호출하고, 필터가 없으면 서블릿을 호출한다. 만약 이 로직을 호출하지 않으면 다음 단계로 진행되지 않는다.
  • 서블릿을 호출하고 모든 작업이 다 끝나면 다시 이 필터로 돌아온다. 응답은 역순이다. 그래서 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`로 검색해보자.

 

서블릿 필터 - 인증 체크

이제 인증 관련 필터를 만들어보자. 위에서 만든 로그 필터는 필터의 동작을 알아보기 위한 맛보기였다.

LoginCheckFilter

package hello.login.web.filter;

import hello.login.web.session.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.PatternMatchUtils;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;

@Slf4j
public class LoginCheckFilter implements Filter {

    private static final String[] whitelist = {"/", "/members/add", "/login", "logout", "/css/*"};

    @Override
    public void doFilter(ServletRequest servletRequest,
                         ServletResponse servletResponse,
                         FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        String requestURI = request.getRequestURI();

        try {
            log.info("인증 체크 필터 시작 {}", requestURI);

            if (isLoginCheckPath(requestURI)) {
                log.info("인증 체크 로직 실행 {}", requestURI);
                HttpSession session = request.getSession(false);
                if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
                    log.info("미인증 사용자 요청 {}", requestURI);
                    response.sendRedirect(request.getContextPath() + "/login?redirectURL=" + requestURI);
                    return;
                }
            }

            filterChain.doFilter(request, response);
        } catch (Exception e) {
            throw new ServletException(e);
        } finally {
            log.info("인증 체크 필터 종료 {}", requestURI);
        }
    }

    private boolean isLoginCheckPath(String requestURI) {
        return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
    }
}
  • private static final String[] whitelist = {"/", "/members/add", "/login", "logout", "/css/*"};
    • 인증 필터를 적용해도 홈, 회원가입, 로그인 화면, CSS와 같은 리소스에는 접근할 수 있어야 한다. 이렇게 화이트 리스트 경로는 인증과 무관하게 항상 허용한다. 화이트 리스트를 제외한 나머지 모든 경로에는 인증 체크 로직을 적용한다.
  • isLoginCheckPath(String requestURI)
    • 화이트 리스트를 제외한 모든 경우에 인증 체크 로직을 적용한다. 스프링에서 지원하는 PatternMatchUtils를 사용해서 간단하게 구현할 수 있다.
  • response.sendRedirect(request.getContextPath() + "/login?redirectURL=" + requestURI);
    • 미인증 사용자는 로그인 화면으로 리다이렉트 한다. 그런데 로그인 이후에 다시 홈으로 이동해버리면 원하는 경로를 다시 찾아가야하는 불편함이 있다. 예를 들어, 상품 관리 화면을 보려고 들어갔다가 로그인 화면으로 이동하면 로그인 이후에 다시 상품 관리 화면으로 들어가는 것이 좋다. 이런 부분이 개발자 입장에서는 좀 귀찮을 수 있어도 사용자 입장으로 보면 편리한 기능이다. 이런 기능을 위해 현재 요청한 경로인 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) // 비 로그인 사용자

 

인터셉터에서 적절하지 않은 요청이라고 판단하면 거기에서 끝을 낼 수 있다. 그래서 로그인 여부를 체크하기에 딱 알맞다.

 

스프링 인터셉터 체인

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러

스프링 인터셉터는 체인으로 구성되는데, 중간에 인터셉터를 자유롭게 추가할 수 있다. 예를 들어서 로그를 남기는 인터셉터를 먼저 적용하고, 그 다음에 로그인 여부를 체크하는 인터셉터를 만들 수 있다.

 

스프링 인터셉터 인터페이스

package org.springframework.web.servlet;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;

public interface HandlerInterceptor {
    default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        return true;
    }

    default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
    }

    default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
    }
}
  • 스프링 인터셉터를 사용하려면 HandlerInterceptor 인터페이스를 구현하면 된다.
  • preHandle: 컨트롤러 호출 전에 실행되는 메서드
  • postHandle: 컨트롤러 호출 후 실행되는 메서드
  • afterCompletion: 요청 완료 이후에 실행되는 메서드
  • 서블릿 필터의 경우, 단순히 request, response만 제공했지만, 인터셉터는 어떤 컨트롤러(handler)가 호출되는지 호출 정보도 받을 수 있고 어떤 modelAndView가 반환되는지도 알 수 있다.

 

스프링 인터셉터 정상 호출 흐름

  • preHandle: 컨트롤러 호출 전에 호출된다. (더 정확히는 핸들러 어댑터 호출 전에 호출된다.) 중요한 건 이 preHandle의 반환값이 true이면 다음으로 진행하고, false이면 더는 진행하지 않는다. false인 경우 나머지 인터셉터는 물론이고 핸들러 어댑터도 호출되지 않는다. 
  • postHandle: 컨트롤러 호출 후에 호출된다. 
  • afterCompletion: 뷰가 렌더링 된 이후에 호출된다. 

 

스프링 인터셉터 예외 상황

  • preHandle: 컨트롤러 호출 전에 호출된다.
  • postHandle: 컨트롤러에서 예외가 발생하면 postHandle은 호출되지 않는다.
  • afterCompletion: 항상 호출된다. 예외가 발생하면 파라미터로 받는 `ex`에 예외를 받아 어떤 예외가 발생했는지 로그를 출력할 수 있다.

afterCompletion은 예외가 발생하던 발생하지 않던 호출되기 때문에 예외와 무관하게 공통 처리가 필요한 부분은 여기서 처리하면 된다. 그리고 이 메서드는 예외 정보(ex)도 받는다. 

 

정리를 하자면

인터셉터는 스프링 MVC 구조에 특화된 필터 기능을 제공한다고 이해하면 된다. 스프링 MVC를 사용하고, 특별히 필터를 꼭 사용해야 하는 상황이 아니라면 인터셉터를 사용하는 것이 더 편리하다.

 

스프링 인터셉터 - 요청 로그

인터셉터 맛보기를 해보자. 사용자의 요청 로그를 찍어보자.

LogInterceptor

package hello.login.web.interceptor;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;

@Slf4j
public class LogInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String uuid = UUID.randomUUID().toString();
        request.setAttribute("uuid", uuid);
        
        // @RequestMapping: HandlerMethod
        // 정적 리소스: ResourceHttpRequestHandler
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;
        }
        
        log.info("REQUEST [{}][{}][{}]", uuid, request.getRequestURI(), handler);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("postHandle [{}]", modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String uuid = (String) request.getAttribute("uuid");
        String requestURI = request.getRequestURI();
        log.info("RESPONSE [{}][{}][{}]", uuid, requestURI, handler);

        if (ex != null) {
            log.error("afterCompletion err", ex);
        }
    }
}
  • 요청 로그를 구분하기 위한 UUID를 생성한다.
  • request.setAttribute("uuid", uuid); → 서블릿 필터의 경우 doFilter 메서드 안에서 모든것을 처리하기 때문에 상관없지만 인터셉터의 경우 호출 시점이 분리되어 있기 때문에 preHandle에서 지정한 값을 postHandle, afterCompletion에서 사용하려면 어딘가에 담아두어야 한다. 인터페이스도 싱글톤처럼 사용되기 때문에 멤버변수를 사용하면 멀티스레드 환경에서 위험하다. 따라서 request에 담는다. 
  • return true; true를 반환해야만 다음 인터셉터 또는 컨트롤러로 넘어간다.

위에서 말했지만, 어떤 컨트롤러를 호출할지 이미 preHandle 메서드가 호출되는 시점은 알고 있기 때문에 컨트롤러 정보도 알 수 있고 컨트롤러 정보를 통해 어떤 작업을 할 수 있음을 보여주기 위해 다음 코드를 작성했다.

// @RequestMapping: HandlerMethod
// 정적 리소스: ResourceHttpRequestHandler
if (handler instanceof HandlerMethod) {
    HandlerMethod hm = (HandlerMethod) handler;
}
  • 스프링에서는 일반적으로 @Controller, @RequestMapping 이러한 애노테이션 기반 핸들러 매핑을 사용하는데 이 경우, 핸들러 정보로 HandlerMethod가 넘어온다. 
  • @Controller가 아니라 /resources/static과 같은 정적 리소스가 호출되는 경우 ResourceHttpRequestHandler가 핸들러 정보로 넘어오기 때문에 타입에 따라 처리가 필요하다.

위에서 말했지만, 컨트롤러 이후 단계에서 에러가 발생해서 응답과정에 인터셉터까지 에러가 올라오는 경우, postHandle은 호출되지 않는다고 했다. 그렇기 때문에 afterCompletion 메서드에 종료 로그를 출력하게 했다.

 

인터셉터 등록

package hello.login;

import hello.login.web.interceptor.LogInterceptor;
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 LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico");
    }
}
  • WebMvcConfigurer가 제공하는 addInterceptors()를 사용해서 인터셉터를 등록할 수 있다.
  • addInterceptor(new LogInterceptor()): 인터셉터를 등록한다.
  • order(1): 인터셉터 호출 순서를 지정한다. 낮을수록 먼저 호출된다.
  • addPathPatterns("/**"): 인터셉터를 적용할 URL 패턴을 지정한다.
  • excludePathPatterns("/css/**", "/*.ico"): 인터셉터에서 제외할 패턴을 지정한다.

실행 로그

[2024-09-04 14:05:13] [http-nio-8080-exec-1] [] INFO  h.l.web.interceptor.LogInterceptor - REQUEST [e140ad1a-e3c8-42d7-8d20-598751f3adf0][/][hello.login.web.HttpServletSessionHomeController#homeWithSpring(Member, Model)]
[2024-09-04 14:05:13] [http-nio-8080-exec-1] [] INFO  h.l.web.interceptor.LogInterceptor - postHandle [ModelAndView [view="home"; model={}]]
[2024-09-04 14:05:13] [http-nio-8080-exec-1] [] INFO  h.l.web.interceptor.LogInterceptor - RESPONSE [e140ad1a-e3c8-42d7-8d20-598751f3adf0][/][hello.login.web.HttpServletSessionHomeController#homeWithSpring(Member, Model)]
  • 정말 신기하게도, 어떤 컨트롤러의 어떤 메서드를 호출하는지에 대한 정보도 출력되는 것을 볼 수 있다.
  • 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

참조 링크

 

PathPattern (Spring Framework 6.1.12 API)

Compare this pattern with a supplied pattern: return -1,0,+1 if this pattern is more specific, the same or less specific than the supplied pattern.

docs.spring.io

 

스프링 인터셉터 - 인증 체크

이제 인터셉터를 활용해서 인증을 체크해보자! 이걸 사용하면 서블릿 필터 생각도 안날것이다.

LoginCheckInterceptor

package hello.login.web.interceptor;

import hello.login.web.session.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        String requestURI = request.getRequestURI();
        log.info("인증 체크 인터셉터 실행: {}", requestURI);

        HttpSession session = request.getSession();

        if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
            log.info("미인증 사용자 요청");
            response.sendRedirect(request.getContextPath() + "/login?redirectURL=" + requestURI);
            return false;
        }
        return true;
    }
}
  • 인터셉터가 제공하는 세개의 메서드 중 우리가 필요한 것은 딱 하나 preHandle이다. 왜냐하면 컨트롤러에 도달하기 전 인증만 하면 끝이기 때문에 컨트롤러가 호출된 후의 메서드나 요청이 끝난 후 메서드는 필요가 없다.
  • 미인증 사용자라면 리다이렉트 후 `return false;`를 호출하면 된다.
  • 인증 사용자라면 `return true;`를 호출하면 된다.

이게 끝이다. 어? 서블릿 필터에서는 허용 URL을 따로 작성해서 그 URL에 일치하는지 확인하고 뭐하고 이런 코드가 여기선 하나도 없다. 왜냐? 인터셉터 등록할 때 아주 편리하고 세밀하게 적용하면 되니까.

 

인터셉터 등록

WebConfig

package hello.login;

import hello.login.web.interceptor.LoggingInterceptor;
import hello.login.web.interceptor.LoginCheckInterceptor;
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())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico");

        registry.addInterceptor(new LoginCheckInterceptor())
                .order(2)
                .addPathPatterns("/**")
                .excludePathPatterns("/", "/members/add", "/login", "/logout", "/css/**", "/*.ico", "/error");
    }
}
  • 두번째 인터셉터를 등록한다. 일단 모든 경로에 이 인터셉터를 적용한다음 excludePathPatterns로 인증이 필요없는 경우를 걸러버리면 끝이다. 너무 편리하다.

 

정리를 하자면

서블릿 필터를 사용하거나 스프링 MVC의 인터셉터를 사용해서 인증 처리를 공통적으로 할 수 있다. 그러나, 둘 다 사용해본 입장에서 인터셉터를 두고 필터를 사용할 필요가 없다는 게 느껴진다. 이렇게 인터셉터를 알아보았다. 다음 포스팅에서는 ArgumentResolver를 사용해서 세션에 있는 로그인 한 유저를 굉장히 간편하게 가져올 수 있는 방법을 알아보자. 이것을 배우면 세션에 있는 로그인 한 유저를 간편하게 가져오는 방법 뿐만 아니라, 컨트롤러에서 공통 작업이 필요한 경우 전부 다 응용할 수 있다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

사용자 로그인은 웹 애플리케이션이라면 무조건 있는 기능이다. 그래서 반드시 알고 배워야 한다.

우선, 로그인 처리를 하기 앞서 기본적인 폼이랑 컨트롤러가 필요하다. 실제 서비스에선 당연히 회원 유저에 대한 데이터베이스도 필요하지만 여기서는 데이터베이스 대신 메모리로 처리를 할 것이다. 그게 중요한 게 아니니까.

 

Member, MemberRepository

Member

package hello.login.domain.member;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.NotEmpty;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Member {

    private Long id;

    @NotEmpty
    private String loginId;
    @NotEmpty
    private String password;
    @NotEmpty
    private String name;

    public Member(String loginId, String password, String name) {
        this.loginId = loginId;
        this.password = password;
        this.name = name;
    }
}

MemberRepository

package hello.login.domain.member;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;

import java.util.*;

@Slf4j
@Repository
public class MemberRepository {

    private static final Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;

    public Member save(Member member) {
        member.setId(++sequence);
        log.info("save: member = {}", member);
        store.put(member.getId(), member);
        return member;
    }

    public Member findById(Long id) {
        return store.get(id);
    }

    public Optional<Member> findByLoginId(String loginId) {
        return findAll().stream().filter(member -> member.getLoginId().equals(loginId)).findFirst();
    }

    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }

    public void clear() {
        store.clear();
    }
}
  • 회원 정보에 대한 모델인 Member, 회원 정보에 대한 CRUD에 사용될 MemberRepository를 작성했다.

MemberController

MemberController

package hello.login.web.member;

import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
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 org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/add")
    public String addForm(@ModelAttribute("member") Member member) {
        return "members/addMemberForm";
    }

    @PostMapping("/add")
    public String save(@Validated @ModelAttribute Member member, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "members/addMemberForm";
        }

        memberRepository.save(member);
        return "redirect:/";
    }
}
  • 로그인을 하려면 회원 가입이 필요하다. 회원 가입을 위한 컨트롤러를 만들었다. 저번 시간에 배운 검증하는 방법을 그대로 사용해서 @Validated, BindingResult를 사용했다. 그리고 Bean Validation으로 Member 클래스에 애노테이션을 달았다. 
  • 사실 회원가입도 데이터 검증 이후에 데이터베이스를 조회해서 해당 회원이 이미 있는지 확인하는 로직이 필요하지만 그 부분은생략했다. 그리고 검증에 성공하면 바로 회원을 저장하는 코드를 호출한다. 
  • 검증 시 에러가 발생하면 다시 로그인 폼을 사용자에게 보여주고 어떤 에러가 발생했는지 보여준다.

resources/templates/members/addMemberForm.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2>회원 가입</h2>
    </div>

    <h4 class="mb-3">회원 정보 입력</h4>

    <form action="" th:action th:object="${member}" method="post">

        <div th:if="${#fields.hasGlobalErrors()}">
            <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
        </div>

        <div>
            <label for="loginId">로그인 ID</label>
            <input type="text" id="loginId" th:field="*{loginId}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{loginId}"></div>
        </div>
        <div>
            <label for="password">비밀번호</label>
            <input type="password" id="password" th:field="*{password}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{password}"></div>
        </div>
        <div>
            <label for="name">이름</label>
            <input type="text" id="name" th:field="*{name}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{name}"></div>
        </div>


        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit">회원 가입</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/}'|"
                        type="button">취소</button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

LoginService, LoginController, LoginForm

LoginService

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;
}
  • 로그인 컨트롤러에서 사용자의 입력값에 대한 DTO 클래스이다.

resources/templates/login/loginForm.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2>로그인</h2>
    </div>

    <form action="item.html" th:action th:object="${loginForm}" method="post">

        <div th:if="${#fields.hasGlobalErrors()}">
            <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
        </div>

        <div>
            <label for="loginId">로그인 ID</label>
            <input type="text" id="loginId" th:field="*{loginId}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{loginId}"></div>
        </div>
        <div>
            <label for="password">비밀번호</label>
            <input type="password" id="password" th:field="*{password}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{password}"></div>
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit">로그인</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/}'|"
                        type="button">취소</button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

여기까지가 이제 가장 기본적으로 필요한 구성들이다. 이제 하나씩 로그인 처리를 알아가보자!

 

쿠키를 사용하여 로그인 처리하기

결론부터 말하면 이 방식은 사용하면 안된다. 하지만 이 과정을 먼저 진행해보면서 왜 사용하면 안되고 어떤 방식이 이를 해결하는지 이해해보자. 쿠키는 브라우저와 서버간 상태를 유지할 수 있는 방법 중 하나이다. 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를 넣었다. (idloginId와 다른 것)
  • HttpServletResponse 객체에 addCookie() 메서드로 쿠키를 추가한다.

이 상태로 서버를 다시 실행해서 로그인을 시도해보자. 그리고 브라우저의 Network창을 켜두고 확인해보자.

로그인 시도 후 돌아온 응답의 Headers를 살펴보자. Set-Cookie: memberId=1 이라는 값이 들어있다.

이제 이 상태에서 브라우저를 새로고침하거나 허용된 path에 요청을 보내면 항상 쿠키가 들어가 있다.

위 사진처럼, 같은 서버에 요청을 날리면 Request HeadersCookiememberId=1 이라는 쿠키가 넘어가고 있음을 확인 가능하다.

이제 서버는 이 요청 헤더에 있는 쿠키를 꺼내서 서버와 클라이언트 간 약속에 의해 이 memberId=x라는 값이 있는지 확인을 하는 것이다.

 

 

쿠키의 로그인 정보 유지와 로그아웃 기능 

그럼 이제, 로그인에 성공한 경우에는 아래와 같은 화면을 보여주자. 로그인 한 유저의 이름을 보여주면서 로그인 유지가 되는 모습이다. (loginHome.html)

로그인이 되지 않은 경우 다음과 같은 아래 화면을 보여주자. (home.html)

resources/templates/home.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
</head>
<body>

<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>홈 화면</h2>
    </div>

    <div class="row">
        <div class="col">
            <button class="w-100 btn btn-secondary btn-lg" type="button"
                    th:onclick="|location.href='@{/members/add}'|">
                회원 가입
            </button>
        </div>
        <div class="col">
            <button class="w-100 btn btn-dark btn-lg" onclick="location.href='items.html'"
                    th:onclick="|location.href='@{/login}'|" type="button">
                로그인
            </button>
        </div>
    </div>

    <hr class="my-4">

</div> <!-- /container -->

</body>
</html>

resources/templates/loginHome.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
</head>
<body>

<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>홈 화면</h2>
    </div>

    <h4 class="mb-3" th:text="|로그인: ${member.name}|">로그인 사용자 이름</h4>

    <hr class="my-4">

    <div class="row">
        <div class="col">
            <button class="w-100 btn btn-secondary btn-lg" type="button"
                    th:onclick="|location.href='@{/items}'|">
                상품 관리
            </button>
        </div>
        <div class="col">
            <form th:action="@{/logout}" method="post">
                <button class="w-100 btn btn-dark btn-lg" onclick="location.href='items.html'" type="submit">
                    로그아웃
                </button>
            </form>
        </div>
    </div>

    <hr class="my-4">

</div> <!-- /container -->

</body>
</html>

 

HomeController

package hello.login.web;

import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
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;

@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {

    private final MemberRepository memberRepository;

    @GetMapping("/")
    public String home(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {
        if (memberId == null) {
            return "home";
        }

        Member member = memberRepository.findById(memberId);
        if (member == null) {
            return "home";
        }

        model.addAttribute("member", member);
        return "loginHome";
    }
}
  • 화면 중 하나인 "/" 경로의 홈 화면이다.
  • 우선, 로그인 여부를 확인하기 위해 쿠키로부터 `memberId` 라는 키를 가진 쿠키를 가져온다. 그 방법은 여러가지가 있지만 스프링이 지원해주는 @CookieValue를 사용하자. 
  • 우선 첫번째로, 아예 쿠키값이 없는 경우엔 그냥 home 화면으로 보낸다. 
  • 쿠키값이 있는 경우, 쿠키값을 찾았다고 끝이 아니다. 쿠키값을 통해 회원을 찾아서 해당 회원이 실제로 존재하는 회원인지 확인해야 한다. MemberRepository를 사용해 멤버를 찾는다.
  • 멤버를 찾지 못했을 경우 다시 그냥 home 화면으로 보낸다.
  • 멤버를 찾은 경우 Model에 멤버 객체를 담아 loginHome 화면으로 보낸다.

위 방식처럼 사용자가 로그인 됐는지 아닌지를 확인하면 된다. 그럼 이제 로그아웃 기능을 구현해보자.

쿠키를 통해 로그인 처리를 했을 때 로그아웃 하는 방법은 그냥 쿠키 시간을 없애버리면 된다.

@PostMapping("/logout")
public String logout(HttpServletResponse res) {
    expireCookie(res, "memberId");
    return "redirect:/";
}

private static void expireCookie(HttpServletResponse res, String cookieName) {
    Cookie cookie = new Cookie(cookieName, null);
    cookie.setMaxAge(0);
    res.addCookie(cookie);
}
  • 삭제하고자 하는 쿠키를 생성해서 setMaxAge(0)을 호출한다.
  • 해당 쿠키를 응답에 담는다.

로그아웃 버튼을 클릭하면 저 "/logout" 경로로 POST 요청을 날리게 하면 된다. 아래처럼.

<form th:action="@{/logout}" method="post">
    <button class="w-100 btn btn-dark btn-lg" onclick="location.href='items.html'" type="submit">
        로그아웃
    </button>
</form>

 

굉장히 간단하게 로그인과 로그아웃 기능을 쿠키를 사용해 구현해봤다. 그러나, 이 쿠키를 사용한 방식은 심각한 보안 문제가 있다. 그래서 쿠키를 사용해서 절대로 로그인 기능을 구현하지 않는다. 그 이유가 뭘까?

 

쿠키의 보안 문제

  • 쿠키값은 임의로 변경을 할 수 있다. 
    • 클라이언트가 쿠키를 강제로 변경이 가능하고 변경하면 다른 사용자가 된다. 예) `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:/";
}
  • 다른 부분들은 전부 동일하고 로그인 성공 처리 부분에서 단지 SessionManagercreateSession()을 호출해주면 끝이다. 이 메서드 안에서 세션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는 필요없다. 그저 SessionManagergetSession()을 호출하면 된다. 이 메서드를 호출하면 요청 헤더에 담긴 세션ID를 통해 세션 저장소에서 이 세션ID와 한 쌍을 이루는 멤버 객체를 반환할 것이다.
  • 찾지 못했다면 로그인되지 않은것으로 간주한다.
  • 찾았다면 로그인 된 화면으로 이동시킨다.

이렇게 직접 만든 세션도 잘 적용해보았다. 그러나, 이번을 계기로 다시는 직접 만들지 않아도 된다. 훨씬 더 잘 만들어져 있는 제공된 기능을 사용하면 된다. 

 

서블릿 HTTP 세션사용하기

이제 직접 만드는 세션이 아니라 제공되는 좋은 기능을 사용하면 된다. 서블릿은 세션을 위해 HttpSession이라는 기능을 제공한다.

이 녀석을 통해 세션을 생성하면 다음과 같은 쿠키를 생성한다. `JSESSIONID`. 이게 이제 쿠키의 이름이고 그 값으로는 똑같이 알 수 없는 랜덤값을 넣는다. 그럼 서블릿 세션도 내부적으로 그 랜덤값을 통해 우리가 실질적으로 매핑한 값(멤버 객체)를 찾아서 로그인 여부를 확인하게 된다.

 

우선, 이 서블릿 세션을 사용할 땐 세션의 키가 필요하다. 뭐 아무렇게나 지어도 상관없다. 그래서 나는 인터페이스를 만들어서 상수를 정의하려고 한다.

SessionConst

package hello.login.web.session;

public interface SessionConst {
    String LOGIN_MEMBER = "loginMember";
}

 

그리고 이제, 로그인과 로그아웃을 이 서블릿 세션을 사용해서 처리할거니까 로그인 컨트롤러를 수정해주자.

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을 반환한다. 
  • HttpSessionnull이 아니라면 invalidate()을 호출해서 세션의 데이터를 무효화한다.

 

이번엔 HomeControllerHttpSession을 사용하도록 수정해보자.

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";
    }
}

 

정리

이제 세션을 사용해 로그인 기능도 구현해보았다. 다음 포스팅에는 필터와 인터셉터를 사용해서 로그인 안 된 사용자를 걸러내는 방법을 알아보자.

728x90
반응형
LIST

+ Recent posts