처음 써보는 위지윅 에디터 라이브러리에 기여하기

사내 에디터 라이브러리 기여 도전기

Polar

  • 테크 인사이트

안녕하세요~ 채널톡 웹팀에서 프론트엔드 개발을 담당하고 있는 폴라입니다

채널톡은 다양한 곳에서 에디터를 사용하고 있습니다. 최근 새롭게 발표한 채널톡의 도큐먼트가 에디터를 최대한 활용해서 만든 서비스입니다.

이렇게 에디터가 많이 사용되고 중요한 만큼 채널톡은 ProseMirror를 래핑한 사내 라이브러리를 가지고 있습니다. 제가 입사했을 때에는 이미 완성도가 높았던 라이브러리였는데 어떻게 기여할 수 있었는지 새내기 개발자의 시각에서 이야기를 풀어보려 합니다.

ProseMirror 어디까지 써봤니?

채널톡에 입사하기 전, 개인 블로그에 커스텀 에디터를 구현하기 위해 ProseMirror를 도입하려고 했습니다. 하지만 ProseMirror의 높은 진입 장벽에 다른 툴로 전환했던 경험이 있습니다. 다시 ProseMirror를 만나게 될 줄은 상상도 못했지만, 이번에는 꼭 해내겠다는 마음가짐으로 온보딩을 시작했습니다.

어떤 기능이 있는지 알아야 더 기여할 수 있구나!

개발을 하다 보면 어려운 문제를 만나는 경우가 많습니다. 이때, 아는 게 있으면 다양한 방법을 시도해 보면서 해결책을 찾아낼 수 있습니다. 우선 ProseMirror가 어떤 일들을 할 수 있는지 차근차근 알아보기로 했습니다.

만약 혼자 했다면 또 한 번 포기했을지도 모르지만 이번엔 저와 입사 시기가 비슷한 동료가 있습니다. 나중에 있을 어려움에 대비하기 위해 매주 2회씩 ProseMirror 공식 문서 정독 스터디를 진행했습니다.

최대한 공식 문서를 꼼꼼히 읽으면서 ProseMirror의 목적과 모든 API들을 학습해 나갔습니다. 사실 3~4회차까지는 큰 진전이 없었습니다. 하지만 문서를 읽으며 어려웠던 내용에 대해 토론하고 서로에게 작업한 영역에 대해 설명해 주면서 점점 이해도가 올라가기 시작했습니다.

치열했던 스터디의 기록 - Mapping 파트를 학습하면서 ProseMirror에 대한 이해도가 많이 상승했다

동료와 함께 스터디를 진행한 덕분에 예전에 어려운 난이도로 도입을 포기했었던 라이브러리에 빠르게 익숙해질 수 있었습니다. 스터디를 진행하는 데 있어서 채널팀의 업무 문화가 큰 도움이 되었습니다. 업무 시간 언제든 자유롭게 스터디를 할 수 있는 분위기 덕분에 출퇴근 부담 없이 꾸준히 진행할 수 있었습니다.

ProseMirror 스터디가 끝나고 Next.js 공식문서 읽기도 진행했었는데요. 기회가 된다면 다음 포스트에는 Next.js 공식문서 읽기로 배운 On-demand Revalidation을 실제 프로젝트에 적용한 개발기를 가져오도록 하겠습니다.

첫 PR 을 작성해보자 - Blockquote 만들기

스티디를 진행함과 동시에 첫 번째 온보딩 티켓 개발을 시작했습니다. 저는 오픈소스에 많은 기여를 하지 않았고 팀으로 개발을 진행한 경험이 짧아서 어떻게 시작해야 할지 막막했습니다. 프로세스를 이해하기 위해 온보딩을 진행해 준 동료 개발자분을 찾아가서 참고할 만한 레퍼런스를 받았습니다. 이전 작업자의 PR을 커밋 순서대로 보면서 어떤 흐름으로 작업을 할지 알아갔습니다.

잘 정리된 PR을 commit 순서대로 보니 이해도가 한 번에 많이 올랐습니다. 나중에 제 PR도 레퍼런스가 될 수 있으니 commit 을 최대한 개발 흐름대로 작성하려고 노력했습니다. 그리고 일주일간의 작업과 리뷰 과정을 거쳐 드디어 첫 PR이 머지 될 수 있었습니다

blockquote에 스타일이 적용된 결과물!

본격적으로 라이브러리에 기여하기

주어진 온보딩 티켓을 해결하고 나니 자율적으로 티켓을 해결할 수 있는 기회를 얻었습니다. 조금 더 빨리 라이브러리 개발에 익숙해지고 싶어서 새로운 티켓을 하나 해결해 보기로 하였습니다.

1. Nested Placeholder 만들기

