logo

DowanKim

5. 마크다운 라이브러리를 사용하는데, 링크가 새창에서 열리도록 하려면?

2025년 10월 20일

포트폴리오 사이트

트러블슈팅 : 마크다운 링크가 새 창에서 열리도록 설정하기

1. 문제 상황

블로그 시스템을 구현한 후, 프로젝트 설명과 블로그 글에 마크다운으로 작성된 링크가 있었습니다. 예를 들어:

이 프로젝트는 [GitHub 저장소](https://github.com/example/repo)에서 확인할 수 있습니다.

기본 동작:

  • 링크 클릭 시 현재 탭에서 이동
  • 블로그 글을 읽다가 외부 링크를 클릭하면 현재 페이지를 벗어남
  • 뒤로 가기로 돌아와야 함

문제점:

  • 사용자가 블로그 글을 읽다가 외부 링크를 클릭하면 현재 페이지를 벗어남
  • 프로젝트 설명에서 여러 링크를 확인하려면 계속 뒤로 가기를 눌러야 함
  • 사용자 경험 저하

해결 방향:

  • 마크다운 내 모든 링크를 새 창(target="_blank")에서 열도록 설정
  • 현재 페이지는 유지하고 새 탭에서 외부 링크 열기

2. ReactMarkdown의 components prop 이해하기

2.1 ReactMarkdown이란?

react-markdown은 마크다운 텍스트를 React 컴포넌트로 렌더링하는 라이브러리입니다.

기본 사용법:

import ReactMarkdown from 'react-markdown' <ReactMarkdown> # 제목 [링크](https://example.com) </ReactMarkdown>

이렇게 하면:

<h1>제목</h1> <a href="https://example.com">링크</a>

2.2 components prop의 역할

components prop으로 마크다운이 렌더링하는 HTML 요소를 커스터마이징할 수 있습니다.

기본 구조:

<ReactMarkdown components={{ // HTML 태그명을 키로 사용 h1: ({ children }) => <h1 className="custom-h1">{children}</h1>, p: ({ children }) => <p className="custom-p">{children}</p>, a: ({ href, children }) => <a href={href}>{children}</a>, // ... }} > {markdown} </ReactMarkdown>

각 키는 마크다운이 생성하는 HTML 태그와 대응됩니다:

  • h1, h2, h3 → 제목 태그
  • p → 문단 태그
  • a → 링크 태그
  • code → 코드 태그
  • pre → 코드 블록 태그
  • 등등

2.3 components prop의 타입

react-markdownComponents 타입을 제공합니다:

import { Components } from 'react-markdown' const components: Components = { a: ({ href, children, ...props }) => ( <a href={href} {...props}> {children} </a> ), }

각 컴포넌트는 해당 HTML 요소의 props를 받습니다:

  • a 컴포넌트: href, children, 기타 <a> 태그의 표준 props
  • h1 컴포넌트: children, 기타 <h1> 태그의 표준 props

3. 링크 컴포넌트 커스터마이징 구현

3.1 기본 구현

가장 간단한 방법:

<ReactMarkdown components={{ a: ({ ...props }) => ( <a {...props} target="_blank" rel="noopener noreferrer" /> ), }} > {content} </ReactMarkdown>

코드 설명:

  1. a 컴포넌트 정의

    a: ({ ...props }) => (
    • a는 마크다운의 링크([텍스트](URL))가 렌더링될 때 사용되는 컴포넌트
    • { ...props }로 ReactMarkdown이 전달하는 모든 props를 받음
    • href, children, className 등이 포함됨
  2. 기존 props 전달

    <a {...props} ... />
    • {...props}로 기존 props를 그대로 전달
    • href, children 등이 유지됨
  3. target="_blank" 추가

    target="_blank"
    • 새 탭에서 링크 열기
  4. rel="noopener noreferrer" 추가

    rel="noopener noreferrer"
    • 보안과 개인정보 보호를 위한 속성

3.2 rel="noopener noreferrer"의 중요성

보안 문제: target="_blank"만 사용할 때

// 위험한 코드 <a href="https://example.com" target="_blank">링크</a>

문제:

  • 새 창에서 열린 페이지가 window.opener로 원본 페이지에 접근 가능
  • 악성 사이트가 window.opener.location = '악성사이트'로 피싱 가능

예시:

// 악성 사이트의 코드 if (window.opener) { window.opener.location = 'https://phishing-site.com' }

해결: rel="noopener noreferrer"

// ✅ 안전한 코드 <a href="https://example.com" target="_blank" rel="noopener noreferrer">링크</a>

효과:

  • noopener: window.openernull로 설정하여 원본 페이지 접근 차단
  • noreferrer: Referer 헤더를 전송하지 않아 개인정보 보호

권장:

  • target="_blank" 사용 시 항상 rel="noopener noreferrer" 함께 사용

4. 실제 구현 코드

4.1 프로젝트 설명 페이지 (app/blog/[tag]/page.tsx)

import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' export default async function TagPage({ params }: PageProps) { const decodedTag = decodeURIComponent(params.tag) const project = await getProjectByTag(decodedTag) return ( <section className="min-h-screen bg-[#050a13] text-white pt-32 px-4 pb-16"> <div className="max-w-[1200px] mx-auto"> {project && project.description && ( <div className="bg-[#1b1e26] p-4 md:p-8 rounded-2xl border border-[rgba(3,232,249,0.2)] mb-12 leading-[1.8] overflow-hidden"> <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: ({ ...props }) => ( <a {...props} target="_blank" rel="noopener noreferrer" style={{ wordBreak: 'break-all', overflowWrap: 'anywhere' }} /> ), }} > {project.description} </ReactMarkdown> </div> )} </div> </section> ) }

코드 설명:

  1. remarkGfm 플러그인

    remarkPlugins={[remarkGfm]}
    • GitHub Flavored Markdown 지원
    • 테이블, 체크리스트, 취소선 등 지원
  2. 링크 컴포넌트 커스터마이징

    components={{ a: ({ ...props }) => ( <a {...props} target="_blank" rel="noopener noreferrer" style={{ wordBreak: 'break-all', overflowWrap: 'anywhere' }} /> ), }}
    • 모든 링크에 target="_blank"rel="noopener noreferrer" 적용
    • 긴 URL 줄바꿈을 위한 스타일 추가
  3. 긴 URL 처리 스타일

    style={{ wordBreak: 'break-all', overflowWrap: 'anywhere' }}
    • wordBreak: 'break-all': 단어 중간에서도 줄바꿈 허용
    • overflowWrap: 'anywhere': 컨테이너를 넘치지 않도록 어디서든 줄바꿈
    • 긴 URL이 레이아웃을 깨뜨리지 않도록 함

4.2 블로그 글 상세 페이지 (app/blog/[tag]/[slug]/page.tsx)

import ReactMarkdown, { Components } from 'react-markdown' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' export default async function BlogPostPage({ params }: PageProps) { const post = await getPostBySlug(decodedSlug) return ( <article className="min-h-screen bg-[#050a13] text-white pt-32 px-4 pb-16"> <div className="max-w-3xl mx-auto"> <div className="leading-[1.8] text-lg"> <ReactMarkdown rehypePlugins={[rehypeRaw]} remarkPlugins={[remarkGfm]} components={{ a: ({ ...props }) => ( <a {...props} target="_blank" rel="noopener noreferrer" /> ), code: ({ className, children, ...props }) => { // 코드 하이라이팅 로직... }, } satisfies Components} > {post.content} </ReactMarkdown> </div> </div> </article> ) }

코드 설명:

  1. rehypeRaw 플러그인

    rehypePlugins={[rehypeRaw]}
    • HTML 태그를 마크다운에서 렌더링할 수 있게 함
    • 예: <video>, <iframe>
  2. 타입 안전성

    } satisfies Components}
    • satisfies Components로 타입 체크
    • any 사용 없이 타입 안전성 확보
  3. 여러 컴포넌트 커스터마이징

    components={{ a: ({ ...props }) => (...), code: ({ className, children, ...props }) => (...), }}
    • 링크와 코드 블록을 동시에 커스터마이징
    • 각 컴포넌트는 독립적으로 정의

4.3 두 페이지의 차이점

프로젝트 설명 페이지:

  • wordBreak: 'break-all', overflowWrap: 'anywhere' 스타일 추가
  • 긴 URL이 레이아웃을 깨뜨리지 않도록 처리

블로그 글 상세 페이지:

  • 추가 스타일 없음
  • CSS 클래스로 스타일링 ([&_a]:text-[#03e8f9] 등)

이유:

  • 프로젝트 설명은 카드 형태로 공간이 제한적
  • 블로그 글은 전체 너비를 사용하므로 긴 URL 문제가 덜함

5. props 전달 방식 이해하기

5.1 { ...props }의 의미

a: ({ ...props }) => ( <a {...props} target="_blank" rel="noopener noreferrer" /> )

동작 과정:

  1. ReactMarkdown이 링크를 렌더링할 때:

    // ReactMarkdown 내부에서 생성하는 props { href: 'https://example.com', children: '링크 텍스트', className: 'markdown-link', // 있을 수도 있음 // 기타 표준 <a> 태그 props }
  2. 커스터마이징된 컴포넌트에서:

    a: ({ ...props }) => { // props = { href: '...', children: '...', className: '...' } return <a {...props} ... /> }
  3. 최종 렌더링:

    <a href="https://example.com" className="markdown-link" target="_blank" rel="noopener noreferrer" > 링크 텍스트 </a>

5.2 props 순서의 중요성

// 올바른 순서 <a {...props} target="_blank" rel="noopener noreferrer" />

이유:

  • {...props}를 먼저 전개하면 기존 props가 먼저 적용됨
  • 그 다음 targetrel을 덮어씀
  • 만약 propstarget이나 rel이 있어도 우리가 지정한 값으로 덮어씀
// 잘못된 순서 (의도하지 않은 동작 가능) <a target="_blank" rel="noopener noreferrer" {...props} />

문제:

  • {...props}가 나중에 오면 기존 targetrel을 덮어쓸 수 있음
  • ReactMarkdown이 전달하는 props에 target이나 rel이 있으면 우리가 설정한 값이 무시될 수 있음

6. 실제 사용 예시

6.1 마크다운 작성

프로젝트 설명이나 블로그 글에 다음과 같이 작성:

이 프로젝트는 다음 기술을 사용했습니다: - [React](https://react.dev) - [Next.js](https://nextjs.org) - [Firebase](https://firebase.google.com) 자세한 내용은 [GitHub 저장소](https://github.com/example/repo)에서 확인할 수 있습니다.

6.2 렌더링 결과

위 마크다운이 다음과 같이 렌더링됩니다:

<p>이 프로젝트는 다음 기술을 사용했습니다:</p> <ul> <li> <a href="https://react.dev" target="_blank" rel="noopener noreferrer"> React </a> </li> <li> <a href="https://nextjs.org" target="_blank" rel="noopener noreferrer"> Next.js </a> </li> <li> <a href="https://firebase.google.com" target="_blank" rel="noopener noreferrer"> Firebase </a> </li> </ul> <p> 자세한 내용은 <a href="https://github.com/example/repo" target="_blank" rel="noopener noreferrer"> GitHub 저장소 </a> 에서 확인할 수 있습니다. </p>

모든 링크가 target="_blank"rel="noopener noreferrer"를 포함합니다.

7. 추가 고려사항

7.1 내부 링크와 외부 링크 구분

현재 구현은 모든 링크를 새 창에서 엽니다. 내부 링크는 같은 탭에서 열고 외부 링크만 새 창에서 열고 싶다면:

components={{ a: ({ href, ...props }) => { const isExternal = href?.startsWith('http://') || href?.startsWith('https://') const isInternal = href?.startsWith('/') if (isExternal) { return ( <a {...props} href={href} target="_blank" rel="noopener noreferrer" /> ) } // 내부 링크는 Next.js Link 컴포넌트 사용 if (isInternal) { return ( <Link href={href} {...props}> {props.children} </Link> ) } return <a {...props} href={href} /> }, }}

이 프로젝트에서는 모든 링크를 새 창에서 열도록 통일했습니다.

7.2 접근성 고려사항

새 창에서 링크를 열 때 접근성을 위해 aria-label을 추가할 수 있습니다:

a: ({ ...props }) => ( <a {...props} target="_blank" rel="noopener noreferrer" aria-label={`${props.children} (새 창에서 열림)`} /> )

스크린 리더 사용자에게 새 창에서 열린다는 정보를 제공합니다.

7.3 긴 URL 처리 (프로젝트 설명 페이지)

프로젝트 설명 페이지에서는 긴 URL이 카드 레이아웃을 깨뜨릴 수 있어 추가 스타일을 적용했습니다:

style={{ wordBreak: 'break-all', overflowWrap: 'anywhere' }}

설명:

  1. wordBreak: 'break-all'

    • 단어 중간에서도 줄바꿈 허용
    • 긴 URL이 한 줄에 들어가지 않을 때 강제 줄바꿈
  2. overflowWrap: 'anywhere'

    • 컨테이너 너비를 넘치지 않도록 어디서든 줄바꿈
    • word-break보다 더 유연한 줄바꿈

예시:

원본: https://very-long-url-that-might-break-the-layout.com/path/to/resource
줄바꿈 후:
https://very-long-url-that-might-
break-the-layout.com/path/to/
resource

8. 마무리

마크다운 링크를 새 창에서 열도록 설정했습니다:

  1. ReactMarkdown의 components prop으로 링크 컴포넌트 커스터마이징
  2. target="_blank"로 새 창에서 열기
  3. rel="noopener noreferrer"로 보안 강화
  4. 프로젝트 설명 페이지에 긴 URL 처리 스타일 추가

결과:

  • 사용자가 블로그 글을 읽다가 외부 링크를 클릭해도 현재 페이지 유지
  • 여러 링크를 순서대로 확인 가능
  • 보안 문제 방지

단순하게 사용자 경험을 약간 증가시키는 것에 이렇게 많은 지식이 들어갑니다. 개발자는 항상 사용자 환경을 생각하고 이를 구현할 수 있어야 하지 않을까요...