[Go] Golang 알아보기

좀 바보같이 생겼지만 귀엽다

개요 

 C, Java, Kotlin에 이어 4번째로는 Go를 공부해보려고 한다. 필자는 이전에 공부했던 3개의 언어를 기반으로 Go를 새로 배우는 입장이라, Java나 C와 비교하며 이해해보자.

 

 

특징

 Go는 C와 C++, Java, Python 등의 장점을 뽑아 개발되었는데, 다시 말하면 절차지향 언어와 객체지향 언어의 특징을 모두 가지고 있다. Go가 가지는 대표적인 특징들은 다음과 같다.

 

- 적은 키워드 (25개의 키워드 - Java의 절반 수준)

- 정적 타입 (자료형에 타입이 정해져 있음)

- 명시적 형변환 (명시적 타입 캐스팅 필요)

- 안전성 (타입, 메모리 안전성)

- 병행성 (스레드와 비슷한 고루틴이라는 개념을 사용하여 스레드 개수를 빡빡하게 관리하지 않아도 됨)

- 가비지 컬렉션 (Go Runtime이 메모리를 핸들링)

- 빠른 컴파일 (인터프리터 언어에 근접한 수준의 빠른 속도)

- 포인터 존재 (하지만 포인터 연산은 없음)

- 제한적 객체지향 (클래스, 상속, 생성자, final, 제네릭이 존재하지 않음)

- 풍부한 라이브러리

 

자바의 GC, C의 포인터 등 여러가지 특징들이 혼합되어있는 것을 확인할 수 있다.

 

문법

Go의 문법은 C와 유사한 점도 있으나, 상당히 특이한 구조를 갖는다. Go라는 언어의 특징 중 하나는 선언만 하고 사용하지 않았다면 컴파일 에러가 발생한다는 점인데, 앞으로 설명할 변수 뿐 아니라 패키지, 함수 등 모든 부분에서 동일하게 적용된다. Go는 이를 통해 아무 이유 없이 메모리를 차지하는 현상을 방지한다.

 

변수의 선언, 지역/전역성

[변수명] [변수형]
var s string = "str"
var i, j int = 10, 11
b := true

 

 Go의 변수, 상수 등은 일반적인 [변수형] [변수명]의 형태가 아니라, var이라는 변수를 나타내는 키워드 다음에 [변수명] [변수형]과 같이 이름이 먼저 오고 타입이 그다음에 오는 형태를 띈다. 이러한 선언 방식은 변수 뿐 아니라 상수 등 다양한 범위에 적용된다.

 

추가로 go에는 'Short Assignment Statement'라고 불리는 :=라는 변수 선언법이 존재하는데, 이것을 선언하면 별다른 형 선언 없이 타입 추론이 가능하다. 단, 이 용법은 함수 내에서만 사용이 가능하다.

 

지역/전역성

Go에서 사용되는 변수의 지역성/전역성은 Java나 C와 크게 다르지 않다. 함수 블록 내에서는 동일한 이름일 경우 지역변수가 우선적으로 유효성을 가지며, 전역변수는 메모리에 올라와서 프로그램이 종료될 떄까지 공간을 차지하고 있게 된다.

 

상수 선언

상수는 크게 두 가지 방법으로 초기화할 수 있다. 이러한 상수는 변수와 다르게 반드시 선언과 동시에 초기화해야 한다.

const username = "kim"

첫 번째는 const 키워드를 이용하는 것이고,

 

const(
    name = "kim"
    kim
    age = 10
    ...
)

두 번째는 const 블록을 이용하는 방법이다. 이렇게 묶어서 선언할 경우 선언되지 않은 값은 바로 전 상수의 값을 그대로 가진다. 따라서 위 예시에서 kim이라는 변수의 값은 "kim"이 된다.

 

 

연산자

기본적인 내용이므로 다른 언어와의 큰 차이점 3가지만 정리하면,

1. 전위 연산이 불가능하다.

num := cnt++ (X)

 

2. 증감 연산자를 사용하고 동시에 대입할 수 없다.

++cnt (X)

 

3. 논리 연산자에는 bool type만 사용 가능하다.

var a int = 1
!a (X)

 

 

문자열

 Go의 문자열(String)은 다른 언어와 유사하게 immutable하며, 값을 수정할 수 없다는 특징을 가진다. 또한 크게 두 가지로 문자열을 선언할 수 있는데, 첫 번째로는 back quote를 이용한 방법(`string` - ('string'과 다름))이며, 두 번째는 쌍따옴표를 이용한 방법("string")이다.

 

