[Spring MVC 2편] 6번 ~ 11번 강의 내용 정리

HJ·2023년 3월 22일
0

Spring MVC 2편

목록 보기
13/13

김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard


1. 쿠키

1-1. 로그인 및 로그아웃

// 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 에 담아 웹 브라우저에게 전달한다

  • 로그인 성공 시, 쿠키를 전달할 때 시간 정보를 주지 않았다 ➜ 세션 쿠키

  • 세션 쿠키는 브라우저 종료 시까지만 유지한다


1-2. 클라이언트가 보낸 쿠키 조회

// HomeController
@GetMapping("/")
public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId) { ... }
  • @CookieValue 를 사용해서 쿠키를 조회한다

  • required 를 false 로 지정하면 쿠키가 없는 사용자도 해당 url 로 요청을 보낼 수 있다


1-3. 쿠키의 보안 문제

  • 쿠키의 보안 문제로 인해 세션을 사용한다

  • 쿠키에 memberId와 같이 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출




2. 세션

2-1. 쿠키와 세션

  1. 세션 저장소에 UUID를 통해 토큰( 랜덤 값 )을 생성해서 세션 저장소의 key( sessionId )로 사용하고 value는 회원을 저장

  2. 쿠키의 value에 sessionId를 넣어서 클라이언트에게 전달

  3. 클라이언트가 요청할 때 sessionId를 가진 쿠키가 항상 포함된다

  4. 서버는 쿠키 정보로 세션 저장소를 조회해서 로그인 시 보관한 세션 정보를 사용 ( 쿠키의 value가 세션 저장소의 key에 해당 )


2-2. 코드로 알아보기

// 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 는 세션을 생성하지 않는다


2-3. 동작 정리

  • 서블릿을 통해 HttpSession 을 생성하면 JSESSIONID 라는 이름의 쿠키를 생성

  • getSession() : request 에서 얻어온 쿠키의 value 에 저장된 UUID 값으로 세션 저장소에서 UUID 가 일치하는 세션을 찾는다

  • setAttribute() : 찾아진 session 의 개인 세션 저장소에 값을 저장한다

  • 참고로 랜덤세션id( UUID )는 tomcat이 생성한다




3. 필터

3-1. 필터 흐름

HTTP 요청 ➜ WAS ➜ 필터 ➜ 서블릿( DispatcherServlet ) ➜ Controller

  • 필터는 서블릿이 지원, 필터가 정상적인 요청이라고 판단하면 서블릿을 호출

  • 필터가 적절하지 못한 요청이라고 판단하면 뒤의 서블릿을 호출하지 않는다

  • 필터는 특정 URL 패턴을 적용해 URL 마다 다르게 수행하는 것이 가능

  • 필터는 체인으로 구성되는데 중간에 여러 가지 필터를 적용할 수 있다

    • WAS ➜ 필터1 ➜ 필터2 ➜ .. ➜ 서블릿 ➜ Controller

3-2. 필터 인터페이스

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() : 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다


3-3. 필터 등록

@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 패턴을 지정, 한 번에 여러 패턴 지정 가능




4. 인터셉터

4-1. 스프링 인터셉터 흐름

HTTP 요청 ➜ WAS ➜ 필터 ➜ 서블릿 ➜ 스프링 인터셉터 ➜ Controller

  • 스프링 인터셉터는 스프링 MVC가 제공하는 기술

  • URL 패턴을 적용할 수 있는데 서블릿 URL 패턴과는 다르고, 매우 정밀하게 설정 가능

  • 스프링 인터셉터도 체인으로 구성되는데 중간에 여러 가지 인터셉터를 추가할 수 있다


4-2. 인터셉터 인터페이스

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가 반환되는지 응답 정보도 알 수 있다


4-3. 인터셉터 등록

@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("...") : 인터셉터를 적용하지 않을 경로들을 설정

  • 경로 패턴 방법


4-4. 전체 흐름

  • preHandle

    • Controller 호출 전인데 정확히는 핸들러 어댑터 호출 전에 호출

    • preHandle() 의 응답값이 true 이면 다음으로 진행하고, false 이면 중단
      ( 뒤의 인터셉터와 핸들러 어댑터가 호출되지 않는다 )

  • postHandle : 컨트롤러 호출 후에 호출 ( 정확히는 핸들러 어댑터 호출 후에 호출 )

  • afterCompletion : 뷰가 렌더링 된 이후에 호출


  • if> Controller 에서 예외 발생 ➜ postHandle 호출 X ➜ afterCompletion 호출 O

    • afterCompletion 은 예외 발생과 관계 없이 항상 호출

    • 예외( ex )를 파라미터로 받아서 어떤 예외가 발생했는지 로그로 출력할 수 있다




5. 서블릿 예외 처리

WAS ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ Controller( 예외 발생 )

