Go에서 패키지 가시성 이해

소개

Go에서 패키지를 생성할 때, 최종 목표는 일반적으로 다른 개발자들이 더 높은 수준의 패키지나 전체 프로그램에서 사용할 수 있도록 패키지를 접근 가능하게 만드는 것입니다. 패키지를 가져옴으로써, 당신의 코드는 다른 더 복잡한 도구들의 구성 요소로 사용될 수 있습니다. 그러나 특정 패키지만이 가져올 수 있는 것으로 제한되어 있습니다. 이는 패키지의 가시성에 의해 결정됩니다.

가시성은 이 맥락에서 패키지나 다른 구조물이 참조될 수 있는 파일 공간을 의미합니다. 예를 들어, 함수 내에서 변수를 정의하면, 그 변수의 가시성(범위)은 정의된 함수 내로 제한됩니다. 마찬가지로, 패키지 내에서 변수를 정의한다면, 그 변수를 해당 패키지 내에서만 볼 수 있게 하거나, 패키지 외부에서도 볼 수 있게 할 수 있습니다.

패키지 가시성을 신중하게 제어하는 것은 특히 패키지에 대해 나중에 변경하고자 하는 사항을 고려할 때 인체공학적인 코드를 작성할 때 중요합니다. 버그를 수정하거나 성능을 향상시키거나 기능을 변경해야 할 경우, 패키지를 사용하는 모든 사람의 코드를 손상시키지 않는 방식으로 변경하고자 할 것입니다. 변경 사항을 최소화하는 한 가지 방법은 패키지를 제대로 사용하는 데 필요한 부분에만 접근을 허용하는 것입니다. 접근을 제한함으로써, 다른 개발자들이 패키지를 사용하는 방식에 영향을 덜 주면서 패키지 내부적으로 변경할 수 있습니다.

이 기사에서는 패키지 가시성을 제어하는 방법과 패키지 내부에서만 사용되어야 하는 코드 부분을 보호하는 방법을 배우게 됩니다. 이를 위해 다양한 정도의 항목 가시성을 가진 패키지를 사용하여 로그 및 디버그 메시지를 기록하는 기본 로거를 만들 것입니다.

사전 준비사항

이 기사의 예제를 따라가려면 다음이 필요합니다:

.
├── bin 
│ 
└── src
    └── github.com
        └── gopherguides

내보내진 항목과 내보내지지 않은 항목

Java나 Python과 같은 다른 프로그래밍 언어와 달리 Go는 접근 제어자를 사용하여 범위를 지정하는 대신, 선언 방식을 통해 항목이 내보내짐 또는 내보내지지 않음 여부를 결정합니다. 이 경우 항목을 내보내면 현재 패키지 외부에서 보이게 됩니다. 내보내지 않으면 해당 항목이 정의된 패키지 내에서만 보이고 사용할 수 있습니다.

이러한 외부 가시성은 선언된 항목의 첫 글자를 대문자로 하는 것으로 제어됩니다. 타입, 변수, 상수, 함수 등과 같은 모든 선언이 대문자로 시작하면 현재 패키지 외부에서 보이게 됩니다.

다음 코드를 살펴보면서 대소문자에 주의해 주세요:

greet.go
package greet

import "fmt"

var Greeting string

func Hello(name string) string {
	return fmt.Sprintf(Greeting, name)
}

이 코드는 greet 패키지에 속해 있음을 선언합니다. 그런 다음 Greeting이라는 변수와 Hello라는 함수, 이렇게 두 개의 심볼을 선언합니다. 둘 다 대문자로 시작하기 때문에 exported되어 외부 프로그램에서 사용할 수 있습니다. 앞서 언급했듯이, 접근을 제한하는 패키지를 만들면 API 설계가 더 우수해지고 패키지를 내부적으로 업데이트하면서 패키지에 의존하는 모든 코드를 손상시키지 않을 수 있습니다.

패키지 가시성 정의

프로그램에서 패키지 가시성이 어떻게 작동하는지 자세히 살펴보기 위해 logging 패키지를 만들어 봅시다. 우리가 패키지 외부에서 보이게 할 것과 보이지 않게 할 것을 염두에 두고 있습니다. 이 로깅 패키지는 프로그램 메시지를 콘솔에 로깅하는 역할을 할 것입니다. 또한 어떤 level로 로깅하고 있는지 확인할 것입니다. 레벨은 로그의 유형을 설명하며 info, warning, error 세 가지 상태 중 하나가 될 것입니다.

