본문 바로가기
🦫 Go

Go를 사용하며 느꼈던 몇 가지 충격(?) 포인트들

by kukim 2023. 3. 25.

웹 백엔드 개발자로 주로 Java/Spring(Boot) 기반 개발을 하였지만 회사에서 Go/Gin 기반 개발을 시작하였습니다. Java를 사용했던 개발자가 Go를 사용하며 느꼈던 몇 가지 충격(?) 포인트를 소개드리려 합니다. 특정 언어가 더 좋다를 이야기하는 것이 아닌 Go의 매력을 느끼고 있는 중이라고 생각해 주시면 감사하겠습니다. 잘못된 내용이나 의견 있다면 편하게 말씀해 주세요. 🙏🏻

 

목차

1. Go 언어 소개

2. 포인트

- 포인터가 있습니다. 근데 GC를 곁들인

- 암묵적 형 변환을 지원하지 않습니다.

- 함수(Function)와 메서드(Method)가 구분되어 사용됩니다. 

- OOP를 지원하지만 클래스가 없습니다.

- OOP를 구조체의 확장형으로 사용하고 있기에 상속이 없습니다.

- Java에서 OOP 캡슐화를 위해 public/private 접근제어자 대신에 대소문자로 구분합니다.

- 함수/메서드 리턴이 1개 이상 가능합니다. (multiple return values)

- Exception이 없습니다.  (Go의 Error handling)

- 테스트, 벤치마크, 문서를 언어 차원에서 지원합니다.

- 웹 백엔드 프레임워크에 관하여

3. 마치며


1. Go 언어 소개

Go 언어를 만든 사람들

Go 언어는 구글(Google)의 켄 톰슨(Ken Thompson), 로버트 그리즈머(Robert Griesemer), 롭 파이크(Rob Pike) 등이 개발한 프로그래밍 언어입니다. 혹시 이름이 익숙하신가요? 네, 맞습니다. 세 사람 모두 유명한 사람들입니다. 켄 톰슨은 유닉스 개발과 C언어의 기원이 되는 B 언어를 만들었고 이를 데니스 리치와 발전시켜 C언어 개발에 도움을 주었습니다. 롭 파이크도 켄 톰슨과 함께 유닉스, C 언어 개발에 참여했습니다. 로버트 그리즈머는 썬-마이크로시스템즈에서 Java, JVM 개발하고 V8 JS 엔진 개발을 하였습니다. 

 

Go 언어 탄생 타임라인  

2006년 - 최초의 멀티코어 프로세서 등장

2007년 - 거대해진 Google 시스템에 맞게 멀티코어 프로세서 활용, 네트워크 시스템 처리 대규모 코드 시스템 컴파일 등 할 수 있는 언어 설계 토론(켄 톰슨, 로버트 그리즈머, 롭 파이크)

2009년 - Google에서 Go 언어 소개

2012년 - Go 언어 Version 1 Release! 

(Go 언어는 Golang의 이름도 가지고 있는데 "Go" 검색이 어려워 뒤에 lang을 붙여 Golang이 되었다는 이야기)

 

Go의 특징

적은 키워드 수, 컴파일 기반의 정적 타입(강타입), Duck Typing 지원, 다중 패러다임(절차지향, 객체지향, 동시성, 함수형(?) 프로그래밍)과 GC(Garbage Collection), 빠른 컴파일 등을 지원합니다. (완벽히 다중 패러다임을 지원하지 않는다고도 이야기가 있지만 넘어가려합니다.)

 

요약

Go는 프로그래머 대가들이 최근(? 2009년)에 만든 언어입니다. 마치 현대판 C언어 같습니다. 정적타입 제약이 매우 강하며 동시에 덕타이핑을 지원합니다. 절차지향적이지만 동시에 모던한 OOP를 지원하고 함수형(?)과 유사하게 작성할 수 있으며 포인터가 있고 C에서 없던 GC를 지원합니다. 빠르게 컴파일할 수 있고 goroutine(고루틴)이라는 경량 쓰레드를 활용하여 동시/병렬 처리가 가능한 언어입니다. 


2. 충격

