코딩 문제 생성 Web 인터페이스 개발

2024.07.20 ~ 2024.07.22

2024.07.20 ~ 2024.07.22

WYS|WYG MD EditorPre-signed URLMD Export & Import

Next.jsTypeScriptTailwind CSS

목차

  • 프로젝트 개요
  • 트러블 슈팅
  • A. AST-JSON 변환 구조 재설계
  • 문제
  • 원인 분석
  • 1. 단일 노드 반환 구조
  • 2. 재귀 처리 결과의 구조 유지 실패
  • 3. inline parsing 구조와 node 구조 불일치
  • 해결 방법
  • 1. 다중 노드 반환 기반 구조로 변경
  • 2. inline node 분해 기준 통합
  • 결과
  • 핵심 포인트
  • B. 이미지 상태 동기화
  • 문제
  • 원인 분석
  • 1. 이벤트 기반 이미지 상태 관리 구조
  • 2. Undo 동작에 대한 상태 복원 처리 부재
  • 3. 전체 문서 기준 이미지 상태 검증 부재
  • 해결 방법
  • 1. 전체 문서 기반 상태 동기화 구조로 변경
  • 2. 이미지 상태 비교 기반 처리 방식 도입
  • 3. Undo 전용 상태 복원 처리 추가
  • 결과
  • 핵심 포인트
  • C. Next.js CSR/SSR 충돌 해결
  • 문제
  • 원인 분석
  • 1. CSR 컴포넌트가 SSR 경로에 포함된 구조
  • 2. use client 선언으로 해결되지 않는 렌더링 경로 문제
  • 3. 서버가 not-found 페이지로 잘못 인식하는 초기 HTML 생성
  • 해결 방법
  • 1. CSR/SSR 렌더링 경로 분리
  • 2. 클라이언트 경계 명시 (use client 보완)
  • 3. 서버 HTML 기준 검증
  • 결과
  • 핵심 포인트

WYS|WYG MD EditorPre-signed URLMD Export & Import

Next.jsTypeScriptTailwind CSS

목차

  • 프로젝트 개요
  • 트러블 슈팅
  • A. AST-JSON 변환 구조 재설계
  • 문제
  • 원인 분석
  • 1. 단일 노드 반환 구조
  • 2. 재귀 처리 결과의 구조 유지 실패
  • 3. inline parsing 구조와 node 구조 불일치
  • 해결 방법
  • 1. 다중 노드 반환 기반 구조로 변경
  • 2. inline node 분해 기준 통합
  • 결과
  • 핵심 포인트
  • B. 이미지 상태 동기화
  • 문제
  • 원인 분석
  • 1. 이벤트 기반 이미지 상태 관리 구조
  • 2. Undo 동작에 대한 상태 복원 처리 부재
  • 3. 전체 문서 기준 이미지 상태 검증 부재
  • 해결 방법
  • 1. 전체 문서 기반 상태 동기화 구조로 변경
  • 2. 이미지 상태 비교 기반 처리 방식 도입
  • 3. Undo 전용 상태 복원 처리 추가
  • 결과
  • 핵심 포인트
  • C. Next.js CSR/SSR 충돌 해결
  • 문제
  • 원인 분석
  • 1. CSR 컴포넌트가 SSR 경로에 포함된 구조
  • 2. use client 선언으로 해결되지 않는 렌더링 경로 문제
  • 3. 서버가 not-found 페이지로 잘못 인식하는 초기 HTML 생성
  • 해결 방법
  • 1. CSR/SSR 렌더링 경로 분리
  • 2. 클라이언트 경계 명시 (use client 보완)
  • 3. 서버 HTML 기준 검증
  • 결과
  • 핵심 포인트

프로젝트 개요


악어에듀의 주 서비스인 AI 교육 플랫폼 웹 서비스 AKEO의 웹 Front-end 개발 프로젝트를 수행하였다.

1. 코딩 문제 생성 인터페이스 개발

교육 플랫폼 서비스 내부 교육자가 활용할 수 있는 코딩 문제 생성 인터페이스 개발을 전담하여 개발하였다.

