코딩 문제 생성 Web 인터페이스 개발
2024.07.20 ~ 2024.07.22
2024.07.20 ~ 2024.07.22
WYS|WYG MD EditorPre-signed URLMD Export & Import
Next.jsTypeScriptTailwind CSS
프로젝트 개요
악어에듀의 주 서비스인 AI 교육 플랫폼 웹 서비스 AKEO의 웹 Front-end 개발 프로젝트를 수행하였다.
교육 플랫폼 서비스 내부 교육자가 활용할 수 있는 코딩 문제 생성 인터페이스 개발을 전담하여 개발하였다.
이 과정에서 문제 내용, 강의 자료 작성 등 여러 기능에서 활용 가능한 Markdown 기반 에디터 component를 구현하였다.
WYS|WYG 기반으로 Markdown 문법 지원LaTex 기반 수식 작성Markdown Import & Export텍스트 내부 이미지 import 및 배치, 크기 제어.
에디터 편집 모드 Web UI2. Next.js CSR/SSR 환경 유지보수
AKEO의 Front-end는 Next.js 환경을 기반으로 구현되었으며, 기능의 목적에 맞게 CSR/SSR을 적절하게 혼용하여 개발하였다.
이 과정에서 발생하는 렌더링 저하 문제를 대응하고, 유지 보수하였다.
트러블 슈팅
A. AST-JSON 변환 구조 재설계
문제
Markdown import 이후 에디터에서 수정이 정상적으로 반영되지 않는 문제가 발생하였다.특정 케이스에서 JSON tree 구조가 비정상적으로 생성되어 기존의 Edit component에서 기대하는 구조와 불일치하는 상태가 발생하였다.특히 inline 요소(수식, bold, italic 등)가 포함된 텍스트에서 불필요한 paragraph nesting이 생성되며 렌더링 및 수정 로직이 깨졌다.결과적으로 동일한 데이터를 다시 수정할 경우 변경 사항이 반영되지 않거나 editor 내부 상태와 UI가 불일치하는 문제가 발생하였다.기존 구조는 Markdown AST의 각 node를 1:1로 JSON node로 변환하는 방식으로 설계되어 있었으며, 복합 inline 구조를 표현할 수 없는 제한을 가진 상태였다.
원인 분석
1. 단일 노드 반환 구조
convertMarkdownTreeToJson이 하나의 markdown node에 대해 하나의 JSON node만 반환하는 구조였다.text node 내부에서 bold, italic, inlineMath 등이 혼합된 경우에도 하나의 node로 처리되었다.이를 보정하기 위해 불필요한 paragraph wrapping이 추가되면서 구조가 왜곡되었다.결과적으로 하나의 입력 node가 여러 의미 단위를 가지는 경우에도 단일 node로 강제 변환되면서 트리 구조가 비정상적으로 생성되었다.
2. 재귀 처리 결과의 구조 유지 실패
기존 구현은 children을 map으로 순회한 뒤 그대로 배열로 유지하였다.node 변환 결과가 배열이 아닌 단일 객체 기준으로 설계되어 flatten 과정이 존재하지 않았다.이로 인해 중첩 배열 또는 불필요한 depth가 유지되었다.결과적으로 AST의 계층 구조는 유지되었지만 JSON 구조에서는 의미 없는 depth가 증가하여 editor가 처리할 수 없는 구조가 생성되었다.
3. inline parsing 구조와 node 구조 불일치
text node는 내부적으로 split을 통해 여러 JSON node로 분해되는 구조였다.그러나 상위 convert 함수는 이를 단일 node로 취급하였다.inline 처리 결과와 트리 구조 생성 방식 간의 불일치가 발생하였다.결과적으로 하위 로직은 multi node를 생성하지만 상위 구조는 single node를 기대하는 불일치 구조가 문제를 발생시켰다.
해결 방법
1. 다중 노드 반환 기반 구조로 변경
convertMarkdownTreeToJson의 반환 타입을 Md.JsonNode[] | null로 변경하였다.하나의 markdown node가 여러 JSON node로 변환될 수 있도록 설계를 변경하였다.node → JSON node (1:1 매핑)children.map → 결과 그대로 유지inline 분해 결과를 상위에서 처리하지 못함node → JSON node[] (1:N 매핑)모든 재귀 결과를 flat으로 평탄화inline 분해 결과를 자연스럽게 흡수단일 반환 구조 → 배열 반환 구조depth 기반 트리 유지 → 의미 기반 평탄화 구조inline 처리와 트리 구조 간 불일치 제거2. inline node 분해 기준 통합
text node 내부에서 split을 통해 생성된 여러 node를 그대로 반환하도록 수정하였다.inline 요소 존재 여부에 따라 반환 형태를 분기하였다.inline이 포함된 경우: 여러 node 반환일반 텍스트: 단일 node 유지결과
inline 요소가 포함된 텍스트에서도 정상적인 JSON tree 구조 생성이 가능해졌다.불필요한 paragraph 중첩이 제거되어 기존의 Edit component에서 요구하는 구조와 일치하게 되었다.Markdown import 이후 수정 시 변경 사항이 정상적으로 반영되었다.다양한 Markdown 확장(GFM, LaTeX 등)에서도 안정적으로 동작하는 구조가 되었다.핵심 포인트
이 개선의 핵심은 AST node를 단일 JSON node로 강제 변환하던 구조를 다중 node 반환 기반으로 재설계한 것에 있다.
node 1:1 매핑 구조 제거inline parsing 결과를 구조적으로 수용재귀 결과를 flat 처리하여 트리 정합성 확보AST 단일 노드 변환 구조에서 다중 노드 반환 + 평탄화 기반 구조로 변경
B. 이미지 상태 동기화
문제
에디터에서 이미지를 업로드한 후 삭제하거나 Undo를 수행하는 과정에서 실제 서버에 매핑된 이미지 상태와 에디터 내부 상태가 불일치하는 문제가 발생했다이미지를 삭제했음에도 서버에는 여전히 매핑된 상태로 남거나, Undo로 복구된 이미지가 서버에는 다시 생성되지 않는 문제가 발생했다이미지 추가, 삭제, Undo가 반복되는 상황에서 현재 사용 중인 이미지 목록을 정확하게 유지하지 못해 데이터 정합성이 깨지는 문제가 발생했다특히 문제 생성 중 이미지 업로드 후 삭제하거나 Undo로 되돌리는 경우, 최종 저장 시 서버에 전달되는 이미지 ID 목록이 실제 화면과 다르게 전달되는 문제가 발생했다기존 구조는 이미지 상태를 이벤트 단위로만 처리하고 있었으며, 에디터 전체 상태 기준으로 동기화하지 못하는 구조적 한계를 가진 상태였다
원인 분석
1. 이벤트 기반 이미지 상태 관리 구조
이미지 추가 시점에는 imgCreateHandler를 호출하고, 삭제 시점에는 imgDeleteHandler를 호출하는 방식으로 이벤트 단위 처리 구조로 설계되어 있었다현재 에디터 전체 상태(JSON tree)를 기준으로 이미지 존재 여부를 판단하지 않고, 특정 이벤트 발생 시점에만 상태를 변경하는 구조였다이 구조에서는 Undo와 같이 상태를 되돌리는 경우 이벤트가 재실행되지 않기 때문에 실제 상태와 동기화가 불가능했다
2. Undo 동작에 대한 상태 복원 처리 부재
Undo는 에디터 내부 상태를 이전 상태로 되돌리지만, 이미지 매핑 상태는 별도의 로직으로 관리되고 있었다Undo 실행 시 기존 이미지 ID 목록(imgIds.current)에 없는 이미지가 다시 등장해도 이를 감지하여 복구하는 로직이 존재하지 않았다결과적으로 Undo 이후 에디터에는 이미지가 존재하지만 서버 매핑 상태에는 반영되지 않는 불일치가 발생했다
3. 전체 문서 기준 이미지 상태 검증 부재
현재 에디터 상태를 전체 순회하여 이미지 존재 여부를 확인하는 구조가 없었고, 특정 시점의 부분 상태만 기준으로 처리되었다JSONContent 전체를 기준으로 이미지 노드를 탐색하지 않고, imgIds 배열을 기준으로만 상태를 관리하였다이 구조는 이미지 삭제, Undo, 복구가 반복되는 상황에서 누락 또는 중복 상태를 발생시키는 원인이 되었다
기존 구조는 이벤트 기반 상태 변경 + 부분 상태 기준 관리로 인해 에디터 실제 상태와 서버 매핑 상태를 일관되게 유지할 수 없는 구조였다
해결 방법
1. 전체 문서 기반 상태 동기화 구조로 변경
이미지 상태를 이벤트 기반이 아닌 “현재 에디터 전체 JSON 상태 기준”으로 동기화하는 구조로 변경하였다onUpdate 시점마다 전체 문서를 순회하여 실제 존재하는 이미지 ID를 기준으로 상태를 재계산하도록 변경하였다이미지 추가 시: imgCreateHandler 호출이미지 삭제 시: imgDeleteHandler 호출상태 관리 기준: 이벤트 발생 시점전체 상태 검증 없음onUpdate 실행 시 editor.getJSON()으로 전체 상태 조회JSONContent를 재귀적으로 순회하며 이미지 node 탐색현재 존재하는 이미지 ID와 기존 imgIds 비교누락된 ID → 삭제 처리새롭게 등장한 ID (Undo 포함) → 생성 처리이벤트 기반 → 전체 상태 기반 동기화로 변경부분 처리 → 전체 JSON tree 순회 기반 처리로 변경단방향 처리 → 비교 기반 상태 재구성 방식으로 변경2. 이미지 상태 비교 기반 처리 방식 도입
기존 imgIds를 기준으로 notFoundImgIds 배열을 생성현재 문서에서 발견된 이미지 ID를 제거하면서 실제 삭제 대상만 추출남은 ID를 기준으로 imgDeleteHandler 실행이 방식은 “현재 상태 기준으로 차이를 계산하는 구조”로 변경된 핵심 처리 방식이다
3. Undo 전용 상태 복원 처리 추가
undoCheckRef를 통해 Undo 실행 여부를 명시적으로 관리Undo 발생 시 기존 imgIds에 없는 이미지가 등장하면 imgCreateHandler 호출이 처리로 Undo 시 이미지 상태 복원이 가능하도록 보완하였다
결과
이미지 추가, 삭제, Undo 반복 상황에서도 서버 매핑 상태와 에디터 상태가 일관되게 유지되도록 개선되었다최종 저장 시 전달되는 이미지 ID 목록이 실제 화면 상태와 동일하게 유지되도록 보장되었다Undo 이후 이미지 복구 시 누락 없이 정상적으로 서버 매핑이 복원되도록 개선되었다이미지 상태 관리가 이벤트 흐름이 아닌 데이터 상태 기준으로 동작하도록 변경되어 안정성이 크게 향상되었다핵심 포인트
이 개선의 핵심은 이벤트 기반 이미지 처리 구조를 전체 문서 상태 기반 동기화 구조로 변경한 것에 있다
에디터 JSON 전체를 기준으로 이미지 상태를 재계산하도록 변경기존 상태와 현재 상태의 차이를 기반으로 create/delete를 결정Undo를 별도 플래그로 관리하여 상태 복원 흐름을 명시적으로 처리이벤트 기반 이미지 처리 구조에서 전체 상태 비교 기반 동기화 구조로 변경하여 Undo 포함 상태 일관성을 확보한 구조
문제
Next.js 환경에서 client component 중심으로 구현된 페이지에서 렌더링 지연이 발생했고, 특히 단순 텍스트 위주의 페이지에서도 LCP가 비정상적으로 증가하는 문제가 확인되었다.console과 Network 탭 모두에서 에러 로그가 확인되지 않아 일반적인 디버깅 방식으로는 원인 추적이 불가능했다.view-source 기준으로 확인 시 서버가 반환한 HTML에는 next-error not-found 메타 정보가 포함되어 있었고, 정상 페이지가 아닌 에러 페이지로 인식되는 상태였다.초기 HTML이 정상 페이지 기준이 아니라 에러 경로 기준으로 생성되면서 클라이언트에서 전체를 다시 렌더링해야 하는 구조였고, 이로 인해 LCP 지연과 500 오류가 동시에 발생하는 상태였다.기존 구조는 CSR 기반으로 작성되었지만 SSR 개입을 차단하는 구조가 없었고, 서버와 클라이언트 렌더링 경로가 혼합된 상태였다.
원인 분석
1. CSR 컴포넌트가 SSR 경로에 포함된 구조
상태에 따라 렌더링되는 component를 일반 import로 포함Next.js가 서버에서 해당 component를 해석하려고 시도CSR 전용 로직이 SSR 단계에 개입CSR 기반으로 작성된 component라도 import 구조상 SSR 판단 대상이 되면서 서버 렌더링 경로에 포함되었다.
2. use client 선언으로 해결되지 않는 렌더링 경로 문제
모든 component와 custom hook에 use client 적용API method가 포함된 페이지도 포함하여 누락 보완하지만 500 error 지속 발생문제는 client 선언 여부가 아니라 렌더링 실행 경로였고, 선언만으로 SSR 개입을 차단할 수 없는 구조였다.
3. 서버가 not-found 페이지로 잘못 인식하는 초기 HTML 생성
<meta name="next-error" content="not-found"/> 존재Next.js가 해당 페이지를 404로 판단서버 HTML이 정상 렌더링 기준이 아님이 상태에서는 서버 HTML을 사용할 수 없어 클라이언트에서 전체를 다시 렌더링해야 했고, 초기 렌더링 비용이 증가했다.
기존 구조는 CSR 설계와 관계없이 SSR 판단 경로가 열려 있었고, 서버가 잘못된 초기 HTML을 생성하면서 렌더링 경로 충돌이 발생했다.
해결 방법
기준: “어디에서 렌더링되어야 하는가”CSR 전용 component를 SSR 경로에서 제거렌더링 주체 기준으로 컴포넌트를 분리하는 방식으로 구조를 재설계했다.
모든 component가 일반 import로 연결됨CSR component도 SSR 판단 대상에 포함서버가 상태 기반 렌더링 시도CSR component를 dynamic import로 전환ssr: false 명시하여 서버 렌더링 제외서버는 해당 컴포넌트를 초기 HTML 생성에 포함하지 않고, 클라이언트에서만 로드하도록 변경했다.
기존: CSR component → SSR 판단 포함변경: CSR component → 클라이언트 전용 로딩 분리렌더링 대상이 아니라 렌더링 위치 기준으로 구조를 재정의했다.
2. 클라이언트 경계 명시 (use client 보완)
모든 component, hook에 use client 적용클라이언트 실행 대상 명확화이 단계는 문제 해결이 아니라 렌더링 경계 확인 역할을 수행했다.
3. 서버 HTML 기준 검증
view-source 기준으로 변경 전/후 비교next-error meta 태그 제거 확인변경 후에는 정상 HTML 구조가 반환되며 서버가 페이지를 정상 경로로 인식하는 상태가 되었다.
결과
500 error 발생 문제 해결LCP 지표 유의미하게 개선렌더링량과 무관하게 발생하던 초기 지연이 제거되었고, CSR 기반 페이지에서도 안정적인 초기 렌더링이 가능해졌다.
핵심 포인트
이 개선의 핵심은 렌더링 최적화가 아니라 렌더링 경로 분리에 있다.
CSR 컴포넌트를 SSR 경로에서 제거import 방식 기준이 아닌 렌더링 주체 기준으로 구조 재정의서버 HTML 생성 결과를 기준으로 문제 검증CSR로 설계된 컴포넌트를 SSR 경로에서 분리하여 클라이언트 재렌더링을 동시에 제거한 구조 개선
content: node.children
.map(child => convertMarkdownTreeToJson(child))
.filter(Boolean)
content: node.children
.map(child => convertMarkdownTreeToJson(child))
.flat()
.filter(Boolean)
if (isInlineContent) return children;
return [{ type: node.type, text: node.value }];
function findImageTitles(obj: JSONContent) {
if (obj.type === 'image' && obj.attrs && 'title' in obj.attrs) {
notFoundImgIds = notFoundImgIds.filter(
item => item !== Number(obj.attrs!.title),
);
if (undoCheckRef.current) {
if (!imgIds.current.find(item => item === Number(obj.attrs!.title))) {
imgController?.imgCreateHandler(Number(obj.attrs!.title));
}
}
}
if (obj.content && obj.content.length > 0) {
obj.content.forEach(child => {
findImageTitles(child);
});
}
}
notFoundImgIds.forEach(imgId => {
imgController?.imgDeleteHandler(imgId);
imgIds.current = imgIds.current.filter(item => item !== id);
});
if (undoCheckRef.current) {
if (!imgIds.current.find(item => item === Number(obj.attrs!.title))) {
imgController?.imgCreateHandler(Number(obj.attrs!.title));
}
}
const WorkbookProblemList = dynamic(
() => import('@/widget/workbook').then(mod => mod.WorkbookProblemList),
{ ssr: false },
);
const StarProgress = dynamic(
() => import('@/entities/StarProgress').then(mod => mod.StarProgress),
{ ssr: false },
);
- 서버 초기 HTML이 정상 페이지 기준으로 생성
- 클라이언트 전체 재렌더링 제거