포인터가 있습니다. 근데 GC를 곁들인

Go는 C언어처럼 pointer(포인터)가 있습니다. C언어의 pointer와 완전히 같진 않지만(포인터 산술 기능은 지원 x) 거의 동일한 개념으로 사용됩니다. point를 메모리 주소를 저장하는 변수로 사용하여 주소 연산자(&)를 사용하여 변수의 주소를 가져오고, 역참조 연산자(*)를 사용하여 해당 주소에 저장된 값을 가져옵니다. Go는 GC(Garbage Collection)을 지원합니다. C언어 처럼 pointer에 할당된 heap 메모리 해제를 직접하지 않아도 됩니다. 

+a) Go는 모든것이 pass(call) by value(값에 의한 전달)로 되어있습니다. 함수에 전달되는 모든 인수가 을 복사하고, 함수 내에서는 복사본이 작동합니다. 이때 pointer를 사용하여 변수의 주소 값이 전달되고 참조에 대해 함수 내에서 사용할 수 있는 개념입니다. pass by reference(참조에 의한 전달) 처럼 보일 수 있지만 실제로 포인터 이 전달되기 때문에 pass by value라고 볼 수 있습니다. 이는 Java에서도 마찬가지로 작동합니다. 함수에 객체 자체를 넘길 때 객체의 메모리 주소 값이 복사되는 형식으로 작동합니다. Java는 포인터 변수를 사용하지 않고 객체를 넘김에도 pass by value를 사용하고, Go는 포인터 값을 넘겨 복사하여 pass by value로 사용합니다.

// pointers.go
// https://gobyexample.com/pointers
package main

import "fmt"

func zeroval(ival int) {
    ival = 0
}

func zeroptr(iptr *int) {
    *iptr = 0
}

func main() {
    i := 1
    fmt.Println("initial:", i)

    zeroval(i)
    fmt.Println("zeroval:", i)

    zeroptr(&i)
    fmt.Println("zeroptr:", i)

    fmt.Println("pointer:", &i)
}

// 결과
> go run pointers.go
initial: 1
zeroval: 1
zeroptr: 0
pointer: 0x42131100

암묵적 형 변환을 지원하지 않습니다.

Java의 primitive type의 경우 작은 데이터 타입에서 큰 데이터 타입으로 변환 시 암묵적 형 변환(implicit type conversion)을 지원합니다. 하지만 Go에서는 명시적 형 변환만 지원합니다. Go는 강타입 언어라고 볼 수 있습니다. (형변환 하지 않은 int 16과 int 32는 연산이 안됩니다.)

// Java
@Test
void 자바_형변환_테스트() {
    byte aByte = 1;
    int aInt = 10;
    char aChar = 'a'; // 97
    float aFloat = 10.0f;
    double aDouble = 10.0d;

    // 암묵적 형 변환 : 작은 크기 -> 큰 크기
    int implicitInt = aByte;
    float implicitFloat = aInt;
    double implicitDouble = aChar;

    // 명시적 형 변환 : 큰 크기 -> 작은 크기
    byte explicitByte = (byte) aDouble;
    char explicitChar = (char) aFloat;
    float explicitFloat = (float) aDouble;

    // 컴파일러의 자동 형변환을 통해 연산
    System.out.println(aByte + aInt); // 11
    System.out.println(aInt + aChar); // 107
    System.out.println(aInt + aFloat); // 20.0
}
// Go
func main() {
	var a int16 = 10

	// 암묵적 형 변환 X
	//var b int32 = a // Compile Error: Cannot use 'a' (type int16) as the type int32

	// 명시적 형 변환 O
	var c int32 = int32(a)
	var d string = string(a) // Go는 char type 존재하지 않습니다.

	// 자동 형변환을 통한 연산 불가 e.g. int16와 int32 연산 불가
	//fmt.Println(c + a) // Compile Erorr: Invalid operation: c + a (mismatched types int32 and int16)
	fmt.Println(c + int32(a)) // 명시적 형 변환을 통해 연산 가능
}

함수(Function)와 메서드(Method)가 구분되어 사용됩니다. 

