Ian's Archive 🏃🏻

Profile

Ian

Ian's Archive

Developer / React, SpringBoot ...

📍 Korea
Github Profile →
Categories
All PostsAlgorithm19Book1C1CI/CD2Cloud3DB1DesignPattern9ELK4Engineering1Front3Gatsby2Git2IDE1JAVA7JPA5Java1Linux8Nginx1PHP2Python1React9Security4SpatialData1Spring26
thumbnail

스프링 MVC 2 강의 정리 - 김영한

Spring
2024.01.13. (수정됨)

Series

  • 1스프링 입문 강의 정리 - 김영한
  • 2스프링 핵심 원리 강의 정리 - 김영한
  • 3스프링 MVC 1 강의 정리 - 김영한
  • 4스프링 MVC 2 강의 정리 - 김영한

1. Thymeleaf

Thymeleaf Document

  • 서버 사이드 HTML 렌더링
  • 네츄럴 템플릿 : 순수 HTML을 유지하며 뷰 템플릿 사용할 수 있는 타임리프의 특징
  • 스프링 통합 지원
복사
Simple expressions
- Variable Expressions: ${...}
- Selection Variable Expressions: *{...}
- Message Expressions: #{...}
- Link URL Expressions: @{...}
- Fragment Expressions: ~{...}

Literals
- Text literals: 'one text', 'Another one!',…
- Number literals: 0, 34, 3.0, 12.3,…
- Boolean literals: true, false
- Null literal: null
- Literal tokens: one, sometext, main,…

Text operations:
- String concatenation: +
- Literal substitutions: |The name is ${name}|

Arithmetic operations
- Binary operators: +, -, *, /, %
- Minus sign (unary operator): -

Boolean operations:
- Binary operators: and, or
- Boolean negation (unary operator): !, not

Comparisons and equality:
- Comparators: >, <, >=, <= (gt, lt, ge, le)
- Equality operators: ==, != (eq, ne)

Conditional operators:
- If-then: (if) ? (then)
- If-then-else: (if) ? (then) : (else)
- Default: (value) ?: (defaultvalue)

Special tokens:
- No-Operation: _

1.1 기본 기능

  1. text, utext

HTML의 콘텐츠에 데이터 출력 시 th:text 사용

컨텐츠 안에 직접 출력 시 [[${data}]]

th:text, [[${data}]]은 기본적으로 이스케이프 제공 (HTML 사용하는 특수 문자 HTML 엔티티로 변경) th:utext, [(${data})] 이스케이프 사용 안할 떄

복사
<ul>
  <li>th:text = <span th:text="${data}"></span></li>
  <li>th:utext = <span th:utext="${data}"></span></li>
</ul>

<h1><span th:inline="none">[[...]] vs [(...)]</span></h1>
<ul>
  <li><span th:inline="none">[[...]] = </span>[[${data}]]</li>
  <li><span th:inline="none">[(...)] = </span>[(${data})]</li>
</ul>
  1. SpringEL & Utility Objects

변수 표현 : ${...}

Object, List, Map 등 접근 가능하고 문자, 숫자, 날짜, URL을 편리하게 다루는 다양한 유틸리티 객체들 제공

variables - thymeleaf doc

  1. URL
    <li><a th:href="@{/hello}">basic url</a></li>
    <li><a th:href="@{/hello(param1=${param1}, param2=${param2})}">hello query param</a></li>
    <li><a th:href="@{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}">path variable</a></li>
    <li><a th:href="@{/hello/{param1}(param1=${param1}, param2=${param2})}">path variable + query parameter</a></li>
  1. Attribute Value
  • HTML 태그에 th:* 속성을 지정하는 방식으로 동작
  • th:*로 속성을 적용하면 기존 속성을 대체 / 없으면 새로 생성

setting-attribute-values - thymeleaf doc

  1. 반복문

반복문은 th:each사용, 추가로 여러 상태 값을 지원

  <tr th:each="user : ${users}">
    <td th:text="${user.username}">username</td>
    <td th:text="${user.age}">0</td>
  </tr>

th:each는 List뿐만 아니라 배열, iterable, Enumeration 모두 사용 가능

  1. 조건문
    <tr th:each="user, userStat : ${users}">
        <td th:text="${userStat.count}">1</td>
        <td th:text="${user.username}">username</td>
        <td>
            <span th:text="${user.age}">0</span>
            <span th:text="'미성년자'" th:if="${user.age lt 20}"></span>
            <span th:text="'미성년자'" th:unless="${user.age ge 20}"></span>
        </td>
        <td th:switch="${user.age}">
            <span th:case="10">10살</span>
            <span th:case="20">20살</span>
            <span th:case="*">기타</span>
        </td>
    </tr>
  1. 주석

주석은 3가지 존재

  1. 표준 HTML 주석 -> html에 그대로 남겨둔다.

  2. thymeleaf parser -> 렌더링에서 주석 제거

  3. thymeleaf prototype -> html파일을 그대로 열면 주석처리 되지만, 타임리프 렌더링 하는 경우 정상 렌더링

  4. block 태그

  • th:block템플릿 개발자가 원하는 속성을 지정할 수 있는 단순한 속성 컨테이너
  • Thymeleaf는 이러한 속성을 실행한 다음 블록을 사라지게 만들지만 그 내용은 유지
  • 아래 table 같은 경우 유용
<table>
  <th:block th:each="user : ${users}">
    <tr>
        <td th:text="${user.login}">...</td>
        <td th:text="${user.name}">...</td>
    </tr>
    <tr>
        <td colspan="2" th:text="${user.address}">...</td>
    </tr>
  </th:block>
