728x90
반응형

Graceful Shutdown

  • 취소 가능한 컨텍스트 생성
    • 컨텍스트와 취소 함수가 리턴됨
    • 종료 시그널 감지 시 취소 함수 실행
ctx, cancel := context.WithCancel(context.Background()) defer cancel()

신호 감지

  • ctrl + c -> 운영체제에서 취소 신호(interrupt 신호)를 프로세스로 넘겨줌
  • signal.Notify는 신호를 감지하면 받아서 sigChan 이라는 채널로 넘겨줌
sigChan := make(chan os.Signal, 1) 
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

신호 감지 고루틴

  • 신호가 들어올때까지 sig := <- sigChan은 기다림
    • Buffered Channel 로 생각하면 될듯
  • 취소 신호(interrupt 신호)를 감지하면 signal.Notify로 sigchan 채널에 취소 신호를 넘김
  • sigChan 채널의 값을 sig 변수에 채널을 담고, 해당 블록은 해제됨
  • 로그를 찍은 후 컨텍스트 취소 메서드를 실행
go func() { 
    sig := <-sigChan 
    log.Printf("Received signal %s, shutting down...", sig) 
    cancel() 
}()

컨텍스트 완료 대기

  • 컨텍스트 취소 함수 (cancel())가 실행되기 전까지 대기 (블록)
  • 신호 감지 고루틴을 통해 취소 함수가 실행되면 대기중인 context의 채널이 닫힘
    • 이렇게 되면 해당 context를 사용중인 모든 함수들에 취소 신호가 전파됨
  • 컨텍스트가 취소되면 ctx.Done() 이 실행됨
<-ctx.Done()

완성본

  • 전체 코드와 흐름은 아래와 같다.
  • 취소 신호를 받아 컨텍스가 취소되기 전까지 실행할 함수를 넣어주면 된다.
    • 실행할 함수에도 취소 가능한 context(ctx) 를 넘겨줘서, Graceful 하게 고루틴을 종료시킬 수 있음

func main() {
    // 취소 가능한 context 생성
    ctx, cancel := context.WithCancel(context.Background())

    defer cancel() // 종료 취소 보장

    // 신호 채널 생성
    sigChan := make(chan os.Signal, 1)

    // 취소 신호 감지 시 채널에 넘겨주는 용도
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        sig := <-sigChan // 신호가 들어올 때 까지 대기. 신호가 들어오면 해제됨
        log.Printf("Received signal %s, shutting down...", sig)
        cancel() // context 취소 함수 실행
    }()

    // 실행할 함수
    lib.Download(ctx, url, fileName)

    <-ctx.Done()

    // 종료 로그 등, 종료
}
728x90
반응형
728x90
반응형

Channel

  • 채널은 고루틴 간의 데이터 파이프라인이다.
  • 종류는 Unbuffered channel과 buffered channel로 구분된다.
    • Unbuffered Channel: 동기적
      • 수신 채널쪽에서 송신될 때 까지 채널이 묶는다. 즉 Block을 시키고 대기한다.
      • 수신 채널이 준비되지 않다면 deadlock 에러가 발생한다.
    • Buffered Channel: 비동기적
      • 수신 채널이 데이터를 받을 준비가 되지 않더라도 지정된 버퍼만큼 데이터를 보내고 다음 작업을 수행한다.
  • main() 함수는 가장 메인으로 생성되는 Goroutine이다.

채널 예시1

  • 수신자는 goroutine으로부터 데이터가 채널을 통해 들어올 때 까지 대기한다.
func main() {
    channel := make(chan int) // 정수 타입을 받는 채널 생성

    go func() {
        channel <- 123 // 채널에 123을 보냄
    }()

    var i int

    // goroutine에서 데이터를 전송할 때까지 계속 대기
    i = <- channel // 채널의 123을 수신한다.
}

채널 예시2

  • 수신자와 송신자가 서로 기다리기 때문에, Goroutine 이 끝날 때 까지 기다리게 할 수 있음
func main() {
    done := make(chan bool)

    go func() {
        for i := 0; i < 10; i +=1 {
            fmt.Println(i)
        }

        done <- true
    }()

    <- done // 위 익명함수 Goroutine이 종료될 때까지 대기
}

Channel Buffering

Unbuffered Channel

  • 하나의 수신자가 데이터를 받을 때까지 송신자의 채널에 묶임
  • 즉 위에서 보여준 예시가 Unbuffered Channel 임
  • 만약 수신자가 준비되지 않았다면 에러 발생
    • Deadlock
func main() {
    channel := make(chan int)
    channel <- 1 // 수신 Goroutine이 없으므로 DEADLOCK
    fmt.Println(<- channel) // 별도의 Goroutine이 없으므로 DEADLOCK
}

Buffered Channel

  • 버퍼 채널을 만들어서 사용하면 수신자가 없어도 데이터를 보낼 수 있음
func main() {
    channel := make(chan int, 2) // 두개의 버퍼 채널 생성

    channel <- 10 // 수신자가 없더라도 보낼 수 있음

    fmt.Println(<- channel)

}

송수신 역할 분리

  • 채널은 기본적으로 송신과 수신 역할 전부 다 할 수 있다.
  • 그러나 특정 역할만 수행하게 정해줄 수 있다.
func main() {
    channel := make(chan string, 1) // Buffered Channel
    sendChannel(channel) // 전송
    receiveFromChannel(channel) // 수신
}

