스프링부트에서 쿠키(Cookie)를 구워보자
쿠키? 먹는거 그거?
크롬 브라우저를 열고 네이버에 접속해서 로그인을 한다. 그리고 새로운 탭을 열어서 네이버에 접속하면 로그인된 화면을 볼 수 있다. 이 상태에서 엣지나 웨일같이 다른 브라우저를 통해 네이버에 접속하면 해당 화면에서는 로그인이 되어 있지 않은 모습을 볼 수 있다. 당연하다고 생각했던 모습. 그런데 생각해보면 신기하다. 새로운 탭 화면과 새로운 브라우저 화면, 이 두가지 경우에 어떤 차이가 있길리 다르게 나타나는 것일까?
답은 쿠키(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
- 쿠키와 세션 개념