Search

[강의 요약] 김영한 - 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술

Last update: @2/16/2023
주의
본 포스팅은 인프런 강의를 통해 학습한 내용을 임의로 요약한 것으로 일부 내용의 오류 및 누락, 링크 숨김 등이 존재합니다.

타임리프

기본 기능
스프링 통합 기능
스프링 빈 호출 지원
form 편의기능(th:object, th:field, th:errors, th:errorclass)
form 컴포넌트 편의기능(checkboc, radio button, List 등)
스프링의 메시지, 국제화 기능 통합
스프링의 검증, 오류 처리 통합
스프링의 변환 서비스 통합(ConversionService)

메시지, 국제화

설정
1.
resources 경로에 messages.properties 파일 생성
messages_en.properties, messages_kr.properties 등으로 나라별 메시지 파일 생성
accept-language 헤더 또는 사용자 선택을 통해 쿠키 등을 이용해 처리
아래처럼 코드와 문자열 등록. {n}은 인자값이 들어오는 곳
hello=안녕 hello.name=안녕 {0} label.item=상품 label.item.id=상품 ID label.item.itemName=상품명 ...
Java
복사
2.
MessageSource를 스프링 빈으로 등록
스프링 부트를 사용하면 스프링 부트가 MessageSource를 자동으로 스프링 빈으로 등록함
사용
스프링 내에서 사용 - MessageSouce 주입받아 사용
@Autowired MessageSource ms; ... ms.getMessage("no_code", null, "기본 메시지", null); // "기본 메시지" ms.getMessage("hello.name", new Object[]{"Spring"}, null); // "안녕 Spring" (매개변수 사용) ms.getMessage("hello", null, Locale.KOREA)).isEqualTo("안녕"); // KOREA가 없어서 default가 나옴 (국제화 파일 선택)
Java
복사
Local 정보가 없으면 Locale.getDefault()를 호출해서 시스템의 기본 로케일을 사용
타임리프에서 사용 - #{메시지 코드} 사용
<h2 th:text="#{label.item}"></h2>
Java
복사

검증 - BindingResult

회원가입 등 HTML form을 통해 요청과 함께 파라미터가 날아오면 handler adapter는 argument resolver를 통해 컨트롤러가 요청하는 ModelAttribute에 파라미터를 조립함
이 과정에서 타입이 안 맞거나 사용자가 설정한 애노테이션에 따른 검증에 실패할 경우 오류가 발생함
사용자에게 form 제출 화면을 다시 띄워서 어떤 오류 때문에 요청이 처리되지 않았는지 알리고, 사용자가 작성하던 정보들을 다시 form에 넣어주는 작업을 해야함
직접 검증할 경우 - 타입 오류는 처리도 못함
BindingResult 이용 (Errors 인터페이스를 상속받은 인터페이스)
스프링이 제공하는 객체로, 컨트롤러 파라미터로 받을 경우 검증 오류가 발생하면 여기에 보관됨
BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 field 에러가 자동으로 BindingResult에 담기고 컨트롤러가 호출됨(BindingResult가 없으면 400 오류와 함께 컨트롤러가 호출되지 않음)
컨트롤러 메서드에 파라미터로 BindingResult를 받은 후 if문을 통해 직접 에러를 담을 수도 있음
BindingResult는 model에 자동으로 포함됨

타임리프의 오류 처리

검증에 실패해 다시 form 화면으로 보내진 경우 사용자가 작성하던 정보들을 살려주면서 오류 정보를 알려줘야 함. 이 처리를 타임리프가 도와줌
직접 할 경우 아래처럼 조건문 처리를 해줘야 함
종류
#fields: BindingResult가 제공하는 검증 오류에 접근 가능
th:errors="*{필드명}": 해당 필드에 오류가 있는 경우 태그를 출력(th:if의 편의버전)
아래와 같은 코드를 대체해줌
th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}"
Java
복사
th:errorclass="클래스명": th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가
아래와 같은 코드를 대체해줌
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
Java
복사
th:field: 컨트롤러에서 뷰로 넘어올 때 form의 input의 값들을 채워주는 역할을 함. th:object와 같이 쓰이며, 정상 상황에서는 모델 객체의 값을 사용, 오류 발생 시는 오류 이전에 입력한 값을 보관해 놓았다가 재사용함
<form action="item.html" th:action th:object="${item}" method="post"> <input type="text" id="itemName" th:field="*{itemName}"> </form>
JavaScript
복사
이외에도 name과 id를 적절히 생성해주는 기능도 함
필드 오류 처리
<form action="item.html" th:action th:object="${item}" method="post"> ... <input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control"> <div class="field-error" th:errors="*{itemName}"> 상품명 오류 </div> ... </div>
Java
복사
글로벌 오류 처리
<div th:if="${#fields.hasGlobalErrors()}"> <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p> </div>
Java
복사

