Go를 접하게 된지 1년이 되었다. Go를 사용한지 1년이 된 기념으로, 지난 1년동안 사용했던 Go라는 언어에 대해 소개하는 글을 써 보려고 한다. Go에서 자랑하는 여러 요소들이 있지만, 우선 이번 포스트에서는 Go 언어의 문법적인 특징만을 소개해 보도록 하겠다. Concurrency 처리, Garbage-Collection, 빠른 컴파일 시간, 높은 성능, 등등의 것들은 다음 기회에 소개하겠다.

Go에 처음 손을 댄 동기는 좀 불순(?)했다고 할 수 있다. 채팅 서버를 만들어야 했었는데, 채팅 서버를 구축하는데는 주로 node를 사용하는 것이 일반적인 분위기였다. 하지만 node는 개발 속도는 빠르지만 유지보수가 어렵다는 의견들이 많아서 피하고 싶었다. 다른 대안으로 vert.x가 있었는데, Java 역시 개인적인 취향상 내키지 않았다. Go가 성능도 좋고 concurrency 처리도 잘 지원한다는 소문을 들었던 터라, 이번 기회에 Go를 해 보자라는 생각으로 빠르게 Go언어의 스펙을 훑어보았다.

퇴근하는 지하철에서 A Tour of Go를 보았고, 그날 밤 채팅 서버의 기본 와꾸를 만들었다. 다음날 출근해서 아래와 같이 동작하는 채팅 서버를 Go로 만들었고 무리없이 잘 동작했다.

  1. 클라이언트로부터 웹소켓으로 메세지를 받아 RabbitMQ에 저장
  2. RabbitMQ에 전달된 메세지 처리 후 다시 RabbitMQ에 전달
    메세지 처리 절차
    • 메세지 파싱/분석
    • DB 저장
  3. 처리가 끝나고 RabbitMQ에 담긴 메세지를 다시 클라이언트에 웹소켓으로 전달

최종적으로는 vert.x를 사용하기로 결정되었지만, 어쨌든 이것을 계기로 Go를 처음 접하게 되었다. 그 후 Go의 재미에 푹 빠지게 되었고, 그때부터 개인적으로나 회사 업무에 사용되는 대부분의 코드는 Go로 작성하였다.

간결한 문법

Go 언어의 설계는 간결하고 명확한 문법 작성에 중점을 두었다. 그래서 코드의 복잡함을 줄이고 가독성을 높였다.

Go 언어는 문법적인 요소는 줄이고 유연함을 높였다. 그래서 적은 문법으로도 풍부한 기능을 구현할 수 있다. 예를 들어 while문을 없애고 for문으로만 반복문을 표현하고, switch문의 case에 조건식을 넣어 복잡한 if문을 switch문으로 대신 간결하게 표현할 수 있다.

Go는 엄격하게 타입을 확인하는 정적 타입 언어지만, 동적 타입 언어의 특성들도 수용했다. 변수의 타입을 지정하지 않아도 컴파일러가 변수에 할당되는 값의 타입을 자동으로 감지해서 결정하고, 인터페이스는 덕타이핑(Duck typing) 방식으로 동작한다.

duck typing이란?

컴퓨터 프로그래밍 분야에서 덕 타이핑(duck typing)은 동적 타이핑의 한 종류로, 객체의 변수 및 메소드의 집합이 객체의 타입을 결정하는 것을 말한다. 클래스 상속이나 인터페이스 구현으로 타입을 구분하는 대신, 덕 타이핑은 객체가 어떤 타입에 걸맞은 변수와 메소드를 지니면 객체를 해당 타입에 속하는 것으로 간주한다. “덕 타이핑”이라는 용어는 다음과 같이 표현될 수 있는 덕 테스트에서 유래했다.

만약 어떤 새가 오리처럼 걷고, 헤엄치고, 꽥꽥거리는 소리를 낸다면 나는 그 새를 오리라고 부를 것이다.

-wikipedia-

Static vs. Dynamic

