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이지만 만약 데이터의 크기가 커진다면? 단순 유저의 정보가 아니라면? 고민해 볼 만한 주제이다.

해쉬와 대칭키는 취향차이인 것 같다.

고루틴과 OS의 관계가 궁금해 작성한 글입니다.

고 런타임에 대해 지난번 남긴 글을 읽고 온다면 보다 도움이 더욱 될 것이다.

 

[Concurrency in Go] 6장 고루틴과 Go 런타임

5장은 추후 정리해서 올리고자 한다. "동시성을 지원하는 언어의 장점" , OS 스레드 의 다중화를 위해 고 컴파일러는 "작업 가로채기" 전략을 사용한다 (work-strealing) 작업 가로채기 전략에 대해 알

guiwoo.tistory.com

 


1. 일반적인 OS-프로 스세 -스레드 와 CPU는 어떻게 동작하는가?

우선 용어를 간략하게 정의를 해보자 (wiki)

CPU : 컴퓨터 시스템을 통제하고 프로그램의 연산을 실행-처리하는 가장 핵심적인 컴퓨터의 제어 장치, 혹은 그 기능을 내장한 칩이다.

OS : 운영체제의 약자로 사용자의 하드웨어, 시스템 리소스를 제어하고 프로그램에 대한 일반적 서비스를 지원하는 시스템 소프트웨어이다. (윈도, 맥 OS X, 리눅스, BSD, 유닉스 등)

Process : 컴퓨터에서 연속적으로 실행되고 있는 컴퓨터 프로그램을 말한다. 종종 스케줄링의 대상이 되는 작업이라는 용어와 거의 같은 의미로 사용된다.

Thread : 프로세스에서 실행되는 흐름의 단위를 말하며 다시 말해 프로세스 내에서 실제로 작업을 수행하는 주체를 의미한다.

프로세스는 실행과 즉시 스레드가 생성되는데 이 최초의 스레드를 메인스레드라고 부른다. 
운영체제는 프로세스에 상관없이 생성된 스레드에 대해 프로세서 즉 CPU에 예약하는 형태의 구조를 가지고 있다.

 


2. 멀티스레드 n:m

고 루틴을 사용하는 목적이 무엇인가? 효율적으로 병렬성 및 동시성을 구현하기 위해서이다.

다시 말해 멀티스레드 프로그래밍을 더 편리하고 효율적으로 하기 위해서 사용된다.

그중 멀티스레드의 모델 중 하나인 n:m 모델에 대해서 알아보자.

고 루틴 동작방식과 상당히 유사하다.

커널영역 또한 멀티스레드로 동작한다.

장치관리, 메모리관리 또는 인터럽트 처리 등등 위의 그림처럼 유저 또한 스레드를 여러 개 생성할 수 있는데

유저스레드 와 커널스레드는 1:1, 1:n, n:m의 모델에 의해서 관리되는데 우리는 그중

 

n:m 모델에 대해서 알아보아야 한다.

커널 스레드와 동일한 숫자 혹은 그이 하의 사용자 스레드가 매칭되는 관계를 n:m 관계라고 한다.

이때 중간에 LWP라는 경량프로세스 가 존재해 하나의 커널스레드에 다량의 유저스레드를 해결할 수 있다. 

1:1로 커널 스레드와 유저스레드가 매칭되면 인터럽트, 블로킹이 발생되면 프로세스 자체가 컨택스트 스위칭이 되어버리고 만다. 
해당 문제를 해결하기 위해 하나의 커널스레드에 다량의 유저스레드를 이어 붙여 유저스레드 안에서 컨택스트 스위칭이 발생하게 만들어 os까지 올라가지 않도록 하기 위해 경량 프로세스라는 의미가 붙는다. 

 

이렇게 된다면 커널은 쉼 없이 유저 스레드와 맵핑되어 프로세스는 블로킹될 필요가 없이 사용되는 것이다. 
여러 유저스레드가 병렬성을 가지고 실행될 수 있다.

 

고 루틴도 유사하게 진행된다 다만 lwp는 os 커널에서 관리하고, 고 루틴은 고 런타임 스케줄러에 의해서 관리된다. 


여기서 고 루틴의 GMP 모델에 대해 알아보자.

 

G(Go-Routine) : 고 루틴을 의미한다.

M(Machine) : OS의 스레드 즉 커널스레드를 의미한다. Go에서는 최대 10k를 지원하지만 일반적으로 OS는 이런 많은 스레드를 지원하지 않는다.

P(Processor) : 고 루틴의 논리프로세스 또는 가상 프로세스 이해하면 된다. 해당 P의 숫자는 runtime의 GOMAXPROCS 설정으로 원하는 만큼 설정가능하다.

GMP 모델을 적용한 예이다.

하나의 P는 고 루틴을 실행할 수 있고 해당 루틴은 커널스레드에 할당되어 병렬적으로 실행될 수 있다.

여기서 GMP모델의 특징이 나오는데 G2의 경우 시스템호출이 발생되었다고 가정해 보자.

그렇게 되면 G2의 대기열 고 루틴인 G3이 해당 P를 가져가게되고 G2는 블로킹의 응답이 올떄가지 대기하게되는 고루틴 스위칭이 발생한다.

 

어떤 논리프로세스에 누가 스케줄되어 실행될지는 고 런타임 스케줄러가 결정한다.

LWP 경량 프로세스 
- OS에 의해서 관리되며 n:m의 모델로 적용되어 효율적으로 코어를 사용할 수 있다.

고 루틴
- 고 런타임 스케줄러에 의해서 관리되며 논리프로세스와 고 루틴의 n:m 관계가 적용되어 효율적으로 프로세스의 자원을 사용할 수 있다.

 

