본문 바로가기
2024/프로젝트

[Wiki Page] 내용에 있는 제목 자동 링크걸기 디버깅

by ye-jji 2024. 3. 14.

INTRO

머릿속으로 어떻게 해야 이 기능을 동작하게 할 수 있을까 고민했다. 일단 제목 전체가 내용에 있는 텍스트와 비교해서 같은게 있는지 찾고 같으면 링크를 생성하고 그걸 클릭하면 해당 제목의 디테일 페이지로 이동해야 한다. 이정도로 생각을 정리하고 나서 코딩을 시작했다. 조금 더 생각하고 코드를 작성해야 했다..

시도 1

  // 포스트 내용에서 다른 포스트 제목을 찾아 링크로 변환
  const renderedContent = post.content.split(' ').map((word, index) => {
    const matchingPost = posts.find(p => p.title === word);
    if (matchingPost) {
      return <Link key={index} to={`/postDetail/${matchingPost.id}`}>{word}</Link>;
    }
    return word + ' ';
  });

 

이 시도는 공백을 기준으로 내용에 해당하는 텍스트를 분리해서 제목과 일치하는지 검사하는 로직이었다. 당연한 말이겠지만 작동하지 않았다. 여기에서 간과했던 것은 제목이 만약 '가수'라면 내용에는 '가수' 만 있지 않다는 것이었다. '가수이다.'라고 서술형으로 써져있으면 당연하게도 컴퓨터는 다른 거라고 생각한다..^^ 한글이 영어보다 처리하기 어렵다는 이야기를 이렇게 또 이해해버렸다.

 

시도 2

 

  // 포스트 내용에서 다른 포스트의 제목을 식별하고, 클릭 가능한 요소로 변환
  const processedContent = posts.reduce((acc, { id, title }) => {
    // 제목이 포함된 경우, 링크를 추가
    if (post.content.includes(title)) {
      const replacedText = `<span onClick={() => navigate('/postDetail/${id}')}" style="color: blue; cursor: pointer;">${title}</span>`;
      acc = acc.split(title).join(replacedText);
    }
    return acc;
  }, post.content);

 

제목과 내용이 같은지 확인하는게 아니라 포함하고 있는지 확인하는 걸로 로직을 바꿨다. 이제 제목이 있다면 링크는 잘 생성된다. 근데 두가지 문제가 있었다. 첫번째는 dangerouslySetInnerHTML로 생성된 HTML 내에서는 Link 컴포넌트를 직접 사용할 수 없기 때문에 페이지 이동이 안된다는 것이었고, 두번째는 dangerouslySetInnerHTML가 XSS공격에 매우 취약하다는 것이었다.제목과 내용이 같은지 확인하는게 아니라 포함하고 있는지 확인하는 걸로 로직을 바꿨다. 이제 제목이 있다면 링크는 잘 생성된다. 근데 두가지 문제가 있었다. 첫번째는 dangerouslySetInnerHTML로 생성된 HTML 내에서는 Link 컴포넌트를 직접 사용할 수 없기 때문에 페이지 이동이 안된다는 것이었고, 두번째는 dangerouslySetInnerHTML가 XSS공격에 매우 취약하다는 것이었다.

 

시도 3

// 제목을 키로, 포스트 ID를 값으로 하는 맵 생성
  const titleToIdMap = posts.reduce((acc, cur) => {
    acc[cur.title] = cur.id;
    return acc;
  }, {});

  // 포스트 내용을 React 컴포넌트 배열로 변환
  const contentComponents = post.content.split(/\s+/).map((word, index) => {
    let match = null;
    // 제목을 키로 사용하여 직접 매칭 여부 확인
    const title = Object.keys(titleToIdMap).find(title => word.includes(title));
    if (title) {
      // 제목과 일치하는 경우, Link 컴포넌트를 반환
      return (
        <React.Fragment key={index}>
          <Link to={`/postDetail/${titleToIdMap[title]}`}>{word}</Link>{' '}
        </React.Fragment>
      );
    } else {
      // 일치하지 않는 경우, 단어를 그대로 반환
      return `${word} `;
    }
  });

 

