회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
AWS 이용 중이라면 최대 700만 원 지원받으세요
*FEConf2023에서 발표한 <웹 기반 그래픽 편집기의 구조와 7가지 디자인 패턴>를 정리한 글입니다. 발표 내용을 2회로 나누어 발행합니다. 1회에서는 웹 기반 그래픽 편집기의 기초적인 아키텍처와 이 과정에 녹아있는 디자인 패턴에 대해 살펴봤습니다. 2회에서는 실제 그래픽 편집기를 구현하고 문제점을 수정해 보면서 디자인 패턴에 대해 깊게 알아봅니다. 본문에 삽입된 이미지의 출처는 모두 이 콘텐츠와 같은 제목의 발표 자료로, 따로 출처를 표기하지 않았습니다. 발표 자료는 FEConf2023 홈페이지에서 다운로드할 수 있습니다.
회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
회원가입을 하면
성장에 도움이 되는 콘텐츠를
스크랩할 수 있어요!
확인
*FEConf2023에서 발표한 <웹 기반 그래픽 편집기의 구조와 7가지 디자인 패턴>를 정리한 글입니다. 발표 내용을 2회로 나누어 발행합니다. 1회에서는 웹 기반 그래픽 편집기의 기초적인 아키텍처와 이 과정에 녹아있는 디자인 패턴에 대해 살펴봤습니다. 2회에서는 실제 그래픽 편집기를 구현하고 문제점을 수정해 보면서 디자인 패턴에 대해 깊게 알아봅니다. 본문에 삽입된 이미지의 출처는 모두 이 콘텐츠와 같은 제목의 발표 자료로, 따로 출처를 표기하지 않았습니다. 발표 자료는 FEConf2023 홈페이지에서 다운로드할 수 있습니다.
FEConf2023에서 발표된 ‘웹 기반 그래픽 편집기의 구조와 7가지 디자인 패턴’/심흥운 네이버 프론트엔드 엔지니어
이번 장에서는 가상의 그래픽 편집기를 구현해 보면서 5가지 디자인 패턴을 어떻게 적용할 수 있는지 살펴보겠습니다. 자세한 구현보다는 전체적인 흐름과 개념에 집중해서 따라오시면 좋을 것 같습니다.
우리가 만들 가상의 그래픽 편집기는 선택 툴, 사각형 툴, 펜 툴을 지원한다고 가정하겠습니다. 그러면 이 그래픽 편집기의 도메인 모델은 사각형, 원, 그리고 경로로 구성된 아래와 같은 JSON으로 표현할 수 있습니다.
이제 우리의 첫 번째 목표인 “모델로부터 컨트롤러 생성하기"에 대해 알아보겠습니다. 앞서 내부 구조가 아래 그림과 같이 구현되어 있다고 말씀드렸습니다. 아래 그림에서 강조한 부분이 생성에 관여하는 구조물입니다. 그래픽 에디터는 모델을 읽어서 그래픽 뷰어에 나타냅니다. 그래서 모델이 그래픽 에디터에 들어가면 그래픽 뷰어에 나타나게 됩니다.
이 과정을 수도 코드로 표현하면 다음과 같습니다. 왼쪽 코드를 보면 그래픽 에디터가 그래픽 뷰어에게 모델을 파라미터로 던지면서 파트를 만들라고 합니다. 그러면 그래프 뷰어는 createParts를 호출하면서 모델을 파라미터로 받고 배열인 모델을 순회하면서 배열의 아이템들의 타입에 따라 각각의 파트를 만듭니다. 파트를 만들고 나서 addPart를 통해 렌더링을 하고 화면에 나타냅니다.
그런데 파트를 생성하는 부분이 어딘가 이상하지 않나요? 아래 표시한 부분의 코드는 도형이 추가될 때마다 수정되어야 합니다. 앞서 우리는 디자인 패턴을 이용해 변화하는 부분을 분리할 수 있다고 했습니다. 여기서는 심플 팩토리를 이용해서 변화하는 부분을 분리해 보겠습니다.
해결 방법을 알아보기 전에 팩토리에 대해 알아보겠습니다. 팩토리에는 세 가지 종류가 있습니다.
이번 예제는 심플 팩토리만으로 해결하는 데 충분하기 때문에 심플 팩토리를 이용하겠습니다.
앞서 표시한 코드에서 if-else 조건문을 분리해서 PartFactory로 옮깁니다. 그리고 PartFactory는 분리되었기 때문에 PartFactory를 호출하는 GraphicViewer에서는 더 이상 타입을 알 필요가 없어집니다. 따라서 createParts 함수는 모델을 순회하면서 PartFactory에게 파트만 만들어 달라고 호출하면 됩니다. 즉, 타입이 늘어나거나 변경되어도 GraphViewer는 변경될 필요 없고, 오로지 PartFacroy만 수정하면 됩니다.
위 방법을 통해 얻을 수 있는 장점은 2가지가 있습니다.
심플 팩토리를 이용해서 확장에는 열려 있고, 수정에는 닫혀 있는 OCP 원칙을 지키게 됩니다. 또한, 의존성을 심플 팩토리로 보내고 파트라는 추상에 의존하게 되는 DIP 원칙도 지킬 수 있습니다.
이번에는 뷰 생성하기를 살펴보겠습니다. 앞서 살펴본 모델은 1차원 모델이었습니다. 그러나 우리는 1차원 모델만 편집하지 않고 중첩 구조나 레이어, 그룹 등도 사용합니다. 이러한 다차원 구조를 표현하려면 어떻게 해야 할까요? 중첩된 모델을 생각해 보면 아래 그림과 같습니다.
중첩된 모델을 쉽게 다룰 수 있는 패턴이 있습니다. 바로 컴포지트 패턴입니다. 컴포지트 패턴은 클라이언트가 개별 객체와 복합 객체를 같은 방법으로 다룰 수 있게 합니다.
이 컴포지트 패턴이라는 개념은 부분-전체 계층 구조라는 개념에서 출발합니다. 하나의 트리 노드를 단일 객체인 것처럼 다룹니다. 즉, 아래 그림의 회색으로 칠해진 그룹 영역이 하늘색 노드 객체와 동일한 것처럼 다루는 구조입니다.
그래서 이 패턴은 그래픽 유저 인터페이스를 구현할 때 많이 사용되고, 리액트의 렌더링 과정과도 굉장히 유사하다는 것을 알 수 있습니다.
이 패턴을 사용하면 개별 뷰나 중첩된 뷰를 같은 타입으로 취급해서 렌더링 할 수 있습니다. 아래 왼쪽 코드를 보면 addChild 함수는 파트를 파라미터로 받는데, 이 파트는 단일 객체일 수도 있고 복합 객체일 수도 있습니다. 그러나 그것과 상관없이 자식으로 추가할 수 있습니다. 다음으로 render() 코드를 보면 모델을 처음 읽어드렸거나 변경되었을 때 파트가 렌더링을 호출합니다. 즉, 자신을 렌더링하고 자식을 순회하면서 렌더링 합니다.
그러나 이 부분에 문제가 하나 있습니다. 텍스트 파트같이 자식을 갖지 못하는 구성 요소인 경우에는 오른쪽 코드와 같이 addChild가 호출되었을 때 에러를 던지는 코드가 필요합니다.
정리하면, 중첩된 뷰의 렌더링과 같은 작업을 간단한 코드를 통해 전체 구조를 대상으로 반복해서 적용할 수 있습니다.
앞선 예제는 SRP(Single Responsibility Principle : 단일 책임 원칙)를 위배했습니다. 그래서 컴포지트 패턴은 SRP를 버리고 투명성을 취급하는 패턴으로 알려져 있습니다. 여기서 투명성이란 개별 객체와 복합 객체를 같은 타입으로 바라보는 것을 의미합니다. 이 때문에 타입 안정성이 떨어집니다. 이전 예제의 텍스트 파트처럼 논리에 맞지 않는 동작이 생길 수도 있고, 이경우 이를 막아야 합니다. 따라서 이 패턴을 설계할 때 투명성과 안정성의 균형이 필요한 패턴이라고 말합니다.
다음으로 툴을 이용해 모델을 수정하는 것에 대해 알아보겠습니다. 편집 과정과 관련된 구조는 아래 그림처럼 이벤트 디스패처, 커맨드 스택, 루트 리포트, 툴, 리퀘스트, EditPolicy가 있습니다.
앞에서 편집 작업은 “이벤트를 상태변화로 전환하는 과정"이라고 했습니다. 이 과정에는 특별한 장치가 하나 있습니다. 바로 “툴”입니다. 그리고 이 과정을 앞서 소개 드린 구조를 곁들여 자세히 살펴보면 아래 그림과 같습니다.
이벤트 디스패처는 브라우저의 저수준 이벤트를 에디터의 고수준 이벤트로 변환하는 역할을 합니다. 이벤트 디스패처가 마스킹 레이어를 통해 이벤트를 수신하고, 이벤트를 가공해서 캔버스로 전달하는 과정입니다.
앞서 설명한 툴은 “같은 이벤트라도 선택에 따라서 다른 결과가 나와야 한다”는 특징이 있습니다. 예를 들어 사각형 툴을 선택한 상태라면 드래그했을 때 사각형이 그려져야 하고, 펜 툴을 선택한 상태라면 펜이 그려져 합니다. 즉, 그래픽 에디터는 한 번에 한 개의 상태를 가집니다.
왼쪽 코드는 이벤트 디스패처의 코드입니다. 이벤트를 리스닝 하는 부분을 수도코드로 표현했습니다. 예를 들어 mousemove 이벤트를 받아서 transmitMouseMove를 호출합니다. transmitMouseMove에서는 뷰어에게 이벤트를 전달합니다. 오른쪽의 뷰어 코드에서 이벤트를 받아 툴의 상태에 따라 사각형, 원, 펜 등의 각각에 맞는 요소를 화면에 그립니다.
위 코드에서도 문제점이 있습니다. 툴이 추가될 때마다 오른쪽에 표시된 if-else 문이 계속 늘어나야 합니다. 즉, GraphicViewer 코드가 닫혀 있지 않은 상태가 됩니다. 또한, 유사한 코드가 반복되고 있습니다.
위 문제점을 해결하는 방법이 바로 상태 패턴입니다. 오른쪽의 코드를 보면 if-else 문을 제거하고 receiveEvent 부분에서 툴에게 바로 이벤트를 전달했습니다. 이 툴은 추상 클래스가 되는데 특정 상태에 대한 처리를 특정 클래스에게 모두 위임합니다. 즉, 현재 상태를 하나의 클래스로 나타내는 것이 바로 상태 패턴입니다. 이렇게 하면 특정 상태에서의 행동을 고립시킬 수 있어 코드 관리가 쉬워지고, 상속을 통해 반복되는 코드 문제를 해결할 수 있습니다.
상태 패턴을 통해 해결한 코드에서 GraphicViewer는 확장에 열려있게 되고, 툴은 변경에 닫혀 있게 되어 OCP를 구현할 수 있게 됐습니다.
정리하면, 앞선 예제의 사각형, 원, 펜 툴은 셀렉션 툴이라는 클래스에게 특정 상태에 대한 처리를 모두 위임합니다. 이렇게 하면 실제 기능을 위해 셀렉션 툴을 상속받아 사용하기만 하면 됩니다.
이번에는 지금까지의 이벤트 흐름을 다시 살펴보며 MVC 구조를 완성해 보겠습니다. 방금 툴에 대해 알아봤는데, 그렇다면 조금 전 예제와 같이 툴이 직접 모델을 수정하는 것이 바람직할까요? MVC 구조에서 모델 수정 방법은 누가 알고 있어야 할까요?
예를 들어 비트맵을 지우는 지우개 도구가 있다고 가정하겠습니다. 이 비트맵 지우개는 비트맵만 지우는 도구이기 때문에 왼쪽의 비트맵 그림은 지울 수 있어야 하고, 오른쪽의 벡터 그림은 지울 수 없어야 합니다.
이런 상태에서 지우개 도구가 직접 모델을 수정하려면 지울 수 있는 파트와 지울 수 없는 파트를 모두 알고 있어야 합니다. 그러면 앞으로 파트가 늘어날 때마다 지울 수 있는 요소인지 아닌지 다 알고 있어야 합니다. 즉, 요소들이 늘어날 때마다 코드를 계속 수정해야 합니다. 따라서 지우개 도구의 개방 폐쇄 원칙을 어기게 됩니다.
이를 해결하기 위해서는 파트 스스로 이벤트에 대한 정보를 바탕으로 이벤트를 어떻게 해석해야 하는지 알고 있어야 합니다. 지우개가 요소를 지우라고 요청하면 파트가 이 이벤트를 받아 처리하는 것이죠.
더 근본적으로 생각해 보겠습니다. MVC를 사용한다면 모델을 누가 수정할까요? 바로 컨트롤러입니다. 우리의 예제에서는 파트가 컨트롤러라고 할 수 있습니다. 따라서 파트는 이벤트를 어떻게 해석하는지 알고 있어야 하고 모델을 수정할 수 있어야 합니다. 그리고 툴은 파트의 이벤트를 바로 전달하는 것이 아니라 리퀘스트라는 형태로 가공해서 모델 수정을 요청합니다. 이렇게 하면 다양한 편집 요청을 리퀘스트에 담을 수 있습니다. 여기서 리퀘스트는 하이레벨 이벤트라고 할 수 있습니다.
그럼 파트 내부에 모델 수정 로직을 직접 넣으면 될까요? 이것도 문제가 됩니다. 왜냐하면 유사한 편집 로직이라면 파트 안에서도 중복 로직이 발생할 수도 있습니다. 이렇게 되면 파트 안에서 또다시 개방 폐쇄 원칙이 무너집니다.
위 문제를 스마트하게 해결하는 구조가 바로 EditPolicy입니다. EditPolicy란 파트가 알고 있어야 하는 모델 수정 로직을 파트가 재활용할 수 있게 완전히 분리해서 마이크로 컨트롤러로 만든 것입니다.
아래 코드는 툴의 드래그 동작을 나타내는 코드입니다. 마우스 다운 이벤트가 일어나면 드래그가 시작되고 마우스 업 이벤트가 일어나면 드래그 엔드가 호출됩니다. 드래그 앤드에서는 모델을 바꾸라고 changeModel 함수를 호출합니다. 그리고 ResizeTool의 changeModel이 아래와 같이 특정 작업을 진행합니다.
이제 ResizeTool에서 Policy를 분리해 보겠습니다. 모델 수정 코드를 ResizeTool에서 ResizePolicy로 이동시킵니다. 이렇게 하면 ResizeTool의 chnageModel은 추상화될 수 있습니다. 그리고 파트에 installPolicy 함수를 추가해서 policy를 파라미터로 받을 수 있게 하여 다중 정책을 처리할 수 있게 합니다. 즉 policy가 배열이 되고 여러 가지 컨트롤러 로직을 받을 수 있습니다.
즉, ResizeTool은 요청만 보내면 되고 ResizeTool에서 하던 changeModel 부분이 없어지고, 상위 클래스인 툴에서 처리하게 됩니다.
방금 살펴본 EditPolicy는 전략 패턴입니다. 변하는 부분을 찾아내서 툴과 파트와 같이 변하지 않는 부분을 분리하고 모델 수정 작업을 Policy에 위임합니다. 파트는 Policy를 선택해서 런타임을 포함해 다양한 상황에서 작동 방식을 자유롭게 바꿀 수 있습니다. 또한 알고리즘을 재사용하기 쉽습니다.
이 부분은 히스토리를 구현하는 undo, redo와 관련된 부분입니다. 지금까지 살펴본 이벤트 흐름은 아래 그림과 같이 이벤트 디스패처에서 그래프 뷰어, 그리고 툴과 파트를 통해 EditPolicy가 모델을 수정합니다.
앞에서 봤던 EditPolicy 과정을 한 번 더 개선하면, EditPolicy가 모델을 직접 수정하는 것이 아니라 EditPolicy는 커맨드라는 객체를 생성합니다. 이 커맨드는 커맨드 스택 안에 담겨 전달되고 커맨드 스택이 모델을 변경합니다. 커맨드 스택 안에는 undo, redo와 같은 모델을 수정하기 위한 명령들이 캡슐화돼서 하나씩 쌓여있습니다.
지금까지의 흐름을 최종적으로 정리하면 아래 그림과 같습니다. 툴에서 이벤트를 받고 파트의 EditPolicy에서 커맨드를 만들라고 요청하고 이 커맨드로 모델을 수정합니다. 모델이 수정되면 다시 파트에서 리프레시를 요청하고 뷰에서 갱신됩니다.
지금까지 웹 기반 그래픽 편집기의 구조를 통해 7가지 디자인 패턴에 대해 알아봤습니다. 또, 실제 구현 코드를 직접 개선하며 디자인 패턴에 대해 생각해 봤습니다.
오늘 소개한 내용은 디자인 패턴에 대한 한 가지 경험일 뿐입니다. 제가 공유드린 경험이 그래픽 편집기를 만들 때 의미 있는 내용이 되기를 바라고, 실제 디자인 패턴을 적용하는 데 도움이 되기를 바랍니다. 다양한 상황에서 경험을 통해 더 나은 패턴을 찾고 이를 다른 사람들에게 공유하고 함께 적용해 보시길 바랍니다. 감사합니다.
글 FEConf
편집 오신엽 객원 에디터
요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.