์ฝ˜ํ…์ธ  ๋Œ€ํ‘œ ์ด๋ฏธ์ง€ - ๐Ÿš€ Go์–ธ์–ด๋กœ ๋งŒ๋“œ๋Š” CLI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜

๐Ÿš€ Go์–ธ์–ด๋กœ ๋งŒ๋“œ๋Š” CLI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜

Cobra & Viper๋กœ ํ”„๋กœ์ฒ˜๋Ÿผ ๋ช…๋ นํ–‰ ๋„๊ตฌ ๊ฐœ๋ฐœํ•˜๊ธฐ

## ๐ŸŽฏ ์™œ CLI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค์–ด์•ผ ํ• ๊นŒ? ์•ˆ๋…•! ์˜ค๋Š˜์€ Go์–ธ์–ด๋กœ ๋ฉ‹์ง„ CLI(Command Line Interface) ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๋งŒ๋“œ๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด ์ด์•ผ๊ธฐํ•ด๋ณผ๊ฒŒ. ํ˜น์‹œ ํ„ฐ๋ฏธ๋„์—์„œ git, docker, kubectl ๊ฐ™์€ ๋ช…๋ น์–ด๋ฅผ ์‚ฌ์šฉํ•ด๋ณธ ์  ์žˆ์ง€? ์ด๋Ÿฐ ๊ฐ•๋ ฅํ•œ ๋„๊ตฌ๋“ค์ด ๋ฐ”๋กœ CLI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด์•ผ. ๐Ÿ˜Ž ์š”์ฆ˜ ๊ฐœ๋ฐœ์ž๋“ค ์‚ฌ์ด์—์„œ CLI ๋„๊ตฌ๋Š” ์ •๋ง ํ•ซํ•œ ์ฃผ์ œ์•ผ. GUI๋ณด๋‹ค ๋น ๋ฅด๊ณ , ์ž๋™ํ™”ํ•˜๊ธฐ ์‰ฝ๊ณ , ์„œ๋ฒ„ ํ™˜๊ฒฝ์—์„œ๋„ ์™„๋ฒฝํ•˜๊ฒŒ ๋™์ž‘ํ•˜๊ฑฐ๋“ . ํŠนํžˆ DevOps๋‚˜ ์ž๋™ํ™” ์ž‘์—…์„ ํ•  ๋•Œ๋Š” ํ•„์ˆ˜์ ์ด์ง€!
๐Ÿ’ก CLI ๋„๊ตฌ์˜ ์žฅ์ 

โ€ข ๋น ๋ฅธ ์‹คํ–‰ ์†๋„ - GUI ์˜ค๋ฒ„ํ—ค๋“œ ์—†์ด ์ฆ‰์‹œ ์‹คํ–‰
โ€ข ์ž๋™ํ™” ์นœํ™”์  - ์Šคํฌ๋ฆฝํŠธ์™€ ํŒŒ์ดํ”„๋ผ์ธ์— ์‰ฝ๊ฒŒ ํ†ตํ•ฉ
โ€ข ์›๊ฒฉ ์ž‘์—… ์ตœ์ ํ™” - SSH๋ฅผ ํ†ตํ•œ ์„œ๋ฒ„ ๊ด€๋ฆฌ์— ์™„๋ฒฝ
โ€ข ๋ฆฌ์†Œ์Šค ํšจ์œจ์„ฑ - ๋ฉ”๋ชจ๋ฆฌ์™€ CPU ์‚ฌ์šฉ๋Ÿ‰ ์ตœ์†Œํ™”
โ€ข ๋ฒ„์ „ ๊ด€๋ฆฌ ์šฉ์ด - ์„ค์ • ํŒŒ์ผ์„ Git์œผ๋กœ ์‰ฝ๊ฒŒ ๊ด€๋ฆฌ
## ๐Ÿ”ฅ Go์–ธ์–ด๊ฐ€ CLI ๊ฐœ๋ฐœ์— ์ตœ์ ์ธ ์ด์œ  Go์–ธ์–ด๋Š” CLI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค๊ธฐ์— ์ •๋ง ์™„๋ฒฝํ•œ ์–ธ์–ด์•ผ. ๊ตฌ๊ธ€์—์„œ ๋งŒ๋“  ์ด ์–ธ์–ด๋Š” ๋‹จ์ˆœํ•จ๊ณผ ์„ฑ๋Šฅ์„ ๋™์‹œ์— ์žก์•˜๊ฑฐ๋“ . ๐ŸŽช **์ฒซ์งธ, ๋‹จ์ผ ๋ฐ”์ด๋„ˆ๋ฆฌ ๋ฐฐํฌ**๊ฐ€ ๊ฐ€๋Šฅํ•ด. Python์ด๋‚˜ Node.js์ฒ˜๋Ÿผ ๋Ÿฐํƒ€์ž„์„ ๋”ฐ๋กœ ์„ค์น˜ํ•  ํ•„์š”๊ฐ€ ์—†์–ด. ์ปดํŒŒ์ผํ•˜๋ฉด ํ•˜๋‚˜์˜ ์‹คํ–‰ ํŒŒ์ผ์ด ๋‚˜์˜ค๋Š”๋ฐ, ์ด๊ฑธ ๊ทธ๋ƒฅ ๋ณต์‚ฌํ•ด์„œ ์–ด๋””์„œ๋“  ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ์–ด. ์ •๋ง ํŽธํ•˜์ง€? **๋‘˜์งธ, ํฌ๋กœ์Šค ์ปดํŒŒ์ผ**์ด ๋„ˆ๋ฌด ์‰ฌ์›Œ. Windows์—์„œ ๊ฐœ๋ฐœํ•˜๋ฉด์„œ Linux์šฉ ๋ฐ”์ด๋„ˆ๋ฆฌ๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๊ณ , Mac์—์„œ Windows์šฉ์„ ๋งŒ๋“ค ์ˆ˜๋„ ์žˆ์–ด. ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๋ช‡ ๊ฐœ๋งŒ ์„ค์ •ํ•˜๋ฉด ๋์ด์•ผ! **์…‹์งธ, ๋น ๋ฅธ ์‹คํ–‰ ์†๋„**. C์–ธ์–ด๋งŒํผ์€ ์•„๋‹ˆ์ง€๋งŒ, ์Šคํฌ๋ฆฝํŠธ ์–ธ์–ด๋“ค๋ณด๋‹ค ํ›จ์”ฌ ๋น ๋ฅด๊ณ  ๋ฉ”๋ชจ๋ฆฌ๋„ ์ ๊ฒŒ ๋จน์–ด. ๋Œ€์šฉ๋Ÿ‰ ๋ฐ์ดํ„ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” CLI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค ๋•Œ ์ด ์ฐจ์ด๊ฐ€ ํ™• ๋А๊ปด์ ธ. **๋„ท์งธ, ๋™์‹œ์„ฑ ์ฒ˜๋ฆฌ**๊ฐ€ ์–ธ์–ด ์ฐจ์›์—์„œ ์ง€์›๋ผ. ๊ณ ๋ฃจํ‹ด(Goroutine)์„ ์‚ฌ์šฉํ•˜๋ฉด ๋ณ‘๋ ฌ ์ž‘์—…์„ ์ •๋ง ์‰ฝ๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์–ด. ์—ฌ๋Ÿฌ ํŒŒ์ผ์„ ๋™์‹œ์— ์ฒ˜๋ฆฌํ•˜๊ฑฐ๋‚˜, API๋ฅผ ๋ณ‘๋ ฌ๋กœ ํ˜ธ์ถœํ•˜๋Š” ์ž‘์—…์ด ๊ฐ„๋‹จํ•ด์ง€์ง€. Go ๋‹จ์ผ ๋ฐ”์ด๋„ˆ๋ฆฌ ๋ฐฐํฌ ๊ฐ„ํŽธ ํฌ๋กœ์Šค ์ปดํŒŒ์ผ ๋ฉ€ํ‹ฐ ํ”Œ๋žซํผ ๋น ๋ฅธ ์„ฑ๋Šฅ ๋‚ฎ์€ ๋ฉ”๋ชจ๋ฆฌ ๋™์‹œ์„ฑ ์ฒ˜๋ฆฌ ๊ณ ๋ฃจํ‹ด ์ง€์› ํ’๋ถ€ํ•œ ์ƒํƒœ๊ณ„ Cobra, Viper ๐Ÿš€ ์™„๋ฒฝํ•œ CLI ## ๐Ÿ Cobra ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์™„์ „ ์ •๋ณต ์ž, ์ด์ œ ๋ณธ๊ฒฉ์ ์œผ๋กœ Cobra์— ๋Œ€ํ•ด ์•Œ์•„๋ณผ๊นŒ? Cobra๋Š” Go์—์„œ CLI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๋งŒ๋“ค ๋•Œ ๊ฐ€์žฅ ๋งŽ์ด ์‚ฌ์šฉ๋˜๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์•ผ. Kubernetes, Hugo, GitHub CLI ๊ฐ™์€ ์œ ๋ช…ํ•œ ํ”„๋กœ์ ํŠธ๋“ค์ด ๋ชจ๋‘ Cobra๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์–ด! ๐Ÿ’ช ### Cobra๊ฐ€ ์ œ๊ณตํ•˜๋Š” ํ•ต์‹ฌ ๊ธฐ๋Šฅ Cobra๋Š” ์ •๋ง ๋งŽ์€ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๋Š”๋ฐ, ํ•˜๋‚˜์”ฉ ์‚ดํŽด๋ณด์ž:
๐ŸŽจ Cobra์˜ ์ฃผ์š” ๊ธฐ๋Šฅ๋“ค

1. ์„œ๋ธŒ์ปค๋งจ๋“œ ๊ตฌ์กฐ - git add, git commit์ฒ˜๋Ÿผ ๊ณ„์ธต์  ๋ช…๋ น์–ด ๊ตฌ์กฐ
2. ํ”Œ๋ž˜๊ทธ ๊ด€๋ฆฌ - ์งง์€ ํ”Œ๋ž˜๊ทธ(-v)์™€ ๊ธด ํ”Œ๋ž˜๊ทธ(--verbose) ์ž๋™ ์ฒ˜๋ฆฌ
3. ์ž๋™ ์™„์„ฑ - Bash, Zsh, Fish ๋“ฑ์˜ ์‰˜ ์ž๋™์™„์„ฑ ์ƒ์„ฑ
4. ๋„์›€๋ง ์ƒ์„ฑ - ๋ช…๋ น์–ด ์„ค๋ช…๊ณผ ์‚ฌ์šฉ๋ฒ•์„ ์ž๋™์œผ๋กœ ์ƒ์„ฑ
5. ์—๋Ÿฌ ์ฒ˜๋ฆฌ - ์ผ๊ด€๋œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€์™€ ์ œ์•ˆ ๊ธฐ๋Šฅ
### Cobra ์„ค์น˜ํ•˜๊ณ  ์‹œ์ž‘ํ•˜๊ธฐ ๋จผ์ € Cobra๋ฅผ ์„ค์น˜ํ•ด๋ณด์ž. ํ„ฐ๋ฏธ๋„์„ ์—ด๊ณ  ๋‹ค์Œ ๋ช…๋ น์–ด๋ฅผ ์ž…๋ ฅํ•ด:
go get -u github.com/spf13/cobra@latest
go install github.com/spf13/cobra-cli@latest
์„ค์น˜๊ฐ€ ์™„๋ฃŒ๋˜๋ฉด ์ƒˆ๋กœ์šด CLI ํ”„๋กœ์ ํŠธ๋ฅผ ์‹œ์ž‘ํ•  ์ˆ˜ ์žˆ์–ด. Cobra CLI ๋„๊ตฌ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ๋ฅผ ์ž๋™์œผ๋กœ ๋งŒ๋“ค์–ด์ค˜:
mkdir myapp
cd myapp
go mod init myapp
cobra-cli init
์ด ๋ช…๋ น์–ด๋ฅผ ์‹คํ–‰ํ•˜๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ตฌ์กฐ๊ฐ€ ์ƒ์„ฑ๋ผ:
myapp/
โ”œโ”€โ”€ cmd/
โ”‚   โ””โ”€โ”€ root.go
โ”œโ”€โ”€ main.go
โ””โ”€โ”€ go.mod
root.go ํŒŒ์ผ์ด ํ•ต์‹ฌ์ด์•ผ. ์—ฌ๊ธฐ์— ๋ฃจํŠธ ์ปค๋งจ๋“œ๊ฐ€ ์ •์˜๋˜์–ด ์žˆ๊ณ , ๋ชจ๋“  ์„œ๋ธŒ์ปค๋งจ๋“œ๋“ค์ด ์ด ๋ฃจํŠธ์— ์—ฐ๊ฒฐ๋˜์ง€. ### ์ฒซ ๋ฒˆ์งธ CLI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋งŒ๋“ค๊ธฐ ๊ฐ„๋‹จํ•œ ์˜ˆ์ œ๋กœ ์‹œ์ž‘ํ•ด๋ณด์ž. ์‚ฌ์šฉ์ž์—๊ฒŒ ์ธ์‚ฌํ•˜๋Š” CLI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค์–ด๋ณผ๊ฒŒ:
package main

import (
    "fmt"
    "github.com/spf13/cobra"
    "os"
)

