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

참고자료

 

스프링 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
728x90
반응형
SMALL

참고자료

 

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

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

www.inflearn.com

 

이전 포스팅인 이 데이터 검증에서 BindingResult, Validator, @Validated를 사용한 데이터 검증에 대한 내용을 공부해 보았다.

 

데이터 검증 (BindingResult, Validator, @Validated)

참고자료 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1

cwchoiit.tistory.com

 

근데 개발자들의 욕심은 끝이 없어서, 저 검증 관련 코드 작성하는것도 귀찮아 하게 된다. 그도 그럴게 사실 문자 필드에 빈 값이면 안되는 이 내용은 어떤 웹 애플리케이션에서도 만국 공통 아닐까? 그럼 좀 더 간단하게 공통화된 처리가 가능하지 않을까?라는 생각부터 출발한 것이다. 그래서 애노테이션을 가지고 그냥 검증이 가능하게 공통 처리를 해버렸다.

 

다음 코드를 보자.

 public class Item {
     private Long id;
     
     @NotBlank
     private String itemName;
     
     @NotNull
     @Range(min = 1000, max = 1000000)
     private Integer price;
     
     @NotNull
     @Max(9999)
     private Integer quantity;
     //...
}

이런식으로 범위는 어디서부터 어디까지이고, Null, Blank이면 안되고, 최대값은 얼마이고와 같은 공통 검증 로직을 애노테이션 하나로 처리해 버리는 방식이다.

 

Bean Validation

특정한 구현체가 아니라, Bean Validation 2.0(JSR-380)이라는 표준 기술이다. 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것처럼 말이다.

 

Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. 이름에 하이버네이트가 붙었지만 ORM과는 관련이 없다. 

 

 

Bean Validation 도입

우선, 의존성을 먼저 추가해야 한다.

 implementation 'org.springframework.boot:spring-boot-starter-validation'

 

추가하면 다음과 같은 라이브러리가 추가가 된다.

  • jakarta.validation-api: Bean Validation 인터페이스
  • hibernate-validator: 구현체

그리고 이제 Item 클래스에 애노테이션을 적용해보자.

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    private Long id;

    @NotBlank(message = "필수 필드입니다.")
    private String itemName;

    @NotNull(message = "필수 필드입니다.")
    @Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
    private Integer price;

    @NotNull(message = "필수 필드입니다.")
    @Max(value = 9999, message = "최대값은 9999입니다.")
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • @NotBlank → 빈값, 공백만 있는 경우를 허용하지 않는다.
  • @NotNullnull을 허용하지 않는다.
  • @Range(min = 1_000, max = 1_000_000) → 1000 - 1000000 범위 안의 값이어야 한다.
  • @Max(9999) → 최대 9999까지 허용한다.
참고로, Max, NotBlank, NotNull은 패키지가 javax.validation.constraints이다. 이 말은 javax.validation으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스이란 뜻이다. 그래서 어떤 구현체든 상관없이 사용할 수가 있다. 근데, Range같은 경우는 패키지가 org.hibernate.validator.constraints이다. 이 말은 이 hibernate 구현체를 사용해야만 사용 가능한 애노테이션이라는 의미다. 결론은 거의 대부분 하이버네이트 validator를 사용하기 때문에 자유롭게 사용해도 된다. 스프링도 기본적으로 구현체를 하이버네이트 validator로 다운받아 주기 때문에. 

 

아래는 스프링을 사용하는 것과 상관없이 검증을 실행할 수가 있는데 이건 그냥 참고사항이고 우리는 스프링과 통합해서 사용할 거니까 그냥 아 이렇게 하는구나? 정도로 넘어가면 된다.

package hello.itemservice.validation;

import hello.itemservice.domain.item.Item;
import org.junit.jupiter.api.Test;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;

public class BeanValidationTest {

    @Test
    void beanValidation() {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();

        Item item = new Item();
        item.setItemName(" ");
        item.setPrice(0);
        item.setQuantity(10000);

        Set<ConstraintViolation<Item>> violations = validator.validate(item);
        for (ConstraintViolation<Item> violation : violations) {
            System.out.println("violation = " + violation);
            System.out.println("violation.getMessage() = " + violation.getMessage());
        }

        factory.close();
    }
}
  • 스프링과 통합하면 이런 코드를 직접 작성할 필요가 없어진다.
    • ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    • Validator validator = factory.getValidator();
  • 검증 실행은 이 코드를 호출하면 된다.
    • Set<ConstraintViolation<Item>> violations = validator.validate(item);
  • 호출해서 결과가 비어있으면 검증 오류가 없는 것이고 결과가 비어있지 않으면 뭔가 검증에 실패한 것이다.

실행결과

violation = ConstraintViolationImpl{interpolatedMessage='must be less than or equal to 9999', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.Max.message}'}
violation.getMessage() = must be less than or equal to 9999
violation = ConstraintViolationImpl{interpolatedMessage='must not be blank', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.getMessage() = must not be blank
violation = ConstraintViolationImpl{interpolatedMessage='must be between 1000 and 1000000', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.getMessage() = must be between 1000 and 1000000

 

스프링과 통합하기

이제 스프링과 통합해서 사용해보자. 그러기 위해 저번 포스팅에서 사용했던 컨트롤러를 복사해서 새로 만들고 코드를 좀 정리하자.

ValidationItemControllerV3

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.List;

@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {

    private final ItemRepository itemRepository;

    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "validation/v3/items";
    }

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/item";
    }

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "validation/v3/addForm";
    }

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item,
                            BindingResult bindingResult,
                            RedirectAttributes redirectAttributes) {

        if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
            log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
            return "validation/v3/addForm";
        }

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v3/items/{itemId}";
    }

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/editForm";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v3/items/{itemId}";
    }
}
  • ItemValidator, @InitBinder 등의 코드를 삭제했다. 필요없다.
  • addItem() 메서드는 여전히 동일하게 남아 있다. 이 메서드에 검증할 객체인 @ModelAttribute Item item 앞에 @Validated 애노테이션을 붙여주었고, 검증한 결과를 담고 있을 BindingResult@ModelAttribute Item item 바로 뒤에 넣어주었다. (이 내용은 지난번 포스팅에서 설명했다. 이해가 안된다면 저번 포스팅을 참고)

이렇게 해두고, 테스트를 해보면? 신기하게 검증이 그대로 된다.

아무것도 한 게 없는데, 그저 Item에 애노테이션만 달았을 뿐인데 자동으로 검증이 적용됐다. 어떻게 된 일일까?

 

스프링 부트가 다음 의존성을 추가하면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.

spring-boot-starter-validation

그래서 스프링 부트가 하는 일은 자동으로 글로벌 Validator를 등록한다. 저번 포스팅에서 글로벌로 Validator를 적용하면, 주입받고 @InitBinder 애노테이션으로 Validator 등록할 필요없이 그저 검증하고자 하는 컨트롤러에 @Validated 애노테이션만 검증 객체 바로 옆에 넣어주면 된다고 했다. 그게 지금 적용된 것이다.

 

스프링 부트가 LocalValidatorFactoryBean이라는 글로벌 Validator를 등록한다. 그래서 이 Validator@NotNull과 같은 애노테이션을 보고 검증을 수행한다. 검증을 해서 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담아준다.

 

주의할 점은, 글로벌 Validator를 개발자가 직접 등록한 게 있다면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않는다. 따라서 애노테이션 기반의 빈 검증기가 동작하지 않으니까 만약, 직접 아래와 같이 저번에 배웠던 것처럼 글로벌 Validator를 등록했다면 빈 검증기가 동작하지 않을 것이다.

@Override
public Validator getValidator() {
    return new ItemValidator();
}

 

Bean Validator의 검증 로직

이렇게 간단하게 빈 검증기로 검증을 해봤는데, 이 검증 로직(?), 순서(?)는 어떤식으로 진행될까?

  1. @ModelAttribute 각각의 필드에 타입 변환 시도
    • 사용자가 입력한 값을 해당하는 필드에 집어넣는다.
    • 성공한 경우 다음으로 넘어간다.
    • 실패한 경우 typeMismatchFieldError를 생성해서 BindingResult에 추가한다.
  2. Bean Validator를 적용한다. 
    • 위에 타입 바인딩을 했을 때 성공한 필드에만 검증을 시도한다. 
    • 생각해보면, 타입 자체가 맞지 않는데 추가적인 검증이 중요한게 아니라 사용자에게 먼저 타입부터 고치라는 말을 해줘야한다. 그래서 타입 바인딩을 실패하면 검증을 하지 않는다.
    • 타입 바인딩에 성공한 필드는 검증을 시도한다.
      • 예시) itemName에 문자 "A"를 입력 → 타입 변환 성공 → itemName 필드에 BeanValidation 적용
      • 예시) price에 문자 "A"를 입력 → 타입 변환 시도 실패 → typeMismatch FieldError 추가 → price 필드는 BeanValidation 적용하지 않음

 

Bean Validator의 에러 코드

우선, 기본 에러 메시지를 다루는 방법은 이미 처리해봤다. 바로 아래처럼 애노테이션에 message 속성에 에러 메시지를 입력하면 된다.

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    private Long id;

    @NotBlank(message = "필수 필드입니다.")
    private String itemName;

    @NotNull(message = "필수 필드입니다.")
    @Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
    private Integer price;

    @NotNull(message = "필수 필드입니다.")
    @Max(value = 9999, message = "최대값은 9999입니다.")
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

근데, Bean Validator가 검증 오류가 있으면 어디에 뭘 담는다고 했었나?! 바로 BindingResultFieldError 또는 ObjectError를 만들어서 넣어둔다고 했다. 그 말은 codes가 사용될 것이라는 얘기가 된다. 그래서 실제로 검증 오류를 발생시켜서 어떤 에러 코드를 만드는지 보자. 검증 오류가 있는 경우 로그를 찍어보면 다음과 같이 나온다.

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 [필수 필드입니다.]
  • NotNull.item.quantity
  • NotNull.quantity
  • NotNull.java.lang.Integer
  • NotNull

계속 봤던거랑 똑같다. 대신 애노테이션 이름으로 맨 앞을 채울 뿐이다. 그래서 Bean Validatior를 사용해서 애노테이션 기반으로 검증하고 에러 메시지를 유연하게 사용하려면 이 코드들로 에러 메시지를 만들면 된다.

errors.properties

NotBlank=공백일 수 없습니다.
Range={0}의 범위는 {2} ~ {1} 범위를 허용합니다.
Max={0}의 값은 최대 {1}까지 허용합니다.

 

  • Bean Validation 사용시,
    • {0}은 필드 이름으로 들어간다.
    • Range에서 {1}은 최대값, {2} 최소값으로 들어간다.
    • Max에서 {1}은 최대값으로 들어간다.

이렇게 만들어두고 실행해보자.

