기능이 아무리 훌륭해도 보안이 뚫리면 사용자의 신뢰는 한순간에 무너집니다. 웹 개발자라면 OWASP Top 10에 등재된 주요 취약점을 이해하고 방어하는 방법을 반드시 알아야 합니다.

이 글에서는 웹 서비스에서 가장 자주 발생하는 XSS, CSRF, SQL 인젝션 세 가지 공격 방식과, 보안 헤더(CSP, HSTS, X-Frame-Options)를 활용한 심층 방어 전략을 실전 예제와 함께 살펴봅니다.

1. XSS (Cross-Site Scripting)

XSS는 공격자가 웹 페이지에 악성 스크립트를 삽입해 다른 사용자의 브라우저에서 실행시키는 공격입니다. 세션 쿠키 탈취, 피싱 페이지 리다이렉트, 키로깅 등에 악용됩니다.

공격 유형

  • 반사형(Reflected) — URL 파라미터에 스크립트를 넣어 서버가 그대로 반환하도록 유도합니다.
  • 저장형(Stored) — 게시판, 댓글 등에 스크립트를 저장해 다른 사용자가 열면 실행됩니다.
  • DOM 기반(DOM-based) — 서버를 거치지 않고 클라이언트 JavaScript가 DOM을 조작하는 과정에서 발생합니다.

취약한 코드와 방어

// 취약한 코드 — 사용자 입력을 그대로 innerHTML에 삽입
const userInput = location.search.slice(1); // ?<script>alert(1)</script>
document.getElementById('output').innerHTML = userInput; // XSS 발생!

// 안전한 코드 1 — textContent 사용 (HTML 파싱 없음)
document.getElementById('output').textContent = userInput;

// 안전한 코드 2 — DOMPurify로 HTML 새니타이징
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userInput);
document.getElementById('output').innerHTML = clean;

React, Vue, Angular 같은 현대 프레임워크는 기본적으로 출력을 이스케이프하지만, dangerouslySetInnerHTML(React)나 v-html(Vue) 같은 원시 HTML 삽입 기능을 사용할 때는 반드시 새니타이징이 필요합니다.

2. CSRF (Cross-Site Request Forgery)

CSRF는 인증된 사용자의 브라우저를 이용해 사용자 모르게 서버에 요청을 보내는 공격입니다. 예를 들어 사용자가 은행 사이트에 로그인된 상태에서 악성 사이트를 방문하면, 악성 사이트가 이체 요청을 몰래 발송할 수 있습니다.

CSRF 토큰으로 방어하기

// 서버 — Express 예시 (csurf 미들웨어 사용)
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });

app.get('/form', csrfProtection, (req, res) => {
  // 토큰을 뷰에 전달
  res.render('form', { csrfToken: req.csrfToken() });
});

app.post('/transfer', csrfProtection, (req, res) => {
  // 미들웨어가 자동으로 토큰 검증 — 불일치 시 403 반환
  processTransfer(req.body);
});
<!-- 클라이언트 — 폼에 CSRF 토큰 포함 -->
<form method="POST" action="/transfer">
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
  <input type="number" name="amount" placeholder="금액">
  <button type="submit">이체하기</button>
</form>

SameSite 쿠키 속성

현대 브라우저에서는 쿠키에 SameSite 속성을 설정하는 것만으로도 대부분의 CSRF를 방어할 수 있습니다.

// SameSite=Strict — 같은 사이트에서의 요청에만 쿠키 전송
res.cookie('session', token, {
  httpOnly: true,   // JS에서 접근 불가
  secure: true,     // HTTPS에서만 전송
  sameSite: 'Strict' // 외부 사이트에서 발생한 요청에는 쿠키 미포함
});

3. SQL 인젝션

SQL 인젝션은 사용자 입력을 SQL 쿼리에 직접 삽입할 때 발생합니다. 공격자는 쿼리 구조를 바꿔 데이터베이스 전체를 탈취하거나 데이터를 삭제할 수 있습니다.

// 취약한 코드 — 사용자 입력을 쿼리에 직접 연결
const username = req.body.username; // ' OR '1'='1
const query = `SELECT * FROM users WHERE username = '${username}'`;
// 위 쿼리는 'SELECT * FROM users WHERE username = '' OR '1'='1'' 이 됨
// 전체 users 테이블 반환!

// 안전한 코드 — Prepared Statement (파라미터 바인딩)
const [rows] = await db.execute(
  'SELECT * FROM users WHERE username = ?',
  [req.body.username] // DB 드라이버가 이스케이프 처리
);

// ORM 사용 시 (Prisma 예시)
const user = await prisma.user.findUnique({
  where: { username: req.body.username } // 자동으로 안전하게 처리
});

4. 보안 HTTP 헤더

응답 헤더 몇 줄만으로 많은 공격을 차단할 수 있습니다. helmet 미들웨어는 Express에서 이 헤더들을 한번에 설정합니다.

