PromleeBlog
sitemap
aboutMe

posting thumbnail
React 보안과 안정성 (XSS, CSRF 예방부터 OAuth, 서드파티 관리까지) - React, 알고 쓰자 12일차
React Security & Stability (XSS/CSRF Prevention, OAuth, 3rd Party Mgmt) - React Explained Day 12

📅

🚀

들어가기 전에 🔗

우리는 지금까지 React의 다양한 기능과 아키텍처, 성능 최적화 기법들에 대해 배웠습니다. 하지만 아무리 기능이 뛰어나고 빠른 애플리케이션이라도 보안이 취약하거나 안정적이지 못하다면 사용자들은 신뢰를 잃고 떠나갈 것입니다.
특히 사용자의 개인 정보나 금융 정보 등을 다루는 서비스라면 보안과 안정성은 더욱 중요합니다. 최근 SKT에서 일어난 유심 정보 유출 사건이나, 카카오의 서비스 장애와 같은 사례를 보면 알 수 있듯이, 보안과 안정성은 단순히 개발자의 선택이 아니라 기업의 생존과 직결되는 문제입니다.
프론트엔드 개발자도 서버 개발자만큼이나 보안과 안정성에 대한 책임감을 가지고 개발에 임해야 합니다.


오늘은 프론트엔드 개발 시 반드시 알아야 할 대표적인 보안 위협인
크로스 사이트 스크립팅(XSS)
사이트 간 요청 위조(CSRF)
를 예방하는 방법,
안전한 사용자 인증을 위한
OAuth 2.0/OIDC
플로우에서 프론트엔드의 역할, 그리고 코드 스플리팅이나 외부 라이브러리 사용 시
안정성
을 확보하는 방법에 대해 자세히 알아보겠습니다.

🚀

XSS (크로스 사이트 스크립팅) 예방하기 🔗

XSS는 공격자가 악의적인 JavaScript 코드를 웹 페이지에 삽입하여, 해당 페이지를 방문한 다른 사용자의 브라우저에서 그 코드가 실행되도록 만드는 공격입니다.
이를 통해 공격자는 사용자의 쿠키(세션 정보)를 탈취하거나, 개인 정보를 훔쳐보거나, 사용자의 의도와 다른 행동을 수행하게 만들 수 있습니다.

XSS 공격 예시 🔗

가장 흔한 경우는 사용자가 입력한 내용을 그대로 화면에 보여줄 때 발생합니다.
예를 들어, 사용자가 자신의 프로필 소개를 입력하고 이를 다른 사용자에게 보여주는 기능이 있다고 가정해 봅시다.

🖐️
이 코드는 잘못된 코드 예시입니다. 실습 용도로만 사용하세요.
사용자 입력을 HTML로 직접 렌더링하기 위해 dangerouslySetInnerHTML을 부주의하게 사용하는 경우입니다.
import React from "react";
 
function UserProfile({ bio }: { bio: string }) {
  // 사용자가 입력한 bio 내용을 HTML로 해석하여 그대로 삽입
  // 절대 이렇게 사용하면 안 됩니다!
  return (
    <div>
      <h3>소개</h3>
      <div dangerouslySetInnerHTML={{ __html: bio }} />
    </div>
  );
}
 
const UserProfilePage = () => {
  // 만약 공격자가 bio에 다음과 같은 악성 스크립트를 입력했다면?
  const bio =
    '안녕하세요! 저는 웹 개발자입니다. <script>alert("XSS 공격 성공! 쿠키: " + document.cookie);</script>';
  return (
    <div>
      <h1>사용자 프로필</h1>
      <UserProfile bio={bio} /> 
      {/* 이 컴포넌트가 렌더링되면, 다른 사용자의 브라우저에서 스크립트가 실행되어 경고창이 뜨고 쿠키 정보가 노출될 수 있습니다. */}
    </div>
  );
};
export default UserProfilePage;
dangerouslySetInnerHTML 렌더링 시
dangerouslySetInnerHTML 렌더링 시
위 예시처럼 악성 스크립트가 포함된 사용자 입력이 필터링 없이 dangerouslySetInnerHTML을 통해 렌더링되면, 해당 페이지를 보는 다른 사용자의 브라우저에서 그 스크립트가 실행되어 쿠키 정보 탈취 등 심각한 보안 문제로 이어질 수 있습니다.

XSS 예방 방법 🔗

다행히 React는 기본적인 XSS 공격을 방지하는 기능을 내장하고 있으며, 추가적인 방어 전략도 적용할 수 있습니다.
➡️

1. React의 자동 이스케이핑 활용 🔗

