본문 바로가기
프로그래밍/Go

고 (Golang) | 이것만 알면 나도 개발 전문가 | Cobra 를 이용한 CLI 프로그램 정석으로 구현하기

by K-인사이터 2023. 2. 19.
반응형

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

안녕하세요

K-IN 입니다.

 

서론

"이것만 알면 나도 개발 전문가" 시리즈입니다. 

이번시간에는 Cobra 를 이용해 CLI 프로그램 구조를 잡는 빠르고 정확한 방법을 소개해드리겠습니다. 

 

개발 프로젝트의 종류는 매우 다양합니다. 

라이브러리, 유틸리티, 웹 어플리케이션, 데몬, 미들웨어, GUI 프로그램 등등 매우 다양합니다. 

이중에 우리가 실제 업무에 자주 사용하는 프로그램의 타입은 복잡한 GUI 가 아닌 바로 CLI 프로그램입니다. 

 

가령 리눅스에서 cat, grep, ping 등의 명령을 사용해보았을 것입니다. 

이 모든 것들이 CLI 프로그램입니다. 

 

CLI 란?

Command Line Interface 의 줄임말로 단어의 앞글자를 따서 대문자로 표기한 용어입니다. 씨.엘.아이 라고 읽습니다. 

이러한 프로그램은 아래와 같은 상황에서 많이 사용합니다. 

 

  • 웹 어플리케이션에서 작업을 대행할 때
  • 백엔드에서 별도의 프로그램이 병렬적으로 구동이 필요할 때 
  • 특정 목적에 부합하는 특수한 프로그램을 제작할 때
  • 등등

 

일례로 웹 어플리케이션에서 작업을 대행한다는 것은 어떤 의미일까요? 

일반적으로 웹 어플리케이션은 빠른 응답성을 보장해야 합니다.

사용자를 30초 이상 대기시킬 수는 없는 노릇입니다. 

 

이때 사용할 수 있는 방법은 웹 어플리케이션이 별도의 프로세스를 생성해서 작업을 맡긴 뒤에 

빠르게 사용자에게 응답을 주는 선택을 하게됩니다. 

 

갈수록 복잡해지는 어플리케이션의 수요에 맞추기 위한 불가피한 선택입니다.

물론 처음 개발을 접할 경우 멋지고 화려한 GUI, 웹 개발이 눈에 띌 것입니다.

 

그러나 진정한 숨은 공신은 뒤에 숨겨져 있는 수많은 CLI 프로그램들입니다. 

 

서론이 길었네요. 빠르게 Cobra 에 대해서 알아보겠습니다. 

 

CLI 개발을 위한 가장 편한 지름길 Cobra 

Go 언어는 CLI 를 개발하기 매우 편리합니다. 특히 데몬, CLI, API 등을 구성하기에는 최적이라고 할 수 있습니다.

일례로 넷플릭스 등의 수많은 기업들이 Go 를 이용하고 있죠. 

 

그리고 Cobra 는 Go 프로젝트에서 CLI 개발을 위한 환경을 만들어주는데 뛰어납니다. 

시간을 내서 한번 아래의 사이트를 방문해 소개글을 읽어보도록 할까요? 

 

https://github.com/spf13/cobra

 

GitHub - spf13/cobra: A Commander for modern Go CLI interactions

A Commander for modern Go CLI interactions. Contribute to spf13/cobra development by creating an account on GitHub.

github.com

 

Cobra 를 이용한 CLI 프로젝트 구조 잡기


지금까지 CLI 란 무엇이고 Go 개발 프로젝트에서 Cobra 를 이용한다는 것을 배웠습니다.

지금 부터는 실제로 Cobra 를 이용해 개발 프로젝트를 구성하고 간단한 프로그램을 만들어 보도록 하겠습니다. 

 

만약, go 를 아직 설치하지 않으신 분들은 아래의 글을 우선 실습하고 오면 더욱 빠르게 진행할 수 있습니다. 

2023.01.27 - [Go] - 고(Golang) | Tutorial | 5분만에 Golang 개발 환경 세팅하기

 

우선, cobra-cli 를 우선 설치해보도록 할까요? 