- 첫 번째 방법은 감싼 문자열을 어떤 글자던 문자열 자체로 인식한다. 즉, (`\n`)은 \n이라는 문자열 그대로를 인식한다.

- 반면, 두 번째 방법은 escape문자와 같은 특정 문자열은 특별한 의미로 해석하는, Java 등의 기존 언어 방식과 동일한 해석 방법이다.

 

 

명시적 타입 캐스팅

 앞서 언급했듯이, Go는 명시적 타입 캐스팅이 필요한 언어이다. Go에서 타입 캐스팅 문법은 '타입(변수)'와 같이 할 수 있다.

var num int = 10
var changef float32 = float32(num) //int형을 float32형으로 변환
changei := int8(num)               //int형을 int8형으로 변환

 

 

또한, 다음과 같은 코드는 런타임 에러를 발생시킨다.

var num1, num2 int = 3,4
var result float32 = num1/num2

 

다른 언어라면 int형 변수끼리 /연산시 정수 나누기를 할 수 있지만, Go는 암묵적 타입 캐스팅을 허용하지 않으므로 num1/num2의 타입인 int형과 result변수의 타입인 float32가 일치하지 않기 때문에 런타임 에러가 발생한다.

 

 

반복문과 조건문

Go의 반복문과 조건문은 다른 언어의 그것과 크게 다르지 않지만, 크게 아래에 나열한 차이가 존재한다.

 

1. for문의 괄호는 생략한다.

for i:=0; i<10; i++ {
    sum+=i
}

 

2. while문이 존재하지 않지만, 조건식만 사용한 for문을 이용하여 동일하게 활용 가능하다.

for n<100 {
    sum+=n
}

 

3. 무한루프는 for문의 조건을 생략하는 방법으로 표현할 수 있다.

for {
    fmt.Print("print forever")
}

 

4. 조건문에서는 괄호를 사용할 수 있지만, 일반적으로 생략한다.

5. 조건문에서는 반드시 중괄호를 사용해서 조건 블록을 표시해야 한다 (다른 언어와 다르게 한 줄일경우 생략 불가능하다.)

6. if-else문 사용시, 조건문의 블록과 else 사이에 개행이 허용되지 않는다.

7. 조건문 앞에 간단한 문장을 실행할 수 있다.

num:=1
if val:=num*2; val==2 {
    fmt.Print("case A")
} else {
    fmt.Print("case B")
}

 

'val:=num*2'라는 간단한 문장을 실행한 후, 조건문을 확인하고 있다. 이는 if절 뿐 아니라 else, switch, for 등 다양하게 활용할 수 있다고 한다. (하지만 내 생각에는 가독성에 좋지 않아서 거의 사용되지 않을 것이라고 생각된다)

 

8. for range문이 존재한다.

var arr [6]int = [6]int{1, 2, 3, 4, 5, 6}

for index, num := range arr {
    fmt.Printf("arr[%d]의 값은 %d입니다.\n", index, num)
}

 

for-each문과 비슷한 for range문이 존재하는데, 컬렉션에 들어가 있는 데이터 뿐 아니라 인덱스값 또한 활용할 수 있다.

 

 

컬렉션

배열

Go에서의 배열은 고정 크기 배열을 의미한다. 이러한 배열은 다음과 같이 선언할 수 있다.

var 배열명 [배열크기]자료형

 

명명을 타입보다 먼저 선언하는 것은 Go의 기본적인 방향성과 동일한데, 배열 크기를 자료형 앞에 쓰는것은 상당히 이상해 보인다. 이는 Go 언어에서 배열의 크기 자체가 자료형을 결정하는 또 다른 요소임을 함축하고 있다. 예를 들어, C나 Java에서 크기가 3짜리 int형 배열과, 크기가 5인 int형 배열은 동일한 int[] 형이지만, Go에서는 [3]int와 [5]int로 전혀 다른 타입임을 말하는 것이다.

 

var arr1 [5]int // 길이가 5인 int형 배열 선언하기
arr1 = [5]int{1,2,3,4,5} // 배열 초기화
arr2 := [4]int{1,2,3,4} // 선언과 동시에 초기화
var arr3 = [...]int{1,2,3} // [...]을 이용하여 배열 크기 자동설정

 

이러한 배열은 위와 같은 방법들로 초기화 할 수 있다.

 

슬라이스

고정 크기 배열이 아닌, 동적으로 크기를 변경할 수 있는 슬라이스(Slice)라는 자료구조도 존재한다.

var 슬라이스명 []자료형
make([]int, 0, 3) // 길이가 0인(초기화되지 않은 배열)
make([]int, 3) // 길이, 용량이 3인(초기화된) 배열

 