var rootCmd = &cobra.Command{
    Use:   "greet",
    Short: "์ธ์‚ฌ๋ฅผ ๊ฑด๋„ค๋Š” CLI ๋„๊ตฌ",
    Long:  `์ด๋ฆ„์„ ์ž…๋ ฅํ•˜๋ฉด ์นœ๊ทผํ•˜๊ฒŒ ์ธ์‚ฌํ•ด์ฃผ๋Š” ๊ฐ„๋‹จํ•œ ๋„๊ตฌ์ž…๋‹ˆ๋‹ค.`,
    Run: func(cmd *cobra.Command, args []string) {
        name, _ := cmd.Flags().GetString("name")
        if name == "" {
            name = "์นœ๊ตฌ"
        }
        fmt.Printf("์•ˆ๋…•ํ•˜์„ธ์š”, %s๋‹˜! ๐Ÿ˜Š\n", name)
    },
}

func init() {
    rootCmd.Flags().StringP("name", "n", "", "์ธ์‚ฌํ•  ๋Œ€์ƒ์˜ ์ด๋ฆ„")
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}
์ด ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•˜๋ฉด:
go run main.go --name ์ฒ ์ˆ˜
# ์ถœ๋ ฅ: ์•ˆ๋…•ํ•˜์„ธ์š”, ์ฒ ์ˆ˜๋‹˜! ๐Ÿ˜Š

go run main.go -n ์˜ํฌ
# ์ถœ๋ ฅ: ์•ˆ๋…•ํ•˜์„ธ์š”, ์˜ํฌ๋‹˜! ๐Ÿ˜Š

go run main.go
# ์ถœ๋ ฅ: ์•ˆ๋…•ํ•˜์„ธ์š”, ์นœ๊ตฌ๋‹˜! ๐Ÿ˜Š
์งง์€ ํ”Œ๋ž˜๊ทธ(-n)์™€ ๊ธด ํ”Œ๋ž˜๊ทธ(--name) ๋‘˜ ๋‹ค ์ž๋™์œผ๋กœ ์ž‘๋™ํ•˜๋Š” ๊ฒŒ ๋ณด์ด์ง€? ์ด๊ฒŒ Cobra์˜ ๋งค๋ ฅ์ด์•ผ! ๐ŸŽ‰ ### ์„œ๋ธŒ์ปค๋งจ๋“œ ์ถ”๊ฐ€ํ•˜๊ธฐ ์ด์ œ ์ข€ ๋” ๋ณต์žกํ•œ ๊ตฌ์กฐ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์ž. ์„œ๋ธŒ์ปค๋งจ๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ณผ๊ฒŒ:
cobra-cli add hello
cobra-cli add goodbye
์ด ๋ช…๋ น์–ด๋ฅผ ์‹คํ–‰ํ•˜๋ฉด cmd/hello.go์™€ cmd/goodbye.go ํŒŒ์ผ์ด ์ƒ์„ฑ๋ผ. ๊ฐ ํŒŒ์ผ์„ ์ˆ˜์ •ํ•ด์„œ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์–ด:
// cmd/hello.go
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
)

var helloCmd = &cobra.Command{
    Use:   "hello [์ด๋ฆ„]",
    Short: "๋ฐ˜๊ฐ‘๊ฒŒ ์ธ์‚ฌํ•ฉ๋‹ˆ๋‹ค",
    Args:  cobra.MinimumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        enthusiastic, _ := cmd.Flags().GetBool("enthusiastic")
        name := args[0]
        
        if enthusiastic {
            fmt.Printf("์•ˆ๋…•ํ•˜์„ธ์š”, %s๋‹˜!!! ์ •๋ง ๋ฐ˜๊ฐ‘์Šต๋‹ˆ๋‹ค!!! ๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰\n", name)
        } else {
            fmt.Printf("์•ˆ๋…•ํ•˜์„ธ์š”, %s๋‹˜. ๋ฐ˜๊ฐ‘์Šต๋‹ˆ๋‹ค.\n", name)
        }
    },
}

func init() {
    rootCmd.AddCommand(helloCmd)
    helloCmd.Flags().BoolP("enthusiastic", "e", false, "์—ด์ •์ ์œผ๋กœ ์ธ์‚ฌํ•˜๊ธฐ")
}
์ด์ œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด:
go run main.go hello ๋ฏผ์ˆ˜
# ์ถœ๋ ฅ: ์•ˆ๋…•ํ•˜์„ธ์š”, ๋ฏผ์ˆ˜๋‹˜. ๋ฐ˜๊ฐ‘์Šต๋‹ˆ๋‹ค.

go run main.go hello ๋ฏผ์ˆ˜ --enthusiastic
# ์ถœ๋ ฅ: ์•ˆ๋…•ํ•˜์„ธ์š”, ๋ฏผ์ˆ˜๋‹˜!!! ์ •๋ง ๋ฐ˜๊ฐ‘์Šต๋‹ˆ๋‹ค!!! ๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰
## ๐Ÿ”ง Viper๋กœ ์„ค์ • ๊ด€๋ฆฌ ๋งˆ์Šคํ„ฐํ•˜๊ธฐ CLI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค๋‹ค ๋ณด๋ฉด ์„ค์ •์„ ๊ด€๋ฆฌํ•ด์•ผ ํ•  ์ผ์ด ๋งŽ์•„. ์‚ฌ์šฉ์ž๋งˆ๋‹ค ๋‹ค๋ฅธ API ํ‚ค๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค๊ฑฐ๋‚˜, ๊ธฐ๋ณธ ๋™์ž‘์„ ์ปค์Šคํ„ฐ๋งˆ์ด์ง•ํ•˜๊ณ  ์‹ถ์„ ๋•Œ ๋ง์ด์•ผ. ์ด๋•Œ Viper๊ฐ€ ์ •๋ง ์œ ์šฉํ•ด! ๐ŸŽฏ Viper๋Š” Cobra๋ฅผ ๋งŒ๋“  ๊ฐ™์€ ํŒ€์—์„œ ๊ฐœ๋ฐœํ•œ ์„ค์ • ๊ด€๋ฆฌ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์•ผ. ์—ฌ๋Ÿฌ ์†Œ์Šค์—์„œ ์„ค์ •์„ ์ฝ์–ด์˜ฌ ์ˆ˜ ์žˆ์–ด: - JSON, YAML, TOML, HCL ๋“ฑ์˜ ์„ค์ • ํŒŒ์ผ - ํ™˜๊ฒฝ ๋ณ€์ˆ˜ - ์ปค๋งจ๋“œ ๋ผ์ธ ํ”Œ๋ž˜๊ทธ - ์›๊ฒฉ ์„ค์ • ์‹œ์Šคํ…œ (etcd, Consul ๋“ฑ) ### Viper ์„ค์น˜ ๋ฐ ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ• ๋จผ์ € Viper๋ฅผ ์„ค์น˜ํ•˜์ž:
go get github.com/spf13/viper
๊ธฐ๋ณธ์ ์ธ ์‚ฌ์šฉ ์˜ˆ์ œ๋ฅผ ๋ณด์ž:
package main

import (
    "fmt"
    "github.com/spf13/viper"
)

func main() {
    // ์„ค์ • ํŒŒ์ผ ์ด๋ฆ„๊ณผ ๊ฒฝ๋กœ ์ง€์ •
    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    viper.AddConfigPath(".")
    viper.AddConfigPath("$HOME/.myapp")
    
    // ์„ค์ • ํŒŒ์ผ ์ฝ๊ธฐ
    if err := viper.ReadInConfig(); err != nil {
        fmt.Printf("์„ค์ • ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: %v\n", err)
    }
    
    // ์„ค์ • ๊ฐ’ ์ฝ๊ธฐ
    apiKey := viper.GetString("api.key")
    timeout := viper.GetInt("timeout")
    debug := viper.GetBool("debug")
    
    fmt.Printf("API Key: %s\n", apiKey)
    fmt.Printf("Timeout: %d\n", timeout)
    fmt.Printf("Debug: %v\n", debug)
}
๊ทธ๋ฆฌ๊ณ  config.yaml ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด:
api:
  key: "your-secret-api-key"
  endpoint: "https://api.example.com"

timeout: 30
debug: true
retries: 3
### Cobra์™€ Viper ํ†ตํ•ฉํ•˜๊ธฐ Cobra์™€ Viper๋ฅผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋ฉด ์ •๋ง ๊ฐ•๋ ฅํ•œ ์กฐํ•ฉ์ด ๋ผ. ํ”Œ๋ž˜๊ทธ, ์„ค์ • ํŒŒ์ผ, ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ๋ชจ๋‘ ํ†ตํ•ฉํ•ด์„œ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๊ฑฐ๋“ :
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var cfgFile string

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: "์„ค์ • ๊ด€๋ฆฌ๊ฐ€ ์™„๋ฒฝํ•œ CLI ๋„๊ตฌ",
}

func init() {
    cobra.OnInitialize(initConfig)
    
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", 
        "์„ค์ • ํŒŒ์ผ ๊ฒฝ๋กœ (๊ธฐ๋ณธ๊ฐ’: $HOME/.myapp.yaml)")
    rootCmd.PersistentFlags().Bool("debug", false, "๋””๋ฒ„๊ทธ ๋ชจ๋“œ ํ™œ์„ฑํ™”")
    
    // ํ”Œ๋ž˜๊ทธ๋ฅผ Viper์— ๋ฐ”์ธ๋”ฉ
    viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug"))
}

func initConfig() {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        viper.AddConfigPath("$HOME")
        viper.SetConfigName(".myapp")
        viper.SetConfigType("yaml")
    }
    
    // ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์ž๋™ ๋ฐ”์ธ๋”ฉ
    viper.AutomaticEnv()
    
    if err := viper.ReadInConfig(); err == nil {
        fmt.Println("์„ค์ • ํŒŒ์ผ ์‚ฌ์šฉ:", viper.ConfigFileUsed())
    }
}
์ด๋ ‡๊ฒŒ ์„ค์ •ํ•˜๋ฉด ์šฐ์„ ์ˆœ์œ„๊ฐ€ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ ์šฉ๋ผ: 1. **์ปค๋งจ๋“œ ๋ผ์ธ ํ”Œ๋ž˜๊ทธ** (๊ฐ€์žฅ ๋†’์€ ์šฐ์„ ์ˆœ์œ„) 2. **ํ™˜๊ฒฝ ๋ณ€์ˆ˜** 3. **์„ค์ • ํŒŒ์ผ** 4. **๊ธฐ๋ณธ๊ฐ’** (๊ฐ€์žฅ ๋‚ฎ์€ ์šฐ์„ ์ˆœ์œ„)
๐Ÿ’ก ์‹ค์ „ ํŒ: ์„ค์ • ์šฐ์„ ์ˆœ์œ„ ํ™œ์šฉํ•˜๊ธฐ

๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” ์„ค์ • ํŒŒ์ผ์„ ์‚ฌ์šฉํ•˜๊ณ , ํ”„๋กœ๋•์…˜์—์„œ๋Š” ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋กœ ๋ฏผ๊ฐํ•œ ์ •๋ณด๋ฅผ ์ฃผ์ž…ํ•˜๋Š” ๋ฐฉ์‹์ด ์ผ๋ฐ˜์ ์ด์•ผ. ์˜ˆ๋ฅผ ๋“ค์–ด:

export MYAPP_API_KEY="production-key"
myapp --debug

์ด๋ ‡๊ฒŒ ํ•˜๋ฉด API ํ‚ค๋Š” ํ™˜๊ฒฝ ๋ณ€์ˆ˜์—์„œ, debug ํ”Œ๋ž˜๊ทธ๋Š” ์ปค๋งจ๋“œ ๋ผ์ธ์—์„œ ๊ฐ€์ ธ์˜ค๊ฒŒ ๋ผ.
### ์ค‘์ฒฉ๋œ ์„ค์ • ๋‹ค๋ฃจ๊ธฐ ๋ณต์žกํ•œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ๋Š” ์„ค์ •์ด ๊ณ„์ธต์ ์œผ๋กœ ๊ตฌ์„ฑ๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์•„. Viper๋Š” ์ด๋Ÿฐ ์ค‘์ฒฉ ๊ตฌ์กฐ๋„ ์‰ฝ๊ฒŒ ๋‹ค๋ฃฐ ์ˆ˜ ์žˆ์–ด:
# config.yaml
database:
  host: localhost
  port: 5432
  credentials:
    username: admin
    password: secret
  pool:
    max_connections: 100
    min_connections: 10

server:
  host: 0.0.0.0
  port: 8080
  tls:
    enabled: true
    cert_file: /path/to/cert.pem
    key_file: /path/to/key.pem
Go ์ฝ”๋“œ์—์„œ๋Š” ์ด๋ ‡๊ฒŒ ์ ‘๊ทผํ•ด:
// ์ (.) ํ‘œ๊ธฐ๋ฒ•์œผ๋กœ ์ค‘์ฒฉ๋œ ๊ฐ’ ์ ‘๊ทผ
dbHost := viper.GetString("database.host")
dbPort := viper.GetInt("database.port")
username := viper.GetString("database.credentials.username")
maxConn := viper.GetInt("database.pool.max_connections")

// ์ „์ฒด ์„น์…˜์„ ๋งต์œผ๋กœ ๊ฐ€์ ธ์˜ค๊ธฐ
dbConfig := viper.GetStringMap("database")
fmt.Printf("Database config: %v\n", dbConfig)

// ๊ตฌ์กฐ์ฒด๋กœ ์–ธ๋งˆ์ƒฌ๋ง
type DatabaseConfig struct {
    Host string
    Port int
    Credentials struct {
        Username string
        Password string
    }
    Pool struct {
        MaxConnections int `mapstructure:"max_connections"`
        MinConnections int `mapstructure:"min_connections"`
    }
}