일반적인 프로그래밍 언어의 스레드는 G2 즉 블로킹이 발생된다면 유후 스레드를 하나 가져와서 G3를 실행하게 된다. 

다시 말해 스레드를 생성한다. 

그러나 고 루틴은 동일 스레드를 사용하고 논리프로세스 내에서 스위칭이 발생되기 때문에 스레드를 재사용하기 때문에 즉 블로킹이 최소화 가 되어 동시성 프로그래밍의 효율이 증가한다.


코드를 통해 고 루틴과 스레드의 관계에 대해서 알아보자.

파일을 시스템 호출로 읽어와서 작성하는 로직을 써보자.

func systemCall() {
	fmt.Println("system call ")
	file, _ := syscall.Open("./atask_user.log", syscall.O_RDONLY, uint32(0666))

	defer syscall.Close(file) // 파일 닫기 (defer를 사용하여 함수 종료 시 닫히도록 함)

	// 파일 읽기
	const bufferSize = 1024
	buf := make([]byte, bufferSize)
	for {
		// 파일에서 데이터 읽기
		n, err := syscall.Read(file, buf)
		if err != nil {
			fmt.Println("Error reading file:", err)
			break
		}

		// 더 이상 읽을 데이터가 없으면 종료
		if n == 0 {
			break
		}

		// 읽은 데이터 출력 또는 원하는 작업 수행
		fmt.Print(string(buf[:n]))
	}

	fmt.Println("system call done")
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	for i := 0; i < 1000; i++ {
		go systemCall()
	}

	go func() {
		defer wg.Done()
		http.ListenAndServe("localhost:4000", nil)
	}()

	wg.Wait()
}

 

보이는 것처럼 1000번 을 시스템 호출을 하게 된다.

시스템호출로 파일을 열고, 읽는 작업을 수행하는데  기본생성 스레드 7개를 제외하면 총 4개의 스레드만 생성해서 1000번의 시스템 호출을 해결했다는 의미이다. 
위에서 말한 GMP가 적용되어 스레드가 유휴상태가 되면 다른 고 루틴으로 변경해서 적용하는 모습을 확인할 수 있다.

 

 

만약 사용자와 인터페이스 하는 블로킹 작업이 계속된다고 가정하면 어떻게 될까?

func systemCall() {
	fmt.Println("system call ")
	buf := make([]byte, 1024)
	syscall.Read(0, buf)
	fmt.Println("system call done")
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	for i := 0; i < 1000; i++ {
		go systemCall()
	}

	go func() {
		defer wg.Done()
		http.ListenAndServe("localhost:4000", nil)
	}()

	wg.Wait()
}

 

syscall.Read로 사용자의 인풋을 받아오는 시스템콜이다.

해당 시스템콜을 호출을 하게 되면 고 루틴 이 총 1005개, 생성된 스레드가 1009개이다.
위의 경우는 I/O작업으로 인해 생긴 블로킹이고 스레드가 유휴상태에 빠지게 된다 그에 따라 고 루틴 하나가 통으로 떨어져 나간다는 사실을 확인할 수 있다.

 

여기서 사용자가 인풋을 넣게 된다면?

고 루틴은 회수되고 스레드는 회수되지 않는다.

고 루틴이야 당연히 고루틴의 함수의 끝까지 도달해 고루틴이 가비지컬렉터에 의해 수거된다.

그러나 스레드의 경우 Go런타임이 즉각적으로 반환하지 않고 스레드 풀을 유지하기 때문에 블로킹이 해소되더라도 스레드의 반환이 즉시 일어나지 않을 수 있다.


 

 

재사용되는 스레드에 따라 컨택스트 스위칭이 덜 발생하게 되고 이는 속도적인 측면에 즉각적으로 연결된다.

고 루틴은 경량스레드이다. 직접적인 스레드가 아닌 고런타임에서 논리프로세스가 관리해주는 스레드이다.

고 루틴은 GMP모델을 이용해 커널 스레드를 효율적으로 사용할수 있다. 

이 처럼 IO바운드 작업에 있어서 고루틴은 엄청난 효율을 자랑하고 있으니 고루틴 사용에 대해 생각해 보자.

 

참조

[1] https://20h.dev/post/golang/goroutine/

[2] https://d2.naver.com/helloworld/0814313 

[3] https://ykarma1996.tistory.com/188 

[4] https://www.ardanlabs.com/blog/2018/12/scheduling-in-go-part3.html

오랜만에 작성하는 개발일지이다.  9월은 지난번 개발한 투표/설문 조사의 성능개선을 했고, 10월은 새로운 프로젝트에 들어갔다.
9월에 마무리된 개발건이 배포가 밀려 10월까지 밀리면서 엄청 바쁜 10월 한 달을 보낸 것 같다. 

10월 시작하는 프로젝트는 완전 백지상태에서 시작하는 작업이다 보니 새로운 경험을 생각보다 많이 했는데 해당 부분에 대해 기록으로 남기고자 한다.


유저인증

유저의 인증 경우 토큰방식을 선택했는데, 이 토큰을 jwt가 아닌 암호화 알고리즘을 이용해서 작성하게 되었다.

지난번 작성한 포스팅이 이 암호화 및 해쉬에 대해 작성을 했다.

 

아무튼 로그인에 대해 암호화는 지난번 포스팅에 작성한 것처럼 작성하였다. 

 

토큰의 만료기한 또한 디비에서 관리하게 되면서 매번 만료기한을 업데이트시켜주어야 했다. 