React는 JSX에서 중괄호 {} 안에 들어가는 모든 내용을 기본적으로 문자열로 취급하고, HTML 태그나 스크립트로 해석될 수 있는 특수 문자들(예: <, >, &)을 자동으로
이스케이프(escape)
처리합니다.
즉, <script> 태그를 문자열로 넣어도 브라우저는 이를 일반 텍스트로 인식하여 실행하지 않습니다.
import React from "react";
 
function UserProfile({ bio }: { bio: string }) {
  // 사용자가 입력한 bio 내용을 HTML로 해석하여 그대로 삽입
  // 절대 이렇게 사용하면 안 됩니다!
  return (
    <div>
      <h3>소개</h3>
      <div>{bio}</div>
    </div>
  );
}
 
const UserProfilePage = () => {
  // 만약 공격자가 bio에 다음과 같은 악성 스크립트를 입력했다면?
  const bio =
    '안녕하세요! 저는 웹 개발자입니다. <script>alert("XSS 공격 성공! 쿠키: " + document.cookie);</script>';
  return (
    <div>
      <UserProfile bio={bio} />
      {/* 이 컴포넌트가 렌더링되면, 다른 사용자의 브라우저에서 스크립트가 실행되어 경고창이 뜨고 쿠키 정보가 노출될 수 있습니다. */}
    </div>
  );
};
export default UserProfilePage;
문자열로 취급
문자열로 취급
따라서 가능한 한 dangerouslySetInnerHTML 사용을 피하고, 사용자 입력은 항상
JSX의 중괄호
{}를 통해 렌더링하는 것이 XSS 예방의 가장 기본적이고 중요한 원칙입니다.
➡️

2. dangerouslySetInnerHTML 사용 시 주의 🔗

이름 그대로 '위험하게' 사용될 수 있는 속성입니다.
정말로 HTML 콘텐츠를 직접 렌더링해야 하는 경우에만 사용해야 하며, 이때는 반드시 렌더링할 HTML 콘텐츠를 신뢰할 수 있거나, 사전에
입력 정제(Sanitization)
과정을 거쳐야 합니다.
➡️

3. 입력 정제 (Input Sanitization) 🔗

사용자로부터 입력받은 데이터나 외부 API로부터 받은 HTML 콘텐츠를 화면에 표시해야 할 때, 잠재적으로 위험한 스크립트나 속성을 제거하는 과정을
입력 정제
라고 합니다.
직접 정제 로직을 구현하는 것은 매우 어렵고 실수가 발생하기 쉬우므로, 검증된 라이브러리를 사용하는 것이 안전합니다.
➡️

4. 콘텐츠 보안 정책 (Content Security Policy, CSP) 🔗

CSP는 브라우저가 어떤 출처의 리소스(스크립트, 스타일, 이미지 등)를 로드하고 실행할 수 있는지 제어하는 HTTP 응답 헤더입니다.
서버에서 적절한 CSP 헤더를 설정하면, 예상치 못한 출처의 스크립트(XSS 공격 포함) 실행을 브라우저 레벨에서 차단할 수 있어 매우 효과적인 방어 수단입니다.
예를 들어,
Content-Security-Policy: script-src 'self'
헤더는 현재 도메인에서 로드된 스크립트만 실행하도록 허용합니다.

🚀

CSRF - 사이트 간 요청 위조 🔗

CSRF는 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행동(예: 비밀번호 변경, 글 삭제, 상품 주문)을 특정 웹사이트에 요청하게 만드는 공격입니다.
주로 사용자가 로그인된 상태에서 악성 스크립트가 포함된 다른 사이트나 이메일을 열었을 때 발생합니다.

CSRF 공격 예시 🔗

사용자가 mybank.com에 로그인되어 있는 상태라고 가정해 봅시다.
이때 사용자가 악의적인 웹사이트 evil.com을 방문합니다.
evil.com에는 다음과 같은 코드가 숨겨져 있을 수 있습니다.

🖐️
이 코드는 잘못된 코드 예시입니다. 실습 용도로만 사용하세요.
<!-- 사용자는 이 이미지를 보지만, 브라우저는 mybank.com으로 요청을 보낸다 -->
<img src="https://mybank.com/transfer?to=hacker&amount=1000000" width="1" height="1" />
 
<!-- 또는, 자동으로 제출되는 숨겨진 폼 -->
<form id="csrf-form" action="https://mybank.com/change-password" method="POST" style="display:none;">
  <input type="hidden" name="newPassword" value="hacked123" />