var dbCfg DatabaseConfig
viper.UnmarshalKey("database", &dbCfg)
โš ๏ธ ์ฃผ์˜์‚ฌํ•ญ: ๋ฏผ๊ฐํ•œ ์ •๋ณด ๊ด€๋ฆฌ

API ํ‚ค๋‚˜ ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฐ™์€ ๋ฏผ๊ฐํ•œ ์ •๋ณด๋Š” ์„ค์ • ํŒŒ์ผ์— ์ง์ ‘ ์ €์žฅํ•˜์ง€ ๋ง๊ณ , ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋‚˜ ์‹œํฌ๋ฆฟ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒŒ ์ข‹์•„. ์„ค์ • ํŒŒ์ผ์„ Git์— ์ปค๋ฐ‹ํ•  ๋•Œ ์‹ค์ˆ˜๋กœ ๋…ธ์ถœ๋  ์ˆ˜ ์žˆ๊ฑฐ๋“ !
### ๋™์  ์„ค์ • ๋ณ€๊ฒฝ ๊ฐ์ง€ Viper์˜ ๋ฉ‹์ง„ ๊ธฐ๋Šฅ ์ค‘ ํ•˜๋‚˜๋Š” ์„ค์ • ํŒŒ์ผ์˜ ๋ณ€๊ฒฝ์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๊ฐ์ง€ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฑฐ์•ผ. ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ์žฌ์‹œ์ž‘ํ•˜์ง€ ์•Š๊ณ ๋„ ์„ค์ •์„ ์—…๋ฐ์ดํŠธํ•  ์ˆ˜ ์žˆ์–ด:
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
    fmt.Println("์„ค์ • ํŒŒ์ผ์ด ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค:", e.Name)
    
    // ์ƒˆ๋กœ์šด ์„ค์ • ๊ฐ’ ์ ์šฉ
    newTimeout := viper.GetInt("timeout")
    fmt.Printf("์ƒˆ๋กœ์šด ํƒ€์ž„์•„์›ƒ ๊ฐ’: %d\n", newTimeout)
    
    // ์—ฌ๊ธฐ์„œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋™์ž‘์„ ์—…๋ฐ์ดํŠธ
    updateApplicationConfig()
})
์ด ๊ธฐ๋Šฅ์€ ์žฅ์‹œ๊ฐ„ ์‹คํ–‰๋˜๋Š” ์„œ๋น„์Šค๋‚˜ ๋ฐ๋ชฌ ํ”„๋กœ์„ธ์Šค์—์„œ ํŠนํžˆ ์œ ์šฉํ•ด. ์„ค์ •์„ ๋ฐ”๊พธ๊ณ  ์‹ถ์„ ๋•Œ๋งˆ๋‹ค ์„œ๋น„์Šค๋ฅผ ์žฌ์‹œ์ž‘ํ•  ํ•„์š”๊ฐ€ ์—†์œผ๋‹ˆ๊นŒ! Viper ์„ค์ • ์šฐ์„ ์ˆœ์œ„ 1. ์ปค๋งจ๋“œ ๋ผ์ธ ํ”Œ๋ž˜๊ทธ 2. ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ENV 3. ์„ค์ • ํŒŒ์ผ YAML/JSON 4. ๊ธฐ๋ณธ๊ฐ’ Default Values ๐Ÿ“ config.yaml ๐Ÿ” ํ™˜๊ฒฝ ๋ณ€์ˆ˜ โš™๏ธ ํ”Œ๋ž˜๊ทธ ๋†’์€ ์šฐ์„ ์ˆœ์œ„๊ฐ€ ๋‚ฎ์€ ์šฐ์„ ์ˆœ์œ„๋ฅผ ๋ฎ์–ด์”๋‹ˆ๋‹ค
## ๐ŸŽจ ์‹ค์ „ ํ”„๋กœ์ ํŠธ: ํŒŒ์ผ ๊ด€๋ฆฌ CLI ๋„๊ตฌ ๋งŒ๋“ค๊ธฐ ์ด๋ก ์€ ์ถฉ๋ถ„ํžˆ ๋ฐฐ์› ์œผ๋‹ˆ, ์ด์ œ ์‹ค์ œ๋กœ ์œ ์šฉํ•œ CLI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์ž! ํŒŒ์ผ์„ ๊ฒ€์ƒ‰ํ•˜๊ณ , ์ •๋ฆฌํ•˜๊ณ , ๋ถ„์„ํ•˜๋Š” ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค ๊ฑฐ์•ผ. ์ด๋ฆ„์€ filemaster๋กœ ํ•˜์ž! ๐Ÿ“‚ ### ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ์„ค๊ณ„ ๋จผ์ € ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ๋ฅผ ์žก์•„๋ณด์ž:
filemaster/
โ”œโ”€โ”€ cmd/
โ”‚   โ”œโ”€โ”€ root.go
โ”‚   โ”œโ”€โ”€ search.go
โ”‚   โ”œโ”€โ”€ organize.go
โ”‚   โ””โ”€โ”€ analyze.go
โ”œโ”€โ”€ internal/
โ”‚   โ”œโ”€โ”€ file/
โ”‚   โ”‚   โ”œโ”€โ”€ search.go
โ”‚   โ”‚   โ”œโ”€โ”€ organize.go
โ”‚   โ”‚   โ””โ”€โ”€ analyze.go
โ”‚   โ””โ”€โ”€ config/
โ”‚       โ””โ”€โ”€ config.go
โ”œโ”€โ”€ config.yaml
โ”œโ”€โ”€ main.go
โ””โ”€โ”€ go.mod
์ด ๊ตฌ์กฐ๋Š” ๊ด€์‹ฌ์‚ฌ์˜ ๋ถ„๋ฆฌ(Separation of Concerns) ์›์น™์„ ๋”ฐ๋ผ. cmd ํŒจํ‚ค์ง€๋Š” CLI ์ธํ„ฐํŽ˜์ด์Šค๋งŒ ๋‹ด๋‹นํ•˜๊ณ , ์‹ค์ œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ internal ํŒจํ‚ค์ง€์— ๋ถ„๋ฆฌํ–ˆ์–ด. ### Root ์ปค๋งจ๋“œ ๊ตฌํ˜„ ๋จผ์ € ๋ฃจํŠธ ์ปค๋งจ๋“œ๋ฅผ ๋งŒ๋“ค์ž:
// cmd/root.go
package cmd

import (
    "fmt"
    "os"
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var cfgFile string
var verbose bool

var rootCmd = &cobra.Command{
    Use:   "filemaster",
    Short: "๊ฐ•๋ ฅํ•œ ํŒŒ์ผ ๊ด€๋ฆฌ CLI ๋„๊ตฌ",
    Long: `filemaster๋Š” ํŒŒ์ผ์„ ๊ฒ€์ƒ‰ํ•˜๊ณ , ์ •๋ฆฌํ•˜๊ณ , ๋ถ„์„ํ•˜๋Š” 
์˜ฌ์ธ์› CLI ๋„๊ตฌ์ž…๋‹ˆ๋‹ค. ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ ์‹œ์Šคํ…œ๋„ ๋น ๋ฅด๊ฒŒ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์–ด์š”!`,
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func init() {
    cobra.OnInitialize(initConfig)
    
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", 
        "์„ค์ • ํŒŒ์ผ ๊ฒฝ๋กœ (๊ธฐ๋ณธ๊ฐ’: $HOME/.filemaster.yaml)")
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, 
        "์ƒ์„ธํ•œ ์ถœ๋ ฅ ํ‘œ์‹œ")
    
    viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
}

func initConfig() {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        home, err := os.UserHomeDir()
        if err != nil {
            fmt.Println(err)
            os.Exit(1)
        }
        
        viper.AddConfigPath(home)
        viper.AddConfigPath(".")
        viper.SetConfigName(".filemaster")
        viper.SetConfigType("yaml")
    }
    
    viper.AutomaticEnv()
    
    if err := viper.ReadInConfig(); err == nil && verbose {
        fmt.Println("์„ค์ • ํŒŒ์ผ ์‚ฌ์šฉ:", viper.ConfigFileUsed())
    }
}
### Search ์ปค๋งจ๋“œ ๊ตฌํ˜„ ํŒŒ์ผ์„ ๊ฒ€์ƒ‰ํ•˜๋Š” ๊ธฐ๋Šฅ์„ ๋งŒ๋“ค์–ด๋ณด์ž. ์ด๋ฆ„, ํ™•์žฅ์ž, ํฌ๊ธฐ ๋“ฑ์œผ๋กœ ๊ฒ€์ƒ‰ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•  ๊ฑฐ์•ผ:
// cmd/search.go
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
    "filemaster/internal/file"
)

var searchCmd = &cobra.Command{
    Use:   "search [๊ฒฝ๋กœ]",
    Short: "ํŒŒ์ผ ๊ฒ€์ƒ‰",
    Long:  `์ง€์ •๋œ ๊ฒฝ๋กœ์—์„œ ์กฐ๊ฑด์— ๋งž๋Š” ํŒŒ์ผ์„ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค.`,
    Args:  cobra.ExactArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        path := args[0]
        
        pattern, _ := cmd.Flags().GetString("pattern")
        ext, _ := cmd.Flags().GetString("ext")
        minSize, _ := cmd.Flags().GetInt64("min-size")
        maxSize, _ := cmd.Flags().GetInt64("max-size")
        
        opts := file.SearchOptions{
            Pattern: pattern,
            Extension: ext,
            MinSize: minSize,
            MaxSize: maxSize,
        }
        
        results, err := file.Search(path, opts)
        if err != nil {
            fmt.Printf("๊ฒ€์ƒ‰ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: %v\n", err)
            return
        }
        
        fmt.Printf("์ด %d๊ฐœ์˜ ํŒŒ์ผ์„ ์ฐพ์•˜์Šต๋‹ˆ๋‹ค:\n\n", len(results))
        for _, result := range results {
            fmt.Printf("๐Ÿ“„ %s (%s)\n", result.Path, formatSize(result.Size))
        }
    },
}

func init() {
    rootCmd.AddCommand(searchCmd)
    
    searchCmd.Flags().StringP("pattern", "p", "", "ํŒŒ์ผ๋ช… ํŒจํ„ด (์ •๊ทœ์‹)")
    searchCmd.Flags().StringP("ext", "e", "", "ํŒŒ์ผ ํ™•์žฅ์ž")
    searchCmd.Flags().Int64("min-size", 0, "์ตœ์†Œ ํŒŒ์ผ ํฌ๊ธฐ (๋ฐ”์ดํŠธ)")
    searchCmd.Flags().Int64("max-size", 0, "์ตœ๋Œ€ ํŒŒ์ผ ํฌ๊ธฐ (๋ฐ”์ดํŠธ)")
}

func formatSize(bytes int64) string {
    const unit = 1024
    if bytes < unit {
        return fmt.Sprintf("%d B", bytes)
    }
    div, exp := int64(unit), 0
    for n := bytes / unit; n >= unit; n /= unit {
        div *= unit
        exp++
    }
    return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
### ์‹ค์ œ ๊ฒ€์ƒ‰ ๋กœ์ง ๊ตฌํ˜„ ์ด์ œ internal ํŒจํ‚ค์ง€์— ์‹ค์ œ ๊ฒ€์ƒ‰ ๋กœ์ง์„ ๊ตฌํ˜„ํ•˜์ž:
// internal/file/search.go
package file

import (
    "os"
    "path/filepath"
    "regexp"
    "strings"
)

type SearchOptions struct {
    Pattern   string
    Extension string
    MinSize   int64
    MaxSize   int64
}

type SearchResult struct {
    Path string
    Size int64
    IsDir bool
}

func Search(root string, opts SearchOptions) ([]SearchResult, error) {
    var results []SearchResult
    var pattern *regexp.Regexp
    var err error
    
    if opts.Pattern != "" {
        pattern, err = regexp.Compile(opts.Pattern)
        if err != nil {
            return nil, err
        }
    }
    
    err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return nil // ์—๋Ÿฌ๊ฐ€ ์žˆ์–ด๋„ ๊ณ„์† ์ง„ํ–‰
        }
        
        // ๋””๋ ‰ํ† ๋ฆฌ๋Š” ๊ฑด๋„ˆ๋›ฐ๊ธฐ
        if info.IsDir() {
            return nil
        }
        
        // ํŒจํ„ด ๋งค์นญ
        if pattern != nil && !pattern.MatchString(filepath.Base(path)) {
            return nil
        }
        
        // ํ™•์žฅ์ž ํ•„ํ„ฐ๋ง
        if opts.Extension != "" {
            ext := strings.TrimPrefix(filepath.Ext(path), ".")
            if ext != opts.Extension {
                return nil
            }
        }
        
        // ํฌ๊ธฐ ํ•„ํ„ฐ๋ง
        size := info.Size()
        if opts.MinSize > 0 && size < opts.MinSize {
            return nil
        }
        if opts.MaxSize > 0 && size > opts.MaxSize {
            return nil
        }
        
        results = append(results, SearchResult{
            Path: path,
            Size: size,
            IsDir: false,
        })
        
        return nil
    })
    
    return results, err
}
### Organize ์ปค๋งจ๋“œ๋กœ ํŒŒ์ผ ์ •๋ฆฌํ•˜๊ธฐ ์ด์ œ ํŒŒ์ผ์„ ํ™•์žฅ์ž๋ณ„๋กœ ์ •๋ฆฌํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•ด๋ณด์ž:
// cmd/organize.go
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
    "filemaster/internal/file"
)

