프론트엔드에서 도메인 로직을 처리하게 된다면
브라우저에서 실시간으로 문서를 편집하는 에디터의 붙여넣기 기능을 리팩토링하며 알게된, 프론트엔드 도메인 로직 설계 이야기
"도메인 로직"이라고 하면 보통 백엔드에서 처리하는 걸 떠올리기 쉽습니다. 유효성 검증이나 상태 전이 같은 판단은 서버가 하고, 프론트엔드는 그 결과를 받아서 화면에 그리기만 하면 된다는 그림이 익숙하니까요. 저도 그렇게 생각했습니다.
그런데 브라우저에서 실시간으로 문서를 편집하는 에디터의 붙여넣기 기능을 리팩토링하면서, 제 생각과 다른 경험을 했습니다.
왜 도메인 판단이 브라우저 안에 있어야 했나
실시간 텍스트 에디터는 키를 하나 누를 때마다, 무언가를 붙여넣을 때마다 서버에 데이터를 보내서 "이 구조가 맞는 건가", "이 텍스트를 어떤 모델로 표현해야 하는가"를 물어볼 수 없습니다. 매 입력마다 네트워크를 타면 실시간성도, undo/redo도 성립되지 않으니까요.
그래서 문서의 구조·상태·변환 규칙 같은 도메인 판단 자체가 통째로 브라우저 안에 있어야 합니다. 서버는 이미 완성된 도메인 모델을 저장하고 동기화하는 역할을 하고, "이게 무슨 구조인지"를 판단하는 로직은 클라이언트가 직접 갖고 있어야 하는 것입니다.
저는 붙여넣기 기능을 리팩토링하면서 이걸 직접 겪었습니다. 처음엔 "복사한 HTML 데이터를 그대로 보여주기만 하면 되겠다" 정도로 쉽게 생각했는데, 직접 구현해보니 사실상 도메인 로직 그 자체를 프론트엔드에서 설계하고 있었습니다.
붙여넣기, 생각보다 단순하지 않았습니다
구현에 들어가기 전에는 단순하게 생각했습니다. clipboardData.getData('text/html')로 HTML 문자열을 받아서 에디터 안에 그대로 전달하면 되는 거 아닌가 싶었던 거죠. 그런데 실제로 MS Word, Excel, 웹페이지, 다른 에디터 등 다양한 곳에서 복사한 HTML을 확인해보니, 예상과 다른 부분들이 있었습니다.
1. MS Office에서만 쓰는 HTML 태그들
MS Office에서 복사한 HTML엔 mso-padding-alt 같은 벤더 전용 스타일 속성이 붙어 있습니다.
2. 표준과 다른 태그 중첩들
표준을 지키지 않는 태그 중첩이 흔합니다. <p> 태그 안에 <table>이 들어있는 경우도 실제로 있습니다. 이미지, 도형(Office의 VML 셰이프), 표, 리스트, 텍스트 서식이 임의 깊이로 중첩될 수도 있습니다.
이 모든 걸 브라우저의 DOM 트리가 아니라, 개발하던 에디터가 이해하는 도메인 모델 트리로 바꿔줘야 합니다. (렌더링 시에는 다시 DOM으로 바뀌지만, 이 글에서는 다루지 않겠습니다.)
브라우저가 DOMParser로 문자열을 DOM 트리로 바꿔주는 건 브라우저 API를 쓰면 됩니다. 하지만 그건 "문자열을 구조화된 트리로" 바꿔주는 것뿐이지, "이 구조가 우리 에디터에서 어떤 의미를 갖는가"를 판단해주진 않습니다. 그 판단, 즉 이미 만들어진 DOM 트리를 도메인 모델 트리로 바꿔주는 컨버터를 설계하는 일이 제가 직접 구현해야 했던 도메인 로직이었습니다.
그 도메인 로직을 어떻게 구조화했나 — 패턴 세 개
1) 알고리즘의 기본 구조를 고정한다 — Template Method
가장 먼저 정한 건 "변환 순서"였습니다. HTML 노드 하나를 어떻게 처리하든 순서는 항상 똑같아야 한다고 생각했습니다.
- 이 노드에 대응하는 도메인 모델을 만든다
- 자식 노드들을 순회하며 재귀적으로 변환한다
- 변환이 끝나면 후처리를 한다
이 순서 자체는 태그가 <div>든 <table>이든 절대 바뀌지 않습니다. 바뀌는 건 "1번에서 어떤 모델을 만들지", "3번에서 어떤 후처리를 할지"뿐입니다. 그래서 기본 구조는 부모 클래스에 고정하고, 세부 동작만 하위 클래스가 채우도록 설계했습니다.
// 알고리즘의 기본 구조를 고정한 추상 클래스
abstract class TagConverter {
protected node?: EditorNode;
protected converterContext?: ConverterContext;
convert(html: Node): void {
this.node = this.createNode(html);
html.childNodes.forEach((child) => {
const childConverter = this.createChildConverter(child.nodeName);
childConverter?.setConverterContext(this.converterContext);
childConverter?.convert(child);
});
this.onConvert();
}
protected abstract createNode(html: Node): EditorNode | undefined;
protected createChildConverter(tagName: string): TagConverter | undefined {
return this.converterContext?.createConverter(tagName);
}
protected onConvert(): void {}
}이게 GoF의 Template Method 패턴입니다. Java나 Kotlin으로 백엔드를 해보신 분이라면 낯익으실 겁니다. Spring의 JdbcTemplate이나 서블릿의 doGet()/doPost()도 원리가 같습니다. "언제, 무슨 순서로 실행되는지"는 프레임워크(부모)가 정하고, "무엇을 할지"만 사용자(하위 클래스)가 채우는 방식입니다. 도메인 로직을 다루는 원리 자체는 백엔드든 프론트엔드든 다르지 않다는 걸 이때 알게 됐습니다.
2) 태그가 30종류가 넘어가면 — Factory Method와 Abstract Factory
문제는 지원해야 하는 태그가 하나둘이 아니라는 점이었습니다. 문단(<p>), 굵게(<b>), 링크(<a>), 표(<table>, <tr>, <td>), 심지어 MS Office가 뱉어내는 도형용 VML 태그(<v:shape>, <v:group>, <v:textbox>...)까지 합치면 30종류가 넘었습니다. 웹 페이지 호환성까지 고려하면 지원해야 할 태그는 더 많았고요.
// 잘못된 접근: 태그 하나 늘 때마다 이 함수를 다시 열어야 함
function convertTag(tagName: string, html: Node) {
switch (tagName) {
case 'P':
/* ... */ break;
case 'B':
/* ... */ break;
case 'TABLE':
/* ... */ break;
// 태그가 30개면 case도 30개, 여러 명이 동시에 작업하면 매번 이 함수에서 충돌
}
}이 방식이면 태그 하나 추가할 때마다 switch문을 다시 열어야 했습니다. 그래서 태그 하나당 컨버터 클래스 하나로 쪼갰습니다. "이 태그는 어떤 컨버터로 처리할지"를 결정하는 책임도 함께 분리했는데, 이게 Factory Method입니다.
// 수정된 코드: 태그별로 컨버터 클래스를 분리
class ParagraphTagConverter extends TagConverter {
protected createNode(html: Node) {
return isParagraphLike(html) ? new ParagraphNode() : undefined;
}
// 자식 태그를 어떤 컨버터로 처리할지는 이 컨버터가 직접 결정
protected createChildConverter(tagName: string): TagConverter | undefined {
switch (tagName) {
case 'SPAN':
return this.converterContext?.getFactory().createSpanConverter();
case 'TABLE':
return this.converterContext?.getFactory().createTableConverter();
default:
return this.converterContext?.getFactory().createDefaultConverter();
}
}
}그리고 이 컨버터들을 만드는 책임 자체를 하나의 팩토리 인터페이스로 묶었습니다. 관련된 객체(컨버터)들의 "군(family)"을 한 곳에서 생성하는 Abstract Factory 패턴입니다.
abstract class TagConverterFactory {
abstract createParagraphConverter(): ParagraphTagConverter;
abstract createTableConverter(): TableTagConverter;
abstract createImageConverter(): ImageTagConverter;
// ... 태그 종류만큼
}이렇게 하고 나니 신규 태그를 지원해야 할 때 기존 코드를 건드릴 필요가 없어졌습니다. 컨버터 클래스를 하나 추가하고 팩토리에 등록하면 끝이었습니다. 이게 객체지향에서 말하는 개방-폐쇄 원칙(OCP)이라는 걸 이때 몸으로 배우게 됐습니다.
완벽하진 않았습니다
여기까지만 보면 "도메인 로직을 패턴으로 잘 구조화했다"는 깔끔한 결론으로 끝날 것 같습니다. 그런데 기능을 테스트하다가, 위의 원칙들이 적용된 상태에서도 오류가 발생하는 부분을 발견했습니다.
앞서 말씀드렸듯이, MS Office에서 복사하면 <p> 태그 안에 <table>이 들어있는 경우가 실제로 있습니다. HTML 스펙상으로는 정상이 아니지만, 워드나 파워포인트는 실제로 이런 식으로 클립보드에 담아 전달해줍니다. 이 케이스를 처리하려면 "문단 컨버터가 자식으로 표를 만나면, 표는 문단의 자식이 아니라 문단의 부모 밑에 붙여야 한다"는 예외 처리가 필요했습니다.
문제는 이 로직을 hook(createChildConverter, onConvert 등) 안에 넣을 수 없었다는 점이었습니다. "자식을 순회하는 방식 자체"를 바꿔야 했기 때문입니다. 그래서 결국 또 한 번 손을 대야 했습니다.
class ParagraphTagConverter extends TagConverter {
// 원래는 hook만 오버라이드해야 하는데,
// 기본 구조 메서드인 convertChildNode 자체를 오버라이드하게 됨
protected convertChildNode(childHtml: Node): void {
const converter = this.createChildConverter(childHtml.nodeName);
if (converter instanceof TableTagConverter) {
const parentNode = this.node?.getParent();
// 표는 문단이 아니라 문단의 부모에 붙인다
// ...
} else {
// ...
}
}
}Template Method 패턴은 "알고리즘 기본 구조는 고정, 세부 동작만 하위 클래스가 결정"이 원칙입니다. 그런데 위 코드부터는 이 클래스가 기본 구조 자체에 개입하게 됐습니다. 이게 Template Method의 대표적인 구조적 한계인 Fragile Base Class 문제입니다. 상속 기반 설계는 하위 클래스가 부모의 구현 디테일에 의존하게 만들고, 그 경계가 한번 깨지기 시작하면 계속 깨지게 됩니다.
다른 프로젝트는 프론트엔드 붙여넣기 로직을 어떻게 설계했을까
같은 문제를 다른 방식으로 푼 사례가 궁금해서, 널리 쓰이는 오픈소스 리치 텍스트 에디터 프레임워크인 ProseMirror가 붙여넣기를 어떻게 처리하는지 찾아봤습니다. 여러 에디터 제품이 이 위에서 빌드된 것으로 알려진 프레임워크입니다.
ProseMirror는 완전히 다른 방향으로 풀고 있었습니다. 태그별 컨버터 클래스도, switch-case도, 룩업 테이블 함수도 없습니다. 대신 "이 DOM 구조가 어떤 노드/마크에 대응하는지"를 스키마에 데이터로 선언합니다.
// ProseMirror 스키마의 마크 정의 예시 (공식 문서 기준)
const emphasis = {
parseDOM: [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }],
toDOM() {
return ['em', 0];
},
};붙여넣을 때는 DOMParser.fromSchema(schema)가 스키마에 선언된 모든 parseDOM 규칙을 모아 우선순위대로 정렬한 뒤, DOM 트리를 순회하면서 규칙에 매칭되는 대로 문서를 재구성합니다. 새 노드 타입을 추가하고 싶을 땐 변환 코드를 전혀 건드릴 필요가 없습니다. 스키마에 parseDOM 배열 하나만 추가하면 끝입니다. 트리 순회와 우선순위 판단은 라이브러리가 제공하는 단 하나의 공용 DOMParser 엔진이 전담하고, 개발자는 "무엇을 어떤 것에 매칭시킬지"만 선언하는 구조인 것이죠.
같은 "프론트엔드에서 도메인 로직을 설계해야 한다"는 문제를, 제가 개발하던 에디터는 클래스 계층과 Template Method로 풀었고, ProseMirror는 명령형 코드를 아예 짜지 않고 선언적 규칙 테이블로 풀었습니다. 물론 이건 ProseMirror가 범용 에디터 프레임워크로서 스키마 시스템 자체를 프레임워크 레벨에 갖추고 있어서 가능한 방식이라, 제가 개발했던 특정 제품 전용 컨버터와 곧바로 비교하긴 어렵습니다.
정리하며
돌아보면, 이 글에서 다룬 붙여넣기 컨버터는 "프론트엔드도 도메인 로직을 설계해야 하는 계층"이라는 걸 가장 압축적으로 보여준 사례였습니다.
- 실시간 편집기라는 요구사항 때문에, 문서 구조를 해석하고 변환하는 도메인 판단이 서버가 아니라 브라우저 안에 있어야 했습니다.
- 그 도메인 로직을 재귀적으로 확장 가능한 구조로 설계해야 한다는 판단은 정당했습니다. 이미지, 도형, 중첩된 표까지 다 커버하려면 결국 태그별로 분기해서 내려가는 구조를 피할 수 없었으니까요.
- 다만 그 구조를 반드시 클래스 상속과 GoF 패턴 세 개로 짜야 했느냐는 별개의 문제였습니다. 태그 이름과 처리 함수를 매핑해두는 함수 기반 레지스트리 구조로도, ProseMirror처럼 선언적 규칙 테이블로도 같은 목표를 달성할 수 있었을 것 같습니다.
"도메인 로직은 백엔드의 일"이라는 통념은, 적어도 실시간으로 상태를 다루는 프론트엔드 앱에서는 더 이상 맞지 않는다는 걸 이번에 다시 확인했습니다. 도메인 로직을 설계하는 원리 — 알고리즘의 기본 구조를 고정하고, 세부 동작을 위임하고, 생성 책임을 분리하는 것 — 는 백엔드든 프론트엔드든 다르지 않았습니다. 다른 건 그 로직이 어느 계층에 위치하느냐, 그뿐이었습니다.