no image
[Spring MVC] 스프링 타입 컨버터
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 스프링 타입 컨버터 스프링 프레임워크에서 타입 컨버터(Type Converter)는 한 타입의 객체를 다른 타입으로 변환할 때 사용되는 메커니즘이다. 스프링 MVC에서는 클라이언트로부터 받은 문자열 데이터를 컨트롤러의 파라미터나 필드에 지정된 타입의 객체로 변환할 필요가 자주 있다. 예를 들어, URL 경로에서 가져온 문자열을 날짜 객체나 열거형, 심지어는 사용자 정의 타입으로 변환해야 할 때가 그러하다. 이럴 때 스프링의 타입 컨버터 기능이 유용하게 사용된다. @GetMapping("/hello-v2") public String helloV2(@RequestParam Integer data) { System...
2024.03.24
no image
[Spring MVC] API 예외 처리
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 기본 API 예외 처리 예외가 발생하면 어떠한 경로로 다시 요청할지 정의 WebServerCustomizer @Component public class WebServerCustomizer implements WebServerFactoryCustomizer { @Override public void customize(ConfigurableWebServerFactory factory) { ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404"); //errorPage404 호출 ErrorPage errorPage500 = new ..
2024.03.23
no image
구글 클라우드 비전 OCR API 스프링 부트에서 사용
구글 클라우드 비전 플랫폼 설정 API 및 서비스 구글 클라우드 의 콘솔 창으로 들어가면 왼쪽 상단의 버튼을 눌러 API 및 서비스 - 사용자 인증 정보로 들어간다. 사용자 인증 정보 창에서 사용자 인증 정보 만들기 버튼을 누른다. API 키 를 추가한다. API 활성화 이후 API 및 서비스 - 사용 설정된 API 및 서비스 버튼을 클릭한다. Clould Vision API 를 사용 버튼을 누른다. IAM 및 관리자 IAM 및 관리자 - 서비스 계정 버튼 클릭 아래의 정보들을 입력한다. 아래의 부분은 선택사항이다. 실험 결과 이 부분을 일부 누락해도 OCR API를 사용하는데 지장이 없었다. 만든 계정의 키 관리 버튼을 누른다. 새 키 만들기를 누른 뒤, JSON 파일을 다운로드 받는다. 스프링 부트 ..
2024.03.22
no image
[Spring MVC] 예외 처리와 오류페이지
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 서블릿 예외 처리 서블릿은 다음 2가지 방식으로 예외 처리를 지원한다. Exception (예외) response.sendError(HTTP 상태 코드, 오류 메시지) Exception(예외) 자바 직접 실행 자바의 메인 메서드를 직접 실행하는 경우 main 이라는 이름의 쓰레드가 실행된다. 실행 도중에 예외를 잡지 못하고 처음 실행한 main() 메서드를 넘어서 예외가 던져지면, 예외 정보를 남기고 해당 쓰레드는 종료된다. 웹 애플리케이션 웹 애플리케이션은 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행된다. 애플리케이션에서 예외가 발생했는데, 어디선가 try ~ catch로 예외를 잡..
2024.03.21
no image
[Spring MVC] ArgumentResolver 를 이용한 @Login 어노테이션 구현
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.@Login 애노테이션이 있으면 직접 만든 ArgumentResolver 가 동작해서 자동으로 세션에 있는 로그인 회원을 찾아주고, 만약 세션에 없다면 null 을 반환하도록 개발 HomeController - 추가기존@GetMapping("/")public String homeLoginV3Spring( @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model) { //세션에 회원 데이터가 없으면 home if (loginMem..
2024.03.20
no image
[Spring MVC] HTTP MessageConverter, RequestMapping HandlerAdapter
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. HTTP MessageConverter HTTP MessageConverter는 스프링 프레임워크에서 HTTP 요청 및 응답 본문을 객체로 변환하거나 객체를 HTTP 응답 본문으로 변환하는 역할을 하는 구성 요소이다. 클라이언트와 서버 간의 데이터 교환은 주로 JSON, XML 등의 형식으로 이루어지는데, MessageConverter는 이러한 데이터 형식을 애플리케이션 내부에서 사용하는 객체로 쉽게 변환해주거나, 반대로 객체를 이러한 데이터 형식으로 변환해주는 작업을 담당한다. 뷰 템플릿으로 HTML을 생성해서 응답하는 것이 아니라, HTTP API처럼 JSON 데이터를 HTTP 메시지 바디에서 직접 읽거나 ..
2024.03.19
no image
[Spring MVC] 필터, 인터셉터(로그인 처리 관련)
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 로그인 한 사용자만 상품 관리 페이지에 들어갈 수 있어야 한다. 로그인 하지 않은 사용자도 URL을 직접 호출하면 상품 관리 화면에 들어갈 수 있다. 이렇게 로그인하지 않은 사용자는 다른 URL에 접근할 수 없도록 해주는 것이 서블릿 필터와 인터셉터이다. 애플리케이션 여러 로직에서 공통으로 관심이 있는 있는 것을 공통 관심사(cross-cutting concern)라고 한다. 여기서는 등록, 수정, 삭제, 조회 등등 여러 로직에서 공통으로 인증에 대해서 관심을 가지고 있다. 이러한 공통 관심사는 스프링의 AOP로도 해결할 수 있지만, 웹과 관련된 공통 관심사는 지금부터 설명할 서블릿 필터 또는 스프링 인터셉터를..
2024.03.18
no image
[Spring MVC] 세션을 이용한 로그인
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 세션을 이용한 로그인 처리 쿠키에 중요한 정보를 보관하는 방법은 여러가지 보안 이슈가 있다. 이 문제를 해결하려면 결국 중요한 정보를 모두 서버에 저장해야 한다. 그리고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다. 이렇게 서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다. 사용자가 loginId , password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다. 세션 ID를 생성하는데, 추정 불가능해야 한다. →UUID는 추정이 불가능하다. UUID ex) mySessionId=zz0101xx-bab9-4b92-9b32-dadb280f4b61 세션 저장소의 ..
2024.03.17

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


스프링 타입 컨버터

스프링 프레임워크에서 타입 컨버터(Type Converter)는 한 타입의 객체를 다른 타입으로 변환할 때 사용되는 메커니즘이다. 스프링 MVC에서는 클라이언트로부터 받은 문자열 데이터를 컨트롤러의 파라미터나 필드에 지정된 타입의 객체로 변환할 필요가 자주 있다.

 

예를 들어, URL 경로에서 가져온 문자열을 날짜 객체나 열거형, 심지어는 사용자 정의 타입으로 변환해야 할 때가 그러하다. 이럴 때 스프링의 타입 컨버터 기능이 유용하게 사용된다.

 

@GetMapping("/hello-v2")
 public String helloV2(@RequestParam Integer data) {
     System.out.println("data = " + data);
     return "ok";
}

http://localhost:8080/hello-v2?data=10

HTTP 쿼리 스트링으로 전달하는 data=10 부분에서 10은 숫자 10이 아니라 문자 10이다.

스프링이 제공하는 @RequestParam 을 사용하면 이 문자 10을 Integer 타입의 숫자 10으로 편리하게 받을 수 있다.

이것은 스프링이 중간에서 타입을 변환해주었기 때문이다.

이러한 예는 @ModelAttribute , @PathVariable 에서도 확인할 수 있다.

 

@ModelAttribute 타입 변환 예시

@ModelAttribute UserData data
 class UserData {
   Integer data;
}

 

@PathVariable 타입 변환 예시

// /users/{userId}
@PathVariable("userId") Integer data

URL 경로는 문자다. /users/10 → 여기서 10도 숫자 10이 아니라 그냥 문자 "10"이다. data를 Integer 타입으로 받을 수 있는 것도 스프링이 타입 변환을 해주기 때문이다.

 

스프링의 타입 변환 적용 예

  • 스프링 MVC 요청 파라미터 (@RequestParam , @ModelAttribute , @PathVariable)
  • @Value 등으로 YML 정보 읽기
  • XML에 넣은 스프링 빈 정보를 변환
  • 뷰를 렌더링 할 때

 


스프링과 타입 변환

만약 개발자가 새로운 타입을 만들어서 변환하고 싶다면 컨버터 인터페이스를 확장하면 된다.

 

타입 컨버터 - Converter

컨버터 인터페이스

package org.springframework.core.convert.converter;
 public interface Converter<S, T> {
   T convert(S source);
}

 

Converter 인터페이스: 가장 기본적인 타입 변환 인터페이스로, S 타입을 T 타입으로 변환하는 하나의 메소드 convert를 정의한다.

 

개발자는 스프링에 추가적인 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해서 등록하면 된다.

이 컨버터 인터페이스는 모든 타입에 적용할 수 있다.

 

필요하면 X → Y 타입으로 변환하는 컨버터 인터페이스를 만들고, 또 Y → X 타입으로 변환하는 컨버터 인터페이스를 만들어서 등록하면 된다.

 

StringToIntegerConverter - 문자를 숫자로 변환하는 타입 컨버터

@Slf4j
 public class StringToIntegerConverter implements Converter<String, Integer> {
     @Override
     public Integer convert(String source) {
         log.info("convert source={}", source);
         return Integer.valueOf(source);
     }
}

 

IntegerToStringConverter - 숫자를 문자로 변환하는 타입 컨버터

@Slf4j
 public class IntegerToStringConverter implements Converter<Integer, String> {
     @Override
     public String convert(Integer source) {
         log.info("convert source={}", source);
         return String.valueOf(source);
     }
}

 

사용자 정의 타입 컨버터

127.0.0.1:8080 과 같은 IP, PORT를 입력하면 IpPort 객체로 변환하는 컨버터

 

IpPort

@Getter
 @EqualsAndHashCode
 public class IpPort {
     private String ip;
     private int port;
     public IpPort(String ip, int port) {
         this.ip = ip;
         this.port = port;
     }
}

 

StringToIpPortConverter

@Slf4j
 public class StringToIpPortConverter implements Converter<String, IpPort> {
 @Override
     public IpPort convert(String source) {
         log.info("convert source={}", source);
         String[] split = source.split(":");
         String ip = split[0];
         int port = Integer.parseInt(split[1]);
         return new IpPort(ip, port);
     }
}

 

IpPortToStringConverter

@Slf4j
 public class IpPortToStringConverter implements Converter<IpPort, String> {
     @Override
     public String convert(IpPort source) {
         log.info("convert source={}", source);
         return source.getIp() + ":" + source.getPort();
     }
}

 

참고

스프링은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다.

Converter → 기본 타입 컨버터
ConverterFactory → 전체 클래스 계층 구조가 필요할 때
GenericConverter → 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
ConditionalGenericConverter → 특정 조건이 참인 경우에만 실행


자세한 내용은 공식 문서를 참고
https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#core- convert

스프링은 문자, 숫자, 불린, Enum등 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공한다.
IDE에서 Converter , ConverterFactory , GenericConverter 의 구현체를 찾아보면 수 많은 컨버터를 확인할 수 있다.

 

컨버전 서비스 - ConversionService

타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용하는 것은 매우 불편하다.

그래서 스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공하는데, 이것이 바로 컨버전 서비스
( ConversionService )이다.

 

ConversionService 인터페이스

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 인터페이스: 다양한 Converter와 Formatter를 등록하고 관리하는 중앙 인터페이스로, 실제 타입 변환 작업을 이 인터페이스를 통해 수행한다.

 

  • boolean canConvert(Class<?> sourceType, Class<?> targetType): 지정된 소스 타입에서 타겟 타입으로의 변환이 가능한지 여부를 반환한다.
  • boolean canConvert(TypeDescriptor sourceType, TypeDescriptor targetType): TypeDescriptor를 사용하여 보다 세밀한 제네릭 타입 정보를 포함한 변환 가능 여부를 확인한다.
  • <T> T convert(Object source, Class<T> targetType): 소스 객체를 타겟 타입의 객체로 변환한다.
  • Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType): TypeDescriptor를 사용하여 소스 객체를 타겟 타입의 객체로 변환한다.

 

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

 

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

        //사용
        assertThat(conversionService.convert("10", Integer.class)).isEqualTo(10);
        assertThat(conversionService.convert(10, String.class)).isEqualTo("10");
        IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
        assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));
        String ipPortString = conversionService.convert(new IpPort("127.0.0.1", 8080), String.class);
        assertThat(ipPortString).isEqualTo("127.0.0.1:8080");
    }
}

 

 

웹 애플리케이션에 Converter 적용(등록)

@Configuration
 public class WebConfig implements WebMvcConfigurer {
     @Override
     public void addFormatters(FormatterRegistry registry) {
         registry.addConverter(new StringToIntegerConverter());
         registry.addConverter(new IntegerToStringConverter());
         registry.addConverter(new StringToIpPortConverter());
         registry.addConverter(new IpPortToStringConverter());
	}
}
@GetMapping("/ip-port")
 public String ipPort(@RequestParam IpPort ipPort) {
     System.out.println("ipPort IP = " + ipPort.getIp());
     System.out.println("ipPort PORT = " + ipPort.getPort());
     return "ok";
}

 

스프링은 내부에서 ConversionService 를 제공한다.

 

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

스프링은 내부에서 수 많은 기본 컨버터들을 제공한다.

컨버터를 추가하면 추가한 컨버터가 기본 컨버터 보다 높은 우선 순위를 가진다.

 

@RequestParam 은 @RequestParam 을 처리하는 ArgumentResolver인 RequestParamMethodArgumentResolver 에서ConversionService 를 사용해서 타입을 변환한다.

 

부모 클래스와 다양한 외부 클래스를 호출하는 등 복잡한 내부 과정을 거치기 때문에 대략 이렇게 처리되는 것으로 이해해도 충분하다. 

 

DefaultConversionService

ConversionService 인터페이스의 기본 구현체 중 하나이며, 다양한 일반적인 변환 케이스를 위한 컨버터들이 사전에 등록되어 있다. 이를 통해 개발자는 추가적인 컨버터 구현 없이도 많은 표준 타입 간의 변환을 쉽게 수행할 수 있다. 예를 들어, 문자열을 숫자, 날짜, 열거형 등 다양한 타입으로 변환하거나 그 반대의 변환을 지원한다.

DefaultConversionService를 사용하면 개발자는 타입 변환을 위한 복잡한 코드를 직접 작성할 필요 없이, 스프링이 제공하는 풍부한 타입 변환 기능을 바로 사용할 수 있다. 이는 특히 데이터 바인딩, 프로퍼티 편집, 설정 관리 등에서 유용하게 활용된다.

 

DefaultConversionService 사용 예제

import org.springframework.core.convert.support.DefaultConversionService;

public class ConversionExample {
    public static void main(String[] args) {
        // DefaultConversionService 인스턴스 생성
        DefaultConversionService conversionService = new DefaultConversionService();
        
        // 문자열을 Integer 타입으로 변환
        Integer intValue = conversionService.convert("123", Integer.class);
        
        // 문자열을 Boolean 타입으로 변환
        Boolean boolValue = conversionService.convert("true", Boolean.class);
        
        System.out.println(intValue); // 출력: 123
        System.out.println(boolValue); // 출력: true
    }
}

이 예제에서는 DefaultConversionService를 사용하여 문자열을 Integer와 Boolean 타입으로 간단하게 변환하는 과정을 보여준다. 이처럼 DefaultConversionService는 다양한 기본 타입 변환을 쉽게 처리할 수 있는 강력한 도구이다.

 

커스텀 컨버터 등록

DefaultConversionService는 사용자 정의 컨버터를 등록하여 사용할 수도 있다. 이는 특정 애플리케이션에 특화된 타입 변환을 필요로 할 때 유용하다.

import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.support.DefaultConversionService;

public class CustomConverterExample {
    public static void main(String[] args) {
        DefaultConversionService conversionService = new DefaultConversionService();
        
        // 사용자 정의 컨버터 등록
        conversionService.addConverter(new StringToLocalDateConverter());
        
        // 사용자 정의 컨버터를 이용한 변환
        LocalDate date = conversionService.convert("2023-03-28", LocalDate.class);
        System.out.println(date); // 출력: 2023-03-28
    }
    
    // String을 LocalDate로 변환하는 사용자 정의 컨버터
    private static class StringToLocalDateConverter implements Converter<String, LocalDate> {
        @Override
        public LocalDate convert(String source) {
            return LocalDate.parse(source);
        }
    }
}

 

등록과 사용 분리

컨버터를 등록할 때는 StringToIntegerConverter 같은 타입 컨버터를 명확하게 알아야 한다.

반면에 컨버터를 사용하는 입장에서는 타입 컨버터를 전혀 몰라도 된다. 타입 컨버터들은 모두 컨버전 서비스 내부에 숨어서 제공된다. 따라서 타입을 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다.

 

물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다.

 

ISP(Interface Segregation Principle, 인터페이스 분리 원칙)

소프트웨어 설계 원칙 중 하나로, SOLID 원칙의 한 부분이다. ISP는 "클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다"는 개념을 기반으로 한다. 즉, 하나의 큰 인터페이스보다는 필요한 메서드만을 제공하는 여러 개의 구체적인 인터페이스로 분리하는 것이 바람직하다는 원칙이다.


ISP의 목적
ISP의 주요 목적은 모듈 간의 결합도를 줄이고, 유지보수성과 재사용성을 향상시키는 것이다. 인터페이스가 너무 많은 역할을 담당하게 되면, 그 인터페이스를 구현하는 클래스가 필요하지 않은 메서드까지 구현해야 하는 상황이 발생한다. 이러한 상황은 코드의 복잡성을 증가시키고, 변경에 대한 유연성을 감소시킨다.


ISP를 적용하는 방법
→역할별 인터페이스 분리: 하나의 인터페이스에 여러 역할이 혼합되어 있다면, 역할별로 인터페이스를 분리해야 한다. 각 인터페이스는 하나의 명확한 책임만을 가져야 한다.
→구체적인 인터페이스 정의: 인터페이스는 사용하는 클라이언트에 최대한 밀접하게 정의되어야 한다. 클라이언트가 필요로 하는 메서드만을 포함시키도록 한다.
→인터페이스 상속과 구성 사용: 너무 많은 메서드를 가진 인터페이스는 상속을 통해 더 작은 인터페이스로 분리하거나, 구성을 사용하여 필요한 인터페이스를 조합할 수 있다.

 

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

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

 

이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다.

 

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

 

 

뷰 템플릿에 컨버터 적용하기

타임리프는 렌더링 시에 컨버터를 적용해서 렌더링 하는 방법을 편리하게 지원한다.
이전까지는 문자를 객체로 변환했다면, 이번에는 그 반대로 객체를 문자로 변환하는 작업을 확인할 수 있다.

 

컨트롤러

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

Model 에 숫자 10000 와 ipPort 객체를 담아서 뷰 템플릿에 전달한다.

 