위에서 설정한 에러 메시지가 출력된 것을 볼 수 있다. 이게 다 Bean Validation으로 검증 처리를 하는 것도 결국 검증 오류가 생기면BindingResult를 사용하기 때문에 사용할 수 있는 편리함이다. BindingResult는 메시지를 출력하는 코드를 만들때 MessageCodesResolver를 사용하기 때문이고. 

 

이걸 화면으로 뿌려주는 게 아니라 만약, API로 사용하는 거라고 해도 충분히 이것들을 사용해서 잘못된 데이터라는 응답을 보내줄 수 있다.

왜냐하면 BindingResult에 결국 모든게 다 담겨있기 때문이다. 아래 코드를 보자.

if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
    for (FieldError fieldError : bindingResult.getFieldErrors()) {
        log.debug("fieldError. field: {}", fieldError.getField());
        log.debug("fieldError. rejectedValue: {}", fieldError.getRejectedValue());
        log.debug("fieldError. message: {}", fieldError.getDefaultMessage());
        log.debug("fieldError.getCode(): {}", fieldError.getCode());
    }
}
  • getFieldErrors() 메서드를 통해 모든 필드 에러를 전부 불러올 수 있다.
  • 불러온 모든 FieldError의 정보엔 어떤 필드인지, 어떤값을 사용자가 입력했는지, 기본 에러 메시지는 무엇인지, 에러 코드는 무엇인지를 알 수 있다.
  • 에러 코드를 안다면 에러 코드를 통해 Message를 불러올 수 있다. 다음 코드를 보자.
@Autowired
private MessageSource messageSource;

...

String message = messageSource.getMessage(Objects.requireNonNull(fieldError.getCode()), null, Locale.KOREA);

그럼 이 정보들을 통해 충분히 API 응답도 깔끔하게 내보낼 수 있을 것이다.

 

Bean Validation을 사용했을 때 ObjectError 처리

그럼 이제 필드 에러는 이렇게 깔끔하게 애노테이션으로 처리가 가능했다. 그럼 필드 에러 말고 우리가 했던것처럼 globalError는 어떻게 다루지?에 대한 의문이 남았다면 아주 정상이다. 생각해보면, price * quantity10,000원이 넘어야 하는 어떤 필드 종속적 검증이 아닌 globalError 검증도 있었다. 그러나, 저런 필드가 없으니 애노테이션을 어디 달 곳이 애매하다.

 

이런 경우에 또 지원하는 방식이 있는데 @ScriptAssert 라는 애노테이션이다.

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.ScriptAssert;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "price * quantity의 결과는 10,000원 이상이어야 합니다.")
public class Item {

    private Long id;

    @NotBlank(message = "필수 필드입니다.")
    private String itemName;

    @NotNull(message = "필수 필드입니다.")
    @Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
    private Integer price;

    @NotNull(message = "필수 필드입니다.")
    @Max(value = 9999, message = "최대값은 9999입니다.")
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • 이렇게 클래스 레벨에 @ScriptAssert 라는 애노테이션을 붙여서 해결할 수도 있다. 
    • script, message만 원하는대로 수정하면 된다. message는 기본 에러 메시지를 의미한다.
    • ScriptAssert 검증으로부터 만들어지는 codes는 다음과 같다.
      • ScriptAssert.item
      • ScriptAssert

globalError가 잘 보여진다. 

근데! 간단한 것 정도는 저렇게 해도 상관없을 듯 하지만 그냥 저렇게 하지말고 자바 코드로 푸는것을 추천한다. 실제로는 사용하기도 애매하고 굉장히 지저분하다. 그래서 그냥 저렇게 globalError가 필요한 경우에는 아래처럼 그냥 코드로 검증하는 방식으로 하자.

// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10_000) {
        bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
    }
}

 

현재까지 최종 코드

그래서 현재까지의 컨트롤러, Item, errors.properties의 최종 코드는 이렇게 되면 될 것 같다.

Item

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    private Long id;

    @NotBlank(message = "필수 필드입니다.")
    private String itemName;

    @NotNull(message = "필수 필드입니다.")
    @Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
    private Integer price;

    @NotNull(message = "필수 필드입니다.")
    @Max(value = 9999, message = "최대값은 9999입니다.")
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

ValidationItemControllerV3

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.List;

@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {

    private final ItemRepository itemRepository;

    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "validation/v3/items";
    }

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/item";
    }

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "validation/v3/addForm";
    }

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item,
                          BindingResult bindingResult,
                          RedirectAttributes redirectAttributes) {

        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10_000) {
                bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
            }
        }

        if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
            log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
            return "validation/v3/addForm";
        }

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v3/items/{itemId}";
    }

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/editForm";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v3/items/{itemId}";
    }
}

 

errors.properties

totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

typeMismatch.java.lang.Integer=숫자 필드입니다.
typeMismatch.java.lang.String=문자 필드입니다.
typeMismatch=타입 오류입니다.

NotNull=필수 필드입니다.
NotBlank=공백일 수 없습니다.
Range={0}의 범위는 {2} ~ {1} 범위를 허용합니다.
Max={0}의 값은 최대 {1}까지 허용합니다.

 

Bean Validation의 한계

이렇게 편리하고 좋은 기능도 한계점이란 게 존재한다. 어떤 문제가 있을까?

만약, 이런 요구사항이 들어왔다고 해보자.

 

생성 폼 검증 요구사항

  • 수량의 최대값은 9999

수정 폼 검증 요구사항

  • 수량의 최대값을 두지 않는다.

그러면 생성과 수정 시 검증 요구사항이 다르기 때문에 같은 Item 객체를 사용하는 순간에는 이 애노테이션을 구분하기가 굉장히 껄끄럽다. 사실 구분할 수 없다. 아래 코드가 Item 객체인데 생성폼, 수정폼 둘 다 이 Item 객체를 사용하는데 어떻게 둘을 구분해서 애노테이션을 달겠는가?

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    private Long id;

    @NotBlank(message = "필수 필드입니다.")
    private String itemName;

    @NotNull(message = "필수 필드입니다.")
    @Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
    private Integer price;

    @NotNull(message = "필수 필드입니다.")
    @Max(value = 9999, message = "최대값은 9999입니다.")
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

Bean Validation 한계 돌파

문제를 해결할 수 있는 대표적인 두 가지 방법이 있다.

  • Bean Validationgroups 기능
  • Item 객체를 분리 (예: SaveItemDto, UpdateItemDto)

Bean Validationgroups

이런 문제를 해결하기 위해 Bean Validationgroups 라는 기능을 지원한다. 코드로 보면 바로 이해가 되니까 코드로 바로 시작하자.

우선, 그룹을 지정하기 위한 인터페이스가 필요하다. 생성 용 그룹 인터페이스와 업데이트 용 그룹 인터페이스 두개를 만들자.

 

SaveCheck

package hello.itemservice.domain.item;

public interface SaveCheck {
}

 

UpdateCheck

package hello.itemservice.domain.item;

public interface UpdateCheck {
}

 

그리고 Bean Validation을 위한 애노테이션에 생성과 업데이트 검증을 지정한다.

Item

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    @NotNull(groups = UpdateCheck.class)
    private Long id;

    @NotBlank(message = "필수 필드입니다.", groups = {SaveCheck.class, UpdateCheck.class})
    private String itemName;

    @NotNull(message = "필수 필드입니다.", groups = {SaveCheck.class, UpdateCheck.class})
    @Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.", groups = {SaveCheck.class, UpdateCheck.class})
    private Integer price;

    @NotNull(message = "필수 필드입니다.", groups = {SaveCheck.class, UpdateCheck.class})
    @Max(value = 9999, message = "최대값은 9999입니다.", groups = SaveCheck.class)
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • id@NotNull은 그룹으로 UpdateCheck.class를 지정했다. 이렇게 되면 업데이트 할 때만 이 검증이 요구된다.
  • itemName@NotBlank는 그룹으로 SaveCheck.class, UpdateCheck.class둘 다 지정했다. 이렇게 되면 생성과 업데이트 시 모두 검증이 요구된다. 
  • price@NotNull, @Range 모두 그룹으로 SaveCheck.class, UpdateCheck.class둘 다 지정했다. 이렇게 되면 생성과 업데이트 시 모두 검증이 요구된다. 
  • quantity@NotNull그룹으로 SaveCheck.class, UpdateCheck.class둘 다 지정했다. 그리고 @Max는 그룹으로 SaveCheck.class만 지정했다. 

이렇게 각 검증 요구사항을 그룹으로 지정해 놓은 다음 컨트롤러에서 검증을 하겠다는 의미를 가지는 @Validated에 그룹을 지정해주면 된다.

@PostMapping("/add")
public String addItem(@Validated(value = SaveCheck.class) @ModelAttribute Item item,
                      BindingResult bindingResult,
                      RedirectAttributes redirectAttributes) {

	...
}

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId,
                   @Validated(value = UpdateCheck.class) @ModelAttribute Item item,
                   BindingResult bindingResult) {
	...
}
  • 아이템을 생성하는 addItem 메서드의 @ValidatedSaveCheck.class를 지정했다. 생성 시 검증은 이 SaveCheck가 그룹으로 할당된 애노테이션만 적용된다는 의미이다.
  • 아이템을 수정하는 edit 메서드의 @ValidatedUpdateCheck.class를 지정했다. 업데이트 시 검증은 이 UpdateCheck가 그룹으로 할당된 애노테이션만 적용된다는 의미이다.

 

이렇게 적용하면 생성과 업데이트 간 같은 객체를 사용하면서 검증 처리는 분리할 수 있다.

그런데, 생각보다 불편하기도 하고 복잡하다. 우선 그룹을 지정하기 위해 인터페이스를 만드는 것부터 시작해서 Item 객체를 보면 꽤나 지저분해 진 모습이 보일것이다. 그래서 이 방식은 잘 사용하지 않는다. 위의 이유도 있지만 더 큰 이유는 실제 업무 세상에서는 생성 폼과 업데이트 폼은 매우 다르다. 일반적으로 유명한 사이트의 회원 가입 시 폼과 회원 수정 시 폼을 생각해보라. 

 

그래서 실제 업무 세상에서는 DTO 객체를 분리하여 이 문제를 처리한다. 사실 분리하면 문제를 처리하는 게 아니라 문제가 그냥 없어진다.

 

Item 객체를 ItemSaveForm, ItemUpdateForm으로 분리

우선, 이제 더 이상 Item 객체로 검증을 하지 않을거니까, 검증 관련 애노테이션을 전부 주석처리 하거나 없애자.

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.ScriptAssert;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

그리고, 이제 생성과 업데이트 시 DTO로 사용할 클래스 두 개를 만들자.

ItemSaveForm

package hello.itemservice.web.validation.form;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(value = 9999)
    private Integer quantity;
}

ItemUpdateForm

package hello.itemservice.web.validation.form;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    private Integer quantity;
}
  • 생성시에는 quantity 필드에 검증을 하지만 업데이트 시에는 quantity에 아무런 검증을 하지 않는것을 볼 수 있다.
  • id도 생성시에는 아예 필드조차 없지만, 업데이트 시에는 @NotNull 애노테이션이 붙어있다.

 

