1. 내 브라우저에 불청객이 숨어있다: XSS (교차 사이트 스크립팅)
XSS (Cross-Site Scripting)는 쉽게 말해 "해커가 우리 웹사이트에 교묘하게 악성 자바스크립트 코드를 심어놓고, 다른 유저가 그 코드를 실행하게 만드는 공격"입니다.
가장 흔한 시나리오는 '자유게시판'입니다. 해커가 게시판에 글을 씁니다. 제목은 "강아지 사진 공유해요~" 인데, 본문에는 사진 대신 보이지 않는 HTML 코드를 적어놓습니다.
순진한 유저가 강아지 사진을 보려고 이 게시글을 클릭하는 순간! 브라우저는 저 스크립트 태그를 진짜 코드로 인식하고 실행해 버립니다. 유저의 브라우저 LocalStorage에 안전하게(?) 모셔두었던 피 같은 Access Token이 0.1초 만에 해커의 서버로 복사되어 날아갑니다. 이게 바로 우리가 4편에서 LocalStorage 저장을 그토록 말렸던 이유입니다.

🛡️ 모던 프레임워크(React, Vue)의 XSS 자동 방어막
"어? 저는 React로 개발하면서 저런 공격 한 번도 안 당해봤는데요?" 당연합니다. 다행히도 여러분이 쓰고 계신 React, Vue, Angular 같은 모던 프레임워크들은 기본적으로 강력한 XSS 방어막을 탑재하고 있기 때문입니다.
우리가 React에서 데이터 바인딩을 할 때 {post.content} 처럼 중괄호를 씁니다. 프레임워크는 변수 안에 저런 <script> 같은 태그 문자가 들어오면, 그걸 코드로 실행하지 않고 안전한 일반 문자열로 변환(치환)해 버립니다. (이 과정을 이스케이프(Escape) 처리라고 부릅니다.) < 기호는 <로, > 기호는 >로 바꿔서 화면에 그냥 태그 글자 그대로 출력해 버리죠. 해커의 스크립트가 먹통이 되는 겁니다.
🚨 하지만 주의하세요! (프론트엔드 주니어의 흔한 실수) React에서 에디터로 작성된 본문(태그가 포함된 HTML 문자열)을 화면에 예쁘게 렌더링하려고 dangerouslySetInnerHTML 이라는 무시무시한 이름의 속성을 쓰는 경우가 있습니다. (Vue에서는 v-html). 이 속성을 쓰는 순간 프레임워크의 XSS 방어막이 해제됩니다. 이름에 'dangerously(위험하게)'가 붙은 데는 다 이유가 있습니다. 이걸 쓸 때는 반드시 DOMPurify 같은 별도의 라이브러리로 해커의 악성 스크립트를 소독(Sanitize)한 뒤에 뿌려주어야 합니다!
2. 내가 하지 않은 요청을 브라우저가 몰래 보냈다: CSRF (사이트 간 요청 위조)
자, XSS 방어는 프레임워크가 알아서 해준다고 치고 한시름 놨습니다. 그런데 더 교활하고 무서운 녀석이 있습니다. 바로 CSRF (Cross-Site Request Forgery) 입니다.
CSRF 공격은 "브라우저가 서버로 요청을 보낼 때, 쿠키(Cookie)를 알아서 찰싹 붙여서 보내는 훌륭한(?) 성질"을 악용한 해킹입니다.
소름 돋는 은행 송금 시나리오를 상상해 볼까요?
- 여러분이 우리은행 웹사이트에 로그인했습니다. 브라우저에는 인증이 완료된 '세션 쿠키'가 들어있습니다.
- 탭을 닫지 않은 상태로, 해커가 보낸 스팸 메일의 "무료 아이패드 당첨 확인" 버튼을 클릭합니다.
- 그 버튼 안에는 숨겨진 요청 코드가 있었습니다.
- 이미지 태그는 브라우저가 화면을 그리기 위해 서버로 자동 요청(GET)을 보냅니다.
- 이때 브라우저는 생각합니다. "어? wooribank.com으로 가는 요청이네? 잠깐, 내 주머니에 아까 로그인한 우리은행 쿠키가 있는데? 예의 바르게 이것도 같이 붙여서 보내드려야지!"
우리은행 서버는 요청을 받습니다. 쿠키를 열어보니 완벽하게 로그인된 정상 유저입니다. 서버는 의심 없이 해커의 통장으로 100만 원을 송금해 버립니다. 유저는 아무것도 모른 채 말이죠. 이것이 CSRF의 무서움입니다.