오류 메시지 설정

errors.properties 파일 생성해서 메시지 코드와 메시지 등록(errors_en처럼 국제화도 가능)
required.item.itemName=상품 이름은 필수입니다. range.item.price=가격은 {0} ~ {1} 까지 허용합니다. max.item.quantity=수량은 최대 {0} 까지 허용합니다. totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
Java
복사
스프링 부트 메시지 설정 추가
spring.messages.basename=messages,errors
Java
복사
FiledError, ObjectError에 메시지 코드 추가
참조(다루기 매우 번거로움)
bindingResult.rejectValue(), reject()
번거로운 FieldError, ObjectError를 넣는 대신 bindingResult가 객체에 대한 정보를 가지고 있는 것을 이용해서 특정 필드를 거부(reject)하는 식으로 에러를 만들어주는 메서드
rejectValue()는 특정 필드에 대한 거부(에러)
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
Java
복사
reject()는 글로벌 거부(에러)
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
Java
복사
errorCode: messageResolver를 위한 오류 코드
이 에러코드를 기반으로 messageResolver가 아래 규칙에 따라 오류코드를 여러 개 만들어서 FieldError, ObjectError 객체를 만듦(에러 코드 인자가 여러개 들어갈 수 있는 객체이기 때문)
객체 오류
*** 객체 오류 *** 1. {error code}.{object name} 2. {error code} *** 필드 오류 *** 1. {error code}.{obejct name}.field 2. {error code}.{field} 3. {error code}.{field type} 4. {error code}
Java
복사
위처럼 구체적인 순서로 오류코드를 찾아서 출력하고, 없으면 디폴트 메시지를 출력, 디폴트 메시지가 없으면 스프링 기본 메시지 출력

검증 - Validator의 분리

Validator 인터페이스
public interface Validator { boolean supports(Class<?> clazz); void validate(Object target, Errors errors); }
Java
복사
위의 검증 로직을 Validator 인터페이스 구현체로 분리 후 컨트롤러에서 아래처럼 사용
private final ItemValidator itemValidator; // DI로 주입받음 ... itemValidator.validate(item, bindingResult);
Java
복사
WebDataBinder 이용 - 스프링의 파라미터 바인딩 역할 및 검증기 포함
컨트롤러에 아래 코드 추가
@InitBinder public void init(WebDataBinder dataBinder) { dataBinder.addValidators(itemValidator); }
Java
복사
아래처럼 컨트롤러 메서드 파라미터에 @Validated 추가하면 validator 호출 로직 없이도 검증 자동 적용
...(@Validated @ModelAttribute Item item, BindingResult bindingResult)...
Java
복사
글로벌 설정은 @SpringBootApplication 설정파일에 다음 추가
@Override public Validator getValidator() { return new ItemValidator(); }
Java
복사
다만 이렇게 할 경우 BeanValidator가 자동 등록되지 않으니 사용하지 말 것

검증 - Bean Validation

