728x90
반응형

계기

  • 이전에 만들었던 프로젝트들을 리팩토링 중, 검증 방법이 헤더의 Authorization 으로 토큰 검증을 하게 해 뒀던 걸 발견했다.
  • 뭐 잘못된 방법은 아니지만, 쿠키를 활용해서 검증하는 방법으로 변경하였다.

개요

  • 로그인 요청을 받았을 때, 이메일/패스워드 정보를 검증 후 유저 정보를 Redis 서버에서 체크한다.
    • Redis 서버에 해당 정보가 조회될 경우, 새로운 환경에서 로그인 요청을 한 것으로 판단한다.
      • 이전 로그인은 종료시키기 위해, 해당 정보를 삭제한다.
    • 조회된 정보가 없다면 이전 로그인은 만료된 것이므로 무시한다.
  • 이후 새로운 accessToken과 refreshToken을 생성한다.
    • refreshToken을 상위 키로, 하위 키로는 유저 id 로 삼아 로그인 정보를 세팅한다.
      • 키: refresh:{refreshToken}:{userId}
  • 해당 키들을 응답 쿠키에 각각 도메인, 만료시간, 토큰, secure, httpOnly 를 세팅한다.
    • accessToken의 만료 시간은 한시간, refreshToken의 만료 시간은 일주일로 세팅한다.
  • 이후 로그인 및 유저 정보가 필요한 요청은 미들웨어에서 토큰 검증 및 데이터 추출하여 사용한다.
  • 만료시간이 끝난 경우, 토큰 갱신 요청을 날려서 새로운 access token을 발급 받는다.

코드 구현

  • 백엔드는 golang, 프론트는 nextjs app router를 사용하였다.

백엔드

  • 주요 로직은 주석처리, 토큰 세팅 및 쿠키 세팅하는 로직만 유지
// 컨트롤러가 아닌, 비즈니스 로직을 담은 서비스
func LoginService(request LoginRequest, response http.ResponseWriter, request *http.Request) {    
    /* 
        DB 에서 유저 정보 조회 및 패스워드 매칭
    */

    // Access Token 생성
    token, tokenErr := auth.CreateAccessToken(userData.UserId, uuid.String(), userData.Email, fmt.Sprintf("%d", userData.UserStatus), fmt.Sprintf("%d", userData.IsAdmin), time.Hour)

    if tokenErr != nil {
        log.Printf("[LOGIN] Create Token Error: %v", tokenErr)

        return LoginResponse{
            Status:  http.StatusInternalServerError,
            Code:    "ULG09",
            Message: "Create JWT Error",
        }
    }

    // Refresh Token 생성
    refreshToken, refreshTokenErr := auth.CreateRefreshToken("",, time.Hour*24*7)

    if refreshTokenErr != nil {
        log.Printf("[LOGIN] Create Refresh Token Error: %v", refreshTokenErr)

        return LoginResponse{
            Status:  http.StatusInternalServerError,
            Code:    "ULG09",
            Message: "Create JWT Error",
        }
    }

    // Redis 서버에서 유저 정보 조회 - 이전 로그인 만료 여부 체크
    userDataFromRedis, getRedisErr := GetRefreshUserInfoFromRedis(refreshToken, userData.UserId)

    if getRedisErr != nil {
        return LoginResponse{
            Status:  http.StatusNotFound,
            Code:    "ULG08",
            Message: "Get User Data from redis Error",
        }
    }

    // 유저 정보가 조회되었을 시
    if (userDataFromRedis != LoginUserInfo{}) {
        // 조회된 Redis 정보 삭제
        delerr := DeleteAlreadySetToken(refreshToken, userData.UserId)

        if delerr != nil {
            return LoginResponse{
                Status:  http.StatusInternalServerError,
                Code:    "ULG08",
                Message: "Delete Already Set Data from redis Error",
            }
        }
    }

    // Redis 서버에 새로운 로그인 정보 세팅
    setErr := SetRefreshToken(refreshToken, userData.Email, fmt.Sprintf("%d", userData.UserStatus), userData.UserId, userData.UserName, fmt.Sprintf("%d", userData.IsAdmin))

    if setErr != nil {
        return LoginResponse{
            Status:  http.StatusInternalServerError,
            Code:    "ULG10",
            Message: "Set JWT Error",
        }
    }

    accessTokenCookie := http.Cookie{
        Name:     "accessToken",
        Value:    token,
        Path:     "/",
        Secure:   true,
        HttpOnly: true,
        SameSite: http.SameSiteNoneMode,
    }

    refreshTokenCookie := http.Cookie{
        Name:     "refreshToken",
        Value:    refreshToken,
        Path:     "/",
        Secure:   true,
        HttpOnly: true,
        SameSite: http.SameSiteNoneMode,
    }

    http.SetCookie(response, &accessTokenCookie)
    http.SetCookie(response, &refreshTokenCookie)

    return LoginResponse{
        Status:       http.StatusOK,
        Code:         "0000",
        AccessToken:  token,
        RefreshToken: refreshToken,
        Message:      "Login Success",
    }

프론트

  • App Router의 Server Side Component를 이용해서 쿠키 및 토큰을 관리한다.
    • 로그인 응답으로 토큰을 받고 서버 사이드 쿠키에 세팅한다.
export async function POST(request: NextRequest) {
  // const cookieStore = cookies();

  // 로그인 데이터 확인 - 요청 조건 검증
  const loginSchema = z.object({
    email: z.string().email(),
    password: z.string().min(6).max(16),
  });

  try {
    const _loginData = await request.json();
    const loginData = await loginSchema.parseAsync(_loginData);

    const loginResponse = await login({ ...loginData });

    if (!loginResponse.ok) {
      return NextResponse.json({ message: 'unauthorized' }, { status: 401 });
    }
});

    const response = <LoginResponse>(await loginResponse.json());

    if (response.status !== 200) {
      return NextResponse.json({ message: 'Login Failed' }, { status: 302 });
    }

    // 브라우저에 리턴할 쿠키 설정
    const ONE_HOUR = 60 * 60 * 1000;
    const ONE_WEEK = 60 * 60 * 1000 * 24 * 7;

    const res = NextResponse.json({ message: 'Login Success' }, { status: 200 });

    // 쿠키 설정
    res.cookies.set('accessToken', response.accessToken, {
      secure: true, // 프로덕션 환경에서는 true로 설정
      httpOnly: true,
      domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
      expires: new Date(Date.now() + ONE_HOUR),
    });

    res.cookies.set('refreshToken', response.refreshToken, {
      secure: true,
      httpOnly: true,
      domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
      expires: new Date(Date.now() + ONE_WEEK),
    });

    return res;
  } catch (error) {
    console.log(error);
    return NextResponse.json({ message: 'server error' }, { status: 500 });
  }
}
  • 서버 사이드 컴포넌트. 받은 쿠키들을 이용해 이후 요청을 한다.
    • 만료시 토큰 리프레시를 한다.