먼저 src 디렉토리 내에 logging이라는 디렉토리를 만들어 로깅 파일을 넣어 봅시다:

  1. mkdir logging

다음으로 해당 디렉토리로 이동합니다:

  1. cd logging

그런 다음 nano와 같은 편집기를 사용하여 logging.go라는 파일을 만듭니다:

  1. nano logging.go

방금 만든 logging.go 파일에 다음 코드를 넣습니다:

logging/logging.go
package logging

import (
	"fmt"
	"time"
)

var debug bool

func Debug(b bool) {
	debug = b
}

func Log(statement string) {
	if !debug {
		return
	}

	fmt.Printf("%s %s\n", time.Now().Format(time.RFC3339), statement)
}

이 코드의 첫 번째 줄은 logging이라는 패키지를 선언했습니다. 이 패키지에는 DebugLog라는 두 개의 exported 함수가 있습니다. 이 함수들은 logging 패키지를 가져오는 다른 모든 패키지에서 호출할 수 있습니다. 또한 debug라는 비공개 변수가 있습니다. 이 변수는 logging 패키지 내부에서만 접근할 수 있습니다. 함수 Debug와 변수 debug가 모두 같은 철자를 가지고 있지만, 함수는 대문자로 시작하고 변수는 그렇지 않다는 점에 유의해야 합니다. 이로 인해 둘은 서로 다른 범위를 가진 구분되는 선언문이 됩니다.

파일을 저장하고 종료합니다.

이 패키지를 코드의 다른 부분에서 사용하려면 import하여 새 패키지에 포함시킬 수 있습니다. 이 새 패키지를 만들겠지만, 먼저 해당 소스 파일을 저장할 새 디렉토리가 필요합니다.

logging 디렉토리 밖으로 이동하여 cmd라는 새 디렉토리를 만들고 그 디렉토리로 이동하겠습니다:

  1. cd ..
  2. mkdir cmd
  3. cd cmd

방금 만든 cmd 디렉토리에 main.go라는 파일을 만듭니다:

  1. nano main.go

이제 다음 코드를 추가할 수 있습니다:

cmd/main.go
package main

import "github.com/gopherguides/logging"

func main() {
	logging.Debug(true)

	logging.Log("This is a debug statement...")
}

이제 전체 프로그램을 작성했습니다. 그러나 이 프로그램을 실행하기 전에 코드가 제대로 작동하도록 몇 가지 구성 파일을 만들어야 합니다. Go는 Go 모듈을 사용하여 리소스를 가져오기 위한 패키지 종속성을 구성합니다. Go 모듈은 컴파일러에게 패키지를 어디서 가져올지 알려주는 패키지 디렉토리에 위치한 구성 파일입니다. 모듈에 대해 배우는 것은 이 글의 범위를 벗어나지만, 이 예제를 로컬에서 작동하게 하기 위해 몇 줄의 구성만 작성할 수 있습니다.

cmd 디렉토리의 go.mod 파일을 엽니다:

  1. nano go.mod

그런 다음 파일에 다음 내용을 입력합니다:

go.mod
module github.com/gopherguides/cmd

replace github.com/gopherguides/logging => ../logging

이 파일의 첫 번째 줄은 cmd 패키지가 github.com/gopherguides/cmd 파일 경로를 가지고 있음을 컴파일러에게 알려줍니다. 두 번째 줄은 github.com/gopherguides/logging 패키지가 ../logging 디렉토리의 로컬 디스크에서 찾을 수 있음을 컴파일러에게 알려줍니다.

또한 logging 패키지에 대한 go.mod 파일도 필요합니다. logging 디렉토리로 돌아가서 go.mod 파일을 만들어 봅시다:

  1. cd ../logging
  2. nano go.mod

파일에 다음 내용을 추가합니다:

go.mod
module github.com/gopherguides/logging

이는 우리가 만든 logging 패키지가 실제로 github.com/gopherguides/logging 패키지임을 컴파일러에게 알려줍니다. 이를 통해 앞서 작성한 다음 줄로 main 패키지에서 패키지를 가져올 수 있습니다:

cmd/main.go
package main

import "github.com/gopherguides/logging"

func main() {
	logging.Debug(true)

	logging.Log("This is a debug statement...")
}

이제 다음과 같은 디렉토리 구조와 파일 레이아웃을 가지고 있어야 합니다:

├── cmd
│   ├── go.mod
│   └── main.go
└── logging
    ├── go.mod
    └── logging.go

이제 모든 설정이 완료되었으므로 다음 명령어를 사용하여 cmd 패키지에서 main 프로그램을 실행할 수 있습니다:

  1. cd ../cmd
  2. go run main.go

다음과 유사한 출력을 얻게 됩니다:

Output
2019-08-28T11:36:09-05:00 This is a debug statement...

프로그램은 RFC 3339 형식의 현재 시간을 출력한 후 로거에 전송한 모든 문장을 출력합니다. RFC 3339는 인터넷에서 시간을 표현하기 위해 설계된 시간 형식으로 로그 파일에서 일반적으로 사용됩니다.

로깅 패키지에서 DebugLog 함수가 내보내지므로 main 패키지에서 사용할 수 있습니다. 그러나 logging 패키지의 debug 변수는 내보내지지 않습니다. 내보내지지 않은 선언을 참조하려고 하면 컴파일 시간 오류가 발생합니다.

main.go에 다음과 같은 강조 표시된 줄을 추가하세요:

cmd/main.go
package main

import "github.com/gopherguides/logging"

func main() {
	logging.Debug(true)

	logging.Log("This is a debug statement...")

	fmt.Println(logging.debug)
}

파일을 저장하고 실행하면 다음과 유사한 오류가 발생합니다:

Output
. . . ./main.go:10:14: cannot refer to unexported name logging.debug

이제 패키지에서 exportedunexported 항목의 동작 방식을 살펴보았으므로, 다음으로 structs에서 fieldsmethods를 내보내는 방법을 살펴보겠습니다.

구조체 내 가시성

이전 섹션에서 구축한 로거의 가시성 구성표는 간단한 프로그램에는 적합할 수 있지만, 여러 패키지 내에서 유용하게 사용하기에는 상태를 너무 많이 공유합니다. 이는 내보내진 변수들이 여러 패키지에서 접근 가능하여 변수들을 모순된 상태로 수정할 수 있기 때문입니다. 패키지의 상태가 이런 식으로 변경되도록 허용하면 프로그램이 어떻게 동작할지 예측하기 어렵게 만듭니다. 예를 들어, 현재 설계에서는 한 패키지가 Debug 변수를 true로 설정할 수 있고, 다른 패키지는 같은 인스턴스에서 false로 설정할 수 있습니다. 이는 logging 패키지를 가져오는 두 패키지 모두에 영향을 주는 문제를 야기합니다.

로거를 격리된 상태로 만들기 위해 구조체를 생성한 다음 그 구조체에 메서드를 추가할 수 있습니다. 이를 통해 각 패키지에서 독립적으로 사용할 수 있는 로거의 인스턴스를 만들 수 있습니다.

logging 패키지를 다음과 같이 변경하여 코드를 리팩토링하고 로거를 격리시킵니다:

logging/logging.go
package logging

import (
	"fmt"
	"time"
)

type Logger struct {
	timeFormat string
	debug      bool
}

func New(timeFormat string, debug bool) *Logger {
	return &Logger{
		timeFormat: timeFormat,
		debug:      debug,
	}
}

func (l *Logger) Log(s string) {
	if !l.debug {
		return
	}
	fmt.Printf("%s %s\n", time.Now().Format(l.timeFormat), s)
}

이 코드에서는 Logger 구조체를 생성했습니다. 이 구조체는 출력할 시간 형식과 true 또는 false 설정의 debug 변수를 포함한 내보내지 않는 상태를 보관합니다. New 함수는 로거를 생성하기 위한 초기 상태를 설정하고, 시간 형식과 디버그 상태와 같은 값을 내부적으로 내보내지 않는 변수 timeFormatdebug에 저장합니다. 또한 Logger 타입에 Log라는 메서드를 생성했는데, 이 메서드는 출력하고자 하는 문장을 받습니다. Log 메서드 내부에는 로컬 메서드 변수 l에 대한 참조가 있어 l.timeFormatl.debug와 같은 내부 필드에 접근할 수 있습니다.