</table>
  1. Javascript

<script th:inline="javascript"> 와 같이 적용

<!-- 자바스크립트 인라인 사용 후 -->
<script th:inline="javascript">
    var username = [[${user.username}]];
    var age = [[${user.age}]];

    //자바스크립트 내추럴 템플릿
    var username2 = /*[[${user.username}]]*/ "test username";

    //객체
    var user = [[${user}]];
</script>

<!-- 자바스크립트 인라인 each -->
<script th:inline="javascript">

    [# th:each="user, stat : ${users}"]
    var user[[${stat.count}]] = [[${user}]];
    [/]

</script>
  • 인라인 사용 후 랜더링 결과를 보면 문자 타입인 경우 ” 포함
  • 자바스크립트에서 문제가 될 수 있는 문자가 있으면 이스케이프 처리
  • var user = [[${user}]];와 같이 객체를 인라인 기능으로 사용하면 자동으로 JSON으로 변환
  1. fragment

3가지 방법으로 사용 가능하다.

  • insert : 삽입
  • replace : 대체

template-layout - thymeleaf doc

2. Thymeleaf + Spring

Thymeleaf + Spring Document

스프링 + thymeleaf 같이 사용

  • SpringEL 문법 통합
  • Spring Bean 호출
  • 편리한 폼 관리를 위한 추가 속성
    • th:object (기능 강화, 폼 커맨드 객체 선택)
    • th:field, th:erros, th:errorclass
  • 폼 컴포넌트 기능
    • checkbox, radio button, List 등 편리하게 사용 가능
  • 스프링 메세지, 국제화 기능의 편리한 통합
  • 스프링의 검증, 오류 처리 통합
  • 스프링 변환 서비스 통합 (ConversionService)
  1. form 처리

  2. check box

check box는 선택되지 않으면 서버로 값 자체를 보내지 않는다. -> null

체크 해제를 인식하기 위한 히든 필드 추가

-> 체크 해제 한 경우 open 전송되지 않고 _open만 전송되는데 이 경우 MVC는 체크 해제 -> 체크한 경우는 _open 사용 시

th:field=”*{open}” 사용 시
-> hidden 필드 자동 생성

for문 사용해서 동적으로 id값 넣어줄 때 해당 값 활용 x -> prev(…), next(…) 사용


컨트롤러에 @ModelAttribute 사용 시 설정한 컬럼 값이 request 요청에 있을 경우 반환 값이 모델에 담긴다.



  1. radio

  2. select

3. 메세지 국제화

DB에 저장해 직접 구현할 수 있지만, 스프링은 기본적인 메시지와 국제화 기능 모두 제공

메시지 관리 기능을 사용하려면 스프링이 제공하는 MessageSource를 스프링 빈으로 등록하면 되는데

MessageSource는 인터페이스이다. 따라서 구현체인 ResourceBundleMessageSource를 스프링 빈으로 등록

직접 등록

복사
@Bean
 public MessageSource messageSource() {

     ResourceBundleMessageSource messageSource = new
ResourceBundleMessageSource();
    // 읽어들일 파일 지정
    // -> messages.properties 파일 읽는다.
    messageSource.setBasenames("messages", "errors");
    // 인코딩 정보 지정
    messageSource.setDefaultEncoding("utf-8");
    return messageSource;
}

Spring boot 사용 시 자동으로 빈 설정(MessageSource) 되어있으므로 설정 값만 변경

application.properties
복사
// 기본 값은 spring.messages.basename=messages
// 추가로 resources/config/i18n/messages 읽어들인다.
spring.messages.basename=messages,config.i18n.messages

설정 후엔 application.properties에 설정한 이름으로 properties파일을 생성한다
(파일 위치 : /resources)

ex) messages_ko.properties, messages_en.properties

html파일엔 다음과 같이 적용한다

복사
<th th:text="#{label.item.id}">ID</th>