Bean Validation 2.0(JSR-380)이라는 기술 표준으로, 애노테이션과 여러 인터페이스의 모음
보통 하이버네이트 Validator를 구현체로 많이 사용함(ORM과는 무관)
build.gradle에 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'
Groovy
복사
다음과 같이 사용
@Data public class Item { @NotBlank private String itemName; @NotNull @Range(min = 1000, max = 1000000) private Integer price; ... }
Java
복사
ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); Validator validator = factory.getValidator(); Item item = new Item(...); Set<ConstraintViolation<Item>> violations = validator.validate(item);
Java
복사
Violation 내부에는 검증 오류가 발생한 객체, 필드, 메시지 정보 등이 담겨 있음
@NotNull, @NotEmty, @NotBlank 차이
null
“”(빈 문자열)
“ “(공백 문자열)
@NotNull
X
O
O
@NotEmpty
X
X
O
@NotBlank
X
X
X
보통 직접 사용하지 않고 스프링과 통합하여 사용함
사용법 - @ModelAttribute 앞에 @Validated 부착
@PostMapping("/add") public String addItem( @Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model ) { // 검증에 실패하면 다시 입력 폼으로 if (bindingResult.hasErrors()) { log.info("errors = {}", bindingResult); return "validation/v3/addForm"; } // 성공 로직 Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/validation/v3/items/{itemId}"; }
Java
복사
스프링 부트가 validation 라이브러리를 보면 자동으로 Bean Validator를 인지하고 스프링에 통합함
스프링 부트는 LocalValidatorFactoryBean을 글로벌 Validator로 등록하고, 이게 @NotNull 같은 애노테이션을 통한 검증을 수행하고, 오류 발생 시 FieldError, ObjectError를 생성해서 BindingResult에 담아줌
검증 순서
1.
@ModelAttribute 각각의 필드에 타입 변환 시도
a.
성공하면 다음으로
b.
실패하면 typeMismatchFieldError추가
2.
바인딩에 성공하면 Validator 적용
@Valid: 자바 표준 검증 애노테이션
@Validated: 스프링 전용 검증 애노테이션
@Valid와 똑같이 동작하지만 groups라는 기능을 포함하고 있음
글로벌 오류는 bindingResult.reject()를 통해 직접 자바 코드로 작성
엔티티 클래스에 @ScriptAssert로 가능하긴 하지만 복잡하고 제약이 많음
데이터의 등록/수정 등 상황에 따라 검증이 달라지는 부분은 객체를 DTO 등으로 별도로 분리해서 생성
@NotNull 등의 검증 어노테이션에 groups 속성이 있긴 하지만 매우 복잡해짐
@Valid, @Validated는 HttpMessageConverter(@RequestBody)에서도 적용 가능
...(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult)... if(bindingResult.hasErrors()) { String errorJson = bindingResult.getAllErrors(); }
Java
복사
위의 경우 JSON 객체 조립에 성공한 경우만 검증이 진행되고, 검증이 실패할 경우 bindingResult.getAllErrors()를 통해 FieldError와 ObjectError를 JSON 형태로 얻을 수 있음. 이를 가공해서 API 스펙에 맞춰 가공해 전송하면 됨

로그인 처리 - 세션과 쿠키

HTTP는 기본적으로 무상태(statless)이기 때문에 같은 서비스라고 해도 웹페이지 요청마다 어떤 사용자가 보내는 것인 지 구분할 수 없음
따라서 서버에서는 사용자를 구분하기 위해 session ID라는 상태 코드를 발급하고, session ID는 쿠키라는 문자열 형태로 사용자의 브라우저에 저장됨
쿠키는 session 쿠키와 persistance 쿠키로 나뉨. session 쿠키는 브라우저가 종료되거나 사용자가 로그아웃을 하면 삭제되며, session ID가 이 session 쿠키 형태로 발급됨
만료 날짜를 설정하지 않으면 session 쿠키가 됨
쿠키는 HTTP 헤더를 통해 전달되는 단순 문자열로, 세션을 운송할 수 있는 여러 방법 중 하나일 뿐
서버는 사용자의 매 요청마다 이 session ID를 확인해서 어떤 사용자가 보내는 요청인지 구분해서 서비스할 수 있는데, 이렇게 session ID를 통해 사용자의 요청이 구분되는 논리적인 범위를 세션이라고 함
즉, 세션동일한 session ID를 가진 request의 집합이라고 할 수 있음(물론 session ID를 재발급한 경우도 포함)
참고로 웹 유닛테스트는 MockHttpServletRequest, MockServletResponse를 통해 가능함(링크)
직접 구현
서블릿을 통해 구현
TrackingModes
로그인을 처음 시도하면 웹 브라우저가 쿠키를 지원하지 않을 경우를 대비해 서블릿이 리다이렉트 URL에 세션 ID를 파라미터로 넣는데, 이를 끄려면 application.properties에 다음 추가
server.servlet.session.tracking-modes=cookie
Java
복사
세션 정보 조회
session.getAttribute(attributeName))); session.getId(); // 세션 ID session.getMaxInactiveInterval(); // 세션 유효 시간 new Date(session.getCreationTime()); // 세션 생성 시각 new Date(session.getLastAccessedTime()); // 세션 사용자가 마지막으로 서버에 접근한 시각 session.isNew(); // 새로 생성된 세션인지 과거에 만들어진 세션인지 여부
Java
복사
세션 만료
session.invalidate();
Java
복사
하지만 사용자는 보통 로그아웃 없이 브라우저를 종료하므로 서버는 브라우저가 종료된 것을 알 수 없고, 세션 데이터를 언제 삭제해야 하는지 판단할 수 없음
이 경우 세션을 무한정 보관하면 보안 문제가 생기고, 메모리에 남아 누적됨
따라서 세션의 마지막 request를 기준으로 일정 시간(보통 30분)이 넘어가면 세션을 만료시킴
session.setMaxInactiveInterval(1800); // 30분
Java
복사
또는 application.properties에서 스프링 부트로 글로벌 설정 가능
server.servlet.session.timeout=1800
Java
복사