export async function POST(request: NextRequest) {
    try {
        let tokenCookie = request.cookies.get("accessToken");
        let refreshToken = request.cookies.get("refreshToken");

        let cookie: string;
        let refreshCookie: string;

        if (!tokenCookie) {
            /** 
                refresh token 갱신 요청 로직
            */

            const ONE_HOUR = 60 * 60 * 1000;
            const ONE_WEEK = 60 * 60 * 1000 * 24 * 7;

            cookieStore.set('accessToken', response.accessToken, {
                secure: true,
                httpOnly: true,
                path:"/",
              //   domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
                expires: new Date(Date.now() + ONE_HOUR),
              });

              cookieStore.set('refreshToken', response.refreshToken, {
                secure: true,
                httpOnly: true,
                path:"/",
              //   domain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN,
                expires: new Date(Date.now() + ONE_WEEK),
              });


            cookie = response.accessToken;
            refreshCookie = response.refreshToken;
        } else {
            cookie = tokenCookie.value;
            refreshCookie = refreshToken.value;

            // 기술통계 요청 데이터 확인
            const exampleScheme = z.object({
                // 요청 검증 필드
            });

            const _exampleData = await request.json();
            const exampleData = await exampleScheme.parseAsync(_exampleData);

            const exampleResponse = await example({ ...exampleData }, cookie, refreshCookie);

            if (!exampleResponse.ok) {
                return NextResponse.json({ message: 'unauthorized' }, { status: 401 });
            }

            const dataResponse = <ExampleResponse>await exampleResponse.json();

            const response = NextResponse.json(dataResponse);

            return response;
        }
    } catch (error) {
        console.log(error);
        return NextResponse.json({ message: 'server error' }, { status: 500 });

    }
}
  • Middleware를 적용하여, 쿠키가 없을 시 로그인 페이지로 자동 리디렉션 시킨다.
import { NextFetchEvent, NextMiddleware, NextRequest, NextResponse } from "next/server";

// 인증 없이 방문 불가 페이지
const PROTECTED_ROUTE: string[] = [
  // 페이지 리스트들
];

export type MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware;

export const authMiddleware: MiddlewareFactory = (next: NextMiddleware) => {
  return async (request: NextRequest, event: NextFetchEvent) => {
    // URL 에서 언어 값 분리
    const [ , , ...segments ] = request.nextUrl.pathname.split('/');

    const cookies = request.cookies.get("accessToken");

    // 체크사항
    // 1. 로그인 유무
    // 2. 인증 없이 방문 불가 페이지
    const isProtecteRoute = PROTECTED_ROUTE.includes(request.nextUrl.pathname);

    if (!cookies && isProtecteRoute) {
      return NextResponse.redirect(new URL('/login', request.url));
    }

    if (cookies && isProtecteRoute) {
      if (cookies.value === "") {
        return NextResponse.redirect(new URL('/login', request.url));
      }
    }
    // 인증 성공, 통과
    return next(request, event);
  };
};
728x90
반응형

'기록 > 사이드 프로젝트' 카테고리의 다른 글

[NEXTJS] 사이드 컴포넌트 쿠키 처리  (0) 2025.01.22
[OAUTH] 구글 OAuth  (0) 2025.01.02
728x90
반응형

개요

  • 로그인 시 발급되는 access token을 쿠키에 세팅
  • 미들웨어에서 쿠키의 access token 을 검증

Cookie 세팅

  • 생성된 토큰을 쿠키에 세팅한다
func Login(res http.ResponseWriter, req *http.Request) {

    /*
        로그인 로직
    */

    accessTokenCookie := http.Cookie{
        Name: "accessToken",
        Value: accessToken,
        Path: "/",
        Secure: true, // HTTPS 만
        HttpOnly: true, // 브라우저에서 쿠키 조작 불가하게 세팅
        SameSite: http.SameSiteNoneMode,
    }

    refreshTokenCookie := http.Cookie{
        Name: "refreshToken",
        Value: refreshToken,
        Path: "/",
        Secure: true, // HTTPS 만
        HttpOnly: true, // 브라우저에서 쿠키 조작 불가하게 세팅
        SameSite: http.SameSiteNoneMode,
    }

    http.SetCookie(res, &accessTokenCookie)
    http.SetCookie(res, &refreshTokenCookie)

    /*
        응답 보내기
    */
}

검증 미들웨어

  • 쿠키에서 토큰을 추출해서 컨텍스트에 담는다
  • 담은 컨텍스트의 정보를 컨트롤러에서 사용한다.
// 사용자 정의 키 타입을 사용하여 컨텍스트 충돌 방지

type contextKey string

const (
    // JWT 서명에 사용할 비밀 키 (환경 변수로 관리하는 것이 좋습니다)

    // jwtSecret = configs.GlobalConfig.JwtKey // 실제 배포 시 환경 변수로 관리

    // 컨텍스트에 사용자 정보를 저장할 키
    userContextKey = contextKey("user")
)

// 사용자 정보 구조체

type User struct {
    UserId string
    UserEmail string
    UserStatus string
}

var excludeRouteList = []string{
    "/", "/api", 
    "/user/signup", "/user/login",
}