함수와 메서드 차이는 객체(Object)로부터 독립 여부에 따라 달라지게 됩니다.함수는 객체로부터 독립적으로 작동하고, 메서드는 객체에 종속적입니다. Java의 경우 함수를 단독으로 선언할 수 없고 항상 클래스 구성안에 있어야 합니다. 따라서 함수라고하지 않고 메서드라고 부르고 있습니다. Go의 경우 함수도 있고, 메서드도 있습니다. (What's the difference between a method and a function?

// function.go 

package main

import "fmt"

// Function 함수
func Add(x, y int) int { 
	return x + y
}

func main() {
	result := Add(3, 3)

	fmt.Println(result) // 6
}
// method.go

type Rectangle struct {
	Width  float64
	Height float64
}

// Method 메서드
func (r Rectangle) Area() float64 { 
	return r.Width * r.Height
}


func main() {
	rectangle := Rectangle{
		Width:  10,
		Height: 5}

	area := rectangle.Area()

	fmt.Println(area) // 50
}

OOP를 지원하지만 클래스가 없습니다.

Go에서 OOP를 지원할 때 클래스가 아닌 C 언어 계열의 "구조체(strucy)"를 확장하여 사용합니다.

메서드 예에서 Retangle 라는 구조체를 선언하였습니다. 

type Rectangle struct { // 구조체
	Width  float64
	Height float64
}

Java의 경우 메서드를 작성은 클래스 안에서 이뤄지지만 Go에서는 receiver라는 개념을 사용하여 구조체 밖에서 별도로 선언합니다.

// Rectangle 구조체의 메서드
func (r Rectangle) Area() float64 {  // (r Rectangle) 표기법이 해당 구조체의 Area()메서드라는 표현 : receiver
	return r.Width * r.Height
}

OOP를 구조체의 확장형으로 사용하고 있기에 상속이 없습니다.

Go에서는 구조체를 상속받는 구조체를 지원하지 않습니다. 대신에 struct embedding(구조체 임베딩)를 사용하여 OOP의 composition(조합) 방식을 언어 차원에서 지원하고 권장하고 있습니다. (예제 코드)

 

대신에 인터페이스를 지원합니다.

다만 Java처럼 implements 키워드를 사용하지 않습니다. (예제 코드)

Duck Typing을 지원합니다. Go는 컴파일 기반의 타입언어임에 동시에 동적 타입을 지원합니다. (ref : golang으로 만나보는 Duck Typing)

Java에서 OOP 캡슐화를 위해 public/private 접근제어자 대신에 대소문자로 구분합니다.

변수와 메서드 모두 대소문자로 구분됩니다.

type Rectangle struct {
	Width  float64 // 대문자, 외부 패키지 접근 가능 e.g. Java라고 한다면 public float width;
	Height float64 // 대문자, 외부 패키지 접근 가능
	point  int64 // 소문자, 외부 패키지 접근 불가  e.g. Java라고 한다면 private int point;
}

func (r Rectangle) Area() float64 { // 대문자, 외부 패키지 메서드 접근 가능
	return r.Width * r.Height
}

func (r Rectangle) point() float64 { // 문자, 외부 패키지 메서드 접근 불가
	return r.point
}

함수/메서드 리턴이 1개 이상 가능합니다. (multiple return values)

Python 처럼 multiple return values를 지원합니다. C/Java 에서는 불가능한 일이죠. 

// https://gobyexample.com/multiple-return-values

package main

import "fmt"

func vals() (int, int) {
    return 3, 7
}

func main() {

    a, b := vals()
    fmt.Println(a)
    fmt.Println(b)

    _, c := vals()
    fmt.Println(c)
}

Exception이 없습니다.  (Go의 Error handling)

Exception이 없으니 당연히 try-catch-finally 문도 없습니다. 왜 exception이 없을까요..? 아주 충격적이었습니다.

Go 공식문서에서 말하길 try-catch-finally 문을 사용하면 코드가 매우 복잡하고 처리하기 어렵다는 경향이 있다고합니다.

이를 위해 Multiple returns values를 지원하여 Errors를 동시에 반환하고, 해당 함수/메서드를 사용하는 쪽에서 바로 err에 대한 처리를 한다는 컨셉입니다.

+a) Go의 Error와 함께 panic, recover 관련 키워드도 함께 살펴봐야 합니다. 아래 공식 문서로 대체하겠습니다. e.g. Go 런타임 시 에러(배열 넘는 index 접근 또는 Fatal ..)가 발생할 수 있습니다. painc 에 대한 처리하지 않으면 프로세스가 종료되기 때문에 recover을 사용하여 panicking에 대한 프로그램 제어권을 다시 받아올 수 있습니다.