이 객체를 가지고 컨트롤러에 적용해보자. 먼저 생성 시 메서드이다.

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
                      BindingResult bindingResult,
                      RedirectAttributes redirectAttributes) {

    //특정 필드가 아닌 복합 룰 검증
    if (form.getPrice() != null && form.getQuantity() != null) {
        int resultPrice = form.getPrice() * form.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        }
    }

    //검증에 실패하면 다시 입력 폼으로
    if (bindingResult.hasErrors()) {
        log.info("errors={} ", bindingResult);
        return "validation/v4/addForm";
    }

    //성공 로직
    Item item = new Item();
    item.setItemName(form.getItemName());
    item.setPrice(form.getPrice());
    item.setQuantity(form.getQuantity());

    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v4/items/{itemId}";
}
  • @ModelAttribute 애노테이션의 타입을 ItemSaveForm으로 변경했다. 그래서 이제 사용자가 입력한 값은 이 객체로 변환될 것이다. 그리고 주의할 점이 있다. @ModelAttribute에 어떠한 값도 주지 않으면 Model에 담기는 키가 기본으로 타입의 첫글자를 소문자로 바꾼 `itemSaveForm`이 될 것이다. 근데 우리의 뷰 템플릿은 item으로 받고 있으니 `item`이라고 직접 명시해주자.

다음은 업데이트 시 메서드이다.

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId,
                   @Validated @ModelAttribute("item") ItemUpdateForm form,
                   BindingResult bindingResult) {

    //특정 필드가 아닌 복합 룰 검증
    if (form.getPrice() != null && form.getQuantity() != null) {
        int resultPrice = form.getPrice() * form.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        }
    }

    if (bindingResult.hasErrors()) {
        log.info("errors={}", bindingResult);
        return "validation/v4/editForm";
    }

    Item itemParam = new Item();
    itemParam.setItemName(form.getItemName());
    itemParam.setPrice(form.getPrice());
    itemParam.setQuantity(form.getQuantity());

    itemRepository.update(itemId, itemParam);
    return "redirect:/validation/v4/items/{itemId}";
}
  • @ModelAttribute 애노테이션의 타입을 ItemUpdateForm으로 변경했다. 그래서 이제 사용자가 입력한 값은 이 객체로 변환될 것이다. 마찬가지로 @ModelAttribute의 속성값을 `item`으로 지정한다.

이렇게 생성과 업데이트에 사용되는 DTO를 분리하므로써 더 간결하고 더 명확하게 검증도 나눌 수 있다. 그리고 검증을 하고 말고를 떠나서 실무에서는 항상 이런식으로 DTO가 분리된다. 생성 시 필요한 데이터와 업데이트 시 필요한 데이터가 거의 100% 다르기 때문에.

 

이렇게 Bean Validation의 한계를 두 가지 방법으로 해결해 보았다.

 

HTTP API일 때 Bean Validation 처리

개발을 하다보면 앞단 처리를 할 때도 있지만 API 통신을 할 때가 거의 대부분이다. 그리고 그 때도 물론 당연히 검증처리는 되어야 한다.

그리고 스프링은 완전 비슷하게 검증 처리를 할 수 있다. 

 

다시 이전 포스팅을 복기해보면, @Controller가 붙은 클래스 메서드들 중 @ResponseBody가 붙은 메서드는 HTTP API로 통신한다고 했다. 그리고 이 둘을 합친것이 @RestController라고 했다. 그래서 이 경우 어떻게 검증하는지 확인해보자.

 

ValidationItemApiController

package hello.itemservice.web.validation;

import hello.itemservice.web.validation.form.ItemSaveForm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {

    @PostMapping("/add")
    public Object addItem(@Validated @RequestBody ItemSaveForm itemSaveForm, BindingResult bindingResult) {
        log.info("API 컨트롤러 호출");

        if (bindingResult.hasErrors()) {
            log.info("검증 오류 발생 errors = {}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return itemSaveForm;
    }
}
  • REST API로 요청 바디에 있는 값을 객체로 바로 컨버팅 할 수 있다고 했고 그게 바로 HTTP 메시지 컨버터가 해주는 것이라고 했다. 그리고 컨버터가 인지할 수 있도록 파라미터로 @RequestBody ItemSaveForm itemSaveForm이라고 @RequestBody 애노테이션을 붙여주면 된다. 그럼 사용자가 입력한 요청 바디값이 ItemSaveForm 객체로 변환될 것이다.
  • 그리고 그 이후부터는 기존에 하던 방식과 똑같다. 검증하고자 하는 객체에 @Validated 애노테이션을 붙이고, 그 바로 뒤에는 BindingResult를 넣으면 된다.
  • HTTP API도 검증을 이런식으로 할 수 있음을 보여주기 위한 예시 코드라 리턴값은 아무런 의미를 두지 말자. 검증 오류가 나면 검증 오류 결과가 반환되고, 검증 오류가 없으면 그냥 사용자가 던진 데이터를 그대로 돌려받는다. 

 

정상 응답 결과

 

검증 오류 결과

  • BindingResult가 가지고 있는 오류 내용을 그대로 반환하고 있다. 실제로는 이대로 반환하면 절대 안된다.

메시지 컨버터가 컨버팅을 실패하는 결과

  • 여기서는 검증 오류에 대한 내용이 나오지 않고, 뭔가 스프링에서 에러를 그냥 던진것 같은 응답이 돌아왔다. 맞다. 이 경우 컨트롤러가 실행조차 되지 않았다.
  • 그 이유는, 사용자가 입력한 값이 HTTP 메시지 컨버터에 의해 객체로 컨버팅될 수가 없기 때문이다.
  • JSON 데이터는 필드 하나하나가 아니라 저 `{...}` 이 한뭉텅이가 곧 하나다. 그래서 그 데이터가 변환하려고 하는 객체에 일치하지 않는 부분이 있으면 안된다. 그래서 아예 변환 과정에서 실패했기 때문에 컨트롤러가 호출도 되지 않았고, 검증 자체도 실행되지 않았다. 
  • 즉, HttpMessageConverter 단계에서 실패하면 위처럼 예외가 발생한다.예외 발생 시 원하는 모양으로 예외를 처리하는 방법은 이후에 자세히 배워보자!

 

"어? 근데, API가 아니라 뷰 레이어로 검증할때는 타입이 맞지 않아도 컨트롤러도 잘 호출되고 검증도 잘 됐잖아요!?"

→ 맞다. @ModelAttribute@RequestBody는 처리 방식이 좀 다르다. HTTP 요청 파라미터(QueryString, Form Data)를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.HttpMessageConverter@ModelAttribute와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Validated가 적용된다. 그래서 @ModelAttributeBean Validation을 적용하면 바인딩 된 필드만 검증 처리가 실행되고 바인딩부터 실패한 필드는 검증을 그냥 무시해버리고 바로 typeMismatch로 필드에러 생성 후 넘어가 버린다고 했다. @RequestBody는 메시지 컨버터에 의해 객체를 만들어 내지 못하면 아예 그냥 컨트롤러도 호출되지 않고 @Validated도 적용할 수 없다.

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

참고자료

 

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

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

www.inflearn.com

 

검증 

전달된 데이터에 대한 검증은 웹 애플리케이션에서 빠질 수 없는 요소이다. 누군가는 "앞단에서 검증 처리를 하면 되지 않나요?"라고 말할 수 있겠지만 앞단과 뒷단 모두 검증 처리를 당연히 해야 한다. 

 

왜냐하면, 앞단 검증은 조작이 가능하다. 예를 들면, 이름을 입력하는 필드에 문자가 들어갈텐데 여기에 SQL문을 작성해도 아무런 문제가 되지 않을수도 있다. 같은 문자열이고 문자열 길이에 제한이 없는 경우라면 말이다. 

뒷단만으로 검증을 한다면 즉각적인 고객 피드백이 부족해진다. 예를 들어, 숫자 입력 필드에 문자를 입력함과 동시에 바로 반응을 해주면 고객 친화적인 애플리케이션이 될텐데 서버에서만 검증하면 결국 데이터를 날리기 전까지는 모르니까 말이다.

 

따라서, 둘을 적절하게 섞어서 사용해야 한다. API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다. 

 

스프링의 도움 없이 검증을 직접 해보기

우선, 검증을 직접 처리할때는 어떻게 하는지를 먼저 보고 이를 스프링이 얼마나 편하게 해주는지를 알아보자.

상품을 저장하는 로직이 있다고 하면 상품 저장에 성공 하거나 실패하는 케이스가 있을것이다. 

 

성공하는 케이스의 흐름은 다음과 같다.

1. 사용자가 상품 저장 화면 요청

2. 상품 저장 화면 응답

3. 상품 저장 화면에서 데이터 입력

4. 상품 저장 요청

5. 상품 저장 완료

 

실패하는 케이스의 흐름은 다음과 같다.

1. 사용자가 상품 저장 화면 요청

2. 상품 저장 화면 응답

3. 상품 저장 화면에서 데이터 입력

4. 상품 저장 요청

5. 검증 실패

6. 실패 원인 데이터와 함께 상품 저장 화면을 다시 출력

 

중요한 건, 사용자 경험을 위해 어떤 부분에서 어디가 잘못됐는지에 대한 내용을 포함한 원래 화면이 다시 나와야 한다는 것이다.

이제 직접 처리하는 코드를 작성해보자.

 

우선, @ModelAttributeItem 객체는 이렇게 생겼다.

package hello.itemservice.domain.item;

import lombok.Data;

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

서버단 처리

@PostMapping("/add")
public String addItem(@ModelAttribute Item item, Model model, RedirectAttributes redirectAttributes) {

    // 검증 오류 결과를 보관
    Map<String, String> errors = new HashMap<>();

    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        errors.put("itemName", "Item name is required");
    }
    if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
        errors.put("price", "Price must be between 1000 and 1,000,000");
    }
    if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
        errors.put("quantity", "Quantity must be between 1 and 9999");
    }

    // 특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10_000) {
            errors.put("globalError", "price * quantity must be greater than 10,000. current price is " + resultPrice);
        }
    }

    // 에러가 있는 경우, 다시 폼을 보여줌
    if (hasError(errors)) {
        log.debug("[addItem] errors: {}", errors);
        model.addAttribute("errors", errors);
        return "validation/v1/addForm";
    }

    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v1/items/{itemId}";
}

private boolean hasError(Map<String, String> errors) {
    return !errors.isEmpty();
}

 

  • 상품 데이터를 저장 요청해서 POST 요청을 날리면 검증을 해야 한다. 그래서 컨트롤러의 POST 요청에서 검증을 수행한다.
  • 먼저 데이터에 에러가 있는 경우 어떤 데이터의 어떤 에러인지를 담기 위한 Map을 선언한다.
  • 차례대로 하나씩 검증을 시작한다. 검증 요구사항은 당연히 애플리케이션마다 천지차별이다. 여기서는 다음과 같다.
    • itemName은 없으면 안된다.
    • price는 없거나, 1,000원보다 작거나, 1,000,000원보다 크다면 잘못된 데이터이다.
    • quantity는 없거나, 1개보다 작거나, 9999개보다 크다면 잘못된 데이터이다.
    • price * quantity의 값이 10,000원을 넘지 않는다면 잘못된 데이터이다.
  • 에러가 있는 경우 modelerrors를 담아서 다시 상품 저장 폼을 보여준다.