converter-view.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
        <ul>
            <li>${number}: <span th:text="${number}" ></span></li>
            <li>${{number}}: <span th:text="${{number}}" ></span></li>
            <li>${ipPort}: <span th:text="${ipPort}" ></span></li>
            <li>${{ipPort}}: <span th:text="${{ipPort}}" ></span></li>
        </ul>
    </body>
</html>

타임리프는 ${{...}} 를 사용하면 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해준다. 물론 스프링과 통 합 되어서 스프링이 제공하는 컨버전 서비스를 사용하므로, 우리가 등록한 컨버터들을 사용할 수 있다.

 

  • 변수 표현식 : ${...}
  • 컨버전 서비스 적용 : ${{...}}

 

  • ${{number}} : 뷰 템플릿은 데이터를 문자로 출력한다. 따라서 컨버터를 적용하게 되면 Integer 타입 인 10000 을 String 타입으로 변환하는 컨버터인 IntegerToStringConverter 를 실행하게 된다. 이 부 분은 컨버터를 실행하지 않아도 타임리프가 숫자를 문자로 자동으로 변환히기 때문에 컨버터를 적용할 때와 하지 않을 때가 같다.
  • ${{ipPort}} : 뷰 템플릿은 데이터를 문자로 출력한다. 따라서 컨버터를 적용하게 되면 IpPort 타입을 String 타입으로 변환해야 하므로 IpPortToStringConverter 가 적용된다. 그 결과 127.0.0.1:8080 가 출력된다.

 

 

컨버터를 폼에 적용

converter-form.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
        <form th:object="${form}" th:method="post">
            th:field <input type="text" th:field="*{ipPort}"><br/>
            th:value <input type="text" th:value="*{ipPort}">(보여주기 용도)<br/> 
            <input type="submit"/>
        </form>
    </body>
</html>

 

Form 객체를 데이터를 전달하는 폼 객체로 사용

 

→GET /converter/edit : IpPort 를 뷰 템플릿 폼에 출력
→POST /converter/edit : 뷰 템플릿 폼의 IpPort 정보를 받아서 출력
타임리프의 th:field 는 컨버전 서비스도 함께 적용

 

GET /converter/edit
th:field 가 자동으로 컨버전 서비스를 적용해주어서 ${{ipPort}} 처럼 적용이 되었음, 따라서 IpPort → String 으로 변환된다.

POST /converter/edit
@ModelAttribute 를사용해서 String → IpPort 로 변환된다.

 

Formatter

Formatter는 스프링 프레임워크에서 제공하는 또 다른 타입 변환 방식으로, 주로 객체를 문자열로 변환하거나 문자열을 객체로 변환할 때 사용한다. Formatter는 Converter보다 좀 더 고수준의 추상화를 제공하며, 특히 국제화(i18n) 지원이 필요한 경우 유용하다.

기본 사용법

Formatter 인터페이스를 구현하여 사용자 정의 포맷터를 만들 수 있다. 이 인터페이스는 parse 메소드와 print 메소드 두 가지를 정의한다. parse 메소드는 문자열을 객체로 변환하고, print 메소드는 객체를 문자열로 변환한다.

 

public interface Printer<T> {
     String print(T object, Locale locale);
}

 public interface Parser<T> {
     T parse(String text, Locale locale) throws ParseException;
}

public interface Formatter<T> extends Printer<T>, Parser<T> {
 }

 

  • Printer<T> 인터페이스는 객체를 문자열로 변환하는 기능을 담당한다. print 메소드는 객체 T를 받아서 Locale에 맞는 문자열로 변환하여 반환한다. 이는 데이터를 사용자에게 보여줄 때 특히 유용하며, 로케일에 따른 날짜, 시간, 숫자 형식 변환 등에 사용될 수 있다.
  • Parser<T> 인터페이스는 문자열을 객체로 변환하는 기능을 담당한다. parse 메소드는 문자열과 Locale을 입력받아, 해당 문자열을 객체 T로 변환하여 반환한다. 이는 사용자 입력을 애플리케이션에서 사용할 수 있는 객체 형태로 변환할 필요가 있을 때 사용된다.
  • Formatter<T>는 Printer<T>와 Parser<T>의 기능을 모두 상속받으므로, 객체와 문자열 간의 양방향 변환을 담당한다. 이를 통해 개발자는 하나의 인터페이스 내에서 타입 변환 로직을 중앙화하여 관리할 수 있다. 특히, Locale을 고려한 변환 처리가 가능하기 때문에, 다국어 지원이 필요한 애플리케이션 개발에 매우 적합하다.

 

@Slf4j
public class MyNumberFormatter implements Formatter<Number> {
    @Override
    public Number parse(String text, Locale locale) throws ParseException {
        log.info("text={}, locale={}", text, locale);
        NumberFormat format = NumberFormat.getInstance(locale);
        //Number parse = format.parse(text);
        return format.parse(text);
    }
    @Override
    public String print(Number object, Locale locale) {
        log.info("object={}, locale={}", object, locale);
        //NumberFormat instance = NumberFormat.getInstance(locale);
        //String format = instance.format(object);
        return NumberFormat.getInstance(locale).format(object);
    }
}

 

"1,000" 처럼 숫자 중간의 쉼표를 적용하려면 자바가 기본으로 제공하는 NumberFormat 객체를 사용하면 된다. 이 객체는 Locale 정보를 활용해서 나라별로 다른 숫자 포맷을 만들어준다.

parse() 를 사용해서 문자를 숫자로 변환한다. 참고로 Number 타입은 Integer, Long 과 같은 숫자 타입의 부모 클래스이다.
print() 를 사용해서 객체를 문자로 변환한다.

 

테스트 코드

class MyNumberFormatterTest {
    MyNumberFormatter formatter = new MyNumberFormatter();

    @Test
    void parse() throws ParseException {
        Number result = formatter.parse("1,000", Locale.KOREA);
        assertThat(result).isEqualTo(1000L); //Long 타입 주의
    }

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

테스트 코드가 모두 통과한 것을 볼 수 있다.

 

공식 사이트 문서
https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#format

 

 

DefaultFormattingConversionService

ConversionService에 Formatter와 Converter의 등록 및 관리 기능을 추가한 것이다. 이 클래스는 기본적으로 스프링이 제공하는 여러 기본 Converter와 Formatter를 등록하며, 개발자가 추가적으로 변환기를 등록할 수도 있다. 이를 통해 데이터 바인딩, 타입 변환, 포맷팅을 위한 일관된 방법을 제공한다.

 

주요 기능

 

  • 타입 변환: Converter 인터페이스를 구현한 변환기를 등록하여, 특정 타입에서 다른 타입으로의 변환을 지원한다.
  • 데이터 포맷팅: Formatter 인터페이스를 구현한 포맷터를 등록하여, 객체를 특정 포맷의 문자열로 변환하거나 문자열을 객체로 변환하는 기능을 제공한다.
  • 어노테이션 기반 포맷팅: @NumberFormat과 @DateTimeFormat 같은 애너테이션을 사용하여, 필드 레벨에서 직접 데이터 포맷을 지정할 수 있다.

 

FormattingConversionService 는 포맷터를 지원하는 컨버전 서비스이다.

DefaultFormattingConversionService 는 FormattingConversionService 에 기본적인 통화, 숫자 관련 몇가지 기본 포맷터를 추가해서 제공한다.

 

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

        //컨버터 등록
        conversionService.addConverter(new StringToIpPortConverter());
        conversionService.addConverter(new IpPortToStringConverter());

        //포맷터 등록
        conversionService.addFormatter(new MyNumberFormatter());

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

        //포맷터 사용
        assertThat(conversionService.convert(1000, String.class)).isEqualTo("1,000");
        assertThat(conversionService.convert("1,000", Long.class)).isEqualTo(1000L);
    }
}

 

 

DefaultFormattingConversionService 상속 관계

FormattingConversionService 는 ConversionService 관련 기능을 상속받기 때문에 결과적으로 컨버터도 포맷터도 모두 등록할 수 있다. 그리고 사용할 때는 ConversionService 가 제공하는 convert() 를 사용하면 된다.

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

 

포맷터 적용(등록)하기

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        //registry.addConverter(new StringToIntegerConverter());
        //registry.addConverter(new IntegerToStringConverter());
        registry.addConverter(new StringToIpPortConverter());
        registry.addConverter(new IpPortToStringConverter());

        registry.addFormatter(new MyNumberFormatter());
    }
}

StringToIntegerConverter , IntegerToStringConverter 주석처리 → MyNumberFormatter 도 숫자 → 문자, 문자 → 숫자로 변경하기 때문에 둘의 기능이 겹친다.

우선순위는 컨버터가 우선하므로 포맷터가 적용되지 않고, 컨버터가 적용된다.

 

실행 - 객체 → 문자
http://localhost:8080/converter-view


• ${number}: 10000
• ${{number}}: 10,000

컨버전 서비스를 적용한 결과 MyNumberFormatter 가 적용되어서 10,000 문자가 출력된 것을 확인할 수 있다.

 

실행 - 문자 → 객체
http://localhost:8080/hello-v2?data=10,000

실행 로그
→MyNumberFormatter : text=10,000, locale=ko_KR
→data = 10000

"10,000" 이라는 포맷팅 된 문자가 Integer 타입의 숫자 10000으로 정상 변환 된 것을 확인할 수 있다.

 

 

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

스프링은 자바에서 기본으로 제공하는 타입들에 대해 수 많은 포맷터를 기본으로 제공한다.
IDE에서 Formatter 인터페이스의 구현 클래스를 찾아보면 수 많은 날짜나 시간 관련 포맷터가 제공되는 것을 확인 할 수 있다.

그런데 포맷터는 기본 형식이 지정되어 있기 때문에, 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다.

스프링은 이런 문제를 해결하기 위해 애노테이션 기반으로 원하는 형식을 지정해서 사용할 수 있는 매우 유용한 포맷터 두 가지를 기본으로 제공한다.

 

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

 

컨트롤러

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

 

뷰(타임리프)

formatter-form.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
        <form th:object="${form}" th:method="post">
            number <input type="text" th:field="*{number}"><br/>
            localDateTime <input type="text" th:field="*{localDateTime}"><br/>
            <input type="submit"/>
        </form>
    </body>
</html>

 

formatter-view.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
<body>
    <ul>
        <li>${form.number}: <span th:text="${form.number}" ></span></li>
        <li>${{form.number}}: <span th:text="${{form.number}}" ></span></li>
        <li>${form.localDateTime}: <span th:text="${form.localDateTime}" ></span></li>
        <li>${{form.localDateTime}}: <span th:text="${{form.localDateTime}}" ></span></li>
    </ul>
</body>
</html>

 

 

 

 메시지 컨버터에는 컨버전 서비스가 적용되지 않는다.

메시지 컨버터(HttpMessageConverter)에는 컨버전 서비스가 적용되지 않는다.

 

특히 객체를 JSON으로 변환할 때 메시지 컨버터를 사용하면서 이 부분을 많이 오해하는데, HttpMessageConverter 의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다.

예를 들어서 JSON을 객체로 변환하는 메시지 컨버터는 내부에서 Jackson 같은 라이브러리를 사용 한다. 객체를 JSON으로 변환한다면 그 결과는 이 라이브러리에 달린 것이다.

 

따라서 JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야 한다. 결과적으로 이것은 컨버전 서비스와 전혀 관계가 없다.
컨버전 서비스는 @RequestParam, @ModelAttribute, @PathVariable, 뷰 템플릿 등에서 사용할 수 있다.

 

 

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


기본 API 예외 처리

예외가 발생하면 어떠한 경로로 다시 요청할지 정의

WebServerCustomizer

@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    @Override
    public void customize(ConfigurableWebServerFactory factory) {
        ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404"); //errorPage404 호출
        ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500"); //errorPage500 호출
        ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500"); //RuntimeException 또는 그 자식 타입의 예외 -> errorPage500 호출

        factory.addErrorPages(errorPage404, errorPage500, errorPageEx); //위에서 설정한 오류페이지 등록
    }
}
  • ErrorPage: 이 클래스는 특정 오류가 발생했을 때 보여줄 페이지를 정의한다. 첫 번째 생성자는 HTTP 상태 코드에 대한 오류 페이지를, 두 번째 생성자는 특정 예외 타입에 대한 오류 페이지를 정의한다.
  • addErrorPages: 이 메서드는 ConfigurableWebServerFactory 인스턴스에 하나 이상의 ErrorPage 인스턴스를 등록한다. 이렇게 등록된 오류 페이지는 해당 오류 상황이 발생했을 때 자동으로 사용자에게 보여진다.
  • HTTP 404(Not Found) 오류가 발생하면, 사용자는 "/error-page/404"에 정의된 오류 페이지를 본다.
  • HTTP 500(Internal Server Error) 오류 또는 RuntimeException (또는 그 자식 타입의 예외)이 발생하면, 사용자는 "/error-page/500"에 정의된 오류 페이지를 본다.

 

API 예외 컨트롤러

@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;
    }
}
  • localhost:8080/api/members/ex 에 들어가면 RuntimeException이 발생하고 잘못된 사용자라는 메시지가 담기게 된다.

 

에러 페이지 컨트롤러

@Slf4j
@Controller
public class ErrorPageController {

    //RequestDispatcher 상수로 정의되어 있음
    public static final String ERROR_EXCEPTION = "jakarta.servlet.error.exception"; //예외
    public static final String ERROR_EXCEPTION_TYPE = "jakarta.servlet.error.exception_type"; //예외 타입
    public static final String ERROR_MESSAGE = "jakarta.servlet.error.message"; //오류 메시지
    public static final String ERROR_REQUEST_URI = "jakarta.servlet.error.request_uri"; //클라이언트 요청 URI
    public static final String ERROR_SERVLET_NAME = "jakarta.servlet.error.servlet_name"; //오류가 발생한 서블릿 이름
    public static final String ERROR_STATUS_CODE = "jakarta.servlet.error.status_code"; //HTTP 상태 코드

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

    @RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE) //같은 URL 매핑이어도 더 자세하기 떄문에 우선순위 높음
    public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response) {
        log.info("API errorPage 500");

        Map<String, Object> result = new HashMap<>();
        Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION); //요청에서 발생한 예외 객체를 추출
        result.put("status", request.getAttribute(ERROR_STATUS_CODE)); //예외 발생 시의 HTTP 상태 코드를 결과를 맵에 추가
        result.put("message", ex.getMessage()); //발생한 예외의 메시지를 결과를 맵에 추가
        Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); //요청에서 오류 상태 코드를 추출
        return new ResponseEntity(result, HttpStatus.valueOf(statusCode)); //결과 맵과 HTTP 상태 코드를 사용하여 ResponseEntity 객체를 생성하고 반환, 이 객체는 스프링 MVC에 의해 JSON 형식으로 클라이언트에게 전송
    }
}
  • produces = MediaType.APPLICATION_JSON_VALUE 의 뜻은 클라이언트가 요청하는 HTTP Header의
    Accept 의 값이 application/json 일 때 해당 메서드가 호출된다는 것이다. 결국 클라어인트가 받고 싶은 미디어 타입이 json이면 이 컨트롤러의 메서드가 호출된다.
  • 응답 데이터를 위해서 Map 을 만들고 status , message 키에 값을 할당한다.
  • Jackson 라이브러리는 Map 을 JSON 구조로 변환할 수 있다.
  • ResponseEntity 를 사용해서 응답하기 때문에 메시지 컨버터가 동작하면서 클라이언트에 JSON이 반환된다.

 

API 예외 처리 - 스프링 부트 기본 오류 처리

스프링 부트가 제공하는 BasicErrorController 의 코드 중에서 중요한 코드 조각은 아래와 같다.

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
 public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}
 
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
  • errorHtml() : produces = MediaType.TEXT_HTML_VALUE : 클라이언트 요청의 Accept 해더 값이 text/html 인 경우에는 errorHtml() 을 호출해서 view를 제공한다.
  • error() : 그외 경우에 호출되고 ResponseEntity 로 HTTP Body에 JSON 데이터를 반환한다.

 

WebServerCustomizer 의 @Componet 어노테이션을 제거하면 BasicErrorController 가 실행되고, 기존에 설정한 뷰 파일이 리턴된다.

 

스프링 부트가 제공하는 BasicErrorController 는 HTML 페이지를 제공하는 경우에는 매우 편리하다.

하지만 API 오류 처리는 BasicErrorController 보다는 @ExceptionHandler 를 사용하는 것이 좋다.

이유는 아래와 같다.

 

  1. 세밀한 제어와 커스터마이징
    @ExceptionHandler를 사용하면, 특정 예외나 상황에 대해 매우 세밀한 오류 처리 로직을 구현할 수 있다. 개발자는 예외 유형별로 다른 처리 방식을 적용할 수 있으며, HTTP 상태 코드, 응답 본문 등을 자유롭게 조정할 수 있다.
  2. 컨트롤러 내 예외 처리
    @ExceptionHandler는 특정 컨트롤러 내부 또는 @ControllerAdvice를 사용하여 애플리케이션 전역에서 발생하는 예외를 처리하는 데 사용할 수 있다. 이는 오류 처리 로직을 관련 도메인 또는 API에 더 가까이 위치시켜, 코드의 가독성과 유지 보수성을 향상시킨다.
  3. 응답 본문의 유연성
    API를 통해 클라이언트에게 반환되는 오류 응답의 형태를 자유롭게 정의할 수 있다. JSON, XML 등 클라이언트가 요구하는 형식에 맞춰 유연하게 오류 메시지를 구성할 수 있어, API의 일관성을 유지하며 사용자 경험을 개선할 수 있다.
  4. 프로그래밍적 예외 처리
    @ExceptionHandler는 예외 처리 로직을 직접 프로그래밍하여 구현하기 때문에, 개발자가 실행 흐름을 더 잘 이해하고 제어할 수 있다. 이는 복잡한 요구 사항이나 특별한 처리가 필요한 경우 유용하다.
  5. 테스트와 유지 보수의 용이성
    @ExceptionHandler를 사용한 오류 처리는 테스트하기가 더 쉽다. 각 컨트롤러 또는 @ControllerAdvice 클래스 단위로 테스트를 진행할 수 있으며, 예외 처리 로직이 명확하게 분리되어 있어 유지 보수가 용이하다.
  6. 특정 예외에 대한 직접적인 처리
    특정 예외 유형에 대해 맞춤형 오류 메시지나 처리 로직을 적용하고 싶을 때 @ExceptionHandler가 더 적합하다. 이를 통해 API 사용자에게 더 상세하고 명확한 오류 정보를 제공할 수 있다.

 

HandlerExceptionResolver

HandlerExceptionResolver는 컨트롤러에서 발생한 예외를 중앙에서 처리할 수 있도록 도와준다. 이 인터페이스를 구현함으로써, 애플리케이션 전반에 걸친 예외 처리 전략을 구현하고, 예외가 발생했을 때 일관된 방식으로 응답을 반환할 수 있다.