선언 방식에서 배열과 가장 큰 차이는 크기가 명시되지 않았다는 점이다. 배열의 경우 명시한 크기만큼 메모리가 할당되지만, 슬라이스는 동적 배열을 가리킬 포인터를 생성해주게 된다. (따라서 복사 시 얕은 복사가 이루어지게 된다)

 

슬라이스는 선언될 배열을 가리키는 포인터인 ptr, 배열의 길이를 저장하는 len, 전체 크기(용량)인 cap이라는 3가지 정보를 갖는다. len은 배열에 실제 들어가있는 element의 개수를 말하고, cap은 element들을 저장하기 위해서 할당된 메모리의 크기를 말한다. 

 

슬라이스는 자바의 ArrayList와 유사하게 동작한다. 길이가 cap인 고정 크기 배열에 데이터들을 할당하고, 슬라이스에 포함되어야 하는 element의 수(len)이 cap보다 커진다면 더 큰 크기의 배열에 element들을 복제하는 방식으로 동작하는 것이다. 

 

슬라이스를 다루기 위해서 append(), copy()라는 함수들이 존재한다.

 

- append()

sliceA:=[]int{1,2,3}
sliceB:=[]int{4,5,6}
sliceA = append(sliceA, sliceB...) // sliceA 뒤에 sliceB 붙이기

append 함수를 이용하여 sliceA 뒤에 sliceB에 들어가 있는 원소들을 확장하였다.

 

- copy()

sliceA:=[]int{0,1,2}
sliceB:=make([]int, len(sliceA), cap(sliceA)*2)
copy(sliceB, sliceA)

copy함수를 이용하여 sliceB에 sliceA를 붙여넣었다. copy함수는 

붙여넣을 슬라이스:=복사할 슬라이스[복사 시작 인덱스 : 복사 종료 인덱스+1]

과 같이 선언해도 동일하게 동작한다.

 

key:value 형태로 값을 매핑해서 저장할 수 있는 자료구조이다. 자바의 그것과 유사하다고 볼 수 있다.

var [맵명] [key 자료형]value자료형

 

var m = make(map[string]string)
m["02"] = "서울특별시"
m["031"] = "경기도"

m["031"] = "양강도" // 존재하는 key값이므로 갱신
delete(m, "031") // 해당 키에 해당하는 데이터 삭제

 

 

 

함수

func [함수명] ([매개변수명] [매개변수타입])[반환타입]

 

Go에서 함수를 선언하기 위해서는 func라는 키워드를 사용하면 된다. 함수선언 역시 함수명이 먼저 온 후에 매개변수 정보와 반환 정보가 나타나는 Go의 일반적인 선언 순서를 따른다.

 

C, Java등 보편적인 언어와의 차이점은 다음과 같다.

 

1. C와 같은 절차지향 언어와 다르게 호출되는 함수가 호출하는 함수 앞에 선언되어야 할 필요는 없다.

2. 반환값이 여러개일 수 있다.

func input() (int, int) { //반환 값 2개
    var a, b int
    fmt.Scanln(&a, &b)
    return a, b
}

func main() {
    n1, n2 := input()
    ...
}

-> int형 2개를 반환값으로 주는 함수를 활용할 수 있다.

 

3. 리턴값에 이름을 붙일 수 있다(Named Return Parameter).

// 반환값에 count, list라는 이름을 명시
func dessertList(fruit ...string) (count int, list []string) { 

    for i := 0; i < len(fruit); i++ { // count, list는 내부에서 이미 선언된 효과를 가진다.
        list = append(list, fruit[i])
        count++
    }

    return // named parameter 사용시 생략 불가능
}

-> 특별한 것은 아니고, 리턴값을 미리 선언할 필요 없게 할 수 있는 문법이다. 사용 빈도가 높지는 않을 것이라고 생각된다.

 

4. 일급 함수

Go는 함수를 기본 타입과 동일하게 사용할 수 있어, 함수를 매개변수나 리턴 값으로 사용할 수 있는데, 이를 일급 객체, 일급 함수라고 한다. (Java와 동일하다)

 

매개변수

Pass by value는 인자의 값을 복사해서 전달하는 방법이고, Pass by reference는 값의 참조를 전달하는 방법을 말한다. Go는 기본적으로 인자의 값을 복사하여 전달한다 - 즉, pass by value를 사용한다. 

 

func printSqure(a int) {
    a *= a
    fmt.Println(a)
}
func main() {
    a := 4 //지역변수 선언
    printSqure(a)
    fmt.Println(a)
}

 