이렇게 하면 끝이다. 그리고 만약 에러가 있어서 다시 상품 저장 화면이 출력되면, 타임리프 문법을 통해 errors에서 에러가 있는 경우 그 데이터를 출력만 해주면 된다.

 

검증 단계를 하나씩 살펴보자.

1. 에러가 있는 경우에 해당 에러를 보관할 Map을 선언

// 검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();

 

2. itemName은 없으면 안된다.

if (!StringUtils.hasText(item.getItemName())) {
    errors.put("itemName", "Item name is required");
}

 

3. price는 없거나, 1,000원보다 작거나, 1,000,000원보다 크면 안된다.

if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
    errors.put("price", "Price must be between 1000 and 1,000,000");
}

 

4. quantity는 없거나, 1보다 작거나, 9999보다 크면 안된다.

if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    errors.put("quantity", "Quantity must be between 1 and 9999");
}

 

5. price * quantity의 값이 10,000원을 넘어야 한다.

if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10_000) {
        errors.put("globalError", "price * quantity must be greater than 10,000. current price is " + resultPrice);
    }
}

 

6. 이렇게 검증을 직접 처리를 한 후, 검증 결과를 확인해서 에러가 있는 경우 해당 에러 데이터를 포함해서 다시 등록 폼을 보여준다.

if (hasError(errors)) {
    log.debug("[addItem] errors: {}", errors);
    model.addAttribute("errors", errors);
    return "validation/v1/addForm";
}

 

여기까지가 서버에서 해 줄 작업이다. 그럼 앞단에서는 상품 등록 폼에서 에러가 있으면 해당 에러를 보여주면 된다.

 

앞단 처리

addForm.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;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2 th:text="#{page.addItem}">상품 등록</h2>
    </div>

    <form action="item.html" th:action th:object="${item}" method="post">
        <div>
            <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
        </div>
        <div>
            <label for="price" th:text="#{label.item.price}">가격</label>
            <input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/validation/v1/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>
    </form>

</div> <!-- /container -->
</body>
</html>
  • 이렇게 타임리프로 만들어진 상품 등록 폼이 있다. 이 폼에 하나씩 에러 처리 부분을 적용해보자.

globalError

우선, 어떤 특정 필드 처리가 아니라 전체적으로 적용되는 에러가 있었다. 

그 에러가 발생하는 경우는 `가격 * 수량 < 10,000`인 경우이다.

 

그래서 form 태그 안에 바로 위에 이 부분을 넣어주자.

<div th:if="${errors?.containsKey('globalError')}">
    <p class="field-error" th:text="${errors['globalError']}">글로벌 에러 라인</p>
</div>
  • 만약, 에러가 발생해서 다시 상품 등록 폼으로 돌아왔다면, 에러 내용을 담은 errors라는 Map이 있을 것이다.
  • errorsglobalError가 있다면 이 부분이 렌더링된다.

그래서 이 globalError를 추가한 전체 코드는 이렇게 된다. (위에 스타일도 들어간 것 확인. 아무래도 빨간색이 에러 내용에 적합하니)

<!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 th:text="#{page.addItem}">상품 등록</h2>
    </div>

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

        <div th:if="${errors?.containsKey('globalError')}">
            <p class="field-error" th:text="${errors['globalError']}">글로벌 에러 라인</p>
        </div>

        <div>
            <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
        </div>
        <div>
            <label for="price" th:text="#{label.item.price}">가격</label>
            <input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/validation/v1/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

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

 

그래서 이 상태에서 내가 globalError를 발생시키는 데이터를 전송하면 다음과 같이 나오게 된다. 

 

 

itemName

이번엔 itemName에 대한 에러 내용을 출력해보자.

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div>
</div>

 

  • itemName 필드를 출력하는 부분에 다음과 같이 에러 출력 div 태그 하나를 추가해주면 된다.

그럼 이렇게 보여진다. 간단하다.

근데, 지금 상태에서 조금 더 고객에 친절한 애플리케이션이 되고 싶다면 필드의 border-color를 추가해주면 좋을것 같다.

<input type="text" id="itemName" th:field="*{itemName}" th:class="${errors.containsKey('itemName')} ? 'form-control field-error' : 'form-control'" placeholder="이름을 입력하세요">
  • 타임리프의 도움을 받아 th:class로 클래스를 추가할 수 있는데, 여기서 errorsitemName을 가지고 있냐 없냐에 따라 분기할 수 있다.

그래서 최종적으로 이렇게 만들 수 있다.

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" th:class="${errors.containsKey('itemName')} ? 'form-control field-error' : 'form-control'" placeholder="이름을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div>
</div>

그리고 이렇게 한 결과를 보면 조금 더 이쁘다는 것을 알 수 있다.

타임리프에서 지원하는 classappend를 사용해도 된다.

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" class="form-control" th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _" placeholder="이름을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div>
</div>

저 값이 있으면 추가하고자 하는 클래스만 작성하고, 없으면 `_(아무것도 하지 않음)` 처리를 해주는 것도 가능하다.

 

price

이제 가격 차례다. 위 itemName이랑 똑같이 하면 된다.

<div>
    <label for="price" th:text="#{label.item.price}">가격</label>
    <input type="text" id="price" th:field="*{price}" th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'" placeholder="가격을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">상품명 오류</div>
</div>

 

quantity

<div>
    <label for="quantity" th:text="#{label.item.quantity}">수량</label>
    <input type="text" id="quantity" th:field="*{quantity}" th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control'" placeholder="수량을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}">상품명 오류</div>
</div>

 

 

여기까지 하면, 뭔가 검증 다 끝난거 같은데? 하면 큰 오산이다. 우선 남은 문제점이 있다.

  • 타입 안정성이 해결되지 않았다. 예를 들어, 가격에 'qqq'라고 작성해도 검증이 되는게 아니라 아예 컨트롤러 자체가 호출이 되지 않을 것이다. 왜냐하면 Item이라는 객체에 데이터를 담아서 그 값들을 하나씩 검증해야 하는데 Item안에 price 필드는 Integer 타입이라 스트링 값 자체가 들어가지 않는다. 
  • 뷰 템플릿에서 중복 처리가 많다.

이런 것들을 직접 처리한다면 얼마나 괴로울까? 스프링이 다 도와준다. 이제 스프링이 도와주는 것들을 하나씩 차근차근 알아보자.

 

스프링의 도움을 받아 검증 처리하기

이제 스프링이 도와주는 것들을 사용해서 하나씩 세련된 코드를 작성해보자. 

BindingResult

바로 이 BindingResult라는 스프링이 만들어 놓은 좋은 녀석을 사용하면 된다.

근데, 이 녀석 사용할때 주의할 점은 @ModelAttribute 바로 뒤에 와야한다는 것이다.

이유는 무엇이냐면, ResultBinding한다는 것은 사용자가 입력한 데이터를 Result 해서 어떤 객체에 바인딩시켜야 한다는 것을 의미한다. 그렇다면 바인딩 할 대상이 있어야 하고 그 대상을 스프링에서 지정하기를 바로 앞에 있는 @ModelAttribute로 선언된 객체라고 정의한 것이다.

서버단 처리

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.addError(new FieldError("item", "itemName", "Item name is required"));
    }
    if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
        bindingResult.addError(new FieldError("item", "price", "Price must be between 1 and 1_000_000"));
    }
    if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
        bindingResult.addError(new FieldError("item", "quantity", "Quantity must be between 1 and 9999"));
    }

    // 특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10_000) {
            bindingResult.addError(new ObjectError("item", "price * quantity must be greater than 10,000. current price is " + resultPrice));
        }
    }

    // 에러가 있는 경우, 다시 폼을 보여줌
    if (bindingResult.hasErrors()) {
        log.debug("[addItemV1] bindingResult errors: {}", bindingResult);
        return "validation/v2/addForm";
    }

    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}
  • 파라미터 부분에 @ModelAttribute Item 바로 뒤에 BindingResult가 나온것을 알 수 있다. 이게 규칙이기 때문에 반드시 이렇게 해야한다.
  • 코드를 보니 뭔가 엄청 깔끔해진 것 같고 코드 양도 줄어보인다.

검증 단계를 하나씩 살펴보자.

1. 에러가 있는 경우에 해당 에러를 보관할 Map을 선언

BindingResult를 파라미터로 선언한 순간부터 더 할 작업이 없다.

 

2. itemName은 없으면 안된다.

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item", "itemName", "Item name is required"));
}
  • BindingResult가 가지고 있는 addError()를 호출한다.
  • addError()는 파라미터로 ObjectError를 받는다. 그것을 상속받는 FieldErrornew로 생성한다.
  • FieldError는 말 그대로 특정 필드에 대한 에러를 저장하는 객체이다. 그리고 파라미터는 3가지를 받는다.
    • objectName → @ModelAttribute로 선언한 객체를 의미한다.
    • field → 위 objectName으로 선언한 객체의 특정 필드인 itemName을 의미한다.
    • defaultMessage → 보여줄 에러 메시지를 의미한다.

3. price는 없거나, 1,000원보다 작거나, 1,000,000원보다 크면 안된다.

if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
    bindingResult.addError(new FieldError("item", "price", "Price must be between 1 and 1_000_000"));
}

 

4. quantity는 없거나, 1보다 작거나, 9999보다 크면 안된다.

if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    bindingResult.addError(new FieldError("item", "quantity", "Quantity must be between 1 and 9999"));
}

 

5. price * quantity의 값이 10,000원을 넘어야 한다.

if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10_000) {
        bindingResult.addError(new ObjectError("item", "price * quantity must be greater than 10,000. current price is " + resultPrice));
    }
}
  • 이번엔 특정 필드가 아니라 글로벌 에러를 저장할 때 예시다. 
  • 이번엔 ObjectErrornew로 생성해서 넣으면 된다. 이 객체는 두가지 파라미터를 받는다.
    • objectName → @ModelAttribute로 선언한 객체를 의미한다.
    • defaultMessage → 보여줄 에러 메시지를 의미한다.

6. 이렇게 검증을 직접 처리를 한 후, 검증 결과를 확인해서 에러가 있는 경우 해당 에러 데이터를 포함해서 다시 등록 폼을 보여준다.

if (bindingResult.hasErrors()) {
    log.debug("[addItemV1] bindingResult errors: {}", bindingResult);
    return "validation/v2/addForm";
}

 

여기까지가 서버에서 해 줄 작업이다. 그럼 앞단에서는 상품 등록 폼에서 에러가 있으면 해당 에러를 보여주면 된다.

 

앞단 처리