미들웨어를 사용해서 해당 비즈니스 문제를 해결했다.

미들웨어란 요청,응답이 서비스 라우팅에 닿기 전 또는 닿은 후 응답을 각각 가로챌 수 있는데 가로채서 토큰의 유무를 검증하고 토큰의 파싱값을 컨택스트에 넣어주는 방법으로 작성했다.

 

여기서 컨택스트란 에코-프레임워크를 비롯해 고에서는 http 요청을 하나의 고 루틴으로 해결한다. 이렇게 고 루틴으로 할당되면 해당 고 루틴을 이용해 request와 response를 처리한다. 
이에 따라 각 레이어를 넘나들면서 context 를 들고 이동하게 되는데 이는 처음 루틴을 생성했을 때부터 데이터들을 가지고 레이어들을 넘나 든다. 그 외에도 context를 이용해 deadline, cancel 등 고 루틴의 핸들링을 쉽게 다루는 방법이 존재하니 검색해서 확인하자.

 

func Authorization(key,tokenURL string) echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.handlerFunc {
		return func(c echo.Context) error {
			headerToken := c.Requset().Header.Get("Authorization")
			if 특정 URL 을 가지고 있다면? {
				URL 요청 토큰연장
			}
			if tkn,err := 토큰 parse; err != nil {
				return http.StatusUnauthorized 요청
			}else{
				c.Set("token",tkn)
				return next(c)
			}
		}
	}
}

미들웨어를 반환해 주는 함수를 작성해 준다면 미들웨어의 적용이 가능하다.
작성된 미들웨어는 아래와 같이 적용가능하다.

func run() error {
	e := echo.New()
	e.Use(atask_middleware.Authorization("key","tokenURL"))
	routing(e)
	return e.Start("포트")	
}

이렇게 작성된 에코는 라우팅되어 있는 URL 함수가 실행되기 전 미들웨어를 먼저 타게 된다.


Request, Response dump

입출 하는 모든 Request, Response에 대해서 로그를 남기고자 했다.

위에서 언급한 미들웨어의 특성 중 Request Response 모든 시점에 캐치가 가능하다고 작성했다. 

해당 특성을 이용하면 로그에 대해 손쉽게 작성할 수 있다.

 

type dumpResponseWriter struct {
	io.Writer
	http.ResponseWriter
}

func (w *dumpResponseWriter) Write(b []byte) (int, error) {
	return w.Writer.Write(b)
}


func LoggerDump() echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			// request 요청
			reqBody := []byte{}
			if c.Request().Body != nil { // Read
				reqBody, _ = io.ReadAll(c.Request().Body)
			}
			c.Request().Body = io.NopCloser(bytes.NewBuffer(reqBody))
			

			// response 요청
			resBody := new(bytes.Buffer)
			mw := io.MultiWriter(c.Response().Writer, resBody)
			writer := &dumpResponseWriter{Writer: mw, ResponseWriter: c.Response().Writer}
			c.Response().Writer = writer

			if err := next(c); err != nil {
				return err
			}

			dump(c, reqBody, resBody.Bytes())
			return nil
		}
	}
}

 

Request부터 확인해 보면 바디를 읽어서 선언한 reqBody에 읽어온 데이터값을 할당하고, 
다시 c.Request(). Body에 값을 reqBody로부터 읽어온 값을 할당해 준다.

왜 이런 2번의 작업이 필요하냐면 c.Reqeust(). Body는 일회성 스트림이다.

다시 말해 한번 읽으면 다시 읽을 수 없다는 것을 의미하기 때문에 다시 할당해 주는 부분이 필요하다.

(그렇지 않다면 실제 라우팅의 함수에서 리퀘스트 바인딩에 어떠한 값도 가져올수없다...)

 

Response의 경우 response writer를 위에 작성한 커스텀타입의 Writer로 덮어주는 방법이다. 
multiWriter를 이용해 바이트 버퍼와, response 응답값 모두에 응답값을 작성하게 된다.

