웹 보안 기초 (HTTPS, XSS, CSRF)
HTTPS의 전송 보호와 XSS·SQL 삽입·CSRF·무차별 대입·DDoS의 공격 경로 및 기본 방어를 구분합니다.
지금까지 프런트엔드와 백엔드의 역할, 요청과 응답, 메모리 배열을 쓰는 CRUD 흐름을 익혔고, 데이터베이스는 필요성과 SQL, NoSQL의 기초를 비교했습니다. 하지만 실제 서비스에서는 기능이 동작하는 것만으로 충분하지 않습니다. 반드시 함께 챙겨야 하는 것이 보안(security)과 성능(performance)입니다. 개인 정보 유출, 계정 탈취, 서비스 마비 같은 사고는 기능이 얼마나 잘 만들어졌든 사용자의 신뢰를 한 번에 무너뜨립니다. 반대로 처음부터 보안과 성능의 기준을 세워 두면 운영 비용과 장애 대응의 부담을 크게 줄일 수 있습니다.
13장 웹 보안과 성능 최적화의 첫 글에서는 웹 보안의 기본 개념과 자주 일어나는 공격, 그리고 개발할 때 바로 적용할 수 있는 방어 방법을 다룹니다. 웹 보안은 웹 애플리케이션과 사용자의 데이터를 악의적인 공격으로부터 지키는 기술과 활동입니다. 웹은 누구나 접근할 수 있는 인터넷에서 동작하므로 늘 여러 위협에 노출되어 있고, 개발자는 이런 위협을 알고 알맞게 방어해야 합니다.
웹 보안이 중요한 이유
이름, 이메일, 주소 같은 개인 정보와 카드 번호 같은 금융 정보, 건강 기록 같은 민감한 정보가 새어 나가면 사용자는 금전적인 피해를 입고 서비스는 신뢰를 잃습니다. 서비스가 해킹당하거나 오작동하면 사용자가 떠나고 사업에도 손실이 생깁니다. 개인정보 보호법이나 유럽의 GDPR 같은 법규를 지키지 않으면 법적인 처벌을 받을 수도 있습니다. 또 DDoS(distributed denial of service) 공격 같은 공격으로 서비스가 마비되지 않도록 해, 사용자가 언제든 서비스를 쓸 수 있게 해야 합니다.
DEFENSE LAYERS
요청 경로마다 방어 책임을 나눕니다
HTTPS가 보호한 요청에도 공격 입력이나 권한 없는 작업이 들어올 수 있습니다. 서버는 외부 입력과 요청자의 허용 범위를 다시 확인합니다.
브라우저 · 출력과 조작
일반 텍스트는 안전하게 출력하고 HTML은 필요한 경우 정화합니다. CSP는 보완 통제이며, 브라우저의 폼 검증과 숨긴 버튼은 서버 권한 검사를 대신하지 않습니다.
전송·엣지 · 연결과 남용
HTTPS 인증서 검증·전송 보호, 요청 제한과 필터링을 담당합니다. 암호화된 연결이 입력 내용의 안전성을 보장하지는 않습니다.
서버 · 요청과 작업
형식·길이 등 입력을 검증하고 보호 자원의 인증·인가를 확인합니다. 쿠키 기반 상태 변경에는 별도의 CSRF 보호가 필요합니다.
데이터 접근 · 쿼리와 권한
SQL 문장과 값을 분리하고 DB 계정에 최소 권한을 부여합니다. 비밀번호는 전송 암호화와 별도로 안전한 해시로 저장합니다.
운영 · 탐지와 재검증
요청 식별자와 필요한 보안 로그로 의심 요청을 추적합니다. 비밀번호·세션·토큰 원문은 기록하지 않고 수정한 공격 시나리오를 다시 검증합니다.
화살표는 요청의 설명 순서이며 실제 미들웨어 배치 순서를 고정하지 않습니다. 서로 다른 계층의 통제가 각자의 실패를 다룹니다.
위 그림은 요청이 브라우저에서 데이터베이스까지 가는 동안 구간마다 다른 방어를 겹쳐 두는 모습입니다. HTTPS로 보호된 요청이라도 공격하는 입력이나 권한 없는 작업이 들어올 수 있으므로, 서버는 바깥에서 온 입력과 요청한 사람이 할 수 있는 일을 다시 확인해야 합니다.
브라우저에서는 사용자가 입력한 값을 일반 텍스트로 안전하게 표시하고, 입력을 돕는 검사를 합니다. 다만 브라우저의 폼 검사나 화면에서 숨긴 버튼은 서버의 권한 검사를 대신하지 못하고, CSP는 보완하는 방어입니다. 전송과 엣지(edge) 구간에서는 HTTPS가 인증서를 검증하고 전송 내용을 보호하며, 요청 수 제한과 필터링으로 남용을 막습니다. 암호화된 연결이라고 입력의 내용까지 안전해지는 것은 아닙니다.
서버에서는 입력의 타입과 길이를 검증하고, 보호해야 할 자원에 대해 인증(authentication)과 인가(authorization)를 확인하며, 쿠키로 인증하는 상태 변경 요청에는 별도의 CSRF 보호를 둡니다. 데이터 접근 구간에서는 SQL 문장과 값을 분리하고 데이터베이스 계정에 꼭 필요한 권한만 주며, 비밀번호는 전송 암호화와 별개로 안전한 해시로 저장합니다. 운영 단계에서는 요청 식별자와 보안 로그로 의심스러운 요청을 추적하되 비밀번호나 세션, 토큰의 원문은 기록하지 않고, 고친 공격 시나리오는 다시 검증합니다.
그림의 화살표는 요청을 설명하는 순서일 뿐, 실제 미들웨어의 배치 순서를 정한 것은 아닙니다. 각 구간의 방어는 서로를 대신하지 않고, 각자 다른 실패를 다룹니다.
주요 공격과 방어
ATTACK & DEFENSE
공격이 넘는 경계에서 방어를 선택합니다
전송·출력·SQL·요청 위조·인증·가용성은 서로 다른 실패입니다. 하나의 필터로 묶지 않고 각 경계에 맞는 통제를 적용합니다.
전송 · 도청과 변조
인증서를 검증하는 HTTPS/TLS로 전송을 보호합니다. HSTS는 브라우저가 HTTP 대신 HTTPS를 사용하도록 보강합니다.
출력 · XSS
일반 텍스트는 안전한 DOM API와 출력 문맥별 인코딩을 사용합니다. HTML 허용 시에는 검증된 정화 도구를 사용하며 CSP를 보완합니다.
SQL · 입력이 구문이 되는 경계
SQL과 값은 파라미터 API로 분리합니다. 동적 테이블·열 이름은 허용 목록으로 제한하고 DB 계정에는 필요한 권한만 줍니다.
상태 변경 요청 · CSRF
쿠키 인증 요청에 토큰 검증과 SameSite·출처 검사를 적용합니다. 토큰은 사용자의 의도를 완전히 증명하거나 인증·인가를 대신하지 않습니다.
인증 · 반복 로그인 시도
비밀번호를 솔트가 있는 적절한 해시로 저장하고 로그인 재시도를 제한합니다. 계정 잠금의 남용과 정상 사용자의 복구도 고려합니다.
가용성 · DDoS와 과부하
엣지의 필터링·요청 제한과 용량 분산을 함께 검토합니다. 로드 밸런서만으로 공격을 제거하거나 무한한 용량을 확보할 수는 없습니다.
입력 검증은 모든 경계의 공통 보완입니다. 출력 인코딩·SQL 파라미터화·서버 권한 검사처럼 실제 해석 지점의 방어를 대신하지 않습니다.
위 그림은 공격이 넘어서는 지점마다 알맞은 방어를 고르는 지도입니다. 전송, 출력, SQL, 요청 위조, 인증, 가용성은 서로 다른 실패라서 하나의 필터로 묶지 않고 지점마다 맞는 방어를 둡니다. 위쪽의 세 갈래가 특히 중요합니다. XSS는 데이터가 HTML로 해석되어 스크립트가 실행되는 위험이므로 일반 텍스트로 출력하고, 출력하는 위치에 맞게 처리하며, HTML을 꼭 허용해야 할 때만 정화합니다. SQL 삽입은 값이 SQL 문장에 문자열로 이어 붙는 위험이므로 문장과 값을 분리해 매개변수로 넘기고, 데이터베이스 권한을 최소로 줍니다. CSRF는 쿠키에 담긴 자격 증명 때문에 위조된 상태 변경 요청이 통과하는 위험이므로, 요청이 정말 사용자의 의도인지 CSRF 토큰과 쿠키, 출처 정책으로 확인합니다.
세 갈래는 하나의 필터나 서로 배타적인 처리 경로가 아니라 각각 따로 판단해야 하는 지점입니다. 그림 아래쪽은 나머지 방어를 정리합니다. 전송은 인증서를 검증하는 HTTPS와 TLS로 보호하고, HSTS로 브라우저가 HTTP 대신 HTTPS를 쓰게 합니다. 반복되는 로그인 시도는 솔트가 있는 해시로 비밀번호를 저장하고 재시도를 제한해 막되, 계정 잠금이 오히려 정상 사용자를 막는 데 악용되지 않도록 복구 방법도 생각합니다. 대량 트래픽에는 엣지의 필터링과 요청 제한, 용량 분산을 함께 검토하며, 부하 분산 장치만으로 공격을 없애거나 용량을 무한히 늘릴 수는 없습니다. 그림 맨 아래의 설명처럼 입력 검증은 모든 지점을 보완하지만, 출력 인코딩이나 SQL 매개변수화, 서버의 권한 검사처럼 실제로 해석되는 지점의 방어를 대신하지는 못합니다.
XSS
XSS(cross-site scripting)는 공격자가 웹 사이트에 악성 스크립트를 심어, 다른 사용자의 브라우저에서 그 스크립트가 실행되게 하는 공격입니다. 이를 통해 세션 쿠키를 훔치거나 개인 정보를 빼내거나 가짜 페이지로 이동시킬 수 있습니다. 게시판 글에 <script>alert(document.cookie);</script> 같은 코드를 넣거나, URL의 쿼리 문자열에 스크립트를 넣어 그 값을 그대로 보여 주는 페이지에서 실행되게 하는(reflected XSS) 방식이 대표적입니다.
방어의 첫 단계는 입력의 형식과 길이를 검증하는 것입니다. 하지만 위험한 태그나 문자열을 막는 목록만으로는 XSS를 막을 수 없으므로, 출력하는 쪽의 처리가 핵심입니다. 일반 텍스트는 textContent 같은 안전한 DOM API로 출력하고, HTML 본문, 속성, URL처럼 출력하는 위치마다 그에 맞는 인코딩을 합니다. HTML 엔티티로 바꾸는 한 가지 방법이 자바스크립트나 URL의 위치까지 보호하지는 못합니다. React나 Vue가 기본으로 해 주는 텍스트 출력 보호는 그대로 쓰고, HTML을 직접 넣어야 할 때는 검증된 정화 도구(sanitizer)로 정화한 값만 넣습니다.
CSP(Content Security Policy)는 응답 헤더 Content-Security-Policy로 브라우저에게 어떤 출처의 리소스를 불러오고 어떤 조건에서 스크립트를 실행할지 알려 주는 보안 정책입니다. 정책의 내용에 따라 실행을 제한하므로 CSP만으로 모든 XSS가 막힌다고 가정하지 말고, 출력 위치에 맞는 처리와 함께 쓰는 추가 방어로 생각합니다.
SQL 삽입
SQL 삽입(SQL injection)은 공격자가 로그인 폼이나 검색 폼 같은 입력란에 SQL 구문을 넣어, 데이터베이스 쿼리를 조작하거나 데이터를 빼내는 공격입니다. 로그인 ID에 ' OR 1=1 --를 넣어 비밀번호 없이 로그인하거나, 검색어에 '; DROP TABLE users; --를 넣어 테이블을 지우려는 시도가 대표적입니다.
가장 중요한 방어는 매개변수화된 쿼리(parameterized query)입니다. SQL 문장과 입력 값을 분리해 데이터베이스 드라이버의 매개변수 API로 넘기면, 입력 값은 아무리 SQL처럼 보여도 값으로만 다뤄집니다. 테이블이나 열 이름처럼 매개변수로 넘길 수 없는 부분은 허용 목록으로 제한합니다. ORM을 쓰더라도 직접 쓴 SQL 문자열을 이어 붙이면 안전하지 않으며, MongoDB의 Mongoose는 SQL용 ORM이 아니라는 점도 알아 둡니다. 여기에 더해 입력 값의 타입과 길이, 허용 문자를 백엔드에서 엄격히 검증하고, 조회만 하는 계정에는 DROP 같은 권한을 주지 않는 최소 권한 원칙(least privilege)을 지킵니다. 데이터베이스 계정과 역할로 권한을 좁히는 방법은 계정·역할과 최소 권한에 있습니다.
CSRF
CSRF(cross-site request forgery)는 사용자가 어떤 사이트에 로그인한 상태에서, 공격자가 만든 페이지가 사용자의 의지와 상관없이 그 사이트에 비밀번호 변경이나 계좌 이체, 게시물 삭제 같은 요청을 보내게 하는 공격입니다. 예를 들어 은행 사이트에 로그인한 사용자가 악성 사이트를 방문하면, 그 페이지에 숨긴 폼이나 스크립트가 은행의 이체 주소로 요청을 보냅니다. 브라우저는 그 요청에 은행 사이트의 쿠키를 붙일 수 있어 서버는 정상 요청으로 오해할 수 있습니다.
CSRF 토큰(CSRF token)은 서버가 예측하기 어려운 값을 발급하고, 폼을 제출할 때 함께 받아 검증하는 방법입니다. 토큰이 잘 보호되고 서버가 검증한다면 다른 사이트에서 요청을 위조하기 어려워지지만, 토큰이 새어 나가거나 같은 출처에서 실행되는 XSS까지 이것 하나로 막지는 못합니다.
쿠키의 SameSite 속성(Strict, Lax, None)은 다른 사이트에서 시작한 요청에 쿠키를 보낼지를 제한합니다. 여기서 사이트는 보통 스킴과 등록 가능한 도메인으로 판단하므로 출처(origin)와는 다릅니다. Strict는 다른 사이트에서 온 요청에 쿠키를 거의 보내지 않고, 명시한 Lax는 링크를 눌러 이동하는 최상위 GET 같은 안전한 이동만 허용하며 다른 사이트에서 보낸 일반적인 POST에는 쿠키를 보내지 않습니다. None을 쓰려면 Secure도 함께 지정해야 합니다. 그래서 상태를 바꾸는 작업을 GET으로 처리하지 않고, CSRF 토큰과 함께 검토합니다. 요청의 Referer나 Origin 헤더로 요청이 믿을 수 있는 곳에서 왔는지 확인하는 방법도 있지만, 이것만으로는 완벽하지 않습니다.
무차별 대입 공격
무차별 대입 공격(brute force attack)은 비밀번호나 PIN 번호를 알아내려고 가능한 조합을 계속 시도하는 공격으로, 로그인 페이지에서 자주 일어납니다. 일정 횟수 이상 실패하면 계정을 잠시 잠그거나, 한 IP 주소에서 정해진 시간 동안 보낼 수 있는 요청 수를 제한(rate limiting)하고, 자동화된 프로그램이 아닌지 확인하는 CAPTCHA를 더할 수 있습니다.
비밀번호 규칙은 충분한 길이를 요구하고, 유출되었거나 흔한 비밀번호를 막으며, 비밀번호 관리자를 쓸 수 있게 하는 쪽이 좋습니다. 특수 문자를 억지로 섞게 하거나 주기적으로 바꾸게 하기보다, 유출된 정황이 있을 때 바꾸도록 요구합니다.
DDoS
DDoS는 감염된 많은 컴퓨터(봇넷)로 특정 서버에 한꺼번에 대량의 트래픽을 보내 자원을 고갈시켜, 정상적인 서비스를 못 하게 만드는 공격입니다. Cloudflare 같은 CDN(content delivery network)은 공격 트래픽을 분산하고 걸러 내는 데 도움을 주고, 방화벽은 비정상적인 트래픽을 찾아 막습니다. 부하 분산 장치(load balancer)는 트래픽을 여러 서버로 나눠 한 서버에 몰리지 않게 하며, AWS, Azure, GCP 같은 클라우드는 큰 인프라로 공격을 견디는 기능을 제공합니다.
꼭 챙길 보안 기본
모든 통신에는 HTTPS를 씁니다. HTTP는 데이터를 평문으로 보내 중간에서 엿보거나 바꾸는 중간자 공격(man-in-the-middle)에 취약하고, HTTPS는 TLS 암호화로 통신 내용을 보호하고 서버의 신원을 확인합니다. 연결을 맺을 때 키와 인증서를 확인하는 과정은 네트워크 교재의 TLS 핸드셰이크에서 자세히 다룹니다. Let's Encrypt 같은 곳에서 인증서를 무료로 받을 수 있습니다.
비밀번호는 평문으로 저장하지 않습니다. 되돌릴 수 있는 암호문이 아니라 솔트(salt)와 계산 비용을 갖춘 비밀번호 전용 해시로 저장하고, 로그인할 때 입력한 값을 같은 방법으로 검증합니다. 새 시스템이라면 Argon2id를 먼저 검토하고, 쓸 수 없다면 scrypt 같은 대안을 고르며, bcrypt는 기존 시스템과의 호환을 고려할 때 씁니다(OWASP의 비밀번호 저장 지침).
모든 입력 값은 클라이언트와 서버 양쪽에서 검증하되, 클라이언트의 검사는 사용 편의를 위한 것이고 보안을 위한 검사는 반드시 서버가 해야 합니다. 데이터베이스 오류 메시지처럼 시스템의 내부 정보를 드러내는 자세한 오류는 사용자에게 보여 주지 않습니다. 응답 헤더에는 X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security 같은 보안 헤더를 설정해 여러 공격을 막고, 쓰고 있는 프레임워크와 라이브러리, 운영체제를 최신으로 유지해 알려진 취약점을 고칩니다.
더 깊이: HSTS가 지키지 못하는 첫 요청
사용자가 http://로 시작하는 오래된 링크나 북마크로 접속하면, 브라우저는 먼저 HTTP로 요청하고 서버가 HTTPS 주소로 다시 보내 줍니다. 이 첫 HTTP 요청은 암호화되지 않으므로 중간에서 가로채 가짜 페이지로 보낼 수 있습니다. Strict-Transport-Security 헤더는 이런 일을 줄이려고, 이 호스트에는 HTTPS로만 접속하라고 브라우저에 알려 둡니다. 헤더를 받은 브라우저는 max-age에 적힌 초 동안 그 호스트의 http 주소를 요청하기 전에 https로 바꾸고, 포트 80은 443으로 바꿉니다.
HSTS가 적용된 호스트에서는 인증서 오류가 나도 브라우저가 경고를 무시하고 계속할 방법을 주지 않습니다. 또 헤더는 HTTPS 응답에 담겨 왔을 때만 인정되고, HTTP 응답에 담긴 헤더는 무시됩니다. 중간자가 이 헤더를 지우거나 만료를 앞당기지 못하게 하려는 것입니다. includeSubDomains를 붙이면 모든 하위 도메인에도 같은 규칙이 적용됩니다.
약점은 처음 방문입니다. 브라우저가 이 헤더를 한 번도 받은 적이 없다면 첫 HTTP 요청은 여전히 보호받지 못합니다. 이를 보완하려고 Google이 운영하는 HSTS 사전 등록 목록에 도메인을 올리면, 브라우저에 미리 들어 있는 목록 덕분에 첫 방문부터 HTTPS로만 접속합니다. 등록하려면 max-age가 1년(31536000초) 이상이고 includeSubDomains와 preload가 있어야 하므로, 모든 하위 도메인이 HTTPS를 지원하는지 먼저 확인합니다. MDN의 Strict-Transport-Security 설명은 이 목록이 HSTS 명세의 일부는 아니라는 점도 밝힙니다.
작은 예제: 표시 이름을 검증하고 글자로 보여 주기
입력 검증과 안전한 출력이 어디서 필요한지 표시 이름 하나로 살펴봅니다. 이름을 보내면 서버가 문자열인지, 앞뒤 공백을 뺀 길이가 허용 범위인지 확인하고, 브라우저는 돌려받은 이름을 HTML이 아닌 글자로 보여 줍니다. 이름을 저장하거나 회원을 만드는 기능은 없습니다. 리스트, 테이블, 폼 요소에서 본 required, maxlength와 RESTful API 설계와 구현의 JSON 요청과 응답을 연결하는 예제이며, 두 파일은 별도의 폴더에서 쓰는 전체 코드입니다.
두 파일 준비하기
Node.js와 npm이 있는 터미널에서 새 폴더를 만듭니다. 이 예제는 Express 5를 쓰며, Express 5는 Node.js 18 이상이 필요합니다. 아직 환경이 없다면 Node.js와 Express.js 입문을 먼저 읽으세요.
mkdir security-preview
cd security-preview
npm init -y
npm install express@5
mkdir public마지막 mkdir public은 브라우저에 보낼 화면 파일을 둘 폴더를 만듭니다.
편집기에서 security-preview/public/index.html을 만들고 다음 내용을 작성합니다. 이 화면은 입력한 표시 이름을 fetch()로 서버의 /api/preview에 보내고, 응답으로 받은 이름이나 오류 안내를 결과 문단에 textContent로 넣습니다. 응답을 innerHTML이 아니라 textContent로 넣기 때문에, 이름에 태그처럼 보이는 글자가 있어도 HTML로 해석되지 않습니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>표시 이름 미리보기</title>
</head>
<body>
<h1>표시 이름 미리보기</h1>
<form id="previewForm">
<label for="displayName">표시 이름</label>
<input id="displayName" name="displayName" type="text" required maxlength="40">
<button id="previewButton" type="submit">미리보기</button>
</form>
<p id="result" role="status" aria-live="polite"></p>
<script>
const form = document.getElementById('previewForm');
const input = document.getElementById('displayName');
const button = document.getElementById('previewButton');
const result = document.getElementById('result');
form.addEventListener('submit', async (event) => {
event.preventDefault();
button.disabled = true;
result.textContent = '';
try {
const response = await fetch('/api/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: input.value }),
});
const data = await response.json();
if (!response.ok) {
result.textContent = data && typeof data.message === 'string'
? data.message
: '입력을 확인해 주세요.';
return;
}
if (data === null || typeof data !== 'object'
|| Array.isArray(data) || typeof data.name !== 'string') {
result.textContent = '응답 형식이 다릅니다. Network의 Response를 확인하세요.';
return;
}
result.textContent = data.name;
} catch {
result.textContent = '요청을 완료하지 못했습니다. Network와 서버 터미널을 확인하세요.';
} finally {
button.disabled = false;
}
});
</script>
</body>
</html>같은 폴더의 security-preview/security-demo.cjs에 서버 코드를 작성합니다. .cjs 확장자는 이 파일이 CommonJS의 require()를 쓴다는 뜻입니다.
const express = require('express');
const path = require('node:path');
const app = express();
const port = Number(process.env.PORT || 3000);
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
app.post('/api/preview', (req, res) => {
const rawName = req.body?.name;
if (typeof rawName !== 'string') {
return res.status(400).json({ message: 'name은 문자열이어야 합니다.' });
}
const name = rawName.trim();
if (name.length < 1 || name.length > 40) {
return res.status(400).json({
message: '앞뒤 공백을 뺀 표시 이름은 길이가 1~40이어야 합니다.',
});
}
return res.status(200).json({ name });
});
app.listen(port, '127.0.0.1', () => {
console.log('미리보기 주소: http://127.0.0.1:' + port);
});express.json()으로 읽은 값도 사용자가 보낸 입력이므로, trim()을 호출하기 전에 문자열인지부터 확인합니다. 문자열이면 앞뒤 공백을 지운 뒤 길이를 검사하고, 조건을 만족할 때만 HTTP 200과 { name }을 돌려줍니다. 여기서의 length와 HTML의 maxlength는 UTF-16 코드 단위로 셉니다. 한글 음절 하나는 보통 1이지만 일부 이모지는 2 이상이라서, 눈에 보이는 글자 수와 항상 같지는 않습니다.
서버를 열고 입력 비교하기
security-preview 폴더에서 서버를 시작합니다. 서버는 요청을 기다리는 동안 계속 실행되므로 그 터미널은 다른 명령에 쓸 수 없는 상태가 되고, 서버를 끌 때는 그 터미널에서 Ctrl+C를 누릅니다.
node security-demo.cjs터미널에 나온 http://127.0.0.1:3000을 브라우저로 엽니다. HTML 파일을 직접 열지 마세요. 서버가 public 폴더의 화면과 /api/preview를 같은 주소와 포트로 제공하므로, 화면의 상대 경로 요청도 같은 서버로 갑니다. EADDRINUSE 오류가 나오면 3000번 포트를 이미 다른 프로그램이 쓰고 있는 것이므로, 다른 포트를 지정합니다. PowerShell에서는 다음처럼 실행합니다.
$env:PORT = '3001'
node security-demo.cjsmacOS나 Linux의 셸에서는 PORT=3001 node security-demo.cjs로 실행하고, 포트를 바꿨다면 브라우저에서도 http://127.0.0.1:3001을 엽니다.
처음에는 민수를 입력하고 미리보기를 누릅니다. 화면에 이름이 나오면 개발자 도구 Network 패널에서 POST /api/preview의 상태 코드와 응답을 함께 보고, 입력을 바꿔 가며 다음 결과를 비교합니다.
| 입력과 보내는 방법 | 확인할 결과 |
|---|---|
폼에 민수 입력 | HTTP 200, JSON {"name":"민수"}, 화면에 민수 |
폼에 민수 입력 | HTTP 200, 앞뒤 공백을 뺀 민수가 응답과 화면에 보임 |
| 폼을 비운 채 제출 | 브라우저의 필수 입력 안내가 뜨고 API 요청은 나가지 않음 |
| 폼에 공백 세 개만 입력 | 브라우저 검사는 통과하지만 서버가 HTTP 400으로 거절하고 화면에 길이 안내가 보임 |
name을 숫자 42로 직접 요청 | HTTP 400, 콘솔에 name은 문자열이어야 합니다.를 담은 JSON |
name을 ASCII 문자 41개로 직접 요청 | HTTP 400, 콘솔에 길이 안내를 담은 JSON |
폼에 <b>안녕</b> 입력 | HTTP 200, 굵은 글씨가 아니라 <b>안녕</b>가 그대로 보임 |
일반적인 폼 입력에서는 maxlength가 길이를 제한하고 input.value가 언제나 문자열이므로, 서버가 숫자나 긴 문자열을 거절하는지 보려면 개발자 도구의 Console에서 폼을 거치지 않고 요청합니다.
async function requestPreview(name) {
const response = await fetch('/api/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
console.log(response.status, await response.json());
}
requestPreview(42);첫 요청의 상태와 JSON을 확인한 뒤 requestPreview('a'.repeat(41));도 실행해, 둘 다 HTTP 400인지 봅니다. 이 직접 요청은 화면의 제출 함수를 거치지 않으므로 결과는 콘솔에서 확인합니다. 결과가 다르다면 먼저 Network에서 요청 주소와 상태 코드, 응답 JSON을 확인합니다. HTTP 400은 요청이 서버에 도착했지만 이름 규칙에 맞지 않은 경우이고, 연결에 실패했거나 JSON을 읽지 못하면 화면에 일반 오류 안내가 나오므로 주소와 서버 터미널도 봅니다. HTTP 성공이라도 응답에 name 문자열이 없으면 화면은 형식 오류로 처리합니다.
이 예제가 보호하는 것
브라우저의 required와 maxlength는 입력을 돕지만, 폼을 거치지 않은 직접 요청은 막지 못합니다. 그래서 서버가 문자열 타입과 앞뒤 공백을 뺀 길이를 직접 확인합니다. Express의 req.body 문서도 바깥에서 온 입력의 모양과 값을 쓰기 전에 검증하라고 안내합니다.
길이 검사를 통과한 <b>안녕</b>가 서버를 거쳐 돌아와도, 브라우저는 그 값을 result.textContent에 넣어 글자로 보여 줍니다. 입력에서 꺾쇠를 지웠기 때문이 아니라, HTML로 해석하는 곳에 넣지 않았기 때문입니다(MDN의 textContent, OWASP의 안전한 출력 위치 설명). 이 방식은 일반 요소의 텍스트 출력에 해당하며, 사용자가 쓴 HTML을 허용하거나 값을 URL, HTML 속성, 자바스크립트 코드에 넣을 때는 그 위치에 맞는 별도의 처리가 필요합니다.
이 예제는 로그인, 데이터베이스 저장, CSRF 방어, CSP, HTTPS를 구현하지 않았으며, 서비스 전체의 보안을 확인하는 것도 아닙니다. 앞의 방어 계층 그림에서 서버의 입력 검사와 브라우저의 텍스트 출력만 이어 본 작은 사례입니다. 서버와 브라우저 어디에도 이름을 보관하지 않으므로 지울 데이터는 없으며, 마칠 때는 서버를 띄운 터미널에서 Ctrl+C를 누릅니다. PowerShell에서 임시 포트를 지정했다면 같은 터미널에서 Remove-Item Env:PORT로 해제합니다.
서비스의 보안 점검 흐름
서비스 전체를 점검할 때는 먼저 로그인, 입력 폼, 파일 업로드, 관리자 API처럼 바깥의 입력이 들어오는 지점을 모두 적어 공격받을 수 있는 곳을 정리합니다. 그다음 HTTPS, 보안 헤더, 비밀번호 해시, CSRF 보호, 요청 수 제한 같은 기본 방어가 적용되었는지 확인합니다. XSS, SQL 삽입, 권한 우회 같은 공격 시나리오를 실제로 재현해 방어가 동작하는지 검증하고, 의심스러운 요청이 로그에 남고 알림이 가는지도 봅니다. 문제를 고친 뒤에는 같은 시나리오를 다시 실행해 같은 문제가 되살아나지 않는지 확인합니다.
정리
- 보안은 요청이 지나가는 브라우저, 전송, 서버, 데이터 접근, 운영의 구간마다 다른 방어를 겹쳐 두는 것이며, 한 구간의 방어가 다른 구간을 대신하지 않습니다.
- XSS는 출력하는 위치에 맞게 처리하고 일반 텍스트는
textContent처럼 안전한 API로 출력하며, CSP는 보완하는 방어입니다. - SQL 삽입은 문장과 값을 분리하는 매개변수화된 쿼리와 최소 권한으로 막습니다.
- CSRF는 CSRF 토큰과
SameSite쿠키, 출처 확인으로 막고, 상태를 바꾸는 작업을 GET으로 처리하지 않습니다. - 비밀번호는 솔트와 계산 비용을 갖춘 전용 해시로 저장하고, 로그인 재시도를 제한합니다.
- 브라우저의 입력 검사는 편의를 위한 것이고, 보안을 위한 검증과 권한 확인은 반드시 서버가 합니다.
연습 문제
1. 길이와 타입 검사
서버에 올바른 JSON으로 직접 요청한다고 가정합니다. ① a 39개 뒤에 이모지 😀 하나를 붙인 이름, ② a 38개 뒤에 같은 이모지를 붙인 이름, ③ name 값이 숫자 42인 요청의 상태와 이유를 먼저 적어 보세요. ④ 문자열 <b>안녕</b>가 200으로 응답되어도 화면에서 굵은 글씨가 되면 안 되는 이유도 설명해 보세요.
해설 보기
①은 이 이모지가 UTF-16 코드 단위 두 개라서 39 + 2 = 41이 되어 길이 검사에서 400입니다. ②는 38 + 2 = 40이라서 200입니다. 눈에 보이는 글자 수와 이 예제가 세는 코드 단위의 길이를 구별해야 합니다. ③은 문자열이 아니므로 trim()을 호출하기 전, 첫 타입 검사에서 400입니다.
④에서 textContent는 요소의 내용을 텍스트로 채우므로 태그가 문서의 요소로 해석되지 않습니다. 이름의 타입과 길이를 검사하는 것과 출력하는 지점을 보호하는 것은 서로 다른 일입니다. 또 이 응답이 성공했다고 로그인이나 저장, CSRF 방어가 구현되었다는 뜻은 아닙니다.
2. SameSite=Lax 쿠키
은행 사이트가 로그인 쿠키에 SameSite=Lax를 지정했습니다. ① 악성 사이트에 숨겨 둔 폼이 은행의 이체 주소로 POST를 자동 제출했을 때, ② 악성 사이트의 링크를 눌러 은행의 GET /transfer?to=attacker&amount=100000으로 이동했을 때, 각각 쿠키가 함께 전송될까요? 이 결과에서 어떤 설계 원칙을 얻을 수 있을까요?
해설 보기
①은 다른 사이트에서 보낸 POST이므로 Lax 쿠키가 전송되지 않습니다. ②는 링크를 눌러 이동하는 최상위 GET이므로 Lax 쿠키가 전송됩니다.
그래서 이체처럼 상태를 바꾸는 작업을 GET으로 처리하면 SameSite=Lax로도 막을 수 없습니다. 상태 변경은 POST 같은 메서드로만 받고, SameSite는 보완하는 방어로 두며 CSRF 토큰 검증을 함께 써야 합니다.
3. 문자열로 이어 붙인 쿼리
다음 코드는 무엇이 위험하고, 어떻게 고쳐야 할까요?
const email = req.body.email;
db.query("SELECT * FROM users WHERE email = '" + email + "'");해설 보기
입력 값을 SQL 문장에 문자열로 이어 붙였기 때문에 SQL 삽입에 취약합니다. 이메일 자리에 ' OR '1'='1을 넣으면 조건이 항상 참이 되어 모든 사용자가 조회됩니다.
db.query('SELECT * FROM users WHERE email = ?', [email])처럼 SQL 문장과 값을 분리해 드라이버의 매개변수 API로 넘기면, 입력은 값으로만 다뤄집니다. 여기에 더해 email이 문자열인지와 길이를 검증하고, 이 쿼리를 실행하는 계정에는 필요한 권한만 줍니다.
출력 위치와 안전한 출력 방법은 OWASP의 XSS 방지 지침에서, SameSite의 보완 역할은 OWASP의 CSRF 방지 지침에서 확인할 수 있습니다.
다음 글 인증과 권한 관리 기초에서는 사용자가 누구인지 확인하는 인증과, 그 사용자가 무엇을 할 수 있는지 정하는 권한 관리를 세션과 토큰 방식으로 살펴봅니다.