이 과정에서 문제 내용, 강의 자료 작성 등 여러 기능에서 활용 가능한 Markdown 기반 에디터 component를 구현하였다.

  • WYS|WYG 기반으로 Markdown 문법 지원
  • LaTex 기반 수식 작성
  • Markdown Import & Export
  • 텍스트 내부 이미지 import 및 배치, 크기 제어.
  • 에디터 편집 모드 Web UI
    에디터 편집 모드 Web UI

    2. 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로 변환될 수 있도록 설계를 변경하였다.
  • 1.1. 기존 구조

  • node → JSON node (1:1 매핑)
  • children.map → 결과 그대로 유지
  • inline 분해 결과를 상위에서 처리하지 못함
  • 1.2. 변경된 구조

  • node → JSON node[] (1:N 매핑)
  • 모든 재귀 결과를 flat으로 평탄화
  • inline 분해 결과를 자연스럽게 흡수
  • 1.3. 구조 변화 요약

  • 단일 반환 구조 → 배열 반환 구조
  • 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를 기준으로 상태를 재계산하도록 변경하였다
  • 1.1 기존 구조

  • 이미지 추가 시: imgCreateHandler 호출
  • 이미지 삭제 시: imgDeleteHandler 호출
  • 상태 관리 기준: 이벤트 발생 시점
  • 전체 상태 검증 없음
  • 1.2 변경된 구조

  • onUpdate 실행 시 editor.getJSON()으로 전체 상태 조회
  • JSONContent를 재귀적으로 순회하며 이미지 node 탐색
  • 현재 존재하는 이미지 ID와 기존 imgIds 비교
  • 누락된 ID → 삭제 처리
  • 새롭게 등장한 ID (Undo 포함) → 생성 처리
  • 1.3 구조 변화 요약

  • 이벤트 기반 → 전체 상태 기반 동기화로 변경
  • 부분 처리 → 전체 JSON tree 순회 기반 처리로 변경
  • 단방향 처리 → 비교 기반 상태 재구성 방식으로 변경
  • 2. 이미지 상태 비교 기반 처리 방식 도입

  • 기존 imgIds를 기준으로 notFoundImgIds 배열을 생성
  • 현재 문서에서 발견된 이미지 ID를 제거하면서 실제 삭제 대상만 추출
  • 남은 ID를 기준으로 imgDeleteHandler 실행
  • 이 방식은 “현재 상태 기준으로 차이를 계산하는 구조”로 변경된 핵심 처리 방식이다

    3. Undo 전용 상태 복원 처리 추가

  • undoCheckRef를 통해 Undo 실행 여부를 명시적으로 관리
  • Undo 발생 시 기존 imgIds에 없는 이미지가 등장하면 imgCreateHandler 호출
  • 이 처리로 Undo 시 이미지 상태 복원이 가능하도록 보완하였다

    결과

  • 이미지 추가, 삭제, Undo 반복 상황에서도 서버 매핑 상태와 에디터 상태가 일관되게 유지되도록 개선되었다
  • 최종 저장 시 전달되는 이미지 ID 목록이 실제 화면 상태와 동일하게 유지되도록 보장되었다
  • Undo 이후 이미지 복구 시 누락 없이 정상적으로 서버 매핑이 복원되도록 개선되었다
  • 이미지 상태 관리가 이벤트 흐름이 아닌 데이터 상태 기준으로 동작하도록 변경되어 안정성이 크게 향상되었다
  • 핵심 포인트

    이 개선의 핵심은 이벤트 기반 이미지 처리 구조를 전체 문서 상태 기반 동기화 구조로 변경한 것에 있다

  • 에디터 JSON 전체를 기준으로 이미지 상태를 재계산하도록 변경
  • 기존 상태와 현재 상태의 차이를 기반으로 create/delete를 결정
  • Undo를 별도 플래그로 관리하여 상태 복원 흐름을 명시적으로 처리
  • 이벤트 기반 이미지 처리 구조에서 전체 상태 비교 기반 동기화 구조로 변경하여 Undo 포함 상태 일관성을 확보한 구조

    C. Next.js CSR/SSR 충돌 해결


    문제

  • 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을 생성하면서 렌더링 경로 충돌이 발생했다.

    해결 방법

    1. CSR/SSR 렌더링 경로 분리

  • 기준: “어디에서 렌더링되어야 하는가”
  • CSR 전용 component를 SSR 경로에서 제거
  • 렌더링 주체 기준으로 컴포넌트를 분리하는 방식으로 구조를 재설계했다.

    1.1. 기존 구조

  • 모든 component가 일반 import로 연결됨
  • CSR component도 SSR 판단 대상에 포함
  • 서버가 상태 기반 렌더링 시도
  • 1.2. 변경된 구조

  • CSR component를 dynamic import로 전환
  • ssr: false 명시하여 서버 렌더링 제외
  • 서버는 해당 컴포넌트를 초기 HTML 생성에 포함하지 않고, 클라이언트에서만 로드하도록 변경했다.

    1.3. 구조 변화 요약

  • 기존: 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이 정상 페이지 기준으로 생성
    • 클라이언트 전체 재렌더링 제거