비어있는 blockquote에는 placeholder가 보일 수 있도록 Plugin을 추가하기로 하였습니다. 기존에도 PlaceholderExtension이 있었지만 문서가 완전히 비어있을 때만 적용되고 있었습니다. 작업을 하다 보니 blockquote가 아닌 모든 블록에 대해 placeholder를 적용할 수 있을 것 같았습니다. 작업을 하면서 노션을 가장 많이 참고했는데, 노션과 동일한 동작이 될 수 있도록 구현해 보았습니다.

그리고 아래와 같은 피드백을 받았습니다.

제가 구현한 작업에서는 Nested Placeholder Extension이 들어갈 수 있는 블록을 직접 모두 가지고 있어야 하는 구조였습니다. 그러다 보니 확장성이 떨어지고 새로운 블록에 placeholder를 넣을 때마다 라이브러리 코드를 직접 업데이트해야 하는 상황이 발생할 수 있었습니다. 아래처럼 application level에서 placeholder를 필요한 상황에 맞게 넣어주도록 변경하고 최종적으로 Plugin을 완성할 수 있었습니다.

최종적으로 완성된 Nested Placeholder 시연 영상입니다

나중에는 기획이 바뀌어서 제가 만들었던 코드가 없어졌지만, Extension 간의 의존성에 대해 깊게 생각해 보았고 ProseMirror의 Plugin과 Decoration에 대해 배울 좋은 기회였습니다.

새로운 도전

그런데 테스트하던 중 몇 가지 문제가 있었습니다. 스토리북이 자꾸 원인 모를 이유로 터지고 있었습니다. 처음에는 제 작업이 문제인 줄 알아서 계속 원인을 찾았습니다. 살펴보니 List Extension에 문제가 있었습니다. 이미 팀에서는 알고 있던 이슈였고 조금 시간이 걸리고 난이도가 있는 티켓이라 추후 개선될 예정이라고 하였습니다.

에러를 보니 자꾸 눈에 밟혀서 해결하고 싶은 욕심이 생겼습니다. 조금 더 ProseMirror에 다가갈 수 있는 기회로 생각해서 한번 도전해 보기로 했습니다

2. 기존 List 의 버그를 고쳐보자

그런데 버그가 발생하는 상황이 이해가 되지 않았습니다. 아무 커맨드도 실행하지 않고 커서만 옮겼는데 자꾸 에러가 발생했습니다. 원인을 찾다 보니 아무런 동작을 하지 않아도 커맨드가 호출되고 호출된 커맨드에서 에러가 발생하고 있었습니다. 여기서 ProseMirror의 특징을 하나 알 수 있었습니다. 아래는 Command의 타입 정의입니다.

여기서 dispatch가 optional로 전달되는 것을 알 수 있는데, 처음에는 굳이 optional로 전달될 필요가 있을까라고 생각했습니다. 하지만 에디터에서는 커맨드가 수행되지 않지만 수행될 수 있는지 확인할 필요가 있습니다. 예를 들어 보겠습니다. 아래와 같은 사진에서 2, 3번째 아이콘이 활성화되지 않은 것을 볼 수 있습니다. 리스트로 바꿀 수 없는 블록에 커서가 위치하고 있기 때문입니다.

이처럼 커맨드가 실제로 수행되지 않지만 해당 커맨드가 수행될 수 있는 상황인지 확인하기 위해서 dispatch를 넘기지 않고 반환값만 체크합니다. 만약 커맨드가 true를 반환한다면 커맨드가 수행 가능한 상태이므로 아래 사진처럼 토글 버튼을 활성화시킬 수 있습니다. 이후 사용자가 버튼을 클릭했을 때 dispatch를 넘겨 수행한 transaction 들을 반영합니다.

첫번째 시련 - 함수형과 prosemirror에 대한 이해도 부족

버그가 나는 부분을 찾고 난 후 코드를 보는 순간 숨이 턱 막혔습니다. prosemirror 특성상 코드에 매직 넘버가 많은데 당시엔 익숙하지 않다 보니 수많은 매직 넘버에 갈 길을 잃어버렸습니다.

prosemirror contributor가 만든 list-command의 일부

사내 라이브러리에서는 위 코드를 내부 구조에 맞게 함수형으로 적절히 리팩토링해서 사용하고 있었는데, 아직 함수형에도 익숙하지 않다 보니 코드를 이해하는 데 어려움을 겪었습니다. 하루 종일 함수 하나만 보고 있었는데도 완전히 이해하지 못하니 점점 의지가 떨어지고 있었습니다.

코드를 한줄도 안짰는데 포기할 순 없으니 우선 제가 할 수 있는 방법을 시도해보기로 하였습니다. 제가 알고 있는 지식들을 바탕으로 완전히 새로 구현 하기로 하였습니다. '과연 내가 잘 하고 있는 건가'라는 생각이 수백번 들었지만 이전 코드를 과감하게 지우고 저만의 로직으로 하나씩 채워나가기 시작했습니다.