// 채널에 데이터 입력하는 역할만 수행
func sendToChannel(ch chan <- string ){
    ch <- "data"
    // data := <- ch 에러 발생
}

// 채널의 데이터를 수신하는 역할만 수행
func receiveFromChannel(ch <- chan string) {
    data := <- ch
    fmt.Println(data) // "data" 반환
}

채널 닫기

  • 채널을 닫은 이후 더 이상 해당 채널로 데이터 전송은 불가하다
  • 그러나 남은 데이터가 있다면 수신은 가능하다.
  • channel의 리턴값은 두개이다
    • 채널 내의 데이터 값과 수신 성공 여부
func main() {
    channel := make(chan int, 2)

    channel <- 1 // 채널에 데이터 전송
    channel <- 3 // 채널에 데이터 전송

    close(channel) // 채널 종료

    fmt.Println(<-channel) // 남은 데이터가 채널에 있다면 수신
    fmt.Println(<-channel) // 남은 데이터가 채널에 있다면 수신

    if _, success := <- channel; !success { // 채널에 남아있는 데이터가 없다면 false
        fmt.Println("데이터 없음")
    }

}
728x90
반응형
728x90
반응형

Goroutine

  • Go 런타임이 관리하는 가상(논리) 쓰레드
    • go 키워드로 고루틴 실행 가능
  • 비동기적으로 함수 루틴을 수행하며, 동시 동작 수행에 사용됨
  • OS 스레드보다 훨씬 가볍고, 생성할 때 비용이 적음
    • OS 스레드는 1mb의 메모리 스택, goroutine은 kb 단위 (동적 증가 가능)
  • 기본적으로 CPU 1개를 시분할하여 사용

다중 처리

  • Go는 기본적으로 CPU 개를 사용한다
  • 다중 병렬 처리를 위해 CPU를 여러개 사용하기 위해선 GOMAXPROCS로 증가시킬 수 있다.
runtime.GOMAXPROCS(4) // CPU 4개 사용

익명함수 (go func)

  • 익명 함수 go func() {}() 으로 비동기 실행 가능
func main() {  
  var wait sync.WaitGroup // Go 루틴 대기열 두개 생성  
  wait.Add(2)


  go func (parameter string) {
      defer wait.Done()
      //
  } ("parameterValue")

  wait.Wait() // Go 루틴 끝날 때 까지 대기
 }

Graceful Shutdown

  • Go 언어에서 여러 고루틴을 운영할 때 Graceful Shutdown 로직이 필요한 이유는, Go 애플리케이션이 종료 신호를 받았을 때 하위 고루틴들이 메인 고루틴보다 먼저 종료될 가능성이 있기 때문
  • 쉽게 말하면, 작업 지시서이다

Context

  • 동시성을 관리
  • 여러 goroutine에 값을 전달
  • 취소 신호 전파
  • 분산 시스템 / 서비스 간의 상호 작용에서 아용

context.Background

  • 모든 context 의 기본이 되는 빈 context 를 반환
  • 대부분의 context 는 이를 기반으로 생성

context.TODO()

  • 아직 구현되지 않은 부분을 나타내는 context 를 반환
728x90
반응형
728x90
반응형

계기

  • Go 로 사이드 프로젝트 개발을 하다, 동적 쿼리가 필요한 경우도 많았고 동일한 유틸/함수들을 다양한 프로젝트에서 동일하게 사용해야 하는 경우가 너무나 많았다.
  • 문득 내가 모든 프로젝트들에 사용하는, 내가 만든 함수들을 패키지화 시키고 간편하게 사용하면 어떨까 생각하게 되었다.
    • 그렇게 '동적 쿼리 유틸'과 'DB 연결 및 쿼리' 유틸들을 오픈소스 패키지화 하기로 결정하였다.

버전 릴리즈

  • 우선 깃허브에 퍼블릭 레포를 파고 패키지 소스코드를 푸시 하였다.
  • 패키지를 임포트 해 사용할 것이기 때문에, 메인 함수의 패키지명은 사용할 패키지 명으로 설정해야 한다.
package gqbd

// ... 코드
  • 테스트 코드들로 각 함수및 유틸들이 원하는 리턴값을 가져오는지 검증을 진행하였다.

    • 여담이지만 <>_test.go 파일들을 테스트 코드로 인식한다.
    • t *testing.T 타입을 인자로 넘겨주면 테스트 진행할 함수로 인식된다.
  • 완료된 후 버전을 릴리즈했다. 최초 베타 테스트이므로 v0.1.0 으로 잡았다.

git tag v0.1.0
git push origin v0.1.0
  • 위처럼 레포에 v0.1.0으로 푸시를 했다면, 이제 Go 쪽에 해당 패키지와 버전을 알려주면 배포가 완료된다.
GOPROXY=proxy.golang.org go list -m github.com/<repo_owner>/<repo_name>@<version>
  • 이렇게 하면 GoDoc에서 내가 배포한 레포를 확인할 수 있다.
    • 다만 배포했다고 GoDoc이 빠르게 반영되는건 아니라는 함정.
    • 그리고 레포의 README.md 파일은 GODOC의 메인 설명 페이지로 나온다는 것.

결과

  • 아래는 내가 배포한 두가지 패키지들이다. 지속적으로 유지보수하면서 버전업 시킬 예정이다.
  • GQBD : Go-Query-Builder
  • GDCT : Go-Database-ConnecT
728x90
반응형
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
반응형

+ Recent posts