// AuthMiddleware는 accessToken 쿠키를 추출하고 JWT를 검증하는 미들웨어입니다.
func AuthMiddleware(next http.Handler) http.Handler {

    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

        // 제외할 경로는 바로 다음 핸들러로 넘김
        for _, route := range excludeRouteList {
            if r.URL.Path == route {
                log.Printf("Found Match Route: %s", route)
                next.ServeHTTP(w, r)
                return
            }
        }

        // accessToken 쿠키 추출
        cookie, err := r.Cookie("accessToken")

        log.Printf("Cookie: %s", cookie)

        if err != nil {
            log.Printf("Get Cookie Error :%v", err)  

            if err == http.ErrNoCookie {
                response.Response(w, response.CommonResponseWithMessage{
                    Status: http.StatusUnauthorized,
                    Code: "AUTH001",
                    Message: "No access token provided",    
                })

                return
            }

            // 다른 쿠키 에러 처리

            response.Response(w, response.CommonResponseWithMessage{
                Status: http.StatusUnauthorized,
                Code: "AUTH002",
                Message: "Invalid cookie format",
            })

            return
        }

        accessToken := cookie.Value

        userId, userEmail, userStatus, validateErr := auth.ValidateJwtTokenFromString(accessToken)


        if validateErr != nil {
            log.Printf("ValidateErr Cookie Error :%v", validateErr)

            // 토큰 만료에 대한 응답
            if strings.Contains(validateErr.Error(), "token expired") {
                response.Response(w, response.CommonResponseWithMessage{
                    Status: http.StatusUnauthorized,
                    Code: "AUTH003",
                    Message: "Token expired",
                })

                return
            }

            // 일반적인 JWT 검증 실패 응답
            response.Response(w, response.CommonResponseWithMessage{
                Status: http.StatusUnauthorized,
                Code: "AUTH004",
                Message: "Invalid token",
            })

            return        
        }

        // 사용자 정보를 구조체로 생성

        user := User{
            UserId: userId,
            UserEmail: userEmail,
            UserStatus: userStatus,
        }

        // 사용자 정보를 컨텍스트에 추가
        ctx := context.WithValue(r.Context(), userContextKey, user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}



// 사용자 정보를 핸들러 및 컨트롤러에서 가져오는 헬퍼 함수
func GetUserFromContext(ctx context.Context) (User, bool) {
    user, ok := ctx.Value(userContextKey).(User)
    return user, ok
}

컨트롤러에서 추출된 정보들 사용


func SampleController(res http.ResponseWriter, req *http.Request) {
    user, ok := middlewares.GetUserFromContext(req.Context())

    if !ok {    
        dto.SetErrorResponse(res, 401, "01", "JWT Verifying Error", nil)

        return
    }
    /*
        이후 로직
    */

    dto.SetResponse(res, 200, "01")
}
728x90
반응형
728x90
반응형

기존

  • 기존에는 직접 CORS 체크 미들웨어를 작성해 사용했다.
func CorsMiddlewares(next http.Handler) http.Handler {
    return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {

        origin := req.Header.Get("Origin")

        if origin == "" {
            origin = "unknown"
        }

        for _, o := range originList {
            if o == origin {    
                log.Printf("Allowed Origin: %s", origin)
                res.Header().Set("Access-Control-Allow-Origin", origin)
                res.Header().Set("Access-Control-Max-Age", "86400")
                res.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
                res.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
                res.Header().Set("Access-Control-Allow-Credentials", "true")
                break
            }
        }

        // Handle preflight request
        if req.Method == http.MethodOptions {
            res.WriteHeader(http.StatusOK)
            return
        }

        next.ServeHTTP(res, req)
    })
}

변경

  • 그러나 조금 더 심플하고 편하게 설정할 수 있는 패키지가 있어 가져와 사용했다.
get -u github.com/rs/cors
  • 이후 세팅은 이렇게 된다.
func CorsHanlder() *cors.Cors {
    corHandler := cors.New(cors.Options{
        AllowedOrigins: originList,
        AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions},
        AllowedHeaders: []string{"Origin", "Accept", "Content-Type", "Authorization"},
        AllowCredentials: true,
        MaxAge: 86400,        
        Debug: false,
    })

    return corHandler
}

미들웨어 등록하기

  • 현재 표준 라이브러리인 net/http 에서 gorilla/mux 로 변경한 상태이다.
func OpenServer() *http.Server {
    router := mux.NewRouter()

    routers.DefaultRouter(router)

    routers.UploadImageController(router)

    routers.AdminUserRouter(router)

    routers.AdminPostRouter(router)

    routers.UserRouter(router)

    routers.PostRouter(router)

    router.Use(middlewares.AuthMiddleware)

    // CORS 핸들러
    handler := middlewares.CorsHanlder().Handler(router)

    // 기존 방식  
    // handler := middlewares.CorsMiddlewares(router)

    serving := &http.Server{
        Handler: handler,
        Addr: configs.GlobalConfig.AppHost,
        WriteTimeout: 30 * time.Second,
        ReadTimeout: 30 * time.Second,
    }

    return serving
}
728x90
반응형
728x90
반응형

Nullable 데이터

  • DB의 필드 중에 NULL 값이 들어갈 수 있는 값들이 있다. 이 값들을 쿼리해서 코드단에서 처리할 때 어떻게 해야 할까?
  • 다른 언어들은 그냥 쿼리해서 사용하면 null 값으로 처리되던데...

Struct 정의

  • 우선 쿼리된 결과를 원하는 값들에 할당해준다. 아래는 기존에 사용하던 코드
type SampleQueryItem struct {
  UserId string `json:"userId"`
  UserName string `json:"userName"`
  UserEmail string `json:"userEmail"`
}
  • 이렇게 하면 받아오는 데이터 세개 전부 값이 있다고 가능하는 셈이다.
    • 이렇게 된다면 null 값들은 처리할 수 없다.

포인터 사용하기

type SampleQueryItem struct {
  UserId string `json:"userId"`
  UserName string `json:"userName"`
  UserEmail *string `json:"userEmail"` // 포인터 사용
}
  • 이렇게 처리하면 userEmail 값은 null 로 받아올 수 있다.

번외; 단건 조회 데이터가 없을 때

  • 한 건 조회할 때, 쿼리 데이터가 없다면?
  • golang은 심플함을 강조하기 때문에, 이러한 상황들을 직접 핸들링 해 줘야 한다.

