WorkDuo라는 프로젝트를 Spring -> Go-Echo로 마이그레이션 하던 중 Jwt를 구현하는 데 있어 궁금증이 들어 벤치테스트 및 구현했던 과정에 대해서 작성하고자 한다.
토큰 발생 배경
Http 프로토콜의 특성중 하나는 바로 Stateless 하다는 점이다. Http프로토콜이 이러한 방식을 채택한 이유는 편리함 때문이다.
각각의 요청이 독립적 으로 수행되다 보니 서버는 특정 클라이언트에 대해 상태를 유지할 필요가 없다.
이에 따라 서버는 많은 클라이언트와 동시에 통신할 수 있다.
위와 같은 특징들 덕분에 Http 프로토콜은 Stateless 방식을 채택하고 있는데 이러한 무상태성 때문에, 애플리케이션 특정 상황에서 상태를 유지하기 위해 쿠키, 세션, 토큰 등을 이용해 상태를 관리한다.
1. 대표적인 방법으로 쿠키-세션 방식이 널리 사용되어왔다.
쿠키
- 웹사이트 접속할 때 서버에 의해 사용자의 컴퓨터에 저장되는 정보를 의미한다.
세션
- 사용자와 서버 사이의 연결을 확인하기 위한 정보를 일정하게 유지시키는 기술이다.
아래와 같은 방식으로 세션 아이디 값을 응답값에 포함시켜 주면 브라우저는 해당 세션 아이디를 쿠키에 저장해 매 요청마다 보내게 된다.
Cookie에 대해 추가적으로 아래와 같은 특징들에 대해 알아보면 더욱 도움 된다.
물론 서버에서 Set-Cookie로 내려주고 , Cors Credential, Cookie SameSite 등등 설명하게 많지만 세션-쿠키에 대해 언급하지 않겠다.
이렇게 되면 위에 언급한 Http 프로토콜의 Stateless의 속성이 있지만 필요한 부분에 있어 세션-쿠키의 기술을 활용해 유저의 상태성을 유지할 수 있게 된다.
그렇다면 Keep-alive는 왜 있을까?
Http 1.1부터는 Http 1.0 과는 달리 keep-alive 가 디폴트로 header에 포한된다.
그러면 어? keep-alive 가 있으니깐 상태가 유지되는 게 아닌가?라는 의문이 들 수도 있는데
keep-alive의 목적에 대해서 이해하면 된다. 해당 keep-alive기술의 목적은 tcp 연결을 재사용하여 연결의 오버헤드를 줄이기 위해 등장한 것이기 때문에 Http 프로토콜의 특징인 각 요청은 독립적으로 간주되어 여전히 상태는 공유하지 않는다.
keep-alive는 매번 새로운 열결을 맺는 오베허드를 감소시키고자 하는 목적이지, 상태를 공유하기 위해 사용되는 기술이 아니다.
위의 세션 쿠키 만으로도 충분한 상태를 관리할 수 있다고 생각되는데 왜 토큰이라는 개념이 등장하게 된 걸까?
1. 쿠키-세션 방식의 단점이 있다. 바로 유저의 증가에 따른 디비 혹은 서버 리소스가 많이 사용된다.
- 각 요청에 대해 서로 다른 수많은 유저가 있다면 서버는 해당 유저들을 관리하기 위해 리소스를 사용해야 하며, 디비의 리소스 또한 소모가 된다.
위의 이유라고 치기엔 요즘처럼 대부분의 서비스에서 토큰을 사용할 이유가 없다.
토큰은 쿠키-세션 방식의 단점보다는 웹서비스 환경의 변화가 제일 크다.
2. RESTful API의 선택
- 대부분의 웹페이지 와 서버는 현재 RESTful API 방식을 채택한다.
다양한 포맷의 지원과 Http 프로토콜을 기반으로 하기 때문에 추가적인 구현이 필요가 없다.
가장 중요한 점은 URI 자원식별을 통해 API의 이해와 사용이 간단해지기 때문에 대부분의 웹페이지는 REST방식을 이용한다.
Rest 말고 무엇이 더 있을까?
GraphQL -> REST 방식의 단점 중 하나인 필요한 정보 이외 즉 OVER-REQUEST 가 되는 경우가 존재한다. 해당 문제를 해결하기 위해 SQL과 유사한 쿼리언어를 만들어 서버에 필요한 데이터에 필요한 만큼 요청하는 것을 목적으로 개발된 방식이다.
그래프라는 이름이 붙은 이유는 관계에 대해서 탐색이 가능하기 때문에 위와 같은 이름이 붙어있지 않을까 한다.
RPC -> 이전에 개발된 구성요소나 서드 파티 구성 요소를 사용하여 함수를 호출실행 할 수 있는 API를 의미한다.
REST의 URL을 메서드에 의해 표현하게 되는데 RPC는 메서드 호출을 통해 REST보다 복잡한 작업에 적합하다.
또한 RPC는 동기식 호출을 지향한다. REST의 비동기 방식이 적용되지 않는 경우에 해당 문제점들을 해결하기 위해 RPC가 등장하게 되었다.
3. 다중 플랫폼 및 디바이스 지원
- 쿠키-세션 방식은 브라우저에서만 유효한 방식이다. 현대 다양한 장비 스마트폰, 태블릿, IOT 등 다양한 접근방법이 생겨 쿠키-세션 방식보다는 보다 유연한 인증방식이 필요하게 되었다.
4. 분산 환경과 확장성
- 사용자가 늘어남에 따라, 서비스를 확장해 감에 따라 서버를 늘릴 수 있고 DB를 샤딩을 하던 스케일업을 하던 필연적으로 늘어나게 되어있다. 이렇게 되면 쿠키-세션 방식은? 1번에 언급한 것과 같이 DB를 늘리고 각 서버에서는 해당 DB의 유저인증을 위해 트랜잭션을 할당받아야 하고 복잡해지며, 리소스가 생각보다 많이 사용된다.
무어의 법칙에 따라 하드웨어의 발전은 점점 느려지고 있다. 소프트웨어 적으로 해결할 수 있으면 소프트웨어적으로 해결될 수 있는 부분인데 굳이...?
돈이 많고 프로젝트의 여유가 있다면 쿠키-세션 방식으로 엄청나게 좋은 서버에 좋은 디비를 써도 상관은 없다.
위의 4 가지 이유 덕에 토큰 방식이 조명받고 있다. 비즈니스를 운영함에 있어서 운영비용의 절감은 필연적인 게 아닌가?
서론이 엄청 길었는데 해당 자료들을 조사하며 토큰에 대해 조금 더 이해할 수 있지 않았나 싶다.
WORKDUO는 JWT 토큰을 활용해서 작성했다. JWT에 대해 정의를 해보자.
JsonWebToken 이란?
- 인터넷 표준 인증 방식 중 하나로 인증에 필요한 정보를 token에 담아 암호화시켜서 사용하는 토큰이다.
- 헤더 : 어떤 알고리즘 형태인지, 어떤 타입인지가 정보로 포함된다. ("HS256", "JWT")
- 페이로드 : 토큰에 담고 싶은 내용이 들어간다.
- 서명 : 개인키로 서명한 전자 서명이 들어있다.
이렇게 발급된 토큰을 서버는 어떻게 해결하는지 확인해 보자.
만약 HS256 알고리즘이라면
위에 보이는 그림처럼 디코딩을 과정을 거쳐 페이로드와 시크릿 문자열이 합쳐져서 해쉬 결괏값을 비교하는 형태가 된다.
만약 다른 알고리즘을 사용해 보면 어떨까 라는 궁금증이 생겼고 RSA, AES256, HS256을 각각 구현해서 벤치테스틀 수행해 봤다.
해쉬 구현(HS256))
func generateSymmetricWay(memberID, email string) (string, error) {
t := Token{
MemberID: memberID,
Email: email,
ExpiresAt: time.Now().Add(ExpiredTime),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &t)
return token.SignedString([]byte("This is Secret Key"))
}
가장 일반적으로 사용하는 HS256 방식이다.
어떠한 알고리즘을 사용할 것인지 정하고 뭐 별 특별할 것 없는 부분이다.
func parseSymmetricKWay(tokenStr string) (*Token, error) {
t := &Token{}
token, err := jwt.ParseWithClaims(tokenStr, t, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Header["alg"]; !ok {
return nil, fmt.Errorf("missing algorithm in token header")
}
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method : %v", token.Header["alg"])
}
return "This is Secret Key", nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Token); ok {
return claims, nil
} else {
return nil, fmt.Errorf("fail to type cast token")
}
}
대칭 구현(AES256)
func generateAESWay(memberID, email string) (string, error) {
t := Token{
MemberID: memberID,
Email: email,
ExpiresAt: time.Now().Add(ExpiredTime),
}
token, err := json.Marshal(&t)
if err != nil {
return "", err
}
cip, err := aes.NewCipher([]byte("This is must!@#$"))
if err != nil {
return "", err
}
length := (len(token) + aes.BlockSize) / aes.BlockSize
plain := make([]byte, length*aes.BlockSize)
copy(plain, token)
pad := byte(len(plain) - len(token))
for i := len(token); i < len(plain); i++ {
plain[i] = pad
}
encrypted := make([]byte, len(plain))
for bs, be := 0, cip.BlockSize(); bs <= len(token); bs, be = bs+cip.BlockSize(), be+cip.BlockSize() {
cip.Encrypt(encrypted[bs:be], plain[bs:be])
}
return hex.EncodeToString(encrypted), nil
}
지난번 포스팅 했던 것처럼 암호화를 진행 한 부분이다. 블록 사이즈에 맞게 남은 공간을 패딩 처리해 주는 부분 때문에 코드가 긴 것뿐이다.
func parseAESWay(token string) (*Token, error) {
encrypted, err := hex.DecodeString("This is must!@#$")
if err != nil {
return nil, err
}
cip, err := aes.NewCipher([]byte("This is must!@#$"))
if err != nil {
return nil, err
}
decrypted := make([]byte, len(encrypted))
for bs, be := 0, cip.BlockSize(); bs < len(encrypted); bs, be = bs+cip.BlockSize(), be+cip.BlockSize() {
cip.Decrypt(decrypted[bs:be], encrypted[bs:be])
}
trim := 0
if len(decrypted) > 0 {
trim = len(decrypted) - int(decrypted[len(decrypted)-1])
}
data := string(decrypted[:trim])
var parsed Token
if err := json.Unmarshal([]byte(data), &token); err != nil {
return nil, err
}
return &parsed, nil
}
비대칭 구현(Ecdsa)
func Generate(memberID, email string) (string, error) {
t := Token{
MemberID: memberID,
Email: email,
ExpiresAt: time.Now().Add(ExpiredTime),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, &t)
return token.SignedString(getPrivateKey())
}
func Parse(tokenStr string) (*Token, error) {
t := &Token{}
token, err := jwt.ParseWithClaims(tokenStr, t, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Header["alg"]; !ok {
return nil, fmt.Errorf("missing algorithm in token header")
}
if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
return nil, fmt.Errorf("unexpected signing method : %v", token.Header["alg"])
}
return privateKey.Public(), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Token); ok {
return claims, nil
} else {
return nil, fmt.Errorf("fail to type cast token")
}
}
ECDSA(Elliptic Curve Digital Signature Algorithm)를 이용해서 구현했다.
고언어는 아무래도 블록체인 쪽에 관련이 있다 보니 이러한 암호화에 있어 생각보다 지원을 많이 해주는데 그중 ECDSA라는 게 생소해서 사용했다. 별다른 이유는 없다.
보면 privateKey는 자체적으로 만든키이며 복호화할 때 위의 두구현 방식과는 상당히 다르다
비대칭 키 이기 때문에 priavteKey와 Public키를 각각 받게 되고 해당 키들을 이용해 토큰을 검증하게 된다.
- 비대칭 방식은 구현하다 보니 의문점이 든다.
만약 비대칭 구현 방식을 선택한다면 서버에서 공개키와 비밀키를 둘 다 관리하게 되면 Aes 즉 대칭키 구현 방식과 별반 다르지 않다.
만약 서버가 탈취되면? 둘 다 그냥 복호화 가능해진다. 이것은 비대칭 방식에 어울리지 않는 키보관 방법이라고 생각한다.
만약 정말로 보안이 중요해서 비대칭키 방식을 사용하게 된다면 모든 유저가 동일한 privateKey를 사용하는 게 아닌 유저 개별적으로 privateKey를 보관해야 하지 않을까 싶다.
벤치 테스트
키 생성 코드전문
func BenchmarkGenerateToken(b *testing.B) {
randomStr := func() string {
uuid, _ := uuid.NewUUID()
return uuid.String()
}
b.Run("Rsa 방법", func(b *testing.B) {
for i := 0; i < b.N; i++ {
Generate(randomStr(), randomStr())
}
})
b.Run("해쉬 방법", func(b *testing.B) {
for i := 0; i < b.N; i++ {
generateSymmetricWay(randomStr(), randomStr())
}
})
b.Run("대칭키 방법", func(b *testing.B) {
for i := 0; i < b.N; i++ {
generateAESWay(randomStr(), randomStr())
}
})
}
해석을 해보면 평균 1회 수행당 비대칭 방법은 20116 ns , 해쉬는 2013 ns, 대칭은 1129ns 만큼 걸린다. 중간에 있는 숫자는 돌아간 횟수이다.
ns 여서 실제 서비스에서는 그렇게 큰 차이가 없을 것으로 보이긴 하는데 생각보다 차이가 많이 나서 놀랐다.
비대칭 방법이 느리다 느리다 말 만들었지 실제로 벤치 돌려보니 10배, 20배가량 차이 나는 게....
유저의 수가 많다면 비대칭키에 대해서는 재고해 보는 것이 현명할 것 같다.
해시 방법이 대칭키 방법보다 느리다는 것이 생각보다 놀라웠다. 그냥 알고리즘에 넣어서 뚝딱 나오는 줄 알았는데 HS256의 알고리즘 내부적으로 무언갈 많이 수행하는 것 같다.
복호화도 위와 비슷한 결과를 보일 것 같은데 한번 복호화를 확인해 보자.
키 복호화 코드 전문
func BenchmarkParsingToken(b *testing.B) {
randomStr := func() string {
uuid, _ := uuid.NewUUID()
return uuid.String()
}
rsa, _ := Generate(randomStr(), randomStr())
symmetric, _ := generateSymmetricWay(randomStr(), randomStr())
aes, _ := generateAESWay(randomStr(), randomStr())
b.Run("비대칭 방법", func(b *testing.B) {
for i := 0; i < b.N; i++ {
Parse(rsa)
}
})
b.Run("해쉬 방법", func(b *testing.B) {
for i := 0; i < b.N; i++ {
parseSymmetricKWay(symmetric)
}
})
b.Run("대칭키 방법", func(b *testing.B) {
for i := 0; i < b.N; i++ {
parseAESWay(aes)
}
})
}
개별적으로 키를 생성해서 각가의 키들에 대해서 복화를 진행했다.
위에 보이는 것처럼 암호화 할 때의 순위와 동일하다
다만 흥미로운 점은 해쉬의 경우 해쉬 된 값을 각각 비교하기 때문에 암호화와 동일한 성능이 나온다는 점이다.
사실 비대칭, 대칭, 해쉬에 대해서 그렇게 크게 고민 없이 사용했던 것도 맞다.
위에 보이는 것처럼 단 1초 테스트에 이 정도 차이가 나는 것인데 만약 데이터의 암호화를 함에 있어 상황과 케이스에 맞게 적절한 방식을 채택해야 한다.
보안이 정말로 중요하지 않다면 웬만하면 비대칭 방식은 사용하지 않을 듯싶다. 성능이 생각보다 많이 좋지 않다.
물론 n/s이지만 만약 데이터의 크기가 커진다면? 단순 유저의 정보가 아니라면? 고민해 볼 만한 주제이다.
해쉬와 대칭키는 취향차이인 것 같다.
'사이드 프로젝트 > 워크듀오-개발일지' 카테고리의 다른 글
[리팩토링] 멀티모듈(1일차,그래들 설정) (2) | 2023.01.26 |
---|---|
멤버 일정 달력, 일정 목록 API (2) | 2022.09.30 |
멤버 피드 댓글 삭제 API (1) | 2022.09.28 |
멤버 피드 댓글 업데이트 API (0) | 2022.09.28 |
멤버 피드 댓글 조회 API (1) | 2022.09.28 |