앞단 처리가 예술이다. 스프링과 타임리프를 사용해서 웹 애플리케이션을 만든다면 꽤나 검증 처리를 재밌게 할 수 있다.

<!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 th:text="#{page.addItem}">상품 등록</h2>
    </div>

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

        <div th:if="${#fields.hasGlobalErrors()}">
            <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 에러 라인</p>
        </div>

        <div>
            <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}" class="form-control" th:errorclass="field-error" placeholder="이름을 입력하세요">
            <div class="field-error" th:errors="*{itemName}">상품명 오류</div>
        </div>
        <div>
            <label for="price" th:text="#{label.item.price}">가격</label>
            <input type="text" id="price" th:field="*{price}" class="form-control" th:errorclass="field-error" placeholder="가격을 입력하세요">
            <div class="field-error" th:errors="*{price}">상품명 오류</div>
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control" th:errorclass="field-error" placeholder="수량을 입력하세요">
            <div class="field-error" th:errors="*{quantity}">상품명 오류</div>
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/validation/v2/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

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

 

globalError

<div th:if="${#fields.hasGlobalErrors()}">
    <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 에러 라인</p>
</div>
  • 글로벌 에러는 복수로 존재할 수 있다. 그래서 th:if="${#fields.hasGlobalErrors()}" 이 부분을 보면 글로벌 에러가 있는지 먼저 체크하는데 거기에 hasGlobalErrors라고 복수형태로 확인하는 것을 볼 수 있다.
  • #fields: #fieldsBindingResult가 제공하는 검증 오류에 접근할 수 있다.

 

itemName

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" class="form-control" th:errorclass="field-error" placeholder="이름을 입력하세요">
    <div class="field-error" th:errors="*{itemName}">상품명 오류</div>
</div>
  • th:errors: 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if의 편의 버전이다. 
  • th:errorclass: th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

price

<div>
    <label for="price" th:text="#{label.item.price}">가격</label>
    <input type="text" id="price" th:field="*{price}" class="form-control" th:errorclass="field-error" placeholder="가격을 입력하세요">
    <div class="field-error" th:errors="*{price}">상품명 오류</div>
</div>

 

quantity

<div>
    <label for="quantity" th:text="#{label.item.quantity}">수량</label>
    <input type="text" id="quantity" th:field="*{quantity}" class="form-control" th:errorclass="field-error" placeholder="수량을 입력하세요">
    <div class="field-error" th:errors="*{quantity}">상품명 오류</div>
</div>

 

아까보다 훨씬 코드가 깔끔해지고 중복도 제거됐음을 확인할 수 있다. 그러면, 아까 여전히 남아있던 문제점을 이 BindingResult는 해결해줄까? 

 

  • @ModelAttribute에 바인딩 시 타입 오류가 발생하면? 그러니까 가격에 'qqq'와 같은 문자열을 넣는다면?
    • BindingResult가 없으면 400 에러가 발생하면서 컨트롤러가 호출되지 않고 오류 페이지로 이동하게 된다.
    • BindingResult가 있으면 오류 정보를 BindingResult에 담아서 컨트롤러를 정상 호출한다. 결국 바인딩 하는 것 자체도 검증에 포함을 시켜주는 게 이 BindingResult인 것이다.
    • 그럼 어떻게? → @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError를 생성해서 BindingResult에 직접 넣어준다.

 

그럼 이제, BindingResult를 사용해서 훨씬 더 편리하게 검증을 할 수가 있다.

그런데 남은 문제가 있다. 오류가 발생하면 고객이 입력한 내용이 모두 사라진다는 점이다. 아래 사진을 보자.

 

고객의 요청데이터

오류가 발생한 결과

이렇듯 가격 필드에 입력했던 '10'이 사라졌다. 이것을 해결해보자.

 

FieldError의 생성자

bindingResult.addError(new FieldError("item", "itemName", "Item name is required"));

앞서 해본것처럼 어떤 필드에 에러가 있으면 이렇게 addError()를 호출해서 FieldError를 만들어 넣었다. 이때, FieldError는 여러 생성자를 가지는데 그 중에 사용자가 입력한 값을 보존하는 생성자가 있다.

 

그래서 위에서 처리했던, itemName, price, quantity 필드에 대한 에러를 추가할 때 이렇게 변경한다.

itemName

bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "Item name is required"));

 

price

bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "Price must be between 1 and 1_000_000"));

 

quantity

bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "Quantity must be between 1 and 9999"));

 

  • 첫번째 인자로는, objectName을 그대로 받는다.
  • 두번째 인자로는, field를 그대로 받는다.
  • 세번째 인자로는, rejectedValue이다. 사용자가 입력했던 값을 집어넣는다. 즉, 사용자가 입력한 값을 @ModelAttribute에서 알아서 바인딩을 해주는데 그 값을 여기에 넣어주는 것이다.
  • 네번째 인자로는, 바인딩 자체가 실패했는지를 묻는다. 즉, Integer로 들어올 값이 String으로 들어왔다던가 이런 바인딩 자체의 실패의 여부이다. 여기서는 그건 아니니까 false를 모두 입력한다.
  • 다섯번째 인자로는, codes 이다. 이건 이후에 더 자세히 얘기한다.
  • 여섯번째 인자로는, arguments 이다. 이건 이후에 더 자세히 얘기한다.
  • 일곱번째 인자로는, 사용자에게 보여줄 메시지이다.

그래서 이렇게 FieldError의 또다른 생성자를 사용해서 넣어주면 된다. 다른 말로 FieldError가 가지고 있는 rejectedValue에 값을 넣어주면 타임리프에서 에러가 있는 경우 이 값을 보여주게 될 것이다. 

 

그래서 itemName으로 예시를 들자면,

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" class="form-control" th:errorclass="field-error" placeholder="이름을 입력하세요">
    <div class="field-error" th:errors="*{itemName}">상품명 오류</div>
</div>
  • th:field="*{itemName}" 이 녀석이 정말 똑똑하게도 정상 상황에서는 모델 객체의 값을 사용하고, 에러가 발생했으면 FieldError에서 보관하고 있는 rejectedValue값을 사용해서 출력한다.

그래서 이제는 아래처럼 에러가 나도 사용자의 입력값을 보관한다.

 

 

남은 문제는 무엇일까?

바인딩 자체가 안되는 경우에 이러한 모습이 보여진다.

스프링이 이렇게 바인딩 자체에 실패한 경우에 BindingResult에 직접 해당 에러를 넣어주는데 그때 보이는 저 에러 메시지가 상당히 불친절하다. 사용자 입장에서는 이해할 수 없는 내용이다. 이 부분을 해결해보자.

 

FieldError생성자 2

위에서 말한 문제를 해결하기 앞서, 아까 봤던 FieldError의 생성자에 대해 좀 더 알아보는 시간을 먼저 가져보자.

잠깐 살펴봤지만 파라미터로 들어가는 값 중, codes, arguments가 있었다.

 

만약 이 웹 애플리케이션이 규모가 엄청 크고 비슷한 에러 문구가 나오는 화면이 여러개라면 그때마다 일일이 에러메시지를 작성하기는 싫을 것이다. 또한 그렇게 한 경우 변경할 필요가 생겼을 때 하나하나 다 변경해줘야 한다. 즉, 이 BindingResult로 에러를 저장하는 것도 메시지 기능을 사용할 수가 있다.

 

그래서 메시지 기능을 적용해보자. 우선 그러려면 스프링한테 메시지를 어디에 보관할건지 알려줘야 한다.

application.yaml

spring.messages.basename=messages,errors
  • 기본값이 저 messages라고 메시지, 국제화 포스팅에서 말한 바 있다.
  • 만약, 다른 파일이 더 있다면 추가적으로 저렇게 작성해줘야 한다. 나는 에러 메시지에 대해 파일을 만들거니까 이름을 errors라고 했다.

그리고 메시지 파일 하나를 만든다.

errors.properties

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
  • 당연히 이 또한 국제화도 가능하다. errors_en.properties 파일을 만들어서 국제화 기능 충분히 사용할 수 있다.
  • 이렇게 보고 나니, codes 파라미터 말고 arguments 파라미터는 뭘 의미하는지 바로 알 것 같다. 바로 저 {0}, {1}에 들어갈 배열이고 숫자는 배열의 인덱스를 의미한다.

 

에러 메시지 파일을 만들었으면 이것을 가져다가 사용하면 된다. 아래처럼.

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
}
if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
    bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1_000, 1_000_000}, null));
}
if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, null));
}
  • arguments는 여러개가 있을 수 있으니 배열로 받는게 이해가 된다. 근데 왜 codes도 배열일까?
  • 그 이유는 0번째 인덱스가 가장 우선순위가 높고 여기서부터 찾으면 바로 적용을 하지만 만약 그 코드를 찾지 못하면 다음 인덱스를 찾아가면서 제일 먼저 찾아진 코드를 메시지로 적용하게 설계됐기 때문이다.
  • 그리고 배열에 모든 인덱스를 다 뒤져도 값을 찾지 못한 경우 가장 마지막 파라미터인 defaultMessage값을 적용하는데 이 값마저 null이면 에러가 발생할 것이다.

 

이렇게 사용되는게 FieldErrorcodes, arguments 파라미터이다.

근데, 솔직히 이런 생각이 든다. "불편한데..?" 맞다. 솔직히 파라미터가 너무 많고 사용하기가 꽤 불편하다. 근데 스프링이 이렇게 불편하다고 느끼는 것들을 가만히 놔두지 않는다. 더 개선한다. 그래서 어떻게 개선할 수 있을까?

 

BindingResult의 위치

위에서 말했듯 이 BindingResult는 무조건 @ModelAttribute 객체 바로 다음에 와야 한다고 했다. 그 말은, 이미 BindingResult는 어떤 객체에 데이터를 바인딩 해야 하는지 알고 있다는 뜻이다. 그래서 이 BindingResult에는 이런 메서드가 있다.

 

  • getTarget()
  • getObjectName()

그래서 한번 이렇게 코드를 작성해보자.

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    log.debug("target: {}", bindingResult.getTarget());
    log.debug("objectName: {}", bindingResult.getObjectName());
    
    ...
}

 

2024-08-31 17:54:24.562 DEBUG 51870 --- [nio-8080-exec-1] h.i.w.v.ValidationItemControllerV2       : target: Item(id=null, itemName=, price=null, quantity=null)
2024-08-31 17:54:24.571 DEBUG 51870 --- [nio-8080-exec-1] h.i.w.v.ValidationItemControllerV2       : objectName: item

이미 targetobjectName을 알고 있다. 그 말은 FieldError()라는 객체를 만들 때, objectName 이런 값을 굳이 넣을 필요가 없을 것 같다. 그래서 BindingResult가 다음과 같은 메서드를 제공한다.

 

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
    bindingResult.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
}
if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
}
  • rejectValue() 메서드를 사용하면, 그냥 어떤 필드이고 에러 메시지에 대한 코드가 어떤 코드인지만 작성하면 끝난다. 근데 여기서 코드를 그냥 "required"만 적었다. 우리가 작성한 코드는 "required.item.itemName"이었다. 어떻게 저렇게만 작성해도 될까? 이후에 자세히 알아보자.
  • 그리고 세번째 인자와 네번째 인자가 필요한 경우는 이제 codearguments가 필요할 때이다.
