자바 개발자가 Go 잠깐 사용해 봤습니다(2)

이글은 지난 글 "자바 개발자가 Go 잠깐 사용해 봤습니다(1)" 이은 두번째 글입니다.

에러 처리(try...catch)

Go 언어를 처음 접하면서 가장 어려웠던 부분이 에러에 대한 처리 부분입니다. Go 언어는 기본적으로 try.. catch 같은 절을 지원하지 않습니다. 대신 앞에서 살펴본 함수에서 여러개의 결과 값을 반환할 수 있는 기능을 이용하여 에러를 처리합니다. 앞의 searchPost 함수를 호출하는 코드는 다음과 같이 에러 처리를 합니다.

1
2
3
4
5
6
7
posts, totalCount, err := searchPost()
if err != nil {
  fmt.Println("Error:", err.Error())
} else {
  fmt.Println("Num Posts: ", len(posts))
  fmt.Println("totalCount: ", totalCount)
}

searchPost() 함수 내부에서는 DB 접속에러, SQL 에러 등이 발생하면 error를 반환하고 이를 호출하는 측에서는 위 예제 코드와 같이 error가 nil 이 아닌 경우 에러 처리를 합니다.

Go 언어에서는 에러 처리가 아주 중요한데, 위 코드에서 에러 처리를 하지 않게 되면 nil posts 변수를 이용하여 연산을 수행하기 때문에 프로세스 자체가 종료되어 버립니다.  에러 처리를 하지 않는 코드가 하나라도 존재하고, 그 코드가 실행되면 Go로 만든 Application Server 자체가 종료[1]되어 버리기 때문에 에러 처리에 아주 주의해야 합니다.

Go 언어에서는 if 절 내에 for 문과 비슷하게 statement을 추가할 수 있습니다. 이런 기능을 이용해서 다음과 같이 if 절 내에서 searchPosts를 호출함과 동시에 에러 체크를 같이 할 수 있습니다.

1
2
3
4
5
6
7
8
9
if posts, totalCount, err := searchPost(); err != nil {
 fmt.Println("Error:", err.Error())
} else {
 fmt.Println("Num Posts: ", len(posts))
 fmt.Println("totalCount: ", totalCount)
}
// if 절 내에서 선언된 변수의 scope는 if...else 절 내부.
// 따라서 다음과 같은 사용은 컴파일 에러
numPosts := len(posts)

이때 주의 사항은 if  절 내에서 선언된 변수는 if...else 문 내에서만 유효하기 때문에 위 예제 코드에서의 마지막 문과 같이 if 문 밖에서 사용할 경우 컴파일 에러가 발생합니다.

여기까지보면 그렇게 나쁘지는 않습니다. 하지만 위와 같이 단순하게 처리하는 경우 문제가 없겠지만 조금만 다른 로직이 추가되면 다음 코드와 같이 아주 복잡해 집니다. 아래 예제 코드는 웹에서 요청한 page, pageSize 파라미터를 이용하여 Posts 를 검색하는 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
pageSize, err := strconv.Atoi(params["pageSize"])
if err != nil {
  return makeErrorResult(err)
}
page, err := strconv.Atoi(params["page"])
if err != nil {
  return makeErrorResult(err)
}
posts, totalCount, err := searchPost(page, pageSize)
if err != nil {
  return makeErrorResult(err)
} 
return makeJsonResult(posts, totalCount)

파라미터는 모두 String 타입이기 때문에 이를 int로 변환하기 위해 Atoi() 함수를 사용했습니다. 이 함수는 파라미터로 받은 String이 숫자로 구성되지 않으면 Error를 반환합니다. 따라서 위 코드와 같이 pageSize, err := 와 같이 사용하여 변환된 값과 에러 값을 동시에 받고 이후에 Error에 대한 처리를 하고 있습니다. 위 코드는 try...catch를 지원하는 언어에서는 다음과 같이 처리할 수 있습니다.

1
2
3
4
5
6
7
8
try {
  pageSize := strconv.Atoi(params["pageSize"])
  page := strconv.Atoi(params["page"])
  posts, totalCount := searchPost(page, pageSize)
  return makeJsonResult(posts, totalCount)
} catch (err) {
  return makeErrorResult(err)
}

필자는 업무 시간의 1/3 정도를 코드 리뷰를 하는데 Go와 같이 Error를 비즈니스 로직 구성 내에서 개별적으로 처리하는 방식보다는 자바와 같이 catch 절을 이용하여 비즈니스 로직과 조금 분리시키는 코드를 읽을 때 비즈니스 로직을 더 잘 이해할 수 있었고, 로직의 버그를 더 잘 찾아낼 수 있었습니다. 물론 자바 코드에 많이 익숙한 개인적인 취향이라고 볼 수도 있습니다. 자바 개발자 입장에서는 try... catch 절을 지원하지 않는 것이 많이 불편할 수도 있습니다.

