스프링부트에서 쿠키(Cookie)를 구워보자
728x90
반응형

"쿠키런"이면 "뛰는 데이터"같은건가. (아이고 배야)

 

쿠키? 먹는거 그거?

크롬 브라우저를 열고 네이버에 접속해서 로그인을 한다. 그리고 새로운 탭을 열어서 네이버에 접속하면 로그인된 화면을 볼 수 있다. 이 상태에서 엣지나 웨일같이 다른 브라우저를 통해 네이버에 접속하면 해당 화면에서는 로그인이 되어 있지 않은 모습을 볼 수 있다. 당연하다고 생각했던 모습. 그런데 생각해보면 신기하다. 새로운 탭 화면과 새로운 브라우저 화면, 이 두가지 경우에 어떤 차이가 있길리 다르게 나타나는 것일까?

 

답은 쿠키(Cookie)에 있다. 쿠키는 일종의 파일이다. 유저가 통신 중에 본인을 인증하기 위해 클라이언트에 보관하는 파일이다. 저장된 쿠키를 서버에 전달해서 이 사용자임을 확인한다. 앞 예시를 떠올려보자. 크롬 브라우저에 쿠키가 저장을 할 수 있어서 새 탭으로 네이버에 접근해도 로그인 화면을 얻을 수 있다.

 

만약 요청과 응답이 반복되는 상황이 계속 연결되어 있고 클라이언트에서 상태를 저장하고 있다면 이런 쿠키가 필요하지 않을 수 있다. 하지만 HTTP 프로토콜은 Connectionless 프로토콜(비연결지향), Stateless 프로토콜(상태정보 유지 안함)의 특징을 가지고 있다. 요청과 응답, 한 번의 사이클이 끝나면 연결은 끊어진다. 또한 클라이언트의 요청을 통해 받은 클라이언트 상태를 다음 요청까지 보관하고 있지 않다. 즉, 상태정보를 유지하고 있지 않다. 그렇기에 유저 스스로를 인증해야 하는 방식이 필요했고 그 중 하나가 쿠키를 이용하는 것이다.

 

 

실습에 앞서

이제 스프링부트에서 쿠키를 만들어보자. 다음과 같은 조건이 만족되어 있다는 가정하에서 진행한다.

 

  • 로그인을 할 수 있는 클라이언트와 서버가 준비되어 있다
  • 토큰을 생성하는 모듈이 있다
  • Refresh 토큰을 쿠키 내 넣으려고 한다
  • 쿠키를 통해 Refresh 토큰을 획득하여 토큰을 갱신한다
  • SignOut을 하면 쿠키를 만료시킨다

 

API 응답에 쿠키를 넣는 방법

먼저 API 진입점이 될 함수를 Controller에 추가한다. 이 때 응답을 ResponseEntity로 둔다. 이유는 뒤에서 설명하겠다.

@RestController
@RequestMapping("public")
class PublicController() {

    @Operation(summary = "로그인")
    @PostMapping("signin")
    fun signIn(
        @RequestBody(required = false) userSignInRequestBody: UserSignInRequestBody?
    ): ResponseEntity<UserSignModel> {
        return userSignInService.signInEmail(userSignInRequestBody)
    }

}
data class UserSignInRequestBody(
    val email: String, 
    val password: String
)

 

이제 비즈니스 로직이 들어갈 Service를 구현한다. 본 컨텐츠에서 중점적으로 다룰 부분은 Cookie와 관련한 부분이라서 ID 확인과 비밀번호 검증 등은 생략한다. 

@Service
@Transactional
class UserSignInService {

    fun signIn(userSignInRequestBody: UserSignInRequestBody): ResponseEntity<UserSignModel> {

        // 비즈니스 로직 생략

        val model = generateUserTokenForSignIn(user) // 토큰 생성. 상세한 로직은 생략
        return ResponseEntity.ok()
            .header(SET_COOKIE, model.token.generateCookie().toString())
            .body(model)
    }
    
}

위에서 응답을 ResponseEntity로 만든 이유가 여기에 있다. Spring Framework에서는 HttpEntity라는 클래스를 제공해준다. HTTP 요청(Request)이나 응답(Response)에 해당하는 HttpHeader와 HttpBody를 포함하고 있다. 이 HttpEntity 클래스를 상속받아 구현한 클래스가 RequestEntity, ResponseEntity 클래스이다. ResponseEntity 클래스는 응답용으로 HttpHeader를 세팅할 수 있는데 이때 스프링 프레임워크에서 제공하는 HttpHeaders 내 상수로 SET_COOKIE가 존재한다.

 