if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10_000) {
        bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
    }
}
  • globalError의 경우, reject()라는 메서드를 사용하면 된다. 특정 필드에 한정 짓는게 아니기 때문에 필드 이름은 필요없다.

이렇게 작성해서 실행해도 에러 메시지를 잘 출력해준다. 흐름 자체는 이해가 쉽다. 근데 저 오류 코드가 저렇게만 작성해도 정상적으로 동작한다는 게 받아들이기 쉽지 않다. 이 내용에 대해 자세히 알아보자!

 

스프링에서는 언제나 디테일한게 우선순위가 높다

굉장히 중요한 제목이다. 위 codes의 비밀도 이것과 연관이 있다. 다음과 같이 errors.properties 파일을 수정해보자.

errors.properties

required=필수값 입니다.
required.item.itemName=상품 이름은 필수입니다.


range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

이렇게 되어 있을때, 아래 코드는 어떤 메시지를 출력할까?

bindingResult.rejectValue("itemName", "required");

 

`required.item.itemName`을 출력했음을 알 수 있다. 

 

"엥?! 왜 정확히 `required`라는 키가 있는데 저걸 출력하지?" 이게 스프링에서 우선순위를 결정하는 방식이다.

단순히 `required` 이렇게만 작성한 에러 메시지는 굉장히 범용적이다. 그래서 여기저기서 가져다가 사용할 수 있을 것 같다.

근데 개발을 하다 보니 어떤 화면에서는 조금 더 사용자 친화적으로 자세한 내용을 알려주고 싶은 필수 필드 내용이 있는 것이다. 

그런 경우에 이렇게 더 디테일하게 키를 작성하면 그 값이 스프링은 더 우선순위를 높게 친다. 

 

그래서, `required.item.itemName`, `required` 이렇게 두개가 나란히 있어도 더 자세한 값인 `required.item.itemName`를 출력하는 것이다. 그래서 개발하는 입장에서는 단순하게 그냥 아래처럼 코드를 작성해두면,

bindingResult.rejectValue("itemName", "required");

추후에 더 자세한 메시지가 필요하다고 느껴져서 erros.properties 파일에 자세한 키를 추가만 하면 된다. 개발 코드에 아무런 변화를 주지 않아도 된다는 뜻이다. 이게 가장 중요하다. 

 

근데 의문이 들것이다. "아니 그럼 `required.item.itemName`을 어떻게 만들어내는 것인가? 그냥 단순히 앞에 `required`가 있는 에러 메시지 키를 다 찾는것인가?" 스프링은 에러 코드를 만들기 위한 구현체도 다 있다. MessageCodesResolver라는 인터페이스를 구현한 구현체를 통해 저 에러 코드를 만들어 낸다. 

 

다음 코드를 보자.

MessageCodesResolverTest

package hello.itemservice.validation;

import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.FieldError;
import org.springframework.validation.MessageCodesResolver;

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

public class MessageCodesResolverTest {

    MessageCodesResolver messageCodesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverObject() {
        String[] messageCodes = messageCodesResolver.resolveMessageCodes("required", "item");
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }

        assertThat(messageCodes).containsExactly("required.item", "required");
    }
}
  • MessageCodesResolver의 기본 구현체로 스프링은 DefaultMessageCodesResolver를 사용한다.
  • 그리고 그 구현체는 resolveMessageCodes(String s, String s1)라는 메서드를 가지고 있는데, 여기에서 저렇게 두 가지 문자열인 "required", "item" 이라는 값을 넣으면 어떻게 출력되냐면 다음과 같이 출력된다.
messageCode = required.item
messageCode = required

 

그러니까 넘겨 받은 파라미터를 통해 디테일 한 코드부터 디테일하지 않은 코드를 만들어내는 것이다.

 

다음 코드도 보자.

@Test
void messageCodesResolverField() {
    String[] messageCodes = messageCodesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
    for (String messageCode : messageCodes) {
        System.out.println("messageCode = " + messageCode);
    }
}
  • resolveMessageCodes(String s, String s1, String s2, Class<?> aClass) 메서드도 존재하는데 이 메서드에 파라미터로 "required", "item", "itemName", String.class라고 넣으면 어떤 값이 나올까?
messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required

 

이렇게 나오게 된다. 그리고 BindingResultreject(), rejectValue() 메서드는 내부에서 이 MessageCodesResolver를 사용한다. 

 

"하지만? rejectValue()는 파라미터로 필드 이름하고 코드만 넘기고, reject()는 코드 이름만 넘기는데요?"

그렇다, 근데 BindingResult는 뭘 알고 있냐? 바로 objectName을 알고 있다. 파라미터의 위치로 이미 알고 있는 상태이다. 그래서! 결국 resolveMessageCodes("required", "item", "itemName", String.class) 라는 메서드를 똑같이 사용할 수 있고 그 메서드를 통해 얻은 반환값을 통해 사실은 아래 코드를 만든다.

new FieldError("item", "itemName", item.getItemName(), messageCodes, null, null);
  • 그럼 저 String[] codes 파라미터 부분에 들어가는 messageCodes 위에서 만든 messageCodes 그대로 들어가게 된다.
  • 그리고 저 부분을 얘기하면서 0번 인덱스가 가장 우선순위가 높고 찾지 못하면 다음 인덱스 순서로 에러메시지를 찾아서 타임리프가 뿌려준다고 했다. 
  • 그럼 0번 인덱스는? "required.item.itemName"이다. 가장 마지막 인덱스는? "required"이다.

그래서 아래와 같은 코드와 에러 메시지가 있을 때 우선순위는 required.item.itemName이 적용되어 이 메시지가 화면에 보이는 것이다.

bindingResult.rejectValue("itemName", "required");

errors.properties

required=필수값 입니다.
required.item.itemName=상품 이름은 필수입니다.


range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

 

이제야 의문이 해소가 됐다. 참고로, DefaultMessageCodesResolver의 기본 메시지 생성 규칙은 다음과 같다.

 

객체 오류

객체 오류의 경우 다음 순서로 2가지 생성 
1.: code + "." + object name 
2.: code

예) 오류 코드: required, object name: item
1.: required.item
2.: required

 

필드 오류

필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성 
1.: code + "." + object name + "." + field 
2.: code + "." + field
3.: code + "." + field type
4.: code

예) 오류 코드: typeMismatch, object name "user", field "age", field type: int 
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"

 

BindingResult는 이런 숨겨진 내부 로직이 존재하는 것이다! 즉, 디테일한 코드를 먼저 생성해내기 때문에 디테일 한 코드부터 제일 디테일하지 않은 코드를 쭉 순회하면서 먼저 찾아진 코드를 그대로 사용한다. 

 

다시 강조하지만 중요한건, 메시지 코드의 변경이 필요할때, 예를 들어, "필수값 입니다." → "이름 필드는 필수값 입니다" 이런식으로 메시지를 바꾸고 싶을 때 애플리케이션 코드를 변경하는 게 아니라 메시지를 디테일하게 추가함으로써 원하는 결과를 얻어낼 수 있다는 것이다.

 

참고로, ValidationUtils 라는 유틸 클래스가 있다. 이걸 사용하면 코드 양을 좀 줄여주는 효과가 있다.

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required");
}

위 두 코드가 같은 코드이다. 근데 ValidationUtils는 저렇게 빈 문자열인 경우에 검증 처리같은 매우 간단한 기능만 있기 때문에 그냥 참고정도로만 알아두면 될 것 같다.

 

 

스프링이 직접 처리해주는 TypeMismatch 처리

이제 드디어 Integer 필드에 문자열을 입력했을 때 나오는 이상한 에러문구에 대한 원인을 이해할 수 있다.

자 일단 그 에러를 마주해보자.

  • "가격은 1,000 ~ 1,000,000까지 허용합니다." 이 에러메시지는 내가 작성한 에러메시지다. 내가 체크하는 검증 로직에도 실패했으니 당연히 저 에러 메시지가 보여야 한다. 
  • 그 부분을 제외한 위에 부분은 어디서 나온걸까?

 

우리가 BindingResult에 에러가 있으면 에러를 출력하는 로그를 찍은적이 있다. 그 로그를 봐보자.

Field error in object 'item' on field 'price': rejected value [qq]; \
codes [typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch]; \
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.price,price]; \
arguments []; \
default message [price]]; \
default message [Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Integer' for property 'price'; 
nested exception is java.lang.NumberFormatException: For input string: "qq"]
  • 보기 좋게 조금 단락을 나누었다. 
  • 이 에러를 보면 price라는 필드에서 에러가 발생했고 rejected value [qq]라고 되어 있다.
  • 그리고 그 때 codes를 보면 이렇게 되어 있다.
    • typeMismatch.item.price
    • typeMismatch.price
    • typeMismatch.java.lang.Integer
    • typeMismatch
  • 즉, 스프링이 이 경우에 rejectedValue("qq", typeMismatch")라고 나 대신에 메서드 호출해준 것이다.
  • 그러니까 이제 에러 메시지를 출력하기 위해 저 코드를 내가 선언한 errors라는 메시지 파일을 찾아서 보는데 코드가 없기 때문에? defaultMessage를 출력하게 된다.
  • 위 로그에서 defaultMessage는 다음과 같다.
    • Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Integer' for property 'price'; nested exception is java.lang.NumberFormatException: For input string: "qq"

이제야 이 에러 메시지의 비밀이 풀리게 되는 순간이다. 그럼 우리가 저 보기 싫은 에러 메시지를 없애고 사용자 친화적인 메시지를 보여주려면? 저 codes를 추가하면 된다. 나는 딱 이렇게만 넣었다.

typeMismatch.java.lang.Integer=숫자 필드입니다.

 

그랬더니 다음과 같은 아름다운 결과가 도출된다. 

 

누군가는 그럴수도 있다. "왜 에러메시지가 두개나 보여지죠? 헷갈리니까 하나만 보이면 좋을 것 같아요!"

나는 이 경우도 문제가 되지 않는다고 생각하지만 만약 타입 미스매치로 바인딩 자체에 실패가 된 경우 바로 다시 등록 폼을 보여주는 방법을 택해도 된다. 다음 코드처럼 맨 앞에 바인딩 자체에 실패했는지 로직을 추가하면 끝난다. (이건 원하는 사람만 이렇게 하면 된다)

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    if (bindingResult.hasErrors()) {
        log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
        return "validation/v2/addForm";
    }

    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.rejectValue("itemName", "required");
    }
    if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
        bindingResult.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
    }
    if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
        bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
    }

    // 특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10_000) {
            bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
        }
    }

    // 에러가 있는 경우, 다시 폼을 보여줌
    if (bindingResult.hasErrors()) {
        log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
        return "validation/v2/addForm";
    }

    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}

 

