Developer.

[멋사 백엔드 19기] TIL 38일차 Spring MVC 심화

📂 목차


📚 본문

웹 브라우저와 서버 사이에서 정보를 작고 단순한 문자열 형태로 저장하고 주고 받는 방법이며, 서버가 브라우저에 데이터를 잠깐 기억해달라고 요청하면 브라우저는 이를 로컬에 저장하고 같은 도메인으로 요청할 때마다 서버에 다시 전달하게 된다.

용도

  • 로그인 상태 유지(Session ID 저장)
  • 사용자 선호 설정 저장(언어, 테마)
  • 트래킹 및 분석(방문 기록, 장바구니 정보)

서버측에서는 응답으로 보낼때 쿠키를 저장해달라고 요청할 수 있고, 그때 HTTP 에는 Set-Cookie 헤더가 포함되게 된다. 이후에 브라우저가 요청을 서버에 한다면 자동으로 이 쿠키들이 전송되게 된다.

서버 -> 브라우저 Set-Cookie 헤더

Set-Cookie: userId=park123; Path=/; Max-Age=3600; HttpOnly; Secure

브라우저 -> 서버 Cookie

Cookie: userId=park123

HTTP 상태를 보존하는 가장 낮은 레벨의 메커니즘이 바로 Cookie 이다.

  • Name: 이름
  • Value: 값
  • Domain: 쿠키가 적용될 도메인
  • Path: 쿠키가 적용될 경로
  • Max-Age: 쿠키 유효기간(-1 이면 창을 껏다 키면 사라지는 옵션)
  • Secure: HTTPS 에서만 전송
  • HttpOnly: JS 에서 접근 불가, XSS 방어용
  • SameSite: CSRF 공격 방지(Strict, Lax, None)

@CookieValue 어노테이션을 통해 읽어오기

@GetMapping("/viewCookie")
public String viewCookie(@CookieValue(value = "userId", required = false) String userId,
                         Model model) {
    model.addAttribute("userId", userId);
    return "cookie_view";
}

브라우저에 쿠키가 저장되어 있고 userId 라는 이름의 쿠키가 있다면 들고옴 없으면 null

쿠키 생성 및 저장

@PostMapping("/addCookie")
public String addCookie(CookieRequest cookieRequest, /* @ModelAttribute 생략 가능 */
                        HttpServletResponse response) {

    Cookie cookie = new Cookie(cookieRequest.cookieName(), cookieRequest.cookieValue());
    cookie.setPath("/");
    cookie.setMaxAge(-1);

    response.addCookie(cookie);

    return "redirect:/viewCookie";
}

@ModelAttribute 는 하나의 object 만이 요청으로 오게 되면 생략이 가능하다.

쿠키 삭제

@GetMapping("/delCookie")
public String deleteCookie(HttpServletResponse response) {
    Cookie cookie = new Cookie("userId", null);
    cookie.setMaxAge(0); // 만료
    cookie.setPath("/");
    response.addCookie(cookie);
    return "cookie_deleted";
}

브라우저에서 쿠키를 제거하려면 같은 이름, 같은 Path, MaxAge=0 으로 다시 보내야 함

setHttpOnly

자바스크립트에서 쿠키에 접근하는 것을 차단한다. 즉, document.cookie 로 접근할 수 없게 된다.

  • XSS 공격 방지용
  • SessionID 가 드러나지 않도록
setSecure

HTTPS 연결에서만 쿠키를 전송하도록 제한을하며, HTTP 요청에서는 이 쿠키가 전송되지 않는다.

  • 네트워크 상에서 쿠키가 노출되는 것을 방지하고
  • HTTP 의 암호화 부재로, 누군가 트래픽 감청 시 세션ID 가 노출되면 안된다
setSameSite

CSRF 공격 방지를 위해 쿠키가 어떤 요청 상황에서 전송될 수 있는지를 제한하는 옵션이다. 기본값은 Lax 이다.

CSRF 는 요청을 보낸 페이지의 도메인 과 요청을 받는 서버의 도메인이 다를 수 있는데 이럴때 크로스 사이트 요청이라고 한다. 기본적으로 다른 사이트의 요청은 쿠키를 전송하지 않는다.

Session

웹에서 HTTPstateless 프로토콜이다. 즉, 요청 -> 응답 사이에 서버는 사용자를 기억하지 못하는데, 대부분의 웹 어플리케이션은 사용자 상태 유지가 필요하다.

여기서 등장하는 것이 session 이며, 세션은 서버가 클라이언트 별로 유지하는 일시적 데이터 저장 공간이라고 볼 수 있다.

