Go로 파일 관리 용 웹서버 추가 하기

양성연·2023년 7월 25일
0
post-thumbnail

숨 고르기

저희는 Vue.js를 활용하여 바쁘게 달려왔습니다. 이제 잠시 숨 고르기 시간을 가져봅시다. 왜 java와 같은 언어 냅두고 백엔드 웹서버를 go 언어로 구현하려는 걸까요?

우선, 저는 java를 다룰 줄 모릅니다. 한 때 백엔드 툴로 java spring boot를 공부 해보겠다고 마음 먹은 적은 있었습니다. System.out.println() 명령어에서 느껴지는 기묘한 거부감(print *, 를 쓰던 시절의 본인)과 vscode 보다 많이 쓰인다는 이클립스, IntelliJ와 같은 IDE의 거부감 그리고 프로젝트 생성 시에 다운 받던 여러가지 익숙치 않던 modules 때문에 마음이 3단으로 꺾였습니다.

자바의 풍부한 라이브러리 생태계는 숙련자들에게는 있어서 감사한 것 들이겠지만, 초심자였던 저에겐 너무나 복잡했습니다. 잠시, 컴퓨터 공학 비전공자인 저의 프로그래밍 학습 역사를 간략하게 말씀드려보자면, 대학교 교양 시절 처음 접했던 C 언어로 시작해, 천문학에서 많이 사용하던 Fortran (수치 계산) + python (그래프 시각화)을 거쳐, 지금의 Go 까지 왔습니다. Go를 접하기 바로 전이 바로 Java에 기웃거렸던 때였습니다.

(신입) 개발자라는 명찰을 단 지 이제 막 1년이 채 넘은 상황에서, 지금 자바를 다시 보면 다르게 느껴질까 싶기도 합니다. 비슷하게, 지금와서 생각하면 왜 Go를 주력 언어로 선택했는가 제 스스로도 의문이긴 한데요. 아마도 쿠버네티스가 그 역할에 한 몫을 하지 않았나 생각 됩니다.

쿠버네티스라는 컨테이너 관리 운영 툴은 Go 로 작성되어 있습니다. 리눅스가 C언어로 짜여져 있기에, 리눅스 프로그래밍을 하려면 C언어를 알아야 하는 것처럼, 저 역시도 쿠버네티스를 깊은 수준까지 다루고 싶었기에 Go의 선택은 필수불가결 이었습니다.

물론, 제가 쿠버네티스 때문에 Go를 선택했다는 것이, 다른 사람들에게 있어서 새로 배워야 할 이유가 될 수는 없겠죠. Java, Python, javascript 등의 쟁쟁한 언어들을 뒤로 하고 Go을 선택 해야 하는 이유가 있을까요? 몇 가지 장점이 되는 포인트를 생각해보면 다음과 같습니다.

  1. 적당한 추상화 속 좋은 성능

    이곳은 백엔드 프레임워크의 성능 지표를 비교한 사이트 입니다. Composite scores 항목에서 종합적인 점수를 순서대로 살펴볼 수 있습니다. 어림짐작으로 보았을 때 Go의 프로젝트들이 Python과 Js보다 대체로 나은 경향을 확인 할 수 있습니다. (Rust가 상위권에 많은데, 배우고자 하는 언어 중 하나 입니다)

    Rust와 Go를 비교하면 성능적으로 Rust가 좋을 수 밖에 없습니다. 하지만 Rust는 학습곡선이 매우 높습니다. 인터넷에서 본 누군가의 표현으로 “사람 목숨 값 정도로 코드의 성능과 안정성이 중요해 질 때 Rust를 선택한다.” 라고 합니다. Go는 편리한 추상화 단계 속에서 적당히 좋은 성능을 가져 갈 수 있습니다.

  2. Cloud Native

    Cloud Native란 클라우드 제공 모델에서 제공하는 분산 컴퓨팅을 활용하기 위해 애플리케이션을 구축 및 실행하는 개념입니다. Go는 Cloud Native 시장에서 매우 중추적인 역할을 하고 있습니다. 위에서 언급한 Kubernetes를 포함해 Docker 역시 Golang으로 작성되어 있습니다. 이들은 Cloud Native 환경에서 매우 중요한 도구 입니다.

    Go는 크로스 플랫폼 언어로, 다양한 운영체제와 아키텍처에서 실행될 수 있습니다. 이는 어떤 환경에서도 일관된 성능과 동작을 제공하여 개발 및 배포를 간편하게 만듭니다. 또한 빌드 속도도 빠릅니다.

  3. 동시성

    여러 다른 언어에서는 동시성을 구현하기 위해, OS가 관리하는 메모리 상에서 쓰레드와 lock을 이용해 처리를 합니다. 하지만 이는 오버해드를 낳습니다.

    Go는 조금 특별한 동시성 처리를 이용해 매우 작은 메모리 공간으로 일을 진행 합니다. Goroutine과 Go Channel이 주목할 만한 기능입니다. 고루틴은 가볍고 효율적인 쓰레드의 개념입니다. 기존의 쓰레드와는 다르게 Go 런타임에 의해 스케줄링되며, 스택 크기가 작기 때문에 많은 수의 고루틴을 동시에 생성하더라도 메모리 부담이 적습니다. 일반적인 쓰레드에 비해 생성과 종료가 더 빠르며, 작업량에 따라 쉽게 확장 가능합니다.

  4. 마스코트가 귀엽다

    정말 귀엽습니다.