</form>
<script>document.getElementById('csrf-form').submit();</script>
사용자가 evil.com을 로드하면, 브라우저는 사용자의 mybank.com
로그인 쿠키를 자동으로 포함
하여 저 이미지 URL이나 폼 제출 요청을 mybank.com 서버로 보냅니다.
만약 mybank.com 서버가 이 요청이 정말 사용자가 의도한 것인지 검증하는 장치가 없다면, 공격자는 사용자의 계정으로 돈을 이체하거나 비밀번호를 변경하는 등의 악의적인 행동을 성공시킬 수 있습니다.

CSRF 예방 방법 🔗

CSRF 공격은 주로 서버 측에서 방어해야 하지만, 프론트엔드에서도 협력하여 방어 전략을 구현해야 합니다.
➡️

1. Synchronizer Token Pattern - CSRF 토큰 사용 🔗

가장 널리 사용되는 방법입니다.
CSRF 토큰 패턴 동작 흐름도
CSRF 토큰 패턴 동작 흐름도
➡️

2. SameSite 쿠키 속성 활용 🔗

최신 브라우저는 쿠키의 SameSite 속성을 지원하여 CSRF 공격을 완화하는 데 도움을 줍니다.
서버에서 중요한 세션 쿠키에 SameSite=Lax 또는 Strict 속성을 설정하는 것이 CSRF 예방에 큰 도움이 됩니다. 프론트엔드 개발자는 이 속성이 제대로 설정되어 있는지 확인하고 이해하는 것이 좋습니다.
➡️

3. 기타 방법 (보조 수단) 🔗

Q: "CSRF 공격이란 무엇이고 어떻게 예방할 수 있나요?"
A: 공격 원리, 예방책(CSRF 토큰, SameSite 쿠키)의 원리, 그리고 프론트엔드 개발자의 역할을 함께 설명

🚀

OAuth 2.0 / OIDC 🔗

최근 많은 웹 서비스는 자체적인 로그인 시스템 대신 구글, 페이스북, 카카오 등 외부 서비스의 계정을 이용한 소셜 로그인(간편 로그인) 기능을 제공합니다.
이러한 기능을 구현하는 데 표준적으로 사용되는 프로토콜이 바로
OAuth 2.0
OpenID Connect(OIDC)
입니다.
프론트엔드 개발자는 이 플로우에서 사용자를 외부 인증 서비스(인가 서버)로 안내하고, 인증/인가 결과를 안전하게 처리하여 최종적으로 사용자 로그인 상태를 관리하는 중요한 역할을 담당합니다.

인가 코드 플로우 (Authorization Code Flow with PKCE) 🔗

