고민의 시작
대학생분들이 IT 스타트업을 창업해 서비스를 구현한다고 하면, 회원제는 필수로 구현하게 될 것이다. 하지만, 고민에 빠지게 되는 것은 큰 IT 기업은 대부분 세션 방식을 쓰는데 왜 세션을 사용하는 거지? 토큰 방식은 왜? 근데, 그럼 뭐써? 라는 고민을 한 번쯤은 해봤을 것이다.
필자도 로그인 파트를 진행하면서 세션과 토큰 중 추후를 위해 어느 방식이 좋은 것인가에 대해 고민하게 되었고, 직접 사용해본 경험을 토대로 세션과 Token의 필요성, 장,단점 그래서 레디베리는 왜 이 방식을 선택했는지 추후 문제는 없는지를 이야기 해보고자 한다.
왜 인증에서 상태가 필요해?
Stateless인 HTTP 특성으로 사용자를 특정할 수 있는 어떠한 수단이 필요하다.
이를 위해, 세션 혹은 토큰을 사용해 서버와 클라이언트 사이에서 값을 확인하고 사용자를 특정할 수 있다.
세션과 토큰의 가장 큰 차이점은 서버까지 값을 저장하는가 클라이언트에만 저장을 하는가이다.
이 차이점으로부터 무엇을 사용하는지에 따라 각각의 이점들이 존재하게 된다.
(LocalStorage or Cookie) + 토큰(Token)
토큰 방식에는 JWT라는 웹 표준으로서 두 개체에서 JSON 객체를 사용하고 가볍고 인증에 필요한 정보를 모두 지니고 있는 방식이 있다.
토큰은 서버로부터 발급 받은 이후, 클라이언트에서 LocalStorage, Cookie등 보관이 가능하다.
동작과정
- 사용자는 클라이언트에서 ID / PW를 통해 로그인을 요청한다
- 유효한 ID/PW라면, Access Token과 Refresh Token을 발급한다.
- 클라이언트는 전달 받은 토큰을 LocalStorage 혹은 Cookie에 저장한다.
- 클라이언트는 헤더(Authorization)에 Access Token을 담아 서버에 요청한다.
- 서버에서는 Access Token을 검증하고, 응답을 클라이언트에 보낸다.
- Access Token이 유효하지 않다면 Refresh Token으로 Access Token을 재발급한 뒤, Access Token을 리턴해줌
JWT 구조
JWT는 크게 헤더, 내용, 서명으로 사용자 정보와 인증에 필요한 정보를 모두 담고 있다.
헤더 : typ과 alg 두가지 정보를 가진다.
- alg : 서명을 해싱하기 위한 알고리즘의 종류를 지정한다.
- typ : 토큰의 타입이 들어간다. 예 ) JWT
내용 : payload라고도 하며 토큰에 담을 정보들이 존재하고 보통은 유저를 구분하고자 하는 유저의 정보를 담는다. 여기서 담는 정보의 한 조각을 claim이라고 한다.
클레임은 key-value 형태로 존재하고, registered claim, public claim, private claim 이 있다.
registered Claim : 등록된 클레임들은 서비스에서 필요한 정보들이 아닌, 토큰에 대한 정보들을 담기 위해 이미 지정된 클레임이다. 등록된 클레임을 사용하는 것은 모두 선택적이다.
- iss : 토큰 발급자(issuer)
- sub : 토큰 제목(subject)
- aud : 토큰 대상자(audience)
- exp : 토큰의 만료시간(expiraton)
- nbf : Not Before를 의미하며, 토큰의 활성 날짜와 비슷한 개념
- iat: 토큰이 발급된 시간(issued at)
- jti: JWT의 고유 식별자(중복 처리 방지) - 일회용 토큰 사용 시, 사용
public claim : 특정 커뮤니티나 사용자 그룹에서 공통으로 사용할 수 있도록 정의된 클레임.충돌이 방지된 이름이 있어야 함→충돌을 방지하기 위해 클레임 이름을 url 형식으로 지음.
{ "https://farmfarm1223.tistory.com/claims/name": "John Doe", "https://farmfarm1223.tistory.com/claims/email": "john.doe@example.com" }
private claim : 양측간의 협의 하에 사용되는 클레임 이름들. 합의하에 설정하는 것이기 때문에 중복 충돌에 주의해야한다.
{ "username" : "1223v" "email" ; "1223v@naver.com" }
{
"iss" : "1223v@naver.com",
"exp" : "1234000000",
"https://farmfarm1223.tistory.com/claims/name": true,
"userId" : "1234123414",
"username" : "hyunsik"
}
2개의 register Claim, 1개의 public claim, 2개의 private claim으로 이루어져있다.
서명: 토큰을 인코딩하거나 유효성 검증을 할때 사용하는 고유한 암호화 코드
- 헤더와 페이로드 값을 각각 base 64로 인코딩
- 인코딩된 값을 비밀 키를 이용해 헤더에서 정의한 알고리즘으로 해싱
- 위에서 해싱한 값을 다시 base64로 인코딩
JWT의 장점
- HTTP 헤더에 넣어 쉽게 전달 가능
- 확장성 용이
- 토큰을 해석하는 알고리즘만 서버에 두면 되기에 MSA와 같은 분산 서비스에 적합
- 세션 사용시 문제점이였던 stateful한 특성을 JWT 토큰 사용시 stateless하게 할 수 있음. → 서버는 클라이언트의 상태를 유지할 필요가 없음
- 인증에 필요한 정보가 토큰에 있기에 별도의 저장소가 필요없음.
- 보안성을 높이기 위해 Refresh Token을 사용하는 경우 별도의 저장소에 저장하면서 사용하는 경우도 있음
JWT의 단점
- 토큰의 정보가 클수록 네트워크에 부하를 줄 수 있음.
- 페이로드는 암호화된 것이 아니기에 Base64 디코딩을 하면 내용을 볼 수 있다.
JWT의 암호화 방식
JWT토큰 생성 시, JWT헤더와 페이로드 정보를 인코딩하고 둘을 합친 문자열을 비밀키로 서명. 이때 대칭키 암호화, 비대칭키 암호화 방식을 사용할 수 있다.
대칭키 암호화 : 암호화, 복호화 키가 같으면 대칭키 암호화 방식
- 같은 키를 사용해 암호화, 복호화를 수행하기 때문에 속도가 빠르다
- 값에 SHA-256를 적용해서 해싱 후 private key(= secret key, 대칭키 역할)로 암호화
- private key를 알고 있는 서버만 Signature유효성 검증이 가능. JWT 복호화할 수 있음.
비대칭키 암호화 : 암호화, 복호화 키가 다르면 비대칭키 암호화 방식
- 다른 키를 사용해 암호화, 복호화를 수행하기 때문에 속도가 느리지만, 대칭키 암호화에 비해 안전
- 공개키는 공개적으로 제공. 모든 서버는 해당 공개키를 통해 JWT 암호화가 가능
- 수신자는 본인의 private key만으로 복호화 가능
Refresh Token(Access Token가 탈취된다면)
토큰이 탈취당할 경우를 대비하여 사용한다.
Access Token만으로 공격자의 요청인지 정상적 요청인지 알 수 가 없음.
Access Token은 어떤 상황(XSS, 사회공학적 기법)에서든 탈취 될 수 있기 때문에 Access Token에는 중요한 정보를 담으면 안된다.
그래서, Access Token의 유효기간을 짧게 설정하고 Refresh Token의 유효기간을 길게 설정한다.
물론, Access Token의 만료되기 전까지는 공격자에게 노출되나 피해를 최소화하기 위한 방법이다. (세션도 탈취될 경우 동일한 피해이기에)
Access Token이 탈취됐을 때를 대비하기 위해 Refresh Token 개념을 도입
★ But. Access Token과 Refresh Token 모두 같은 곳에 저장하면 같이 탈취 당함
★ So. Access Token을 로컬스토리지 or 세션스토리지에 저장. Refresh Token은 쿠키에 저장하고 보안 옵션(http only, secure cookies)을 최대로 걸어 bom 접근을 막는다.
★ Also. Refresh Token은 서버에 저장 (Redis or RDB) → 어디에 저장하는지에 대한 설명은 아래 블로그 참고
💡 Secure Cookies : HTTP 프로토콜은 wireshark 등의 패킷 캡쳐를 통해 언제든 패킷을 가로챌 수 있는데 이러한 문제를 해결한 것이 HTTPS 프로토콜을 사용해 데이터를 암호화한 것이다.
💡 HTTP Only Cookies : 클라이언트에서 자바스크립트(document.cookie)로 쿠키를 조회할 수 있는데 해당 옵션을 활성화하면 브라우저에서 쿠키에 접근할 수 없게 되어 xss를 통한 쿠키 탈취가 불가능해지면서 보안을 강화할 수 있다.
Refresh Token만 탈취된다면?
탈취 당한 Refresh Token으로 계속 Access Token을 생성해서 정상적인 사용자처럼 사용될 수 있는거 아닌가? 라는 의문이 들 수 있다.
그래서 우리는 이를 대비해 서버에서 추가 검증 로직으로 방어해야한다.
1) DB(Redis)에 사용자와 Access Token, Refresh Token들을 매핑하여 저장한다.
2) 정상적인 유저의 Access Token이 만료된 경우.
- Access Token과 Refresh Token을 서버로 보내서 새 Access Token을 요청한다.
- 서버에서는 DB(Redis)에 저장된 Access Token, Refresh Token 쌍과 클라이언트에서 보낸 토큰 쌍들을 비교한다.
- 일치하는 경우 새 Access Token과 Refresh Token을 발급해준다.
- 다시 DB(Redis)에 새로 발급된 토큰을 갱신한다.
3) 공격자에게 Refresh Token을 탈취된 경우.
- 탈취한 Refresh Token으로 새 Access Token 생성 요청
- Access Token이 없이 요청하면 공격으로 간주
- 서버에서 Access Token, Refresh Token 폐기
쿠키(Cookie) + 세션(Session)
쿠키에 아이디와 유저의 중요 정보가 아닌 인증을 위한 별개의 정보를 세션 저장소에 저장하고, 클라이언트는 세션을 쿠키에 담아 서버에 요청한다. 서버는 세션 저장소에 있는 세션과 일치하는지 확인 후 적절한 응답을 주는 방식이다.
동작 과정
- 클라이언트가 ID / PW로 서버에 로그인
- ID / PW로 인증 후, 사용자를 식별한 특정 유니크한 세션 ID를 만들어 서버의 세션 저장소에 저장
- 세션 ID를 특정한 형태로 클라이언트에 반환
- 이후 사용자 인증이 필요한 정보를 요청할 때마다 세션 ID를 쿠키에 담아 서버에 함께 전달
- 인증이 필요한 API일 경우, 서버는 세션 ID가 세션 저장소에 저장된 것이지 파악
- 유효한 세션의 경우 200. 유효하지 않거나 없다면 401 에러 반환
문제점
무엇보다 HTTP의 Stateless 특성을 위배한다. Stateless 특성은 서버에서는 클라이언트의 상태를 저장하지 않아야 하지만 세션 저장소라는 곳에 클라이언트의 상태를 저장하게 되므로 stateful 한 상태가 된다.
- 이는 결국 확장성의 문제로 이어진다. 1번 서버에서 로그인한 사용자가 다른 2번 서버로 요청하게 되면 2번 서버에서는 세션이 저장되어 있지 않아 유효하지 않은 세션으로 인식
- 해결하기 위해 세션 저장소를 별도로 외부에 두는 것이 가장 일반적인 방식이다. Redis가 세션 저장소로 가장 많이 사용
해결 방법
스티키 서버
- 스케일 아웃시 여러 서버에 세션 정보를 복사할 필요 없도록 특정 세션을 처음 처리한 서버에게 이후 같은 세션의 요청을 같은 서버가 처리하도록 하는 방식
- 사용자가 A서버에게 요청했다면, A 사용자의 요청은 모두 A서버에서 처리하는 방식
- 해당 방법의 문제점은 각 서버가 균일하게 요청을 받을 수 없다는 것. → 특정 서버에 부하가 올 수 있는 문제 존재
세션 스토리지 분리
- 세션 스토리지를 외부로 분리하는 방식은 입출력 I/O가 잦기 때문에 세션 특성상 I/O 성능이 느린 데이터베이스는 사용하기 적합하지 않음 → In Memory DB(Redis or Memcached)를 사용하는 것이 일반적
JWT와 세션 전격 비교
사이즈
세션은 Cookie 헤더에 세션 아이디만 실어 보내면 되므로, 트래픽을 적게 사용하지만 JWT의 경우는 사용자 인증 정보와 토큰의 발급시각, 만료시각, 토큰의 ID 등 담겨있는 정보가 크키 때문에 굉장히 무겁다.
이는 현재 레디베리 인증 API를 burpsuite를 통해 패킷을 캡쳐한 것이다.
확인을 위해 Session ID를 켜놓은 상태인데 길이만 봐도 Access Token과 세션의 크기는 압도적인 차이가 난다..
안정성과 보안성
세션의 경우 모든 인증정보를 서버에서 관리하기에 서버의 의존성이 높아 보안적 측면에서는 조금 유리한 편이다.
세션이 탈취되어도 서버 측에서 해당 세션을 무효처리하면 되기 때문이다. 또한 세션은 모든 데이터가 서버에 의존해있기에 아무나 함부로 열람할 수 없어 저장할 수 있는 데이터의 제한이 없다.
토큰의 경우는 좀 다르다. 서버가 트래킹하지 않고 stateless한 특성으로 서버에서는 검증 알고리즘만 존재하기 때문에 토큰이 한번 탈취 당하면 세션보다 복잡한 방식으로 해킹을 막아야한다.
확장성
토큰의 가장 큰 장점이자 토큰 기반 인증을 사용하는 이유는 바로 확장성이다.
일반적으로 웹 애플리케이션은 서버를 수평 확장을 사용한다.
세션 방식
- 여러대가 서버의 요청을 처리하는데 별도의 작업을 해주지 않는다면, 세션 기반 인증 방식은 세션 불일치가 발생.
- → 스티키 서버, 세션 스토리지 등의 방식으로 외부에서 분리작업을 해주어야한다.
- 결국, 사용자 인증이라는 과정이 외부(인프라)에 의존하게 되는 것이다. 즉, 단일 책임 원칙을 벗어나는 문제가 있다.
토큰 방식
- 서버가 직접 인증 방식을 저장하지 않고 토큰 복호화 로직을 통해 인증처리를 하기때문에 세션 불일치 문제로부터 자유롭다. 토큰 기반 인증 방식은 HTTP Stateless를 그대로 활용할 수 있고, 높은 확장성을 갖는다.
서버의 부담
세션 기반 인증 방식은 서비스가 세션 데이터를 직접 저장하고 관리. → 세션 데이터의 양이 많아지면 서버의 부담이 됌
토큰 기반 인증 방식은 클라이언트에 인증 데이터를 직접 저장하고 관리. → 유저 수의 증가에 따라 서버의 부담으로 이어지지는 않는다.
그래서 레디베리는 뭐 써? 왜?
우리는 위 문제를 앞서 토큰과 세션의 장, 단점을 보며 당시에 맞는 토큰
방식을 선택했다.
그래서 왜?
우리가 주목한 문제 인식은 최소한 수준에 맞는 확장 가능성을 열어놔야 한다 는것이였다.
무슨 말인가 싶다.
요약하자면, 우리는 당시 고차원적인 인프라(k8s)등을 통한 scale out 방식을 하고 싶으나 그만큼의 높은 수준의 능력이 아니였고 공부를 하면서 점차 고차원적으로 변경해나가고자 했다.
하지만 지금에 개발에 있어 인프라에게 책임을 분산하는 것는 부담으로 이어진다.
예를 들어, 로그인을 세션스토리지으로 구현했다고 가정하자.
초기에 서비스는 하나의 컨테이너로 구동하고 있을때는 상관이 없다.
하지만, MVP가 끝나고 서버 분산을 적용하기 위해 nginx의 로드 벨런싱을 구현한다고 했을때, 문제가 생긴다.
위에서 살펴봤듯이, 세션스토리지는 서버 즉, 컨테이너 하나가 갖는 저장소이다. 그러면 결국 인프라 확장 부분에서 로드벨런싱
이라는 과제와 세션 불일치
문제 해결(외부로 세션스토리지 분리 or redis, 스티키 서버 적용) 이라는 과제가 확장이라는 맹목하에 2개가 생겨버리는 것이다.
💡 토큰 방식의 경우, 로그인의 책임을 전적으로 컨테이너에서 담당하게 되므로 인프라 서버 확장의 난이도가 낮음. (토큰 복호화 알고리즘만 컨테이너에 통일하고 key값만 맞춰주면 되기때문에)
이를 회사 부서로 예를 든다면, 로그인 팀의 일과 인프라팀의 일에서 회색지대 혹은 로그인팀의 일이 인프라팀에 책임이 전가가 된 것이다.
우리는 그 당시 백엔드 역할 분배를 줜형과 나 2인이서 진행했다.
나 : 로그인 및 Oauth | 사장님 API
형 : 인프라 | 유저 API
각자만의 기능부분 말고도 API 담당 업무가 있어서 추후 확장에 있어 각자 다른일을 하고 있을 수도 있는데
로그인이 인프라에 의존하는 구조
를 만들고 싶지 않았다.
하나의 변경사항(인프라 확장)이 다른 서비스 로직까지 개편해야하는 문제를 고려해 이 방식을 선택했다.
즉, 로그인이라는 기능을 서비스 비즈니스 로직에 단일 책임을 부여해야 확장에 있어 변화가 용이하다.
결론적으로 가져가고 싶은 키워드는 확장성
, 변화의 용이
, 의존성 분리
, 기간 엄수
, 인프라의 난이도 낮춤
이였고, 이 요구사항을 모두 충족한 것이 바로 토큰
방식이였던 것이다.
하지만 토큰의 단점은 여전히 거슬렸고,
토큰 탈취의 위험성도 인지하여 앞서 말한 추가 검증 로직도 구현해놨으며, xss 방어를 위해
- AT → localStorage , RT → Cookie(HTTP Only, Secure 옵션 풀 장착)에 보관
- 입력창 최소화를 진행
- redis 사용
보안적 단점마저 보완했다.
고찰
그렇다면 왜 기업들은 왜 일반적으로 세션 방식을 사용할까?
이유로는
첫번째로 정보에 있어 주도권이 확실하다는 것이다.
세션이 악용될 경우, 앞서 말한 이유로 토큰은 탈취 당할 경우 즉각 대응이 어렵다. 하지만 세션은 서버에서 인지만 할 수 있다면 즉각 대응이 가능하다. 그렇기 때문에 정보보호의 막중한 책임감을 가진 기업들은 효율(성능)보다는 안전(보안)을 선택할 수 밖에 없다.
두번째로는 정보 활용이 비즈니스적으로 잘 이어지기 때문이다.
마지막 로그인한 날짜라던가, 어느정도 기간동안 서비스에 머물렀는가를 세션으로 알 수 있다. 이를 분석하여 사용자 경험( 세션 정보를 기반으로 맞춤형 서비스 )으로 고도화로 전환이 가능하다.
세 번째로 전환의 어려움이다.
토큰 방식이 주목 받은지는 세션에 비해 비교적 최신이다. 이전의 세션 방식의 서비스를 토큰 방식으로 전환하기에는 전환비용이 너무 과하게 든다.
이러한 안전과 활용, 전환의 문제로 세션을 사용하는 것이라 생각해볼 수 있다.
느낀점
번외의 이야기지만, 이를 통해 생긴 내 개발 가치관을 이야기해보고자 한다.
이만큼까지 고찰한 것은 아무래도 타인을 위한 마음이 있었기 때문이라고 생각한다.
타인을 생각하지 않고 짠 코드는 결국 그 누구도 활용할 수 없는 변화와 확장이 막힌 코드가 된다. 배려가 없기 때문에 나의 중심적이고 서로 어울리지 못하는 코드가 된다. 뜻이 후대로 이어지지 않는 사회성이 떨어지는 코드가 된다는 말이다.
이와 다르게, 나는 형과 역할이 분배되고 서로의 짐을 덜어주고 싶은 마음에 코드를 작성했었던것 같다.
이때문에 "누군가가 내 코드 때문에 더 책임이 과중되지는 않을까?" "추후 확장에 있어 더 편한 방식을 간접적으로 기여할 수 있지 않을까" 한 마음 혹은 배려로 코드가 작성되었고 이 마음이 확장과 변화가 용이하도록 이어지지 않았는가 생각이 든다.
늘 느끼는 것은 타인을 생각하는 마음 즉, 인연의 중요함을 알고 기대에 부응하는 것이 더 좋은 코드를 나오게 하고 나를 움직이게하며 성장시키는 것 같다.
타인의 대한 메타인지는 개발자에게 있어 필수 덕목이라 깨달게 해준 나의 주변인들에게 다시금 감사함을 느끼는 계기였다.
'개발' 카테고리의 다른 글
[React] 음원, 녹음 동시 작업 실행 시, 녹음 품질 저하 문제 해결 및 고찰 (0) | 2024.09.23 |
---|---|
단위테스트 적응기 1편 (1) | 2024.07.16 |
Redis로 RT 마이그레이션 적용기 및 유닛테스트 (0) | 2024.03.14 |
[React] 검색 디바운싱(Debouncing) 적용 (0) | 2024.01.13 |
이화여대 외주 이미지 (0) | 2023.02.13 |
댓글