Session 동작 원리

  1. 사용자가 브라우저로 요청을 보냄
  2. 서버는 세션 객체를 생성하고, 고유한 Session ID 를 발급 받음
  3. 세션 ID 는 주로 쿠키(JSESSIONID) 를 통해 클라이언트에 전달됨
  4. 클라이언트는 이후 요청마다 세션 ID 를 서버에 보내고, 서버는 이를 사용하여 세션 저장소를 조회하게 된다.
Session 생성

Spring MVC 에서 Session 은 HttpSession 인터페이스를 통해 제공되지만, 실제로는 Servlet 컨테이너(Tomcat, Jetty 등)에서 관리한다. 즉, Spring 이 직접 세션 객체를 만드는 것이 아니라 요청 시 Servlet 컨테이너가 세션을 정하고 Spring 은 그걸 활용한다.

따라서 DispatcherServlet 에서 받아진 HttpServletRequest 안에 Session 이 들어있게 된다.

  1. request.getSession() 호출
    1. 요청 쿠키에 JSESSIONID 를 확인
    2. 없으면 새로운 세션 객체 새성
    3. 고유한 세션 ID 를 생성(UUID 또는 랜덤 문자열)
    4. 서버 메모리(기본은 HashMap 등)에 세션 저장
    5. 클라이언트에게 Set-Cookie: JSESSIONID=랜덤값 전송
  2. 이후 요청에서 세션 사용
    1. 브라우저가 JSESSIONID 쿠키를 보내면 컨테이너가 세션 객체를 조회해서 반환
    2. Spring Controller 에서 바로 session.getAttribute("user") 등으로 접근 가능

Session 구조

  • HttpSession: 인터페이스
  • StandardSession (Tomcat 구현)
    • id: 세션 식별자
    • creationTime: 세션 생성 시간
    • lastAccessedTime: 마지막 접근 시간
    • attributes: 세션에 저장된 key-value Map

Servlet 이 생성함을 알고 있자

Session 활용 예시

@GetMapping("/login")
public String login(HttpSession session) {
    // 로그인 성공 후 사용자 정보를 세션에 저장
    session.setAttribute("user", "park");
    session.setAttribute("role", "admin");
    return "home";
}

이때 String, Object 로 들어가는데, ObjectSerializable 해야 한다.

SessionAttribute

@ModelAttribute 처럼 여기서도 @SessionAttributename 옵션은 생략 가능하다.

@GetMapping("/dashboard")
public String dashboard(@SessionAttribute(name="user", required=false) String user) {
    if (user == null) return "redirect:/login";
    return "dashboard";
}

SessionAttributes

이 어노테이션은 살짝 헷갈릴 수 있는데, 새롭게 접속하는 클라이언트는 쓸려는 keySession 에 없기 때문에 @ModelAttributekey 가 생성되면 @SessionAttributes 덕분에 이 값이 세션에도 저장되게 된다. 즉, HTTP Session 이 생성될 때마다 자동으로 저장하도록 지정한다.

@SessionAttributes("visitCount")
public class SessionController {

	// HTTP Request 에서 모델에 값이 없을 때만 호출되는 애너테이션이다.
    // 하지만, 위에서 SessionAttributes 로 HTTP Session 범위로 만들었기 때문에 해당 key 는 세션이 끝날때까지 계속 유지
	@ModelAttribute("visitCount")
	public Integer initVisitCount() {
		System.out.println("initVisitCount");
		return 0;
	}

    ...
}

만약 클라이언트가 같은 컨트롤러의 다른 요청을 다시 보내게 되면 세션에 이미 key 가 존재해서, 이 값을 자동으로 Model 에 적용하고, 이 어노테이션은 Controller 단위로 적용되므로 다른 컨틀롤러에서는 해당 key-value 를 못쓴다. 다만, 다른 컨트롤러에서 동일 key 를 수동으로 꺼내서 쓰는건 가능하다. 세션은 공유 영역이기 때문

Exception Handling

보통 자바에서 에러를 만나게 되면 내부적으로 try-catch 를 사용하여 처리를 하게 된다. 하지만 이를 컨트롤러에서 메서드 하나하나 마다 생성시킨다고 한다면 유지보수가 어려워진다.

이때 사용할 수 있는게 @ExceptionHandler 이다. 컨트롤러 단위에서 예외 처리를 해주고 예외를 한 곳에서 처리할 수 있다. 만약 하나의 컨트롤러에 대해서 Exception Handling 을 하고 싶다면 그 컨트롤러 내부에 @ExceptionHandler 가 붙은 메서드를 정의하면 된다.