+a) defer 키워드를 사용해 리소스를 안전하게 제거할 수 있습니다. 

// Go에서 Error handling 방법 예

func main() {
	f, err := os.Open("filename.ext")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close() // defer 키워드는 해당 스택이 종료될 때 실행됩니다.
    
	// do something with the open *File f
}

(Why does Go not have exceptions?, Error handling and Go)

 

고루틴 + 채널을 이용해 동시성을 처리합니다.

Go에서 고루틴(goroutine)을 사용하여 손쉽게 경량 스레드를 만들고 동시성 프로그래밍할 수 있습니다. race condition 문제를 방지하기 위해 mutex lock이나 atomic을 사용할 수 있지만 보통 Go에서 지원하는 채널(channel)을 사용하여 동시성 프로그래밍합니다.

// https://gobyexample.com/channels

package main

import "fmt"

func main() {
 
    messages := make(chan string) // 채널 생성

    go func() { messages <- "ping" }() // 고루틴, 동시성 시작, 채널이라는 공간에서 데이터 주고 받음(격리)

    msg := <-messages // 채널에서 데이터 꺼내오기
    fmt.Println(msg)
}

테스트, 벤치마크, 문서를 언어 차원에서 지원합니다.

Go에서는 테스트, 벤치마크, godoc 생성을 언어 차원에서 지원합니다.

.go 파일이 xxx_test.go 이고 해당 파일의 내용 중 함수명이 Testxxx(), Benchmarkxxx(), Examplexxx() 와 같이 키워드를 붙여 사용할 수 있습니다. 

 

// repeat.go

package iteration

func Repeat(character string, repeatCount int) string {
	var repeated string

	for i := 0; i < repeatCount; i++ {
		repeated += character
	}

	return repeated
}
// repeat_test.go

package iteration

import (
	"fmt"
	"testing"
)

func TestRepeat(t *testing.T) { // 테스트
	repeated := Repeat("a", 5)
	expected := "aaaaa"

	if repeated != expected {
		t.Errorf("expected %q but got %q", expected, repeated)
	}
}

func BenchmarkRepeat(b *testing.B) { // 벤치마크
	for i := 0; i < b.N; i++ {
		Repeat("a", 5)
	}
}

func ExampleRepeat() { // godoc 생성
	repeated := Repeat("a", 5)
	fmt.Println(repeated)
	// Output: aaaaa
}
// 테스트 실행
> go test -v
=== RUN   TestRepeat
--- PASS: TestRepeat (0.00s)
=== RUN   ExampleRepeat
--- PASS: ExampleRepeat (0.00s)
PASS
ok      learn-go-with-tests/iteration   0.468s

// 벤치마크 실행
> go test -bench=. 
goos: darwin
goarch: amd64
pkg: learn-go-with-tests/iteration
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkRepeat-12       7814460               137.7 ns/op
PASS
ok      learn-go-with-tests/iteration   1.394s

Test 사용성

언어 차원에서 테스트에 대한 방법을 제공해주지만, Java/Spring(Boot)에서 자주 사용되는 JUnit 테스트 프레임워크와는 사용 방법이 좀 다릅니다. Test context를 직접 관리해야하거나 사용성이 좀 익숙하지 않습니다. TDD 방식으로 구현할 때, IntelliJ에서 아직 구현하지 않은 메서드나 객체를 생성하고 ⌥ + Enter으로 생성하기 쉬우나 Goland에선 잘 작동하지 않는 경우가 있습니다. 아직까진 Go가 익숙하지 않은게 당연하겠지만요. Go + Test 관련해서는 새롭게 배우는 내용을 추가적으로 덧붙여보려 합니다. 

 