var organizeCmd = &cobra.Command{
    Use:   "organize [๊ฒฝ๋กœ]",
    Short: "ํŒŒ์ผ ์ •๋ฆฌ",
    Long:  `ํŒŒ์ผ์„ ํ™•์žฅ์ž๋ณ„๋กœ ํด๋”์— ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค.`,
    Args:  cobra.ExactArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        path := args[0]
        
        dryRun, _ := cmd.Flags().GetBool("dry-run")
        byDate, _ := cmd.Flags().GetBool("by-date")
        
        opts := file.OrganizeOptions{
            DryRun: dryRun,
            ByDate: byDate,
        }
        
        result, err := file.Organize(path, opts)
        if err != nil {
            fmt.Printf("์ •๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: %v\n", err)
            return
        }
        
        if dryRun {
            fmt.Println("๐Ÿ” ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ๋ชจ๋“œ (์‹ค์ œ๋กœ ํŒŒ์ผ์„ ์ด๋™ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค)")
        }
        
        fmt.Printf("\nโœจ ์ •๋ฆฌ ์™„๋ฃŒ!\n")
        fmt.Printf("์ด๋™๋œ ํŒŒ์ผ: %d๊ฐœ\n", result.MovedFiles)
        fmt.Printf("์ƒ์„ฑ๋œ ํด๋”: %d๊ฐœ\n", result.CreatedDirs)
        
        if len(result.Errors) > 0 {
            fmt.Printf("\nโš ๏ธ  %d๊ฐœ์˜ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:\n", len(result.Errors))
            for _, err := range result.Errors {
                fmt.Printf("  - %v\n", err)
            }
        }
    },
}

func init() {
    rootCmd.AddCommand(organizeCmd)
    
    organizeCmd.Flags().Bool("dry-run", false, "์‹ค์ œ๋กœ ์ด๋™ํ•˜์ง€ ์•Š๊ณ  ์‹œ๋ฎฌ๋ ˆ์ด์…˜๋งŒ")
    organizeCmd.Flags().Bool("by-date", false, "๋‚ ์งœ๋ณ„๋กœ ์ •๋ฆฌ")
}
๐ŸŽ“ ํ”„๋กœ ํŒ: Dry-run ๋ชจ๋“œ

ํŒŒ์ผ์„ ์‹ค์ œ๋กœ ์ด๋™ํ•˜๊ฑฐ๋‚˜ ์‚ญ์ œํ•˜๋Š” ๋ช…๋ น์–ด๋ฅผ ๋งŒ๋“ค ๋•Œ๋Š” ํ•ญ์ƒ --dry-run ์˜ต์…˜์„ ์ œ๊ณตํ•˜๋Š” ๊ฒŒ ์ข‹์•„. ์‚ฌ์šฉ์ž๊ฐ€ ์‹ค์ˆ˜๋กœ ์ค‘์š”ํ•œ ํŒŒ์ผ์„ ๋‚ ๋ฆฌ๋Š” ๊ฑธ ๋ฐฉ์ง€ํ•  ์ˆ˜ ์žˆ๊ฑฐ๋“ . ์ด๊ฑด Git, Docker ๊ฐ™์€ ์œ ๋ช…ํ•œ ๋„๊ตฌ๋“ค๋„ ์‚ฌ์šฉํ•˜๋Š” ํŒจํ„ด์ด์•ผ!
### Analyze ์ปค๋งจ๋“œ๋กœ ํ†ต๊ณ„ ๋ถ„์„ํ•˜๊ธฐ ๋งˆ์ง€๋ง‰์œผ๋กœ ๋””๋ ‰ํ† ๋ฆฌ์˜ ํŒŒ์ผ ํ†ต๊ณ„๋ฅผ ๋ถ„์„ํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•˜์ž:
// cmd/analyze.go
package cmd

import (
    "fmt"
    "github.com/spf13/cobra"
    "filemaster/internal/file"
    "sort"
)

var analyzeCmd = &cobra.Command{
    Use:   "analyze [๊ฒฝ๋กœ]",
    Short: "ํŒŒ์ผ ๋ถ„์„",
    Long:  `๋””๋ ‰ํ† ๋ฆฌ์˜ ํŒŒ์ผ ํ†ต๊ณ„๋ฅผ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค.`,
    Args:  cobra.ExactArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        path := args[0]
        
        showChart, _ := cmd.Flags().GetBool("chart")
        topN, _ := cmd.Flags().GetInt("top")
        
        stats, err := file.Analyze(path)
        if err != nil {
            fmt.Printf("๋ถ„์„ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: %v\n", err)
            return
        }
        
        fmt.Printf("\n๐Ÿ“Š ๋””๋ ‰ํ† ๋ฆฌ ๋ถ„์„ ๊ฒฐ๊ณผ: %s\n\n", path)
        fmt.Printf("์ด ํŒŒ์ผ ์ˆ˜: %d๊ฐœ\n", stats.TotalFiles)
        fmt.Printf("์ด ํฌ๊ธฐ: %s\n", formatSize(stats.TotalSize))
        fmt.Printf("ํ‰๊ท  ํŒŒ์ผ ํฌ๊ธฐ: %s\n\n", formatSize(stats.AverageSize))
        
        // ํ™•์žฅ์ž๋ณ„ ํ†ต๊ณ„
        fmt.Println("๐Ÿ“ ํ™•์žฅ์ž๋ณ„ ๋ถ„ํฌ:")
        
        type extStat struct {
            ext   string
            count int
            size  int64
        }
        
        var extStats []extStat
        for ext, count := range stats.ExtensionCount {
            extStats = append(extStats, extStat{
                ext:   ext,
                count: count,
                size:  stats.ExtensionSize[ext],
            })
        }
        
        sort.Slice(extStats, func(i, j int) bool {
            return extStats[i].size > extStats[j].size
        })
        
        displayCount := topN
        if len(extStats) < displayCount {
            displayCount = len(extStats)
        }
        
        for i := 0; i < displayCount; i++ {
            stat := extStats[i]
            percentage := float64(stat.size) / float64(stats.TotalSize) * 100
            
            fmt.Printf("  .%-10s %4d๊ฐœ  %10s  (%.1f%%)\n", 
                stat.ext, stat.count, formatSize(stat.size), percentage)
            
            if showChart {
                barLength := int(percentage / 2)
                bar := strings.Repeat("โ–ˆ", barLength)
                fmt.Printf("             %s\n", bar)
            }
        }
        
        // ๊ฐ€์žฅ ํฐ ํŒŒ์ผ๋“ค
        fmt.Println("\n๐Ÿ“ฆ ๊ฐ€์žฅ ํฐ ํŒŒ์ผ๋“ค:")
        for i, f := range stats.LargestFiles {
            if i >= 5 {
                break
            }
            fmt.Printf("  %d. %s (%s)\n", i+1, f.Name, formatSize(f.Size))
        }
    },
}

func init() {
    rootCmd.AddCommand(analyzeCmd)
    
    analyzeCmd.Flags().Bool("chart", false, "์ฐจํŠธ ํ‘œ์‹œ")
    analyzeCmd.Flags().IntP("top", "t", 10, "์ƒ์œ„ N๊ฐœ ํ™•์žฅ์ž ํ‘œ์‹œ")
}
์ด์ œ ์ด๋ ‡๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด:
# ํŒŒ์ผ ๊ฒ€์ƒ‰
filemaster search ~/Documents --pattern "report.*\.pdf" --min-size 1048576

# ํŒŒ์ผ ์ •๋ฆฌ (์‹œ๋ฎฌ๋ ˆ์ด์…˜)
filemaster organize ~/Downloads --dry-run

# ํŒŒ์ผ ์ •๋ฆฌ (์‹ค์ œ ์‹คํ–‰)
filemaster organize ~/Downloads

# ๋””๋ ‰ํ† ๋ฆฌ ๋ถ„์„
filemaster analyze ~/Projects --chart --top 5
## ๐Ÿš€ ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ ๊ตฌํ˜„ํ•˜๊ธฐ ๊ธฐ๋ณธ ๊ธฐ๋Šฅ์€ ๋‹ค ๋งŒ๋“ค์—ˆ์œผ๋‹ˆ, ์ด์ œ ์ข€ ๋” ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ๋“ค์„ ์ถ”๊ฐ€ํ•ด๋ณด์ž. ํ”„๋กœํŽ˜์…”๋„ํ•œ CLI ๋„๊ตฌ๊ฐ€ ๋˜๋ ค๋ฉด ์ด๋Ÿฐ ๊ธฐ๋Šฅ๋“ค์ด ํ•„์š”ํ•ด! ### ์ง„ํ–‰ ์ƒํ™ฉ ํ‘œ์‹œํ•˜๊ธฐ ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ์„ ์ฒ˜๋ฆฌํ•  ๋•Œ๋Š” ์ง„ํ–‰ ์ƒํ™ฉ์„ ๋ณด์—ฌ์ฃผ๋Š” ๊ฒŒ ์ค‘์š”ํ•ด. ์‚ฌ์šฉ์ž๊ฐ€ ํ”„๋กœ๊ทธ๋žจ์ด ๋ฉˆ์ถ˜ ๊ฑด์ง€ ์•„๋‹ˆ๋ฉด ์—ด์‹ฌํžˆ ์ž‘๋™ ์ค‘์ธ์ง€ ์•Œ ์ˆ˜ ์žˆ์–ด์•ผ ํ•˜๊ฑฐ๋“ . progressbar ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์‰ฝ๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์–ด:
go get github.com/schollz/progressbar/v3
package file

import (
    "github.com/schollz/progressbar/v3"
    "os"
    "path/filepath"
)

func SearchWithProgress(root string, opts SearchOptions) ([]SearchResult, error) {
    // ๋จผ์ € ์ด ํŒŒ์ผ ์ˆ˜๋ฅผ ์„ธ๊ธฐ
    var totalFiles int
    filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
        if err == nil && !info.IsDir() {
            totalFiles++
        }
        return nil
    })
    
    // ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ” ์ƒ์„ฑ
    bar := progressbar.NewOptions(totalFiles,
        progressbar.OptionSetDescription("ํŒŒ์ผ ๊ฒ€์ƒ‰ ์ค‘..."),
        progressbar.OptionSetTheme(progressbar.Theme{
            Saucer:        "=",
            SaucerHead:    ">",
            SaucerPadding: " ",
            BarStart:      "[",
            BarEnd:        "]",
        }),
        progressbar.OptionShowCount(),
        progressbar.OptionShowIts(),
        progressbar.OptionSetWidth(50),
    )
    
    var results []SearchResult
    
    err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return nil
        }
        
        if !info.IsDir() {
            bar.Add(1)
            
            // ๊ฒ€์ƒ‰ ๋กœ์ง...
            if matchesOptions(path, info, opts) {
                results = append(results, SearchResult{
                    Path: path,
                    Size: info.Size(),
                })
            }
        }
        
        return nil
    })
    
    bar.Finish()
    return results, err
}
### ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ๋กœ ์„ฑ๋Šฅ ํ–ฅ์ƒํ•˜๊ธฐ Go์˜ ๊ฐ•์ ์ธ ๊ณ ๋ฃจํ‹ด์„ ํ™œ์šฉํ•ด์„œ ํŒŒ์ผ ์ฒ˜๋ฆฌ๋ฅผ ๋ณ‘๋ ฌํ™”ํ•˜๋ฉด ์„ฑ๋Šฅ์ด ํฌ๊ฒŒ ํ–ฅ์ƒ๋ผ. ํŠนํžˆ ๋งŽ์€ ํŒŒ์ผ์„ ์ฒ˜๋ฆฌํ•  ๋•Œ ํšจ๊ณผ๊ฐ€ ์ข‹์•„:
package file

import (
    "sync"
    "runtime"
)

func AnalyzeParallel(root string) (*Stats, error) {
    numWorkers := runtime.NumCPU()
    
    // ์ž‘์—… ํ์™€ ๊ฒฐ๊ณผ ์ฑ„๋„
    jobs := make(chan string, 100)
    results := make(chan FileInfo, 100)
    
    // ์›Œ์ปค ํ’€ ์ƒ์„ฑ
    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for path := range jobs {
                info, err := analyzeFile(path)
                if err == nil {
                    results <- info
                }
            }
        }()
    }
    
    // ํŒŒ์ผ ๋ชฉ๋ก ์ˆ˜์ง‘ ๋ฐ ์ž‘์—… ๋ถ„๋ฐฐ
    go func() {
        filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
            if err == nil && !info.IsDir() {
                jobs <- path
            }
            return nil
        })
        close(jobs)
    }()
    
    // ์›Œ์ปค๋“ค์ด ์™„๋ฃŒ๋˜๋ฉด ๊ฒฐ๊ณผ ์ฑ„๋„ ๋‹ซ๊ธฐ
    go func() {
        wg.Wait()
        close(results)
    }()
    
    // ๊ฒฐ๊ณผ ์ˆ˜์ง‘
    stats := &Stats{
        ExtensionCount: make(map[string]int),
        ExtensionSize:  make(map[string]int64),
    }
    
    for info := range results {
        stats.TotalFiles++
        stats.TotalSize += info.Size
        
        ext := filepath.Ext(info.Path)
        stats.ExtensionCount[ext]++
        stats.ExtensionSize[ext] += info.Size
    }
    
    if stats.TotalFiles > 0 {
        stats.AverageSize = stats.TotalSize / int64(stats.TotalFiles)
    }
    
    return stats, nil
}
โšก ์„ฑ๋Šฅ ์ตœ์ ํ™” ํŒ

