이 글은 노션 블록데이터를 활용해 적절한 스타일링을 통한 커스텀 렌더링하는 함수를 설계하고 구현하고, 마주한 여러가지 문제점들을 해결한 과정을 담은 글이다.
기존 코드의 문제점 파악
NotionRenderer를 본격적으로 설계하고 구현하기 전에, 기존에 사용하던 코드의 문제점과 단점을 먼저 파악하는 것이 중요했다. 초기 코드에서는 Notion의 복잡한 블록 데이터를 효과적으로 처리하지 못해 여러 문제가 발생했다.
노션데이터 구조를 분석해보자
[
{
"parent": {
"type": "page_id",
"page_id": "29c3db9a-42de-41a8-b513-28e6d376f2cd"
},
"created_time": "2024-04-01T05:21:00.000Z",
"archived": false,
"paragraph": { // 키값이 블록 타입을 나타냄
"color": "default", // 색상
"rich_text": ["예시 데이터입니다."] // 텍스트
},
"last_edited_time": "2024-04-01T05:21:00.000Z",
"has_children": false, // 자식 블록 유무
"id": "843ee77c-ac71-49e6-ab0e-0f280a0f34dd",
"type": "paragraph", // 블록 타입
"created_by": {
"object": "user",
"id": "fd508e79-47c6-4452-9a53-a7246147d204"
},
"last_edited_by": {
"object": "user",
"id": "fd508e79-47c6-4452-9a53-a7246147d204"
},
"object": "block"
},
// ...생략
]
• id: 블록의 고유 식별자.
• type: 블록의 유형을 나타내며, 이 예시에서는 "paragraph".
• parent: 이 블록이 속한 부모 객체를 정의. 여기서는 특정 페이지(page_id)에 속해 있음을 나타낸다.
• created_time & last_edited_time: 블록이 생성되고 마지막으로 수정된 시각을 ISO 8601 형식으로 기록.
• archived: 블록이 보관(아카이브)되었는지를 나타내는 불리언 값.
• has_children: 이 블록에 자식 블록이 있는지를 나타내는 불리언 값.
• paragraph: 블록의 콘텐츠를 담고 있는 객체로, 여기서는 단락의 색상(color)과 텍스트 내용(rich_text)을 포함.
• created_by & last_edited_by: 블록을 생성하고 마지막으로 수정한 사용자의 정보를 포함.
• object: 객체의 유형을 나타내며, 이 경우 "block".
초기 코드의 주요 문제점 분석
1. 복잡한 블록 타입 처리의 어려움
Notion은 다양한 블록 타입을 지원하며, 각 블록 타입마다 고유한 속성과 구조를 가지고 있다. 초기 코드에서는 이러한 다양한 블록 타입을 효과적으로 처리하지 못했다. 단순히 배열로 된 노션데이터를 각 요소의 타입별로 정해진 스타일을 적용하여 보여주는 것에 그쳤기 때문에 기본적인 Markdown 변환과 렌더링에는 적합했으나, Notion데이터의 복잡한 계층 구조를 처리하는 데 한계가 있었다.
예를 들어, bulleted_list_item과 같은 리스트 항목 블록을 처리할 때, 단순히 리스트 항목의 텍스트만 변환하고 자식 블록의 처리는 제대로 이루어지지 않았다. 이로 인해 중첩된 리스트나 복잡한 구조의 블록을 렌더링할 때 문제가 생겼다.
switch (type) {
case "paragraph":
return [`<p>${text}</p>`];
case "bulleted_list_item":
return [`<li>${text}</li>`];
// 기타 블록 타입 처리 미흡
default:
return [`<p>${text}</p>`];
}
2. 함수의 과도한 책임과 복잡성 / 코드 중복과 유지보수의 어려움
초기 코드에서는 각 함수들이 너무 많은 일을 처리하고 있어 가독성이 떨어지고, 복잡성이 높았다. 예를 들어, convertToMarkdown 함수는 텍스트 변환뿐만 아니라 HTML 태그 생성, UTM 파라미터 삽입 등 여러 가지 작업을 동시에 처리하고있어, 코드의 유지보수를 어렵게 만들었다.
// 초기 convertToMarkdown 함수 예시
export function convertToMarkdown(
block: BlockData,
prevBlock: BlockData | null,
nextBlock: BlockData | null,
type: string,
plainText: string | string[],
annotations: Annotation,
href: string,
children: string[] | null = [],
): string[] {
// 텍스트 변환, HTML 태그 생성, UTM 삽입 등 다양한 작업 수행
// ...
}
3. UTM 파라미터 삽입의 비효율성
링크에 UTM 파라미터를 삽입하는 기능도 초기 코드에서는 비효율적으로 구현되었다. 모든 <a> 태그에 대해 일일이 UTM 파라미터를 추가하는 로직이 코드 내에 직접 포함되어 있어, 유지보수가 어려웠다. 또한, UTM 파라미터를 삽입할 때 조건문이 복잡하게 얽혀 있어, 코드의 가독성이 떨어졌다.
// 초기 UTM 삽입 로직 예시
const insertUtmContent =
utm && md.indexOf("<a ") > 0
? md.indexOf("?") > 0
? md.replace(/">/g, `&${utmSource}" target="_blank">`)
: md.replace(/">/g, `?${utmSource}" target="_blank">`)
: md;
개선 필요성 도출
위에서 언급한 문제점들을 종합해보면, 초기 코드는 Notion 블록 데이터를 효과적으로 처리하고 렌더링하는 데 한계가 있었다. 이를 해결하기 위해서는 다음과 같은 개선이 필요했다.
- 다양한 블록 타입의 효율적 처리: 각 블록 타입을 체계적으로 관리하고, 새로운 블록 타입이 추가될 때 쉽게 확장할 수 있는 구조가 필요했다.
- 계층 구조의 명확한 반영: 블록 간의 부모-자식 관계를 명확히 관리하여, 중첩된 리스트나 섹션별 블록 그룹화를 효과적으로 구현할 수 있어야 했다.
- 코드 중복 제거 및 유지보수성 향상: 블록 타입별 조건부 렌더링 로직을 간소화하고, 코드 중복을 줄여 유지보수를 용이하게 해야 했다.
- UTM 파라미터 삽입 로직의 개선: 링크에 UTM 파라미터를 효율적으로 삽입할 수 있는 로직을 구현하여, 코드의 가독성과 유지보수성을 높여야 했다.
이러한 문제점들을 해결하기 위해, 기존 코드를 리팩토링하고 구조를 재설계하는 과정이 필요했다.
리스트의 중첩 렌더링 시도
리스트의 중첩 렌더링을 구현하기 위해서는 블록 데이터를 트리 구조로 변환하고, 이를 기반으로 재귀적으로 렌더링하는 방식이 필요했다.
1. buildBlockTree 설계
노션데이터의 다양한 계층구조를 적절히 표현하기 위해, 노션 블록 데이터를 트리 구조로 변환하는 buildBlockTree 함수를 설계했다. 이 함수는 평면적인 블록 배열을 계층적인 트리 구조로 변환하여, 각 블록의 자식 블록들을 적절히 연결해준다. 이를 통해 다양한 중첩 데이터 요소들을 올바르게 렌더링할 수 있게 되었다.
const buildBlockTree = (blocks: BlockData[]): BlockData[] => {
const root: BlockData = {
id: "root",
type: "root",
children: [],
parent: {},
has_children: true,
};
const blockMap: { [id: string]: BlockData } = { root };
blocks.forEach(block => {
block.children = [];
blockMap[block.id] = block;
});
blocks.forEach(block => {
if (block.parent.block_id && blockMap[block.parent.block_id]) {
blockMap[block.parent.block_id].children!.push(block);
} else {
root.children!.push(block);
}
});
return root.children || [];
};
이 함수는 모든 블록을 id로 매핑한 후, 각 블록의 부모를 찾아 자식 블록들을 연결한다. 최종적으로 루트 노드의 자식으로 변환된 트리 구조를 반환함으로써, 블록 간의 계층 관계를 명확히 할 수 있었다.
2. 중첩 렌더링 스타일화 성공
트리 구조로 변환된 블록 데이터를 기반으로 재귀적으로 리스트를 렌더링하는 컴포넌트를 구현했다. 이를 통해 중첩된 리스트의 뎁스를 시각적으로 표현할 수 있게 되었고, 사용자에게 보다 직관적인 UI를 제공할 수 있었다.
const renderListGroup = ({ group, depth }) => {
const listType = group[0].type === "bulleted_list_item" ? "ul" : "ol";
const listClass = group[0].type === "bulleted_list_item" ? "list-disc" : "list-decimal";
return (
<ListTag className={`ml-${depth * 4}`}>
{group.map(block => (
<li key={block.id}>
{renderBlockContent(block)}
{block.children && renderListGroup({ group: block.children, depth: depth + 1 })}
</li>
))}
</ListTag>
);
};
여러가지 블록 타입 데이터 중첩 렌더링
리스트의 중첩 렌더링을 성공적으로 구현한 후, 다음으로 마주한 문제는 헤딩 태그를 기준으로 블록들을 그룹화하는 것이었다. 예를 들어, heading_1 아래에 여러 블록들이 포함될 때, 이를 하나의 섹션으로 묶어 관리하고자 했다.
하지만 문제는, heading 태그 데이터는 자식 요소가 없는 각각의 독립적인 블록 요소로 데이터가 구성되어있었고, 헤딩태그 이후의 데이터들을 헤딩태그의 자식요소로 만들기위해 각 블록 타입에 우선순위를 부여하고, 이를 기반으로 트리 구조를 재설계해야 했다.
1. 우선순위를 정해, 트리 구조를 만드는 함수 재설계
헤딩 태그를 기준으로 블록들을 그룹화하기 위해, 블록 타입에 우선순위를 부여하고 이를 기반으로 트리 구조를 재설계해야 했다. 우선순위는 헤딩의 레벨에 따라 다르게 설정했다. 예를 들어, heading_1은 가장 높은 우선순위를 가지며, heading_3은 상대적으로 낮은 우선순위를 가진다. 이를 통해 각 헤딩 아래에 포함될 블록들을 적절히 그룹화할 수 있었다.
const getPriority = (block: BlockData): number => {
switch (block.type) {
case "heading_1":
return 4;
case "heading_2":
return 3;
case "paragraph":
// 모든 rich_text를 검사하여 보라색 텍스트가 있는지 확인
if (
block.paragraph?.rich_text?.some(
(rt: any) => rt.annotations.color === "purple",
)
) {
return 2;
}
return 0;
case "heading_3":
return 1;
default:
return 0;
}
};
const buildBlockTree = (blocks: BlockData[]): BlockData[] => {
// 루트 노드를 생성합니다.
const root: BlockData = {
id: "root",
type: "root",
children: [],
parent: {},
has_children: true,
};
// 모든 블록을 id로 매핑
const blockMap: { [id: string]: BlockData } = { root };
blocks.forEach((block) => {
block.children = [];
blockMap[block.id] = block;
});
// 헤딩 기반 트리 구조 생성을 위한 스택 초기화
const stack: { block: BlockData; priority: number }[] = [
{ block: root, priority: 0 },
];
// 각 블록을 순회하며 트리 구조를 만듬
blocks.forEach((block) => {
const priority = getPriority(block);
if (priority > 0) {
// 헤딩 블록인 경우
// 현재 스택의 마지막 요소보다 우선순위가 높거나 같은 경우 스택을 조정
while (stack.length > 1 && stack[stack.length - 1].priority <= priority) {
stack.pop();
}
// 새로운 섹션 생성
const newSection: BlockData = { ...block, children: [] };
stack[stack.length - 1].block.children!.push(newSection);
// 스택에 새로운 섹션을 추가
stack.push({ block: newSection, priority });
} else if (listTypes.includes(block.type as (typeof listTypes)[number])) {
// 리스트 항목인 경우
const parentId = block.parent.block_id;
if (parentId && blockMap[parentId]) {
blockMap[parentId].children!.push(block);
} else {
// 리스트 항목의 부모가 없을 경우 현재 스택의 마지막 섹션에 추가
stack[stack.length - 1].block.children!.push(block);
}
} else {
// 헤딩도 리스트 항목도 아닌 블록인 경우
const richText = block[block.type]?.rich_text;
if (richText && richText.length > 0) {
stack[stack.length - 1].block.children!.push(block);
}
}
});
return root.children || [];
};
이 재설계된 buildBlockTree 함수는 블록의 우선순위를 기준으로 스택을 조정하여 블록을 적절히 그룹화한다. 우선순위가 높은 헤딩 블록은 새로운 섹션의 시작을 의미하며, 이를 통해 헤딩 아래의 자식 블록들을 효과적으로 묶을 수 있었다. 또한, 리스트 항목의 경우 별도의 처리 로직을 통해 중첩 렌더링을 유지하면서도 섹션 단위로 그룹화할 수 있게 되었다.
2. Wrapper 컴포넌트 개발
트리 구조로 변환된 블록 데이터를 기반으로, 각 섹션을 감싸는 Wrapper 컴포넌트를 개발했다. 이를 통해 섹션별로 스타일을 적용하거나, 특정 기능(예: 아코디언)을 추가할 수 있게 되었고, 앞으로의 확장성도 유연하게 가져갈 수 있었다. 각 헤딩 타입에 맞는 Wrapper 컴포넌트를 작성함으로써, 코드의 재사용성과 유지보수성을 높였다.
const H1Wrapper = ({ children }) => <div className="H1 스타일링 클래스">{children}</div>;
const H2Wrapper = ({ children }) => <div className="H2 스타일링 클래스">{children}</div>;
const H3Wrapper = ({ children }) => <div className="H3 스타일링 클래스">{children}</div>;
const DefaultWrapper = ({ children }) => <div>{children}</div>;
const NotionBlockRenderer = ({ block, accordionHeadings }) => {
let WrapperComponent;
switch (block.type) {
case "heading_1":
WrapperComponent = accordionHeadings.includes("heading_1") ? AccordionWrapper : H1Wrapper;
break;
case "heading_2":
WrapperComponent = accordionHeadings.includes("heading_2") ? AccordionWrapper : H2Wrapper;
break;
case "heading_3":
WrapperComponent = accordionHeadings.includes("heading_3") ? AccordionWrapper : H3Wrapper;
break;
case "paragraph":
WrapperComponent = block.isPurple ? PurpleTagWrapper : DefaultWrapper;
break;
default:
WrapperComponent = DefaultWrapper;
break;
}
return (
<WrapperComponent>
{renderBlockContent(block)}
{block.children && block.children.map(child => (
<NotionBlockRenderer key={child.id} block={child} accordionHeadings={accordionHeadings} />
))}
</WrapperComponent>
);
};
추가적인 개선
1. 공통 로직 최적화
지금까지의 과정을 통해 블록 타입별로 조건부 렌더링과 Wrapper 컴포넌트를 활용하여 효율적으로 렌더링을 구현할 수 있었다. 하지만 여전히 코드 중복이 발생하고, 새로운 블록 타입을 추가할 때마다 조건을 추가해야 하는 불편함이 있었다. 이를 해결하기 위해 블록 타입과 렌더링 로직을 매핑하는 방식을 도입했다.
const blockRenderMap: {
[key: string]: {
tag: keyof JSX.IntrinsicElements;
className: string;
isSelfClosing?: boolean;
};
} = {
paragraph: {
tag: "p",
className: "mb-4 text-base text-gray-700",
},
heading_1: {
tag: "h1",
className: "mb-4 text-xl font-semibold text-gray-900 md:text-2xl",
},
heading_2: {
tag: "h2",
className: "mb-3 text-xl font-medium text-gray-800 md:text-2xl",
},
heading_3: {
tag: "h3",
className: "mb-2 text-lg font-medium text-gray-700 md:text-xl",
},
bulleted_list_item: {
tag: "span",
className: "",
},
numbered_list_item: {
tag: "span",
className: "",
},
quote: {
tag: "blockquote",
className: "mb-4 border-l-4 border-gray-500 pl-4 text-gray-600",
},
divider: {
tag: "hr",
className: "my-6 border-t border-gray-300",
isSelfClosing: true,
},
image: {
tag: "img",
className: "mb-4 rounded-lg",
isSelfClosing: true,
},
};
const renderBlockContent = (block: BlockData) => {
const renderInfo = blockRenderMap[block.type];
const richText = block[block.type]?.rich_text;
if (renderInfo) {
const { tag, className, isSelfClosing } = renderInfo;
if (isSelfClosing) {
if (block.type === "image") {
return (
<img
key={block.id}
src={block.image.file.url}
alt="Notion Image"
className={className}
/>
);
}
if (block.type === "divider") {
return <hr key={block.id} className={className} />;
}
}
if (block.type === "bulleted_list_item" || block.type === "numbered_list_item") {
return richText && richText.length > 0 ? (
<span key={block.id}>{renderRichText(richText)}</span>
) : null;
}
if (richText && richText.length > 0) {
const content = renderRichText(richText);
return React.createElement(tag, { key: block.id, className }, content);
}
return null;
}
if (richText && richText.length > 0) {
return (
<p key={block.id} className="text-sm text-gray-600 md:text-base">
{renderRichText(richText)}
</p>
);
}
return null;
};
이 매핑 객체를 활용하여 블록 타입에 따른 렌더링 로직을 동적으로 처리할 수 있게 되었다. 이를 통해 코드의 중복을 줄이고, 새로운 블록 타입을 추가할 때 매핑 객체에 항목을 추가하는 것만으로 쉽게 확장할 수 있게 되었다. 예를 들어, 새로운 블록 타입 callout을 추가하고자 할 때, 단순히 매핑 객체에 다음과 같이 항목을 추가하면 된다.
blockRenderMap["callout"] = {
tag: "div",
className: "p-4 bg-blue-100 rounded-lg",
};
이를 통해 새로운 블록 타입을 추가할 때마다 switch 문이나 조건부 렌더링 로직을 수정할 필요 없이, 매핑 객체에 항목을 추가하는 것만으로 손쉽게 지원할 수 있게 되었다. 이는 코드의 유지보수성을 크게 향상시켰으며, 프로젝트의 확장성을 높이는 데 기여했다.
2. 타입 안전성 강화
현재 블록 타입을 string으로 처리하고 있지만, TypeScript의 유니언 타입이나 enum을 활용하여 타입 안전성을 강화했다. 이를 통해 잘못된 블록 타입이 입력되었을 때 컴파일 단계에서 오류를 잡아낼 수 있다.
type BlockType = "paragraph" | "heading_1" | "heading_2" | "heading_3" | "bulleted_list_item" | "numbered_list_item" | "quote" | "divider" | "image" | "callout";
interface BlockData {
id: string;
type: BlockType;
parent: {
block_id?: string;
page_id?: string;
};
has_children: boolean;
children?: BlockData[];
[key: string]: any;
}
이렇게 타입을 정의함으로써, 잘못된 블록 타입이 사용되는 것을 방지하고, 코드의 안정성을 높일 수 있었다. 또한, 각 블록 타입에 맞는 속성들을 명확히 정의하여, 블록 데이터를 다룰 때 실수를 줄일 수 있었다.
3. 성능 최적화
블록 데이터가 매우 많아질 경우, 렌더링 성능이 저하될 수 있다. 이를 개선하기 위해 React의 memo나 useMemo를 활용하여 불필요한 리렌더링을 방지할 수 있었다.
const NotionBlockRenderer = React.memo(({ block, accordionHeadings }) => {
// 렌더링 로직...
});
또한, 블록 데이터를 가공하는 과정에서 useMemo를 활용하여 계산 비용을 줄일 수 있었다.
const blockTree = useMemo(() => buildBlockTree(blocks), [blocks]);
이러한 최적화를 통해, 블록 데이터의 양이 많아져도 렌더링 성능을 유지할 수 있었으며, 사용자에게 원활한 경험을 제공할 수 있었다.
4. 에러 핸들링 강화
블록 데이터가 예상과 다르게 입력되었을 때를 대비하여, 에러 핸들링을 강화할 필요가 있다. 예를 들어, 이미지 블록에서 file.url이 누락된 경우 대체 이미지를 표시하거나, 콘솔에 경고를 출력하는 방식으로 처리할 수 있다.
case "image":
const fileUrl = block.image?.file?.url;
if (!fileUrl) {
console.warn(`Image block with id ${block.id} is missing file URL.`);
}
return fileUrl ? (
<img src={fileUrl} alt="Notion Image" className="mb-4 rounded-lg" />
) : (
<div className="mb-4 w-full rounded-lg bg-grey-200 p-4">
Notion Image
</div>
);
NotionRenderer를 설계하고 구현하는 과정은 단순한 컴포넌트 개발을 넘어, 데이터 구조의 이해와 효율적인 코드 설계의 중요성을 깨닫게 해주었다. 초기의 단순한 조건부 렌더링 방식에서 시작하여, 트리 구조 처리와 공통 로직의 최적화를 통해 점점 복잡한 요구사항을 만족시키는 방향으로 나아가면서 많은 고민과 시도를 거쳤다.
특히, 리스트의 중첩 렌더링과 헤딩을 기준으로 한 블록 그룹화는 사용자에게 명확하고 일관된 UI를 제공하는 데 큰 역할을 했다. 또한, 공통 로직의 최적화와 기능별 모듈화는 코드의 가독성과 유지보수성을 크게 향상시켰다. 문제를 체계적으로 분석하고, 기능별로 모듈화하며, 공통 로직을 최적화하는 것이 코드의 질을 높이는 데 얼마나 중요한지를 많이 생각해본 계기도 되었다.
'개발 일기' 카테고리의 다른 글
작지만 생각할게 참 많았던, Dropdown 컴포넌트 설계 과정 기록 (2) | 2024.11.06 |
---|---|
컴포넌트 주도 개발(CDD)을 도입해보면 어떨까? (feat: 디자인시스템 라이브러리 & 스토리북) (1) | 2024.11.06 |
음대생, 프론트엔드개발자, 스타트업에서의 1년, 회고 (4) | 2024.09.04 |
React로 만든 일정 관리를 위한 백오피스 캘린더 컴포넌트 (2) | 2024.08.31 |
2주차 회고 - React 파헤치기 (0) | 2024.03.30 |