간단 prosemirror 용어 정리!

Lift : nestable list에서 한단계 높이기 (shift-tab 형태의 동작)

Sink : nestable list에서 한단계 내리기 (tab 형태의 동작)

공식문서를 열심히 보다보니 lift라는 API를 알게 되었습니다. 이 API를 활용하면 복잡하지 않게 해결될 것 같았습니다.

직접 사용해보니 기대한 방식으로 동작하는 것을 확인했습니다. 하지만 사용자가 여러 줄을 선택할 수도 있으니 selection에 따라 순회하면서 lift API를 호출하기로 하였습니다. 아래는 간단한 수도 코드입니다.

Plaintext
while(현재 블럭이 있을 때까지) {
  if (selection 시작 <= 현재 블럭 && 현재 블럭 <= selection 끝) {
    lift(현재 블럭)
    현재 블럭 = 다음 블럭
  }
}

이처럼 코드를 작성하면 아래와 같은 결과를 얻을 수 있습니다!

생각보다 쉽게 완성해서 여러가지 테스트를 돌리던 중 상상도 못한 시련이 다가왔습니다.

두번째 시련 - nestable list에서는 동작을 안하네?

하지만 위 방식은 nestable list에서는 기대한대로 동작하지 않았습니다. 여기서는 자식 list를 복제하는 방식을 활용해서 해결하였습니다. 두번째 시련은 너무 많은 고민을 했고 구조도 매일매일 변경할 정도로 많은 문제들이 발생했는데, 당시 앞만 보고 달린다고 기록하지 않았더니 기억이 휘발되어 빠르게 넘어가도록 하겠습니다

두번째 시련 해결!

세번째 시련 - lift한 list의 타입을 유지하지 못함

이번에는 nestable list의 타입(ul, ol)을 유지하지 못하는 이슈가 있었습니다. 즉, ol을 하던 ul을 하던 무조건 lift 후에는 ul로 고정되는 이슈가 있었습니다.

list의 타입을 유지하려면 두가지 방법이 있었습니다.

  1. lift를 진행할 때마다 상태를 관리

  2. lift를 진행할 최상위 부모의 자식을 모두 lift

prosemirror에서 상태관리를 하는 것은 지양하는 방법이기 때문에 최대한 두번째 방법을 활용해야 했습니다. 하지만 아래 사진처럼 빨간색 부분을 전부 lift 하면 선택되지 않은 초록색 부분까지 모두 한 단계 올라가게 됩니다.

그러다 갑자기 아이디어가 떠올랐습니다.

선택된 부분이 포함된 list를 하위 자식까지 전부 lift 시킨 뒤에 선택되지 않은 부분부터는 다시 기존 위치로 되돌려 두면 되지 않을까?

항상 선택된 부분만 바꿔야 한다는 생각에 사로잡혀 있었는데, 결과적으로 마지막에 되돌려두면 사용자 입작에서는 기대한 동작이 바로 수행되는 것 처럼 느껴질 수 있겠다는 생각을 하였습니다.

최종 완성

드디어 세번째 시련까지 공략하였습니다. 선택되지 않은 부분까지 모두 lift시키는 multiSelectionLIft를 수행한 후, 다시 원래 위치로 되돌려주는 recursiveSink를 이어서 진행하도록 구현하였습니다. 각 함수의 상세한 기능을 그림으로 표현해 보았습니다.

multiSelectionLift 동작 원리

1) selection의 $from에서 찾은 ordered_list, unordered_list blockRange의 노드의 자식을 순회

2) 이 때, node의 start, end와 selection을 비교해서 해당 노드를 lift 시킬지 판단

3) Lift 시키는 범위를 선택된 노드의 전체로 선택

4) MultiSelectionLift의 결과

recursiveSink 동작 원리 (multiSelectLift 이후에 동작)

1) 위 과정 진행 중 ListItem의 childCount가 1보다 크면 recursiveSink 동작을 수행

2) recursiveSink는 두가지 순회를 합니다.

  • ul, ol 자식인 list_item에 대한 순회

  • list_item 자식인 p, ul, ol 에 대한 순회

아래는 첫번째 케이스에 대한 순회입니다.

3) selectionTo가 처음으로 node의 text의 시작을 넘는 pos인 overPos를 기록

이때, 두번째 노드의 시작이 처음으로 selectionTo의 pos을 넘었기 때문에 기록

4) overPos가 기록되었으면 node.cut api를 사용해 해당 pos부터 뒷부분을 모두 복사

5) 복사한 노드의 type.name을 기록 (ol 인지 ul 인지)

