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-markdown은 Components 타입을 제공합니다:
import { Components } from 'react-markdown' const components: Components = { a: ({ href, children, ...props }) => ( <a href={href} {...props}> {children} </a> ), }
각 컴포넌트는 해당 HTML 요소의 props를 받습니다:
a컴포넌트:href,children, 기타<a>태그의 표준 propsh1컴포넌트:children, 기타<h1>태그의 표준 props
3. 링크 컴포넌트 커스터마이징 구현
3.1 기본 구현
가장 간단한 방법:
<ReactMarkdown components={{ a: ({ ...props }) => ( <a {...props} target="_blank" rel="noopener noreferrer" /> ), }} > {content} </ReactMarkdown>
코드 설명:
-
a컴포넌트 정의a: ({ ...props }) => (a는 마크다운의 링크([텍스트](URL))가 렌더링될 때 사용되는 컴포넌트{ ...props }로 ReactMarkdown이 전달하는 모든 props를 받음href,children,className등이 포함됨
-
기존 props 전달
<a {...props} ... />{...props}로 기존 props를 그대로 전달href,children등이 유지됨
-
target="_blank"추가target="_blank"- 새 탭에서 링크 열기
-
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.opener를null로 설정하여 원본 페이지 접근 차단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> ) }
코드 설명:
-
remarkGfm플러그인remarkPlugins={[remarkGfm]}- GitHub Flavored Markdown 지원
- 테이블, 체크리스트, 취소선 등 지원
-
링크 컴포넌트 커스터마이징
components={{ a: ({ ...props }) => ( <a {...props} target="_blank" rel="noopener noreferrer" style={{ wordBreak: 'break-all', overflowWrap: 'anywhere' }} /> ), }}- 모든 링크에
target="_blank"와rel="noopener noreferrer"적용 - 긴 URL 줄바꿈을 위한 스타일 추가
- 모든 링크에
-
긴 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> ) }
코드 설명:
-
rehypeRaw플러그인rehypePlugins={[rehypeRaw]}- HTML 태그를 마크다운에서 렌더링할 수 있게 함
- 예:
<video>,<iframe>등
-
타입 안전성
} satisfies Components}satisfies Components로 타입 체크any사용 없이 타입 안전성 확보
-
여러 컴포넌트 커스터마이징
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" /> )
동작 과정:
-
ReactMarkdown이 링크를 렌더링할 때:
// ReactMarkdown 내부에서 생성하는 props { href: 'https://example.com', children: '링크 텍스트', className: 'markdown-link', // 있을 수도 있음 // 기타 표준 <a> 태그 props } -
커스터마이징된 컴포넌트에서:
a: ({ ...props }) => { // props = { href: '...', children: '...', className: '...' } return <a {...props} ... /> } -
최종 렌더링:
<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가 먼저 적용됨- 그 다음
target과rel을 덮어씀 - 만약
props에target이나rel이 있어도 우리가 지정한 값으로 덮어씀
// 잘못된 순서 (의도하지 않은 동작 가능) <a target="_blank" rel="noopener noreferrer" {...props} />
문제:
{...props}가 나중에 오면 기존target과rel을 덮어쓸 수 있음- 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' }}
설명:
-
wordBreak: 'break-all'- 단어 중간에서도 줄바꿈 허용
- 긴 URL이 한 줄에 들어가지 않을 때 강제 줄바꿈
-
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. 마무리
마크다운 링크를 새 창에서 열도록 설정했습니다:
- ReactMarkdown의
componentsprop으로 링크 컴포넌트 커스터마이징 target="_blank"로 새 창에서 열기rel="noopener noreferrer"로 보안 강화- 프로젝트 설명 페이지에 긴 URL 처리 스타일 추가
결과:
- 사용자가 블로그 글을 읽다가 외부 링크를 클릭해도 현재 페이지 유지
- 여러 링크를 순서대로 확인 가능
- 보안 문제 방지
단순하게 사용자 경험을 약간 증가시키는 것에 이렇게 많은 지식이 들어갑니다. 개발자는 항상 사용자 환경을 생각하고 이를 구현할 수 있어야 하지 않을까요...