// 파라미터 적용 시
<p th:text="#{hello.name(${item.itemName})}"></p>`

spring boot docs message properties

추후 사용자가 선택해서 구현할 땐 LocaleResolver의 구현체를 변경해서 쿠키나 세션 기반의 Locale 선택 기능 구현

4. Validation

검증 종류
-> 타입 검증, 필드 검증, 특정 필드의 범위를 넘어서는 검증

복사
// Back
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes
redirectAttributes, Model model) {
    //검증 오류 결과를 보관
    Map<String, String> errors = new HashMap<>();

    //검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        errors.put("itemName", "상품 이름은 필수입니다.");
    }

    ...

    //특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();

        if (resultPrice < 10000) {
            errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
        }
    }

    //검증에 실패하면 다시 입력 폼으로
    if (!errors.isEmpty()) {
        model.addAttribute("errors", errors);
        return "validation/v1/addForm";
    }
}


// Front
// Global
<div th:if="${errors?.containsKey('globalError')}">
  <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시</div>
<div>

// Field
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _" class="form-control">

다음과 같이 errors?. 로 사용하는데 springEL문법이다.

errors?. : errors가 null일 때 NullPointerException 발생 대신, null반환하는 문법
-> th:if에서 null은 실패처리 되므로 오류 출력x

classappend : 해당 필드에 오류가 있으면 field-error라는 클래스 정보를 더하고 값이 없으면 _(No-Operation)을 사용하여 아무 일도 하지 않음


검증을 직접 처리하면

  • 뷰 템플릿에서 중복된 코드가 많음
  • 타입 오류 처리 불가능

4.1 Binding Result

스프링이 제공하는 검증 오류 처리 방법

  • bindingResult사용 시 파라미터 위치는 연관지을 @ModelAttribute 다음에 와야 한다.
복사
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    //검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
    }
    ...

    //특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();

        if (resultPrice < 10000) {
            bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
        }
    }

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

필드 오류 - FieldError

필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담는다.

복사
public FieldError(String objectName, String field, String defaultMessage) {}
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)

objectName : @ModelAttribute이름
field : 오류가 발생한 필드 이름
rejectedValue : 사용자가 입력한 값(거절된 값)
bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
codes : 메시지 코드 ex) new String[]{"range.item.price"}
arguments : 메시지에서 사용하는 인자 ex) new Object[]{1000, 100000}
defaultMessage : 오류 기본 메세지

글로벌 오류 - ObjectError

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 buindingResult 담는다.

복사
public ObjectError(String objectName, String defaultMessage) {}

objectName : @ModelAttribute 의 이름
defaultMessage : 오류 기본 메시지

front단 코드

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

// Field
<input type="text" id="itemName" th:field="*{itemName}"
  th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
  상품명 오류
</div>

타임리프 스프링 오류 표현 방법

#fields : #fieldsBindingResult 가 제공하는 검증 오류에 접근
th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력 -> th:if 의 편의 버전
th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가

타임리프의 사용자 입력 값 유지

th:field="*{price}

정상 상황일 경우 -> 모델 객체 값 사용
오류 발생 시 -> FieldError에서 보관한 값을 사용해 값 출력

Validation and Error Messages - thymeleaf + spring docs

BindingResult 정리

  • 스프링이 제공하는 검증 오류 보관 객체
  • BindingResult가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출
  • 타입 오류 시 -> 오류 정보(FieldError)를 담아 컨트롤러에 호출

BindingResult에 검증 오류 적용 3가지 방법

  • @ModelAttribute 객체에 타입 오류 등으로 바인딩 실패 시 스프링이 FieldError생성 해서 BindingResult에 넣는다.
  • 검증 로직에서 개발자가 직접 삽입
  • Validator 사용

errors 메시지 생성, 국제화

errors.properties 별도의 파일 만들어서 관리 가능

복사
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
  bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
}

rejectValue(), reject()

컨트롤러에서 BindingResult 는 검증해야 할 객체인 target 바로 다음에 와 BindingResult는 검증해야 할 객체인 target 을 알고 있어 reject를 사용하면 깔끔하게 코드 작성 가능

복사
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
  bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000},null);
}
...

//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
  int resultPrice = item.getPrice() * item.getQuantity();
  if (resultPrice < 10000) {
    bindingResult.reject("totalPriceMin", new Object[]{10000,resultPrice}, null);
  }
}

MessageCodesResolver

rejectValue, reject는 MessageCodesResolver가 오류 메시지 처리

  • MessageCodesResolver가 인터페이스, DefaultMessageCodeResolver 기본 구현체

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`라는 오류 코드 사용

ex) 오류 코드: typeMismatch, object name "user", field "age", field type: int

1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"

