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

마이크로 서비스를 구성할 경우 각각의 서비스 특징에 따라 다른 프로그램언어 또는 플랫폼으로 서비스를 구성하는 경우가 있습니다. 필자가 있는 팀에서는 주로 Go 언어를 많이 사용하지만 Java, Python, Rails, C# 등으로 만들어진 서비스도 운영되고 있습니다. 이런 상황에서 개발자는 다양한 언어에 대한 이해를 하고 필요에 따라서는 서비스의 개발 또는 수정을 할 수 있어야 합니다.

golang-vs-java

필자의 경우 과거에는 주로 자바 언어를 이용하여 많이 개발하였습니다. 자바 언어를 사용했지만 Spring 등은 많이 사용해보지 않았습니다. 추가로 C, Ruby & Rails 등으로도 서비스를 개발, 운영한 경험이 있습니다. 이런 경험을 기반으로 최근 몇개월 동안은 Go 언어를 이용하여 서비스를 개발하고 있습니다. 이번 글에서는 저와 같이 자바 언어에 익숙한 개발자들이 Go 언어를 처음 접할 때[1] 겪는 어려움 또는 버그를 발생시킬 수 있는 상황에 대해 공유하려고 합니다.

이글은 2편으로 나누어져 있습니다.

Pass by Reference vs. Pass by Value

자바 개발에 익숙한 개발자가 처음 Go를 접했을 때 가장 주의해야 할 부분이라고 할 수 있습니다. 자바는 값을 전달할 때 int, float와 같은 primitive type인 경우 Pass by Value 로 전달하고 Class type 인 경우 Pass by Reference로 전달합니다. 다음 자바 코드를 보면 쉽게 이해할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Emp {
  String name;
  int salary;
  public Emp(String name, int salary) {
    this.name = name;
    this.salary = salary;
  }
}
public static void main (String[] args) {
  Emp babokim = new Emp("babokim", 1000);
  calculateSalary(babokim);
  System.out.println("Salary: " + babokim.salary);
}
public static void calculateSalary(Emp emp) {
  emp.salary = 2000;
}

(실제 상황에서는 이렇게 코드를 만들지 않고 calculateSalary 메소드가 Emp 클래스 내에 구현될 것입니다. 설명을 쉽게 하기 위해 이렇게 코드를 만들었습니다.)

위 코드의 결과는 2000을 출력합니다. 이유는 calculateSalary()의 파라미터로 전달된  emp 변수는 babokim 객체의 레퍼런스(쉽게 생각하면 포인트)를 가지고 있기 때문에 이 변수가 가리키는 객체의 값을 변경하면 원본 값도 같이 바뀌게 되기 때문입니다.

그러면 Go 언어는 어떨까요? Go 언어는 기본적으로는 Pass by Value를 사용합니다. 다음은 위의 자바 코드와 비슷한 Go 언어로 만든 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Emp struct {
  Name string
  Salary int
}
func calculateSalary(emp Emp) {
  emp.Salary = 2000
}
func main() {
  emp := Emp{
    Name: "babokim",
    Salary: 1000,
  }
  calculateSalary(emp)
  fmt.Println("Salary:", emp.Salary)
}

위 결과는 1000이 출력됩니다. 이것은 calculateSalary()로 전달된 파라미터가 레퍼런스가 아닌 main()에서 생성한 emp 객체의 값이 복사되어 전달되기 때문입니다. calculateSalary() 내의 변경 사항이 main에서 생성한 emp 객체에는 반영되지 않습니다.

제가 리뷰를 본 코드에서는 이렇게 눈에 띄는 함수 파라미터인 경우에는 Pass by Value 특징에 주의해서 코드를 만듭니다. 설령 처음에는 그렇게 만들지 않았다 하더라도 버그 찾는 과정에서 어느 정도 찾아 낼수 있습니다. 하지만 다음과 같은 몇가지 경우에는 실수하기 아주 쉽습니다.

  • assignment(=)
  • for loop: 이것은 어떻게 보면 assignment 문이지만 for loop에 가려져 있어 잘 보이지 않음

다음 코드와 같이 Assignment를 이용하여 변수에 값을 할당하는 경우에도 값이 복사되어 할당됩니다.

1
2
3
4
5
6
7
8
9
10
func main() {
  emp1 := Emp{
    Name: "babokim",
    Salary: 1000,
  }
  emp2 := emp1
  emp2.Salary = 2000
  fmt.Println("Emp1 Salary:", emp1.Salary)
  fmt.Println("Emp2 Salary:", emp2.Salary)
}

따라서 이 코드의 결과는 Emp1: 1000, Emp2: 2000이 됩니다. 이와 유사한 자바 코드는 동일한 값을 가지게 됩니다. 함수의 파라미터나 Assignment 문에는 어느 정도 예측이 가능하지만 다음과 같은 for 문에서는 실수를 자주하고 이런 실수가 프로그램에서는 큰 버그를 만들 가능성이 높습니다. 그리고 찾기도 어렵습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
  employees := make([]Emp, 1)
  employees[0] = Emp{
    Name: "babokim",
    Salary: 1000,
  }
  for _, emp := range employees {
    emp.Salary = 2000
  }
  fmt.Println("After for each statement:", employees[0].Salary)
  for i := 0; i < len(employees); i++ {
    employees[i].Salary = 3000;
  }
  fmt.Println("After for with index:", employees[0].Salary)
}

