go cui에서 한글 출력 문제로 인한 삽질 기록

2020. 4. 17. 15:06Go

go-cui에서 채팅 출력 기능을 구현한 뒤, 한글이 화면에 제대로 출력되지 않던 문제가 발생했다.
*"hi, 안녕하세요" 를 입력하면 "hi, 안하요" 라고 출력되었는데 이 부분을 고쳐보기로 했다.*

g.Update(func(g *gocui.Gui) error {
    v, _ := g.View("chatline")
    info := fmt.Sprintf("%s", cm.jsonMessage.Content)
    v.Write([]byte(info))
    return nil
})

문제가 발생한 코드의 일부다. 먼저 "chatline" 이라는 View를 가져오고, 컨트롤러에서 보낸 메세지를 info 에 담는다. (info에는 "hi, 안녕하세요"라는 string이 담길 것이다) 그 다음, 가져온 View에 Write() 함수를 부른다. 이 때, info를 byte array로 변환해서 넘겨주기 때문에 이 부분에서 문제가 발생할 것이라고 생각해서 View.Write() 함수를 살펴보기로 했다.

func (v *View) Write(p []byte) (n int, err error) {
     for _, ch := range bytes.Runes(p) {
         switch ch {
         default:
             cells := v.parseInput(ch)
             v.lines = append(v.lines, cells)
         }
     }
 }

go-cui에 정의된 View.Write() 함수의 일부다. bytes.Runes(p) 를 통하여 파라미터로 넘어온 byte array를 rune array로 변환한다. rune 타입에 대한 설명은 링크에서 확인할 수 있다. 간단히 설명하면 rune은 UTF-8 방식으로 인코딩한 한 개의 문자이다. 한글 "가"는 byte array로 표현하면 [234 176 128]이다. 즉, 3개 byte의 array로 표현된다. 하지만 rune으로 나타내면 44032로 나타난다. 즉 int32 형식의 하나의 숫자로 표현이 가능해진다.

var a rune = '가'
fmt.Println([]byte(string(a)))  // [234 176 128]
fmt.Printf("%d", a)             // 44032

다시 원래 코드로 돌아와서, 파라미터로 넘어온 "hi, 안녕하세요"는 ['h', 'i', ',', '안', '녕', '하', '세', '요']로 나눠져서 ch 변수에 담기게 된다. 그리고 각각은 v.parseInput(ch) 를 통하여 cells 로 바뀐다. cells 는 rune type의 문자 하나와 색깔에 대한 정보를 담고 있는 구조체이다. 이를 v.lines 에 담는다. 이제 컨트롤러로부터 메세지가 올때마다 View의 View.linescells 구조체로 정보가 담긴다는 것을 알았다. 이제 View.lines 를 사용하는 곳을 찾기 위해 MainLoop를 살펴보자. 컨트롤러에서 호출하는 MainLoop로부터 View.lines 가 사용되는 곳까지 꼬리물기 형태로 찾아 내려가봤다.

// gui.go
func (g *Gui) MainLoop() error {
    select {
        case ev := <-g.tbEvents:
            ...
        }
        if err := g.flush(); err != nil {
            return err
        }
}

func (g *Gui) flush() error {
    ...
    for _, v := range g.views {
        if err := g.draw(v); err != nil {
            return err
        }
    }
    ...
    termbox.Flush()
}

func (g *Gui) draw(v *View) error {
    ...
    if err := v.draw(); err != nil {
        return err
    }
    ...
}