어느 정도 Go에 흥미가 생기신 분들이 있으셨으면 좋겠습니다. 이제 Go를 설치해 보는 것부터 시작해 보겠습니다.

Go 설치하기

Go는 전통적인 컴파일, 링크 모델을 따르는 범용 프로그래밍 언어이다. Go는 일차적으로 시스템 프로그래밍을 위해 개발되었으며, C++, Java, Python의 장점들을 뽑아 만들어졌다. C++와 같이 Go는 컴파일러를 통해 컴파일되며, 정적 타입 (Statically Typed)의 언어이다. 또한 Java와 같이 Go는 Garbage Collection 기능을 제공한다. Go는 단순하고 간결한 프로그래밍 언어를 지향하였는데, Java의 절반에 해당하는 25개의 키워드만으로 프로그래밍이 가능하게 하였다. 마지막으로 Go의 큰 특징으로 Go는 Communicating Sequential Processes (CSP) 스타일의 Concurrent 프로그래밍을 지원한다. - 예제로 배우는 Go 프로그래밍

설치가 반이라는 말이 있죠. 이번 단계에서는 Go를 설치를 해볼 것 입니다. 저는 주로 리눅스 서버 환경에서 작업을 해왔습니다.

  • 리눅스

    아래 명령을 실행하려면, root 혹은 관리자 계정을 필요로 합니다.

    $ wget https://go.dev/dl/go1.20.6.linux-amd64.tar.gz
    $ rm -rf /usr/local/go && tar -C /usr/local -xzf go1.20.6.linux-amd64.tar.gz

    $HOME/.profile 또는 /etc/profile 에 다음을 추가 해주어야 합니다.

    export PATH=$PATH:/usr/local/go/bin

    추가 후 source $HOME/.profile 명령어를 실행 해주셔야 바로 적용이 됩니다. 설치가 제대로 되었는 지 확인 하십시오.

    $ go version

    아래 윈도우 설치 과정 처럼, GOPATH에 알맞게 폴더를 구성해주셔야 합니다.

  • 윈도우

    다음 페이지를 들어가 Download and install - The Go Programming Language 차례대로 순서를 따라 해줍시다. .msi (23.07.24 기준 1.20.6 버전) 를 다운 받고 실행해 줍시다.

    $ go version
    go version go1.20.6 windows/amd64

    터미널에서 다음 명령어를 쳐서 Go와 관련된 환경 변수 값들을 확인 해봅시다.

    $ go env 
    ...

    제어판 > 시스템 및 보안 > 시스템 > 고급시스템설정 >환경변수 설정 에서도 환경 변수 들을 확인 할 수 있습니다. 그리고 GOPATH에 저장된 경로에 맞게 폴더를 만들어주시고, 그 안에 bin, src, pkg 폴더를 만들어주셔야 합니다.

    • bin 폴더 : *.go 소스코드 컴파일을 하면, 실행 가능한 바이너리 파일이 저장.

    • pkg 폴더 : 프로젝트에 필요한 패키지가 컴파일 되어, 라이브러리 파일이 저장.

    • src 폴더 : 사용자가 작성한 소스코드나 사용하려는 오픈소스를 저장하는 곳.

      이제 앞으로 작성할 코드는 src 폴더 아래 만들어주시면 되겠습니다. 저는 ~/go/src/github.com/{계정명}/test/main.go 파일을 만들어 보겠습니다. 계정명은 저의 경우에 Larshavin 입니다.

      이제 ~/go/src/github.com/{계정명}/test/ 폴더에서 vscode 편집기를 켜줍시다. (편의를 위해서 사용하는 것이니, vscode가 아닌 다른 IDE를 사용해도 괜찮습니다)

      vscode에서 가장 먼저 해야 할 일은 golang 관련 패키지를 설치하는 것 입니다. 저는 이미 설치를 해놓은 상태입니다.

      그리고 터미널을 실행해 다음 명령어를 수행해 줍시다.

      $ go mod init
      go: creating new go.mod: module github.com/Larshavin/test
      go: to add module requirements and sums:
              go mod tidy
      
      $ go mod tidy

      이제 마지막으로 main.go 파일을 열어 다음과 같이 편집해주고 파일을 실행 시켜주면 Hello, World! 가 보여집니다.

      package main
      
      import "fmt"
      
      func main() {
      	fmt.Println("Hello, World!")
      }
      $ go run main.go # = go run .
      Hello, World!

      설치가 완료되었습니다! 지금까지의 과정 중 막히는 부분이 있으시다면, 이 블로그의 스크린샷을 참고하며 진행해주시면 좋을 것 같습니다.