이 방법을 사용하면 여러 다른 패키지에서 Logger를 생성하고 다른 패키지의 사용 방식과 독립적으로 사용할 수 있습니다.

다른 패키지에서 사용하려면 cmd/main.go를 다음과 같이 수정하세요:

cmd/main.go
package main

import (
	"time"

	"github.com/gopherguides/logging"
)

func main() {
	logger := logging.New(time.RFC3339, true)

	logger.Log("This is a debug statement...")
}

이 프로그램을 실행하면 다음과 같은 출력이 나타납니다:

Output
2019-08-28T11:56:49-05:00 This is a debug statement...

이 코드에서는 내보내진 함수 New를 호출하여 로거의 인스턴스를 생성했습니다. 이 인스턴스에 대한 참조를 logger 변수에 저장했습니다. 이제 logging.Log를 호출하여 문장을 출력할 수 있습니다.

만약 Logger의 내보내지 않는 필드인 timeFormat 필드를 참조하려고 하면, 컴파일 시간 오류가 발생합니다. 다음과 같은 강조된 줄을 추가하고 cmd/main.go를 실행해 보세요:

cmd/main.go

package main

import (
	"time"

	"github.com/gopherguides/logging"
)

func main() {
	logger := logging.New(time.RFC3339, true)

	logger.Log("This is a debug statement...")

	fmt.Println(logger.timeFormat)
}

이 경우 다음과 같은 오류가 발생합니다:

Output
. . . cmd/main.go:14:20: logger.timeFormat undefined (cannot refer to unexported field or method timeFormat)

컴파일러는 logger.timeFormat이 내보내지지 않았음을 인식하므로 logging 패키지에서 가져올 수 없습니다.

메서드 내 가시성

구조체 필드와 마찬가지로 메서드도 내보내거나 내보내지 않을 수 있습니다.

이를 설명하기 위해 로거에 레벨 기반 로깅을 추가해 보겠습니다. 레벨 기반 로깅은 특정 유형의 이벤트에 대한 로그를 검색할 수 있도록 로그를 분류하는 방법입니다. 로거에 추가할 레벨은 다음과 같습니다:

  • info 레벨은 프로그램 시작 또는 이메일 전송과 같이 사용자에게 작업을 알리는 정보 유형 이벤트를 나타냅니다. 이러한 이벤트는 예상되는 동작이 발생하고 있는지 프로그램의 일부를 디버깅하고 추적하는 데 도움이 됩니다.

  • warning 레벨은 이메일 전송 실패, 재시도와 같이 오류는 아니지만 예상치 못한 일이 발생하는 경우를 식별합니다. 이러한 이벤트는 우리가 예상했던 것처럼 진행되지 않는 프로그램의 부분을 확인하는 데 도움이 됩니다.

  • 에러 수준, 프로그램이 문제를 만나는 것을 의미합니다. 예를 들어 找不到文件 과 같습니다. 이것은 자주 프로그램의 opera tion이 실패하게 됩니다.

로그 某些等级を켜고 끕니다. 특히 프로그램이 기대하는 것과 다르게 동작하고 프로그램을 디버깅하고자 하면 유용합니다. 우리는 이러한 기능을 프로그램을 변경하여 debugtrue로 설정되면 모든 等级의 메시지를 인쇄하고, false면 에러 메시지만 인쇄하도록 하게 만듭니다.

logging/logging.go

package logging

import (
	"fmt"
	"strings"
	"time"
)

type Logger struct {
	timeFormat string
	debug      bool
}

func New(timeFormat string, debug bool) *Logger {
	return &Logger{
		timeFormat: timeFormat,
		debug:      debug,
	}
}

func (l *Logger) Log(level string, s string) {
	level = strings.ToLower(level)
	switch level {
	case "info", "warning":
		if l.debug {
			l.write(level, s)
		}
	default:
		l.write(level, s)
	}
}

func (l *Logger) write(level string, s string) {
	fmt.Printf("[%s] %s %s\n", level, time.Now().Format(l.timeFormat), s)
}