Short variable declaration

위 Go 예제 코드를 보면 err 변수를 여러번 선언하고 있습니다.

pageSize, err := strconv.Atoi(params["pageSize"]) page, err := strconv.Atoi(params["page"])

Go 언어에서 := (Short variable declaration)는 변수의 선언과 동시에 값을 할당을 할 수 있으며 Go 언어가 타입을 추론하기 때문에 타입 선언을 하지 않아도 됩니다. 따라서  위 코드의 경우 err 변수가 두번 선언되었기 때문에 컴파일 에러가 발생할 것으로 예상되는데 실제로는 에러가 발생하지 않습니다. 그 이유는 ":=" 로 여러개의 번수를 선언할 때 하나 이상의 새로운 변수를 선언하면 기존에 존재하는 변수를 새로 선언하지 않고 기본 변수에 값을 할당하기 때문입니다. 예를 들어

1
2
3
4
i, j := 10, 20
i, k := 30, 40  //여기서 k는 새로 선언되며, i는 기존 선언된 변수에 값만 변경됨
fmt.Println(i, j, k)
k := 50  // 컴파일 에러 발생

위 코드에서 마지막 라인은 k만 새로 생성하기 때문에 컴파일 에러가 발생합니다.

사용자 정의 에러

Go 언어에서도 사용자 정의 에러를 만들 수는 있습니다. 하지만 위에서 보시는 것 처럼 catch 절이 없기 때문에 Error  타입별로 구분을 해서 처리하기가 애매합니다. 주로 Error에 정보를 더 추가하기 위한 용도로 많이 사용하는 것 같습니다. 굳이 에러를 타입별로 처리하는 경우에는 다음과 같이 if 절이나 switch 절로 처리를 해야 합니다.

1
2
3
4
5
6
7
err := TestFunc()
 switch t := err.(type) {
 default:
     fmt.Println("Error:", t)
 case *MyError:
     fmt.Println("MyError:", t)
}

위와 같이 처리를 해야 하는 불편함이 있어 저같은 경우 굳이 사용자 정의 에러를 만들어서 사용하지는 않습니다. 사용자 에러에 대한 자세한 예제 및 상세한 설명은 다음 문서를 참고 하세요.

리소스 해제(finally)

try...catch 절을 지원하지 않기 때문에 사용한 리소스 해제를 finally 절은 지원하지 않고 별도의 구문을 지원합니다. Go 언어에서는 "defer" 키워드를 이용하여 할당 받은 리소스를 해제합니다.

1
2
3
4
5
func main() {
  dbConn, err = xorm.NewEngine("mysql", "root:123@/test?charset=utf8")
  defer dbConn.Close()
  dbConn.Select(....)
}

defer 구문의 호출은 코드의 흐름대로 실행되는 것이 아니라 defer 구문을 사용한 함수(예제에서는 main)가 완료되는 시점에 실행됩니다. 따라서 "defer dbConn.Close()" 호출 이후에 실행된 dbConn.Select 문은 정상적으로 실행됩니다. defer 문을 위와 같이 Connection 설정 이후에 작성하는 것이 코드를 볼 때 해제 여부를 바로 확인할 수 있어 권장하는 방식입니다.

Nil

Null 관련 처리는 대부분의 언어에서 애매하거나 난감한 경우가 많습니다. 최근 자바는 이 부분을 Optional 을 이용하는 방식으로 많이 해결하고 있는 것 같습니다. 자바의 Null에 해당하는 것이 Go에서는 nil 입니다. 일단 가장 많이 사용하면서 혼란스러운 부분은 다음 상황입니다.

1
2
3
4
5
6
7
8
// 이렇게 선언하면 nil 반환 시 컴파일 에러
func hello() string {
  return nil
}
// 포인터로 선언해야 함
func hello() *string {
  return nil
}

위 예제 코드에서 보는 것처럼 함수에서 결과를 반환할 때 nil을 반환하는 경우에는 반드시 포인터로 선언을 해줘야 합니다.  이렇게 일반적인 값으로 nil을 사용하지 못하는 것은 Go언어에서 nil의 정의는 다음과 같이 되어 있습니다.

nil is a predeclared identifier representing the zero value for a pointer, channel, func, interface, map, or slice type.