HandlerExceptionResolver는 특히 API 개발에서 유용하게 사용되며, 예외를 적절한 HTTP 상태 코드와 메시지로 변환하여 클라이언트에 전달하는 데 도움을 준다.

 

작동 방식

디스패처 서블릿(DispatcherServlet)은 컨트롤러에서 발생한 예외를 처리하기 위해 HandlerExceptionResolver 인터페이스를 사용한다. 컨트롤러 메서드 실행 도중 예외가 발생하면, Spring은 등록된 HandlerExceptionResolver 구현체를 차례로 호출하여 예외를 처리한다.

각 HandlerExceptionResolver는 예외를 받아 처리할 수 있는지 여부를 판단하고, 처리할 수 있다면 ModelAndView 객체를 생성하여 예외에 대한 응답 정보를 담는다.

 

참고
ExceptionResolver 로 예외를 해결해도 postHandle() 은 호출되지 않는다.

 

HandlerExceptionResolver 인터페이스

public interface HandlerExceptionResolver {
    ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response,
    Object handler, Exception ex);
}
  • handler : 핸들러(컨트롤러) 정보
  • Exception ex : 핸들러(컨트롤러)에서 발생한 발생한 예외

 

 

예외가 발생해서 서블릿을 넘어 WAS까지 예외가 전달되면 HTTP 상태코드가 500으로 처리된다. 발생하는 예외에 따 라서 400, 404 등등 다른 상태코드로 처리하고 싶고, 오류 메시지, 형식 등을 API 마다 다르게 처리하고 싶다면 아래와 같은 방식으로 처리할 수 있다.

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

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}

 

localhost:8080/api/members/bad 로 요청이 오면 500번 오류가 아닌 400번으로 아래와 같이 처리 할 수 있다.

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) { //전달된 예외가 IllegalArgumentException 인지 확인
                log.info("IllegalArgumentException resolver to 400");
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage()); //클라이언트에게 HTTP 상태 코드 400(Bad Request)와 함께 예외 메시지를 전송
                return new ModelAndView(); //빈 객체를 리턴하여 정상 동작 유도(뷰 처리 건너뜀)
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}

ExceptionResolver가 ModelAndView를 반환하는 이유는 마치 try, catch를 하듯이, Exception을 처리해서 정상 흐름처럼 변경하는 것이 목적이다.

이름 그대로 ExceptionResolver는 Exception을 해결하는 것이 목적이다.

여기서는 IllegalArgumentException이 발생하면 response.sendError(400)를 호출해서 HTTP 상태 코드를 400으로 지정하고, 빈 ModelAndView를 반환한다.

 

 

반환 값에 따른 동작 방식

HandlerExceptionResolver의 반환 값에 따른 DispatcherServlet의 동작 방식은 다음과 같다.

→빈 ModelAndView 반환

new ModelAndView()와 같이 빈 ModelAndView 객체를 반환하는 경우, Spring MVC는 뷰를 렌더링하지 않고, 정상적인 서블릿 응답 흐름으로 돌아간다. 이는 예외가 발생했지만, 특별한 에러 페이지를 보여줄 필요가 없거나, 예외 처리 로직에서 이미 충분한 처리(예: 클라이언트에게 에러 정보를 JSON 형태로 전송)를 수행했음을 의미할 수 있다.

 

ModelAndView 지정

ModelAndView 객체에 뷰 이름이나 모델 정보를 지정해서 반환하는 경우, Spring MVC는 해당 정보를 사용해 뷰를 렌더링한다. 이는 오류에 대한 사용자 친화적인 웹 페이지를 제공하고자 할 때 유용하다. 예를 들어, 오류 메시지와 함께 사용자에게 보여줄 오류 페이지를 지정할 수 있다.

 

null 반환

null을 반환하는 경우, Spring MVC는 현재 HandlerExceptionResolver가 예외를 처리하지 않았음을 의미하며, 다음 HandlerExceptionResolver를 찾아 실행한다. 만약 더 이상 처리할 HandlerExceptionResolver가 없고, 예외가 여전히 처리되지 않은 상태라면, 예외는 DispatcherServlet 밖으로 전파되고, 최종적으로 서블릿 컨테이너의 기본 오류 페이지 처리 메커니즘에 의해 처리된다.



HandlerExceptionResolver 활용 예

예외 상태 코드 변환

특정 예외를 response.sendError(statusCode) 호출을 통해 HTTP 상태 코드로 변환하여 클라이언트에게 전달할 수 있다. 이 경우, 일반적으로 빈 ModelAndView를 반환하며, 서블릿 컨테이너가 해당 상태 코드에 맞는 처리(예: 에러 페이지 렌더링)를 수행하게 한다.

 

뷰 템플릿 처리

오류에 대한 사용자 정의 뷰를 렌더링하여 보다 친절한 사용자 경험을 제공할 수 있다. 이 경우 ModelAndView에 오류 정보와 함께 사용할 뷰 이름을 지정한다.

 

API 응답 처리

REST API와 같이 HTTP 응답 바디에 직접 에러 메시지나 상태 코드를 전송해야 하는 경우, response.getWriter().print(errorResponse)와 같이 처리한 뒤 빈 ModelAndView를 반환하여 추가적인 뷰 렌더링을 방지할 수 있다.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }
}

configureHandlerExceptionResolvers(..) 를 사용하면 스프링이 기본으로 등록하는 ExceptionResolver 가 제거되므로 주의해야 한다.

그렇기 떄문에 extendHandlerExceptionResolvers 를 사용하는 것이 좋다.

 

HandlerExceptionResolver 활용

예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error 를 호출하는 과정은 너무 복잡하다.

ExceptionResolver 를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 문제를 깔끔하게 해결할 수 있다.

 

사용자 정의 예외

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

 

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

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}

http://localhost:8080/api/members/user-ex 를 호출하면 UserException 이 발생한다.

 

UserException 처리 컨트롤러

@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) { //UserException 이 발생했을 때
                log.info("UserException resolver to 400");
                String acceptHeader = request.getHeader("accept"); //accept 헤더 정보를 가져옴
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST); //HTTP 응답 상태 코드를 400(Bad Request)으로 설정

                if ("application/json".equals(acceptHeader)) { //accept 헤더 정보가 application/json 이라면
                    Map<String, Object> errorResult = new HashMap<>();
                    errorResult.put("ex", ex.getClass()); //예외의 클래스 타입을 넣음
                    errorResult.put("message", ex.getMessage()); //예외 메시지를 넣음
                    String result = objectMapper.writeValueAsString(errorResult); //ObjectMapper 를 사용하여 예외 정보를 문자로 변환

                    response.setContentType("application/json"); //응답의 Content-Type을 application/json으로 설정
                    response.setCharacterEncoding("utf-8"); //문자 인코딩을 UTF-8로 설정
                    response.getWriter().write(result); //응답 본문에 result 를 넣음

                    return new ModelAndView(); //빈 ModelAndView 객체를 반환하여 뷰 렌더링 없이 직접 응답을 처리
                } else {
                    //TEXT/HTML
                    return new ModelAndView("error/400");
                }
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}

HTTP 요청 해더의 ACCEPT 값이 application/json 이면 JSON으로 오류를 내려주고, 그 외 경우에는 error/ 500에 있는 HTML 오류 페이지를 보여준다.

 

물론 WebConfig에 UserHandlerExceptionResolver 를 추가해주어야 한다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

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

 

정리

ExceptionResolver 를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver 에서 예외를 처리해버린다. 따라서 예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이난다.

결과적으로 WAS 입장에서는 정상 처리가 된 것이다. 이렇게 예외를 이곳에서 모두 처리할 수 있다는 것이 핵심이다. 서블릿 컨테이너까지 예외가 올라가면 복잡한 추가 프로세스가 실행된다. 반면에 ExceptionResolver 를 사용하면 예외처리가 상당히 깔끔해진다.

 

스프링이 제공하는 ExceptionResolver

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

 

HandlerExceptionResolverComposite 에 다음 순서로 등록되어 있다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver (우선순위가 가장 낮음)

 

HandlerExceptionResolverComposite는 Spring MVC에서 다양한 HandlerExceptionResolver 인스턴스들을 하나의 컨테이너 안에 결합하여 관리하는 클래스이다. 이 클래스는 HandlerExceptionResolver 인터페이스를 구현함으로써, 여러 예외 해결자들 사이에서 발생하는 예외를 효과적으로 처리할 수 있는 중앙 집중식 방식을 제공한다. 등록된 모든 예외 Resolver들 중에서 해당 예외를 처리할 수 있는 첫 번째 Resolver를 찾아서 예외 처리를 위임한다. 처리가 완료되면, 해당 Resolver는 처리 결과를 나타내는 ModelAndView 객체를 반환한다.

Spring MVC의 DispatcherServlet은 애플리케이션에서 발생하는 예외를 HandlerExceptionResolverComposite에게 전달한다. 그러면 HandlerExceptionResolverComposite는 자신이 관리하는 해결자 목록을 순회하면서 각 Resolver가 해당 예외를 처리할 수 있는지 확인한다. 처리할 수 있는 Resolver가 발견되면, 그 Resolver에게 예외 처리를 맡긴다. 만약 등록된 Resolver 중 어떤 것도 예외를 처리할 수 없다면, 예외는 처리되지 않은 채로 남게 되며, Spring MVC 외부로 전파된다.

 

  • ExceptionHandlerExceptionResolver
    @ExceptionHandler 을 처리한다. API 예외 처리는 대부분 이 기능으로 해결한다.
  • ResponseStatusExceptionResolver
    HTTP 상태 코드를 지정해준다.
    예) @ResponseStatus(value = HttpStatus.NOT_FOUND)
  • DefaultHandlerExceptionResolver
    스프링 내부 기본 예외를 처리한다.

 

ResponseStatusExceptionResolver

ResponseStatusExceptionResolver 는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다.

예외 발생 시 HTTP 상태 코드와 메시지를 클라이언트에게 동적으로 반환할 수 있도록 해주는 HandlerExceptionResolver의 한 구현체이다.

이 Resolver는 예외 클래스에 @ResponseStatus 어노테이션이 붙어 있는 경우, 해당 어노테이션에 정의된 HTTP 상태 코드와 이유(설명)를 사용하여 응답을 구성한다.

이 리졸버는 다음 두 가지 경우를 처리한다.

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

 

@ResponseStatus

예외에 다음과 같이 @ResponseStatus 어노테이션을 적용하면 HTTP 상태 코드를 변경해준다.

@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
    throw new BadRequestException();
}
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}

BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver 예외가 해당 어노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST (400)으로 변경하고, 메시지도 담는다.

 

ResponseStatusExceptionResolver 코드를 확인해보면 결국 response.sendError(statusCode, resolvedReason) 를 호출하는 것을 확인할 수 있다.

 

sendError(400) 를 호출했기 때문에 WAS에서 다시 오류 페이지( /error )를 내부 요청한다.

 

또한 ResponseStatusExceptionResolver 는 MessageSource 에서 찾는 기능도 제공한다.

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

 

 

 

ResponseStatusException

이 예외는 런타임에 ResponseStatusExceptionResolver에 의해 처리되며, 개발자가 예외 발생 시점에 상태 코드와 메시지를 유연하게 지정할 수 있게 해준다.

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

 

 

DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver 는 스프링 내부에서 발생하는 스프링 예외를 해결한다.

대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException 이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.

그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다. HTTP 에서는 이런 경우 HTTP 상태 코드 400을 사용하도록 되어 있다.

DefaultHandlerExceptionResolver 는 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다.

 

작동 원리

DefaultHandlerExceptionResolver는 다음과 같은 방식으로 작동한다

  • 예외 매핑: 컨트롤러나 다른 컴포넌트에서 발생한 예외를 캐치하고, 해당 예외 타입에 따라 미리 정의된 HTTP 상태 코드로 매핑한다. 예를 들어, HttpRequestMethodNotSupportedException은 405 Method Not Allowed로 매핑된다.
  • HTTP 상태 코드 설정: 매핑된 HTTP 상태 코드를 사용하여 HTTP 응답의 상태 코드를 설정한다. 이 과정에서, 필요에 따라 추가적인 응답 헤더를 설정할 수도 있다.
  • 응답 반환: 처리된 예외에 대해 적절한 HTTP 상태 코드를 포함하는 응답을 클라이언트에게 반환한다. 일반적으로 별도의 응답 본문 없이 상태 코드만으로 응답이 구성된다.

 

 

주요 특징

  • 자동 등록: Spring MVC 설정의 일부로 DefaultHandlerExceptionResolver는 기본적으로 자동으로 등록되어 있으며, 개발자가 별도로 구성할 필요가 없다.
  • 기본 예외 처리: Spring MVC와 관련된 예외에 대해 기본적인 처리를 제공한다. 이는 애플리케이션의 예외 처리 전략의 기본 레이어로 작동한다.
  • 커스터마이징: 필요한 경우, 개발자는 DefaultHandlerExceptionResolver를 상속받아서 더 세밀한 예외 처리 로직을 구현할 수 있다. 또는, HandlerExceptionResolverComposite에 DefaultHandlerExceptionResolver와 함께 사용자 정의 예외 리졸버를 추가하여 예외 처리 전략을 확장할 수 있다.

 

@GetMapping("/api/default-handler-ex")
 public String defaultException(@RequestParam Integer data) {
     return "ok";
 }

 

 

 

ExceptionHandlerExceptionResolver(@ExceptionHandler)

스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler 라는 어노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver 이다.

 

스프링은 ExceptionHandlerExceptionResolver 를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다. 실무에서 API 예외 처리는 대부분 이 기능을 사용한다.

 

이 어노테이션을 사용하면, 특정 메서드를 예외 처리기로 지정하여 해당 예외가 발생했을 때 자동으로 호출되게 할 수 있다. 이를 통해 개발자는 예외별로 맞춤형 로직을 구현하여 예외 처리를 보다 세밀하게 관리할 수 있다.

작동 방식

  • 지정 예외 처리: @ExceptionHandler 어노테이션이 적용된 메서드는 해당 메서드의 파라미터로 지정된 예외 타입을 처리한다. 다수의 예외 타입을 처리하고자 할 경우, 어노테이션의 값으로 예외 클래스 배열을 지정할 수 있다.
  • 컨트롤러 범위: @ExceptionHandler가 컨트롤러 내에 선언된 경우, 해당 컨트롤러 내에서 발생하는 예외에 대해서만 처리한다.
  • 전역 범위: @ControllerAdvice 또는 @RestControllerAdvice 어노테이션이 적용된 클래스 내에 @ExceptionHandler를 사용하면, 애플리케이션 전역에서 발생하는 해당 예외를 처리할 수 있다.

 

@Data
 @AllArgsConstructor
 public class ErrorResult {
     private String code;
     private String message;
 }

 

API 오류 처리 컨트롤러

package hello.exception.exhandler;

import hello.exception.exception.UserException;
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.*;
import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;

@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()); //ErrorResult 객체를 JSON으로 반환

//        컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다. 예외가 발생했으로 ExceptionResolver 가 작동한다. 가장 우선순위가 높은
//        ExceptionHandlerExceptionResolver 가 실행된다.
//        ExceptionHandlerExceptionResolver 는 해당 컨트롤러에 IllegalArgumentException 을 처리할 수 있는 @ExceptionHandler 가 있는지 확인한다.
//        illegalExHandle() 를 실행한다. @RestController 이므로 illegalExHandle()에도 @ResponseBody가 적용된다. 따라서 HTTP 컨버터가 사용되고,
//        응답이 다음과 같은 JSON으로 반환된다. @ResponseStatus(HttpStatus.BAD_REQUEST)를 지정했으므로 HTTP 상태 코드 400으로 응답한다.
    }

    @ExceptionHandler //처리할 예외를 생략할 수 있음
    public ResponseEntity<ErrorResult> userExHandle(UserException e) { //UserException 예외 처리
        log.error("[exceptionHandle] ex", e);
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage()); //ErrorResult 에 오류 메시지를 담음
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST); //ResponseEntity에 오류 메시지와 상태코드를 담고 전송(JSON 형태)

//        @ExceptionHandler 에 예외를 지정하지 않으면 해당 메서드 파라미터 예외를 사용한다.
//        여기서는 UserException 을 사용한다.
//        ResponseEntity 를 사용해서 HTTP 메시지 바디에 직접 응답한다. 물론 HTTP 컨버터가 사용된다.
//        ResponseEntity를 사용하면 HTTP 응답 코드를 프로그래밍해서 동적으로 변경할 수 있다. @ResponseStatus는 애노테이션이므로 HTTP 응답 코드를 동적으로 변경할 수 없다.
    }

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) //상태코드 설정
    @ExceptionHandler
    public ErrorResult exHandle(Exception e) { //Exception 처리 -> 이전에 처리하지 못한 예외나 공통 예외 처리는 여기서 담당
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("EX", "내부 오류"); //ErrorResult 객체를 JSON으로 반환

//        throw new RuntimeException("잘못된 사용자")이 코드가 실행되면서, 컨트롤러 밖으로 RuntimeException 이 던져진다.
//        RuntimeException은 Exception 의 자식 클래스이다. 따라서 이 메서드가 호출된다. 
//        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) 로 HTTP 상태 코드를 500으로 응답한다.
    }

    @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 예외 처리 방법

@ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다.

→ 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다.

 

우선순위

스프링의 우선순위는 항상 자세한 것이 우선권을 가진다. 예를 들어서 부모, 자식 클래스가 있고 다음과 같이 예외가 처리된다.

@ExceptionHandler(부모예외.class)
public String 부모예외처리()(부모예외 e) {}

@ExceptionHandler(자식예외.class)
public String 자식예외처리()(자식예외 e) {}


@ExceptionHandler 에 지정한 부모 클래스는 자식 클래스까지 처리할 수 있다. 따라서 자식예외가 발생하면 부모 예외처리() , 자식예외처리() 둘다 호출 대상이 된다.

→그런데 둘 중 더 자세한 것이 우선권을 가지므로 자식예외처리() 가 호출된다. 물론 부모예외가 호출되면 부모예외처리()만 호출 대상이 되므로 부모예외처리() 가 호출된다.

 

예외 생략

@ExceptionHandler 에 예외를 생략할 수 있다. 생략하면 메서드 파라미터의 예외가 지정된다.

 @ExceptionHandler
 public ResponseEntity<ErrorResult> userExHandle(UserException e) {}

 

 

파리미터와 응답

@ExceptionHandler 에는 마치 스프링의 컨트롤러의 파라미터 응답처럼 다양한 파라미터와 응답을 지정할 수 있다.

자세한 파라미터와 응답은 다음 공식 메뉴얼을 참고

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann- exceptionhandler-args

 

HTML 오류 화면

다음과 같이 ModelAndView 를 사용해서 오류 화면(HTML)을 응답하는데 사용할 수도 있다.

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

 

 