위와 같은 간단한 예제를 사용하여 pass by value임을 확인할 수 있다.

 

Go는 포인터 연산을 지원하기 때문에, C와 유사하게 pass by reference 방법을 모방할 수 있다. 단, Go는 C와 다르게 복잡한 용법을 쓸 필요가 없다(예를 들어 배열명이 배열의 첫 번째 인덱스의 주소를 가리켜서 scanf와 %s를 사용할 때 배열명에 &연산자를 붙일 필요가 없다던가, *(배열명+idx)가 배열의 idx번째 element를 의미한다던가).

 

Go를 사용할 때는 딱 2가지만 기억하면 된다. '&'는 주소를 나타내고, '*'는 직접참조를 나타낸다.

 

func printSquare(a *int) {
    *a *= *a
    fmt.Println(*a)
}

func main() {
    a:=4
    printSquare(&a)
    fmt.Println(a)
}

 

이렇게 파라미터로 a의 주소값을 넘겨주면, call by ref처럼 주소값을 이용하여 변수의 값을 변경할 수 있다.

 

일급 함수와 type문

앞서 Go의 함수는 일급 함수이며, 따라서 매개변수로 사용할 수 있다고 언급했다. 아래의 예시를 보자. 

func calc(f func(int, int) int, a int, b int) int {
	result := f(a, b)
	return result
}

func main() {
	multi := func(i int, j int) int {
		return i * j
	}
	
	r1 := calc(multi, 10, 20)
	fmt.Println(r1)

	r2 := calc(func(x int, y int) int { return x + y }, 10, 20)
	fmt.Println(r2)
}

 

calc라는 함수는 두 번째, 세 번째 파라미터로 들어온 int값 a, b를 첫 번째 파라미터로 들어온 함수 f에 대입한 후 그 결과를 반환한다. main함수에서는 x,y를 파라미터로 받고, int값을 반환하는 익명 함수를 calc의 첫 번째 파라미터에 넣어주고 있다. 또한 그 위쪽에서는 아예 익명함수 자체가 multi라는 변수에 초기화되는 것을 볼 수 있다. 이 또한 함수가 일급 함수이기 때문에 가능한 것이다.

 

위 코드는 아무런 문제가 없다. 하지만 함수가 매개변수로 들어올 수 있다는 것을 표현하기 위해서 타입정보에 func(int, int) int와 같이 표현하는 것은 그것이 가지는 의미를 파악하기 쉽지 않고, 실수할 여지 또한 충분해 보인다. 따라서 type문을 사용하여 이를 예방할 수 있다.

 

 

type문은 C의 typedef와 유사하다.

 

//함수 원형을 type으로 정의
type calculatorNum func(int, int) int 
type calculatorStr func(string, string) string

func calNum(f calculatorNum, a int, b int) int {
	result := f(a, b)
	return result
}

func calStr(f calculatorStr, a string, b string) string {
	sentence := f(a, b)
	return sentence
}

func main() {
	multi := func(i int, j int) int {
		return i * j
	}
	duple := func(i string, j string) string {
		return i + j + i + j
	}

	r1 := calNum(multi, 10, 20)
	fmt.Println(r1)

	r2 := calStr(duple, "Hello", " Golang ")
	fmt.Println(r2)
}

 

type을 사용하여 각각의 형태를 갖는 함수형을 명명했고, 그 결과 calNum과 calStr의 매개변수 부분을 살펴보면 가독성이 올라가서 그 의미를 이해하기 쉽게 변했음을 확인할 수 있다.

 

클로저

 클로저란 함수 안에서 익명 함수를 정의하여, 바깥쪽 함수에 선언된 변수에 접근할 수 있는 함수를 말한다. 함수 안에서 함수를 정의하기 위해서는 익명 함수를 사용해야 하는데, 이 때 익명 함수에서는 외부에 정의된 함수의 변수를 그냥 접근할 수 있다.

 

func main() {
	a, b := 10, 20
	
	result := func () int{
		return a + b 
	}()
}

 main함수 내부에 선언된 익명함수에서 main함수에 선언된 변수들에 접근하고 있는 것을 볼 수 있다.

 

func next() func() int {
	i := 0
	return func() int {
		i += 1
		return i
	}
}

 

 next함수는 [int를 반환하는 익명 함수]를 반환하고 있다. 이렇게 반환된 익명 함수에서는 next함수에 선언된 지역변수 i의 값을 증가시킨 후, 그 값을 반환한다. 이것은 단순히 '함수를 반환되었다'라는 개념적 이해에서 끝나지는 않는다. 클로저가 반환되면, 외부 함수는 일반적인 함수들과 다르게 반환된 이후에도 그 메모리 상태를 유지하게 된다.

 