포인터, 채널, 함수 등의 "0" 값을 표현하기 위한  식별자라는 의미입니다. 즉, string 타입의 값으로는 nil을 사용할 수 없다는 의미입니다. 이런 nil의 특징때문에 프로그램의 함수 정의와 이를 호출하는 측의 구성이 개발자의 의도와 다르게 구성해야 하는 경우가 많습니다. 위의 예제 코드의 경우에도 개발자의 의도는 포인터를 결과로 받기 보다는 문자열 값 자체를 받고 싶었는데 nil 의 특성때문에 포인터를 반환하도록 되어 있습니다. 문자열의 경우 return "" 와 같이 빈 문자열을 반환하면 되지만  다음과 같이 Struct 를 반환하는 경우에는 애매한 상황이 발생합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func GetEmp(id int) *Emp {
  if id < 0 {
    return nil
  }
  return &Emp{
    name: "babokim",
  }
}
func saveEmp(emp Emp) {
  fmt.Println("Emp saved:", emp.name)
}
func main() {
  emp := GetEmp(1)
  fmt.Println(emp.name)
  // 아래 코드틑 컴파일 에러 발생 
  saveEmp(emp)
}

위 코드에서 GetEmp() 함수는 특정 조건이 될때 nil을 반환하기 위해 return 타입을 포인터로 선언했습니다. 하지만 원래 개발자의 의도는 포인터로 받기 보다는 Emp 값 그 자체를 받고 싶었습니다. 이 값을 이용해서 saveEmp() 함수를 호출해서 Emp를 저장하는 코드를 만들 경우 위 코드의 경우 컴파일 에러가 발생합니다.  이 에러를 해결하는 방법은 두가지 방법이 있습니다.

  • 호출하는 측에서 포인터를 값으로 변경 saveEmp(*emp)
  • saveEmp의 파라미터를 포인터로 변경 func saveEmp(emp *Emp)

이 두 방법 모두 자연스러운 방법은 아니라고 생각합니다.

패키지 순환 참조

Go언어도 패키지라는 개념이 있습니다. 패키지를 선언하면 패키지 선언하면 타입, 필드, 함수 등에 대한 접근 제한을 할 수 있으며 어떤 타입에도 속하지 않는 함수 등을 묶어서 관리할 수 있는 등의 장점이 있습니다. 하지만 패키지의 순환 참조는 지원하지 않습니다. 패키지의 순환 참조는 어떤 경우에는 좋은 프로그램 구성은 아니지만 순환 참조 제약이 있으면 약간 귀찮은 경우가 발생합니다. 하지만 Go 언어의 철학중에 나쁜 습관은 언어 차원에서 배제시키려고 하는 부분도 있으니 어느 정도 이해는 갑니다. 다음 문서를 보시면 자바에서 순환 의존 관계에 대한 내용이 있습니다.

패키지의 init() 함수

저는 이상하게 예약된 이름으로 뭔가를 자동으로 실행하게 하는 방식을 별로 좋아하지는 않습니다. 제가 개발 언어나 플랫폼을 익히는 스타일이 그냥 대충대충이기 때문일 수도 있다고 생각 합니다. Go 언어에서는 이런 부분은 거의 없는데 패키지의 init() 함수는 Go 언어에서 자동으로 실행시켜주는 함수입니다.

init() 함수는 주로 패키지에서 사용하는 환경설정 정보를 로딩하거나 패키지가 동작하기 위해 필요한 사항들을 점검하는 용도로 많이 사용합니다. init() 함수 사용 시 주의 사항은 init() 함수의 실행 순서인데 대략 다음과 같은 순서로 실행됩니다.

  • import된 패키지를 로딩
  • import된 패키지의 전역 변수를 초기화
  • import된 패키지의 init() 함수 실행
  • main 패키지의 전역 변수 초기화
  • main 함수의 init() 함수 실행

순서 관련해서 신경써야 하는 부분이 하나 더 있습니다. 하나의 패키지에서 init() 함수는 여러개 선언할 수 있기 때문입니다. 다음 코드는 컴파일 에러 없이 정상적으로 실행됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
a.go
package main
func init() {
  fmt.Println("main.a.Init1")
}
func init() {
  fmt.Println("main.a.Init2")
}
func main() {
  fmt.Println("Main")
}
=============
b.go
package main
func init() {
  fmt.Println("b.go Init")
}
=============
c.go
package main
func init() {
  fmt.Println("c.go Init")
}
//실행 결과
main.a.Init1
main.a.Init2
b.go Init
c.go Init
Main

실행 결과에서 보는 것과 같이 하나의 패키지에 여러 개의  init() 함수가 있으면 파일 순서대로 실행되는 것을 알 수 있습니다.

패키지의 init() 함수는 패키지를 import 해야만 실행이 되는데 Go 언어는 사용하지 않는 패키지를 inport 할 경우 컴파일 에러가 발생합니다. 하지만 가끔 패키지의 init() 만 실행되는 것이 필요한 경우가 있는데 이 경우 "_"(Blank identifier) 를 이용하여 import 하면 init() 함수만 실행이 됩니다.

import _ "image/png"

init() 함수는 테스트 프로그램 개발에도 유용하게 사용될 수 있습니다. 예를 들어 DB로부터 데이터를 조회하는 기능을 테스트하는 경우 테스트 패키지의 init 함수에 테스트 DB로의 연결 등을 처리하면 실제 코드와 테스트 코드의 환경을 분리할 수 도 있습니다.