// view.go
func (v *View) draw() error {
    ...
    for i, vline := range v.viewLines {
        for j, c := range vline.line {
            if err := v.setRune(x, y, c.chr, fgColor, bgColor); err != nil {
                return err
            }
    ...
}

func (v *View) setRune(x, y int, ch rune, fgColor, bgColor Attribute) error {
    ...
    termbox.SetCell(v.x0+x+1, v.y0+y+1, ch,
        termbox.Attribute(fgColor), termbox.Attribute(bgColor))

    return nil
}

메인 루프로부터 최종적으로 setRune 함수가 불리는 것을 알 수 있다. 길고 긴 꼬리물기를 지나서 go-cui에서 cui를 위해 만들어진 다른 패키지 termbox (링크)를 호출하는 것을 알 수 있다. setRune 함수 내부에서 rune type인 ch 을 출력해보면 "안", "녕", "하", "세", "요" 모두 문제없이 출력되었기 때문에 우리는 termbox 패키지에서 문제가 있을 것이라고 확신했다. 또, 링크에서 알 수 있듯이 termbox는 더이상 유지보수 되지 않고 있다. 이쯤에서 우리가 원인을 찾아도 고칠 수 있을까하는 생각이 들었지만, 일단 원인을 찾는데 집중하기로 했다.

// termbox.go
var (
    back_buffer    cellbuf
    front_buffer   cellbuf
)

func SetCell(x, y int, ch rune, fg, bg Attribute) {
    ...
    back_buffer.cells[y*back_buffer.width+x] = Cell{ch, fg, bg}
}

back, front buffer? 이게 뭔가 했는데, 종우가 더블 버퍼링에 대해 간단히 설명해줬다. 간단히 말해서 더블 버퍼링은 화면 깜빡임 현상을 없애기 위해, 백 버퍼에서 미리 보여줄 내용을 다 계산해놓고 한번에 프론트 버퍼로 옮겨서 보여주는 기법이다. SetCell() 함수는 백 버퍼에 보여줄 내용을 저장하는 기능을 하는 함수인 것이다. 여기서는 각 문자(ch)들이 정상적으로 출력되었기 때문에, 백 버퍼의 내용을 프런트 버퍼로 옮길 때 문제가 발생할 것이라고 추론할 수 있었다.

// termbox.go
// back buffer의 내용을 front buffer로 옮기는(정확히는 동기화시키는) 함수
func Flush() error {
    ...
    for x := 0; x < front_buffer.width; {
        cell_offset := line_offset + x
        back := &back_buffer.cells[cell_offset]

        w := runewidth.RuneWidth(back.Ch) //back.Ch는 rune
        ...
        x += w
    }
    ...
}

바로 이 부분이다! 백 버퍼에서 cell_offset 위치에 있는 cell을 읽어온다. 이 때 cell_offset은 매 루프마다 x의 변화량만큼 늘어난다. 다음으로 x는 매 loop마다 runewidth.RuneWidth(back.Ch) 만큼 늘어난다는 것을 알았다. 그렇다면 영어와 한글의 rune 길이는 어떻게 될까?

fmt.Println(runewidth.RuneWidth('a'))    // 영어의 rune width: 1
fmt.Println(runewidth.RuneWidth('가'))    // 한글의 rune width: 2

즉, 백 버퍼로부터 값을 읽어올 때, 영어는 index가 1씩 증가하면서 읽고, 한글은 index가 2씩 증가하면서 읽는다는 것을 알 수 있었다. 이 부분에서 "안녕하세요"가 "안하요"로 출력되는 이유를 어렴풋이 짐작할 수 있었다.

SetCell() 함수에서 저장할 때는 index를 1씩 증가시키면서 저장하고, 읽어올 때는 index를 2씩 증가시키면서 읽기 때문에 한 글자씩 건너뛰면서 출력되는 게 아닐까? 확인하기 위해 SetCell()함수에서 x,y 값을 출력해봤다.

// 출력결과 - 27,0: 안, 28,0: 녕, 29,.0: 하, 30,0: 세, 31,0: 요
func SetCell(x, y int, ch rune, fg, bg Attribute) {
    ...
    back_buffer.cells[y*back_buffer.width+x] = Cell{ch, fg, bg}
    fmt.Printf("%d,%d: %c", x, y, ch)
}

// 출력결과 - 27: 안, 29: 하, 31: 요 
func Flush() error {
    ...
    for x := 0; x < front_buffer.width; {
        **cell_offset := line_offset + x**
        back := &back_buffer.cells[cell_offset]
        ****fmt.Printf("%d: %c", x, back.Ch)
        ...
    }
    ...
}

역시나 SetCell() 함수에서는 각 문자별로 index가 1씩 늘어나며 저장되고, 읽어올 때는 index가 2씩 늘어나며 읽어온다는 사실을 확인할 수 있었다.

도대체 왜?

조금 더 공부를 하다보니 왜 이런 식의 코드가 나왔나 추측해 볼 수 있었다. 링크를 참고하면 runewidth.RuenWidth() 는 rune의 cell 갯수를 세는 함수다. 즉, rune과 cell은 일대다 관계다. 예를 들어, 영어 rune은 1개의 cell이, 한글 rune은 2개의 cell이 존재한다. 하지만 코드에서는 어떨까.

type Cell struct {
    Ch rune
    Fg Attribute
    Bg Attribute
}

func SetCell(x, y int, ch rune, fg, bg Attribute) {
    ...
    back_buffer.cells[y*back_buffer.width+x] = Cell{ch, fg, bg}
}

rune과 cell이 1대1 대응으로 되어있다. 심지어 cell이 rune을 포함한다고 되어있다. 사실 Cell 구조체는 ColoredRune 정도의 정보를 담고있고, SetCell()함수는 SetRune()정도의 기능을 하고 있는 것이다. 이제 코드 작성자가 어떤 실수를 했는지 명확하게 알 수 있다. 작성자의 원래 의도는 저장과 읽어오는 단위를 모두 cell 단위로 하려는 것이지만, 실제로는 정보를 저장할 때는 rune 단위로 저장하고, 출력할 때는 cell 단위로 읽어오는 실수를 범한 것이다! 잘못된 도메인 지식으로 인해 벌어진 헤프닝이거나 나중에 코드가 수정되면서 일부만 수정된 것 같은데, 덕분에 3시간 넘게 삽질했던 것 같다. 안타깝게도 termbox는 더이상 유지보수 되지 않기 때문에 영어를 사용할 것이 아니라면 termbox를 사용하는 패키지들을 사용하기 어려울 것 같다.

그래서 해결책은?

간단한 해결책으로는 저장할 때 항상 rune 단위로 저장되기 때문에 읽어올 때도 index를 1씩 늘려가며 읽어오도록 수정하는 방식이 있다. 하지만 이로 인해 어떤 일이 벌어질지 예상할 수 없으므로 다른 누군가가 만들어놓은 패키지를 찾아보았다. termbox의 대안을 찾진 않았지만 go-cui의 대안은 찾을수 있었다. 코드를 살펴보니 termbox를 커스터마이징해서 아예 새로 짰다. 네덜란드 사람 둘이 짠 패키지였는데, 세상엔 이런 대단한 사람도 있구나 하는 생각이 들면서 한없이 작아지는 기분을 느꼈다.

버그를 해결하고 몸과 마음은 굉장히 지쳤지만, 한편으로는 뿌듯하기도 하고 재밌기도 했다. 아마도 혼자 봤으면 고칠 생각도 안 했을 거고, 이유를 찾아볼 생각도 못 해봤을 텐데 스터디를 하면서 이런 경험도 할 수 있다는 점도 좋았다. '오브젝트'에서 봤던 *"예상 가능한 코드를 작성해라"*라는 문구도 떠올랐다. Cell이라는 구조체는 Cell로서의 기능을 수행할 것이라고 예상된다. 하지만 실제로는 Rune으로서의 기능을 수행한다. 예상이 어려운 코드이기 때문에 버그가 발생한 것이다. 또, 다른 사람이 짜놓은 go 코드를 해석하면서도 많은 것을 배웠다. 앞으로 go 코드를 작성할 때, 많은 도움이 될 것 같다.