1. ์›Œ์ปค ์ˆ˜ ์กฐ์ ˆ - CPU ์ฝ”์–ด ์ˆ˜๋งŒํผ ์›Œ์ปค๋ฅผ ๋งŒ๋“œ๋Š” ๊ฒŒ ์ผ๋ฐ˜์ ์ด์ง€๋งŒ, I/O ์ž‘์—…์ด ๋งŽ์œผ๋ฉด ๋” ๋งŽ์ด ๋งŒ๋“ค ์ˆ˜๋„ ์žˆ์–ด
2. ๋ฒ„ํผ ํฌ๊ธฐ - ์ฑ„๋„ ๋ฒ„ํผ๋ฅผ ์ ์ ˆํžˆ ์„ค์ •ํ•˜๋ฉด ๊ณ ๋ฃจํ‹ด ๊ฐ„ ํ†ต์‹  ์˜ค๋ฒ„ํ—ค๋“œ๋ฅผ ์ค„์ผ ์ˆ˜ ์žˆ์–ด
3. ์ปจํ…์ŠคํŠธ ์‚ฌ์šฉ - context.Context๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์ž‘์—… ์ทจ์†Œ๋‚˜ ํƒ€์ž„์•„์›ƒ์„ ์‰ฝ๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์–ด
### ์ปฌ๋Ÿฌํ’€ํ•œ ์ถœ๋ ฅ ๋งŒ๋“ค๊ธฐ ํ„ฐ๋ฏธ๋„ ์ถœ๋ ฅ์— ์ƒ‰์ƒ์„ ์ถ”๊ฐ€ํ•˜๋ฉด ๊ฐ€๋…์„ฑ์ด ํ›จ์”ฌ ์ข‹์•„์ ธ. fatih/color ํŒจํ‚ค์ง€๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๊ฐ„๋‹จํ•ด:
go get github.com/fatih/color
package cmd

import (
    "github.com/fatih/color"
    "fmt"
)

var (
    successColor = color.New(color.FgGreen, color.Bold)
    errorColor   = color.New(color.FgRed, color.Bold)
    warningColor = color.New(color.FgYellow, color.Bold)
    infoColor    = color.New(color.FgCyan)
    highlightColor = color.New(color.FgMagenta, color.Bold)
)

func printSuccess(msg string) {
    successColor.Printf("โœ“ %s\n", msg)
}

func printError(msg string) {
    errorColor.Printf("โœ— %s\n", msg)
}

func printWarning(msg string) {
    warningColor.Printf("โš  %s\n", msg)
}

func printInfo(msg string) {
    infoColor.Printf("โ„น %s\n", msg)
}

func printHighlight(msg string) {
    highlightColor.Println(msg)
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
func showResults(results []SearchResult) {
    if len(results) == 0 {
        printWarning("๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค")
        return
    }
    
    printSuccess(fmt.Sprintf("์ด %d๊ฐœ์˜ ํŒŒ์ผ์„ ์ฐพ์•˜์Šต๋‹ˆ๋‹ค", len(results)))
    
    for _, result := range results {
        infoColor.Printf("  ๐Ÿ“„ %s ", result.Path)
        highlightColor.Printf("(%s)\n", formatSize(result.Size))
    }
}
### ์ž๋™ ์™„์„ฑ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ํ•˜๊ธฐ Cobra๋Š” ์‰˜ ์ž๋™์™„์„ฑ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์ž๋™์œผ๋กœ ์ƒ์„ฑํ•ด์ค˜. ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์ด ํ›จ์”ฌ ์ข‹์•„์ง€์ง€:
// cmd/completion.go
package cmd

import (
    "os"
    "github.com/spf13/cobra"
)

var completionCmd = &cobra.Command{
    Use:   "completion [bash|zsh|fish|powershell]",
    Short: "์‰˜ ์ž๋™์™„์„ฑ ์Šคํฌ๋ฆฝํŠธ ์ƒ์„ฑ",
    Long: `์ง€์ •๋œ ์‰˜์— ๋Œ€ํ•œ ์ž๋™์™„์„ฑ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

์‚ฌ์šฉ ๋ฐฉ๋ฒ•:
  Bash:
    $ source <(filemaster completion bash)
    ๋˜๋Š”
    $ filemaster completion bash > /etc/bash_completion.d/filemaster

  Zsh:
    $ filemaster completion zsh > "${fpath[1]}/_filemaster"

  Fish:
    $ filemaster completion fish > ~/.config/fish/completions/filemaster.fish

  PowerShell:
    PS> filemaster completion powershell | Out-String | Invoke-Expression
`,
    DisableFlagsInUseLine: true,
    ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
    Args: cobra.ExactValidArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        switch args[0] {
        case "bash":
            cmd.Root().GenBashCompletion(os.Stdout)
        case "zsh":
            cmd.Root().GenZshCompletion(os.Stdout)
        case "fish":
            cmd.Root().GenFishCompletion(os.Stdout, true)
        case "powershell":
            cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
        }
    },
}

func init() {
    rootCmd.AddCommand(completionCmd)
}
์‚ฌ์šฉ์ž๋Š” ์ด๋ ‡๊ฒŒ ์„ค์น˜ํ•  ์ˆ˜ ์žˆ์–ด:
# Bash
echo 'source <(filemaster completion bash)' >> ~/.bashrc

# Zsh
filemaster completion zsh > ~/.zsh/completion/_filemaster
echo 'fpath=(~/.zsh/completion $fpath)' >> ~/.zshrc

# Fish
filemaster completion fish > ~/.config/fish/completions/filemaster.fish
## ๐Ÿงช ํ…Œ์ŠคํŠธ์™€ ๋””๋ฒ„๊น… ํ”„๋กœํŽ˜์…”๋„ํ•œ CLI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค๋ ค๋ฉด ํ…Œ์ŠคํŠธ๊ฐ€ ํ•„์ˆ˜์•ผ. Go๋Š” ํ…Œ์ŠคํŠธ ์ž‘์„ฑ์ด ์ •๋ง ์‰ฌ์›Œ! ### ์œ ๋‹› ํ…Œ์ŠคํŠธ ์ž‘์„ฑํ•˜๊ธฐ
// internal/file/search_test.go
package file

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

func TestSearch(t *testing.T) {
    // ํ…Œ์ŠคํŠธ์šฉ ์ž„์‹œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ
    tmpDir := t.TempDir()
    
    // ํ…Œ์ŠคํŠธ ํŒŒ์ผ๋“ค ์ƒ์„ฑ
    testFiles := []struct {
        name string
        size int64
    }{
        {"test1.txt", 100},
        {"test2.pdf", 1000},
        {"test3.txt", 500},
        {"large.bin", 10000},
    }
    
    for _, tf := range testFiles {
        path := filepath.Join(tmpDir, tf.name)
        f, err := os.Create(path)
        if err != nil {
            t.Fatal(err)
        }
        f.Truncate(tf.size)
        f.Close()
    }
    
    // ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋“ค
    tests := []struct {
        name     string
        opts     SearchOptions
        expected int
    }{
        {
            name: "txt ํŒŒ์ผ๋งŒ ๊ฒ€์ƒ‰",
            opts: SearchOptions{Extension: "txt"},
            expected: 2,
        },
        {
            name: "500๋ฐ”์ดํŠธ ์ด์ƒ ํŒŒ์ผ",
            opts: SearchOptions{MinSize: 500},
            expected: 3,
        },
        {
            name: "ํŒจํ„ด ๋งค์นญ",
            opts: SearchOptions{Pattern: "test[0-9]"},
            expected: 3,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            results, err := Search(tmpDir, tt.opts)
            if err != nil {
                t.Errorf("Search() error = %v", err)
                return
            }
            
            if len(results) != tt.expected {
                t.Errorf("Search() got %d results, want %d", 
                    len(results), tt.expected)
            }
        })
    }
}

func BenchmarkSearch(b *testing.B) {
    tmpDir := b.TempDir()
    
    // ๋ฒค์น˜๋งˆํฌ์šฉ ํŒŒ์ผ ์ƒ์„ฑ
    for i := 0; i < 1000; i++ {
        path := filepath.Join(tmpDir, fmt.Sprintf("file%d.txt", i))
        os.Create(path)
    }
    
    opts := SearchOptions{Extension: "txt"}
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        Search(tmpDir, opts)
    }
}
ํ…Œ์ŠคํŠธ ์‹คํ–‰:
# ๋ชจ๋“  ํ…Œ์ŠคํŠธ ์‹คํ–‰
go test ./...

# ์ปค๋ฒ„๋ฆฌ์ง€ ํ™•์ธ
go test -cover ./...

# ๋ฒค์น˜๋งˆํฌ ์‹คํ–‰
go test -bench=. ./...

# ์ƒ์„ธํ•œ ์ถœ๋ ฅ
go test -v ./...
### ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ CLI ๋ช…๋ น์–ด ์ž์ฒด๋ฅผ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ๋„ ์ค‘์š”ํ•ด:
// cmd/root_test.go
package cmd

import (
    "bytes"
    "testing"
)

func TestRootCommand(t *testing.T) {
    // ์ถœ๋ ฅ ์บก์ฒ˜๋ฅผ ์œ„ํ•œ ๋ฒ„ํผ
    buf := new(bytes.Buffer)
    rootCmd.SetOut(buf)
    rootCmd.SetErr(buf)
    
    // ๋ช…๋ น์–ด ์‹คํ–‰
    rootCmd.SetArgs([]string{"--help"})
    err := rootCmd.Execute()
    
    if err != nil {
        t.Errorf("rootCmd.Execute() error = %v", err)
    }
    
    output := buf.String()
    if !strings.Contains(output, "filemaster") {
        t.Error("Help output doesn't contain 'filemaster'")
    }
}

func TestSearchCommand(t *testing.T) {
    tmpDir := t.TempDir()
    
    // ํ…Œ์ŠคํŠธ ํŒŒ์ผ ์ƒ์„ฑ
    os.Create(filepath.Join(tmpDir, "test.txt"))
    
    buf := new(bytes.Buffer)
    rootCmd.SetOut(buf)
    
    rootCmd.SetArgs([]string{"search", tmpDir, "--ext", "txt"})
    err := rootCmd.Execute()
    
    if err != nil {
        t.Errorf("search command error = %v", err)
    }
    
    output := buf.String()
    if !strings.Contains(output, "test.txt") {
        t.Error("Search didn't find test.txt")
    }
}
๐Ÿ” ๋””๋ฒ„๊น… ํŒ

1. Verbose ํ”Œ๋ž˜๊ทธ - ๋””๋ฒ„๊ทธ ์ •๋ณด๋ฅผ ์ถœ๋ ฅํ•˜๋Š” --verbose ํ”Œ๋ž˜๊ทธ๋ฅผ ํ•ญ์ƒ ์ œ๊ณตํ•ด
2. ๋กœ๊น… - log ํŒจํ‚ค์ง€๋‚˜ logrus ๊ฐ™์€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋กœ ์ƒ์„ธํ•œ ๋กœ๊ทธ๋ฅผ ๋‚จ๊ฒจ
3. Delve ๋””๋ฒ„๊ฑฐ - Go ์ „์šฉ ๋””๋ฒ„๊ฑฐ์ธ Delve๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋ธŒ๋ ˆ์ดํฌํฌ์ธํŠธ ์„ค์ •์ด ๊ฐ€๋Šฅํ•ด
4. pprof - ์„ฑ๋Šฅ ํ”„๋กœํŒŒ์ผ๋ง์ด ํ•„์š”ํ•˜๋ฉด pprof๋ฅผ ์‚ฌ์šฉํ•ด
## ๐Ÿ“ฆ ๋นŒ๋“œ์™€ ๋ฐฐํฌ ์ด์ œ ์™„์„ฑ๋œ CLI ๋„๊ตฌ๋ฅผ ๋นŒ๋“œํ•˜๊ณ  ๋ฐฐํฌํ•ด๋ณด์ž! ### ํฌ๋กœ์Šค ์ปดํŒŒ์ผ Go์˜ ๊ฐ€์žฅ ํฐ ์žฅ์  ์ค‘ ํ•˜๋‚˜๊ฐ€ ํฌ๋กœ์Šค ์ปดํŒŒ์ผ์ด์•ผ. ํ•œ ๋ฒˆ์— ์—ฌ๋Ÿฌ ํ”Œ๋žซํผ์šฉ ๋ฐ”์ด๋„ˆ๋ฆฌ๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ์–ด:
# Linux (64๋น„ํŠธ)
GOOS=linux GOARCH=amd64 go build -o filemaster-linux-amd64

# Windows (64๋น„ํŠธ)
GOOS=windows GOARCH=amd64 go build -o filemaster-windows-amd64.exe

# macOS (Intel)
GOOS=darwin GOARCH=amd64 go build -o filemaster-darwin-amd64

# macOS (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o filemaster-darwin-arm64

# Linux (ARM, ๋ผ์ฆˆ๋ฒ ๋ฆฌํŒŒ์ด ๋“ฑ)
GOOS=linux GOARCH=arm GOARM=7 go build -o filemaster-linux-arm
### ๋นŒ๋“œ ์Šคํฌ๋ฆฝํŠธ ๋งŒ๋“ค๊ธฐ ๋ชจ๋“  ํ”Œ๋žซํผ์šฉ ๋ฐ”์ด๋„ˆ๋ฆฌ๋ฅผ ํ•œ ๋ฒˆ์— ๋นŒ๋“œํ•˜๋Š” ์Šคํฌ๋ฆฝํŠธ๋ฅผ ๋งŒ๋“ค์ž:
#!/bin/bash
# build.sh