func main() {
	nextInt := next()

	fmt.Println(nextInt())
	fmt.Println(nextInt())
	fmt.Println(nextInt())

	newInt := next()
	fmt.Println(newInt())
}

 

 main함수에서 next()가 호출되어 i가 0으로 초기화된 새로운 클로저가 생성되고, nextInt 변수에 할당된다. 이후 nextInt를 반복 호출하여 콘솔에 print해보면, 찍을 때마다 수가 1씩 증가하는 것을 확인할 수 있다. 이는 곧 클로저를 포함하는 외부 함수(next)의 메모리가 해제되지 않았음을 의미한다. 이는 무슨 의미를 가질까? 

 

1. 캡슐화

 클로저를 반환하는 함수에 존재하는 변수에 접근하기 위해서는, 해당 변수에 접근하는 클로저가 필요하다. 이를 통해 높은 수준의 은닉성을 보장할 수 있다.

2. 메모리 해제 시점

 모든 클로저에 대한 참조가 해제될때까지 메모리는 유지된다. 즉, GC는 참조 카운팅을 통해서 모든 참조가 해제되어야 메모리를 해제한다. 이것에 관련된 문제에 대한 포스트(https://medium.com/code-zen/why-gos-closure-can-be-dangerous-f3e5ad0b9fce)도 존재한다.

 

 

구조체

type [구조체명] struct {
    [필드명1] [필드속성1]
    [필드명2] [필드속성2]
    [필드명3] [필드속성3]
}

 

 구조체는 C의 구조체와 거의 동일하다. 하지만 Go라는 언어가 전통적인 객체지향의 특징인 클래스, 객체, 상속이라는 특징을 가지고 있지 않기 때문에 필드와 메소드를 함께 갖는 Java의 클래스와는 다르게, Go의 구조체는 필드에 대한 정보만을 가지고, 메소드는 별도로 분리하여 정의된다.

type person struct {
	name string
	age int
	contact string
}

 

Go에서 구조체는 기본적으로 mutable 객체이다. 즉, 구조체를 선언해두고 나중에 초기화할 수도 있고, 값을 수정하면 새 개체를 만드는 것이 아니라 해당 개체의 메모리에 접근하여 값을 직접 변경하게 된다.

 

이러한 구조체를 생성하는 방법은 크게 두 가지가 있는데, 가장 먼저 구조체 생성자를 이용하여 생성하는 방법(p1, p2), new 키워드를 사용하는 방법(p3)이다.

p1 := person{} // 빈 구조체 생성
p2 := person{"name!", 31, "01012341234"} // 값 할당된 구조체 생성
p3 := new(person)    // 포인터 구조체 객체 생성

 

이때 p3처럼 new키워드를 사용하여 포인터를 생성할 경우, p3는 구조체 객체가 아니라 구조체 객체를 가지키는 포인터가 된다. 즉, 포인터 구조체 객체는 구조체 객체에 주소를 나타내는 연산자 &를 붙인것과 동일하다.

 

또한, 구조체에는 생성자 기능이 있는데, 구조체 안에 들어가는 필드중에 초기화가 필요한 경우 이를 사용하여 초기화 후 실행되도록 할 수 있다.

type mapStruct struct {
    data map[int] string
}

func newStruct() *mapStruct { // 구조체를 가리키는 포인터 반환할 예정
    d := mapStruct() // 구조체를 생성한 뒤에
    d.data = map[int]string{} // 내부 map타입 필드에 접근하여 초기화
    return &d // 생성한 구조체의 주소 반환
}}

 

 

메서드

 앞서 구조체가 하나 이상의 변수를 묶어서 새로운 타입을 정의하는 것이라고 했다. 클래스 내부에 관련된 메서드가 선언되는 자바와 다르게 Go는 구조체 내부에 함수가 선언되지는 않는다. 하지만 특정 구조체의 특정 속성들의 기능을 수행하기 위해서 만들어지는 함수를 메서드라고 한다.

func ([매개변수명] [구조체명]) [메서드명] ([매개변수명] [매개변수타입]) [반환타입]

 

함수의 일종이라고 볼 수도 있지만, 구조체의 필드를 이용해서 특정 기능을 하기 위한 함수인 만큼 선언형 자체에 차이가 있다. func 키워드 바로 뒤에 구조체를 명시해주고 활용할 수 있도록 했다. 이렇게 함수명 앞에 타입과 변수명이 괄호로 명시된 부분은 리시버(Receiver)라고 부르며, 이렇게 리시버가 붙은 함수를 메서드라고 한다.

 

