# 타임리프
- 백엔드 서버에서 HTML을 동적으로 렌더링하는 SSR 템플릿 엔진
- 네츄럴 템플릿: 서버 없이 브라우저에서 직접 열어도 정상 렌더링 (JSP는 깨짐)
- Spring과 자연스럽게 통합 지원
@GetMapping("/text-basic")
public String textBasic(Model model) {
model.addAttribute("data", "hello spring");
return "basic/text-basic";
}
<!-- th:text: 이스케이프 처리 O -->
<p th:text="${data}"></p>
<!-- th:utext: 이스케이프 처리 X (HTML 태그 그대로 렌더링) -->
<p th:utext="${data}"></p>
<!-- 인라인 표현식: 태그 없이 콘텐츠 안에서 직접 출력 -->
<p>[[${data}]]</p>
th:inline="none": 해당 태그 내 인라인 표현식([[...]])을 처리하지 않고 문자열 그대로 출력- Spring EL 표현식으로 객체 프로퍼티, 배열 요소 등에 접근 가능
${user.name},${list[0]},${map['key']}
- 날짜 처리:
${#temporals.day(localDateTime)} - URL 생성:
@{...}문법 사용- 기본:
th:href="@{/hello}" - 쿼리 파라미터:
th:href="@{/hello(param1=${v1}, param2=${v2})}" - 경로 변수:
th:href="@{/hello/{id}(id=${item.id})}"
- 기본:
- 리터럴
- 문자 리터럴은 작은따옴표로 감싸야 함:
'hello' - 리터럴 대체 문법으로 보간 가능:
|hello ${data}|
- 문자 리터럴은 작은따옴표로 감싸야 함:
- 반복문:
th:each="item : ${items}"→ Java의 for-each와 동일 - 그 외 사용법은 필요할 때 공식 문서 참고
# 메시지, 국제화
- 다양한 메시지를 한 곳에서 관리하도록 하는 기능을 메시지 기능이라 한다.
messages.properties라는 파일을 만들어 메시지 매핑 정보를 만드는 형태이다.item=상품..
- 이렇게 되면 HTML에서 key값을 기준으로 불러들여 사용 가능하다.
<label for="itemName" th:text="#{item.itemName}"></label>
messages_en.properties와 같이 언어별로 메시지 파일을 관리하면 국제화 대응도 쉽게 가능하다.- HTTP Accept-Language별로 표시할 페이지를 다르게 하는 등의 방법이 있다.
# 메시지 소스 설정
- 스프링은 기본적인 메시지 관리 기능을 제공한다.
MessageSource의 구현체인ResourceBundleMessageSource스프링 빈으로 등록한다.
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasenames("messages", "errors");
messageSource.setDefaultEncoding("utf-8");
return messageSource;
}
- basenames: 설정파일 이름을 지정한다.
messages로 지정하면messages.properties파일을 읽어 사용한다/resources/messages.properties에 파일을 두면 된다.- 여러 파일을 한번에 지정할 수 있다.
_ko,_en처럼 suffix를 붙여 파일을 만들어두기만 하면 HTTP Accept-Language헤더를 보고 자동으로 국제화에 맞는 파일을 선택해준다.- 언어가 매칭되지 않는 경우 basename에 지정한 기본 파일명을 사용한다.
- 스프링 부트를 사용하면 application.properties에
spring.messages.basename=messages,config.i18n.messages처럼 적용 가능하다. - 기본 값은
messages이다. MessageSource.getMessage()주요 파라미터code: 메시지 코드 (properties 파일의 key)args: 메시지 내 치환 인자 ({0},{1}등) — 순서대로 값이 대입됨defaultMessage: 코드 매칭 실패 시 반환할 기본 메시지locale: 언어 설정 (Locale.KOREAN,Locale.ENGLISH등),null이면 기본값 사용
# messages.properties
hello=안녕하세요 {0}님, 나이는 {1}살입니다.
// 기본 사용
messageSource.getMessage("hello", null, Locale.KOREAN);
// 치환 인자 사용 → "안녕하세요 경준님, 나이는 29살입니다."
messageSource.getMessage("hello", new Object[]{"경준", 29}, Locale.KOREAN);
// 코드 없을 때 기본 메시지 반환
messageSource.getMessage("없는코드", null, "기본메시지", Locale.KOREAN);
// 코드 없고 기본 메시지도 없으면 NoSuchMessageException 발생
Thymeleaf 국제화 한글 깨짐 이슈
- 증상:
#{label.item}표현식이??로 출력됨 - 원인: IntelliJ의
Transparent native-to-ascii conversion옵션이 활성화되어 있으면 properties 파일 저장 시 한글을 ASCII 코드로 변환해버림 - 해결:
Settings→Editor→File Encodings→Properties Files→Transparent native-to-ascii conversion체크 해제
LocaleResolver인터페이스는 클라이언트 지역정보를 담는 Locale을 결정하는 인터페이스이다.- 스프링 부트는 기본적으로
AcceptHeaderLocaleResolver를 사용한다.- Accept-Language로부터 지역정보를 가져온다.
- 쿠키나 세션 기반으로 지역정보를 가져올 수 있는 여러 구현체가 존재한다.
- 스프링 빈을 통해 등록하면 된다.
# 검증 / Validation
- 서버에는 클라이언트의 유효하지 않은 데이터 입력에 대해 검증 로직이 필요하다.
- 직접 if문으로 검증 로직을 작성하는 경우 고려할 점이 많다.
- 숫자 타입에 문자가 들어오는 경우 컨트롤러 호출 자체가 실패
- 오입력 값 보관 불가
- 뷰 템플릿 중복 다수 발생
# BindingResult
- 스프링이 제공하는 검증 오류 처리의 핵심 객체
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
// ..
}
BindingResult파라미터를 추가한 뒤addError()로 에러 추가 가능FieldError(objectName, field, defaultMessage): 필드 단위 오류ObjectError: 필드를 넘어서는 글로벌 오류
타임리프가 편리하게 지원해주는 것이고, REST API에서는
BindingResult보다 다른 방식(@ExceptionHandler등)을 주로 사용한다.타입 오류 등으로 바인딩 실패 시 Spring이 직접
FieldError를 생성하여BindingResult에 넣어준다.rejectedValue필드에 잘못 입력된 값이 저장된다.
FieldError/ObjectError생성자의codes,arguments는 오류 코드로 메시지를 찾는 데 사용된다.spring.messages.basename=messages,errors설정으로errors.properties도 인식 가능- 에러 메시지는 별도 파일로 관리하는 것이 좋다.
range.item.price=가격은 {0} ~ {1} 까지 허용
// codes, arguments로 동적 에러 메시지 작성
bindingResult.addError(new FieldError("item", "price",
item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
rejectValue(),reject()를 사용하면FieldError/ObjectError를 직접 생성하지 않고 간결하게 처리 가능
bindingResult.rejectValue("itemName", "required");
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
rejectValue(field, errorCode, errorArgs, defaultMessage)errorCode는 properties key가 아닌 MessageResolver를 위한 코드MessageCodesResolver가 errorCode를 기반으로 실제 메시지 코드를 생성한다.
MessageCodesResolver 메시지 코드 생성 규칙
- 필드 오류:
errorCode.objectName.field→errorCode.field→errorCode.type→errorCode - 객체 오류:
errorCode.objectName→errorCode
- 구체적인 코드부터 먼저 매칭하고, 없으면 덜 구체적인 코드로 fallback
- 필드 오류:
errors.properties에 Spring 자동 생성 에러 코드 규칙에 맞게 메시지를 정의해두면 클라이언트에 불필요한 에러 메시지 노출을 막을 수 있다.
# Validator 분리
- 검증 로직이 복잡해지면 별도 클래스로 분리하는 것이 좋다.
- Spring은 이를 위해
Validator인터페이스를 제공한다.
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
// 직접 호출
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
itemValidator.validate(item, bindingResult);
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
// ..
}
@InitBinder로 검증기를 등록하면@Validated어노테이션만으로 자동 실행 가능
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
// ..
}
- 여러 검증기 등록 시
supports()메서드로 적합한 검증기를 선택한다. @InitBinder: 검증기 등록, 날짜 포맷 설정 등을 컨트롤러에 일괄 적용- 글로벌하게 적용하려면
WebMvcConfigurer를 구현하여 Bean으로 등록한다.
# Bean Validation
- 대부분의 검증 로직은 빈값 여부, 특정 크기 초과 여부 등 일반적인 로직이다.
- 이를 공통화하고 표준화한 것이 Bean Validation이다.
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
}
- 사용을 위해
spring-boot-starter-validation의존성을 추가해야 한다.- Spring Boot가 자동으로 Bean Validator를 글로벌 Validator로 등록한다.
@SpringBootApplication클래스에서 커스텀 글로벌 Validator를 등록하면 Bean Validator는 동작하지 않으니 주의
- 바인딩에 성공한 필드에만 Bean Validation이 동작한다.
- 타입 미스매치 등 바인딩 실패 시 Spring이
typeMismatchFieldError를 생성하여 BindingResult에 추가
- 타입 미스매치 등 바인딩 실패 시 Spring이
- 오류 코드는
typeMismatch와 유사하게 어노테이션명 + 객체명 + 필드명 조합으로 생성된다.- 오류 메시지는 어노테이션의
message파라미터로 지정하거나errors.properties에서 일괄 관리
- 오류 메시지는 어노테이션의
- 필드 조합 등 글로벌 객체 레벨 오류는 직접 자바 코드로 처리하는 것이 좋다.
# Bean Validation Groups
- CREATE / UPDATE 여부에 따라 검증 조건이 다를 때 groups를 활용한다.
public interface SaveCheck {}
public interface UpdateCheck {}
@NotNull(groups = UpdateCheck.class) // 수정 시에만 적용
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class}) // 둘 다 적용
private String itemName;
- 실무에서는 groups보다 DTO 분리 방식을 선호한다.
// 저장용 DTO
@Getter
public class ItemSaveRequest {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
}
// 수정용 DTO
@Getter
public class ItemUpdateRequest {
@NotNull
private Long id;
@NotBlank
private String itemName;
}
- DTO(Data Transfer Object): 계층 간 데이터를 전달하기 위한 객체
- 엔티티와 독립적으로 존재하며 상속/참조 없이 자체적으로 정의
- 엔티티 ↔ DTO 변환은 정적 팩토리 메서드(
from()) 또는 생성자를 통해 처리
# @RequestBody와 Bean Validation
@RequestBody로 JSON 요청을 받을 때도@Valid/@Validated로 Bean Validation 적용 가능@RequestBodyvs@ModelAttribute바인딩 실패 처리 차이
@ModelAttribute | @RequestBody | |
|---|---|---|
| 바인딩 실패 시 | 해당 필드만 실패, 나머지 진행 | 객체 변환 자체 실패 → 예외 발생 |
| BindingResult | 사용 가능 | 사용 불가 |
null 불가 타입(int) | 추가 에러 발생 | - |
null 가능 타입(Integer) | null로 바인딩 후 진행 | - |
@RequestBody바인딩 실패는BindingResult로 잡을 수 없어@ExceptionHandler로 전역 처리해야 한다.
# 로그인 처리 - 쿠키 / 세션
# 쿠키 기반 로그인
- 로그인 성공 시 서버가 쿠키를 생성하여 응답에 담아 브라우저에 전달
- 브라우저는 이후 모든 요청에 해당 쿠키를 자동으로 포함하여 전송
// 로그인 성공 시 세션 쿠키 생성
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
// 로그아웃 시 쿠키 만료
Cookie cookie = new Cookie("memberId", null);
cookie.setMaxAge(0);
response.addCookie(cookie);
- 영속 쿠키: 만료 날짜를 지정하면 해당 날짜까지 유지
- 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료 시까지 유지
# 쿠키 보안 문제
- 쿠키 값은 클라이언트에서 임의로 변경 가능 → 다른 사용자로 위장 가능
- 쿠키에 보관된 정보는 탈취 가능
- 탈취된 쿠키는 만료 전까지 악의적으로 사용 가능
# 세션 기반 로그인
- 중요한 정보는 서버에 저장하고, 클라이언트에는 추정 불가능한 세션 ID만 전달
- 세션 ID는 UUID로 생성하여 예측 불가능하게 만듦
로그인 성공
↓
서버: 세션 ID 생성 (UUID) + 세션 저장소에 {sessionId: memberA} 저장
↓
응답: Set-Cookie: mySessionId=zz0101xx...
↓
이후 요청: Cookie: mySessionId=zz0101xx... → 서버가 세션 저장소에서 조회
# 서블릿 HttpSession
- 서블릿이 제공하는 세션 기능 (
JSESSIONID쿠키 자동 생성)
// 로그인 - 세션 생성 및 데이터 저장
HttpSession session = request.getSession(); // 없으면 새로 생성
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
// 로그아웃 - 세션 제거
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
// 홈 - 세션 조회
HttpSession session = request.getSession(false); // 없으면 null 반환
Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
getSession(true): 세션 없으면 새로 생성 (기본값)getSession(false): 세션 없으면 null 반환 (조회 시 사용)
# @SessionAttribute
- 스프링이 제공하는 세션 조회 편의 기능 (세션 생성 X)
- 세션을 찾고 세션에 들어있는 데이터를 쉽게 조회하는 방법이다.
@GetMapping("/")
public String home(
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember,
Model model) {
if (loginMember == null) return "home";
model.addAttribute("member", loginMember);
return "loginHome";
}
# 세션 타임아웃
- 사용자가 로그아웃 없이 브라우저를 종료하면 서버는 알 수 없음 → 타임아웃 설정 필요
- 마지막 요청 시간 기준으로 타임아웃 계산 (요청 시마다 초기화)
# 글로벌 설정 (기본 1800초 = 30분)
server.servlet.session.timeout=1800
# URL에 jsessionid 노출 방지
server.servlet.session.tracking-modes=cookie
// 특정 세션 단위 설정
session.setMaxInactiveInterval(1800);
- 세션에는 최소한의 데이터만 보관해야 함 (메모리 부담)
- 세션 시간이 너무 길면 메모리 누적 위험
사용자 10만명이 로그인
↓
세션 10만개가 서버 메모리에 생성
↓
세션 시간이 길면 (예: 24시간)
↓
로그아웃 안 한 사용자 세션이 24시간 동안 메모리에 유지
↓
새로운 사용자가 계속 로그인하면 메모리 계속 누적
↓
서버 메모리 부족 → 장애 발생
# 세션 저장소
기본적으로 서버 메모리(JVM Heap)에 Map 형태로 저장된다.
{ sessionId: { loginMember: memberA, 생성시간, 만료시간 } }
서버가 여러 대인 경우 세션 공유 문제가 발생한다.
- 서버 A에서 생성한 세션을 서버 B는 모름 → 로그인 풀림
해결 방법
| 방식 | 설명 |
|---|---|
| Sticky Session | 같은 사용자는 항상 같은 서버로 라우팅 |
| 세션 클러스터링 | 서버들끼리 세션 동기화 |
| Redis 세션 저장소 | 외부 저장소에 세션 저장 (실무 주류) |
# 서블릿 필터 / 스프링 인터셉터
# 공통 관심사 (Cross-Cutting Concern)
- 로그인 여부 확인처럼 여러 컨트롤러에 공통으로 필요한 로직을 공통 관심사라 한다.
- 컨트롤러마다 직접 작성하면 중복이 많고, 변경 시 모든 코드를 수정해야 한다.
- 웹 관련 공통 관심사는 서블릿 필터 또는 스프링 인터셉터로 해결한다.
# 서블릿 필터
HTTP 요청 → WAS → 필터 → 서블릿(DispatcherServlet) → 컨트롤러
- 필터에서 부적절한 요청을 차단하면 서블릿까지 도달하지 않음
- 체인 구조로 여러 필터를 순서대로 적용 가능
chain.doFilter()호출해야 다음 단계로 진행됨 (미호출 시 여기서 종료)
public interface Filter {
default void init(FilterConfig filterConfig) {}
void doFilter(ServletRequest request, ServletResponse response, FilterChain chain);
default void destroy() {}
}
init(): 서블릿 컨테이너 생성 시 호출doFilter(): 요청마다 호출, 핵심 로직 구현- chain.doFilter() 호출
- 다음 필터 있음 → 다음 필터의 doFilter() 실행
- 다음 필터 없음 → 서블릿(DispatcherServlet) 실행 → 컨트롤러
- chain.doFilter() 호출
destroy(): 서블릿 컨테이너 종료 시 호출
// Filter 인터페이스 구현 예시 (인증 체크)
@Slf4j
public class LoginCheckFilter implements Filter {
private static final String[] whitelist = {"/", "/members/add", "/login", "/logout", "/css/*"};
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String requestURI = httpRequest.getRequestURI();
try {
if (isLoginCheckPath(requestURI)) {
HttpSession session = httpRequest.getSession(false);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
return; // 이후 진행 차단
}
}
chain.doFilter(request, response);
} catch (Exception e) {
throw e;
}
}
private boolean isLoginCheckPath(String requestURI) {
return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
}
}
- 미인증 사용자는 doFilter를 실행하지 않고 리다이렉트만 진행한 뒤 return한다.
- 로그인 완료 후 redirectURL로 다시 접근할 수 있도록 해당 URL을 쿼리 파라미터로 전달해준다.
login처리를 하는 컨트롤러가 해당 URL을 받는다.
@PostMapping("/login")
public String login(
@Valid @ModelAttribute LoginForm form,
BindingResult bindingResult,
@RequestParam(defaultValue = "/") String redirectURL, // ← 여기서 받음
HttpServletRequest request) {
// 로그인 성공 처리
// ...
return "redirect:" + redirectURL; // ← /items로 리다이렉트
}
- 필터 등록은
FilterRegistrationBean사용
@Bean
public FilterRegistrationBean loginCheckFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LoginCheckFilter());
filterRegistrationBean.setOrder(2);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
- 로그인 성공 시
redirectURL파라미터로 원래 요청 경로로 이동 가능
# 스프링 인터셉터
HTTP 요청 → WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러
- 서블릿 필터와 달리 스프링 MVC가 제공하는 기능 (DispatcherServlet 이후 동작)
- 필터보다 세밀한 URL 패턴 설정 가능
HandlerInterceptor인터페이스 구현
public interface HandlerInterceptor {
boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler);
void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView);
void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex);
}
preHandle: 컨트롤러 호출 전,false반환 시 이후 진행 차단postHandle: 컨트롤러 호출 후 (예외 발생 시 미호출)afterCompletion: 뷰 렌더링 이후, 예외 발생해도 항상 호출- 인터셉터 등록은
WebMvcConfigurer의addInterceptors()사용
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
registry.addInterceptor(new LoginCheckInterceptor())
.order(2)
.addPathPatterns("/**")
.excludePathPatterns("/", "/members/add", "/login", "/logout", "/css/**", "/*.ico", "/error");
}
}
- 화이트리스트를
excludePathPatterns으로 처리 → 필터보다 훨씬 간결
# 스프링 인터셉터 기반 인증 체크 구현 예시
// 1. 인증 체크 인터셉터 구현
@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);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("미인증 사용자 요청");
response.sendRedirect("/login?redirectURL=" + requestURI);
return false; // 이후 진행 차단
}
return true;
}
}
// 2. WebConfig에 인터셉터 등록
// excludePathPatterns으로 화이트리스트 처리 (필터보다 간결)
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginCheckInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/", "/members/add", "/login", "/logout", "/css/**", "/*.ico", "/error");
}
}
// 3. 로그인 성공 시 redirectURL로 이동
@PostMapping("/login")
public String login(
@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(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:" + redirectURL; // 원래 요청 경로로 이동
}
- 미인증 사용자 접근 시
/login?redirectURL={requestURI}로 리다이렉트- 컨트롤러 호출 자체가 안됨
- 로그인 성공 시
redirectURL파라미터로 원래 요청 경로로 복귀 - 화이트리스트는
excludePathPatterns으로 처리 (서블릿 필터보다 간결)
# 필터 vs 인터셉터 비교
| 서블릿 필터 | 스프링 인터셉터 | |
|---|---|---|
| 제공 주체 | 서블릿 | 스프링 MVC |
| 동작 위치 | 서블릿 앞 | DispatcherServlet 뒤 |
| 메서드 | doFilter() 하나 | preHandle, postHandle, afterCompletion |
| URL 패턴 | 서블릿 URL 패턴 | 스프링 PathPattern (더 정밀) |
| 화이트리스트 | 코드로 직접 처리 | excludePathPatterns으로 간결하게 처리 |
| 특수 기능 | request/response 객체 교체 가능 | 컨트롤러/ModelAndView 정보 접근 가능 |
- 특별한 이유가 없다면 스프링 인터셉터 사용을 권장
# ArgumentResolver 활용
- 커스텀 어노테이션 + ArgumentResolver로 컨트롤러 파라미터를 편리하게 처리 가능
- 어노테이션은 표식(마커) 일 뿐이라 단독으로는 아무 동작도 하지 않음
@Login을 보고 실제로 세션에서 회원을 꺼내주는ArgumentResolver구현체가 반드시 필요- 둘은 세트로 동작함
// @Login 어노테이션 정의
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME) // 리플렉션으로 런타임에 어노테이션 정보를 읽기 위해 필요
public @interface Login {}
// ArgumentResolver 구현 - @Login 어노테이션의 실제 동작 정의
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) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
HttpSession session = request.getSession(false);
if (session == null) return null;
return session.getAttribute(SessionConst.LOGIN_MEMBER);
}
}
getSession(false)는 세션 생성없이 조회목적으로만 사용- getSession(true) 호출 시 JSESSIONID 기준 세션이 없으면 새로 생성
// WebConfig: WebMvcConfigurer를 구현하여 스프링 MVC 설정을 커스터마이징하는 설정 클래스
// 인터셉터, ArgumentResolver, CORS 등 필요한 설정만 골라서 오버라이드하면 됨
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}
}
// 컨트롤러에서 사용
@GetMapping("/")
public String home(@Login Member loginMember, Model model) {
if (loginMember == null) return "home";
model.addAttribute("member", loginMember);
return "loginHome";
}
@Login→ "이 파라미터는 로그인 회원이야"라는 표식ArgumentResolver→ 그 표식을 보고 세션에서 회원을 꺼내주는 구현체- 실무에서는 Spring Security의
@AuthenticationPrincipal이 같은 역할을 함
# 예외 처리와 오류 페이지
# 서블릿 예외 처리 방식
서블릿은 두 가지 방식으로 예외를 처리한다.
1. Exception 발생 → WAS까지 전파 → 500 에러
2. response.sendError(상태코드) → WAS가 확인 후 오류 페이지 처리
// Exception 발생
throw new RuntimeException("예외 발생!");
// sendError 사용
response.sendError(404, "404 오류!");
response.sendError(500);
# 오류 페이지 작동 원리
1. WAS(여기까지 전파) ← 필터 ← 서블릿 ← 인터셉터 ← 컨트롤러(예외발생)
2. WAS `/error-page/500` 다시 요청 → 필터 → 서블릿 → 인터셉터 → 컨트롤러 → View
- 클라이언트는 서버 내부에서 이런 일이 일어나는지 전혀 모름
- WAS가 오류 페이지를 찾아 내부적으로 재요청하는 구조
- WAS는 오류 정보를
request.attribute에 담아서 전달
# DispatcherType
- 요청의 발생 원인에 따라 WAS가 자동으로 타입을 부여한다.
| 타입 | 설명 |
|---|---|
REQUEST | 클라이언트 요청 |
ERROR | 오류 페이지 재요청 (WAS 내부) |
FORWARD | 서블릿에서 다른 서블릿/JSP 호출 |
INCLUDE | 다른 서블릿/JSP 결과 포함 |
ASYNC | 서블릿 비동기 호출 |
setDispatcherTypes()에 설정한 타입에서만 필터가 실행된다.- 포함된 타입 → 필터 실행
- 포함되지 않은 타입 → 필터 미실행
// REQUEST만 설정 (기본값)
// → 클라이언트 요청 시에만 필터 실행
// → 오류 페이지 재요청(ERROR) 시 필터 미실행
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST);
// REQUEST + ERROR 설정
// → 클라이언트 요청 + 오류 페이지 재요청 모두 필터 실행
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
기본값이
REQUEST인 이유- 오류 페이지 재요청은 서버 내부에서 발생하는 것으로 이미 첫 번째 요청에서 인증을 통과한 상태
ERROR타입을 추가하면 오류 페이지에서도 인증 로직이 수행되어 불필요한 중복 호출 발생- 오류 페이지에서는 인증 관련 흐름이 수행될 필요가 없음
인터셉터는 스프링이 제공하는 기능이라
DispatcherType과 무관하게 항상 호출된다.- 대신
excludePathPatterns()으로 오류 페이지 경로를 직접 제외해야 한다.
- 대신
registry.addInterceptor(new LogInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/error-page/**"); // 오류 페이지 경로 제외
# 전체 흐름 정리
정상 요청
WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러 → View
오류 요청
1. WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러 (예외 발생)
2. WAS ← 예외 전파
3. WAS → 필터(x) → 서블릿 → 인터셉터(x) → 오류 페이지 컨트롤러 → View
# Spring Boot 오류 페이지
Spring Boot는 오류 페이지 처리를 자동으로 제공한다.
ErrorPage자동 등록 (/error경로)BasicErrorController자동 등록
개발자는 오류 페이지 뷰 파일만 등록하면 됨
뷰 선택 우선순위
1. 뷰 템플릿: resources/templates/error/500.html
2. 정적 리소스: resources/static/error/500.html
3. 기본: resources/templates/error.html
- 구체적인 코드(
404.html)가 와일드카드(4xx.html)보다 우선순위 높음
오류 정보 노출 설정 (application.properties)
server.error.include-exception=false # 예외 포함 여부
server.error.include-message=never # 메시지 포함 여부
server.error.include-stacktrace=never # 스택트레이스 포함 여부
server.error.include-binding-errors=never # 바인딩 오류 포함 여부
never: 사용 안 함 (기본값)always: 항상 포함on_param: 요청 파라미터가 있을 때만 포함 (개발 서버에서만 사용 권장)- 실무에서는 오류 정보를 클라이언트에 노출하지 않고 서버 로그로 확인해야 함
# API 예외 처리
# API vs HTML 오류 처리의 차이
HTML 오류는 4xx/5xx 오류 페이지만 있으면 충분하지만, API는 각 오류 상황에 맞는 JSON 응답 스펙을 별도로 정의해야 한다.
핵심 원칙
API 클라이언트는 정상 요청이든 오류 요청이든 항상 JSON 응답을 기대한다. HTML 오류 페이지를 그대로 반환하면 안 된다.
# BasicErrorController (스프링 부트 기본)
스프링 부트는 /error 경로를 기본 오류 처리 경로로 사용하며, BasicErrorController가 Accept 헤더에 따라 분기한다.
text/html→ HTML 뷰 반환- 그 외 → JSON 반환
언제 쓰나?
BasicErrorController는 HTML 화면 오류 처리에 적합하다. API 오류 처리에는 @ExceptionHandler를 사용하자.
보안 주의
아래 옵션으로 상세 오류 정보를 노출할 수 있지만, 운영 환경에서는 간결한 메시지만 노출하고 상세 내용은 로그로 확인하자.
server.error.include-message=always
server.error.include-exception=true
server.error.include-stacktrace=always
- 컨트롤러 메서드에
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)처럼 produces 파라미터를 통해 application/json 요청에 대해 처리할 수 있도록 조건을 걸어두자.
# 스프링이 제공하는 ExceptionResolver 우선순위
HandlerExceptionResolverComposite에 다음 순서로 등록된다.
- ExceptionHandlerExceptionResolver —
@ExceptionHandler처리 - ResponseStatusExceptionResolver — HTTP 상태 코드 지정
- DefaultHandlerExceptionResolver — 스프링 내부 예외 처리
# ResponseStatusExceptionResolver
예외에 따라 HTTP 상태 코드를 지정해주는 역할을 한다.
@ResponseStatus를 예외 클래스에 직접 선언하는 방법:
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {}
reason은 MessageSource와 연동 가능하다 (reason = "error.bad" → messages.properties에서 메시지 조회).
라이브러리 예외처럼 직접 수정할 수 없거나, 조건에 따라 동적으로 상태 코드를 바꿔야 할 때는 ResponseStatusException을 사용한다:
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
# DefaultHandlerExceptionResolver
스프링 내부 예외를 적절한 HTTP 상태 코드로 자동 변환해준다. 별도 구현 없이 동작한다.
대표 예시: TypeMismatchException (파라미터 타입 불일치) → 500이 아닌 400으로 변환
# @ExceptionHandler
API 예외 처리의 실질적인 표준 방법이다. 해당 컨트롤러에서 발생한 예외를 메서드 단위로 처리할 수 있으며, 지정한 예외의 자식 클래스까지 모두 잡는다. 더 구체적인 타입이 우선순위를 가진다.
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
return new ErrorResult("BAD", e.getMessage());
}
// 예외 생략 시 파라미터 타입(UserException)이 자동 지정됨
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
// Exception으로 나머지 모든 예외를 커버
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
return new ErrorResult("EX", "내부 오류");
}
}
- 정의해둔 예외처리 메서드는 컨트롤러 스코프 기반으로 동작한다.
- 컨트롤러 내부 어떤 메서드에서 발생하던, 지정해둔 예외에 해당되는 경우 예외처리 메서드로 모이게 된다.
@ResponseStatus vs ResponseEntity
@ResponseStatus— 상태 코드를 정적으로 고정ResponseEntity— 상태 코드를 동적으로 제어 가능. 조건에 따라 상태 코드가 달라져야 한다면ResponseEntity를 사용하자.
# @ControllerAdvice / @RestControllerAdvice
@ExceptionHandler를 컨트롤러 내부에 두면 정상 코드와 예외 처리 코드가 섞인다. @RestControllerAdvice로 전역 분리하자.
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException 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) {
return new ErrorResult("EX", "내부 오류");
}
}
대상을 지정하지 않으면 모든 컨트롤러에 글로벌 적용된다. 범위를 좁히려면 아래처럼 지정한다:
@ControllerAdvice(annotations = RestController.class)
@ControllerAdvice("org.example.controllers")
@ControllerAdvice(assignableTypes = {ControllerInterface.class})
TIP
@RestControllerAdvice = @ControllerAdvice + @ResponseBody. @RestController와 @Controller의 관계와 동일하다.
# 스프링 타입 컨버터
# 타입 변환이 필요한 이유
HTTP 요청 파라미터는 모두 문자(String) 로 전달된다. 스프링은 @RequestParam, @ModelAttribute, @PathVariable 등에서 이를 자동으로 원하는 타입으로 변환해준다.
// 스프링이 "10"(문자) → Integer 10 으로 자동 변환
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) { ... }
스프링은 이 변환을 위해 내부적으로 Converter 인터페이스를 사용한다.
# Converter 인터페이스
public interface Converter<S, T> {
T convert(S source);
}
S→ 소스 타입,T→ 변환 대상 타입- 양방향 변환이 필요하면 각각 따로 구현해야 한다 (
String→IpPort,IpPort→String) org.springframework.core.convert.converter.Converter를 임포트할 것 (동명 인터페이스 주의)
커스텀 컨버터 예시 — IpPort
public class StringToIpPortConverter implements Converter<String, IpPort> {
@Override
public IpPort convert(String source) {
String[] split = source.split(":");
return new IpPort(split[0], Integer.parseInt(split[1]));
}
}
# ConversionService
컨버터를 개별로 직접 호출하는 대신, ConversionService에 등록해두고 일괄 사용한다.
DefaultConversionService conversionService = new DefaultConversionService();
conversionService.addConverter(new StringToIpPortConverter());
conversionService.addConverter(new IpPortToStringConverter());
// 사용
IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
ISP 원칙 적용
ConversionService— 컨버터 사용에 집중ConverterRegistry— 컨버터 등록에 집중
사용하는 쪽은 ConversionService만 의존하면 되므로 등록 세부사항을 몰라도 된다.
# 스프링에 Converter 등록하기
WebMvcConfigurer의 addFormatters()를 오버라이드해서 등록한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
}
}
우선순위
직접 등록한 컨버터는 스프링 기본 컨버터보다 높은 우선순위를 가진다.
# 뷰 템플릿(Thymeleaf)에서 컨버터 사용
타임리프에서 이중 중괄호를 사용하면 ConversionService가 자동 적용된다.
<span th:text="${ipPort}"> </span>
<!-- hello.typeconverter.type.IpPort@... -->
<span th:text="${{ipPort}}"> </span>
<!-- 127.0.0.1:8080 -->
폼에서 th:field를 사용하면 자동으로 컨버전 서비스가 적용된다 (GET 시 객체→문자, POST 시 문자→객체).
# Formatter
- Formatter는
Converter의 특수 버전으로, 문자 ↔ 객체 변환에 특화되어 있고Locale을 지원한다.- 나라마다 숫자 표현이나 단위가 다른 점을 고려
Converter : 객체 ↔ 객체 (범용)
Formatter : 문자 ↔ 객체 (문자 특화 + Locale)
public class MyNumberFormatter implements Formatter<Number> {
@Override
public Number parse(String text, Locale locale) throws ParseException {
return NumberFormat.getInstance(locale).parse(text); // "1,000" → 1000
}
@Override
public String print(Number object, Locale locale) {
return NumberFormat.getInstance(locale).format(object); // 1000 → "1,000"
}
}
등록:
registry.addFormatter(new MyNumberFormatter());
컨버터와 포맷터 우선순위 충돌
StringToIntegerConverter와 MyNumberFormatter는 기능이 겹친다.
컨버터가 포맷터보다 우선순위가 높으므로, 포맷터를 쓰려면 겹치는 컨버터를 주석 처리해야 한다.
# 스프링 기본 포맷터 애노테이션
필드별로 포맷을 다르게 지정하고 싶을 때 사용한다.
@Data
static class Form {
// DecimalNumberFormat
@NumberFormat(pattern = "###,###")
private Integer number;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime localDateTime;
}
# HttpMessageConverter와의 관계
중요 — ConversionService는 HttpMessageConverter에 적용되지 않는다
@RequestBody, @ResponseBody로 JSON을 처리할 때는 Jackson 같은 라이브러리가 담당하며,
ConversionService와는 무관하다. JSON 날짜/숫자 포맷 변경은 Jackson 설정으로 해야 한다.
ConversionService가 적용되는 곳: @RequestParam, @ModelAttribute, @PathVariable, 뷰 템플릿
# 파일 업로드
# 폼 전송 방식 비교
| application/x-www-form-urlencoded | multipart/form-data | |
|---|---|---|
| 기본 enctype | O (생략 시 기본값) | enctype="multipart/form-data" 명시 필요 |
| 전송 가능 데이터 | 문자만 | 문자 + 바이너리 혼합 가능 |
| 파일 업로드 | 불가 | 가능 |
파일은 바이너리 데이터이므로 문자만 전송하는 기본 방식으로는 업로드할 수 없다. multipart/form-data는 각 항목을 Part로 분리해서 문자와 바이너리를 동시에 전송한다.
# 멀티파트 주요 옵션 (application.properties)
spring.servlet.multipart.enabled=true # 기본값 true, false 시 멀티파트 처리 안 함
spring.servlet.multipart.max-file-size=1MB # 파일 하나의 최대 크기
spring.servlet.multipart.max-request-size=10MB # 요청 전체 합산 최대 크기
사이즈 초과 시
SizeLimitExceededException 발생. 운영 환경에서는 반드시 제한값을 명시적으로 설정할 것.
# 서블릿 Part vs 스프링 MultipartFile
서블릿 Part는 직접 헤더를 파싱하고 파일 여부를 체크해야 해서 코드가 복잡하다. 스프링 MultipartFile을 사용하는 것이 훨씬 간결하다.
// 서블릿 Part 방식
Collection<Part> parts = request.getParts();
for (Part part : parts) {
if (StringUtils.hasText(part.getSubmittedFileName())) {
part.write(fileDir + part.getSubmittedFileName());
}
}
// 스프링 MultipartFile 방식
@PostMapping("/upload")
public String saveFile(@RequestParam MultipartFile file) throws IOException {
if (!file.isEmpty()) {
file.transferTo(new File(fileDir + file.getOriginalFilename()));
}
}
MultipartFile 주요 메서드
file.getOriginalFilename()— 클라이언트가 업로드한 원본 파일명file.transferTo(File)— 파일 저장@ModelAttribute에서도 동일하게 사용 가능- ModelAttribute 대상인 객체 속성의 타입으로도 MultipartFile이 사용 가능하다는 의미
# 파일 저장 전략 (FileStore)
원본 파일명으로 저장하면 안 된다
서로 다른 사용자가 같은 이름의 파일을 올리면 덮어씌워진다. 반드시 UUID로 저장 파일명을 새로 생성해야 한다.
private String createStoreFileName(String originalFilename) {
String ext = extractExt(originalFilename);
return UUID.randomUUID().toString() + "." + ext;
}
직접 정의한 파일 타입에 원본 파일명(uploadFileName)과 저장 파일명(storeFileName)을 분리해서 보관하는 것이 일반적이다.
# 파일 다운로드
이미지 조회와 첨부파일 다운로드는 처리 방식이 다르다.
// 이미지 — 바이너리 직접 반환
@ResponseBody
@GetMapping("/images/{filename}")
public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
return new UrlResource("file:" + fileStore.getFullPath(filename));
}
// 첨부파일 — Content-Disposition 헤더로 다운로드 유도
@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));
String encodedName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
String contentDisposition = "attachment; filename=\"" + encodedName + "\"";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.body(resource);
}
- 조회는 일반적으로
<img>태그 렌더링 목적이므로 바이너리 데이터를 응답 바디에 넣어 반환- 브라우저가 Content-Type 확인 후 알아서 화면에 출력
- 다운로드는 Content-Disposition 헤더를 보고 브라우저가 다운로드 화면을 노출시켜줌. (
Content-Disposition: attachment; filename="파일명")
다운로드 시 한글 파일명
UriUtils.encode()로 인코딩하지 않으면 한글 파일명이 깨진다.
# 이미지 조회 vs 첨부파일 다운로드
두 방식의 차이는 Content-Disposition 헤더 유무다.
// 이미지 조회 — 브라우저가 화면에 바로 렌더링
@ResponseBody
@GetMapping("/images/{filename}")
public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
return new UrlResource("file:" + fileStore.getFullPath(filename));
}
// 첨부파일 다운로드 — 브라우저가 다운로드 창을 띄움
@GetMapping("/attach/{itemId}")
public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
String storeFileName = item.getAttachFile().getStoreFileName();
String uploadFileName = item.getAttachFile().getUploadFileName();
UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));
String encodedName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
String contentDisposition = "attachment; filename=\"" + encodedName + "\"";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.body(resource);
}
Content-Disposition 헤더
브라우저에게 응답을 어떻게 처리할지 지시하는 헤더다.
| 값 | 동작 |
|---|---|
| 없음 | Content-Type 보고 브라우저가 알아서 렌더링 |
inline | 브라우저가 열 수 있으면 표시, 없으면 다운로드 |
attachment | Content-Type 무관하게 무조건 다운로드 창 |
filename에는 다운로드 창에서 보여줄 파일명을 넣는다. 서버의 UUID 파일명 대신 사용자 원본 파일명을 넣어야 하고, 한글이 포함된 경우 UriUtils.encode()로 인코딩이 필요하다.
첨부파일 다운로드에서 itemId로 조회하는 이유
저장 파일명이 UUID라 URL에 직접 노출할 수 없고, 권한 체크 같은 비즈니스 로직을 끼워 넣기 위해 itemId를 받아서 DB에서 파일 정보를 조회하는 방식을 쓴다.
# 다중 이미지 업로드
폼에서 multiple="multiple" 속성을 주고, List<MultipartFile>로 받으면 된다.
// ItemForm
private List<MultipartFile> imageFiles;
// FileStore
public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
List<UploadFile> result = new ArrayList<>();
for (MultipartFile file : multipartFiles) {
if (!file.isEmpty()) result.add(storeFile(file));
}
return result;
}
<input type="file" name="imageFiles" multiple="multiple">클라이언트에서 같은 name으로 여러번 보내면 다중 파일을 업로드하게 된다.