VERSION="1.0.0"
APP_NAME="filemaster"

platforms=(
    "linux/amd64"
    "linux/arm64"
    "darwin/amd64"
    "darwin/arm64"
    "windows/amd64"
)

for platform in "${platforms[@]}"
do
    platform_split=(${platform//\// })
    GOOS=${platform_split[0]}
    GOARCH=${platform_split[1]}
    
    output_name=$APP_NAME'-'$VERSION'-'$GOOS'-'$GOARCH
    
    if [ $GOOS = "windows" ]; then
        output_name+='.exe'
    fi
    
    echo "Building $output_name..."
    
    env GOOS=$GOOS GOARCH=$GOARCH go build \
        -ldflags="-s -w -X main.version=$VERSION" \
        -o dist/$output_name
    
    if [ $? -ne 0 ]; then
        echo "Failed to build $output_name"
        exit 1
    fi
done

echo "Build complete!"
### ๋ฐ”์ด๋„ˆ๋ฆฌ ํฌ๊ธฐ ์ตœ์ ํ™” Go ๋ฐ”์ด๋„ˆ๋ฆฌ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์ข€ ํฐ ํŽธ์ด์•ผ. ํ•˜์ง€๋งŒ ์ตœ์ ํ™”ํ•˜๋ฉด ํฌ๊ธฐ๋ฅผ ๋งŽ์ด ์ค„์ผ ์ˆ˜ ์žˆ์–ด:
# ๊ธฐ๋ณธ ๋นŒ๋“œ
go build -o filemaster

# ์ตœ์ ํ™”๋œ ๋นŒ๋“œ
go build -ldflags="-s -w" -o filemaster

# UPX๋กœ ์ถ”๊ฐ€ ์••์ถ• (์„ ํƒ์‚ฌํ•ญ)
upx --best --lzma filemaster
๋นŒ๋“œ ์˜ต์…˜ ์„ค๋ช… ํฌ๊ธฐ ๊ฐ์†Œ
-s ์‹ฌ๋ณผ ํ…Œ์ด๋ธ” ์ œ๊ฑฐ ~10%
-w DWARF ๋””๋ฒ„๊ทธ ์ •๋ณด ์ œ๊ฑฐ ~20%
UPX ์‹คํ–‰ ํŒŒ์ผ ์••์ถ• ~50-70%
โš ๏ธ ์ฃผ์˜: UPX ์••์ถ•

UPX๋กœ ์••์ถ•ํ•˜๋ฉด ๋ฐ”์ด๋„ˆ๋ฆฌ ํฌ๊ธฐ๋Š” ํฌ๊ฒŒ ์ค„์–ด๋“ค์ง€๋งŒ, ์ผ๋ถ€ ์•ˆํ‹ฐ๋ฐ”์ด๋Ÿฌ์Šค ํ”„๋กœ๊ทธ๋žจ์ด ์˜คํƒ์ง€ํ•  ์ˆ˜ ์žˆ์–ด. ๋˜ํ•œ ์‹คํ–‰ ์‹œ ์••์ถ• ํ•ด์ œ ์‹œ๊ฐ„์ด ์ถ”๊ฐ€๋˜๋ฏ€๋กœ ์‹œ์ž‘ ์†๋„๊ฐ€ ์•ฝ๊ฐ„ ๋А๋ ค์งˆ ์ˆ˜ ์žˆ์–ด.
### GitHub Releases๋กœ ๋ฐฐํฌํ•˜๊ธฐ GitHub Actions๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์ž๋™์œผ๋กœ ๋ฆด๋ฆฌ์Šค๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ์–ด:
# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version: '1.21'
      
      - name: Run tests
        run: go test -v ./...
      
      - name: Build binaries
        run: |
          chmod +x build.sh
          ./build.sh
      
      - name: Create Release
        uses: softprops/action-gh-release@v1
        with:
          files: dist/*
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
์ด์ œ ํƒœ๊ทธ๋ฅผ ํ‘ธ์‹œํ•˜๋ฉด ์ž๋™์œผ๋กœ ๋ฆด๋ฆฌ์Šค๊ฐ€ ์ƒ์„ฑ๋ผ:
git tag v1.0.0
git push origin v1.0.0
### Homebrew๋กœ ๋ฐฐํฌํ•˜๊ธฐ (macOS) macOS ์‚ฌ์šฉ์ž๋“ค์„ ์œ„ํ•ด Homebrew Formula๋ฅผ ๋งŒ๋“ค ์ˆ˜๋„ ์žˆ์–ด:
# Formula/filemaster.rb
class Filemaster < Formula
  desc "๊ฐ•๋ ฅํ•œ ํŒŒ์ผ ๊ด€๋ฆฌ CLI ๋„๊ตฌ"
  homepage "https://github.com/yourusername/filemaster"
  url "https://github.com/yourusername/filemaster/archive/v1.0.0.tar.gz"
  sha256 "your-sha256-hash"
  license "MIT"

  depends_on "go" => :build

  def install
    system "go", "build", *std_go_args(ldflags: "-s -w")
  end

  test do
    system "#{bin}/filemaster", "--version"
  end
end
์‚ฌ์šฉ์ž๋Š” ์ด๋ ‡๊ฒŒ ์„ค์น˜ํ•  ์ˆ˜ ์žˆ์–ด:
brew tap yourusername/tap
brew install filemaster
๋ฐฐํฌ ํŒŒ์ดํ”„๋ผ์ธ ์†Œ์Šค ์ฝ”๋“œ GitHub ํ…Œ์ŠคํŠธ CI/CD ๋นŒ๋“œ ํฌ๋กœ์Šค ์ปดํŒŒ์ผ ๋ฆด๋ฆฌ์Šค GitHub Linux amd64 arm64 macOS Intel Apple Silicon Windows amd64 386 ๊ธฐํƒ€ FreeBSD OpenBSD ๐Ÿ“ฆ Homebrew ๐Ÿณ Docker ๐Ÿ“ฅ ์ง์ ‘ ๋‹ค์šด๋กœ๋“œ ๐Ÿ”ง ์†Œ์Šค ๋นŒ๋“œ
## ๐ŸŽ“ ์‹ค์ „ ์˜ˆ์ œ: ์žฌ๋Šฅ๋„ท API ํด๋ผ์ด์–ธํŠธ ๋งŒ๋“ค๊ธฐ ์ด์ œ ๋ฐฐ์šด ๋‚ด์šฉ์„ ํ™œ์šฉํ•ด์„œ ์‹ค์ œ๋กœ ์œ ์šฉํ•œ ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์ž! ์žฌ๋Šฅ๋„ท์˜ API๋ฅผ ์‚ฌ์šฉํ•˜๋Š” CLI ํด๋ผ์ด์–ธํŠธ๋ฅผ ๋งŒ๋“ค์–ด๋ณผ๊ฒŒ. ์žฌ๋Šฅ๋„ท์€ ๋‹ค์–‘ํ•œ ์žฌ๋Šฅ์„ ๊ฑฐ๋ž˜ํ•˜๋Š” ํ”Œ๋žซํผ์ด๋‹ˆ๊นŒ, ์žฌ๋Šฅ ๊ฒ€์ƒ‰์ด๋‚˜ ๋“ฑ๋ก์„ CLI๋กœ ํ•  ์ˆ˜ ์žˆ์œผ๋ฉด ํŽธ๋ฆฌํ•˜๊ฒ ์ง€? ๐Ÿ˜Š ### ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ
jaenung-cli/
โ”œโ”€โ”€ cmd/
โ”‚   โ”œโ”€โ”€ root.go
โ”‚   โ”œโ”€โ”€ search.go
โ”‚   โ”œโ”€โ”€ post.go
โ”‚   โ””โ”€โ”€ profile.go
โ”œโ”€โ”€ internal/
โ”‚   โ”œโ”€โ”€ api/
โ”‚   โ”‚   โ””โ”€โ”€ client.go
โ”‚   โ””โ”€โ”€ config/
โ”‚       โ””โ”€โ”€ config.go
โ”œโ”€โ”€ .jaenung.yaml
โ””โ”€โ”€ main.go
### API ํด๋ผ์ด์–ธํŠธ ๊ตฌํ˜„ ๋จผ์ € ์žฌ๋Šฅ๋„ท API์™€ ํ†ต์‹ ํ•˜๋Š” ํด๋ผ์ด์–ธํŠธ๋ฅผ ๋งŒ๋“ค์ž:
// internal/api/client.go
package api

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "time"
)

type Client struct {
    BaseURL    string
    APIKey     string
    HTTPClient *http.Client
}

type Talent struct {
    ID          int       `json:"id"`
    Title       string    `json:"title"`
    Description string    `json:"description"`
    Category    string    `json:"category"`
    Price       int       `json:"price"`
    Author      string    `json:"author"`
    CreatedAt   time.Time `json:"created_at"`
}

type SearchOptions struct {
    Query    string
    Category string
    MinPrice int
    MaxPrice int
    Limit    int
}

func NewClient(baseURL, apiKey string) *Client {
    return &Client{
        BaseURL: baseURL,
        APIKey:  apiKey,
        HTTPClient: &http.Client{
            Timeout: 30 * time.Second,
        },
    }
}

func (c *Client) SearchTalents(opts SearchOptions) ([]Talent, error) {
    url := fmt.Sprintf("%s/api/talents/search", c.BaseURL)
    
    params := map[string]interface{}{
        "query":     opts.Query,
        "category":  opts.Category,
        "min_price": opts.MinPrice,
        "max_price": opts.MaxPrice,
        "limit":     opts.Limit,
    }
    
    body, err := json.Marshal(params)
    if err != nil {
        return nil, err
    }
    
    req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
    if err != nil {
        return nil, err
    }
    
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+c.APIKey)
    
    resp, err := c.HTTPClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        return nil, fmt.Errorf("API error: %s", string(body))
    }
    
    var talents []Talent
    if err := json.NewDecoder(resp.Body).Decode(&talents); err != nil {
        return nil, err
    }
    
    return talents, nil
}

func (c *Client) PostTalent(talent Talent) error {
    url := fmt.Sprintf("%s/api/talents", c.BaseURL)
    
    body, err := json.Marshal(talent)
    if err != nil {
        return err
    }
    
    req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
    if err != nil {
        return err
    }
    
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+c.APIKey)
    
    resp, err := c.HTTPClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusCreated {
        body, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("API error: %s", string(body))
    }
    
    return nil
}
### Search ์ปค๋งจ๋“œ ๊ตฌํ˜„ ์žฌ๋Šฅ์„ ๊ฒ€์ƒ‰ํ•˜๋Š” ๋ช…๋ น์–ด๋ฅผ ๋งŒ๋“ค์ž:
// cmd/search.go
package cmd

import (
    "fmt"
    "github.com/fatih/color"
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
    "jaenung-cli/internal/api"
)

var searchCmd = &cobra.Command{
    Use:   "search [ํ‚ค์›Œ๋“œ]",
    Short: "์žฌ๋Šฅ๋„ท์—์„œ ์žฌ๋Šฅ ๊ฒ€์ƒ‰",
    Long:  `ํ‚ค์›Œ๋“œ์™€ ํ•„ํ„ฐ๋ฅผ ์‚ฌ์šฉํ•ด์„œ ์žฌ๋Šฅ๋„ท์—์„œ ์žฌ๋Šฅ์„ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค.`,
    Args:  cobra.MinimumNArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        query := args[0]
        
        category, _ := cmd.Flags().GetString("category")
        minPrice, _ := cmd.Flags().GetInt("min-price")
        maxPrice, _ := cmd.Flags().GetInt("max-price")
        limit, _ := cmd.Flags().GetInt("limit")
        
        // API ํด๋ผ์ด์–ธํŠธ ์ƒ์„ฑ
        client := api.NewClient(
            viper.GetString("api.base_url"),
            viper.GetString("api.key"),
        )
        
        // ๊ฒ€์ƒ‰ ์‹คํ–‰
        opts := api.SearchOptions{
            Query:    query,
            Category: category,
            MinPrice: minPrice,
            MaxPrice: maxPrice,
            Limit:    limit,
        }
        
        talents, err := client.SearchTalents(opts)
        if err != nil {
            color.Red("โŒ ๊ฒ€์ƒ‰ ์‹คํŒจ: %v", err)
            return
        }
        
        // ๊ฒฐ๊ณผ ์ถœ๋ ฅ
        if len(talents) == 0 {
            color.Yellow("โš ๏ธ  ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค")
            return
        }
        
        color.Green("โœ“ %d๊ฐœ์˜ ์žฌ๋Šฅ์„ ์ฐพ์•˜์Šต๋‹ˆ๋‹ค:\n", len(talents))
        
        for i, talent := range talents {
            fmt.Printf("\n%d. ", i+1)
            color.Cyan(talent.Title)
            fmt.Printf("   ์นดํ…Œ๊ณ ๋ฆฌ: %s | ๊ฐ€๊ฒฉ: %s์›\n", 
                talent.Category, 
                formatPrice(talent.Price))
            fmt.Printf("   ์ž‘์„ฑ์ž: %s\n", talent.Author)
            
            if viper.GetBool("verbose") {
                fmt.Printf("   ์„ค๋ช…: %s\n", talent.Description)
            }
        }
        
        fmt.Println("\n๐Ÿ’ก ์ƒ์„ธ ์ •๋ณด๋ฅผ ๋ณด๋ ค๋ฉด --verbose ํ”Œ๋ž˜๊ทธ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”")
    },
}

func init() {
    rootCmd.AddCommand(searchCmd)
    
    searchCmd.Flags().StringP("category", "c", "", "์นดํ…Œ๊ณ ๋ฆฌ ํ•„ํ„ฐ")
    searchCmd.Flags().Int("min-price", 0, "์ตœ์†Œ ๊ฐ€๊ฒฉ")
    searchCmd.Flags().Int("max-price", 0, "์ตœ๋Œ€ ๊ฐ€๊ฒฉ")
    searchCmd.Flags().IntP("limit", "l", 10, "๊ฒฐ๊ณผ ๊ฐœ์ˆ˜ ์ œํ•œ")
}

func formatPrice(price int) string {
    if price >= 1000000 {
        return fmt.Sprintf("%.1f๋งŒ", float64(price)/10000)
    }
    return fmt.Sprintf("%d", price)
}
### Post ์ปค๋งจ๋“œ ๊ตฌํ˜„ ์ƒˆ๋กœ์šด ์žฌ๋Šฅ์„ ๋“ฑ๋กํ•˜๋Š” ๋ช…๋ น์–ด๋„ ๋งŒ๋“ค์ž:
// cmd/post.go
package cmd

import (
    "bufio"
    "fmt"
    "os"
    "strings"
    "github.com/fatih/color"
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
    "jaenung-cli/internal/api"
)

var postCmd = &cobra.Command{
    Use:   "post",
    Short: "์ƒˆ๋กœ์šด ์žฌ๋Šฅ ๋“ฑ๋ก",
    Long:  `์žฌ๋Šฅ๋„ท์— ์ƒˆ๋กœ์šด ์žฌ๋Šฅ์„ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค.`,
    Run: func(cmd *cobra.Command, args []string) {
        interactive, _ := cmd.Flags().GetBool("interactive")
        
        var talent api.Talent
        
        if interactive {
            talent = promptTalentInfo()
        } else {
            title, _ := cmd.Flags().GetString("title")
            description, _ := cmd.Flags().GetString("description")
            category, _ := cmd.Flags().GetString("category")
            price, _ := cmd.Flags().GetInt("price")
            
            if title == "" || description == "" || category == "" {
                color.Red("โŒ ํ•„์ˆ˜ ์ •๋ณด๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค")
                fmt.Println("--interactive ํ”Œ๋ž˜๊ทธ๋ฅผ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜ ๋ชจ๋“  ํ•„์ˆ˜ ํ”Œ๋ž˜๊ทธ๋ฅผ ์ง€์ •ํ•˜์„ธ์š”")
                return
            }
            
            talent = api.Talent{
                Title:       title,
                Description: description,
                Category:    category,
                Price:       price,
            }
        }
        
        // ํ™•์ธ
        fmt.Println("\n๐Ÿ“ ๋“ฑ๋กํ•  ์žฌ๋Šฅ ์ •๋ณด:")
        fmt.Printf("์ œ๋ชฉ: %s\n", talent.Title)
        fmt.Printf("์นดํ…Œ๊ณ ๋ฆฌ: %s\n", talent.Category)
        fmt.Printf("๊ฐ€๊ฒฉ: %d์›\n", talent.Price)
        fmt.Printf("์„ค๋ช…: %s\n", talent.Description)
        
        fmt.Print("\n๋“ฑ๋กํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? (y/N): ")
        reader := bufio.NewReader(os.Stdin)
        confirm, _ := reader.ReadString('\n')
        confirm = strings.TrimSpace(strings.ToLower(confirm))
        
        if confirm != "y" && confirm != "yes" {
            color.Yellow("โš ๏ธ  ๋“ฑ๋ก์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค")
            return
        }
        
        // API ํด๋ผ์ด์–ธํŠธ ์ƒ์„ฑ
        client := api.NewClient(
            viper.GetString("api.base_url"),
            viper.GetString("api.key"),
        )
        
        // ์žฌ๋Šฅ ๋“ฑ๋ก
        if err := client.PostTalent(talent); err != nil {
            color.Red("โŒ ๋“ฑ๋ก ์‹คํŒจ: %v", err)
            return
        }
        
        color.Green("โœ“ ์žฌ๋Šฅ์ด ์„ฑ๊ณต์ ์œผ๋กœ ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค!")
        fmt.Println("์žฌ๋Šฅ๋„ท(https://www.jaenung.net)์—์„œ ํ™•์ธํ•˜์„ธ์š”.")
    },
}

func init() {
    rootCmd.AddCommand(postCmd)
    
    postCmd.Flags().BoolP("interactive", "i", false, "๋Œ€ํ™”ํ˜• ๋ชจ๋“œ")
    postCmd.Flags().String("title", "", "์žฌ๋Šฅ ์ œ๋ชฉ")
    postCmd.Flags().String("description", "", "์žฌ๋Šฅ ์„ค๋ช…")
    postCmd.Flags().String("category", "", "์นดํ…Œ๊ณ ๋ฆฌ")
    postCmd.Flags().Int("price", 0, "๊ฐ€๊ฒฉ")
}

func promptTalentInfo() api.Talent {
    reader := bufio.NewReader(os.Stdin)
    
    fmt.Print("์ œ๋ชฉ: ")
    title, _ := reader.ReadString('\n')
    title = strings.TrimSpace(title)
    
    fmt.Print("์นดํ…Œ๊ณ ๋ฆฌ: ")
    category, _ := reader.ReadString('\n')
    category = strings.TrimSpace(category)
    
    fmt.Print("๊ฐ€๊ฒฉ (์›): ")
    var price int
    fmt.Scanln(&price)
    reader.ReadString('\n') // ๋ฒ„ํผ ๋น„์šฐ๊ธฐ
    
    fmt.Print("์„ค๋ช…: ")
    description, _ := reader.ReadString('\n')
    description = strings.TrimSpace(description)
    
    return api.Talent{
        Title:       title,
        Description: description,
        Category:    category,
        Price:       price,
    }
}
### ์„ค์ • ํŒŒ์ผ
# .jaenung.yaml
api:
  base_url: "https://api.jaenung.net"
  key: "your-api-key-here"

defaults:
  search_limit: 10
  category: ""

output:
  format: "table"  # table, json, yaml
  color: true
  verbose: false
์ด์ œ ์ด๋ ‡๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด:
# ์žฌ๋Šฅ ๊ฒ€์ƒ‰
jaenung-cli search "ํ”„๋กœ๊ทธ๋ž˜๋ฐ" --category "๊ฐœ๋ฐœ" --limit 5

# ๋Œ€ํ™”ํ˜• ๋ชจ๋“œ๋กœ ์žฌ๋Šฅ ๋“ฑ๋ก
jaenung-cli post --interactive

# ํ”Œ๋ž˜๊ทธ๋กœ ์žฌ๋Šฅ ๋“ฑ๋ก
jaenung-cli post \
  --title "Go ์–ธ์–ด ๊ณผ์™ธ" \
  --description "Go ์–ธ์–ด๋ฅผ ์ฒ˜์Œ๋ถ€ํ„ฐ ์•Œ๋ ค๋“œ๋ฆฝ๋‹ˆ๋‹ค" \
  --category "๊ต์œก" \
  --price 50000
## ๐ŸŒŸ ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค์™€ ํŒ CLI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค๋ฉด์„œ ๋ฐฐ์šด ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค๋“ค์„ ์ •๋ฆฌํ•ด๋ณผ๊ฒŒ! ### 1. ์‚ฌ์šฉ์ž ์นœํ™”์ ์ธ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋Š” ๋ช…ํ™•ํ•˜๊ณ  ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์„ ์ œ์‹œํ•ด์•ผ ํ•ด:
// ๋‚˜์œ ์˜ˆ
fmt.Println("Error: invalid input")

// ์ข‹์€ ์˜ˆ
color.Red("โŒ ์˜ค๋ฅ˜: ์œ ํšจํ•˜์ง€ ์•Š์€ ์ž…๋ ฅ์ž…๋‹ˆ๋‹ค")
fmt.Println("\nํ•ด๊ฒฐ ๋ฐฉ๋ฒ•:")
fmt.Println("  1. ํŒŒ์ผ ๊ฒฝ๋กœ๋ฅผ ํ™•์ธํ•˜์„ธ์š”")
fmt.Println("  2. ํŒŒ์ผ ๊ถŒํ•œ์„ ํ™•์ธํ•˜์„ธ์š”")
fmt.Println("  3. --help ์˜ต์…˜์œผ๋กœ ์‚ฌ์šฉ๋ฒ•์„ ํ™•์ธํ•˜์„ธ์š”")
### 2. ์ง„ํ–‰ ์ƒํ™ฉ ํ‘œ์‹œ ์˜ค๋ž˜ ๊ฑธ๋ฆฌ๋Š” ์ž‘์—…์€ ๋ฐ˜๋“œ์‹œ ์ง„ํ–‰ ์ƒํ™ฉ์„ ๋ณด์—ฌ์ค˜:
spinner := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
spinner.Suffix = " ํŒŒ์ผ ์ฒ˜๋ฆฌ ์ค‘..."
spinner.Start()

// ์ž‘์—… ์ˆ˜ํ–‰
processFiles()

spinner.Stop()
color.Green("โœ“ ์™„๋ฃŒ!")
### 3. ์„ค์ •์˜ ์šฐ์„ ์ˆœ์œ„ ์„ค์ • ๊ฐ’์˜ ์šฐ์„ ์ˆœ์œ„๋ฅผ ๋ช…ํ™•ํžˆ ํ•ด: 1. ์ปค๋งจ๋“œ ๋ผ์ธ ํ”Œ๋ž˜๊ทธ 2. ํ™˜๊ฒฝ ๋ณ€์ˆ˜ 3. ์„ค์ • ํŒŒ์ผ 4. ๊ธฐ๋ณธ๊ฐ’ ### 4. ๋„์›€๋ง ์ž‘์„ฑ ๋ช…ํ™•ํ•˜๊ณ  ์˜ˆ์ œ๊ฐ€ ํฌํ•จ๋œ ๋„์›€๋ง์„ ์ž‘์„ฑํ•ด:
var searchCmd = &cobra.Command{
    Use:   "search [ํ‚ค์›Œ๋“œ]",
    Short: "์žฌ๋Šฅ ๊ฒ€์ƒ‰",
    Long: `์žฌ๋Šฅ๋„ท์—์„œ ํ‚ค์›Œ๋“œ๋กœ ์žฌ๋Šฅ์„ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค.

๊ฒ€์ƒ‰ ์˜ต์…˜:
  - ์นดํ…Œ๊ณ ๋ฆฌ ํ•„ํ„ฐ๋ง
  - ๊ฐ€๊ฒฉ ๋ฒ”์œ„ ์ง€์ •
  - ๊ฒฐ๊ณผ ๊ฐœ์ˆ˜ ์ œํ•œ`,
    Example: `  # ๊ธฐ๋ณธ ๊ฒ€์ƒ‰
  jaenung-cli search "ํ”„๋กœ๊ทธ๋ž˜๋ฐ"

  # ์นดํ…Œ๊ณ ๋ฆฌ์™€ ๊ฐ€๊ฒฉ ํ•„ํ„ฐ
  jaenung-cli search "๋””์ž์ธ" --category "๊ทธ๋ž˜ํ”ฝ" --max-price 100000

  # ์ƒ์„ธ ์ •๋ณด ํฌํ•จ
  jaenung-cli search "๋ฒˆ์—ญ" --verbose`,
    Run: searchRun,
}
### 5. ๋ฒ„์ „ ์ •๋ณด ์ œ๊ณต ํ•ญ์ƒ ๋ฒ„์ „ ์ •๋ณด๋ฅผ ์ œ๊ณตํ•ด:
var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "๋ฒ„์ „ ์ •๋ณด ์ถœ๋ ฅ",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("jaenung-cli version %s\n", version)
        fmt.Printf("commit: %s\n", commit)
        fmt.Printf("built at: %s\n", date)
    },
}
๋นŒ๋“œ ์‹œ ๋ฒ„์ „ ์ •๋ณด๋ฅผ ์ฃผ์ž…:
go build -ldflags="-X main.version=1.0.0 -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
### 6. ๋กœ๊น… ๋ ˆ๋ฒจ ๋‹ค์–‘ํ•œ ๋กœ๊น… ๋ ˆ๋ฒจ์„ ์ง€์›ํ•ด:
import "github.com/sirupsen/logrus"

var log = logrus.New()

func init() {
    if viper.GetBool("debug") {
        log.SetLevel(logrus.DebugLevel)
    } else if viper.GetBool("verbose") {
        log.SetLevel(logrus.InfoLevel)
    } else {
        log.SetLevel(logrus.WarnLevel)
    }
}
### 7. ์•ˆ์ „ํ•œ ์ข…๋ฃŒ ์‹œ๊ทธ๋„์„ ์ฒ˜๋ฆฌํ•ด์„œ ์•ˆ์ „ํ•˜๊ฒŒ ์ข…๋ฃŒ:
import (
    "context"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    
    // ์‹œ๊ทธ๋„ ์ฒ˜๋ฆฌ
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    
    go func() {
        <-sigChan
        fmt.Println("\n์ข…๋ฃŒ ์ค‘...")
        cancel()
    }()
    
    // ๋ฉ”์ธ ๋กœ์ง ์‹คํ–‰
    if err := run(ctx); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}
๐ŸŽฏ ํ•ต์‹ฌ ์ฒดํฌ๋ฆฌ์ŠคํŠธ
  • โœ… ๋ช…ํ™•ํ•œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€์™€ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ• ์ œ์‹œ
  • โœ… ์ง„ํ–‰ ์ƒํ™ฉ ํ‘œ์‹œ (์Šคํ”ผ๋„ˆ, ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”)
  • โœ… ์ƒ์„ธํ•œ ๋„์›€๋ง๊ณผ ์˜ˆ์ œ
  • โœ… ๋ฒ„์ „ ์ •๋ณด ์ œ๊ณต
  • โœ… ๋‹ค์–‘ํ•œ ์ถœ๋ ฅ ํ˜•์‹ ์ง€์› (JSON, YAML, ํ…Œ์ด๋ธ”)
  • โœ… ์„ค์ • ํŒŒ์ผ๊ณผ ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์ง€์›
  • โœ… ์ž๋™ ์™„์„ฑ ์Šคํฌ๋ฆฝํŠธ ์ œ๊ณต
  • โœ… ์ ์ ˆํ•œ ์ข…๋ฃŒ ์ฝ”๋“œ ๋ฐ˜ํ™˜
  • โœ… ๋กœ๊น…๊ณผ ๋””๋ฒ„๊ทธ ๋ชจ๋“œ
  • โœ… ์•ˆ์ „ํ•œ ์‹œ๊ทธ๋„ ์ฒ˜๋ฆฌ
## ๐Ÿ”ฎ ๋” ๋‚˜์•„๊ฐ€๊ธฐ CLI ๋„๊ตฌ ๊ฐœ๋ฐœ์˜ ๊ธฐ๋ณธ์„ ๋งˆ์Šคํ„ฐํ–ˆ๋‹ค๋ฉด, ์ด์ œ ๋” ๊ณ ๊ธ‰ ์ฃผ์ œ๋“ค์„ ํƒํ—˜ํ•ด๋ณผ ์ฐจ๋ก€์•ผ! ### TUI (Text User Interface) ๋งŒ๋“ค๊ธฐ ํ„ฐ๋ฏธ๋„์—์„œ๋„ GUI์ฒ˜๋Ÿผ ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒํ•œ ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ์–ด. bubbletea ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋ผ:
go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/lipgloss
๊ฐ„๋‹จํ•œ ๋ฉ”๋‰ด ์˜ˆ์ œ:
package main

import (
    "fmt"
    "os"
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
)

type model struct {
    choices  []string
    cursor   int
    selected map[int]struct{}
}

func initialModel() model {
    return model{
        choices: []string{"๊ฒ€์ƒ‰", "๋“ฑ๋ก", "ํ”„๋กœํ•„", "์ข…๋ฃŒ"},
        selected: make(map[int]struct{}),
    }
}

func (m model) Init() tea.Cmd {
    return nil
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+c", "q":
            return m, tea.Quit
        case "up", "k":
            if m.cursor > 0 {
                m.cursor--
            }
        case "down", "j":
            if m.cursor < len(m.choices)-1 {
                m.cursor++
            }
        case "enter", " ":
            _, ok := m.selected[m.cursor]
            if ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        }
    }
    return m, nil
}

func (m model) View() string {
    s := "์žฌ๋Šฅ๋„ท CLI - ๋ฉ”๋‰ด๋ฅผ ์„ ํƒํ•˜์„ธ์š”:\n\n"
    
    for i, choice := range m.choices {
        cursor := " "
        if m.cursor == i {
            cursor = ">"
        }
        
        checked := " "
        if _, ok := m.selected[i]; ok {
            checked = "x"
        }
        
        s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
    }
    
    s += "\n(โ†‘/โ†“: ์ด๋™, Space: ์„ ํƒ, Enter: ์‹คํ–‰, q: ์ข…๋ฃŒ)\n"
    
    return s
}

func main() {
    p := tea.NewProgram(initialModel())
    if err := p.Start(); err != nil {
        fmt.Printf("Error: %v", err)
        os.Exit(1)
    }
}
### ํ”Œ๋Ÿฌ๊ทธ์ธ ์‹œ์Šคํ…œ ํ™•์žฅ ๊ฐ€๋Šฅํ•œ CLI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค๊ณ  ์‹ถ๋‹ค๋ฉด ํ”Œ๋Ÿฌ๊ทธ์ธ ์‹œ์Šคํ…œ์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์–ด:
// plugin/plugin.go
package plugin

import "plugin"

type Plugin interface {
    Name() string
    Execute(args []string) error
}

func LoadPlugin(path string) (Plugin, error) {
    p, err := plugin.Open(path)
    if err != nil {
        return nil, err
    }
    
    symPlugin, err := p.Lookup("Plugin")
    if err != nil {
        return nil, err
    }
    
    var plug Plugin
    plug, ok := symPlugin.(Plugin)
    if !ok {
        return nil, fmt.Errorf("unexpected type from module symbol")
    }
    
    return plug, nil
}
### ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ†ตํ•ฉ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•ด์•ผ ํ•œ๋‹ค๋ฉด SQLite๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด:
go get github.com/mattn/go-sqlite3
package db

import (
    "database/sql"
    _ "github.com/mattn/go-sqlite3"
)

type DB struct {
    conn *sql.DB
}

func New(path string) (*DB, error) {
    conn, err := sql.Open("sqlite3", path)
    if err != nil {
        return nil, err
    }
    
    // ํ…Œ์ด๋ธ” ์ƒ์„ฑ
    _, err = conn.Exec(`
        CREATE TABLE IF NOT EXISTS history (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            command TEXT NOT NULL,
            timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
        )
    `)
    if err != nil {
        return nil, err
    }
    
    return &DB{conn: conn}, nil
}

func (db *DB) SaveCommand(command string) error {
    _, err := db.conn.Exec("INSERT INTO history (command) VALUES (?)", command)
    return err
}

func (db *DB) GetHistory(limit int) ([]string, error) {
    rows, err := db.conn.Query(
        "SELECT command FROM history ORDER BY timestamp DESC LIMIT ?", 
        limit,
    )
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    var history []string
    for rows.Next() {
        var cmd string
        if err := rows.Scan(&cmd); err != nil {
            return nil, err
        }
        history = append(history, cmd)
    }
    
    return history, nil
}
### ์›น ์„œ๋ฒ„ ๋ชจ๋“œ CLI ๋„๊ตฌ์— ์›น ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜๋„ ์žˆ์–ด:
var serveCmd = &cobra.Command{
    Use:   "serve",
    Short: "์›น ์„œ๋ฒ„ ๋ชจ๋“œ๋กœ ์‹คํ–‰",
    Run: func(cmd *cobra.Command, args []string) {
        port, _ := cmd.Flags().GetInt("port")
        
        http.HandleFunc("/api/search", handleSearch)
        http.HandleFunc("/api/post", handlePost)
        
        addr := fmt.Sprintf(":%d", port)
        color.Green("๐ŸŒ ์›น ์„œ๋ฒ„ ์‹œ์ž‘: http://localhost%s", addr)
        
        if err := http.ListenAndServe(addr, nil); err != nil {
            color.Red("โŒ ์„œ๋ฒ„ ์‹œ์ž‘ ์‹คํŒจ: %v", err)
        }
    },
}

func init() {
    rootCmd.AddCommand(serveCmd)
    serveCmd.Flags().IntP("port", "p", 8080, "ํฌํŠธ ๋ฒˆํ˜ธ")
}
๐Ÿ’ก ํ•™์Šต ๋ฆฌ์†Œ์Šค

๋” ๊นŠ์ด ๊ณต๋ถ€ํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด ์ด๋Ÿฐ ๋ฆฌ์†Œ์Šค๋“ค์„ ์ถ”์ฒœํ•ด:

โ€ข Cobra ๊ณต์‹ ๋ฌธ์„œ - https://cobra.dev
โ€ข Viper GitHub - https://github.com/spf13/viper
โ€ข Awesome Go CLI - CLI ๊ด€๋ จ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๋ชจ์Œ
โ€ข 12 Factor CLI Apps - CLI ๋„๊ตฌ ์„ค๊ณ„ ์›์น™
โ€ข Command Line Interface Guidelines - CLI UX ๊ฐ€์ด๋“œ
## ๐ŸŽฌ ๋งˆ๋ฌด๋ฆฌํ•˜๋ฉฐ ์™€! ์—ฌ๊ธฐ๊นŒ์ง€ ์ •๋ง ๊ธด ์—ฌ์ •์ด์—ˆ์–ด. Go์–ธ์–ด๋กœ CLI ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๋งŒ๋“œ๋Š” ๋ฐฉ๋ฒ•์„ ์ฒ˜์Œ๋ถ€ํ„ฐ ๋๊นŒ์ง€ ์‚ดํŽด๋ดค์–ด. Cobra์™€ Viper๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ํ”„๋กœํŽ˜์…”๋„ํ•œ ๋ช…๋ นํ–‰ ๋„๊ตฌ๋ฅผ ์ •๋ง ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฑธ ์•Œ์•˜์„ ๊ฑฐ์•ผ. ๐ŸŽ‰ ํ•ต์‹ฌ ๋‚ด์šฉ์„ ๋‹ค์‹œ ์ •๋ฆฌํ•˜๋ฉด: **๐Ÿ”น Cobra**๋Š” ๋ช…๋ น์–ด ๊ตฌ์กฐ์™€ ํ”Œ๋ž˜๊ทธ ๊ด€๋ฆฌ๋ฅผ ๋‹ด๋‹นํ•ด. ์„œ๋ธŒ์ปค๋งจ๋“œ๋ฅผ ์‰ฝ๊ฒŒ ๋งŒ๋“ค๊ณ , ์ž๋™ ์™„์„ฑ๊ณผ ๋„์›€๋ง์„ ์ž๋™์œผ๋กœ ์ƒ์„ฑํ•ด์ค˜. **๐Ÿ”น Viper**๋Š” ์„ค์ • ๊ด€๋ฆฌ์˜ ๋ชจ๋“  ๊ฒƒ์„ ํ•ด๊ฒฐํ•ด์ค˜. ์„ค์ • ํŒŒ์ผ, ํ™˜๊ฒฝ ๋ณ€์ˆ˜, ํ”Œ๋ž˜๊ทธ๋ฅผ ํ†ตํ•ฉํ•ด์„œ ๊ด€๋ฆฌํ•˜๊ณ , ์šฐ์„ ์ˆœ์œ„๋„ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌํ•ด. **๐Ÿ”น Go์–ธ์–ด**๋Š” CLI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค๊ธฐ์— ์™„๋ฒฝํ•œ ์–ธ์–ด์•ผ. ๋‹จ์ผ ๋ฐ”์ด๋„ˆ๋ฆฌ ๋ฐฐํฌ, ํฌ๋กœ์Šค ์ปดํŒŒ์ผ, ๋น ๋ฅธ ์„ฑ๋Šฅ, ๋™์‹œ์„ฑ ์ฒ˜๋ฆฌ๊นŒ์ง€ ๋ชจ๋“  ๊ฒŒ ๊ฐ–์ถฐ์ ธ ์žˆ์–ด. ์ด์ œ ์—ฌ๋Ÿฌ๋ถ„๋„ ์ž์‹ ๋งŒ์˜ ๋ฉ‹์ง„ CLI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ์„ ๊ฑฐ์•ผ! ํŒŒ์ผ ๊ด€๋ฆฌ ๋„๊ตฌ๋“ , API ํด๋ผ์ด์–ธํŠธ๋“ , ์ž๋™ํ™” ์Šคํฌ๋ฆฝํŠธ๋“  ๋ญ๋“ ์ง€ ๋งŒ๋“ค ์ˆ˜ ์žˆ์–ด. ์žฌ๋Šฅ๋„ท ๊ฐ™์€ ํ”Œ๋žซํผ์—์„œ ์—ฌ๋Ÿฌ๋ถ„์˜ ๊ฐœ๋ฐœ ์žฌ๋Šฅ์„ ๊ณต์œ ํ•  ์ˆ˜๋„ ์žˆ๊ณ  ๋ง์ด์•ผ! ๐Ÿ˜Š CLI ๋„๊ตฌ ๊ฐœ๋ฐœ์€ ์ •๋ง ์žฌ๋ฏธ์žˆ๊ณ  ๋ณด๋žŒ์ฐฌ ์ž‘์—…์ด์•ผ. ํ•œ ๋ฒˆ ๋งŒ๋“ค์–ด๋‘๋ฉด ๋งค์ผ ์‚ฌ์šฉํ•˜๋ฉด์„œ ์‹œ๊ฐ„์„ ์ ˆ์•ฝํ•  ์ˆ˜ ์žˆ๊ฑฐ๋“ . ๊ทธ๋ฆฌ๊ณ  ์˜คํ”ˆ์†Œ์Šค๋กœ ๊ณต๊ฐœํ•˜๋ฉด ๋‹ค๋ฅธ ๊ฐœ๋ฐœ์ž๋“ค์—๊ฒŒ๋„ ๋„์›€์ด ๋˜์ง€! ์ž, ์ด์ œ ์—ฌ๋Ÿฌ๋ถ„ ์ฐจ๋ก€์•ผ. ํ„ฐ๋ฏธ๋„์„ ์—ด๊ณ , ์ฝ”๋“œ ์—๋””ํ„ฐ๋ฅผ ์ผœ๊ณ , ๋ฉ‹์ง„ CLI ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์ž! ํ™”์ดํŒ…! ๐Ÿš€
๋Œ“๊ธ€ ์ž‘์„ฑ

์ด ๊ธ€์— ๋Œ€ํ•œ ์—ฌ๋Ÿฌ๋ถ„์˜ ์ƒ๊ฐ์„ ๋“ค๋ ค์ฃผ์„ธ์š”

๋Œ“๊ธ€ 0