Learning Go 정리 8일차 - Module, Package, Import

0

Learning Go

목록 보기
9/12

모듈, 패키지, 임포트

저장소, 모듈, 패키지

go에서 라이브러리 관리는 저장소, 모듈 그리고 패키지라는 개념에 기반한다. 저장소는 프로젝트를 위한 소스 코드가 저장된 버전 관리 시스템이다. 가령, github의 repository같은 것들이 있다. 모듈은 저장소에 저장된 go라이브러리나 응용 프로그램의 최상위 루트이다. 모듈은 모듈 구성 및 구조를 제공하는 하나 이상의 패키지로 구성되어 있다.

즉 다음과 같이 생각할 수 있다.

- Github
    - repo(모듈) -> package, package ...
    - repo(모듈) -> package, ...

모든 모듈은 전역적으로 유일한 식별자를 가지고 있어야 한다. 가령, 자바에서는 com.companyname.projectname.library와 같이 전역으로 유일한 패키지 이름을 사용한다.

go.mod

go 소스 코드의 컬렉션은 해당 루트 디렉터리에 유효한 go.mod파일이 있을 때 모듈이 된다. go.mod파일은 해당 go프로젝트의 의존성(모듈)을 관리하는데, npm에서의 package.json, spring에서의 pom.xml과 비슷하다고 생각하면 된다. go mod init MODULE_PATH을 통해서 go.mod파일을 자동으로 생성할 수 있다. MODULE_PATH은 앞서 이야기했던 go 모듈을 식별하는 유일한 식별자 역할을 한다. 주로, github에 저장소를 둔 경우에는 github.com/user/repo로 이름을 짓는다.

가령, github에 저장소를 두고, learning-go-book이라는 유저가 money라는 github repo로 go 모듈을 만든다면 다음과 같이쓰면 된다.

go mod init github.com/learning-go-book/money

참고로 모듈 경로는 대소문자를 구분한다. 또한 혼선이 있기 때문에 대문자는 되도록 쓰지 않는다.

go.mod파일이 만들어지고 다음과 같은 모습을 하게 된다.

module github.com/learning-go-book/money

go 1.15

replace(
    ...
)

require(
    ...
)

구성이 되는 요소들은 다음과 같다.

  1. module: 현재 go 모듈의 유일한 식별자이다. 소스코드는 해당 모듈 이름을 기반으로 package를 찾는다. 가령 해당 모듈에 repository 패키지가 있다면 "github.com/learning-go-book/money/repository"import한다.
  2. go: golang의 버전을 명시한다.
  3. replace: require에 있는 go모듈의 이름이나 버전을 replace할 수 있다. 특정 버전으로 변경하거나 특정 모듈의 이름이 달라졌을 때 유용하게 사용할 수 있다.
  4. require: 해당 모듈에 필요한 다른 라이브러리(모듈)의 이름과 버전을 기록한다.

패키지

go의 import문은 다른 패키지에서 노출된(exposed) 상수, 변수, 함수 ,타입을 접근할 수 있도록 한다. 즉, 패키지의 노출된 식별자(변수, 상수, 타입, 함수, 메서드 혹은 구조체의 field)는 import문 없이는 다른 패키지에서 접근할 수 없다. go에서 패키지 레벨 식별자를 외부에 보여주게 하기위해서는 이름 앞 글자를 대문자로 쓰기만 해주면 된다. 반대로 이름이 소문자로 시작하거나 _로 시작하는 식별자는 선언된 패키지 내에서만 접근이 가능하다.

패키지 생성과 접근

go의 패키지는 directory당 한 개만을 허용한다. 따라서 math라는 디렉터리를 만들고 패키지를 만들어보도록 하자. go에서의 패키지 생성은 다음과 같이 한다.

  • math/math.go
package math

func Double(a int) int {
    return a * 2
}

package는 패키지 절(package clause)라고 하는데, package 키워드와 패키지 이름으로 구성된다. 패키지 절은 go 소스 파일에서 비어 있지 않고 주석이 아닌 첫번째 라인이다.

다음으로 formatter 디렉터리를 만들고 formatter.go파일을 만들도록 하자.

  • formatter/formatter.go
package print

import "fmt"

func Format(num int) string {
    return fmt.Sprintf("The number is %d", num)
}

formatter 디렉터리에 있지만 패키지 이름은 print라고 지었다.

마지막으로 두 코드를 사용하는 main의 코드이다.

  • main.go