[OLD] 서블릿 필터

스프링 인터셉터

흐름
HTTP 요청 -> WAS -> 필터s -> 디스패처 서블릿 -> 스프링 인터셉터 -> 컨트롤러
Java
복사
인터셉터 구현
public interface HandlerInterceptor { default boolean preHandle(...) {} // 컨트롤러 호출 전 default void postHandle(...) {} // 컨트롤러 호출 후 default void afterCompletion(...) {} // 요청 완료 이후 }
Java
복사
예시 - 로깅
@Slf4j public class LogInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestURI = request.getRequestURI(); String uuid = UUID.randomUUID().toString(); // @RequestMapping: HandlerMethod // 정적 리소스: ResourceHttpRequestHandler if (handler instanceof HandlerMethod) { HandlerMethod hm = (HandlerMethod) handler; // 호출할 컨트롤러 메서드의 모든 정보가 포함되어 있음 } log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler); return true; // false 시 진행X } }
Java
복사
세션ID 검증(로그인 여부 검증)
public class LoginCheckInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestURI = request.getRequestURI(); HttpSession session = request.getSession(false); // 인증에 실패하면 이전 URL로 리다이렉트 if (session == null || session.getAttribute("loginMember") == null) { response.sendRedirect("/login?redirectURL=" + requestURI); return false; } return true; } }
Java
복사
만약 postHandle로 데이터를 넘겨주려면 서블릿 필터와 다르게 request.setAttribute()를 사용해야 함
서블릿 필터는 doFilter()라는 하나의 메서드 안에 컨트롤러가 감싸져 있다면, 인터셉터는 preHandle()과 postHandle()이 컨트롤러 앞뒤로 각각 붙어 있는 모양임
인터셉터 등록
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LogInterceptor()) .order(1) .addPathPatterns("/**") //인터셉터 적용URL .excludePathPatterns("/css/**", "/*.ico", "/error"); //미적용 URL registry.addInterceptor(new LoginCheckInterceptor()) .order(2) .addPathPatterns("/**") .excludePathPatterns("/", "/members/add", "/login", "/logout", "/css/**", "/*.ico", "/error"); } }
Java
복사
PathPatter
? 한 문자 일치 * 경로(/) 안에서 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이라는 변수로 캡처
Java
복사
세션 인증(로그인 상태 인증) 실패 이후 컨트롤러 로직
// 실패 후 이곳으로 redirect @GetMapping("/login") public String loginForm(@ModelAttribute("loginForm") LoginForm form) { return "login/loginForm"; } @PostMapping("/login") public String loginV4(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, @RequestParam(defaultValue = "/") String redirectURL, HttpServletRequest request) { if (bindingResult.hasErrors()) { return "login/loginForm"; } Member loginMember = loginService.login(form.getLoginId(), form.getPassword()); if (loginMember == null) { bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다."); return "login/loginForm"; } // 로그인 성공 처리 // 세션이 있으면 있는 세션 반환, 없으면 신규 세션을 생성 HttpSession session = request.getSession(); // 세션에 로그인 회원 정보 보관 session.setAttribute("loginMember", loginMember); return "redirect:" + redirectURL; }
Java
복사

