검색결과 리스트
springboot에 해당되는 글 13건
- 2022.07.20 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 19
- 2022.07.19 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 18
- 2022.07.16 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 17
글
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 19
인프런 강의 57일차.
- 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 1 (김영한 강사님)
- 1편에서 배운 MVC를 활용할 수 있는 기술 습득
- 타입 컨버터, 파일 업로드, 활용, 쿠키, 세션, 필터, 인터셉터, 예외 처리, 타임리프, 메시지, 국제화, 검증 등등
10.1 스프링 타입 컨버터 - 프로젝트 생성
- 프로젝트 선택 Project : Gradle Project
- Language : Java
- Spring Boot : 2.7.0
- Group : hello
- Artifact : typeconverter
- Name : typeconverter
- Package name : hello.typeconverter
- Packaging : Jar
- Java : 11
- Dependencies : Spring Web, Thymeleaf, Lombok
* gradle.build 실행 후 아래 오류 발생 시 gradle-wrapper.properties에서 gradle 버전을 6.9 이하로 변경해서 다운로드하자. (gradle-6.9-all.zip)
> Unable to find method 'org.gradle.api.artifacts.result.ComponentSelectionReason.getDescription()Ljava/lang/String;'. Possible causes for this unexpected error include: Gradle's dependency cache may be corrupt (this sometimes occurs after a network connection timeout.) Re-download dependencies and sync project (requires network)
10.2 스프링 타입 컨버터 - 스프링 타입 컨버터 소개
- 문자를 숫자로 변환하거나, 반대로 숫자를 문자로 변환해야 하는 것 처럼 애플리케이션을 개발하다 보면 타입을 변환해야 하는 경우가 상당히 많다
package hello.typeconverter.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@RestController
public class HelloController {
@GetMapping("/hello-v1")
public String helloV1(HttpServletRequest request) {
String data = request.getParameter("data"); //문자 타입 조회
Integer intValue = Integer.valueOf(data); //숫자 타입으로 변경
System.out.println("intValue = " + intValue);
return "ok";
}
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
}
- hello.typeconverter.controller.HelloController.java
- HttpRequest.getParameter는 전부 String으로 처리된다.
- 그러므로 다른 타입으로 사용하고 싶으면 전부 변환하는 과정을 거쳐야 한다.
- 스프링에서 제공하는 @RequestParam Integer를 사용하게되면 스프링에서 자동으로 타입 변환까지 처리해준다.
> 스프링이 중간에서 타입을 변환해주었기 때문이다.
- 이러한 예는 @ModelAttribute, @PathVariable 에서도 확인할 수 있다
@ModelAttribute UserData data
class UserData {
Integer data;
}
- @RequestParam 와 같이, 문자 data=10 을 숫자 10으로 받을 수 있다.
/users/{userId}
@PathVariable("userId") Integer data
- URL 경로는 문자다.
- /users/10 : 여기서 10도 숫자 10이 아니라 그냥 문자 "10"이다.
- data를 Integer 타입으로 받을 수 있는 것도 스프링이 타입 변환을 해주기 때문이다.
* 스프링의 타입 변환 적용 예
- 스프링 MVC 요청 파라미터
> @RequestParam
> @ModelAttribute
> @PathVariable
- @Value 등으로 YML 정보 읽기
- XML에 넣은 스프링 빈 정보를 변환
- 뷰를 렌더링 할 때
* 스프링과 타입 변환
- 이렇게 타입을 변환해야 하는 경우는 상당히 많다. 개발자가 직접 하나하나 타입 변환을 해야 한다면, 생각만 해도 괴로울 것이다.
- 스프링이 중간에 타입 변환기를 사용해서 타입을 String Integer 로 변환해주었기 때문에 개발자는 편리하게 해당 타입을 바로 받을 수 있다.
- 만약 개발자가 새로운 타입을 만들어서 변환하고 싶으면 어떻게 하면 될까?
* 컨버터 인터페이스
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source);
}
- 스프링은 확장 가능한 컨버터 인터페이스를 제공한다.
- 개발자는 스프링에 추가적인 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해서 등록하면 된다.
- 이 컨버터 인터페이스는 모든 타입에 적용할 수 있다.
- 필요하면 X->Y 타입으로 변환하는 컨버터 인터페이스를 만들고, 또 Y->X 타입으로 변환하는 컨버터 인터페이스를 만들어서 등록하면 된다.
- 예를 들어서 문자로 "true" 가 오면 Boolean 타입으로 받고 싶으면 String Boolean 타입으로 변환되도록 컨버터 인터페이스를 만들어서 등록하고, 반대로 적용하고 싶으면 Boolean String 타입으로 변환되도록 컨버터를 추가로 만들어서 등록하면 된다.
10.3 스프링 타입 컨버터 - Converter
- 타입 컨버터를 어떻게 사용하는지 코드로 알아보자.
- 타입 컨버터를 사용하려면 org.springframework.core.convert.converter.Converter 인터페이스를 구현하면 된다
> Converter 이름을 가진 인터페이스가 많으니 스프링 Converter를 사용해야한다.
package hello.typeconverter.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);
}
}
- hello.typeconverter.converter.StringToIntegerConverter.java
- String 을 Integer로 변환하는 타입 컨버터 생성
package hello.typeconverter.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);
}
}
- hello.typeconverter.converter.IntegerToStringConverter.java
- Integer를 String 으로 변환하는 타입 컨버터 생성
* Convert를 테스트하기 위한 ConvertTest 생성
package hello.typeconverter.converter;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
public class ConverterTest {
@Test
void stringToInteger() {
StringToIntegerConverter converter = new StringToIntegerConverter();
Integer result = converter.convert("10");
assertThat(result).isEqualTo(10);
}
@Test
void integerToString() {
IntegerToStringConverter converter = new IntegerToStringConverter();
String result = converter.convert(10);
assertThat(result).isEqualTo("10");
}
}
- src\test\java\hello\typeconverter\converter\ConverterTest.java
* 사용자 정의 타입 컨버터
- 타입 컨버터 이해를 돕기 위해 조금 다른 컨버터를 준비해보았다.
- 127.0.0.1:8080 과 같은 IP, PORT를 입력하면 IpPort 객체로 변환하는 컨버터를 만들어보자
package hello.typeconverter.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;
}
}
- hello.typeconverter.type.IpPort.java
- 롬복의 @EqualsAndHashCode 를 넣으면 모든 필드를 사용해서 equals() , hashcode() 를 생성한다.
> 따라서 모든 필드의 값이 같다면 a.equals(b) 의 결과가 참이 된다.
package hello.typeconverter.converter;
import hello.typeconverter.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);
//input source type : "127.0.0.1:8080" -> IpPort 객체
String[] split = source.split(":");
String ip = split[0];
int port = Integer.parseInt(split[1]);
return new IpPort(ip, port);
}
}
- hello.typeconverter.converter.StringToIpPortConverter.java
- 127.0.0.1:8080 같은 문자를 입력하면 IpPort 객체를 만들어 반환한다.
package hello.typeconverter.converter;
import hello.typeconverter.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);
//IpPort 객체 -> "127.0.0.1:8080"
return source.getIp() + ":" + source.getPort();
}
}
- hello.typeconverter.converter.IpPortToStringConverter.java
- IpPort 객체를 입력하면 127.0.0.1:8080 같은 문자를 반환한다
* ConvertTest 테스트케이스 추가
@Test
void stringToipPort() {
StringToIpPortConverter converter = new StringToIpPortConverter();
String ipPort = "127.0.0.1:8080";
IpPort result = converter.convert(ipPort);
assertThat(result).isEqualTo(new IpPort("127.0.0.1", 8080)); //@EqualsAndHashCode가 선언되어 있기 때문에 isEqualsTo가 동작한다.
}
@Test
void ipPortToString() {
IpPortToStringConverter converter = new IpPortToStringConverter();
IpPort source = new IpPort("127.0.0.1", 8080);
String result = converter.convert(source);
assertThat(result).isEqualTo("127.0.0.1:8080");
}
- 이렇게 타입 컨버터를 하나하나 직접 사용하면, 개발자가 직접 컨버팅 하는 것과 큰 차이가 없다.
- 타입 컨버터를 등록하고 관리하면서 편리하게 변환 기능을 제공하는 역할을 하는 무언가가 필요하다.
* 스프링은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다.
- Converter : 기본 타입 컨버터
- ConverterFactory : 전체 클래스 계층 구조가 필요할 때
- GenericConverter : 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
- ConditionalGenericConverter : 특정 조건이 참인 경우에만 실행
- 스프링은 문자, 숫자, 불린, Enum등 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공한다.
- IDE에서 Converter , ConverterFactory , GenericConverter 의 구현체를 찾아보면 수 많은 컨버터를 확인할 수 있다
10.4 스프링 타입 컨버터 - 컨버전 서비스 - 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);
<T> T convert(@Nullable Object source, Class<T> targetType);
Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
}
- ConversionService Interface
- 컨버전 서비스 인터페이스는 컨버팅이 가능한지 확인하는 canConvert와 실제 컨버팅하는 convert 메소드를 제공한다
package hello.typeconverter.converter;
import hello.typeconverter.type.IpPort;
import org.assertj.core.api.Assertions;
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 conversionService = new DefaultConversionService();
conversionService.addConverter(new StringToIntegerConverter()); //컨버터 등록
conversionService.addConverter(new IntegerToStringConverter()); //컨버터 등록
conversionService.addConverter(new StringToIpPortConverter()); //컨버터 등록
conversionService.addConverter(new IpPortToStringConverter()); //컨버터 등록
//사용
Integer result = conversionService.convert("10", Integer.class); //StringToIntegerConverter 가 동작함
System.out.println("result = " + result);
assertThat(result).isEqualTo(10);
assertThat(conversionService.convert(10, String.class)).isEqualTo("10"); //IntegerToStringConverter 가 동작함
IpPort ipPortResult = conversionService.convert("127.0.0.1L8080", IpPort.class);
assertThat(ipPortResult).isEqualTo(new IpPort("127.0.0.1", 8080));
assertThat(conversionService.convert(new IpPort("127.0.0.1", 8080), String.class)).isEqualTo("127.0.0.1:8080");
}
}
- hello.typeconverter.converter.ConversionServiceTest.java
- conversionService 테스트 코드 구현
- DefaultConversionService 는 ConversionService 인터페이스를 구현했는데, 추가로 컨버터를 등록하는 기능도 제공한다.
* 등록과 사용 분리
- 컨버터를 등록할 때는 StringToIntegerConverter 같은 타입 컨버터를 명확하게 알아야 한다.
- 반면에 컨버터를 사용하는 입장에서는 타입 컨버터를 전혀 몰라도 된다.
- 타입 컨버터들은 모두 컨버전 서비스 내부에 숨어서 제공된다.
- 따라서 타입을 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다.
- 물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다.
* 컨버전 서비스 사용 방법
- Integer value = conversionService.convert("10", Integer.class)
* 인터페이스 분리 원칙
- ISP(Interface Segregation Principal) : 인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다
- 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다.
- 특히 컨버터를 사용하는 클라이언트는 ConversionService 만 의존하면 되므로, 컨버터를 어떻게 등록하고 관리하는지는 전혀 몰라도 된다.
- 결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게된다.
> 이렇게 인터페이스를 분리하는 것을 ISP 라 한다.
* DefaultConversionService 는 두개의 인터페이스를 구현했다.
- ConversionService : 컨버터 사용에 초점
- ConverterRegistry : 컨버터 등록에 초점
* 스프링은 내부에서 ConversionService 를 사용해서 타입을 변환한다.
- 예를 들어서 앞서 살펴본 @RequestParam 같은 곳에서 이 기능을 사용해서 타입을 변환한다.
- 이제 컨버전 서비스를 스프링에 적용해보자.
10.5 스프링 타입 컨버터 - 스프링에 Converter 적용하기
- 애플리케이션에 Converter 를 적용해보자.
package hello.typeconverter;
import hello.typeconverter.converter.IntegerToStringConverter;
import hello.typeconverter.converter.IpPortToStringConverter;
import hello.typeconverter.converter.StringToIntegerConverter;
import hello.typeconverter.converter.StringToIpPortConverter;
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) {
//formatter는 converter의 좀 더 확장된 버전이다.
registry.addConverter(new StringToIntegerConverter());
registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
}
}
- hello.typeconverter.WebConfig.java
- WebConfig에 컨버터 등록
- 스프링은 내부에서 ConversionService 를 제공한다.
- 우리는 WebMvcConfigurer 가 제공하는 addFormatters() 를 사용해서 추가하고 싶은 컨버터를 등록하면 된다.
> 이렇게 하면 스프링은 내부에서 사용하는 ConversionService 에 컨버터를 추가해준다.
* 실행
import javax.servlet.http.HttpServletRequest;
@RestController
public class HelloController {
...
@GetMapping("/ip-port")
public String ipPort(@RequestParam IpPort ipPort) {
System.out.println("ipPort.class "+ipPort.getIp());
System.out.println("ipPort.class = "+ipPort.getPort());
return "ok";
}
...
}
- hello.typeconverter.controller.HelloController.java
- 직접 정의한 타입인 IpPort를 컨버터로 변환해보자
- 실행 url : http://localhost:8080/ip-port?ipPort=127.0.0.1:8080
StringToIpPortConverter : convert source=127.0.0.1:8080
ipPort IP = 127.0.0.1
ipPort PORT = 8080
- 실행 로그
- ?ipPort=127.0.0.1:8080 쿼리 스트링이 @RequestParam IpPort ipPort 에서 객체 타입으로 잘 변환 된 것을 확인할 수 있다
* 처리 과정
- @RequestParam 은 @RequestParam 을 처리하는 ArgumentResolver 인 RequestParamMethodArgumentResolver 에서 ConversionService 를 사용해서 타입을 변환한다.
- 부모 클래스와 다양한 외부 클래스를 호출하는 등 복잡한 내부 과정을 거치기 때문에 대략 이렇게 처리되는 것으로 이해해도 충분하다.
10.6 스프링 타입 컨버터 - 뷰 템플릿에 컨버터 적용하기
- 뷰 템플릿에 컨버터를 적용하는 방법을 알아보자.
- 타임리프는 렌더링 시에 컨버터를 적용해서 렌더링 하는 방법을 편리하게 지원한다.
- 이전까지는 문자를 객체로 변환했다면, 이번에는 그 반대로 객체를 문자로 변환하는 작업을 확인할 수 있다
package hello.typeconverter.controller;
import hello.typeconverter.type.IpPort;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ConverterController {
@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";
}
}
- hello.typeconverter.controller.ConverterController.java
- Model 에 숫자 10000 와 ipPort 객체를 담아서 뷰 템플릿에 전달한다.
<!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>
- templates/converter-view.html
- 타임리프는 ${{...}} 를 사용하면 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해준다.
> 스프링과 통합 되어서 스프링이 제공하는 컨버전 서비스를 사용하므로, 우리가 등록한 컨버터들을 사용할 수 있다.
- 변수 표현식 : ${...}
- 컨버전 서비스 적용 : ${{...}} ( 중괄호가 2개이다 )
* 실행 결과
• ${number}: 10000
• ${{number}}: 10000
• ${ipPort}: hello.typeconverter.type.IpPort@59cb0946
• ${{ipPort}}: 127.0.0.1:8080
- 타임리프 뷰 템플릿은 데이터를 문자로 출력한다.
* 실행 결과 로그
IntegerToStringConverter : convert source=10000
IpPortToStringConverter : convert
source=hello.typeconverter.type.IpPort@59cb0946
- ${number} : 단순 문자 출력
- ${{number}} : IntegerToStringConverter 컨버터가 적용되어 Integer 타입인 10000 을 String 타입으로 변환해서 실행
> 이 부분은 컨버터를 실행하지 않아도 타임리프가 숫자를 문자로 자동으로 변환히기 때문에 컨버터를 적용할 때와 하지 않을 때가 같다.
- ${ipPort} : 뷰 템플릿은 데이터를 문자로 출력하기 때문에 객체는 toString이 적용되어 표시된다.
- ${{ipPort}} : 컨버터를 적용하게 되면 IpPort 타입을 String 타입으로 변환해야 하므로 IpPortToStringConverter 가 적용된다.
> 127.0.0.1:8080 가 출력된다.
* form에 컨버터 적용하기
package hello.typeconverter.controller;
import hello.typeconverter.type.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 ConverterController {
@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";
}
@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 converterEdit(@ModelAttribute 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;
}
}
}
- hello.typeconverter.controller.ConverterController.java
- 'Form' 객체를 데이터를 전달하는 폼 객체로 사용한다
- GET /converter/edit : IpPort 를 뷰 템플릿 폼에 출력한다.
- POST /converter/edit : 뷰 템플릿 폼의 IpPort 정보를 받아서 출력한다
<!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:value <input type="text" th:value="*{ipPort}">(보여주기 용도)<br/>
<input type="submit"/>
</form>
</body>
</html>
- templates/converter-form.html
- 타임리프의 th:field 는 id , name 를 출력하는 등 다양한 기능이 있는데, 여기에 컨버전 서비스도 함께 적용된다.
- 컨버전을 사용하고 싶으면 th:field, 컨버전을 하지 않으려면 th:value를 쓰면 된다.
- GET /converter/edit
> th:field 가 자동으로 컨버전 서비스를 적용해주어서 ${{ipPort}} 처럼 적용이 되었다.
> 따라서 IpPort String 으로 변환된다.
- POST /converter/edit
> @ModelAttribute 를 사용해서 String IpPort 로 변환된다
> form.submit 할 경우 submit 당시에는 문자가 넘어간다(th:field가 변환된 HTML 코드 value의 값은 문자 "127.0.0.1"임)
> submit 후 Controller에서 @ModelAttribute로 정의된 Form 을 변환하려고 봤더니 내부에 IpPort 객체가 존재하여 문자를 ipPort 객체로 변환하는 작업이 수행이 된다.
> 즉 StringToIpPortConverter가 호출이 된다. (ModelAttribute가 내부적으로 컨버터를 사용함을 알 수 있다)
10.7 스프링 타입 컨버터 - 포맷터 - Formatter
- Converter 는 입력과 출력 타입에 제한이 없는, 범용 타입 변환 기능을 제공한다.
- 이번에는 일반적인 웹 애플리케이션 환경을 생각해보자.
- boolean 타입을 숫자로 바꾸는 것 같은 범용 기능 보다 개발자 입장에서는 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 상황이 대부분이다.
- 앞서 살펴본 예제들을 떠올려 보면 문자를 다른 객체로 변환하거나 객체를 문자로 변환하는 일이 대부분이다
* 웹 애플리케이션에서 객체를 문자로, 문자를 객체로 변환하는 예
- 화면에 숫자를 출력해야 하는데, Integer String 출력 시점에 숫자 1000 문자 "1,000" 이렇게 1000 단위에 쉼표를 넣어서 출력하거나, 또는 "1,000" 라는 문자를 1000 이라는 숫자로 변경해야 한다.
- 날짜 객체를 문자인 "2021-01-01 10:50:11" 와 같이 출력하거나 또는 그 반대의 상황
> 날짜 정보는 Local 정보를 활용하여 현지화 정보가 사용될 수 있다.
- 이렇게 객체를 특정한 포맷에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능이 바로 포맷터( Formatter )이다. 포맷터는 컨버터의 특별한 버전으로 이해하면 된다
* Converter vs Formatter
- Converter 는 범용(객체 -> 객체)
- Formatter 는 문자에 특화(객체 -> 문자, 문자 -> 객체) + 현지화(Locale)
> Converter 의 특별한 버전
* 포맷터 - Formatter 만들기
- 포맷터( Formatter )는 객체를 문자로 변경하고, 문자를 객체로 변경하는 두 가지 기능을 모두 수행한다.
- String print(T object, Locale locale) : 객체를 문자로 변경한다.
- T parse(String text, Locale locale) : 문자를 객체로 변경한다.
package hello.typeconverter.formatter;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import java.text.ParseException;
import java.util.Locale;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
class MyNumberFormatterTest {
MyNumberFormatter formatter = new MyNumberFormatter();
@Test
void parse() throws ParseException {
Number number = formatter.parse("1,000", Locale.KOREA);
assertThat(number).isEqualTo(1000L); //Long 타입이므로 주의
}
@Test
void print() {
String print = formatter.print(1000, Locale.KOREA);
assertThat(print).isEqualTo("1,000");
}
}
- hello.typeconverter.formatter.MyNumberFormatterTest.java
- "1,000" 처럼 숫자 중간의 쉼표를 적용하려면 자바가 기본으로 제공하는 NumberFormat 객체를 사용하면 된다.
- 이 객체는 Locale 정보를 활용해서 나라별로 다른 숫자 포맷을 만들어준다
- parse() 를 사용해서 문자를 숫자로 변환한다.
> 참고로 Number 타입은 Integer , Long 과 같은 숫자 타입의 부모 클래스이다.
- print() 를 사용해서 객체를 문자로 변환한다
package hello.typeconverter.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> { //String을 제외한 변환할 객체를 Formatter<> 로 implements 하면 된다.
@Override
public Number parse(String text, Locale locale) throws ParseException {
//text : 1,000
//1,000 -> 1000으로 변환시키는 메소드 parse
log.info("text = {}, locale = {}", text, locale);
NumberFormat format = NumberFormat.getInstance(locale);
Number number = format.parse(text); //format.parse 시 1,000 -> 1000으로 변환된다.
return number;
}
@Override
public String print(Number object, Locale locale) {
log.info("object = {}, local = {}", object, locale);
NumberFormat instance = NumberFormat.getInstance(locale);
String format = instance.format(object); //format 메소드 호출 시 String 으로 변환된 값이 나온다.
return format;
}
}
- hello.typeconverter.formatter.MyNumberFormatter.java
- parse() 의 결과가 Long 이기 때문에 isEqualTo(1000L) 을 통해 비교할 때 마지막에 L 을 넣어주어야 한다.
* 실행 결과
MyNumberFormatter - text=1,000, locale=ko_KR
MyNumberFormatter - object=1000, locale=ko_KR
* 참고
- 스프링은 용도에 따라 다양한 방식의 포맷터를 제공한다.
> Formatter : 포맷터
> AnnotationFormatterFactory : 필드의 타입이나 애노테이션 정보를 활용할 수 있는 포맷터
10.8 스프링 타입 컨버터 - 포맷터를 지원하는 컨버전 서비스
- 컨버전 서비스에는 컨버터만 등록할 수 있고, 포맷터를 등록할 수 는 없다.
> 그런데 생각해보면 포맷터는 객체 -> 문자, 문자 -> 객체로 변환하는 특별한 컨버터일 뿐이다.
- 포맷터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다.
- 내부에서 어댑터 패턴을 사용해서 Formatter 가 Converter 처럼 동작하도록 지원한다
> FormattingConversionService 는 포맷터를 지원하는 컨버전 서비스이다.
> DefaultFormattingConversionService 는 FormattingConversionService 에 기본적인 통화, 숫자 관련 몇가지 기본 포맷터를 추가해서 제공한다.
package hello.typeconverter.formatter;
import hello.typeconverter.converter.IpPortToStringConverter;
import hello.typeconverter.converter.StringToIpPortConverter;
import hello.typeconverter.type.IpPort;
import org.assertj.core.api.Assertions;
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();
//컨버터 등록
//FormattingConversionService가 ConversionService, ConversionRegistry를 구현하고 있기 때문에 컨버터 등록이 가능하다.
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", Long.class)).isEqualTo(1000L);
}
}
- hello.typeconverter.formatter.FormattingConversionServiceTest.java
- formattingConversionService 테스트 생성
* DefaultFormattingConversionService 상속 관계
- FormattingConversionService 는 ConversionService 관련 기능을 상속받기 때문에 결과적으로 컨버터도 포맷터도 모두 등록할 수 있다.
- 그리고 사용할 때는 ConversionService 가 제공하는 convert 메소드를 사용하면 된다.
- 추가로, 스프링 부트는 DefaultFormattingConversionService 를 상속 받은 WebConversionService 를 내부에서 사용한다.
10.9 스프링 타입 컨버터 - 포맷터 적용하기
- 포맷터를 웹 애플리케이션에 적용해보자.
package hello.typeconverter;
import hello.typeconverter.converter.IntegerToStringConverter;
import hello.typeconverter.converter.IpPortToStringConverter;
import hello.typeconverter.converter.StringToIntegerConverter;
import hello.typeconverter.converter.StringToIpPortConverter;
import hello.typeconverter.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) {
//formatter는 converter의 좀 더 확장된 버전이다.
//StringToIntegerConverter, IntegerToStringConverter 는 MyNumberFormatter와 동작(숫자를 문자로, 문자를 숫자로 변경)이 동일하다.
//우선순위 때문에 MyNumberFormatter보다 먼저 호출되기 때문에 현재 테스트에서는 주석처리한다.
// registry.addConverter(new StringToIntegerConverter());
// registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
//추가
registry.addFormatter(new MyNumberFormatter());
}
}
- hello.typeconverter.WebConfig.java
- StringToIntegerConverter , IntegerToStringConverter 를 꼭 주석처리 하자
> MyNumberFormatter 도 숫자 문자, 문자 숫자로 변경하기 때문에 둘의 기능이 겹친다.
> 우선순위는 컨버터가 우선하므로 포맷터가 적용되지 않고, 컨버터가 적용된다
* 실행 결과
• ${number}: 10000
• ${{number}}: 10,000
- 호출 URL : http://localhost:8080/converter-view
- 컨버전 서비스를 적용한 결과 MyNumberFormatter 가 적용되어서 10,000 문자가 출력된 것을 확인할 수 있다.
* 실행 로그
MyNumberFormatter : text=10,000, locale=ko_KR
data = 10000
- 호출 URL : http://localhost:8080/hello-v2?data=10,000
- "10,000" 이라는 포맷팅 된 문자가 Integer 타입의 숫자 10000으로 정상 변환 된 것을 확인할 수 있다.
10.10 스프링 타입 컨버터 - 스프링이 제공하는 기본 포맷터
- 스프링은 자바에서 기본으로 제공하는 타입들에 대해 수 많은 포맷터를 기본으로 제공한다.
- IDE에서 Formatter 인터페이스의 구현 클래스를 찾아보면 수 많은 날짜나 시간 관련 포맷터가 제공되는 것을 확인할 수 있다.
- 그런데 포맷터는 기본 형식이 지정되어 있기 때문에, 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다.
- 스프링은 이런 문제를 해결하기 위해 애노테이션 기반으로 원하는 형식을 지정해서 사용할 수 있는 매우 유용한 포맷터 두 가지를 기본으로 제공한다
> @NumberFormat : 숫자 관련 형식 지정 포맷터 사용, NumberFormatAnnotationFormatterFactory
> @DateTimeFormat : 날짜 관련 형식 지정 포맷터 사용, Jsr310DateTimeFormatAnnotationFormatterFactory
package hello.typeconverter.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;
}
}
- hello.typeconverter.controller.FormatterController.java
<!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>
- templates/formatter-form.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>
- templates/formatter-view.html
* 실행 결과
• ${form.number}: 10000
• ${{form.number}}: 10,000
• ${form.localDateTime}: 2021-01-01T00:00:00
• ${{form.localDateTime}}: 2021-01-01 00:00:00
- 지정한 포맷으로 출력된 것을 확인할 수 있다.
* 정리
- 컨버터를 사용하든, 포맷터를 사용하든 등록 방법은 다르지만, 사용할 때는 컨버전 서비스를 통해서 일관성 있게 사용할 수 있다.
* 주의
- 메시지 컨버터( HttpMessageConverter )에는 컨버전 서비스가 적용되지 않는다.
- 특히 객체를 JSON으로 변환할 때 메시지 컨버터를 사용하면서 이 부분을 많이 오해하는데, HttpMessageConverter 의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다.
- 예를 들어서 JSON을 객체로 변환하는 메시지 컨버터는 내부에서 Jackson 같은 라이브러리를 사용한다.
- 객체를 JSON으로 변환한다면 그 결과는 이 라이브러리에 달린 것이다.
- 따라서 JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야 한다.
- 결과적으로 이것은 컨버전 서비스와 전혀 관계가 없다.
- 컨버전 서비스는 @RequestParam , @ModelAttribute , @PathVariable , 뷰 템플릿 등에서 사용할 수 있다
'Spring 정리' 카테고리의 다른 글
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 21 (0) | 2022.07.25 |
---|---|
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 20 (0) | 2022.07.22 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 18 (0) | 2022.07.19 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 17 (0) | 2022.07.16 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 16 (0) | 2022.07.16 |
설정
트랙백
댓글
글
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 18
인프런 강의 56일차.
- 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 1 (김영한 강사님)
- 1편에서 배운 MVC를 활용할 수 있는 기술 습득
- 타입 컨버터, 파일 업로드, 활용, 쿠키, 세션, 필터, 인터셉터, 예외 처리, 타임리프, 메시지, 국제화, 검증 등등
9.7 API 예외 처리 - @ExceptionHandler
* HTML 화면 오류 vs API 오류
- 웹 브라우저에 HTML 화면을 제공할 때는 오류가 발생하면 BasicErrorController 를 사용하는게 편하다.
- 이때는 단순히 5xx, 4xx 관련된 오류 화면을 보여주면 된다.
- BasicErrorController 는 이런 메커니즘을 모두 구현해두었다.
- 그런데 API는 각 시스템 마다 응답의 모양도 다르고, 스펙도 모두 다르다.
- 예외 상황에 단순히 오류 화면을 보여주는 것이 아니라, 예외에 따라서 각각 다른 데이터를 출력해야 할 수도 있다.
- 그리고 같은 예외라고 해도 어떤 컨트롤러에서 발생했는가에 따라서 다른 예외 응답을 내려주어야 할 수 있다.
- 한마디로 매우 세밀한 제어가 필요하다.
- 결국 지금까지 살펴본 BasicErrorController 를 사용하거나 HandlerExceptionResolver 를 직접 구현하는 방식으로는 API 예외를 다루기는 쉽지 않다.
* API 예외처리의 어려운 점
- HandlerExceptionResolver 를 떠올려 보면 ModelAndView 를 반환해야 했다. 이것은 API 응답에는 필요하지 않다
- API 응답을 위해서 HttpServletResponse 에 직접 응답 데이터를 넣어주었다. 이것은 매우 불편하다. 스프링 컨트롤러에 비유하면 마치 과거 서블릿을 사용하던 시절로 돌아간 것 같다
- 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어렵다. 예를 들어서 회원을 처리하는 컨트롤러에서 발생하는 RuntimeException 예외와 상품을 관리하는 컨트롤러에서 발생하는 동일한 RuntimeException 예외를 하나의 UserHandleExceptionResolver에서 처리하려면 소스가 굉장히 지저분해진다. 컨트롤러 별로 서로 다르게 처리하고 싶다면 어떻게 해야할까?
* @ExceptionHandler
- 스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler 라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver를 동작시킨다.
- 스프링은 ExceptionHandlerExceptionResolver 를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다.
package hello.exception.exhandler;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
- hello.exception.exhandler.ErrorResult.java
- 예외가 발생했을 때 API 응답으로 사용하는 객체를 정의했다. (에러코드, 에러 메시지 응답)
package hello.exception.api;
import hello.exception.exception.UserException;
import hello.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
public class ApiExceptionV2Controller {
@ResponseStatus(HttpStatus.BAD_REQUEST) //ErrorResult를 ExceptionHandler로 처리 시 http 상태코드는 200으로 된다(정상적인 흐름으로 돌려놨기 때문에!) 해당 상태코드를 변경해보자..
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
log.error("[exceptionHandle] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
@GetMapping("/api2/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
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;
}
}
- hello.exception.api.ApiExceptionV2Controller.java
- @ExceptionHandler를 사용하는 컨트롤러 정의
* @ExceptionHandler 예외 처리 방법
- @ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
- 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 지정한 예외와 그 예외의 자식 클래스는 모두 잡을 수 있다.
* 우선순위
- 스프링의 우선순위는 항상 자세한 것이 우선권을 가진다.
- 예를 들어서 부모, 자식 클래스가 있고 다음과 같이 예외가 처리된다.
@ExceptionHandler(부모예외.class)
public String 부모예외처리()(부모예외 e) {}
@ExceptionHandler(자식예외.class)
public String 자식예외처리()(자식예외 e) {}
- @ExceptionHandler 에 지정한 부모 클래스는 자식 클래스까지 처리할 수 있다.
- 따라서 자식예외 가 발생하면 부모예외처리() , 자식예외처리() 둘다 호출 대상이 된다.
- 그런데 둘 중 더 자세한 것이 우선권을 가지므로 자식예외처리() 가 호출된다.
- 물론 부모예외 가 호출되면 부모예외처리() 만 호출 대상이 되므로 부모예외처리() 가 호출된다
* 다양한 예외
- 다양한 예외를 한번에 처리할 수 있다
@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
log.info("exception e", e);
}
- 익셉션 A, 익셉션 B에 대해 정의해서 한번에 처리 가능
* 예외 생략
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {}
- @ExceptionHandler 에 예외를 생략할 수 있다. 생략하면 메서드 파라미터의 예외가 지정된다.
* 파라미터와 응답
- @ExceptionHandler 에는 마치 스프링의 컨트롤러의 파라미터 응답처럼 다양한 파라미터와 응답을 지정할 수 있다.
- 자세한 파라미터와 응답은 다음 공식 메뉴얼을 참고하자.
* POST 맨으로 호출 테스트1
- 호출 URL : http://localhost:8080/api2/members/bad
- bad 호출 시 llegalArgumentException 처리한다.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
* 실행 흐름
- 컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.
- 예외가 발생했으로 ExceptionResolver 가 작동한다.
> 우선순위가 높은 ExceptionHandlerExceptionResolver 가 실행된다.
> ExceptionHandlerExceptionResolver 는 해당 컨트롤러에 IllegalArgumentException 을 처리할 수 있는 @ExceptionHandler 가 정의되어 있는지 확인한다.
- illegalExHandle() 를 실행한다.
> @RestController 이므로 illegalExHandle() 에도 @ResponseBody 가 적용된다.
- 따라서 HTTP 컨버터가 사용되고, 응답이 JSON으로 반환된다.
> @ResponseStatus(HttpStatus.BAD_REQUEST) 를 지정했으므로 HTTP 상태 코드는 400으로 응답한다
{
"code": "BAD",
"message": "잘못된 입력 값"
}
* POST 맨으로 호출 테스트2
- 호출 URL : http://localhost:8080/api2/members/user-ex
- bad 호출 시 llegalArgumentException 처리한다.
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
log.error("[exceptionHandle] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
- UserException 처리 클래스
- @ExceptionHandler 에 예외를 지정하지 않으면 해당 메서드 파라미터 예외를 사용한다.
- 여기서는 UserException 을 사용한다. ResponseEntity 를 사용해서 HTTP 메시지 바디에 직접 응답한다.
- 물론 HTTP 컨버터가 사용된다.
> ResponseEntity 를 사용하면 HTTP 응답 코드를 프로그래밍해서 동적으로 변경할 수 있다.
- @ResponseStatus 는 애노테이션이므로 HTTP 응답 코드를 동적으로 변경할 수 없다.
* POST 맨으로 호출 테스트3
- 호출 URL : http://localhost:8080/api2/members/ex
- bad 호출 시 llegalArgumentException 처리한다.
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
- @ExceptionHandler 에 예외를 지정하지 않으면 해당 메서드 파라미터 예외를 사용한다.
- 여기서는 UserException 을 사용한다. ResponseEntity 를 사용해서 HTTP 메시지 바디에 직접 응답한다.
- 물론 HTTP 컨버터가 사용된다.
> ResponseEntity 를 사용하면 HTTP 응답 코드를 프로그래밍해서 동적으로 변경할 수 있다.
> 앞서 살펴본 @ResponseStatus 는 애노테이션이므로 HTTP 응답 코드를 동적으로 변경할 수 없다.
throw new RuntimeException("잘못된 사용자") 이 코드가 실행되면서, 컨트롤러 밖으로
RuntimeException 이 던져진다.
RuntimeException 은 Exception 의 자식 클래스이다. 따라서 이 메서드가 호출된다.
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) 로 HTTP 상태 코드를 500으로 응답한다
- throw new RuntimeException("잘못된 사용자") 이 코드가 실행되면서, 컨트롤러 밖으로 RuntimeException 이 던져진다
- RuntimeException 은 Exception 의 자식 클래스이다. 따라서 이 흐름으로 메서드가 호출된다.
> @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) 로 HTTP 상태 코드를 500으로 응답한다.
* HTML 오류 화면
@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
log.info("exception e", e);
return new ModelAndView("error");
}
- ModelAndView 를 사용해서 API응답이 아닌 오류 화면(HTML)을 응답하는데 사용할 수도 있다.
9.8 API 예외 처리 - @ControllerAdvice
- @ExceptionHandler 를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다.
> @ControllerAdvice 또는 @RestControllerAdvice 를 사용하면 둘을 분리할 수 있다.
package hello.exception.exhandler.advice;
import hello.exception.exception.UserException;
import hello.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 {
//마치 AOP를 Controller에 적용한 것 같은 느낌이다.
@ResponseStatus(HttpStatus.BAD_REQUEST) //ErrorResult를 ExceptionHandler로 처리 시 http 상태코드는 200으로 된다(정상적인 흐름으로 돌려놨기 때문에!) 해당 상태코드를 변경해보자..
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
log.error("[exceptionHandle] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
/*@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
log.info("exception e", e);
return new ModelAndView("error");
}*/
}
- hello.exception.exhandler.advice.ExControllerAdvice.java
- @RestControllerAdvice 사용
- 각자 Controller에 있는 ExceptionHandler를 통합
package hello.exception.api;
import hello.exception.exception.UserException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@GetMapping("/api2/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
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;
}
}
- ApiExceptionV2Controller 코드에 있는 @ExceptionHandler 모두 제거
* @ControllerAdvice
- @ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할
- @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
- @RestControllerAdvice 는 @ControllerAdvice 와 같고, @ResponseBody 가 추가되어 있다.
- @Controller , @RestController 의 차이와 같다
* 대상 컨트롤러 지정 방법
// 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 {}
- 특정 애노테이션이 있는 컨트롤러를 지정
- 특정 패키지를 직접 지정
> 패키지 지정의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 된다.
- 특정 클래스를 지정
- 대상 컨트롤러 지정을 생략하면 모든 컨트롤러에 적용된다
- @ExceptionHandler 와 @ControllerAdvice 를 조합하면 예외를 깔끔하게 해결할 수 있다
'Spring 정리' 카테고리의 다른 글
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 20 (0) | 2022.07.22 |
---|---|
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 19 (0) | 2022.07.20 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 17 (0) | 2022.07.16 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 16 (0) | 2022.07.16 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 15 (0) | 2022.07.16 |
설정
트랙백
댓글
글
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 17
인프런 강의 55일차.
- 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 1 (김영한 강사님)
- 1편에서 배운 MVC를 활용할 수 있는 기술 습득
- 타입 컨버터, 파일 업로드, 활용, 쿠키, 세션, 필터, 인터셉터, 예외 처리, 타임리프, 메시지, 국제화, 검증 등등
9.1 API 예외 처리 - 시작
- API 예외 처리는 어떻게 해야할까
- HTML 페이지의 경우 지금까지 설명했던 것 처럼 4xx, 5xx와 같은 오류 페이지만 있으면 대부분의 문제를 해결할 수 있다.
- 그런데 API의 경우에는 생각할 내용이 더 많다.
- 오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.
- 지금부터 API의 경우 어떻게 예외 처리를 하면 좋은지 알아보자.
- API도 오류 페이지에서 설명했던 것 처럼 처음으로 돌아가서 서블릿 오류 페이지로 방식을 사용해보자
package hello.exception;
import org.springframework.boot.web.server.ConfigurableWebServerFactory;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
//Springboot에서 제공해주는 ErrorPage 클래스는 사용해 에러 정의
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); //에러 페이지 등록
}
}
- hello.exception.WebServerCustomizer.java
- WebServerCustomizer 가 다시 사용되도록 하기 위해 @Component 애노테이션에 적용한 주석을 풀자
- 이제 WAS에 예외가 전달되거나, response.sendError() 가 호출되면 위에 등록한 예외 페이지 경로가 호출된다
package hello.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("잘못된 사용자");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
- hello.exception.api.ApiExceptionController.java
- 회원을 조회하는 기능을 만들고, 예외 테스트를 위해 URL에 전달된 id 의 값이 ex 이면 예외가 발생하도록 구현.
* 정상 호출 시 응답
{
"memberId": "spring",
"name": "hello spring"
}
- Postman으로 테스트
- HTTP Header에 Accept 가 application/json으로 날려야 JSON으로 날아간다는 것을 주의
- API를 요청했는데, 정상의 경우 API로 JSON 형식으로 데이터가 정상 반환된다.
* 예외 발생 오류 호출 시 응답
<!DOCTYPE HTML>
<html>
<head>
</head>
<body>
...
</body>
</html>
- 그런데 오류가 발생하면 우리가 미리 만들어둔 오류 페이지 HTML이 반환된다. 이것은 기대하는 바가 아니다.
- 클라이언트는 정상 요청이든, 오류 요청이든 JSON이 반환되기를 기대한다.
- 웹 브라우저가 아닌 이상 HTML을 직접 받아서 할 수 있는 것은 별로 없다.
- 오류 페이지 컨트롤러도 JSON 응답을 할 수 있도록 수정해야 한다.
package hello.exception.servlet;
import lombok.extern.slf4j.Slf4j;
...
@Slf4j
@Controller
public class ErrorPageController {
...
//Client가 보내는 헤더 타입에 대해 자세하게 정의되어 있는 메소드를 먼저 호출한다.
//application/json인 경우 같은 error-page/500이 호출되었을 때 produces에 APPLICATION_JSON_VALLUE 가 걸린 메소드가 먼저 호출된다.
@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");
HashMap<String, Object> result = new HashMap<>();
Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);;
result.put("status", request.getAttribute(ERROR_STATUS_CODE));
result.put("message", ex.getMessage());
Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
...
}
- hello.exception.servlet.ErrorPageController.java
- produces = MediaType.APPLICATION_JSON_VALUE 의 뜻은 클라이언트가 요청하는 HTTP Header의 Accept 의 값이 application/json 일 때 해당 메소드가 호출된다는 것이다.
> 결국 클라어인트가 받고 싶은 미디어타입이 json이면 이 컨트롤러의 메소드가 호출된다.
- 응답 데이터를 위해서 HashMap 을 만들고 status, message 키에 값을 할당했다.
> Jackson 라이브러리는 Map 을 JSON 구조로 변환할 수 있다.
- ResponseEntity 를 사용해서 응답하기 때문에 메시지 컨버터가 동작하면서 클라이언트에 JSON이 반환된다
* APPLICATION_JSON_VALUE 추가 후 예외 발생 오류 호출 시 응답
{
"message": "잘못된 사용자",
"status": 500
}
- HTTP Header에 Accept 가 application/json 이 아니면, 기존 오류 응답인 HTML 응답이 출력되는 것을 확인할 수 있다
9.2 API 예외 처리 - 스프링 부트 기본 오류 처리
- API 예외 처리도 스프링 부트가 제공하는 기본 오류 방식을 사용할 수 있다.
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
HttpStatus status = getStatus(request);
Map<String, Object> model = Collections
.unmodifiableMap(getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
response.setStatus(status.value());
ModelAndView modelAndView = resolveErrorView(request, response, status, model);
return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
HttpStatus status = getStatus(request);
if (status == HttpStatus.NO_CONTENT) {
return new ResponseEntity<>(status);
}
Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
return new ResponseEntity<>(body, status);
}
- 스프링부트에서 제공하는 BasicErrorController.java
- /error 동일한 경로를 처리하는 errorHtml() , error() 두 메서드를 확인할 수 있다
- errorHtml() : produces = MediaType.TEXT_HTML_VALUE이므로 클라이언트 요청의 Accept 헤더 값이 text/html 인 경우에는 errorHtml() 을 호출해서 view를 제공한다.
- error() : 그외 경우에 호출되고 ResponseEntity 로 HTTP Body에 JSON 데이터를 반환한다.
* 스프링 부트의 예외 처리
- 스프링 부트의 기본 설정은 오류 발생시 /error 를 오류 페이지로 요청한다.
- BasicErrorController 는 이 경로를 기본으로 받는다. ( server.error.path 로 수정 가능, 기본 경로 / error )
- BasicErrorController 를 사용하도록 WebServerCustomizer 의 @Component 를 주석처리 하자
> WebServerCustomizer 은 서블릿에서 제공하는 예외처리
{
"timestamp": "2022-07-16T00:00:00.000+00:00",
"status": 500,
"error": "Internal Server Error",
"exception": "java.lang.RuntimeException",
"trace": "java.lang.RuntimeException: 잘못된 사용자\n\tat
hello.exception.web.api.ApiExceptionController.getMember(ApiExceptionController.java:19...,
"message": "잘못된 사용자",
"path": "/api/members/ex"
}
- 스프링 부트는 BasicErrorController 가 제공하는 기본 정보들을 활용해서 오류 API를 생성해준다
* Html 페이지 vs API 오류
- BasicErrorController 를 확장하면 JSON 메시지도 변경할 수 있다. 그런데 API 오류는 조금 뒤에 설명할 @ExceptionHandler 가 제공하는 기능을 사용하는 것이 더 나은 방법이므로 지금은 BasicErrorController 를 확장해서 JSON 오류 메시지를 변경해보자.
- 스프링 부트가 제공하는 BasicErrorController 는 HTML 페이지를 제공하는 경우에는 매우 편리하다.
- 4xx, 5xx 등등 모두 잘 처리해준다. 그런데 API 오류 처리는 다른 차원의 이야기이다.
- API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다.
> 예를 들어서 회원과 관련된 API에서 예외가 발생할 때 응답과, 상품과 관련된 API에서 발생하는 예외에 따라 그 결과가 달라질 수 있다.
- 결과적으로 매우 세밀하고 복잡하다.
- 따라서 이 방법은 HTML 화면을 처리할 때 사용하고, API는 오류 처리는 뒤에서 설명할 @ExceptionHandler 를 사용하자
9.3 API 예외 처리 - HandlerExceptionResolver 시작
- 예외가 발생해서 서블릿을 넘어 WAS까지 예외가 전달되면 HTTP 상태코드가 500으로 처리된다.
- 발생하는 예외에 따라서 400, 404 등등 다른 상태코드도 처리해보자,
- 오류 메시지, 형식등을 API마다 다르게 처리해보자.
- IllegalArgumentException 을 처리하지 못해서 컨트롤러 밖으로 넘어가는 일이 발생하면 HTTP 상태코드를 400으로 처리하고 싶다. 어떻게 해야할까?
...
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
return new MemberDto(id, "hello " + id);
}
...
}
- hello.exception.api.ApiExceptionController.java
- /api/members/bad 호출 시 IllegalArgumentException이 발생하도록 처리했다.
* /api/members/bad 호출 결과
{
"status": 500,
"error": "Internal Server Error",
"exception": "java.lang.IllegalArgumentException",
"path": "/api/members/bad"
}
- Http Status 500 에러가 발생함을 확인
* HandlerExceptionResolver
- 스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다.
- 컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver 를 사용하면 된다. 줄여서 ExceptionResolver 라 한다
* ExceptionResolver 적용 전
* ExceptionResolver 적용 후
> ExceptionResolver 를 적용하기 전에도 컨트롤러에서 예외가 발생하면 postHandle()가 호출되지 않았다.
> ExceptionResolver 로 예외를 해결해도 postHandle() 은 호출되지 않는다
* HandlerExceptionResolver - 인터페이스
public interface HandlerExceptionResolver {
ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response,Object handler, Exception ex);
}
- handler : 핸들러(컨트롤러) 정보
- Exception ex : 핸들러(컨트롤러)에서 발생한 발생한 예외
* MyHandlerExceptionResolver
package hello.exception.resolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
log.info("resolveException --- ");
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; //null로 리턴 시 전달받은 예외가 그대로 전달됨
}
}
- hello.exception.resolver.MyHandlerExceptionResolver.java
- ExceptionResolver 가 ModelAndView 를 반환하는 이유는 try, catch를 하듯이 Exception 을 처리해서 정상 흐름 처럼 변경하는 것이 목적이다. 이름 그대로 Exception 을 Resolver(해결)하는 것이 목적이다.
- 여기서는 IllegalArgumentException 이 발생하면 response.sendError(400) 를 호출해서 HTTP 상태 코드를 400으로 지정하고, 빈 ModelAndView 를 반환한다
* 반환 값에 따른 동작 방식
- HandlerExceptionResolver 의 반환 값에 따른 DispatcherServlet 의 동작 방식은 다음과 같다.
> 빈 ModelAndView : new ModelAndView() 처럼 빈 ModelAndView 를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.
> 특정 ModelAndView 지정 : ModelAndView 에 View , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.
> null: null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver 가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.
* ExceptionResolver 활용
- 예외 상태 코드 변환
> 예외를 response.sendError(xxx) 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임
> 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출, 예를 들어서 스프링 부트가 기본으로 설정한 / error 가 호출됨
- 뷰 템플릿 처리
> ModelAndView 에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공
- API 응답 처리
> response.getWriter().println("hello"); 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하다.
> 여기에 JSON 으로 응답하면 API 응답 처리를 할 수 있다.
@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) {
//내가 구현한 ExceptionResolver를 ExceptionHandler에 등록한다.
resolvers.add(new MyHandlerExceptionResolver());
}
...
}
- hello.exception.WebConfig.java
- configureHandlerExceptionResolvers(..) 를 사용하면 스프링이 기본으로 등록하는 ExceptionResolver 가 제거되므로 주의,
> extendHandlerExceptionResolvers 를 사용하자
* Postman으로 실행
- http://localhost:8080/api/members/ex -> HTTP 상태 코드 500 반환
- http://localhost:8080/api/members/bad -> HTTP 상태 코드 400 반환
9.4 API 예외 처리 - HandlerExceptionResolver 활용
- 예외가 발생하면 WAS까지 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error 를 호출하는 과정은 복잡하다.
> ExceptionResolver 를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 깔끔하게 해결할 수 있다.
package hello.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);
}
}
- hello.exception.exception.UserException.java
- RuntimeException을 오버라이드한 UserException 추가
...
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
...
}
- hello.exception.api.ApiExceptionController.java
- /api/members/user-ex 호출 시 UserException이 발생하도록 처리
- 이제 UserException을 처리하는 UserHandlerExceptionResolver를 만들어보자.
package hello.exception.resolver;
import com.fasterxml.jackson.databind.ObjectMapper;
import hello.exception.exception.UserException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
private 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 acceptHeader = request.getHeader("accept");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST); //Https 상태코드 400으로 변경
if ("application/json".equals(acceptHeader)) {
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("ex", ex.getClass());
errorResult.put("message", ex.getMessage());
//errorResult를 objectMapper.writeValueAsString을 사용해 JSON 형태로 바꿔준다.
String result = objectMapper.writeValueAsString(errorResult);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(result);
return new ModelAndView();
} else {
// TEXT/HTML
return new ModelAndView("error/500");
}
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
- hello.exception.resolver.UserHandlerExceptionResolver.java
- HTTP 요청 해더의 ACCEPT 값이 application/json 이면 JSON으로 오류를 내려주고, 그 외 경우에는 error/500에 있는 HTML 오류 페이지를 보여준다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
...
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
//내가 구현한 ExceptionResolver를 ExceptionHandler에 등록한다.
resolvers.add(new MyHandlerExceptionResolver());
resolvers.add(new UserHandlerExceptionResolver());
}
...
}
- 내가 만든 UserHandlerExceptionResolver를 핸들러에 등록
* /api/members/user-ex 호출 결과
//Accept : application/json 인 경우 JSON 으로 리턴
{
"ex": "hello.exception.exception.UserException",
"message": "사용자 오류"
}
//Accept : text/html 인 경우 HTML 코드 리턴
<!DOCTYPE HTML>
<html>
...
</html>
* 정리
- ExceptionResolver 를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver 에서 예외를 처리해버린다.
- 따라서 예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이 난다.
- 결과적으로 WAS 입장에서는 정상 처리가 된 것이다.
> 예외를 이곳에서 모두 처리할 수 있다는 것이 핵심이다
- 서블릿 컨테이너까지 예외가 올라가면 복잡하고 지저분하게 추가 프로세스가 실행된다.
- 반면에 ExceptionResolver 를 사용하면 예외처리가 상당히 깔끔해진다.
- 그런데 직접 ExceptionResolver 를 구현하려고 하니 상당히 복잡하다.
> 스프링이 제공하는 ExceptionResolver 들을 알아보자.
9.5 API 예외 처리 - 스프링이 제공하는 ExceptionResolver1
- 스프링 부트가 기본으로 제공하는 ExceptionResolver 는 다음과 같다.
* HandlerExceptionResolverComposite 에 3개가 순서대로 등록된다.
1. ExceptionHandlerExceptionResolver
- @ExceptionHandler 을 처리한다. API 예외 처리는 대부분 이 기능으로 해결한다.
2. ResponseStatusExceptionResolver
- HTTP 상태 코드를 지정해준다.
- @ResponseStatus(value = HttpStatus.NOT_FOUND)
3. DefaultHandlerExceptionResolver (우선 순위가 가장 낮다)
- 스프링 내부 기본 예외를 처리한다.
* ResponseStatusExceptionResolver
- ResponseStatusExceptionResolver 는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다
> @ResponseStatus 가 달려있는 예외
> ResponseStatusException 예외
package hello.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 {
}
- hello.exception.exception.BadRequestException.java
- 예외에 @ResponseStatus 애노테이션을 적용하면 HTTP 상태 코드를 변경해준다.
- BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver 예외가 해당 애노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST (400)으로 변경하고, 메시지도 담는다
- ResponseStatusExceptionResolver 코드를 확인해보면 결국 response.sendError(statusCode, resolvedReason) 를 호출하는 것을 확인할 수 있다.
> sendError(400) 를 호출했기 때문에 WAS에서 다시 오류 페이지( /error )를 내부 요청한다
@Slf4j
@RestController
public class ApiExceptionController {
...
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
throw new BadRequestException();
}
...
}
- hello.exception.api.ApiExceptionController.java
- @ResponseStatus 를 테스트하기 위한 responseStatusEx1 메소드 추가
* /api/response-status-ex1 호출 결과
{
"status": 400,
"error": "Bad Request",
"exception": "hello.exception.exception.BadRequestException",
"message": "잘못된 요청 오류",
"path": "/api/response-status-ex1"
}
- @ResponseStatus에 정의한 것 처럼 status 코드는 400으로, message는 "잘못된 요청 오류"로 변경되었다.
* 메시지 기능
- @ResponseStatus에 사용한 reason 을 MessageSource 에서 찾는 기능도 제공한다.
> reason = "error.bad" 으로 사용
error.bad=잘못된 요청 오류입니다. 메시지 사용
- message.properties 에 error.bad 값정의
* 메시지 기능 사용한 /api/response-status-ex1 호출 결과
{
"status": 400,
"error": "Bad Request",
"exception": "hello.exception.exception.BadRequestException",
"message": "잘못된 요청 오류",
"path": "/api/response-status-ex1"
}
* ResponseStatusException
- @ResponseStatus 는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다.
> 애노테이션을 직접 넣어야 하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다.
- 추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다.
- 이때는 ResponseStatusException 예외를 사용하면 된다.
@Slf4j
@RestController
public class ApiExceptionController {
...
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
...
}
- hello.exception.api.ApiExceptionController.java
- 조건에 따라 동적으로 변동되는 에러를 처리하기 위해 ResponseStatusException을 구현한 responseStatusEx2 메소드
* /api/response-status-ex2 호출 결과
{
"status": 404,
"error": "Not Found",
"exception": "org.springframework.web.server.ResponseStatusException",
"message": "잘못된 요청 오류입니다. 메시지 사용",
"path": "/api/response-status-ex2"
}
9.6 API 예외 처리 - 스프링이 제공하는 ExceptionResolver2
- DefaultHandlerExceptionResolver 를 살펴보자
- DefaultHandlerExceptionResolver 는 스프링 내부에서 발생하는 스프링 예외를 해결한다.
- 대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException 이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.
- 그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다.
- HTTP 에서는 이런 경우 HTTP 상태 코드 400을 사용하도록 되어 있다.
> DefaultHandlerExceptionResolver 는 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다.
- 스프링 내부 오류를 어떻게 처리할지 수 많은 내용이 정의되어 있다
* 코드 확인
- DefaultHandlerExceptionResolver.handleTypeMismatch 를 보면 코드를 확인할 수 있다.
- response.sendError(HttpServletResponse.SC_BAD_REQUEST) (400)
> 결국 response.sendError() 를 통해서 문제를 해결한다.
> sendError(400) 를 호출했기 때문에 WAS에서 다시 오류 페이지( /error )를 내부 요청한다
@Slf4j
@RestController
public class ApiExceptionController {
...
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
return "ok";
}
...
}
- hello.exception.api.ApiExceptionController.java
- Integer data 에 문자를 입력하면 내부에서 TypeMismatchException 이 발생한다.
* /api/default-handler-ex?data=hello&message= 호출 결과
{
"status": 400,
"error": "Bad Request",
"exception": "org.springframework.web.method.annotation.MethodArgumentTypeMismatchException",
"message": "Failed to convert value of type 'java.lang.String' to required type 'java.lang.Integer'; nested exception is java.lang.NumberFormatException: For input string: \"hello\"",
"path": "/api/default-handler-ex"
}
- 실행 결과를 보면 TypeMismatchException이고 HTTP 상태 코드가 400인 것을 확인할 수 있다
* 정리
- 지금까지 HTTP 상태 코드를 변경하고, 스프링 내부 예외의 상태코드를 변경하는 기능도 알아보았다.
- 그런데 HandlerExceptionResolver 를 직접 사용하기는 복잡하다.
- API 오류 응답의 경우 response 에 직접 데이터를 넣어야 해서 매우 불편하고 번거롭다.
- ModelAndView 를 반환해야 하는 것도 API에는 잘 맞지 않는다.
- 스프링은 이 문제를 해결하기 위해 @ExceptionHandler 라는 매우 혁신적인 예외 처리 기능을 제공한다.
'Spring 정리' 카테고리의 다른 글
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 19 (0) | 2022.07.20 |
---|---|
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 18 (0) | 2022.07.19 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 16 (0) | 2022.07.16 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 15 (0) | 2022.07.16 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 14 (0) | 2022.07.10 |