go install github.com/spf13/cobra-cli@latest

 

homebrew 를 통해 go 를 설치하였다면 설치 경로는 $HOME/go 일 것입니다. 

그리고 go install 명령을 cobra-cli 유틸리티를 $HOME/go/bin 폴더에 설치합니다. 

 

자 이제 모든 준비가 완료되었네요. 

폴더를 만들어 볼까요? 

 

mkdir go-cli-app01
cd go-cli-app01

# 반드시 go mod init 부터 실행 합니다. 
go mod init k-in.com/go-cli-app01
# 다음으로 cobra-cli 유틸을 이용해 init 해주세요.
cobra-cli init

 

생성된 폴더의 구조를 한번 보겠습니다. 

cmd 폴더 그리고 main.go 등등은 cobra-cli 가 자동으로 생성한 내용입니다. 

 

main.go 파일부터 살펴볼까요? 

main 함수 내에 cmd 폴더에서 정의한 패키지를 임포트하는 구문

그리고 cmd 패키지를 실행(Excecute)하는 구문이 생성되어 있습니다. 

꽤나 심플한 구조입니다. 

/*
Copyright © 2023 NAME HERE <EMAIL ADDRESS>

*/
package main

import "k-in.com/go-cli-app01/cmd"

func main() {
	cmd.Execute()
}

 

이번에는 root.go 파일의 내용을 보겠습니다. (불필요한 주석은 제가 미리 삭제했습니다.)

먼저 아래의 소스에서 특징을 추출해볼까요? 

cobra.Command, Execute 그리고 init 이 있습니다. 

 

1. rootCmd 변수에 할당되는 cobra.Command 

cobra-cli init 명령어를 통해서 최초로 생성되는 rootCmd 입니다.

프로그램의 어떠한 플래그나 서브 커맨드 없이도 실행됩니다. 

 

2. Execute 함수

main 패키지인 main.go 에서 사용하는 함수입니다. 

대부분 변경하지 않습니다. 

 

3. init 함수 

서브 커맨드를 등록하거나 Flag 를 설정하는 역할을 합니다. 

여기서 플래그(Flag)란 명령어의 옵션과 같은 것으로 Cobra 에서는 이를 Flag 라고 부릅니다.

Flag 의 종류에는 Persistent Flag 와 Local Flag 가 있는데 관련 내용은

다음 장의 "미국식 아재 개그를 출력하는 CLI 프로그램"을 작성하면서 설명드리겠습니다. 

 

/*
Copyright © 2023 NAME HERE <EMAIL ADDRESS>
*/
package cmd