{"level":"info","time":"2023-10-31T14:53:26+09:00","caller":"hsad-poc/atask_common/atask_middleware/logger.go:76","message":"
	[REQUEST][METHOD:POST][URI:/user/api/v1.0/login][USER:] body : {"email":"","password":""}
	[RESPONSE][STATUS:200][Latency:42.674µs] body: {"code":200,"data":{"access_token":"","email":"","grade":"","member_id":,"username":""},"msg":"SUCCESS"}"}

결과적으로 위와 같은 로그라인을 작성했다.

 

문제발생

다음날 출근해 보니 총 4개의 프로세스가 떠있어야 했는데 특정 프로세스 한 개가 죽어있었다... 
로그를 확인해 보니 1개의 프로세스에서는 이미지 업로드가 포함되어 있어 멀티파트 폼 데이터를 사용하게 되는데... 
해당 이미지의 바이트 코드를 전부 로그에 작성해 버리는 것이 아닌가.... 

func parseMultiPartForm(c echo.Context) []byte {
	if err := c.Request().ParseMultipartForm(maxMemory); err != nil {
		log.Error().Err(err).Msgf("fail to parsing multi part form data")

	}
	m := make(map[string]interface{})
	for key, value := range c.Request().Form {
		if key != "file" {
			m[key] = value
		}
	}
	data, err := json.Marshal(m)
	if err != nil {
		log.Error().Err(err).Msgf("fail to marshal data %+v", data)
		data = []byte{}
	}
	return data
}

멀티파트를 포함한 요청인경우 분기를 나누고 위와 같은 방법을 적용했다. 
에코에서 parse를 지원해 줘서 다행이지.. 아니었으면 한 삽 펐을 것으로 보인다.
map을 이용해 file을 제외한 모든 경우를 담아 json으로 엔코딩 해서 바이트로  반환해 주었다.


zerolog

지난 프로젝트에서 logurs 라이브러리를 이용해 로그를 작성했다. 

하나 해당 로그라이브러리로 인해 tps측정에 문제가 되는 것을 확인하고, 이번 신규프로젝트에서는 zerolog를 적용하기로 했다.

 

middleware 쪽을 담당하고 있다 보니 로그도 자연스레 나의 파트가 되어버렸다.

 

목적 

1. 콘솔 로그 작성

2. 파일 로그 작성(일별로)

 

1차시도 Hook을 이용한 방법  (실패)

zerolog 홈페이지에 Hook에 대한설명이 짤막하게 남아있는데, 해당 hook을 이용하면 매번 로그가 호출되어 콘솔에 적힐 때마다.
파일에 남긴다 라는 생각을 했다.

type FileWriter struct {
	log  chan byte
	cur  time.Time
	file *os.File
}

func (f FileWriter) Run(e *zerolog.Event, level zerolog.Level, message string) {
	// 날짜 비교 해서 날짜가 지났으면 카피 후 새파일 생성  
	// 파일 열고 없으면 만들고
	// 파일 에 message를 작성한다.
}

이렇게 작성했더니 이게 웬걸 message 에는 내가 원하는 정보들이 담겨있지 않았다.... 
어디서 로그가호출 되었고, 타임라인이 어떤지 는 없고 단순 로그에 넘긴 메세지만 저 message 함수인자 값으로 넘어온다...

하루종일 zerolog hook에 대해서 찾아봤으나 원하는 정보를 찾기 힘들었다. 

 

2차시도 Multiwriter를 이용한 방법

zerolog document를 처음부터 정독했다. 거의 막바지에 Multiple Log Output이라는 섹션이 있는데 이를 이용하면 로그를 여러 방면으로 남길 수 있게 된다. 
제공하는 함수로는 MultiLevelWriter이고, io.Writer 인터페이스만 만족하면 된다. 

바로 인터페이스 구현체를 작성했다.

func New(path, fileName string, logLevel zerolog.Level) zerolog.Logger {
	console := zerolog.ConsoleWriter{Out: os.Stderr}
	multi := zerolog.MultiLevelWriter(console, NewFileWriter(path, fileName))
	zerolog.CallerMarshalFunc = func(pc uintptr, file string, line int) string {
		return file + ":" + strconv.Itoa(line)
	}
	return zerolog.New(multi).Level(logLevel).With().Timestamp().Caller().Logger()
}

콘솔은 콘솔라인에 남기기 위한 writer, NewFileWriter는 file에 남기기 위한 writer 가 되겠다.

CallerMarshalFunc 같은 경우는 로그의 호출이 무슨 파일, 몇 번째 줄에서 발생되어 어떤 형식으로 작성하겠는가? 를 설정해 주는 부분이다.

 

type FileWriter struct{
	log chan []byte
	file *os.File
	cur time.Time
	path string
}
func prettier(s string) []byte {
	// \문자제거, 라인분할
}
func (f *FileWriter) Write(p []byte) (n int,err error) {
	// log <- 채널로 메세지 전송
}
func (f *FileWriter) WriteLog(msg []byte) (int,error){
	// 인터페이스 구현함수
	return f.file.Write(msg)
}
func(f *FileWriter) IsRotate(now time.Time) bool {
	// 마지막 파일 수정일 기준으로 하루가 지났는가?
}
func(f *FileWriter) RotateFile(now time.Time)error{
	// 현재 파일의 이름을 변경
	// 현재 시간 변경
	// 현재 파일 새로운 파일로 변경
}

func NewFileWriter(path, name string) *FileWriter{
	// 파일 셋팅
	// 시간 세팅
	go func() {
		for v := range 로그채널 {
			//v 값을 writeLog 함수 호출
		}
	}
}

우선 타입을 살펴보면  log의 값을 받기 위해 채널을 설정했다. 버퍼링 채널을 위해 1024를 설정했다.

비동기 채널링을 설정한 이유는 로그의 중복된 값 혹은 로그의 씹힘을(동시성)문제를 방지하기 위해 채널링을 생성했다. 

아무래도 채널을 구성하게되면 큐의 자료형태로 들어가게 되고 1024의 로그만큼 계속 비동기적으로 로그를 작성하게 된다고 생각되어 채널을 선택했다.

지난번 고의 동시성 프로그래밍 책에서도 뮤텍스보다는 채널을이용해서 작성할 것을 권장했는데 이렇게 적용하게 될 줄을 몰랐다..

 

또한 파일의 OS 값을 받아오면 해당 파일의 마지막 업데이트 값을 받아올 수 있다.... 

해당 사실을 몰라 마지막 로그의 인덱스로부터 1024바이트씩 읽어 날짜 값을 가져와 파싱 해서 비교하는 로직을 작성했는데...

엄청난 삽질이 아닐 수가 없다. 

 

해당 로그의 인터페이스를 구현 하면서 지난번 읽었던 UltimateGo 에서 왜 인터페이스의 함수의 변수 사용에 대해 강조했는지 조금이나마 이해가 간다.

함수의 변수를 인터페이스로 받으니깐 다형성의 적용이 무궁무진하다...

다만 이런생각이 든다. 고였으니깐.. 인터페이스의 구현이 생각보다 쉬웠의깐

나만의 커스텀 함수를 작성해서 인터페이스의 구현체를 작성하지 않았을까? 라는 생각이 든다.

과연 스프링 부트였다면  이 인터페이스를 구현해서 커스텀하게 작성했을까? 라는 의문이 드는 부분이었다... 

검색을 통해 내가 구현하고자 하는 라이브러리에 대해 찾고 해당 라이브러리를 추가해서 사용하지 않을까 싶다...

 

3. 일별 로그 기록 -> 용량별 로그기록

Git Action, Kubernetes, istio 등의 DevOps를 팀장님이 설정하고 나니, 클라우드워치(aws 인프라를 사용한다.) 또한 적용하게 되는 것이 아닌가. 이러다 보니, 쿠버네티스의 각 파즈 별로 들어가서 로그를 확인할 일이 줄어들고, 이에 따라 로그의 작성을 일단위가 아닌 용량단위 그리고 백업은 대략 3~5개 파일로 구성하자는 의견이 팀회의를 통해 나왔고... 

 

해당 로직을 구성하기 위해 lumberjack 이라는 라이브러리를 사용했다. 

lumberjack 라이브러리는 로그를 기록하고자 하는 폴더의 로그 파일을 감시하고, 로그의 용량을 판단해 새로운 로그 파일을 작성할지 아닐지에 대해 구현해 놓은 간단한 라이브러리였다.

지난번 일별구현은 왜 커스텀하게 하고 이거는 라이브러리를 사용했는가에 대해 의문이 생길 수 있다. 

지난번 일별 로그기록을 할 때 당연히 해당 라이브러리의 기능을 확인했으나, 팀에서 추구하는 바와 달라 포기했던 기억이 있어 팀의 비즈니스 로직이 변경되자마자 lumberjack 라이브러리를 적용했다.

 


 

이렇게 미들웨어부터 msa의 공통 로그까지 작성해 봤다. 물론 api 핸들러를 작성한 것은 당연한 것이고...

처음 시작할 때 말했다 것처럼 이번 프로젝트의 초기 뼈대를 잡는데 정말 많은 시간을 할애했다.

 

내가 분명 UltimateGo, EffectiveGo에서 본 naming convention, package convention 과는 많이 달랐다 그러나 팀이 추구하는 방향에 따라 상당 부분 달라질 수 있다.

이러한 자율성이 고가 가진 힘이자, 위험한 부분이라고 생각한다.

 

프로젝트 시작과 동시에 이러한 컨벤션을 작성하지 않은 것이 참 후회된다.

사수님과 내가 작성한 코드를 보면 코드의 통일성이 없이 각 프로세스 별로 각자의 주관이 상당히 뚜렷하다.

DVT (Testing)가 성공적으로 종료되었지 만 개인적으로 이번 프로젝트는 10점 만점에 6점짜리 프로젝트인 것 같다.

 

지난번 투표/설문조사를 통해 가독성에 대해 상당히 많은 코드리뷰를 받았었다... 그렇기 때문에 ultimateGo, EffectiveGo, GoogleGO CodeConvention, BankSalad Go BLog, Buzvill 블로그 등 상당히 많은 블로그를 보며 고의 표준 레이아웃, 패키지 컨벤션 등 을 확인했지만, 오히려 해당 컨벤션을 지키면서 작성했던 것이 팀의 코드 히스토리상 어긋난다는 이유로 전부 변경 했어야 했다. 

항상 코드리뷰를 보면 서비스의 로직에 대한 부분보다 이러한 컨벤션에 대한 리뷰가 많았다...

내가 보고 배운 표준 컨벤션과는 다른 것을 적용하다 보니, 이러한 컨벤션에 대한 정의를 미리 내려야 하지 않을까 싶다.

 

이렇게 제공되는 자율성이 고가 가진 장점이자 단점이라고 생각한다.

 

이러한 부분을 수정하면서 느낀 것은 왜 코드컨벤션이 필요한가? 에 대한 명확한 이유를 납득했다.

'개발일지' 카테고리의 다른 글

FRP 적용  (0) 2024.12.31
7월개발~ 8월초  (0) 2023.08.16
6월 개발  (1) 2023.07.09
5월 개발  (0) 2023.06.11

새로운 프로젝트의 처음 기반부터 다지다 보니 설정 파일까지 새로 작성해야 했다.

기존에 사용했던 yaml 혹은 json parsing을 통한 방법이 아닌 새로운 envConfig 방식으로 작성하고자 한다.

 

Libraray : https://github.com/kelseyhightower/envconfig

 

GitHub - kelseyhightower/envconfig: Golang library for managing configuration data from environment variables

Golang library for managing configuration data from environment variables - GitHub - kelseyhightower/envconfig: Golang library for managing configuration data from environment variables

github.com

사실 해당 페이지에 너무 자세하게 설명되어 있어서 간단하게 사용하는 방법에 대해 작성했다.
main run을 실행할 때 아래와 같이 넣어주면 된다.

실제 서버에서는 쉘스크립트 하나 작성해서 적용하면 편리하게 사용가능하다.

# common-config
#!/bin/sh

export G_NAME=guiwoo
export G_EMAIL=guiwoo@naver.com
export G_KEY=key
export G_WORD=wordword==


# patch_main
#!/bin/sh

source ./common-config
./main > /dev/null 2>&1 &

 

// main.go
import (
	mCfg "env/config"
	"fmt"
	"github.com/kelseyhightower/envconfig"
)

var config CustomConfig

type CustomConfig struct {
	Name  string `envconfig:"NAME" required:"true"`
	Email string `envconfig:EMAIL required:"true"`
	mCfg.Secret
}

func init() {
	cfg := CustomConfig{}
	prefix := "G"
	if err := envconfig.Process(prefix, &cfg); err != nil {
		panic(err)
	}
	fmt.Printf("%+v", cfg)
}

func main() {
	fmt.Println("Config")
}

// env/config.go
type Secret struct {
	Key  string `envconfig:"KEY" required:"true"`
	Word string `envconfig:"WORD" required:"true"`
}

사실 포스팅하려고 했던 목적은 구조체 타입의 임베딩을 적용하는 경우 컨피그 항목을 읽어오지 못하는 부분이 있어 디코딩을 이용해서 작성하는 방법을 포스팅 하려고 했으나 다시 확인해 보니 컨피그의 키값을 올바르게 입력하지 못해 발생한 문제였다.. ㅠ

'Go > Go Basic' 카테고리의 다른 글

Parser 성능개선 (pprof)  (3) 2024.10.29
AES 암호화 적용 In Go  (1) 2023.10.10
Ultimate-Go-06 [에러처리]  (0) 2023.09.19
Ultimate-Go-04 [디커플링]  (0) 2023.09.13
Ultimate-Go-03  (2) 2023.09.07

이번에 들어가게 되는 신규 프로젝트에서 유저관리 부분을 담당하게 되었다. 

신규 프로젝트이다 보니 다양한 세팅에 대해서 신경 써야 했고, 그중 생소했던 암호화에 대해 글을 작성해보고자 한다.


우선 혼동했던 단어들에 대해서 정의를 내려보자.

 

1. Base64 인코딩

- 바이너리 데이터를 텍스트로 바꾸는 엔코딩을 의미한다. 64진법 ascii 문자열 들로만 이루어진 문자열로 변환하는 것

 

2. 암호화(Encryption) / 해시(Hashing)

- 암호화의 목적은 데이터의 기밀성을 보호하고, 비밀번호와 같은 개인 정보를 안전하게 저장 및 전송하기 위해 사용

- 암호화된 데이터는 암호화 알고리즘을 사용하여 암호문으로 변환한다. 변환된 암호문은 특정키를 필요로 하며, 키를 이용해 평문으로 복호화 가능하다.

 

- 해시의 목적은 데이터의 무결성을 확인하고, 데이터의 고유한 표현을 생성하기 위해 사용된다. (주로 비밀번호에 많이 사용됨)

- 해시는 해시함수를 이용하여 고정길이의 해시 값으로 변환된다. 이는 단방향 함수이며 원래 데이터로 역으로 복원 불가능하다.

 

3. Sha512, Aes256 뒤에 붙는 숫자와 차이

- Sha는 대표적인 해시함수이다. (Secure Hash Algorithm) 즉 64바이트, 32바이트 등 해시의 길이를 나타낸다. 당연히 숫자가 클수록 보안강도가 높으며 처리속도는 느리다.

- Aes는 대표적인 암호화 알고리즘이다. (Advanced Encryption Standard) 즉 암-복호화하는데 필요한 키의 길이를 나타낸다. 당연히 숫자가 클수록 보안강도가 높으며 처리속도는 느리다.

 

4. 고에서 제공되는 대표적인 암호화 알고리즘

- Aes "고급 암호화 표준" 암호화, 복호화에 동일한 키를 사용하는 대칭키 알고리즘을 의미한다.

- Des "데이터 암호화 표준" 암호화, 복화에 동일한 키를 사용하며 aes 암호화알고리즘 이전에 주로 사용되었다. (보안표준을 충족하지 못해 사용을 권장하지 않는다.)

- Rsa "비대칭 암호화" 위의 두 종류의 암호화 알고리즘과 달리 공개키, 비밀키를 이용해 암복호화를 하는 방법을 의미한다. 

ex) A 가 B에게 정보를 보낼 때 B의 공개키를 이용해 암호화를 해 보내고, B는 B의 비밀키를 이용해 복화가 가능하다.