@ControllerAdvice

@ExceptionHandler 를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다. @ControllerAdvice 또는 @RestControllerAdvice 를 사용하면 둘을 분리할 수 있다.

 

정상적인 흐름 로직이 작성된 컨트롤러

@Slf4j
@RestController
public class ApiExceptionV3Controller {
    @GetMapping("/api3/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;
    }
}

 

예외 처리를 담당하는 컨트롤러

@Slf4j
@RestControllerAdvice(assignableTypes = ApiExceptionV3Controller.class) //@ControllerAdvice에 추가적으로 @ResponseBody 어노테이션의 기능이 포함, ApiExceptionV3Controller 클래스에만 적용
public class ExControllerAdvice {
    @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", "내부 오류"); }
}

 

@ControllerAdvice와 @RestControllerAdvice

공통의 처리 로직(예외 처리, 데이터 바인딩, 모델 속성 추가 등)을 애플리케이션의 모든 컨트롤러 또는 REST 컨트롤러에 적용하기 위해 사용된다. 이 두 어노테이션은 기능적으로 유사하지만, 사용되는 컨텍스트에 따라 선택할 수 있다.

 

→@ControllerAdvice

@ControllerAdvice는 모든 @Controller에 대한 전역 설정을 제공한다. 이는 주로 웹 애플리케이션에서 HTML 뷰를 반환하는 컨트롤러에 적합하다.

 

예외 처리(@ExceptionHandler), 모델 속성 추가(@ModelAttribute), 바인딩 설정(@InitBinder)과 같은 공통 로직을 정의할 때 사용된다.
예외가 발생했을 때, 에러 페이지로 리다이렉트하거나, 특정 뷰를 반환하는 등의 처리를 할 때 적합하다.

 

@ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)

 

@InitBinder

컨트롤러에서 요청이 들어올 때마다 특정 필드에 대한 바인딩이나 포매팅 설정을 커스터마이즈할 수 있도록 해주는 어노테이션이다. 이 어노테이션을 메서드에 적용하면, Spring MVC의 바인딩 프로세스가 시작될 때 해당 메서드가 자동으로 호출되어 요청 파라미터를 Java 객체에 바인딩하기 전에 필요한 설정을 할 수 있다.

 

→@RestControllerAdvice

@RestControllerAdvice는 @ControllerAdvice에 추가적으로 @ResponseBody 어노테이션의 기능이 포함되어 있다. 즉, 이 어노테이션을 사용하면 반환되는 객체는 자동으로 JSON이나 XML과 같은 응답 본문으로 변환된다.

 

RESTful 웹 서비스를 개발할 때, JSON이나 XML로 데이터를 반환하는 @RestController에 대한 전역 설정을 제공하는 데 사용된다.
예외 처리를 통해 클라이언트에게 JSON 형식의 에러 응답을 보낼 때 유용하다.

 

대상 컨트롤러 지정 방법

// 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 {}

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann- controller-advice

(스프링 공식 문서 참고)

 

스프링 공식 문서 예제에서 보는 것 처럼 특정 애노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지를 직접 지정 할 수도 있다. 패키지 지정의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 된다. 그리고 특정 클래스를 지정할 수도 있다.

대상 컨트롤러 지정을 생략하면 모든 컨트롤러에 적용된다.

구글 클라우드 비전 플랫폼 설정

API 및 서비스

구글 클라우드 의 콘솔 창으로 들어가면 왼쪽 상단의 버튼을 눌러 API 및 서비스 - 사용자 인증 정보로 들어간다.

 

 

사용자 인증 정보 창에서 사용자 인증 정보 만들기 버튼을 누른다.

 

 

 

API 키 를 추가한다.

 

 

API 활성화

이후 API 및 서비스 - 사용 설정된 API 및 서비스 버튼을 클릭한다.

 

 

Clould Vision API 를 사용 버튼을 누른다.

 

IAM 및 관리자

IAM 및 관리자 - 서비스 계정 버튼 클릭

 

 

아래의 정보들을 입력한다.

 

 

아래의 부분은 선택사항이다. 실험 결과 이 부분을 일부 누락해도 OCR API를 사용하는데 지장이 없었다.

 

 

만든 계정의 키 관리 버튼을 누른다.

 

새 키 만들기를 누른 뒤, JSON 파일을 다운로드 받는다.

스프링 부트 설정

환경변수 추가

 

스프링 부트 프로젝트에서 Edit - Configurations... 버튼을 누른다.

 

Modify Options 를 누르고 Environment variables 를 눌러 환경 변수를 추가해준다.

 

환경변수에 아래와 같이 입력한다.

핵심은 다운로드 받은 JSON 파일의 경로를 입력하는 것이다.

GOOGLE_APPLICATION_CREDENTIALS=JSON 파일의 경로(상대 경로도 가능)

 

 

 

스프링 부트 의존성 추가

build.gradle

dependencies {
	// Google Cloud Vision API 클라이언트 라이브러리
	implementation 'com.google.cloud:google-cloud-vision:3.34.0'
    
    ...
}

 

컨트롤러와 서비스 코드

서비스 코드

import com.google.cloud.vision.v1.AnnotateImageRequest;
import com.google.cloud.vision.v1.AnnotateImageResponse;
import com.google.cloud.vision.v1.BatchAnnotateImagesResponse;
import com.google.cloud.vision.v1.Feature;
import com.google.cloud.vision.v1.Image;
import com.google.cloud.vision.v1.ImageAnnotatorClient;
import com.google.protobuf.ByteString;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;

@Service
public class VisionService {

    public String extractTextFromImageUrl(String imageUrl) throws Exception {
        URL url = new URL(imageUrl);
        ByteString imgBytes;
        try (InputStream in = url.openStream()) {
            imgBytes = ByteString.readFrom(in);
        }

        Image img = Image.newBuilder().setContent(imgBytes).build();
        Feature feat = Feature.newBuilder().setType(Feature.Type.TEXT_DETECTION).build();
        AnnotateImageRequest request = AnnotateImageRequest.newBuilder()
                .addFeatures(feat)
                .setImage(img)
                .build();
        List<AnnotateImageRequest> requests = new ArrayList<>();
        requests.add(request);

        try (ImageAnnotatorClient client = ImageAnnotatorClient.create()) {
            BatchAnnotateImagesResponse response = client.batchAnnotateImages(requests);
            StringBuilder stringBuilder = new StringBuilder();
            for (AnnotateImageResponse res : response.getResponsesList()) {
                if (res.hasError()) {
                    System.out.printf("Error: %s\n", res.getError().getMessage());
                    return "Error detected";
                }
                stringBuilder.append(res.getFullTextAnnotation().getText());
            }
            return stringBuilder.toString();
        }
    }
}

 

컨트롤러 코드

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OCRController {

    @Autowired
    private VisionService visionService;

    @GetMapping("/extract-text")
    public String extractText(@RequestParam("imageUrl") String imageUrl) {
        try {
            return visionService.extractTextFromImageUrl(imageUrl);
        } catch (Exception e) {
            //e.printStackTrace();
            return "Failed to extract text: " + e.getMessage();
        }
    }
}

 

 

포스트맨으로 테스트

위에서 작성된 코드는 포스트맨으로 아래와 같이 GET 요청으로 테스트를 할 수 있다.

http://localhost:8080/extract-text?imageUrl=웹상의 이미지 URL 경로

 

위 사진의 원본 경로는 https://gaussian37.github.io/assets/img/vision/etc/gcp/gcp.PNG 이다.

따라서 포스트맨으로 아래와 같이 입력한다.

 

그러면 응답으로 아래와 같은 결과가 반환된다.

 

참고 : https://bkyungkeem.tistory.com/40

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


서블릿 예외 처리

서블릿은 다음 2가지 방식으로 예외 처리를 지원한다.

  • Exception (예외)
  • response.sendError(HTTP 상태 코드, 오류 메시지)

 

Exception(예외)

자바 직접 실행

자바의 메인 메서드를 직접 실행하는 경우 main 이라는 이름의 쓰레드가 실행된다.
실행 도중에 예외를 잡지 못하고 처음 실행한 main() 메서드를 넘어서 예외가 던져지면, 예외 정보를 남기고 해당 쓰레드는 종료된다.

 

웹 애플리케이션

웹 애플리케이션은 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행된다.
애플리케이션에서 예외가 발생했는데, 어디선가 try ~ catch로 예외를 잡아서 처리하면 아무런 문제가 없다. 그런데 만약에 애플리케이션에서 예외를 잡지 못하고, 서블릿 밖으로 까지 예외가 전달되면 WAS 까지 예외가 전달된다.

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

결국 톰캣 같은 WAS 까지 예외가 전달된다.

WAS 에 예외가 전돨되면 WAS(톰캣 등)에서 기본적으로 제공하는 오류 페이지를 보여준다.(스프링부트가 기본적으로 제공하는 whitelabel 페이지를 말하는 것이 아님)

 

response.sendError(HTTP 상태 코드, 오류 메시지)

오류가 발생했을 때 HttpServletResponse 가 제공하는 sendError 라는 메서드를 사용해도 된다. 이것을 호출 한다고 당장 예외가 발생하는 것은 아니지만, 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다.

이 메서드를 사용하면 HTTP 상태 코드와 오류 메시지도 추가할 수 있다.

  • response.sendError(HTTP 상태 코드)
  • response.sendError(HTTP 상태 코드, 오류 메시지)
@Controller
public class ServletExController {
    @GetMapping("/error-ex")
    public void errorEx() {
        throw new RuntimeException("예외 발생!");
    }

    @GetMapping("/error-404")
    public void error404(HttpServletResponse response) throws IOException {
        response.sendError(404, "404 오류!");
    }

    @GetMapping("/error-500")
    public void error500(HttpServletResponse response) throws IOException {
        response.sendError(500);
    }
}

sendError 흐름

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

response.sendError() 를 호출하면 response 내부에는 오류가 발생했다는 상태를 저장해둔다.

그리고 서블릿 컨테이너는 고객에게 응답 전에 response 에 sendError() 가 호출되었는지 확인한다. 그리고 호출 되었다면 설정한 오류 코드에 맞추어 기본 오류 페이지를 보여준다.

 

 

오류 화면 제공

서블릿은 Exception (예외)가 발생해서 서블릿 밖으로 전달되거나 또는 response.sendError() 가 호출 되었 을 때 각각의 상황에 맞춘 오류 처리 기능을 제공한다.

이 기능을 사용하면 친절한 오류 처리 화면을 준비해서 고객에게 보여줄 수 있다. 과거에는 web.xml 이라는 파일에 다음과 같이 오류 화면을 등록했다.

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

 

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

@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    @Override
    public void customize(ConfigurableWebServerFactory factory) {
        ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404"); //errorPage404 호출
        ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500"); //errorPage500 호출
        ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500"); //RuntimeException 또는 그 자식 타입의 예외 -> errorPage500 호출

        factory.addErrorPages(errorPage404, errorPage500, errorPageEx); //위에서 설정한 오류페이지 등록
    }
}

오류 페이지는 예외를 다룰 때 해당 예외와 그 자식 타입의 오류를 함께 처리한다. 예를 들어서 위의 경우 RuntimeException 은 물론이고 RuntimeException 의 자식도 함께 처리한다.

 

오류가 발생했을 때 처리할 수 있는 컨트롤러가 필요하다. 예를 들어서 RuntimeException 예외가 발생하면 errorPageEx 에서 지정한 /error-page/500 이 호출된다.

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

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

 

오류 페이지 작동 원리

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

 

예외 발생 흐름

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

 

sendError 흐름

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

WAS는 해당 예외를 처리하는 오류 페이지 정보를 확인한다.
→new ErrorPage(RuntimeException.class, "/error-page/500")

예를 들어서 RuntimeException 예외가 WAS까지 전달되면, WAS는 오류 페이지 정보를 확인한다.

확인해보니 RuntimeException 의 오류 페이지로 /error-page/500 이 지정되어 있다. WAS는 오류 페이지를 출력하기 위해 /error-page/500 를 다시 요청한다.

 

오류 페이지 요청 흐름

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

 

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

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

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

 

정리하면 다음과 같다.

-예외가 발생해서 WAS까지 전파된다.

-WAS는 오류 페이지 경로를 찾아서 내부에서 오류 페이지를 호출한다. 이때 오류 페이지 경로로 필터, 서블릿, 인터셉터, 컨트롤러가 모두 다시 호출된다.

 

오류 정보 추가

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

@Slf4j
@Controller
public class ErrorPageController {

    //RequestDispatcher 상수로 정의되어 있음
    public static final String ERROR_EXCEPTION = "jakarta.servlet.error.exception"; //예외
    public static final String ERROR_EXCEPTION_TYPE = "jakarta.servlet.error.exception_type"; //예외 타입
    public static final String ERROR_MESSAGE = "jakarta.servlet.error.message"; //오류 메시지
    public static final String ERROR_REQUEST_URI = "jakarta.servlet.error.request_uri"; //클라이언트 요청 URI
    public static final String ERROR_SERVLET_NAME = "jakarta.servlet.error.servlet_name"; //오류가 발생한 서블릿 이름
    public static final String ERROR_STATUS_CODE = "jakarta.servlet.error.status_code"; //HTTP 상태 코드

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

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

    private void printErrorInfo(HttpServletRequest request) {
        log.info("ERROR_EXCEPTION: ex= {}", request.getAttribute(ERROR_EXCEPTION));
        log.info("ERROR_EXCEPTION_TYPE: {}", request.getAttribute(ERROR_EXCEPTION_TYPE));
        log.info("ERROR_MESSAGE: {}", request.getAttribute(ERROR_MESSAGE)); //ex의 경우 NestedServletException 스프링이 한번 감싸서 반환
        log.info("ERROR_REQUEST_URI: {}", request.getAttribute(ERROR_REQUEST_URI));
        log.info("ERROR_SERVLET_NAME: {}", request.getAttribute(ERROR_SERVLET_NAME));
        log.info("ERROR_STATUS_CODE: {}", request.getAttribute(ERROR_STATUS_CODE));
        log.info("dispatchType={}", request.getDispatcherType());
    }
}

위 코드에서 DispatcherType 은 필터와 관련이 있기 떄문에 아래의 필터에서 자세히 설명되어 있다.

 

필터와 인터셉터

필터

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

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

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

오류가 발생하면 오류 페이지를 출력하기 위해 WAS 내부에서 다시 한번 호출이 발생한다. 이때 필터, 서블릿, 인터셉터도 모두 다시 호출된다.

 

그런데 로그인 인증 체크 같은 경우를 생각해보면, 이미 한번 필터나, 인터셉터에서 로그인 체크를 완료했다. 따라서 서버 내부에서 오류 페이지를 호출한다고 해서 해당 필터나 인터셉터가 한번 더 호출되는 것은 매우 비효율적이다. 

 

결국 클라이언트로부터 발생한 정상 요청인지, 아니면 오류 페이지를 출력하기 위한 내부 요청인지 구분할 수 있어야 한다. 서블릿은 이런 문제를 해결하기 위해 DispatcherType이라는 추가 정보를 제공한다.

 

DispatcherType

필터는 dispatcherTypes라는 옵션을 제공한다. 

위에 오류 정보 추가 부분에서 아래와 같은 dispatchType 로그를 출력하는 코드가 있었다.

log.info("dispatchType={}", request.getDispatcherType())

출력해보면 오류 페이지에서 dispatchType=ERROR로 나오는 것을 확인할 수 있다. 고객이 처음 요청하면 dispatcherType=REQUEST 이다.

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

public enum DispatcherType {
     FORWARD,
     INCLUDE,
     REQUEST,
     ASYNC,
     ERROR
}
  • REQUEST: 클라이언트로부터 직접 받은 요청을 처리할 때 사용된다. 일반적으로 웹 브라우저나 다른 클라이언트로부터 서블릿이 직접 호출될 때의 요청 유형이다.
  • FORWARD: 한 서블릿 또는 JSP에서 다른 자원(서블릿, JSP, HTML 파일 등)으로 요청을 전달할 때 사용된다. RequestDispatcher.forward() 메소드를 통해 요청이 전달된다.
  • INCLUDE: 한 서블릿 또는 JSP에서 다른 자원의 출력을 현재 응답에 포함시킬 때 사용된다. RequestDispatcher.include() 메소드를 통해 요청이 처리된다.
  • ERROR: 오류 페이지를 처리할 때 사용된다. 서블릿이나 JSP 실행 중에 오류가 발생했을 때, 웹 애플리케이션의 정의된 오류 페이지로 자동으로 요청이 전달된다.
  • ASYNC: 비동기 처리 방식에서 사용된다. 서블릿 3.0 스펙 이상에서 지원되며, ServletRequest.startAsync()를 호출하여 시작된 비동기 작업을 처리할 때 이 유형이 사용된다.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public FilterRegistrationBean logFilter() {FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");
        filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR); //클라이언트 요청은 물론이고, 오류 페이지 요청에서도 필터가 호출
        return filterRegistrationBean;
    }
}

filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);

이렇게 두 가지를 모두 넣으면 클라이언트 요청은 물론이고, 오류 페이지 요청에서도 필터가 호출된다.
아무것도 넣지 않으면 기본 값이 DispatcherType.REQUEST 이다. 즉 클라이언트의 요청이 있는 경우에만 필터가 적용된다. 특별히 오류 페이지 경로도 필터를 적용할 것이 아니면, 기본 값을 그대로 사용하면 된다.

 

인터셉터

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

앞서 필터의 경우에는 필터를 등록할 때 어떤 DispatcherType 인 경우에 필터를 적용할 지 선택할 수 있었다.

그런데 인터셉터는 서블릿이 제공하는 기능이 아니라 스프링이 제공하는 기능이다. 따라서 `DispatcherType` 과 무관하 게 항상 호출된다.

대신에 인터셉터는 다음과 같이 요청 경로에 따라서 추가하거나 제외하기 쉽게 되어 있기 때문에, 이러한 설정을 사용해서 오류 페이지 경로를 excludePathPatterns 를 사용해서 빼주면 된다.

 

정리

/hello 정상 요청

