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.
실패하면 typeMismatch로 FieldError추가
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를 재발급한 경우도 포함)
•
직접 구현
서블릿을 통해 구현
•
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를 반환, 멀티파트와 관련된 추가 기능을 제공함
▪
다만 MultiparHttpServletRequest는 MultipartFile을 사용하는 것이 더 편하기 때문에 잘 사용하지 않음
[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
복사