AES 암호화 방법

 

func encryptAes(token string) (string, error) {
	cip, err := aes.NewCipher([]byte(sixteen))
	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
}

token 은 단순하게 json을 바이너리 데이터로 변경해서 넘긴 값을 의미한다. 

cip, err := aes.NewCipher([]byte(sixteen))

aes 암호화 알고리즘의 NewCiper는 ciper.Block 인터페이스를 반환한다.

해당 인터페이스는 암호화, 복호화 그리고 블록의 사이즈를 함수로 구현을 정의하고 있다.

왜? 대칭키 암호화 이니깐 암-복호화 함수가 필요하다.

length := (len(token) + aes.BlockSize) / aes.BlockSize
plain := make([]byte, length*aes.BlockSize)
copy(plain, token)
for i := len(token); i < len(plain); i++ {
    plain[i] = pad
}

블록의 사이즈 즉 sixteen이라는 블록의 사이즈 단위로 데이터 암호화를 진행한다.

따라서 해당 sixteen 블록 사이즈에 맞게 plain이라는 바이너리 데이터를 생성하고 남은 공간(패딩)을 채워준다.

	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])
	}

선정된 블록단위로 (결괏값, 입력값) cip.Encrypt에 넘겨주는데 이 Encrypt는 ciper.Block 인터페이스의 함수중 하나이다. 

 