@Controller
public class UserController {

    @GetMapping("/user")
    public String getUser(@RequestParam(required = false) String name) {
        if (name == null) throw new IllegalArgumentException("이름이 없습니다!");
        return "user";
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public String handleIllegalArgument(IllegalArgumentException e, Model model) {
        model.addAttribute("error", e.getMessage());
        return "error"; // error.html 로 포워딩
    }
}

모든 컨트롤러 즉, 전역적인 예외 처리를 하고 싶다면 클래스를 따로 만들어 @ControllerAdvice 를 클래스에 붙여주면 된다.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public String handleIllegalArgument(IllegalArgumentException e, Model model) {
        model.addAttribute("error", e.getMessage());
        return "error";
    }

    @ExceptionHandler(Exception.class)
    public String handleGeneral(Exception e, Model model) {
        model.addAttribute("error", "서버 오류가 발생했습니다.");
        return "error";
    }
}

ControllerAdvice 는 다음 옵션을 제공한다.

  • basePackages: 특정 패키지 하위의 컨트롤러들에만 예외 처리를 적용할 때 사용
@ControllerAdvice(basePackages = "com.example.user")
public class UserExceptionHandler { ... }
  • basePackageClasses: 클래스 기준으로 패키지를 지정할 수 있다. 해당 클래스가 속한 패키지가 자동 인식된다. 아래와 같이 쓰면 UserController 가 속한 패키지의 모든 컨트롤러에 적용됨
@ControllerAdvice(basePackageClasses = UserController.class)
public class UserExceptionHandler { ... }
  • assignableTypes: 특정 클래스나 타입(컨트롤러 클래스 또는 인터페이스)에만 예외 처리를 적용, basePackageClasses 랑 다른 점은 속한 클래스의 적용 유무이다.
@ControllerAdvice(assignableTypes = {AdminController.class, UserController.class})
public class SpecificControllerAdvice { ... }
  • annotations: 특정 애너테이션이 붙은 컨트롤러들에만 적용
@ControllerAdvice(annotations = RestController.class)
public class RestApiExceptionHandler { ... }

RestControllerAdvice 는 REST API 용 전역 처리

ExceptionHandler 규칙

모든 편리한 것에는 규칙이 있음을 잊지 말고 여기서도 규칙을 지키도록 어떤 규칙이 있는지 보자.

보통 @ExceptionHandler 메서드가 자동으로 주입 받을 수 있는 인자는 컨트롤러의 일반 핸들러 메서드와 동일한 규칙으로 인자를 해결한다. 즉 HandlerMethodArgumentResolver 들이 지원하는 모든 타입이 사용 가능하다.

  1. 예외 타입
  2. HTTP 관련 표준 타입
    • HttpServletRequest
    • HttpServletResponse
    • HttpSession
    • ServletRequest / ServletResponse
    • WebRequest
  3. Spring MVC 의 웹레벨 도우미
    • HttpEntity
    • Locale
    • Principal
    • InputStream / Reader 등등
  4. 모델 관련 타입(보통 key-value 를 나타내는거-Map 면 다 된다)
    • Model
    • ModelMap
    • ModelAndView
    • Map<String, Object>
  5. 요청 바인딩 관련
    • @RequestParam
    • @RequestHeader
    • @RequestBody
    • @PathVariable
    • @ModelAttribute
    • @CookieValue
    • @SessionValue

위 타입들은 자동으로 주입 받는다. 따라서 보통 첫 인자로 Exception 하위 클래스를 인자를 정의해주면 내부에서 이를 사용할 수 있다. 그리고 보통 Error 처리를 할 때, Status 코드와 에러 메시지를 열거형으로 선언하고 이를 사용해주는게 깔끔할 것 같다.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ModelAndView handleNotFound(ResourceNotFoundException e) {
        ModelAndView mav = new ModelAndView("error/404");
        mav.addObject("message", e.getMessage());
        return mav;
    }

    @ExceptionHandler(ValidationException.class)
    public String handleValidation(ValidationException e, Model model) {
        model.addAttribute("errors", e.getErrors());
        return "error/validation";
    }

    @ExceptionHandler(DataAccessException.class)
    public String handleDatabaseError(DataAccessException e,
                                     Model model) {
        logger.error("Database error", e);
        model.addAttribute("error", "Database error occurred");
        return "error/500";
    }

    @ExceptionHandler(Exception.class)
    public String handleGeneral(Exception e, Model model) {
        logger.error("Unexpected error", e);
        model.addAttribute("error", "An unexpected error occurred");
        return "error/general";
    }
}

// Custom Exception
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}