기존 코드

var dataItem SampleQueryItem

if scanErr := queryResult.Scan(&dataItem.UserId, &dataItem.UserName, &dataItem.UserEmail);
    scanErr != nil {
        log.Printf("Scan Error: %v", scanErr)
        return scanErr
    }
  • 이렇게 해 주면 scan 에러가 발생한다. 그 이유는 쿼리된 데이터가 없다는 것

수정된 코드

var dataItem SampleQueryItem

if scanErr := queryResult.Scan(&dataItem.UserId, &dataItem.UserName, &dataItem.UserEmail);
    scanErr != nil {
        // 데이터가 없는 에러는 error 에러를 리턴하지 않게 해서 에러로 빠지지 않게 한다.
        if scanErr == sql.NoRowError {
            return SampleQueryItem{}, nil
        }

        log.Printf("Scan Error: %v", scanErr)
        return SampleQueryItem{}, scanErr
    }
  • 조회된 데이터가 없는 것은 에러로 처리된다. scan 할 데이터가 없기 때문
    • 위 처럼 쿼리 후 에러 핸들링을 하면, 에러 처리로 넘기지 않고 빈 데이터를 넘길 수 있다.
728x90
반응형
728x90
반응형

공식 문서

Golang으로 연결

  • neo4j 드라이버 설치
    • 연결의 인증은 여러 방식이 있음; BasicAuth, BearerAuth, AuthToken, CustomAuth, ...
    • 본 예시는 유저 이름과 패스워드로 인증하는 방식
go get -u github.com/neo4j/neo4j-go-driver/v5/neo4j
  • 드라이버 인스턴스 생성
import (
    "github.com/neo4j/neo4j-go-driver/v5/neo4j"
    "github.com/neo4j/neo4j-go-driver/v5/neo4j/db"
)

func main() {
    driver, err := neo4j.NewDriverWithContext(
        "neoj4://<DATABASE_URI>:<DATABASE_PORT>,
        neo4j.BasicAuth(<USER>, <PASSWD>, ""),
    )
}
  • 연결 체크
import (
    "context"

    "github.com/neo4j/neo4j-go-driver/v5/neo4j"
    "github.com/neo4j/neo4j-go-driver/v5/neo4j/db"
)

func main() {
    driver, err := neo4j.NewDriverWithContext(
        "neoj4://<DATABASE_URI>:<DATABASE_PORT>,
        neo4j.BasicAuth(<USER>, <PASSWD>, ""),
    )

    ctx := context.Background()

    err := driver.VerifyConnectivity(ctx)

    if err != nil {
        log.Printf("[GRAPH_DB] Check Connection Error: %v", err)
        return err
    }

    defer driver.Close(ctx)

    return nil
}

Golang으로 쿼리

  • 쿼리 실행
    • ExecuteQuery 메서드로 쿼리 실행
import (
    "context"

    "github.com/neo4j/neo4j-go-driver/v5/neo4j"
    "github.com/neo4j/neo4j-go-driver/v5/neo4j/db"
)

func main() {
    driver, err := neo4j.NewDriverWithContext(
        "neoj4://<DATABASE_URI>:<DATABASE_PORT>,
        neo4j.BasicAuth("<USER>", "<PASSWD>", ""),
    )

    ctx := context.Background()

    // 쿼리 실행
    result, queryErr := neo4j.ExecuteQuery(
        ctx, 
        driver, 
        "MERGE (n:Node {name:"$nodeName"})", // Node라는 유니크 노드 생성
        []map[string]interface{ // 파라미터 생성. nodeName에 들어갈 값
            nodeName: "New Node"
        }, 
        neo4j.EagerResultTransformer, // 쿼리 결과 변환
        neo4j.ExecuteQueryWithDatabase("neo4j"),
    )

    if queryErr != nil {
        return nil, queryErr
    }

    defer db.Close(ctx)

    for _, record := range result.Records {
        log.Printf("[GRAPH_DB] Query Result: %v", record.Values...)
    }

    // 쿼리 결과에 대한 요약
    log.Printf("The query `%v` returned %v records in %+v.\n",
        result.Summary.Query().Text(), len(result.Records),
        result.Summary.ResultAvailableAfter())
}
728x90
반응형

'백엔드 Backend > Golang' 카테고리의 다른 글

[gRPC] Go 언어로 gRPC 사용해보기  (2) 2024.12.24
[Neo4J] 데이터 핸들링하기  (1) 2024.12.09
[Neo4j] Golang 으로 쿼리 핸들링  (0) 2024.12.06
[GO] Ubuntu 서버에 설치  (1) 2024.10.30
[GO] Goroutine이란?  (0) 2024.10.02
728x90
반응형
  • 본 내용은 과거 번역 파일 정리 자동화 프로그램 작업에 대한 업무 일지이다.

Properties 파일 구조

  • properties 파일: <단어> = <번역된 단어> 형태로 관리됨
  • 기능에 따라 폴더가 구분되어있음. 이 폴더명을 back/<폴더명> 명으로 사용
    • 파일 위치 경로: /assets/backend/
  • ko.properties, en.properties로 파일이 나뉘어져 있다.
  • 두 파일은 같은 key를 공유하고 있기 때문에, 하나의 시트에 합쳐서 보여질 수 있게 한다.
    • key, ko, en 으로 칼럼을 잡았다.

코드

  • 번역 파일이 저장되어 있는 디렉토리 내의 파일들 조회
package read

// Import 내용들


// 디렉토리 내의 파일/폴더들 리스트 조회
func ReadDir(dir string) []os.DirEntry {
    entries, err := os.ReadDir(dir)

    if err != nil {
        log.Fatalf("Failed to Read Dir Error: %v", err)

        return nil
    }

    return entries
}

// 파일 읽기
func ReadFile(fileDir string) ([]fs.DirEntry, string) {

    fileList, readErr := os.ReadDir(fileDir)

    if readErr != nil {
        log.Printf("Can't Read json file: %v", readErr)
        return nil, fileDir
    }

    return fileList, fileDir
}
  • Property 파일 내의 데이터 처리하여 저장할 수 있게 가공