이후 암호화된 바이트 슬라이스를 16진수 문자열로 반환한다.

 

암호화를 진행했으면 복호화도 작성해 보자.

 

Aes 복호화 방법

func decryptAes(hexStr string) (string, error) {
	encrypted, err := hex.DecodeString(hexStr)
	if err != nil {
		return "", err
	}
	cip, err := aes.NewCipher([]byte(sixteen))
	if err != nil {
		return "", 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])
	}

	return string(decrypted[:trim]), nil
}

16진수 인코딩 된 암호화 문자열을 바이트 슬라이스로 디코딩한다.

 

aes 알고리즘을 이용해 암호화를 진행했으니 복호화도 동일하게 aes의 사이퍼를 생성한다.

복호화 과정에서는 암호화 과정에서 진행한 패딩을 추가하는 작업이 필요치 않다.

이미 블록사이즈 단위로 패딩이 되어 있기 때문에 블록단위로 복호화를 진행하면 된다.

trim = len(decrypted) - int(decrypted[len(decrypted)-1])

패딩을 제거해 줄 때는 패딩의 시작점부터 패딩의 사이즈가 기록된다.

암호화 과정 중에 pad를 남는 모든 공간에 채워주는 부분이 있는데 그것을 이렇게 활용한다.


사용방법

 