func (s triangle)triArea() float32 { //value receiver
	return s.width * s.height / 2
}

func (s *triangle)triArea() float32 { //pointer receiver
	return s.width * s.height / 2
}

 

trinagle이라는 구조체에 담긴 width, height정보를 사용하여 원하는 값을 도출할 수 있는 메서드이다. 리시버에는 이렇게 value receiverpointer receiver라는 두 가지 종류가 있는데, 가장 큰 차이는 구조체의 필드 값에 직접 접근하는지 여부이다.

 

func main() {
	tri := triangle{12.5, 5.2}
	triarea := tri.triArea()
}

 

이렇게 생성한 메서드는 별도로 구조체를 넣어 호출할 필요 없이, 위와 같이, 마치 Java에서 인스턴스를 통해 메서드를 호출하듯 구조체로부터 호출 할 수 있다.

 

 

용법

defer

 defer란 Golang에서 제공하는 지연 처리 용법이다. 이것은 함수 앞에 쓰이는 용법으로써 함수가 종료되기 직전 반드시 실행하게 되는 구문을 말한다. 정상적인 종료뿐만 아니라 예외가 발생했을 때에도 실행되는데, Java의 try-catch-finally문이 함수 단위로 적용된다고 이해하면 된다.

 

func main() {
    defer fmt.Println("world")
    fmt.Println("Hello")
}

 

아주 간단한 예시이다. defer문을 통해 함수블록의 시작 부분에 'world'라는 단어를 출력하는 문장이 추가되었기에, 함수 본체에서 발생하는 예외 등과 상관없이 defer문은 반드시 수행된다. 이러한 특성을 이용하면 파일을 열고 닫을 때와 같이 리소스를 정리하는데 많이 활용할 수 있다.

 

이러한 defer문은 함수 내에서 선언된 역순으로 실행된다. 즉, 스택의 LIFO방식과 동일하다고 이해하면 된다.

 

panic

 defer와 정반대로, panic은 프로그램이 실행되면서 오류가 발생하여 프로그램을 종료하는 기능을 말한다. 자바에서 발생하는 RuntimeExecption과 유사하다고 생각하면 된다. 일반적으로는 런타임 중에 의도치 않게 발생하지만, panic()함수를 사용하여 직접 에러를 발생시킬 수도 있다. (Java의 throw Exception과 유사하다고 보면 되겠다)

 

recover

func panicTest() {
	defer func() {
		r := recover() //복구 및 에러 메시지 초기화
		fmt.Println(r) //에러 메시지 출력 
	}()
	
    var a = [4]int{1,2,3,4}
    
    for i := 0; i < 10; i++ { //panic 발생
        fmt.Println(a[i])
    }       
}

 

Recover 함수는 panic 상황에서 프로그램을 종료하지 않고 예외처리를 하는 것이다.

 

Java에서 try-catch구문과 비슷한 역할을 하는데, try-catch의 경우 발생하는 예외가 블록 안에 있어야 하지만 Golang의 경우 defer문을 사용하여 오류가 발생할 경우에 곧바로 예외를 처리할 수 있게 할 수 있다.

 

func main() {
	defer func() {
		if r := recover(); r!=nil {
			fmt.Println(r)
			main()
		}
	}()
    ...
}

 

이와 같은 방식으로 익명함수를 이용하여 main에서 실행된 함수에서 panic이 발생하면 main함수를 재실행하게 만드는 방법도 존재한다.

 

 

Error

 Go에서 입출력 처리를 위해서 많이 사용되는 fmt패키지를 살펴보면, 반환값으로 int형과 error형 하나씩이 존재한다. int형은 입력한 문자열의 개수, 두 번째는 에러 값을 의미한다.

https://pkg.go.dev/fmt

 

https://go.dev/blog/error-handling-and-go

 

정확하게 말하면 이 error형이라는 것은 타입은 아니고, Error()라는 함수 하나를 갖는 인터페이스이다. 

 

이러한 에러는 주로 log라는 패키지를 활용해서 로그로 기록하게 된다. 이 패키지에는 여러 종류의 로깅을 위한 패키지가 존재하는데, 동작 방법의 차이가 존재한다.

 

func Print(v ...any)

1. Print 함수는 단순히 에러 메시지를 출력한다.

func Panic(v ...any)

 

2. 'Panic is equivalent to Print followed by a call to panic().'이라고 공식 문서에 나와있다. 로그를 찍고 panic()을 호출, 즉 에러를 던지고 프로그램을 종료한다.