이제 이 코드는 자동으로 링크를 걸고 클릭하면 잘 이동한다. 하지만 이 코드로 테스트를 해보니 문제를 발견했다. 제목 중간에 띄어쓰기가 있으면 링크가 안걸리는 것이다. 그리고 단어에 조사가 같이 붙어있으면 조사까지 링크가 걸린다. 그래서 이 문제를 해결하기 위해서 결국 맨처음에 생각한 로직으로 돌아가게 되었다. 이제 글자 하나하나 분리해서 검사하고 링크를 달기로 했다. 제목을 배열로 담아서 마지막까지 일치하는지 확인하면 띄어쓰기도 해결 될 거라고 생각했다.

 

시도 4

  // 전체 내용을 담을 배열
  let contentWithLinks: React.ReactNode[] = [];
  let lastIndex = 0; // 마지막으로 처리한 인덱스

  // 제목이 포함된 위치를 찾아서 링크를 생성하는 함수
  posts.forEach(({ id, title }) => {
    let titleIndex = post.content.indexOf(title, lastIndex);
    while (titleIndex !== -1) {
      // 제목 이전의 텍스트 추가
      if (titleIndex > lastIndex) {
        contentWithLinks.push(post.content.substring(lastIndex, titleIndex));
      }
      
      // 제목을 Link로 변환
      contentWithLinks.push(
        <Link key={`${id}-${titleIndex}`} to={`/postDetail/${id}`}>
          {title}
        </Link>
      );

      // 조사 처리: 제목 바로 뒤의 조사를 식별하기 위한 정규식
      const postTitleRegex = new RegExp(`^(${title})([가-힣]{1,2})`, 'g');
      const matches = postTitleRegex.exec(post.content.substring(titleIndex));

      if (matches && matches[2]) {
        // 조사가 식별된 경우, 조사도 텍스트로 추가
        contentWithLinks.push(matches[2]);
      }

      // 다음 검색 시작 위치 업데이트
      lastIndex = titleIndex + title.length + (matches && matches[2] ? matches[2].length : 0);
      titleIndex = post.content.indexOf(title, lastIndex); // 다음 제목 위치 검색
    }
  });

  // 마지막 제목 이후의 내용 추가
  if (lastIndex < post.content.length) {
    contentWithLinks.push(post.content.substring(lastIndex));
  }

 

이제 잘 작동한다. 코드가 너무 길어진 것 같은데 그래도 해당 단어에만 링크가 잘 걸린다.

 

배포 후 디버깅

그렇게 기능 구현을 모두 잘 마쳤다고 생각하고 며칠 뒤에 배포 페이지에 들어갔다. 이미 과제는 제출한 상태..

오류를 발견했다..^_ㅠ 내용에 링크가 걸려야 할 제목이 여러개라면 링크가 맨 마지막 요소에만 걸리는 것이다. 확인하고 나는 멘붕이 왔다. 하지만 어쩔 수 없지. 지금이라도 수정하기로 했다.

 

 // 원본 content에서 모든 제목의 위치 정보 수집
  posts.forEach(({ id, title }) => {
    let index = post.content.indexOf(title);
    while (index !== -1) {
      titlesInfo.push({ index, length: title.length, id, title });
      index = post.content.indexOf(title, index + title.length);
    }
  });

  // 위치 정보를 바탕으로 contentWithLinks 배열 생성
  const contentWithLinks = [];
  let lastIndex = 0;

  // 위치 정보를 기준으로 정렬
  titlesInfo.sort((a, b) => a.index - b.index);

  titlesInfo.forEach(({ id, title, index, length }) => {
    // 이전 제목과 현재 제목 사이의 텍스트 추가
    if (index > lastIndex) {
      contentWithLinks.push(post.content.substring(lastIndex, index));
    }
    // 현재 제목에 대한 링크 생성
    contentWithLinks.push(
      <Link key={`${id}-${index}`} to={`/postDetail/${id}`}>
        {title}
      </Link>,
    );
    lastIndex = index + length;
  });

  // 마지막 제목 이후의 텍스트 추가
  if (lastIndex < post.content.length) {
    contentWithLinks.push(post.content.substring(lastIndex));
  }

 

이제 정말 정상적으로 동작한다. 진짜 트러블 슈팅 끝..!