WAS(/hello, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 -> View

 

/error-ex 오류 요청

→필터는 DispatchType 으로 중복 호출 제거 ( dispatchType=REQUEST)

→인터셉터는 경로 정보로 중복 호출 제거( excludePathPatterns("/error-page/**") )


1. WAS(/error-ex, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
2. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
3. WAS 오류 페이지 확인
4. WAS(/error-page/500, dispatchType=ERROR) -> 필터(x) -> 서블릿 -> 인터셉터(x) -> 컨트
롤러(/error-page/500) -> View

 

스프링 부트 오류 페이지

지금까지 예외 처리 페이지를 만들기 위해서 다음과 같은 복잡한 과정을 거쳤다. 

WebServerCustomizer를 만들고, 예외 종류에 따라서 ErrorPage를 추가하고, 예외 처리용 컨트롤러 ErrorPageController를 만듬

 

스프링 부트는 이런 과정을 모두 기본으로 제공한다.

  • ErrorPage를 자동으로 등록한다. 이때 /error 라는 경로로 기본 오류 페이지를 설정한다.
  • new ErrorPage("/error"), 상태코드와 예외를 설정하지 않으면 기본 오류 페이지로 사용된다.
  • 서블릿 밖으로 예외가 발생하거나, response.sendError(...) 가 호출되면 모든 오류는 /error 를 호출하게 된다.

 

스프링 부트에서 예외가 발생하거나 response.sendError() 메서드가 호출되어 HTTP 오류 응답이 생성되면, 내부적으로 /error URL로 이동하여 오류 처리를 진행한다. 스프링 부트는 /error 경로를 처리하는 기본 오류 컨트롤러인 BasicErrorController를 자동으로 등록한다.

 

BasicErrorController 라는 스프링 컨트롤러를 자동으로 등록 → ErrorPage에서 등록한 /error 를 매핑해서 처리하는 컨트롤러

 

스프링 부트는 개발자가 쉽게 오류 페이지를 처리할 수 있도록 자동 구성과 기본적인 메커니즘을 제공한다. 이는 오류 처리에 관련된 복잡한 설정을 대폭 간소화해 준다. 스프링 부트의 오류 처리 과정을 좀 더 자세히 살펴보자.

기본 오류 페이지 등록

스프링 부트는 내부적으로 /error 경로에 대한 기본 오류 페이지를 자동으로 등록한다. 이는 개발자가 별도로 ErrorPage를 등록하지 않아도, 애플리케이션에서 발생하는 모든 오류를 처리할 수 있는 기본적인 설정을 제공한다는 의미이다. 기본 오류 페이지는 상태 코드와 특정 예외 유형에 따라 다르게 동작할 수 있도록 설계되어 있다.

BasicErrorController

스프링 부트는 BasicErrorController라는 컨트롤러를 자동으로 등록하여 /error 경로로 오는 요청을 처리한다. 이 컨트롤러는 HTTP 요청의 헤더를 확인하여 요청이 HTML 페이지를 원하는지, 아니면 JSON 형태의 오류 메시지를 원하는지 판단하고, 적절한 형식의 응답을 반환한다. 따라서 개발자는 특별한 설정 없이도 다양한 클라이언트의 요구에 맞는 오류 응답을 제공할 수 있다.

자동 구성을 통한 오류 페이지 등록

ErrorMvcAutoConfiguration 클래스는 스프링 부트의 자동 구성 중 하나로, 애플리케이션의 오류 페이지 설정을 자동으로 처리한다. 이 구성 클래스는 ErrorPage 등록, BasicErrorController 설정 등 오류 처리와 관련된 다양한 부분을 자동으로 구성한다. 개발자는 이 기능을 통해 오류 처리를 위한 별도의 구성 작업 없이, 바로 오류 처리 로직을 개발하는 데 집중할 수 있다.

오류 처리의 이점

스프링 부트의 이러한 접근 방식은 오류 처리를 위한 복잡한 설정 과정 없이도, 애플리케이션 내에서 발생하는 다양한 오류 상황을 효과적으로 관리할 수 있게 해준다. 또한, 애플리케이션의 오류 페이지를 쉽게 커스터마이즈할 수 있도록 지원하며, 오류 발생 시 사용자에게 보다 친절한 메시지와 정보를 제공할 수 있게 해준다. 이는 애플리케이션의 사용성과 안정성을 높이는 데 중요한 역할을 한다.

 

오류가 발생했을 때 오류 페이지로 /error 를 기본 요청한다. 스프링 부트가 자동 등록한 BasicErrorController 는 이 경로를 기본으로 받는다.

스프링 부트로 오류처리는 만약에 오류가 발생하면 /error url로 이동하고, 해당 컨트롤러는 이미 구현되어 있기 때문에, 오류 페이지만 만들면 된다.

 

 

개발자는 오류 페이지만 등록

BasicErrorController 는 기본적인 로직이 모두 개발되어 있다.
개발자는 오류 페이지 화면만 BasicErrorController 가 제공하는 룰과 우선순위에 따라서 등록하면 된다. 정적 HTML이면 정적 리소스, 뷰 템플릿을 사용해서 동적으로 오류 화면을 만들고 싶으면 뷰 템플릿 경로에 오류 페이지 파 일을 만들어서 넣어두기만 하면 된다.

 

뷰 선택 우선순위

BasicErrorController 의 처리 순서(예시)

1. 뷰템플릿

  • resources/templates/error/500.html
  • resources/templates/error/5xx.html

 

2. 정적 리소스(static, public)

  • resources/static/error/400.html
  • resources/static/error/404.html
  • resources/static/error/4xx.html

 

3. 적용 대상이 없을 때 뷰 이름(error)

  • resources/templates/error.html

해당 경로 위치에 HTTP 상태 코드 이름의 뷰 파일을 넣어두면 된다.
뷰 템플릿이 정적 리소스보다 우선순위가 높고, 404, 500처럼 구체적인 것이 5xx처럼 덜 구체적인 것 보다 우선순위가 높다.
5xx, 4xx 라고 하면 500대, 400대 오류를 처리해준다.

 

스프링 부트 오류 관련 옵션

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

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


@Login 애노테이션이 있으면 직접 만든 ArgumentResolver 가 동작해서 자동으로 세션에 있는 로그인 회원을 찾아주고, 만약 세션에 없다면 null 을 반환하도록 개발

 

HomeController - 추가

기존

@GetMapping("/")
public String homeLoginV3Spring(
        @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
        Member loginMember,
        Model model) {

        //세션에 회원 데이터가 없으면 home
        if (loginMember == null) {
        return "home";
    }

    //세션이 유지되면 로그인으로 이동
    model.addAttribute("member",loginMember);
    return"loginHome";
}

 

변경

@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {
    //세션에 회원 데이터가 없으면 home
    if (loginMember == null) {
        return "home";
    }

    //세션이 유지되면 로그인으로 이동
    model.addAttribute("member", loginMember);
    return "loginHome";
}

 

 

@Login 애노테이션 생성

@Target(ElementType.PARAMETER) //타겟은 파라미터 레벨
@Retention(RetentionPolicy.RUNTIME) //런타임까지 어노테이션 정보가 남아있음
public @interface Login {
}

 

LoginMemberArgumentResolver 생성

HandlerMethodArgumentResolver 인터페이스의 boolean supportsParameter(), Object resolveArgument() 를 오버라이딩해야 한다.

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");
        boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class); //현재 처리 중인 메서드 파라미터에 @Login 어노테이션이 적용되어 있는지 여부를 확인
        boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType()); //파라미터의 타입이 Member 클래스 또는 그 하위 클래스의 인스턴스인지 확인
        return hasLoginAnnotation && hasMemberType; //둘 다 만족한다면 true 리턴, true -> resolveArgument() 메서드 호출
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        log.info("resolveArgument 실행");
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); //HttpServletRequest로 캐스팅하여 실제 서블릿 요청에 접근
        HttpSession session = request.getSession(false); //세션이 존재하지 않을 경우 새로 생성하지 않도록 지정
        if (session == null) {
            return null;
        }
        return session.getAttribute(SessionConst.LOGIN_MEMBER); //세션의 loginId 키를 이용하여 member 객체를 가져와서 리턴
    }
}

supportsParameter(MethodParameter parameter)

이 메서드는 현재 처리 중인 메서드 파라미터가 이 리졸버에 의해 지원되는지 여부를 결정한다. 여기서는 두 가지 조건을 검사한다

  • hasLoginAnnotation: 메서드 파라미터에 @Login 어노테이션이 적용되었는지 여부를 확인한다.
  • hasMemberType: 파라미터 타입이 Member 클래스 또는 그 하위 클래스의 인스턴스인지 확인한다.
    두 조건이 모두 만족할 때만 true를 반환하여 이 파라미터를 처리할 수 있음을 나타낸다.(true -> resolveArgument() 메서드 호출)

 

resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory)

이 메서드는 실제로 파라미터 값의 해석을 담당한다. 여기서는 HTTP 요청과 관련된 HttpServletRequest에 접근하여 세션에서 Member 객체를 추출한다.

  • request.getSession(false): 이 호출은 현재 요청과 관련된 세션이 이미 존재하지 않을 경우 null을 반환하도록 지정한다. 즉, 새 세션을 생성하지 않는다.

세션에서 SessionConst.LOGIN_MEMBER 속성을 사용하여 로그인한 회원 객체를 가져온다. 이 객체는 컨트롤러 메서드에서 @Login 어노테이션이 적용된 Member 타입 파라미터에 주입된다.

 

WebMvcConfigurer에 설정 추가

@Configuration
public class WebConfig implements WebMvcConfigurer {

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

WebMvcConfigurer 인터페이스는 스프링 MVC의 웹 구성을 커스터마이징할 수 있게 해주는 중요한 인터페이스이다. 스프링 부트 사용 시 자동 구성이 많은 부분을 처리해주지만, 애플리케이션의 특정 요구 사항에 맞추어 MVC 구성을 세밀하게 조정하고 싶을 때 WebMvcConfigurer 인터페이스를 구현하는 클래스를 생성하여 사용한다.



주요 사용 사례

  • 인터셉터 추가: HTTP 요청을 가로채어 전처리 및 후처리 로직을 실행할 수 있는 인터셉터를 등록한다.
  • 리소스 핸들러 추가: 정적 리소스에 대한 요청을 처리하기 위한 리소스 핸들러를 등록한다.
  • 메시지 컨버터 추가 또는 변경: HTTP 요청 본문을 객체로 변환하거나 객체를 HTTP 응답 본문으로 변환할 때 사용되는 메시지 컨버터를 추가하거나 변경한다.
  • CORS(Cross-Origin Resource Sharing) 설정: 다른 도메인에서의 API 요청을 허용하기 위한 CORS 설정을 추가한다.
  • 뷰 컨트롤러 추가: 특정 URL 요청을 처리하기 위한 뷰 컨트롤러를 추가한다.
  • 포맷터 및 Validator 추가: 필드 포맷팅 및 검증 로직을 추가한다.
  • 전역 CORS 설정: 애플리케이션 전체에 적용되는 CORS 정책을 설정한다.

 

WebMvcConfigurer 인터페이스의 addArgumentResolvers 메서드는 스프링 MVC에서 컨트롤러 메서드의 파라미터를 처리하기 위한 커스텀 HandlerMethodArgumentResolver 인스턴스를 추가하는 기능을 제공한다. 

 

HandlerMethodArgumentResolver는 컨트롤러 메서드의 파라미터를 해석하는 역할을 담당하며, 이를 사용하여 개발자는 요청에 따라 파라미터 값을 동적으로 생성하거나 변형하여 제공할 수 있다.

 

이 메서드를 구현함으로써, 애플리케이션은 표준 파라미터 해석 방식을 넘어서는 복잡한 요구사항을 충족시킬 수 있는 유연성을 갖게 된다.

 

이렇게 ArgumentResolver 를 활 용하면 공통 작업이 필요할 때 컨트롤러를 더욱 편리하게 사용할 수 있다.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


HTTP MessageConverter

HTTP MessageConverter는 스프링 프레임워크에서 HTTP 요청 및 응답 본문을 객체로 변환하거나 객체를 HTTP 응답 본문으로 변환하는 역할을 하는 구성 요소이다. 클라이언트와 서버 간의 데이터 교환은 주로 JSON, XML 등의 형식으로 이루어지는데, MessageConverter는 이러한 데이터 형식을 애플리케이션 내부에서 사용하는 객체로 쉽게 변환해주거나, 반대로 객체를 이러한 데이터 형식으로 변환해주는 작업을 담당한다.

 

뷰 템플릿으로 HTML을 생성해서 응답하는 것이 아니라, HTTP API처럼 JSON 데이터를 HTTP 메시지 바디에서 직접 읽거나 쓰는 경우 HTTP 메시지 컨버터를 사용하면 편리하다.

 

예를 들어, @ResponseBody 어노테이션을 사용할 때 메시지 컨버터가 어떻게 작동하는지 살펴보면,


@ResponseBody는 HTTP의 BODY에 내용을 직접 반환한다.

따라서 viewResolver 대신에 HttpMessageConverter 가 동작한다.

응답의 경우 클라이언트의 HTTP Accept 해더와 서버의 컨트롤러 반환 타입 정보 둘을 조합해서 HttpMessageConverter 가 선택된다.

  • 기본 문자처리: StringHttpMessageConverter
  • 기본 객체처리: MappingJackson2HttpMessageConverter
  • byte 처리 등등 기타 여러 HttpMessageConverter가 기본으로 등록되어 있음

 

스프링 MVC는 다음의 경우에 HTTP 메시지 컨버터를 적용한다.
HTTP 요청: @RequestBody , HttpEntity(RequestEntity)
HTTP 응답: @ResponseBody , HttpEntity(ResponseEntity)

 

HttpMessageConverter 인터페이스

package org.springframework.http.converter;
 public interface HttpMessageConverter<T> {
     boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);
     boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);
     
     List<MediaType> getSupportedMediaTypes();
     
     T read(Class<? extends T> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException;
     void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException;
}
  • canRead(Class<?> clazz, @Nullable MediaType mediaType): 주어진 클래스 타입과 미디어 타입에 대해 이 컨버터가 읽기를 지원하는지 여부를 반환한다. 즉, HTTP 요청 본문을 해당 타입의 객체로 변환할 수 있는지를 나타낸다.
  • canWrite(Class<?> clazz, @Nullable MediaType mediaType): 주어진 클래스 타입과 미디어 타입에 대해 이 컨버터가 쓰기를 지원하는지 여부를 반환한다. 즉, 해당 타입의 객체를 HTTP 응답 본문으로 변환할 수 있는지를 나타낸다.
  • getSupportedMediaTypes(): 이 컨버터가 지원하는 미디어 타입의 리스트를 반환한다. 예를 들어, JSON을 처리하는 컨버터는 application/json 미디어 타입을 지원한다.
  • read(Class<? extends T> clazz, HttpInputMessage inputMessage): HTTP 요청 본문을 읽어서 주어진 클래스 타입의 객체로 변환한다. 이 과정에서 변환에 실패하면 HttpMessageNotReadableException이 발생할 수 있다.
  • write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage): 주어진 객체를 HTTP 응답 본문으로 변환한다. 이 메서드는 객체를 지정된 미디어 타입의 데이터로 변환하여 HttpOutputMessage에 쓴다. 변환에 실패하면 HttpMessageNotWritableException이 발생할 수 있다.

 

HTTP 메시지 컨버터는 HTTP 요청, HTTP 응답 둘 다 사용된다.

  • canRead() , canWrite() : 메시지 컨버터가 해당 클래스, 미디어타입을 지원하는지 체크
  • read() , write() : 메시지 컨버터를 통해서 메시지를 읽고 쓰는 기능

 

스프링 부트 기본 메시지 컨버터

(일부 생략)

  1. ByteArrayHttpMessageConverter
  2. StringHttpMessageConverter
  3. MappingJackson2HttpMessageConverter
  4. ...

스프링 부트는 다양한 메시지 컨버터를 제공하는데, 대상 클래스 타입과 미디어 타입 둘을 체크해서 사용여부를 결정한다.

만약 만족하지 않으면 다음 메시지 컨버터로 우선순위가 넘어간다.

 

ByteArrayHttpMessageConverter: byte[] 데이터를 처리

  • 클래스 타입: byte[], 미디어타입: */*,
  • 요청 예) @RequestBody byte[] data
  • 응답 예) @ResponseBody return byte[]
  • 쓰기 미디어타입: application/octet-stream

 

ByteArrayHttpMessageConverter는 byte[] 데이터를 처리하는 컨버터이다. 이 컨버터는 클래스 타입으로 byte[]를 사용하며, 모든 미디어 타입(*/*)에 대해 읽기와 쓰기를 지원한다. 요청 본문에서 바이트 배열을 받거나 바이트 배열을 응답 본문으로 반환할 때 사용할 수 있다. 쓰기 작업에 대해서는 주로 application/octet-stream 미디어 타입을 사용한다.

 

StringHttpMessageConverter: String 문자로 데이터를 처리

  • 클래스 타입: String, 미디어타입: */*
  • 요청 예) @RequestBody String data
  • 응답 예) @ResponseBody return "ok"
  • 쓰기 미디어입: text/plain

 

StringHttpMessageConverter는 문자열 데이터를 처리하는 컨버터이다. 클래스 타입은 String이며, 이 역시 모든 미디어 타입(*/*)에 대해 읽기와 쓰기를 지원한다. 문자열 데이터를 요청 본문에서 받거나 응답 본문으로 반환할 때 사용되며, 쓰기 작업에는 text/plain 미디어 타입이 주로 사용된다.

 

MappingJackson2HttpMessageConverter: application/json

  • 클래스 타입: 객체 또는 HashMap, 미디어타입: application/json 관련
  • 요청 예) @RequestBody HelloData data
  • 응답 예) @ResponseBody return helloData 
  • 쓰기 미디어타입: application/json 관련

 

MappingJackson2HttpMessageConverter는 application/json 미디어 타입 관련 데이터를 자바 객체나 HashMap으로 변환하거나, 반대로 자바 객체를 JSON 형태로 변환하는 작업을 담당한다. 이 컨버터는 주로 JSON 형식의 데이터를 처리할 때 사용되며, 클래스 타입은 객체 또는 HashMap이다. @RequestBody나 @ResponseBody 어노테이션과 함께 사용하여 JSON 데이터를 요청 본문에서 받거나 응답 본문으로 반환할 때 사용된다. 쓰기 작업에 대해서는 application/json 미디어 타입을 사용한다.

 

HTTP 요청 데이터 읽기

HTTP 요청 데이터를 읽는 과정은 스프링 프레임워크에서 @RequestBody나 HttpEntity 파라미터를 사용할 때 중요한 역할을 한다. 이 과정에서 메시지 컨버터는 들어오는 HTTP 요청의 본문을 애플리케이션에서 사용할 수 있는 객체로 변환하는 작업을 수행한다.

 

구체적인 단계는 다음과 같다

HTTP 요청 수신: 클라이언트로부터 HTTP 요청이 서버에 도달한다. 이 요청은 특정 Content-Type 미디어 타입을 포함하며, 데이터 본문(body)은 JSON, XML, 텍스트 등 다양한 형태일 수 있다.

컨트롤러 메서드 호출: 스프링 MVC는 요청 URL과 메서드 타입(GET, POST 등)을 기반으로 적절한 컨트롤러 메서드를 결정한다. 해당 메서드가 @RequestBody나 HttpEntity를 파라미터로 사용하는 경우, 메시지 컨버터를 통한 데이터 변환이 필요하다.

