GUI 설계에서 컴포넌트 간 통신과 상태 관리가 어떻게 복잡성을 키우는지, 박스 연결, 상태 끌어올리기, 메시지 버스, 전역 상태/즉시 모드 등 접근법의 함정과 한계를 경험적으로 짚는다.
사용자 인터페이스는 묘한 물건이다. GUI를 만드는 여정에 도움을 준다는 온갖 라이브러리와 프레임워크가 있지만, 모범 사례를 따르고 프레임워크가 시키는 대로 ‘채소를 먹는다’고 해도 GUI는 결국 우스꽝스러운 난장판이 되곤 한다. 이 주제에 대해 곰곰이 생각한 끝에, 왜 이런 문제가 생기는지 그 원인을 깨달았다.
창고(헉)를 관리하는 그린필드 프로젝트를 시작한다고 해보자. 이 GUI는 창고 주인이 바라는 건 다 갖췄고, 디자인 관련 소셜 네트워크에서 업보트를 받으려는 좌측의 쓸데없이 커다란 사이드바까지 있다. 로그인한 사용자의 아바타와 정보는 왼쪽에, 재고 테이블은 가운데에, 온갖 버튼들은 그 밖의 곳에 흩어져 있다.
이런 사용자 인터페이스 콘셉트에서 애플리케이션을 설계해 달라고 하면, 아마 GUI를 머릿속에서 박스로 나눠 ‘UserSection’, ‘InventoryTable’, ‘BottomActionButtons’ 같은 라벨을 붙일 것이다. 요즘은 이런 라벨이 붙은 박스들을 보통 별도의 클래스로 만든다. 클래스는 GUI를 구성하는 좋은 블록이다. 각각의 작은 컴포넌트 내부 상태만 품은 작은 부분들로 복잡성을 쪼갤 수 있기 때문이다. 가운데의 재고 테이블을 프로그래밍할 때 사용자 아바타 섹션을 둘러싼 복잡함 따윈 신경 쓰고 싶지 않다. 달리 말하면, A 박스 안을 정리할 때 B 박스 안의 난장판은 신경 쓸 필요가 없다.
한동안은 ‘다른 박스의 난장판은 내 문제가 아니다’라는 전략이 통한다. 하지만 곧 다음 난관을 만난다. 창고 GUI의 사용자 섹션에 ‘작업 중’이라는 작은 표시등이 있다고 해보자. 사용자가 재고 테이블을 편집하고 있을 때는 그 불이 초록색으로 켜지고, 편집을 멈추면 다시 회색으로 꺼져야 한다.
이런, 이제 ‘다른 박스의 난장판’이 바로 당신의 문제가 됐다. 재고 테이블의 편집 상태를 사용자 아바타 섹션의 표시등 상태와 연결해야 하기 때문이다. 중요한 설계 결정을 두고 갈림길에 서게 된다.
박스를 연결하기: 사용자 아바타 컴포넌트를 만들어 그 인스턴스를 재고 테이블 컴포넌트에 전달한다. 재고 테이블의 편집 상태가 바뀔 때마다, 재고 테이블의 비즈니스 로직이 사용자 아바타의 공개 API를 통해 사용자 아바타 컴포넌트의 상태 변경도 트리거한다.
상태 끌어올리기: 사용자 아바타 컴포넌트의 내부 상태와 재고 테이블의 상태를 별도의 박스/클래스로 옮긴다. 사용자 아바타와 재고 테이블 컴포넌트의 로직은 여전히 각자의 박스에 깔끔히 분리되어 있지만, 재고 테이블이 사용자 아바타에 직접 접근하지 않고도 서로 통신할 수 있게 된다.
메시지 버스 도입: 애플리케이션에서 이벤트를 분배하는 공유 파이프에 재고 테이블과 사용자 아바타 컴포넌트를 연결한다. 사용자 아바타 컴포넌트는 메시지 버스를 구독하고, 테이블 편집 이벤트를 받을 때마다 적절한 동작(예: 표시등 켜기)을 수행한다.
말할 것도 없이, 위의 어떤 선택지도 문제 없이 깔끔하진 않다.
공통 상태를 담는 또 다른 레이어를 들이밀 용기가 없어졌다면, 사용자 아바타 컴포넌트를 재고 테이블 컴포넌트에 직접 주입해 이런 박스 간 통신 문제를 풀 수도 있다. 이론가들과 다른 청교도들은 이건 나쁜 생각이며 생각조차 하면 안 된다고 말할 것이다. 아이들, 아니, 당신이 작성하지 못할 모든 테스트를 생각해 보라고 말이다.
나도 단위 테스트 신성교에 대한 이런 죄를 저질러 본 전적이 있다. 프로젝트가 아직 걸음마 단계이고 무엇을 만들고 있는지조차 명확하지 않을 때, 나는 대개 별생각 없이 컴포넌트들을 마구 끼워 맞추는 편이다. 가끔은 모서리를 깎고, 가끔은 모서리에게 베이기도 한다. 대체 얼마나 나쁠 수 있겠어?
결국, 작은 컴포넌트에 한해서는 이 전략이 꽤 잘 먹힌다. 늘 그렇듯, 프로젝트가 커져 이런 클래스 간 통신 경로가 수백 개로 불어날 때에는 대개 잘못된 선택이 된다. 수백 개 컴포넌트를 주입하는 일은 지겹고 실수하기 쉬우며, 보기에도 흉하다.
이런 힘든 시기엔, 개발자들은 근사해 보이는 의존성 주입 프레임워크에 손이 간다. 컴포넌트 주입 난장판을 깔끔히 정리해 준다고들 한다. 현실에서는, 약간의 편의와 런타임 크래시를 얻는 대신 컴파일 타임 안전성을 내다 판다 1. 이제 문제는 둘이다:
대형 프로젝트에서 내가 의존성 주입 프레임워크를 쓰지 않으려는 주된 이유는, 그것들이 대체로 프로젝트 전체를 더 복잡하게 만들고 이해하기 어렵게 만들기 때문이다. 또 하나 주입을 하나 더 추가하는 건 너무 쉽고, 코드를 리팩터링하려는 노력은 들이지 않게 된다. 선투자 없이도 주입 프레임워크가 이를 허락하니, 자잘한 클래스나 서비스가 우후죽순 늘어나게 된다. 그 죄값은 나중에 온다. 이곳저곳에 주입이 수백 개 흩어지면 그 촘촘한 컴포넌트 그물망을 아무도 따라가지 못하고 디버그도 못 하게 된다(참조: 거대한 코드베이스를 항해하기).
많은 개발자들이, 적어도 ‘대다수가 그러는 듯한’ 손사래 섞인 일반화의 의미에서, 이런 우발적 복잡성을 다루는 선호하는 방식은 상태를 끌어올려 컴포넌트의 상태를 보통 모델이라 부르는 다른 박스에 저장하는 것이다. 모델-뷰-컨트롤러(MVC) 갱, 환호하라. 이 패턴은 컨트롤러를 통해 둘을 연결하면서, 데이터의 표현(뷰)과 실제 데이터(모델)를 분리한다 2.
수백 개의 컴포넌트를 자식들을 모두 지배하는 신 같은 컴포넌트에 주입하는 대신, 관련 GUI 컴포넌트의 공유 상태를 그 모든 자식들의 상태를 지배하는 신 같은 모델에 넣는다. 최종 결과는 비슷하지만, 데이터와 뷰 레이어를 분리하면 연말에 단위 테스트 팬클럽으로부터 좋은 평가를 받을지도 모른다. 상황이 조금 나아지긴 했지만, 여전히 온갖 이상한 에지 케이스로 가득한 거대한 모델을 떠안고 있다. GUI는 본질적으로 거대한 상태 기계이며, 실제로 GUI를 실행해 볼 때에야 비로소 발견되는 이상한 상태에 빠지기 쉽다.
거대하고 지저분한 모델을 유지보수하는 일은 어렵다. 그래서 개념적으로 함께 묶이는 컴포넌트들의 상태를 그룹화하는 더 작은 모델들로 모델을 쪼개기로 한다. 이 ‘모델 톱질’ 과정의 어느 순간에, 서로 다른 두 모델 사이에서도 몇몇 것은 동기화되어야 한다는 걸 깨닫는다. 최신 GUI 프레임워크들은 대개 일종의 데이터 바인딩 추상을 제공해, 이른바 단방향/양방향 데이터 바인딩을 통해 한 모델에서 다른 모델로 상태 변경을 손쉽게 전파할 수 있게 해 준다.
곧, 상태 객체가 바뀔 때마다 동작을 수행하는 변경 리스너를 붙일 수 있으면 정말 유용하겠다는 생각이 든다. 예컨대 ‘작업 중’ 표시등이 켜질 때마다 사용자 아바타 컴포넌트의 배경색을 바꾸고 싶다고 하자. 이 상황을 묘사하는 코드는 대략 다음과 같을 것이다:
this.lightTurnedOn.bind(this.editingInventoryTable);
this.lightTurnedOn.addListener((oldState, lightTurnedOn) -> {
if (lightTurnedOn) {
changeBackgroundToRed();
} else {
changeBackgroundToIbmGray();
}
});
보통 이쯤에서 GUI에 대한 통제력을 잃기 시작한다. 버튼을 클릭하면 이벤트가 트리거되고, 그 이벤트가 모델의 상태를 바꾸고, 그러면 다시 이벤트 리스너가 트리거되어 GUI가 크리스마스트리처럼 번쩍거린다. 데이터 바인딩과 변경 리스너의 문제는, 서로를 여러 번 트리거하는 숨은 순환 이벤트 리스너(event A가 상태 B를 바꾸고, 상태 B의 변경이 다시 event A를 트리거하는 식)를 너무 쉽게 만들어 낸다는 데 있다. 이런 문제는 개발 중에는 좀체 드러나지 않는다. 개발자는 보통 필멸자들이 아직 붙잡고 있는 고물 컴퓨터에 비하면 훨씬 강력한 머신을 쓰기 때문이다. 코드 리뷰로도 잘 걸러지지 않는다. 상태 변경이 저 한 줄짜리 상태 바인딩 어딘가에 교묘히 숨어 있기 때문이다.
이 문제의 제대로 된 해법이 무엇인지는 아직도 모르겠다. 상태 조작을 가능한 한 단순하게 유지하고, 서로 다른 모델 사이에 데이터를 공유하지 않도록 노력하라. 내가 그럴싸한 리스너-바인딩 메커니즘에 손을 댈 때마다, 미묘한 순환 리스너 재계산을 야기했고, 그것들은 극도로 디버그하기 어려웠다.
축하한다. 이제 앱의 비즈니스 로직을 작성하기보다, 기묘한 메시지 버스 문제들을 해결하는 데 노력이 꽤 많이 들어가게 될 것이다. 처음에는 메시지 버스가 괜찮은 생각처럼 들린다. 서로 다른 UI 컴포넌트 간의 통신 문제를 많이 단순화해 주기 때문이다. 파이프를 통해 이벤트를 흘려보내면, 애플리케이션의 다른 부분들이 알아서 반응한다.
하지만, 애플리케이션을 디버그하고 싶다면? 메시지를 받는 쪽에 브레이크포인트를 걸 수는 있지만 큰 도움이 되지 않는다. 이벤트에는 스택트레이스가 없고, 큰 애플리케이션에서는 특정 메시지가 어디서 왔는지 파악하기가 꽤 어렵다. 어딘가에서 무언가가 바뀌었다. 행운을 빈다.
이쯤에서 이렇게 생각할지도 모른다. ‘GUI 컴포넌트 간 통신에 메시지 버스라니? 미친 짓 아냐!’ 글쎄, 메모리를 잔뜩 집어삼키는 거대한 프로세스가 아니어도 메시지 버스를 가질 수 있다. 사실 아마 이미 쓰고 있을 것이다. GUI 프레임워크에는 보통 시스템 내에서 이벤트를 전파하는 데 쓰이는 일종의 이벤트 큐가 내장되어 있기 때문이다.
애플리케이션이 커질수록, 그냥 메시지를 이리저리 마구 뿌리는 것이 성능 문제를 야기한다는 걸 깨닫게 된다. 수백 개의 컴포넌트가 이벤트를 생성하는 상황이라면, 당신의 작은 사용자 아바타 컴포넌트는 오직 하나의 이벤트에만 관심이 있어도 초당 수백 개의 메시지를 까뒤집어 봐야 할지 모른다. 걱정 마라, 메시지 버스가 도와준다. 말 좀 듣는 메시지 버스라면 누구나 서로 다른 채널을 정의해 대략적인 메시지 타입 필터링을 제공한다. 관심 없는 메시지를 다 걸러내느라 CPU 사이클을 낭비할 바에, 관심 있는 주제의 메시지만 받는 특정 채널을 구독하면 된다.
하지만, 또 하나의 메시지를 허공에 던지는 건 너무나도 쉽다.
내가 보기엔 GUI의 복잡성 대부분은 문제 많은 컴포넌트 간 교차 통신에서 비롯된다. 화면의 간단한 버튼 변경처럼 보이는 것조차, 뒤에서 벌어지는 문제적 컴포넌트 배선 때문에 훨씬 더 꼬인 변경이 되기 쉽다(참조: 그냥 버튼일 뿐이잖아). 화면에서 컴포넌트들이 서로 가까이 붙어 있다고 해서, 그들을 서로 엮는 일이 쉬울 거란 뜻은 아니다.
클래스 기반 컴포넌트 모델링이 우리가 생각하는 것만큼 적합하지 않거나, 애플리케이션의 서로 다른 덩어리들을 손쉽게 연결할 수 있게 해 줄 올바른 추상을 아직 찾지 못했거나 둘 중 하나일 것이다. 이 문제에 대한 단순한 해결책은 애플리케이션 전체를 위한 전역 상태 컨테이너를 두고, 각 GUI 컴포넌트가 필요한 필드에 접근하게 하는 것이다.
전역 상태 컨테이너는 프런트엔드 웹 프로그래머들이 상태 컨테이너 라이브러리(예: Redux)로 하고 있는 바로 그것처럼 느껴진다. 다만 그런 라이브러리들은 쓰기 꽤 투박하다. 내가 필요한 건 단순한 해시맵인데 써야 할 보일러플레이트가 지나치게 많다. 평범한 옛날식 해시맵을 쓰면 몇 가지 그럴싸한 기능들(예: 이른바 타임 트래블 디버깅 — 상태 컨테이너가 모든 이벤트를 기록해 현재 상태에 이르기까지 애플리케이션이 어떻게 흘러왔는지 추적하는 기능)은 잃겠지만, 코드는 훨씬 단순해지고 이해하기 쉬워진다.
전역 상태 컨테이너가 요즘 나온 발명품이라고 생각할지 모르지만, 사실 아니다. 게임 개발자들은 태초부터 이런 방식으로 GUI를 만들어 왔다. 다만 용어가 달랐을 뿐이다 — ‘즉시 모드(Immediate Mode)’. 이 모드에서는 GUI 컴포넌트가 더 이상 이벤트를 구독하고 기다리지 않는다. 대신 GUI 컴포넌트가 60fps로 도는 메인 루프의 일부가 되어, 현재 전역 상태를 바탕으로 스스로를 다시 그린다. 이런 즉시 모드 UI 프레임워크의 대표적인 예가 Dear ImGui다. ImGui 갤러리를 보면, 비트리비얼하면서도 성능 좋은 GUI를 만드는 데 전혀 문제없는 방식으로 보인다. 즉시 모드 GUI는 어찌된 일인지 임계 질량을 얻지 못했고, 게임 산업 바깥에서는 아마 보기 힘들 것이다.
흔히들 말하듯, 최소한 하나의 해결책 없이 상사에게 문제를 푸념하지 말라고 한다. 나는 상사가 없고, 해결책도 없다.
그래, 그래. 모두가 타입 세이프한 언어를 쓰는 건 아니다. 어떤 이들은 단위 테스트로 컴파일러를 능가하려고도 한다 — 참고: Unit testing is not enough. You need static typing too 글.↩︎
MVC 이야기를 꺼내면 보통 컴포넌트의 비즈니스 로직을 어디에 둬야 하느냐를 두고 열띤 논쟁이 벌어진다. 하지만 그 얘기는 하지 말자. 현실에서는 별로 중요하지 않다. 그 지저분한 복잡성은, 그 컴포넌트를 뭐라고 부르든 어딘가에 살아야만 한다.↩︎