더 읽기 쉬운 코드를 작성하는 것이 매우 중요하다고 느낀다. 책에서도 매번 나오고 인터넷에서도 그렇고 어디서나 읽기 쉬운 것이 중요하다고는 하는데 사실 뭔가 직접 해보지 않고서는 실제로 와닿지 않는 것이다. 와닿지 않으면 느껴지지 않고, 느낄 수 없다면 배울 수 없다. 그래서, 앞으로는 작성하는 코드에서 뭔가 인사이트가 있었다면 여기에 기록해서 다음 코드에서 반드시 적용할 수 있도록 하려고 한다.

관습적인 코드들이 있다

개인적으로 코드를 작성할 때 map이나 filter의 개념을 잘 받아들이기가 쉽지 않았다. 파이썬의 경우 map, filter를 제공하고 있지만, 코드가 더 복잡한 모양으로 바뀐다고 믿고 있기 때문에 개인적으로는 알고 있지만 절대 사용하지 않는 키워드이다. 특히나 Go에서는 이런 키워드가 존재하지 않으므로 그 다음부터는 신경쓰지 않았던 것들이었고. 그러나 읽기 쉬운 코드라는 것은 봤을때 명확한 코드라는 의미이다. map/filter의 경우 실제로 복잡해보일지 어떨지는 몰라도 너무나 많은 사람들이 알고 있는 개념이며 이 개념을 알고 있는 사람들은 이미 이를 명확하게 파악할 수 있다는 의미이다. 따라서 유명한 개념이 있는 그런 방식이나 코드 작성 스타일은 모두 읽기 쉬운 코드라고도 볼 수 있다.

따라서 어떤 것이 유명하고 널리 쓰이는지는 어느 정도 파악하고 있어야 한다. 나 빼고는 모두 읽기 쉬워하는 코드라는 뜻이니까.

map/filter의 경우 Go에서 유명한 것은 go-funk가 있으니까 이를 자주 써 보는 것도 괜찮을 것 같다.

연관 있는 것들은 최대한 가까이 두자.

이건 꼭 Go에서만 해당되는 거는 아니다. 코드 작성은 결국 특별한 문법을 가진 언어로 글을 쓰는 것과 같으므로 읽기 편한 것이 필요하다. 하물며 한글로 적혀 있어도 두서가 없으면 읽기 힘든데, 코드도 마찬가지다.

func process() {
        //...
        //...
        //...
        msg := MessageMission{
                MissionID:   MissionID,
        }

        content, _ := json.Marshal(msg)

        MissionPlace, err := self.Database.Fetch(ctx, MissionID)
        if err != nil {
                return fmt.Errorf("err: %w", err)
        }

        //...
        //...
        //...

        if err := self.Queue.Publish(ctx, MessageWrapper{ID: uuid.New().String(), Content: content}); err != nil {
                log.Printf("Queue: %v", err)
        }
}

예를 들면 이런 코드가 있을 때… 크게 보면 코드의 흐름은 메시지를 만든 후 적당한 DB처리를 하고 잘 완료가 된다면 큐에 메시지를 발행하는 내용이다. 발행할 메시지는 이미 위에서 결정되었고, 아래에서 아무런 의존성을 가지지 않기 때문에 메시지를 발행하기 직전에 만들어도 된다. 순서대로 코드를 읽다 보면 Publish에서 필요한 파라미터 content가 어디서 온 건지 확인하기 힘들기 때문에 순서를 바꾸기만 해도 가독성이 올라간다.

func process() {
        //...
        //...
        //...

        MissionPlace, err := self.Database.Fetch(ctx, MissionID)
        if err != nil {
                return fmt.Errorf("err: %w", err)
        }

        //...
        //...
        //...

        msg := &MessageMission{
                MissionID:   MissionID,
        }

        ID := uuid.New().String()
        content, _ := json.Marshal(msg)

        if err := self.Queue.Publish(ctx, MessageWrapper{ID: ID, Content: content}); err != nil {
                log.Printf("Queue: %v", err)
        }
}