5-1. 예외 처리 방식

  • Exception

    • 어플리케이션에서 예외를 잡지 못하면 WAS 까지 예외가 전파

    • WAS 는 Exception 이 발생하면 500 상태 코드를 반환

  • response.sendError()

    • response 내부에 오류가 발생했다는 상태를 저장하여 서블릿 컨테이너에게 오류가 발생했다는 사실을 전달할 수 있다

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


5-2. 오류 페이지

5-2-1. 오류 페이지 등록

@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 가 필요하다


5-2-2. 오류 페이지 처리 Controller

@Controller
public class ErrorPageController {

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

5-2-3. 오류 페이지 처리 흐름

1. WAS ⬅ 필터 ⬅ 서블릿 ⬅ 인터셉터 ⬅ Controller ( 예외 발생 )

2. WAS에서 /error-page/404 다시 요청 ➜ 필터 ➜ 서블릿 ➜ 인터셉터
➜ Controller ( /error-page/404 ) ➜ View

  1. 예외 발생 ( ex> 404 ) ➜ WAS까지 전달

  2. WAS에서 WebServerCustomizer 에 등록한 ErrorPage 를 보고 발생한 예외에 맞게 ErrorPage 에 등록된 경로를 요청
    ( ex> /error-page/404 )

  3. WAS부터 다시 필터, 서블릿 등을 거쳐서 요청된 경로에 대한 처리를 해주는 Controller가 호출
    ( ex> @RequestMapping("/error-page/404") )

  4. Controller 에서 오류 페이지를 반환




6. DispatcherType

6-1. 필요성

  • 오류가 발생하면 오류 페이지를 출력하기 위해 WAS 내부에서 다시 호출이 발생해서 필터와 인터셉터가 다시 호출된다

  • but> 로그인 인증 체크의 경우, 이미 필터와 인터셉터에서 확인이 끝났는데 한 번 더 호출하는 것은 비효율적

  • 클라이언트의 요청인지, 오류 페이지 출력을 위한 서버 내부 요청인지 구분할 수 있다면 이런 문제를 해결할 수 있다

➡️ 서블릿은 요청 구분을 위해 WAS에서 넘겨줄 때 DispatcherType라는 추가 정보 제공


6-2. 필터와 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가 다시 요청 )


6-3. 인터셉터

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                ...
                .excludePathPatterns("/css/**", "*.ico", "/error", "/error-page/**");
    }
}
  • 인터셉터는 DispatcherType을 지정할 수 없기 때문에 경로 정보로 중복 호출을 제거한다

  • 오류 페이지 경로를 excludePathPatterns() 에 추가하면 오류 발생했을 때 해당 인터셉터가 호출되지 않는다




7. 스프링부트와 오류 페이지

  • 이전까지 오류 페이지를 만들려면 WebServerCustomizer 를 생성해서 ErrorPage 를 등록하고, 예외 처리용 Controller 를 생성했다

  • 스프링부트는 ErrorPage 를 자동으로 등록하고, BasicErrorController 라는 스프링 컨트롤러를 자동으로 등록한다


  • 스프링부트는 /error 라는 경로로 기본( 디폴트 ) 오류 페이지를 설정한다

  • 서블릿 밖으로 예외가 전달 or sendError() 가 호출되면 모든 오류는 /error 를 호출

  • BasicErrorController 가 /errror 를 받아서 발생한 예외의 상태 코드에 따라 다른 오류 페이지를 보여준다

  • 스프링부트가 오류 페이지를 처리하는 과정




8. API 오류 처리

  • API 예외 처리는 오류 페이지를 보여주는 것이 아니라 각 오류 상황에 맞는 오류 응답 스펙을 정하고 JSON으로 데이터를 내려준다

8-1. JSON 응답하는 메서드 추가

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이 반환된다




12. 파일 업로드

12-1. HTML Form 데이터 전송 방식

12-1-1. application/x-www-form-urlencoded

  • HTML Form 데이터를 서버로 전송하는 가장 기본적인 방법

  • Form 태그에 별도의 enctype 옵션이 없을 때의 Content-Type

  • 문자 데이터를 보낼 때 사용하며 Form 에 입력한 데이터를 & 로 구분해서 message body 에 담는다

12-1-2. multipart/form-data

  • Form 데이터에 문자 뿐만 아니라 파일도 함께 전송할 때 사용 ( 파일은 바이너리 데이터 )

  • multipart/form-data 는 다른 종류의 파일들이 있는 Form 의 내용을 한 번에 전송할 때 사용

  • enctype 에 multipart/form-data 를 지정해서 사용한다

  • 각각의 전송 항목이 boundary로 구분되며, Content-Disposition 이라는 항목별 헤더가 추가된다


12-2. 서블릿과 파일 업로드

12-2-1. 데이터 확인

// 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() : 읽은 바이너리 데이터를 문자로 변환, 인코딩 방식 지정 필수


12-2-2. 파일 저장

< 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를 통해 전송된 데이터를 해당 경로에 저장


12-3. 스프링과 파일 업로드

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() : 파일 저장

profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글