Go를 접하기 이 전에 가장 많이 사용했던 언어는 C#과 Ruby였다.
2006년부터 개발자로 살아오면서 메인으로 사용했던 언어가 C#이고, 2013년부터는 주로 Ruby를 사용해 웹서버 개발을 해 왔다.

오랫동안 사용해 와서인지 C#이 가장 편하다. C#은 태생은 컴파일 기반의 정적 언어지만, 동적인 속성과 함수형 언어의 특징까지 수용해서 언어적인 측면에서 볼때 그 어떤 언어와 비교해도 뒤지지 않는다. 게다가 Visual Studio와 .NET 플랫폼과의 찰떡 궁합으로, C# 만으로도 큰 어려움 없이 대부분의 시스템을 구축하고 운영까지 할 수 있다.

최근에 .NET CLR(Common Language Runtime - Java의 virtual machine과 유사한 개념)이 오픈 소스가 되고(https://github.com/dotnet/coreclr) 이어서 ASP.NET MVC(https://github.com/aspnet/Mvc), MSBuild(https://github.com/Microsoft/msbuild) 등 .NET의 핵심 기술이 오픈소스화 되면서 이제는 윈도우 뿐만 아니라 맥이나 리눅스에서도 .NET 플랫폼의 장점을 사용할 수 있게 되었다. 아직은 맥에서 C# 개발을 하려면 좀 불편한(?) 점이 있긴 하지만, 머지않아 불편함 없이 맥에서도 C# 개발을 할 수 있을 것 같다. MS의 야욕(?)은 갈수록 거대해져서 모바일과 클라우드 개발 플랫폼으로써의 지위도 이미 획득한 것 같고, 최근 유행하는 Docker도 자연스럽게 지원하면서 정말 C#만 제대로 하면 모든 것을 다 할 수 있을 것 같은 생각이 든다.

하지만, 그럼에도 불구하고, 개발할때 가장 재밌는 언어는 Ruby이다. 동적 언어의 유연함이 주는 재미! 사용해 본 사람은 알 것이다. 확고한 타입에 얽메이지 않고 쉽게 객체를 정의하고 어느 위치에서건 그 객체가 가진 원래의 능력에 +α를 더해 사용되어 지는 것을 보면 참 재밌다. 굳이 재미의 요소를 얘기하지 않더라도, 실제 개발 속도와 작성되는 코드의 양으로 봤을때도 Ruby와 같은 동적 언어의 생산성은 컴파일 기반의 정적 타입 언어와 비교했을때 뛰어나다고 할 수 있다.

루비온레일즈로 웹 어플리케이션을 개발할때 좋았던게 여러가지가 있지만, 그 중 한가지가 소스를 수정하면 바로 웹서버에 반영된다는 것이다. ASP.NET MVC로 개발할때는 항상 소스를 수정할 때 마다 컴파일 하고 웹서버를 재구동 하는 과정을 반복해 주어야 했다. 소스 하나 수정하고 컴파일 하고 웹서버를 다시 구동시키고, 또 소스 하나 수정하고 컴파일 하고 웹서버를 다시 구동시키고,,,, 물론 Visual Studio가 이런 일련의 과정을 다 알아서 해 준다. 그리고 테스트 코드를 잘 작성해 놓으면 소스의 변경 사항을 웹서버를 구동시켜 확인하지 않고 테스트 코드를 실행시켜 확인할 수 있다. 하지만 아무리 Visual Studio가 다 알아서 해 준다 하더라도, 또 TDD 패턴으로 개발을 한다 하더라도, 소스를 변경하고 그것을 확인하는 그 싸이클에 소요되는 시간은 어찌 할 수 없다. 조금만 덩치카 큰 프로그램을 한번 빌드하고 재구동 시키려면, 괜히 화장실에 한번 갔다 와야 한다. ㅎㅎ 그에 비해 ruby는 바로바로 반영이 되니까 참 편하더라. 생각한대로 코드를 작성하고 확인하고 또 코드를 수정하고 또 확인하고,,, 컴퓨터가 내 생각의 흐름을 전혀 방해하지 않아서 좋다. 그래서 한번 개발의 리듬을 타면 그 속도가 엄청 빨라진다.

하지만 이런 동적 언어의 한가지 문제는, 컴파일 과정이 없어서 간단한 문법 오류 조차 런타임시에 나타난다는 것이다. 문법 오류가 아니더라도, 동적 언어에서는 타입이 정해져있지 않기 때문에 런타임시에 기대했던 객체가 아닌 다른 객체가 전달될수도 있고 그로 인해 예상치 못한 결과가 나타나기도 한다. 물론 테스트 코드를 통해 그것을 보완 할 수 있다. 당연히 테스트 커버리지를 높일수록 코드의 오류가 테스트 단계에 드러나게 되고 그 과정을 통해 탄탄한 프로그램이 되어 간다. 자연스레 테스트 코드를 작성하는 좋은 습관이 몸에 베이게 된다. 하지만 테스트 코드는 어디까지나 보완책일 뿐이고, 이 문제에서 완전히 자유할 수 없다.

결론적으로, 둘 다 아쉽다.
많은 개발자들은 새로운 프로젝트를 시작할 때 빠른 개발 속도와 높은 성능 사이에서 불편한 선택을 해야 한다. 그래서 ruby나 node로 초기 버전을 빠르게 만들고, 사용자가 늘어나서 성능이 중요해지는 시점이 되면 java나 c++과 같은 언어로 서버를 다시 작성하는 방법을 사용하는 스타트업도 많다.
동적 언어의 개발 속도와 컴파일 기반의 정적 언어의 안정감을 둘 다 가질 수는 없는 것인가?

Static + Dynamic

Go 개발팀은 개발자들이 직면한 이러한 문제를 해결하기 위해 많은 고민을 했다.
이들은 동적 언어의 빠른 개발 속도와 정적 언어의 안정감과 높은 성능. 이 두가지 장점을 모두 수용하고 싶었다.

Go는 컴파일 기반의 정적 타입 언어이다. 하지만 동적 언어의 특성들도 수용함으로써 동적인 느낌으로 코드를 작성할 수 있다.
즉 컴파일러의 보장을 받으면서 동적 언어의 유연함과 자유함을 만끽할 수 있다.
이것을 가능하게 만들어 주는 것은 바로 덕타이핑(Duck Typing) 방식으로 동작하는 Go의 인터페이스이다.

Go의 인터페이스에 대해 좀 더 자세히 얘기하자면, Go에서의 인터페이스의 역할은 객체의 동작을 표현하는 것이다. 인터페이스가 표현하고 있는 방식대로 동작하는 객체는 인터페이스로 사용할 수 있다.
If something can do this, then it can be used here
만약 특정 타입이 어떤 인터페이스를 implement 한다면, 그것의 의미는 그 타입은 인터페이스가 정의한 방식대로 동작할 수 있다는 의미이다. 그냥 그것이 전부이다. implements와 같은 키워드를 사용하여 어떤 인터페이스를 구현했는지 정의할 필요가 없다. 그냥 특정 인터페이스에서 정의하고 있는 메쏘드를 가지고 있기만 하면 된다.

동작이라고 표현한 것을 기억하자. 많은 정적 타입 언어에서 인터페이스는 그 타입의 태생이 무엇인지를 얘기하고 있다면, Go의 인터페이스는 특정 타입이 어떻게 동작하는지를 얘기하고 있다.

만약 Read() 메쏘드를 정의한 Reader라는 인터페이스가 있다고 가정해 보자. A라는 타입에 Read() 메쏘드가 정의되어 있다면, A 타입은 Reader 인터페이스로 사용될 수 있다.
A 타입과 Reader 인터페이스는 코드상으로 어떠한 연결고리도 가지고 있지 않다.

Java와 같은 엄격한 객체지향 언어에서는 특정 클래스가 어떤 인터페이스를 구현하는지 명시적으로 표기를 해 주어야 한다. 당연한 말이지만, 어떠한 클래스가 특정 인터페이스로 사용되려면 implements 키워드로 명시해 주어야 한다. 이 말을 뒤집어 생각해보면, implements 키워드로 특정 인터페이스를 구현하다는 표기를 해 주지 않으면 인터페이스에 정의된 메쏘드를 구현했다 하더라도 그 인터페이스로 사용할 수 있는 방법은 없다.
(헐~~ 당연한 얘길 하자니 말이 점점 꼬인다.)

많은 컴파일 언어에서는, 특정 라이브러리나 프레임워크와 호환되는 어떤 객체를 만들려면, 특정 인터페이스나 클래스를 상속받아 구현하는 것이 일반적이다. 물론 동적으로 인보킹 시킬수록 있지만, 여러가지 이유로 리플렉션 방식은 꼭 필요한 경우가 아니면 자제하는 것이 좋다. 그렇게 생성된 클래스는 특정 라이브러리나 프레임워크와의 연결고리가 생겨버리고, 그러면서 확장성이 떨어지게 된다.

Go에서는 인터페이스의 이러한 특징으로 인해, 모듈간의 연계가 굉장히 쉽다.
전체 플로우를 제어하는 미들웨어를 만들고, 인터페이스 기반으로 전체 플로우를 제어하도록 해서 어떠한 라이브러리나 패키지를 담을 수 있는 형태로 미들웨어를 만드는 것은 Go의 일반적인 패턴이다.

개인적으로 Go의 인터페이스를 덕타이핑 방식으로 동작하도록 설계한 것은 신의 한수라 생각한다.

Go의 인터페이스는 정적 언어에 동적 언어의 유연함과 자유함을 더해 주었고,
컴파일러의 도움을 받으면서 동적인 코딩을 할 수 있게 해 주었다.

Composition over Inheritance

상속에 대해서는 우리가 좀 생각해 볼 필요가 있다.

자바의 창시자 제임스 고슬링과 어느 한 프로그래머가 대화한 유명한 일화가 있다

자바 컨퍼런스에서 어느 프로그래머가 자바의 창시자 제임스 고슬링에게 물었다고 한다.

"자바를 다시 만든다면 무엇을 바꾸고 싶습니까?"

그러자 그는 "클래스를 없애버리겠다"고 했다.
그리고 곧바로, 진짜 문제는 클래스가 아니라 클래스 구현의 상속이라고 했다.
가능하면 인터페이스 상속을 하고, 클래스 구현의 상속은 피하라고 했다.

한 클래스가 다른 클래스를 상속받는 방식으로 만들게 되면, 클래스들 간에 트리 형태의 관계가 만들어지게 되고 이러한 구조가 점점 커짐에 따라서 여러가지 문제들을 유발시키게 된다. 그래서 상속은 굉장히 주의해서 사용해야 한다. 디자인 패턴을 공부해보면, 상당부분의 패턴이 클래스 상속을 인터페이스 구현으로 바꾸거나 상속 관계를 composition 관계로 바꾸는데 있다.

그래서 디자인 패턴에서는, composition over inheritance를 강조한다. 바로 상속보다는 조합의 방식을 사용하라는 말이다. Go는 아예 언어 차원에서 상속을 없애고 composition 방식으로 코드를 재사용 하도록 했다.

Go의 struct는 상속이 불가능하다.
하지만 하나의 struct가 다른 struct를 포함하고 있는 embed type 형태로 struct를 정의할 수 있다. 이러한 composition 방식으로 코드를 재사용 할 수 있다.

물론 다른 언어에서도 composition 방식으로 타입을 정의할 수 있다. 하지만 이는 상속을 구현하는 것보다 복잡하고 어려워서, 실제로 많은 개발자들은 그냥 고민없이 상속의 방식을 사용할때가 많다. Go의 struct는 composotion 방식만 제공하고 있어서, 이러한 타입 기반으로 코드를 작성하면 자연스럽게 composition 방식으로 문제를 해결해가는 것이 익숙해진다.

만약 Java나 C++ 프로그램에서 추상클래스(abstract class)와 인터페이스를 어떻게 구성할지에 대해 일주일 이상의 시간을 써 본 적이 있다면, compoition 기반의 Go의 간결한 타입에 감사하게 될 것이다.



이 글이 도움이 되셨나요?

Feedly에서 Remotty 블로그 구독하기
페이스북에서 Remotty 구독하기