package read

// Import 내용들

type PropertyStruct struct {
    Code string
    Ko   string
    En   string
}

func ReadProperties(fileList []fs.DirEntry, pwd string) []PropertyStruct {
    var propertyDataDataList []PropertyStruct

    for _, file := range fileList {
        fileName := file.Name()

        fileNameWithoutFormat := strings.Split(fileName, ".properties")[0]

        if fileNameWithoutFormat == "example" {
            continue
        }

        fileNameLength := len(fileNameWithoutFormat)
        langCode := fileNameWithoutFormat[fileNameLength-2 : fileNameLength]

        if langCode == "en" || langCode == "ko" {
            // 파일 열기
            file, err := os.Open(pwd + "/" + fileName)
            if err != nil {
                log.Printf("[Properties] data Open Error: %v", err)
            }

            defer file.Close()
            // 스캐너로 파일 한 줄씩 읽기
            scanner := bufio.NewScanner(file)

            for scanner.Scan() {
                line := scanner.Text()

                // 빈 줄 또는 주석(#, ;) 무시
                if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
                    continue
                }
                // key=value 포맷으로 나누기
                parts := strings.SplitN(line, "=", 2)
                if len(parts) == 2 {
                    key := strings.TrimSpace(parts[0])
                    value := strings.TrimSpace(parts[1])

                    if langCode == "ko" {
                        convertedString, convTerr := utils.DecodeUnicodeEscapes(value)

                        if convTerr != nil {
                            log.Printf("Convert To Utf8 Err: %v", convTerr)
                        }
                        value = convertedString
                    }

                    isFound := false

                    for i := range propertyDataDataList {
                        data := &propertyDataDataList[i]

                        if data.Code == key {
                            isFound = true

                            switch langCode {
                            case "en":
                                data.En = value
                            case "ko":
                                data.Ko = value
                            }
                            break // 값을 찾았으므로 루프를 종
                        }
                    }

                    if !isFound {
                        propertyData := PropertyStruct{Code: key} // 새 구조체 생성
                        switch langCode {
                        case "en":
                            propertyData.En = value
                        case "ko":
                            propertyData.Ko = value
                        }
                        propertyDataDataList = append(propertyDataDataList, propertyData)
                    }

                }
            }

            if err := scanner.Err(); err != nil {
                log.Printf("Scan Error: %v", err)
            }
        }

    }

    return propertyDataDataList
}
  • 처리한 데이터들을 받아 액셀파일 생성
package write

// Import 내용들


type FileWork struct {
    file        *excelize.File
    FileName    string
    SheetLength int
}

// 칼럼 세팅
func (excelFile *FileWork)HandlePropertiesColumns () {
    excelFile.file.SetCellValue(sheetName, "A1", "Code")
    excelFile.file.SetCellValue(sheetName, "B1", "Ko")
    excelFile.file.SetCellValue(sheetName, "C1", "En")
}

// 데이터 레코드 입력
func (excelFile *FileWork)HandlePropertiesRows(dataList []read.PropertyStruct) {
    for i := 0; i <= len(dataList)-1; i += 1 {
        excelFile.file.SetCellValue(sheetName, fmt.Sprintf("A%d", i+2), dataList[i].Code)
        excelFile.file.SetCellValue(sheetName, fmt.Sprintf("B%d", i+2), dataList[i].Ko)
        excelFile.file.SetCellValue(sheetName, fmt.Sprintf("C%d", i+2), dataList[i].En)
    }
}

// 액셀 파일 저장
func (file *FileWork) SaveExcelFile() {
    savingErr := file.file.SaveAs(file.FileName)
    if savingErr != nil {
        log.Printf("Saving File Error: %v", savingErr)
    } else {
        log.Printf("Saving Success: %s", file.FileName)
    }
}

func OpenSaveExcelWithProperties(sheetName string, index int, excelFileName string, pwd string, dataList []read.PropertyStruct) {
    file := HandleFiles(sheetName, index, excelFileName, pwd, dataList)

    sheetIndex := file.HandleSheet(sheetName)

    log.Printf("Handled Sheet Index: %d", sheetIndex)

    file.file.HandlePropertiesColumns()

    file.HandlePropertiesRows(dataList)

    file.SaveExcelFile()
}
  • property 파일들이 저장되어 있는 디렉토리 내의 폴더와 파일 리스트 내에서 property 파일들만 읽어 처리하기
package libraries

// Import 내용들

// 위의 함수들 호출하여 properties 파일 처리 및 액셀 파일 생성
func CreatePropertiesTranslationData(fileName string, pwd string) {
    entries := read.ReadDir(pwd + "/assets/backend")

    log.Printf("[PROPERTIES] Entry Length: %d", len(entries))

    for i, folderData := range entries {
        folderName := folderData.Name()

        var propertyFileList []fs.DirEntry
        var proPwd string

        // 리드미 파일 혹은 예시 파일들은 제외
        if folderName == "README.md" || folderName == "example.properties" {
            continue
        }

        // 디렉토리인지 파일인지 여부 체크하여 분기처리
        if folderData.IsDir() {
            log.Printf("[PROPERTIES] Folder Name: %s", folderName)

            propertyFileList, proPwd = read.ReadFile(pwd + "/assets/common/" + folderName)

            if propertyFileList == nil {
                log.Printf("[PROPERTIES] The File is not readable: %s", proPwd)
                continue
            }
        } else {
            log.Printf("[PROPERTIES] Common File Name: %s", folderName)

            propertyFileList, proPwd = read.ReadFile(pwd + "/assets/common")

            if propertyFileList == nil {
                log.Printf("[PROPERTIES] The File is not readable: %s", proPwd)
                continue
            }

            folderName = "default"
        }

        fileDataList := read.ReadProperties(propertyFileList, proPwd)
        log.Printf("File Data List: %d", len(fileDataList))

        write.OpenSaveExcelWithProperties(folderName, i, fileName, proPwd, fileDataList)
    }
}

정리

  1. 디렉토리 내의 번역 파일 / 하위 디렉토리를 조회
  2. 번역 파일 / 하위 디렉토리 내부의 번역 파일 읽기
  3. 데이터를 파싱하여 원하는 형태로 나눠 구조체 안에서 관리
  4. 액셀 파일 생성