package main

import (
    "fmt"
    "github.com/learning-go-book/package_example/formatter"
    "github.com/learning-go-book/package_example/math"
)

func main() {
    num := math.Double(2)
    output := print.Format(num)
    fmt.Println(output)
}

import문을 보도록 하자. 표준 라이브러리를 제외한 두 개의 경로는 해당 프로그램에 있는 패키지를 참조한다. 이 사실로 알 수 있는 것은 표준 라이브러리를 제외한 모든 것들은 import path를 반드시 지정해야한다는 것이다.

main함수에서 다른 패키지에 있는 식별자(함수, 변수, 타입 등등)을 가져올 때는 package이름.식별자로 호출한다.

디렉터리에 있는 모든 go파일은 동일한 패키지 절을 가져야 한다. 우리는 github.com/learning-go-book/package_example/formatter 경로의 모듈에서 print패키지를 가여왔다. 이는 import 경로가 아니라 패키지 절로 패키지 이름으로 결정되기 때문이다. 때문에 formatter.Format이 아닌 print.Format을 호출하는 것이다.

일반적으로 디렉터리 이름과 패키지 이름을 일치하는 패키지 이름을 만드는 것이 좋다. 포함하는 디렉터리와 이름이 다르다면 패키지를 찾아내기 어려울 수 있다. 하지만 디렉터리와 패키지 이름을 다르게 사용해야 하는 몇 가지 상황들이 있다.

첫 번째는 특별한 패키지 이름인 main을 사용하여 go 응용 프로그램의 시작점으로 패키지를 선언한다. main 패키지는 가져올 수 없기 때문에 혼란스러운 import문을 생성하지 않는다.

두 번째 이유는 디렉터리를 사용하여 버전 관리를 지원하는 것이다. 이는 추후에 더 다뤄보도록 하자.

패키지 이름짓기

패키지 이름을 짓는 데에는 몇 가지 노하우들이 있다.

  1. 패키지 이름은 모호하지 않아야하고 설명적이어야한다. 가령 util이라는 패키지 이름보다는 기능을 설명하는 패키지 이름이 더 좋다. util에 숫자를 문자열로 바꿔주는 convertIntToString함수와 이름을 포맷팅해주는 formatName함수가 있다면, 둘 다 util.convertIntToString, util.formatName으로 사용해야한다. 여기에 util은 그 어떠한 설명을 해주지 않는다. 차라리 두 개의 패키지로 나누어서 convert.IntToString()format.Name()으로 바꿔주는 것이 좋다.
  2. 패키지 내의 함수 및 타입의 이름에서 패키지 이름을 반족하지 않도록 하자. format.formatName은 기능에 대한 설명을 두 번 반복하는 꼴이 된다. 다만 식별자의 이름이 패키지 이름과 같은 경우에는 해당 규칙에서 예외로 한다. 가령 sort패키지에는 Sort라는 함수가 있고, context에는 Context라는 인터페이스가 있다.

패키지 이름 재정의

때로는 중복된 이름의 두 패키지를 가져와야 하는 경우가 있다. 암호학적으로 안전한 cryto/rand와 안전하지 않은 math/rand가 있다. 이 둘 모두 같은 패키지 이름인 rand를 가지는데, 함께 사용하는 일이 발생한다면 현재 파일 내에서 하나의 패키지에 대체 이름을 부여해야 한다.

package main

import (
	crand "crypto/rand"
	"encoding/binary"
	"fmt"
	"math/rand"
)

func seedRand() *rand.Rand {
	var b [8]byte
	_, err := crand.Read(b[:])
	if err != nil {
		panic("cannot seed with crytographic random number generator")
	}
	fmt.Println(b) // [230 99 80 178 36 21 38 57]
	r := rand.New(rand.NewSource(int64(binary.LittleEndian.Uint64(b[:]))))
	return r
}

func main() {
	seedRand()
}

위의 코드는 crypto/rand에서 랜덤한 8개의 숫자를 가져오고 이를 math/randNew매서드에 seed로 넣어 새로운 랜덤 값을 생성하는 코드이다. crypto/randmath/rand는 패키지 이름이 동일하므로 충돌이 발생한다. 따라서, "crypto/rand"에 대체 패키지 이름으로 crand를 부여한 것이다. 이렇게 함으로서 crypto/rand의 식별자와 math/rand의 식별자가 충돌하지 않은 것이다.

crand.Read(b[:])