동작 방식

  • FieldError, ObjectError의 생성자를 보면, 오류 코드를 하나가 아니라 여러개 가지고 있다.
  • MessageCodesResolver를 통해 생성된 순서대로 오류 코드 보관
    • ex) FiledError - rejectValue(“itemName”, “required”)
      • required.item.itemName
      • required.itemName
      • required.java.lang.String
      • required
    • ex) ObjectError - reject(“totalPriceMin”)`
      • totalPriceMin.item
      • totalPriceMin
  • 타임리프 화면에서 렌더링 시 th:errors 실행 후 에러가 있을 경우 메시지 노출

ValidationUtil

Empty , 공백 같은 단순한 기능만 사용 시 ValidationUtil로 간단하게 코드 작성 가능

복사
// 사용 전
if (!StringUtils.hasText(item.getItemName())) {
  bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}

// 사용 후
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

스프링을 통한 오류 메세지 처리

  1. 검증 로직 분리

스프링은 검증을 체계적으로 관리하기 위해 Validator 인터페이스 제공

복사
// Validator
public interface Validator {

  // 해당 검증기 지원 여부 확인
	boolean supports(Class<?> clazz);

  // 검증 대상 객체와 BindingResult를 통해 validate 로직
	void validate(Object target, Errors errors);
}

// custom validator
@Component
public class ItemValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required");

        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() > 10000) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

        //특정 필드 예외가 아닌 전체 예외
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }
    }
}
  1. WebDataBinder를 통해 스프링 파라미터 바인딩

WebDataBinder는 스프링의 파라미터 바인딩 역할, 검증 기능 포함

  1. 적용

3.1) controller에 직접 적용

@InitBinder를 통해

3.2) Global 설정

복사
@SpringBootApplication
public class ItemServiceApplication {

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

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

}

동작 방식

@Validated는 검증 실행 어노테이션

해당 어노테이션이 붙으면 WebDataBinder에 등록한 검증기를 찾아서 실행하는데

supports() 에 등록된 검증기를 실행

5. Bean Validation

Bean Validation 2.0(SJR-380) 기술 표준

검증 애노테이션과 여러 인터페이스 모음

하이버네이트 Validator 관련 링크

build.gradle 추가

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

Jakarta Bean Validation

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

SpringBoot에 validation라이브러리를 넣으면 자동으로 Bean Validator를 인식 -> 스프링 통합

사용 어노테이션

  • @Validated : 스프링 전용 검증 애노테이션
  • @Valid : 자바 표준 검증 애노테이션
  • 동일하게 작동하지만 @Validated는 내부에groups 라는 기능을 포함

검증 순서

  • LocalValidatorFactoryBean을 글로벌 Validator로 등록
  • @ModelAttribute 각각의 필드에 타입 변환 시도
    • 성공 시 Pass
    • 데이터 바인딩 실패 시 typeMismatch -> FieldError 추가
  • @Valid, @Validated 적용
  • 검증 오류 시 FieldError,ObjectError -> BindingResult생성

에러 코드 처리

설정한 어노테이션은 오류코드 기반으로 MessageCodesResolver를 통해 메시지 코드 생성

ex

복사
@NotBlank

NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank

@Range

Range.item.price
Range.price
Range.java.lang.Integer
Range

해당 오류코드로 errors.properties에 등록 해 별도의 파일로 메시지 관리

BeanValidation 메세지 찾는 순서

    1. 생성된 메세지 코드 순서대로 messageSource 에서 메시지 확인
    1. 어노테이션의 message 속성 사용 -> @NotBlank(message = “오류”)
    1. message 속성이 없는 경우 기본 값 사용

오브젝트 오류

어노테이션을 통해서 validation기능을 사용해 필드 에러 처리

오브젝트 오류는 @ScriptAssert()를 사용한다.

실제 에러처리를 할 땐 제약이 많고 복잡하므로 오브젝트 오류의 경우 @ScriptAssert를 억지로 사용하는 것 보단

오브젝트 오류만 직접 자바 코드로(bidingResult 사용) 작성 권장

복사
// 특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
        bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
    }
}

케이스 별 검증

하나의 model entity를 다르게 검증할 땐 2가지 방법으로 구현이 가능하다

  • Bean Validation의 groups 기능 사용
  • Item을 직접 사용하지 않고 별도의 모델 객체(DTO)를 만들어 사용

groups기능을 사용할 땐 @Validated 사용 - (@Valid에는 기능이 없음)

복사

// entity

@Data
public class Item {

    @NotNull(groups = UpdateCheck.class) //수정시에만 적용
    private Long id;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    private String itemName;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Range(min= 1000, max = 1000000)
    private Integer price;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Max(99999)
    private Integer quantity;

    public Item() {
    }

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


// controller

public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
  ...
}

실무에선 DTO를 더 많이 사용함

DTO 사용

entity 객체에서 검증 어노테이션을 제거하고 DTO객체 생성 후 검증로직 옮겨준다.

controller에서 변환 코드 추가

복사
// Item
@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;
    }
}


// ItemSaveForm
@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

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

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

}

// controller
@Slf4j
@Controller
@RequestMapping("/validation/v4/items")
@RequiredArgsConstructor
public class ValidationItemControllerV4 {

    ...

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

        // 특정 필드 예외가 아닌 전체 예외
        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}";
    }

    ...
}

HTTP Message Converter

@Valid, @ValidatedHttpMessageConverter@RequestBody에도 적용 가능

참고로

ModelAttribute는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)에 사용
@RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용 -> 주로 API JSON 요청을 다룰 때 사용

API의 경우 3가지 결과

  • 성공
  • Json 객체 생성 실패
  • 검증 실패

응답은 BindingResult의 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 맞는 객체를 반환

@ModelAttribute는 필드 단위로 정교하게 바인딩 적용
-> 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩, Validator를 사용한 검증 적용 가능

@RequestBody는 HttpMessageConverter 단계에서 JSON데이터를 객체로 변경하지 못하면 이후 단계가 진행되지 않고 예외 발생


Springboot + jpa를 사용한 프로젝트의 validate는 따로 글을 작성해서 정리한다.

6. 로그인 처리 1 (쿠키, 세션)

패키지 설계할 때 domainweb단을 구분한다.

도메인 : 화면, UI, 인프라 영역을 제외한 시스템이 구현해야 하는 핵심 비즈니스 업무 영역

이렇게 구성하면 web은 domain을 알고 있지만 의존하지만, domain은 web에 의존하지 않는다.

쿠키

사용자의 로그인을 유지하기 위해선 웹브라우저엔 쿠키값을 가지고 서버는 세션값을 유지한다.

쿠키에는 영속 쿠키와 세션 쿠키가 있는데

영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
세션 쿠키 : 만료 날짜는 생략하면 브라우저 종료 시 까지만 유지

쿠키 사용 시 값을 그대로 노출하면 보안 상 문제 -> 임의의 랜덤 토큰값을 노출하고

서버세어 토큰과 사용자 id를 매핑하여 인식

처리방법

  1. 생성 시 HttpSession 사용
  2. 검증 시 HttpSession 사용 or @SessionAttribute 사용

  1. httpSession 사용
복사
request.getSession(true);

request.getSession(false);

request.getSession() 사용

  • 세션이 있으면 기존 세션 반환

true값을 넘길 경우

  • 세션이 없으면 새로운 세션 생성 후 반환

false값을 넘길 경우

  • 세션이 없으면 새로운 세션 생성x -> null 반환

  1. @SessionAttribete

이미 로그인 된 사용자를 찾을 때는

@SessionAttribute(name="loginMember", required = false) Member loginMember

사용해서 해당값을 검증한다.


추가로 웹 브라우저가 쿠키를 지원하지 않을 땐 쿠키 대신 URL을 통해서 세션을 유지시키는데

해당 기능을 끄기 위해선 application.properties에 다음과 같이 추가한다

server.servlet.session.tracking-modes=cookie

세션 데이터를 확인해보면 다음과 같은 값들이 존재한다.

  • sessionId : 세션Id, JSESSIONID 값 예) 34B14F008AA3527C9F8ED620EFD7A4E1
  • maxInactiveInterval : 세션의 유효 시간, 예) 1800초, (30분)
  • creationTime : 세션 생성일시
  • lastAccessedTime : 사용자가 최근 서버 접근 시간, 클라이언트에서 서버로 sessionId : ( JSESSIONID )를 요청한 경우에 갱신된다.
  • isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId(JSESSIONID)를 요청해서 조회된 세션인지 여부

세션의 종료 시점을 설정하기 위해선 application.properties에 다음과 같이 추가

server.servlet.session.timeout=1800 # 30분(1800초)가 default

lastAccessedTime이후로 timeout시간이 지나게 되면, WAS가 내부에서 해당 세션값 제거


세션은 기본적으로 메모리에 저장되는데 실무에서 사용 시 session storage를 추가해 사용한다.

7. 로그인 처리 2 (필터, 인터셉터)

7.1 서블릿 필터

필터 흐름

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

필터 인터페이스

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

  public void doFilter(ServletRequest request, ServletResponse response, Filter Chain chain) throws IOException, ServletException;

  public default void destroy() {]}
}

필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성 후 관리

  • init(): 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출
  • doFilter(): 고객의 요청이 올 때 마다 해당 메서드가 호출 (필터 로직 구현)
  • destroy(): 필터 종료 메서드, 서블릿 컨테이너가 종료 시 호출

webconfig를 만들어 filter등록

log filter test

복사
// LogFilter
@Slf4j
public class LogFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("log filter init");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestURI = httpRequest.getRequestURI();
        String uuid = UUID.randomUUID().toString();
        try {
            log.info("REQUEST  [{}][{}]", uuid, requestURI);
            chain.doFilter(request, response);
        } catch (Exception e) {
            throw e;
        } finally {
            log.info("RESPONSE [{}][{}]", uuid, requestURI);
        }
    }

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

// WebConfig
@Configuration
 public class WebConfig {
     @Bean
     public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();

        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");

        return filterRegistrationBean;
  }
}

필터를 등록하는 방법은 여러가지 있지만, FilterRegistrationBean을 사용해 등록

@ServletComponentScan @WebFilter(filterName = "logFilter", urlPatterns = "/*) 어노테이션을 사용해 필터 등록이 가능하지만, 이 경우엔 필터 순서 조절 불가능
-> FilterRegistrationBean 사용 권장


7.2 스프링 인터셉터

서블릿 필터 -> 서블릿이 제공
스프링 인터셉터 -> 스프링 MVC가 제공

둘 다 웹과 관련된 공통 관심 사항 처리

스프링 인터셉터 흐릅

HTTP요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러

  • 디스패처 서블릿과 컨트롤러 사이에 호출
  • 스프링 인터셉터는 스프링 MVC가 제공하는 기능이기 떄문에 디스패처 서블릿 이후 동작
  • 서블릿 URL보다 정밀하게 설정 가능
복사
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 {
	}
}
  • 서블릿 필터는 doFilter()만 제공이 됐는데, 인터셉터는 컨트롤러 호출 전(preHandle), 호출 후 (postHandle), 요청 완료 이후 (afterCompletion)으로 분리
  • 어떤 컨트롤러가 호출 되는지 호출 정보 활용 가능

preHandle()

  • 핸들러 실행 전의 인터셉션 지점
  • HandlerMapping이 적절한 핸들러 객체를 결정한 후 HandlerAdapter가 핸들러를 호출하기 전에 호출
  • 컨트롤러 이전에 처리해야 되는 전처리 작업
  • 3번째 파라미터 handler 파라미터는 컨트롤러 빈에 매핑되는 HandlerMethod라는 새로운 타입의 객체로써, @RequestMapping이 붙은 메소드의 정보를 추상화한 객체
  • preHandle의 반환 값이 true면 다음 단계로 진행, false면 이후 작업은 진행되지 않음.

postHandle()

  • 핸들러가 성공적으로 실행된 후의 인터셉션 지점
  • HandlerAdapter가 핸들러를 호출한 후 DispatcherServlet이 뷰를 렌더링하기 전에 호출
  • 지정된 ModelAndView를 통해 뷰에 추가 모델 객체를 노출할 수 있다.
  • 컨트롤러 이후 처리해야 되는 후처리 작업

afterCompletion()

  • 요청 처리가 완료된 후, 즉 뷰를 렌더링한 후 호출
  • 핸들러 실행의 모든 결과에 대해 호출되므로 적절한 리소스 정리 가능
  • afterCompletion은 컨트롤러 중간에 예외가 터지더라도 호출

인터셉터 정상 흐름

preHandle 호출 -> 컨트롤러 -> postHandle 호출 -> 뷰 렌더링 -> afterCompletion 호출

인터셉터 예외 발생 시

preHandle 호출 -> 컨트롤러 예외 발생 -> 예외 전달 되면 postHandle 호출 x
-> afterCompletion은 항상 호출 (예외를 파라미터로 받아 어떤 예외인지 오류 출력 가능)

예외와 무관하게 공통 처리를 하려면 afterCompletion() 사용

7.3 Argumentresolver 활용

Argumentresolver란 주어진 요청을 처리할 때, 메서드 파라미터를 인자값들에 주입시켜주는 전략 인터페이스

쿼리 스트링 변수에 바인딩 -> @RequestParam
가변적인 경로를 바인딩 -> @PathVariable
HTTP Body 바인딩 -> @RequestBody

HTTP Header, Session, Cookie 등 직접적이지 않은 방식 혹은 외부 데이터 저장소로부터 데이터를 바인딩해야할 땐 -> Argument Resolver 사용

Argument Resolver를 사용하면 컨트롤러 요청에 있는 메서드의 파라미터 중 특정 조건에 맞는 파라미터를 이용해 원하는 객체를 만들어 바인딩 가능

Argument Resolver를 사용하지 않고 Controller에서 처리할 수 있지만, 사용하면 반복되는 복잡한 로직 없이 깔끔하게 코드 관리 가능

구현 방법

  1. HandlerMethodArgumentResolver를 상속한 resolver 구현
  2. WebConfig 등록
  3. 여기까지 하면 특정 class에 대한 검증, 바인딩이 가능한데 특정 조건에 맞을 때만 검증하고 싶을 떈 어노테이션을 구현해 입력값에 어노테이션을 붙여준다.

resolver

복사
@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");

        boolean hasLoginAnnotations = parameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());

        return hasLoginAnnotations && hasMemberType;

    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        log.info("resolveArgument 실행");
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }
        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}

supportsParameter : 주어진 파라미터가 Argument Resolver에서 지원하는 타입인지 검사
resolveArgument : 반환값이 대상이 되는 메소드의 파라미터에 바인딩

webConfig

복사
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }

    ...

}

annotation

복사
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}

@Target : 파라미터에만 사용 정의
@Retention : 리플렉션 등을 활용할 수 있게 런타임까지 어노테이션 정보가 남아있게 설정

Controller

복사
    @GetMapping("")
    public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {
        //세션이 없으면 home
        if (loginMember == null) {
            return "home";
        }
        //세션이 유지되면 로그인으로 이동
        model.addAttribute("member", loginMember);
        return "loginHome";
    }

8. 예외 처리와 오류 페이지

8.1 서블릿 컨테이너의 예외 처리

2가지 방식으로 예외 처리 지원

  1. Exception
  2. response.sendError(Http 상태 코드, 오류 메세지)

톰캣은 기본적으로 오류가 났을 땐 500에러, 페이지(리소스) 없을 땐 404에러 반환

다른 화면을 보여주고 싶을 땐 에러 코드에 대한 리턴 주소를 지정하고 컨트롤러에서 화면을 정의한다.

추가로 WAS는 오류 페이지를 단순히 다시 요청만 하는 것이 아니라, 오류 정보를 requestattribute에 추가해서 넘겨준다.

복사
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    @Override
    public void customize(ConfigurableWebServerFactory 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);
    }
}

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

...

지정한 response.sendError를 통해 전달 된 httpStatus 상태나 Exception 발생 시 처리 -> 해당 controller를 만들어 리턴한다.

구현된 부분의 흐름을 살펴보면

springArchi

https://gowoonsori.com/spring/architecture/

filterInterceptorAOP

https://jake-seo-dev.tistory.com/83

예외 발생 시

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

sendError 호출 시

컨트롤러(sendError) -> 인터셉터 -> 서블릿 -> 필터 -> WAS(sendError 호출 확인)

이렇게 구현하면 filter나 intercepter를 2번 호출한다.

즉, 요청을 구분해야 한다.

WAS는 오류페이지를 단순히 요청하는 것만 아니라 오류 정보를 requestattribute에 추가해 넘겨준다.

복사
// 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";

서블릿은 이러한 문제를 해결하기 위해 DispatcherType 추가 정보 제공

DispatcherType
REQUEST : 클라이언트 요청
ERROR : 오류 요청
FORWARD : MVC에서 배웠던 서블릿에서 다른 서블릿이나 JSP를 호출할 때 RequestDispatcher.forward(request, response);
INCLUDE : 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때 RequestDispatcher.include(request, response);
ASYNC : 서블릿 비동기 호출

필터나 인터셉터에 적용될 filter나 intercepter를 명시해주는데

필터에 조건을 추가하고 싶을 떈 setDispatcherTypes로 처리할 request를 명시
인터셉터에 특정 조건을 제외하고 싶을 땐 excludePathPatterns로 제외

setDispatcherType의 기본값은 REQUEST라 특별히 명시하지 않으면 client의 request 요청만 처리 된다.

복사
// web config
@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/**");//오류 페이지 경로
    }

    ...

    // filter는 특별히 명시하지 않으면 기본 request만 처리

}

8.2 스프링 부트 예외 처리 페이지

서블릿에서는

  1. WebServerCustomizer 생성
  2. 예외 종류에 따라 ErrorPage추가 -> 처리용 ErrorPageController 생성

스프링 부트는 에러 상황에 대한 페이지 노출을 기본적으로 제공

  • ErrorPage 를 자동으로 등록
  • /error 라는 경로로 기본 오류 페이지를 설정
    • new ErrorPage("/error") , 상태코드와 예외를 설정하지 않으면 기본 오류 페이지로 사용
    • 서블릿 밖으로 예외가 발생하거나, response.sendError(...) 가 호출되면 모든 오류는 /error 를 호출
  • ErrorPage 에서 등록한 /error 를 매핑해서 처리하는 컨트롤러 BasicErrorController 자동 등록
  • ErrorMvcAutoConfiguration가 오류 페이지를 자동으로 등록하는 역할

즉, BasicErrorController에 기본적인 로직이 모두 개발되어 있으니

오류 페이지 화면만 BasicErrorController가 제공하는 룰과 우선순위에 따라 등록하면 된다.

view 선택 우선순위

  1. 뷰 템플릿
  • resources/templates/error/500.html
  • resources/templates/error/5xx.html
  1. 정적 리소스(static, public)
  • resources/static/error/400.html
  • resources/static/error/404.html
  • resources/static/error/4xx.html
  1. 적용 대상이 없는 경우 뷰 이름
  • resources/templates/error.html`