문제해결

  • 디렉토리 내부의 하위 디렉토리를 따로 감지하여 파일 읽는 방법
    • golang에 디렉토리 여부를 체크할 수 있는 기능이 내장되어 있음
  • 함수의 분리
    • 로직을 최소 기능 단위로 분리하여 함수 먼저 작성
    • 함수 Receiver를 이용해 클래스 내부 메서드처럼 활용할 수 있게 작성
  • 에러 발생 시 처리
    • 요구사항 상, 프로그램이 종료 되기보다 에러 발생 부분은 건너뛰고 처리
    • 로그를 확인하여 해당 부분은 매뉴얼하게 체크하는 방식을 선택함
728x90
반응형
728x90
반응형

JSON 파일 읽기

  • json 파일은 /assets/front 디렉토리 아래에 위치한다.
  • 파일은 한국어 번역 파일인 ko.json 과 en.json으로 구성됨
  • json 형식의 번역 파일은 프론트에서 다국어 처리를 위해 사용된다.
func ReadJson(fileName string) ([]byte, error) {
    // 현재 프로젝트 루트 디렉토리
    pwDir, getPwdErr := os.Getwd()

    // 에러 발생 시, 에러 리턴. 에러 발생 파일은 스킵할 수 있게 하기 위하여 리턴함
    if getPwdErr != nil {
        log.Printf("[READ_JSON] Get Pwd Error: %v", getPwdErr)
        return []byte{}, getPwdErr
    }

    file, readErr := os.ReadFile(pwDir + "/assets/front" + fileName)

    if readErr != nil {
        log.Printf("[READ_JSON] Read File Error\nFileName: %s, Error: %v", fileName, getPwdErr)
        return []byte{}, readErr
    }

    return file, nil
}

JSON 데이터 파싱

  • 파일명에 따라 한국어 / 영어로 구분
  • json 파일의 형식은 '{First: {Second: { Third: {"단어": "번역내용"}}}}' 의 형식으로 되어있다.
    • Third 에서 depth가 더 들어가는 케이스도 있음
type JsonRowStruct struct {
    First string
    Second string
    Third string
    Fourth string
}

func ParseJsonData(jsonData []byte) (map[string]interface{}, error) {
    // json 파일 데이터 매핑할 맵 변수 선언 -> 키값 아래에 데이터를 매핑시키는 방식으로 관리
    var jsonDataMap map[string]interface{}

    // json 데이터를 위에서 선언한 Map에 넣어주기
    unMarshalErr := json.Unmarshal(jsonData, &jsonDataMap)

    if unMarshalErr != nil {
        log.Printf("[PARSE_JSON] unmarshal json error: %v", unMarshalErr)
        return map[string]interface{}, unMarshalErr
    }

    return jsonDataMap, nil
}

파싱된 JSON 데이터 평탄화

  • 이러한 Depth 들을 평탄화 시켜줘야 액셀 파일에 넣기 용이함
    • 평탄화 하지 않으면 depth 별로 for 문을 돌려야 하고, depth 길이에 따른 동적 처리가 어려워 졌었음
// 액셀 파일 데이터 구조체
type FileWork struct {
    file        *excelize.File
    FileName    string
    SheetLength int
}

// 이미 한글/영어 데이터가 입력 되었을 때, 코드 값을 기준으로 데이터를 입력할 위치 탐색
func (excelData *FileWork)FindCode(sheetName string, rowIndex int, majorDepth , secondDepth, thirdDepth, fourthDepth string) bool {
    // 각 깊이별로 셀 값을 가져옴
    cellFirst, _ := excelData.file.GetCellValue(sheetName, fmt.Sprintf("A%d", rowIndex))
    cellSecond, _ := excelData.file.GetCellValue(sheetName, fmt.Sprintf("B%d", rowIndex))
    cellThird, _ := excelData.file.GetCellValue(sheetName, fmt.Sprintf("C%d", rowIndex))
    cellFourth, _ := excelData.file.GetCellValue(sheetName, fmt.Sprintf("D%d", rowIndex))

    // 데이터 코드 비교
    if cellFirst == majorDepth 
      && cellSecond == secondDepth 
      && cellThird == thirdDepth 
      && cellFourth == fourthDepth {
          return true
      }

        return false
}


// 데이터 평탄화
func (excelData *FileWork)FlattenJsonData(sheetName string, parsedData map[string]interface{}) {
    switch v := data.(type) {
    case map[string]interface{}:
        for key, value := range v {
            newPrefix := prefix

            if prefix != "" {
                newPrefix += "."
            }

            newPrefix += key

            // map value의 데이터 역시 depth가 있을 수 있기 때문에 평탄화 시킨다
            excelData.flattenKorJSON(sheetName, value)
        }
     // 문자로 들어왔으면 카테고리 구분하여 처리 - 이미 front 시트에 데이터가 들어가 있을 때
    case string:
        depths := strings.Split(prefix, ".")
        firstDepth := ""
        secondDepth := ""
        thirdDepth := ""
        fourthDepth := ""

        if len(depths) > 0 {
            firstDepth = depths[0]
        }
        if len(depths) > 1 {
            secondDepth = depths[1]
        }
        if len(depths) > 2 {
            thirdDepth = depths[2]
        }
        if len(depths) > 3 {
            fourthDepth = strings.Join(depths[3:], ".")
        }

        // 엑셀의 모든 행을 탐색하여 코드 일치 여부 확인
        rows, err := excelData.file.GetRows(sheetName)
        if err != nil {
            log.Printf("GetRows Error: %v", err)
            return
        }

        for rowIndex := range rows {
            // 첫 번째 행은 헤더이므로 건너뜀
            if rowIndex == 0 {
                continue
            }

            // 1번째 row는 칼럼 값들이므로, 2번째 줄 부터 데이터 입력(index+1)
            isCodeMatch := excelData.FindCode(sheetName, rowIndex+1, firstDepth, secondDepth, thirdDepth, fourthDepth)

            if isCodeMatch {
                // 'F' 열에 값 삽입
                excelData.file.SetCellValue(sheetName, fmt.Sprintf("E%d", rowIndex+1), v)
                break // 매칭되는 첫 번째 행에만 값을 삽입하고 루프 종료
            }
        }

    default:
        // 다른 타입은 처리하지 않음
    }
}