// The HTTP Set-Cookie header field name.
// See Also: Section 4.2.2 of RFC 2109 
public static final String SET_COOKIE = "Set-Cookie";

 

이를 키값으로 사용하고 쿠키를 value에 넣어서 header를 세팅할 수 있다. 필자의 경우에는 Token이라는 별도의 클래스를 만들고 그 내부 함수를 통해 쿠키를 만들도록 했다. 또한 쿠키도 스프링 프레임워크에서 제공하는 ResponseCookie를 활용했기 때문에 헤더에 담을 때는 toString 함수를 통해 String 형태로 담는다. ResponseCookie는 아래에서 설명하겠다.

 

data class Token(
    val accessToken: String, 
    val accessTokenExp: Long, 
    @JsonIgnore val refreshToken: String
) {

    // 일부 생략
    
    // 상단 model.token.generateCookie().toString()에서
    // generateCookie()에 해당하는 부분
    fun generateCookie(): ResponseCookie {
        return ResponseCookie.from("refreshToken", this.refreshToken)
            .httpOnly(true)
            .secure(true)
            .sameSite("None")
            .path("/refresh-token")
            .build()
    }
}
  • ResponseCookie
    • 2017년, 스프링 프레임워크 5.0이 릴리즈되면서 등장했다
    • Set-Cookie response header에 원하는 속성을 추가할 수 있다
    • Option
      • httpOnly : Cross-site 스크립팅 공격을 방지하기 위한 옵션
      • secure : HTTPS 프로토콜 상에서 암호화된 요청을 위한 옵션
      • sameSite : 서로 다른 도메인간의 쿠키 전송에 대한 보안을 설정
      • path : Cookie 헤더를 전송하기 위하여 요청되는 URL 내에 반드시 존재해야 하는 URL 경로

 

이렇게 세팅을 하고 로그인을 하면 크롬 브라우저 개발자 도구에서 아래 이미지처럼 set-cookie에 값이 입력된 것을 볼 수 있다. 필자는 쿠키 내부에 토큰을 넣었다. 원하는 값 외 옵션 값도 들어가 있음을 볼 수 있다.

 

 

 

API 요청 헤더에서 쿠키읽는 방법

토큰을 갱신하려면 Refresh 토큰을 다시 서버로 받아야 한다. 이때도 클라이언트에서는 set-cookie로 토큰을 전달해줄 수 있다. 이때 서버는 어떻게 쿠키를 읽을 수 있을까? 스프링부트에서는 어노테이션을 통해 편하게 확인할 수 있다.

 

@PostMapping("refresh")
fun refresh(
    @CookieValue("refreshToken") refreshToken: String
): ResponseEntity<UserSignModel> {
    return signInProvider.refreshToken(refreshToken)
}

set-cookie에는 의도적으로 넣어 둔 token 정보 외에도 옵션 정보가 있다. refreshToken이라는 키값에 해당하는 value, 즉 실제 토큰값을 받기 위해서는 위 코드처럼 CookieValue 어노테이션과 키값을 입력하면 된다. 

 

 

쿠키를 제거하는 방법

간단한 옵션만으로 쿠키를 무효화할 수 있다. 이 방법으로 로그아웃도 가능하다. maxAge를 작은 값으로 두면 쿠키가 해당시간동안만 유지되기 때문에 금방 무효화된다. 

// 비즈니스 로직 부분
fun signOut(): ResponseEntity<CommonResponse<String>> {
    val model = SigninModel("")
    return ResponseEntity.ok()
        .header(HttpHeaders.SET_COOKIE, Token.generateSignOutCookie().toString())
        .body(model)
}

// Token 클래스
fun generateSignOutCookie(): ResponseCookie {
    return ResponseCookie.from("refreshToken", "")
        .maxAge(1)
        .build()
}

 

 

 

사실 쿠키와 세션 등은 처음에 배우기에는 진입장벽이 높다. 실습을 통해 원리를 조금씩 파악하고, 사용함에 있어서 보안에 취약하거나 통신에 있어서 신경써야하는 부분들을 찾다보면 조금씩 익숙해질 것으로 보인다.

 

급 쿠키땡기는 짤

 

참고자료
- How to set a cookie with Response Entity in Spring Boot
- HTTP 쿠키
- Cookie SameSite 설정하기 (Chrome 80 쿠키 이슈)
- [Spring] 세션, 쿠키, 인터셉터
- [Spring Boot] 쿠키 (Cookie)
- Springboot 웹프로젝트 (20) - Cookie
- 쿠키와 세션 개념

 

728x90
반응형