다음과 같이 접근한 것을 확인할 수 있다.

패키지 주석과 godoc

go는 자동적으로 문서로 변환해주는 주석을 작성하기 위한 go만의 포맷을 가진다. 이를 godoc이라고 부른다. godoc주석에는 특별한 심볼은 없고 관례만 있다.

패키지 선언 전에 주석은 패키지 레벨 주석을 생성한다. 패키지를 위한 긴 주석을 작성해야한다면, 따로 패키지 내에 doc.go라는 파일에 주석을 넣는 것이 관례이다.

// package money provides various utilities to make it easy to manage money
package money

다음으로 노출된 구조체에 대한 주석 작성이다.

//Money represents the combination of an amount of money
// and the currency the money is in.
type Money struct {
    Value decimal.Decimal
    Currency string
}

함수의 주석 예이다.

// Convert converts the value of one currency to another
//
// It has two parameters: a Money instance with the value to convert,
// and a string that represents the currency to convert to. Convert returns
// the converted currency and any errors encountered from unknown or unconvertible
// currencies
// If an error is returned, the Money istance is set to the zero value.
//
// Supported currencies are:
//              USD - US Dollar
//              CAT - Canadian Dollar
//
// More information on exchange rates can be found
// at https://wwww.investopedia.com/terms/e/excahngerate.asp
func Convert(from Money, to string) (Money, error) {
    // ...
}

godoc을 만들기 위해서는 go doc커맨드를 사용하면 된다. go doc PACKAGE_NAME은 지정된 패키지를 위한 godoc과 패키지의 식별자 목록을 보여준다. 패키지의 특정 식별자를 위한 문서를 출력하기 위해서는 go doc PACKAGE_NAME.IDENTIFIER_NAME 명령어를 사용하도록 하자.

최소한 패키지에서 exposed 코드에 대한 주석이 있어야 한다.

내부 패키지

때로는 모듈에서 패키지 간에 함수, 타입, 상수를 공유하고 싶지만 이를 API의 일부가 되게 하고 싶지 않은 경우가 있다. 가령 사용자에게 database기능을 제공하는 database 모듈을 라이브러리로 만들었는데, 이 내부에서 공용으로 사용하는 식별자들에 대해서는 사용자에게 노출시키고 싶지 않을 것이다. 대문자로 시작하는 식별자를 최대한 쓰지 않으면 되지만, 해당 라이브러리 내의 패키지끼리의 로직을 위해서는 불필요하게 대문자로 시작하는 식별자를 만들 수 밖에 없는 경우가 있다. 이렇게 되면 해당 라이브러리를 사용하는 사용자도 해당 패키지 내부에서만 통용으로 쓰려고 만든 패키지의 식별자들에 대해 접근할 수 있어 문제가 된다. 이를 위해서 go에서는 특별한 패키지인 internal이라는 패키지를 제공한다.

즉 이제 database라는 모듈은 내부 패키지들 끼리 공용할 식별자들을 internal에 넣어주고 호출하면 된다. 사용자는 database 모듈을 사용한다해도 internal에는 접근이 불가능하여 대문자로 시작하는 식별자에 접근이 안된다.

한 가지 조심할 것은 해당 모듈에서도 internal의 접근이 일부 제한되는데, internal 패키지는 자신과 같은 레벨에 있거나, 바로 상위의 부모 패키지 외에는 자신을 호출하지 못한다. 다음의 구조를 확인해보자.

* bar
    - bar.go
* foo
    - foo.go
    * internal
        - internal.go
    * sibling
        - sibling.go

foo 패키지와 sibling 패키지는 internal패키지와 같은 위치에 있거나 바로 상위에 있는 위치이다. 그러나 bar는 바로 상위도 아니고, 같은 위치에 있지도 않으므로 bar 패키지에서는 internal패키지에 접근할 수 없다.

init함수

어떠한 파라미터도 받지 않고 반환하는 값이 없는 init이라는 함수를 선언하면 다른 패키지에서 패키지가 처음 참조되는 순간 수행된다. init함수는 어떠한 입력과 출력을 가지지 않기 때문에, 패키지 레벨 함수나 변수와 상호 작용하는 범위에서만 동작한다. 즉, init함수는 패키지가 초기에 호출될 때 상태를 설정하는 부분인 것이다.

