이번 편의 결과물: index.html에 Content-Security-Policy를 적용해 위반을 콘솔에서 확인하고, 알림 권한이 거부되면 화면 안내(토스트)로 대체합니다. · 다루는 개념: CSP 지시어, 권한 요청 UX, origin 격리
이 편에서 만드는 파일
notes-app/
├── index.html (~ CSP 메타 태그 추가)
└── src/
├── render.js (~ showToast 추가)
└── main.js (~ 알림 거부 시 대체 동작 연결)개념 정리
Content Security Policy(CSP)
CSP는 브라우저에게 “이 페이지에서 허용할 리소스 출처”를 알려주는 정책입니다. <meta http-equiv="Content-Security-Policy">나 서버 응답 헤더로 지정합니다. 정책을 어기는 리소스는 로드가 막히고 콘솔에 위반 메시지가 뜹니다.
| 지시어 | 의미 |
|---|---|
default-src | 다른 지시어가 없을 때 적용되는 기본값 |
script-src | 스크립트를 불러올 수 있는 출처. 'self'만 두면 인라인 스크립트·eval이 막힘 |
img-src | 이미지 출처. data:를 추가해야 첨부 이미지의 dataUrl이 보임 |
connect-src | fetch/XMLHttpRequest가 접속할 수 있는 출처 |
notes-app은 17편의 첨부 이미지(data: URL)와 json-server(http://localhost:3001) 접속이 있으므로 이 둘을 명시적으로 허용해야 합니다.
origin과 격리
origin은 프로토콜 + 호스트 + 포트의 조합입니다. http://localhost:5173과 http://127.0.0.1:5173은 호스트 문자열이 달라 서로 다른 origin입니다. 브라우저는 localStorage, IndexedDB, 쿠키를 origin 단위로 완전히 분리해서 저장합니다. 다른 origin의 페이지는 이 데이터를 읽을 방법이 없습니다.
| 비교 대상 | 같은 origin 여부 |
|---|---|
http://localhost:5173 vs http://localhost:5173/notes/3 | 같음(경로는 origin에 포함 안 됨) |
http://localhost:5173 vs https://localhost:5173 | 다름(프로토콜) |
http://localhost:5173 vs http://localhost:3001 | 다름(포트) |
http://localhost:5173 vs http://127.0.0.1:5173 | 다름(호스트 문자열) |
권한 요청 UX
18편에서 Notification.requestPermission()을 썼습니다. 거부(denied) 상태에서는 다시 요청해도 팝업이 뜨지 않으므로, 앱은 권한이 없어도 핵심 기능(저장 완료 확인)을 사용자가 알 수 있는 대체 수단을 갖춰야 합니다.
실습
1. index.html에 CSP 메타 태그 추가
<!-- index.html의 head 안에 추가 -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data:; connect-src 'self' http://localhost:3001; script-src 'self'; style-src 'self' 'unsafe-inline';"
/>2. 위반 테스트
개발자 도구 콘솔에서 아래 코드를 실행합니다.
document.body.setAttribute('onclick', "alert('csp-test')");인라인 이벤트 핸들러는 script-src 'self' 정책에 걸려 실행되지 않고, 콘솔에 Refused to execute inline event handler 위반 메시지가 뜹니다.
3. src/render.js에 토스트 알림 함수 추가
// src/render.js 맨 아래에 추가
export function showToast(message) {
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
document.body.append(toast);
setTimeout(() => toast.remove(), 2000);
}4. src/main.js에서 권한 거부 시 대체 동작 연결
18편에서 만든 enableNotifBtn, notifySaved를 아래처럼 바꿉니다.
// src/main.js (18편의 enableNotifBtn 리스너와 notifySaved 함수만 교체)
import { showToast } from './render.js';
enableNotifBtn.addEventListener('click', async () => {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
enableNotifBtn.textContent = '알림 켜짐';
} else {
enableNotifBtn.textContent = '알림 거부됨';
showToast('알림 권한이 거부되어 화면 안내로 대체합니다');
}
});
function notifySaved(note) {
if (Notification.permission === 'granted') {
new Notification('메모 저장됨', { body: note.title });
} else {
showToast(`저장됨: ${note.title}`);
}
}5. 실행
npm run dev확인
- 콘솔에
Content-Security-Policy위반 로그 없이 앱이 정상 로드됩니다(이미지·json-server 접속 허용됨). - 2단계의 인라인 스크립트 테스트가 콘솔에 CSP 위반 메시지를 남기고 실행되지 않습니다.
- 알림 권한을 거부한 뒤 메모를 저장하면, 화면 하단에 “저장됨: 제목” 토스트가 2초간 보입니다.
http://localhost:5173과http://127.0.0.1:5173으로 각각 접속해 메모 목록이 서로 다르게(따로) 보이는 것을 확인합니다.
직접 해보기
connect-src에서http://localhost:3001을 빼고 08편의fetch동기화를 실행해 콘솔에 어떤 에러가 뜨는지 확인해봅니다.img-src에서data:를 빼고 첨부 이미지가 있는 메모를 새로고침해 무엇이 달라지는지 확인해봅니다.
정답 보기
connect-src에서 http://localhost:3001을 빼면 fetch가 net::ERR_BLOCKED_BY_CSP(콘솔 표시는 Refused to connect)로 실패합니다.
img-src에서 data:를 빼면 첨부 이미지가 안 뜨고 Refused to load the image 'data:image/...' 위반 메시지가 콘솔에 뜹니다. 두 경우 모두 정책 문자열에 필요한 값을 다시 추가하면 해결됩니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 첨부 이미지가 갑자기 안 보임 | CSP img-src에 data: 누락 | img-src 'self' data:로 명시 |
| json-server 동기화가 CSP 때문에 막힘 | connect-src에 서버 origin 누락 | connect-src에 http://localhost:3001 추가 |
인라인 style="..." 속성이 다 깨짐 | style-src에서 'unsafe-inline' 뺌 | 인라인 스타일을 쓴다면 유지, 가능하면 CSS 파일로 옮겨 제거 |
| 다른 포트에서 접속했더니 메모가 하나도 없음 | 정상 동작(origin이 달라 저장소가 분리됨) | 같은 origin(같은 호스트·포트)으로 접속 |