위 코드의 결과는 첫번째 for 문에서는 1000이 두번째 for 문에서는 3000이 출력됩니다. 첫번째 for 문의 emp 변수는  employees 배열의 실제 객체가 아닌 복사된 값이 전달됩니다. 따라서 for loop  내부에서 값을 변화시켜도 for loop를 벗어나면 아무런 영향을 줄 수 없게 됩니다.

두번째 for 문을 사용할 수 있지만 최근의 프로그래밍 가이드에서는 대부분 첫번째 형태의 for 문을 사용하는 것을 추천합니다. 그러면 go 언어에서는 첫번째 for 문을 사용해서 배열의 각 엘리머트 객체의 필드 값을 변경하는 방법은 없을까요? Go의 포인터를 이용하면 가능합니다.

Go의 포인터

자바는 Pass by Reference만 지원하지만 Go 언어는 Pass By Value와 Pass By Reference 모두 지원합니다. 이를 위해 C/C++ 언어에서 있는 포인터라는 개념을 그대로 지원하고 있습니다. 엄밀하게 말하면 Go 언어는 Pass By Value 만 지원하는데 Value에 객체의 주소값(포인터) Value를 전달함으로써 Reference를 전달하는 것과 같은 효과를 내게 됩니다.

앞의 for 루프 예제를 포인터를 이용하면 다음 코드와 같이 만들수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
  employees := make([]*Emp, 1)   // Type앞에 *를 붙이면 포인터 선언
  employees[0] = &Emp{           // 값에 &를 붙이면 해당 값이 저장되는 주소를 나타냄
    Name: "babokim",
    Salary: 1000,
  }
  for _, emp := range employees {
    emp.Salary = 2000
  }
  fmt.Println("After for each statement:", employees[0].Salary)
  for i := 0; i < len(employees); i++ {
    employees[i].Salary = 3000;
  }
  fmt.Println("After for with index:", employees[0].Salary)
}

employees  변수 선언을 Emp의 포인터(*Emp)를 배열 요소로 가질 수 있도록 선언하고 배열에 값을 저장할 때 '&' 를 이용하여 주소값을 할당하였습니다. 위 예제에서는 첫 번째 for 문 이후 출력 결과는 2000, 두 번째 for 문 이후 출력 결과는 3000으로 의도 했던대로 출력됩니다. 이렇게 포인터를 사용하면 배열의 첨자를 사용하지 않는 for each 형태의 for 문을 사용하여 배열에 저장된 객체의 상태를 수정할 수 있습니다.

함수 호출 시에도 다음과 같은 코드로 포인터를 전달하고 함수 내부에서 변경된 값이 함수를 호출한 측에서도 반영이 되도록 할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
func calculateSalary(emp *Emp) {  //함수의 인자를 pointer로 변경
 emp.Salary = 2000
}
func main() {
  emp := Emp{
    Name: "babokim",
    Salary: 1000,
  }
  calculateSalary(&emp)  // 함수 호출 시 주소값을 전달
  fmt.Println("Salary:", emp.Salary)
}

위 예제 코드에서는 함수의 인자를 기존의 Emp  타입에서 *Emp와 같이 포인터를 전달받도록 수정하였습니다. 그리고 이 함수를 호출하기 위한 코드도 calculateSalary(&emp)와 같이 &를 추가하여 주소 값을 전달하도록 하였습니다.

Receiver에서는 포인터를 사용해야 하나?

Go 언어는 객체지향 언어가 아니기 때문에 Struct에 함수를 정의할 수 없습니다. 대신 Receiver라고 해서 Struct 선언 외부에서 Struct가 함수를 연결할 수 있는 기능을 제공하고 있습니다. 위 예제 코드에서 조금 이상하게 만들어진 calculateSalary라는 메소드를 Emp 타입과 결합시킨 코드는 다음과 같이 만들수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Emp struct {
  Name string
  Salary int
}
// calculateSalary 함수를 Emp type과 연결시킴
func (emp Emp)calculateSalary() {
  emp.Salary = 2000
}
func main() {
  emp := Emp{
    Name: "babokim",
    Salary: 1000,
  }
  // emp 타입에 연결된 함수를 호출
  emp.calculateSalary()
  fmt.Println("Salary:", emp.Salary)
}

일반적인 자바 개발자라면 위 코드는 분명 2000을 출력할 것으로 예상합니다. 하지만 위 코드의 출력 결과는 1000입니다. 이유는 Receiver 함수 선언 시 Emp의 포인터를 사용하지 않았기 때문입니다. 다음과 같이 변경하면 정상적으로 2000이 출력됩니다.

1
2
3
func (emp *Emp)calculateSalary() {
  emp.Salary = 2000
}