타입을 적극적으로 재정의하자

Go에서는 새로운 타입을 간단히 지정할 수 있다. 예를 들면 이런 것이다.

type People struct{
    Age int
    Name string
}

type Member People
type Members []Member

type이라는 키워드는 이런저런 역할을 하는데, 예를 들어 어떤 변수의 타입을 알아낼 때도 쓰이지만 위 코드와 같이 단순하게 새로운 타입을 정의하기도 한다. 여기서는 아래와 같이 쓰였다.

  • struct를 정의하여 이를 새로운 타입 People로 만들었다.
  • 이미 존재하는 People타입을 새로운 타입 Member, Members로 만들었다.

타입을 새로 지정했다는 것은 해당 타입에 대한 메소드를 새로 추가할 수 있다는 의미가 된다. 따라서 Member만 만들어진 상태에서의 코드와 Members가 추가된 상태의 코드는 작성이 많이 달라질 것이다.

메소드를 추가하는 것을 알아보기 전에 다음과 같은 상황을 생각해 보자. 파일을 src에서 to로 복사하는 함수 Copy가 있다고 치면, 이를 어떻게 함수로 만들 것인가? 시그니처만 생각해 보면 아래와 같을 것이다.

func Copy(src, to string) error

그런데 이를 잘 생각해 보면 어떤 사람은 이를 반대로 지정할 수도 있겠다.

func Copy(to, src string) error

Go에서는 Named Parameter의 개념이 없기 때문에 쓰는 사람 입장에서는 좌우가 바뀔 수 있다는 것을 항상 알고 있어야 한다. 따라서 문서를 반드시 읽어야 하는 것이고. 물론 이 함수 Copy는 관습적으로 src가 앞에 나온다는 것을 다들 암묵적으로 알고 있기 때문에 큰 문제는 없겠지만 우리가 다 모르는 생소한 함수의 파라미터가 이런 식으로 작성이 된다면 사용자 입장에서는 반드시 문서를 보고, 아니면 IDE의 시그니처 가이드를 보고 이를 사용하여야 하는 것이다.

이를 더 읽기 쉽게 바꾸기 위해서는 다음과 같은 방식으로 타입을 재정의해서 사용해볼 수 있겠다.

type Source string
type Destination string

func Copy(s Source, d Destination) error

이렇게 하면 파라미터를 절대 바꿔서 작성할 수가 없다. 애초에 타입이 다르기 때문에 s와 d를 바꿔 넣었을 경우 컴파일 단에서 에러가 발생하기 때문이다. 이런 방법은 꽤 좋지만 개인적으로는 아래의 방법이 더 뛰어나다고 생각한다.

type Source string
func (s Source) Copy(to string) error

if err := source.Copy("/tmp"); err != nil {
    // ...
}

파라미터가 적을수록 함수의 의도를 더 잘 표현할 수 있다고 믿는다. Source의 메소드 Copy를 만들어서 Copy가 해야할 의도를 더 명확히 표현한 것이라 생각한다.

streams := []Stream{..., ...}

if len(streams) == 0 {
    // ...
}

위와 같은 코드를 보면 관습적으로 IsEmpty를 호출했음을 알 수 있다. 하지만 이는 == 0까지 읽었을 때 IsEmpty라는 것을 알아챌 수 있게 된다. 그리고 더 정확히 말한다면, 이게 IsEmpty를 표현하는지도 사실 정확히 알 수는 없다. 길이가 0임을 체크하는 것이지 비었다를 확인한다는 의도를 전달하는 것은 아니기 때문이다. 따라서 이 코드도 다음과 같이 작성해볼 수 있다.

type Streams []Stream

type (s Streams) IsEmpty() bool {
    return len(s) == 0
}

if streams.IsEmpty() {
    // ...
}

훨씬 보기 좋지 않은가?