우테코에서 안드로이드 프로젝트 <총대마켓>을 진행하면서, 카카오 소셜 로그인을 구현하는 데에 성공하였다~~ 
하지만 아직 로그인 파트가 완전히 완료된 것은 아니었으니.. 바로 Access Token과 Refresh Token에 관한 문제였다..
백엔드 측에서 Access Token과 Refresh Token을 클라이언트가 서버로 보낼 때, 쿠키에 담아서 보내달라는 요청을 줬다. 그런데.. 쿠키가 뭔데..? ㅋㅋㅋㅋㅋ 이번 포스팅에서는 쿠키가 무엇인지, 그리고 총대마켓은 쿠키를 어떻게 활용했는지를 기록해 보겠다!
1. 쿠키가 뭐지?
백엔드에서 안드로이드에게 요청한 사항은 다음과 같다.
1.
총대마켓에 로그인을 성공하면 서버가 클라이언트(안드로이드)에 Access Token과 Refresh Token을 보내준다. 이때 이 토큰들을 쿠키에 담아서 내려준다.
2.
이후 모든 서버 API 호출시 마다 이 Access Token을 쿠키에 담아서 서버로 보내야 한다. 이때 Access Token이 유효하지 않거나 만료되었다면 Access Token을 재발급받아야 하며, 이를 클라이언트에게 알리기 위해 HTTP 상태코드 401을 내려준다.
3.
클라이언트가 401을 받았다면 Refresh Token을 서버로 보내서 Access Token을 재발급 받는다. 이때 Refresh Token마저 유효하지 않거나 만료되었다면, 로그인을 새로 해야 하며 이를 클라이언트에 알리기 위해 HTTP 상태코드 403을 내려준다.
4.
클라이언트가 403을 받았다면 로그인 화면으로 이동해 재로그인을 시킨다. 재로그인 시 새로운 Access Token과 Refresh Token이 내려오므로, 이것으로 서버와 통신한다.
쿠키의 개념이 생소한 나에게는 토큰들을 쿠키에 담아서 보내달라는 말이 이해가 가지 않았다. 그래서 가장 처음 해봐야 할 일은 쿠키가 무엇인지 이해하는 것이었다.
HTTP 쿠키(HTTP cookie)란 웹 서버에 의해 사용자의 컴퓨터에 저장되는, '이름을 가진 작은 크기의 데이터'이다.
아직 쿠키를 처음 써 보는 입장이니 너무 깊게 파고들진 않기로 했다. 쿠키란 쉽게 말해 서버가 클라이언트에 보내주는 작은 크기의 데이터 조각이고, 클라이언트가 저장하고 있다가 서버와 통신할 때마다 전송하는 데이터라고 이해했다. 이 정도만 이해해도 우리 총대마켓 프로젝트에서 쿠키를 사용하기에 큰 문제는 없었다.
총대마켓에서는 Access Token과 Refresh Token이라는 작은 문자열 데이터를 쿠키에 저장하게 되는 것이다. 클라이언트는 서버에 로그인하면 이 쿠키를 내려받게 되며, 로컬에 저장해 두었다가 이후 API통신 시 이 쿠키를 보내주면 되는 것이다! 여기까지 이해가 되었으니 첫 번째 관문은 통과한 것이다! 
2. 쿠키를 어떻게 받고 보낼까?
이제 우리 프로젝트에서 쿠키를 처리하고 관리하는 법에 대해서 고민해볼 차례였다. 안드로이드에서는 일반적으로 헤더와 인터셉터를 통해 Access Token을 주고받는다고 알고 있었다. 하지만 나는 헤더와 인터셉터의 사용도 익숙하지 않았던 데다, 쿠키의 경우에도 같은 방법을 사용할 수 있는지에 대해서 의문점이 생겼다. 여기서부터는 구글링이다! 안드로이드에서 Retrofit을 쓰고 있을 때 쿠키를 어떻게 저장하고 보관해야 할지 열심히 검색해 보았다.
좋은 글 발견!
나와 같은 고민을 한 사람이 꽤 있었는지 좋은 글을 금방 찾을 수 있었다. 위 글에서 설명하길 가장 일반적인 방법인 인터셉터를 사용하는 방법도 코드양이 많다는 단점이 있다고 한다. 그리고 CookieJar를 사용하면 이런 단점을 보완할 수가 있다고 한다!! 
3. CookieJar.. 이 녀석에 대해 알아보자.
CookieJar? 쿠키를 담는 항아리라는 뜻인가? 이름을 처음 듣고 유추해 보았을 때 쿠키를 담는 그릇 정도로 생각이 되었다.
CookieJar OkHttp에서 제공하는 기능으로, 클라이언트가 쉽게 쿠키를 받고 보낼 수 있도록 도와준다!! 
CookieJar 인터페이스 원형
CookieJar는 OkHttp 라이브러리에서 인터페이스로 선언되어 있다. 우리 안드로이드 개발자들이 이 인터페이스의 구현체를 만들면, 이후 API 통신 시마다 OkHttp가 이 구현체의 메서드들을 자동으로 호출한다.
이 인터페이스에는 `saveFromResponse()`와 `loadForRequest()` 의 두 가지 메서드가 있으며, 이 두 메서드의 동작은 다음과 같다. 하나씩 알아보자.
`saveFromResponse()` : HTTP 응답로부터 받은 쿠키를 저장소에 저장한다. 아래에서 자세히 설명하겠지만 쿠키를 저장할 저장소는 개발자가 직접 구현해 주어야 한다.
이 함수는 파라미터로 `url`과 `cookies`를 받게 된다. 함수 호출 위치를 타고 들어가서 보게 되면 CookieJar의 내부 동작을 알 수 있는데, 인터셉터가 Response를 인터셉트해서 헤더의 쿠키를 받는 것을 알 수 있다. Response에 `Set-Cookie` 헤더가 포함되어 있다면 그 쿠키를 받아오게 되는 것이다. 궁금하면 직접 타고 들어가서 확인해 보자!
그러면 OkHttp가 이 함수를 호출하면서 알아서 API의 url과 쿠키를 두 파라미터에 넘겨주게 되는 것이다. 함수만 override 해주면 OkHttp가 다 알아서 해주니 이렇게 편할 수가 없다 ㅎㅎ
`loadForRequest()` : HTTP 요청을 보내기 전에 요청에 사용할 쿠키를 반환한다.
`saveFromResponse()`가 쿠키를 받는 함수라면, `loadForRequest()`는 쿠키를 보내는 함수라고 보면 된다. List<Cookie>라는 반환 타입에 맞춰서 개발자가 반환값을 정해준다면, OkHttp가 API 요청 시 이 함수를 호출해 List<Cookie>를 HTTP 요청의 헤더에 추가하고 서버로 전송하게 된다.
CookieJar의 구현체의 예시로 다음과 같이 구현해 볼 수 있다.
class TokensCookieJar : CookieJar {
private val cookies: MutableMap<String, List<Cookie>> = mutableMapOf()
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return cookies[url.host] ?: emptyList()
}
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
this.cookies[url.host] = cookies
}
}
Kotlin
복사
`cookies`라는 MutableMap을 만들어 두고 url에 해당하는 List<Cookie>를 이 MutableMap에 저장하는 식으로 CookieJar를 구현했다. 서버에서 클라이언트로 쿠키를 보내면 OkHttp가 `saveFromResponse()`를 호출해서 쿠키가 `cookies`에 저장되고, HTTP 요청 시에는 OkHttp가 `loadForRequest()`를 호출해서 반환된 쿠키를 헤더에 넣어서 서버로 보낸다.
우리가 만든 CookieJar의 인스턴스는 아래와 같이 사용해주면 된다.
OkHttpClient의 `Builder`의 `cookieJar()` 함수를 호출하고 우리가 만든 CookieJar 구현체의 인스턴스를 넣어주면 된다. 이러면 앞으로 CookieJar가 쿠키를 자동으로 처리해 줄 것이다 
4. 쿠키를 영속적으로 관리하려면?
그런데 이것이 끝이 아니다. 위에서 구현한 방법은 쿠키를 MutableMap, 즉 메모리에 저장할 뿐이다. 앱의 프로세스가 종료되면 이 쿠키는 사라져 버린다는 뜻이다. 
라이브러리 공식 주석으로 다음과 같이 설명되어 있다.
솔직히 모든 설명이 이해가진 않지만, 눈여겨봐야 할 부분이 있는데 바로 이 부분이다.
As persistence, implementations of this interface must also provide storage of cookies. Simple implementations may store cookies in memory; sophisticated ones may use the file system or database to hold accepted cookies.
지속성 측면에서 이 인터페이스의 구현체는 쿠키의 저장도 제공해야 합니다. 간단한 구현은 쿠키를 메모리에 저장할 수 있고, 보다 정교한 구현은 파일 시스템이나 데이터베이스를 사용하여 허용된 쿠키를 저장할 수 있습니다.
아까 짧게 언급한 '쿠키를 저장할 저장소'에 대해서 자세히 설명해 보고자 한다. CookieJar는 헤더에 포함된 쿠키를 꺼내고, 넣어주는 작업만 해줄 뿐 쿠키를 저장소에 저장해 주는 기능은 없다. 이는 개발자가 직접 구현해 주어야 한다.
위에서 만들어준 구현체의 경우, 쿠키를 MutableMap이라는 메모리에 저장하는 방식으로 구현했다. 하지만 휘발성 메모리에 저장한 것이기 때문에, 이런 방법이라면 앱의 프로세스가 종료되었을 때 데이터가 사라져 버린다. 간단한 구현이라면 이렇게 사용할 수도 있지만, 우리가 쿠키에 담을 데이터는 토큰이기 때문에 프로세스가 종료되어도 정보가 남아있어야 한다.
따라서 우리는 데이터베이스나 Shared Preference, Data Store와 같은 로컬 데이터 저장소를 사용해서, 프로세스가 끝나도 데이터가 남아있도록 해야 한다. 우리 프로젝트에서는 간단한 문자열 토큰 데이터만 저장하면 되었기 때문에 데이터베이스까지 사용할 필요는 없다는 판단 하에 Data Store를 사용하기로 하였다.
우선 안드로이드 Data Store에는 객체를 저장할 수가 없다. 그래서 쿠키에서 String 타입으로 저장되어 있는 토큰을 꺼내서 토큰을 Data Store에 저장한 후, 저장한 값을 꺼낼 때는 다시 쿠키에 담는 방식을 사용하였다.
그리고 여기서 한 가지 고민할 점이 있었다.
토큰을 Data Store에 저장하는 작업을 어느 위치에서 할까?
뷰모델은 MVVM에서 데이터 처리를 담당하는 객체이므로 Data Store에 접근하기에 가장 용이하다. 또 API를 호출하는 위치이므로 API에서 내려주는 데이터를 다루는 책임을 할당하기에 자연스럽다.
하지만 해당 작업을 뷰모델이 하게 될 경우 API를 호출하는 모든 뷰모델이 CookieJar를 생성자로 주입받아야 했다. 토큰은 쿠키에 들어있고, 쿠키를 CookieJar가 갖고 있기 때문이다. 따라서 모든 뷰모델에서 수정 작업을 진행해야 했고 이후 쿠키 관련 변경사항이 생길 때마다 계속 모든 뷰모델을 바꿔주어야 하는 번거로움이 있을 것 같았다.
그렇다면 CookieJar에서 토큰을 저장하면 어떨까? CookieJar 내에서 Data Store에 접근하기 위해 CoroutineScope를 열어야 한다는 소소한 단점이 있었지만, 다른 객체에 토큰을 넘겨줄 필요 없이 직접 Data Store에 저장하므로 다른 객체와의 의존성이 생기지 않고 응집도가 높아질 것 같았다.
또한 쿠키를 관리하는 CookieJar의 객체 특성상 쿠키 안에 들어있는 토큰을 영속적으로 관리하는 책임을 할당하기에 충분히 자연스러웠다! 이러한 판단 하에 해당 작업은 CookieJar가 하는 것으로 결정하였다. 
그럼 코드를 통해 어떻게 구현되었는지 살펴보자!
override fun saveFromResponse(
url: HttpUrl,
cookies: List<Cookie>,
) {
this.cookies[url.host] = cookies
saveTokensToDataStore(cookies)
}
private fun saveTokensToDataStore(cookies: List<Cookie>) {
val accessToken = cookies.first { it.name == ACCESS_TOKEN_NAME }.value
val refreshToken = cookies.first { it.name == REFRESH_TOKEN_NAME }.value
CoroutineScope(Dispatchers.IO).launch {
userPreferencesDataStore.saveTokens(accessToken, refreshToken)
}
}
Kotlin
복사
먼저 Response로부터 쿠키를 받아옴과 동시에 해당 쿠키의 토큰을 데이터스토어에 저장한다. List<Cookie> 타입의 `cookies`를 파라미터로 받아서 Access Token과 Refresh Token을 꺼내면 된다.
쿠키 객체에는 다양한 프로퍼티가 존재한다. (아래에서 자세히 설명하겠다.) 이 중에서 `value` 프로퍼티에 쿠키에 담긴 데이터, 즉 토큰이 들어있다. `name`에는 서버에서 "access_token"과 "refresh_token"이라는 이름을 넣어주었다. 따라서 이 이름에 해당하는 쿠키를 List에서 골라서 그 녀석의 value를 꺼내면 우리가 원하는 String 타입의 토큰을 얻게 된다!!
이 토큰을 Data Store에 저장해 주면 된다.
init {
loadTokensFromDataStore()
}
private fun loadTokensFromDataStore() {
CoroutineScope(Dispatchers.IO).launch {
val accessToken = userPreferencesDataStore.accessTokenFlow.first() ?: return@launch
val refreshToken = userPreferencesDataStore.refreshTokenFlow.first() ?: return@launch
val accessTokenCookie = makeTokenCookie(ACCESS_TOKEN_NAME, accessToken)
val refreshTokenCookie = makeTokenCookie(REFRESH_TOKEN_NAME, refreshToken)
cookies[urlHost] = listOf(accessTokenCookie, refreshTokenCookie)
}
}
private fun makeTokenCookie(
tokenName: String,
tokenValue: String,
): Cookie {
return Cookie.Builder()
.name(tokenName)
.value(tokenValue)
.hostOnlyDomain(urlHost)
.httpOnly()
.build()
}
Kotlin
복사
그리고 CookieJar의 인스턴스가 생성될 때 init블럭에서 Data Store에 저장된 토큰들을 꺼내서 `cookies`에 담도록 한다.
Application이 최초에 Retrofit 모듈을 생성할 때 CookieJar의 인스턴스도 함께 만들어진다. 이는 앱 실행시마다 이루어지므로, 앱이 재시작될 때마다 메모리(`cookies`)에 Data Store의 토큰을 옮긴다고 생각하면 되겠다! 앱이 실행중일 때에는 굳이 토큰을 Data Store에서 다시 꺼내지 않고 메모리에서 꺼내서 사용하면 되는 것이다~~ 
5. 쿠키 좀 더 살펴보기
위에서 Data Store에 저장된 토큰을 쿠키에 담는 작업을 했다. 그러려면 쿠키를 생성해야 하는데, 쿠키를 생성하려면 어떻게 해야 하는 걸까? 위의 `makeTokenCookie()`함수를 다시 잘 보자.
private fun makeTokenCookie(
tokenName: String,
tokenValue: String,
): Cookie {
return Cookie.Builder()
.name(tokenName)
.value(tokenValue)
.hostOnlyDomain(urlHost)
.httpOnly()
.build()
}
Kotlin
복사
Cookie 객체의 빌더 클래스를 통해 쿠키를 생성하고있다! Cookie를 좀 더 뜯어보자.
길어서 중간 생략..
역시 생성자가 private하기 때문에 생성자로 직접 쿠키를 생성할 수는 없다. 그리고 `name`, `value` 와 같은 프로퍼티를 갖고 있는 것을 볼 수 있다.
길어서 중간 생략..
쿠키를 생성하기 위해서 사용하는 빌더 클래스다. 여기서는 쿠키의 프로퍼티들을 한눈에 볼 수 있었다.
`name()`, `value()`같은 함수들을 통해서 쿠키에 데이터를 담을 수 있다.
쿠키에 데이터를 모두 담은 후 마지막으로 `build()` 함수를 써 주면 쿠키를 리턴해 준다. 그럼 쿠키 생성 완료!
이제 CookieJar가 이 쿠키를 서버로 전송해 주는 거다.