6) 해당 타입을 활용해 List 완전체 생성

7) 기존 노드를 delete하고 제거한 위치 overPos - 3의 위치에 복사한 노드를 insert

왜 overPos - 3 위치에 insert를 하나요?

만약 overPos가 <C> 에 있다면, 결과는 우측 처럼 되어야 합니다.

즉 자기 자신과 동일한 depth의 node의 end 포지션이 되어야 합니다.

overPos 앞의 <li><p> 그리고 이전 노드의 </li> 태그 앞에 insert되도록 해야 합니다.

8) 최종 결과

아쉬웠던 점...

아무래도 난이도 있는 티켓을 아직 익숙하지 않은 상태에서 작업해서 아쉬운 점이 많았습니다. 특히 하나의 command에 너무 많은 역할을 주려고 했었던 부분이 가장 아쉬웠습니다. 예를들어 nestable list와 non-nestable list에 대한 command를 굳이 하나의 함수에서 모두 처리하지 않는게 구조적으로 더 직관적일 것으로 보입니다. 당시에는 기능에만 집중하다보니 구조적인 부분을 많이 놓치게 되었습니다.

3. Nested List Command를 더 만들어보자

nestable list를 조금 더 편하게 쓰기위해 tab, shift-tab을 구현하기로 하였습니다. 이전에 실수를 교훈삼아 이번에는 액션을 수행할 리스트의 타입을 잃어버리지 않도록 설계를 진행했습니다. 핵심 개념은 위에서 설명한 부분과 비슷해서 이번에는 추가로 고려한 구조를 위주로 작성해 보았습니다.

첫번째 고민 - selection을 어떻게 보존하지?

ProseMirror에서 가장 아쉬운 부분은 바로 selection 보존이 안되는 경우가 많다는 것입니다. tab 기능을 구현하면서 리스트 아이템의 위계를 옮기기 위해 delete, insert 혹은 replace API를 활용해야 합니다. 하지만 위 API들의 단점은 만약 해당 API가 적용되는 범위에 selection이 포함되어 있었다면 해당 selection이 유실 된다는 것입니다.

그래서 selection을 다시 계산해주지 않으면 커서가 앞에 있었다가 tab 커맨드 실행 후, 사이에 위치하게 되는 문제가 발생할 수도 있는 것입니다.

ProseMirror API를 활용해서 해결할 방법을 찾지 못해 결국 직접 selection을 계산하였습니다. tab, shift-tab의 경우 content에 변화가 있는게 아니라서 dom 구조만 잘 이해하면 selection을 계산하는데에는 큰 어려움이 없었습니다.

복잡한 경우에도 selection이 잘 보존된다!

두번째 고민 - 리스트의 타입이 달라도 하나의 리스트처럼 동작하기

현재 구조에서는 ulol이 별도의 블럭으로 생기게 됩니다. 아래의 예시를 보겠습니다. 보기에는 하나의 리스트 처럼 보이지만 실제 dom구조는 오른쪽처럼 별도의 list로 구성됩니다. 이때, 3번째 아이템에서 tab을 누르면 마지막 사진처럼 ol의 두번째 아이템의 자식으로 들어가야 합니다.

리스트 타입이 복합적으로 되어있어도 아래처럼 자연스러운 tab 동작이 이뤄지도록 수정했습니다.

tab 커맨드 최종 결과!

마무리

여기까지가 입사 후 4개월 정도 라이브러리에 기여한 내용입니다. 사내 위지윅 라이브러리에 기여하는 것은 항상 후순위에 있었는데요. 에디터에 대한 관심과 한번 도전해보고 싶은 욕심 때문에 남는 시간 틈틈이 작업해서 성과를 낼 수 있었던 것 같습니다. 하루 일과를 끝내고 나면 3 - 4시간씩 회사에 남아 꾸준히 개발할 정도로 재밌는 작업이었습니다.

이번 작업들을 진행하면서 공식문서를 꼼꼼히 정독하는 것의 중요성을 많이 느끼게 되었습니다. 요즘 코파일럿, ChatGPT 등이 나오면서 막히는 부분에서만 질문하는 방식의 개발을 무의식적으로 하고 있었습니다. 하지만 미리 기술에 대해 학습하고 지식을 넓혀두니 새로운 기능을 만들거나 제안할 때 더 많은 시도를 할 수 있었습니다. 최근에는 이렇게 학습한 지식을 기반으로 미디어 Drag & Drop 으로 붙여넣기, Cursor Controll 등 다양한 시도를 해보고 있습니다.

We Make a Future Classic Product

채널팀과 함께 성장하고 싶은 분을 기다립니다

사이트에 무료로 채널톡을 붙이세요.

써보면서 이해하는게 가장 빠릅니다

회사 이메일을 입력해주세요