BasicErrorController 컨트롤러는 다음 정보를 model에 담아서 뷰에 전달한다. 뷰 템플릿은 이 값을 활용해서 출력할 수 있다.

에러 페이지 노출 시 제공하는 정보

  • 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)

에러 값을 전부 노출하는건 좋지 않으므로

ErrorController로 넘겨줄 때 오류 정보를 model에 포함할 지 여부를 properties에 설정 가능

복사
// application.properties

server.error.include-exception=true // exception 포함 여부
server.error.include-message=on_param // message 포함 여부
server.error.include-stacktrace=on_param // trace 포함 여부
server.error.include-binding-errors=on_param // error 포함 여부

// `never` : 사용하지 않음
// `always` :항상 사용
// `on_param` : 파라미터가 있을 때 사용

server.error.whitelabel.enabled=true // 오류 처리 화면을 못 찾을 시, 스프링 whitelabel 오류 페이지 적용
`server.error.path=/error //오류 페이지 경로, 스프링이 자동 등록하는 서블릿 글로벌 오류 페이지 경로와 `BasicErrorController` 오류 컨트롤러 경로에 함께 사용

에러 공통 처리 컨트롤러 기능 변경이 필요한 경우

ErrorControllerBasicErrorController를 상속 받아서 기능 추가하면 된다.


추가 Spring Filter에서 주로 사용되는 예시

  1. Authentication Filters (인증 필터): 사용자의 인증 상태를 확인하고, 필요한 경우 인증을 처리하는 데 사용

  2. Logging and Auditing Filters (로깅 및 감사 필터): 요청 및 응답의 로깅, 감사 로그 작성 등과 같은 로깅 및 감사 작업을 수행하는 데 사용

  3. Image conversion Filters (이미지 변환 필터): 이미지를 다른 형식으로 변환하거나 크기를 조절하는 등의 이미지 처리 작업에 사용

  4. Data compression Filters (데이터 압축 필터): 요청이나 응답 데이터를 압축하여 대역폭을 절약하거나 응답 시간을 개선하는 데 사용

  5. Encryption Filters (암호화 필터): 데이터의 암호화 및 복호화 작업에 사용

  6. Tokenizing Filters (토큰화 필터): 데이터를 특정 기준으로 분할하거나 토큰화하는 데 사용

  7. Filters that trigger resource access events (리소스 액세스 이벤트를 트리거하는 필터): 특정 리소스에 대한 액세스 이벤트를 감지하고 트리거하는 데 사용

  8. XSL/T filters (XSL/T 필터): XML 기반의 데이터를 변환하는 데 XSL/T 스타일시트를 사용하여 필요한 HTML, XML 또는 다른 형식으로 변환하는 데 사용

  9. Mime-type chain Filter (마임형 체인 필터): 요청의 마임 타입에 따라 다른 처리를 수행하는 데 사용

9. API 예외 처리

Rest Api의 응답은 오류 상황에 맞는 응답 스펙 정의, JSON 데이터 생성 작업이 필요하다
(해당 작업이 되어있지 않으면 HTML 반환)

복사
@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");

  Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);

  Map<String, Object> result = new HashMap<>();
  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));
 }

@RequestMapping에 produces 파라미터를 지정해 넘겨 HTTP Header의 Accept값이 application/json 일 때 메서드가 호출되게 수정

ResponseEntity를 사용해서 응답하기 때문에 메시지 컨버터가 동작하고 JSON 반환

스프링 부트 기본 오류 처리

BasicErrorController에 html, json 에러 처리가 되어있고, 확장해서 상황에 따라 에러처리 할 수 있지만 API오류 처리는 조건이 다양해 전부 확장하기엔 관리의 어려움이 있음

@ExceptionHandler를 사용해 오류 처리 권장

HandlerExceptionResolver

예외 발생 시 동작을 새로 정의할 수 있는 방법

적용 전 : 예외가 전달되서 500에러 발생

적용 후 : 예외가 전달되면 ExceptionResolver가 호출되고 정상 처리

ExceptionResolver로 예외

복사
//controller
    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        ...
        if (id.equals("bad")) {
            throw new IllegalArgumentException("잘못된 입력 값");
        }
        ...
    }

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {

    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {

        log.info("call resolver", 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;
    }
}

// webconfig
@Configuration
public class WebConfig implements WebMvcConfigurer {

    ...

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

    ...
}

HandlerExceptionResolver를 상속해 MyHandlerExceptionResolver를 구현하고 webconfig에 HandlerExceptionResolvers에 등록시켜준다.

반환값에 따른 동작은 3가지로 구분

  1. 빈 ModelAndView: ModelAndView` 를 시 정상 흐름으로 서블릿이 리턴된다.
  2. ModelAndView 지정: ModelAndViewView , Model 등의 정보를 지정해서 반환하면 뷰 렌더링
  3. null: null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver 가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.

