멋쟁이 사자처럼 프론트엔드 스쿨 1기를 수료한지 3달이 지나고, 멋사 디스코드에 제주코딩베이스캠프의 이호준 대표님께서 CSS flex와 grid를 실습할 수 있는 웹페이지와 e-book을 출간하는 프로젝트에 참여하실 분을 모집하시는 글을 올리셨다. 나는 예전부터 개발 교육용 웹페이지에 관심이 있다. 사실 멋사 기간동안 만들었던 프로젝트 중 하나인 Guten Tag도 초보자를 위한 마크업 교육용 웹을 목표로(목표는 좋았는데, 완성은 못했던..) 만든 것이었다. 이번에는 완성된 페이지를 만들 수 있을 것이란 기대감을 가지고 참여하게 되었다.
첫 회의 때 호준님께서 CSS 각 속성을 설명할 때 필요한 코드 에디터가 필요하다고 하셨다. 나는 에디터가 제일 재미있고 도전적인 작업이 될 것 같아서 역할 배분을 할 때 에디터를 맡기로 하였다. 그리고 디자인 팀에서 너무 예쁜 에디터를 만들어 주셔서 더욱 더 의욕이 높아졌다.
솔직히 처음에는 그렇게 오래 걸릴 것이라고 생각하지 않았다. 목표 기간은 총 8주였고, 거의 대부분을 e-book을 집필하고 그 이후에 집필 내용을 바탕으로 페이지를 만드는 것이라 나는 빠르게 에디터를 완성하고 남은 기간 동안에는(나는 e-book 집필에는 참여하지 않았다) 다른 공부를 하면서 에디터에 대한 피드백이 들어오면 그 때마다 수정해서 pr을 올릴 계획이었다. 그런데... 에디터를 만드는 것이 그렇게 간단한 일이 아니었다..
📝 에디터
https://github.com/tesseractjh/fg-editor
원래는 에디터가 Flex & Grid 프로젝트 내에 있지만, CDN 배포를 위해 따로 깃헙 레포를 만들었다. 그 CDN 주소로 이 글에 직접 에디터를 삽입해 보았다. 그런데 이상하게 티스토리에 넣으려니까 스타일이 깨져서 임시방편으로 일부 스타일만 수동으로(?) 조정하였다.
.container {
display: flex;
justify-content: flex-start;
}
.container {
display: flex;
justify-content: center;
}
.container {
display: flex;
justify-content: flex-end;
}
이 에디터는 각 CSS 속성 설명 페이지에 예시와 함께 CSS 속성 키워드에 따른 차이를 비교하기 위한 에디터이다.
CSS 코드 수정을 텍스트가 아닌 마우스 클릭으로 하도록 만든 이유는 이 에디터가 flex와 grid를 처음 공부하는 사람들에게 소개하는 페이지에 사용되는데, space-between 같은 CSS 키워드들에 익숙하지 않은 상태에서 굳이 텍스트로 입력해보는 수고를 하지 않을 것이라고 생각했기 때문이다. 또한 이미 radio 버튼으로 미리 입력되어 있는 코드들을 비교할 수 있어서 실제로 코드를 수정할 일이 적을 것이기 때문이기도 하다.
설명 페이지 외에도 자유롭게 코드를 수정해보는 페이지도 있었다. 그 페이지를 위한 에디터를 또 만들어야 했는데, 자유롭게 코드를 수정하려면 텍스트로 수정하는 방식이 더 적합할 것이라고 판단하여 텍스트 에디터를 만들어 보기로 하였다.
.container {
display: flex;
justify-content: flex-start;
}
이 에디터에서는 CSS를 텍스트로 편집하고, HTML을 마우스 클릭으로 편집할 수 있도록 하였다. 기본적으로 모든 Flex/Grid Item에는 1부터 숫자가 1씩 증가하여 들어가게 하여 CSS를 작성할 때 구분하기 편하도록 하였다. HTML 편집시에도 마찬가지로 태그 코드에 마우스를 올리면 그 태그에 해당하는 DOM 요소를 테두리로 강조하여 어떤 태그를 나타내는지 바로 알 수 있도록 했다.
.container {
display: flex;
}
.item1 {
flex: 1;
}
.item2 {
flex: 2;
}
.item3 {
flex: 1;
background-color: #eceafe;
}
.item4 {
flex: 1;
}
.item5 {
flex: 2;
background-color: #eceafe;
}
.item7 {
flex: 1;
background-color: #eceafe;
}
.container {
display: flex;
}
.container1 {
justify-content: space-between;
}
.item1 {
flex: 1;
}
.item2 {
flex: 3;
background-color: #eceafe;
}
.item3 {
flex: 1;
}
.item4 {
flex: 0;
background-color: #eceafe;s
}
.item5 {
flex: 1;
background-color: #eceafe;
}
.item6 {
flex: 1;
}
.item7 {
flex: 2;
}
.item8 {
flex: 1;
background-color: #eceafe;
}
.container {
display: flex;
}
.container3 {
justify-content: space-between;
flex-direction: row-reverse;
}
.item1 {
flex: 1;
}
.item2 {
flex: 0;
background-color: #eceafe;
}
.item3 {
flex: 1;
}
.item4 {
flex: 3;
background-color: #eceafe;
}
.item5 {
flex-basis: 30%;
}
.item6 {
flex-basis: 40%;
background-color: #eceafe;
}
.item7 {
flex: 1;
}
.item8 {
background-color: #eceafe;
}
이 에디터는 메인 페이지에 쓰일 캐러셀 형태의 에디터이다. 캐러셀이라고는 했지만 사실 맨 처음 에디터에서 radio 버튼을 캐러셀 indicator처럼 바꾼 것에 불과하다.
🧾 어려웠던 점
처음에는 에디터를 사용하지 않고 codepen과 같은 라이브러리를 사용할 것을 고려했었다. 그러나 Flex & Grid 웹페이지 고유의 에디터가 있으면 이미 존재하는 CSS를 소개하는 다른 페이지들과 차별점이 될 것이라고 생각하여 직접 만들기로 결정했다. 기왕 만드는거 에디터 내부에서도 다른 라이브러리에 의존하지 않고 모든 것을 직접 다 구현하기로 하였다. 처음에는 그리 어렵지 않을 것이라 생각했으나 고려해야 될 점들이 너무나도 많았다.
👓 미리보기 화면 구현하기
일단 어떤 형태로 맨 처음의 코드를 입력받고, 그 코드를 통해 미리보기 화면을 어떻게 그려내야 하는지에 대해서 고민했었다. React가 아닌 바닐라로 만드는 컴포넌트라, 페이지 담당하시는 분들께서 가져다 쓰기 불편하지 않아야 했다.
<div id="carouselExampleSlidesOnly" class="carousel slide" data-bs-ride="carousel">
<div class="carousel-inner">
<div class="carousel-item active">
<img src="..." class="d-block w-100" alt="...">
</div>
<div class="carousel-item">
<img src="..." class="d-block w-100" alt="...">
</div>
<div class="carousel-item">
<img src="..." class="d-block w-100" alt="...">
</div>
</div>
</div>
위 코드는 Bootstrap에서 Carousel 컴포넌트를 사용하는 예시 코드이다. 여기에서 영감을 얻어서 class와 data 어트리뷰트로 필요한 값들을 받아서 컴포넌트를 렌더링시키는 방식으로 만들었다. 그러나 코드 내용까지 data 어트리뷰트로 받기에는 너무 불편하므로 <code> 태그 내부에 코드를 작성하고 렌더링할 때 이 코드들을 파싱하도록 하였다.
<div data-mode="snippet" class="fg-editor css-flex">
<code data-snippet="flex-start">
.container {
display: flex;
justify-content: flex-start;
}
</code>
<code data-snippet="center">
.container {
display: flex;
justify-content: center;
}
</code>
<code data-snippet="flex-end">
.container {
display: flex;
justify-content: flex-end;
}
</code>
</div>
웹페이지가 로드될 때 모든 'fg-editor' 클래스를 가진 DOM들을 찾고, 각 에디터에 대한 Editor 객체를 생성한다. Editor 객체 내부에는 각 snippet들에 대한 css 선택자와 속성에 대한 정보, 그리고 미리보기 화면의 구조를 객체로 나타낸 일종의 가상 DOM이 있다.
class Editor {
// CSS 선택자 파싱을 위한 정규표현식 문자열
static CSS_SELECTOR =
'((\\.((container\\d*)|(item\\d*)))+(\\[[^:{}]*\\])?(:[^:{}]*)?(::[^:{}]*)?\\s*)+';
// CSS 파싱을 위한 정규표현식
static CSS_STRING = new RegExp(
`${Editor.CSS_SELECTOR}(,\\s*${Editor.CSS_SELECTOR})*\\{[^{}]*\\}`,
'g'
);
constructor() {
// 생략
}
// 생략
}
const findEditors = () => {
return [...document.querySelectorAll('.fg-editor')];
};
findEditors().forEach((editor, index) => new Editor(editor, index).render());
에디터 내부에서 Flex/Grid Container는 container라는 클래스를, Item은 item이라는 클래스를 갖는 것으로 정하였다. 그리고 각각의 container와 item은 추가로 순서에 맞는 container1, container2, item1, item2 ... 라는 고유의 클래스를 별도로 갖는다. 이러한 방식에 맞게끔 CSS 파싱을 위한 정규표현식을 따로 만들었다.
// Editor
{
// 생략
_snippets: [
{
name: 'flex-start',
css: [
{
selector: '.container',
props: [
{ prop: 'display', value: 'flex' },
{ prop: 'justify-content', value: 'flex-start' }
]
}
],
html: [
{
elem: (실제 DOM 객체),
parent: null,
children: [
{ elem: (실제 DOM 객체), parent: (부모 객체), children: [] },
{ elem: (실제 DOM 객체), parent: (부모 객체), children: [] },
{ elem: (실제 DOM 객체), parent: (부모 객체), children: [] }
]
}
]
},
{
name: 'center',
css: [], // 생략
html: [] // 생략
},
// 생략
],
// 생략
}
페이지가 로드되면 클래스, data 어트리뷰트, 그리고 <code> 태그 내부의 코드를 가지고 Editor 객체를 생성한다. Editor의 _snippets 프로퍼티에는 각 snippet별 css와 가상 DOM을 위와 같은 방식으로 저장되어 있다. 이 값을 가지고 render 메서드가 실행될 때 실제 DOM을 렌더링한다.
🚑️ transition 효과 살리기
미리보기 화면에는 transition 속성이 들어가 있어서 코드에 변화를 주면 그에 맞게 부드럽게 미리보기 화면이 변하게 된다.
그러나 현재 상태에서 CSS 코드를 수정하면 자연스럽게 transition이 적용되지만, 다른 snippet으로 넘어갈 때에는 적용되지 않았다. 왜냐하면 snippet을 전환할 때마다 미리보기 화면의 DOM을 완전히 갈아치우기 때문이었다. 다른 snippet으로 넘어갈 때에도 transition이 적용되어 좀 더 자연스럽게 넘어가는 것이 더 좋을 것 같아서 이 점을 변경해보기로 했다.
일단 transition은 동일한 DOM에 대해서 스타일에 변화가 일어날 때 그 변화를 일정 시간에 걸쳐 서서히 변화시키는 것이기 때문에, snippet을 전환할 때 기존의 DOM을 재활용해야 했다. 그런데, (사실 그럴 일이 거의 없긴 한데..) snippet별로 DOM 구조가 다를 수도 있다. 만약 container 하나에 item 3개가 들어있는 형태에서 container 2개에 각각 item 2개씩 들어 있는 형태로 DOM 구조가 바뀐다면 어떻게 해야 할까? 이런 경우에는 지금 container에서 item을 1개 삭제하고, item 2개가 담긴 새로운 container를 추가하면 된다. 이렇게 하면 새로 추가된 두 번째 container는 transition 효과가 적용되지 않지만, 적어도 첫 번째 container와 그 안의 item들은 transition 효과가 적용된다.
class Editor {
// 생략
_updateAllPreviewDOM(dom, vDom) {
const minLength = Math.min(dom.children.length, vDom.length);
for (let i = 0; i < minLength; i++) {
vDom[i].elem = dom.children[i];
}
while (dom.children.length < vDom.length) {
const newElem = vDom[dom.children.length].elem;
dom.appendChild(newElem);
}
while (dom.children.length > vDom.length) {
dom.removeChild(dom.lastElementChild);
}
const domChildren = [...dom.children];
for (let i = 0; i < vDom.length; i++) {
this._updateAllPreviewDOM(domChildren[i], vDom[i].children);
}
}
// 생략
}
_updateAllPreviewDOM은 부모 요소부터 시작해서 현재 미리보기 화면의 DOM과 다음에 렌더링해야 할 가상 DOM을 비교하여 DOM 요소의 개수가 다를 때마다 DOM 요소를 추가하거나 삭제하여 미리보기 화면의 DOM 구조를 가상 DOM 구조와 같게 만든다.
snippet이 변환될 때 매개변수 dom과 vDom에 각각 현재 미리보기 화면 DOM의 최상단 부모 요소, 다음에 변환될 snippet의 최상단 부모 요소의 자식 배열(children)로 받아 함수가 호출된다. dom의 자식 개수와 vDom의 길이를 비교하여 더 적은 쪽의 개수(minLength)는 snippet 변환 후에도 유지되는 DOM의 개수를 나타낸다. 0번째 인덱스부터 minLength만큼의 DOM으로 vDOM의 elem을 교체하여 vDOM의 elem이 현재 미리보기 화면의 DOM을 가리키게 하였다.
그 다음, 가상 DOM의 자식이 더 많으면 더 많은 만큼의 vDOM.elem을 미리보기 화면의 DOM에 추가하였다. 반대로 미리보기 화면의 DOM이 더 많으면 더 많은 만큼의 DOM을 삭제하였다.
마지막으로 현 상태에서 다시 자식 요소들에 대해서 _updateAllPreviewDOM을 재귀호출하여 모든 자손 노드까지 실행이 되면서 미리보기 화면의 DOM과 가상 DOM의 구조가 같아지게 된다.
이러한 방식으로 최대한 지금 이미 렌더링된 DOM을 최대한 재활용하는 방식으로 transition 효과를 유지시켰다.
🆎 텍스트 에디터 구현하기
처음에 에디터를 구상할 때에는 마우스 클릭만을 사용하는 것으로 구상하였으나, 텍스트 에디터를 사용하는 모드가 필요하게 되면서 만들게 되었다.
먼저 기존의 텍스트 에디터들을 조사해보았다. codepen, 백준, 프로그래머스의 경우 CodeMirror라는 라이브러리를 사용하고 있었다. 지금 글을 쓰고 있는 티스토리의 경우에는 기본모드에서 tinyMCE라는 라이브러리를 사용하고 있고, HTML 모드에서는 CodeMirror를 사용하고 있다. 그리고 노션의 경우는 자체적으로 에디터를 제작한 것으로 보인다.
CodeMirror는 숨겨진 textarea를 사용하여 input 이벤트를 감지하고 텍스트 선택을 추적하는 방식으로 만들어졌다. tinyMCE나 노션 같은 경우에는 HTML의 contenteditable 어트리뷰트를 활용하여 input이나 textarea가 아닌 태그도 수정할 수 있게 만드는 방식을 사용하였다. (CodeMirror는 1버전에서 contenteditable을 사용하였으나 버그가 많아서 2버전 부터 이런 방식으로 변경되었다고 한다.)
사실 에디터로 구글링을 하면 contenteditable이 가장 많이 나오는데도 불구하고 나는 contenteditable의 존재를 너무 늦게 알게 되었다. 구글링 대신 codepen, 백준, 프로그래머스, 노션 등의 에디터가 어떤 방식으로 만들어졌는지를 먼저 확인했다. 노션 에디터를 확인해봤을 때도 대충 슥 보고서 contenteditable이 HTML 표준 어트리뷰트가 아니라 그냥 data 어트리뷰트인 줄 알았다. 하필 노션에 "data-content-editable-leaf"라는 data 어트리뷰트가 있어서 착각을 했던 것 같다. 그래서 input, textarea 같은 태그가 아닌 다른 태그의 텍스트는 절대로 수정할 수 없는 것이라고 생각했다. 조금만 검색을 해봤다면 알 수 있었던 것을... 그리하여 contenteditable을 사용하지 않고 텍스트 에디터를 만드는 뻘짓을 하게 되었다..
일단은 처음 접한 CodeMirror이 너무 충격적이었다. 커서 깜빡임부터 글자를 선택할 때 색칠되는 부분마저 전부 다 div로 만든 것일거라고는 상상도 하지 못했기 때문이다. 일단 이 프로젝트 자체가 기한이 정해져 있기도 하고, 난이도가 너무 높을 것 같다는 생각에 대안을 생각해냈다. CSS 코드가 처음에는 table로 구성되어 있다가, 클릭을 하거나 텍스트를 선택한 채로 키보드 이벤트가 발생하면 스타일을 동일하게 유지한 채로 textarea로 변하도록 하는 방식으로 만드는 것이다. 이렇게 하면 table일 때 코드에는 색깔이 알록달록 들어가 있는데, textarea로 변하면서 전부 같은 색으로 변한다는 점을 제외하고는 그럴싸하게 만들 수 있을 것이라고 생각했다.
<div class="wrapper-code">
<table>
<tr>
<td>1</td>
<td>코드</td>
</tr>
<tr>
<td>2</td>
<td>코드</td>
</tr>
<tr>
<td>3</td>
<td>코드</td>
</tr>
<tr>
<td>4</td>
<td>코드</td>
</tr>
<tr>
<td>5</td>
<td>코드</td>
</tr>
</table>
</div>
처음에는 이렇게 tr에 2개의 td(왼쪽은 코드 라인 번호, 오른쪽은 코드 내용)가 들어가는 방식으로 되어 있다. 그리고 클릭 또는 키보드 이벤트가 발생하면 아래와 같은 구조로 변한다.
<div class="wrapper-code">
<div>
<div>
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
</div>
<textarea></textarea>
</div>
</div>
HTML 구조가 완전히 변했지만 스타일은 그대로 유지하도록 하였다. textarea 부분의 색깔이 모두 같은 색으로 변하는 것이 어쩔 수 없는 한계라고 생각했다. 그러나 이제 contenteditable의 존재를 알게 되었으니 이를 활용해서 글자의 스타일을 유지한 채로 텍스트를 수정할 방법을 찾아 봐야겠다.
이 외에도 table 상태의 코드를 드래그해서 선택한 상태에서 키보드를 누르면 textarea로 변하면서 동시에 table에서 선택한 코드 그대로 textarea에서 선택되도록 하였다. Web API 중 하나인 Selection API를 활용하여 현재 선택된 텍스트에 대한 정보를 얻고, textarea에서 하나로 합쳐진 텍스트에서의 위치를 계산하여 Element.selectionStart와 Element.selectionEnd로 textarea의 텍스트를 선택하였다. 이렇게 해서 table 상태의 텍스트를 드래그(클릭할 때는 textarea로 바로 변하지만 드래그하면 변하지 않도록 하였다)하고 키보드를 눌러 값을 변경하거나 삭제(Backspace, Delete)하면 textarea로 전환됨과 동시에 해당 텍스트가 선택이 되고, 그 다음에 키보드 이벤트의 기본 동작(키 입력, 삭제)이 발생하면서 자연스럽게 편집이 가능하도록 하였다.
🤔 아쉬웠던 점
일단 구현에 급급하다보니 코드 퀄리티가 너무 좋지 않다. 중복 코드, 애매한 변수명을 사용하고, 땜빵식으로 버그를 수정하다보니 어느새 스파게티 코드가 되어 있었다. 지금 『쏙쏙 들어오는 함수형 코딩』을 읽고 있는데, 다 읽고 나면 함수형으로 리팩토링 해보고 싶다.
이 에디터는 접근성을 거의 고려하지 못 했다. 일단 이러한 에디터를 어떻게 하면 좀 더 접근성을 좋게 만들 수 있을 지 잘 모르겠다. 다른 코드 에디터에서는 어떤 시도를 했는지 더 공부를 해봐야겠다. 리팩토링을 하게 된다면 적절하게 대체 텍스트를 제공하고, 키보드만으로도 에디터를 조작할 수 있도록 하고 싶다.
🧠 더 고민해볼 점
에디터 디자인이 예쁘긴 하지만 한 가지 문제점이 있다. 이 디자인을 위해서 들어간 CSS와 코드의 CSS가 충돌할 수 있다는 점이다. 일단 코드의 CSS가 더 우선하므로 기본 CSS를 덮어씌울 수 있긴 한데, flex와 grid를 처음 배우는 사람들이 이용하는 페이지라는 점에서 CSS 속성을 이해하는데 혼란을 줄 가능성이 있어 보인다.
예를 들어, Flex Item에는 "width: 70px; height: 70px;"가 기본으로 설정되어 있는데, 원래 flex-basis를 따로 설정하지 않으면 content의 최대 크기로 기본 크기가 설정되는데, width나 height가 설정되어 있으면 width / height 값으로 기본 크기가 지정된다. 이 외에도 몇 가지 헷갈릴 여지가 있는 속성들이 더 존재한다.
그렇다고 기본 CSS를 코드에 같이 표시를 하기에는 너무 코드가 길어진다. 단순히 justify-content의 flex-start, center, flex-end를 비교하기 위한 예제에서 굳이 width, height, gap 등의 기본으로 적용된 속성들을 명시할 필요는 없어 보인다.
아직까지는 뾰족한 수가 떠오르지 않았다...
정말 오랜만에 집중해서 달렸던 프로젝트였다. 기능을 하나씩 구현해나가면서 성취감을 느낄 수 있는 도전적인 프로젝트였던 것 같다. 덕분에 Selection API에 대해서도 알게 되었고, WYSIWYG 에디터를 어떤 방식으로 만드는지에 대해서도 이해할 수 있었다.
에디터를 만들긴 했지만 사실상 반쪽짜리 유사 에디터라고 생각한다. 현실적으로 시간이 되지 않아서 어느 정도 타협한 부분이 많기 때문이다. 시간이 된다면 contenteditable을 적용해서 편집시에도 스타일이 유지되는 진짜 에디터를 만들 수 있도록 수정을 해봐야겠다.