const helmet = require('helmet');

app.use(helmet()); // 기본 보안 헤더 모두 적용

// 개별 설정이 필요한 경우
app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],          // 같은 출처만 허용
      scriptSrc: ["'self'", "https://cdn.jsdelivr.net"], // 허용 CDN
      styleSrc:  ["'self'", "'unsafe-inline'"],
      imgSrc:    ["'self'", "data:", "https://images.unsplash.com"],
      connectSrc: ["'self'"],
      frameSrc:  ["'none'"],           // iframe 완전 차단
    },
  })
);

주요 보안 헤더 정리

  • Content-Security-Policy (CSP) — 허용된 출처의 스크립트·스타일·이미지만 로드합니다. XSS의 가장 강력한 방어선입니다.
  • Strict-Transport-Security (HSTS) — 브라우저가 해당 도메인에 항상 HTTPS로 접속하도록 강제합니다. max-age=31536000; includeSubDomains
  • X-Content-Type-Options: nosniff — 브라우저가 Content-Type을 임의로 추측하지 못하게 합니다.
  • X-Frame-Options: DENY — 클릭재킹(Clickjacking) 방어. 다른 사이트의 iframe에 삽입되지 않습니다.
  • Referrer-Policy: strict-origin-when-cross-origin — 외부 요청 시 URL 경로가 Referer 헤더에 노출되지 않습니다.

의존성 취약점 관리

직접 작성한 코드만큼 npm 패키지의 취약점도 중요합니다. 다음 명령으로 정기적으로 점검하세요.

# 취약한 패키지 확인
npm audit

# 자동으로 수정 가능한 취약점 패치
npm audit fix

# 의존성 업데이트 (호환성 확인 후 사용)
npx npm-check-updates -u && npm install
핵심 정리
웹 보안의 출발점은 세 가지 원칙입니다. ① 사용자 입력은 항상 불신하고 검증·이스케이프하라, ② 최소 권한 원칙을 지켜라, ③ 심층 방어(Defense in Depth)로 여러 레이어에서 막아라. CSP, HSTS, SameSite 쿠키, Prepared Statement만 제대로 적용해도 대부분의 일반적인 공격을 막을 수 있습니다.

보안 사고는 왜 반복될까 — 개발 프로세스 관점

같은 유형의 보안 취약점이 해마다 반복적으로 발견되는 이유는 기술의 문제가 아니라 프로세스의 문제인 경우가 많습니다. XSS나 SQL 인젝션 같은 취약점은 예방 방법이 이미 잘 알려져 있음에도 불구하고 계속 등장합니다. 주요 원인은 보안이 개발 마지막 단계에서만 점검되는 구조입니다. 기능 개발을 먼저 완성하고 배포 직전에 보안 검토를 하다 보면, 근본적인 설계 문제를 발견해도 수정 비용이 너무 커서 타협하게 됩니다. 이를 해결하려면 DevSecOps, 즉 개발 초기 단계부터 보안을 내재화하는 접근이 필요합니다. 코드 리뷰 체크리스트에 보안 항목을 추가하고, 자동화된 정적 분석(SAST) 도구를 CI 파이프라인에 연동하면 취약점을 조기에 발견할 수 있습니다.

개발자 보안 교육도 중요합니다. 개발자가 일반적인 공격 원리를 직접 이해하면 안전한 코드를 자연스럽게 작성하게 됩니다. OWASP WebGoat나 HackTheBox 같은 실습 환경을 활용하면 이론이 아닌 실제 해킹 경험을 통해 보안 감각을 키울 수 있습니다. 팀 전체가 공격자의 시각을 이해하는 것이 방어적 코딩 문화를 만드는 가장 효과적인 방법입니다.

서드파티 라이브러리 보안 관리

현대 웹 애플리케이션의 보안 취약점 중 상당수는 직접 작성한 코드가 아니라 사용 중인 외부 라이브러리에서 발생합니다. npm에 등록된 패키지 중 일부는 악의적인 코드가 포함되거나, 관리가 중단된 후 취약점이 패치되지 않는 상태로 방치되기도 합니다. 주기적으로 npm audit를 실행해 알려진 취약점을 가진 패키지를 확인하고, 심각도가 높은 것은 즉시 업데이트하거나 대안 패키지를 검토해야 합니다. 의존성 수를 최소화하는 것도 공격 표면을 줄이는 좋은 방법입니다. 단순한 기능 하나를 위해 대형 라이브러리를 도입하기보다, 직접 구현하거나 더 가벼운 대안을 선택하는 것이 장기적으로 유지보수와 보안 모두에 유리합니다. Dependabot이나 Snyk 같은 도구를 GitHub 레포지토리에 연동하면 새로운 취약점이 발견되었을 때 자동으로 알림을 받고 패치 PR을 생성할 수 있습니다.