활용 방법 3가지

  1. 예외를 response.sendError(xxx)로 변경해서 상태 코드에 따른 오류 처리 -> 스프링 부트에서 설정한 error 호출
  2. ModelAndView값을 만들어 제공
  3. response 응답 바디에 직접 데이터를 설정해 응답 처리 response.getWriter()~~

활용

복사
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);
    }
}

//controller

    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        ...
        if (id.equals("user-ex")) {
            throw new UserException("사용자 오류");
        }
        ...
    }

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

                if ("application/json".equals(acceptHeader)) {
                    Map<String, Object> errorResult = new HashMap<>();
                    errorResult.put("ex", ex.getClass());
                    errorResult.put("message", ex.getMessage());
                    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;
    }
}

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

custom한 Exception을 만들어 추가 가능하다.

SpringBoot의 ExceptionResolver 처리

스프링 부트가 등록해주는 ExceptionResolver는 다음과 같다.
(HandlerExceptionResolverComposite가 등록)

HandlerExceptionResolverComposite

각각 resolver가 하는 역할을 살펴보면

  1. ExceptionHandlerExceptionResolver

@ExceptionHandler 처리

  1. ResponseStatusExceptionResolver

ResponseStatusExceptionResolver를 확인해보면 @ResponseStatus의 code값과 reason값을 response.sendError를 호출해 처리

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