Go 시작하기

Go를 문법부터 처음부터 차근차근 시작하는 것이 좋을지, 뚝딱뚝딱 만들어보는 것으로 시작하는 게 좋을지 잘 모르겠습니다.

다만, 저희의 목적은 백엔드 서버를 만들어서 블로그 게시물을 관리하는 데에 있습니다. 초점을 맞춰 빠르게 목적 지향적으로 나가보도록 하고, 다음에 이어질 내용을 최대한 언어적인 장벽에 가로막히지 않게 설명 드리도록 노력 해보겠습니다.

그리고 언어라는 게 사실, 자료형, 반복문, 제어문 만 알아도 첫 시작은 가볍게 할 수 있지 않을까 싶습니다. Go는 키워드가 적다보니 문법 학습에 그리 오랜 시간을 사용하지 않아도 되어서 좋습니다. 예제로 배우는 Go 프로그래밍 사이트나, A Tour of Go에서 쉽게 문법들을 익히실 수 있으십니다. 저는 nomad coder의 무료 강의로 학습했습니다.

기능을 구현하다가, 이 문법은 조금 설명을 드려야겠다 싶은 부분이 나올 때 따로 설명을 드리도록 하겠습니다.

백엔드 깃허브 주소는 다음과 같습니다. https://github.com/Larshavin/blog_backend

gin-gonic 실행하기

저희는 우선 웹 서버를 만들 겁니다. 웹 서버를 구동시키는 방법으로는 여러 방식이 존재합니다.

첫 번째로 Go는 언어 자체적인 풍부한 표준 라이브러리를 지니고 있습니다. net/http 패키지를 사용하여 간단하게 HTTP 서버를 구축할 수 있습니다.

두 번째 방법으로는 프레임워크 프로젝트 레벨의 라이브러리들을 활용하는 것 입니다. Go 생태계에서는 gin-gonic, Fiber, Echo 등이 존재합니다. 저는 이 중에 gin을 가장 많이 사용 해보았습니다.

웹서버에 있어서 누군가는 프레임워크에 종속되지 않는 것을 추천 합니다. 여러 프레임워크들이 기본적으로 net/http 패키지를 기반으로 하기 때문에, net/http 패키지를 이해하고 나서 프레임워크를 사용하는 것이 좋다는 취지인 듯 합니다. 또한 MSA화 되어 만들어지는 여러 서비스들에 부가적인 오버해드를 굳이 줄 필요가 없을 수도 있습니다.

그래도 저는 gin으로 실습을 시작하는 게 그다지 나쁜 선택이라고는 생각하지는 않습니다. 왜냐하면, 어차피 라이브러리를 사용하는 데 있어서 추상화 된 기능을 얼마나 빨리 습득하냐가 중요하다고 생각합니다. 기능 사용이 능숙해질 때 그 속의 원리를 파악할 준비가 되었다고 보기 때문입니다.

아무튼, https://github.com/gin-gonic/gin 문서 설명에 따라 아주 간단한 웹서버를 실행 해봅시다.

우선, go get 명령어를 통해 github.com/gin-gonic/gin 레포지토리의 코드들을 저희의 프로젝트로 끌어옵시다. (만약 이 과정이 제대로 진행되지 않는다면, 위의 설치 과정 중 go env 에 기록된 환경 변수의 경로 값들이 잘못 설정된 것입니다.)

$ go get -u github.com/gin-gonic/gin
package main

import (
  "net/http"

  "github.com/gin-gonic/gin"
)

func main() {
  r := gin.Default()
  r.GET("/ping", func(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{
      "message": "pong",
    })
  })
  r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

저희의 첫 웹 서버 코드입니다! 사실 아래에 달린 주절주절 설명들은, Go를 처음 접하신 분들에게는 잘 이해가 되지 않게 느껴질 수 있을 거라고 봅니다. 특히 문법 공부를 잠시 미뤄둔 채 블로그 글을 읽으신다면 뛰어넘고 기능의 동작만 봐주시면 좋겠습니다.