액셀 파일 생성 및 데이터 저장

  • 데이터를 처리, 액셀 파일 저장하기 위해 github.com/xuri/excelize/v2 외부 패키지를 사용하였다.
  • 매핑된 데이터를 평탄화하여 저장한다.
# 패키지 설치
go get -u github.com/xuri/excelize/v2

// 번역 엑셀 파일 존재 여부 체크
func (excelData *FileWork)CheckIfFileExist() bool {
    _, err := os.Stat(excelData.fileName)

    return !os.IsNotExist(err)
}

// 파일 저장
func (excelData *FileWork) SaveExcelFile() {
    savingErr := excelData.file.SaveAs(excelData.FileName)
    if savingErr != nil {
        log.Printf("[EXCEL_SAVE] Saving File Error: %v", savingErr)
    } else {
        log.Printf("[EXCEL_SAVE] Saving Success: %s", excelData.file.FileName)
    }
}

// 칼럼 설정
func (excelData *FileWork) SetJsonColumns() {
    excelData.file.SetCellValue(sheetName, "A1", "First")
    excelData.file.SetCellValue(sheetName, "B1", "Second")
    excelData.file.SetCellValue(sheetName, "C1", "Third")
    excelData.file.SetCellValue(sheetName, "D1", "Ko")
    excelData.file.SetCellValue(sheetName, "F1", "En")
}

func (excelData *FileWork)WriteJsonExcelFile(parsedData map[string]interface[}) error {
    // 번역 파일 존재 여부에 따라 액셀 파일 여는 방식이 다르므로 미리 선언
    // 프로그램 실행 시 먼저 파일을 만들고, 항상 openFile 하는 방법으로 작성하였으나 여기서는 두가지 방식을 보여주기 위해 분리해서 보여줌    
    if CheckIfFileExist(excelData.fileName) {
        file, openErr := excelize.OpenFile(excelFilePath)

        if openErr != nil {
            log.Printf("[EXCEL_WRITE] Open Excel File for JSON Error: %v", openErr)
            // 파일 오픈 실패 시, 새로 파일 생성
            excelData.file = excelize.NewFile()
        }

        excelData.file = file
    } else {
        excelData.file = excelize.NewFile()
    }

    sheetIndex, indexErr := excelData.file.GetSheetIndex("front")

    if indexErr != nil {
        log.Printf("[EXCEL_WRITE] Failed to get Sheet Index for JSON error: %v", indexErr)
        return indexErr
    }

    excelData.SetJsonColumns()

    return nil
}
728x90
반응형
728x90
반응형
  • 본 내용은 과거 번역 파일 정리 자동화 프로그램 작업에 대한 업무 일지이다.

시작

  • 회사에서 서비스에 사용되는 다국어 데이터의 검수 및 정리를 위한 액셀 파일 필요
  • 수기로 작업하던 방식에 불만을 갖게 되어, 나도 모르게 자동화 프로그램을 만들어주겠다고 선언해 버렸다
  • 기능 정리를 해 보면 아래와 같다.
    • 번역 파일을 액셀파일로 만들고, 수정 뒤에 다시 액셀파일을 번역 파일 생성해 주는 프로그램 각각을 만들어야 할 것으로 판단하였음
    • 파일 뿐만 아니라, DB의 데이터도 처리해야 해야 했음
  • 복기하는 겸, 그 내용의 일지를 옮겨 적어본다.

액셀 파일 생성 프로그램 - 번역 파일 -> 하나의 액셀 파일

  • 다국어 데이터를 사용하는 서비스는 총 3개
  • 파일들은 백엔드에서 사용하는 파일 / 프론트에서 사용하는 파일 / DB 에서 사용하는 파일 각각은 front / back / DB 폴더 안에 위치
  • 프론트에서 사용하는 다국어는 json 파일로 관리되고, 백엔드에서는 .properties 파일과 DB로 관리됨
    • properties 파일: <단어> = <번역된 단어> 형태로 관리됨
      • 기능에 따라 폴더가 구분되어있음. 이 폴더명을 back_<폴더명> 명으로 사용
      • ko.properties, en.properties로 파일이 나뉘어져 있다.
      • 두 파일은 같은 key를 공유하고 있기 때문에, 하나의 시트에 합쳐서 보여질 수 있게 한다.
      • key, ko, en 으로 칼럼을 잡았다.
    • json 파일: {"key1": {"key2": {"단어": "번역내용"}}} 형태로 관리됨
      • key2 아래에 Depth 가 최대 두개까지 더 들어갈 수 있다.
      • 파일은 한국어 ko.json과 en.json 두가지로 나뉘어지고, 두 파일을 하나의 sheet 에서 보여주게끔 진행해야 함
      • sheet명은 front_json 으로 잡음
      • 따라서 First, Second, Thir, Fourth, Ko, En 을 칼럼으로 잡았다.
    • DB: Sequential 한 id 값이 Primary Key 이고, ko 필드에 한국어 / en 영어 데이터가 저장되어 있음
      • 프로그램 실행 시, DB 에서 데이터 조회
      • 테이블 명을 db_<테이블명> 명으로 잡음
      • id, ko, en 으로 칼럼명을 잡았다.