canRead() 메서드 호출: 스프링은 요청 본문을 읽고 변환하기 전에, 등록된 메시지 컨버터 중에서 요청 데이터를 처리할 수 있는 적절한 컨버터를 찾아야 한다. 이를 위해 각 메시지 컨버터의 canRead() 메서드를 호출한다. 이 메서드는 다음 두 가지 조건을 검사한다

  1. 대상 클래스 타입 지원 여부: 메서드 파라미터로 지정된 타입(byte[], String, HelloData 등)을 이 컨버터가 처리할 수 있는지 확인한다.
  2. HTTP 요청의 Content-Type 미디어 타입 지원 여부: 요청의 Content-Type(예: text/plain, application/json, */* 등)이 이 컨버터가 지원하는 미디어 타입과 일치하는지 확인한다.

read() 메서드 호출: canRead() 검사를 통과한 컨버터가 발견되면, 스프링은 해당 컨버터의 read() 메서드를 호출하여 HTTP 요청 본문을 애플리케이션에서 사용할 수 있는 객체로 변환한다. 이 과정에서 컨버터는 요청 본문의 데이터를 파싱하고, 지정된 타입의 객체를 생성하여 반환한다.

 

HTTP 요청이 오고, 컨트롤러에서 @RequestBody, HttpEntity 파라미터를 사용한다. 
메시지 컨버터가 메시지를 읽을 수 있는지 확인하기 위해 canRead() 를 호출한다.

-대상 클래스 타입을 지원하는가.
예) @RequestBody 의 대상 클래스 ( byte[], String, HelloData )

-HTTP 요청의 Content-Type 미디어 타입을 지원하는가.
예) text/plain, application/json, */*

canRead() 조건을 만족하면 read() 를 호출해서 객체 생성하고, 반환한다.

 

 

HTTP 응답 데이터 생성

HTTP 응답 데이터 생성 과정은 스프링 MVC에서 컨트롤러가 클라이언트에 데이터를 반환할 때 중심적인 역할을 하는 메시지 컨버터를 통해 이루어진다.

이 과정은 클라이언트에게 보내는 응답의 본문을 생성하는 단계로, 다음과 같은 단계로 진행된다

@ResponseBody 또는 HttpEntity 사용: 컨트롤러에서 메서드의 반환 값 앞에 @ResponseBody를 붙이거나, HttpEntity 타입의 객체를 반환함으로써 응답 본문을 생성할 데이터를 지정한다. 이는 스프링에게 해당 메서드의 반환 값을 HTTP 응답 본문에 직접 쓰도록 지시한다.

메시지 컨버터 선택을 위한 canWrite() 호출: 스프링은 메서드의 반환 타입과 클라이언트가 요청한 Accept 미디어 타입을 기반으로 적절한 메시지 컨버터를 선택해야 한다. 이를 위해 등록된 각 메시지 컨버터의 canWrite() 메서드를 호출하여, 해당 컨버터가 반환 타입을 처리할 수 있고, 요청의 Accept 미디어 타입과 호환되는지 확인한다.

  1. 대상 클래스 타입 지원 여부 확인: canWrite() 메서드는 컨버터가 처리할 수 있는 데이터 타입(예: byte[], String, HelloData)이 컨트롤러의 반환 타입과 일치하는지 검사한다.
  2. Accept 미디어 타입 지원 여부 확인: 컨트롤러에서 지정한 @RequestMapping의 produces 속성 또는 클라이언트의 Accept 헤더에 지정된 미디어 타입(예: text/plain, application/json, */*)이 컨버터가 지원하는 미디어 타입과 일치하는지 확인한다.

write() 메서드를 통한 데이터 생성: canWrite() 조건을 만족하는 메시지 컨버터가 선택되면, 스프링은 해당 컨버터의 write() 메서드를 호출하여 반환 값 객체를 HTTP 응답 메시지의 본문으로 변환하고 생성한다. 이 과정에서 컨버터는 객체를 적절한 형식(예: JSON, XML, 텍스트)으로 직렬화하고, 응답 스트림에 쓴다.

 

컨트롤러에서 @ResponseBody, HttpEntity 로 값이 반환된다.

메시지 컨버터가 메시지를 쓸 수 있는지 확인하기 위해 canWrite() 를 호출한다.

대상 클래스 타입을 지원하는가.
예) return의 대상 클래스 ( byte[], String, HelloData )

HTTP 요청의 Accept 미디어 타입을 지원하는가.(더 정확히는 @RequestMapping 의 produces )
예) text/plain, application/json, */*

canWrite() 조건을 만족하면 write() 를 호출해서 HTTP 응답 메시지 바디에 데이터를 생성한다.

 

 

RequestMappingHandlerAdater

RequestMappingHandlerAdapter는 어노테이션 기반의 컨트롤러를 처리하기 위해 사용되는 중심 컴포넌트 중 하나이다.

컨트롤러 내의 @RequestMapping 어노테이션이 붙은 메서드를 HTTP 요청에 매핑하고, 해당 메서드를 호출하는 역할을 한다. 이 과정에서 HTTP 요청 데이터를 메서드의 파라미터로 변환하고, 메서드 실행 결과를 HTTP 응답으로 변환하는 등의 작업을 수행한다.

핵심 기능

메서드 매핑: 클라이언트의 요청 URL, HTTP 메서드, 요청 헤더, 파라미터 등을 기반으로 적절한 컨트롤러 메서드를 찾아낸다.

데이터 바인딩: 요청 본문이나 쿼리 파라미터와 같은 HTTP 요청 데이터를 컨트롤러 메서드의 파라미터 타입으로 변환한다. 이를 위해 HttpMessageConverter를 사용한다.

메서드 호출: 매핑된 컨트롤러 메서드를 실행한다. 이 과정에서 메서드 파라미터로 변환된 요청 데이터가 전달된다.

응답 생성: 컨트롤러 메서드의 반환 값을 클라이언트에 전달할 HTTP 응답으로 변환한다. 반환 값이 void인 경우, 메서드 실행 과정에서 생성된 ModelAndView를 기반으로 뷰를 렌더링한다. 반환 값이 있는 경우, HttpMessageConverter를 사용하여 반환 값을 HTTP 응답 본문으로 변환한다.

 

HandlerMethodArgumentResolver

어노테이션 기반의 컨트롤러는 매우 다양한 파라미터를 사용할 수 있다.

이렇게 다양한 파라미터를 유연하게 처리할 수 있는 이유가 ArgumentResolver 덕분이다.

 

 

 

 

 

애노테이션 기반 컨트롤러를 처리하는 RequestMappingHandlerAdapter 는 바로 이 ArgumentResolver 를 호출해서 컨트롤러(핸들러)가 필요로 하는 다양한 파라미터의 값(객체)을 생성한다.

그리고 이렇게 파리미터의 값이 모 두 준비되면 컨트롤러를 호출하면서 값을 넘겨준다.
스프링은 30개가 넘는 ArgumentResolver 를 기본으로 제공한다.

가능한 파라미터 목록은 다음 공식 메뉴얼에서 확인할 수 있다.
https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann- arguments

 

HandlerMethodArgumentResolver는 특정한 조건에 따라 메서드 파라미터의 값을 결정하는 데 사용된다. 예를 들어, HTTP 요청의 헤더, 쿠키, 세션 등에서 값을 추출하여 메서드 파라미터로 전달할 수 있다. 또는, 보다 복잡한 객체를 구성하여 컨트롤러 메서드에 전달할 수도 있다.

 

HandlerMethodArgumentResolver 인터페이스

public interface HandlerMethodArgumentResolver {
    
    boolean supportsParameter(MethodParameter parameter);
    
    @Nullable
    Object resolveArgument(MethodParameter parameter, @Nullable
    ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory
    binderFactory) throws Exception;
}

 

supportsParameter(MethodParameter parameter)

이 메서드는 현재 리졸버가 주어진 MethodParameter에 대해 파라미터 값을 제공할 수 있는지 여부를 결정한다. 만약 이 메서드가 true를 반환한다면, resolveArgument 메서드가 호출되어 파라미터 값이 해석된다.

 

  • 매개변수: MethodParameter - 컨트롤러 메서드의 특정 파라미터
  • 반환값: boolean - 이 리졸버가 해당 파라미터를 지원하면 true, 그렇지 않으면 false

 

resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception

이 메서드는 실제로 파라미터 값을 해석하는 로직을 구현한다. supportsParameter 메서드에 의해 현재 리졸버가 파라미터를 지원한다고 판단되면 호출된다. 이 메서드는 파라미터에 전달할 객체를 반환해야 한다.

매개변수

  • MethodParameter parameter: 해석할 메서드 파라미터
  • ModelAndViewContainer mavContainer: 컨트롤러 메서드 실행 결과를 담는 컨테이너, 뷰와 모델 정보를 포함할 수 있음 (@Nullable)
  • NativeWebRequest webRequest: 현재 요청에 대한 정보를 포함하는 객체
  • WebDataBinderFactory binderFactory: 데이터 바인딩을 위한 팩토리 객체, 커스텀 바인딩이 필요한 경우 사용될 수 있음 (@Nullable)

 

반환값

컨트롤러 메서드의 파라미터에 주입될 객체이다. 예를 들어, @RequestBody 어노테이션이 붙은 파라미터를 처리하는 경우, resolveArgument 메서드는 요청 본문의 내용을 파라미터 타입의 객체로 변환하여 반환한다.

반환된 객체는 스프링 MVC에 의해 해당 컨트롤러 메서드의 해당 파라미터에 자동으로 할당된다.

(@Nullable표시는 해당 객체가 null일 수 있음을 의미한다.)

 

동작 방식

ArgumentResolver 의 supportsParameter() 를 호출해서 해당 파라미터를 지원하는지 체크하고, 지원하면 resolveArgument() 를 호출해서 실제 객체를 생성한다. 그리고 이렇게 생성된 객체가 컨트롤러 호출시 넘어가는 것이다.

원한다면 직접 이 인터페이스를 확장해서 원하는 ArgumentResolver 를 만들 수도 있다.
 
1. 인터페이스 구현: HandlerMethodArgumentResolver 인터페이스를 구현하는 클래스를 생성한다. 이 클래스에서는 supportsParameter 메서드와 resolveArgument 메서드를 구현해야 한다.

2. supportsParameter: 이 리졸버가 특정 파라미터를 지원하는지 여부를 결정한다. resolveArgument: 실제로 파라미터의 값을 해석하는 로직을 구현한다.

3. 스프링 설정에 리졸버 등록: 생성한 리졸버를 스프링 MVC 설정에 등록해야 한다. WebMvcConfigurer 인터페이스를 구현하는 설정 클래스에서 addArgumentResolvers 메서드를 오버라이드하여 리졸버를 추가한다.

 

HandlerMethodReturnValueHandler

HandlerMethodReturnValueHandler 인터페이스는 컨트롤러 메서드의 반환값을 처리하는 역할을 담당한다.

컨트롤러 메서드가 실행된 후, 그 결과로 반환된 값이 있을 경우, 이 인터페이스의 구현체를 통해 어떻게 처리할지 결정한다. 이 과정에서 반환값을 HTTP 응답 본문으로 변환하거나, 모델과 뷰를 설정하는 등 다양한 작업을 수행할 수 있다.

핵심 기능

HandlerMethodReturnValueHandler 인터페이스의 주요 기능은 컨트롤러 메서드의 반환값을 적절하게 처리하여 클라이언트에 응답을 제공하는 것이다. 

반환값의 타입에 따라, 예를 들어 @ResponseBody가 붙은 메서드의 반환값을 HTTP 응답 본문으로 변환하거나, ModelAndView 객체를 반환하는 경우에는 뷰를 렌더링하는 등의 처리를 담당한다.

 

컨트롤러에서 String으로 뷰 이름을 반환해도, 동작하는 이유가 바로 ReturnValueHandler 덕분이다.

 

스프링은 10여개가 넘는 ReturnValueHandler 를 지원한다.
예) ModelAndView , @ResponseBody , HttpEntity , String

가능한 응답 값 목록은 다음 공식 메뉴얼에서 확인할 수 있다.
https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann- return-types

 

HTTP 메시지 컨버터

HTTP MessageConverter 위치

HTTP 메시지 컨버터를 사용하는 @RequestBody 도 컨트롤러가 필요로 하는 파라미터의 값에 사용된다.

@ResponseBody 의 경우도 컨트롤러의 반환 값을 이용한다.

 

-요청의 경우
@RequestBody를 처리하는 ArgumentResolver가 있고, HttpEntity를 처리하는 ArgumentResolver가 있다. 이 ArgumentResolver들이 HTTP 메시지 컨버터를 사용해서 필요한 객체를 생성하는 것이다.

 

-응답의 경우

@ResponseBody와 HttpEntity를 처리하는 ReturnValueHandler가 있다. 그리고 여기에서 HTTP 메시지 컨버터를 호출해서 응답 결과를 만든다.


스프링 MVC는 @RequestBody @ResponseBody가 있으면 RequestResponseBodyMethodProcessor(), HttpEntity가 있으면 HttpEntityMethodProcessor()를 사용한다.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


로그인 한 사용자만 상품 관리 페이지에 들어갈 수 있어야 한다. 로그인 하지 않은 사용자도 URL을 직접 호출하면 상품 관리 화면에 들어갈 수 있다.
이렇게 로그인하지 않은 사용자는 다른 URL에 접근할 수 없도록 해주는 것이 서블릿 필터와 인터셉터이다.

 

애플리케이션 여러 로직에서 공통으로 관심이 있는 있는 것을 공통 관심사(cross-cutting concern)라고 한다. 

여기서는 등록, 수정, 삭제, 조회 등등 여러 로직에서 공통으로 인증에 대해서 관심을 가지고 있다.

이러한 공통 관심사는 스프링의 AOP로도 해결할 수 있지만, 웹과 관련된 공통 관심사는 지금부터 설명할 서블릿 필터 또는 스프링 인터셉터를 사용하는 것이 좋다.

 

웹과 관련된 공통 관심사를 처리할 때는 HTTP의 헤더나 URL의 정보들 이 필요한데, 서블릿 필터나 스프링 인터셉터는 HttpServletRequest 를 제공한다.

 

서블릿 필터

필터 흐름

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

필터를 적용하면 필터가 호출된 다음에 서블릿이 호출된다. 그래서 모든 고객의 요청 로그를 남기는 요구사항이 있다면 필터를 사용하면 된다.

필터는 특정 URL 패턴에 적용할 수 있다. /* 이라고 하면 모든 요청에 필터가 적용된다. 스프링을 사용하는 경우 여기서 말하는 서블릿은 스프링의 디스패처 서블릿으로 생각하면 된다.

 

필터 제한

로그인 사용자 : HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러

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

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

 

필터 체인

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

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

 

Filter 인터페이스

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

 public interface Filter {
     public default void init(FilterConfig filterConfig) throws ServletException {}
     
     public void doFilter(ServletRequest request, ServletResponse response,
             FilterChain chain) throws IOException, ServletException {}
             
     public default void destroy() {}
}

init(FilterConfig filterConfig)

이 메서드는 필터 인스턴스가 생성될 때 호출된다. 필터의 초기화 작업을 위한 메서드이다. FilterConfig 객체를 매개변수로 받아, 필터 구성 정보에 접근할 수 있다. Java 8 이상에서는 default 메서드로 제공되어, 필수적으로 오버라이드할 필요는 없다. 필터에 초기화할 특별한 작업이 없다면, 이 메서드를 구현하지 않아도 된다.

doFilter(ServletRequest request, ServletResponse response, FilterChain chain)

이 메서드는 필터의 핵심 기능을 수행한다. 요청과 응답을 가로채어 필요한 처리를 한 뒤, 필터 체인에서 다음 필터로 요청과 응답을 넘기거나, 혹은 요청 처리를 중단할 수 있다. ServletRequest와 ServletResponse 객체를 통해 요청과 응답 데이터에 접근하며, FilterChain 객체를 사용해 체인 내의 다음 필터로 작업을 전달한다. 이 메서드는 모든 필터에서 반드시 구현해야 한다.

destroy()

이 메서드는 필터 인스턴스가 제거될 때 호출된다. 필터가 더 이상 필요하지 않을 때 정리 작업을 수행하기 위한 메서드이다. 이 역시 Java 8 이상에서는 default 메서드로 제공되므로, 필요한 경우에만 오버라이드하면 된다.

 

요청 로그와 인증 체크 

요청 로그

@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 {

        //HTTP 요청이 오면 doFilter 가 호출
        HttpServletRequest httpRequest = (HttpServletRequest) request; //HttpServletRequest 로 캐스팅
        String requestURI = httpRequest.getRequestURI(); //요청 URL 얻어옴
        String uuid = UUID.randomUUID().toString(); //UUID 생성

        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"); //필터가 종료된 시점을 로깅
    }
}

public class LogFilter implements Filter {}

→필터를 사용하려면 필터 인터페이스를 구현해야 한다.

 

doFilter(ServletRequest request, ServletResponse response, FilterChain chain)

→HTTP 요청이 오면 doFilter 가 호출된다.

→ServletRequest request 는 HTTP 요청이 아닌 경우까지 고려해서 만든 인터페이스이다. HTTP를 사용하면 HttpServletRequest httpRequest = (HttpServletRequest) request; 와 같이 다운 케스팅 하면 된다.

 

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

→HTTP 요청을 구분하기 위해 요청당 임의의 uuid 를 생성해둔다. log.info("REQUEST [{}][{}]", uuid, requestURI);
uuid 와 requestURI 를 출력한다.

 

chain.doFilter(request, response);

다음 필터가 있으면 필터를 호출하고, 필터가 없으면 서블릿을 호출한다. 만약 이 로직을 호출하지 않으면 다음 단계로 진행되지 않는다.

 

인증 체크

@Slf4j
public class LoginCheckFilter implements Filter {
    private static final String[] whitelist = {"/", "/members/add", "/login", "/ logout","/css/*"}; //이 URL 에 대해서는 필터를 적용하지 않음
    @Override
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
         HttpServletRequest httpRequest = (HttpServletRequest) request; // HttpServletRequest 로 캐스팅
         String requestURI = httpRequest.getRequestURI(); //요청 URL 가져오기
         HttpServletResponse httpResponse = (HttpServletResponse) response;// HttpServletResponse 로 캐스팅
         try {
                 log.info("인증 체크 필터 시작 {}", requestURI);
                 if (isLoginCheckPath(requestURI)) { //url 과 whitelist 와 매치되는 것이 없다면 true
                        log.info("인증 체크 로직 실행 {}", requestURI);
                        HttpSession session = httpRequest.getSession(false); //세션이 이미 존재하지 않는 경우 새로 만들지 않고, null 을 반환
                        if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) { //세션이 없거나 세션을 찾을 수 없다면
                            log.info("미인증 사용자 요청 {}", requestURI);
                            httpResponse.sendRedirect("/login?redirectURL=" + requestURI); //로그인으로 기존에 접속했던 url 을 파라미터로 넘겨서 redirect
                            return; //여기가 중요, 미인증 사용자는 다음으로 진행하지 않고 끝
                        }
                 }
                 //필터링 후 이상이 없다면
                 chain.doFilter(request, response); //다음 필터를 호출하거나 서블릿, 컨트롤러 호출
         } catch (Exception e) {
                 throw e; //예외 로깅 가능 하지만, 톰캣까지 예외를 보내주어야 함
         } finally {
                 log.info("인증 체크 필터 종료 {}", requestURI);
         }
    }

    /**
     * 화이트 리스트의 경우 인증 체크X
     */
    private boolean isLoginCheckPath(String requestURI) {
        return !PatternMatchUtils.simpleMatch(whitelist, requestURI); //문자열 탐색 메소드(매치되는 것이 있다면 true, 여기서는 매치되는 것이 없다면 true)
    }
}

