김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
// LoginController
public String login(HttpServletResponse response, @Validated @ModelAttribute LoginForm form, BindingResult bindingResult) {
// 로그인 성공 시
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
return "redirect:/";
}
서버가 쿠키를 생성하여 HttpServletResponse 에 담아 웹 브라우저에게 전달한다
로그인 성공 시, 쿠키를 전달할 때 시간 정보를 주지 않았다 ➜ 세션 쿠키
세션 쿠키는 브라우저 종료 시까지만 유지한다
// HomeController
@GetMapping("/")
public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId) { ... }
@CookieValue
를 사용해서 쿠키를 조회한다
required 를 false 로 지정하면 쿠키가 없는 사용자도 해당 url 로 요청을 보낼 수 있다
쿠키의 보안 문제로 인해 세션을 사용한다
쿠키에 memberId와 같이 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출
세션 저장소에 UUID를 통해 토큰( 랜덤 값 )을 생성해서 세션 저장소의 key( sessionId )로 사용하고 value는 회원을 저장
쿠키의 value에 sessionId를 넣어서 클라이언트에게 전달
클라이언트가 요청할 때 sessionId를 가진 쿠키가 항상 포함된다
서버는 쿠키 정보로 세션 저장소를 조회해서 로그인 시 보관한 세션 정보를 사용 ( 쿠키의 value가 세션 저장소의 key에 해당 )
// LoginController
public String login(HttpServletResponse response, @Validated @ModelAttribute LoginForm form, BindingResult bindingResult) {
// 로그인 성공 시
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
return "redirect:/";
}
request.getSession()
: 세션이 있으면 반환, 없으면 만들어서 반환
session.setAttribute()
: 세션에 데이터를 보관
참고로 세션만 만들면 쿠키는 자동으로 만들어진다
// HomeController
@GetMapping("/")
public String homeLogin(HttpServletRequest request, Model model) {
HttpSession session = request.getSession(false);
Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER);
...
}
세션 저장소에서 세션을 찾고, 찾아진 세션에서 member 를 가져온다
session.getAttribute()
: 세션에서 데이터를 조회
// HomeController
@GetMapping("/")
public String homeLoginSpring(Model model,
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember) { ... }
위에서 getSession()
, getAttribute()
를 통해 하던 과정을 @SessionAttribute
로 처리할 수 있다
댠> @SessionAttribute
는 세션을 생성하지 않는다
서블릿을 통해 HttpSession 을 생성하면 JSESSIONID 라는 이름의 쿠키를 생성
getSession()
: request 에서 얻어온 쿠키의 value 에 저장된 UUID 값으로 세션 저장소에서 UUID 가 일치하는 세션을 찾는다
setAttribute()
: 찾아진 session 의 개인 세션 저장소에 값을 저장한다
참고로 랜덤세션id( UUID )는 tomcat이 생성한다
HTTP 요청 ➜ WAS ➜ 필터 ➜ 서블릿( DispatcherServlet ) ➜ Controller
필터는 서블릿이 지원, 필터가 정상적인 요청이라고 판단하면 서블릿을 호출
필터가 적절하지 못한 요청이라고 판단하면 뒤의 서블릿을 호출하지 않는다
필터는 특정 URL 패턴을 적용해 URL 마다 다르게 수행하는 것이 가능
필터는 체인으로 구성되는데 중간에 여러 가지 필터를 적용할 수 있다
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()
: 필터 초기화 메서드, 서블릿 컨테이너가 호출될 때 생성
doFilter()
: 고객의 요청이 올 때마다 WAS 가 호출하는 메서드 ( 예시 )
destroy()
: 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
}
}
구현한 필터를 사용하기 위해서는 등록을 해야한다
스프링부트를 사용하면 FilterRegistrationBean
을 사용해서 등록한다
setFilter(new LogFilter())
: 등록할 필터를 지정
setOrder(1)
: 필터는 체인으로 동작하기 때문에 순서가 필요 ( 낮을수록 먼저 동작 )
addUrlPatterns("/*")
: 필터를 적용할 URL 패턴을 지정, 한 번에 여러 패턴 지정 가능
HTTP 요청 ➜ WAS ➜ 필터 ➜ 서블릿 ➜ 스프링 인터셉터 ➜ Controller
스프링 인터셉터는 스프링 MVC가 제공하는 기술
URL 패턴을 적용할 수 있는데 서블릿 URL 패턴과는 다르고, 매우 정밀하게 설정 가능
스프링 인터셉터도 체인으로 구성되는데 중간에 여러 가지 인터셉터를 추가할 수 있다
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception { ... }
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()
: Controller 호출 전
postHandle()
: Controller 호출 후
afterCompletion()
: 요청 완료 이후
어떤 Controller( Handler )가 호출되는지와 어떤 ModelAndView가 반환되는지 응답 정보도 알 수 있다
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
}
}
인터셉터는 메서드를 오버라이딩해서 등록한다
addPathPatterns("/**")
: 인터셉터를 적용할 URL 패턴을 지정
excludePathPatterns("...")
: 인터셉터를 적용하지 않을 경로들을 설정
preHandle
Controller 호출 전인데 정확히는 핸들러 어댑터 호출 전에 호출
preHandle()
의 응답값이 true 이면 다음으로 진행하고, false 이면 중단
( 뒤의 인터셉터와 핸들러 어댑터가 호출되지 않는다 )
postHandle
: 컨트롤러 호출 후에 호출 ( 정확히는 핸들러 어댑터 호출 후에 호출 )
afterCompletion
: 뷰가 렌더링 된 이후에 호출
if> Controller 에서 예외 발생 ➜ postHandle 호출 X ➜ afterCompletion 호출 O
afterCompletion 은 예외 발생과 관계 없이 항상 호출
예외( ex )를 파라미터로 받아서 어떤 예외가 발생했는지 로그로 출력할 수 있다
WAS ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ Controller( 예외 발생 )
Exception
어플리케이션에서 예외를 잡지 못하면 WAS 까지 예외가 전파
WAS 는 Exception 이 발생하면 500 상태 코드를 반환
response.sendError()
response 내부에 오류가 발생했다는 상태를 저장하여 서블릿 컨테이너에게 오류가 발생했다는 사실을 전달할 수 있다
서블릿 컨테이너는 고객에게 응답하기 전에 response에 sendError()가 호출되었는지 확인하여 호출되었다면 설정한 오류 코드에 맞추어 오류 페이지를 보여준다
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
// 오류 페이지 등록
factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
}
}
위처럼 작성하면 스프링부트가 실행될 떄 톰캣에 오류 페이지를 등록
서블릿은 Exception이 발생해서 서블릿 밖으로 전달되거나 sendError()가 호출되었을 때 설정된 오류 페이지를 찾는다
ex> 404 에러가 발생하면 /error-page/404
가 호출되는데 해당 요청을 처리할 수 있는 Controller 가 필요하다
@Controller
public class ErrorPageController {
@RequestMapping("/error-page/404")
public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 404");
return "error-page/404";
}
}
1. WAS ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ Controller ( 예외 발생 )
2. WAS에서
/error-page/404
다시 요청 ➜ 필터 ➜ 서블릿 ➜ 인터셉터
➜ Controller (/error-page/404
) ➜ View
예외 발생 ( ex> 404
) ➜ WAS까지 전달
WAS에서 WebServerCustomizer 에 등록한 ErrorPage 를 보고 발생한 예외에 맞게 ErrorPage 에 등록된 경로를 요청
( ex> /error-page/404
)
WAS부터 다시 필터, 서블릿 등을 거쳐서 요청된 경로에 대한 처리를 해주는 Controller가 호출
( ex> @RequestMapping("/error-page/404")
)
Controller 에서 오류 페이지를 반환
오류가 발생하면 오류 페이지를 출력하기 위해 WAS 내부에서 다시 호출이 발생해서 필터와 인터셉터가 다시 호출된다
but> 로그인 인증 체크의 경우, 이미 필터와 인터셉터에서 확인이 끝났는데 한 번 더 호출하는 것은 비효율적
클라이언트의 요청인지, 오류 페이지 출력을 위한 서버 내부 요청인지 구분할 수 있다면 이런 문제를 해결할 수 있다
➡️ 서블릿은 요청 구분을 위해 WAS에서 넘겨줄 때 DispatcherType
라는 추가 정보 제공
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean logFilter() {
...
// 해당 필터는 아래 두 경우에 호출된다
filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
return filterRegistrationBean;
}
}
필터를 등록할 때 setDispatcherTypes()
으로 DispatcherType 을 지정한다
setDispatcherTypes()
에 지정된 DispatcherType의 경우에만 필터가 호출된다
위의 예시의 경우 클라이언트 요청과 오류 페이지 요청 둘 다 필터가 호출
DispatcherType.REQUEST : 클라이언트 요청
DispatcherType.ERROR : 오류 요청 ( 오류 페이지 요청을 위해 WAS가 다시 요청 )
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
...
.excludePathPatterns("/css/**", "*.ico", "/error", "/error-page/**");
}
}
인터셉터는 DispatcherType을 지정할 수 없기 때문에 경로 정보로 중복 호출을 제거한다
오류 페이지 경로를 excludePathPatterns()
에 추가하면 오류 발생했을 때 해당 인터셉터가 호출되지 않는다
이전까지 오류 페이지를 만들려면 WebServerCustomizer 를 생성해서 ErrorPage 를 등록하고, 예외 처리용 Controller 를 생성했다
스프링부트는 ErrorPage 를 자동으로 등록하고, BasicErrorController 라는 스프링 컨트롤러를 자동으로 등록한다
스프링부트는 /error
라는 경로로 기본( 디폴트 ) 오류 페이지를 설정한다
서블릿 밖으로 예외가 전달 or sendError()
가 호출되면 모든 오류는 /error
를 호출
BasicErrorController 가 /errror
를 받아서 발생한 예외의 상태 코드에 따라 다른 오류 페이지를 보여준다
public class ErrorPageController {
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(
HttpServletRequest request, HttpServletResponse response) {
...
return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
}
에러 페이지를 처리하는 ErrorPageController 에 메서드를 추가
produces = MediaType.APPLICATION_JSON_VALUE 로 지정하면 클라이언트가 요청하는 HTTP Header 의 Accept 의 값이 application/json 일 때 호출된다
응답 데이터를 위해 Map 을 만들고, Jackson 라이브러리가 Map 을 JSON 구조로 변환한다
ResponseEntity 를 사용해서 응답하기 때문에 메시지 컨버터가 동작하면서 클라이언트에 JSON이 반환된다
application/x-www-form-urlencoded
HTML Form 데이터를 서버로 전송하는 가장 기본적인 방법
Form 태그에 별도의 enctype 옵션이 없을 때의 Content-Type
문자 데이터를 보낼 때 사용하며 Form 에 입력한 데이터를 &
로 구분해서 message body 에 담는다
multipart/form-data
Form 데이터에 문자 뿐만 아니라 파일도 함께 전송할 때 사용 ( 파일은 바이너리 데이터 )
multipart/form-data
는 다른 종류의 파일들이 있는 Form 의 내용을 한 번에 전송할 때 사용
enctype 에 multipart/form-data
를 지정해서 사용한다
각각의 전송 항목이 boundary로 구분되며, Content-Disposition
이라는 항목별 헤더가 추가된다
// Controller
@PostMapping("/upload")
public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
Collection<Part> parts = request.getParts();
log.info("parts={}", parts);
for (Part part : parts) {
log.info("==== PART ====");
log.info("name={}", part.getName());
Collection<String> headerNames = part.getHeaderNames();
for (String headerName : headerNames) {
log.info("header {}: {}", headerName, part.getHeader(headerName));
}
// content-disposition; filename
log.info("submittedFilename={}", part.getSubmittedFileName());
log.info("size={}", part.getSize()); // part body size
// 데이터 읽기 ( body에 있는 데이터 읽기 )
InputStream inputStream = part.getInputStream();
String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("body={}", body);
}
return "upload-form";
}
// 결과
==== PART ====
name=itemName
header content-disposition: form-data; name="itemName"
submittedFileName=null
size=4
body=test
==== PART ====
name=file
header content-disposition: form-data; name="file"; filename="conversionService.JPG"
header content-type: image/jpeg
submittedFileName=conversionService.JPG
size=28661
body=���� JFIF x x ���Exif MM * ;
getParts()
: HTTP 요청 메세지에서 나누어서 전송된 부분을 확인할 수 있다
parts.getName()
: HTML의 input 태그의 name 속성에 지정한 이름을 반환
parts.getHeaderNames()
: parts도 헤더와 바디로 구분, parts의 모든 헤더 이름을 반환
문자라면 content-disposition 헤더가 존재
파일이라면 content-disposition, content-type 헤더가 존재
part.getHeader(헤더이름)
: parts의 헤더이름으로 헤더 가져오기
문자라면 content-disposition에 name이 있음
파일이라면 content-disposition에 name, filename이 있음
part.getSubmittedFileName()
: content-disposition 에 있는 filename 을 반환
( 클라이언트가 전달한 파일명 )
part.getInputStream()
: parts의 body에 있는 데이터를 읽기
StreamUtils.copyToString()
: 읽은 바이너리 데이터를 문자로 변환, 인코딩 방식 지정 필수
< application.properties >
file.dir=경로/
fild.dir=경로/
: application.propertoes에 파일 저장 경로 지정, 마지막에 /
붙여야함public class ServletUploadControllerV2 {
@Value("${file.dir}")
private String fileDir;
@PostMapping("/upload")
public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
...
// 파일 저장하기
if (StringUtils.hasText(part.getSubmittedFileName())) {
String fullPath = fileDir + part.getSubmittedFileName();
log.info("파일 저장 fullPath = {}", fullPath);
part.write(fullPath);
}
}
return "upload-form";
}
@Value("${file.dir}")
: application.properties 에서 설정한 file.dir 의 값을 주입한다
StringUtils.hasText(part.getSubmittedFileName())
: 전송된 파일이 있는지 확인
part.write(경로)
: part를 통해 전송된 데이터를 해당 경로에 저장
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, HttpServletRequest request) throws IOException {
if (!file.isEmpty()) {
String fullPath = fileDir + file.getOriginalFilename();
file.transferTo(new File(fullPath));
}
return "upload-form";
}
}
스프링은 MultipartFile
이라는 인터페이스로 멀티파트 파일을 편리하게 지원
HTML Form 의 name 에 맞춰 @RequestParam
을 적용하면 된다
file은 MultipartFile, 문자는 String 을 사용
input 태그의 name 속성과 파라미터 이름이 동일하면 @RequestParam
에 이름 생략 가능
@ModelAttribute
에서도 MultipartFile 사용 가능
file.getOriginalFilename()
: 사용자가 업로드한 파일명
file.transferTo()
: 파일 저장