import (
	"os"

	"github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
	Use:   "go-cli-app01",
	Short: "A brief description of your application",
	Long: `A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
	// Run: func(cmd *cobra.Command, args []string) { },
}

func Execute() {
	err := rootCmd.Execute()
	if err != nil {
		os.Exit(1)
	}
}

func init() {
	rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

 

미국식 아재 개그를 출력하는 CLI 프로그램

가끔 업무를 하다보면 간단한 농담 혹은 음악 감상을 통해 지루함을 달래기도 합니다. 

이번에 만들어볼 프로그램은 "미국식 아재 개그를 출력"하는 간단한 CLI 프로그램입니다.

 

이를 위해 아래의 두 가지 구현사항을 떠올렸고 즉시 코딩을 진행해보겠습니다. 

 

1. [program_name] joke 라는 명령어를 입력하면 랜덤하게 아재 개그를 출력 

2. [program_name] joke --term "<search_word>" 라는 명령어를 입력하면 조회 키워드에 맞는 아재 개그를 출력

3. [program_name] joke --verbose 라는 명령어를 입력하면 프로그램의 동작 과정을 출력으로 보여줌

 

구현을 위해서 joke 라는 커맨드를 등록해야 합니다. 아래의 명령어를 입력해볼까요? 

cobra-cli add joke

 

명령어 입력과 함께 cmd 폴더에 joke.go 라는 파일이 만들어 졌습니다.

내용은 root.go 가 처음 만들어 졌을때와 다르지 않습니다. 

그러나 차이점은 Execute 함수가 없다는 점과 init 함수의 구문이 미묘하게 다릅니다. 

 

cobra-cli 를 통해 joke 커맨드를 등록한 후 파일 구조

 

init 을 보면 rootCmd 에 신규로 생성한 jokeCmd 명령이 등록되는 뉘앙스의 코드가 보입니다. 

네 맞습니다. rootCmd 는 어떠한 명령어 없이 실행되는 경우이며 jokeCmd 는 그 하위에 위치한다는 개념입니다. 

가령 ./[program_name] 과 같이 입력하면 rootCmd 의 Run 에 정의한 로직이 구동됩니다. 

 

/*
Copyright © 2023 NAME HERE <EMAIL ADDRESS>
*/
package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
)

// jokeCmd represents the joke command
var jokeCmd = &cobra.Command{
	Use:   "joke",
	Short: "A brief description of your command",
	Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println("joke called")
	},
}

func init() {
	rootCmd.AddCommand(jokeCmd)
}

 

1. [program_name] joke 라는 명령어를 입력하면 랜덤하게 아재 개그를 출력 

자! 구현 사항 첫번째 입니다.

 

joke 라는 명령어를 입력하면 랜덤하게 아재 개그를 출력하도록 코딩을 해봅시다. 

 

우선, 아래의 코드를 joke.go 에 넣어주세요.

 

Free API 를 통해서 joke 를 랜덤하게 혹은 특정 키워드로 검색하는 함수들의 정의입니다. 

 

Unmarshall 및 struct 타입에 대한 내용은 별도의 글에서 다룰 예정이니

지금은 아 이렇게 사용하는구나 정도만 익히셔도 충분합니다. 

 

type SearchResult struct {
	Results    json.RawMessage `json:"results"`
	SearchTerm string          `json:"search_term"`
	Status     int             `json:"status"`
	TotalJokes int             `json:"total_jokes"`
}

type Joke struct {
	JokeId string `json:"id"`
	Joke   string `json:"joke"`
	Status int    `json:"status"`
}

func getRandomJoke() {
	url := "https://icanhazdadjoke.com/"
	responseBytes := getJokeData(url)
	joke := Joke{}

	if err := json.Unmarshal(responseBytes, &joke); err != nil {
		log.Printf("Could not unmarshal response - %v", err)
	}

	fmt.Println(string(joke.Joke))
}

func getJokeDataWithTerm(jokeTerm string) (int, []Joke) {
	url := fmt.Sprintf("https://icanhazdadjoke.com/search?term=%s", jokeTerm)
	responseBytes := getJokeData(url)

	jokeListRaw := SearchResult{}

	if err := json.Unmarshal(responseBytes, &jokeListRaw); err != nil {
		log.Printf("Could not unmarshal reponseBytes. %v", err)
	}

	jokes := []Joke{}
	if err := json.Unmarshal(jokeListRaw.Results, &jokes); err != nil {
		log.Printf("Could not unmarshal reponseBytes. %v", err)
	}

	return jokeListRaw.TotalJokes, jokes

}

func randomiseJokeList(length int, jokeList []Joke) {
	rand.Seed(time.Now().Unix())

	min := 0
	max := length - 1

	if length <= 0 {
		err := fmt.Errorf("No jokes found with this term")
		fmt.Println(err.Error())
	} else {
		randomNum := min + rand.Intn(max-min)
		fmt.Println(jokeList[randomNum].Joke)
	}
}

func getJokeData(baseAPI string) []byte {
	request, err := http.NewRequest(
		http.MethodGet,
		baseAPI,
		nil,
	)

	if err != nil {
		log.Printf("Could not request a dadjoke - %v", err)
	}

	request.Header.Add("Accept", "application/json")

	response, err := http.DefaultClient.Do(request)
	if err != nil {
		log.Printf("Could not make a request - %v", err)
	}

	responseBytes, err := ioutil.ReadAll(response.Body)
	if err != nil {
		log.Printf("Could not read response body - %v \n", err)
	}

	return responseBytes
}

 

 

우리는 joke 라는 명령어를 입력하면 곧바로 랜덤한 미국 아재 개그를 출력하도록 구현해야 합니다.

따라서, jokeCmd 의 Run 필드의 함수를 수정해야 합니다. 

 

cobra-cli add joke 라는 명령어를 통해 "fmt.Println("joke called")" 코드가 생성되어 있을 것입니다.

지우고 아래와 같이 입력해주세요.

 

// 생략 
var jokeCmd = &cobra.Command{
	Use:   "joke",
	Short: "미국식 아재 개그를 출력하는 커맨드",
	Long:  `미국식 아재 개그는 영문으로 출력되는 ..... 이하 생략 ...`,
	Run: func(cmd *cobra.Command, args []string) {
		getRandomJoke()
	},
}
// 생략

 

자 그리고 바로 명령어를 실행해볼까요?

go run main.go joke 와 같이 명령어를 사용하면 곧바로 프로그램은 아재 개그를 출력합니다. 

랜덤하게 출력되는 미국식 아재 개그

 

쉬어가는 광고타임

코딩에 있어서 제일 중요한 것은 좋은 환경입니다.

저는 항상 작업을 할 때 애플 제품을 사용하고 있습니다.

 

애플 제품은 제품들 끼리 모아두면 시너지가 두세배가 된다고 합니다.

편안한 환경에서 조용한 음악과 함께 코딩을 하면서 새로운 가치를 창출해 해나가는 여러분의 모습을 상상해볼까요? 

 

사진: Unsplash 의 Humphrey Muleba

 

Apple 2020 맥북 에어 13, 스페이스 그레이, M1, 256GB, 8GB, MGN63KH/A, MAC OS Apple 2022 에어팟 프로 2세대 블루투스 이어폰, 화이트 Apple 에어팟 맥스 블루투스헤드셋, 스페이스 그레이 Apple 2021 맥북프로 14, 스페이스그레이, M1 Pro 8코어, GPU 14코어, 512GB, 16GB, MKGP3KH/A, MAC OS Apple 정품 아이폰 14 Pro 자급제, 스페이스블랙, 256GB Apple 2022 맥스튜디오, M1 Max 10코어, GPU 24코어, 32GB, 512GB

 

 

2. [program_name] joke --term "<search_word>" 라는 명령어 구현

자! 두번째 구현 사항입니다. 이제는 플래그에 대해 간략하게 설명하도록 하겠습니다. 

앞서 Local Flag 와 Persistent Flag 가 있다고 소개해드렸습니다. 

여기서 --term 은 바로 Local Flag 에 속합니다. 

 

그렇다면 Persistent Flag 는 어디에 쓰일까요? 

바로 --help 와 --verbose 와 같이 하위 커맨드에서도 제공될 옵션에 사용됩니다.

(단, --help 는 Cobra 에서 기본적으로 제공이됩니다.)

 

간략한 예시를 들어보겠습니다. 

현재 jokeCmd 는 rootCmd 의 개념상 하위에 위치합니다. 

그런데, joke2Cmd 가 새롭게 생성되고 jokeCmd 하위에 있다고 가정해보겠습니다. 

 

그리고, joke2Cmd 가 jokeCmd 에서 정의한 Flag 를 동일하게 사용한다고 가정해보겠습니다.

이 경우엔 Local Flag 만으로는 공유가 되지 않습니다. 

Persistent Flag 를 써야하죠. 

 

rootCmd 

ㄴ jokeCmd

    ㄴ joke2Cmd 

 

다시 본론으로 돌아와 --term 플래그를 Local Flag 로 지정해서 검색어를 입력 받아 처리해보도록 하겠습니다. 

joke.go 소스코드에서 init 함수를 아래와 같이 수정해보겠습니다. 

func init() {
	rootCmd.AddCommand(jokeCmd)
	jokeCmd.Flags().String("term", "", "검색할 아재 개그의 키워드를 지정해주세요.")
}

 

그리고 "--help" 플래그를 통해서 Cobra 가 어떻게 출력하는지 살펴보겠습니다. 

term Local Flag 설정 후 --help 텍스트

 

이제 구현을 해볼까요? 

아래의 코드는 term 플래그에 정보가 전달될 경우에 getJokeDataWithTerm 함수를 이용해서 검색을 합니다. 

그리고 여러 건의 정보가 검색이되면 가장 상위에 있는 정보를 출력합니다. 

var jokeCmd = &cobra.Command{
	Use:   "joke",
	Short: "미국식 아재 개그를 출력하는 커맨드",
	Long:  `미국식 아재 개그는 영문으로 출력되는 ..... 이하 생략 ...`,
	Run: func(cmd *cobra.Command, args []string) {
		jokeTerm, _ := cmd.Flags().GetString("term")
		if jokeTerm != "" {
			_, jokes := getJokeDataWithTerm(jokeTerm)
			if len(jokes) > 0 {
				joke := jokes[0]
				fmt.Println(joke.Joke)
			}

		} else {
			getRandomJoke()
		}
	},
}

실행을 하면 잘 되는 것을 확인할 수 있습니다. 

3. [program_name] joke --verbose 구현 

그런데, 위 프로그램엔 문제가 있습니다. 

가령 아래와 같이 검색어와 일치하지 않을 경우엔 어떤 메시지를 출력하지 않아 여러 오해가 생길 수 있습니다.

API 서버가 죽었다던지 프로그램에 문제가 있다던지 등등이 파악이 되지 않습니다. 

 

이를 극복하기 위해 Persistent Flag 를 rootCmd 에 부여하여 --verbose 라는 옵션을 전역적으로 사용할 수 있도록 선언해보겠습니다. 

그리고, 이 옵션이 설정될 경우 프로그램은 세세한 내용을 사용자에게 알려주도록 프로그램을 고쳐보겠습니다. 

root.go 소스코드 파일로 돌아가 init 함수를 아래와 같이 고쳐볼까요? 

 

// Filename: root.go

// 생략 

func init() {
	rootCmd.PersistentFlags().BoolP("verbose", "v", false, "프로그램의 로직을 디버깅하는 로그가 출력됩니다.")
}

 

그리고 곧바로 joke.go 소스코드를 아래와 같이 고쳐봅시다. 

i) verbose 플래그 값을 입력습니다. 

ii) else 절을 만들어 검색된 결과가 없다면 DEBUG 로그를 남깁니다. 

var jokeCmd = &cobra.Command{
	Use:   "joke",
	Short: "미국식 아재 개그를 출력하는 커맨드",
	Long:  `미국식 아재 개그는 영문으로 출력되는 ..... 이하 생략 ...`,
	Run: func(cmd *cobra.Command, args []string) {
		jokeTerm, _ := cmd.Flags().GetString("term")
		isVerbose, _ := cmd.Flags().GetBool("verbose") // i)

		if jokeTerm != "" {
			_, jokes := getJokeDataWithTerm(jokeTerm)
			if len(jokes) > 0 {
				joke := jokes[0]
				fmt.Println(joke.Joke)
			} else {
				if isVerbose { // ii)
					log.Println("DEBUG: There are no jokes... :(")
				}
			}

		} else {
			getRandomJoke()
		}
	},
}

 

그럼 프로그램 실행 결과를 볼까요?

--verbose 옵션은 main.go 뒤에 혹은 joke 뒤에 놓여도 동일하게 동작합니다. 

또한, rootCmd 영역에 verbose 플래그를 지정할지라도 Persistent Flag 이기에

하위 커맨드도 사용이 가능함을 알 수 있었습니다. 

 

--verbose 옵션이 있을 경우

 

--verbose 옵션이 없을 경우

 

 

결론 

오늘은 Go 프로그래밍 시에 반드시 만나게될 Cobra 를 이용해 CLI 프로그램을 만들어 보았습니다.

또한, 서브 커맨드를 만들어 원하는 기능을 추가해보았습니다. 

마지막으로, Persistent Flag 와 Local Flag 의 차이점에 대해서 직접 눈으로 확인하는 시간을 가졌습니다. 

 

위에서 소개해드린 핵심원리만 기억하시면 앞으로도 헷갈리지 않고 Go 언어를 즐길 수 있습니다. 

 

이상으로 K-IN 이었습니다.

즐거운 하루되세요 

 

반응형