ArgumentResolver 활용 @Login 애노테이션 만들기

자동으로 세션에 있는 로그인 회원을 찾아주고 세션에 없다면 null을 반환하도록 개발하기
애노테이션 생성
@Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface Login { }
Java
복사
ArgumentResolver 생성
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class); boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType()); return hasLoginAnnotation && hasMemberType; } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { HttpSession session = ((HttpServletRequest) webRequest.getNativeRequest()).getSession(); if (session == null) { return null; } return session.getAttribute("loginMember"); } }
Java
복사
Configuration 클래스에 설정 추가
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add(new LoginMemberArgumentResolver()); } ... }
Java
복사
@Login 사용
@GetMapping("/") public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) { // 세션에 회원 데이터가 없으면 home if (loginMember == null) { return "home"; } // 세션이 유지되면 로그인으로 이동 model.addAttribute("member", loginMember); return "loginHome"; }
Java
복사

[OLD] 서블릿 예외처리

스프링 예외처리

인터셉터는 서블릿이 아니라 스프링에 제공하는 기능이기 때문에 DispatcherType과 무관하게 항상 호출
따라서 아래처럼 오류 페이지 경로를 추가하거나 빼주는 방법이 좋음
@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/**"); // 오류 페이지 경로 } }
Java
복사
인터셉터 흐름 및 오류 발생 시 흐름
예외 발생 시 postHandle은 호출되지 않음
지금까지 흐름
WAS -> 필터 -> 디스패처 서블릿 -> 인터셉터 -> ┐ 컨트롤러(예외 발생)<- 필터 <- 디스패처 서블릿 <- 인터셉터 <-WAS(예외 페이지 확인 후 재요청)-> 필터 -> 디스패처 서블릿 -> 인터셉터 -> ┐ 컨트롤러 WAS <- HTML <- View(에러 페이지) 렌더링 <-
Java
복사
스프링은 위의 오류 처리 컨트롤러 등록(WebSeverCustomizer) → 톰캣이 오류 컨트롤러 호출 → 컨트롤러가 뷰 페이지 호출하는 과정을 기본으로 제공함(사용자가 등록한 게 없을 경우)
/error 경로로 기본 오류 컨트롤러 매핑을 설정함(new ErrorPage(”/error”) 등록)
/error 경로에 매핑된 BasicErrorController라는 컨트롤러를 등록함
BasicErrorController에서 아래 우선순위에 따라 오류페이지를 찾아 뷰 렌더링 후 출력함
오류 페이지 뷰 렌더링 우선순위
1.
뷰 템플릿
a.
resources/templates/error/nnn.html → 오류 코드가 구체적일수록 우선순위가 높음
b.
resources/templates/error/nxx.html
2.
정적 리소스(static, public)
a.
resources/static/error/nnn.html
b.
resources/static/error/nxx.html
3.
적용 대상이 없을 때 뷰 이름(error)
a.
resources/templates/error.html
4.
이마저 없으면 스프링 에러가 아래 설정에 따라 기본 whitelable 오류페이지를 보여주거나 오류가 톰캣까지 올라가서 톰캣 기본 에러페이지 생성
server.error.whitelabel.enabled=false
Java
복사
application.properties
BasicErrorController는 아래 에러 정보를 model에 담아 뷰에 전달함
에러 정보
내용
timestamp
날짜 및 시각
status
상태코드 정보
error
에러 이름
exception
예외 클래스 이름
trace
예외 trace
message
에러 메시지
errors
Errors(BindingResult)
path
클라이언트 요청 경로
위 정보들 중 민감한 정보는 model에 포함할지 여부를 아래처럼 application.properties에 설정 가능
server.error.include-exception=true|false server.error.include-messsage=never|always|on_param server.error.include-stacktrace=never|always|on_param server.error.include-binding-errors=never|always|on_param
Java
복사
설정 옵션 never: 사용하지 않음 always: 항상 사용 on_param: 파라미터가 있을 때 사용 → 운영서버에서도 미권장함. URL에 message=&error=&trace=와 같이 넣어도 뷰 템플릿에 출력되기 때문
위의 /error 경로는 아래 설정을 통해 변경 가능함
server.error.path=/error
Java
복사
에러 공통 처리 컨트롤러 기능을 변경하고 싶다면
ErrorController 인터페이스 상속 받아서 직접 구현
BasicErrorController 클래스 상속 받아서 기능 확장

API 예외 처리

API 예외처리의 어려움
API는 각 시스템 마다 응답의 모양과 스펙이 다름
예외에 따라 각각 다른 데이터를 출력해야할 수도 있음
같은 예외도 컨트롤러에 따라 다른 예외 응답을 줘야할 때도 있음
즉, HTML 화면을 제공할 때보다 매우 세밀한 제어가 필요함
직접 처리(에러 페이지와 produce를 활용)
스프링 부트의 기본 API 예외 처리를 통해 처리 - 위와 같은 방법을 사용
직접 처리(HandlerExceptionResolver 활용)
스프링이 제공하는 HandlerExceptionResolver 활용
스프링 부트가 HandlerExceptionResolverComposite에 기본적으로 등록하는 HandlerExceptionResolver를 우선순위에 따라 나열하면 아래와 같음
1. ExceptionHandlerExceptionResolver // @ExceptionHandler 처리 2. ResponseStatusExceptionResolver // HTTP 상태 코드 지정 3. DefaultHandlerExceptionResolver // 스프링 내부 기본 예외를 처리
Java
복사
2. ResponseStatusExceptionResolver
@ResponseStatus 애노테이션이 달린 예외를 처리 - 해당 에노테이션 속성에 설정된 응답 코드와 메시지(또는 오류코드)로 response.sendError()를 호출함
코드를 수정할 수 없는 라이브러리에서 적용이 불가능하고 조건에 따른 동적인 변경이 어려움
3. DefaultHandlerExceptionResolver
TypeMismatchException같은 스프링 예외를 500이 아닌 400으로 바꿔주는 등의 처리를 함
이 역시 response.sendError()를 통해 문제를 해결함
1. ExceptionHandlerExceptionResolver
HandlerExceptionResolver의 문제
HandlerExceptionResolver는 ModelAndView를 반환하는데, API 응답에는 ModelAndView가 필요 없음
API응답을 위해 HttpServletResponse에 직접 데이터를 넣어줘야 함
이를 해결하기 위해 @ExceptionHandler 어노테이션을 활용한 리졸버를 사용함
사용법
이런 예외처리 핸들러를 한 클래스에 모은 후 클래스에 @RestControllerAdvice 어노테이션 지정하면 글로벌로 적용됨
@RestControllerAdvice public class ExControllerAdvice { @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResult illegalExHandler(IllegalArgumentException e) { return new ErrorResult("BAD", e.getMessage()); } ... }
Java
복사
특정 컨트롤러에 지정하고 싶으면
// 특정 애노테이션 지정 @ControllerAdvice(annotations = RestController.class) // 특정 패키지 지정 @ControllerAdvice("org.example.controllers") // 특정 클래스 지정 @ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
Java
복사

스프링 타입 컨버터

사용처
@RequestParam, @ModelAtrribute, @PathVariable 등 요청 파라미터 변환 시
@Value 등으로 YML 정보 읽을 때
XML에 넣은 스프링 빈 정보를 변환할 때
뷰를 렌더링할 때
스프링은 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공함
새로운 타입 컨버터 생성
org.springframework.core.convert.converter 인터페이스 사용
public interface Converter<S, T> { T convert(S source); }
Java
복사
아래는 구현 예제
public class StringToIpPortConverter implements Converter<String, IpPort> { @Override public IpPort convert(String source) { //"127.0.0.1:8080" -> IpPort String[] split = source.split(":"); String ip = split[0]; int port = Integer.parseInt(split[1]); return new IpPort(ip, port); } }
Java
복사
DefaultConversionService를 통해 직접 등록 및 사용
스프링에 등록 해서 자동 적용으로 사용
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registry) { registry.addConverter(new StringToIpPortConverter()); registry.addConverter(new IpPortToStringConverter()); } }
Java
복사

포맷터

객체를 특정한 포맷에 맞추어 문자열로, 또는 그 반대로 변환하는 특수한 형태의 컨버터
Locale 정보가 포함됨
직접 제작 및 사용
스프링에 등록해 자동으로 사용
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registry) { ... registry.addFormatter(new MyNumberFormatter()); } }
Java
복사
이렇게 하면 컨버터의 사용처와 같은 곳에서 포매터도 자동으로 사용이 됨
만약 컨버터와 포매터 충돌 시 컨버터가 더 우선순위가 높음
스프링 어노테이션 포매터 사용
스프링은 수많은 포매터를 기본으로 제공하지만 이는 기본 타입들에 대해서만 지정되어 있어서 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어려움
이를 해결하는 것이 아래 두 가지의 어노테이션 기반 포매터
@NumberFormat: 숫자 관련 형식 지정 포매터 사용 (NumberFormatAnnotationFormatterFactory)
@DataTimeFormat: 날짜 관련 형식 지정 포매터 사용 (Jsr310DateTimeFormatAnnotationFormatterFactory)
예시
@Data static class Form { @NumberFormat(pattern = "###,###") private Integer number; @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime localDateTime; }
Java
복사
위처럼 하면 각 필드의 값이 문자열로 변환될 때 패턴에 맞춰 바뀜
타임리프에서 포맷팅 전, 후 출력
${form.number}: 10000
${{form.number}}: 10,000
메시지 컨버터(HttpMessageConverter)를 통한 JSON 변환에는 이 컨버터, 포매터가 적용되지 않음

파일 업로드

HTML form에서 문자와 바이너리 등 여러 데이터 형식을 동시에 전송해야 할 때 content-type으로 multipart/form-data 을 사용함
이 데이터 형식은 경계선을 기준으로 여러 데이터를 part로 나눠서 전송함
각 파트는 저마다 헤더와 부가 정보를 가지고 있음
이 방식을 사용할 때 form 태그에 별도로 enctype="multipart/form-data"를 지정해야 함
스프링 부트 - 서블릿 컨테이너의 멀티파트 처리 설정(application.properties)
spring.servlet.multipart.enabled=true
Java
복사
기본값은 true이며 서블릿 컨테이너가 멀티파트 관련 처리를 해서 request.getParameter() 및 request.getParts()를 사용할 수 있게 됨
위 옵션이 켜지면 스프링은 DispatcherServlet에서 기본 MultipartResolver를 실행해서 HttpServletRequst의 자식 인터페이스인 MultiparHttpServletRequest의 구현체StandardMultiparHttpServletRequest를 반환, 멀티파트와 관련된 추가 기능을 제공함
다만 MultiparHttpServletRequestMultipartFile을 사용하는 것이 더 편하기 때문에 잘 사용하지 않음
[OLD] 서블릿으로 파일 업로드
스프링으로 파일 업로드
@Controller @RequestMapping("/spring") public class SpringUploadController { @Value("${file.dir}") private String fileDir; @GetMapping("/upload") public String newFile() { return "upload-form"; } @PostMapping("/upload") public String saveFile( @RequestParam String itemName, @RequestParam MultipartFile file, @RequestParam List<MultipartFile> imageFiles, HttpServletRequest request ) throws IOException { if (!file.isEmpty()) { String fullPath = fileDir + file.getOriginalFilename(); file.transferTo(new File(fullPath)); } return "upload-form"; } }
Java
복사
주요 메서드
file.getOriginalFilename(): 업로드 파일 명
file.transferTo(...): 파일 저장
form에서 multiple 옵션을 통해 여러 파일을 업로드할 경우 List<MultipartFile>로 받을 수 있음
스프링으로 파일 다운로드 구현
<img> 태그로 이미지를 조회할 때
@ResponseBody @GetMapping("/images/{filename}") public Resource downloadImage(@PathVariable String filename) throws MalformedURLException { return new UrlResource("file:" + fileStore.getFullPath(filename)); }
Java
복사
UrlResource로 이미지 파일을 읽어서 @ResponseBody로 이미지 바이너리를 반환
파일을 다운로드할 때
@GetMapping("/attach/{itemId}") public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException { Item item = itemRepository.findById(itemId); String storeFileName = item.getAttachFile().getStoreFileName(); String uploadFileName = item.getAttachFile().getUploadFileName(); UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName)); log.info("uploadFileName = {}", uploadFileName); String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8); String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\""; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) .body(resource); }
Java
복사