type user struct {
	Id   string
	Tier string
	Win  int
	Lose int
}

func main() {
	u := user{"guiwoo", "gold", 10, 10}
	b, _ := json.Marshal(&u)

	rs, _ := encryptAes(string(b))
	fmt.Println("encrypted \n", rs)

	data, _ := decryptAes(rs)

	var found user
	json.Unmarshal([]byte(data), &found)

	fmt.Printf("user : %+v", found)
}

1. json 엔코더를 이용해 바이너리 데이터를 작성했다.

2. encryptAes를 이용해 암호화 진행 

 

결괏값

encrypted 
b56e6b2a5bb2142d57eafba52696e5ac53912f5dbcb5e9b30c23fa2764e11c65ab182f6572f22445e0a479f434be549b6a10f8e053a

 

3. decryptAes를 이용해 복호화 진행

결괏값

user : {Id:guiwoo Tier:gold Win:10 Lose:10}

 

대칭키 암호화를 선정한 이유로는 데이터의 기밀성을 유지와 안전한 전송이 암호화의 목적이었으며,

비대칭키 암호화는 전자서명 및 키교환과 같은 특정한 용도에 적합하여 대칭키 암호화를 채택하였다.

'Go > Go Basic' 카테고리의 다른 글

Parser 성능개선 (pprof)  (3) 2024.10.29
Env-Config (kelseyhightower/envconfig)  (2) 2023.10.16
Ultimate-Go-06 [에러처리]  (0) 2023.09.19
Ultimate-Go-04 [디커플링]  (0) 2023.09.13
Ultimate-Go-03  (2) 2023.09.07

대부분의 고 패키지 또는 라이브러리를 보게 되면 결괏값과 error 인터페이스를 주로 반환되는 것을 상당 부분 많이 확인할 수 있다.

실제 이러한 에러처리가 미숙해 한번 서버를 패닉으로 내려버린 적도 있다.

 

이에 대해 고의 에러처리 철학과 그 방법에 대해 알아보자.


기본에러 값

type error interface {
	Error() string
}

고에서 빌트인으로 제공하는 에러 인터페이스이다. 

해당 error 패키지 에서 New를 이용해 error 포인터 구조체를 반환한다.

(error는 unexport 타입인데 어떻게 패키지에서 import 가 가능할까?- 빌트인 함수이기에 가능)

반환된 구조체는 errorString error 인터페이스를 만족하는 구현체가 있고 Error는 단순하게 메시지를 반환한다.

이렇게 에러메세지는 디커플링 되어있다. 왜? 클라이언트 즉 error를 구현하고 받는 모든 의사소통은

인터페이스로 이루어주기 때문이다.

 

가장 기본적인 에러 핸들링방식

func TestError01(t *testing.T) {
	webCall := func() error {
		n := time.Now().Unix()
		if n%2 == 0 {
			return nil
		}
		return errors.New("지금은 홀수 입니다.")
	}

	if err := webCall(); err != nil {
		fmt.Println(err)
		panic(err)
	}
	fmt.Println("Life is Good")
}

함수를 호출하고 반환되는 함수를 처리한다.


에러변수

위처럼 인터페이스를 만족만 시킨다면? 정말 손쉽게 커스텀 에러작성이 가능해진다. 

gorm에 정의되어 있는 커스텀 에러를 확인해 보면 

var (
	// ErrInvalidTransaction invalid transaction when you are trying to `Commit` or `Rollback`
	ErrInvalidTransaction = errors.New("invalid transaction")
    --- 생략 ---
)

이런 식으로 errors 패키지에서 제공해 주는 생성자함수를 이용하면 손쉽게 에러생성이 가능하다.

 

func TestError02(t *testing.T){
    --- 생략 webCall() ---
    if err := webCall(); err != nil {
        switch err {
        case ErrBadRequest:
            fmt.Println("Bad Request")
            return
        case ErrBadPageMoved:
            fmt.Println("The Page Moved")
            return
        }
    }
}

 

타입 컨택스트

빌트인에서 제공되는 에러 인터페이스보다 더많은 컨택스트가 필요한 경우도 존재한다. 

예를 들어 네트워킹 문제는 복잡할 수 있어, 에러 변수 만으로는 부족하다. 이럴 때 사용자 정의 타입을 통해 문제를 해결할 수 있다.

 

json 패키지중 unmarshal error 가 그중 하나이다.

type InvalidUnmarshalError struct {
	Type reflect.Type
}

func (e *InvalidUnmarshalError) Error() string {
	if e.Type == nil {
		return "json: Unmarshal(nil)"
	}

	if e.Type.Kind() != reflect.Pointer {
		return "json: Unmarshal(non-pointer " + e.Type.String() + ")"
	}
	return "json: Unmarshal(nil " + e.Type.String() + ")"
}