3. 완벽한 CSRF 방어막: 토큰과 SameSite의 마법
이 끔찍한 공격을 막기 위해 과거부터 다양한 방법이 쓰였고, 최근에는 거의 완벽한 해결책이 등장했습니다.
🛡️ 전통적인 방어책: CSRF 토큰 (Synchronizer Token Pattern)
과거 백엔드 프레임워크(Spring Security, Django 등)에서 폼(Form) 전송 시 기본적으로 켜져 있는 기능입니다. 서버가 유저에게 페이지를 열어줄 때, 눈에 보이지 않는 무작위 난수(CSRF 토큰)를 하나 줍니다. 그리고 유저가 송금 요청을 보낼 때, 쿠키뿐만 아니라 폼 데이터 안에 이 'CSRF 토큰'을 같이 넣어서 보내라고 강제합니다. 해커는 유저의 쿠키가 자동으로 날아가게 할 수는 있어도, 서버가 발행한 무작위 난수(토큰) 값은 알 길이 없으니 공격에 실패하게 됩니다.
🛡️ 모던 웹의 완벽한 방패: 쿠키의 SameSite 속성 🌟
하지만 요즘처럼 프론트(React)와 백엔드(Spring)가 분리된 REST API 환경에서는 매번 CSRF 토큰을 주고받기가 꽤나 번거롭습니다. 그래서 갓(God) 구글 크롬을 비롯한 현대의 브라우저들은 쿠키 자체에 강력한 자물쇠를 달아버렸습니다. 백엔드에서 쿠키를 구워줄 때(Set-Cookie) 붙이는 SameSite 속성이 바로 그 주인공입니다.
이름 그대로 "같은 사이트(출처)에서 출발한 요청에만 쿠키를 실어 보낼게!" 라는 약속입니다.
- SameSite=Strict (절대 방어): 가장 빡빡한 규칙입니다. 정확히 같은 도메인에서 보낸 요청이 아니면 쿠키를 절대 보내지 않습니다. 해커 사이트에서 우리은행으로 요청을 보내면 브라우저가 쿠키를 빼고 보냅니다. CSRF는 100% 방어되지만, 외부 링크를 타고 들어왔을 때 로그인이 풀려버리는 등 유저가 엄청 불편해집니다.
- SameSite=Lax (합리적 타협 - 모던 브라우저 기본값): 대부분은 Strict처럼 막아주지만, "안전한 요청(GET 메서드로 페이지를 이동할 때 등)"에는 예외적으로 쿠키를 실어 보내줍니다. 덕분에 유저는 카카오톡에서 링크를 타고 들어와도 로그인이 유지되고, 해커의 폼 전송(POST) 공격은 훌륭하게 막아냅니다. 현재 대부분 브라우저의 디폴트 값입니다.
- SameSite=None (오픈 마인드): 아무 데서나 요청해도 쿠키를 다 보내줍니다. "어? 그럼 해킹당하잖아요!" 맞습니다. 하지만 우리가 5편에서 배운 소셜 로그인이나 크로스 도메인 환경에서는 꼭 필요할 때가 있습니다. (단, 이 옵션을 쓰려면 반드시 암호화된 Secure 옵션을 짝꿍으로 같이 써야만 브라우저가 허락해 줍니다.)
우리가 4편에서 "Refresh Token을 HttpOnly 쿠키에 저장하세요!" 라고 자신 있게 말할 수 있었던 이유가 바로, 이 SameSite=Lax 정책이 기본적으로 CSRF 공격을 든든하게 막아주고 있기 때문입니다.

마치며: 프론트와 백엔드, 모두의 숙제
오늘은 내 토큰을 직접 훔쳐 가는 XSS와, 내 신분을 위조해서 사고를 치는 CSRF 공격의 소름 돋는 원리와 방어법에 대해 알아보았습니다. 프론트엔드는 위험한 데이터 렌더링을 조심하고, 백엔드는 안전하게 쿠키 속성을 설정해 주는 완벽한 팀워크가 있어야만 사용자의 소중한 데이터를 지켜낼 수 있습니다.
"자, 이제 보안도 챙겼고 프론트와 백엔드가 사이좋게 데이터만 주고받으면 되겠네요!" 그런데, 백엔드 서버를 띄우고 프론트엔드에서 API를 호출하는 순간... 브라우저 콘솔 창이 시뻘겋게 물들며, 프론트엔드 개발자들의 영혼을 파괴하는 에러 메시지가 등장합니다.
Access to XMLHttpRequest at '...' from origin '...' has been blocked by CORS policy:
네, 바로 그 녀석입니다. 개발자들의 영원한 웬수, CORS! 다음 마지막 대단원의 [7편] 백엔드와 프론트엔드의 영원한 숙제: CORS 에러, 이제는 당황하지 않기 편에서 이 새빨간 에러를 어떻게 우아하게 해결하는지 그 비법을 전수해 드리겠습니다. 대망의 마지막 편에서 뵙겠습니다!
[실무 웹 인증과 보안 완벽 가이드 #07] 백엔드와 프론트엔드의 영원한 숙제: CORS 에러, 이제는 당
안녕하세요! 길고 길었던 [실무 웹 인증과 보안 완벽 가이드]의 대장정이 드디어 마지막 장에 도착했습니다. 지난 6편에서 우리는 해커들의 악랄한 공격(XSS, CSRF)을 막아내는 방법을 배웠습니다."
code-bricks.tistory.com