ResponseStatusExceptionResolver를 사용해 에러처리하면 ResponseStatusException를 활용해 조건에 따라 exception처리

복사
@GetMapping("/api/response-status-ex2")
 public String responseStatusEx2() {
     throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new
 IllegalArgumentException());
}
  1. DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver 는 스프링 내부에서 발생하는 스프링 예외를 해결 -> 500에러가 아닌 400으로 반환 (ex - TypeMismatchExcpetion)

@ExceptionHandler

BasicErrorControllerHandlerExceptionResolver를 전부 확장해서 구현하는 방법보다 간단히 @ExceptionHandler 활용

@ExceptionHandler 애노테이션을 적용하고 싶은 컨트롤러에서 예외를 지정
-> 컨트롤러에서 예외 발생 시 메서도 호출
-> 예외를 상속받은 자식 클래스까지 모두 처리
-> 추가로 해당 컨트롤러 적용 시 200을 반환하는데 status를 변경하고 싶으면 @ResponseStatus를 추가한다.

복사
@Slf4j
@RestController
public class ApiExceptionV2Controller {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        log.error("[exceptionHandle] 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;
    }
}

// 여러 익센셥 처리
@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
    log.info("exception e", e);
}

// HTML리턴
@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
    log.info("exception e", e);
    return new ModelAndView("error");
}