Public, Private?

Go 언어는 Public, Private와 같이 타입의 필드나 함수의 접근을 제한하는 접근 제한자를 제공하지 않습니다. 대신 이름의 첫글자가 대문자인지 소문자인지로 이를 구분하고 있습니다.

1
2
3
4
5
6
7
8
9
10
package model
type Emp {
  Name string
  salary int
}
=======================
package controller
emp := GetEmp(id)
fmt.Println(emp.Name)
fmt.Println(emp.salary)

위 코드에서 emp.salary와 같이 접근하면 컴파일 에러가 발생합니다. 타입이나 함수 역시 이 규칙을 따르고 있는데

type emp {}

와 같이 선언하면 emp 타입은 다른 패키지에서는 접근할 수 없습니다.

필자의 경우 이런 규칙이 다음과 같은 이유때문에 좋은 방법은 아닌것 같습니다.

  • 타입을 소문자로 사용하는 경우가 있다 보니 프로그램 내에서 변수와 타입이 혼란스러운 경우가 있다. 예를 들면 위의 emp 타입을 사용할 경우 다음과 같이 선언될 수 있다. var emp emp
  • 처음 개발 시에는 함수가 패키지 내에서만  사용되도록 소문자로 작성했는데, 나중에 이 함수를 다른 패키지에서 사용할 필요가 있는 경우 이 함수를 사용하는 많은 부분을 수정하거나, 다음과 같이 다른 함수를 하나 더 추가 해야 한다. func calc() int { } <- 내부에서만 사용하는 함수 func Calc() int { return calc() }  <- 불필요하게 다음 코드를 추가해야 한다.

JSON 변환 시 대소문자 주의

이것과 유사한 난감한 상황을 경험했는데 다른 API 서버를 호출한 결과로 받은 JSON 문자열을 객체로 변환하는 과정에서 필드가 소문자로 되어 있으면 변환을 하지 못하는 문제가 있었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Emp struct {
	name string
}
func main() {
emp := Emp{
      name: "babokm",
  }
  jsonEmp, err := json.Marshal(emp)
  if err != nil {
    fmt.Println("Error:", err.Error())
    return
  }
  fmt.Println("JSON:", string(jsonEmp))
}

위 예제 코드의 json 문자열 변환 결과는

JSON: {}

와 같이 나타납니다. 여기서 Emp의 name 필드를 Name으로 대문자로 변경하면 {"Name":"babokm"} 와 같이 정상적으로 출력됩니다.

글을 마치며

지금까지 두차례에 걸쳐 자바 개발자 입장에서 살펴본 Go 언어의 차이나 조금은 이상하게 생각되는 부분에 대해 공유드렸습니다. Go 언어의 여러 문서를 보면 심플하고 쉽다라는 의미의 문장이 많이 나오는데 이 문장을 그대로 이해하면 안될 것 같다는 말씀을 드리고 싶습니다. Go를 사용하는 많은 개발자들이 C/C++ 로 개발을 하시는 분들이 많았는데 C/C++ 과 비교할 때 심플하고 쉽다는 의미가 아닐까 합니다. 자바, 파이썬, 루비 등과 같은 언어들은 이미 개발자에게 많은 것(포인터 등)을 감추면서 개발자는 로직에 집중할 수 있는 기능을 많이 제공하고 있다고 봅니다. 즉, 충분히 어렵지 않다고 봅니다.

이번 글에서는 Go의 객체지향적인 측면이나 프로그램의 설계 관점에 대해서는 정리를 못했습니다. 아직까지 정확하게 글로 표현할 수준까지 경험해보지 못했기 때문이라고 핑계를 대봅니다. 언젠가는 한번은 정리를 할 예정입니다.

필자 입장에서 총평을 해보면 작고 가볍고, 간단한 웹 API 서버를 만들기 위해서는 Go 언어도 좋은 선택이 될 수 있을 것 같습니다.필자에게 테이블 4 ~ 5대에  API 20개 정도 지원하는 기능의 API 서버를 만들라고 하면 지금은 자바보다 Go를 선택할 겁니다. 현재 개발하고 있는 서비스도 대부분은 위와 같이 작은 마이크로 서비스를 만들기 위해 Go를 많이 사용하고 있습니다. 그리고 도커를 이용한 컨테이너로 올리기 간편하고, 프로그램 로딩에 필요한 메모리도 많이 사용하지 않기 때문에 수십, 수백개의 마이크로 서비스로 구성된 서비스에서는 좋은 선택이 될 수 있을 것 같습니다. 하지만 크기가 아주 큰 서비스를 하나의 Go 프로젝트로 개발하는 것은 추천하고 싶지 않습니다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.