재밌는 것은 특정 패키지들은 init만 호출되고 어떠한 식별자(변수, 함수, 구조체, 인터페이스 등)이 참조되지 않는 경우가 있다. 단, golang의 경우 어떠한 식별자도 호출되지 않으면 해당 패키지를 import`하는 것을 막기 때문에 다음과 같은 패턴을 사용해야한다.

import (
    "database/sql"

    _ "github.com/lib/pq"
)

github.com/lib/pq 패키지에 있는 어떠한 식별자를 쓰지 않고 init만 호출해야한다면 위와 같이 _로 공백 가져오기(black import)를 하면된다. 이렇게 해도 import할 때 init이 호출될 것이다.

이러한 패턴을 registy pattern이라고 하는데, go의 표준 라이브러리인 database/sql에 데이터베이스 드라이버를 init으로 심어주는 것이다. 그러나, 이러한 코드는 이제 사장되었다고 보면된다. 코드가 명시적이지 않고 디버깅을 어렵기 하기 때문이다. 때문에 현재는 명시적으로 플러그인을 등록하면 된다.

init함수의 주요 사용처는 단일 할당에서 설정될 수 없는 패키지 레벨 변수를 초기화하는 경우이다. 그러나 사실 패키지 레벨 변수를 관리하는 것 역시도 좋은 방법이 아니기 때문에 init의 사용은 긍정적인 신호가 아니다.

third-party 코드 가져오기

go는 third-party의 패키지를 통합하기 위해서도 같은 표준 라이브러리와 같은 import 방식을 사용한다. 다른 컴파일 언어와 달리 go는 응용 프로그램을 위한 third-party에서 가져온 코드와 본인이 작성한 코드 모두를 컴파일하여 단일 바이너리로 만든다. 프로젝트 내에 있는 패키지를 가져올 때처럼, third-pary 패키지를 가져올 때는 패키지가 있는 소스 코드 저장소의 위치를 지정하기만 하면된다.

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/learning-go-book/formatter"
    "github.com/shopspring/decimal"
)

func main() {
    if len(os.Args) < 3 {
        fmt.Println("Need two parameters: amount and percent")
        os.Exit(1)
    }
    amount, err := decimal.NewFromString(os.Args[1])

    if err != nil {
        log.Fatal(err)
    }

    percent, err := decimal.NewFromString(os.Args[2])
    if err != nil {
        log.Fatal(err)
    }
    percent = percent.Div(decimal.NewFromInt(100))
    total := amount.Add(amount.Mul(percent)).Round(2)
    fmt.Println(formatter.Space(80, os.Args[1], os.Args[2], total.StringFixed(2)))
}

가져온 두 개의 모듈 github.com/learning-go-book/formattergithub.com/shopstring/decimal은 third-party 모듈을 지정한 것이다. 해당 모듈은 저장소의 위치로 포함시킨다는 것을 기억하도록 하자. 이들을 가져오기 위해서는 go get <pakcage storage location>을 사용하면 된다.

go get github.com/learning-go-book/formatter
go get github.com/shopspring/decimal

일단 가져오면, 다른 가져온 패키지들과 같이 해당 패키지의 노출된 항목을 접근 할 수 있다. go.mod에 가보면 다음과 같이 되어있을 것이다.

module github/gyu-young-park/learning-go

go 1.19

require (
	github.com/learning-go-book/formatter v0.0.0-20200921021027-5abc380940ae // indirect
	github.com/shopspring/decimal v1.3.1 // indirect
)

go.mod 파일의 require 섹션에서 자신이 가져온 모듈을 기록한다. 모듈 이름 다음이 버전의 이름이다. formatter의 경우는 버전 태그를 가지고 있지 않아 go가 pseudo version을 만들어 기록한 것이다. go.sum도 설치한 모듈들이 기록되었을 것이다. go.sum 파일은 2개의 항목이 업데이터 되었을 텐데, 하나는 모듈과 모듈의 버전 그리고 모듈의 해시이다. 다른 하나는 모듈을 위한 go.mod파일의 해시이다. 이러한 해시값들이 어디에 쓰이는 지는 추후에 알아보자.

의존성이 필요한 go 명령(go run, go build, go test, go list)를 실행하면 go.mod에 아직 기록되지 않은 임포트 모듈이 캐시에 다운로드된다. go.mod 파일은 모듈의 버전과 패키지를 포함하는 모듈 경로를 포함하면 자동으로 업데이트 된다.

버전 작업

기본적으로 go get으로 모듈을 설치할 때 버전을 따로 명시해주지 않으면 최신버전을 가져온다. 어떤 버전들이 있는 지 궁금하다면 go list 명령어로 해당 모듈의 버전들을 알아낼 수 있다.

go list -m -versions github.com/learning-go-book/simpletax
github.com/learning-go-book/simpletax v1.0.0 v1.1.0

v1.0.0v1.1.0이 있는 것을 확인하였다. go list 명령어는 프로젝트에 사용된 패키지들을 나열한다. -m 플래그는 패키지 대신에 모듈을 나열하도록 변경하고, -version플래그는 go list가 지정된 모듈의 가능한 버전을 보고하도록 변경한다.

만약, 낮은 버전을 선택하고 싶다면 다음과 같이 go get명령어를 수행하면 된다.

go get github.com/learning-go-book/simpletax@v1.0.0

go get 명령어는 모듈과 관련된 작업과 의존성 업데이트를 할 수 있도록 한다.

참고로 go.mod 파일에서 // indirect라는 레이블이 붙은 의존성을 볼 수 있다. 이것은 프로젝트에 직접적으로 선언되지 않은 의존성이다. go.mod파일에 추가되지 않은 몇 가지 이유가 있는데, 한 가지 이유는 프로젝트가 go.mod파일이 없는 이전 모듈에 의존하거나, go.mod 파일에 오류가 있고 일부 의존성이 누락되어있기 때문이다. 모듈을 빌드할 때 모든 의존성은 반드시 go.mod파일에 나열되어야 한다. 의존성 선언이 어단 가에 있어야 하기 때문에 go.mod가 수정되는 것이다.

직접 의존성이 간접 의존성을 알맞게 지정하지만 프로젝트에 설치된 것보다 이전 버전을 지정하는 경우 간접 선언이 있을 수도 있다. go get으로 간접 의존성을 명시적으로 업데이트하거나 의존성 버전을 낮출 때 발생한다.

go 모듈은 시멘틱 버전 관리를 따른다. SemVermajor, minor, patch로 나뉘며 v문자를 필두로 major.minor.patch로 표기한다. 패치 버전은 버그 수정이 있을 때 증가하고, minor는 기존 버전과 호환을 유지하면서 새로운 기능이 추가될 때 증가한다. 이 때 패치는 0부터 다시시작한다. major는 기존 버전과 호환되지 않은 변경이 있을 때 증가하고, minorpatch는 0부터 시작한다.

최소 버전 선택(minimum version selection)

만약 프로젝트에 추가한 third-party 모듈 2개가 동일한 모듈을 의존하지만 다른 버전을 의존한다면 어떤 버전이 설치될까?? go모듈은 최소 버전 선택 원칙(minimum version selection)을 사용한다. 이는 go.mod에 선언된 의존성들을 만족하는 가장 낮은 버전의 공통 모듈을 불러온다는 것이다. go는 모듈의 requirement를 만족하는 가장 최소 버전을 선택하여 종속성 트리를 만들어 버전 충돌을 막고, 불필요한 모듈을 포함하지 않는다. 단, 오해하면 안되는 것이 최소라는 말이 가장 낮은 버전을 선택한다는 것이 아니다. 최소한 모듈들이 안정적으로 돌아갈 모듈을 선택한다는 것이다.

가령, main application이 있고, 모듈 A, B, C를 사용하고 있다고 하자. 이들이 모듈 D를 의존한다고 하고, 모듈 A는 모듈 D의 v1.1.0을 의존하고, 모듈 B는 모듈 D의 v1.2.0에 의존하고, 모듈 C는 모듈 D의 v1.3.0에 의존한다면 minimum version selection은 어떻게 되는가? 이때 minimum version selection에 의해 v1.3.0이 선택된다. 즉, v1.3.0v1.1.0, v1.2.0, v1.3.0을 모두 만족하는 최소한의 버전이라는 것이다. 아까도 말했듯이 최소는 가장 낮은 버전이 아니라, 최소한 동작하는 데 무리가 없는 버전을 말하는 것이다. v1.1.0을 선택하면 모듈 B,C에 문제가 발생할 수 있고, v.1.2.0를 선택하면 모듈 C에 문제가 발생할 수 있기 때문에, 이들 중 가장 높은 버전인 v.1.3.0을 선택한 것이다. 그런데 최소라고 말한 것은, 모듈 D가 v1.7.0과 같이 높은 버전을 가지고 있어도 이를 선택하지 않는다는 것이다. 이는 불필요한 라이브러리를 가져올 수 있기도하고, minorpatch가 너무 달라져 하위호환성이 성립하지 않을 수 있기 때문이다.

golang의 convention은 major가 달라지지 않는 이상 무조건 minorpatch는 하위호환성을 맞춰야한다. 만약 모듈 D를 v1.3.0으로 선택했는데, 모듈 A, B가 모듈 D를 사용하는데 있어 호환하지 못하면 이는 개발자에게 당당하게 찾아가 잘못만들었으니 고치라고 해야한다.

npm과 같은 빌드 시스템에서는 같은 패키지에 여러 버전을 포함한다. 그렇기 때문에 npm모듈의 크기가 굉장히 비대해지고, 심한 경우는 버저닝 떄문에 버그가 발생할 때가 있는 것이다.

https://go.dev/ref/mod#minimal-version-selection

재밌는 것은 golang에서 하위호환성을 맞추지 못하면 major를 올려야하는데, semantic import version관리는 다음의 규칙을 따른다.
1. 모듈의 major 버전은 반드시 증가해야한다.
2. 0과 1을 제외한 major버전의 경우는 경로 마지막에 vN으로 끝나야한다. N은 major버전 값이다.

가령, github.com/learning-go-book/simpletaxv1.1.0 모듈은 github.com/learning-go-book/simpletax으로 import하면 된다.

그런데, 만약 major가 2로 올라가서 v2.0.0이 되었다면 github.com/learning-go-book/simpletax/v2로 모듈을 바꾸어야 주어야 한다.

다음의 github 예제를 살펴보면 더 와닿는다.
https://github.com/learning-go-book/simpletax

기존의 코드를 두고 v2 디렉터리가 따로 있는 것을 확인할 수 있다. 따라서 go getv2를 기준으로 해주어야 한다.

go get github.com/learning-go-book/simpletax/v2

벤더링

모듈이 항상 동일한 의존성으로 빌드되는 것을 보장하기 위해 어떤 조직에서는 해당 모듈 내에 의존성의 복사본을 유지하고 싶어한다. 가령 npm의 경우 node_modules를 매번 삭제하여 새로 생성해주는 것이 아니라 패키징 시에 그대로 유지하도록 하는 것이다. go에서는 이를 vendoring이라고 한다. go mod vendor 명령을 수행하여 활성화 시킬 수 있다. 이것은 모듈의 최상위 디렉터리에 모듈이 가지는 의존성 모두를 포함하는 verndor라는 디렉터리를 생성한다.

go mod vendor

vendor 디렉터리에 우리가 사용했던 모듈들이 설치된 것을 확인할 수 있다. 단, go.mod에 새로운 의존성을 추가하거나 버전을 변경하면 vendor 디렉터리에 새로 업데이트해주기 위해 go mod vendor를 잊지말도록 하자. 만약 버전이 안맞거나, 모듈이 없으면 go build, go run, go test에서 에러가 발생하니 조심해야한다.

의존성을 벤더링할 지는 조직에 달려있다. 예전에는 별다른 수없이 go mod vendor를 사용했지만, go 모듈과 프록시 서버의 등장으로 이러한 방식은 선호되지 않는다. 즉, B2B로 회사에 코드를 제공해야하는 입장에서도 go mod vendor는 이제 사장된 방식이다.

모듈을 위한 프록시 서버

라이브러리를 위한 단일의 중앙 집중 저장소에 의존하는 대신에 go는 하이브리드 모델을 사용한다. 모든 go 모듈은 github와 gitlab과 같은 소스코드 저장소에 저장된다. 하지만 기본적으로 go get은 소스 코드 저장소에서 직접 가져오지는 못한다. 대신에 해당 명령은 구글에서 수행하는 프록시 서버로 요청한다. 해당 서버는 거의 모든 공개 go 모듈의 모든 버전을 복사하여 보관한다. 모듈이나 모듈의 버전이 프록시 서버에 없다면 모듈 저장소에서 다운로드를 받아 복사본을 저장하고 모듈에게 반환한다.

프록시 서버 외에도 구글은 sum database도 유지 관리한다. 모든 모듈의 모든 버전의 정보를 저장하고 있는 것이다. 여기에는 go.sum 파일에 보여지는 해당 모듈의 버전과 모듈에 대한 체크섬을 포함한다.

프록시 서버가 인터넷에서 제거되는 모듈이나 모듈의 버전으로부터 사용자를 보호하는 것과 같이 sum database는 모듈의 버전 수정으로부터 사용자를 보호한다. 이것은 악의적일 누군가가 모듈을 가로채 악의적인 코드를 넣는 경우일 수도 있고, 부주의하여 버그를 수정하거나 새로운 기능을 추가해서 기존 버전 태그를 재사용하는 경우도 있다. 이런 두 경우에 같은 바이너리로 빌드하지 못하거나 응용 프로그램에 어떤 영향이 있는 지 알 수 없기 때문에 변경된 모듈 버전 사용을 원치 않을 것이다.

매번 go build, go test, go get을 이용해 모듈을 다운로드 받을 때마다 go 도구는 모듈을 위한 해시를 계산하고 sum database에 접근하여 모듈 버전을 위한 저장된 해시와 계산된 해시를 비교한다. 만약 두 해시 값이 일치하지 않으면 모듈은 설치되지 않는다.

프록시 서버 지정하기

third-party 라이브러리의 요청을 구글에 보내는 것을 원치 않을 경우가 있다. 이를 위한 몇가지 옵션이 있다.

  1. GOPROXY 환경 변수의 값을 direct로 설정하여 프록시 서버를 완전히 비활성화 할 수 있다. 해당 저장소에서 직접 모듈을 다운받을 수 있지만, 저장소에서 지워진 버전에 의존하고 있다면 더 이상 접근할 수 없다.
  2. 직접 프록시 서버를 수행할 수도 있다. ArifactorySonatype은 해당 제품의 엔터프라이즈 저장소 제품에 go프록시 서버를 포함한다. Athens는 오픈 소스 프록시 서버를 제공한다. 이런 제품 중 하나를 네트워크 내에 설치하여 GOPROXY에 해당 URL로 가리키게 하면 된다.

GOPROXY 환경 변수를 사용하여 Go 모듈을 다운로드할 때 비공개 저장소를 지정하는 방법은 다음과 같다.

  1. 비공개 저장소 구성
    Go 모듈을 호스팅할 비공개 저장소를 구성한다. 가령 gitlab, github.com 엔터프라이즈 등이 있다. 비공개 저장소와 함께 직접 프록시를 구성하여 모듈을 관리하고, 보안상의 이점을 얻을 수 있다. 이를 위해서는 내부적으로 Nexus, Artifactory 또는 Goproxy.io와 같은 유명한 프록시 서버를 사용할 수 있다.

  2. 저장소에 인증 구성
    비공개 저장소를 사용하기 위해서는 해당 저장소에 대한 인증 구성이 필요하다. 저장소에서 제공하는 인증 방법에 따라 달라질 수 있으며, 대개는 HTTP Basic Authentication, OAuth 또는 API 키를 사용합니다.

  3. GOPROXY 설정
    GOPROXY 환경 변수에 비공개 저장소의 URL을 설정한다. 예를 들어, 내부적으로 실행 중인 Nexus 프록시 서버를 사용하려면 다음과 같이 GOPROXY를 설정할 수 있다.

export GOPROXY=http://nexus.example.com/repository/go-proxy/
  1. 인증 정보 추가
    저장소에 대한 인증 정보를 프록시 서버에 제공해야 한다. 이를 위해, GOPRIVATE 환경 변수에 저장소의 호스트 이름을 추가하고, GONOPROXY 환경 변수에 저장소의 패턴을 추가하여 프록시 서버에 인증 정보를 제공합니다.
export GOPRIVATE=example.com
export GONOPROXY=*.example.com

이제 Go 모듈을 다운로드하기위해 go get 명령어를 실행할 때 GOPROXY 환경 변수가 설정되어 있으므로, Go는 해당 저장소에서 모듈을 다운로드하려고 시도한다. 만약 인증 정보가 필요한 경우, GOPRIVATEGONOPROXY 환경 변수를 사용하여 인증 정보를 제공한다.

즉, GOPRIVATE는 인증 작업이 필요한 비공개 저장소를 의미한다. 만약 example.com이 인증이 필요하다면 GORPIVATE=example.com을 쓰면된다. 이 경우 example.com인증을 위한 token을 제시하라고 할 것이다. 만약, GOPRIVATE에 해당 호스트를 써주지 않으면 GOPROXY서버는 아무리 회사의 네트워크 망에서 배포된 프록시 서버라도 비공개 저장소에 접근할 인증이 안되기 때문에 접근이 불가능하다. github인증에 필요한 정보를 적어두어도 GOPRIVATE에 인증이 필요한 호스트 이름을 써주지 않으면 GOPROXY에서 인증 작업이 이뤄지지 않는다.

재미난 것은 GONOPROXYGOPRIVATE에 있는 호스트 이름을 똑같이 적어도 GOPROXY가 적용된다. 이는 GOPRIVATE에 있는 호스트는 비공개 저장소이기 때문에, 보안상의 이유로 인증이 필요하며 GOPROXY를 통해 안전하게 모듈을 설치해야하기 때문이다.

따라서, 위의 예제를 보면 GOPRIVATEexample.com인 반면에 GONOPROXY*.example.com이다. 이는 example.com 비공개 저장소에 대해서는 인증이 필요하지만, 이 아래의 sub.example.com, sub2.example.com이라는 서브 도메인에 대해서는 GONOPROXY 패턴이 적용되어 GOPROXY를 거지치 않고 다운로드 할 수 있다는 것이다.

비공개 저장소에 있는 모듈을 GONOPROXY에 설정은 사실 선택적인 부분이다. 비공개 저장소는 인증을 필요로 하기 때문에 GOPROXY에서 저장소로 인식하지 못한다. 따라서, GOPRIVATE를 설정해주어 비공개 저장소에 대한 인증을 마무리 하면, GONOPROXY로 설정하지 않아도 해당 비공개 저장소의 모듈들을 가져올 수 있다.

그러나, GONOPROXY를 설정해주지 않으면 GOPRIVATE 하위의 도메인일지라도 GOPROXY를 통해서도 모듈을 요청한다. 즉, GOPROXY가 비공개 저장소와 저장된 공개 저장소에서 둘 다 모듈을 찾으려는 시도가 이루어진다는 말이다. 이를 악용하여 해커가 원치 않은 모듈을 설치하게 만들 수 있다. 따라서, 비공개 저장소의 모듈에 대해서는 GONOPROXY로 설정해주는 것이 좋다.

참고로 직접 구성한 프록시 서버에 비공개 저장소 모듈을 모두 넣어서 GOPRIAVTEGONOPROXY없이 모듈 요청을 처리할 수 있다. 그러나 이러한 방법은 보안상의 위배되는 점이 많아 추천되지 않는다.

비공개 저장소

대부분의 조직은 조직의 코드를 비공개 저장소에 보관한다. go프로젝트 내에 비공개 모듈 사용을 원한다면 구글 프록시 서버로 해당 모듈을 요청할 수는 없다. go는 비공개 저장소를 직접 확인하는 것으로 대체되지만, 비공개 서버 및 저장소의 이름을 외부 서비스로 유출하고 싶지 않을 수 있다.

자체 프록시 서버를 사용하거나 프록시를 비활성화했다면 문제가 되지 않는다. 비공개 프록시 서버를 운영하는 것은 몇 가지 추가적인 이점이 있다. 먼저 회사의 네트워크에 캐시되어 있기 때문에 third-party 모듈의 다운로드 속도를 높일 수 있다. 비공개 저장소에 접근하는 것이 인증을 요구한다면 프록시 서버의 사용은 CI/CD 파이프라인에서 인증 정보를 노출시키는 것에 관하여 걱정할 필요가 없다는 의미이다. 비공개 프록시 서버는 비공개 저장소에 인증하기 위해 설정이 되지만 비공개 프록시 서버에 대한 호출은 인증할 필요가 없다.

공개 프록시 서버를 사용한다면 GOPRIVATE 환경 변수에 쉼표로 구분된 비공개 저장소의 목록을 설정할 수 있다. 가령 .example.comcompany.com/repo 아래에 있는 비공개 저장소에 있는 모듈에 접근하고 싶다면 다음과 같이 쓰면 된다.

GOPRIVATE=*.example.com,compancy.com/repo

모든 모듈은 example.com을 하위 도메인으로 하는 위치의 저장소에 저장되어 있거나, company.com/repo로 시작하는 URL로 부터 직접 다운로드 받을 수 있다. 그러나 이는 proxy서버를 거치지 않는다는 말이 아니다. 보통의 비공개 저장소는 인증이 필요하고 프록시 서버에서는 인증이 불가능하다. 따라서 GOPRIVATE로 설정해놓은 비공개 저장소에 있는 모듈에 대해서는 GOPROXY 서버와 함께 설정된 인증 정보를 사용하여 다운로드를 한다.

0개의 댓글