싱글 페이지 애플리케이션(SPA)과 같은 퍼블릭 클라이언트에서는 보안 강화를 위해
PKCE(Proof Key for Code Exchange)
확장이 추가된 인가 코드 플로우를 사용하는 것이 권장됩니다.
  1. 인가 요청 시작
    • 사용자가 '구글 로그인' 버튼 등을 클릭합니다.
    • 프론트엔드는
      code_verifier
      라는 임의의 비밀 문자열을 생성하고, 이를 해시(SHA256 등)하여
      code_challenge
      값을 만듭니다.
    • 구글 인가 서버의 URL로 사용자를 리디렉션 시킵니다. 이때 URL 파라미터에 다음 정보들을 포함합니다.
      • client_id: 사전에 등록된 우리 앱의 식별자.
      • redirect_uri: 인가 코드를 받을 우리 앱의 주소.
      • response_type=code: 인가 코드를 요청함을 의미.
      • scope: 요청할 사용자 정보 범위 (예: openid profile email).
      • code_challenge: 위에서 생성한 해시값.
      • code_challenge_method=S256: 해시 알고리즘 지정.
      • state: CSRF 공격 방지를 위한 임의의 문자열 (선택적이지만 권장).
  2. 인가 코드 수신
    • 사용자가 구글에서 로그인 및 권한 동의를 완료하면, 구글 인가 서버는 프론트엔드가 지정한 redirect_uri로 사용자를 다시 리디렉션 시킵니다.
    • 이때 리디렉션 URL의 쿼리 파라미터에
      인가 코드(authorization_code)
      와 함께 아까 보냈던 state 값이 포함되어 돌아옵니다.
    • 프론트엔드는 URL에서 인가 코드와 state 값을 추출합니다. (state 값이 일치하는지 확인하여 CSRF 방어)
  3. 토큰 요청 (백엔드 위임 권장)
    • 보안상 중요
      프론트엔드(브라우저)에서 직접 클라이언트 시크릿을 사용하여 토큰을 요청하는 것은 매우 위험합니다.
      따라서 인가 코드를 백엔드 서버(또는 BFF - Backend for Frontend)로 전달하고, 백엔드 서버가 인가 코드를 사용하여 인가 서버로부터
      Access Token
      Refresh Token
      을 받아오는 것이 일반적이고 안전한 방식입니다.
    • 프론트엔드는 추출한 인가 코드를 백엔드 API로 보냅니다. 이때 1단계에서 생성했던
      code_verifier
      값도 함께 보내야 합니다. (백엔드는 이 값과 인가 코드를 인가 서버로 보내 토큰을 요청합니다.)
  4. 토큰 수신 및 저장
    • 백엔드 서버는 성공적으로 토큰을 발급받으면, Access Token과 (필요하다면) Refresh Token을 프론트엔드에게 전달해 줍니다.
    • 프론트엔드는 이 토큰들을 안전하게 저장해야 합니다.
      • Access Token
        : 보통 만료 시간이 짧으며, API 요청 시 사용됩니다. 메모리(JavaScript 변수)에 저장하는 것이 비교적 안전합니다. LocalStorage나 SessionStorage는 XSS 공격에 취약할 수 있습니다.
      • Refresh Token
        : 만료 시간이 길며, Access Token이 만료되었을 때 새 Access Token을 발급받는 데 사용됩니다. 보안상 매우 민감하므로, 가능하다면 서버 측 세션과 연결된 HttpOnly, Secure, SameSite=Strict (또는 Lax) 속성의 쿠키에 저장하여 프론트엔드 JavaScript가 직접 접근할 수 없도록 하는 것이 가장 안전합니다. 이것이 어렵다면 메모리에 저장하고 다른 보안 수단을 강구해야 합니다.
  5. API 요청 시 토큰 사용
    • 보호된 API를 호출할 때마다 프론트엔드는 저장된 Access Token을 HTTP 요청 헤더의 Authorization 필드에 담아 보냅니다 (예: Authorization: Bearer YOUR_ACCESS_TOKEN).
  6. 토큰 갱신
    • API 요청 시 Access Token이 만료되었다는 응답(예: 401 Unauthorized)을 받으면, 프론트엔드는 (HttpOnly 쿠키에 저장된 경우 브라우저가 자동으로 보내거나, 메모리에 저장된 경우 직접) Refresh Token을 사용하여 백엔드에게 새 Access Token 발급을 요청합니다. 백엔드가 새 토큰을 발급해주면 이를 다시 저장하고 실패했던 API 요청을 재시도합니다.
  7. 로그아웃
    • 프론트엔드는 저장된 토큰(메모리, 쿠키 등)을 삭제합니다.
    • 필요하다면, 인가 서버의 로그아웃 엔드포인트로 사용자를 리디렉션하여 외부 서비스에서도 로그아웃 처리를 완료합니다.
OAuth 2.0 인가 코드 플로우
OAuth 2.0 인가 코드 플로우

Access Token 🔗

API 요청 시 사용자 인증 및 인가에 사용되는 토큰입니다.
만료 시간이 짧고, 보통 API 요청 시 Authorization 헤더에 담아 전송합니다.
JavaScript 변수 등
메모리
에 저장하는 것이 XSS 공격으로부터 비교적 안전합니다. 브라우저 저장소(LocalStorage, SessionStorage)는 스크립트로 접근 가능하여 XSS에 취약합니다.
// Zustand 스토어 예시
import create from 'zustand';
interface AuthState {
  accessToken: string | null;
  setAccessToken: (token: string | null) => void;
}
const useAuthStore = create<AuthState>((set) => ({
  accessToken: null,
  setAccessToken: (token) => set({ accessToken: token }),
}));
 
// 로그인 성공 후 토큰 저장
// const { setAccessToken } = useAuthStore.getState();
// setAccessToken(receivedAccessToken);
 
// API 요청 시 토큰 사용
// const accessToken = useAuthStore((state) => state.accessToken);
// axios.get('/api/protected', { headers: { Authorization: `Bearer ${accessToken}` } });

Refresh Token 🔗

Access Token이 만료되었을 때, 사용자의 재로그인 없이 새로운 Access Token을 발급받는 데 사용되는 토큰입니다.
만료 시간이 길고(수 일 ~ 수 개월), 보안상 매우 민감합니다. 탈취 시 보안 위험이 큽니다.
➡️

옵션 1: 서버 측 HttpOnly 쿠키 (가장 권장되는 방식) 🔗

백엔드가 토큰 응답 시 Refresh Token을 HttpOnly, Secure, SameSite=Strict (또는 Lax) 속성을 가진 쿠키로 설정하여 브라우저에 저장시킵니다.
HttpOnly 속성 때문에 클라이언트 JavaScript에서는 이 쿠키에 접근할 수 없어 XSS 공격으로 탈취가 불가능합니다.
Secure는 HTTPS에서만 쿠키가 전송되도록 하고, SameSite는 CSRF 공격을 방어합니다.
➡️