SpringBoot exception 문서

흐름을 간단히 정리하면

  1. Exception시 컨트롤 밖으로 던져진다.
  2. 우선 순위대로 ExceptionResolver 실행
  3. 처리할 수 있는 Handler 확인
  4. Handler가 처리, @RestController가 컨트롤러에 있어 @ResponseBody 적용 -> JSON으로 반환

Advice

AOP활용

정리

  1. ExceptionHandlerExceptionResolver -> Exception을 처리 할 Handler 추가
  2. ResponseStatusExceptionResolver -> HTTP 응답 코드 변경
  3. DEfaultHandlerExceptionResolver -> 스프링 내부 예외 처리

best 적용 방법

  1. Controller에서 상황에 맞춰 처리 할 exception으로 반환
  2. RestControllerAdvice에서 exception마다 처리 방법 구현 -> 구현 시 @ExceptionHandle 활용
  3. RestcontrollerAdvice에서 적용 할 메서드 지정
복사
// 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 {}

Spring controller advice 문서

10. 스프링 타입 컨버터

Request(필요한 값을 담아) -> 서버에서 처리 -> Response(필요한 값 담아) -> 클라이언트 처리

request는 요청 파라미터가 모두 문자로 처리되는데 보통 입력받을 때 사용하는 @RequestParam, @ModelAttribute, @PathVariable는 Spring이 적용해준다.

입력값을 custom할 때 사용하는 기능이 Converter다.

복사
package org.springframework.core.convert.converter;

public interface Converter<S, T> {
  T convert(S source);
}

Converter : 범용 (객체 -> 객체)
Formatter : 문자체 특화(객체 -> 문자, 문자 -> 객체) + 현지화(Locale)

추가로

컨버전은 @RequestParam , @ModelAttribute , @PathVariable , 뷰 템플릿 등에서 사용가능 하지만

메세지 컨버터(HttpMessageConverter)에는 컨버전 서비스가 적용X

메시지 컨버터는 내부에서 Jackson같은 라이브러리를 사용
-> JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 라이브러리가 제공하는 설정을 통해 포맷 지정

추가 Tip!

  • 요청마다 로그 찍는 설정
복사
logging.level.org.apache.coyote.http11=debug

Reference

김영한 스프링 MVC 2 강의 Thymeleaf Document Thymeleaf + Spring Document
[level2. 협업 미션]Interceptor와 Argument Resolver - 포모
스프링에서 Argument Resolver 사용하기 - Hudi 공통 인증 로직 어디서 처리 할 수 있을까?(feat. Interceptor, Filter, Resolver) - 애송이 개발자 블로그

Previous Post
Gatsby Blog Theme 제작기 2
Next Post
CMake, Makefile에 대해 알아보자
Thank You for Visiting My Blog, I hope you have an amazing day 😆
© 2023 Ian, Powered By Gatsby.