하나의 타입에 여러 Receiver를 정의할 수 있는데 Receiver내에서 객체의 값을 변경하는 경우 포인터를 사용하고 그렇지 않은 경우 포인터를 사용하지 않게 하는 것이 좋은 코드 습관이기는 한데 자칫 코드 수정을 할때 실수할 가능성이 많습니다.

함수의 로직이 조금 긴 경우 개발자는 함수 Receiver의 객체가 포인터인지는 신경을 쓰지 않고 로직에만 집중하여 로직을 추가하거나 수정하는데, 이때 이 함수가 최초 제작 시에는 객체의 값을 변경하지 않았기 때문에 포인터를 사용하지 않았는데 요구사항이 변경되어 객체의 값을 변경하는 경우 로직에서 값만 변경하고 Receiver  객체는 포인터로 바꾸는 것을 놓치는 경우가 많습니다. 이것때문에 저의 경우 대부분은 포인터를 사용하고 있습니다.

위 예제 코드를 보면서 일부 개발자는 저와 같이 다음과 같은 질문을 할 수 있습니다.

"Receiver를 프인터 형으로 정의했으면 호출 시에도 주소값으로 호출해야 하는 것이 아니냐?"

예를 들어 다음과 같이 호출해야 하지 않냐는 것입니다.

1
2
3
4
emp := &Emp{...}
emp.calcualteSalary() 
또는
(&emp).calculateSalary()

이 코드도 동작은 하지만 Go 언어에서는 emp.calculateSalary()와 같은 표기법도 허용하고 있습니다.  여기에 몇가지 예외 사항도 있는데 다음 글에서 확인할 수 있습니다.

함수에서 한 개 이상 Return

자바와 다르게 Go 언어는 한개 이상의 결과를 반환할 수 있습니다. 이 기능은 화면에서 목록 형태의 데이터를 조회하는 경우 전체 건수와 데이터를 같이 전달할 경우 다음과 같이 유용하게 사용할 수 있습니다.

1
2
3
4
5
func searchPost() ([]Post, int, error) {
  posts := db.Find()
  totalCount := db.Count()
  return posts, totalCount, nil
}

이렇게 표기할 경우 두번째 반환 값이 무엇인지 함수 선언만 보면 알기 어려운데 다음과 같이 변수명을 지정할 수도 있습니다.

1
2
3
4
5
func searchPost() (posts []Post, totalCount int, err error) {
  posts = db.Find()
  totalCount = db.Count()
  err = nil
}

이 경우 모든 반환 값에 변수명을 지정해야 하며 하나라도 지정하지 않는 경우(posts []Post, totalCount int, error와 같이) 컴파일 에러가 발생합니다. 또한 함수 내부에서 함수 선언에서 명시한 반환 값의 변수명을 재 선언할 수 없습니다. 다음 예제와 같이 블록으로 감싼 별도의 scope 에서는 사용이 가능하지만 코드의 가독성이 떨어지고, 버그를 만들 가능성이 많기 때문에 추천하지는 않습니다.

1
2
3
4
5
6
7
8
func searchPost() (posts []Post, totalCount int, err error) {
  posts := nil  // 컴파일 에러
  totalCount = 0
  err = nil
  if totalCount == 0 {
    posts := nil    // 가능하지만 return value에는 영향을 주지 않음
  }
}

한개 이상의 결과 값을 반환하는 기능은 편리한 기능이기는 하지만 과도하게 많은 갯수의 결과 값을 반환하는 경우 코드를 더 읽기 어렵게 만들 수 있습니다. 그리고 앞에서 설명한 포인터를 통한 Pass By Reference 스타일을 병행 사용하게 되면 더 혼란스러워지기 때문에 가능한 최소화하는 것을 추천합니다.

맺음말

이 글은 저와 같이 일하는 개발자 중 현재 자바 언어로 주로 개발하고 있는 분들이 Go 언어를 이용할 때 제가 겪었던 착오를 줄이고자 작성한 글입니다. 항상 느끼는 것이지만 글을 쓰면서 그동안 모르고 무심결에 사용하고 있었던 것들을 알게 되었습니다. 저와 비슷한 상황에 있는 개발자에게 많은 도움이 되었으면 합니다.

한편으로 마무리 하려고 했지만 글을 쓰다보니 내용이 길어져 두편으로 나누었습니다. 다음 편에서는 에러처리, Short variable declaration, init 함수, Nil 등에 대해 살펴보겠습니다.

각주

[1]: 필자의 Go 언어 경험은 대략 6개월 정도이고,  Go 언어 책을 정독하지도 않았으며, 그냥 키워드 정도만 대략 읽어 보고 개발을 진행했습니다. Go 언어의 기본 개념 이런것 없이 그냥 직관에 의존하면서 만들었는데 이렇게 직관에 의존하면서 겪은 내용이라고 보시면 됩니다. 아직 Go 언어를 많이 파악하기 어렵다고 할 수 있는데 글 중에 틀린 부분이 있으면 바로 알려주시면 반영하겠습니다.


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