React Markdown에서 특수문자로 인해 마크다운 형식이 제대로 적용되지 않는 문제

1. 특수문자 이스케이프 처리

서버에서 메시지를 받을 때 특수문자를 이스케이프 처리하는 유틸리티 함수를 만들어 사용합니다:

// utils/markdownHelper.js
export const escapeSpecialChars = (text) => {
  if (!text) return '';

  // 마크다운 특수문자들을 이스케이프 처리
  const specialChars = {
    '*': '\\\\*',
    '_': '\\\\_',
    '`': '\\\\`',
    '#': '\\\\#',
    '+': '\\\\+',
    '-': '\\\\-',
    '.': '\\\\.',
    '!': '\\\\!',
    '[': '\\\\[',
    ']': '\\\\]',
    '(': '\\\\(',
    ')': '\\\\)',
    '{': '\\\\{',
    '}': '\\\\}',
    '|': '\\\\|',
    '>': '\\\\>',
    '<': '\\\\<'
  };

  // 코드 블록 내부는 이스케이프하지 않도록 처리
  const codeBlockRegex = /```[\\s\\S]*?```|`[^`]+`/g;
  const codeBlocks = [];
  let processedText = text;

  // 코드 블록을 임시로 치환
  processedText = processedText.replace(codeBlockRegex, (match) => {
    codeBlocks.push(match);
    return `__CODE_BLOCK_${codeBlocks.length - 1}__`;
  });

  // 특수문자 이스케이프
  Object.entries(specialChars).forEach(([char, escaped]) => {
    const regex = new RegExp(`\\\\${char}`, 'g');
    processedText = processedText.replace(regex, escaped);
  });

  // 코드 블록 복원
  codeBlocks.forEach((block, index) => {
    processedText = processedText.replace(`__CODE_BLOCK_${index}__`, block);
  });

  return processedText;
};

2. MessageItem 컴포넌트 수정

MessageItem.jsx 파일을 수정하여 react-markdown에 추가 설정을 적용합니다:

import React from 'react'
import styles from './MessageItem.module.css'
import NoaIcon from '../../assets/noamessageicon.svg'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import PlanCardGroup from './PlanCard'

const MessageItem = ({ message, formatTime }) => {
  if (message.role === 'card') {
    return <PlanCardGroup plans={message.content} />
  }

  const messageClasses = [
    styles.message,
    styles[message.role],
    message.isStreaming ? styles.streaming : '',
    message.isTemp ? styles.temp : '',
  ].join(' ')

  // 마크다운 컴포넌트 커스터마이징
  const markdownComponents = {
    // 줄바꿈 처리
    p: ({children}) => <p className="mb-2">{children}</p>,
    // 코드 블록 스타일링
    code: ({inline, children}) => {
      if (inline) {
        return <code className="bg-gray-100 px-1 py-0.5 rounded text-sm">{children}</code>
      }
      return (
        <pre className="bg-gray-100 p-3 rounded overflow-x-auto">
          <code>{children}</code>
        </pre>
      )
    },
    // 리스트 스타일링
    ul: ({children}) => <ul className="list-disc list-inside mb-2">{children}</ul>,
    ol: ({children}) => <ol className="list-decimal list-inside mb-2">{children}</ol>,
    // 링크 스타일링
    a: ({href, children}) => (
      <a href={href} target="_blank" rel="noopener noreferrer"
         className="text-blue-500 hover:underline">
        {children}
      </a>
    ),
  }

  return (
    <div className={styles.messageWrapper} data-role={message.role}>
      {message.role === 'assistant' && (
        <div className={styles.avatarContainer}>
          <img src={NoaIcon} alt="노아 아이콘" className={styles.avatarIcon} />
          <span className={styles.noaText}>NOA</span>
        </div>
      )}

      <div className={messageClasses}>
        <div className={styles.messageContent}>
          <ReactMarkdown
            remarkPlugins={[remarkGfm, remarkBreaks]}
            components={markdownComponents}
            skipHtml={false}
            allowedElements={undefined} // 모든 HTML 요소 허용
          >
            {message.content}
          </ReactMarkdown>
        </div>
        <div className={styles.messageTime}>
          {formatTime(message.timestamp)}
          {message.isTemp && <span className={styles.tempIndicator}>전송중</span>}
        </div>
      </div>
    </div>
  )
}

export default MessageItem

3. package.json에 필요한 패키지 추가

{
  "dependencies": {
    // 기존 의존성...
    "remark-gfm": "^4.0.0",
    "remark-breaks": "^4.0.0"
  }
}

4. useChat 훅에서 메시지 전처리

useChat.js에서 메시지를 받을 때 전처리를 추가합니다:

// useChat.js에 추가
const preprocessMessage = (content) => {
  if (!content) return '';

  // 특수문자 앞뒤에 공백이 없어서 마크다운이 적용되지 않는 경우 처리
  let processed = content;

  // 볼드(**)나 이탤릭(*) 처리
  processed = processed.replace(/(\\S)\\*\\*(\\S)/g, '$1 **$2');
  processed = processed.replace(/(\\S)\\*(\\S)/g, '$1 *$2');

  // 코드 블록(```) 처리
  processed = processed.replace(/(\\S)```/g, '$1\\n```');
  processed = processed.replace(/```(\\S)/g, '```\\n$1');

  // 인라인 코드(`) 처리
  processed = processed.replace(/(\\S)`(\\S)/g, '$1 `$2');

  return processed;
};

// handleStreamChunk 함수 수정
const handleStreamChunk = chunk => {
  if (chunk && streamingMessageIdRef.current) {
    setMessages(prev =>
      prev.map(msg =>
        msg.id === streamingMessageIdRef.current
          ? { ...msg, content: preprocessMessage(msg.content + chunk) }
          : msg
      )
    )
  }
}

5. 추가 CSS 스타일 (MessageItem.module.css)

/* 마크다운 콘텐츠 스타일링 */
.messageContent {
  word-break: break-word;
  white-space: pre-wrap;
}

.messageContent h1,
.messageContent h2,
.messageContent h3 {
  margin-top: 1em;
  margin-bottom: 0.5em;
  font-weight: bold;
}

.messageContent h1 { font-size: 1.5em; }
.messageContent h2 { font-size: 1.3em; }
.messageContent h3 { font-size: 1.1em; }

.messageContent blockquote {
  border-left: 4px solid #ddd;
  padding-left: 1em;
  margin: 0.5em 0;
  color: #666;
}

.messageContent table {
  border-collapse: collapse;
  margin: 0.5em 0;
}

.messageContent th,
.messageContent td {
  border: 1px solid #ddd;
  padding: 0.5em;
}

피드백 요약

  1. remark-gfm과 remark-breaks 플러그인 추가: GitHub Flavored Markdown과 자동 줄바꿈을 지원
  2. 특수문자 전처리: 메시지를 받을 때 특수문자 주변에 적절한 공백 추가
  3. 커스텀 컴포넌트: 마크다운 요소별로 스타일 적용
  4. CSS 스타일링: 마크다운 콘텐츠가 더 보기 좋게 표시되도록 스타일 추가