r := gin.Default() 는 gin package 중 Default 라는 함수를 실행 하는 것 입니다. 리턴 값으로 *gin.Engine 이라는 구조체(Struct) 데이터 구조를 넘겨줍니다. 여기서 *gin.Enginegin 패키지의 핵심 요소인 엔진(Engine)을 가리키는 포인터 타입 입니다.

그리고 그 엔진은, 더 정확하게는 엔진을 포함하는 RouterGroup Struct는 REST API 요청을 핸들링하는, 그리고 그 외의 Method들을 구현 합니다.

쉽게 생각하면, r.GET("/ping", func(c *gin.Context){ … } 와 같은 방식을 쓰면 HTTP의 GET, POST, DELETE, PATCH, PUT 등의 기능을 사용할 수 있다고 생각하시면 됩니다. 저희가 javascript 상에서 axios 라이브러리를 활용해 get 요청으로 markdown 데이터를 얻어온 것 처럼요.

그럼 함수가 어떤 input 값을 받는 지 확인해봅시다. 가장 먼저 relativePath를 string으로 넣어줘야 합니다. 말 그대로 경로가 되겠습니다. 그 뒤로는 함수를 보내는데, Context 구조체 포인터를 input 값으로 얻는 함수를 넣어줘야 합니다.
Context 구조체는 gin 패키지에서 가장 중요한 부분이라고 합니다. 흐름을 관리하고, 요청의 JSON을 검증하고, JSON 응답을 렌더링 할 수 있게 하는 구조체 입니다. 이 안에 이제 net/http의 요소들이 들어가 있습니다. responseWriter, *http.Request 가 그 대표적인 요소인데, HTTP Response에 데이터를 쓰기 위한 Writer와 HTTP Request 입력 데이터 입니다.

이런 짧은 코드 안에도 수많은 작업이 뒷단에 숨겨져 있다는 사실이 참 저를 겸허하게 만들곤 합니다.

만약 저희가 localhost:8080/ping의 경로를 브라우저에서 접속 한다면, 저희의 웹서버는 JSON의 형태로 {"message": "pong",}를 보내며, http.StatusOK 즉 200 코드와 함께 응답할 것 입니다. go run . 를 실행해 접속해봅시다.

응답이 제대로 왔습니다! 좋아요. 이제 저희는 프론트엔드에서 요청할 여러 요구에 대해 간단히 정리해보는 시간을 가져야 할 것 같습니다. 그리고 정리한 내용을 토대로 gin 패키지를 이용해 구현하면 되겠습니다.

어떻게 시스템을 구성할까요?

보통의 경우에 Vue.js를 빌드하고 나서의 index.html 파일을 Apache, Nginx, Envoy 등의 웹서버를 사용하여 연결해 서비스하는 것이 보통입니다. 아니면, golang 서버에서도 html 파일을 serving 할 수 있습니다. 허나 저희 백엔드 서버의 목적은 클라언트에게 html을 전달하는 것이 아닙니다. github.io를 이용해 블로그를 운영할 것 입니다.

즉, 백엔드 서버는 markdown 파일과 그에 종속된 사진 파일들을 관리하고 클라이언트에 보내주는 용도입니다.

클라이언트는 서버에 두 가지의 요청을 보낼 수 있습니다. 게시글 목록 리스트를 받아오는 요청 하나와, markdown 파일 내용 전체에 대한 요구 입니다. 후자의 경우에는 저희가 vue.js 코드 내에서 axios를 사용해 비슷한 과정을 만들어 놓았습니다.

저는 게시글을 notion에서 작성하곤 합니다. notion에서 작성 후 markdown으로 파일을 추출해보면, 사진과 markdown 파일이 모아진 폴더 구조로 생성이 되는데요. 최대한 이 형식을 가져가려고 합니다.

만약 제가 어딘가에 게시글 단위의 폴더를 차곡차곡 쌓아 놓는다면, Go 웹서버에서 그 파일들을 스캔하고, 목록을 뽑아내서 클라이언트에게 적절한 자료구조 형태로 보내줄 수 있게 됩니다. 혹은 데이터베이스를 만들어서 정보를 차곡차곡 쌓아 둬야 하겠죠.

클라이언트는 메인 페이지나 /posts 경로에서 가장 먼저 게시글 목록을 마주하게 됩니다. 필요한 정보는 제목, 내용 앞부분, 그리고 게시 날짜와 읽는 시간입니다. 고민이 되는 부분은 이것들을 어떻게 효율적으로 전달해 줄 것 인가 입니다.

  • 만약 게시글이 10개, 20개를 넘어 100개 이상이 될 때, 프론트엔드에 목록 전부를 넘겨줘야 할까요? 프론트엔드에서는 확실히 pagination 기능을 사용해야겠습니다. 100개 이상의 게시글 목록을 순차에 따라 예를 들면 10개씩 나눠서 보내주는 것이 효율적일 겁니다.
  • 게시글의 내용 앞 부분, 그리고 게시 날짜와 읽는 시간은 어떻게 보내줘야 할까요? 저희는 vue.js 코드로 markdown 하나에서 이를 계산하는 로직은 만들었습니다만, 리스트 전부에 대해서 위의 과정을 수행하는 것은 고려해본 적이 없습니다. 그리고 또, 프론트엔드에서 위의 과정을 수행한다면, 서버와 클라이언트 사이의 HTTP 통신 중에 보내야 할 데이터의 양이 과도하게 많아집니다. 아니면 백엔드에서도 markdown을 파싱해서, 설정에 관련된 부분만 응답으로 보내줄 수도 있겠습니다. 저는 이 방법을 채택하겠습니다.
  • 추후에 DB를 연동하게 되면 어떤 구조가 될 수 있을까요? DB에 CRUD 하는 로직을 백엔드에서 구축해 놓는다면, 그리고 Velog 처럼 클라이언트가 직접 게시물을 등록할 수 있게 UI를 만들어 놓는 방향이 추후의 발전 방향 아닐까요? 이번 포스트에서는 곧장 DB를 만들지는 않습니다.

파일 보내주기

여러가지 생각이 스쳐지나가는데, 우선 할 수 있는 것부터 해봅시다. 가장 간단한 작업부터 해봐야 감이 좀 오겠네요. 바로 파일 보내주기 입니다.

~/blog_data 경로에 다음과 같은 파일들을 만들어 주겠습니다. (~ 는 리눅스에서 홈 경로를 뜻 합니다. 저는 syyang이라는 계정을 사용하고 있으니 리눅스에서 제 홈경로는 /home/syyang/ 입니다)

tree
.
├── vue1
│   ├── images
│   │   └── threecubes.png
│   └── vue1.md
└── vue2
    └── vue2.md

폴더 명을 test에서 blog로 바꾸고, go mod init을 새로 시작해봅시다.

그리고 main.go 를 다음과 같이 수정 해줍시다. ping 이라는 경로로 테스트 할 필요가 없으니 지워주겠습니다. 가장 먼저 static 파일을 건내주는 gin의 기능을 추가 해봅시다. markdown을 html으로 파싱해보면, 그림을 요구하는 img 태그들이 존재합니다. 백엔드 서버에 그 요청을 처리하는 경로를 만들어 줄 것 입니다.

package main

import (
	"net/http"
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	r.Static("/image", "/home/syyang/blog_data/")

	r.Run("192.168.15.246:8080") // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

r.Static은 사용자가 ~/image/{filename} 경로로 요청을 보낼 때, /home/syyang/blog_data/ 라는 경로 아래서 {filename} 을 찾아보내 줍니다. 저는 vue1/images/threecubes.png 라는 이미지를 경로 밑에 넣어두었습니다. 브라우저 상에서http://192.168.15.246:8080/image/vue1/images/threecubes.png 경로를 확인하니 다음처럼 요청에 응답하는 것을 확인 했습니다. 해당하는 이미지 파일이 정상적으로 전송된 것입니다.

아, r.Run("192.168.15.246:8080") 에서 192.168.15.246은 저의 사설 서브넷 아이피 입니다. 저곳을 빈 공간으로 두면 원래는 loopback ip(127.0.01.)와 8080포트가 디폴트 입니다.

잠깐, markdown도 하나의 static 파일인데, 이 역시도 간단하게 불러와 지는 것 아닐까요?

package main

import (
	"net/http"
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	r.Static("/image", "/home/syyang/blog_data/")
  r.Static("/markdown", "/home/syyang/blog_data/")

	r.Run("192.168.15.246:8080") // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

http://192.168.15.246:8080/markdown/vue1/vue1.md 경로를 확인해 어떤 결과가 보여지는 지 확인 해봅시다. 제 눈에는 markdown 파일의 내용이 그대로 들어와 있는 것이 보입니다. 이 경로에 맞게 추후에 Vue.js를 수정해볼 것 입니다.

게시글 목록 리스트 만들기

게시글 목록 전달 방식은, 백엔드에서 markdown을 파싱하고 설정에 관련된 부분을 잘 갈무리하여 JSON 응답으로 보내는 것을 선택하겠습니다. 중요한 점은, 이 데이터 구조가 클라이언트 입장에서 가장 처음 맞닥뜨리는 데이터라는 것입니다. 즉, /경로와 /posts경로에서 마주한 카드의 근간이 되는 데이터 입니다.

더해, 이 데이터를 근간으로 블로그 방문객이 카드 중 하나를 클릭하면, vue.js에선 데이터 안에 기록된 ‘markdown 파일 저장 폴더 경로’ 정보로 자연스럽게 게시글 디테일에 필요한 마크다운 파일을 요청하게 되겠습니다.

이제 go project 폴더를 다음과 같이 만들어주겠습니다. main.go에 모든 파일을 집어 넣어 줄 순 있지만, 코드를 기능 별로 잘게 나누는 편이 코드 유지 관리에 더 좋습니다.

$ tree
.
├── go.mod
├── go.sum
├── main.go
└── markdown
    └── markdown.go

이제, main.go 파일에 /posts 경로를 추가합니다. 또한, 아래 gin.HandlerFunc를 리턴하는 blogPostsHandler() 함수를 만들어줍니다. Go에서는 같은 파일 내부에서만 사용하는 변수의 첫 번째 문자가 소문자 인지, 대문자 인지에 따라 그 용도가 private 한지, public 한지 결정 합니다. 소문자로 시작하기 때문에 main 패키지 내부에서만 쓰이는 함수 입니다.

package main

import (
	"blog/markdown"
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	r.GET("/posts", blogPostsHandler())

	r.Run("192.168.15.246:8080") // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

func blogPostsHandler() gin.HandlerFunc {
	return func(c *gin.Context) {

		list, err := markdown.FindFolderList("/home/syyang/blog_data") // blog_data 폴더의 경로를 적어줍시다.
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		}
		response := gin.H{
			"data": list,
		}
		c.JSON(http.StatusOK, response)
	}
}

여기서 이제 blog/markdown 라는 패키지를 불러왔는데 미리 만들어 놓았던 markdown.go를 다음처럼 만들어줍니다. 이 패키지 안에 구현 할 기능으로 blog_data 경로에 있는 게시글 폴더들을 추적할 것 입니다. 이 과정은 게시글 미래에 DB가 잘 관리되고 있다면 필요 없는 기능이기도 합니다. 코드의 디테일을 굳이 설명드리진 않겠습니다.

package markdown

import (
	"os"
	"path/filepath"
)

func FindFolderList(path string) ([]string, error) {
	var folders []string

	// Walk the directory starting from the given path
	err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
		// Check if it's a directory and not the root directory itself
		if err == nil && info.IsDir() && filePath != path {
			// Get the relative folder path
			relPath, err := filepath.Rel(path, filePath)
			if err != nil {
				return err
			}
			// Add the folder name to the list
			folders = append(folders, relPath)
		}
		return nil
	})

	if err != nil {
		return nil, err
	}

	return folders, nil
}

이제, Go를 다시 시작하고 웹 서버 동작을 확인하면, {"data":["vue1","vue2"]} 의 결과를 내뱉는 것을 확인하실 수 있습니다. Go는 함수의 리턴 값으로 여러 개를 내뱉을 수 있습니다. 다른 언어처럼 try & catch 문으로 에러 핸들링을 하는 것에 비해 더 간결해 질 수 있다는 것이 이유라고 합니다.

그래서 Go 에서 정말 많이 볼 수 있는 코드가 있습니다. 에러 처리입니다. nil Go에서 Null 같은 친구라고 보시면 됩니다. err가 없는 게 아니라면 (= 에러가 있다면) 함수의 리턴 값으로 err를 넘겨주는 것을 보실 수 있으십니다.

	if err != nil {
		return nil, err
	}

자, 하나의 슬라이스(리스트) 안에 두 개의 폴더 이름이 들어와 있음은 확인하였는데요. 저는 이 안에 들어와 있는 요소들이 파일 생성 날짜의 순서대로 정렬 되었으면 합니다. info.ModTime() , 폴더의 수정 시간을 이용해 정렬 해봅시다.

package markdown

import (
	"os"
	"path/filepath"
	"sort"
)

type folderInfo struct {
	Name    string `json:"name"`
	ModTime int64  `json:"modTime"`
}

func FindFolderList(path string) ([]folderInfo, error) {
	var folders []folderInfo

	// Walk the directory starting from the given path
	err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
		// Check if it's a directory and not the root directory itself
		if err == nil && info.IsDir() && filePath != path {
			// Add the folder information to the list
			folders = append(folders, folderInfo{
				Name:    filePath,
				ModTime: info.ModTime().Unix(),
			})
			return filepath.SkipDir
		}
		return nil
	})

	if err != nil {
		return nil, err
	}

	// Sort the folders by modification time (latest first)
	sort.SliceStable(folders, func(i, j int) bool {
		return folders[i].ModTime > folders[j].ModTime
	})

	return folders, nil
}