whitelist = {"/", "/members/add", "/login", "/logout", "/css/*"};

인증 필터를 적용해도 홈, 회원가입, 로그인 화면, css 같은 리소스에는 접근할 수 있어야 한다. 이렇게 화이트 리스트 경로는 인증과 무관하게 항상 허용한다. 화이트 리스트를 제외한 나머지 모든 경로에는 인증 체크 로직을 적용한다. 

 

isLoginCheckPath(requestURI)

화이트 리스트를 제외한 모든 경우에 인증 체크 로직을 적용한다.

 

httpResponse.sendRedirect("/login?redirectURL=" + requestURI);

미인증 사용자는 로그인 화면으로 리다이렉트 한다. 그런데 로그인 이후에 다시 홈으로 이동해버리면, 원하는 경로를 다시 찾아가야 하는 불편함이 있다. 예를 들어서 상품 관리 화면을 보려고 들어갔다가 로그인 화면으로 이동하면, 로그인 이후에 다시 상품 관리 화면으로 들어가는 것이 좋다. 이런 부분이 개발자 입장에서는 좀 귀찮을 수 있어도 사용자 입장으로 보면 편리한 기능이다. 이러한 기능을 위해 현재 요청한 경로인 requestURI 를 /login 에 쿼리 파라미터로 함께 전달한다. 물론 /login 컨트롤러에서 로그인 성공시 해당 경로로 이동하는 기능은 추가로 아래의 코드와 같이 개발해야 한다.

@PostMapping("/login")
 public String loginV4(
         @Valid @ModelAttribute LoginForm form, BindingResult bindingResult,
         @RequestParam(defaultValue = "/") String redirectURL,
         HttpServletRequest request) {
         
          //redirectURL 적용
     return "redirect:" + redirectURL; //redirectURL 적용
 
 }

 

return; 

필터를 더는 진행하지 않는다. 이후 필터는 물론 서블릿, 컨트롤러가 더는 호출되지 않는다. 앞서 redirect 를 사용했기 때문에 redirect 가 응답으로 적용되고 요청이 끝난다.

 

필터 인터페이스를 구현한다고 해서 필터가 적용되지 않는다. 따라서 필터를 등록해줘야 한다.

 

필터 등록

필터를 등록하는 방법은 여러가지가 있지만, 스프링 부트를 사용한다면 FilterRegistrationBean 을 사용해서 등록하면 된다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    //FilterRegistrationBean 필터 등록
    @Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter()); //등록할 필터 지정
        filterRegistrationBean.setOrder(1); //필터 체인 순서, 낮을 수록 먼저 동작
        filterRegistrationBean.addUrlPatterns("/*"); //필터를 적용할 URL 패턴 지정, 한 번에 여러 패턴 지정 가능
        return filterRegistrationBean;
    }

    @Bean
    public FilterRegistrationBean loginCheckFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LoginCheckFilter());
        filterRegistrationBean.setOrder(2);
        filterRegistrationBean.addUrlPatterns("/*");
        return filterRegistrationBean;
    }
}

 

  • setFilter(new LogFilter()) : 등록할 필터를 지정한다.
  • setOrder(1) : 필터는 체인으로 동작한다. 따라서 순서가 필요하다. 낮을 수록 먼저 동작한다.
  • addUrlPatterns("/*") : 필터를 적용할 URL 패턴을 지정한다. 한번에 여러 패턴을 지정할 수 있다.

 

@ServletComponentScan, @WebFilter(filterName = "logFilter", urlPatterns = "/*") 로 필터 등록이 가능하지만 필터 순서 조절이 안된다.
따라서 FilterRegistrationBean  을 사용하는 것이 좋다.

 

스프링 인터셉터

스프링 인터셉터도 서블릿 필터와 같이 웹과 관련된 공통 관심 사항을 효과적으로 해결할 수 있는 기술이다.

서블릿 필터가 서블릿이 제공하는 기술이라면, 스프링 인터셉터는 스프링 MVC가 제공하는 기술이다. 둘다 웹과 관련 된 공통 관심 사항을 처리하지만, 적용되는 순서와 범위, 그리고 사용방법이 다르다.

 

스프링 인터셉터 흐름

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

스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출된다.

스프링 인터셉터는 스프링 MVC가 제공하는 기능이기 때문에 결국 디스패처 서블릿 이후에 등장하게 된다. 스프링 MVC의 시작점이 디스패처 서블릿이라고 생각해보면 이해가 될 것이다.
스프링 인터셉터에도 URL 패턴을 적용할 수 있는데, 서블릿 URL 패턴과는 다르고, 매우 정밀하게 설정할 수 있다.

스프링 인터셉터 제한

로그인 사용자 : HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러

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

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

스프링 인터셉터 체인

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

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

 

스프링 인터셉터는 서블릿 필터보다 편리하고, 더 정교하고 다양한 기능을 지원한다.

 

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

스프링의 인터셉터를 사용하려면 HandlerInterceptor 인터페이스를 구현하면 된다.

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 {
    }
}

인터셉터는 컨트롤러 호출 전 ( preHandle ), 호출 후( postHandle ), 요청 완료 이후( afterCompletion )와 같이 단계적으로 잘 세분화 되어 있다.

서블릿 필터의 경우 단순히 request , response 만 제공했지만, 인터셉터는 어떤 컨트롤러( handler ) 가 호출되는지 호출 정보도 받을 수 있다. 그리고 어떤 modelAndView 가 반환되는지 응답 정보도 받을 수 있다.

 

preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)

목적: 컨트롤러 메서드가 호출되기 전에 실행된다. 인증 검사나 로깅과 같은 전처리 작업에 사용된다.

반환값: 이 메서드에서 true를 반환하면 요청 처리가 계속 진행된다. false를 반환하면, 요청 처리가 중단되며 더 이상의 컨트롤러 메서드 호출(핸들러 어댑터 호출 등)이나 다른 인터셉터 체인의 실행이 발생하지 않는다.

postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView)

목적: 컨트롤러 메서드가 실행된 후, 클라이언트에게 뷰를 렌더링하기 전에 호출된다(핸들러 어댑터 호출 후). 컨트롤러에서 생성된 데이터를 가공하거나 추가적인 속성을 모델에 추가하는 등의 후처리 작업에 사용될 수 있다.

매개변수: ModelAndView 객체를 통해 컨트롤러에서 반환된 모델과 뷰 정보에 접근하고 수정할 수 있다.

afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex)

목적: 요청 처리의 전체 과정이 완료된 후에 호출된다. 즉, 뷰 렌더링까지 모두 마친 후에 실행된다. 주로 리소스를 정리하는 작업이나 예외 로깅 등의 작업에 사용된다.

매개변수: Exception 객체를 통해 컨트롤러에서 발생한 예외를 처리할 수 있다.

 

예외가 발생시

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

afterCompletion은 예외가 발생해도 호출된다.

예외가 발생하면 postHandle()는 호출되지 않으므로 예외와 무관하게 공통 처리를 하려면 afterCompletion()을 사용해야 한다.
예외가 발생하면 afterCompletion()에 예외 정보(ex)를 포함해서 호출된다.


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

 

요청 로그와 인증 체크

요청 로그

@Slf4j
public class LogInterceptor implements HandlerInterceptor {
    public static final String LOG_ID = "logId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI(); //요청 URL 받아옴
        String uuid = UUID.randomUUID().toString(); //UUID 생성
        request.setAttribute(LOG_ID, uuid); //UUID 값을 logId 라는 이름으로 request 에 저장

        //@RequestMapping: HandlerMethod
        //정적 리소스: ResourceHttpRequestHandler

        //핸들러 타입 체크: handler 객체가 HandlerMethod 의 인스턴스인지 확인한다.
        //이는 요청이 실제 컨트롤러 메서드에 의해 처리되는 경우에만 특정 로직을 실행하고자 할 때 유용
        if (handler instanceof HandlerMethod) { //HandlerMethod 는 요청을 처리하는 실제 메서드를 나타냄
            //호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
            HandlerMethod hm = (HandlerMethod) handler; //핸들러(컨트롤러)를 HandlerMethod 로 캐스팅
        }
        log.info("REQUEST  [{}][{}][{}]", uuid, requestURI, handler);
        return true; //false 이면 다음 인터셉터나 컨트롤러가 호출 되지 않는다.
    }

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

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String requestURI = request.getRequestURI();  //요청 URL 받아옴
        String logId = (String)request.getAttribute(LOG_ID); //request 에서 logId 로 저장되어 있는 UUID 를 가져옴

        log.info("RESPONSE [{}][{}]", logId, requestURI);

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

 

  1. 요청 URL 받아오기: HttpServletRequest 객체로부터 요청된 URL을 얻는다.
  2. UUID 생성: 고유한 식별자(UUID)를 생성한다. 이 식별자는 요청을 구분하는 데 사용될 수 있다.
  3. UUID 저장: 생성된 UUID를 request 객체에 속성으로 저장한다. 이를 통해 나중에 같은 요청에서 이 UUID를 참조할 수 있다.
  4. 핸들러 타입 체크: handler 객체가 HandlerMethod의 인스턴스인지 확인한다. 이는 요청이 실제 컨트롤러 메서드에 의해 처리되는 경우에만 특정 로직을 실행하고자 할 때 유용하다.
    →HandlerMethod는 요청을 처리하는 컨트롤러 메서드의 모든 정보를 포함한다.
    →요청이 정적 리소스를 요구하는 경우, handler 객체는 ResourceHttpRequestHandler의 인스턴스가 될 수 있다.
  5. 로깅: UUID, 요청된 URL, 핸들러 객체의 정보를 로깅한다. 이는 추후 문제 해결이나 모니터링에 유용할 수 있다.
  6. 요청 처리 계속: preHandle 메서드에서 true를 반환하면 요청 처리가 계속된다. 만약 false를 반환하면, 요청 처리가 여기서 중단되고, 컨트롤러 메서드는 호출되지 않는다.

 

HandlerMethod

Spring MVC에서 컨트롤러의 메서드를 처리하는 데 사용되는 개념이다.
컨트롤러 내의 각 메서드는 HTTP 요청을 처리하기 위한 엔드포인트로 동작하며, HandlerMethod는 이러한 메서드와 관련된 메타데이터(예: 메서드 이름, 파라미터 타입, 어노테이션 등)를 캡슐화한다.

Spring MVC는 들어오는 HTTP 요청을 매핑하고 처리하는 적절한 컨트롤러 메서드를 결정하기 위해 HandlerMethod 객체를 사용한다.

HandlerMethod의 핵심 기능
-메서드 실행: HandlerMethod는 Spring MVC에서 요청을 처리하는 실제 메서드를 나타낸다.이 객체를 사용하면, 리플렉션 없이 관련 메서드를 직접 실행할 수 있다.
-메타데이터 접근: 메서드에 적용된 어노테이션, 메서드 파라미터, 메서드 반환 타입 등에 쉽게 접근할 수 있게 해준다. 이는 요청 처리, 파라미터 바인딩, 응답 생성 등에서 유용하게 사용된다.
-어노테이션 기반 처리: @RequestMapping과 같은 어노테이션이 적용된 메서드를 찾아 요청을 매핑하고 처리하는 데 중요한 역할을 한다.


@RequestMapping 어노테이션은 특정 HTTP 요청(예: GET, POST 등)을 컨트롤러의 메서드와 매핑한다. 이 과정에서 Spring MVC의 디스패처 서블릿(DispatcherServlet)은 들어오는 요청을 분석하여 해당 요청을 처리할 수 있는 HandlerMethod를 결정한다.

처리 과정
-요청 분석: 디스패처 서블릿은 요청의 URL, HTTP 메서드, 헤더, 파라미터 등을 분석한다.
-핸들러 매핑: 요청 정보를 바탕으로, 해당 요청을 처리할 컨트롤러 메서드를 찾기 위해 HandlerMapping 전략을 사용한다. 이때, @RequestMapping과 같은 어노테이션이 붙은 메서드가 후보가 된다.
-HandlerMethod 결정: 요청을 처리할 메서드가 결정되면, 해당 메서드와 연결된 HandlerMethod 객체가 생성된다. 이 객체는 메서드 자체뿐만 아니라, 메서드를 포함하는 컨트롤러 객체에 대한 참조도 포함한다.
-메서드 실행: HandlerAdapter를 사용하여 HandlerMethod를 실행한다. 이 과정에서 메서드의 파라미터는 요청에서 추출한 값으로 채워지며, 메서드의 반환 값은 적절한 뷰로 변환되어 응답을 생성한다

 

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

→요청 로그를 구분하기 위한 uuid 를 생성한다.

 

request.setAttribute(LOG_ID, uuid)

→서블릿 필터의 경우 지역변수로 해결이 가능하지만, 스프링 인터셉터는 호출 시점이 완전히 분리되어 있다. 따라서 preHandle 에서 지정한 값을 postHandle, afterCompletion 에서 함께 사용하려면 어딘가에 담아두어야 한다. LogInterceptor 도 싱글톤 처럼 사용되기 때문에 맴버변수를 사용하면 위험하다. 따라서 request 에 담아두었다. 이 값은 afterCompletion 에서 request.getAttribute(LOG_ID) 로 찾아서 사용한다. 

 

return true

→true 면 정상 호출이다. 다음 인터셉터나 컨트롤러가 호출된다.(false 이면 추가적인 인터셉터나 컨트롤러 호출 X)

 

HandlerMethod

핸들러 정보는 어떤 핸들러 매핑을 사용하는가에 따라 달라진다. 스프링을 사용하면 일반적으로 @Controller, @RequestMapping을 활용한 핸들러 매핑을 사용하는데, 이 경우 핸들러 정보로 HandlerMethod가 넘어온다.

ResourceHttpRequestHandler

@Controller가 아니라 /resources/static 와 같은 정적 리소스가 호출 되는 경우 ResourceHttpRequestHandler가 핸들러 정보로 넘어오기 때문에 타입에 따라서 처리가 필요(intstancof 이용)하다.

postHandle, afterCompletion

종료 로그를 postHandle 이 아니라 afterCompletion에서 실행한 이유는, 예외가 발생한 경우 postHandle가 호출되지 않기 때문이다. afterCompletion은 예외가 발생해도 호출 되는 것을 보장한다.

 

인증 체크

@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();
        log.info("인증 체크 인터셉터 실행 {}", requestURI);
        HttpSession session = request.getSession(false); //세션이 이미 존재하지 않는 경우 새로 만들지 않고, null 을 반환
        if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {  //세션이 없거나 세션을 찾을 수 없다면
            log.info("미인증 사용자 요청");
            response.sendRedirect("/login?redirectURL=" + requestURI); //로그인으로 기존에 접속했던 url 을 파라미터로 넘겨서 redirect
            return false;  //false 이면 다음 인터셉터나 컨트롤러가 호출 되지 않는다.
        }
        return true; //다음 인터셉터나 컨트롤러 호출
    }
}

 

이 코드는 스프링 인터셉터인 LoginCheckInterceptor 클래스의 구현 예이다. 이 클래스는 HandlerInterceptor 인터페이스를 구현한다. 로그인 여부를 확인하는 로직을 포함하고 있다. @Slf4j 어노테이션을 사용함으로써, 로그를 기록하는 기능을 클래스에 추가한다.

preHandle 메서드는 HTTP 요청이 컨트롤러에 도달하기 전에 호출된다. 이 메서드 내에서 요청된 URI를 로그로 기록하고, 현재 HTTP 세션의 유효성을 검사한다. 만약 세션이 존재하지 않거나, 세션 내에 로그인한 사용자 정보(SessionConst.LOGIN_MEMBER)가 없는 경우, 사용자가 미인증 상태로 판단된다.

이러한 경우, 사용자는 로그인 페이지로 리다이렉트되며, 원래 요청하려고 했던 URI는 쿼리 파라미터로 함께 전송되어 로그인 후 해당 페이지로 바로 이동할 수 있게 한다. preHandle 메서드에서 false를 반환하면, 이는 스프링 MVC가 이후의 인터셉터나 컨트롤러를 호출하지 않아야 함을 의미한다. 반면, 세션이 유효하고 로그인한 사용자 정보가 있는 경우 true를 반환하여 다음 인터셉터나 컨트롤러로 요청 처리를 계속 진행할 수 있도록 한다.

 

인터셉터 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {

    //addInterceptors 인터셉터 등록
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor()) //인터셉터를 등록
                .order(1) //인터셉터의 호출 순서를 지정한다. 낮을 수록 먼저 호출
                .addPathPatterns("/**") //인터셉터를 적용할 URL 패턴을 지정
                .excludePathPatterns("/css/**", "/*.ico", "/error"); //인터셉터에서 제외할 패턴을 지정

        registry.addInterceptor(new LoginCheckInterceptor())
                .order(2)
                .addPathPatterns("/**")
                .excludePathPatterns("/", "/members/add", "/login", "/logout", "/css/**", "/*.ico", "/error");
    }
}

WebMvcConfigurer가 제공하는 addInterceptors()를 사용해서 인터셉터를 등록할 수 있다.

  • registry.addInterceptor(new LogInterceptor()) : 인터셉터를 등록한다.
  • order(1) : 인터셉터의 호출 순서를 지정한다. 낮을수록 먼저 호출된다. addPathPatterns("/") : 인터셉터를 적용할 URL 패턴을 지정한다. 
  • excludePathPatterns("/css/", "/*.ico", "/error") : 인터셉터에서 제외할 패턴을 지정한다.

필터와 비교해보면 인터셉터는 addPathPatterns, excludePathPatterns로 매우 정밀하게 URL 패턴을 지정할 수 있다.

 

스프링의 URL 경로

스프링이 제공하는 URL 경로는 서블릿 기술이 제공하는 URL 경로와 완전히 다르다. 더욱 자세하고, 세밀하게 설정할 수 있다.