func Fatal(v ...any)

3. 'Fatal is equivalent to l.Print() followed by a call to os.Exit(1).' - 로그를 찍고 Exit함수를 통해 프로그램을 정상 종료한다.

 

 

GoRoutine

 Java를 사용할 때, 멀티 스레드 환경에서 비동기 메서드를 호출할 수 있었다. 이를 위해 일반적으로 main함수에서 Thread를 이용해서 여러 개의 스레드를 실행하고, 여러 스레드에서 동시에 메서드를 시작할 수 있었다.

 

Golang은 GoRoutine이라는 논리적 가상 스레드를 사용하여 비동기 프로세스를 구현한다. 이러한 GoRoutine은 모든 작업에 스레드를 1개씩 대응시키는 것이 아니라, 훨신 적은 스레드를 사용하여 효율적으로 메모리를 관리한다.

 

GoRoutine 사용을 위해서는 함수 실행 시점에 앞에 go 키워드를 붙여주면 된다.

 

func testGo() {
	fmt.Println("Test")
}

func main() {
	go testGo()
}

 

main함수에서는 testGo를 동기가 아닌, 비동기 방식으로 실행한다. 따라서 위 예제를 수행하면, testGo()가 완료되기 전에 main()이 먼저 반환되어 문자열이 출력되지 않는다.

 

WaitGroup

몇 달 전에 스프링을 활용한 비동기를 공부했을 때, Future를 활용하여 비동기적 호출의 반환을 기다리는 예시를 본 적이 있다.

 

...
// execute하기 전에 다른 스레드가 다른 connection을 주입하는 것을 방지하기 위해 동기화
private static synchronized void setConnAndExecute(ExecutorService threadPool, Socket connection, RequestHandler handler) {
    logger.debug("connection = " + connection);
    handler.setConnection(connection);

    Future<?> future = threadPool.submit(handler); // 쓰레드를 할당하고 비동기적으로 작업 배치
    try{
        future.get(); // Future.get()은 블로킹 방식으로 동작
    } catch (ExecutionException | InterruptedException e) {
        logger.error(e.getMessage(), e);
    }
}
...

 

Golang도 sync 패키지의 WaitGroup을 사용하여 비동기 함수의 반환을 기다릴 수 있다. WaitGroup은 'sync' 패키지에 선언된 구조체로서, 변수로 선언하여 비동기 호출을 제어할 수 있다.

 

func hello(n int, w *sync.WaitGroup) {
    defer w.Done() //끝났음을 전달
    
    r := rand.Intn(3)
    
    time.Sleep(time.Duration(r) * time.Second)
    
    fmt.Println(n)  
}

func main() {
    wait := new(sync.WaitGroup) //waitgroup 생성
    
    wait.Add(100) // 100개의 고루틴을 기다림
    
    for i := 0; i < 100; i++ {
            go hello(i, wait) //wait을 매개변수로 전달
    }   
    
    wait.Wait() // 고루틴이 모두 끝날때까지 대기
}

 

wait이라는 변수에 WaitGroup 구조체를 할당하고 지연 처리 용법 defer를 사용하여 hello함수가 종료되면 인자로 전달된 WaitGroup 변수의 Done() 함수가 호출되도록 한 뒤, main함수에서는 Wait() 함수를 사용하여 고루틴으로 실행시킨 비동기 로직이 종료되기를 대기하였다. 이때 wait.Add()를 통해 100개의 고루틴만을 기다리도록 설정하였다.

 

.Add() : 기다릴 고루틴의 수 설정
.Done() : 고루틴이 실행된 함수 내에서 호출하여 함수 호출이 완료되었음을 알림
.Wait() : 고루틴이 모두 끝날때까지 차단

 

func main() {
    var wait sync.WaitGroup
    wait.Add(102)
 
	str := "world!"
	
    go func() {
        defer wait.Done()
        fmt.Println("Hello")
    }()
	
	go func() {
        defer wait.Done()
        fmt.Println(str)
    }()
 
	for i := 0; i<100; i++ {
		go func(n int) {
			defer wait.Done()
			
			fmt.Println(n)
		}(i)
	}
 
    wait.Wait()
}

 

익명함수를 사용한 예시이다. 비동기로 익명함수를 실행하고, 각 함수의 반환값마다  defer와 wait.Done()을 통해서 모든 비동기 함수 호출이 완료되기를 기다릴 수 있다.

 

 