결과는 다음과 같습니다.

[
	{"name":"/home/syyang/blog_data/vue1","modTime":1690256494},
	{"name":"/home/syyang/blog_data/vue2","modTime":1690249879}
]

JSON 이 그럴싸하게 준비가 되었습니다. 하지만, 여기서 멈추지 않고, 저희가 사용했던 자바스크립트의 front-matter와 똑같은 역할을 해주는 부분을 만들면 좋겠습니다. 검색해보니, Go에도 frontmatter라는 라이브러리가 존재합니다. 만약 위의 코드에서 markdown 파일에 적어 놓았던, 설정들을 추출하는 과정을 넣으려면 FindFolderList 함수가 도는 가운데 진행되면 좋겠습니다.

package markdown

import (
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/adrg/frontmatter"
)

type folderInfo struct {
	Name    string `json:"name"`
	ModTime int64  `json:"modTime"`
	Matter  matter `json:"matter"`
}

type matter struct {
	Title string   `yaml:"title"`
	Tags  []string `yaml:"Tags"`
}

func FindFolderList(path string) ([]folderInfo, error) {
	var folders []folderInfo

	// Walk the directory starting from the given path
	err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
		// Check if it's a directory and not the root directory itself
		if err == nil && info.IsDir() && filePath != path {
			// Read the markdown file
			content, err := os.ReadFile(filePath + "/" + info.Name() + ".md")
			if err != nil {
				fmt.Println(err)
			}

			var matter matter
			_, err = frontmatter.Parse(strings.NewReader(string(content)), &matter)
			if err != nil {
				fmt.Println(err)
			}

			// Add the folder information to the list
			folders = append(folders, folderInfo{
				Name:    filePath,
				ModTime: info.ModTime().Unix(),
				Matter:  matter,
			})
			return filepath.SkipDir
		}
		return nil
	})

	if err != nil {
		return nil, err
	}

	// Sort the folders by modification time (latest first)
	sort.SliceStable(folders, func(i, j int) bool {
		return folders[i].ModTime < folders[j].ModTime
	})

	return folders, nil
}