Test Double

+a) Go의 경우 TestDouble 처리가 좀 번거롭습니다. Java/Spring(Boot)의 경우 Mock할 대상을 interface 생성하여 직접 만들거나 어노테이션 기반으로 프록시 기반 자동 생성할 수 있지만 Go의 경우 Mock 대상 자동 생성이 어려워 자동 생성 프로세스를 별도로 실행해야 합니다. (e.g. mockery, gomock) 혹시 다른 방법을 아신다면 소개 부탁드려요.

웹 백엔드 프레임워크에 관하여 

Go에서 사용하는 유명한 웹 백엔드 프레임워크(Echo, Gin, ...)가 있습니다. 하지만 Go의 프레임워크들은 Spring(Boot) 처럼 엔터프라이즈 기능이 제한적이거나 없고, 많은 레퍼런스가 쌓여있지 않습니다.  Go의 표준 패키지(net/http...) 가 잘 만들어져있기 때문에 상황에 맞게 필요한 패키지를 조합하거나 직접 만들어 사용합니다. 미들웨어 방식으로 마이크로 단위로 서비스를 만들어 사용하는것을 권장하는 느낌이 듭니다. 오히려 Go 유저들(Gopher)들은 프레임워크에서 표준을 강제하는 것을 부정적으로 생각하는 거 같습니다. 이것이 장점이자 단점으로 다가오는 것 같습니다. 가끔 생각없이 Spring(Boot)를 사용하다 보면 내부가 어떻게 동작하는지 몰라도 무작정 쓰는 경우가 있습니다. Go는 Low 레벨부터 뚝딱뚝딱 만들어야하는 경우가 있기에 자연스럽게 학습(?)하게 됩니다.

 

Go/Gin, Echo vs Java/Spring(Boot)  무엇을 더 좋은걸까요? 

정답은 없다고 생각합니다. 당연히 상황에 맞게 적절히 골라야겠죠. 한국에서 웹 백엔드 개발만 생각해 본다면 Java/Spring(Boot)가 어느정도 강점이 있다고 생각합니다. 하지만 또 웹이 아닌 네트워크나 특정 도메인에 대한 서버 개발 쪽만 생각했을 땐 Go가 가지는 장점이 있겠죠. 팀 구성원에 따라 다를거고 하고자 하는 애플리케이션마다 다릅니다.!

 

아직 잘 모르지만 웹 백엔드 개발에서 Go를 사용하여 얻은 장점은 언어 자체의 키워드가 적어 익히기 쉽고(사실 익숙하지 않아 어렵습니다.), 표준 라이브러리가 탄탄하고 빠른 빌드와 속도, 메모리 소비도 적습니다. Docker image 빌드 결과가 10MB 이하도 안된다는 사실이 놀랍기만 합니다. 하지만 Spring(Boot)와 비교했을 때 몇 가지 아쉬운 점들도 있습니다. Go가 자유도가 높은 만큼 코드를 통일화하여 작성하기 어려울 수 있습니다. 물론 구현하기 나름이겠지만요. 유지보수하기 어려울 수 있습니다. (한국 인력 구조 상 Go 개발자가 적다는 것도 있지만요.) 프레임워크 단에서 지원해주는 e.g. Spring Security나 Hibernate, Transactional, Test, Batch 코드도 Go에서는 통일되어있지 않거나 직접 구현해야하거나 오픈소스를 사용해야하는 어려움도 있습니다. 물론 Spring(Boot)를 쓴다고 코드가 항상 유지보수하기 좋거나 기술이 세련되기만 한것은 아니지만요.


3. 마치며

Go를 사용하며 많은 것을 배우고 있습니다. 특히 언어적인 측면에서 다른 매력을 느끼고 있습니다.

회사 일 외에도 Go로 프로젝트를 해봐야겠다는 욕심도 생기게 되었네요. (언젠가 Gopher 🦫가 될 수 있겠죠..?)

읽어주셔서 감사합니다.

(이 글은 Go를 사용하며 꾸준히 업데이트 될 예정입니다.)

 

 

댓글