Channel

 가상 스레드를 호출하여 비동기적으로 작업을 진행하는 고루틴간의 순서를 지정하기 위한 방법이다. 앞서 소개한 WaitGroup의 경우에도 고루틴의 개수를 기준으로 종료를 대기할 뿐, 특정 고루틴간의 흐름을 제어하지는 못한다. 이를 위해 사용할 수 있는 것이 채널(Channel)이다.

 

채널은 고루틴 사이에서 값을 주고받는 통로 역할을 하여 송/수신자가 서로를 기다리게 만들어 흐름을 제어할 수 있다. 

make(chan 데이터타입)
func main() {
	var a, b = 10, 5
	var result int
	
	c := make(chan int)
	
	go func() {
		c <- a + b
	}()
	
	result = <-c
	fmt.Printf("두 수의 합은 %d입니다.", result)
}

 

만약 채널이 존재하지 않았다면, 익명함수가 실행되어 result에 a+b의 값이 들어가기전에 Printf문이 실행되어버릴 것이다.

 

하지만 현재 make 함수를 이용하여 채널을 생성하고, 익명 함수의 결과를 채널에 받은 후 result변수에 저장하고 있다. 따라서 result에 채널에서 나오는 값을 받기 위해 main스레드는 대기하게 되고, 익명함수를 실행하는 스레드로부터 a+b값을 받은 후에야 Print문이 호출된다.

 

여기서 주의할 점이 하나 있는데, 채널은 고루틴 자체를 이어주기보다 플로우를 이어준다는 것이다.

func main() {
    var a, b, rst int
    c := make(chan int)
    fmt.Scanln(&a, &b)
    fmt.Println("main start")
    go func() {
       c <- a + b
       time.Sleep(2 * time.Second)
       fmt.Println("do sth important")
    }()
    rst = <-c
    fmt.Println("main end, rst = ", rst)
    return
}

 

익명 함수에서 2초를 기다리기 전에 채널 c에 a+b의 값을 넣었기 때문에, 익명 함수는 끝까지 실행되지 못한 채 main함수가 종료되어 프로그램이 종료되었다. 사실상 고루틴이 아니라 데이터 송/수신 시점이 함수 대기 시점과 관련있다라는 것을 인지해두자

 

 

Buffer - 동기와 비동기 채널

채널의 재미있는 특징이 또 하나 있는데, 채널은 동시성 문제 방지를 위해 블로킹 방식으로 동작한다는 것이다. 즉, 송신자는 수신자가 데이터를 받기 전까지 블로킹되고, 수신자는 송신자가 데이터를 보낼 때까지 블로킹된다.

func main() {
    c := make(chan string)

    c <- "Hello goorm!"

    fmt.Println(<-c)
}

 

언뜻 보면 채널 c에 string이 들어가고, 다음 문장에 해당 string을 채널에서 꺼내 출력하므로 아무런 문제가 발생하지 않을 것으로 보인다. 하지만 송신(main)자는 수신자가 데이터를 받기를 기다린다. 따라서 Print문은 실행될 수 없어 데드락이 발생하게 된다.

 

이를 해결하기 위해서는 비동기 채널, 버퍼(Buffer)를 사용하면 된다. 버퍼 사용시 채널에 데이터를 넣으면 값이 버퍼에 임시로 저장되고, 채널에서 데이터를 제거하면 버퍼에서 값을 꺼내오게 된다.

func main() {
    c := make(chan string, 1)

    c <- "Hello goorm!"

    fmt.Println(<-c)
}

 

채널을 만들 때 버퍼 사이즈만 지정해주었는데도 이제 데드락이 발생하지 않는다. 즉 버퍼 크기를 지정하지 않은, 기본 채널에는 버퍼가 존재하지 않음을 확인할 수 있다. 단, 버퍼 크기보다 많은 양의 데이터가 들어오거나, 버퍼에 값이 송신되지 않은 상태에서 꺼내려고 하면 데드락이 발생할 수 있다.

 

 

이와 반대로 버퍼를 사용하지 않은, 버퍼의 크기가 0인 채널을 비동기 채널이라고 한다. 비동기 채널의 경우 송신쪽에서 채널에 데이터를 넣은 후 수신쪽에서 데이터를 가져가기까지 대기하고, 수신자는 데이터를 채널에서 제거하고 만약 데이터가 존재하지 않는다면 대기한다.

 

채널 닫기

 생성한 채널은 닫을 수 있다. 다만 특이한 점은 닫힌 채널로는 데이터를 더이상 송신할 수 없지만, 채널이 닫힌 후에도 수신은 계속 가능하다(수신시 채널이 비어있더라도 무한 대기가 발생하지 않는다).