과정이 추가되었습니다. content, err := os.ReadFile(filePath + "/" + info.Name() + ".md") 를 이용해, 폴더 이름과 같은 .md 확장자의 파일 내용을 읽어줍니다. 그 내용이 content 변수에 들어가 있습니다.

이제 저희는 frontmatter 라이브러리의 기능을 사용해 설정을 읽어냈습니다.

frontmatter.Parse(strings.NewReader(string(content)), &matter) 입니다. 이 과정은 매 폴더를 찾을 때 마다 var matter matter로 새로 정의한 matter 구조체에 데이터를 업데이트 해줄 것입니다.

가만보니 위에서 정의한 matter 구조체에 yaml:"title" 와 같은 부가적인 요소가 붙어있습니다. 마크다운 파일이 yaml이 아니라도 괜찮습니다. 결과는 이렇습니다.

[
	{
		"name":"/home/syyang/blog_data/vue2",
		"modTime":1690249879,
		"matter":
			{
				"Title":"Vue.js로 블로그 UI 만들기 (2)",
				"Tags":["vue.js","blog"]
			}
	},
	{
		"name":"/home/syyang/blog_data/vue1",
		"modTime":1690256494,
		"matter":
			{
				"Title":"Vue.js로 블로그 UI 만들기 (1)",
				"Tags":["vue.js","blog"]
			}
	}
]

