티스토리 뷰

반응형

저는 백엔드 서버를 만들기 위해 주로 Golang을 사용해서 프로타이핑을 해보고 실제 프로덕션에 적용여부를 결정하는데요, golang의 경우 아직까지는 자바의 스프링에 비해 대중성이 떨어지긴 하나, 메이저 서비스에서 조금씩 golang으로 변화되며 google에서 밀어주는 언어이다보니 단순히 프로토타이핑 뿐 아니라 프로덕션 레벨로서 입지를 갖춰가기 시작하고 있습니다. 저 역시 실제 서비스를 위해 백엔드로 golang으로 구현했는데요 물론 대규모 트래픽을 요하는 서비스는 아니기 때문에 프로덕션 레벨로 걱정되는 부분이 있더라도 적용해서 실제 서비스하는데 문제가 없다는 판단을 했습니다. 저는 아래와 같은 구조로 백엔드를 구축했습니다.

백엔드 개발을 위한 프레임워크

  • Firebase authentication
  • Echo

API 웹 서버를 만들기를 위해서 위 두가지 프레임워크를 사용했는데요, golang에서 가장 많이 쓰는 웹 서버를 위한 프레임워크가 바로 echo와 gin 입니다. echo의 경우 속도가 빠르다 어쩐다고 하나, 사실 저 두 프레임워크에서 제가 선택한 기준은 문서화 인데요. 아무래도 api를 구축할때 어떻게 셋업하냐에 대한 궁금증들은 문서가 잘 되어 있다면 해결 가능하기에 echo를 사용했습니다. (사실 속도도 더 빠르다고 하니 금상첨화지요). 다만, 이런 프래임워크를 사용할 때 항상 조심할 것은 굳이 이런 프레임워크를 써야하는지에 대한 고민입니다. 단순한 API 들만 빠르게 만들어 보려고 하면 굳이 저랑 덩치의 프레임워크를 사용할 필요는 없겠죠? 프레임워크는 항상 그 프레임워크가 제공하는 기능들을 이용해서 개발 생산성을 높이고 안정화된 서버를 구축하기 위함입니다. 저는 프로덕션 레벌의 서비스를 구현함에 있어서, API도 지속적으로 추가가 될 것인지라 기왕이면 이러한 프레임워크 하나를 선택해서 사용하는 것이 좋겠죠?

그리고 API 웹 서버를 만듬에 있어서 인증이라는 절차를 안할 수가 없는데요, 만들어진 웹서버가 오픈되어 아마나 마구 접속해서 트래픽을 발생시킨다면 비용도 비용이지만 해킹의 우려등 여러가지 문제가 발생할 수 있어 프로덕션 레벨로 퍼블릭하게 오픈할 때는 인증된 유저만 접속할 수 있게 하는 것이 있습니다. 인증관련해서는 다른 글에서 설명했으니, 한번 살펴보시고 저는 인증을 위해 Firebase를 이용하고 있습니다. SDK가 잘 되어 있어 쉽게 붙여 쓸수도 있고 어느 정도 사용까지는 무료기도 하구요. 3rd Provider 인증도 엮을 수 있으니 확정성도 있습니다. 그렇다면 echo 와 firebase가 딱이겠죠?

라우터 설정

API 웹 서버를 위해서 가장 기본적으로 Router를 설정하는 것입니다. 이 설정을 통해 거의 뼈대를 갖춘다고 보면 되는데요. 저는 간단한 API와 이를 인증된 유저만 접근할 수 있게 echo의 미들웨어단을 연결하도록 하겠습니다.

func Router() (*echo.Echo, error) {
	e := echo.New()
	e.Debug = true

	e.Use(middleware.Logger())
	e.Use(middleware.Recover())
	e.Use(md.Cors)

	h, err := handler.NewHandler()
	if err != nil {
		return nil, err
	}

	userGroup := e.Group("api/v1/user", md.Auth())
	{
		userGroup.GET("", wrapCustomContext(h.GetUser))
	}
	
	return e, nil
}

func wrapCustomContext(fn func(c *md.FireBaseCtx) error) echo.HandlerFunc {
	return func(ctx echo.Context) error {
		return fn(ctx.(*md.FireBaseCtx))
	}
}

자, 위 코드를 모면 Router에서 핸들러 인스턴스를 하나 생성하고 있고, api/v1/user에 이를 매핑 시키고 있죠? 여기서 하나 다른게 md.Auth() 란 것이 있고 wrapCustomContext가 핸들러를 감싸고 있습니다. 

package middleware

import (
	"context"
	"net/http"
	"strings"
	"os"
	"path/filepath"
	
	firebase "firebase.google.com/go"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
	"google.golang.org/api/option"
)

var Cors = middleware.CORSWithConfig(middleware.CORSConfig{
	AllowOrigins: []string{"*"},
	AllowMethods: []string{http.MethodGet, http.MethodPut, http.MethodPost, http.MethodDelete},
})