json 값을 언마샬링 할때 포인터의 값이 들어오지 않을 경우 반환값으로 해당 구조체가 사용된다.

 

func TestError03(t *testing.T) {
	type user struct {
		Name string
	}

	var u user
	err := json.Unmarshal([]byte(`{"name":"bill"}`), u)
	switch e := err.(type) {
	case *json.InvalidUnmarshalError:
		fmt.Printf("Invalid input type %v\n", e.Type)
	default:
		fmt.Println(err)
	}

	fmt.Println("Name", u.Name)
}

 


기능을 통한  컨택스트 처리

위에서 제공된 방법은 타입을 꺼내와서 사용하기에 디커플링으로부터 멀어진다. 디커플리를 유지하면서 타입단언을 막는 방법에 대해 알아보자.

type CustomOneError struct {
	message string
}

func (c *CustomOneError) Error() string {
	return "one error" + c.message
}

type CustomTwoError struct {
	message string
}

func (c *CustomTwoError) Error() string {
	return "two error" + c.message
}

func TestError04(t *testing.T) {
	errChecker := func() error {
		n := time.Now().Unix()
		if n%2 == 0 {
			return &CustomOneError{"wrong"}
		} else {
			return &CustomTwoError{"something"}
		}
	}
	err := errChecker()
	switch e := err.(type) {
	case *CustomOneError:
		if e.IsNotInteger(){
			fmt.Println("Not Integer",e.message)
		}
	case *CustomTwoError:
		if e.IsNotInteger(){
			fmt.Println("Not Integer",e.message)
		}
	}
}

이렇게 숫자가 아닌경우의 에러를 핸들링하기 위해서  swtich-case로 위에서 제공되는 방법으로 사용하는 경우이다.

숫자가 들어오는 경우에 대해서만 신경을 쓴다고 가정한다면 해당 switch는 인터페이스로 포장할 수 있다.

func TestError04(t *testing.T) {
	--- 생략 ---
	switch e := err.(type) {
	case ErrNotInteger:
		fmt.Println("Not Integer Error ", e.IsNotInteger())
	default:
		fmt.Println("default ", err)
	}
}

에러포장

github.com/pkg/errors.Wrap()  해당 패키지를 이용하면 고에서 제공하는 일반 렙핑이 아닌 스택트레이스가 제공되는 에러를 반환할 수 있다.

func TestError05(t *testing.T) {
	thirdCall := func(i int) error {
		return errors.New("99")
	}
	secondCall := func(i int) error {
		if err := thirdCall(i); err != nil {
			return fmt.Errorf()
		}
		return nil
	}
	firstCall := func(i int) error {
		if err := secondCall(i); err != nil {
			return pkgErr.Wrap(err, "Second Call Error")
		}
		return nil
	}
	for i := 0; i < 5; i++ {
		if err := firstCall(i); err != nil {
			fmt.Println("stack trace")
			fmt.Printf("%+v\n", err)
		}
	}
}

//고 기본 패지키이용 방법
return fmt.Errorf("third call error %w", err)

기본 패키지를 이용해서 랩핑이 가능하지만, 스택트레이스 없이 에러로그를 확인하는것은 상단 한 피로감을 유발할 것으로 보인다.

이렇게 처리된 에러는 에러의 근원지를 확인할수 있는. Cause method를 제공한다.

func TestError05(t *testing.T) {
     --- 생략 --- 
	for i := 0; i < 1; i++ {
		if err := firstCall(i); err != nil {
			switch v:= pkgErr.Cause(err).(type){
			case *ErrCustomOne:
				fmt.Println("custom One error")
			}
			--- 생략 --- 
		}
	}
}

이 방법외에도 errors 기본패키지에서 as 메서드를 제공한다.

func TestError05(t *testing.T) {

	thirdCall := func(i int) error {
		return &CustomOneError{"hoit"}
	}
	secondCall := func(i int) error {
		if err := thirdCall(i); err != nil {
			return fmt.Errorf("third call error %w", err)
		}
		return nil
	}
	firstCall := func(i int) error {
		if err := secondCall(i); err != nil {
			return fmt.Errorf("second call error %w", err)
		}
		return nil
	}
	for i := 0; i < 1; i++ {
		if err := firstCall(i); err != nil {
			var v *CustomOneError
			switch {
			case errors.As(err, &v):
				fmt.Println("custom One error found")
			}

			fmt.Println("Stack Trace")
			fmt.Printf("%+v\n", err)
			fmt.Printf("Stack Done")
		}
	}
}

as를 사용하면 기본 빌트인 타입으로도 충분히 에러 발생 근원지에 대해 트랙킹 할 수 있다.

 

이렇게 다양한 error를 go 에서는 지원해주고 있다. 

왜? 명시적으로 err를 처리하기를 바라기 때문이다. 다시 말해 err 처리를 회피하지 않고 제대로 처리하길 바라는 마음에서 위와 같은 다양한 빌트인 함수를 제공해 주고, 또 계속 패키지를 발전시켜 나가는 것이 아닌가 싶다.


출처: https://github.com/hoanhan101/ultimate-go/tree/master/go/design

출처: https://ultimate-go-korean.github.io/translation/#%EC%97%90%EB%9F%AC-%ED%8F%AC%EC%9E%A5wrapping-errors

'Go > Go Basic' 카테고리의 다른 글

Env-Config (kelseyhightower/envconfig)  (2) 2023.10.16
AES 암호화 적용 In Go  (1) 2023.10.10
Ultimate-Go-04 [디커플링]  (0) 2023.09.13
Ultimate-Go-03  (2) 2023.09.07
Ulitmate-Go-02  (0) 2023.09.05

+ Recent posts