임의로 두 가지의 옵션만 읽어내겠다라는 생각으로 matter 구조체를 만들었지만, 저희는 content 에 대한 내용과, date 역시 뽑아내야 합니다. 그런데 태그와 날짜 그리고 컨텐츠 요약 내용이 마크다운 파일에 공백으로 남아도 된다면, 생략할 수 있게끔 구조체를 수정해봅시다.

type matter struct {
	Title string   `yaml:"title"`
	Tags  []string `yaml:"Tags,omitempty"`
	Content string `yaml:"content,omitempty"`
	Date string   `yaml:"date,omitempty"`
}

지금 제 md 파일에는 content 옵션이 들어가 있지 않습니다. 에러를 뱉어 내는지 확인 해봅시다. 문제없이 동작합니다!

Date 요소에 대해 좀 더 고민 해봅시다. 지금은 저희가 수동으로 폴더를 만들고, 폴더의 수정 시간을 기반으로 파일을 정렬하고 있습니다. 폴더 수정 시간은 매력적이지 않습니다. 그래서 저는 가장 먼저 markdown 파일에 적어둔 Date 값을 기준으로 정렬하도록 코드를 수정했습니다.

허나 만약 md 파일에 생성 시간 Date가 설정 옵션으로 기록되지 않으면, 시간이 빈 공백값으로 클라이언트에게 날라가겠네요. 보완의 의미로 폴더 수정 시간을 사용하게끔 만들어봅시다. 아래 부분을 frontmatter.Parse가 작동한 다음에 추가해주었습니다.

