웹 보안 모범 사례: 6개 계층별 실전 가이드 (2026)

Advertisement
웹 보안 모범 사례란 무엇인가요?
웹 보안 모범 사례는 웹사이트나 웹 애플리케이션이 탈취되거나, 변조(디페이스)되거나, 방문자를 공격하는 데 악용되거나, 데이터가 조용히 빠져나가는 일을 막는 보안 통제의 집합입니다. 제품 하나나 설정 하나로 끝나는 문제가 아닙니다. 도메인 레지스트라에서 시작해 DNS, TLS, 서버가 보내는 HTTP 헤더, 로그인과 입력을 처리하는 코드, 기반으로 삼는 서드파티 패키지, 서버 자체를 거쳐, 마지막으로 문제가 생겼을 때 알려 주는 로그까지 이어지는 일련의 결정입니다.
계층별로 생각해야 하는 이유는 공격자가 그렇게 생각하기 때문입니다. Verizon의 2026 데이터 침해 조사 보고서(DBIR)에 따르면 이제 침해 사고의 31%가 소프트웨어 취약점 악용에서 시작되며, 처음으로 탈취된 크리덴셜을 제치고 가장 흔한 침투 경로가 되었습니다. 또한 전체 침해 사고의 48%에 랜섬웨어가 관련되어 있습니다. 패치 하나를 빠뜨리거나, API 키 하나가 유출되거나, 속도 제한이 없는 관리자 페이지 하나만 있어도 충분합니다. 이 가이드에 나오는 보안 통제 중 특별히 까다로운 것은 없습니다. 침해당하는 사이트는 대개 한 계층을 다듬느라 다른 계층의 기본을 건너뛴 곳입니다.
이 가이드는 바깥쪽에서 안쪽 순서로 구성되어 있습니다. 각 항목은 왜 중요한지, 정확히 무엇을 설정해야 하는지, 명령어나 무료 도구로 어떻게 검증하는지를 설명합니다. HTTP 헤더 확인과 SSL 인증서 확인 도구에서 가장 흔히 발견되는 실패는 잘못된 결정이 아니라, 누군가 켜져 있다고 믿고 한 번도 확인하지 않은 설정이기 때문입니다.
웹 보안 스택: 보호해야 할 6개 계층
웹사이트에 대한 모든 공격은 6개 계층 중 하나를 노립니다. 아래 표는 이 가이드 전체의 지도입니다. 각 계층에 무엇이 있는지, 보통 어떻게 공격받는지, 방어가 실제로 적용되었는지 어떤 무료 검사로 확인하는지를 정리했습니다.
| 계층 | 공격자가 노리는 것 | 핵심 모범 사례 | 검증 방법 |
|---|---|---|---|
| 1. 도메인과 DNS | 도메인 탈취, 네임서버 변경, 방치된 서브도메인 탈취, 부정 인증서 발급 | 레지스트라 락, MFA, DNSSEC, CAA, 오래된 레코드 점검 | WHOIS 조회, DNS 조회, 서브도메인 검색 |
| 2. 전송 계층(TLS) | HTTP로 다운그레이드, 암호화 제거, 오래된 암호 스위트 악용, 만료된 인증서 | TLS 1.2 이상, preload를 적용한 HSTS, 자동 갱신 | SSL 인증서 확인 |
| 3. HTTP 헤더 | 스크립트 삽입(XSS), 클릭재킹용 프레임 삽입, 콘텐츠 유형 스니핑, 리퍼러 유출 | CSP, frame-ancestors, nosniff, Referrer-Policy, Permissions-Policy | HTTP 헤더 확인 |
| 4. 애플리케이션 | 크리덴셜 스터핑, 인젝션, 접근 제어 취약점, CSRF, 안전하지 않은 파일 업로드 | Argon2id + MFA + 속도 제한, 매개변수화된 쿼리, 서버 측 권한 검사, SameSite 쿠키 | 코드 리뷰, OWASP ZAP, 비밀번호 강도 테스트 |
| 5. 의존성 | 오염된 패키지, 라이브러리의 알려진 CVE, 침해된 CI 파이프라인 | 락파일, 감사, 버전 고정, SBOM, 최소 권한 토큰 | npm audit, Dependabot, Trivy |
| 6. 서버와 운영 | 열린 포트, 기본 크리덴셜, 누락된 패치, 백업 부재, 로그 부재 | 방화벽, 키 전용 SSH, 패치, 3-2-1 백업, 중앙 로깅 | 포트 확인, IP 블랙리스트 확인, 도메인 상태 검사기 |
Advertisement
1계층: 도메인과 DNS 잠그기
이 가이드의 나머지 내용은 모두 여러분이 여전히 도메인을 통제하고 있다는 전제에서 출발합니다. 공격자가 레지스트라에 로그인하거나 네임서버를 바꿀 수 있다면 트래픽을 원하는 곳으로 보내고, 여러분의 도메인 이름으로 유효한 인증서를 발급받고, 여러분에게 오는 모든 이메일을 읽을 수 있습니다. 그동안 여러분의 애플리케이션 코드는 단 한 줄도 실행되지 않습니다. 그래서 도메인과 DNS 보안이 첫 번째 웹 보안 모범 사례이며, 그 내용은 거의 전부 설정 작업입니다.
먼저 자신의 도메인으로 WHOIS 조회를 실행해 세 가지를 확인하세요. 상태 코드에 clientTransferProhibited가 포함되어 있는지, 만료일이 1년 이상 남았는지, 레지스트라가 실제로 여러분이 계정을 가진 곳인지입니다. 회사 도메인이 예전 외주 업체의 레지스트라 계정에 그대로 남아 있는 경우가 의외로 흔합니다. WHOIS 조회 가이드에서 조회 결과에 나오는 모든 상태 코드를 설명합니다.
레지스트라 락, MFA, 만료 알림
레지스트라 락(전송 잠금이라고도 함)을 활성화해 여러분이 명시적으로 잠금을 해제하지 않는 한 도메인을 다른 레지스트라로 옮길 수 없게 하고, 레지스트라 계정 자체에도 다단계 인증(MFA)을 켜세요. 로그인 하나가 그 아래의 모든 것을 통제하기 때문에 레지스트라 계정은 피싱 공격자가 가장 좋아하는 표적입니다. 레지스트라가 지원한다면 SMS 대신 하드웨어 키나 인증 앱을 사용하세요.
만료되지 않을 결제 수단으로 도메인 자동 갱신을 설정하고, 그래도 만료일 60일 전에 캘린더 알림을 따로 추가해 두세요. 만료된 도메인은 몇 시간 안에 드롭캐처(drop-catcher)에게 넘어가며, 새 소유자는 여러분의 이메일, 트래픽, 그리고 해당 도메인을 신뢰하는 모든 OAuth 로그인을 그대로 물려받습니다.
레지스트리 락(레지스트리 수준에서 별도 채널을 통해 수동으로 처리하는 잠금)은 비즈니스가 의존하는 도메인이라면 추가 비용을 들일 가치가 있습니다. 레지스트라 계정이 탈취되더라도 네임서버를 바꿀 수 없게 막아 줍니다.
역할을 분리하세요. 도메인 비용을 내는 사람, 도메인을 소유한 계정, DNS 제공업체를 각각 문서화하고, 특정 직원 한 명의 메일함에 묶이지 않게 하세요.
WHOIS 개인정보 보호를 켜서 연락처 정보가 피싱 공격의 지도가 되지 않게 하고, 계정에는
domains@같은 모니터링되는 역할 주소를 등록해 두세요.
DNSSEC로 영역 서명하기
DNSSEC는 DNS 영역(zone)에 서명해 리졸버가 위조된 응답을 탐지할 수 있게 합니다. DNSSEC가 없으면 리졸버의 캐시를 오염시킬 수 있는 공격자가 방문자를 가짜 서버로 보내도, 브라우저에는 여전히 여러분의 도메인 이름이 표시됩니다. 대부분의 관리형 DNS 제공업체(Cloudflare, Route 53, Google Cloud DNS, 그리고 많은 레지스트라)는 스위치 하나로 DNSSEC를 켤 수 있으며, 수동으로 해야 할 단계는 레지스트라에 DS 레코드를 게시하는 것뿐입니다. dig 명령이나 도메인 상태 검사기의 DNSSEC 검사로 확인하세요:
dig +dnssec +short yourdomain.com A
# A signed zone returns the record AND an RRSIG line:
# 203.0.113.10
# A 13 2 3600 20261015000000 20260924000000 34505 yourdomain.com. Kx3f...==
dig +short yourdomain.com DS
# A DS record at the parent zone proves the chain of trust is complete
# 34505 13 2 6A1B...C9CAA로 인증서 발급 제한하기
CAA 레코드(RFC 8659)는 어떤 인증 기관(CA)이 여러분의 도메인에 인증서를 발급할 수 있는지 지정합니다. 모든 공개 CA는 발급 전에 이 레코드를 반드시 확인해야 하므로, 다른 CA의 도메인 검증을 속여 넘긴 공격자도 CAA 레코드로 막을 수 있습니다. 일반적인 경우는 레코드 세 개로 충분합니다. 누가 발급할 수 있는지, 누가 와일드카드를 발급할 수 있는지, 거부된 요청을 어디로 보고할지입니다:
yourdomain.com. CAA 0 issue "letsencrypt.org"
yourdomain.com. CAA 0 issuewild ";"
yourdomain.com. CAA 0 iodef "mailto:security@yourdomain.com"오래된 레코드 점검: 서브도메인 탈취
서브도메인 탈취(subdomain takeover)는 DNS 레코드가 더 이상 쓰지 않는 서비스를 여전히 가리키고 있을 때 일어납니다. 삭제된 GitHub Pages 사이트, Azure 앱, S3 버킷, SaaS 업체의 호스트 이름을 가리키는 CNAME 같은 경우입니다. 누구든 버려진 대상을 등록하면 old-app.yourdomain.com에서 여러분의 이름으로, 그것도 유효한 인증서까지 갖춘 채 콘텐츠를 제공할 수 있습니다. 아무도 그 레코드가 있다는 사실을 기억하지 못하기 때문에 버그 바운티 프로그램에서 가장 흔한 발견 사항 중 하나입니다.
인증서 투명성(CT) 로그를 읽는 서브도메인 검색으로 도메인을 검사하면 지금까지 인증서가 발급된 모든 호스트 이름을 볼 수 있습니다. 그런 다음 DNS 조회로 하나씩 확인하고, 대상이 NXDOMAIN을 반환하거나 제공업체의 "no such app" 페이지를 보여 주는 레코드는 삭제하세요. 이 점검을 분기마다 반복하고, 서비스를 폐기할 때는 DNS 레코드 삭제를 절차에 포함하세요.
2계층: 제대로 구성한 HTTPS와 TLS
HTTPS는 더 이상 모범 사례가 아니라 기본입니다. 브라우저는 일반 HTTP 페이지를 "주의 요함"으로 표시합니다. 안전한 사이트와 단지 암호화만 된 사이트를 가르는 기준은 허용하는 TLS 버전, HTTP로 접속할 여지가 조금이라도 남아 있는지, 그리고 2026년 3월에 시작된 인증서 유효 기간 단축을 견딜 만큼 갱신이 자동화되어 있는지입니다.
Advertisement
TLS 1.2는 최소, TLS 1.3은 권장
TLS 1.0과 1.1은 2021년 RFC 8996으로 공식 폐기되었으며, 현재 어떤 브라우저도 이 버전으로 협상하지 않습니다. 서버에서 이 버전들을 비활성화하고, TLS 1.3(알려진 취약한 암호 스위트를 모두 제거하고 한 번의 왕복으로 핸드셰이크를 완료)을 우선 사용하며, TLS 1.2는 ECDHE-ECDSA-AES128-GCM-SHA256이나 ECDHE-RSA-CHACHA20-POLY1305 같은 AEAD 암호 스위트와 함께만 유지하세요.
외부에서 테스트할 때 중요한 줄은 두 가지입니다. 협상된 프로토콜, 그리고 전체 인증서 체인이 검증되었다는 뜻인 Verify return code: 0입니다. 중간 인증서 누락은 "Chrome에서는 되는데 curl과 Android에서는 실패한다"는 문의의 가장 흔한 원인입니다. SSL 인증서 체인 가이드에서 해결 방법을 확인할 수 있고, SSL 인증서 확인 도구는 불완전한 체인을 바로 표시합니다. 다음은 dnsrobot.net을 대상으로 실행한 검사 결과입니다:
echo | openssl s_client -connect dnsrobot.net:443 -servername dnsrobot.net 2>/dev/null \
| grep -E 'Protocol|Cipher|Verify return'
# Protocol : TLSv1.3
# Cipher : TLS_AES_256_GCM_SHA384
# Verify return code: 0 (ok)
# Confirm the old versions are refused:
openssl s_client -connect dnsrobot.net:443 -tls1_1 2>&1 | grep -iE 'alert|no protocols'HSTS: 브라우저가 다시는 HTTP를 쓰지 않게 하기
HTTP를 HTTPS로 리디렉션하는 것만으로는 충분하지 않습니다. 방문자가 http://yourdomain.com으로 보내는 첫 요청은 여전히 평문으로 전송되며, 같은 네트워크에 있는 공격자가 리디렉션보다 먼저 응답할 수 있습니다. HTTP Strict Transport Security(RFC 6797)가 이 문제를 해결합니다. 브라우저가 이 헤더를 한 번 보고 나면, 이후 여러분의 도메인으로 향하는 모든 http:// URL을 요청을 보내기 전에 https://로 바꿔 씁니다.
max-age는 최소 1년(31536000초)으로 설정하고, 모든 서브도메인이 HTTPS를 제공하게 되면 includeSubDomains를 추가한 다음, hstspreload.org에 도메인을 제출해 Chrome, Firefox, Safari, Edge에 하드코딩된 상태로 배포되게 하세요. 프리로드는 첫 방문의 공백을 완전히 없애 줍니다. HTTP 헤더 확인 도구로 헤더를 검증하세요. 이 도구는 A~F 보안 점수의 일부로 HSTS를 평가합니다.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload47일 인증서: 지금 갱신을 자동화하세요
CA/Browser Forum의 투표안 SC-081은 모든 공개 TLS 인증서의 최대 유효 기간을 단계적으로 줄이고 있습니다. 첫 번째 단축이 2026년 3월 15일에 시행되었기 때문에 오늘 구매하거나 갱신하는 인증서는 이미 200일로 제한되며, 2029년이 되면 인증서를 대략 6주마다 교체해야 합니다:
| 시행일 | 인증서 최대 유효 기간 | 도메인 검증 재사용 기간 |
|---|---|---|
| 2026년 3월 15일 이전 | 398일 | 398일 |
| 2026년 3월 15일 | 200일 | 200일 |
| 2027년 3월 15일 | 100일 | 100일 |
| 2029년 3월 15일 | 47일 | 10일 |
수동 갱신으로는 이 일정을 버틸 수 없습니다. 모범 사례는 ACME를 통한 완전 자동 발급입니다. certbot, acme.sh, Caddy로 Let's Encrypt나 Google Trust Services 인증서를 발급받거나, Cloudflare, Vercel, Netlify와 대부분의 호스팅 패널에 내장된 자동 인증서를 사용하세요. 필요해지기 전에 갱신 경로를 테스트하세요(certbot이라면 certbot renew --dry-run). 찾아야 할 실패 원인은 마지막 갱신 이후 추가된 CAA 레코드나 방화벽 규칙이 이제 검증을 막고 있는 경우입니다. 무엇을 사용하든 외부에서도 만료일을 모니터링하세요. SSL 인증서 확인 도구는 남은 일수를 보여 주며, 인증서가 만료되면 모든 방문이 전체 화면 브라우저 경고로 바뀝니다.
3계층: HTTP 보안 헤더
보안 헤더는 서버가 브라우저에게 페이지로 무엇을 해도 되고 무엇을 하면 안 되는지 알려 주는 지시입니다. 어떤 스크립트를 실행할 수 있는지, 페이지를 프레임 안에 넣을 수 있는지, 브라우저가 콘텐츠 유형을 추측해도 되는지, 방문자가 어디에서 왔는지를 다른 사이트에 얼마나 알려 줄지를 정합니다. 비용이 전혀 들지 않고, 모든 페이지에 한 번에 적용되며, 공격 유형 전체를 차단합니다. 동시에 대부분의 사이트가 일부를 잘못 설정하는 계층이기도 해서 HTTP 헤더 확인 도구가 이를 등급으로 평가합니다. 다음은 dnsrobot.net이 보내는 헤더 중 보안 헤더만 추린 것입니다:
curl -sI https://dnsrobot.net/ | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy'
strict-transport-security: max-age=31536000; includeSubDomains; preload
content-security-policy: default-src 'self'; script-src 'self' ... https://*.googletagmanager.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
x-frame-options: DENY
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(self), microphone=(), geolocation=(), payment=(), usb=()모든 사이트가 보내야 할 7가지 보안 헤더
일반적인 사이트가 목표로 삼아야 할 값입니다. 처음 다섯 개가 등급을 좌우하고, 나머지 두 개는 다른 헤더를 적용한 뒤 부담 없이 추가할 수 있는 항목입니다.
| 헤더 | 권장 값 | 막아 주는 공격 |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains; preload | 프로토콜 다운그레이드, HTTP를 통한 쿠키 탈취 |
Content-Security-Policy | 논스 기반 script-src('strict-dynamic' 포함); object-src 'none'; base-uri 'none'; frame-ancestors 'none' | 크로스 사이트 스크립팅(XSS), 데이터 삽입, 클릭재킹 |
X-Content-Type-Options | nosniff | 업로드된 파일을 실행 가능한 스크립트로 바꾸는 MIME 스니핑 |
Referrer-Policy | strict-origin-when-cross-origin | 전체 URL(토큰, 검색어)이 서드파티에 유출되는 것 |
Permissions-Policy | camera=(), microphone=(), geolocation=(), payment=() | 서드파티 스크립트가 강력한 브라우저 API를 몰래 사용하는 것 |
X-Frame-Options | DENY(frame-ancestors의 레거시 대체 수단) | CSP Level 2를 지원하지 않는 브라우저에서의 클릭재킹 |
Cross-Origin-Opener-Policy | same-origin | window.opener를 통한 교차 창 공격 |
사이트를 깨뜨리지 않고 Content Security Policy 적용하기
Content Security Policy는 XSS를 막는 가장 효과적인 단일 방어 수단입니다. 인젝션 버그가 있더라도 삽입된 스크립트가 실행되지 못하게 막기 때문입니다. 문제는 엄격한 정책이 여러분이 잊고 있던 인라인 스크립트나 서드파티 태그를 모두 깨뜨린다는 점입니다. Google이 권장하고 MDN에 문서화된 최신 방식은 논스(nonce) 기반 정책입니다. 서버가 응답마다 무작위 논스를 생성해 의도적으로 렌더링하는 모든 script 태그에 붙이고, 브라우저는 그 외의 스크립트를 모두 거부합니다.
정책을 실용적으로 만드는 것은 'strict-dynamic'입니다. 논스가 붙은 스크립트는 추가 스크립트(애널리틱스, 태그 관리자, 위젯)를 하나하나 허용 목록에 넣지 않아도 불러올 수 있으며, https:와 'unsafe-inline' 토큰은 최신 브라우저에서는 무시되고 오래된 브라우저를 위한 대체 수단으로만 쓰입니다. 먼저 Content-Security-Policy-Report-Only로 배포해 일주일 동안 위반 보고서를 지켜본 뒤 강제 모드로 전환하세요. Next.js, Rails, Django 같은 프레임워크는 논스를 자체 지원하며, 정적 사이트는 대신 해시 기반 script-src를 사용할 수 있습니다.
Content-Security-Policy:
script-src 'nonce-r4nd0m1z3dV4lu3' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
form-action 'self';
report-to csp-endpoint
<!-- Only scripts carrying the nonce run; strict-dynamic lets them load their own dependencies -->
<script nonce="r4nd0m1z3dV4lu3" src="/app.js"></script>클릭재킹 방어: frame-ancestors와 X-Frame-Options
클릭재킹은 여러분의 사이트를 공격자의 페이지 안에 보이지 않게 불러온 뒤, 방문자가 볼 수 없는 버튼을 클릭하도록 속이는 공격입니다. CSP의 frame-ancestors 'none'(자체 페이지를 임베드한다면 'self')이 모든 최신 브라우저에서 이를 막고, X-Frame-Options: DENY가 오래된 브라우저를 커버합니다. 의도적으로 임베드 가능한 위젯을 제공한다면 해당 경로만, 그리고 필요한 출처(origin)에 대해서만 예외를 두세요. X-Frame-Options 가이드에서 정확한 서버 규칙과 테스트 중에 마주칠 "refused to connect" 오류를 자세히 설명합니다.
nosniff, Referrer-Policy, Permissions-Policy, COOP
X-Content-Type-Options: nosniff는 브라우저가 Content-Type을 임의로 추측하지 못하게 합니다. 바로 이 추측 때문에 HTML이 담긴 사용자 업로드 "이미지"가 실행 가능한 페이지로 바뀝니다. Referrer-Policy: strict-origin-when-cross-origin(최신 브라우저의 기본값이지만 명시적으로 설정하세요)은 다른 사이트에 여러분의 출처만 보내므로, URL에 포함된 비밀번호 재설정 토큰과 검색어가 노출되지 않습니다. Permissions-Policy는 사용하지 않는 브라우저 기능을 비활성화해, 서드파티 스크립트가 침해되더라도 카메라를 켜거나 위치를 읽을 수 없게 합니다. Cross-Origin-Opener-Policy: same-origin은 여러분이 연 페이지와의 window.opener 연결을 끊어 교차 창 공격의 한 유형을 차단합니다.
제거해야 할 헤더도 중요합니다. Server와 X-Powered-By는 정확한 소프트웨어 버전을 취약점 스캐너에 광고하는 셈이고, X-XSS-Protection은 폐기되었으며 오래된 브라우저에서 버그를 일으킬 수 있으므로 0으로 설정하거나 아예 제거하세요. CMS 감지기를 사용하면 스캐너가 헤더만으로 여러분의 기술 스택에 대해 무엇을 알아낼 수 있는지 볼 수 있습니다.
4계층: 크리덴셜 스터핑을 견디는 인증
공격자가 비밀번호를 하나씩 추측하는 경우는 드뭅니다. 다른 사이트에서 유출된 수십억 개의 이메일·비밀번호 쌍을 그대로 대입하고(크리덴셜 스터핑), 나머지는 피싱으로 얻습니다. 따라서 인증 모범 사례는 세 가지로 이루어집니다. 데이터베이스가 유출되어도 비밀번호까지 유출되지 않도록 저장하고, 탈취된 비밀번호 하나만으로는 쓸모없게 만들고, 무차별 대입 공격이 의미 없을 만큼 느리게 만드는 것입니다. OWASP는 OWASP Top 10:2025에서 Authentication Failures(인증 실패)를 A07로 분류합니다.
Advertisement
Argon2id 또는 bcrypt로 비밀번호 해싱하기
비밀번호를 그대로 저장해서는 안 되며, 빠른 해시로 저장해서도 안 됩니다. MD5, SHA-1은 물론 SHA-256도 빠르게 동작하도록 설계되었기 때문에, 유출된 해시 테이블은 GPU 한 장으로 초당 수십억 번 대입해 볼 수 있습니다. 비밀번호마다 고유한 솔트를 사용하는 느리고 메모리 집약적인 비밀번호 해싱 함수를 사용하세요. OWASP Password Storage Cheat Sheet의 권장 순서는 다음과 같습니다:
Argon2id: 최소 19 MiB 메모리, 반복 2회, 병렬도 1(또는 46 MiB 메모리에 반복 1회).
scrypt: Argon2를 사용할 수 없는 경우 N = 2^17, r = 8, p = 1.
bcrypt: 작업 계수(work factor) 10 이상(72바이트 입력 제한에 주의하세요. 더 긴 비밀번호를 미리 해싱할 때는 신중해야 합니다).
PBKDF2-HMAC-SHA256: FIPS 규정 준수 때문에 불가피한 경우에만 600,000회 반복으로 사용.
// Node.js with the argon2 package
import argon2 from "argon2";
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // KiB = 19 MiB
timeCost: 2,
parallelism: 1,
});
// Later, on login:
const ok = await argon2.verify(hash, submittedPassword);실제로 도움이 되는 비밀번호 규칙 (NIST SP 800-63B)
대부분의 사이트가 여전히 강제하는 조합 규칙(대문자 하나, 특수문자 하나, 90일마다 변경)은 Summer2026! 같은 예측 가능한 비밀번호를 만들고 재사용을 부추깁니다. 최신 NIST SP 800-63B 지침은 이를 실제로 중요한 요소를 측정하는 규칙으로 대체합니다:
MFA를 함께 요구하는 경우 최소 8자, 비밀번호만 단독으로 쓰는 경우 최소 15자로 하고, 최대 길이는 64자 이상 허용하세요.
조합 규칙 없음, 주기적 강제 변경 없음. 침해 증거가 있을 때만 변경을 요구하세요.
모든 새 비밀번호를 유출 목록과 대조하고(Have I Been Pwned range API를 사용하면 비밀번호를 전송하지 않고도 확인할 수 있습니다), 이미 유출된 것으로 알려진 비밀번호는 거부하세요.
붙여넣기와 비밀번호 관리자를 허용하고, 공백과 유니코드를 받아들이며, 규칙이 아니라 엔트로피를 기준으로 한 강도 표시기를 보여 주세요.
비밀번호 강도 테스트를 사용하면 이런 기준이 옛 규칙과 어떻게 다른지 확인할 수 있습니다. 엔트로피와 패턴 탐지로 크래킹 시간을 추정하고 비밀번호를 유출 데이터와 대조하므로, 빨간색 "특수문자가 필요합니다" 메시지보다 훨씬 유용한 피드백을 줍니다.
MFA와 패스키
다단계 인증은 탈취된 비밀번호를 막다른 길로 만듭니다. 모든 사용자에게 제공하고, 관리자·재무·고객지원 역할에는 필수로 적용하세요. 선택지는 피싱 저항성을 기준으로 고르세요. 패스키와 FIDO2 보안 키는 크리덴셜이 실제 출처에 묶여 있어 피싱할 수 없습니다. 인증 앱(TOTP)은 좋은 선택입니다. SMS 코드는 없는 것보다는 낫지만 SIM 스와핑으로 가로챌 수 있습니다. 패스키는 모든 최신 브라우저와 운영체제에서 지원되며 비밀번호 자체를 없애므로, 크리덴셜 스터핑이라는 공격 유형도 함께 사라집니다.
복구 경로도 로그인만큼 신중하게 보호하세요. 복구 코드는 해시로 저장하고, 이메일 재설정에는 15분 안에 만료되는 일회용 토큰을 사용하며, 보안 질문은 쓰지 마세요.
속도 제한, 계정 잠금, 봇 방어
로그인, 회원가입, 비밀번호 재설정, MFA 엔드포인트에는 IP별 그리고 계정별 속도 제한을 적용하고, 한도에 도달하면 429 Too Many Requests를 Retry-After 헤더와 함께 반환하세요(HTTP 오류 429 가이드에서 클라이언트가 어떻게 대응해야 하는지 설명합니다). 한 계정에서 실패가 반복되면 점점 길어지는 지연이나 임시 잠금을 함께 적용하고, 분산 공격을 받는 엔드포인트에는 CAPTCHA나 작업 증명(proof-of-work) 챌린지를 추가하세요. 실패한 로그인은 모두 출발지 IP와 함께 기록해, 크리덴셜 스터핑 공격의 패턴을 몇 달이 아니라 몇 분 안에 파악할 수 있게 하세요.
# nginx: 5 login attempts per minute per client IP
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
location = /api/login {
limit_req zone=login burst=4 nodelay;
limit_req_status 429;
proxy_pass http://app;
}세션, 쿠키, CSRF
로그인한 뒤에는 세션 쿠키가 곧 사용자이므로, 비밀번호와 같은 수준으로 보호해야 합니다. 쿠키 속성 세 가지와 이름 접두사 하나가 대부분의 일을 해냅니다:
Secure속성이 있으면 쿠키가 평문 HTTP로는 절대 전송되지 않으므로, 공용 Wi-Fi에서 스니핑될 수 없습니다.HttpOnly속성은 쿠키를 JavaScript에서 보이지 않게 하므로, XSS 버그가 있어도 쿠키를 읽을 수 없습니다.SameSite=Lax(관리자 패널이라면Strict)는 크로스 사이트 POST 요청에 쿠키가 실리지 않게 해 대부분의 CSRF 공격을 무력화합니다.__Host-접두사를 붙이면 브라우저는 쿠키가Secure이고Path=/이며Domain속성이 없을 때만 쿠키를 받아들이므로, 서브도메인이 쿠키를 덮어쓸 수 없습니다.
Set-Cookie: __Host-session=9f1c7e2a...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=28800CSRF: SameSite 뒤의 두 번째 방어선
크로스 사이트 요청 위조(CSRF)는 로그인된 브라우저가 다른 페이지에서 여러분의 사이트로 상태 변경 요청을 보내도록 속이는 공격입니다. SameSite 쿠키가 일반적인 경우를 막아 주지만, GET이 아닌 모든 요청에는 두 번째 방어를 유지하세요. 숨겨진 필드나 사용자 정의 헤더에 담은 세션별 anti-CSRF 토큰, 또는 Origin(또는 Sec-Fetch-Site) 헤더가 여러분의 출처와 일치하는지 확인하는 검사입니다. 대부분의 프레임워크(Django, Rails, Laravel, Next.js 서버 액션)는 기본으로 이를 처리합니다. 흔한 실수는 API 엔드포인트에서 이 기능을 끈 뒤 그 엔드포인트를 브라우저에서 호출하는 것입니다.
세션 ID, ID 교체, JWT
세션 ID는 최소 128비트의 무작위성으로 생성하고, 세션 고정 공격을 막기 위해 로그인할 때와 권한이 바뀔 때마다 ID를 교체하고, 일정 시간 활동이 없으면 세션을 만료시키고, 서버 측에서 세션을 무효화하는 "모든 기기에서 로그아웃" 버튼을 제공하세요. JWT는 수명을 짧게 유지하고(몇 분 단위로, 폐기할 수 있는 리프레시 토큰과 함께), 어떤 스크립트든 읽을 수 있는 localStorage에는 절대 저장하지 마세요. 서명 키가 유출되면 그 키로 서명한 모든 토큰을 위조할 수 있게 되므로 완전한 침해로 간주해야 합니다.
인젝션과 XSS: 입력은 검증하고 출력은 인코딩하기
인젝션(A05:2025)과 크로스 사이트 스크립팅은 장소만 다를 뿐 같은 실수입니다. 사용자에게서 받은 데이터를 마치 코드인 것처럼 인터프리터(SQL, 셸, LDAP 쿼리, HTML 페이지)에 넘기는 것입니다. 해결책도 어디서나 같습니다. 데이터와 코드를 분리하고, 문자열을 이어 붙여 명령을 만들지 마세요.
Advertisement
문자열 조합 대신 매개변수화된 쿼리
SQL에는 프리페어드 스테이트먼트(prepared statement)나 이를 생성해 주는 ORM을 사용하세요. 그러면 데이터베이스는 입력을 쿼리 구조를 절대 바꿀 수 없는 값으로 취급합니다. 같은 규칙이 셸 명령(문자열이 아니라 인자 배열로 전달), NoSQL 필터(사용자 입력에 포함된 {"$gt": ""} 같은 연산자 객체는 거부), 파일 경로(경로를 해석한 뒤 의도한 디렉터리 안에 머무는지 확인)에도 적용됩니다.
// Vulnerable: the input becomes part of the query text
const rows = await db.query(`SELECT * FROM users WHERE email = '${email}'`);
// Safe: the driver sends the value separately from the query
const rows = await db.query("SELECT * FROM users WHERE email = $1", [email]);
// Python / psycopg: same idea
cur.execute("SELECT * FROM users WHERE email = %s", (email,))출력 인코딩으로 XSS 막기
크로스 사이트 스크립팅은 사용자가 제어하는 텍스트가 들어가는 위치(컨텍스트)에 맞게 인코딩되지 않은 채 페이지에 쓰일 때 발생합니다. 최신 템플릿 엔진(React와 JSX, Vue, Jinja2, Blade, ERB)은 기본적으로 인코딩합니다. 오늘날 XSS는 대개 이 보호를 우회하는 탈출구에서 생깁니다. dangerouslySetInnerHTML, v-html, |safe, innerHTML, 그리고 사용자 데이터로 URL이나 인라인 이벤트 핸들러를 만드는 경우입니다. 정확한 컨텍스트에 맞춰 인코딩하고, 리치 HTML은 정규식이 아니라 DOMPurify 같은 전용 라이브러리로 새니타이즈하세요.
| 데이터가 들어가는 위치 | 인코딩 방식 | 예시 |
|---|---|---|
| HTML 본문 | HTML 엔티티 | <는 <로, "는 "로 바뀜 |
| HTML 속성 | 속성용 엔티티(따옴표로 감싼 값) | 속성 값은 항상 따옴표로 감싸고 "와 '를 인코딩 |
| JavaScript | 스크립트 본문에 직접 삽입하지 말고 data- 속성이나 JSON <script type="application/json"> 블록으로 데이터를 전달 | 문자열 안의 </script>는 \u003c/script\u003e로 바뀜 |
| URL 매개변수 | 퍼센트 인코딩 | encodeURIComponent(value) |
| CSS 값 | 아예 사용하지 말고 허용 목록에서 클래스 이름을 선택 | 사용자 입력을 style에 절대 보간하지 않기 |
SSRF, 파일 업로드, 역직렬화
서버 측 요청 위조(SSRF): 서버가 사용자가 입력한 URL을 가져온다면(웹훅, 이미지 프록시, 링크 미리보기), 공격자는 그 요청을 내부 서비스나 클라우드 메타데이터 엔드포인트 169.254.169.254로 향하게 해 크리덴셜을 읽어 낼 수 있습니다. 먼저 호스트 이름을 해석하고, 사설 및 링크 로컬 대역은 거부하고, 리디렉션을 비활성화하고, URL을 가져오는 컴포넌트는 내부 어디에도 접근할 수 없는 네트워크 세그먼트에 두세요.
파일 업로드: 파일 유형은 확장자나 클라이언트가 보낸 Content-Type이 아니라 내용을 검사해 검증하세요. 크기 제한을 두고, 파일은 웹 루트 밖이나 오브젝트 스토리지에 직접 생성한 무작위 이름으로 저장하며, 가능하면 nosniff와 Content-Disposition: attachment를 적용한 별도 출처에서 제공하세요. 업로드된 파일이 웹 서버가 실행할 수 있는 위치에 놓이게 해서는 절대 안 됩니다.
역직렬화와 대량 할당(mass assignment): 임의의 클래스를 인스턴스화할 수 있는 형식(Java 네이티브 직렬화, Python pickle, PHP unserialize, 사용자 정의 태그를 쓰는 YAML)으로 신뢰할 수 없는 데이터를 역직렬화하지 마세요. 스키마를 갖춘 JSON을 사용하세요. 요청 본문은 명시적인 필드 허용 목록에만 바인딩해, 우연히 해당 컬럼이 있는 모델에 사용자가 "role": "admin"을 POST로 밀어 넣을 수 없게 하세요.
Broken Access Control: 가장 큰 위험
Broken Access Control(접근 제어 취약점)은 2021년부터 OWASP Top 10의 1위를 지켜 왔고, 2025년판에서도 A01:2025로 그 자리를 유지했습니다. 단일 버그가 아니라 습관의 문제입니다. 요청마다 상대가 누구인지(인증)는 확인하면서 무엇에 접근해도 되는지(권한 부여)는 확인하지 않는 것입니다. 전형적인 형태는 안전하지 않은 직접 객체 참조(IDOR)입니다. GET /api/invoices/1042는 청구서 소유자에게도 작동하지만, 숫자만 바꾼 다른 누구에게나 작동합니다.
기본값은 거부. 모든 라우트에는 접근을 허용하는 명시적인 규칙이 있어야 하며, 규칙이 없으면 200이 아니라 403을 반환해야 합니다.
서버에서, 객체 단위로 강제하세요. UI에서 버튼을 숨기는 것은 접근 제어가 아닙니다. 모든 읽기와 쓰기에서 현재 사용자가 해당 레코드를 소유하거나 접근 권한이 있는지 확인해야 하며, 일괄 처리 엔드포인트, 내보내기, 백그라운드 작업도 예외가 아닙니다.
도움이 된다면 추측할 수 없는 ID(UUID)를 사용하되, 그것에 의존하지는 마세요. 숨기는 것은 권한 부여가 아닙니다.
CORS를 잠그세요. 크리덴셜과 함께
Access-Control-Allow-Origin: *를 쓰거나, 요청이 보내는Origin을 그대로 반사하면 사용자가 방문하는 모든 웹사이트에 여러분의 API를 넘겨주는 것과 같습니다.디렉터리 목록을 비활성화하고, 웹 서버 수준에서
.git,.env, 백업 및 설정 파일을 차단하고, 관리자 패널은 공개 인터넷에서 분리하거나 MFA와 IP 허용 목록 뒤에 두세요.권한 부여 실패에 속도 제한을 걸고 기록하세요. 한 세션에서 403이 연달아 발생한다면 열거 공격이 진행 중이라는 뜻입니다.
// Express: ownership check on every object access
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
if (!invoice || invoice.ownerId !== req.user.id) {
return res.status(404).end(); // 404, not 403: don't confirm the record exists
}
res.json(invoice);
});5계층: 의존성과 소프트웨어 공급망
일반적인 웹 애플리케이션에서 직접 작성한 코드는 5% 정도이고 나머지 95%는 내려받은 코드입니다. 그래서 2025년 OWASP Top 10에 Software Supply Chain Failures(소프트웨어 공급망 실패)가 A03으로 처음 등장했고, 2026 DBIR에서는 취약점 악용이 탈취된 크리덴셜을 앞질렀습니다. 2025년 9월에는 피싱에 당한 메인테이너 한 명 때문에, 합쳐서 주간 다운로드가 20억 회를 넘는 chalk, debug 외 16개 npm 패키지에 암호화폐 탈취 악성 코드가 들어갔습니다. 해당 기간에 버전을 고정하지 않고 설치한 모든 프로젝트가 이를 자동으로 받아 갔습니다.
락파일을 커밋하고(
package-lock.json,yarn.lock,pnpm-lock.yaml,poetry.lock,go.sum) CI에서는npm ci로 설치해 빌드를 재현 가능하게 만들고, 새 업스트림 릴리스가 검토 없이 끼어들지 못하게 하세요.지속적으로 스캔하세요:
npm audit,pip-audit,bundler-audit, 업데이트용 GitHub Dependabot이나 Renovate, 그리고 OS 계층용 Trivy나 Grype 같은 컨테이너 스캐너를 사용합니다.보안과 무관한 업그레이드는 며칠 늦추세요(Renovate의
minimumReleaseAge). 오염된 릴리스는 대개 여러분이 도입하기 전에 회수됩니다. 단, 보안 패치는 즉시 적용하세요.CI에서 서드파티 액션과 스크립트를 커밋 SHA로 고정하고, 공개 CDN에서 불러오는 모든 스크립트에는 Subresource Integrity(SRI,
integrity="sha384-...")를 적용하세요.CI에는 최소 권한만 부여하세요: 가능한 곳에서는 읽기 전용 토큰을 쓰고, 오래 유지되는 시크릿 대신 수명이 짧은 OIDC 크리덴셜을 사용하며, 게시 권한은 릴리스 작업에만 부여하세요.
빌드 시점에 SBOM을 생성하세요(CycloneDX 또는 SPDX). 다음번 Log4Shell급 CVE가 터졌을 때 "우리도 영향을 받는가?"라는 질문에 몇 분 안에 답할 수 있습니다.
# Reproducible install + audit in CI
npm ci --ignore-scripts
npm audit --audit-level=high
# Pin a GitHub Action to a commit, not a floating tag
# uses: actions/checkout@v4 <- can change underneath you
# uses: actions/checkout@<full-commit-sha> # v4.2.2
# Subresource Integrity for a CDN script (hash from: openssl dgst -sha384 -binary lib.min.js | openssl base64 -A)
<script src="https://cdn.example.com/lib.min.js"
integrity="sha384-<base64-hash>"
crossorigin="anonymous"></script>WordPress나 다른 CMS를 운영한다면 플러그인과 테마에도 같은 규칙이 적용되며, CMS 침해는 대부분 여기서 시작됩니다. 코어와 모든 플러그인을 최신 상태로 유지하고, 쓰지 않는 것은 삭제하고, CMS 감지기로 스캐너가 여러분의 버전에 대해 무엇을 알아낼 수 있는지 확인하세요.
6계층: 서버 강화(포트, 패치, 시크릿, 백업)
그 아래의 서버가 SSH 비밀번호 로그인을 허용하거나, 데이터베이스를 공개 포트에서 실행하거나, 구축 이후 한 번도 패치되지 않았다면 애플리케이션 계층의 보안은 아무 의미가 없습니다. 서버 모범 사례는 지루하지만, 랜섬웨어가 들어오는 곳이 바로 여기입니다.
서비스하지 않는 포트는 모두 닫기
공개 웹 서버에 열려 있어야 할 것은 80번과 443번 포트, 그리고 자신의 IP 대역에서 접속하는 SSH뿐입니다. 데이터베이스(3306, 5432, 27017), Redis(6379), Elasticsearch(9200), 관리자 패널, 메트릭 엔드포인트는 localhost나 사설 네트워크에만 바인딩해야 합니다. 변경할 때마다 포트 확인 도구로 외부에서 점검하세요. 그것이 공격자가 보는 모습이며, 클라우드 보안 그룹 설정은 잘못 읽기 쉽습니다.
# Ubuntu: default-deny firewall, allow only web + SSH from your office range
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow from 203.0.113.0/24 to any port 22 proto tcp
sudo ufw enable
# SSH: keys only, no root password login
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
sudo systemctl reload ssh정기적인 패치, 시크릿은 코드 밖에
운영체제의 무인 보안 업데이트를 활성화하고, 사용하는 런타임과 프레임워크의 보안 메일링 리스트를 구독하고, 인터넷에 노출된 구성 요소의 치명적인 CVE는 당일에 처리할 작업으로 다루세요. 서비스는 권한 없는 사용자로, 컨테이너 안에서 또는 systemd 샌드박싱을 적용해 실행하세요. 그래야 프로세스 하나가 침해되어도 시스템 전체를 읽을 수 없습니다.
시크릿(데이터베이스 비밀번호, API 키, 서명 키)은 저장소, Docker 이미지 레이어, 클라이언트 측 JavaScript 어디에도 있어서는 안 됩니다. 배포 시점에 주입되는 환경 변수나 시크릿 관리자에서 불러오고, 접근 권한이 있던 사람이 떠나면 교체하고, gitleaks 같은 도구로 저장소 기록을 스캔하세요. 한 번이라도 커밋된 키는 커밋을 삭제한 뒤에도 영원히 노출된 것으로 봐야 하기 때문입니다.
랜섬웨어에도 살아남는 백업, 그리고 앞단의 CDN
3-2-1 규칙을 따르세요. 사본 3개를 서로 다른 매체 2종에 보관하고 그중 1개는 외부에 두며, 서버에 도달한 랜섬웨어가 백업까지 암호화하지 못하도록 최소 1개는 변경 불가능(immutable)하거나 오프라인 상태로 두세요. 백업은 암호화하고, 데이터베이스와 업로드 디렉터리를 포함하며, 무엇보다 정기적으로 복원을 테스트하세요. 아무도 복원해 보지 않은 백업은 계획이 아니라 희망일 뿐입니다. 복구 시간이 사고를 단순한 서비스 중단으로 끝낼지, 회사의 존립을 위협하는 사태로 키울지를 결정합니다.
오리진 앞단에 CDN이나 WAF(Cloudflare, Fastly, WAF를 적용한 AWS CloudFront, 또는 호스팅 업체의 동등한 서비스)를 두세요. 대용량 DDoS를 흡수하고, 흔한 인젝션 페이로드와 악성 봇에 대한 관리형 규칙을 적용하고, 오리진 IP를 숨기며, 애플리케이션을 건드리지 않고 속도 제한과 지역 규칙을 적용할 수 있는 지점이 되어 줍니다. 그런 다음 오리진 방화벽을 제한해 CDN의 IP 대역만 443번 포트에 직접 접근할 수 있게 하세요. 그렇지 않으면 공격자는 CDN을 그냥 우회합니다. 호스팅 확인 도구는 사이트의 실제 오리진이 CDN 뒤에서 노출되어 있는지 보여 줍니다.
이메일 인증: SPF, DKIM, DMARC
웹 보안에는 비밀번호 재설정, 청구서, 고객지원 답장을 전달하는 이메일도 포함됩니다. SPF, DKIM, DMARC가 없으면 누구나 billing@yourdomain.com 명의로 메일을 보낼 수 있으며, 2024년 2월부터 Google과 Yahoo는 대량 발송자에게 세 가지를 모두 요구하고 있습니다. 전체 구성은 DNS TXT 레코드 세 개입니다:
yourdomain.com. TXT "v=spf1 include:_spf.google.com -all"
google._domainkey.yourdomain.com. TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."
_dmarc.yourdomain.com. TXT "v=DMARC1; p=reject; rua=mailto:dmarc@yourdomain.com; adkim=s; aspf=s"DMARC는 p=none으로 시작해 보고서를 수집하고, 모든 정상 발신자(마케팅 플랫폼, 헬프데스크, CRM)가 정렬(alignment)을 통과하면 p=quarantine, 그다음 p=reject로 올리세요. MTA-STS와 TLS-RPT 레코드를 추가해 메일 서버로의 암호화 전송을 강제하고, 메일을 보내지 않는 도메인에는 null MX(MX 0 .)를 게시해 아예 스푸핑할 수 없게 하세요. 각 레코드는 SPF 레코드 확인, DKIM 확인, DMARC 확인으로 검증하고, 전달률이 떨어지면 IP 블랙리스트 확인으로 발송 IP가 차단 목록에 올라 있는지 확인하세요.
로그, 모니터링, 그리고 침해 대비
OWASP 2025의 10개 카테고리 중 두 개는 실수가 일어난 뒤의 일에 관한 것입니다. Security Logging and Alerting Failures(보안 로깅 및 경보 실패, A09)와 새로 추가된 Mishandling of Exceptional Conditions(예외 상황 처리 미흡, A10)입니다. 전형적인 침해는 증거가 기록되지 않았거나, 기록되었지만 아무도 보지 않았기 때문에 여전히 몇 달 동안 발견되지 않습니다.
맥락과 함께 보안 이벤트를 기록하세요: 모든 로그인 성공과 실패, MFA 변경, 비밀번호 재설정, 권한 변경, 접근 제어 거부, 입력 검증 실패, 관리자 작업을 타임스탬프, 사용자 ID, 출발지 IP, 사용자 에이전트와 함께 남기세요.
시크릿은 절대 로그에 남기지 마세요: 비밀번호, 세션 토큰, 전체 카드 번호, API 키가 여기에 해당합니다. 호출하는 곳마다가 아니라 로깅 계층에서 마스킹하세요.
로그를 서버 밖으로 보내세요: 최소 90일 보존 기간을 둔 중앙 저장소(CloudWatch, Loki, Elastic, SIEM)로 전송하면, root 권한을 얻은 공격자도 흔적을 지울 수 없습니다.
단일 이벤트가 아니라 패턴에 경보를 거세요: 1분 동안 50회의 로그인 실패, 한 세션에서 급증한 403, 업무 시간 외에 생성된 새 관리자 계정, 요청하지 않았는데 여러분의 도메인으로 발급된 인증서 같은 패턴입니다.
실패 시 차단(fail closed)하세요: 하위 검사(인증 서비스, 속도 제한기, WAF)에서 오류가 나면 요청을 거부하세요. A10이 존재하는 이유는 차단했어야 할 검사가 예외를 던질 때 접근을 허용해 버리는 시스템이 그만큼 많기 때문입니다.
외부에서 모니터링하세요: 가동 시간, 인증서 만료, DNS 변경, 차단 목록 등재, 헤더 회귀를 확인합니다. 도메인 상태 검사기는 DNS, SSL, 이메일 인증, 헤더 검사를 하나의 점수로 묶어 주므로 배포할 때마다 다시 실행할 수 있습니다.
필요해지기 전에 사고 대응 계획 작성하기
누가 비상 대기하는지, 모든 크리덴셜을 어떻게 교체하는지, 사이트를 어떻게 읽기 전용으로 전환하는지, 깨끗한 백업이 어디에 있는지, 어느 규제 기관이나 고객에게 몇 시간 안에 통지해야 하는지(GDPR은 72시간)를 미리 정해 두세요. 1년에 한 번은 모의 훈련(테이블톱 연습)을 실시하세요. 계획을 연습해 둔 팀은 며칠 만에 복구하지만, 계획이 없는 팀은 몇 주 동안 즉흥적으로 대응합니다.
테스트: 스캐너, 모의 해킹, 지속적인 점검
위의 모든 보안 통제는 검증할 수 있으며, 시간이 지나도 무너지지 않도록 가능한 한 검증을 자동화해야 합니다. 무료로 즉시 할 수 있는 것부터 주기적인 유료 점검까지, 합리적인 단계는 다음과 같습니다:
| 점검 항목 | 도구 | 주기 |
|---|---|---|
| TLS 설정, 체인, 만료 | SSL 인증서 확인, SSL Labs, testssl.sh | 인증서나 서버를 변경할 때마다, 만료는 매일 |
| 보안 헤더와 CSP | HTTP 헤더 확인, MDN HTTP Observatory | 배포할 때마다(CI에 포함) |
| 열린 포트 | 포트 확인, nmap | 방화벽이나 클라우드 설정을 변경할 때마다 |
| DNS, DNSSEC, 이메일 인증 | 도메인 상태 검사기, DMARC 확인 | 매월, 그리고 DNS를 수정할 때마다 |
| 방치된 서브도메인 | 서브도메인 검색 | 분기마다, 그리고 서비스를 폐기할 때마다 |
| 알려진 취약점이 있는 의존성 | npm audit, Dependabot, Trivy | 빌드할 때마다 |
| 코드 수준 결함(SAST) | Semgrep, CodeQL | 풀 리퀘스트마다 |
| 런타임 웹 취약점(DAST) | OWASP ZAP, Nuclei | 매주 스테이징 환경 대상 |
| 권한 부여와 비즈니스 로직 | 수동 모의 해킹 또는 버그 바운티 프로그램 | 매년, 그리고 주요 기능 출시 후 |
스캐너는 인젝션, 헤더, 오래된 구성 요소는 잘 찾지만 권한 부여와 비즈니스 로직에는 약합니다. 따라서 금전이나 개인정보를 다루는 서비스라면 1년에 최소 한 번은 사람이 직접 수행하는 테스트 예산을 확보하세요. 수정 순서는 노출 정도로 정하세요. 인증되지 않은 사용자가 인터넷에서 악용할 수 있는 문제가 가장 먼저입니다.
웹 보안 체크리스트
이 표를 프로젝트의 README나 티켓 트래커에 복사해 두세요. 각 줄은 위에서 다룬 모범 사례 하나이며, 완료 여부를 표시할 수 있도록 작성했습니다. 마지막 열이 완료의 증거입니다.
| 계층 | 모범 사례 | 완료 기준 |
|---|---|---|
| 도메인 | 레지스트라 락과 레지스트라 계정 MFA 적용, 자동 갱신 켜짐 | WHOIS에 clientTransferProhibited 표시, 만료일까지 12개월 이상 |
| DNS | DNSSEC 서명, 사용하는 CA만 나열한 CAA 레코드 | dig +dnssec 결과에 RRSIG 반환, 상위 영역에 DS 레코드 존재 |
| DNS | 방치된 CNAME 또는 A 레코드 없음 | 서브도메인 검색에서 찾은 모든 호스트 이름이 직접 통제하는 대상으로 해석됨 |
| TLS | TLS 1.2 이상만 허용, TLS 1.3 우선, 전체 체인 제공 | SSL 인증서 확인 결과: 체인 완전, TLS 1.0/1.1 거부 |
| TLS | max-age 1년, includeSubDomains, 프리로드를 적용한 HSTS | hstspreload.org에 등재됨 |
| TLS | ACME로 갱신 자동화, 만료 모니터링 | certbot renew --dry-run 통과, 만료 14일 전 알림 설정 |
| 헤더 | CSP(논스 기반), frame-ancestors, nosniff, Referrer-Policy, Permissions-Policy | HTTP 헤더 확인 등급 A |
| 인증 | Argon2id 또는 bcrypt 해싱, NIST 비밀번호 규칙, 유출 목록 대조 | 해시 1회에 100~250ms 소요, 조합 규칙 없음 |
| 인증 | 모든 사용자에게 MFA 제공, 관리자는 필수, 패스키 지원 | 두 번째 인증 요소 없이는 관리자 로그인 불가 |
| 인증 | 로그인, 재설정, MFA 엔드포인트에 IP별·계정별 속도 제한 | 1분 안의 여섯 번째 시도에 429 반환 |
| 세션 | Secure, HttpOnly, SameSite를 적용한 __Host- 쿠키, 로그인 시 ID 교체 | 개발자 도구에서 쿠키 속성 확인 가능, 로그인 후 이전 세션 무효화 |
| 입력 | 모든 곳에 매개변수화된 쿼리, 컨텍스트별 출력 인코딩, 내용 기반 업로드 검증 | 코드 검색에서 문자열로 조합한 쿼리 없음, XSS 페이로드가 텍스트로 렌더링됨 |
| 접근 제어 | 기본 거부 라우팅, 객체별 소유권 검사, 엄격한 CORS | 다른 사용자의 ID로 재전송하면 404 또는 403 반환 |
| 의존성 | 락파일 커밋, npm ci, CI에서 감사, 액션을 SHA로 고정, CDN 스크립트에 SRI 적용 | 심각도 high인 CVE가 있으면 빌드 실패 |
| 서버 | 80번과 443번만 공개, SSH는 키 인증만, 무인 보안 업데이트, 시크릿은 환경 변수나 볼트에 보관 | 포트 확인 결과 인터넷에서 22, 3306, 6379번 포트가 닫혀 있음 |
| 백업 | 변경 불가능한 사본 1개를 포함한 3-2-1 백업, 복원 테스트 완료 | 마지막 복원 테스트 성공일이 90일 이내 |
| 이메일 | SPF -all, DKIM, DMARC p=reject, MTA-STS | DMARC 확인 통과, 집계 보고서 수신 중 |
| 모니터링 | 인증 및 권한 부여 이벤트 중앙 기록, 패턴 기반 경보, 외부 가동 시간·인증서·DNS 모니터링 | 테스트 경보 발송 및 수신 확인 |
| 대응 | 사고 대응 계획 문서화, security.txt 게시, 모의 훈련 실시 | 최근 12개월 안에 계획 검토 |
OWASP Top 10:2025와의 대응 관계
OWASP Top 10은 웹 애플리케이션 위험 목록 중 가장 많이 인용되는 목록이며, 2025년판에서는 순위가 재편되고 두 개의 새 카테고리가 추가되었습니다. 고객, 감사인, 컴플라이언스 프레임워크가 이에 어떻게 대응하는지 묻는다면, 아래 표가 이 가이드의 모범 사례와의 대응표입니다:
| OWASP Top 10:2025 | 이 가이드의 모범 사례 |
|---|---|
| A01 Broken Access Control(접근 제어 취약점) | 기본 거부, 객체별 검사, 엄격한 CORS, 관리자 페이지 잠금 |
| A02 Security Misconfiguration(보안 설정 오류) | 보안 헤더, HSTS, 닫힌 포트, 버전 헤더 제거, 디렉터리 목록 비활성화 |
| A03 Software Supply Chain Failures(소프트웨어 공급망 실패, 신규) | 락파일, 감사, 고정된 액션, SRI, SBOM, 최소 권한 CI |
| A04 Cryptographic Failures(암호화 실패) | TLS 1.2 이상 및 1.3, HSTS, Argon2id, 시크릿 관리, 암호화된 백업 |
| A05 Injection(인젝션) | 매개변수화된 쿼리, 출력 인코딩, CSP, 업로드 검증 |
| A06 Insecure Design(안전하지 않은 설계) | 계층별 위협 모델, 실패 시 차단 기본값, 설계 단계부터 반영한 속도 제한 |
| A07 Authentication Failures(인증 실패) | NIST 비밀번호 규칙, 유출 대조, MFA와 패스키, 세션 ID 교체 |
| A08 Software or Data Integrity Failures(소프트웨어 및 데이터 무결성 실패) | SRI, 서명 및 버전 고정된 의존성, 안전한 역직렬화 |
| A09 Security Logging and Alerting Failures(보안 로깅 및 경보 실패) | 중앙 로깅, 패턴 기반 경보, 외부 모니터링 |
| A10 Mishandling of Exceptional Conditions(예외 상황 처리 미흡, 신규) | 실패 시 차단, 일관된 오류 응답, 사용자에게 스택 트레이스 노출 금지 |
사이트의 보안 헤더를 몇 초 만에 확인하세요
DNS Robot의 무료 HTTP 헤더 확인 도구는 어떤 URL이든 가져와 HSTS, Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy 등 보안 헤더를 A부터 F까지 등급으로 평가하고, 실제로 발견한 값과 누락된 항목을 정확히 보여 줍니다. 회원가입이 필요 없습니다.
사용해보기 HTTP 헤더 확인Advertisement
웹 보안 모범 사례 FAQ
최신 TLS와 HSTS로 HTTPS를 강제하고, 엄격한 보안 헤더(특히 Content Security Policy)를 보내고, 비밀번호는 Argon2id로 해싱한 뒤 MFA와 속도 제한으로 보호하고, 매개변수화된 쿼리와 출력 인코딩을 사용하고, 모든 객체에 대해 서버에서 접근 제어를 강제하고, 의존성은 패치를 적용하고 버전을 고정하고, 사용하지 않는 포트를 닫고, 보안 이벤트를 중앙에서 기록하세요. 다른 모든 것이 도메인 이름을 계속 통제하고 있다는 전제 위에 있으므로, 도메인과 DNS부터 잠가야 합니다.