0 단계. Go 언어를 배우기 전에
00장. 개발환경구축
다음 0.2 이후 버전까지 지원되므로 현재 나온 버전의 0.2 를 뺀 버전까지가 안정한 버전이지 않을까 싶다.
현재 19 버전이므로, go1.19 (released 2022-08-02)
1.16 버전을 받아보자.
책도 1.16 버전으로 진행하고 있다.
- 고랭 설치
brew install go
- 버전 확인
go version
- 버전 변경
echo '\n# 고랭 버전\nexport PATH="/opt/homebrew/opt/go@1.16/bin:$PATH"' >> ~/.zshrc
지정 단축키
- Opt G: new Go file
- Opt D: new Directory
- hello/hello.go
package main
import "fmt"
func main() {
fmt.Println("Hello world!")
}
Go 1.16 버전부터 Go 모듈 사용이 기본이 됐습니다. 이전까지 Go 모듈을 만들지 않는 Go 코드는 모두 $GOPATH\src 폴더 아래 있어야 했지만 Go 모듈이 기본이 되면서 모든 Go 코드는 Go 모듈 아래 있어야 합니다. 그래서 모든 예제를 빌드하기 전에 go mod init 명령을 실행해서 Go 모듈을 만들어줘야 합니다. 여기서는 모듈 이름을 goproject\hello 로 하겠습니다.
Go 모듈에 대해서는 16 장에서 살펴보겠습니다.
go mod init goproject\hello
Go 1.16 버전 미만 버전을 사용할 때는 이 작업이 필요 없습니다.
- hello\go.mod 가 생성되었다.
module goprojecthello
go 1.16
자 이제 실행해보자.
go run hello.go
go mod init Tucker-Go-lang-Programming/hello
go build
./hello
go build 를 실행하면 hello 파일이 생성된다.
똑같이 Hello world! 가 출력된다.
- 예제파일 다운로드
git clone https://github.com/tuckersgo/musthavego
01장. 컴퓨터 원리
10진수를 2진수로 변환하는 방법
맥에서 계산기를 실행 후 Cmd 3 을 누르면 프로그래머 계산기로 바뀐다.
DEC 는 10진수 값, BIN 이 2 진수 값이다.
오른쪽 위의 10 을 누르고 숫자를 입력하면 다른 모든 진수에 해당 값이 자동으로 표시된다. (윈도우도 마찬가지)
02장. 프로그래밍 언어
2.4 프로그래밍 언어의 구분
프로그램이 동작하려면 기계어로 변환되는 컴파일 과정이 필요한데 이 컴파일 과정을 언제 할 것인가를 언어를 나누는 기준으로 삼기도 합니다.
미리 컴파일을 해두면 정적 컴파일 언어, 사용할 때 컴파일하면 동적 컴파일 언어입니다.
정적 컴파일 언어
기계어로 변환해둔 파일을 실행 파일이라고 합니다.
윈도우에서는 .exe 파일이 미리 기계어로 변환된 실행파일입니다. 그래서 실행파일이란 곧 기계어 코드라고 볼 수 있습니다.
실행할 때 변환과정이 필요없어서 빠르고, 타입 에러를 컴파일 시점에서 발견할 수 있어 타입 안전성이 뛰어납니다.
동적 컴파일 언어
실행 시점 (runtime) 에 기계어로 변환하는 방식의 언어를 동적 컴파일 언어라고 합니다.
동적 컴파일 언어들은 정적 컴파일 언어보다 나중에 개발됐습니다.
이미 더 빠른 정적 컴파일 언어 방식이 있는데 왜 더 느린 방식을 만들게 된 걸까요 ?
바로 정적 컴파일 언어가 가진 단점을 극복하기 위해서입니다.
실행환경은 CPU 아키텍처와 운영체제에 따라서 달라집니다.
예를 들어 CPU 가 64 비트인지 32 비트인지 ARM 계열인지 인텔 계열인지에 따라 달라집니다. 운영체제에 따라서도 달라집니다.
하지만 동적 컴파일 언어는 이런 번거로움 없이 하나의 코드로 모든 플랫폼에서 실행됩니다. 그 이유는 프로그램 실행 시점에 환경에 맞는 기계어로 변환되기 때문입니다. 속도를 희생한 대신 범용성을 얻은 겁니다.
Go 언어는 정적 컴파일 언어이기 때문에 각 플랫폼에 맞는 실행 파일을 따로 만들어주어야 합니다.
하지만 Go 내부 환경변수만 바꿔서 다양한 플랫폼에 맞도록 실행파일을 만들 수 있어 비교적 쉽게 대응할 수 있습니다. (3.3절 코드가 실행되기까지 참조)
또 정적 컴파일 언어답게 매우 빠른 실행속도를 자랑합니다.
2.4.2 약 타입 언어 vs 강 타입 언어
타입검사를 강하게 하는 언어를 강 타입 언어 또는 정적 타입 언어라고 부르고 타입검사를 약하게 하는 언어를 약 타입 언어 또는 동적 타입 언어라고 말합니다.
약 타입 언어는 규칙이 관대해 더 편하게 코딩할 수 있는 장점이 있는 반면 예기치 못한 버그를 발생시킬 수 있습니다.
강 타입 언어는 사용하기 까다롭지만 타입 검사를 언어 자체에서 지원해주기 때문에 타입으로 생길 수 있는 문제를 미연에 방지할 수 있습니다.
Go 언어는 다른 강 타입 언어에서 지원하는 자동 타입 변환까지도 지원하지 않는 최강 타입 언어입니다. 그래서 사용하기 좀 까다롭지만 타입이 달라서 발생할 수 있는 문제점이 전혀 발생하지 않습니다. 이에 대해서는 4장 '변수' 에서 자세히 다룹니다.
가비지 컬렉터가 없는 C, C++ 는 메모리 할당과 해제를 책임져야 합니다.
메모리 청소에 CPU 성능을 사용한다는 문제가 있습니다. 그래서 가비지 컬렉터가 없는 프로그래밍 언어가 대체로 더 빠른 성능을 자랑합니다.
1 단계. 가볍게 Go 입문하기
03장. Hello Go World
Go 언어는 2020년 미국에서는 펄 (Perl), 스칼라 다음으로 많은 연봉을 받는 인기 언어입니다.
백엔드 서버와 시스템 개발에 적합하고 강력한 동시성 프로그래밍을 지원합니다.
3.1 Go 역사
Go 언어는 2009 년 발표된 오픈 소스 프로그래밍 언어입니다.
홈페이지 주소: golang.org
온라인 Go 언어 컴파일러: play.golang.org
깃허브에 올라간 소스코드 중 가장 많이 사용되는 언어 4위에 랭크될 만큼 많이 사용됩니다.
- 2020년 3월 기준
- JavaScript
- Python
- Java
- Go
- TypeScript
- C++
- Ruby
- PHP
- C#
- C
특징
- 클래스 없다. 메서드를 가지는 구조체를 지원한다. - 13장
- 상속 없다.
- 메서드 있다. - 19장
- 인터페이스 있다. - 20장
- 익명 함수 있다. 함수 리터럴이라는 이름으로 제공한다. - 21장
- 메모리 주소를 가리키는 포인터가 있다. - 14장
- 제네릭 추가됨
- 네임스페이스 없다. 모든 코드는 패키지단위로 분리된다.
3.3 코드가 실행되기까지
다음 5단계를 거친다.
- 폴더 생성
- .go 파일 생성 및 작성
- Go 모듈 생성
- 빌드
- 실행
1. 폴더생성
같은 폴더에 위치한 .go 파일은 모두 같은 패키지에 포함된다.
2. .go 파일 생성 및 작성
코딩은 Go 문법을 사용해서 Go 코드를 만드는 과정입니다.
3. Go 모듈 생성
go mod init {모듈이름}
Go 모듈을 생성하면 go.mod 파일이 생성됩니다.
go.mod 파일에는 모듈명과 Go 버전, 필요한 패키지 목록 정보가 담겨 있습니다.
4. 빌드
go build 명령은 Go 코드를 기계어로 변환하여 실행파일을 만듭니다.
GOOS 와 GOARCH 환경변수를 조정해서 다른 운영체제와 아키텍처에서 실행되는 실행파일을 만들 수 있습니다.
터미널에서 go tool dist list 명령을 실행하면 가능한 운영체제와 아키텍처 목록을 볼 수 있습니다.
예를 들어 AMD 64 계열 칩셋을 사용하는 리눅스 실행파일을 만들 때는 다음과 같이 옵션을 주면 됩니다.
GOOS=linux GOARCH=amd64 go build
현재 시스템에서 실행되는 실행 파일을 만들 때는 그냥 go build 만 하면 됩니다.
5. 실행
이렇게 만들어진 실행파일을 명령어로 실행하면 됩니다.
3.4 Hello Go World 코드 뜯어보기
1. package main
package main 은 main 패키지에 속한 코드임을 컴파일러에게 알려줍니다.
main 패키지는 프로그램 시작점을 포함하는 특별한 패키지입니다.
main() 함수가 없는 패키지는 패키지 이름으로 main 을 쓸 수 없습니다.
main() 함수가 없기 때문에 실행파일을 만들 수는 없고, 다른 패키지에서 외부 패키지로 사용됩니다.
2. import "fmt"
fmt 패키지를 가져옵니다.
fmt 패키지는 표준 입출력을 다루는 내장 패키지입니다.
표준 입출력으로 텍스트를 출력하거나 입력받을 때 사용합니다. (5장. fmt 패키지를 이용한 텍스트 입출력 참조)
3. func main() {
main() 함수를 선언하고 중괄호 { 로 본문의 시작을 알립니다.
main() 함수는 프로그램 진입점 함수입니다.
즉 Go 언어로 만든 모든 프로그램은 main() 함수부터 시작되고 main() 함수가 종료되면 프로그램이 종료됩니다.
즉, 프로그램의 시작과 끝이 main() 함수입니다.
4. Hello Go World 출력
Go 언어에서는 외부로 공개되어 다른 프로그램에서 쓰이는 함수 앞에 함수명으로 시작하는 주석을 달아 함수를 설명하도록 코딩 규약으로 권장하고 있습니다.
참고로 외부에 공개되는 함수나 객체에 주석을 달고 godoc 프로그램을 실행하면 해당 주석들을 이용해서 HTML 문서를 자동으로 생성해줍니다. (A.6.1 절 godoc 으로 문서 만들기 참조)
5. fmt.Println("Hello Go World")
표준출력이란 터미널 화면을 말합니다.
6. }
코드블록을 종료합니다.
04장. 변수
var a int = 10 // 키워드 변수명 타입 초깃값
4.4절에서 다른 형태의 변수선언도 살펴보자.
4.3 변수에 대해 더 알아보기
4.3.2 변수는 이름을 가지고 있다.
Go 언어에서 변수명을 지을 때는 다음과 같은 규칙을 따라야 합니다.
- 첫글자는 문자나 _ 로 시작해야 한다.
- 올바르지 않은 예: 123, 1abc, %abcd
- _ 를 제외한 다른 특수문자 (space 포함) 를 포함할 수 없다.
다음과 같은 권장사항이 있습니다.
- 영문자를 제외한 다른 언어의 문자를 사용하지 않습니다.
- 카멜 케이스 (CamelCase) 로 작성합니다.
- 변수명은 되도록 짧게 합니다. 잠시 사용되는 로컬 변수는 한 글자를 권장합니다.
- 밑줄은 일반적으로 사용하지 않습니다.
4.3.3 변수는 타입을 가지고 있다.
Go 언어는 숫자, 불리언, 문자열, 배열, 슬라이스, 구조체, 포인터, 함수, 인터페이스, 맵, 채널 등의 타입을 제공합니다.
크기를 신경 쓰지 않는 경우 보통 int, float64 를 사용합니다.
이름 | 설명 | 값의 범위 |
---|---|---|
unit8 | 1바이트 부호 없는 정수 | 0 ~ 255 |
unit16 | 2바이트 | |
unit32 | 4바이트 | 0 ~ 42억 |
unit64 | 8바이트 | |
int8 | 1바이트 부호 있는 정수 | -128 ~ 127 |
int16 | ||
int32 | 4바이트 | -21억 ~ 21억 |
int64 | ||
float32 | 4바이트 실수 | IEEE-754 32비트 실수 |
이름 | 설명 | 값의 범위 |
---|---|---|
float64 | ||
complex64 | 8바이트 복소수 (진수, 가수) | 진수와 가수 범위는 float32 범위와 같음 |
complex128 | 16바이트 | |
byte | unit8 의 별칭 | 0 ~ 255 |
rune | int32 의 별칭 | -21억 ~ 21억 |
int | 32비트 컴퓨터에서는 int32 64비트 컴퓨터에서는 int64 | |
unit | 32비트 컴퓨터에서는 unit32 64비트 컴퓨터에서는 unit64 |
메모리를 절약해 사용해야 하는 경우에는 값의 범위에 딱 맞는 작은 크기의 타입을 사용해야 합니다.
그 외 타입
- boolean
- 문자열 (string)
- 배열 (array)
- 슬라이스: Go 언어에서 제공하는 가변 길이 배열.
- 구조체: 필드 (변수) 의 집합 자료구조
- 포인터
- 함수타입: 다른 말로 함수 포인터라고 말합니다. 사용할 함수를 동적으로 바꿀 때 유용합니다.
- 인터페이스
- 맵: 전화번호부나 사전을 생각하면 된다.
- 채널: 멀티스레드 환경에 특화된 큐 형태 자죠구조입니다. (멀티스레드는 24장 고루틴과 동시성 프로그래밍, 채널은 25장 채널과 컨텍스트 참조)
4.4 변수 선언의 다른 형태
func main() {
fmt.Println("Hello world!")
var a int = 3
var b int
var c = 4
d := 5
println(a, b, c, d)
}
타입별 기본값
- 정수: 0
- 실수: 0.0
- 불리언: false
- 문자열: "" (빈문자열)
- 그 외: nil
숫자값 기본 타입
정수는 int, 실수는 float64 가 기본 타입입니다.
선언 대입문 :=
선언과 대입을 한꺼번에 하는 구문입니다.
var 키워드와 타입을 생략해 변수를 선언할 수 있습니다.
4.5 타입 변환
Go 언어는 강 타입 언어 중에서도 가장 강하게 타입 검사를 하는 최강 타입 언어입니다.
a := 3
var b float64 = 3.5
var c int = b // float 변수를 int 에 대입 불가
d := a * b // 다른 타입인 int 변수와 float64 연산 불가
var e int64 = 7
f := a * e // a 는 int 타입, e 는 int64 타입으로 같은 정수값이지만
// 타입이 달라서 연산 불가
유의사항
- 실수 타입에서 정수 타입으로 타입 변환하면 소수점 이하 숫자가 없어진다.
4.6 변수의 범위
var g int = 10
func main() {
var m int = 20
{
var s int = 50
println(m, s, g)
}
//m = s + 20 -> Error
}
변수는 중괄호를 벗어나면 사라집니다.
4.7 숫자 표현
첫 번째 비트를 부호 비트로 정해서 1이면 음수를, 0이면 양수를 나타냅니다.
15는 0000 0000 0000 1111 로 나타냅니다.
음수는 보수로 표현합니다.
2의 보수를 만드는 방법은 모든 비트의 0 과 1을 서로 바꾼 후 1을 더하면 됩니다.
-15 는 1111 1111 1111 0001 입니다.
15 와 -15 를 더하게 된다면
0000 0000 0000 0000 가 됩니다.
4.7.2 실수의 표현
소수점이 있는 실수는 1 과 2 사이에도 무수히 많은 숫자가 있기 때문에 정수 타입처럼 바로 2진수로 변환해서 사용할 수가 없습니다.
Go 언어는 IEEE-754 표준을 따라 실수를 표현합니다.
1024.234 는 0.1024234 x 10 4승 으로 나타낼 수 있고, 0.1024234e+04 라고 쓰기도 합니다.
컴퓨터에서는 실수는 이렇게 소수부와 지수부를 나눠서 표현합니다.
4바이트 실수에서는 1비트가 부호비트 8비트가 지수부 23비트가 소수부입니다. (1 + 8 + 23 = 32)
표현할 수 있는 숫자에 한계가 있습니다.
float32 는 소수부 7자리
float64 는 소수부 15자리
05장. fmt 패키지를 이용한 텍스트 입출력
5.1 표준 입출력
표준 입출력스트림은 os 패키지의 Stdin, Stdin, Stdout, Stderr 을 제공합니다.
입출력 스트림 처리는 io.Reader, io.Writer 인터페이스로 처리되는데 fmt 패키지를 이용하면 간단하게 표준 입출력을 사용할 수 있습니다.
5.1.1 fmt 패키지
fmt 패키지는 Go 언어에서 자주 사용하는 기능을 묶어서 패키지로 제공합니다.
fmt 패키지는 3가지 표준 출력용 함수를 제공합니다.
- Print()
- Println()
- Printf()
var a int = 10
var b int = 20
var f float64 = 32799438743.8297
fmt.Println("a:", a, "b:", b, "f:", f)
fmt.Printf("a: %d b: %d f: %f\n", a, b, f)
5.1.2 서식 문자
구분 | 설명 |
---|---|
%d | |
%b | |
%c | |
%o | |
%O | 8진수임을 표시하는 Oo 를 붙여 출력 |
%x | |
%X | |
%s |
구분 | 설명 |
---|---|
%v | 데이터 타입에 맞춰 기본 형태로 출력 |
%T | 데이터 타입 출력 |
%t | 불리언을 true, false 로 출력 |
%e %E | 지수 형태로 실수값 출력 |
%f %F | 지수 형태가 아닌 실숫값 그대로 출력 |
%g %G | 값이 큰 실숫값은 지수형태로 출력, 작은 실숫값은 그대로 출력 |
%q | 특수문자 기능을 동작하지 않고 문자열 그대로 출력 |
%p | 메모리 주솟값을 출력 |
5.1.3 최소 출력 너비 지정
- %5d:최소 5칸 사용해 정숫값 출력 (공백)
- %05d: 빈자리를 0 으로 채운다.
- %-5d: 왼쪽 정렬
최소너비보다 긴 값을 넣으면 모두 지정한 최소 너비가 무시되어 출력됩니다.
5.1.4 실수 소수점 이하 자릿수
- %5.2f: 소수점 이하 값 2개 출력
- 기본 숫자 길이 (6개) 로 정수부 숫자를 모두 표현하지 못하면 (99만을 넘어가면) 지수표현으로 전환합니다.
- %5.3g: 최소 너비 5칸에 소수점 이하 포함해서 총 숫자 3개로 표현
5.1.5 특수 문자
\n | 줄바꿈 |
---|---|
\t | 탭 |
\\ | \ 자체를 출력 |
\" | " 를 출력. 큰따옴표로 묶인 문자열 내부에 따옴표를 넣을 때 사용 |
5.2 표준 입력
func main() {
var a int
var b int
n, err := fmt.Scan(&a, &b)
if err != nil {
fmt.Println(n, err)
} else {
fmt.Println(n, a, b)
}
}
Scanf 는 서식에 맞춘 입력을 받습니다.
fmt.Scanf("%d %d\n", &a, &b)
5.3 키보드 입력과 Scan() 함수의 동작 원리
먼저 입력된 데이터가 먼저 읽히는 구조를 FIFO 라고 말합니다.
표준 입력 스트림은 바로 FIFO 구조를 가지고 있습니다.
func main() {
stdin := bufio.NewReader(os.Stdin)
var a int
var b int
n, err := fmt.Scanln(&a, &b)
if err != nil {
fmt.Println(err)
stdin.ReadString('\n')
} else {
fmt.Println(n, a, b)
}
n, err = fmt.Scanln(&a, &b)
if err != nil {
fmt.Println(err)
} else {
fmt.Println(n, a, b)
}
}
표준 입력 스트림에서 한 줄을 읽어오는 데 bufio, os 등의 패키지를 사용했습니다. bufio 는 입력 스트림으로부터 한 줄을 읽는 Reader 객체를 제공합니다.
줄바꿈 문자가 나올 때 까지 문자를 읽습니다.
이러면 표준 입력 스트림을 비울 수 있습니다.
06장. 연산자
6.1 산술 연산자
구분 | 연산자 | 연산 | 피연산자 타입 |
---|---|---|---|
비트연산 | & | AND 비트 연산 | 정수 |
| | OR 비트 연산 | 정수 | |
^ | XOR 비트 연산 | 정수 | |
&^ | 비트 클리어 | 정수 |
6.1.1 연산의 결과 타입
Go 언어에서 모든 연산자의 각 항의 타입은 항상 같아야 합니다.
6.1.2 비트 연산자
10 & 34 = 2
10 | 34 = 42
10 ^ 34 = 40
^ 는 ^A 처럼 단독으로 사용할 수 있습니다.
^ 를 단독으로 사용하면 비트 반전을 합니다.
&^ (비트 클리어 연산자)
특정 비트를 0 으로 바꾸는 연산자입니다.
^ 를 수행하고 나서 & 를 수행합니다.
10 &^ 2
1. ^ 연산을 먼저 수행한다.
0000 0010 -> 1111 1101
2. & 연산을 수행한다.
0000 1010 (10) & 1111 1101 (^2)
0000 1000 (8)
결과적으로 2번째 비트만 0 으로 바뀌었습니다.
6.2 비교 연산자
비교 연산자를 사용할 때 몇가지 주의할 점이 있습니다.
부호가 있는 정수를 사용할 때 발생하는 오버플로와 언더플로 문제, 실수끼리의 비교입니다.
func main() {
var x int8 = 127
fmt.Printf("%d < %d + 1: %t\n", x, x, x < x+1)
}
6.2.3 float 비교 연산
float 에서 == 연산을 할 때 주의하자.
func main() {
var a float64 = 0.1
var b float64 = 0.2
var c float64 = 0.3
fmt.Printf("%f + %f == %f : %v\n", a, b, c, a+b == c)
}
0.100000 + 0.200000 == 0.300000 : false
float64 표현 방식으로 생긴 오차 때문이다.
0.3 의 정확한 실수값을 2진수 체계로는 표현할 수 없습니다.
그래서 컴퓨터에서는 0.1 + 0.2 != 0.3 이 됩니다.
이 문제를 해결하기 위해 다음 챕터를 봅시다.
6.3.2 오차를 없애는 더 나은 방법
가장 마지막 비트가 1 비트 만큼 차이가 난다면 두 값이 같은 것입니다.
고맙게도 Go 언어에서는 math 패키지에서 Nextafter() 함수를 제공합니다.
실수 비교는 이 함수를 이용하면 됩니다.
func equal(a, b float64) bool {
return math.Nextafter(a, b) == b
}
func main() {
var a float64 = 0.1
var b float64 = 0.2
var c float64 = 0.3
fmt.Printf("%0.18f + %0.18f = %0.18f\n", a, b, a+b)
fmt.Printf("%0.18f == %0.18f : %v\n", c, a+b, equal(a+b, c))
a = 0.0000000000004
b = 0.0000000000002
c = 0.0000000000007
fmt.Printf("%g == %g : %v\n", c, a+b, equal(a+b, c))
}
만약 제작하는 프로그램이 금융 프로그램이라면 math/big 패키지에서 제공하는 Float 객체를 사용해야 합니다.
math/big 의 Flaot 를 이용하면 정밀도를 직접 조정할 수 있기 때문에 정밀도를 높여서 더 정확한 수치를 계산할 수 있습니다.
func main() {
a, _ := new(big.Float).SetString("0.1")
b, _ := new(big.Float).SetString("0.2")
c, _ := new(big.Float).SetString("0.3")
d := new(big.Float).Add(a, b)
fmt.Println(a, b, c, d)
fmt.Println(c.Cmp(d))
}
반환값이 -1 은 x 가 작은 경우, 1 은 x 가 큰 경우, 0 은 두 값이 같을 경우입니다.
6.5 대입 연산자
func main() {
var a int
var b int
//a = b = 10 - Error
}
대입 연산자는 결과를 반환하지 않습니다.
6.5.1 복수 대입 연산자
여러 값을 한 번에 대입할 수 있습니다.
func main() {
var a int = 10
var b int = 20
a, b = b, a
fmt.Println(a, b)
}
Go 언어에서는 전위 증감 연산자를 지원하지 않습니다.
즉, ++a 는 사용하지 못하고 a++ 만 가능합니다.
6.5.4 그 외 연산자
연산자 | 설명 | 참조 |
---|---|---|
[ ] | 배열의 요소에 접근 | |
. | 구조체나 패키지 요소 접근 | |
& | 변수의 메모리 주솟값 반환 | |
* | 포인터 변수가 가리키는 메모리 주소 접근 | |
... | 슬라이스 요소들에 접근하거나 가변인수 만들 때 사용 | |
: | 배열의 일부분을 집어올 때 사용 | |
<- | 채널에서 값을 빼거나 넣을 때 사용 |
6.6 연산자 우선순위
우선순위 | 연산자 |
---|---|
5 | * / % << >> & &^ (곱하기 나누기 나머지 비트) |
4 | + - | ^ (더하기 빼기 비트) |
3 | == != < <= > >= (비교) |
2 | && (AND) |
1 | || (OR) |
연산자 우선순위가 있다고 해도 소괄호로 묶어서 보기 편하게 만들어주는 게 좋습니다.
07장. 함수
func Add(a, b int) int {
// a 와 b 를 더한 결과를 반환합니다.
return a + b
}
// 키워드 함수명 (매개변수) 반환타입 {
// 함수코드블록
// }
첫 글자가 대문자인 함수는 패키지 외부로 공개되는 함수입니다.
소괄호 안에 매개변수를 넣습니다.
반환하는 값이 없으면 비워둡니다.
Go 언어에는 함수 코드 블록의 시작을 알리는 중괄호 { 가 함수를 정의하는 라인과 항상 같은 줄에 있어야 합니다.
7.2 함수를 호출하면 생기는 일
함수를 호출할 때 입력하는 값을 argument (인자), 함수가 외부로부터 입력받는 변수를 parameter (매개변수) 라고 합니다.
7.3.1 멀티 반환 함수
함수는 값을 여러 개 반환할 수 있습니다. 반환값이 여럿일 때는 반환 타입들을 소괄호로 묶어서 표현합니다.
func Divide(a, b int) (int, bool) {
if b == 0 {
return 0, false
}
return a / b, true
}
func main() {
c, success := Divide(9, 3)
fmt.Println(c, success)
d, success := Divide(9, 0)
fmt.Println(d, success)
}
7.3.2 변수명을 지정해 반환하기
함수 선언부에 반환 타입을 적을 때 변수명까지 지정해주면, return 문으로 해당 변수를 명시적으로 반환하지 않아도 값을 반환할 수 있습니다.
앞의 Divide() 함수 예제를 수정해보겠습니다.
func Divide(a, b int) (result int, success bool) {
if b == 0 {
result = 0
success = false
return
}
result = a / b
success = true
return
}
반환할 변수에 이름을 지정할 경우 모든 반환 변수에 이름을 지정해야 합니다.
모두 지정하거나, 모두 지정하지 않거나요 !
7.4 재귀 호출
recursive call 이라고 합니다.
func printNo(n int) {
if n == 0 {
return
}
println(n)
printNo(n - 1)
println("After", n)
}
func main() {
printNo(3)
}
08장. 상수
8.1 상수 선언
기본타입 (primitive) 이 아닌 타입 (complex) 에는 상수를 사용할 수 없습니다.
상수로 사용될 수 있는 타입은 다음과 같습니다.
- 불리언
- 룬
- 정수
- 실수
- 복소수
- 문자열
상수명 규칙은 변수명과 같습니다.
자바와 다르게 카멜 케이스로 작성합니다.
첫 글자가 대문자인 상수는 패키지 외부로 공개되는 상수입니다.
&상수 는 에러를 발생시킵니다.
상수의 메모리 주솟값에 접근할 수 없습니다.
상수는 값, 이름, 타입 3가지 속성만 가집니다.
8.2 상수는 언제 사용하나 ?
8.2.1 변하면 안되는 값에 상수 사용하기
상수를 변경하는 시도를 할 때 컴파일 단계에서 에러가 출력됩니다.
8.2.2 코드값으로 사용하기
const Pig int = 0
const Cow int = 1
const Chicken int = 2
func PrintAnimal(animal int) {
if animal == Pig {
println("꿀꿀")
} else if animal == Cow {
println("음메")
} else if animal == Chicken {
println("꼬끼오")
} else {
println("...")
}
}
func main() {
PrintAnimal(Pig)
PrintAnimal(Cow)
PrintAnimal(Chicken)
}
8.2.3 iota 로 간편하게 열거값 사용하기
iota 는 그리스 알파벳의 9 번째 글자로, 아주 작은 양을 뜻한다고 합니다.
iota 는 0 부터 시작해 1 씩 증가합니다.
소괄호를 벗어나면 초기화됩니다.
첫 번째 값과 똑같은 규칙이 계속 적용된다면 타입과 iota 를 생략할 수 있습니다.
const (
Red int = iota
Blue
Green
)
func main() {
println(Red, Blue, Green)
}
8.3 타입 없는 상수
상수 선언 시 타입을 명시하지 않을 수 있습니다.
const PI = 3.14
const FloatPI float64 = 3.14
func main() {
var a int = PI * 100
//var b int = FloatPI * 100 -> Error
println(a)
//println(b)
}
8.4 상수와 리터럴
컴퓨터에서 리터럴이란 고정된 값, 값 자체로 쓰인 문구라고 볼 수 있습니다.
Go 언어에서 상수는 리터럴과 같이 취급합니다.
그래서 컴파일될 때 상수는 리터럴로 변환되어 실행파일에 쓰입니다.
그래서 상수는 동적 할당 메모리 영역을 사용하지 않습니다.
09장. if 문
if 문은 계속 중첩할 수 있지만 중첩이 심할 경우 코드를 이해하기 힘들기 때문에 되도록 3중첩 이상은 하지 않도록 권장합니다.
자바와 다르게 if 문의 조건에 소괄호가 필요 없습니다.
9.4 if 초기문; 조건문
if 문 조건을 검사하기 전에 초기문을 넣을 수 있습니다.
검사에 사용할 변수를 초기화할 때 사용합니다.
if 초기문; 조건문 {
문장
}
func UploadFile() (string, bool) {
return "file.txt", true
}
func main() {
if filename, success := UploadFile(); success {
println("Upload succeess", filename)
} else {
println("Failed to upload")
}
}
초기값은 if 문 안으로 한정된다는 사실에 주의해야 한다.
10장. switch 문
10.1 switch 문 동작 원리
switch 비교값 {
case 값1:
문장
case 값2:
문장
default:
문장
}
default 는 생략 가능합니다.
func main() {
day := 3
switch day {
case 1:
println("첫째 날입니다.")
println("오늘은 팀미팅이 있습니다.")
case 2:
println("둘째 날입니다.")
println("오늘은 면접이 있습니다.")
case 3:
println("셋째 날입니다.")
println("설계안을 확정하는 날입니다.")
case 4:
println("넷째 날입니다.")
println("예산을 확정하는 날입니다.")
case 5:
println("다섯째 날입니다.")
println("최종 계약하는 날입니다.")
default:
println("프로젝트를 진행하세요.")
}
}
자바와 다르게 모든 case 문에 break 가 필요없다.
10.3 다양한 switch 문 형태
10.3.1 한 번에 여러 값 비교
func main() {
day := "thursday"
switch day {
case "monday", "tuesday":
println("월, 화요일은 수업 가는 날입니다.")
case "wednesday", "thursday", "friday":
println("수, 목, 금요일은 실습 가는 날입니다.")
}
}
10.3.2 조건문 비교
switch 다음에 비교값을 적지 않으면 defalut 값으로 true 를 사용합니다.
func main() {
//day := "thursday"
switch {
case 3 > 5:
println("3 은 5 보다 큽니다.")
case 3*5 == 15:
println("3 곱하기 5 는 15 입니다.")
}
}
10.3.3 switch 초기문
if 문과 마찬가지로 switch 문에서도 초기문을 넣을 수 있습니다.
switch 초기문; 비교값 {
case 값1:
...
case 값2:
...
default:
...
}
func getMyAge() int {
return 22
}
func main() {
switch age := getMyAge(); age {
case 10:
println("Teenage")
case 33:
println("Pair 3")
default:
println("My age is", age) // age 값 사용
}
//println("age is ", age) -> Error
}
func main() {
switch age := getMyAge(); {
case age < 10:
println("Child")
case age < 20:
println("Teenager")
case age < 30:
println("20s")
default:
println("My age is", age) // age 값 사용
}
}
10.4 const 열거값과 switch
type ColorType int
const (
Red ColorType = iota
Blue
Green
Yellow
)
func colorToString(color ColorType) string {
switch color {
case Red:
return "Red"
case Blue:
return "Blue"
case Green:
return "Green"
case Yellow:
return "Yellow"
default:
return "Undefined"
}
}
func getMyFavoriteColor() ColorType {
return Blue
}
func main() {
println("My favorite color is", colorToString(getMyFavoriteColor()))
}
이렇듯 열거값은 switch 문과 잘 어울립니다.
위 예에서 Violet 같은 새로운 색깔을 추가하면 colorToString() 함수도 수정해줘야 합니다.
이런 경우를 커플링 됐다고 하거나 결합되어 있다고 말합니다.
열거값이 수정될 때 연관된 모든 switch case 문도 수정해줘야 합니다.
그래서 열거값에 연관된 switch case 가 많아질수록 작은 수정에도 많은 코드가 변경되어야 하는 산탄총 수술 문제가 발생합니다.
그래서 하나의 열거값에 연관된 switch case 는 최대한 줄이는 게 좋습니다.
10.5 break 와 fallthrough 키워드
일반적으로 다른 언어에서는 switch 문의 각 case 종료 시에 break 문을 사용해야 다음 case 로 코드가 실행되지 않습니다.
Go 는 break 없이 case 를 하나 실행한 후 자동으로 switch 문을 빠져나가게 됩니다.
그런데 만약 하나의 case 문 실행 후 다음 case 문까지 같이 실행하고 싶을 땐 어떻게 할까요 ?
그럴 때 fallthrought 키워드를 사용합니다.
그러면 case 를 하나 더 탑니다.
fallthrought 키워드는 코드를 보는 사람에게 혼동을 일으킬 수 있으니 되도록 사용하지 않기를 권장합니다.
11장. for 문
11.1 for 문 동작 원리
Go 언어는 반복문으로 for 문 하나만 지원하지만, 여러 형태가 있기 때문에 각 형태를 적재적소에 잘 사용해야 합니다.
기본형태는 다음과 같습니다.
for 초기문; 조건문; 후처리 {
코드 블록
}
만약 조건문이 false 이면 후처리 없이 for 문을 종료합니다.
초기문만 혹은 후처리만 생략할 수 있습니다.
for 조건문 {
코드 블록
}
초기문과 후처리 모두 생략하면 세미콜론을 안 써도 됩니다.
11.1.4 무한 루프
for {
코드 블록
}
11.4 중첩 for 문과 break, 레이블
모든 for 문을 빠져나가고 싶을 때는 첫 번째 방법으로 불리언 변수를 사용하는 겁니다.
이런 형태로 불리언 변수를 사용하는 것을 플래그 (flag) 변수라고 합니다.
또는, 레이블을 이용한 방법이 있습니다.
- 플래그
func main() {
a := 1
b := 1
found := false
for ; a <= 9; a++ {
for b = 1; b <= 9; b++ {
if a*b == 45 {
found = true
break
}
}
if found {
break
}
}
fmt.Printf("%d * %d = %d\n", a, b, a*b)
}
- 레이블
func main() {
a := 1
b := 1
OuterFor:
for ; a <= 9; a++ {
for b = 1; b <= 9; b++ {
if a*b == 45 {
break OuterFor
}
}
}
fmt.Printf("%d * %d = %d\n", a, b, a*b)
}
레이블은 혼동을 불러일으킬 수 있고 자칫 잘못 사용하면 예기치 못한 버그가 발생할 수 있습니다.
그래서 되도록 플래그를 사용하고 레이블은 꼭 필요한 경우에만 사용하기를 권장합니다.
클린코드를 지향하려면 중첩된 내부 로직을 함수로 묶어 중첩을 줄이고, 플래그 변수나 레이블 사용을 최소화해야 합니다.
12장. 배열
12.1 배열
var t [5]float64
// var 변수명 [요소개수]타입
func main() {
var t = [5]float64{24.0, 25.9, 27.8, 26.9, 26.2}
for i := 0; i < 5; i++ {
fmt.Printf("%f\n", t[i])
}
}
for i := 0; i < 5; i++ {
fmt.Printf("%f\n", t[i])
fmt.Println(t[i])
println(t[i])
}
fmt.Println() 은 실수를 최소 소숫점 자릿수로 표시한다.
마지막 요소 인덱스는 배열 길이 - 1 이다.
12.2 배열 사용법
- 문자열 배열 만들기
func main() {
days := [3]string{"monday", "tuesday", "wednesday"}
for i, day := range days {
println(i, day)
}
}
- 특정 인덱스에만 값 넣기
func main() {
s := [5]int{1: 10, 3: 30}
for i, n := range s {
println(i, n)
}
}
- 배열 요소 갯수 생략
func main() {
x := [...]int{10, 20, 30}
for i, n := range x {
println(i, n)
}
}
다음과 같이 ... 를 생략하면 slice 가 된다.
x := []int{10, 20, 30}
12.2.2 배열 선언 시 개수는 항상 상수
배열 길이를 변수 값으로 선언할 수 없다.
12.2.3 range 순회
for _, n := range x {
println(n)
}
12.3 배열은 연속된 메모리
컴퓨터는 배열의 시작 주소에 '인덱스 x 타입 크기' 를 더해서 찾아갑니다.
요소 위치 = 배열 시작 주소 + (인덱스 x 타입 크기)
12.3.1 배열 복사
func main() {
a := [...]int{10, 20, 30, 40, 50}
b := [...]int{500, 400, 300, 200, 100}
b = a
for i, v := range b {
fmt.Printf("b[%d] = %d\n", i, v)
}
}
배열의 타입이 같아야 복사가 가능합니다.
12.4 다중 배열
이중 배열을 초기화해보자.
func main() {
b := [2][5]int{
{1, 2, 3, 4, 5},
{6, 7, 8, 9, 10},
}
for _, arr := range b {
for _, v := range arr {
print(v, " ")
}
}
}
초기화 시 닫는 중괄호가 마지막 요소와 같은 줄에 있지 않을 경우 마지막 항목 뒤에 쉼표를 찍어주어야 합니다.
추후 항목이 늘어날 경우 쉼표를 찍지 않아서 생길 수 있는 오류를 방지하기 위해 존재하는 규칙입니다.
자바스크립트에도 마지막 항목에 쉼표를 찍어도 되는 문법입니다.
배열을 이중, 삼중으로 하는 건 데이터를 프로그래머가 다루기 편하기 위함이지 컴퓨터 입장에서는 메모리 크기만 중요하고 이중 배열이냐 삼중 배열이냐는 중요하지 않습니다.
13장. 구조체
13.1 선언 및 기본 사용
- 기본 형식
type 타입명 struct {
필드명 타입
...
필드명 타입
}
타입명의 첫 번째 글자가 대문자이면 패키지 외부로 공개되는 타입입니다.
type Student struct {
Name string
Class int
No int
Score float64
}
type House struct {
Address string
Size int
Price float64
Type string
}
func main() {
var house House
house.Address = "서울시 강동구 ..."
house.Size = 28
house.Price = 9.8
house.Type = "아파트"
println("주소: ", house.Address)
fmt.Printf("크기: %d평\n", house.Size)
fmt.Printf("가격: %.2f억 원\n", house.Price)
println("타입: ", house.Type)
}
13.2 구조 변수 초기화
초기값을 생략하면 모든 필드가 기본값으로 초기화됩니다.
var house House
13.2.2 모든 필드 초기화
house := House{"서울시 강동구", 28, 9.8, "아파트"}
여러 줄로 초기화할 때는 마찬가지로 쉼표를 마지막에 붙여준다.
13.2.3 일부 필드 초기화
house := House {
Size: 28,
Type: "아파트",
}
13.3.2 포함된 필드 방식
vip 에서 Name 이나 ID 와 같이 UserInfo 안에 속한 필드에 접근하려면 vip.UserInfo.Name 과 같이 두 단계를 걸쳐 접근해야 합니다.
필드명을 생략하면 . 을 한번만 찍어 접근할 수 있습니다.
type User struct {
Name string
ID string
Age int
}
type VIPUser struct {
User
VIPLevel int
Price int
}
func main() {
user := User{"송하나", "hana", 23}
vip := VIPUser{
User{"화랑", "hwarang", 40},
3,
250,
}
fmt.Printf("유저: %s ID: %s 나이: %d\n", user.Name, user.ID, user.Age)
fmt.Printf("VIP 유저: %s ID: %s 나이: %d VIP 레벨: %d VIP 가격: %d 만 원\n",
vip.Name,
vip.ID,
vip.Age,
vip.VIPLevel,
vip.Price,
)
구조체 안에 포함된 다른 구조체의 필드명을 생략하는 경우를 '포함된 필드' 라고 부릅니다.
포함된 필드를 이용하면 점 . 을 두 번 찍을 필요 없이 한 번만으로 바로 접근할 수 있어 편리합니다.
필드 중복 해결
필드명이 중복된다면 구조체명을 쓰고 다시 점을 찍어줘야 합니다.
13.4.1 구조체 값 복사
type Student struct {
Age int
No int
Score float64
}
func PrintStudent(s Student) {
fmt.Printf("나이:%d 번호:%d 점수:%.2f\n", s.Age, s.No, s.Score)
}
func main() {
student := Student{15, 23, 88.2}
student2 := student
PrintStudent(student2)
}
13.4.2 필드 배치 순서에 따른 구조체 크기 변화
type User struct {
Age int32
Score float64
}
func main() {
user := User{23, 77.2}
memorySize := unsafe.Sizeof(user)
println(memorySize)
}
메모리 사이즈는 int32 (4바이트) 와 float64 (8바이트) 로 총 12바이트여야 합니다.
하지만 16바이트가 나옵니다.
이건 메모리 정렬 (Memory Alignment) 때문입니다.
13.4.3. 메모리 정렬
레지스터 크기가 4바이트인 컴퓨터를 32비트 컴퓨터라 부르고 레지스터 크기가 8바이트인 컴퓨터를 64비트 컴퓨터라고 부릅니다.
64비트 컴퓨터는 8의 배수인 주소에 메모리를 저장하지 않으면 성능상 손해를 봅니다.
메모리 정렬을 위해 필드 사이에 공간을 띄우는 것을 메모리 패딩 (Memory Padding) 이라고 합니다.
13.4.4 메모리 패딩을 고려한 필드 배치 방법
8바이트보다 작은 필드는 8바이트 크기 (단위) 를 고려해서 몰아서 배치하자.
type User struct {
A int8
B int
C int8
D int
E int8
}
func main() {
user := User{1, 2, 3, 4, 5}
memorySize := unsafe.Sizeof(user)
println("memorySize = ", memorySize)
}
type User struct {
A int8
C int8
E int8
B int
D int
}
메모리 용량이 충분한 데스크톱 애플리케이션이라면 패딩으로 인한 메모리 낭비를 크게 걱정하지 않아도 됩니다.
하지만 메모리 공간이 작은 임베디드 하드웨어에서 돌아가는 프로그램이라면 패딩을 고려하는 것이 좋습니다.
13.5 프로그래밍에서 구조체의 역할
프로그래밍 역사는 객체 간 결합도 (의존관계) 는 낮추고 연관있는 데이터 간 응집도를 올리는 방향으로 흘러왔습니다. (SOLID)
14장. 포인터
포인터는 메모리 주소를 값으로 갖는 타입입니다.
예를 들어 int 타입 변수 a 가 있을 때 a 는 메모리에 저장되어 있고 속성으로 메모리 주소를 가지고 있습니다.
변수 a 의 주소가 0x0100 번지라고 했을 때 메모리 주솟값 또한 숫자값이기 때문에 다른 변수의 값으로 사용될 수 있습니다.
이렇게 메모리 주솟값을 변숫값으로 가질 수 있는 변수를 포인터 변수라고 합니다.
p.= &a
위 구문은 포인터 변수 p 에 a 의 주소를 대입하는 구문입니다.
이것을 포인터 변수 p 가 변수 a 를 가리킨다. 고 말합니다.
이렇게 메모리 주소를 값으로 가져 메모리 공간을 가리키는 타입을 포인터라고 합니다.
포인터를 이용하면 여러 포인터 변수가 하나의 메모리 공간을 가리킬 수도 있고 포인터가 가리키고 있는 메모리 공간의 값을 읽을 수도 변경할 수도 있습니다.
14.1.1 포인터 변수 선언
int 타입 변수를 가리키는 포인터 변수를 선언해보자.
var p *int
var a int
var p *int
p = &a
func main() {
a := 30
var p *int
p = &a
println(a)
*p = 20
println(a)
}
14.1.2 포인터 변수값 비교하기
== 연산을 통해 포인터가 같은 메모리 공간을 가리키는지 확인할 수 있습니다.
func main() {
a := 10
b := 20
p1 := &a
p2 := &a
p3 := &b
fmt.Printf("p1 == p2 : %t\n", p1 == p2)
fmt.Printf("p2 == p3 : %t\n", p2 == p3)
}
14.1.3 포인터의 기본값 nil
포인터 변수값을 초기화하지 않으면 기본값은 nil 입니다.
p := &a
if p != nil {
println("포인터 변수가 nil 이 아닙니다.")
}
14.2 포인터는 왜 쓰나 ?
변수 대입이나 함수 인수 전달은 항상 값을 복사하기 때문에 많은 메모리 공간을 사용하는 문제와 큰 메모리 공간을 복사할 때 발생하는 성능 문제를 안고 있습니다.
또한 다른 공간으로 복사되기 때문에 변경사항이 적용되지도 않습니다.
자바와 다르게 객체를 넘기면 값을 복사합니다.
포인터를 사용해야 합니다.
type Data struct {
value int
data [200]int
}
func ChangeData(arg *Data) {
arg.value = 999
arg.data[100] = 999
}
func main() {
var data Data
ChangeData(&data)
fmt.Printf("value = %d\n", data.value)
fmt.Printf("data[100] = %d\n", data.data[100])
}
14.2.1 Data 구조체를 생성해 포인터 변수 초기화하기
구조체 변수를 별도로 생성하지 않고, 곧바로 포인터 변수에 구조체를 생성해 주소를 초깃값으로 대입하는 방법을 알아보겠습니다.
- 기존 방식
var data Data
var p *Data = &data
- 구조체를 생성해 초기화하는 방식
var p *Data = *Data{}
이렇게 하면 (메모리에 실제로 있는 구조체 데이터의 실체를 가리키게 되므로) 포인터 변수 p 만 가지고도 구조체의 필드값에 접근하고 변경할 수 있습니다.
14.3 인스턴스
인스턴스란 메모리에 할당된 데이터의 실체를 말합니다. 예를 들어 다음 코드는 Data 타입값을 저장할 수 있는 메모리 공간을 할당합니다.
var data Data
이렇게 할당된 메모리 공간의 실체를 인스턴스라고 부릅니다.
포인터 변수가 아무리 많아도 인스턴스가 추가로 생성되는 것은 아닙니다.
14.3.1 인스턴스는 데이터의 실체다
인스턴스는 메모리에 존재하는 데이터의 실체입니다.
구조체 포인터를 함수 매개변수로 받는다는 말은 구조체 인스턴스로 입력을 받겠다는 얘기와 같습니다.
14.3.2 new() 내장 함수
new 내장함수를 이용하면 더 간단히 표현할 수 있습니다.
p1 := &Data{}
p2 := new(Data)
new() 내장함수는 인수로 타입을 받습니다. 타입을 메모리에 할당하고 기본값으로 채워 그 주소를 반환합니다.
new 를 이용해서 내부 필드값을 원하는 값으로 초기화할 수는 없습니다.
반면 1 방식은 사용자 초기화가 가능합니다.
두 방식 모두 다 자주 사용하는 방식이기 때문에 잘 알아두셔야 합니다.
14.4 스택 메모리와 힙 메모리
스택 메모리 영역이 힙 메모리 영역보다 훨씬 효율적입니다.
스택 메모리는 함수 내부에서만 사용 가능한 영역입니다.
자바에서는 클래스 타입을 힙에, 기본 타입을 스택에 할당합니다.
Go 언어는 탈출 검사 (escape anlysis) 를 해서 어느 메모리에 할당할지를 결정합니다.
함수 외부로 공개되는 인스턴스의 경우 함수가 종료되어도 사라지지 않습니다.
이미 사라진 메모리를 가리키는 것을 댕글링 (dangling) 오류라고 합니다.
또 Go 언어에서 스택 메모리는 계속 증가하는 동적 메모리 풀입니다.
C/C++ 언어와 비교해 메모리 효율성이 높고, 재귀 호출 때문에 스택 메모리가 고갈되는 문제도 발생하지 않습니다.
15장. 문자열
15.1 문자열
문자열은 큰따옴표나 백 쿼트 (back quote) 로 묶어서 표시합니다. (그레이브 grave 라고도 부릅니다)
백쿼트로 문자열을 묶으면 문자열 안의 특수문자가 일반 문자처럼 처리됩니다.
func main() {
str1 := "Hello\t'World'\n"
str2 := `Go is "awesome"!\nGo is simple and\t'powerful'`
println(str1)
println(str2)
}
또 백쿼트로 묶을 경우 여러 줄에 걸쳐서 문자열을 쓸 수 있지만 큰따옴표로는 한 줄만 묶을 수 있습니다.
poet2 := `죽는 날까지 하늘을 우러러
한 점 부끄럼이 없기를,
잎새에 이는 바람에도
나는 괴로워했다.`
15.1.1 UTF-8 문자코드
Go 는 UTF-8 문자코드를 표준 문자코드로 사용합니다.
UTF-8 은 다국어 문자를 지원하고 문자열 크기를 절약할 목적으로 Go 언어 창시자인 롭 파이크와 켄 톰슨이 고안한 문자코드입니다.
UTF-16 이 한 문자에 2바이트를 고정 사용하는 것과 달리 UTF-8 은 자주 사용되는 영문자, 숫자, 일부 특수문자를 1바이트로 표현하고 그 외 다른 문자들은 2~3바이트로 표현합니다.
ANSI 코드와 1:1 대응이 되어 ANSI 로 바로 변환된다는 장점이 있습니다.
Go 는 UTF-8 을 표준 문자코드로 사용하기 때문에 별다른 변환없이 한글이나 한자 등을 사용할 수 있습니다.
15.1.2 rune 타입으로 한 문자 담기
UTF-8 은 한 글자가 1~3바이트 크기이기 때문에 UTF-8.문자값을 가지려면 3바이트가 필요합니다.
하지만 Go 언어 기본타입에서 3바이트 정수타입은 제공되지 않기 때문에 rune 타입은 4바이트 정수타입인 int32 타입의 별칭타입입니다.
rune 타입과 int32 는 이름만 다를 뿐 같은 타입입니다.
func main() {
var char rune = '한'
fmt.Printf("%T\n", char)
fmt.Println(char)
fmt.Printf("%c\n", char)
}
15.1.3 len() 으로 문자열 크기 알아내기
문자 수가 아니라 말 그대로 문자열이 차지하는 메모리 크기입니다.
15.1.4 []rune 타입 변환으로 글자 수 알아내기
string 타입과 []rune 타입은 상호 타입변환이 가능합니다.
- 문자배열 -> 문자열
func main() {
str := "Hello World"
runes := []rune{72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100}
println(str)
println(string(runes))
}
len(string) -> 메모리 반환
len(runes) -> 문자열 길이 반환
15.1.5 stirng 타입을 []byte 로 타입변환할 수 있다.
string 타입과 []byte 타입은 상호 타입 변환이 가능합니다. []byte 는 byte 즉 1 바이트 부호 없는 정수 타입의 가변 길이 배열입니다.
문자열이란 것도 결국 메모리에 있는 데이터이고, 메모리는 1 바이트 단위로 저장되기 때문에 모든 문자열은 1 바이트 배열로 변환 가능합니다.
파일을 쓰거나 네트워크로 데이터를 전송하는 경우 io.Writer 인터페이스를 사용하고 io.Writer 인터페이스는 []byte 타입을 인수로 받기 때문에 []byte 타입으로 변환해야 합니다. 그래서 문자열을 쉽게 전송하고자 string 에서 []byte 타입으로 변환을 지원합니다.
15.2 문자열 순회
- 인덱스를 사용한 바이트 단위 순회
- []rune 으로 타입 변환 후 한글자 씩 순회
- range 키워드를 이용한 한 글자 씩 순회
15.2.1 인덱스
func main() {
str := "Hello 월드!"
for i := 0; i < len(str); i++ {
fmt.Printf(" 타입:%T 값:%d 문자값:%c\n", str[i], str[i], str[i])
}
}
len(str) 은 글자 개수가 아닌 바이트 크기를 반환합니다 !
인덱스로 접근하면 요소의 타입은 unit8 즉 byte 입니다. 그래서 1 바이트 크기인 영문자는 잘 표시되는데 3 바이트 크기인 한글은 깨져 표시된 겁니다.
15.2.2 []rune 으로 타입 변환 후 한 글자씩 순회하기
func main() {
str := "Hello 월드!"
arr := []rune(str)
for i := 0; i < len(arr); i++ {
fmt.Printf(" 타입:%T 값:%d 문자값:%c\n", arr[i], arr[i], arr[i])
}
}
[]rune 으로 변환되는 과정에서 별도의 배열을 할당하므로 불필요한 메모리를 사용하게 됩니다.
range 키워드를 사용해 순회하면 이를 방지할 수 있습니다.
15.2.3 range 키워드를 이용해 한 글자씩 순회하기
func main() {
str := "Hello 월드!"
for _, v := range str {
fmt.Printf(" 타입:%T 값:%d 문자값:%c\n", v, v, v)
}
}
15.3.1 문자열 비교하기
==, != 를 사용해서 문자열이 같은지 같지 않은지 비교합니다.
func main() {
str1 := "Hello"
str2 := "Hell"
str3 := "Hello"
fmt.Printf("%s == %s : %t\n", str1, str2, str1 == str2)
fmt.Printf("%s != %s : %t\n", str1, str2, str1 != str2)
fmt.Printf("%s == %s : %t\n", str1, str3, str1 == str3)
fmt.Printf("%s != %s : %t\n", str1, str3, str1 != str3)
}
15.3.2 문자열 대소 비교하기
func main() {
str1 := "BBB"
str2 := "aaaaAAA"
str3 := "BBAD"
str4 := "ZZZ"
str5 := "BBA"
str6 := "BBBA"
str7 := "AAAB"
fmt.Printf("%s > %s : %t\n", str1, str2, str1 > str2)
fmt.Printf("%s < %s : %t\n", str1, str3, str1 < str3)
fmt.Printf("%s <= %s : %t\n", str1, str4, str1 <= str4)
fmt.Printf("%s < %s : %t\n", str1, str5, str1 < str5)
fmt.Printf("%s < %s : %t\n", str1, str6, str1 < str6)
fmt.Printf("%s < %s : %t\n", str1, str7, str1 < str7)
}
앞부터 같다면 글자수가 긴 것이 크다.
소문자가 대문자보다 크다.
문자열 대소 비교는 문자열 앞 글자부터 대소 비교를 합니다.
15.4.1 string 구조 알아보기
reflect 패키지 안의 StringHeader 구조체를 통해서 내부 구현을 엿볼 수 있습니다.
uintptr: 문자열의 데이터가 있는 포인터
Len: 문자열의 길이
15.4.2 string 끼리 대입하기
string 변수가 가리키는 문자열이 아무리 길어도 string 변수끼리 대입 연산에서는 16 바이트 값만 복사될 뿐 문자열 데이터는 복사되지 않습니다.
메모리나 성능 문제가 전혀 생기지 않습니다.
15.5 문자열은 불변이다.
문자열을 바꾸면 문자열이 있는 메모리 주소로 Data 포인터값을 변경하고 Len 값도 문자열 길이에 맞게 변경합니다.
하지만 문자열 일부만 바꿀 수는 없습니다.
일부만 바꾸려면 문자열을 슬라이스에 담아야 합니다.
15.5.1 문자열 합산
Go 언어에서 string 타입 간 합 연산을 지원합니다. 합 연산을 하면 두 문자열이 하나로 합쳐지게 됩니다.
이때 합산이 어떻게 일어나는지 살펴보겠습니다.
문자열을 합치면 기존 문자열 메모리 공간을 건드리지 않고, 새로운 메모리 공간을 만들어서 두 문자열을 합치기 때문에 string 합 연산 이후 주솟값이 변경됩니다. 따라서 문자열 불변 원칙이 준수됩니다.
string 합 연산을 빈번하게 하면 메모리가 낭비됩니다. 그래서 string 합 연산을 빈번하게 사용하는 경우에는 strings 패키지의 Builder 를 이용해서 메모리 낭비를 줄일 수 있습니다.
func ToUpper1(str string) string {
var rst string
for _, c := range str {
if c >= 'a' && c <= 'z' {
rst += string('A' + (c - 'a'))
} else {
rst += string(c)
}
}
return rst
}
func ToUpper2(str string) string {
var builder strings.Builder
for _, c := range str {
if c >= 'a' && c <= 'z' {
builder.WriteRune('A' + (c - 'a'))
} else {
builder.WriteRune(c)
}
}
return builder.String()
}
func main() {
str := "Hello World"
fmt.Println(ToUpper1(str))
fmt.Println(ToUpper2(str))
}
ToUpper2 는 strings.Builder 객체를 이용해서 문자를 더합니다. strings.Builder 는 내부에 슬라이스를 가지고 있기 때문에 WriteRune() 메서드를 통해 문자를 더할 때 매번 메모리를 새로 생성하지 않고 기존 메모리 공간에 빈자리가 있으면 그냥 더하게 됩니다.
그래서 메모리 공간 낭비를 없앨 수 있습니다.
15.5.2 왜 문자열은 불변 원칙을 지키려 할까 ?
가장 큰 이유는 예기치 못한 버그를 방지하기 위해서입니다.
16장. 패키지
16.1.1 main 패키지
프로그램 시작점을 포함한 패키지입니다.
시작점이란 main() 함수를 의미합니다.
16.1.2 그 외 패키지
표준 입출력은 fmt 패키지를, 암호화 기능은 crypto 패키지를, 네트워크 기능은 net 패키지를 임포트해 사용하면 됩니다.
이미 세상에는 수많은 패키지가 제공되므로 프로그램을 만들 때는 원하는 기능을 제공하는 패키지를 먼저 찾아보는 습관을 들이는 것이 좋습니다.
16.1.3 유용한 패키지 찾기
레고를 조립하듯 필요한 기능을 담은 패키지를 조합해서 빠르게 원하는 서비스를 구현하는 것이 최선의 길입니다.
새로 만들기 전에 먼저 표준 패키지에서 같은 기능을 제공하는지 찾아봐야 합니다.
Go 패키지: https://pkg.go.dev/std
위 링크를 통해서 표준 패키지 목록을 확인할 수 있습니다.
Go 언어에서 많이 사용되는 패키지를 Awesome Go 에서 찾아보세요.
Awesome Go: https://github.com/avelino/awesome-go
16.2 패키지 사용하기
패키지명은 가져오는 패키지 경로의 가장 마지막 폴더명입니다.
소괄호로 패키지들을 묶어 여러 패키지들을 임포트시킬 수 있습니다.
import (
"fmt"
"strings"
)
콤마로 구분할 필요가 없습니다.
16.2.3 경로가 있는 패키지 사용하기
import (
"math/rand"
"strings"
)
func main() {
println(rand.Int())
}
경로가 있는 패키지에 접근할 때는 마지막 폴더명인 rand 만 사용합니다.
16.2.4 겹치는 패키지 문제 별칭으로 풀기
별칭은 패키지 명 앞에 쓰면 됩니다.
import (
htemplate "html/template"
"text/template"
)
func main() {
template.New("foo").Parse(`{{define "T"}}Hello`)
htemplate.New("foo").Parse(`{{define "T"}}Hello`)
}
16.2.5 사용하지 않는 패키지 포함하기
패키지를 임포트하고 나서 사용하지 않으면 에러가 발생합니다.
패키지를 직접 사용하지 않지만 부가효과를 얻고자 임포트하는 경우에는 밑줄 _ 을 패키지명 앞에 붙여주면 됩니다.
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
16.2.6 패키지 설치하기
import 로 패키지를 포함시키면 go build 를 통해서 빌드할 때 해당하는 패키지를 찾아서 포함한 다음 실행 파일을 생성합니다.
그럼 Go 는 import 된 패키지를 어떻게 찾을까요 ?
- 기본 패키지들은 Go 설치 경로에 포함되어 있습니다.
- 깃허브와 같이 외부 저장소에 저장된 패키지의 경우 외부 저장소에서 다운받아서 GOPATH/pkg 폴더에 설치합니다.
- 이때 Go 모듈에 정의된 패키지 버전에 맞게 다운로드 하게 됩니다.
- 현재 모듈 아래 위치한 패키지인지 검사합니다.
16.3 Go 모듈
Go 모듈은 Go 패키지들을 모아놓은 Go 프로젝트 단위입니다. Go 1.16 부터 Go 모듈 사용이 기본이 됐습니다.
이전까지 Go 모듈을 만들지 않는 Go 코드는 GOPATH/src 폴더 아래에 있어야 했지만 Go 모듈이 기본이 되면서 모든Go 코드는 Go 모듈 아래에 있어야 합니다.
go build 를 하려면 Go 모듈 루트 폴더에 go.mod 파일이 있어야 합니다. go.mod 파일은 모듈 이름과 Go 버전, 필요한 외부 패키지 등이 명시되어 있습니다. Go 언어에서는 go build 를 통해 실행파일을 만들 때 go.mod 와 외부 저장소 패키지 버전 정보를 담고 있는 go.sum 파일을 통해 외부 패키지와 모듈 내 패키지를 합쳐서 실행파일을 만들게 됩니다.
Go 모듈은 go mod init 명령을 통해 만들 수 있습니다.
go mod init [패키지명]
- 패키지 설치
go mod tidy
명령어를 입력하려고 터미널로 가면 Go land 가 패키지들을 지워버린다...
Option Enter 로 설치했다.
import (
"github.com/guptarohit/asciigraph"
"github.com/tuckersGo/musthaveGo/ch16/expkg"
"goproject/usepkg/custompkg"
)
func main() {
custompkg.PrintCustom()
expkg.PrintSample()
data := []float64{3, 4, 5, 6, 9, 7, 5, 8, 5, 10, 2, 7, 2, 5, 6}
graph := asciigraph.Plot(data)
println(graph)
}
필요한 패키지 정보를 go.mod 파일과 go.sum 파일에 적어주게 됩니다.
go.mod 파일을 수정, go.sum 파일을 생성합니다.
모듈명은 다른 외부 패키지 이름과 겹치지 않도록 주의해야 합니다.
모듈명의 마지막 이름은 되도록 폴더명과 맞춰주세요.
이 모듈이 빌드된 Go 버전이 명시되어 있습니다.
require 에는 필요한 외부 패키지 정보가 적혀있습니다.
맥이라 exe 파일도 아니고 터미널로 실행할 수도 없었다.
폴더에서 실행했다.
16.4 패키지명과 패키지 외부 공개
- 공개되는 함수 내부의 상수는 대문자로 시작하더라도 함수 내부에서 선언됐기 때문에 패키지 외부로 공개되지 않습니다.
- 공개되는 구조체 내부의 대문자로 시작하는 필드는 패키지 외부로 공개됩니다.
- 공개되는 구조체에 포함된 대문자로 시작하는 메서드는 패키지 외부로 공개됩니다.
- 대문자로 시작하더라도 포함된 구조체가 소문자로 시작하면 패키지 외부로 공개되지 않는다.
함수의 필드는 무조건 공개되지 않는다.
구조체의 공개된 필드는 공개된다.
16.5 패키지 초기화
패키지를 임포트하면
- 컴파일러는 패키지 내 전역변수를 초기화합니다.
- 패키지에 init() 함수가 있다면 호출해 패기지를 초기화합니다.
- init 함수는 반드시 입력 매개변수가 없고 반환값도 없는 함수여야 합니다.
- 패키지의 init 함수의 기능만 이용하고 싶다면 밑줄을 이용해서 임포트합니다.
- exinit.go
package exinit
import "fmt"
var (
a = c + b
b = f()
c = f()
d = 3
)
func init() {
d++
fmt.Println("init function", d)
}
func f() int {
d++
fmt.Println("f() d:", d)
return d
}
func PrintD() {
fmt.Println("d:", d)
}
- ex16.3.go
package main
import (
"ch16/ex16.3/exinit"
"fmt"
)
func main() {
fmt.Println("main function")
exinit.PrintD()
}
- 컴파일 및 실행방법 (Mac OS)
go run ex16.3.go // 컴파일 없이 실행
go mod init {폴더경로} // 빌드를 위해서는 모듈을 생성해야 한다. 폴더경로는 .go 파일이 있는 곳이다.
go build // 실행파일 생성
./ex16.3 // 실행파일 실행
go run 을 할 때는 .go 확장자를 꼭 붙여야 한다.
일반적으로 전역변수는 위에서 아래로 선언되지만 a, 는 b, c 가 필요하므로 b 부터 선언된다.
전역변수가 선언된 후 패키지의 init 함수가 실행된다.
그 후 main 함수가 실행된다.
a 는 9 가 된다.
- 패키지의 전역변수
- 패키지의 init 함수
- main 함수
의 순서로 실행된다.
17장. 숫자 맞추기 게임 만들기
17.1 해법
17.2.1 math/rand 패키지
랜덤한 숫자를 얻으려면 rand.Intn(range) 함수를 사용한다.
같은 숫자를 출력하지 않으려면 현재 시각을 인수로 주어야 한다.
- math.rand 패키지
- time 패키지
rand.Intn(100) 을 호출하면 0 ~ 99 사이 값이 생성됩니다.
이때 생성되는 값은 유사 랜덤값입니다.
유사 랜덤값이란 어떤 알고리즘에 의해서 마치 랜덤처럼 보이는 값들을 만들어준다는 뜻입니다.
초깃값을 랜덤 시드라고 말합니다.
rand.Seed() 함수를 이용해 설정할 수 있습니다.
17.2.2 time 패키지
랜덤시드로 가장 많이 사용되는 방법은 현재 시각 값을 설정해주는 겁니다.
현재 시각은 Now 함수를 통해서 알 수 있습니다.
랜덤 시드값은 int64 타입이므로 Time 객체의 메서드인 UnixNano() 메서드를 통해서 int64 로 변환합니다.
UnixNano() 메서드는 UTC 시간 기준인 1970년 1월 1일부터 Time 객체가 나타내는 시각까지 경과한 시간을 나노초 단위로 나타낸 값을 반환해줍니다.
- func Seed(seed int64)
- func Now() Time
- func (t Time) UnixNano() int64
func main() {
rand.Seed(time.Now().UnixNano())
n := rand.Intn(100)
println(n)
}
17.4 숫자값 입력받기
숫자 대신 문자를 입력하면 Scan() 함수가 에러를 반환하고 다시 입력을 받아야 합니다.
이때 제대로 입력을 받으려면 표준 입력 스트림을 비워줘야 합니다.
var stdin = bufio.NewReader(os.Stdin)
func InputIntValue() (int, error) {
var n int
_, err := fmt.Scanln(&n)
if err != nil {
stdin.ReadString('\n')
}
return n, err
}
func main() {
for {
fmt.Printf("숫자값을 입력하세요:")
n, err := InputIntValue()
if err != nil {
fmt.Println("숫자만 입력하세요.")
} else {
fmt.Println("입력하신 숫자는 ", n, " 입니다.")
}
}
}
2 단계. 고급 기법으로 Go 레벨업하기
18장. 슬라이스
18.1.1 슬라이스 선언
var array [10]int
슬라이스는 배열과 비슷하지만 [ ] 안에 배열의 개수를 적지 않고 선언합니다.
var slice []int
슬라이스를 초기화하지 않으면 길이가 0 인 슬라이스가 만들어집니다.
{ } 를 이용해 초기화하기
첫 번째 초기화 방법이다.
var slice1 = []int{1, 2, 3}
var slice2 = []int{1, 5:2, 10:3}
- array vs slice
var array = [...]int{1, 2, 3}
var slice = []int{1, 2, 3}
두 구문은 서로 다른 타입을 만듭니다.
make() 를 이용한 초기화
두 번째 초기화 방법은 make() 내장 함수를 사용하는 방법입니다.
var slice = make([]int, 3)
길이 3개 짜리 int 슬라이스 값이 됩니다.
18.1.2 슬라이스 요소 접근
요소에 접근하는 방법은 배열과 똑같습니다.
자바는 배열과 리스트의 접근 방법이 다릅니다.
고랭은 배열과 슬라이스의 접근 방법이 같습니다.
18.1.3 슬라이스 순회
배열과 사용법이 같다고 보면 됩니다.
18.1.4 슬라이스 요소 추가 - append()
func main() {
var slice = []int{1, 2, 3}
slice2 := append(slice, 4)
fmt.Println(slice)
fmt.Println(slice2)
println(slice)
println(slice2)
}
18.1.5 여러 값 추가
slice = append(slice, 3, 4, 5, 6, 7)
18.2 슬라이스 동작 원리
슬라이스는
- 배열을 가리키는 포인터
- 요소 개수를 나타내는 Len
- 전체 배열 길이를 나타내는 Cap 필드
로 구성된 구조체입니다.
변수 대입 시 배열에 비해서 사용디는 메모리나 속도에 이점이 있습니다.
18.2.1 make() 함수를 이용한 선언
var slice = make([]int, 3)
var slice2 = make([]int, 3, 5)
slice 는 len 과 cap 이 3 입니다.
slice2 는 len 이 3, cap 이 5 인 슬라이스가 만들어집니다.
18.2.2 슬라이스와 배열의 동작 차이
함수 내에서 값을 바꾸면 슬라이스는 바뀌지만 배열은 바뀌지 않는다.
18.2.3 동작 차이의 원인
Go 언어에서는 모든 값의 대입은 복사로 일어납니다.
포인터는 포인터의 값인 메모리 주소가 복사되고 구조체가 복사될 때는 구조체의 모든 필드가 복사됩니다.
슬라이스는 대입하면 복사가 아니라 같은 배열이 되버린다.
18.2.4 append() 를 사용할 때 발생하는 예기치 못한 문제 1
append() 함수가 호출되면 먼저 슬라이스에 값을 추가할 수 있는 빈 공간이 있는지 확인합니다.
남은 빈 공간이 실제 배열 길이 cap 에서 슬라이스 요소 개수 len 을 뺀 값입니다.
남은 빈 공간 = cap - len
func main() {
slice1 := make([]int, 3, 5)
slice2 := append(slice1, 4, 5)
fmt.Println("slice1:", slice1, len(slice1), cap(slice1))
fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
slice1[1] = 100
fmt.Println("After change second element")
fmt.Println("slice1:", slice1, len(slice1), cap(slice1))
fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
slice1 = append(slice1, 500)
fmt.Println("After append 500")
fmt.Println("slice1:", slice1, len(slice1), cap(slice1))
fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
}
18.2.5 append() 를 사용할 때 발생하는 예기치 못한 문제 2
만약 빈 공간이 충분하지 않으면 새로운 더 큰 배열을 마련합니다.
일반적으로 기존 배열의 2배 크기로 마련합니다.
func main() {
slice1 := []int{1, 2, 3}
slice2 := append(slice1, 4, 5)
fmt.Println("slice1:", slice1, len(slice1), cap(slice1))
fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
slice1[1] = 100
fmt.Println("After change second element")
fmt.Println("slice1:", slice1, len(slice1), cap(slice1))
fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
slice1 = append(slice1, 500)
fmt.Println("After append 500")
fmt.Println("slice1:", slice1, len(slice1), cap(slice1))
fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
}
18.3 슬라이싱
슬라이싱은 배열의 일부를 집어내는 기능을 말합니다.
array[startIdx:endIdx]
새로운 배열이 만들어지는 것이 아니라 배열의 일부를 포인터로 가리키는 슬라이스를 만들어낼 뿐입니다.
슬라이싱된 슬라이스에 append 를 사용하면 기존 배열의 중간에 값이 변경됩니다.
18.3.2 슬라이스를 슬라이싱하기
슬라이싱 긴으은 배열뿐 아니라 슬라이스 일부를 집어낼 때도 사용할 수 있습니다.
slice1 := []int{1, 2, 3, 4, 5}
slice2 := slice1[1:2]
슬라이싱에서 시작인덱스를 생략할 수 있습니다.
끝까지도 슬라이싱 할 수 있습니다.
slice1 := []int{1, 2, 3, 4, 5}
slice2 := slice1[2:len(slice1)]
// slice2 := slice1[2:] 다음 두 구문은 같습니다.
전체 슬라이싱
slice := array[:]
전체 슬라이싱은 배열 전체를 가리키는 슬라이스를 만들고 싶을 때 주로 사용합니다.
인덱스 3개로 슬라이싱해 cap 크기 조절하기
인덱스를 3개 사용해서 cap 까지 조절할 수 있습니다.
slice[시작인덱스:끝인덱스:최대인덱스]
slice1 := []int{1, 2, 3, 4, 5}
slice2 := slice1[1:3:4]
cap 은 최대 인덱스 - 시작 인덱스가 됩니다.
앞의 slice2 의 cap 은 4-1 = 3 이 됩니다.
18.4 유용한 슬라이싱 기능 활용
18.4.1 슬라이스 복제
func main() {
slice1 := []int{1, 2, 3, 4, 5}
slice2 := make([]int, len(slice1))
for i, v := range slice1 {
slice2[i] = v
}
slice1[1] = 100
fmt.Println(slice1)
fmt.Println(slice2)
}
append() 함수로 코드 개선하기
이 구문을 한 줄로 줄일 수 있습니다.
slice2 := append([]int{}, slice1...)
배열이나 슬라이스 뒤에 ... 를 하면 모든 요솟값을 넣어준 것과 같게 됩니다.
copy() 함수로 코드 개선하기
func copy(dst, src []Type) int
첫 번째 인수로 복사한 결과를 저장하는 슬라이스 변수를 넣고,
두 번째 인수로 복사 대상이 되는 슬라이스 변수를 넣습니다.
반환값은 실제로 복사된 요소 개수입니다.
실제 복사되는 요소 개수는 목적지의 슬라이스 길이와 대상의 슬라이스 길이 중 작은 개수만큼 복사됩니다.
func main() {
slice1 := []int{1, 2, 3, 4, 5}
slice2 := make([]int, 3, 10)
slice3 := make([]int, 10)
cnt1 := copy(slice2, slice1)
cnt2 := copy(slice3, slice1)
fmt.Println(cnt1, slice2)
fmt.Println(cnt2, slice3)
}
func main() {
slice1 := []int{1, 2, 3, 4, 5}
slice2 := append([]int{}, slice1...)
slice3 := []int{10, 20, 30, 40, 50}
slice4 := make([]int, len(slice1))
copy(slice3, slice1)
copy(slice4, slice1)
fmt.Println(slice1)
fmt.Println(slice2)
fmt.Println(slice3)
fmt.Println(slice4)
}
이미 길이가 같은 슬라이스가 있다면 copy 함수가, 새롭게 슬라이스를 만든다면 append 함수가 나아 보인다.
성능 차이는 없다고 한다.
18.4.2 요소 삭제
append 함수로 가운데 값을 없앨 수 있습니다.
func main() {
slice := []int{1, 2, 3, 4, 5, 6}
idx := 2
slice = append(slice[:idx], slice[idx+1:]...)
fmt.Println(slice)
}
18.4.3 요소 추가
func main() {
slice := []int{1, 2, 3, 4, 5, 6}
idx := 2
//slice = append(slice[:idx], append([]int{100}, slice[idx:]...)...)
slice = append(slice, 0)
copy(slice[idx+1:], slice[idx:])
slice[idx] = 100
fmt.Println(slice)
}
append 로 하면 매우 복잡하다.
슬라이스를 오름차순으로 정렬
func main() {
s := []int{5, 2, 6, 3, 1, 4}
sort.Ints(s)
fmt.Println(s)
}
18.5.2 구조체 슬라이스 정렬
구조체 정렬을 하려면 Len, Less, Swap 메서드를 구현해야 한다.
Go land 는 자동으로 구현하도록 메서드를 만들어준다.
type Student struct {
Name string
Age int
}
type Students []Student
func (s Students) Len() int {
return len(s)
}
func (s Students) Less(i, j int) bool {
return s[i].Age < s[j].Age
}
func (s Students) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func main() {
s := []Student{
{"화랑", 31},
{"백두산", 52},
{"류", 42},
{"켄", 38},
{"송하나", 18},
}
sort.Sort(Students(s))
fmt.Println(s)
}
19장. 메서드
19.1 메서드 선언
메서드를 선언하려면 리시버를 func 키워드와 함수 이름 사이에 소괄호로 명시해야 합니다.
리시버를 사용해서 메서드를 정의하는 코드를 살펴봅시다.
func (r Rabbit) info() int {
return r.width * r.height
}
func [리시버] [메서드명] [반환타입] {
...
}
구조체 변수 (r) 는 해당 메서드에서 매개변수처럼 사용됩니다.
리시버로는 모든 로컬 타입들이 가능한데, 로컬 타입이란 해당 패키지 안에서 type 키워드로 선언된 타입들을 말합니다.
그래서 패키지 내 선언된 구조체, 별칭 타입들이 리시버가 될 수 있습니다.
메서드 정의는 같은 패키지 내 어디에도 위치할 수 있습니다. 하지만 리시버 타입이 선언된 파일 안에 정의하는 게 일반적인 규칙입니다.
예를 들어 type Studnet struct 구조체를 student.go 파일에 정의했으면 Student 의 메서드들도 모두 student.go 파일에 모아놓습니다.
19.1.1 별칭 리시버 타입
별칭 타입도 리시버가 될 수 있고 메서드를 가질 수 있습니다. 즉 int 와 같은 내장 타입들도 별칭 타입을 활용해서 메서드를 가질 수가 있습니다.
type myInt int
func (a myInt) add(b int) int {
return int(a) + b
}
func main() {
var a myInt = 10
fmt.Println(a.add(30))
var b = 20
println(myInt(b).add(50))
}
별칭 타입 간 타입 변환을 지원하므로 myInt 타입으로 변환 후 add 메서드를 사용할 수 있다.
19.2 메서드는 왜 필요한가 ?
좋은 프로그래밍이란 결합도 (coupling) 를 낮추고 응집도 (cohesion) 를 높여야 합니다.
메서드는 데이터와 관련 기능을 묶기 때문에 코드 응집도를 높이는 중요한 역할을 합니다.
응집도가 낮으면 새로운 기능을 추가할 때 흩어진 모든 부분을 검토하고 고쳐야 하는 산탄총 수술 문제가 발생합니다.
* 산탄총 수술 문제: 작은 변화에도 산탄총을 맞은 듯 많은 코드 영역을 수정하는 경우를 말합니다.
19.2.1 객체지향: 절차 중심에서 관계 중심으로 변화
이제는 기능 호출 순서를 나타내는 순서도보다 객체 간의 관계를 나타내는 클래스 다이어그램을 더 중시하게 됐습니다.
19.3 포인터 메서드 vs 값 타입 메서드
type account struct {
balance int
firstName string
lastName string
}
func (a1 *account) withdrawPointer(amount int) {
a1.balance -= amount
}
func (a2 account) withdrawValue(amount int) {
a2.balance -= amount
}
func (a3 account) withdrawReturnValue(amount int) account {
a3.balance -= amount
return a3
}
func main() {
var mainA *account = &account{100, "Joe", "Park"}
mainA.withdrawPointer(30)
fmt.Println(mainA.balance)
mainA.withdrawValue(20)
fmt.Println(mainA.balance)
var mainB account = mainA.withdrawReturnValue(20)
fmt.Println(mainB.balance)
mainB.withdrawPointer(30)
fmt.Println(mainB.balance)
}
원래는 mainA 를 값 타입으로 변환하여 호출하여야 하지만 Go 언어에서는 이럴 때는 자동으로 mainA 의 값으로 변환하여 호출합니다.
mainB 에서 반대도 마찬가지이다.
별표 * 를 붙이면 포인터 메서드이다.
20장. 인터페이스
인터페이스는 우리말로 상호작용면 으로 직역할 수 있습니다.
추상화된 객체로 상호작용할 수 있습니다.
20.1.1 인터페이스 선언
type DuckInterface interface {
Fly()
Walk(distance int) int
}
- 메서드는 반드시 메서드명이 있어야 합니다.
- 매개변수와 반환이 다르더라도 이름이 같은 메서드는 있을 수 없습니다.
- 인터페이스에서는 메서드 구현을 포함하지 않습니다.
type Stringer interface {
String() string
}
type Student struct {
Name string
Age int
}
func (s Student) String() string {
return fmt.Sprintf("안녕! 나는 %d 살 %s 라고 해", s.Age, s.Name)
}
func main() {
student := Student{"철수", 12}
var stringer Stringer
stringer = student
fmt.Printf("%s\n", stringer.String())
}
Go 언어에서는 ~er 을 붙여 인터페이스명을 만드는 것을 권장하고 있습니다. String() 메서드를 가진 인터페이스란 뜻으로 Stringer 라고 만들었습니다. Stringer 원뜻과는 관계 없습니다.
fmt.Sprintf() 함수를 사용해 문자열을 만들 수 있습니다.
20.2 인터페이스 왜 쓰나 ?
func SendBook(name string, sender *fedex.FedexSender) {
sender.Send(name)
}
func main() {
sender := &fedex.FedexSender{}
SendBook("어린 왕자", sender)
SendBook("그리스인 조르바", sender)
}
- 우체국에서 제공하는 패키지로 변경하기
import (
"github.com/tuckersGo/musthaveGo/ch20/fedex"
"github.com/tuckersGo/musthaveGo/ch20/koreaPost"
)
func SendBook(name string, sender *fedex.FedexSender) {
sender.Send(name)
}
func main() {
sender := &koreaPost.PostSender{}
SendBook("어린 왕자", sender)
SendBook("그리스인 조르바", sender)
}
import (
"github.com/tuckersGo/musthaveGo/ch20/fedex"
"github.com/tuckersGo/musthaveGo/ch20/koreaPost"
)
type Sender interface {
Send(parcel string)
}
func SendBook(name string, sender Sender) {
sender.Send(name)
}
func main() {
koreaPostSender := &koreaPost.PostSender{}
SendBook("어린 왕자", koreaPostSender)
SendBook("그리스인 조르바", koreaPostSender)
fedexSender := &fedex.FedexSender{}
SendBook("어린 왕자", fedexSender)
SendBook("그리스인 조르바", fedexSender)
}
20.2.1 추상화 계층
인터페이스는 추상화를 제공하는 추상화 계층 (abstraction layer) 입니다.
20.3 덕 타이핑
Go 언어에서는 어떤 타입이 인터페이스를 포함하고 있는지 여부를 결정할 때 덕 타이핑 (Duck typing) 방식을 사용합니다.
덕 타이핑 방식이란 타입 선언 시 인터페이스 구현 여부를 명시적으로 나타낼 필요 없이 인터페이스에 정의한 메서드 포함 여부만으로 결정하는 방식입니다.
덕 타이핑이란 자바에서 implements [인터페이스명] 과 같이 명시적으로 나타내지 않고 사용하는 방식입니다.
20.4 인터페이스 기능 더 알기
구조체에서 다른 구조체를 포함된 필드로 가질 수 있듯이 인터페이스도 다른 인터페이스를 포함할 수 있습니다.
이를 포함된 인터페이스라고 부릅니다.
type Reader interface {
Read(n int, err error)
Close() error
}
type Writer interface {
Write(n int, err error)
Close() error
}
type ReadWriter interface {
Reader
Writer
}
20.4.2 빈 인터페이스 interface{ } 를 인수로 받기
빈 인터페이스는 모든 타입이 쓰일 수 있다.
func PrintVal(v interface{}) {
switch t := v.(type) {
case int:
fmt.Printf("v is int %d\n", int(t))
case float64:
fmt.Printf("v is int %f\n", float64(t))
case string:
fmt.Printf("v is int %s\n", string(t))
default:
fmt.Printf("Not supported type: %T:%v\n", v, v)
}
}
type Student struct {
Age int
}
func main() {
PrintVal(10)
PrintVal(3.14)
PrintVal("Hello")
PrintVal(Student{15})
}
20.4.3 인터페이스 기본값 nil
인터페이스 뿐만 아니라 nil 값을 기본으로 갖는 다른 타입 변수 역시 사용하기 전에 값이 nil 인지 확인해야 합니다.
기본값을 nil 로 갖는 타입은 포인터, 인터페이스, 함수 타입, 슬라이스, 맵, 채널 등이 있습니다.
invalid memory address 문구는 비정상적인 메모리 주소에 접근하다가 에러가 발생했다는 이야기입니다.
20.5 인터페이스 변환하기
20.5.1 구체화된 다른 타입으로 타입 변환하기
var a Interface
t := a.(ConcreteType)
20.5.2 다른 인터페이스로 타입 변환하기
구체화된 타입으로 변활할 때와는 달리 변경되는 인터페이스가 변경 전 인터페이스를 포함하지 않아도 됩니다.
하지만 인터페이스가 가리키고 있는 실제 인스턴스가 변환하고자 하는 다른 인터페이스를 포함해야 합니다.
var a AInterface = ConcreteType{}
b := a.(BInterface)
20.5.3 타입 변환 성공 여부 반환
타입 변환 반환값을 두 개의 변수로 받으면 런 타임 에러는 발생하지 않습니다.
var a Interface
t, ok := a.(ConcreteType)
if c, ok := reader.(Close); ok {
...
}
한 줄로 표현할 수 있습니다.
많은 프로그래머가 이렇게 한 줄로 표현하는 것을 더 선호합니다.
21장. 함수 고급편
21.1 가변 인수 함수
함수 인수 개수가 고정적이지 않은 함수를 가변 인수 함수 (variadic function) 라고 합니다.
21.1.1 ... 키워드 사용
func sum(nums ...int) int {
sum := 0
fmt.Printf("nums 타입: %T\n", nums)
for _, v := range nums {
sum += v
}
return sum
}
func main() {
fmt.Println(sum(1, 2, 3, 4, 5))
fmt.Println(sum(10, 20))
fmt.Println(sum())
}
가변인수는 함수 내에서 슬라이스로 처리됩니다.
21.2 defer 지연 실행
때론 함수가 종료되기 직전에 실행해야 하는 코드가 있을 수 있습니다.
대표적으로 파일이나 소켓핸들처럼 OS 내부 자원을 사용하는 경우입니다.
파일을 생성하거나 읽을 때 OS 에 파일 핸들을 요청합니다.
그러면 윈도우, 리눅스, 맥 같은 OS 는 파일 핸들을 만들어서 프로그램에 알려줍니다.
하지만 이 같은 자원은 OS 내부 자원이기 때문에 반드시 쓰고 나서 OS 에 돌려줘야 합니다.
되돌려주지 않으면 내부 자원이 고갈되어 더는 파일을 만들지 못하거나 네트워크 통신을 하지 못할 수있 습니다.
defer 명령문
func main() {
f, err := os.Create("test.txt")
if err != nil {
fmt.Println("Failed to create a file")
return
}
defer fmt.Println("반드시 호출됩니다.")
defer f.Close()
defer fmt.Println("파일을 닫았습니다.")
fmt.Println("파일에 Hello World 를 씁니다.")
fmt.Fprintln(f, "Hello World")
}
fmt.Fprint() 는 파일 핸들에 텍스트를 쓰는 함수입니다.
- test.txt
Hello World
21.3 함수 타입 변수
함수 시작지점 역시 숫자로 표현할 수 있습니다. 이 함수 시작지점이 바로 함수를 가리키는 값이고, 마치 포인터처럼 함수를 가리킨다고 해서 함수 포인터 (function pointer) 라고 부릅니다.
func (int, int) int
func add(a, b int) int {
return a + b
}
func mul(a, b int) int {
return a * b
}
func getOperator(op string) func(int, int) int {
if op == "+" {
return add
} else if op == "*" {
return mul
} else {
return nil
}
}
func main() {
var operator func(int, int) int
operator = getOperator("*")
var result = operator(3, 4)
fmt.Println(result)
}
별칭으로 함수 정의 줄여 쓰기
type opFunc func (int, int) int
func getOperator(op string) opFunc
21.4 함수 리터럴
함수 리터럴 (function literal) 은 이름없는 함수로 함수명을 적지 않고 함수 타입 변숫값으로 대입되는 함숫값을 의미합니다.
다른 프로그래밍 언어에서는 익명함수 또는 람다 (Lambda) 라고 불리기도 합니다.
Go 언어에서는 함수 리터럴이라고 합니다.
func getOperator(op string) func(int, int) int {
if op == "+" {
return func(a, b int) int {
return a + b
}
} else if op == "*" {
return func(a, b int) int {
return a * b
}
} else {
return nil
}
}
func main() {
fn := getOperator("*")
result := fn(3, 4)
fmt.Println(result)
}
- main 함수에 익명함수로 출력하기
func main() {
fn := func(a, b int) int {
return a + b
}
result := fn(3, 4)
println(result)
}
func main() {
result := func(a, b int) int {
return a + b
}(3, 4)
fmt.Println(result)
}
21.4.1 함수 리터럴 내부 상태
함수 리터럴은 필요한 변수를 내부 상태로 가질 수 있습니다.
함수 리터럴 내부에서 사용되는 외부 변수는 자동으로 함수 내부상태로 저장됩니다.
func main() {
i := 0
f := func() {
i += 10
}
i++
f()
fmt.Println(i)
}
함수 리터럴에서 외부 변수를 내부 상태로 가져올 때 값 복사가 아닌 인스턴스 참조로 가져오게 됩니다.
포인터 형태로 가져온다고 생각하면 편할 것 같습니다.
21.4.2 함수 리터럴 내부 상태 주의점
함수 리터럴 외부 변수를 내부 상태로 가져오는 것을 캡쳐 (capture) 라고 합니다.
캡쳐는 값 복사가 아닌 참조 형태로 가져오게 되니 주의해야 합니다.
캡쳐로 발생할 수 있는 문제를 살펴봅시다.
func CaptureLoop() {
f := make([]func(), 3)
fmt.Println("ValueLoop")
for i := 0; i < 3; i++ {
f[i] = func() {
fmt.Println(i)
}
}
for i := 0; i < 3; i++ {
f[i]()
}
}
func CaptureLoop2() {
f := make([]func(), 3)
fmt.Println("ValueLoop2")
for i := 0; i < 3; i++ {
v := i
f[i] = func() {
fmt.Println(v)
}
}
for i := 0; i < 3; i++ {
f[i]()
}
}
func main() {
CaptureLoop()
CaptureLoop2()
}
f 는 func() 타입 함수 리터럴을 3개 갖는 슬라이스 입니다. 함수 리터럴 또한 타입이기 때문에 함수 리터럴을 갖는 슬라이스를 만들 수 있습니다.
함수 리터럴 내부에서의 i 는 값이 복사되는 것이 아니라 i 변수가 참조로 캡쳐되기 때문입니다.
21.4.3 파일 핸들을 내부 상태로 사용하는 예
함수 리터럴을 이용해서 원하는 함수를 그때그때 정의해서 함수 타입 변숫값으로 사용할 수 있습니다.
또 필요한 외부 변수를 내부 상태로 가져와서 편리하게 사용할 수 있습니다.
type Writer func(string)
func writeHello(writer Writer) {
writer("Hello World")
}
func main() {
f, err := os.Create("test.txt")
if err != nil {
fmt.Println("Failed to craete a file")
return
}
defer f.Close()
writeHello(func(msg string) {
fmt.Fprintln(f, msg)
})
}
os.Create() 로 파일을 만듭니다.
파일에 Hello World 라는 문자열을 넣습니다.
22장. 자료구조
22.1 리스트
리스트는 기본 자료구조로서 여러 데이터를 보관할 수 있습니다. 배열과 가장 큰 차이점은 배열은 연속된 메모리에 데이터를 저장하는 반면, 리스트는 불연속된 메모리에 데이터를 저장한다는 겁니다.
22.1.1 포인터로 연결된 요소
리스트는 각 데이터를 담고 있는 요소들을 포인터로 연결한 자료구조입니다.
요소들이 포인터로 연결됐다고 해서 링크드 리스트 (Linked List) 라고 부르기도 합니다.
양방향 리스트라고 합니다.
리스트는 서로 떨어진 Element 인스턴스들이 Next 포인터로 연결된 불연속 자료구조입니다.
반면 배열은 연속된 메모리를 사용하는 자료구조입니다.
22.1.2 리스트 기본 사용법
func main() {
v := list.New()
e4 := v.PushBack(4)
e1 := v.PushFront(1)
v.InsertBefore(3, e4)
v.InsertAfter(2, e1)
for e := v.Front(); e != nil; e = e.Next() {
fmt.Print(e.Value, " ")
}
println()
for e := v.Back(); e != nil; e = e.Prev() {
fmt.Print(e.Value, " ")
}
}
- 리스트는 New() 함수로 새로운 인스턴스를 만들어서 사용해야 합니다.
- PushBack() 메서드는 리스트 밑 뒤에 요소를 추가합니다. Element 인스턴스를 e4 변수로 나타냅니다.
- PushFront() 는 리스트 맨 앞에 요소를 추가합니다.
- InsertBefore() 는 앞에 추가합니다. After() 도 마찬가지로 뒤에 추가합니다.
22.1.3 배열 vs 리스트
이 둘은 스택, 큐, 트리 등 다른 자료구조의 기본적인 형태로 사용되기 때문에 모든 자료구조의 기본이 되는 자료구조라고 볼 수 있습니다.
맨 앞에 데이터 추가하기
- 배열: O(N)
- 리스트: O(1)
특정 요소에 접근하기
배열에서 인덱스 이동 공식 -> 배열시작주소 + (인덱스 x 타입크기)
- 배열: O(1)
- 리스트: O(N)
행위 | 배열, 슬라이스 | 리스트 |
---|---|---|
요소 삽입 | O(N) | O(1) |
요소 삭제 | O(N) | O(1) |
인덱스 요소 접근 | O(1) | O(N) |
인덱스를 활용한 접근에서는 배열이, 인덱스를 사용한 접근이 거의 없고 삽입과 삭제가 빈번하게 일어나면 리스트가 더 빠릅니다.
요소 수가 적으면 데이터 지역성 때문에 오히려 배열이 리스트보다 더 효율적입니다.
22.1.4 실습: 큐 구현하기
빼내는 작업이 O(1) 이므로 리스트가 큐를 만들기에 더 효율적입니다.
- 큐 구현
type Queue struct {
v *list.List
}
func (q *Queue) Push(val interface{}) {
q.v.PushBack(val)
}
func (q *Queue) Pop() interface{} {
front := q.v.Front()
if front != nil {
return q.v.Remove(front)
}
return nil
}
func NewQueue() *Queue {
return &Queue{list.New()}
}
- main
func main() {
queue := NewQueue()
for i := 1; i < 5; i++ {
queue.Push(i)
}
v := queue.Pop()
for v != nil {
fmt.Printf("%v -> ", v)
v = queue.Pop()
}
}
22.1.5 실습: 스택 구현하기
스택은 요소의 추가와 삭제가 항상 맨 뒤에서 발생하기 때문에 배열로 만들어도 성능에 손해가 없습니다.
그래서 보통 큐는 리스트로, 스택은 배열로 구현합니다.
22.2 링
func main() {
r := ring.New(5)
n := r.Len()
for i := 0; i < n; i++ {
r.Value = 'A' + i
r = r.Next()
}
for j := 0; j < n; j++ {
fmt.Printf("%c ", r.Value)
r = r.Next()
}
fmt.Println()
for j := 0; j < n; j++ {
fmt.Printf("%c ", r.Value)
r = r.Prev()
}
}
22.2.1 링은 언제 쓸까 ?
링은 저장할 개수가 고정되고, 오래된 요소는 지워도 되는 경우에 적합합니다.
MS 워드는 실행취소를 지원합니다.
- 실행취소기능
- 고정크기버퍼 기능
- 리플레이 기능
22.3 맵
map[key]value
언어에 따라서 딕셔너리, 해시테이블, 해시맵 등으로 부릅니다.
Go 언어에서는 맵이라고 부릅니다.
맵은 리스트나 링과 달리 container 패키지가 아닌 Go 기본 내장 타입입니다.
func main() {
m := make(map[string]string)
m["이화랑"] = "서울시 광진구"
m["송하나"] = "서울시 강남구"
m["백두산"] = "부산시 사하구"
m["최번개"] = "전주시 덕진구"
m["최번개"] = "청주시 상당구"
fmt.Printf("송하나의 주소는 %s 입니다.\n", m["송하나"])
fmt.Printf("백두산의 주소는 %s 입니다.\n", m["백두산"])
fmt.Printf("최번개의 주소는 %s 입니다.\n", m["최번개"])
}
- 맵 순회
type Product struct {
Name string
Price int
}
func main() {
m := make(map[int]Product)
m[16] = Product{"볼펜", 500}
m[46] = Product{"지우개", 200}
m[78] = Product{"자", 1000}
m[345] = Product{"샤프", 3000}
for k, v := range m {
fmt.Println(k, v)
}
}
22.3.1 요소 삭제와 없는 요소 확인
delete() 함수로 요소를 삭제합니다.
요소를 조회할 때 키에 알맞는 요소가 없으면 값 타입의 기본값을 반환합니다.
delete(m, key)
func main() {
m := make(map[int]int)
m[1] = 0
m[2] = 2
m[3] = 3
delete(m, 3)
delete(m, 4) // 없는 요소에 접근하려고 할 때는 아무 일도 일어나지 않습니다.
fmt.Println(m[3])
fmt.Println(m[1])
}
값이 0 일 때와 아예 요소가 없을 때 둘 다 0 이 출력됩니다.
어떻게 이 둘을 구분할 수 있을까요 ?
해답은 복수 반환에 있습니다.
v, ok := m[3]
func main() {
m := make(map[int]int)
m[1] = 0
m[2] = 2
m[3] = 3
delete(m, 3)
delete(m, 4)
if v, ok := m[1]; ok {
fmt.Println("m[1]", v)
}
if v, ok := m[2]; ok {
fmt.Println("m[2]", v)
}
if v, ok := m[3]; ok {
fmt.Println("m[3]", v)
}
if v, ok := m[4]; ok {
fmt.Println("m[4]", v)
}
}
22.3.2 맵, 배열, 리스트 속도 비교
배열, 슬라이스 | 리스트 | 맵 | |
---|---|---|---|
추가 | O(N) | O(1) | O(1) |
삭제 | O(N) | O(1) | O(1) |
읽기 | O(1) - 인덱스로 접근 | O(N) - 인덱스로 접근 | O(1) - 키로 접근 |
22.4 맵의 원리
맵을 이해하려면 먼저 해시 함수 (hash function) 의 동작을 이해해야 합니다.
22.4.1 해시 함수
해시 (hash) 란 잘게 부순다는 뜻입니다.
해시브라운을 연상하면 쉽습니다.
- 같은 입력이 들어오면 같은 결과가 나온다.
- 다시 입력이 들어오면 되도록 다른 결과가 나온다.
- 입력값의 범위는 무한대이고 결과는 특정 범위를 갖는다.
sin(x) 는 해시함수로 쓰일 수 있습니다.
23장. 에러 핸들링
23.1 에러 반환
ReadFile() 함수로 파일을 읽을 때 해당하는 파일이 없어 에러가 발생했다고 합시다.
이럴 때 프로그램이 강제종료되는 것 보다는 적절한 메시지를 출력하고 다른 파일을 읽거나 임시 파일을 생성한다면 훨씬 사용자 경험이 좋을 것입니다.
23.1.1 사용자 에러 반환
func Sqrt(f float64) (float64, error) {
if f < 0 {
return 0, fmt.Errorf(
"제곱근은 양수여야 합니다. f:%g", f)
}
return math.Sqrt(f), nil
}
func main() {
sqrt, err := Sqrt(-2)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Sqrt(-2) = %v\n", sqrt)
}
fmt 패키지의 Errorf() 함수를 이용하면 원하는 에러 메시지를 만들 수 있습니다.
또는 errors 패키지의 New() 함수를 이용해서 error 를 생성할 수 있습니다.
New() 함수 형식은 아래와 같습니다.
인수로 문자열을 입력하면 인수와 같은 메시지를 갖는 error 를 생성해서 반환합니다.
func New(text string) error
errors.New("에러 메시지")
23.2 에러 타입
error 는 인터페이스 입니다.
즉 어떤 타입이든 문자열을 반환하는 Error() 메서드를 포함하고 있다면 에러로 사용할 수 있습니다.
- 회원가입 시 암호 길이를 검사하는 예제
type passwordError struct {
Len int
RequireLen int
}
func (err passwordError) Error() string {
return "암호 길이가 짧습니다."
}
func RegisterAccount(name, password string) error {
if len(password) < 8 {
return passwordError{len(password), 8}
}
return nil
}
func main() {
err := RegisterAccount("myID", "MyPw")
if err != nil {
if errInfo, ok := err.(passwordError); ok {
fmt.Printf("%v Len:%d RequiredLen:%d\n",
errInfo, errInfo.Len, errInfo.RequireLen)
}
} else {
fmt.Println("회원 가입됐습니다.")
}
}
이렇게 하면 다양한 에러 타입에 알맞게 대응할 수 있습니다.
23.2.1 에러 랩핑
때론 에러를 감싸서 새로운 에러를 만들어야 할 수도 있습니다.
예를 들어 파일에서 텍스트를 읽을 때 특정 타입의 데이터로 변환하는 경우 파일 읽기에서 발생하는 에러도 필요하지만 텍스트의 몇 번째 줄의 몇 번째 칸에서 에러가 발생했는지도 알면 더 유용할 겁니다.
func MultipleFromString(str string) (int, error) {
scanner := bufio.NewScanner(strings.NewReader(str))
scanner.Split(bufio.ScanWords)
pos := 0
a, n, err := readNextInt(scanner)
if err != nil {
return 0, fmt.Errorf("Failed to readNextInt(), pos:%d err:%w", pos, err)
}
pos += n + 1
b, n, err := readNextInt(scanner)
if err != nil {
return 0, fmt.Errorf("Failed to readNextInt(), pos:%d err:%w", pos, err)
}
return a * b, nil
}
func readNextInt(scanner *bufio.Scanner) (int, int, error) {
if !scanner.Scan() {
return 0, 0, fmt.Errorf("Failed to scan")
}
word := scanner.Text()
number, err := strconv.Atoi(word)
if err != nil {
return 0, 0, fmt.Errorf("Failed to convert word to int, word:%s err:%w",
word, err)
}
return number, len(word), nil
}
func readEq(eq string) {
rst, err := MultipleFromString(eq)
if err == nil {
fmt.Println(rst)
} else {
fmt.Println(err)
var numError *strconv.NumError
if errors.As(err, &numError) {
fmt.Println("NumberError:", numError)
}
}
}
func main() {
readEq("123 3")
readEq("123 abc")
}
- MultipleFromString() 함수는 문자열에서 두 단어를 읽어서 각 수샂로 변환한 뒤 곱한 결과를 반환합니다.
- 한 단어씩 읽는 bufio 패키지의 Scanner 를 만들어 줍니다.
- bufio.NewScanner(io.Reader)
- strings.NewReader(str) -> 문자열을 Reader 타입으로 만들어준다.
- 문자열을 한 줄씩 또는 한 단어씩 끊어 읽고자 할 때 주로 사용하는 구문이다.
- scanner.Split(bufio.ScanWords) -> 한 단어씩 끊어 읽게 된다. ScanLines 는 한 줄로 끊어 읽게 된다.
scanner
- Scan()
- Text()
strconv
- Atoi() - 문자열 -> int (Alphabet to Integer)
- 숫자가 아닌 문자가 섞여있을 때는 NumberError 타입의 에러를 반환한다.
- Itoa() - (Integer to Alphabet)
errors.As() 는 err 안에 감싸진 에러 중 두 번째 인수 타입인 *strconv.NumError 로 변환될 수 있는 에러가 있다면 변환하여 값을 넣고 true 를 반환하는 함수입니다.
23.3 패닉
나눗셈 함수에서 제수가 0 이면 panic() 함수를 호출하는 예제를 살펴봅시다.
func divide(a, b int) {
if b == 0 {
panic("b 는 0 일 수 없습니다.")
}
fmt.Printf("%d / %d = %d\n", a, b, a/b)
}
func main() {
divide(9, 3)
divide(9, 0)
}
콜 스택이란 panic 이 발생한 마지막 함수 위치부터 역순으로 호출 순서를 표시합니다.
24.3.1 패닉 생성
func panic(interface{})
일반적으로 string 타입 메시지나 fmt.Errorf() 함수를 이용해서 만들어진 에러 타입을 주로 사용합니다.
하지만 int 타입값을 사용하거나 특정 타입 객체를 사용해도 됩니다.
24.3.2 패닉 전파 그리고 복구
func f() {
println("f() 함수 시작")
defer func() {
if r := recover(); r != nil {
fmt.Println("panic 복구 - ", r)
}
}()
g()
fmt.Println("f() 함수 끝")
}
func g() {
fmt.Printf("9 / 3 = %d\n", h(9, 3))
fmt.Printf("9 / 0 = %d\n", h(9, 0))
}
func h(a int, b int) int {
if b == 0 {
panic("제수는 0 일 수 없습니다.")
}
return a / b
}
func main() {
f()
fmt.Println("프로그램이 계속 실행됨")
}
24.3.3 recover() 결과
if r, ok := recover().(net.Error); ok {
fmt.Println("r is net.Error Type")
}
24장. 고루틴과 동시성 프로그래밍
24.1 스레드란 ?
고루틴은 경량 스레드로 함수나 명령을 동시에 실행할 때 사용합니다.
프로그램 시작점인 main() 함수 역시 고루틴에 의해서 실행됩니다.
프로세스는 메모리 공간에 로딩되어 동작하는 프로그램을 말합니다.
스레드는 프로세스 안의 세부 작업 단위입니다.
스레드는 실행 흐름이라고 볼 수 있습니다.
원래 CPU 코어는 한 번에 한 명령밖에 수행할 수 없습니다.
그런데 싱글코어 CPU 에서도 여러 프로그램을 한꺼번에 실행할 수 있습니다.
CPU 코어가 스레드를 빠르게 전환해가면서 수행하면 사용자 입장에서는 마치 동시에 수행하는 것 처럼 보이게 됩니다.
24.1.1 컨텍스트 스위칭 비용
CPU 코어가 여러 스레드를 전환하면서 수행하면 더 많은 비용이 듭니다. 이것을 컨택스트 스위칭 (context switching) 비용이라고 합니다.
스레드를 전환하려면 현재 상태를 보관해야 합니다.
이때 스레드의 명령 포인터 (instruction pointer) , 스택 메모리 등의 정보를 저장하게 되는데 이를 스레드 컨텍스트 (thread context) 라고 합니다. 컨텍스트는 우리말로 문맥이라고도 합니다.
보통 코어 개수의 두 배 이상 스레드를 만들면 스위칭 비용이 많이 발생한다고 말합니다.
하지만 Go 언어에서는 이런 걱정을 할 필요가 없습니다. CPU 코어마다 OS 스레드를 하나만 할당해서 사용하기 때문에 컨텍스트스위칭 비용이 발생하지 않기 때문입니다.
24.2 고루틴 사용
고루틴을 추가로생성하는 구문은 다음과 같습니다.
go 함수_호출
여러 고루틴을 생성하여 문자와 숫자를 출력하는 예제를 살펴봅시다.
func PrintHangul() {
hanguls := []rune{'가', '나', '다', '라', '마', '바', '사'}
for _, v := range hanguls {
time.Sleep(300 * time.Millisecond)
fmt.Printf("%c ", v)
}
}
func PrintNumbers() {
for i := 1; i <= 5; i++ {
time.Sleep(400 * time.Millisecond)
fmt.Printf("%d ", i)
}
}
func main() {
go PrintHangul()
go PrintNumbers()
time.Sleep(3 * time.Second)
}
24.2.1 서브 고루틴이 종료될 때까지 기다리기
sync 패키지의 WaitGroup 객체를 사용해 서브 고루틴이 종료될 때까지 대기해보자.
var wg sync.WaitGroup
wg.Add(3) // 작업 개수 설정
wg.Done() // 작업이 완료될 때 마다 호출
wg.Wait() // 모든 작업이 완료될 때 까지 대기
var wg sync.WaitGroup
func SumAtoB(a, b int) {
sum := 0
for i := a; i <= b; i++ {
sum += i
}
fmt.Println("계산 중 입니다.")
time.Sleep(3 * time.Second)
fmt.Printf("%d 부터 %d 까지 합계는 %d 입니다.\n", a, b, sum)
wg.Done()
}
func main() {
wg.Add(10)
for i := 0; i < 10; i++ {
go SumAtoB(1, 1000000000)
}
wg.Wait()
println("END")
}
24.3 고루틴의 동작 방법
고루틴은 명령을 수행하는 단일 흐름으로 OS 스레드를 이용하는 경량 스레드 (lightweight thread) 입니다.
Go 언어에서는 CPU 코어, OS 스레드, 고루틴을 서로 조율하여 사용해 고루틴을 효율적으로 다룹니다.
24.3.4 시스템 콜 호출 시
시스템 콜이란 운영체제가 지원하는 서비스를 호출할 때를 말합니다.
대표적으로 네트워크 기능 등이 있습니다.
시스템 콜을 호출하면 운영체제에서 해당 서비스가 완료될 때까지 대기해야 합니다.
예를 들어 네트워크로 데이터를 읽을 때는 데이터가 들어올 때 까지 대기상태가 됩니다.
이런 대기 상태인 고루틴에 CPU 코어와 OS 스레드를 할당하면 CPU 자원 낭비가 발생합니다.
그래서 Go 언어에서는 이런 상태에 들어간 루틴을 대기 상태로 보내고, 실행을 기다리는 다른 루틴에 CPU 코어와 OS 스레드를 할당하여 실행될 수 있게 합니다.
지금까지 고루틴 동작원리를 살펴봤습니다.
이렇게 실행되면 컨텍스트 스위칭 비용이 발생하지 않는 장점이 있습니다.
OS 스레드를 직접 사용하는 다른 언어에서는 스레드 개수가 많아지면 컨텍스트 스위칭 비용이 증가되기 때문에 프로그램 성능이 떨어지지만 Go 언어에서는 고루틴이 증가되어도 컨텍스트 스위칭 비용이 발생하지않기 때문에 수백, 수천 고루틴을 마음껏 만들어 쓸 수 있습니다.
24.4 동시성 프로그래밍 주의점
동시성 프로그래밍의 문제점은 동일한 메모리 자원에 여러 고루틴이 접근할 때 발생합니다.
type Account struct {
Balance int
}
func main() {
var wg sync.WaitGroup
account := &Account{0}
wg.Add(10)
for i := 0; i < 10; i++ {
go func() {
for {
DepositAndWithdraw(account)
}
wg.Done()
}()
}
wg.Wait()
}
func DepositAndWithdraw(account *Account) {
if account.Balance < 0 {
panic(fmt.Sprintf("Balance should not be negative value: %d",
account.Balance))
}
account.Balance += 1000
time.Sleep(time.Millisecond)
account.Balance -= 1000
}
24.5 뮤텍스를 이용한 동시성 문제 해결
이런 동시성 문제를 해결하기 위한 가장 단순한 방법은 한 고루틴에서 값을 변경할 때 다른 고루틴이 건들지 못하게 하는 겁니다.
뮤텍스 (mutex) 를 이용하면 자원 접근 권한을 통제할 수 있습니다.
뮤텍스의 Lock() 메서드를 호출해 뮤텍스를 획득할 수 있습니다.
이미 Lock() 메서드를 호출해서 다른 고루틴이 뮤텍스를 획득했다면 나중에 호출한 고루틴은 앞서 획득한 뮤텍스가 반납될 때까지 대기하게 됩니다.
사용중이던 뮤텍스는 Unlock() 메서드를 호출해서 반납합니다.
이후 대기하던 고루틴 중 하나가 뮤텍스를 획득합니다.
var mutext sync.Mutex
type Account struct {
Balance int
}
func DepositAndWithdraw(account *Account) {
mutext.Lock()
defer mutext.Unlock()
if account.Balance < 0 {
panic(fmt.Sprintf("Balance should not be negative value: %d",
account.Balance))
}
account.Balance += 1000
time.Sleep(time.Millisecond)
account.Balance -= 1000
}
func main() {
var wg sync.WaitGroup
account := &Account{0}
wg.Add(10)
for i := 0; i < 10; i++ {
go func() {
for {
DepositAndWithdraw(account)
}
wg.Done()
}()
}
wg.Wait()
}
이번엔 패닉이 발생하지 않습니다.
뮤텍스는 도잇에 고루틴 하나만 확보할 수 있습니다.
따라서 mutex.Lock() 메서드를 먼저 차지한 고루틴만 잔고를 수정할 수 있습니다.
24.6 뮤텍스와 데드락
첫 번째 문제는 동시성 프로그래밍으로 얻는 성능향상을 얻을 수 없습니다.
두 번째 문제는 데드락이 발생할 수 있다는 점입니다.
데드락은 프로그램을 완전히 멈추게 만들어 버리는 아주 무서운 문제입니다.
google cloud profiler 와 같은 프로파일링 툴을 이용해서 뮤텍스 검사를 할 수 있습니다.
cloud profiler: https://cloud.google.com/profiler/docs?hl=ko
24.7 또 다른 자원 관리 기법
각 고루틴이 서로 다른 자원에 접근하게 만드는 방법은 두 가지 방법이 있습니다.
- 영역을 나누는 방법
- 역할을 나누는 방법
첫 번째 영역을 나누는 방법을 예제로 알아보겠습니다.
- 10가지 작업을 고루틴 10개로 수행
type Job interface {
Do()
}
type SqareJob struct {
index int
}
func (j *SqareJob) Do() {
fmt.Printf("%d 작업 시작\n", j.index)
time.Sleep(1 * time.Second)
fmt.Printf("%d 작업 완료 - 결과: %d\n", j.index, j.index*j.index)
}
func main() {
var jobList [10]Job
for i := 0; i < 10; i++ {
jobList[i] = &SqareJob{i}
}
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i < 10; i++ {
job := jobList[i]
go func() {
job.Do()
wg.Done()
}()
}
wg.Wait()
}
25장. 채널과 컨텍스트
25.1 채널 사용하기
채널 (channel) 이란 고루틴끼리 메시지를 전달할 수 있는 메시지 큐입니다.
메시지 큐에 메시지들은 차례대로 쌓이게 되고 메시지를 읽을 때는 맨 처음 온 메시지부터 차례대로 읽게 됩니다.
25.1.1 채널 인스턴스 생성
채널을 사용하기 위해서는 먼저 채널 인스턴스를 만들어야 합니다.
var messages chan string = make(chan string)
var [채널 인스턴스 변수] [채널타입] = make([채널 키워드] [메시지 타입])
채널은 위와 같이 슬라이스, 맵 등과 같이 make() 함수로 만들 수 있습니다.
채널 타입은 채널을 의미하는 chan 과 메시지 타입을 합쳐서 표현합니다.
25.1.2 채널에 데이터 넣기
messages <- "This is a message"
[채널 인스턴스] [연산자] [넣을 데이터]
채널에 데이터를 넣는 데 <- 연산자를 이용합니다.
25.1.3 채널에서 데이터 빼기
var msg string = <- messages
[빼낸 데이터를 담을 변수] = [얀신지] [채널 인스턴스]
넣을 때는 채널 인스턴스를 가리키는 반면, 빼올 때는 데이터를 담을 변수를 가리킵니다.
데이터를 빼올 때 만약 채널 인스턴스에 데이터가 없으면 데이터가 들어올 때까지 기다립니다.
- 채널을 사용해서 데이터 하나를 넣고 빼기
func main() {
var wg = sync.WaitGroup{}
ch := make(chan int) // 1. 채널 생성
wg.Add(1)
go square(&wg, ch) // 2. 고루틴 생성
ch <- 9 // 3. 채널에 데이터 넣음
wg.Wait() // 4. 작업이 완료되길 기다림
}
func square(wg *sync.WaitGroup, ch chan int) {
n := <-ch // 5. 데이터 빼옴
time.Sleep(time.Second)
fmt.Printf("Square: %d\n", n*n)
wg.Done()
}
25.1.4 채널 크기
일반적으로 채널을 생성하면 크기가 0 인 채널이 만들어집니다.
크기가 0 이라는 뜻은 채널에 들어온 데이터를 담아둘 곳이 없다는 얘기가 됩니다.
채널 크기가 0 이란 얘기는 택배 보관함이 없는 경우라고 보시면 됩니다.
즉 데이터를 넣을 때 보관할 곳이 없기 때문에 데이터를 빼갈 때까지 대기하게 됩니다.
- 채널에서 데이터를 가져가지 않아서 프로그램이 멈추는 경우
func main() {
ch := make(chan int)
ch <- 9
fmt.Println("Never print")
}
25.1.5 버퍼를 가진 채널
내부에 데이터를 보관할 수 있는 메모리 영역을 버퍼 (buffer) 라고 부릅니다.
그래서 보관함을 가지고 있는 채널을 버퍼를 가진 채널이라고 말합니다.
단순하게 make() 함수에서 뒤에 버퍼 크기를 적어주면 됩니다.
var chan string messages = make(chan string, 2)
25.1.6 채널에서 데이터 대기
- 데이터를 계속 기다리면서 데이터가 들어오면 작업을 수행
func square(wg *sync.WaitGroup, ch chan int) {
for n := range ch { // 2. 데이터를 계속 기다림
fmt.Printf("Square: %d\n", n*n)
time.Sleep(time.Second)
}
wg.Done() // 4. 실행되지 않음
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(1)
go square(&wg, ch)
for i := 0; i < 10; i++ {
ch <- i * 2 // 1. 데이터를 넣음
}
wg.Wait() // 3. 작업 완료를 기다림
}
for range 구문은 채널에 데이터가 들어오기를 계속 기다리기 때문에 절대 wg.Done() 가 실행되지 않고 모든 고루틴이 멈추게 된다.
가져갈 사람이 대기하고 있기 때문에 버퍼 사이즈가 0 이여도 괜찮은 것 같다.
이 문제를 해결하기 위해 채널을 다 사용하면 close(ch) 를 호출해 채널을 닫고 채널이 닫혔음을 알려줘야 한다.
func square(wg *sync.WaitGroup, ch chan int) {
for n := range ch { // 2. 데이터를 계속 기다림
fmt.Printf("Square: %d\n", n*n)
time.Sleep(time.Second)
}
wg.Done() // 4. 실행되지 않음
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(1)
go square(&wg, ch)
for i := 0; i < 10; i++ {
ch <- i * 2 // 1. 데이터를 넣음
}
close(ch)
wg.Wait() // 3. 작업 완료를 기다림
}
25.1.7 select 문
채널에서 데이터가 들어오기를 대기하는 상황에서 만약 데이터가 들어오지 않으면 다른 작업을 하거나, 아니면 여러 채널을 동시에 대기하고 싶을 때 어떻게 해야 할까요 ? 바로 select 문을 사용해서 대기하면 됩니다.
select {
case n := <- ch1:
...
case n2 := <- ch2:
...
case ...
}
select 문은 위와 같이 여러 채널을 동시에 기다릴 수 있습니다. 만약 어떤 채널이라도 하나의 채널에서 데이터를 읽어오면 해당 구문을 실행하고 select 문이 종료됩니다.
하나의 case 만 처리되면 종료되기 때문에 반복해서 데이터를 처리하고 싶다면 for 문과 함께 사용해야 합니다.
- select 를 사용해서 데이터를 읽고 처리하는 예제
func square(wg *sync.WaitGroup, ch chan int, quit chan bool) {
for {
select {
case n := <-ch:
fmt.Printf("Square: %d\n", n*n)
time.Sleep(time.Second)
case <-quit:
wg.Done()
return
}
}
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
quit := make(chan bool)
wg.Add(1)
go square(&wg, ch, quit)
for i := 0; i < 10; i++ {
ch <- i * 2
}
quit <- true
wg.Wait()
}
25.1.8 일정 간격으로 실행
메시지가 있으면 메시지를 빼와서 실행하고 그렇지 않다면 1초 간격으로 다른 일을 수행해야 한다고 가정해보겠습니다. 이런 경우 어떻게 만들 수 있을까요 ?
time 패키지의 Tick() 함수로 원하는 시간 간격으로 신호를 보내주는 채널을 만들 수 있습니다.
- 1초 간격으로 메시지를 출력하고 10초 이후 종료되는 프로그램
func square(wg *sync.WaitGroup, ch chan int) {
tick := time.Tick(time.Second) // 1초 간격 시그널
terminate := time.After(10 * time.Second) // 10초 이후 시그널
for {
select { // tick, terminate, ch 순서로 처리
case <-tick:
fmt.Println("Tick")
case <-terminate:
fmt.Println("Terminated!")
wg.Done()
return
case n := <-ch:
fmt.Printf("Square: %d\n", n*n)
time.Sleep(time.Second)
}
}
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(1)
go square(&wg, ch)
for i := 0; i < 10; i++ {
ch <- i * 2
}
wg.Wait()
}
time.Tick() 은 일정시간 간격 주기로 신호를 보내주는 채널을 생성해서 반환하는 함수입니다.
이 함수가 반환한 채널에서 데이터를 읽어오면 일정 시간 간격으로 현재 시각을 나타내는 Time 객체를 반환합니다.
time.After() 는 현재 시간 이후로 일정 시간 경과 후에 신호를 보내주는 채널을 생성해서 반환하는 함수입니다.
이 함수가 반환한 채널에서 데이터를 읽으면 일정 시간 경과 후에 현재 시각을 나타내는 Time 객체를 반환합니다.
select 문을 이용해서 tick, terminate, ch 숨서로 채널에서 데이터 읽기를 시도합니다.
tick 에서 메시지를 읽어오면Tick 을 출력하고 terminate 에서 읽어오면 함수를 종료합니다.
tick 과 terminate 에서 신호를 못 읽으면 ch 에서 읽어오게 됩니다.
func square(wg *sync.WaitGroup, ch chan int) {
tick := time.Tick(time.Second)
terminate := time.After(10 * time.Second)
for {
select {
case t := <-tick:
fmt.Println("Tick", t)
case end := <-terminate:
fmt.Println("Terminated!", end)
wg.Done()
return
case n := <-ch:
fmt.Printf("Square: %d\n", n*n)
time.Sleep(time.Second)
}
}
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(1)
go square(&wg, ch)
for i := 0; i < 10; i++ {
ch <- i * 2
}
wg.Wait()
}
25.1.9 채널로 생산자 소비자 패턴 구현하기
24장 고루틴에서 뮤텍스를 사용하지 않는 방법 중 첫 번째 방법인 영역을 나누는 방법을 알아봤고,
두 번째 방법인 채널을 이용해서 역할을 나누는 방법을 알아보겠습니다.
예를 들어 자동차 공장에서 자동차를 차체 생산 -> 바퀴 설치 -> 도색 -> 완성 단계를 거쳐 생산한다고 가정합시다.
각 공정에 1초가 걸린다고 보면 자동차 한 대를 만드는 데 3초가 걸릴 겁니다.
그런데 3명이 공정 하나씩 처리하면 첫 차 생산에만 3초가 걸리고 그 뒤론 1초마다 하나씩 생산할 수 있습니다.
이것을 컨베이어 벨트 시스템이라고 합니다.
- 채널을 사용해 컨베이어 벨트 구현
type Car struct {
Body string
Tire string
Color string
}
var wg sync.WaitGroup
var startTime = time.Now()
func main() {
tireCh := make(chan *Car)
paintCh := make(chan *Car)
fmt.Printf("Start Factory\n")
wg.Add(3)
go MakeBody(tireCh)
go InstallTire(tireCh, paintCh)
go PaintCar(paintCh)
wg.Wait()
fmt.Println("Close the factory")
}
func MakeBody(tireCh chan *Car) {
tick := time.Tick(time.Second)
after := time.After(10 * time.Second)
for {
select {
case <-tick:
// Make a body
car := &Car{}
car.Body = "Sports car"
tireCh <- car
case <-after:
close(tireCh)
wg.Done()
return
}
}
}
func InstallTire(tireCh, paintCh chan *Car) {
for car := range tireCh {
// Make a body
time.Sleep(time.Second)
car.Tire = "Winter tire"
paintCh <- car
}
wg.Done()
close(paintCh)
}
func PaintCar(paintCh chan *Car) {
for car := range paintCh {
// Make a body
time.Sleep(time.Second)
car.Color = "Red"
duration := time.Now().Sub(startTime) // 경과시간 출력
fmt.Printf("%.2f Complete Car: %s %s %s\n", duration.Seconds(),
car.Body, car.Tire, car.Color)
}
wg.Done()
}
이와 같이 한쪽에서 데이터를 생성해서 넣어주면 다른 쪽에서 생성된 데이터를 빼서 사용하는 방식을 생산자 소비자 패턴 (Producer Consumer Pattern) 이라고 합니다.
이번 예제에서는 MakeBody() 루틴이 생산자, InstallTire() 루틴은 소비자입니다. 또 InstallTire() 는 PaintCar() 루틴에 대해서는 생산자가 되는 구조입니다.
25.2 컨텍스트 사용하기
컨텍스트는 context 패키지에서 제공하는 기능으로 작업을 지시할 때 작업 가능 시간, 작업 취소 등의 조건을 지시할 수 있는 작업 명세서 역할을 합니다.
ㅐ로운 고루틴으로 작업을 시작할 때 일정시간동안만 작업을 지시하거나 외부에서 작업을 취소할 때 사용합니다. 또한 작업 설정에 관한 데이터를 전달할 수 도 있습니다.
25.2.1 작업 취소가 가능한 컨텍스트
작업 취소 기능을 가진 컨텍스트 입니다. 이 컨텍스트를 만들어서 작업자에게 전달하면 작업을 지시한 지시자가 원할 때 작업 취소를 알릴 수 있습니다.
작업이 취소될 때 까지 1초마다 메시지를 출력하는 고루틴을 만들어봅니다.
- 작업이 취소될 때 까지 1초마다 메시지를 출력
var wg sync.WaitGroup
func main() {
wg.Add(1)
ctx, cancel := context.WithCancel(context.Background()) // 1. 컨텍스트 생성
go PrintEverySecond(ctx)
time.Sleep(5 * time.Second)
cancel() // 3. 취소
wg.Wait()
}
func PrintEverySecond(ctx context.Context) {
tick := time.Tick(time.Second)
for {
select {
case <-ctx.Done():
wg.Done()
return
case <-tick:
fmt.Println("Tick")
}
}
}
context.WithCancel() 함수로 취소 가능한 컨텍스트를 생성했습니다.
상위 컨텍스트를 인수로 넣으면 그 컨텍스트를 감싼 새로운 컨텍스트를 만들어줍니다.
상위 컨텍스트가 없다면 가장 기본적인 컨텍스트인 context.Background() 를 넣어줍니다.
context.WithCancel() 함수는 값을 두 개를 반환하는데 첫 번째가 컨텍스트 객체이고 두 번째가 취소 함수입니다.
두 번째 취소 함수를 사용해서 원할 때 취소할 수 있습니다.
cancel() 함수를 호출 해 작업 취소를 알립니다.
그러면 컨텍스트의 Done() 채널에 시그널을 보내 작업자가 취소를 할 수 있도록 알립니다.
25.2.2 작업 시간을 설정한 컨텍스트
이번에는 일정한 시간 동안만 작업을 지시할 수 있는 컨텍스트를 만들어보겠습니다.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
두 번째 인수로 시간을 설정하면 그 시간이 지난 뒤 컨텍스트의 Done() 채널에 시그널을 보내서 작업 종료를 요청합니다.
WithTimeout() 함수 역시 두 번째 반환값으로 cancel 함수를 반환하기 때문에 작업 시작 전에 원하면 언제든지 작업 취소를 할 수도 있습니다.
25.2.3 특정 값을 설정한 컨텍스트
때론 작업자에게 작업을 지시할 때 별도 지시사항을 추가하고 싶을 수 있습니다. 그래서 컨텍스트에 특정 키로 값을 읽어올 수 있도록 설정할 수 있습니다. 바로 context.WithValue() 함수를 이용해서 컨텍스트에 값을 설정할 수 있습니다.
- 컨텍스트에 값 설정하기
var wg sync.WaitGroup
func main() {
wg.Add(1)
ctx := context.WithValue(context.Background(), "number", 9)
go square(ctx)
wg.Wait()
}
func square(ctx context.Context) {
if v := ctx.Value("number"); v != nil {
n := v.(int)
fmt.Printf("Square:%d", n*n)
}
wg.Done()
}
func PrintEverySecond(ctx context.Context) {
tick := time.Tick(time.Second)
for {
select {
case <-ctx.Done():
wg.Done()
return
case <-tick:
fmt.Println("Tick")
}
}
}
Value() 메서드의 반환타입은 빈 인터페이스입니다.
위 예제는 쉽게 설명하기 위해서 단순한 함수 호출로 만들었지만 컨텍스트에 값을 설정해서 다른 고루틴으로 작업을 지시할 때 외부 지시사항으로 설정할 수 있습니다.
이때 지시자와 작업자 사이에 어떤 키로 어떤 값이 들어올지에 대한 약속이 필요합니다.
그럼 취소도 되면서 값도 설정하는 컨텍스트는 어떻게 만들까요 ?
컨텍스트를 만들 때 항상 상위 컨텍스트 객체를 인수로 넣어줘야 했습니다. 일반적으로 context.Background() 를 넣어줬는데 여기에 이미 만들어진 컨텍스트 객체를 넣어줘도 됩니다. 이를 통해서 여러 값을 설정하거나 기능을 설정할 수 있습니다.
ctx, cancel := context.WithCancel(context.Background())
ctx = context.WithValue(ctx, "number", 9)
ctx = context.WithValue(ctx, "keyword", "Lilly")
- 먼저 취소 기능이 있는 컨텍스트를 만듭니다.
- 이것을 다시 감싸서 값을 값을 설정한 컨텍스트를 만듭니다.
- 컨텍스트를 여러 번 감싸서 여러 값을 설정할 수 있습니다.
Go 언어에서는 채널과 컨텍스트를 통해 동시성 프로그래밍에서 다양하게 활용하고 있습니다.
구글에 "golang concurrency patterns" 을 검색해보면 더 많은 활용법을 찾을 수 있습니다.
golang concurrency patterns: https://go.dev/blog/pipelines
26장. 단어 검색 프로그램 만들기
26.1 해법
사용자로부터 파일 경로와 특정 단어를 입력받아서 파일에서 해당 단어를 검색하는 프로그램입니다.
단어가 발견된 라인과 해당 라인 내용을 출력하고 종료하면 됩니다.
26.2 사전지식
파일 검색 프로그램을 구현하려면 와일드카드, 실행인수, 파일핸들을 알아야 합니다.
26.2.1 와일드카드
* 는 0 개 이상의 아무 문자를 나타내고 ? 는 문자 하나를 나타냅니다.
와일드카드는 파일 경로 뿐 아니라 다양한 곳에서 사용되기 때문에 알아두면 유용합니다.
26.2.2 os.Args 변수와 실행인수
보통 터미널 명령을 실행할 때 실행 인수를 넣어서 명령의 행동을 조정합니다. 예를 들어 아래와 같이 파일을 지우는 명령인 del 을 실행할 때 del 은 실행 명령이 되고 filename 은 실행 인수가 됩니다.
del filename
Go 언어에서는 os 패키지의 Args 변수를 이용해 실행 인수를 가져올 수 있습니다.
Args 는 os 패키지의 전역변수로 각 실행인수가 []string 슬라이스에 담겨 있습니다.
var Args []string
os.Args 의 첫 번째 항목으로는 실행 명령이 들어갑니다. 두 번째 항목부터 우리가 입력한 인수가 차례대로 들어갑니다.
예를 들어 우리가 만들 프로그램은 두 실행인수를 받습니다. 첫 번째는 찾을 단어, 두 번째는 파일 경로입니다.
find word filepath
[실행명령] [찾을단어] [파일경로]
총 반환되는 인수는 3개로 os.Args[0] 에는 find, os.Args[1] 에는 찾을 단어, os.Args[2] 에는 파일 경로가 저장됩니다.
이렇게 실행 인수를 가져온 다음 파일 경로에 해당하는 파일 목록을 가져와야 합니다. 파일 경로는 하나의 파일을 지정할 수도 있지만 ? 혹은 * 같은 와일드카드를 사용해서 여러 파일을 지정할 수도 있습니다.
26.2.3 파일 핸들링
파일열기
파일을 열려면 os 패키지의 Open() 함수를 이용해서 파일을 열어 파일 핸들을 가져와야 합니다. name 에 해당하는 파일을 읽기 전용으로 열고 *File 타입인 파일 핸들 객체를 반환합니다.
func Open(name string) (*File, error)
*File 타입은 io.Reader 인터페이스를 구현하고 있기 때문에 다음에 설명할 bufio 패키지의 NewScanner() 함수를 통해 스캐너 객체를 만들어서 사용할 수 있습니다.
파일 목록 가져오기
path/filepath 패키지의 Glob() 함수를 이용해서 파일 경로에 해당하는 파일 목록을 가져올 수 있습니다.
이 함수의 선언은 다음과 같습니다.
filespaths, err := filepath.Glob("*.txt")
위 구문은 현재 실행폴더 내 확장자가 txt 인 모든 파일 리스트를 반환합니다.
파일 내용 한 줄씩 읽기
파일을 한 줄씩 읽는 데 bufio 패키지의 NewScanner() 함수를 이용합니다.
함수 선언은 다음과 같습니다.
func NewScanner(r io.Reader) *Scanner
io.Reader 인터페이스를 구현한 모든 인스턴스를 인수로 사용 가능합니다. *File 객체 또한 io.Reader 인터페이스를 구현하고 있어서 앞서 구한 파일 핸들을 사용해서 스캐너를 만들 수 있습니다.
스캐너를 이용하면 파일을 한 줄씩 손쉽게 읽어올 수 있습니다.
type Scanner
func (s *Scanner) Scan() bool
func (s *Scanner) Text() string
Scan() 메서드는 다음 줄을 읽어오고 Text() 는 읽어온 한 줄을 문자열로 반환합니다.
이 두 메서드를 이용해서 파일을 한 줄씩 읽어서 해당 줄에 우리가 찾으려는 문자가 있는지 확인합니다.
26.2.4 단어 포함 여부 검사
앞 서 읽어온 한 줄 내용 중에 우리가 찾으려는 단어가 있는지 검사하는 데 strings 패키지의 Contains() 함수를 이용하겠습니다.
func Contains(s, substr string) bool
strings.Contains() 는 첫 번째 인수가 s 안에 두 번째 인수인 substr 이 포함되어 있는지 여부를 반환하는 함수입니다.
26.3 실행 인수 읽고 파일 목록 가져오기
func main() {
if len(os.Args) < 3 { // 1. 실행 인수 개수 확인
fmt.Println("2개 이상의 실행 인수가 필요합니다. ex) ex26.1 word filepath")
return
}
word := os.Args[1] // 2. 실행 인수 가져오기
files := os.Args[2:]
fmt.Println("찾으려는 단어:", word)
PrintAllFiles(files)
}
func GetFileList(path string) ([]string, error) {
return filepath.Glob(path)
}
func PrintAllFiles(files []string) {
for _, path := range files {
filelist, err := GetFileList(path) // 3. 파일 목록 가져오기
if err != nil {
fmt.Println("파일 경로가 잘못되었습니다. err:", err, "path:", path)
return
}
fmt.Println("찾으려는 파일 리스트")
for _, name := range filelist {
fmt.Println(name)
}
}
}
26.5 파일 검색 프로그램 완성하기
// 찾은 라인정보
type LineInfo struct {
lineNo int
line string
}
// 파일 내 라인정보
type FindInfo struct {
filename string
lines []LineInfo
}
func main() {
if len(os.Args) < 3 {
fmt.Println("2개 이상의 실행 인수가 필요합니다. ex) ex26.3 word filepath")
return
}
word := os.Args[1]
files := os.Args[2:]
findInfos := []FindInfo{}
for _, path := range files {
findInfos = append(findInfos, FindWordInAllFiles(word, path)...)
}
for _, findInfo := range findInfos {
fmt.Println(findInfo.filename)
println("------------------------")
for _, lineInfo := range findInfo.lines {
println("\t", lineInfo.lineNo, "\t", lineInfo.line)
}
fmt.Println(findInfo.filename)
fmt.Println()
}
}
func FindWordInAllFiles(word string, path string) []FindInfo {
findInfos := []FindInfo{}
filelist, err := GetFileList(path)
if err != nil {
fmt.Println("파일 경로가 잘못되었습니다. err:", err, "path:", path)
return findInfos
}
for _, filename := range filelist {
findInfos = append(findInfos, FindWordInFile(word, filename))
}
return findInfos
}
func GetFileList(path string) ([]string, error) {
return filepath.Glob(path)
}
func FindWordInFile(word, filename string) FindInfo {
findInfo := FindInfo{filename, []LineInfo{}}
file, err := os.Open(filename)
if err != nil {
fmt.Println("파일을 찾을 수 없습니다. ", filename)
return findInfo
}
defer file.Close()
lineNo := 1
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, word) {
findInfo.lines = append(findInfo.lines, LineInfo{lineNo, line})
}
lineNo++
}
return findInfo
}
26.6 개선하기
앞의 구현을 개선해보겠습니다. ex26.2 구현은 모든 파일 검색을 하나의 main() 고루틴에서 실행합니다.
고루틴을 사용해 파일 개수가 늘어도 빠르게 검색되도록 개선해보겠습니다.
각 파일별로 작업을 할당하고 작업이 완료되면 채널을 이용해서 결과를 수집하는 방식을 사용하겠습니다.
이 방식을 Scatter-Gather 방식이라고 합니다.
// 찾은 라인정보
type LineInfo struct {
lineNo int
line string
}
// 파일 내 라인정보
type FindInfo struct {
filename string
lines []LineInfo
}
func main() {
if len(os.Args) < 3 {
fmt.Println("2개 이상의 실행 인수가 필요합니다. ex) ex26.3 word filepath")
return
}
word := os.Args[1]
files := os.Args[2:]
findInfos := []FindInfo{}
for _, path := range files {
findInfos = append(findInfos, FindWordInAllFiles(word, path)...)
}
for _, findInfo := range findInfos {
fmt.Println(findInfo.filename)
println("------------------------")
for _, lineInfo := range findInfo.lines {
println("\t", lineInfo.lineNo, "\t", lineInfo.line)
}
fmt.Println()
}
}
func FindWordInAllFiles(word, path string) []FindInfo {
findInfos := []FindInfo{}
filelist, err := GetFileList(path)
if err != nil {
fmt.Println("파일 경로가 잘못되었습니다. err:", err, "path:", path)
return findInfos
}
ch := make(chan FindInfo)
cnt := len(filelist)
recvCnt := 0
for _, filename := range filelist {
go FindWordInFile(word, filename, ch) // 1. 고루틴 실행
}
for findInfo := range ch {
findInfos = append(findInfos, findInfo) // 3. 결과 수집
recvCnt++
if recvCnt == cnt {
// all received
break
}
}
return findInfos
}
func GetFileList(path string) ([]string, error) {
return filepath.Glob(path) // path 에 해당하는 파일리스트를 가져온다.
}
func FindWordInFile(word, filename string, ch chan FindInfo) {
findInfo := FindInfo{filename, []LineInfo{}}
file, err := os.Open(filename)
if err != nil {
fmt.Println("파일을 찾을 수 없습니다. ", filename)
ch <- findInfo
}
defer file.Close()
lineNo := 1
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, word) {
findInfo.lines = append(findInfo.lines, LineInfo{lineNo, line})
}
lineNo++
}
ch <- findInfo
}
- FindInfo 채널을 생성하고 파일 갯수를 셉니다.
- 파일에서 단어를 찾는 함수에 채널을 파라미터로 넣어 고루틴으로 실행합니다.
- return 대신 결과를 채널에 넣습니다.
- 채널의 for-each 문을 실행합니다.
3 단계. Go 프로그래밍에 유용한 기법 익히기
27장. 객체지향 설계 원칙 SOLID
27.1 객체지향 설계 5가지 원칙 SOLID
SOLID 란 객체지향 설계 5가지 원칙의 영문명 앞글자를 따서 만든 용어입니다.
- 단일 책임 원칙 (single responsibility principle)
- 개방-폐쇄 원칙 (open-closed principle)
- 리스코프 치환 원칙 (liskov substitution principle)
- 인터페이스 분리 원칙 (interface segregation principle)
- 의존 관계 역전 원칙 (dependency inversion principle)
의무사항은 아니지만 이 원칙들에 입각해서 설계를 하면 더 좋은 설계를 할 수 있습니다.
나쁜 설계
좋은설계를 하려면 먼저 나쁜 설계를 알아야 합니다.
- 경직성 (rigidity)
모듈 간의 결합도 (coupling) 가 너무 높아서 코드를 변경하기 매우 어려운 구조를 말합니다.
- 부서지기 쉬움 (fragility)
한 부분을 건드렸더니 다른 부분까지 망가지는 경우입니다. 언제 어떤 부분이 망가질지 모르기 때문에 프로그램을 변경하기가 매우 힘듭니다.
- 부동성 (immobility)
코드 일부분을 현재 어플리케이션에서 분리해서 다른 프로젝트에도 쓰고 싶지만 모듈 간 결합도가 너무 높아서 옮길 수 없는 경우입니다.
그렇게 되면 코드 재사용률이 급격히 감소하므로 (같거나) 비슷한 기능을 매번 새로 구현해야 합니다.
상호 결합도가 높으면 경직성이 증가하고 그로 인해 한 모듈의 수정이 다른 모듈로 전파되어 예기치 못한 문제가 생기고 코드 재사용성을 낮추게 됩니다.
좋은 설계
상호 결합도가 낮기 때문에 모듈을 쉽게 떼어내서 다른 곳에 사용할 수 있고 모듈 간 독립성이 있기 때문에 한 부분을 변경하더라도 다른 모듈에 문제를 발생시키지 않습니다.
27.2 단일 책임 원칙
필자는 5가지 원칙 중에서 제일 중요하다고 생각합니다.
정의
- 모든 객체는 책임을 하나만 져야한다.
이점
- 코드 재사용성을 높여줍니다.
type FinanceReport struct { // 회계 보고서
report string
}
func (r *FinanceReport) SendReport(email string) { // 보고서 전송
}
문제없어 보이는 코드지만 단일 책임 원칙을 위배했습니다.
회계 보고서라는 책임만 져야 하지만 이 코드는 보고서를 전송하는 책임까지 지고 있습니다.
MarketingReport 가 생긴다면 SendReport 메서드를 사용할 수 없습니다.
FinanceReport 는 Report 인터페이스를 구현하고, ReportSender 는 Report 인터페이스를 이용하는 관계를 형성하면 됩니다.
FinanceReport 는 경제 보고서만 책임지고, ReportSender 는 보고서 전송이라는 책임 하나만 지고 있습니다.
type Report interface {
Report() string
}
type FinanceReport struct { // 회계 보고서
report string
}
func (r *FinanceReport) Report() string {
return r.report
}
type ReportSender struct {
// ...
}
func (s *ReportSender) SendReport(report Report) { // 보고서 전송
// Report 인터페이스 객체를 인수로 받음
// ...
print(report.Report())
}
func main() {
f := FinanceReport{"재무 보고서 내용"}
r := ReportSender{}
r.SendReport(&f)
}
27.3 개방-폐쇄 원칙
정의
- 확장에는 열려 있고, 변경에는 닫혀있다.
이점
- 상호 결합도를 줄여 새 기능을 추가할 때 기존 구현을 변경하지 않아도 됩니다.
프로그램에 기능을 추가할 때 기존 코드의 변경을 최소화해야 한다.
정도로 이해하고 원칙이 지켜지지 않는 코드를 살펴보며 자세히 알아봅시다.
func SendReport(r *Report, method SendType, receiver string) {
switch method {
case Email:
// 이메일 전송
case Fax:
// 팩스 전송
case PDF:
// pdf 파일 생성
case Printer:
// 프린팅
// ...
}
}
전송방식을 추가하려면 새로운 case 를 만들어 구현을 추가해주면 됩니다.
즉 , 기존 SendReport() 함수 구현을 변경하게 되는거죠. 따라서 개방-폐쇄 원칙에 위배됩니다.
이 SendType 에 따른 switch 문이 한 곳만 있다면 그나마 다행이지만 코드 여러 곳에 퍼져있다면 변경 범위가 늘어나게 되고 그만큼 버그를 발생시킬 위험성도 커집니다.
개방-폐쇄 원칙에 입각해서 코드를 수정해보겠습니다.
type ReportSender interface {
Send(r *Report)
}
type EmailSender struct {
}
func (e *EmailSender) Send(r *Report) {
// 이메일 전송
}
type FaxSender struct {
}
func (f *FaxSender) Send(r *Report) {
// 팩스 전송
}
EmailSender 와 FaxSender 는 모두 ReportSender 라는 인터페이스를 구현한 객체입니다.
여기에 새로운 전송방식을 추가하면 어떻게 될까요 ?
ReportSender 를 구현한 새로운 객체를 추가해주면 됩니다.
새 기능을 추가했지만, 기존 구현을 변경하지 않아도 되는거죠.
27.4 리스코프 치환 원칙
원칙들 중 가장 이해하기 어렵다는 리스코프 치환 원칙을 알아보겠습니다.
정의
- q(x) 를 타입 T 의 객체 x 에 대해 증명할 수 있는 속성이라 하자. 그렇다면 S 가 T 의 하위 타입이라면 q(x) 는 타입 S 의 객체 y 에 대해 증명할 수 있어야한다.
이점
- 예상치 못한 작동을 예방할 수 있습니다.
코드로 살펴보겠습니다.
type T interface {
Something()
}
type S struct {
}
func (s *S) Something() { // T 인터페이스 구현
}
type U struct {
}
func (u *U) Something() {
}
func q(t T) {
// ...
}
var y = &S{} // S 타입 y
var u = &U{} // U 타입 u
q(y)
q(u) // 둘 다 잘 동작해야 한다.
리스코프 치환원칙에 입각한 코드는 함수 계약관계를 준수하는 코드를 말합니다.
사실 리스코프 치환 원칙은 Go 언어보다는 상속을 지원하는 다른 언어에서 더 큰 문제를 발생시킵니다.
Go 언어가 상속을 지원하지 않는 이유 역시 상속을 잘못 사용해 리스코프 치환 원칙을 위반하는 일을 예방하려는 의도라고 생각합니다.
27.5 인터페이스 분리 원칙
정의
- 클라이언트는 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.
이점
- 인터페이스를 분리하면 불필요한 메서드들과 의존관계가 끊어져 더 가볍게 인터페이스를 이용할 수 있습니다.
- 원칙을 위반한 코드
type Report interface {
Report() string
Pages() int
Author() string
WrittenDate() time.Time
}
func SendReport(r Report) {
send(r.Report())
}
Report 인터페이스는 메서드를 총 4개 포함합니다.
하지만 SendReport() 는 Report 인터페이스가 포함한 4개 메서드 중에 Report() 메서드만 사용합니다.
그래서 인터페이스 분리 원칙을 위반한 코드입니다.
- 원칙을 지킨 코드
type Report interface {
Report() string
}
type WrittenInfo interface {
Pages() int
Author() string
WrittenDate() time.Time
}
func SendReport(r Report) {
send(r.Report())
}
27.6 의존 관계 역전 원칙
SOLID 에서 가장 중요한 원칙으로 단일책임 원칙과 함께 의존관계역젼 원칙을 꼽을 수 있습니다.
정의
- 상위 계층이 하위 계층에 의존하는 전통적인 의존관계를 반전시킴으로써 상위계층이 하위 계층의 구현으로부터 독립되게 할 수 있다.
원칙
- 상위 모듈은 하위 모듈에 의존해서는 안된다. 둘 다 추상 모듈에 의존해야 한다.
- 추상모듈은 구체화된 모듈에 의존해서는 안된다. 구체화된 모듈은 추상모듈에 의존해야 한다.
27.6.1 원칙 1 뜯어보기
입력 <- 전송 -> 출력
키보드 네트워크
키보드, 네트워크, 전송 모두 추상모듈에 의존하고 있는 관계가 됩니다.
각 의존관계를 떨어뜨리면 각 모듈은 본연의 기능에 충실할 수 있습니다.
전송은 입력을 출력으로 연결시키는 본연의 기능에, 키보드는 입력이라는 기능에, 네트워크는 출력이라는 기능에 충실해집니다.
또 서로 결합도가 낮아짐으로써 독립적이 됩니다.
즉, 키보드가 아니라 터치나 음성, 제스처 등 다른 입력 장치를 사용하더라도 입력 추상 모듈을 구현한다면 전송 모듈을 사용할 수 있습니다.
또한, 프린터 파일, 소리 등 출력 추상모듈을 구현한 다른 모듈로도 출력할 수 있습니다.
서로 독립성이 유지되기 때문에 전송 모듈을 쉽게 분리해 다른 애플리케이션에도 사용할 수 있습니다.
즉, 코드 재사용성이 높아집니다.
27.6.2 원칙 2 뜯어보기
구체화된 모듈간의 의존관계를 끊고 추상모듈을 통해서 의존해야 한다는 원칙을 살펴봅시다.
예를 들어 메일이 수신되면 알람이 울린다고 가정하겠습니다. 메일이라는 모듈과 알람이라는 모듈이 서로 관계 맺고 있는 코드를 살펴보겠습니다.
type Mail struct {
alarm Alarm
}
type Alarm struct {
}
func (m *Mail) OnRecv() { // OnRecv() 메서드는 메일 수신 시 호출됩니다.
m.alarm.Alarm() // 알람을 울립니다.
}
메일객체는 알람객체를 소유하고 있고, 메일 수신 시 호출되는 OnRecv() 메서드에서 소유한 알람객체를 사용해 알람을 울립니다.
Event <- EventListener
메일 알람
메일은 Event 인터페이스를 구현하고 알람은 EventListener 라는 인터페이스를 구현하고 있습니다.
type Event interface {
Register(EventListener)
}
type EventListener interface {
OnFire()
}
type Mail struct {
listener EventListener
}
func (m *Mail) Register(listener EventListener) {
m.listener = listener
}
func (m *Mail) OnRecv() {
m.listener.OnFire()
}
type Alarm struct {
}
func (a *Alarm) OnFire() {
// 알람
fmt.Println("알람! 알람!")
}
var mail = &Mail{}
var listener EventListener = &Alarm{}
func main() {
mail.Register(listener)
mail.OnRecv() // 4. 알람이 울리게 됩니다.
}
Event 인터페이스는 Register() 메서드를 가지고 있고, Mail 객체는 이를 구현하여 Register() 메서드가 호출되면 EventListener 를 등록합니다.
그래서 OnRecv() 메서드가 호출되면 등록된 EventListener 를 등록합니다.
OnRecv() 메서드가 호출되면 EventListener 객체의 OnFire() 메서드를 호출해줍니다.
Alarm 객체는 EventListner 인터페이스를 구현하여 OnFire() 메서드가 호출될 때 알람이 울리도록 구현합니다.
그래서 mail 인스턴스에 Alarm 인스턴스를 등록하면 메일 수신 시 알람이 울리게 됩니다.
28장. 테스트와 벤치마크
28.1 테스트 코드
Go 언어는 테스트 코드 작성과 실행을 언어 자체에서 지원합니다.
빠르고 손쉽게 테스트 코드를 작성할 수 있어 버그를 사전에 막는 데 효과적입니다.
3가지 표현 규약을 따라 테스트 코드를 작성해야 하며, go test 명령으로 실행합니다.
작성 규약은 다음과 같습니다.
- 파일명이 _test.go 로 끝나야 합니다.
- testing 패키지를 임포트해야 합니다.
- 테스트 코드는 func TextXxxx(t *testing.T) 형태이어야 합니다.
각 규약을 다음과 같이 해석할 수 있습니다.
- 테스트 코드는 파일명이 _test.go 로 끝나는 파일 안에 존재해야 합니다.
- 테스트 코드를 작성하려면 import "testing" 으로 testing 패키지를 가져와야 합니다.
- 테스트 코드들은 모두 함수로 묶여 있어야 하고 함수명은 반드시 Test 로 시작해야 합니다.
- Test 다음에 나오는 첫 글자는 대문자여야 합니다. 또한 함수 매개변수는 t *testing.T 하나만 존재해야 합니다.
28.1.1 테스트 코드 작성하기
func square(x int) int {
return 81
}
func main() {
fmt.Printf("9 * 9 = %d\n", square(9))
}
func square(x int) int {
return 81
}
func main() {
fmt.Printf("9 * 9 = %d\n", square(9))
}
func TestSquare1(t *testing.T) {
rst := square(9)
if rst != 81 {
t.Errorf("square(9) should be 81 but square(9) returns %d", rst)
}
}
testing.T 객체의 Error() 와 Fail() 메서드를 이용해서 테스트 실패를 알릴 수 있습니다.
Error() 는 테스트가 실패하면 모든 테스트를 중단하지만, Fail() 은 테스트가 실패해도 다른 테스트들을 계속 진행합니다.
테스트 코드를 작성하고 작성한 폴더의 터미널 창에서 go test 를 실행합니다.
- 에러
폴더명과 go 파일명이 main 이라서 그렇다. 다르게 바꿔보자.
테스트가 성공했다.
- ex28.1.go
func square(x int) int {
return x * x
}
func main() {
fmt.Printf("9 * 9 = %d\n", square(9))
}
- ex28.1_test.go
func TestSquare1(t *testing.T) {
rst := square(9)
if rst != 81 {
t.Errorf("square(9) should be 81 but square(9) returns %d", rst)
}
}
func TestSquare2(t *testing.T) {
rst := square(3)
if rst != 9 {
t.Errorf("square(3) should be 9 but square(3) returns %d", rst)
}
}
28.1.2 일부 테스트만 실행하기
go test 를 하면 전체 테스트를 모두 실행합니다.
-run 플래그를 사용해서 특정 테스트만 실행할 수 있습니다.
go test -run [테스트명]
예를 들어 go test -run Square2 를 입력하면 Square2 테스트만 실행합니다.
또 go test -run Square 라고 입력하면 Square 로 시작하는 모든 테스트를 실행합니다.
28.1.3 테스트를 돕는 외부 패키지
테스트 코드 작성을 돕는 외부 패키지인 "stretchr/testify" 를 사용해보겠습니다.
이 패키지는 테스트하고 테스트 실패를 알릴 수 있는 다양한 함수를 제공합니다.
무엇보다 코드가 간략해서 인기가 높습니다.
go get 명령으로 패키지를 설치합니다.
go get github.com/stretchr/testify
ex28_1_test.go 코드를 고쳐보겠습니다.
func TestSquare1(t *testing.T) {
assert2 := assert2.New(t) // 테스트 객체 생성
assert2.Equal(81, square(9), "square(9) should be 81") // 테스트 함수 호출
}
func TestSquare2(t *testing.T) {
assert2 := assert2.New(t)
assert2.Equal(9, square(3), "square(3) should be 9")
}
assert 객체는 테스트 코드를 쉽게 만들 수 있는 다양한 메서드를 포함합니다.
assert 객체에서 제공하는 Equal() 메서드를 사용해 함수 출력을 비교합니다.
Equal() 메서드 외에 NotEqual(), Nil(), NotNil() 등 많은 메서드를 합니다. 테스트를 도와주는 다양한 기능들을 제공하므로 유용하게 이용하시기 바랍니다.
stretchr/testify/assert
이 패키지에서 제공하는 Equal(), Greater(), Len(), NotNilf(), NotEqualf() 메서드는 테스트에 유용한 기능을 제공합니다.
각 기능과 정의를 간략히 정리해뒀습니다.
- Equal(expected, actual interface{}, msgAndArgs ...interface{}) bool
- Greater(e1, e2 interface{}, msgAndArgs ...interface{}) bool - e1 이 e2 보다 커야합니다.
- Len(object interface{}, length int, msgAndArgs ...interface{}) bool - object 항목 갯수가 len 개 이여야 합니다.
- NotNilf(object, msg string, args ...interface{}) bool - object 가 not nil 이여야 합니다.
- NotEqualf() expected 와 actual 이 달라야 합니다.
stretchr/testify/assert 패키지에서 제공하는 그 외 유용한 기능
이 패키지에서는 mock 과 suite 패키지를 제공합니다.
mock 패키지는 테스트용 목업을 만드는 데 사용합니다.
suite 패키지는 테스트 시작과 종료 작업을 도와줍니다.
- mock 패키지: 모듈의 행동을 가장하는 목업 (mockup) 객체를 제공합니다. 예를 들어 온라인 기능을 테스트 할 때 하위 영역인 네트워크 기능까지 모두 테스트하기는 힘듭니다. 내트워크 객체를 가장하는 목업 객체를 만들 때 유용합니다.
- suit 패키지: 테스트 준비 작업이나 테스트 종료 후 뒤처리 작업을 쉽게 할 수 있도록 도와주는 패키지입니다. 예를 들어 테스트에 특정 파일이 있어야 한다면 테스트 시작 전 임시 파일을 생성하고 테스트 종료 후 생성한 임시 파일을 삭제해주는 작업을 만들 때 유용합니다.
28.2 테스트 주도 개발
테스트의 중요성은 과거에 비해서 점차 커지고 있습니다.
크게 두가지 이유로 꼽고 싶습니다.
첫 번째는 과거에 비해서 프로그램 규모가 커졌습니다.
요즘은 수십 명이서 한 프로젝트에서 협업합니다.
두 번째로 과거에 비해 고가용성 (high availability) 에 대한 요구사항이 높아졌습니다.
가용성이란 프로그램이나 웹 서비스가 얼마나 오랫동안 정상 동작하는가를 의미합니다.
대부분 유명한 웹 서비스는 주 7일 24시간 서비스를 기본으로 제공합니다.
테스트는 크게 블랙박스 테스트와 화이트박스 테스트로 구분할 수 있습니다.
블랙박스 테스트
제품 내부를 오픈하지 않은 상태에서 진행하는 테스트를 말합니다.
사용자 입장에서 테스트한다고 해서 사용성 테스트 (usability test) 라고 하기도 합니다.
프로그래머보다는 전문 테스터, QV, QA 직군에서 주로 담당합니다.
화이트박스 테스트
프로그램 내부 코드를 직접 검증하는 방식입니다. 유닛 테스트 (unit test, 단위 테스트) 라고 부릅니다.
이 테스트는 프로그래머가 직접 코드를 작성해서 내부 테스트를 검사하는 방식입니다.
코드 작성 이후에 테스트 코드를 작성하다 보면 메인 시나리오에 의존해 테스트하고 예외 상황이나 경계 체크 (boundary check) 가 무시되기 쉽습니다.
두 번째로 테스트 통과를 목적으로 하는 형식적인 테스트 코드를 작성하기 십상입니다.
테스트 주도 개발
Test Driven Development (TDD) 은 이런 문제를 해결하는 대안입니다.
테스트 주도 개발은 테스트 코드 작성 시기를 과감하게 코드 작성 이전으로 옮긴 방식입니다.
- 테스트코드를 작성
- 코드 작성해서 테스트 성공시키기
- SOLID 원칙에 입각해 코드 개선 (리팩터링 refactoring)
- 새로운 코드 작성해 다시 테스트 실패를 만듬
허술한 테스트 코드 없이 다양한 경우에 대해서 검증할 수 있고, 작은 목표설정 -> 실패 -> 달성 -> 달성강화 -> 새로운 작은 목표 설정 절차를 따르기 때문에 개발 자체가 재밌어집니다.
테스트는 많을수록 촘촘할수록 좋습니다.
28.3 벤치마크
Go 언어는 테스트 외 코드 성능을 검사하는 벤치마크 기능도 지원합니다.
마찬가지로 testing 패키지를 통해 제공되고 다음과 같은 표현 규약을 가지고 있습니다.
- 파일명이 _test.go 로 끝나야 한다.
- testing 패키지를 임포트해야 한다.
- 벤치마크 코드는 func BenchmarkXxxx(b *testing.B) 형태이어야 한다.
- 피보나치 수열 값을 구하는 두 가지 방식 (재귀 호출, 반복문) 의 함수
func fibonacci1(n int) int {
if n < 0 {
return 0
}
if n < 2 {
return n
}
return fibonacci1(n-1) + fibonacci1(n-2) // 재귀 호출
}
func fibonacci2(n int) int {
if n < 0 {
return 0
}
if n < 2 {
return n
}
one := 1
two := 0
rst := 0
for i := 2; i <= n; i++ { // 반복문
rst = one + two
two = one
one = rst
}
return rst
}
func main() {
fmt.Println(fibonacci1(13))
fmt.Println(fibonacci2(13))
}
- 테스트 코드
func TestFibonacci1(t *testing.T) {
assert := assert2.New(t)
assert.Equal(0, fibonacci1(-1), "fibonacci1(-1) should be 0")
assert.Equal(0, fibonacci1(0), "fibonacci1(-1) should be 0")
assert.Equal(1, fibonacci1(1), "fibonacci1(-1) should be 1")
assert.Equal(2, fibonacci1(3), "fibonacci1(-1) should be 2")
assert.Equal(233, fibonacci1(13), "fibonacci1(-1) should be 233")
}
func TestFibonacci2(t *testing.T) {
assert := assert2.New(t)
assert.Equal(0, fibonacci2(-1), "fibonacci2(-1) should be 0")
assert.Equal(0, fibonacci2(0), "fibonacci2(-1) should be 0")
assert.Equal(1, fibonacci2(1), "fibonacci2(-1) should be 1")
assert.Equal(2, fibonacci2(3), "fibonacci2(-1) should be 2")
assert.Equal(233, fibonacci2(13), "fibonacci2(-1) should be 233")
}
- 벤치마크
func BenchmarkFibonacci1(b *testing.B) {
for i := 0; i < b.N; i++ { // b.N 만큼 반복
fibonacci1(20)
}
}
func BenchmarkFibonacci2(b *testing.B) {
for i := 0; i < b.N; i++ {
fibonacci2(20)
}
}
아래 명령은 현재 폴더의 모든 테스트와 벤치마크 테스트를 실행하는 명령입니다.
벤치마크 테스트를 실행합니다.
go test -bench .
재귀호출 보다 반복문이 훨씬 빠릅니다.
29장. Go 언어로 만드는 웹 서버
29.1 HTTP 웹 서버 만들기
Go 언어로 웹 서버를 만드는 방법을 알아보겠습니다.
Go 언어에서는 net/http 패키지를 사용하여 손쉽게 웹 서버를 만들 수 있습니다.
몇 줄 안되는 코드로 강력한 웹 서버를 만들 수 있기 때문에 웹 서버를 만들 때 Go 를 자주 사용합니다.
Go 언어에서 웹 서버를 만들려면 핸들러 등록과 웹서버 시작이라는 두 단계를 거쳐야 합니다.
29.1.1 핸들러 등록
각 HTTP 요청 URL 경로에 대응할 수 있는 핸들러를 등록합니다. 우선 핸들러란 각 HTTP 요청 URL 이 수신됐을 때 그것을 처리하는 함수 또는 객체라고 보면 됩니다. (HTTP 동작 원리는 29.2 절 HTTP 동작원리 참조)
핸들러는 HandleFunc() 함수로 등록할 수 있고, Handle() 함수로는 http.Handler 인터페이스를구현한 객체를 등록할 수 있습니다.
그러면 URL 경로에 해당하는 HTTP 요청 수신 시 핸들러에 해당하는 함수를 호출하거나 http.Handler 객체의 인터페이스인 ServeHTTP{) 메서드를 호출하여서 요청에 따른 로직을 수행할 수 도 있습니다.
func IndexPathHandler(w http.ResponseWriter, r *http.Reqiest) {
...
}
http.HandleFunc("/", IndexPathHandler)
/ 경로에 해당하는 HTTP 요청을 수신할 때 IndexPathHandler() 함수를 호출합니다.
Method 에는 GET, POST, PUT, DELETE 와 같은 메서드값입니다.
Proto -> HTTP 프로토컬 버전 정보입니다. 1.0 인지 2.0 인지 알아올 수 있습니다.
Header 에 맵 형태로 저장됩니다.
Body 를 io.Reader 인터페이스를 통해서 읽어올 수 있습니다.
29.1.2 웹 서버 시작
각 경로에 대한 핸들러 등록을 마치면 본격적으로 웹 서버를 시작하게 됩니다.
func ListenAndServe(addr string, handler Handler) error
첫 번째 인수인 addr 은 HTTP 요청을 수신하는 주소를 나타냅니다. 일반적으로 "3000" 과 같이 요청을 수신하는 포트 번호를 적어주게 됩니다.
두 번째 인수는 핸들러 인스턴스를 넣어주게 됩니다.
이 값을 nil 로 넣으면 디폴트 핸들러가 실행됩니다.
패키지 함수인 http.HandleFunc() 로 핸들러 함수를 등록할 때는 두 번째 인수로 nil 을 넣어줍니다.
새로운 핸들러 인스턴스를 만들어서 두 번째 인수로 넣어주는 예제는 29.4 절 ServeMux 인스턴스 이용하기에서 살펴보겠습니다.
- 요청을 받으면 Hello World 문자열을 반환하는 웹 서버
func main() {
http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
fmt.Fprint(writer, "Hello world") // 웹 핸들러 등록
})
http.ListenAndServe(":3000", nil) // 웹 서버 시작
}
단 여섯 줄 코드로 웹 서버를 만들었습니다.
두 번째 인수로 실행할 함수를 입력해줍니다.
이떄 함수 정의는 반드시 첫 번째 인수로 http.ResponseWriter 를 받고 두 번째 인수로 *http.Request 타입을 받아야 합니다.
fmt 패키지의 Fprint() 는 출력 스트림에 값을 쓰는 함수입니다.
fmt 패키지의 Print() 함수가 표준 출력 스트림으로 출력이 고정되지만, Fprint() 는 지정한 출력 스트림에 출력한다는 점이 다릅니다.
웹 서버는 위와 같이 핸들러를 먼저 등록하고 ListenAndServe 를 통해서 웹 서버를 시작합니다.
그리고 사용자에 요청을 보내면 등록된 핸들러가 있는지 확인하고 핸들러를 실행합니다.
29.2 HTTP 동작 원리
웹 브라우저는 먼저 도메인 네임 시스템 (DNS) 에 도메인에 해당하는 IP 주소를 요청합니다.
IP 주소가 목적지를 나타낸다면 포트번호는 수신한 데이터를 놓을 창구와 같습니다.
즉 IP 는 컴퓨터 자체를, 포트번호는 컴퓨터 내 데이터를 수신할 수 있는 창구를 의미한다고 볼 수 있습니다.
참고로 컴퓨터는 0 ~ 65535 포트를 가지고 있습니다.
포트번호 없이 요청을 전송하면 HTTP 는 80번, HTTPS 는 443 번 입니다.
https:// 는 데이터를 보내는 통신규약 HTTPS 를 사용하겠다는 것을 나타냅니다.
29.3 HTTP 쿼리 인수 사용하기
func barHandler(w http.ResponseWriter, r *http.Request) {
values := r.URL.Query()
name := values.Get("name")
if name == "" {
name = "World"
}
id, _ := strconv.Atoi(values.Get("id"))
fmt.Fprintf(w, "Hello %s! id:%d", name, id)
}
func main() {
http.HandleFunc("/bar", barHandler)
http.ListenAndServe(":3000", nil) // 웹 서버 시작
}
핸들러는 responseWriter 와 *Request 를 사용한다.
29.4 ServeMux 인스턴스 이용하기
DefaultServeMux 를 사용하면 http.HandleFunc() 함수에 같은 패키지 함수들을 이용해서 등록한 핸들러를 사용하기 때문에 다양한 기능을 충가하기 어려운 문제가 있습니다.
이번 절에서는 새로운 ServeMux 인스턴스를 생성해서 사용하는 방법을 알아보겠습니다.
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
fmt.Fprint(writer, "Hello World") // 인스턴스에 핸들러 등록
})
mux.HandleFunc("/bar", func(writer http.ResponseWriter, request *http.Request) {
fmt.Fprint(writer, "Hello Bar")
})
http.ListenAndServe(":3000", mux) // 웹 서버 시작
}
http.HandleFunc 대신에 새로 만든 mux.HandleFunc 를 사용해 핸들러를 만듭니다.
Mux
multiplexer (멀티플렉서) 의 약자로 여러 타입 중 하나를 선택해서 반환하는 디지털 장치를 말합니다.
웹 서버는 각 URL 에 해당하는 핸들러들을 등록한 다음 HTTP 요청이 왔을 때 URL 에 해당하는 핸들러를 선택해서 실행하는 방식입니다.
이 핸들러를 선택하고 실행하는 구조체 이름이 Mux 를 제공한다고 해서 ServeMux 라고 부릅니다.
비슷한 의미인 라우터 (router) 라고 말하기도 합니다.
29.5 파일 서버
웹 서버로 파일을 제공하는 방법을 살펴보겠습니다.
앞서 HTML 은 하이퍼텍스트 문서 포맷으로 문자 뿐 아니라 이미지나 음악 등 멀티미디어 컨텐츠를 포함할 수 있다고 설명했습니다.
HTML 문서가 이미지나 음악 데이터를 직접 포함하는 형태가 아니라 이미지나 음악 파일의 경로 URL 을 포함하는 형태로 데이터를 담게 됩니다.
29.5.1 "/" 경로에 있는 파일 읽어오기
static 폴더 아래 파일들을 제공하는 파일서버를 만들어봅시다.
- 파일서버 만들기
func main() {
http.Handle("/", http.FileServer(http.Dir("static")))
http.ListenAndServe(":3000", nil)
}
이제 아래 경로로 접근해보자.
http://localhost:3000/gopher.jpeg
29.5.2 특정 경로에 있는 파일 읽어오기
예를 들어 http://localhost:3000/static/gopher.jpg 를 요청했을 때 이미지가 나오도록 하려면 어떻게 해야 할까요 ?
func main() {
//http.Handle("/", http.FileServer(http.Dir("static")))
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
http.ListenAndServe(":3000", nil)
}
파일서버를 바로 쓰지 않고 StripPrefix 안에 파일서버를 작성해 줍니다.
root 에 html 파일을 작성해보자.
- test.html
<html>
<body>
<img src="http://localhost:3000/static/gopher.jpeg" alt="">
<h1>이것은 Gopher 이미지입니다.</h1>
</body>
</html>
웹 브라우저가 http://localhost:3000/static/gopher.jpg 경로의 이미지 데이터를 요청하는 HTTP 요청을 보내면 -> 웹 서버는 해당 경로의 이미지 데이터를 웹 브라우저로 변환해서 -> 웹 브라우저가 이미지를 출력할 수 있게 된 겁니다.
실제 웹 서비스에서는 파일을 웹 서버에서 직전 전달하는 방식 대신 대부분은 콘텐츠 전송 네트워크 (content delivery network, CDN) 서비스를 이용하는 방식으로 제공합니다. CDN 를 이용하면 파일을 사용자에게 가장 가까운 데이터 센터에서 바로 제공하기 때문에 매우 빠르게 파일 데이터를 제공할 수 있습니다.
29.6 웹 서버 테스트 코드 만들기
메인 웹 서버를 빌드하고 실행한 다음 웹 브라우저로 테스트하는 방식은 번거롭습니다.
테스트 코드로 웹 서버 동작을 테스트할 수 있다면 더욱 편리할 겁니다.
웹 서버 테스트 코드를 만듭니다.
func TestIndexHandler(t *testing.T) {
assert := assert2.New(t)
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil) // 1. / 경로 테스트
mux := MakeWebHandler()
mux.ServeHTTP(res, req)
assert.Equal(http.StatusOK, res.Code) // 3. Code 확인
data, _ := io.ReadAll(res.Body)
assert.Equal("Hello World", string(data))
}
func TestBarHandler(t *testing.T) {
assert := assert2.New(t)
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/bar", nil)
mux := MakeWebHandler()
mux.ServeHTTP(res, req)
assert.Equal(http.StatusOK, res.Code)
data, _ := io.ReadAll(res.Body)
assert.Equal("Hello Bar", string(data))
}
이와 같이 httptest 패키지를 이용하여 웹 서버를 실행해서 웹 브라우저를 사용하지 않아도 테스트할 수 있어서 많은 시간을 아낄 수 있습니다.
29.7 JSON 데이터 전송
HTTP 는 하이퍼 텍스트 즉 HTML 문서를 전송하는 프로토콜이지만 HTML 문서뿐 아니라 이미지나 다양한 데이터도 전송할 수 있습니다.
이번 절에서는 많이 사용되는 데이터 포맷 중 하나인 JSON 데이터를 전송하는 방법에 대해서 알아보겠습니다.
JSON 은 자바스크립트 오브젝트 표기법 (JavaScript Object Notation) 의 약자로 말 그대로 자바스크립트에서 오브젝트를 표현하는 방법으로 사용되는 포맷입니다.
하지만 이 표기법이 매우 간단하기 때문에 자바스크립트뿐 아니라 다양한 용도로 광범위하게 사용됩니다.
JSON 표기 규칙은 다음과 같습니다.
- 오브젝트 시작은 { 로 시작 } 로 끝
- 필드는 key, value 형태
- 각 필드는 , 로 구분
- 배열은 [ ]
- 문자열은 " 로 묶어서
- 학생 데이터를 JSON 데이터로 반환하는 웹 서버
type Student struct {
Name string
Age int
Score int
}
func MakeWebHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/student", StudentHandler)
return mux
}
func StudentHandler(writer http.ResponseWriter, request *http.Request) {
var student = Student{"aaa", 16, 87}
data, _ := json.Marshal(student)
writer.Header().Add("content-type", "application/json") // 3. JSON 포맷임을 표시
writer.WriteHeader(http.StatusOK)
fmt.Fprint(writer, string(data))
}
func main() {
http.ListenAndServe(":3000", MakeWebHandler())
}
- 테스트 코드
func TestJsonHandler(t *testing.T) {
assert := assert2.New(t)
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/student", nil)
mux := MakeWebHandler()
mux.ServeHTTP(res, req)
assert.Equal(http.StatusOK, res.Code)
student := new(Student)
err := json.NewDecoder(res.Body).Decode(student) // 2. 결과 반환
assert.Nil(err)
assert.Equal("aaa", student.Name)
assert.Equal(16, student.Age)
assert.Equal(87, student.Score)
}
content-type 을 application/json 으로 명시해주지 않으면 데이터는 JSON 으로 가더라도 text/plain 이라고 표시됩니다.
29.8 HTTPS 웹 서버 만들기
본래 HTTP 는 보안을 염두해 두지 않고 설계된 프로토콜이라 모든 요청이 평문 (일반 문자열) 입니다.
그래서 보안에 매우 취약합니다.
특히 스니핑과 같은 해킹으로 HTTP 전문을 볼 수 있어서 비밀번호, 개인정보와 같은 중요 데이터를 보호하지 못하는 문제가 있습니다.
이를 방지하고자 나온 게 HTTPS 입니다.
기존 HTTP 요청과 응답을 공개키 암호화 방식을 사용해서 암호화한 프로토콜입니다.
패킷이 암호화되기 때문에 해커가 스니핑을 한다고 해도 어떤 내용인지 알 수 없어서 비밀번호나 개인정보가 노출되지 않게 됩니다.
29.8.1 공개키 암호화 방식
공캐기 암호화 방식은 공개키와 비밀키 두 가지 키를 생성해서 공개키는 클라이언트에 알려주고 비밀키는 서버에 비공개 상태로 놔두게 됩니다.
HTTPS 요청을 보낼 때 공개키로 암호화하고 서버는 비밀키를 이용해서 다시 원문으로 되돌리는 복호화를 하게 됩니다.
공개키 암호화 방식은 암호화와 복호화에 쓰이는 키가 서로 다르기 때문에 비대칭 암호화 방식입니다.
공개키 암호화 방식에서는 비밀키가 노출되지 않도록 각별히 주의해야 합니다.
공개키 암호화 원리에 대해서는 칸 아카데미에서 제작한 영상이 매우 유익합니다.
암호화 사이트: https://ko.khanacademy.org/computing/computer-science
암호화 유튜브: https://www.youtube.com/watch?v=EPXilYOa71c&t=3s
29.8.2 인증서와 키 생성
HTTPS 서버를 실행하려면 인증서와 비밀키를 생성해야 합니다.
본래 인증서는 인증기관 * 에서 발급해야 하지만 개인 프로젝트이기 때문에 셀프 인증을 하겠습니다.
인증서를 발급하려면 openssl 프로그램을 설치해야 하는데 이 프로그램은 우리가 이미 설치한 깃에 포함되어 있습니다.
환경설정에서 Path 에 깃 경로를 추가해줍니다.
맥의 ZSH 플러그인에 git 이 있어서 그런지 필자의 경우 환경변수 추가 없이 openssl 명령이 잘 동작했습니다.
* 인증기관은 Sectigo, DigiCert 등 많은 회사가 있고, 각 회사마다 인증기관과 금액이 모두 다릅니다.
터미널에 다음 명령을 실행합니다.
openssl req -new newkey rsa:2048 -nodes -keyout localhost.key -out localhost.csr
이 명령은 rsa:2048 방식으로 키를 생성해서 비밀키는 localhost.key 파일로 저장하고 인증 파일은 localhost.csr 로 저장합니다.
이 인증파일은 인증기관에 제출해서 인증서인 .crt 파일을 생성하게 됩니다.
localhost.key 는 비밀키이기 때문에 절대 누구에게도 공개해서는 안됩니다. 실제 웹 서비스를 운영할 때는 안전한 별도 저장소에 저장하고 웹 서버 로컬 파일 시스템에 저장하지 않는 것을 권장합니다. 그리고 일정한 주기로 교체해주셔야 합니다.
여기서는 셀프 인증을 하겠습니다.
openssl x509 -req -days 365 -in localhost.csr -signkey localhost.key -out localhost.crt
대표적인 인증 알고리즘인 x509 를 사용해서 1년짜리 인증서를 발급했습니다.
이 명령을 수행하면 localhost.crt 파일이 생성됩니다.
공개키는 localhost.crt 파일에 포함되어 있습니다.
HTTPS 서버를 실행하여 클라이언트 접속 시 클라이언트에 localhost.crt 파일로 인증정보와 공개키를 전송하게 됩니다.
개인정보를 수집하고 외부로 공개되는 사이트는 반드시 인증기관을 통해 인증받은 인증서를 사용하도록 법으로 강제하고 있습니다.
왜 인증을 받아야 할까 ?
왜 공인기관의 인증을 받아야 할까요 ?
해커가 웹사이트를 가장할 수 있기 때문입니다.
이것을 피싱 (pishing) 사이트 라고 합니다.
겉모양을 은행 사이트와 똑같이 만든 뒤 사용자의 비밀번호와 같은 개인정보를 입력하라고 요청할 수 있습니다.
이 때 HTTPS 프로토콜을 지원하기 위해서 해커가 만든 공개키를 클라이언트에 보내서 암호화하라고 할 수 있습니다.
만약 클라이언트에서 이 웹사이트를 신뢰할 수 있는지 여부를 모른다면 해커가 준 공개키로 패킷을 암호화하게 되고, 해커는 자신이 가진 비밀키로 이것을 복호화해서 알 수 있게 됩니다.
웹 사이트 공개키는 외부 공인기관의 비밀키로 다시 암호화되어서 공인기관 내 저장됩니다.
사용자는 인증서를 받게 되고, 그 인증서에는 웹사이트 공개키 대신 인증정보와 인증기관의 공개키가 들어있어서 인증기관을 통해 웹 사이트 공개키를 요청하게 됩니다.
피싱 사이트라고 하더라도 해커가 제공한 공개키가 아닌 공인 인증기관에 저장된 웹 사이트 본래의 공개키로 암호화되어서 해커가 볼 수 없게 됩니다.
앞서 만든 localhost.csr, localhost.crt, localhost.key 파일을 HTTPS 서버 프로그램 폴더에 복사합니다.
30장. RESTful API 서버 만들기
30.1 해법
- gorilla/mux 와 같은 RESTful API 웹 서버 제작을 도와주는 패키지를 설치합니다.
- RESTful API 에 맞춰서 웹 핸들러 함수를 만들어줍니다.
- Restful API 를 테스트하는 테스트 코드를 만듭니다.
- 웹 브라우저로 데이터를 조회합니다.
30.2 사전지식: RESTful API
즉 Representational State Transfer 를 직역하면 '표현식으로 데이터를 전송한다' 는 의미입니다.
REST 란 로이필딩이 2000년에 소개한 웹 아키텍처의 형식으로 REST 설계 원칙에 입각한 시스템을 RESTful API 라고 부릅니다.
REST 를 간단히 말하자면 URL 과 메서드로 데이터와 동작을 표현하는 방식입니다.
HTTP 메서드
메서드 | URL | 동작 |
---|---|---|
GET | /students | |
GET | /students/id | |
POST | /students | |
PUT | /students/id | |
DELETE | /students/id |
- 메서드로 행위표현
30.3 RESTful API 서버 만들기
29장 처럼 기본 패키지로 만들 수도 있지만, 그러면 더 번거로운 작업을 해줘야 합니다.
웹 프레임워크인 gin 이 많이 사용됩니다.
이 책에서는 가볍게 쓸 수 있는 gorilla/mux 를 사용했습니다.
다른 웹 프레임워크는 Awesome Go 를 참조하세요.
학생 목록을 반환하는 웹 서버를 만들어봅시다.
- 패키지 설치
go get -u github.com/gorilla/mux
- ex30.1 - 학생 목록 반환
type Student struct {
Id int
Name string
Age int
Score int
}
var students map[int]Student // 학생 목록을 저장하는 맵
var lastId int
func MakeWebHandler() http.Handler {
mux := mux2.NewRouter()
mux.HandleFunc("/students", GetStudentListHandler).Methods("GET")
// 2. 새 핸들러를 등록
// 3. 임시 데이터 생성
students = make(map[int]Student)
students[1] = Student{1, "aaa", 16, 87}
students[2] = Student{2, "bbb", 18, 98}
lastId = 2
return mux
}
func GetStudentListHandler(writer http.ResponseWriter, request *http.Request) {
list := make(Students, 0) // 1. 학생 목록을 Id 로 정렬
for _, student := range students {
list = append(list, student)
}
sort.Sort(list)
writer.WriteHeader(http.StatusOK)
writer.Header().Set("Content-Type", "application/json")
json.NewEncoder(writer).Encode(list) // 2. JSON 포맷으로 변경
}
type Students []Student
func (s Students) Len() int {
return len(s)
}
func (s Students) Less(i, j int) bool {
return s[i].Id < s[j].Id
}
func (s Students) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func main() {
http.ListenAndServe(":3000", MakeWebHandler())
}
- ex30_1_test
func TestJsonHandler(t *testing.T) {
assert := assert2.New(t)
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/students", nil)
// 1. /students 경로 테스트
mux := MakeWebHandler()
mux.ServeHTTP(res, req)
assert.Equal(http.StatusOK, res.Code)
var list []Student
err := json.NewDecoder(res.Body).Decode(&list)
assert.Nil(err)
assert.Equal(2, len(list))
assert.Equal("aaa", list[0].Name)
assert.Equal("bbb", list[1].Name)
}
30.5 특정 학생 데이터 반환하기
strconv 패키지를 사용해봅시다.
- ex30.1
type Student struct {
Id int
Name string
Age int
Score int
}
var students map[int]Student // 학생 목록을 저장하는 맵
var lastId int
func MakeWebHandler() http.Handler {
mux := mux2.NewRouter()
mux.HandleFunc("/students", GetStudentListHandler).Methods("GET")
mux.HandleFunc("/students/{id:[0-9]+}", GetStudentHandler).Methods("GET")
// 2. 새 핸들러를 등록
// 3. 임시 데이터 생성
students = make(map[int]Student)
students[1] = Student{1, "aaa", 16, 87}
students[2] = Student{2, "bbb", 18, 98}
lastId = 2
return mux
}
func GetStudentHandler(writer http.ResponseWriter, request *http.Request) {
vars := mux2.Vars(request)
id, _ := strconv.Atoi(vars["id"])
student, ok := students[id]
if !ok {
writer.WriteHeader(http.StatusNotFound)
// 2. id 에 해당하는 학생이 없으면 에러
return
}
writer.WriteHeader(http.StatusOK)
writer.Header().Set("Content-Type", "application/json")
json.NewEncoder(writer).Encode(student)
}
func GetStudentListHandler(writer http.ResponseWriter, request *http.Request) {
list := make(Students, 0) // 1. 학생 목록을 Id 로 정렬
for _, student := range students {
list = append(list, student)
}
sort.Sort(list)
writer.WriteHeader(http.StatusOK)
writer.Header().Set("Content-Type", "application/json")
json.NewEncoder(writer).Encode(list) // 2. JSON 포맷으로 변경
}
type Students []Student
func (s Students) Len() int {
return len(s)
}
func (s Students) Less(i, j int) bool {
return s[i].Id < s[j].Id
}
func (s Students) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func main() {
http.ListenAndServe(":3000", MakeWebHandler())
}
- ex30_1_test
func TestJsonHandler(t *testing.T) {
assert := assert2.New(t)
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/students", nil)
// 1. /students 경로 테스트
mux := MakeWebHandler()
mux.ServeHTTP(res, req)
assert.Equal(http.StatusOK, res.Code)
var list []Student
err := json.NewDecoder(res.Body).Decode(&list)
assert.Nil(err)
assert.Equal(2, len(list))
assert.Equal("aaa", list[0].Name)
assert.Equal("bbb", list[1].Name)
}
func TestJsonHandler2(t *testing.T) {
assert := assert2.New(t)
var student Student
mux := MakeWebHandler()
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/students/1", nil)
mux.ServeHTTP(res, req)
assert.Equal(http.StatusOK, res.Code)
err := json.NewDecoder(res.Body).Decode(&student)
assert.Nil(err)
assert.Equal("aaa", student.Name)
res = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/students/2", nil)
mux.ServeHTTP(res, req)
assert.Equal(http.StatusOK, res.Code)
err = json.NewDecoder(res.Body).Decode(&student)
assert.Nil(err)
assert.Equal("bbb", student.Name)
}
30.6 학생 데이터 추가/삭제하기
HTTP 요청의 테스트코드 작성 순서
- asssert 와 student 인스턴스 생성
- mux 가져옴
- req, res 생성
- mux 로 req, res 보냄
- assertSsd
- HTTP 코드 equal
- json 의 res.Body 를 객체로 디코드
- err 검증
- 이름 검증
- 학생 등록 핸들러
func PostStudentHandler(response http.ResponseWriter, request *http.Request) {
var student Student
err := json.NewDecoder(request.Body).Decode(&student) // JSON 데이터 변환
if err != nil {
response.WriteHeader(http.StatusBadRequest)
return
}
lastId++ // 2. id 를 증가시킨 후 맵에 등록
student.Id = lastId
students[lastId] = student
response.WriteHeader(http.StatusCreated)
}
- 학생 등록 테스트코드
func TestJsonHandler3(t *testing.T) {
var student Student
mux := MakeWebHandler()
res := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/students",
strings.NewReader(`{"Id":0,"Name":"ccc","Age":15,"Score":78}`))
// 1. 새로운 학생 데이터
mux.ServeHTTP(res, req)
assert2.Equal(t, http.StatusCreated, res.Code)
res = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/students/3", nil)
// 3. 추가된 학생 데이터
mux.ServeHTTP(res, req)
err := json.NewDecoder(res.Body).Decode(&student)
assert2.Nil(t, err)
assert2.Equal(t, "ccc", student.Name)
}
go test 명령을 사용하면 IDE 의 디버그를 할 수 없다.
테스트코드를 Control Shift D 키로 IDE 에서 작동시켜보자.
학생 데이터 삭제는 "/students/id" DELETE 요청으로 처리합니다.
핸들러를 추가합니다.
- 삭제 핸들러
func DeleteStudentHandler(response http.ResponseWriter, request *http.Request) {
vars := mux2.Vars(request)
id, _ := strconv.Atoi(vars["id"])
_, ok := students[id]
if !ok {
response.WriteHeader(http.StatusNotFound)
return
}
delete(students, id)
response.WriteHeader(http.StatusOK)
}
- 삭제 테스트코드
func TestJsonHandler4(t *testing.T) {
assert := assert2.New(t)
mux := MakeWebHandler()
res := httptest.NewRecorder()
req := httptest.NewRequest("DELETE", "/students/2", nil)
mux.ServeHTTP(res, req)
assert.Equal(http.StatusOK, res.Code)
res = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/students/2", nil)
mux.ServeHTTP(res, req)
assert.Equal(http.StatusNotFound, res.Code)
}
31장. Todo 리스트 웹 사이트 만들기
31.1 해법
어떻게 구현해야 할지 생각해봅시다.
Todo 리스트 웹 서비스를 구현하는 순서는 다음과 같습니다.
- 먼저 RESTful API 에 맞춰서 서비스를 정의합니다.
- 할 일을 나타내는 Todo 구조체를 만듭니다.
- 앞서 정의한 RESTful API 에 맞춰서 각 핸들러를 맞듭니다.
- 화면을 구성하는 HTML 문서를 만듭니다.
- 프론트엔드 동작을 나타내는 자바스크립트 코드를 만듭니다.
- 웹 브라우저로 동작을 확인합니다.
31.2 준비하기
웹 서버를 만들기 앞서 몇 가지 유용한 패키지를 더 설치하겠습니다.
지난 시간에 gorilla/mux 를 설치해서 사용했는데요,
이번 장에서는 gorilla/mux 와 같이 사용할 수 있고 웹 서버를 편하게 만드는 데 도움을 주는 urfave/negroni 와 unrolled/render 를 설치해서 사용하겠습니다.
31.2.1 urfave/negroni 패키지 설치
자주 사용되는 웹 핸들러를 제공하는 패키지입니다.
negroni 를 사용하면 다음과 같은 기능을 이용할 수 있습니다.
- 로그 기능
- 웹 요청을 받아 응답할 때 자동으로 로그를 남겨줘서 웹 서버 동작을 확인할 수 있습니다.
- panic 복구 기능
- 웹 요청을 수행하다가 panic 이 발생하면 자동으로 복구해주는 동작을 지원합니다.
- 파일 서버 기능
- public 폴더의 파일 서버를 자동으로 지원해줍니다.
- 설치
go get github.com/urfave/negroni
사용법은 매우 간단합니다.
기본 기능인 negroni.Classic() 핸들러로 우리가 만든 핸들러를 감싸서 사용하면 됩니다.
mux := MakeWebHandler() // 1. 우리가 만든 핸들러
n := negroni.Classic() // 2. negroni 기본 핸들러
n.UseHandler(mux) // 3. 우리가 만든 핸들러를 감쌈
err := http.ListenAndServe(":3000", n) // 4. negroni 기본 핸들러가 동작함
31.2.2 unrolled/render 패키지 설치
웹 서버 응답을 구현하는 데 사용하는 유용한 패키지입니다.
웹 서버 응답으로 HTML 이나 JSON, TEXT 같은 포맷을 간단히 사용할 수 있습니다.
go get github.com/unrolled/render
render 패키지를 이용하면 아래와 같이 한 줄로 간편하게 JSON 포맷으로 변환하여 응답할 수 있습니다.
writer.WriteHeader(http.StatusOK)
writer.Header().Set("Content-Type", "application/json")
json.NewEncoder(response).Encode(list) // 2. JSON 포맷으로 변경
r 만 미리 전역변수로 정의한다면 위 세 줄을 한 줄로 작성할 수 있습니다.
r := render.New()
r.JSON(response, http.StatusOK, list)
31.3 웹 서버 만들기
31.3.1 RESTful API 정의하기
- /todos
- /todos/id
31.3.2 웹 서버 만들기
var rd *render.Render
type Todo struct {
ID int `json:"id,omitempty"`
Name string `json:"name"`
Completed bool `json:"completed,omitempty"`
}
var todoMap map[int]Todo
var lastID int = 0
`json:"id,omitempty"` 라고 표시하면 JSON 포맷으로 변환 시 항목 이름이 "ID" 가 아닌 "id" 로 변환되고 생략 가능함을 표시합니다.
웹 서버 핸들러를 생성합니다.
func MakeWebHandler() http.Handler {
todoMap = make(map[int]Todo)
mux := mux2.NewRouter()
mux.Handle("/", http.FileServer(http.Dir("public")))
mux.HandleFunc("/todos", GetTodoListHandler).Methods("GET")
mux.HandleFunc("/todos", PostTodoHandler).Methods("POST")
mux.HandleFunc("/todos/{id:[0-9]+}", RemoveTodoHandler).Methods("DELETE")
mux.HandleFunc("/todos/{id:[0-9]+}", UpdateTodoHandler).Methods("PUT")
return mux
}
- ex31.1.go
var rd *render.Render
type Success struct {
Success bool `json:"success"`
}
type Todo struct {
ID int `json:"id,omitempty"`
Name string `json:"name"`
Completed bool `json:"completed,omitempty"`
}
type Todos []Todo
func (t Todos) Len() int {
return len(t)
}
func (t Todos) Less(i, j int) bool {
return t[i].ID < t[j].ID
}
func (t Todos) Swap(i, j int) {
t[i], t[j] = t[j], t[i]
}
var todoMap map[int]Todo
var lastID int = 0
func MakeWebHandler() http.Handler {
todoMap = make(map[int]Todo)
mux := mux2.NewRouter()
mux.Handle("/", http.FileServer(http.Dir("public")))
mux.HandleFunc("/todos", GetTodoListHandler).Methods("GET")
mux.HandleFunc("/todos", PostTodoHandler).Methods("POST")
mux.HandleFunc("/todos/{id:[0-9]+}", RemoveTodoHandler).Methods("DELETE")
mux.HandleFunc("/todos/{id:[0-9]+}", UpdateTodoHandler).Methods("PUT")
return mux
}
func GetTodoListHandler(response http.ResponseWriter, request *http.Request) {
list := make(Todos, 0)
for _, todo := range todoMap {
list = append(list, todo)
}
sort.Sort(list)
rd.JSON(response, http.StatusOK, list) // 2. ID 로 정렬하여 전체 목록 반환
}
func PostTodoHandler(response http.ResponseWriter, request *http.Request) {
var todo Todo
err := json.NewDecoder(request.Body).Decode(&todo)
if err != nil {
log.Fatal(err)
response.WriteHeader(http.StatusBadRequest)
return
}
lastID++ // 1. 새로운 ID 로 등록하고 만든
todo.ID = lastID
todoMap[lastID] = todo
rd.JSON(response, http.StatusCreated, todo)
}
func UpdateTodoHandler(response http.ResponseWriter, request *http.Request) {
var newTodo Todo
err := json.NewDecoder(request.Body).Decode(&newTodo)
if err != nil {
log.Fatal(err)
response.WriteHeader(http.StatusBadRequest)
return
}
vars := mux2.Vars(request)
id, _ := strconv.Atoi(vars["id"])
if todo, ok := todoMap[id]; ok {
todo.Name = newTodo.Name
todo.Completed = newTodo.Completed
rd.JSON(response, http.StatusOK, Success{true})
} else {
rd.JSON(response, http.StatusBadRequest, Success{false})
}
}
func RemoveTodoHandler(response http.ResponseWriter, request *http.Request) {
vars := mux2.Vars(request)
id, _ := strconv.Atoi(vars["id"])
if _, ok := todoMap[id]; ok {
delete(todoMap, id)
rd.JSON(response, http.StatusOK, Success{true})
} else {
rd.JSON(response, http.StatusNotFound, Success{false})
}
}
func main() {
rd = render.New()
m := MakeWebHandler()
n := negroni.Classic()
n.UseHandler(m)
log.Println("Started App")
err := http.ListenAndServe(":3000", n)
if err != nil {
panic(err)
}
}
앞서 살펴본 대로 negroni 기본 핸들러를 이용하겠습니다.
이를 통해서 각 요청이 올 때마다 터미널에 로그가 표시되고 기본으로 "public" 폴더에서 파일 서버가 동작하게 됩니다.
31.4 프론트엔드 만들기
예제 파일: https://github.com/tuckersGo/musthaveGo
필요한 파일을 가져옵니다.
- todo.css
- index.html
- todo.js
고랜드의 실행으로는 화면이 보이지 않고 go build 후 실행해야 화면이 보인다.
31.5 웹 배포 방법 고려하기
클라우드 서비스 유형으로는 (IPS)
- IaaS
- 인프라 스트럭처 (Infrastructure as a Service) 라는 뜻으로 머신 인프라를 대여하기 때문에 웹 서비스를 하려면 예상 사용량에 맞는 장비를 대여하고 IP 를 할당받은 뒤 본인이 직접 머신에 접속해서 웹 서버를 복사하고 실행해야 합니다.
- 세밀한 조정이 가능하고 가격이 저렴합니다.
- PaaS
- 서비스 형태의 플랫폼 (Platform as a Service) 라는 뜻으로 플랫폼이 알아서 사용량에 따라 성능을 맞춰주고 웹 서버도 알아서 실행해주기 때문에 훨씬 간편하게 사용할 수 있습니다.
- SaaS
- 서비스 형태의 소프트웨어 (Software as a Service) 라는 뜻으로 구글드라이브, 구글 문서도구와 같은 서비스들이 해당합니다. 편하게 쓸 수 있지만 정해진 소프트웨어만 사용해야 합니다.
- 정해진 포맷에 맞춰서 클릭 몇 번으로 빠르게 웹 사이트를 만들어주는 서비스들 역시 SaaS 라고 볼 수 있습니다.
가 있습니다.
대표적으로 AWS, Google Cloud Platform, 마이크로소프트 애저 같은 서비스가 있습니다.
앞에서 언급한 서비스는 모두 유료이빈다.
그래서 이 책에서 간단하게 쓸 수 있고, 또 무료로 테스트 할 수 있는 헤로쿠 (Heroku) 서비스를 이용해서 웹 서버를 배포해보겠습니다.
헤로쿠는 PaaS 서비스로 간단하게 명령어로 웹 서버를 배포할 수 있는 장점이 있고, 무엇보다 무료 등급이 있어서 간단한 웹 서버를 개발하고 테스트하는 용도로는 딱입니다.
31.6 헤로쿠로 배포하기
헤로쿠에 가입하고 웹 서버를 수정해서 서비스를 배포하는 방법을 알아보겠습니다.
31.6.1 헤로쿠 가입
회원가입부터 하자.
헤로쿠 CLI 설치 사이트: https://devcenter.heroku.com/articles/heroku-cli
- 설치 (Mac OS)
brew tap heroku/brew && brew install heroku
31.6.3 Go Module 만들기
웹 서버를 배포하려면 go mod init 명령으로 웹 서버를 별도 모듈로 만들어줘야 합니다.
먼저 우리가 만든 웹 서버가 있는 폴더인 ex31.1 폴더를 GOPATH 바깥으로 복사하고 폴더명을 todo 로 바꿉니다.
go mod init mustHaveGo/todo // go.mod 파일을 생성합니다.
go build // 패키지명으로 실행파일을 생성합니다.
git init // git 저장소를 만들어줍니다.
31.6.4 헤로쿠 로그인
heroku login
프로젝트 폴더에서 heroku create 명령을 실행합니다.
heroku create
그러면 터미널에 헤로쿠가 임시로 만들어준 도메인 이름이 출력됩니다.
31.6.6 웹 서버 수정
1. 먼저 웹 서버를 실행할 때 포트번호를 OS 환경변수에서 가져와야 합니다.
- main.go
port := os.Getenv("PORT")
err := http.ListenAndServe(":"+port, n)
메인함수의 포트를 다음과 같이 바꿉니다.
2. Profile 을 작성합니다.
- Procfile
web: bin/todo
31.6.7 깃 커밋
이제 배포를 시작해보겠습니다.
todo.exe 파일을 제거합니다.
어차피 웹 서버를 배포하면 헤로쿠가 알아서 빌드하기 때문입니다.
git add .
git commit -m 'first commit of todo'
git push heroku master
- 에러
아래 링크로 접속할 수 있다.
https://tranquil-reaches-71219.herokuapp.com/
혹은 다음 명령으로 우리가 배포한 웹 서비스에 접근할 수 있다.
heroku open
Appendix Tucker 노트
A. Go 문법 보충수업
A.1 배열과 슬라이스
슬라이스는 Go 언어에서 주로 동적배열용도로 많이 사용됩니다. 즉, 크기가 계속 늘어날 때 슬라이스를 사용합니다.
하지만 슬라이스 본래 정의는 배열 일부분을 가리키는 타입입니다.
배열 포인터라고 볼 수 있습니다.
그래서 슬라이스는 항상 다른 배열의 일부분을 가리키고 있다는 점을 명심해야 합니다.
A.1.1 배열 슬라이싱
var array [10]int
var slice []int = array[1:3]
Len 필드값은 [끝 인덱스 - 처음 인덱스] 로 계산됩니다.
A.1.2 슬라이스를 만드는 다양한 방법
func main() {
var array [10]int = [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
var slice1 []int = array[1:5] // 1. 배열 슬라이싱
var slice2 []int = slice1[1:8:9] // 2. 슬라이스 슬라이싱
var slice3 []int = make([]int, 5) // 3. make()
var slice4 []int = make([]int, 0) // 4. 길이 0 인 슬라이스
var slice5 []int = []int{1, 2, 3, 4, 5} // 5. 초기화
var slice6 []int // 6. 기본값은 nil
fmt.Println("slice1", slice1)
fmt.Println("slice2", slice2, cap(slice2))
fmt.Println("slice3", slice3)
fmt.Println("slice4", slice4)
if slice4 != nil {
fmt.Println("slice4 is not nil")
}
fmt.Println("slice5", slice5)
fmt.Println("slice6", slice6)
if slice6 == nil {
fmt.Println("slice6 is nil")
}
}
- 정리
var slice = array[n:m] // 슬라이스
var slice = []int{1, 2, 3, 4, 5] // 초기화
A.2 for range
채널타입
채널타입일 때는 채널에서 값이 들어올 때까지 계속 대기합니다.
값이 들어오면 들어온 값을 채널에서 빼내서 반환합니다. 그리고 다시 대기합니다.
이것은 채널이 close() 로 닫힐 때까지 계속 반복됩니다.
주의할 점은 채널이 닫히기 전까지 계속 대기한다는 점입니다.
그래서 사용하지 않는 채널을 닫아 주지 않으면 좀비 루틴이 만들어질 수 있습니다.
A.3 입출력 처리
A.3.1 Go 언어에서 입출력을 처리하는 방식
Go 언어에서는 io 패키지의 Reader, Writer 인터페이스를 사용해서 모든 입출력을 처리합니다.
byte 슬라이스를 생성해서 넣어주면 p 크기만큼 데이터를 읽어서 채워줍니다.
파일 핸들러인 os.File 객체나 네트워크 연결을 다루는 net.Conn 객체 모두 io.Reader 와 io.Writer 인터페이스를 구현하고 있기 때문에 모두 같은 방식으로 다룰 수 있습니다.
가장 기본적인 메서드만 제공하기 때문에 바로 사용하기 불편합니다. 그래서 내부에 메모리 버퍼를 가진 bufio 의 Reader 나 Writer 또는 Scanner 를 사용해야 편리하게 이용할 수 있습니다.
bufio.Reader 객체
이 객체는 내부에 메모리 버퍼를 가지고 io.Reader 인스턴스를 편하게 사용할 수 있는 다양한 기능을 제공합니다.
- ReadLine()
- ReadRune()
- ReadString(): 특정 문자가 나올 때 까지 읽는 메서드
bufio.Scanner 객체
일정한 패턴으로 io.Reader 인스턴스에서 값을 읽어올 때 사용합니다.
type Scanner
func NewScanner(r io.Reader) *Scanner
func (s *Scanner) Scan() bool
func (s *Scanner) Err() error
func (s *Scanner) Split(split SplitFunc)
func (s *Scanner) Text() string
특정 패턴으로 반복해서 읽어야할 때는 Scanner 가 편리합니다.
역시 NewScanner() 함수에 io.Reader 인수로 사용해서 만들 수 있습니다.
Scan() 메서드를 통해서 토큰 읽기를 시도하고 Text() 를 통해 읽어온 토큰을 반환합니다.
토큰이란 패턴에 해당하는 만큼 읽어온 문자열을 말합니다.
Scan() 에서 읽기에 실패한 경우 false 를 반환합니다. 일반적으로 더 읽을 수 없을 때 false 를 반환합니다.
그 외의 경우는 Err() 메서드를 통해서 오류를 검사할 수 있습니다.
- Scanner 를 이용해서 단어 개수를 세는 예제
func main() {
const input = "Now is the winter of our discontent," +
"\nMade glorious summer by this sun of York.\n"
scanner := bufio.NewScanner(strings.NewReader(input)) // 1. 스캐너 생성
scanner.Split(bufio.ScanWords) // 2. 단어 단위로 검색
count := 0
for scanner.Scan() {
count++
println(scanner.Text())
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "reading input:", err)
}
fmt.Printf("%d\n", count)
}
공식 패키지 문서: https://pkg.go.dev/bufio
NewScanner() 함수는 io.Reader 인터페이스를 인수로 받기 때문에 string 타입을 바로 사용할 수 없습니다.
그래서 strings.NewReader() 를 사용해서 문자열을 io.Reader 인터페이스를 구현한 객체로 변환해서 넣어줬습니다.
기본적으로 Scanner 는 한 줄 단위로 토큰을 읽어오게 됩니다.
다른 방식으로 토큰을 읽으려면 Split() 메서드를 이용해 토큰을 구분하는 함수를 등록해야 합니다.
본인만의 패턴으로 토큰을 읽고 싶을 때는 사용자 함수를 등록해서 원하는 방식으로 구분지을 수 있습니다.
더 읽을 수 없어서 검색이 중단되면 Err() 메서드는 nil 을 반환합니다.
bufio.Writer 객체
io.Writer 인스턴스에 문자열을 쓸 때 유용합니다.
type Writer
func NewWriter(w io.Writer) *Writer
func (b *Writer) WriterString(s string) (int, error)
보통 WriteString() 메서드로 문자열을 쓸 때 유용합니다.
하지만 fmt 패키지의 FPrint() 시리즈를 이용하면 더 편하게 원하는 형태로 io.Writer 인스턴스에 문자열을 쓸 수 있습니다.
io.ReadAll() 함수
func ReadAll(r Reader) ([]byte, error)
A.3.2 fmt.Fprint() 시리즈 이용하기
io.Writer 인스턴스에 원하는 형태의 문자열을 쓸 때 사용합니다.
Print() 시리즈와 다른 점이 있다면 Print() 시리즈는 표준 출력인 os.Stdout 에 쓰는 반면 Fprint() 시리즈는 프로그래머가 어떤 io.Writer 에 쓸지 정할 수 있다는 점입니다.
Fprintf() 함수를 이용해서 파일에 문자열을 쓰는 예제를 살펴봅시다.
func main() {
f, err := os.Create("output.txt") // 1. 파일 생성
if err != nil {
fmt.Errorf("Create: %v\n", err)
return
}
defer f.Close()
const name, age = "Kim", 22
n, err := fmt.Fprint(f, name, " is ", age, " years old\n") // 2.
if err != nil {
fmt.Errorf("Fprint: %v\n", err)
}
print(n, " bytes written.\n")
}
Create() 함수는 파일이 이미 있으면 삭제하고 새로 파일을 생성합니다.
성공할경우 파일 핸들 객체인 *File 를 반환합니다.
*File 타입은 io.Writer 를 구현하고 있기 때문에 Fprint() 함수의 인수로 사용할 수 있습니다.
A.4 알아두면 유용한 go 명령어
Go 언어에서 제공하는 유용한 도구 (명령어) 를 살펴보겠습니다.
go 뒤에 원하는 명령어를 써주면 됩니다.
예를 들어 빌들르 수행하는 도구인 build 명령은 다음과 같이 쓰면 됩니다.
go build
- 유용한 go 명령어
명령어 | 설명 |
---|---|
bug | Go 언어 자체의 버그를 리포트할 수 있는 사이트 브라우저로 접속합니다. 버그 리포트를 시작합니다. |
build | 패키지를 컴파일합니다. |
clean | 컴파일 시 생성되는 패키지 목적 파일 (object files) 들을 삭제합니다. |
doc | 패키지 문서를 출력합니다. 특정 패키지 설명을 볼 때 유용합니다. |
env | Go 환경설정을 출력합니다. |
fix | 오래된 API 를 사용하는 Go 프로그램을 찾아서 새로운 API 로 업데이트 합니다. 자세한 사항은 go doc cmd/fix 를 참조하세요. |
fmt | 패키지를 리포맷하는 gofmt 툴을 실행합니다. Go 코딩 규약에 맞춰서 소스코드를 수정해줍니다. |
generate | 만약 패키지 파일안에 파일 생성절차가 정의되어 있으면 그에 따라서 go 파일을 생성합니다. |
get | 현재 모듈 패키지 목록에 패키지를 추가하고 다운받습니다. |
install | 컴파일한 뒤 결과를 GOPATH/bin 경로에 설치합니다. |
list | 패키지나 모듈 목록을 출력합니다. |
mod | 새로운 모듈을 만들거나 관리합니다. |
run | 컴파일한 뒤 결과를 실행합니다. 실행파일을 생성하지 않습니다. |
test | 패키지를 테스트합니다. |
tool | 특정 go 도구를 실행합니다. go tool [command] 형태로 사용되고 [command] 에 도구명을 적어줍니다. cgo, pprof, pack 등 다양한 추가도구들이 있습니다. |
version | go 버전을 출력합니다. |
vet | 패키지 내 버그로 의심되는 부분을 보고합니다. 자세한 사항은 go doc cmd/vet 을 참조하세요. |
A.5 cgo 로 C 언어 호출하기
C 언어는 메모리 관리를 프로그래머가 직접 해야하고 모던 언어에서 제공하는 편리한 기능이 부족하기 때문에 새로운 대규모 프로젝트에서 메인 언어로 사용되긴 힘들지만 역사가 깊고 속도가 빠르기 때문에 라이브러리나 임베디드 프로그램에서 여전히 사용되고 있습니다.
A.6 go doc
go doc 은 패키지 문서를 출력해주는 명령입니다.
go doc fmt
표준 패키지 뿐 아니라 우리가 만든 패키지 문서도 볼 수 있습니다.
패키지의 각 요소에 주석으로 설명을 추가할 수 있습니다.
작성 규칙은 간단합니다.
각 요소에 그 이름으로 시작하는 주석을 추가하면 됩니다.
// CharSize 상수 설명입니다.
const CharSize = 3
- go doc 으로 패키지 문서 만들기
// Package doc This is example package for
// explaining go doc. You can see the detail of go do with
// https://pkg.go.dev/golang.org/x/tools/cmd/godoc link.
package doc
import "fmt"
// CharSize 상수 설명입니다.
const CharSize = 3
const (
// CharColorRed 빨간색 - iota
CharColorRed = iota
// CharColorBlue 빨간색 - iota
CharColorBlue
// CharColorGreen 빨간색 - iota
CharColorGreen
)
// PrintDoc 문서 출력 함수 설명입니다.
func PrintDoc() {
}
// TextDoc 문서 작성 시 구조체 예제입니다.
type TextDoc struct {
// Msg 내부 메시지입니다.
Msg string
// size 를 나타냅니다.
size int
}
// NewTextDoc 외부로 공개되는 함수 설명입니다.
func NewTextDoc() *TextDoc {
return &TextDoc{}
}
// PrintDoc 외부로 공개되는 메서드 설명입니다.
// t.PrintDoc() 같이 사용합니다.
func (t *TextDoc) PrintDoc() {
fmt.Println("This is TextDoc PrintDoc method")
}
A.6.1 godoc 으로 문서 만들기
go doc 명령으로 패키지 문서를 볼 수 있지만 텍스트 형태로 출력되다 보니 사용하기 불편합니다.
Go 언어에서 공식으로 제공하는 godoc 툴을 이용하면 웹 페이지 형태로 문서를 볼 수 있습니다.
go get golang.org/x/tools/cmd/godoc
설치한 뒤 아래 명령을 실행합니다.
godoc -http=:6060
- 오류
godoc 명령어를 인식하지 못 할 경우 go/bin 폴더에 있는 godoc 실행파일을 실행한 후 터미널을 종료했다가 다시 켜보세요.
웹 브라우저를 열어서 http://localhost:6060 에 접속합니다.
- example_test.go
// PrintDoc() 함수에 대한 예제입니다.
func ExamplePrintDoc() {
fmt.Println("This is package level example")
}
// TextDoc 의 PrintDoc() 메서드에 대한 예제입니다.
func ExampleTextDoc_PrintDoc() {
fmt.Println("This is PRintDoc() example")
}
// TextDoc 에 대한 예제입니다.
func ExampleTextDoc_lines() {
fmt.Println("This is lines() example")
}
테스트 파일에 ExampleXxxxx() 형태로 예제 함수를 작성합니다.
메서드에 대한 예제는 타입명_메서드명 형태로 작성할 수 있습니다.
그래서 TextDoc 타입의 PrintDoc() 메서드에 대한 예제는 func ExampleTextDoc_PrintDoc() 형태로 만듭니다.
다시 godoc -http=:6060 을 실행합니다.
A.7 Embed
Go 1.16 버전에서 새로 추가된 기능으로 특정 파일들을 실행 파일 바이너리 안에 포함시켜서 파일 읽기 성능을 향상시키는 기능입니다.
주로 웹 서버에서 파일을 읽을 때 성능을 향상시키는 용도로 사용합니다.
실행 파일 안에 파일을 추가해서 웹 서버를 실행합시다.
- exA6.go
// static 폴더 아래 있는 모든 파일을 실행파일 내에 포함시킵니다.
//go:embed static
var files embed.FS // 1. 파일 추가
func main() {
http.Handle("/", http.FileServer(http.FS(files))) // 2. 파일서버 실행
http.ListenAndServe(":3000", nil)
}
- test.html
<html>
<body>
<img src="http://localhost:3000/static/gopher.jpeg" alt="">
<h1>이것은 Gopher 이미지입니다.</h1>
</body>
</html>
//go:embed 를 작성할 때 슬래쉬와 go 사이에 공백이 있으면 안됩니다.
- 장점
포함된 파일을 빠르게 읽을 수 있습니다.
- 단점
실행파일 크기가 늘어나고 메모리 사용량도 늘어납니다.
포함된 파일이 변경될 때마다 다시 빌드해줘야 합니다.
B. 생각하는 프로그래밍
B.1.1 상속
상속 (Inheritance) 이란 객체를 확장하여 새로운 객체를 정의하는 기능을 말합니다.
B.1.2 메서드 오버라이딩
메서드 오버라이딩 (method overriding) 이란 자식 객체에서 부모 객체의 메서드 기능을 변경하여 다시 정의하는 행위를 말합니다.
B.1.3 다이아몬드 상속 문제
다이아몬드 상속은 상속의 고질적인 문제로써 이 문제를 없애고자 많은 언어가 다중 상속 자체를 금지합니다. (자바도 다중상속을 금지합니다.)
B.1.5 상속은 강력한 의존 관계를 형성한다
Is-a 관계는 두 객체가 상속 관계를 맺고 있을 때이고
Has-a 관계는 두 객체가 포함관계를 맺고 있을 때입니다.
B.1.6 상속관계는 포함관계보다 더 의존적이다
상속보다 인터페이스를 이용한 포함을 하자.
B.1.7 정리: 상속은 양날의 검이다.
무분별한 상속은 의존성 문제를 일으켜서 유지보수를 어렵게 만드는 역효과가 있습니다.
B.2 구조체에 생성자를 둘 수 있나 ?
Go 언어에서는 구조체의 생성자 메서드를 지원하지 않습니다.
var scanner1 = bufio.NewScanner(os.Stdin)
var scanner2 = &bufio.Scanner{}
이렇게 패키지 외부로 공개되는 구조체의 경우 별도의 생성함수를 제공하더라도 패키지 이용자에게 꼭 생성함수를 이용하도록 문법적으로 강제할 방법은 없습니다.
해결책으로 구조체를 외부에 공개하지 않고 인터페이스만 공개하는 방법이 있습니다.
내부 구조체를 감추고 인터페이스를 공개함으로써 생성함수를 강제하는 패키지 예제를 살펴봅시다.
- account.go
type Account interface {
Withdraw(money int) int
Deposit(money int)
Balance() int
}
func NewAccount() Account {
return &innerAccount{balance: 1000}
}
type innerAccount struct {
balance int
}
func (a *innerAccount) Withdraw(money int) int {
a.balance -= money
return a.balance
}
func (a *innerAccount) Deposit(money int) {
a.balance += money
}
func (a *innerAccount) Balance() int {
return a.balance
}
- exB1.go
func main() {
account := bankaccount.NewAccount() // 1. 계좌 생성
account.Deposit(1000)
fmt.Println(account.Balance())
}
이 방법은 강제할 수는 있지만 일반적으로 많이 사용되는 방법은 아닙니다.
구조체를 생성할 때 특정 로직을 강제할 때만 사용하기 바랍니다.
B.3 포인터를 사용해도 복사가 일어나나 ?
Go 언어에서 변수 간 값의 전달은 타입에 상관없이 항상 복사로 일어납니다.
따라서 대입 연산자 = 는 우변의 값을 좌변 변수 (메모리 공간) 에 복사합니다.
B.3.1 배열과 슬라이스의 복사
배열은 복사되고 슬라이스는 주소값을 복사합니다.
B.3.2 함수 호출 시 인수값 전달
Go 언어는 함수 호출 시 인수값도 항상 복사로 전달됩니다.
맵과 채널 또한 포인터 필드를 가지고 있어서 포인터만 복사됩니다.
B.4 값 타입을 쓸 것인가 ? 포인터를 쓸 것인가 ?
B.4.1 성능에는 거의 차이가 없다
사실 Go 언어에서는 메모리를 많이 차지하는 슬라이스, 문자열, 맵 등이 모두 내부 포인터를 가지는 형태로 제작되어 있어서 값 복사에 따른 메모리 낭비를 걱정하지 않으셔도 됩니다.
그럼 왜 값 타입과 포인터를 구분하는게 중요할까 ?
B.4.2 객체 성격에 맞춰라
온도는 온도가 올라가면 다른 객체, 학생은 나이를 먹어도 같은 객체입니다.
time 패키지의 Time 객체는 값 타입을 사용합니다.
그에 반해 Timer 객체는 포인터로 사용합니다.
Timer 객체는 일정시간 이후 함수를 호출하거나 채널을 통해서 알림을 주는 객체입니다.
객체를 정의할 때 둘을 섞어 쓰기보다는 값 타입이나 포인터 중 하나만 사용하는 게 좋습니다.
B.5 구체화된 객체와 관계하라고 ?
Go 언어는 덕 타이핑을 지원하기 때문에 구체화된 객체로 빠르게 제작하고 유지보수가 필요할 대마다 기존 객체를 수정하지 않고 인터페이스만 추가해서 의존 관계를 역전시킬 수 있습니다.
즉 높은 생산성과 지속적 개선에 너무 잘 맞는 프로그래밍 언어입니다.
- 아이템 구매 기능을 담당하는 모듈
type Marketplace struct {
}
func NewMarketplace() *Marketplace {
// ...
}
func (m *Marketplace) PurchaseItem() *item.Item {
}
이 모듈을 사용할 때 굳이 인터페이스를 만들지 않고 Marketplace 객체를 바로 사용해서 빠르게 제작합니다.
- main
func main() {
mp := marketplace.NewMarketplace()
mp.PurchaseItem()
// ...
}
추후에 Marketplace 를 아래 패키지로 교체해야 한다고 해보겠습니다.
type MarketplaceV2 struct {
}
func NewMarketplace() *MarketplaceV2 {
// ...
return nil
}
func (m *MarketplaceV2) PurchaseItem() *item.Item {
return nil
}
이제 인터페이스를 만들어서 의존 관계를 역전할 때가 됐습니다.
Go 언어는 덕 타이핑을 지원하기 때문에 Marketplace 나 MarketplaceV2 를 수정할 필요 없이 패키지 이용자 쪽에서 인터페이스를 정의해서 사용할 수 있습니다.
type ItemPurchaser interface { // 인터페이스 정의
PurchaseItem() *item.Item
}
func main() {
var purchaser ItemPurchaser
purchaser = marketplaceV2.NewMarketplace() // 1
purchaser.PurchaseItem()
// ...
}
모듈 제공자가 아니라 모듈을 이용하는 쪽에서 인터페이스를 정의해서 사용할 수 있습니다.
만약 MarketplaceV2 의 인터페이스가 기존 Marketplace 와 다르더라도 어댑터 패턴* 을 적용해서 맞춰줄 수 있습니다.
위와 같이 Go 언어에서는 구체화된 객체를 바로 사용해서 빠르게 제작한 다음에 나중에 필요할 때 인터페이스를 통한 의존성 역전을 통해 지속적인 개선을 할 수 있습니다.
* 어댑터 패턴 (adapter pattern): 서로 다른 인터페이스를 맞춰주는 프로그래밍 패턴입니다.
B.6 Go 언어 가비지 컬렉터
B.6.1 가비지 컬렉터 알고리즘
표시하고 지우기 (mark and sweep) 은 단순한 알고리즘입니다.
삼색 표시 (tri-color mark and sweep) 는 메모리 블록에 색깔을 칠하는 방식입니다. (실제로는 0, 1, 2 로 표시합니다.)
객체 위치 이동 (moving object) 은 삭제할 메모리를 표시한 뒤 한쪽으로 몰아서 한꺼번에 삭제하는 방식입니다.
세대 단위 수집
generational garbage collection
B.6.2 Go 언어 가비지 컬렉터
Go 언어 1.16 버전은 동시성 삼색 표시 수집 방식을 사용합니다.
여러 고루틴에서 병렬로 삼색 검사를 한다는 뜻입니다.
- 장점
- 매우 짧은 멈춤 시간 (1ms 이하)
- 단점
- 추가 힙 메모리 필요
- 실행 성능이 저하될 수 있음
B.6.3 쓰레기를 줄이는 방법
1. 불필요한 메모리 할당을 없앤다.
슬라이스 크기 증가
요소 개수가 예상되는 슬라이스를 만들 때는 예상 개수만큼 초기에 할당해 불필요한 메모리 할당을 줄일 수 있습니다.
string 합산
문자열은 불변이기 때문에 strings.Builder 를 이용하는 게 좋습니다.
또 strings 패키지는 다양한 문자열 조작 기능을 제공하고 있으니 새로 만드는 것 보다 strings 패키지를 이용하는 걸 추천합니다.
2. 재활용
메모리 쓰레기 역시 재활용할 수 있습니다.
자주 할당되는 객체 풀 (pool) 에 넣었다가 다시 꺼내쓰면 됩니다.
이것을 플라이웨이트 (flyweight) 패턴 방식이라고 합니다.
3. 80대 20 법칙
경제학자인 벨프레도 파레토가 한 말이라서 파레토 법칙이라고 합니다.
메모리의 80% 는 20% 의 객체가 차지한다 이렇게 적용할 수 있습니다.
각종 프로파일링 툴을 사용하면 알아낼 수 있습니다.
go test -cpuprofile cpu.prof -memprofile mem.prof -bench .
이 명령으로 벤치마크를 수행해서 사용되는 cpu 사용량 데이터와 메모리 사용량 데이터를 얻어서 분석할 수 있습니다.
또 datadog 나 google cloud profiler 등을 통해서 실제 서비스되는 클라우드 머신의 성능을 분석할 수 있습니다.
B.7 Sort 동작 원리
[]int 슬라이스는 위 세 메서드를 포함하고 있지 않습니다.
위 예제에서는 sort.IntSlice(s) 를 사용해서 []int 타입 변수인 s 를 sort.Interface 를 포함한 타입으로 변환합니다.
func main() {
s := []int{5, 2, 6, 3, 1, 4}
sort.Sort(sort.IntSlice(s))
fmt.Println(s)
}
- The Ultimate Go Study Guide: https://ultimate-go-korean.github.io/translation/
- 유튜브: https://www.youtube.com/c/tuckerprogramming
- Go 언어 오픈소스 패키지
- 깃허브에 있는 Go 언어 오픈소스 프로젝트
- 관심있는 프로젝트 코드를 직접 살펴보면 실력 향상에 큰 도움이 될 겁니다.
- 만약 그런 프로젝트에 기여까지 하신다면 취업이나 이직에도 큰 도움이 되겠죠.
10/4 (화) 이 책을 완독했다. 🧐
'Backend > 노트' 카테고리의 다른 글
Hands-On Full stack development with Go (1) | 2022.10.07 |
---|---|
한 번에 끝내는 Node.js 웹 프로그래밍 초격차 패키지 Online - 2 (1) | 2022.09.20 |
한 번에 끝내는 Node.js 웹 프로그래밍 초격차 패키지 Online (0) | 2022.07.06 |
🍀스프링부트 (0) | 2022.03.29 |
DB 연결 (0) | 2022.03.19 |