modTime := info.ModTime().Format("2006-01-02T15:04:05-07:00")
if matter.Date == "" {
	matter.Date = modTime
}

여전히 폴더 수정 시간이라는 존재가 꺼림칙 합니다. 그래도 다행이 추후에 DB CRUD가 만들어지면 Create가 이루어지는 때 시간이 자동으로 기록되게 만들 수 있습니다. 그렇게 되면 시간은 빈 공간으로 남진 않습니다.

위의 내용을 모두 반영한 수정 코드는 다음과 같습니다. 더 이상 folderInfo 구조체에 슬라이스 내용을 정렬하기 위해 고려한 ModTime이 들어가지 않습니다.

package markdown

import (
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/adrg/frontmatter"
)

type folderInfo struct {
	Name   string `json:"name"`
	Matter matter `json:"matter"`
}

type matter struct {
	Title   string   `yaml:"title"`
	Tags    []string `yaml:"Tags,omitempty"`
	Content string   `yaml:"content,omitempty"`
	Date    string   `yaml:"date,omitempty"`
}

func FindFolderList(path string) ([]folderInfo, error) {
	var folders []folderInfo

	// Walk the directory starting from the given path
	err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
		// Check if it's a directory and not the root directory itself
		if err == nil && info.IsDir() && filePath != path {
			// Read the markdown file
			content, err := os.ReadFile(filePath + "/" + info.Name() + ".md")
			if err != nil {
				fmt.Println(err)
			}

			var matter matter
			_, err = frontmatter.Parse(strings.NewReader(string(content)), &matter)
			if err != nil {
				fmt.Println(err)
			}

			modTime := info.ModTime().Format("2006-01-02T15:04:05-07:00")
			if matter.Date == "" {
				matter.Date = modTime
			}

			// Add the folder information to the list
			folders = append(folders, folderInfo{
				Name:   filePath,
				Matter: matter,
			})
			return filepath.SkipDir
		}
		return nil
	})

	if err != nil {
		return nil, err
	}

	// Sort the folders by modification time (latest first)
	sort.SliceStable(folders, func(i, j int) bool {
		time1, err := time.Parse(time.RFC3339, folders[i].Matter.Date)
		if err != nil {
			fmt.Println("Error parsing time1:", err)
		}
		time2, err := time.Parse(time.RFC3339, folders[j].Matter.Date)
		if err != nil {
			fmt.Println("Error parsing time1:", err)
		}
		return time1.After(time2)
	})

	return folders, nil
}

이제 폴더의 개수가 아주 많아질 때를 대비해 paginator 숫자에 따라 적절하게 10개 정도만 파싱하고 싶습니다. 이는 Slice로 만든 folders 변수를 적당하게 커트해주면 됩니다. 다만, 그렇기 위해서는 /posts를 get 할 당시에, paginator의 숫자가 필요합니다.

main.go 에서 경로를 다음과 같이 바꿔 줍니다. 그리고 핸들러에서 number params를 수신합니다. paginatorNumber 에 따라 슬라이스의 크기를 조절해줍니다.

r.GET("/posts/:number", blogPostsHandler())
func blogPostsHandler() gin.HandlerFunc {
	return func(c *gin.Context) {

		number := c.Params.ByName("number")
		paginatorNumber, err := strconv.Atoi(number)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		}

		folders, err := markdown.FindFolderList("/home/syyang/blog_data")
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		}

		length := len(folders)

		if paginatorNumber-1 > length/10 {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Paginator number is too big"})
		} else if paginatorNumber*10 > length {
			c.JSON(http.StatusOK, folders[(paginatorNumber-1)*10:])
		} else {
			c.JSON(http.StatusOK, folders[(paginatorNumber-1)*10:paginatorNumber*10])
		}
	}
}

[(paginatorNumber-1)*10 : (paginatorNumber) * 10]의 범위에 따라 슬라이스 되는 데이터의 양이 달라집니다. 다만 슬라이스 양보다 더 많은 영역을 지정하면 에러가 난다는 점이기에, 조건문을 이용해 적절하게 문제를 해결해줬습니다. 또한, paginatorNumber 가 지나치게 크게 잡혀 들어올 때를 대비해 에러 핸들링 까지 만들어 주었습니다.

이제 얼추 필요한 요소는 갖췄습니다. 다음 게시글에서 다시 vue.js로 넘어와 프론트엔드를 수정 해보겠습니다.

profile
In the realm of astronomy once, but now becoming a dream-chasing gopher

0개의 댓글