PathPattern 공식 문서
? : 한 문자 일치
* : 경로(/) 안에서 0개 이상의 문자 일치
** : 경로 끝까지 0개 이상의 경로(/) 일치
{spring} : 경로(/)와 일치하고 spring이라는 변수로 캡처

{spring:[a-z]+} matches the regexp [a-z]+ as a path variable named "spring" {spring:[a-z]+} regexp [a-z]+ 와 일치하고, "spring" 경로 변수로 캡처
{*spring} 경로가 끝날 때 까지 0개 이상의 경로(/)와 일치하고 spring이라는 변수로 캡처
 /pages/t?st.html — matches /pages/test.html, /pages/tXst.html but not /pages/
 toast.html
 /resources/*.png — matches all .png files in the resources directory
 /resources/** — matches all files underneath the /resources/ path, including /
 resources/image.png and /resources/css/spring.css
 /resources/{*path} — matches all files underneath the /resources/ path and
 captures their relative path in a variable named "path"; /resources/image.png
will match with "path" → "/image.png", and /resources/css/spring.css will match
with "path" → "/css/spring.css"
 /resources/{filename:\\w+}.dat will match /resources/spring.dat and assign the
 value "spring" to the filename variable

링크: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/ web/util/pattern/PathPattern.html

 

서블릿 필터와 스프링 인터셉터는 웹과 관련된 공통 관심사를 해결하기 위한 기술이다.
서블릿 필터와 비교해서 스프링 인터셉터가 개발자 입장에서 훨씬 편리하다. 특별한 문제가 없다면 인터셉터를 사용하는 것이 좋다.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


세션을 이용한 로그인 처리

쿠키에 중요한 정보를 보관하는 방법은 여러가지 보안 이슈가 있다.
이 문제를 해결하려면 결국 중요한 정보를 모두 서버에 저장해야 한다. 그리고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다.

이렇게 서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다.

 

사용자가 loginId , password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.

 

세션 ID를 생성하는데, 추정 불가능해야 한다.
→UUID는 추정이 불가능하다.

UUID ex) mySessionId=zz0101xx-bab9-4b92-9b32-dadb280f4b61

세션 저장소의 세션 아이디에는 UUID를 저장하고, value에는 정보를 저장한다.

즉, 생성된 세션 ID(UUID)와 세션에 보관할 값( memberA )을 서버의 세션 저장소에 보관한다.

 

서버는 클라이언트에 mySessionId 라는 이름으로 세션ID(UUID) 만 쿠키에 담아서 전달한다.

클라이언트는 쿠키 저장소에 mySessionId 쿠키를 보관한다.

여기서 중요한 포인트는 회원과 관련된 정보는 전혀 클라이언트에 전달하지 않는다는 것이다.
오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달한다.

 

클라이언트는 요청시 항상 mySessionId 쿠키를 전달한다.
서버에서는 클라이언트가 전달한 mySessionId 쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용한다.

 

세션을 사용해서 서버에서 중요한 정보를 관리하게 되었다.
따라서 아래와 같은 보안 문제들을 해결할 수 있다.

-쿠키 값을 변조 가능 → 예상 불가능한 복잡한 세션Id를 사용한다.
-쿠키에 보관하는 정보는 클라이언트 해킹시 털릴 가능성이 있다. → 세션Id가 털려도 여기에는 중요한 정보가 없다.

쿠키 탈취후 사용 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 세션의 만료 시간을 짧게(예: 30분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 세션을 강제로 제거하면 된다.

 

직접 세션 관리

세션 관리는 크게 아래와 같이 3가지 기능을 제공하면 된다.

  • 세션 생성

    →sessionId 생성 (임의의 추정 불가능한 랜덤 값)
    세션 저장소에 sessionId와 보관할 값 저장
    sessionId로 응답 쿠키를 생성해서 클라이언트에 전달

  • 세션 조회
    클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회

  • 세션 만료
    클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거

 

/**
 * 세션 관리
 */
@Component
public class SessionManager {
    public static final String SESSION_COOKIE_NAME = "mySessionId";
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
    /**
     * 세션 생성
     */
    public void createSession(Object value, HttpServletResponse response) {
        //세션 id를 생성하고, 값을 세션에 저장
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId, value);

        //쿠키 생성
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);
    }

    /**
     * 세션 조회
     */
    public Object getSession(HttpServletRequest request) {

        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie == null) {
            return null;
        }
        return sessionStore.get(sessionCookie.getValue());
    }

    /**
     * 세션 만료
     */
    public void expire(HttpServletRequest request) {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie != null) {
            sessionStore.remove(sessionCookie.getValue());
        }
    }
    private Cookie findCookie(HttpServletRequest request, String cookieName) {

//        Cookie[] cookies = request.getCookies();
//        if(cookies == null){
//            return null;
//        }
//        for (Cookie cookie : cookies) {
//            if(cookie.getName().equals(SESSION_COOKIE_NAME)){
//                return sessionStore.get(cookie.getValue());
//            }
//        }
//        return null;
        
        if (request.getCookies() == null) {
            return null;
        }

        return Arrays.stream(request.getCookies())
                .filter(cookie -> cookie.getName().equals(cookieName))
                .findAny()
                .orElse(null);
    }
}
Stream API에는 findFirst()와 findAny() 메소드가 있다.
이 메소드들은 스트림에서 요소를 찾을 때 사용되며, Optional<T> 타입의 결과를 반환한다. 이는 찾은 요소가 존재할 수도 있고, 존재하지 않을 수도 있다는 것을 의미한다.


stream.findFirst()
findFirst() 메소드는 스트림의 첫 번째 요소를 반환한다. 이 메소드는 순서가 중요한 스트림에서 특히 유용하다.
예를 들어, 리스트나 정렬된 컬렉션에서 생성된 스트림, 또는 정렬된 스트림에서 첫 번째 요소를 찾고 싶을 때 사용된다.


stream.findAny()
findAny() 메소드는 스트림의 "어떤" 요소라도 반환할 수 있다. 병렬 스트림에서 작업을 수행할 때 이 메소드를 사용하면 효율적이다.
순서가 중요하지 않거나, 단순히 스트림 내의 어떤 요소라도 반환받기를 원할 때 유용하다. findAny()는 첫 번째 요소가 아닌 다른 요소를 반환할 수도 있다는 점에서 findFirst()와 구별된다.

orElse()
Optional 객체가 값이 있으면 그 값을 반환하고, 값이 없을 경우에는 메소드에 전달된 인자를 기본값으로 반환

 

ConcurrentHashMap
java.util.concurrent 패키지에 있는 스레드 안전한 해시 테이블의 구현체이다.
이 클래스는 멀티 스레드 환경에서 고성능을 유지하면서도, 데이터의 일관성을 보장하기 위해 설계되었다. HashMap과 비교했을 때, ConcurrentHashMap은 동시성(concurrency)을 향상시키기 위한 여러 가지 전략을 채택하고 있다.

특징
스레드 안전: ConcurrentHashMap의 모든 연산은 스레드 안전하다. 여러 스레드가 동시에 맵을 수정하거나 조회해도, ConcurrentHashMap은 데이터의 일관성을 유지한다.

락 분할(Lock Stripping): ConcurrentHashMap은 내부적으로 여러 개의 락을 사용하여 데이터 구조의 다른 부분을 동시에 수정할 수 있도록 한다. 이로 인해, 동시에 여러 스레드가 맵에 접근하더라도 높은 동시성을 제공한다.

null 값 불허: ConcurrentHashMap은 키(key)나 값(value)으로 null을 허용하지 않는다. 이는 HashMap과의 주요 차이점 중 하나이다.

고성능: 락 분할 기법 덕분에, ConcurrentHashMap은 동시에 여러 스레드가 맵을 사용할 때 뛰어난 성능을 제공한다.

 

테스트 코드

class SessionManagerTest {
    SessionManager sessionManager = new SessionManager();
    @Test
    void sessionTest() {
        //세션 생성
        MockHttpServletResponse response = new MockHttpServletResponse();
        Member member = new Member();
        sessionManager.createSession(member, response);

        //요청에 응답 쿠키 저장
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setCookies(response.getCookies());

        //세션 조회
        Object result = sessionManager.getSession(request);
        assertThat(result).isEqualTo(member);

        //세션 만료
        sessionManager.expire(request);
        Object expired = sessionManager.getSession(request);
        assertThat(expired).isNull();
    }
}

MockHttpServletResponse와 MockHttpServletRequest

스프링 프레임워크에서 제공하는 목(mock) 객체로, 실제 HTTP 요청과 응답을 모의하는데 사용된다.
이를 통해 실제 네트워크 환경이나 웹 서버 없이 HTTP 통신을 시뮬레이션할 수 있다.

 

세션 생성: SessionManager의 createSession() 메소드를 사용하여 Member 객체를 세션에 저장한다. 이 과정에서 MockHttpServletResponse 객체를 사용하여 HTTP 응답에 세션 정보를 쿠키로 추가한다.

 

요청에 응답 쿠키 저장: 생성된 세션 쿠키를 MockHttpServletRequest 객체에 설정한다. 이는 클라이언트가 서버로 요청을 보낼 때 쿠키를 포함시키는 것을 모의한다.

 

세션 조회: SessionManager의 getSession() 메소드를 호출하여 요청 객체를 기반으로 세션 정보를 조회한다. 조회 결과가 원래 저장한 Member 객체와 동일한지 검증한다.

 

세션 만료: SessionManager의 expire() 메소드를 사용하여 세션을 만료시킨다. 이후 같은 요청 객체로 세션을 조회했을 때 null이 반환되는지 검증한다. 이는 세션이 성공적으로 만료되었음을 확인한다.

 

 

HttpSession

서블릿은 세션을 위해 HttpSession 이라는 기능을 제공하는데, 지금까지 나온 문제들을 해결해준다.

직접 구현한 세션의 개념이 이미 구현되어 있고, 더 잘 구현되어 있다.

서블릿이 제공하는 HttpSession 도 결국 직접 만든 SessionManager 와 같은 방식으로 동작한다.

서블릿을 통해 HttpSession 을 생성하면 다음과 같은 쿠키를 생성한다.

쿠키 이름이 JSESSIONID 이고, 값은 추정 불가능한 랜덤 값이다.

 

세션 생성: 사용자가 웹 사이트에 처음 방문하거나 세션이 만료된 후 새로운 요청을 할 때, 서버는 자동으로 새로운 HttpSession 객체를 생성한다.

 

세션 데이터 저장 및 검색: HttpSession 객체는 사용자별 정보를 저장할 수 있는 속성을 제공한다.

-setAttribute(String name, Object value) 메소드를 사용해 데이터를 세션에 저장하고

-getAttribute(String name) 메소드로 데이터를 검색할 수 있다.

 

세션 유효 시간 관리: HttpSession의 유효 시간은 기본적으로 웹 서버에서 설정되며, setMaxInactiveInterval(int interval) 메소드를 사용해 프로그래밍 방식으로 세션의 유효 시간을 조정할 수 있다. 사용자가 설정된 시간 동안 활동이 없으면 세션이 만료된다.

 

세션 종료: invalidate() 메소드를 호출하여 언제든지 세션을 종료시킬 수 있다. 이 메소드는 세션에 저장된 모든 데이터를 삭제하고 세션을 무효화한다.

 

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    // 세션 얻기
    HttpSession session = request.getSession();

    // 세션에 데이터 저장
    session.setAttribute("user", "John Doe");

    // 세션에서 데이터 검색
    String user = (String) session.getAttribute("user");
    System.out.println("User: " + user);

    // 세션 유효 시간 설정 (초 단위)
    session.setMaxInactiveInterval(300); // 5분

    // 세션 종료
    session.invalidate();
}

 

세션의 생성과 조회

세션을 생성하려면 request.getSession(true) 를 사용하면 된다.
public HttpSession getSession(boolean create);​


세션의 create 옵션
request.getSession(true) :  세션이 있으면 기존 세션을 반환한다. 세션이 없으면 새로운 세션을 생성해서 반환한다. (기본 값은 true)
request.getSession(false) :  세션이 있으면 기존 세션을 반환한다. 세션이 없으면 새로운 세션을 생성하지 않는다. null 을 반환한다. 


세션을 찾아서 사용하는 시점에는 create: false 옵션을 사용해서 세션을 생성하지 않아야 한다.

 

@SessionAttribute

스프링은 세션을 더 편리하게 사용할 수 있도록 @SessionAttribute 을 지원한다.
이미 로그인 된 사용자를 찾을 때는 다음과 같이 사용하면 된다.

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

이 기능은 세션을 생성하지 않는다.

 

이 어노테이션을 사용하면, 세션에서 직접 속성을 가져오거나 세션에 속성을 추가하는 복잡한 작업 없이, 세션에 저장된 데이터를 쉽게 사용할 수 있다.

 

@SessionAttribute는 주로 컨트롤러 클래스 레벨에 선언되며, 이 어노테이션으로 지정된 속성은 해당 컨트롤러의 모든 핸들러 메소드에서 접근할 수 있게 된다. 또한, 메소드 파라미터 레벨에서 사용될 수도 있어, 특정 핸들러 메소드에서만 세션 속성을 사용하고 싶을 때 유용하다.

 

클래스 레벨에서의 사용 예

@Controller
@SessionAttributes("user")
public class MyController {

    @ModelAttribute("user")
    public User createUserModel() {
        return new User();
    }

    @GetMapping("/user/profile")
    public String userProfile(Model model) {
        // "user" 세션 속성 사용 가능
        User user = (User) model.getAttribute("user");
        return "userProfile";
    }
}

 

메소드 파라미터 레벨에서의 사용 예

@GetMapping("/user/profile")
public String getUserProfile(@SessionAttribute("user") User user, Model model) {
    // 메소드 파라미터로 "user" 세션 속성 직접 접근
    return "userProfile";
}

 

 @GetMapping("/")
public String homeLoginV3Spring(
        @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
        Member loginMember,
        Model model) {

        //세션에 회원 데이터가 없으면 home
        if (loginMember == null) {
        return "home";
    }

    //세션이 유지되면 로그인으로 이동
    model.addAttribute("member",loginMember);
    return"loginHome";
}

 

@SessionAttribute로 지정된 세션 속성은 컨트롤러 내부에서 모델에 자동으로 추가되므로, 뷰 템플릿에서 해당 데이터를 사용할 수 있다.

 

이 어노테이션은 주로 읽기 전용 시나리오에서 사용될 것을 권장한다. 

 

세션 데이터를 변경하고 싶다면, 직접 HttpSession을 사용하는 것이 좋다.

 

@SessionAttributes와 혼동하지 말아야 한다. @SessionAttributes는 모델 속성을 세션에 저장하기 위해 사용되며, 주로 폼 제출 과정에서 사용자의 입력 데이터를 임시로 저장하는 데 쓰인다. 

 

반면, @SessionAttribute는 이미 세션에 저장된 속성에 접근하는 데 사용된다.

 

세션에서 직접 관리해야 하는 데이터가 아니라면, 가능한 한 세션 사용을 최소화하는 것이 좋다. 세션 데이터는 서버의 메모리를 사용하기 때문에, 과도한 사용은 애플리케이션의 성능에 부정적인 영향을 줄 수 있다.

 

TrackingModes

세션을 이용해 로그인을 하게되면 URL이 다음과 같이 jsessionid 를 포함하고 있는 것을 확인할 수 있다.

-http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872

jsessionid가 노출되지 않도록 하기 위해서는 application.properties에 아래의 코드를 넣어주면 된다.

server.servlet.session.tracking-modes=cookie

 

세션 정보와 타임아웃 설정

세션 정보 확인

@Slf4j
@RestController
public class SessionInfoController {
    @GetMapping("/session-info")
    public String sessionInfo(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return "세션이 없습니다.";
        }

    //세션 데이터 출력
    session.getAttributeNames().asIterator()
            .forEachRemaining(name -> log.info("session name={}, value={}", name, session.getAttribute(name)));
        log.info("sessionId={}", session.getId()); //세션Id, JSESSIONID 의 값
        log.info("maxInactiveInterval={}", session.getMaxInactiveInterval()); //세션의 유효 시간
        log.info("creationTime={}", new Date(session.getCreationTime())); //세션 생성일시
        log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime())); //세션과 연결된 사용자가 최근에 서버에 접근한 시간
        log.info("isNew={}", session.isNew()); //새로 생성된 세션인지 아닌지
        return "세션 출력";
    }
}

 

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

 

세션 타임아웃 설정

세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate() 가 호출 되는 경우에 삭제된다.

그런데 대부분 의 사용자는 로그아웃을 선택하지 않고, 그냥 웹 브라우저를 종료한다.

문제는 HTTP가 비 연결성(ConnectionLess) 이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인식할 수 없다.

따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다.

 

이 경우 남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있다.

  • 세션과 관련된 쿠키( JSESSIONID )를 탈취 당했을 경우 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수 있다.
  • 세션은 기본적으로 메모리에 생성된다. 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우만 생성해서 사용해야 한다. 10만명의 사용자가 로그인하면 10만개의 세션이 생성되는 것이다.

 

세션의 종료 시점

세션 생성 시점으로부터 30분 정도로 잡는다면,  30분이 지나면 세션이 삭제되기 때문에 30분 마다 계속 로그인해야 하는 번거로움이 발생한다.

더 나은 대안은 세션 생성 시점이 아니라 사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도를 유지해주는 것이다. 이렇게 하면 사용자가 서비스를 사용하고 있으면, 세션의 생존 시간이 30분으로 계속 늘어나게 된다.

따라서 30 분 마다 로그인해야 하는 번거로움이 사라진다. HttpSession 은 이 방식을 사용한다.

 

세션 타임아웃 설정

application.properties 에서 타임아웃을 설정할 수 있다.

server.servlet.session.timeout=60

60초, 기본은 1800(30분) (글로벌 설정은 분 단위로 설정해야 한다. 60(1분), 120(2분), ...)

 

특정 세션 단위로 시간 설정

session.setMaxInactiveInterval(1800); //1800초

 

세션 타임아웃 발생

세션의 타임아웃 시간은 해당 세션과 관련된 JSESSIONID 를 전달하는 HTTP 요청이 있으면 현재 시간으로 다시 초기화 된다.

이렇게 초기화 되면 세션 타임아웃으로 설정한 시간동안 세션을 추가로 사용할 수 있다.

  • session.getLastAccessedTime() : 최근 세션 접근 시간

LastAccessedTime 이후로 timeout 시간이 지나면, WAS가 내부에서 해당 세션을 제거한다.

 

보관한 데이터 용량 사용자 수로 세션의 메모리 사용량 이 급격하게 늘어나서 장애로 이어질 수 있다.
추가로 세션의 시간을 너무 길게 가져가면 메모리 사용이 계속 누적 될 수 있으므로 적당한 시간을 선택하는 것이 필요하다.
기본이 30분이라는 것을 기준으로 고민하면 된다.