다음과 같은 변경을 logging/logging.go에 적용하여 수준 기반 로깅을 추가합니다.이 예시에서는 Log 方法的 새로운 인자를 introduce했습니다. 이제 level를 로그 메시지의 level에 전달할 수 있습니다. Log 方法이 어떤 수준의 메시지인지 결정합니다. 만약 infowarning 메시지라면 debug 필드가 true면 그 메시지를 쓸 수 있습니다. 그렇지 않으면 그 메시지를 忽略합니다. 다른 수준인 것처럼 error면 그 메시지를 쓸 것입니다.

메시지가 출력되는지 여부를 결정하는 대부분의 로직은 Log 메서드에 존재합니다. 또한 비공개 메서드인 write를 도입했습니다. write 메서드는 실제로 로그 메시지를 출력하는 역할을 합니다.

이제 이 레벨 기반 로깅을 다른 패키지에서 사용하기 위해 cmd/main.go를 다음과 같이 변경할 수 있습니다:

cmd/main.go
package main

import (
	"time"

	"github.com/gopherguides/logging"
)

func main() {
	logger := logging.New(time.RFC3339, true)

	logger.Log("info", "starting up service")
	logger.Log("warning", "no tasks found")
	logger.Log("error", "exiting: no work performed")

}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다:

Output
[info] 2019-09-23T20:53:38Z starting up service [warning] 2019-09-23T20:53:38Z no tasks found [error] 2019-09-23T20:53:38Z exiting: no work performed

이 예제에서 cmd/main.go는 성공적으로 내보낸 Log 메서드를 사용했습니다.

이제 debugfalse로 전환하여 각 메시지의 level을 전달할 수 있습니다:

main.go
package main

import (
	"time"

	"github.com/gopherguides/logging"
)

func main() {
	logger := logging.New(time.RFC3339, false)

	logger.Log("info", "starting up service")
	logger.Log("warning", "no tasks found")
	logger.Log("error", "exiting: no work performed")

}

이제 error 레벨 메시지만 출력되는 것을 볼 수 있습니다:

Output
[error] 2019-08-28T13:58:52-05:00 exiting: no work performed

logging 패키지 외부에서 write 메서드를 호출하려고 하면 컴파일 타임 오류가 발생합니다:

main.go
package main

import (
	"time"

	"github.com/gopherguides/logging"
)

func main() {
	logger := logging.New(time.RFC3339, true)

	logger.Log("info", "starting up service")
	logger.Log("warning", "no tasks found")
	logger.Log("error", "exiting: no work performed")

	logger.write("error", "log this message...")
}
Output
cmd/main.go:16:8: logger.write undefined (cannot refer to unexported field or method logging.(*Logger).write)

컴파일러가 다른 패키지에서 소문자로 시작하는 것을 참조하려는 것을 보면, 그것이 내보내지지 않았다는 것을 알고 있으므로 컴파일러 오류를 발생시킵니다.

이 튜토리얼의 로거는 우리가 다른 패키지에서 소비하길 원하는 부분만을 노출하는 코드를 작성하는 방법을 보여줍니다. 우리가 패키지의 어떤 부분이 외부에 보이는지 제어하기 때문에, 우리 패키지에 의존하는 어떤 코드에도 영향을 주지 않고 미래의 변경을 할 수 있습니다. 예를 들어, debug가 false일 때 info 레벨 메시지만 끄고 싶다면, 이러한 변경을 다른 API의 어떤 부분에도 영향을 주지 않고 할 수 있습니다. 또한 프로그램이 실행되는 디렉토리와 같은 더 많은 정보를 로그 메시지에 포함하도록 안전하게 변경할 수도 있습니다.

결론

이 글은 패키지의 구현 세부 사항을 보호하면서 패키지 간에 코드를 공유하는 방법을 보여주었습니다. 이를 통해 이전 버전과의 호환성을 위해 거의 변경되지 않는 간단한 API를 내보낼 수 있지만, 필요에 따라 패키지 내부에서 변경하여 미래에 더 잘 작동하도록 할 수 있습니다. 이는 패키지와 그에 상응하는 API를 만들 때 최선의 방법으로 간주됩니다.

Go에서 패키지에 대해 더 알아보려면 Go에서 패키지 가져오기Go에서 패키지 작성하는 방법 기사를 확인하거나 전체 Go로 코딩하는 방법 시리즈를 살펴보세요.

Source:
https://www.digitalocean.com/community/tutorials/understanding-package-visibility-in-go