중간 정리를 하자면..

BindingResult를 통해 컨트롤러에서 들어온 데이터를 검증하고 검증 실패 시 어떤 필드에 어떤 에러가 있는지 잘 담아 다시 사용자에게 화면에 뿌려줄 수 있었다. 그리고 이 과정에서 에러 메시지를 유연하게 사용하는 방법을 배웠다. 

 

여기서 핵심은, 내가 rejectValue(item.getItemName(), "required") 라고 codes 부분에 가장 덜 디테일한 `required`를 적어도 스프링이 내부적으로 MessageCodesResolver를 사용해서 만들어내는 가장 디테일한 코드부터 가장 덜 디테일한 코드까지를 모두 가져다가 사용해서 가장 디테일한 코드부터 먼저 찾는순서로 에러 메시지를 찾아내기 때문에, 추후에 더 자세하거나 다른 에러 메시지가 필요해진 경우에 애플리케이션 코드를 변경할 필요 없이 그냥 에러 메시지만 추가해주면 된다는 것이다. 

 

근데, 한가지 불편한 점이 있다. 컨트롤러에 검증 로직이 대부분을 차지한다는 점. 즉, 역할과 책임이 너무 가중되어 있다. 이 부분을 해결하기 위해 Validator라는 인터페이스를 사용해서 검증 로직을 분리할 수 있다. 그 부분을 이제 알아보자.

 

Validator 인터페이스

우리는 이제 스프링이 제공하는 Validator라는 인터페이스를 구현한 구현체로 검증 로직을 분리하려고 한다.

무엇을 검증하느냐? Item을 검증한다. 그래서 이름을 ItemValidator라고 작성한 클래스를 만든다.

 

ItemValidator

package hello.itemservice.domain.item;

import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> aClass) {
        return Item.class.isAssignableFrom(aClass); // Item 클래스와 Item 클래스의 자식 클래스까지 검증 지원
    }

    @Override
    public void validate(Object o, Errors errors) {
        Item item = (Item) o;

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName", "required");
        }
        if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
            errors.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10_000) {
                errors.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
            }
        }
    }
}
  • Validator 인터페이스는 두 가지 클래스를 구현해야 한다.
    • supports(Class<?> aClass)
    • validate(Object o, Errors errors)
  • support() 메서드 안에는 어떤 클래스를 검증할 수 있는지를 작성한다. 그래서 우리는 Item이라는 객체를 검증할 것이기 때문에 Item.class.isAssignableFrom(aClass)라고 작성한다. 여기서 isAssignableFrom()은 넘겨받은 클래스와 그 클래스의 자식까지 지원한다. 
  • validate() 메서드 안에 기존에 검증 로직을 그대로 가져다가 복사-붙여넣기 하면 끝이다. 
  • 그리고 ItemValidator를 컴포넌트 스캔이 가능하도록 @Component 애노테이션을 사용한다.

그럼 이제 다시 컨트롤러로 돌아와서, 일단 이 ItemValidator를 주입받는다.

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.domain.item.ItemValidator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.ValidationUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

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

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

    ...

    @PostMapping("/add")
    public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        itemValidator.validate(item, bindingResult);

        if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
            log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
            return "validation/v2/addForm";
        }

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v2/editForm";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v2/items/{itemId}";
    }
}
  • 검증하는 부분에 주입받은 ItemValidatorvalidate()을 호출하면 끝이다. 
  • BindingResult의 부모가 Errors이기 때문에 validate(Class<?> aClass, Errors errors)의 두번째 파라미터로 넘기는데 아무런 문제가 없다.

그런데, 생각해보면 별로다. 뭐가 별로냐면 이걸 굳이 주입받고, 빈으로 등록하고, Validator 인터페이스를 구현하고 이럴 필요 없이 그냥 아래와 같은 클래스를 만든 다음,

package hello.itemservice.domain.item;

import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;

public class ItemValidator {
    
    public void validate(Object o, Errors errors) {
        Item item = (Item) o;

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName", "required");
        }
        if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
            errors.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10_000) {
                errors.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
            }
        }
    }
}

 

컨트롤러에서는 아래처럼 그냥 new로 객체 생성해서 validate() 메서드를 호출해도 될 것 같다. 맞다, 된다.

@PostMapping("/add")
public String addItemV5(@Validated @ModelAttribute Item item,
                        BindingResult bindingResult,
                        RedirectAttributes redirectAttributes) {

    new ItemValidator().validate(item, bindingResult);

    if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
        log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
        return "validation/v2/addForm";
    }

    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}

 

 

근데 굳이 굳이 이렇게 스프링이 만든 Validator 인터페이스를 구현하고 빈으로 등록한 다음 주입 받아 사용하는데는 이유가 있다. 더 편리하게 사용할 수 있기 때문이다.

 

@Validated, @InitBinder

스프링이 제공하는 @Validated 애노테이션으로 다음 코드도 없앨 수 있다.

itemValidator.validate(item, bindingResult);

 

다음 컨트롤러를 보자.

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.domain.item.ItemValidator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

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

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

    @InitBinder
    public void init(WebDataBinder binder) {
        binder.addValidators(itemValidator);
    }

    ...

    @PostMapping("/add")
    public String addItemV5(@Validated @ModelAttribute Item item,
                            BindingResult bindingResult,
                            RedirectAttributes redirectAttributes) {

        if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
            log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
            return "validation/v2/addForm";
        }

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v2/items/{itemId}";
    }
}
  • Validator 인터페이스를 구현한 구현체를 빈으로 등록하고 검증이 필요한 컨트롤러에서 주입을 받는다.
  • @InitBinder라는 애노테이션으로 이 컨트롤러가 호출될 때마다 검증할 Validator 구현체를 집어넣는다.
  • 여기까지 하면, 이 컨트롤러에 한하여 어떤 요청이 들어와도 ItemValidator를 통해 검증을 할 수 있게 된다. 그래서 "/add" 라는 pathPOST요청이 들어왔을 때 이 검증기를 실행시키고 싶으면, @Validated 애노테이션을 @ModelAttribute Item item 옆에 붙이면 끝난다. (addItemV5)

근데, 보다시피 @Validated 애노테이션만 붙이면 어떤 Validator가 사용될지 어떻게 알까? 보니까 binder.addValidators(Validator... validator)는 여러 Validator 구현체가 들어갈 수 있는데 말이다. 아래처럼 말이다.

binder.addValidators(itemValidator, categoryValidator, productValidator);

이때 바로 Validator 인터페이스의 메서드인 supports(Class<?> aClass)가 사용되는 것이다. 그래서 @Validated 애노테이션이 붙은 바로 옆 객체인 Itemsupports()에 넘어가게 된다. 그래서 이 메서드가 `true`를 반환하는 Validator를 선택하여 검증하게 된다.

 

 

글로벌 설정

참고로, 글로벌로 설정할 수도 있다. 지금은 현재 이 @InitBinder 애노테이션이 달린 컨트롤러에 한하여 검증이 가능하게 설정했지만 모든 컨트롤러에 다 적용할 수도 있다. 다만, 그럴일이 매우 드물지만 가능은 하다.

 

글로벌 설정 방법

아래와 같이 WebMvcConfigurer 인터페이스를 구현한 구현체를 만든다. 단순하게 하기 위해 여기서는 메인 클래스로 적용했다.

package hello.itemservice;

import hello.itemservice.domain.item.ItemValidator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.validation.Validator;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}

	@Override
	public Validator getValidator() {
            return new ItemValidator();
	}
}
  • 그리고 Validator를 반환하는 메서드를 만들고 원하는 Validator를 반환하면 된다.
  • 글로벌로 적용했으니 특정 컨트롤러에 @InitBinder는 제거해야 한다. (아래 코드처럼 주석 처리)
//private final ItemValidator itemValidator;

/*@InitBinder
public void init(WebDataBinder binder) {
    binder.addValidators(itemValidator);
}*/
  • 글로벌로 적용해도 어떤 컨트롤러를 호출할 때 검증할지 설정하는 @Validated 애노테이션은 당연히 넣어야 한다. (아래 코드처럼)
@PostMapping("/add")
public String addItemV5(@Validated @ModelAttribute Item item,
                        BindingResult bindingResult,
                        RedirectAttributes redirectAttributes) {

    if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
        log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
        return "validation/v2/addForm";
    }

    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}

 

여기까지가 글로벌 설정이다. 그런데, 글로벌로 설정하는 경우는 거의 없다. 그냥 필요한 컨트롤러에서 @InitBinder를 통해 사용하자.

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

참고자료:

 

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

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

www.inflearn.com

 

 

메시지

화면에 보이는 어떤 특정 단어를 변경해야 한다면 어떻게 할까? 예를 들어, 상품명이라는 단어를 모두 상품이름으로 변경하고자 하는 요구사항이 들어왔다면 말이다. 이 경우, 모든 파일을 다 뒤져서 상품명을 상품이름으로 바꿔야 할까?

 

매우 귀찮은 작업이 될 것이다. 이럴때 상품명이라는 단어를 한 곳에서 관리하도록 하고 관리하는 파일에서 상품명상품이름으로만 바꿔주면 모든 파일에 적용이 된다면 좋을것이다. 이게 메시지 기능이다. 

 

예를 들어, messages.properties 라는 파일에 메시지 관리용 파일을 만들고,

item=상품
item.id=상품 ID
item.itemName=상품명
item.price=가격
item.quantity=수량

 

각 HTML들은 다음과 같이 해당 데이터를 key값으로 불러서 사용하는 것이다.

`<label for="itemName" th:text="#{item.itemName}"></label>`

 

국제화

사용자의 주 언어에 따라 보여지는 서비스의 언어가 달라지게 하는건 웹 애플리케이션이라면 그냥 필수이다. 이 경우도 위 메시지처럼 비슷하게 언어별로 파일을 만들고 관리할 수 있다.

 

message_en.properties

item=Item
item.id=Item ID
item.itemName=Item Name
item.price=price
item.quantity=quantity

 

message_ko.properties

item=상품
item.id=상품 ID
item.itemName=상품명
item.price=가격
item.quantity=수량

 

이런식으로 파일을 분리해서 관리하고 영어를 사용하는 사용자라면 message_en.properties이 파일을 사용하고, 한국어를 사용하는 사용자라면 message_ko.properties이 파일을 사용하면 된다.

 

한국에서 접근한 것인지 영어권에서 접근한 것인지는 HTTP 헤더accept-language 값을 사용하거나, 사용자가 직접 언어를 선택한 것을 쿠키에 저장해서 서버와 통신해서 처리하면 된다. 그리고, 결론적으로 스프링은 기본적인 메시지와 국제화 기능을 모두 제공한다. 그래서 스프링을 사용해서 메시지와 국제화 기능을 알아보자.

 

스프링 메시지, 국제화 소스 설정

만약, 스프링 부트를 사용하지 않는다면 직접 빈을 등록해야 한다.