번역 파일 재생성 - 하나의 액셀 파일 -> 기존 폴더 구조에 맞게 번역 파일로 분리

  • 액셀 파일에 저장 및 수정이 되어 전달 받았을 때, 다시 번역 파일 생성 및 데이터 업데이트/삽입 프로그램
  • json, properties 파일과 DB 내용을 다시 업데이트 해 주기
    • sheet 명의 prefix에 따라, front / back / db 로직으로 분리
    • 다시 생성한 번역 파일들은 dist 폴더 아래에 저장
    • properties 파일 - dist폴더 아래에 prefix(back)으로 디렉토리 생성 후 그 아래에 파일 저장
      • 각각 시트명에 대한 폴더 생성
      • ko / en 칼럼들은 en.properties / ko.properties 파일로 분리 시켜 저장
      • <key칼럼> = <번역 내용> 으로 데이터 생성해서 저장
    • json 파일 - dist 폴더 아래에 prefix(front)으로 디렉터리 생성 후 그 아래에 파일 저장
      • ko 칼럼은 ko.json 파일로, en 칼럼은 en.json 파일로 저장
      • First, Second, Third, Fourth 칼럼들은 각각 json의 키깂으로 사용.
    • DB 데이터 - 해당 데이블에 데이터 업데이트 / 삽입
      • id 값을 PK 로 사용하고 있기 때문에, INSERT INTO ON DUPLICATE KEY 로 쿼리
      • 실 사용 서버에 적용시키기 전 개발 서버의 DB에 적용

설계

  • 언어는 Golang으로 선택. 실행 파일을 빌드해서 넘기기에 용이하며, 여러 OS 에서 실행가능함
  • 번역 파일들은 그 폴더 채로 assets 폴더 안에 옮겨두고 프로그램 실행하면 읽고 각 폴더명에 맞게 sheet 생성 및 데이터 저장
    • 폴더 없이 가장 상위 레벨 디렉토리의 파일은 default 라는 시트명 안에 저장
  • DB 데이터들은 데이터 조회 후, 테이블명을 sheet 명으로 지정하고 id - ko - en 형식으로 데이터 저장
  • 액셀 파일들은 하나의 파일 안에 모든 데이터가 저장되어야 함
  • 처리가 완료되면, 프로그램은 종료되고 실행 파일과 같은 디렉토리에 액샐파일 생성
728x90
반응형
728x90
반응형

UBUNTU에 Go 설치하기

  • 항상 컨테이너로 GO 프로그램을 돌렸지만, 불가피하게 로컬에 GO를 설치해 빌드하고 돌려야 할 상황이 와서 해당 내용을 기록한다.

소스 다운 받아서 설치하기

최신 버전 확인

  • 공식 사이트의 Golang 버전 확인 후 다운받기 (2024.10.21 시점 1.23.2)
  • 공식 사이트

WGET을 이용해 다운로드

  • WGET을 이용해 다운받기
    wget https://go.dev/dl/go1.23.2.linux-amd64.tar.gz

압축 해제

  • 다운 받은 압축 파일 해제
    sudo tar -C /usr/local -xzf go1.23.2.linux-amd64.tar.gz

경로 설정

vi ~/.profile

# 파일 마지막 줄 아래에 내용 추가
export PATH=$PATH:/usr/local/go/bin
export GOPATH=$HOME/go
export PATH=$PATH:$GOPATH/bin

변경 사항 적용

source ~/.profile

Go 버전 확인

go version

UBUNTU 피키지 매니저로 다운받기

패키지 인덱스 업데이트

sudo apt update

GO 설치

sudo apt-get install golang-go

Go 버전 확인

go version
728x90
반응형

'백엔드 Backend > Golang' 카테고리의 다른 글

[gRPC] Go 언어로 gRPC 사용해보기  (2) 2024.12.24
[Neo4J] 데이터 핸들링하기  (1) 2024.12.09
[Neo4j] Golang 으로 쿼리 핸들링  (0) 2024.12.06
[Neo4j] Golang으로 Neo4j 연동  (0) 2024.12.05
[GO] Goroutine이란?  (0) 2024.10.02
728x90
반응형

Golang은 뛰어난 동시성 지원이 장점이라고들 한다.

일반적으로 쓰레드를 활용하여 동시성 프로그래밍을 하지만, Golang은 고루틴 goroutine으로 가능하다.

 

고루틴 Goroutine

공시적인 설명으로는 경량 쓰레드이다. 실제 OS의 쓰레드를 사용하는 것이 아닌, golang의 런타임에서 관리되는 논리적 / 가상적 쓰레드이다.

 

사용법

go func() {

    // 대충 코드

  }()

이게 끝이다.

 

thread VS goroutine

1. 메모리 사용량

  • goroutine: 생성시 약 2KB의 스택 메모리 혹은 힙 메모리 공간만을 사용한다.
  • thread: 약 1MB 의 메모리 공간을 사용한다.
  • P.S. Guard Page: 쓰레드가 사용할 메모리 공간과 각 메모리 간의 경계 역할
  • 따라서 스레드 기반의 동시성 처리는 결국 메모리 부족 현상(OutOfMemory) 문제 발생할 수 있으므로, 스레드를 미리 생성해 재활용하는 형태가 될 수 있음.

 

2. 비용

  • goroutine: 런타임에서 논리적으로(하드웨어와 상관 없이) 생성되고 소거되기 때문에 가볍다.
  • thread: OS 에 스레드 생성을 요청해 사용하고 작업 완료 시 다시 OS에 반환해야 한다.
  • 따라서 쓰레드 생성시마다 OS에 요청해 생성하고 다시 반환하는 방식이 더 리소스 사용량이 많다.

 

3. Context Switching 비용

  • 하나의 스레드가 특정 작업 처리를 위해 사용(Blocking)된다면, 다른 쓰레드가 대신하여 처리하도록 스케쥴링 됨
  • goroutine: 3개의 레지스터(PC(Program Counter)), SP(Stack Pointer), DX)만 save/restore 작업을 함
  • thread: 스레드가 스케쥴링고 교체되는 동안 스케쥴러에서는 모든 레지스터들을 save/restore 해야함
  • 일반적으로 16개의 범용 레지스터, PC, SP, Segment 레지스터, 16개의 XMM레지스터, FP coprocessor state, 16개의 AVX 레지스터, 모든 MSR들 등 save/restore 작업을 진해해야함
  • 따라서 Context Switching 할 때 save/restore 해야하는 레지스터의 개수부터가 큰 차이가 남

 

결론

golang이 메모리 사용량과 쓰레드 생성과 작업처리에 있어서의 비용 측면에서 효율적이다.

 

마지막

goroutine의 동작 방식과 쓰레드 종류에 대해선 다음에 서술하고자 한다.

 

REFERENCE

- https://velog.io/@khsb2012/go-goroutine

 

 

728x90
반응형

+ Recent posts