옵션 2: 메모리 저장 (차선책, 주의 필요) 🔗

Access Token과 마찬가지로 JavaScript 변수(상태 관리 라이브러리, Context API 등)에 저장합니다.
구현이 간단하고 CSRF에는 비교적 안전합니다.
브라우저 새로고침 시 토큰이 소실
되어 로그인이 풀립니다. 이를 해결하기 위해 LocalStorage/SessionStorage에 저장하는 경우가 있는데, 이는
XSS 공격에 매우 취약
하므로 Refresh Token 저장소로는 절대 권장되지 않습니다.
또한, 메모리에 저장된 토큰도 XSS 공격자가 특정 시점에 메모리 덤프 등을 시도할 경우 이론적으로 탈취 가능성이 존재합니다.

새로고침 문제 해결 시도 (덜 안전한 방법)
: 일부에서는 새로고침 시 로그인을 유지하기 위해 Refresh Token을 LocalStorage에 저장하고, 앱 시작 시 LocalStorage에서 읽어와 메모리에 로드하는 방식을 사용하기도 합니다.
하지만 이는 Refresh Token을 XSS 위험에 노출시키는 행위이므로 보안상 권장되지 않습니다. 만약 불가피하게 사용한다면 XSS 방어에 극도로 신경 써야 합니다.
👨‍💻
가능하다면 반드시 서버 측 HttpOnly 쿠키 방식을 사용하는 것이 Refresh Token을 안전하게 관리하는 최선의 방법입니다.
Q: "OAuth 2.0 인가 코드 플로우에서 프론트엔드의 역할은 무엇인가요?" 또는 "Access Token은 어디에 저장하는 것이 안전할까요?"
A: 플로우 각 단계별 역할과 토큰 저장소별 보안 고려사항(LocalStorage의 XSS 취약점, HttpOnly 쿠키의 CSRF 방어 이점 등)을 명확히 설명

🚀

코드 스플리팅과 서드파티 스크립트 안정성 🔗

애플리케이션의 안정성은 보안만큼이나 중요합니다.
코드 스플리팅이나 외부 라이브러리(서드파티 스크립트) 사용은 성능 향상이나 개발 편의성을 가져다주지만, 안정성 측면에서 새로운 고려사항을 야기하기도 합니다.

코드 스플리팅과 오류 격리 🔗

React.lazy 등을 이용한 코드 스플리팅은 초기 로딩 성능을 개선하는 주된 목적 외에도, 오류의 영향 범위를 제한하는 부수적인 효과를 기대할 수 있습니다.
만약 특정 페이지나 기능에 해당하는 코드 청크 로딩에 실패하거나 해당 청크 내에서 런타임 오류가 발생하더라도, 애플리케이션 전체가 중단되는 대신 Suspense의 fallback이나 ErrorBoundary를 통해 해당 부분만 오류 처리를 할 수 있는 가능성이 생깁니다. (물론, 공통 모듈 오류는 여전히 전체에 영향을 줄 수 있습니다.)

서드파티 스크립트의 위험성 🔗

분석 도구(Google Analytics), 광고 스크립트, 소셜 미디어 위젯, 외부 라이브러리 CDN 등 우리가 사용하는 많은 서드파티 스크립트는 편리함을 제공하지만 다음과 같은 위험을 내포합니다.

감시 및 관리 전략 🔗

이러한 위험을 줄이고 안정성을 높이기 위해 다음과 같은 전략을 고려할 수 있습니다.

🚀

결론 🔗

오늘은 React 애플리케이션의 보안과 안정성을 높이기 위한 중요한 개념과 방법들을 살펴보았습니다.
보안과 안정성은 한 번 설정하고 끝나는 것이 아니라, 지속적인 관심과 노력이 필요한 영역입니다.
새로운 취약점이 발견되고 기술이 변화함에 따라 우리의 방어 전략도 꾸준히 업데이트되어야 합니다.
무엇보다 중요한 것은 보안과 안정성을 개발 문화의 일부로 받아들이고, 설계 단계부터 배포, 운영에 이르기까지 모든 과정에서 항상 염두에 두는 자세일 것입니다.
다음 시간에는 React 개발 생산성과 코드 품질을 높여주는
개발자 도구와 테스트
방법에 대해 알아보겠습니다. React DevTools부터 테스트 라이브러리(Jest, React Testing Library), Storybook까지 다양한 도구들을 살펴보겠습니다.

참고 🔗