@Bean
public MessageSource messageSource() {
    ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
    messageSource.setBasenames("messages", "errors");
    messageSource.setDefaultEncoding("utf-8");
    return messageSource;
}
  • basenames: 설정 파일의 이름을 지정하는 것이다. 위 코드처럼 복수로 여러개를 지정할 수 있다. 저렇게 여러개로 지정하면 저 이름으로된 파일들을 찾고 그 파일들로 메시지 기능을 사용할 수 있다.
  • 파일 위치는 기본이 /resources/messages.properties 이다.

그렇지만, 이 빈을 스프링 부트는 자동으로 등록해준다. 스프링 부트를 사용한다면, 다음과 같이 메시지 소스를 설정할 수 있다.

 

application.yaml

spring:
  messages:
    basename: messages

참고로 위 값이 기본값이라 저렇게 작성할거면 작성 안해도 무방하다.

 

그래서 resources 안에 이렇게 파일 두 개를 만들어보자.

 

messages.properties

hello=안녕
hello.name=안녕 {0}

 

messages_en.properties

hello=hello
hello.name=hello {0}

 

위처럼 작성하면 기본값을 한국어로 설정한 것이다. 만약, messages_en.properties, messages_es.properties, messages.properties 이렇게 파일이 있을 때, 한국어로 설정된 언어 사용자가 진입하면 최초에 _ko를 찾는데 그게 없으면 기본값인 그냥 messages.properties가 적용된다. 

 

스프링 메시지, 국제화 소스 사용

이제 이 메시지 기능을 사용해보자. 간단하게 테스트 코드로 작성해서 확인을 해보자.

 

`test` 폴더 하위에 테스트 파일 하나를 만들자.

@SpringBootTest
public class MessageSourceTest {

    @Autowired
    MessageSource messageSource;
    
}
  • 그리고 우선 스프링 부트가 자동으로 빈으로 등록해준 MessageSource를 주입받는다.
@Test
void helloMessage() {
    String hello = messageSource.getMessage("hello", null, Locale.US);
    assertThat(hello).isEqualTo("hello");
}
  • getMessage()를 호출해서 원하는 키를 가져온다. 여기서는 "hello"를 가져온다. 두번째 파라미터는 arguments이다. 이건 이후에 알아본다. 세번째 파라미터는 Locale 값이다. US로 지정해보자.
  • 그리고 가져온 값이 "hello"와 일치하는지 테스트한다.
@Test
void notFoundMessage() {
    assertThatThrownBy(() -> messageSource.getMessage("no", null, Locale.US)).isInstanceOf(NoSuchMessageException.class);
}
  • 이번엔 없는 키를 가져오는 경우 NoSuchMessageException을 받는다. 그것을 테스트한다.
@Test
void defaultMessage() {
    String defaultMessage = messageSource.getMessage("no", null, "default Message", Locale.US);
    assertThat(defaultMessage).isEqualTo("default Message");
}
  • 값이 없는 경우, 기본값을 설정해서 예외를 피할수 있다. 여기서는 "default Message" 라고 기본값을 설정했다.
  • 가져온 값이 "default Message"와 일치한지 확인한다.
@Test
void argsMessage() {
    String message = messageSource.getMessage("hello.name", new Object[]{"Spring"}, Locale.US);
    assertThat(message).isEqualTo("hello Spring");
}
  • 이번에는 arguments를 사용해본다. 위에서 `hello.name=hello {0}` 이렇게 messages 파일을 작성한 것이 있다.
  • 이 `{0}`에 넘겨진 배열의 0번 인덱스인 "Spring"을집어넣는 것이다.
  • 반환값이 "hello Spring"인지 확인한다.
@Test
void localizedMessage() {
    String enMessage = messageSource.getMessage("hello", null, Locale.US);
    assertThat(enMessage).isEqualTo("hello");

    String koMessage = messageSource.getMessage("hello", null, Locale.KOREAN);
    assertThat(koMessage).isEqualTo("안녕");
}
  • 이번엔 국제화 기능을 사용해본다. Locale.US로 넘긴 경우 messages_en.properties 파일을 찾을것이다. 
  • Locale.KOREAN을 넘긴 경우 messages_ko.properties 파일을 찾을것이다.

 

이렇게 스프링의 메시지, 국제화 기능을 코드로써 편리하게 사용할 수 있다.

 

웹 애플리케이션에 메시지 적용하기

만약, 웹 애플리케이션 개발을 할 때 스프링 부트 + 타임리프를 사용한다면 타임리프에서 스프링의 메시지, 국제화 기능을 바로 적용할 수 있다. 

참고로, 앞단을 리액트와 같이 백엔드와 분리하여 개발한다면 앞단에서 처리를 하면 된다.

 

우선, messages.properties 파일을 이렇게 적용한다.

label.item=상품
label.item.id=상품 ID
label.item.itemName=상품명
label.item.price=가격
label.item.quantity=수량

page.items=상품 목록
page.item=상품 상세
page.addItem=상품 등록
page.updateItem=상품 수정

button.save=저장
button.cancel=취소

 

그리고 타임리프에서 이 값을 그대로 가져다가 사용하면 된다. 아래처럼.

<h2 th:text="#{page.updateItem}">상품 수정 폼</h2>

 

 

만약, 파라미터를 줘야하는 `hello.name=hello {0}` 이런 경우는 타임리프에서 이렇게 사용할 수 있다.

`<p th:text="#{hello.name(${item.itemName})}"></p>`

 

웹 애플리케이션에 국제화 적용하기

우선, messages_en.properties 파일을 이렇게 수정하자.

label.item=Item
label.item.id=Item ID
label.item.itemName=Item Name
label.item.price=price
label.item.quantity=quantity

page.items=Item List
page.item=Item Detail
page.addItem=Item Add
page.updateItem=Item Update

button.save=Save
button.cancel=Cancel

 

그럼 끝이다. 왜냐? 메시지 적용하면서 타임리프 템플릿에 이미 필요한 부분에 다음과 같이 적용을 해놨다.

<h2 th:text="#{page.updateItem}">상품 수정 폼</h2>

 

그래서, 사용자의 브라우저가 어떤 언어를 쓰냐에 따라 알아서 국제화가 적용될 것이다.

그럼 테스트해보자. 일단 브라우저 언어를 바꿔보자.

 

다시 들어가서 보면 이렇게 영어로 잘 나오게 된다.

 

LocaleResolver

이걸 살짝만 깊게 들어가보면, 스프링도 결국 Locale 정보를 알아야 언어를 선택할 수 있을 것이다. 그래서 스프링은 언어 선택시 기본으로 Accept-Language의 헤더값을 사용하는데 이게 스프링 부트가 LocaleResolver 인터페이스의 구현체를 기본으로 AcceptHeaderLocaleResolver를 사용하기 때문이다.

 

LocaleResolver 인터페이스

public interface LocaleResolver {
    Locale resolveLocale(HttpServletRequest var1);

    void setLocale(HttpServletRequest var1, @Nullable HttpServletResponse var2, @Nullable Locale var3);
}

 

AcceptHeaderLocaleResolver

package org.springframework.web.servlet.i18n;

import java.util.ArrayList;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.LocaleResolver;

public class AcceptHeaderLocaleResolver implements LocaleResolver {
    private final List<Locale> supportedLocales = new ArrayList(4);
    @Nullable
    private Locale defaultLocale;

    public AcceptHeaderLocaleResolver() {
    }

    public void setSupportedLocales(List<Locale> locales) {
        this.supportedLocales.clear();
        this.supportedLocales.addAll(locales);
    }

    public List<Locale> getSupportedLocales() {
        return this.supportedLocales;
    }

    public void setDefaultLocale(@Nullable Locale defaultLocale) {
        this.defaultLocale = defaultLocale;
    }

    @Nullable
    public Locale getDefaultLocale() {
        return this.defaultLocale;
    }

    public Locale resolveLocale(HttpServletRequest request) {
        Locale defaultLocale = this.getDefaultLocale();
        if (defaultLocale != null && request.getHeader("Accept-Language") == null) {
            return defaultLocale;
        } else {
            Locale requestLocale = request.getLocale();
            List<Locale> supportedLocales = this.getSupportedLocales();
            if (!supportedLocales.isEmpty() && !supportedLocales.contains(requestLocale)) {
                Locale supportedLocale = this.findSupportedLocale(request, supportedLocales);
                if (supportedLocale != null) {
                    return supportedLocale;
                } else {
                    return defaultLocale != null ? defaultLocale : requestLocale;
                }
            } else {
                return requestLocale;
            }
        }
    }

    @Nullable
    private Locale findSupportedLocale(HttpServletRequest request, List<Locale> supportedLocales) {
        Enumeration<Locale> requestLocales = request.getLocales();
        Locale languageMatch = null;

        Locale locale;
        label38:
        do {
            while(requestLocales.hasMoreElements()) {
                locale = (Locale)requestLocales.nextElement();
                if (supportedLocales.contains(locale)) {
                    continue label38;
                }

                if (languageMatch == null) {
                    Iterator var6 = supportedLocales.iterator();

                    while(var6.hasNext()) {
                        Locale candidate = (Locale)var6.next();
                        if (!StringUtils.hasLength(candidate.getCountry()) && candidate.getLanguage().equals(locale.getLanguage())) {
                            languageMatch = candidate;
                            break;
                        }
                    }
                }
            }

            return languageMatch;
        } while(languageMatch != null && !languageMatch.getLanguage().equals(locale.getLanguage()));

        return locale;
    }

    public void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale) {
        throw new UnsupportedOperationException("Cannot change HTTP accept header - use a different locale resolution strategy");
    }
}

 

그 말은 우리가 LocaleResolver 구현체를 다른것으로 변경한다면, 얼마든지 Locale 정보를 다른 방식으로 알아올 수 있다. 가장 대표적인 예시가 잘 만든 웹 사이트를 보면 사이트 자체적으로 언어를 선택할 수 있는 사이트가 있다. 이렇게 사용자가 브라우저 단위가 아니라 웹 애플리케이션 단위로 언어를 선택했을 때 그 값을 쿠키같은 곳에 저장하고 서버와 통신하게 하고 Locale 정보를 내가 직접 만든 LocaleResolver 구현체를 쿠키에서 가져와서 Locale값을 알아내는 구현체를 만들면 된다.

 

그리고? 그 구현체를 빈으로 등록하면 스프링은 내가 만든 구현체로 변경해줄것이다. 이게 바로 스프링의 위대함이다. 얼마든지 구현체만 갈아끼우면 된다는 것. 이게 바로 DI의 핵심이다. 하나의 인터페이스에 여러 구현체. 의존관계 설정을 나중으로 미루는 것. 확장에는 열려있고 변경에는 닫혀있는 OCP 법칙.

 

아무튼, 이렇게 스프링, 스프링 부트를 이용해서 메시지, 국제화 기능을 알아보았다!

728x90
반응형
LIST

+ Recent posts