func Auth() echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			rcc := &FireBaseCtx {
				Context: c,
				Token: nil,
			}
			path, pathErr := filepath.Abs(os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"))
			if pathErr != nil {
				return c.JSON(http.StatusInternalServerError, pathErr.Error())
			}
			
			opt := option.WithCredentialsFile(path)
			app, err := firebase.NewApp(context.Background(), nil, opt)
			if err != nil {
				return c.JSON(http.StatusInternalServerError, err.Error())
			}

			auth, err := app.Auth(context.Background())
			if err != nil {
				return c.JSON(http.StatusInternalServerError, err.Error())
			}

			header := c.Request().Header.Get(echo.HeaderAuthorization)
			idToken := strings.TrimSpace(strings.Replace(header, "Bearer", "", 1))
			token, err := auth.VerifyIDToken(context.Background(), idToken)
			if err != nil {
				return c.JSON(http.StatusUnauthorized, err.Error())
			}

			rcc.Token = token
			rcc.Client = auth

			return next(rcc)
		}
	}
}

md는 middleware 패키지를 임포트할때 썻던 이름이니까 md.Auth는 바로 위에 있는 코드가 되겠습니다. Firebase로 부터 생성한 프로젝트의 Credential 값을 읽어서 firebase 프로젝트에 접근할 수 있도록 하고 request 값 중 header를 가져와서 idToken을 확인하는 절차를 진행하고 있습니다. 만약 이때 firebase를 통해 로그인한 유저가 아닌 경우 valid한 토큰을 헤더에 넣고 request를 할 수 없으니 에러가 나겠죠?

이렇게 firebase를 인증서버로 사용하면서 header에 인증 후 부여받은 idToken을 싣어서 request하고 header의 값을 서버에서 파싱 한뒤, echo의 미들웨어에서 firebase로 부터 확인 작업을 거치게 함으로서 간단하게 서버를 구현할 수 있었습니다. 만약 firebase를 사용하지 않았다면 자체적으로 token manager를 만드는 방법도 있는데요. 이 방법도 가능합니다만, 비즈니스 로직 구현 하는것에 더 집중하는 것이 좋겠죠? 물론 firebase 인증을 이용하다보면 claims를 줄 수 없어 authorization을 주기가 어려운데요. 이부분은 aws amplify를 사용하거나, firebase를 활용해서 custom id token을 로그인이 들어올때 서버자체에서 발급할 수 있게도 할 수 있습니다.

package main

import (
    "log"
    "context"
    "path/filepath"
    "encoding/json"
    "fmt"
    "net/http"
    "io/ioutil"
    "bytes"

    firebase "firebase.google.com/go"
    "firebase.google.com/go/auth"
    "google.golang.org/api/option"
)

const (
    verifyCustomTokenURL = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=%s"
)

var (
    apiKey = ""
)

func main() {
    
    ctx := context.Background()

    
    serviceAccountKeyFilePath, err := filepath.Abs("")
    if err != nil {
        panic("Unable to load serviceAccountKeys.json")
    }

    opt := option.WithCredentialsFile(serviceAccountKeyFilePath)

    app, err := firebase.NewApp(ctx, nil, opt)
    if err != nil {
        panic("Firebase load error")
    }

    //Firebase Auth
    auth, err := app.Auth(ctx)
    if err != nil {
        panic("Filebase load error")
    }

    signIn(ctx, auth)
    
}
func signIn(ctx context.Context, client *auth.Client) *auth.UserRecord {

    email := "test@gmail.com"
    u, err := client.GetUserByEmail(ctx, email)
    if err != nil {
        log.Fatalf("error getting user by email %s: %v\n", email, err)
    }
    log.Printf("user data: %v\n", u.ProviderUserInfo[0].UID)
    log.Printf("user data: %v\n", u.ProviderUserInfo[0].Email)
    log.Printf("%v\n", u.UserInfo.UID)

    token, err1 := client.CustomToken(ctx, u.UserInfo.UID)
    if err1 != nil {
        log.Fatalf("error minting custom token: %v\n", err1)
    }

    log.Printf("Got custom token: %v\n", token)

    idToken, err2 := SignInWithCustomToken(token)
    if err2 != nil {
        log.Printf("%v\n", err2)
    }

    log.Printf("\n")
    log.Printf("%v\n", idToken)
    token1, err3 := client.VerifyIDToken(ctx, idToken)
    if err3 != nil {
        log.Fatalf("error verifying ID token: %v\n", err3)
    }

    log.Printf("Verified ID token: %v\n", token1)

    return u
}

func SignInWithCustomToken(token string) (string, error) {
    req, err := json.Marshal(map[string]interface{}{
        "token":             token,
        "returnSecureToken": true,
    })
    if err != nil {
        return "", err
    }

    resp, err := postRequest(fmt.Sprintf(verifyCustomTokenURL, apiKey), req)
    if err != nil {
        return "", err
    }
    var respBody struct {
        IDToken string `json:"idToken"`
    }
    if err := json.Unmarshal(resp, &respBody); err != nil {
        return "", err
    }
    return respBody.IDToken, err
}

func postRequest(url string, req []byte) ([]byte, error) {
    resp, err := http.Post(url, "application/json", bytes.NewBuffer(req))
    if err != nil {
        return nil, err
    }

    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("unexpected http status code: %d", resp.StatusCode)
    }
    return ioutil.ReadAll(resp.Body)
}

우선은 참조로만 코드를 올려놓고, 빠르게 개발하기위해선 Claims도 기본적으로만 제공하는 email, uid만 사용하도록 하고 진행하도록 하겠습니다. 아주 쉽죠?

반응형
댓글