이미지 로딩 중...
AI Generated
2025. 11. 14. · 2 Views
액세스 제어와 인증 완벽 가이드
웹 애플리케이션의 보안을 책임지는 액세스 제어와 인증 메커니즘을 Nginx를 중심으로 실무 관점에서 다룹니다. 기본 인증부터 JWT 토큰 기반 인증, IP 기반 제어까지 실제 적용 가능한 방법들을 배워봅니다.
목차
- 기본_인증_Basic_Authentication
- JWT_토큰_기반_인증
- IP_기반_접근_제어
- CORS_교차_출처_리소스_공유
- OAuth_2.0_소셜_로그인_통합
- API_Rate_Limiting_속도_제한
- HTTPS_SSL_TLS_암호화_통신
- 세션_기반_인증_관리
1. 기본_인증_Basic_Authentication
시작하며
여러분이 간단한 관리자 페이지나 내부 도구를 만들 때 이런 고민을 해본 적 있나요? "복잡한 로그인 시스템을 구축하기에는 시간이 부족한데, 그렇다고 아무나 접근하게 둘 수는 없고..." 실제로 많은 개발자들이 초기 단계에서 이런 딜레마에 빠집니다.
이런 상황에서 아무런 보안 장치 없이 배포하면 큰 문제가 발생할 수 있습니다. 악의적인 사용자가 민감한 정보에 접근하거나, 관리 기능을 무단으로 사용할 수 있기 때문입니다.
하지만 OAuth나 복잡한 세션 관리를 구현하기에는 시간과 리소스가 부족한 경우가 많죠. 바로 이럴 때 필요한 것이 HTTP 기본 인증(Basic Authentication)입니다.
Nginx에서 몇 줄의 설정만으로 간단하면서도 효과적인 인증 계층을 추가할 수 있어, 빠르게 서비스를 보호할 수 있습니다.
개요
간단히 말해서, HTTP 기본 인증은 브라우저가 제공하는 기본 로그인 팝업을 통해 사용자 이름과 비밀번호로 접근을 제한하는 가장 단순한 형태의 인증 방식입니다. 이 방식이 필요한 이유는 명확합니다.
개발 환경의 스테이징 서버, 관리자 대시보드, 내부 API 문서 같은 곳은 공개되어서는 안 되지만, 복잡한 인증 시스템을 구축하기에는 과한 경우가 많습니다. 기본 인증을 사용하면 10분 안에 이런 리소스를 보호할 수 있습니다.
기존에는 애플리케이션 코드 안에 직접 인증 로직을 구현해야 했다면, Nginx의 기본 인증을 사용하면 웹 서버 레벨에서 먼저 요청을 차단할 수 있습니다. 이는 애플리케이션 서버의 부담을 줄이고, 보안 계층을 분리하는 좋은 아키텍처 패턴입니다.
핵심 특징은 다음과 같습니다. 첫째, 설정이 매우 간단해서 htpasswd 파일 하나와 몇 줄의 Nginx 설정만 있으면 됩니다.
둘째, 모든 브라우저가 기본 지원하므로 별도의 클라이언트 구현이 필요 없습니다. 셋째, HTTPS와 함께 사용하면 충분히 안전한 보안을 제공합니다.
이러한 특징들은 빠른 프로토타이핑이나 내부 도구 보호에 이상적입니다.
코드 예제
# Nginx 설정 파일 (/etc/nginx/sites-available/myapp)
server {
listen 80;
server_name admin.example.com;
location /admin {
# 기본 인증 활성화
auth_basic "Admin Area - Login Required";
# 인증 정보가 담긴 파일 경로
auth_basic_user_file /etc/nginx/.htpasswd;
# 인증 성공 후 프록시
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
# htpasswd 파일 생성 명령어 (터미널)
# sudo htpasswd -c /etc/nginx/.htpasswd admin
설명
이것이 하는 일: 사용자가 보호된 경로에 접근하려고 할 때, Nginx가 먼저 요청을 가로채서 브라우저에게 "인증이 필요합니다"라는 신호를 보냅니다. 그러면 브라우저가 자동으로 사용자 이름과 비밀번호를 입력하는 팝업창을 띄우고, 입력된 정보를 Nginx가 검증한 후 통과시키거나 거부합니다.
첫 번째 단계로, auth_basic 디렉티브가 인증을 활성화합니다. 여기에 지정한 문자열("Admin Area - Login Required")은 브라우저의 로그인 팝업에 표시되는 메시지입니다.
사용자에게 왜 로그인이 필요한지 알려주는 친절한 설명을 넣는 것이 좋습니다. 두 번째로, auth_basic_user_file이 실제 사용자 정보가 저장된 파일을 가리킵니다.
이 파일은 htpasswd 유틸리티로 생성하며, 사용자 이름과 암호화된 비밀번호가 저장됩니다. 파일 위치는 보안을 위해 웹 루트 밖에 두는 것이 중요합니다.
Nginx는 매 요청마다 이 파일을 확인해서 입력된 인증 정보와 비교합니다. 세 번째 단계로, 인증이 성공하면 proxy_pass가 실행되어 실제 애플리케이션 서버로 요청이 전달됩니다.
인증이 실패하면 401 Unauthorized 응답이 반환되고, 브라우저는 다시 로그인 팝업을 표시합니다. 이 과정에서 애플리케이션 서버는 전혀 호출되지 않아 리소스를 절약할 수 있습니다.
여러분이 이 설정을 사용하면 몇 가지 중요한 이점을 얻을 수 있습니다. 첫째, 애플리케이션 코드 수정 없이 즉시 보안 계층을 추가할 수 있습니다.
둘째, 웹 서버 레벨에서 차단되므로 DDoS나 무작위 대입 공격으로부터 애플리케이션 서버를 보호합니다. 셋째, 여러 애플리케이션이나 경로에 동일한 인증을 쉽게 적용할 수 있어 관리가 편리합니다.
실무에서는 개발/스테이징 환경 보호, 내부 모니터링 도구 접근 제한, API 문서 페이지 보호 등에 자주 사용됩니다. 특히 프로젝트 초기에 빠르게 보안을 적용해야 할 때 매우 유용하며, 나중에 더 정교한 인증 시스템으로 마이그레이션하기도 쉽습니다.
실전 팁
💡 반드시 HTTPS와 함께 사용하세요. 기본 인증은 비밀번호를 Base64로만 인코딩하므로, HTTP에서는 평문이나 다름없습니다. SSL/TLS 없이 사용하면 중간자 공격에 매우 취약합니다.
💡 htpasswd 파일은 여러 사용자를 지원합니다. htpasswd /etc/nginx/.htpasswd user2 명령으로 추가 사용자를 등록할 수 있으며, -c 플래그는 파일을 새로 생성하므로 첫 사용자 외에는 빼야 합니다.
💡 특정 IP에서는 인증을 건너뛰도록 설정할 수 있습니다. satisfy any;와 allow 디렉티브를 조합하면 사무실 IP에서는 인증 없이 접근하고, 외부에서는 인증을 요구하도록 만들 수 있습니다.
💡 비밀번호 파일의 권한을 엄격하게 관리하세요. chmod 600 /etc/nginx/.htpasswd로 소유자만 읽을 수 있게 설정하고, 파일이 웹 루트 밖에 있는지 반드시 확인하세요.
💡 로그를 모니터링해서 무작위 대입 공격을 탐지하세요. fail2ban 같은 도구와 연동하면 반복된 인증 실패 시 IP를 자동으로 차단할 수 있습니다.
2. JWT_토큰_기반_인증
시작하며
여러분이 모바일 앱이나 SPA(Single Page Application)를 개발할 때 이런 문제를 겪어본 적 있나요? "사용자가 로그인한 상태를 어떻게 유지하지?
서버에 세션을 저장하면 확장이 어려운데..." 특히 마이크로서비스 아키텍처나 여러 서버가 분산된 환경에서는 세션 관리가 정말 복잡합니다. 전통적인 세션 기반 인증은 서버에 상태를 저장해야 하므로 확장성에 문제가 있습니다.
로드 밸런서 뒤에 여러 서버가 있다면 세션 공유를 위해 Redis 같은 별도 저장소가 필요하고, 이는 인프라 복잡도를 높입니다. 또한 모바일 앱이나 다른 도메인의 프론트엔드에서는 쿠키 기반 세션이 잘 작동하지 않는 경우도 많습니다.
바로 이럴 때 필요한 것이 JWT(JSON Web Token) 기반 인증입니다. 서버가 상태를 저장하지 않고도(stateless) 안전하게 사용자를 인증할 수 있어, 현대적인 웹 애플리케이션의 표준으로 자리잡았습니다.
개요
간단히 말해서, JWT는 사용자 정보를 암호화된 토큰에 담아서 클라이언트가 보관하게 하고, 매 요청마다 이 토큰을 헤더에 포함시켜 인증하는 방식입니다. 이 방식이 필요한 이유는 확장성과 유연성 때문입니다.
서버는 토큰의 서명만 검증하면 되므로 어떤 서버가 요청을 받든 상관없이 인증할 수 있습니다. 모바일 앱, 웹 앱, 다른 서비스 등 어디서든 동일한 토큰으로 API에 접근할 수 있어 매우 편리합니다.
예를 들어, 마이크로서비스 환경에서 각 서비스가 중앙 인증 서버 없이 독립적으로 토큰을 검증할 수 있습니다. 기존에는 서버 메모리나 데이터베이스에 세션을 저장하고 세션 ID를 쿠키로 주고받았다면, JWT를 사용하면 모든 인증 정보가 토큰 자체에 포함되어 있어 서버가 아무것도 기억할 필요가 없습니다.
이를 "stateless 인증"이라고 합니다. 핵심 특징은 세 가지입니다.
첫째, 자체 포함적(self-contained)이어서 토큰 안에 사용자 ID, 권한, 만료 시간 등 필요한 모든 정보가 들어있습니다. 둘째, 암호화 서명으로 토큰의 위변조를 방지합니다.
셋째, 표준 규격(RFC 7519)이라 다양한 언어와 플랫폼에서 라이브러리를 쉽게 찾을 수 있습니다. 이러한 특징들이 현대 웹 애플리케이션의 복잡한 인증 요구사항을 해결합니다.
코드 예제
// Node.js + Express에서 JWT 토큰 생성 및 검증
const jwt = require('jsonwebtoken');
const SECRET_KEY = process.env.JWT_SECRET;
// 로그인 성공 시 토큰 생성
function generateToken(user) {
// payload에 사용자 정보 포함 (민감 정보는 제외)
const payload = {
userId: user.id,
email: user.email,
role: user.role
};
// 토큰 생성 (7일 유효)
return jwt.sign(payload, SECRET_KEY, {
expiresIn: '7d',
issuer: 'myapp'
});
}
// 미들웨어: 모든 요청에서 토큰 검증
function authenticateToken(req, res, next) {
// Authorization 헤더에서 토큰 추출
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
// 토큰 검증
jwt.verify(token, SECRET_KEY, (err, decoded) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
// 검증 성공 시 사용자 정보를 요청 객체에 저장
req.user = decoded;
next();
});
}
// 사용 예시
app.post('/login', async (req, res) => {
const user = await authenticateUser(req.body); // 사용자 인증
if (user) {
const token = generateToken(user);
res.json({ token, user: { id: user.id, email: user.email } });
}
});
// 보호된 라우트
app.get('/api/profile', authenticateToken, (req, res) => {
// req.user에 토큰의 payload가 들어있음
res.json({ userId: req.user.userId, email: req.user.email });
});
설명
이것이 하는 일: 사용자가 로그인에 성공하면 서버가 사용자 정보를 담은 JWT 토큰을 생성해서 클라이언트에게 전달합니다. 클라이언트는 이 토큰을 로컬 스토리지나 메모리에 저장하고, 이후 모든 API 요청의 Authorization 헤더에 이 토큰을 포함시킵니다.
서버는 매 요청마다 토큰의 서명을 검증해서 사용자를 인증합니다. 첫 번째 단계로, generateToken 함수가 사용자 정보를 받아 JWT를 생성합니다.
이 함수는 payload에 사용자 ID, 이메일, 역할 같은 정보를 넣되, 비밀번호 같은 민감 정보는 절대 포함하지 않습니다. jwt.sign()은 이 payload를 비밀키로 서명하고, expiresIn 옵션으로 토큰의 유효 기간을 설정합니다.
서명 덕분에 누군가 토큰을 조작하려고 하면 검증 단계에서 즉시 탐지됩니다. 두 번째로, authenticateToken 미들웨어가 실행되면서 요청 헤더에서 토큰을 추출합니다.
일반적으로 "Bearer TOKEN" 형식으로 전달되므로 공백으로 split 해서 토큰 부분만 가져옵니다. 토큰이 없으면 401(Unauthorized) 응답을 바로 반환해서 인증되지 않은 접근을 차단합니다.
세 번째 단계로, jwt.verify()가 토큰의 유효성을 검증합니다. 이 함수는 서명이 올바른지, 토큰이 만료되지 않았는지, 발급자(issuer)가 맞는지 등을 확인합니다.
검증에 성공하면 토큰 안에 담긴 payload를 디코딩해서 반환하고, 이를 req.user에 저장해서 다음 핸들러에서 사용할 수 있게 합니다. 실패하면 403(Forbidden) 응답을 반환합니다.
마지막으로, 보호된 라우트에서는 authenticateToken 미들웨어를 먼저 거치므로, 핸들러 함수에 도달했다는 것은 이미 인증이 완료되었다는 의미입니다. req.user를 통해 현재 사용자의 정보에 안전하게 접근할 수 있으며, 데이터베이스 조회 없이도 사용자 ID나 권한을 알 수 있습니다.
여러분이 이 방식을 사용하면 여러 실질적인 이점을 얻습니다. 첫째, 서버 확장이 매우 쉬워집니다.
어떤 서버가 요청을 받든 동일한 비밀키만 있으면 검증할 수 있어, 로드 밸런싱이나 오토스케일링이 간단합니다. 둘째, 모바일 앱, SPA, 서버 간 통신 등 다양한 클라이언트가 동일한 인증 메커니즘을 사용할 수 있습니다.
셋째, CORS 문제가 줄어듭니다. 쿠키와 달리 헤더로 전달되므로 다른 도메인에서도 쉽게 사용할 수 있습니다.
실무에서는 access token과 refresh token을 함께 사용하는 패턴이 일반적입니다. Access token은 짧은 유효기간(15분1시간)으로 설정하고, refresh token은 길게(7일30일) 설정해서 보안과 사용자 경험의 균형을 맞춥니다.
또한 로그아웃 시 토큰을 블랙리스트에 추가하거나, Redis에 토큰을 저장해서 강제 만료시키는 방법도 자주 사용됩니다.
실전 팁
💡 비밀키(SECRET_KEY)는 절대 코드에 하드코딩하지 말고 환경 변수로 관리하세요. 충분히 길고 복잡한 랜덤 문자열을 사용해야 하며, 정기적으로 갱신하는 것이 좋습니다.
💡 Payload에는 민감한 정보를 넣지 마세요. JWT는 암호화가 아니라 서명만 되므로, 누구나 디코딩해서 내용을 볼 수 있습니다. jwt.io 같은 사이트에서 토큰을 붙여넣으면 바로 내용이 보입니다.
💡 토큰의 유효기간을 적절히 설정하세요. 너무 길면 보안 위험이 크고, 너무 짧으면 사용자 경험이 나빠집니다. Access token은 짧게(15-60분), refresh token은 길게(7-30일) 설정하는 이중 토큰 전략을 추천합니다.
💡 XSS 공격을 방지하기 위해 토큰을 localStorage보다는 httpOnly 쿠키나 메모리에 저장하는 것을 고려하세요. 특히 민감한 서비스라면 토큰이 JavaScript로 접근 불가능하도록 해야 합니다.
💡 토큰에 jti(JWT ID) 클레임을 추가하면 특정 토큰을 강제로 무효화할 수 있습니다. 로그아웃이나 비밀번호 변경 시 Redis에 jti를 블랙리스트로 저장해서 해당 토큰의 사용을 막을 수 있습니다.
3. IP_기반_접근_제어
시작하며
여러분이 회사 내부 도구나 관리자 페이지를 운영할 때 이런 걱정을 해본 적 있나요? "이 페이지는 사무실에서만 접근할 수 있어야 하는데, 외부에서 접근하면 어떡하지?" 특히 민감한 데이터를 다루는 대시보드나 데이터베이스 관리 도구는 물리적으로 특정 위치에서만 접근 가능해야 하는 경우가 많습니다.
이런 요구사항을 애플리케이션 레벨에서 구현하려면 복잡합니다. 사용자의 IP를 확인하고, 화이트리스트와 비교하고, 로깅하는 코드를 모든 곳에 추가해야 하죠.
게다가 애플리케이션에 도달하기 전에 차단하지 못하면 DDoS 공격이나 무작위 대입 공격에 여전히 노출됩니다. 바로 이럴 때 필요한 것이 Nginx의 IP 기반 접근 제어입니다.
웹 서버 레벨에서 요청이 들어오는 순간 IP 주소를 확인해서 허용된 곳에서만 트래픽을 통과시키고, 나머지는 즉시 차단할 수 있습니다.
개요
간단히 말해서, IP 기반 접근 제어는 요청을 보낸 클라이언트의 IP 주소를 확인해서 특정 IP나 IP 대역만 허용하고 나머지는 거부하는 방식입니다. 이 방식이 필요한 이유는 네트워크 레벨의 보안이 가장 강력하기 때문입니다.
사무실 네트워크, VPN, 특정 데이터센터에서만 접근을 허용하면 물리적 보안 계층이 추가됩니다. 예를 들어, 데이터베이스 관리 도구(phpMyAdmin, Adminer)나 모니터링 대시보드(Grafana, Kibana)는 IP 제한 없이 인터넷에 노출되면 매우 위험합니다.
기존에는 방화벽 규칙을 설정하거나 애플리케이션 코드에 IP 체크 로직을 넣어야 했다면, Nginx에서는 몇 줄의 설정으로 훨씬 효율적으로 처리할 수 있습니다. 웹 서버가 먼저 걸러주므로 애플리케이션 서버는 허용된 트래픽만 받게 됩니다.
핵심 특징은 다음과 같습니다. 첫째, 매우 빠르게 동작합니다.
IP 체크는 단순한 문자열 비교나 비트 연산이므로 오버헤드가 거의 없습니다. 둘째, CIDR 표기법을 지원해서 전체 서브넷을 쉽게 허용하거나 거부할 수 있습니다.
셋째, 지역별 차단(geo-blocking)과 결합하면 특정 국가의 모든 IP를 차단하는 것도 가능합니다. 이러한 특징들은 보안 인프라의 첫 번째 방어선 역할을 합니다.
코드 예제
# Nginx 설정: IP 화이트리스트 방식
server {
listen 80;
server_name admin.example.com;
location /admin {
# 사무실 IP 허용 (단일 IP)
allow 203.0.113.50;
# 회사 네트워크 대역 허용 (CIDR 표기법)
allow 192.168.1.0/24;
# VPN 서버 IP 허용
allow 198.51.100.10;
# 위에 명시되지 않은 모든 IP 거부
deny all;
# 허용된 IP에서만 프록시
proxy_pass http://localhost:3000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
# 블랙리스트 방식 (특정 IP만 차단)
server {
listen 80;
server_name api.example.com;
location /api {
# 알려진 공격자 IP 차단
deny 198.51.100.99;
deny 203.0.113.0/25;
# 나머지 모든 IP 허용
allow all;
proxy_pass http://localhost:8080;
}
}
설명
이것이 하는 일: Nginx가 모든 HTTP 요청을 받을 때 가장 먼저 클라이언트의 IP 주소($remote_addr 변수)를 확인합니다. 설정 파일의 allow와 deny 디렉티브를 위에서 아래로 순서대로 평가해서 첫 번째로 매칭되는 규칙을 적용합니다.
허용되면 다음 단계로 진행하고, 거부되면 즉시 403 Forbidden 응답을 반환합니다. 첫 번째 예시(화이트리스트 방식)를 보면, 세 개의 allow 디렉티브가 특정 IP와 IP 대역을 명시합니다.
allow 203.0.113.50은 정확히 이 IP만 허용하고, allow 192.168.1.0/24는 192.168.1.0부터 192.168.1.255까지 256개의 IP를 모두 허용합니다. CIDR 표기법을 사용하면 서브넷 전체를 한 줄로 표현할 수 있어 매우 편리합니다.
마지막 deny all이 핵심인데, 위에서 허용되지 않은 모든 IP를 차단하는 안전장치 역할을 합니다. 두 번째 예시(블랙리스트 방식)는 반대 접근입니다.
특정 IP나 대역만 차단하고 나머지는 모두 허용합니다. 이는 대부분의 트래픽을 받아야 하지만 알려진 공격자나 문제가 되는 IP만 걸러내고 싶을 때 유용합니다.
deny 규칙들을 먼저 평가하고, 매칭되지 않으면 allow all이 적용되어 통과시킵니다. 중요한 점은 규칙의 순서입니다.
Nginx는 위에서 아래로 순차적으로 평가하므로, 더 구체적인 규칙을 위에, 일반적인 규칙을 아래에 배치해야 합니다. 예를 들어, deny all을 맨 위에 두면 아래의 모든 allow 규칙이 무시됩니다.
또한 프록시나 로드 밸런서 뒤에 있다면 $remote_addr 대신 X-Forwarded-For 헤더를 확인해야 실제 클라이언트 IP를 얻을 수 있습니다. 여러분이 이 설정을 사용하면 여러 보안 이점을 얻습니다.
첫째, 애플리케이션이 전혀 호출되지 않으므로 서버 리소스를 아낍니다. 차단된 요청은 Nginx에서 즉시 끝나므로 데이터베이스 연결이나 비즈니스 로직이 실행되지 않습니다.
둘째, 로그에서 허용/거부된 접근을 쉽게 추적할 수 있어 보안 감사가 편해집니다. 셋째, IP 기반 제어와 기본 인증, JWT 등을 함께 사용하면 다층 보안(defense in depth)을 구현할 수 있습니다.
실무에서는 관리자 패널, 데이터베이스 도구, 내부 API, CI/CD 웹훅 엔드포인트 등에 자주 적용됩니다. 특히 사무실 고정 IP나 회사 VPN이 있다면 매우 효과적인 보안 수단입니다.
클라우드 환경에서는 보안 그룹과 함께 사용하면 더욱 강력한 보호를 제공합니다.
실전 팁
💡 프록시나 로드 밸런서 뒤에 있다면 실제 클라이언트 IP를 올바르게 가져오도록 설정하세요. set_real_ip_from과 real_ip_header 디렉티브로 신뢰할 수 있는 프록시를 지정하고 X-Forwarded-For 헤더를 사용하도록 합니다.
💡 동적 IP를 사용하는 재택근무자를 위해 VPN 서버 IP를 화이트리스트에 추가하세요. 개인 IP를 직접 추가하는 대신 모든 직원이 VPN을 통해 접속하게 하면 관리가 훨씬 쉬워집니다.
💡 IP 변경이 잦은 환경에서는 geo 모듈로 별도 설정 파일에서 IP 목록을 관리하세요. /etc/nginx/allowed_ips.conf 같은 파일에 IP만 나열하고 include 하면 Nginx 재시작 없이 reload만으로 변경 사항을 적용할 수 있습니다.
💡 allow와 deny를 혼용할 때는 순서에 주의하세요. 더 구체적인 규칙을 먼저, 일반적인 규칙을 나중에 배치해야 합니다. 예를 들어 allow 192.168.1.50; deny 192.168.1.0/24; allow all; 순서로 하면 특정 IP만 예외로 허용할 수 있습니다.
💡 IP 기반 제어는 단독으로 사용하기보다 다른 인증 방법과 함께 사용하세요. satisfy any; 디렉티브로 "IP 화이트리스트에 있거나, 또는 기본 인증에 성공하면 허용" 같은 유연한 정책을 만들 수 있습니다.
4. CORS_교차_출처_리소스_공유
시작하며
여러분이 프론트엔드 개발을 하다가 이런 에러를 본 적 있나요? "Access to fetch has been blocked by CORS policy..." 특히 백엔드 API와 프론트엔드가 다른 도메인에 있을 때 이 에러가 자주 발생합니다.
로컬에서는 잘 되던 것이 배포하니 갑자기 API 호출이 막히는 경험은 정말 답답합니다. 이 문제는 브라우저의 보안 정책 때문에 발생합니다.
기본적으로 브라우저는 다른 도메인으로의 요청을 차단해서 악의적인 사이트가 사용자 정보를 훔쳐가는 것을 방지합니다. 하지만 정상적인 개발에서도 프론트엔드(app.example.com)와 API(api.example.com)가 다른 도메인인 경우가 많아, CORS 설정 없이는 통신 자체가 불가능합니다.
바로 이럴 때 필요한 것이 CORS(Cross-Origin Resource Sharing) 설정입니다. Nginx에서 적절한 헤더를 추가하면 브라우저가 교차 출처 요청을 안전하게 허용하도록 만들 수 있습니다.
개요
간단히 말해서, CORS는 서버가 특정 HTTP 헤더를 응답에 포함시켜서 "이 도메인에서 오는 요청은 신뢰할 수 있으니 허용해줘"라고 브라우저에게 알려주는 메커니즘입니다. 이 설정이 필요한 이유는 현대 웹 아키텍처의 특성 때문입니다.
SPA(React, Vue, Angular)는 정적 호스팅 서비스에 배포하고, API는 별도 서버에서 운영하는 경우가 일반적입니다. 예를 들어, 프론트엔드는 Netlify나 Vercel에, 백엔드는 AWS EC2나 Google Cloud Run에 배포하면 당연히 도메인이 다릅니다.
모바일 앱이나 다른 서비스에서도 API를 호출할 수 있게 하려면 CORS 설정이 필수입니다. 기존에는 JSONP 같은 해킹스러운 방법을 쓰거나, 프록시 서버를 중간에 두어야 했다면, CORS는 표준화된 방식으로 안전하게 교차 출처 요청을 처리합니다.
브라우저가 먼저 OPTIONS 메서드로 "preflight" 요청을 보내 권한을 확인하고, 서버가 허용하면 실제 요청을 진행합니다. 핵심 특징은 세 가지입니다.
첫째, Access-Control-Allow-Origin 헤더로 어떤 도메인을 허용할지 명시합니다. 둘째, Access-Control-Allow-Methods로 GET, POST, PUT, DELETE 등 허용할 HTTP 메서드를 지정합니다.
셋째, Access-Control-Allow-Headers로 커스텀 헤더(Authorization, Content-Type 등)를 허용할 수 있습니다. 이러한 헤더들이 브라우저와 서버 간의 신뢰 협상을 가능하게 합니다.
코드 예제
# Nginx CORS 설정 - 모든 도메인 허용 (개발용)
server {
listen 80;
server_name api.example.com;
location /api {
# OPTIONS 메서드 처리 (preflight)
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Requested-With';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Length' 0;
return 204;
}
# 실제 요청에 CORS 헤더 추가
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length, X-Total-Count' always;
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
# 프로덕션용 - 특정 도메인만 허용
server {
listen 80;
server_name api.example.com;
location /api {
# 허용할 도메인 목록
set $cors_origin "";
if ($http_origin ~* (https?://(app\.example\.com|mobile\.example\.com)$)) {
set $cors_origin $http_origin;
}
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE';
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
return 204;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
proxy_pass http://localhost:8080;
}
}
설명
이것이 하는 일: 브라우저가 다른 도메인의 API를 호출하려고 할 때, 먼저 OPTIONS 메서드로 preflight 요청을 보냅니다. Nginx는 이 요청을 감지하고 CORS 헤더들을 응답에 포함시켜 "이 도메인에서 이런 메서드와 헤더로 요청하는 것은 괜찮아"라고 브라우저에게 알려줍니다.
브라우저는 이 응답을 확인하고 안전하다고 판단되면 실제 요청을 진행합니다. 첫 번째 예시는 개발 환경에 적합한 설정입니다.
Access-Control-Allow-Origin: *는 모든 도메인에서의 요청을 허용합니다. 이는 로컬 개발(localhost:3000)이나 다양한 테스트 환경에서 편리하지만, 프로덕션에서는 보안상 위험합니다.
OPTIONS 요청에는 204(No Content)를 반환하고, Access-Control-Max-Age로 브라우저가 이 응답을 하루(86400초) 동안 캐시하도록 해서 매번 preflight를 보내지 않게 최적화합니다. 두 번째 예시는 프로덕션 환경용 설정입니다.
$http_origin을 정규식으로 검증해서 app.example.com과 mobile.example.com만 허용합니다. 변수 $cors_origin에 검증된 origin을 저장하고, 이를 헤더에 사용합니다.
중요한 점은 Access-Control-Allow-Credentials: true인데, 이는 쿠키나 Authorization 헤더를 포함한 요청을 허용한다는 의미입니다. 이 옵션을 사용할 때는 Access-Control-Allow-Origin을 와일드카드(*)로 설정할 수 없고 정확한 도메인을 명시해야 합니다.
add_header 디렉티브 끝에 always를 붙이는 이유는 중요합니다. 기본적으로 Nginx는 성공 응답(2xx, 3xx)에만 헤더를 추가하는데, always를 쓰면 4xx, 5xx 에러 응답에도 CORS 헤더가 포함됩니다.
이렇게 하지 않으면 인증 실패(401)나 권한 없음(403) 응답에 CORS 헤더가 없어서 브라우저가 에러 내용조차 읽을 수 없게 됩니다. Access-Control-Expose-Headers는 브라우저가 JavaScript에서 읽을 수 있는 커스텀 헤더를 지정합니다.
기본적으로 브라우저는 표준 헤더(Content-Type, Content-Length 등)만 노출하는데, API가 페이지네이션 정보를 X-Total-Count 같은 커스텀 헤더로 전달한다면 이를 명시해야 프론트엔드에서 읽을 수 있습니다. 여러분이 이 설정을 올바르게 적용하면 몇 가지 중요한 이점을 얻습니다.
첫째, 프론트엔드와 백엔드를 완전히 분리해서 독립적으로 배포하고 확장할 수 있습니다. 둘째, 모바일 앱, 웹 앱, 서드파티 서비스 등 다양한 클라이언트가 동일한 API를 사용할 수 있습니다.
셋째, CDN이나 정적 호스팅을 활용해서 프론트엔드 성능을 극대화할 수 있습니다. 실무에서는 개발과 프로덕션 환경에 따라 설정을 다르게 가져갑니다.
개발에서는 모든 origin을 허용하고, 스테이징에서는 특정 도메인만, 프로덕션에서는 더욱 엄격하게 제한합니다. 또한 Nginx의 map 디렉티브를 사용하면 화이트리스트 관리가 더 깔끔해집니다.
실전 팁
💡 프로덕션에서는 절대 Access-Control-Allow-Origin: *를 사용하지 마세요. 정확한 도메인 목록을 화이트리스트로 관리해야 하며, 정규식으로 여러 서브도메인을 안전하게 허용할 수 있습니다.
💡 쿠키나 인증 토큰을 사용한다면 반드시 Access-Control-Allow-Credentials: true를 설정하세요. 그리고 이 경우 origin을 와일드카드로 설정할 수 없으며, 프론트엔드에서도 credentials: 'include' 옵션을 fetch나 axios에 추가해야 합니다.
💡 add_header 끝에 always를 붙여서 에러 응답에도 CORS 헤더가 포함되게 하세요. 그렇지 않으면 401이나 403 에러가 발생해도 브라우저는 "CORS 에러"만 보여주고 실제 에러 내용을 숨깁니다.
💡 OPTIONS preflight 요청은 캐싱하세요. Access-Control-Max-Age를 충분히 길게 설정하면(86400초 = 1일) 브라우저가 반복적인 preflight 요청을 줄여서 성능이 향상됩니다.
💡 Nginx의 map 디렉티브로 화이트리스트를 깔끔하게 관리하세요. 여러 location에서 동일한 CORS 설정을 재사용할 수 있고, 도메인 추가/제거가 한 곳에서 관리됩니다.
5. OAuth_2.0_소셜_로그인_통합
시작하며
여러분이 새로운 서비스에 가입할 때 이런 경험 해보셨죠? "또 회원가입이야?
이메일 인증하고, 비밀번호 정하고, 정보 입력하고... 귀찮은데 그냥 구글 로그인으로 할까?" 실제로 많은 사용자들이 복잡한 회원가입 과정에서 이탈합니다.
통계에 따르면 소셜 로그인을 제공하면 전환율이 20-40% 증가한다고 합니다. 개발자 입장에서도 전통적인 회원가입은 복잡합니다.
비밀번호 암호화, 이메일 인증, 비밀번호 재설정, 개인정보 보호 등 신경 써야 할 것이 너무 많죠. 게다가 보안 취약점이 하나라도 있으면 사용자 데이터가 유출될 위험이 있습니다.
초기 스타트업이나 사이드 프로젝트에서 이 모든 것을 완벽하게 구현하기는 부담스럽습니다. 바로 이럴 때 필요한 것이 OAuth 2.0 기반의 소셜 로그인입니다.
구글, 네이버, 카카오 같은 신뢰할 수 있는 서비스에 인증을 위임하면, 사용자는 클릭 몇 번으로 가입하고, 개발자는 보안 부담을 크게 줄일 수 있습니다.
개요
간단히 말해서, OAuth 2.0은 사용자가 비밀번호를 직접 공유하지 않고도 제3자 애플리케이션에 자신의 정보 접근 권한을 안전하게 위임할 수 있는 표준 프로토콜입니다. 이 방식이 필요한 이유는 사용자 경험과 보안 두 마리 토끼를 모두 잡을 수 있기 때문입니다.
사용자는 이미 사용하는 구글이나 페이스북 계정으로 즉시 로그인할 수 있어 편리하고, 개발자는 구글 수준의 보안 인프라를 활용할 수 있습니다. 예를 들어, 2단계 인증(2FA)이나 이상 접근 탐지 같은 고급 보안 기능을 직접 구현하지 않아도 구글이 제공하는 보안을 그대로 활용할 수 있습니다.
기존에는 각 서비스마다 아이디와 비밀번호를 따로 만들고 관리해야 했다면, OAuth를 사용하면 하나의 신뢰할 수 있는 계정으로 여러 서비스를 사용할 수 있습니다. 사용자 입장에서는 비밀번호를 기억할 필요가 없고, 서비스 입장에서는 비밀번호를 저장하지 않아도 됩니다.
핵심 특징은 다음과 같습니다. 첫째, 표준 프로토콜이라 구글, 네이버, 카카오, 페이스북 등 모두 동일한 흐름으로 구현됩니다.
둘째, 권한 범위(scope)를 제한해서 필요한 정보만 요청할 수 있습니다(이메일만, 또는 프로필 사진도 포함 등). 셋째, 액세스 토큰과 리프레시 토큰으로 안전하게 사용자 정보에 접근합니다.
이러한 특징들이 현대 웹 서비스의 사실상 표준 인증 방식으로 만들었습니다.
코드 예제
// Node.js + Express에서 구글 OAuth 2.0 구현 (passport.js 사용)
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
// 구글 OAuth 전략 설정
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: 'https://example.com/auth/google/callback'
},
async (accessToken, refreshToken, profile, done) => {
// 사용자 정보로 DB에서 찾거나 생성
try {
let user = await User.findOne({ googleId: profile.id });
if (!user) {
// 새 사용자 생성
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
displayName: profile.displayName,
profilePhoto: profile.photos[0].value
});
}
return done(null, user);
} catch (error) {
return done(error, null);
}
}
));
// 로그인 시작 라우트
app.get('/auth/google',
passport.authenticate('google', {
scope: ['profile', 'email']
})
);
// 콜백 라우트 (구글이 리다이렉트하는 곳)
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
// 인증 성공 시 JWT 토큰 생성
const token = generateJWT(req.user);
res.redirect(`/dashboard?token=${token}`);
}
);
// 프론트엔드에서 사용 예시
// <a href="/auth/google">구글로 로그인</a>
설명
이것이 하는 일: 사용자가 "구글로 로그인" 버튼을 클릭하면 구글 로그인 페이지로 리다이렉트됩니다. 사용자가 구글에서 로그인하고 권한을 승인하면, 구글이 우리 서비스의 콜백 URL로 인증 코드를 보냅니다.
우리 서버는 이 코드를 사용해 구글 서버에서 액세스 토큰을 받고, 토큰으로 사용자 정보를 가져와서 회원가입하거나 로그인 처리합니다. 전체 과정에서 사용자의 구글 비밀번호는 우리 서비스에 전혀 노출되지 않습니다.
첫 번째 단계로, Passport.js의 GoogleStrategy를 설정합니다. 구글 개발자 콘솔에서 받은 Client ID와 Client Secret을 환경 변수로 관리하며, 절대 코드에 하드코딩하면 안 됩니다.
callbackURL은 구글이 인증 완료 후 리다이렉트할 우리 서비스의 URL입니다. 이 URL은 구글 콘솔에 등록된 것과 정확히 일치해야 합니다.
두 번째로, 사용자가 /auth/google에 접근하면 passport.authenticate('google')이 구글 로그인 페이지로 리다이렉트합니다. scope 매개변수로 어떤 정보를 요청할지 지정하는데, ['profile', 'email']은 기본 프로필과 이메일 주소를 의미합니다.
사용자가 구글에서 로그인하고 "이 앱이 내 정보에 접근하는 것을 허용합니다"를 클릭하면 다음 단계로 진행됩니다. 세 번째 단계에서 구글이 /auth/google/callback으로 인증 코드를 보내면, Passport가 자동으로 이 코드를 액세스 토큰으로 교환합니다.
그리고 토큰으로 구글 서버에서 사용자 정보(profile)를 가져옵니다. 콜백 함수 안에서 이 정보로 데이터베이스를 조회해서 기존 사용자인지 확인하고, 없으면 새로 생성합니다.
googleId를 저장해두면 다음 로그인 때 동일 사용자임을 식별할 수 있습니다. 마지막으로, 인증이 성공하면 우리 서비스의 JWT 토큰을 생성해서 프론트엔드로 전달합니다.
이후 모든 API 요청은 이 JWT로 인증하게 됩니다. 실패하면 /login으로 리다이렉트해서 사용자에게 다시 시도하도록 안내합니다.
전체 흐름이 완전히 자동화되어 있어서 사용자는 클릭 몇 번으로 가입과 로그인을 완료합니다. 여러분이 이 방식을 구현하면 여러 실질적인 이점을 얻습니다.
첫째, 사용자 전환율이 크게 향상됩니다. 복잡한 양식 대신 클릭 한 번이면 되니까요.
둘째, 비밀번호 관리 부담이 사라집니다. 비밀번호 해싱, 재설정, 강도 검증 등을 신경 쓸 필요가 없습니다.
셋째, 이메일 인증도 필요 없습니다. 구글이 이미 인증한 이메일이므로 신뢰할 수 있습니다.
넷째, 보안 사고 위험이 줄어듭니다. 사용자 비밀번호를 저장하지 않으니 유출될 것도 없습니다.
실무에서는 여러 소셜 로그인을 함께 제공하는 것이 일반적입니다. 구글, 네이버, 카카오를 모두 지원하면 사용자가 선호하는 방식을 선택할 수 있습니다.
또한 기존 이메일 로그인 사용자가 나중에 소셜 계정을 연동할 수 있는 기능도 제공하면 좋습니다. Passport.js는 100개 이상의 OAuth 제공자를 지원하므로 거의 모든 소셜 서비스와 통합할 수 있습니다.
실전 팁
💡 Client Secret은 절대 클라이언트 사이드 코드에 노출하지 마세요. OAuth 흐름은 반드시 서버 사이드에서 처리해야 하며, 환경 변수로 비밀키를 관리해야 합니다. 깃허브에 푸시하기 전에 .env 파일이 .gitignore에 있는지 확인하세요.
💡 필요한 최소한의 scope만 요청하세요. 사용자는 앱이 너무 많은 권한을 요구하면 불안해합니다. 이메일과 기본 프로필만 필요하다면 그것만 요청하고, 나중에 필요하면 추가 권한을 요청할 수 있습니다.
💡 소셜 로그인과 기존 이메일 계정을 연동하는 기능을 제공하세요. 사용자가 처음에 이메일로 가입했다가 나중에 구글 로그인을 추가하고 싶어할 수 있습니다. 이메일 주소를 기준으로 동일 사용자임을 식별하세요.
💡 State 매개변수로 CSRF 공격을 방어하세요. Passport.js는 자동으로 처리하지만, 직접 구현한다면 로그인 시작 시 랜덤 state를 생성하고 콜백에서 검증해야 합니다.
💡 사용자 정보를 과신하지 마세요. 소셜 로그인으로 받은 이메일도 사용자가 나중에 변경할 수 있습니다. 중요한 작업(결제, 민감 정보 변경)을 할 때는 추가 인증(2FA, 비밀번호 재확인)을 요구하는 것이 좋습니다.
6. API_Rate_Limiting_속도_제한
시작하며
여러분이 운영하는 API 서버가 갑자기 느려지거나 다운된 적 있나요? 로그를 확인해보니 한 사용자나 봇이 초당 수천 번의 요청을 보내고 있었다면?
이런 상황은 의도적인 DDoS 공격일 수도 있고, 버그가 있는 클라이언트 코드가 무한 루프에 빠진 것일 수도 있습니다. 어느 쪽이든 서버는 과부하로 정상 사용자들까지 피해를 봅니다.
이런 문제를 방치하면 서버 비용이 폭증하고, 서비스가 불안정해지며, 최악의 경우 완전히 다운될 수 있습니다. 특히 외부에 공개된 API나 인기 있는 서비스일수록 악의적이거나 실수로 인한 과도한 요청에 노출됩니다.
데이터베이스 쿼리가 많은 엔드포인트는 조금만 요청이 몰려도 치명적일 수 있죠. 바로 이럴 때 필요한 것이 Rate Limiting(속도 제한)입니다.
각 사용자나 IP가 일정 시간 동안 보낼 수 있는 요청 수를 제한해서 서버를 보호하고, 공정하게 리소스를 분배할 수 있습니다.
개요
간단히 말해서, Rate Limiting은 특정 클라이언트(IP 주소, API 키, 사용자 ID 등)가 일정 시간 동안 보낼 수 있는 요청 수를 제한하고, 초과하면 429(Too Many Requests) 응답을 반환하는 기법입니다. 이 기법이 필요한 이유는 서버 리소스는 유한하기 때문입니다.
모든 사용자가 공평하게 서비스를 이용하려면 한 사용자가 독점하지 못하도록 제한해야 합니다. 예를 들어, 검색 API가 있다면 "IP당 분당 100회"로 제한해서 봇이나 크롤러가 서버를 점유하는 것을 막을 수 있습니다.
또한 유료 API 서비스라면 요금제별로 다른 제한을 두어 수익 모델을 구현할 수도 있습니다. 기존에는 애플리케이션 코드에서 Redis로 요청 카운터를 관리했다면, Nginx의 limit_req 모듈을 사용하면 웹 서버 레벨에서 훨씬 효율적으로 처리할 수 있습니다.
애플리케이션에 도달하기 전에 차단되므로 데이터베이스나 비즈니스 로직이 전혀 호출되지 않아 서버 자원을 절약합니다. 핵심 특징은 다음과 같습니다.
첫째, 매우 빠르게 동작합니다. Nginx의 메모리 기반 카운터로 오버헤드가 거의 없습니다.
둘째, 유연한 제한 단위를 지원합니다. IP, 사용자 ID, API 키 등 어떤 기준으로도 제한할 수 있습니다.
셋째, Leaky Bucket 알고리즘으로 버스트 트래픽을 부드럽게 처리합니다. 이러한 특징들이 안정적인 서비스 운영의 필수 요소입니다.
코드 예제
# Nginx Rate Limiting 설정
http {
# 공유 메모리 영역 정의: IP 기반, 10MB 메모리 (약 16만 IP 추적 가능)
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
# 사용자 정의 변수로 API 키 기반 제한도 가능
# limit_req_zone $http_x_api_key zone=api_key_limit:10m rate=100r/s;
server {
listen 80;
server_name api.example.com;
# 일반 API 엔드포인트: 초당 10개 요청, 버스트 20개
location /api {
limit_req zone=api_limit burst=20 nodelay;
limit_req_status 429;
# 429 응답에 커스텀 헤더 추가
add_header X-RateLimit-Limit 10 always;
add_header X-RateLimit-Remaining $limit_req_remaining always;
proxy_pass http://localhost:8080;
}
# 로그인 엔드포인트: 분당 5개 요청 (무작위 대입 공격 방지)
location /auth/login {
limit_req zone=login_limit burst=2;
limit_req_status 429;
proxy_pass http://localhost:8080;
}
# 429 에러 커스텀 페이지
error_page 429 = @rate_limit_exceeded;
location @rate_limit_exceeded {
add_header Content-Type application/json always;
return 429 '{"error":"Rate limit exceeded. Please try again later.","retry_after":60}';
}
}
}
설명
이것이 하는 일: Nginx가 모든 요청을 받을 때 클라이언트의 식별자(IP 주소, API 키 등)를 확인하고, 공유 메모리 영역에서 해당 클라이언트의 요청 카운터를 업데이트합니다. 설정한 속도 제한을 초과하면 즉시 429 응답을 반환하고, 제한 내라면 다음 단계로 진행시킵니다.
이 모든 과정이 마이크로초 단위로 일어나므로 성능 영향이 거의 없습니다. 첫 번째 단계로, limit_req_zone 디렉티브가 공유 메모리 영역을 생성합니다.
$binary_remote_addr는 클라이언트 IP를 바이너리 형태로 사용해서 메모리를 절약합니다(문자열 대비 75% 절약). zone=api_limit:10m은 "api_limit"라는 이름으로 10MB 메모리를 할당하며, 이는 약 16만 개의 IP를 동시에 추적할 수 있습니다.
rate=10r/s는 초당 10개 요청을 의미하며, r/m(분당), r/h(시간당)도 사용 가능합니다. 두 번째로, limit_req 디렉티브가 실제 제한을 적용합니다.
zone=api_limit는 위에서 정의한 메모리 영역을 사용하라는 의미입니다. burst=20은 순간적인 트래픽 폭증을 허용하는 버퍼입니다.
평균적으로 초당 10개를 유지하되, 짧은 순간에는 최대 30개(기본 10 + 버스트 20)까지 허용합니다. 이는 Leaky Bucket 알고리즘으로 구현되어 일시적인 스파이크를 부드럽게 처리합니다.
nodelay 옵션이 중요합니다. 기본적으로 버스트 요청은 큐에 들어가서 속도에 맞춰 천천히 처리되는데, nodelay를 쓰면 버스트 범위 내의 요청은 즉시 처리됩니다.
사용자 경험이 훨씬 좋아지지만, 순간적인 서버 부하는 약간 높아질 수 있습니다. API 특성에 따라 선택하세요.
로그인 엔드포인트는 더 엄격하게 제한합니다. rate=5r/m으로 분당 5회만 허용하고, burst=2로 버스트도 작게 설정해서 무작위 대입 공격(brute-force attack)을 효과적으로 방어합니다.
공격자가 수천 개의 비밀번호를 시도하려 해도 분당 5~7회로 제한되므로 현실적으로 불가능합니다. 커스텀 에러 페이지와 헤더로 사용자 경험을 개선할 수 있습니다.
X-RateLimit-Limit와 X-RateLimit-Remaining 헤더를 응답에 추가하면 클라이언트가 남은 요청 수를 알 수 있고, 429 에러 시 JSON 형태로 명확한 메시지와 재시도 대기 시간을 안내할 수 있습니다. 여러분이 이 설정을 적용하면 여러 중요한 이점을 얻습니다.
첫째, 서버가 안정적으로 유지됩니다. 과도한 요청으로 인한 다운타임을 방지할 수 있습니다.
둘째, 비용을 절감할 수 있습니다. 클라우드 환경에서 CPU나 네트워크 사용량이 줄어듭니다.
셋째, 공정한 서비스를 제공합니다. 모든 사용자가 동등하게 리소스를 사용할 수 있습니다.
넷째, 보안이 강화됩니다. 크롤링, 스크래핑, 무작위 대입 공격을 효과적으로 차단합니다.
실무에서는 엔드포인트마다 다른 제한을 적용합니다. 데이터 조회는 느슨하게, 데이터 생성/수정은 엄격하게, 로그인은 매우 엄격하게 설정합니다.
또한 프리미엄 사용자에게는 더 높은 제한을 주는 등 비즈니스 모델과 연계할 수 있습니다. Redis와 연동하면 여러 Nginx 서버 간에 제한을 공유해서 분산 환경에서도 정확하게 작동합니다.
실전 팁
💡 burst 값을 적절히 설정하세요. 너무 작으면 정상적인 사용자도 차단되고, 너무 크면 보호 효과가 줄어듭니다. 일반적으로 rate의 2배 정도가 적당하며, 실제 트래픽 패턴을 분석해서 조정하세요.
💡 민감한 엔드포인트는 더 엄격하게 제한하세요. 로그인, 회원가입, 비밀번호 재설정 같은 곳은 분당 5~10회 정도로 낮게 설정해서 자동화된 공격을 막아야 합니다.
💡 사용자에게 명확한 피드백을 주세요. 429 응답에 Retry-After 헤더와 함께 언제 다시 시도할 수 있는지 알려주면 사용자 경험이 좋아집니다. JSON 응답에 남은 요청 수도 포함하면 더욱 친절합니다.
💡 화이트리스트 기능을 활용하세요. 신뢰할 수 있는 내부 서버나 파트너사 IP는 limit_req를 적용하지 않도록 geo 모듈과 조합할 수 있습니다.
💡 로그를 모니터링해서 제한에 자주 걸리는 IP를 분석하세요. 정상 사용자가 제한에 걸린다면 설정을 조정하고, 악의적인 봇이라면 완전히 차단하는 것을 고려하세요. Fail2ban과 연동하면 자동화할 수 있습니다.
7. HTTPS_SSL_TLS_암호화_통신
시작하며
여러분이 카페 와이파이를 사용할 때 이런 생각 해보셨나요? "내 로그인 정보가 네트워크에 그대로 노출되는 건 아닐까?" 실제로 암호화되지 않은 HTTP 통신은 중간자 공격(Man-in-the-Middle)에 매우 취약합니다.
같은 네트워크에 있는 누구나 Wireshark 같은 도구로 여러분의 비밀번호, 개인정보, 세션 쿠키를 가로챌 수 있습니다. 이런 위험 때문에 구글은 2014년부터 HTTPS를 검색 순위 요소로 포함시켰고, 현대의 브라우저들은 HTTP 사이트에 "안전하지 않음" 경고를 표시합니다.
실제로 사용자들은 자물쇠 아이콘이 없는 사이트에서 결제나 개인정보 입력을 꺼립니다. 보안뿐 아니라 신뢰와 SEO에도 직접적인 영향을 미칩니다.
바로 이럴 때 필요한 것이 HTTPS(SSL/TLS) 암호화 통신입니다. Let's Encrypt 같은 무료 인증서를 Nginx에 설정하면 모든 통신을 암호화해서 중간에서 누가 가로채도 내용을 읽을 수 없게 만들 수 있습니다.
개요
간단히 말해서, HTTPS는 HTTP 통신을 TLS(Transport Layer Security) 프로토콜로 암호화해서 클라이언트와 서버 간에 주고받는 모든 데이터를 보호하는 기술입니다. 이 기술이 필요한 이유는 인터넷 통신은 기본적으로 여러 중간 노드를 거치기 때문입니다.
여러분의 브라우저에서 서버까지 데이터는 ISP, 라우터, 프록시 등을 통과하는데, 암호화 없이는 이 중간 지점 어디서든 내용을 엿볼 수 있습니다. 예를 들어, 공용 와이파이에서 HTTP로 로그인하면 같은 네트워크의 누군가가 여러분의 세션을 훔쳐 계정을 탈취할 수 있습니다.
기존에는 SSL 인증서 비용이 비싸고 설정이 복잡해서 쇼핑몰이나 은행 같은 곳만 사용했다면, Let's Encrypt가 등장한 2015년 이후로는 누구나 무료로 HTTPS를 사용할 수 있게 되었습니다. Certbot 같은 도구로 자동화하면 인증서 갱신도 전혀 신경 쓸 필요가 없습니다.
핵심 특징은 다음과 같습니다. 첫째, 종단 간 암호화로 중간자 공격을 완벽히 차단합니다.
둘째, 인증서로 서버의 신원을 검증해서 피싱 사이트를 구별할 수 있습니다. 셋째, HTTP/2와 HTTP/3는 HTTPS 위에서만 작동하므로 성능 향상을 위해서도 필수입니다.
이러한 특징들이 HTTPS를 현대 웹의 필수 요소로 만들었습니다.
코드 예제
# Nginx HTTPS 설정 with Let's Encrypt
server {
# HTTP는 HTTPS로 리다이렉트
listen 80;
server_name example.com www.example.com;
# Let's Encrypt 인증서 발급을 위한 경로는 허용
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# 나머지 모든 트래픽은 HTTPS로 리다이렉트
location / {
return 301 https://$server_name$request_uri;
}
}
server {
# HTTPS 서버
listen 443 ssl http2;
server_name example.com www.example.com;
# SSL 인증서 경로
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# SSL 보안 설정 (Mozilla Intermediate 권장)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
ssl_prefer_server_ciphers off;
# OCSP Stapling으로 성능 향상
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
# HSTS (HTTP Strict Transport Security) 활성화
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 추가 보안 헤더
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Let's Encrypt 인증서 자동 갱신 (crontab)
# 0 0 * * * certbot renew --quiet && systemctl reload nginx
설명
이것이 하는 일: 사용자가 사이트에 접속하면 브라우저와 서버가 TLS 핸드셰이크를 수행해서 암호화 키를 교환합니다. 이후 모든 HTTP 트래픽은 이 키로 암호화되어 전송되므로, 중간에 누가 패킷을 가로채도 암호화된 데이터만 보이고 실제 내용은 읽을 수 없습니다.
브라우저는 서버의 인증서를 검증해서 정말 의도한 서버가 맞는지 확인합니다. 첫 번째 서버 블록은 HTTP(포트 80)로 들어오는 모든 요청을 HTTPS(포트 443)로 리다이렉트합니다.
return 301은 영구 리다이렉트를 의미하며, 검색 엔진도 이를 인식해서 HTTPS URL을 색인합니다. 예외적으로 /.well-known/acme-challenge/ 경로는 허용하는데, 이는 Let's Encrypt가 도메인 소유권을 확인하는 데 사용되므로 인증서 발급과 갱신을 위해 필요합니다.
두 번째 서버 블록이 실제 HTTPS 서버입니다. listen 443 ssl http2는 HTTPS와 HTTP/2를 동시에 활성화합니다.
HTTP/2는 HTTPS 위에서만 작동하며, 멀티플렉싱과 헤더 압축으로 성능을 크게 향상시킵니다. ssl_certificate와 ssl_certificate_key는 Let's Encrypt가 발급한 인증서 파일들을 가리킵니다.
보안 설정이 매우 중요합니다. ssl_protocols TLSv1.2 TLSv1.3는 안전한 최신 프로토콜만 사용하도록 합니다.
오래된 TLSv1.0과 TLSv1.1은 취약점이 있어 제외합니다. ssl_ciphers는 강력한 암호화 알고리즘만 허용하며, Mozilla의 SSL Configuration Generator를 사용하면 최신 권장 설정을 얻을 수 있습니다.
너무 오래된 브라우저 지원을 포기하면 더 강력한 보안을 적용할 수 있습니다. HSTS(HTTP Strict Transport Security) 헤더는 추가 보안 계층입니다.
max-age=31536000은 1년 동안 브라우저가 이 도메인을 항상 HTTPS로만 접속하도록 강제합니다. 사용자가 실수로 http:// URL을 입력해도 브라우저가 자동으로 https://로 바꿔줍니다.
includeSubDomains는 모든 서브도메인에도 적용한다는 의미입니다. 한 번 설정되면 되돌리기 어려우니 처음에는 짧은 max-age로 테스트하세요.
OCSP Stapling은 성능 최적화 기법입니다. 기본적으로 브라우저는 인증서가 취소되지 않았는지 확인하기 위해 인증 기관(CA)에 별도 요청을 보내는데, 이는 지연을 발생시킵니다.
OCSP Stapling을 활성화하면 서버가 미리 CA에서 받은 응답을 함께 전달해서 추가 요청이 필요 없습니다. 여러분이 HTTPS를 적용하면 여러 중요한 이점을 얻습니다.
첫째, 사용자 데이터가 완벽히 보호됩니다. 비밀번호, 결제 정보, 개인 메시지가 암호화되어 안전합니다.
둘째, SEO 순위가 향상됩니다. 구글은 HTTPS 사이트를 선호합니다.
셋째, 브라우저 경고가 사라집니다. 주소창에 자물쇠 아이콘이 표시되어 신뢰를 줍니다.
넷째, 최신 웹 기술을 사용할 수 있습니다. Service Worker, HTTP/2, Geolocation API 등은 HTTPS를 요구합니다.
실무에서는 Certbot으로 인증서 발급과 갱신을 완전히 자동화합니다. certbot --nginx 명령 한 번이면 Nginx 설정까지 자동으로 해주고, cron 작업으로 90일마다 자동 갱신됩니다.
와일드카드 인증서(*.example.com)도 DNS 챌린지로 발급받을 수 있어 여러 서브도메인을 한 번에 커버할 수 있습니다.
실전 팁
💡 인증서는 자동 갱신되도록 설정하세요. Let's Encrypt 인증서는 90일마다 만료되므로 cron 작업으로 certbot renew를 매일 실행하는 것이 안전합니다. Certbot이 만료 30일 전부터만 갱신하므로 매일 실행해도 문제없습니다.
💡 HSTS를 처음 적용할 때는 짧은 max-age(예: 300초)로 테스트하세요. 문제가 없으면 점진적으로 늘려서 최종적으로 1년(31536000초)으로 설정합니다. 한 번 긴 max-age를 설정하면 되돌리기 어렵습니다.
💡 모든 리소스(이미지, CSS, JS)도 HTTPS로 로드하세요. Mixed Content(HTTPS 페이지에서 HTTP 리소스 로드)는 브라우저가 차단하거나 경고합니다. 상대 경로(/images/logo.png)나 프로토콜 상대 경로(//cdn.example.com/style.css)를 사용하세요.
💡 SSL Labs(ssllabs.com/ssltest)로 설정을 검증하세요. A+ 등급을 받으면 보안 설정이 우수하다는 의미이며, 취약점이 있으면 구체적으로 알려줍니다.
💡 CDN을 사용한다면 CDN에서 HTTPS를 제공하는지 확인하세요. Cloudflare 같은 무료 CDN은 HTTPS를 자동으로 제공하며, origin 서버와 CDN 간에도 암호화(Full SSL)를 적용할 수 있습니다.
8. 세션_기반_인증_관리
시작하며
여러분이 웹사이트에 로그인한 후 페이지를 이동해도 로그인 상태가 유지되는 경험, 당연하게 느끼시죠? 하지만 HTTP 프로토콜은 기본적으로 "stateless", 즉 상태를 기억하지 못합니다.
매 요청이 독립적이어서 서버는 "이 사람이 방금 로그인한 그 사람"인지 알 수 없습니다. 세션 없이는 매 페이지마다 다시 로그인해야 하는 끔찍한 상황이 벌어집니다.
이 문제를 해결하지 않으면 사용자 경험이 최악이 됩니다. 쇼핑몰에서 장바구니에 상품을 담았는데 페이지를 넘기면 사라진다고 상상해보세요.
또한 "누가 이 요청을 보냈는지" 알 수 없으면 권한 관리도 불가능합니다. 관리자와 일반 사용자를 구별할 수 없으니 보안도 엉망이 됩니다.
바로 이럴 때 필요한 것이 세션 기반 인증입니다. 서버가 로그인한 사용자마다 고유한 세션을 생성하고, 클라이언트는 세션 ID를 쿠키로 저장해서 매 요청마다 보냅니다.
이렇게 하면 stateless한 HTTP 위에서도 상태를 유지할 수 있습니다.
개요
간단히 말해서, 세션 기반 인증은 사용자가 로그인하면 서버가 고유한 세션 ID를 생성해서 서버 메모리나 데이터베이스에 저장하고, 클라이언트는 이 세션 ID를 쿠키로 받아서 이후 모든 요청에 포함시키는 방식입니다. 이 방식이 필요한 이유는 웹 애플리케이션의 기본적인 사용자 경험을 제공하기 위해서입니다.
소셜 미디어에서 글을 쓰고, 온라인 쇼핑을 하고, 대시보드에서 데이터를 조회하는 등 거의 모든 웹 서비스가 "누가 이 작업을 하는가"를 알아야 합니다. 예를 들어, 뱅킹 앱에서 계좌 잔액을 조회할 때 내 계좌인지 확인하려면 세션으로 사용자를 식별해야 합니다.
기존에는 매 요청마다 사용자 이름과 비밀번호를 전송하는 방식도 있었지만, 이는 매우 비효율적이고 위험합니다. 세션을 사용하면 한 번 인증 후 세션 ID만 주고받으므로 안전하고 빠릅니다.
세션 ID는 추측 불가능한 랜덤 문자열이라 탈취되지 않는 한 안전합니다. 핵심 특징은 다음과 같습니다.
첫째, 서버에 상태를 저장하므로(stateful) 언제든 세션을 무효화할 수 있습니다. 사용자가 로그아웃하거나 관리자가 강제 로그아웃시키면 즉시 효력을 잃습니다.
둘째, 세션 데이터에 사용자 정보뿐 아니라 장바구니, 임시 설정 등 다양한 정보를 저장할 수 있습니다. 셋째, httpOnly 쿠키로 JavaScript 접근을 막아 XSS 공격을 방어할 수 있습니다.
이러한 특징들이 전통적인 웹 애플리케이션의 표준 인증 방식으로 만들었습니다.
코드 예제
// Node.js + Express에서 세션 기반 인증 구현
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');
const bcrypt = require('bcrypt');
const app = express();
// Redis 클라이언트 생성 (세션 저장용)
const redisClient = redis.createClient({
host: 'localhost',
port: 6379
});
// 세션 미들웨어 설정
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // 세션 암호화 키
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS에서만 전송
httpOnly: true, // JavaScript 접근 차단
maxAge: 1000 * 60 * 60 * 24 * 7, // 7일
sameSite: 'strict' // CSRF 방지
}
}));
// 로그인 라우트
app.post('/login', async (req, res) => {
const { email, password } = req.body;
// 사용자 조회
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 비밀번호 검증
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 세션에 사용자 정보 저장
req.session.userId = user.id;
req.session.role = user.role;
req.session.email = user.email;
res.json({ message: 'Login successful', user: { id: user.id, email: user.email } });
});
// 인증 미들웨어
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
// 보호된 라우트
app.get('/api/profile', requireAuth, async (req, res) => {
// 세션에서 사용자 ID 가져오기
const user = await User.findById(req.session.userId);
res.json(user);
});
// 로그아웃
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('connect.sid'); // 세션 쿠키 삭제
res.json({ message: 'Logout successful' });
});
});
설명
이것이 하는 일: 사용자가 로그인에 성공하면 서버가 고유한 세션 ID를 생성하고, 이 ID를 키로 해서 사용자 정보를 Redis 같은 세션 저장소에 저장합니다. 동시에 브라우저에게 이 세션 ID를 쿠키로 보냅니다.
브라우저는 이후 모든 요청에 이 쿠키를 자동으로 포함시키고, 서버는 쿠키의 세션 ID로 저장소를 조회해서 "아, 이 사람은 로그인한 사용자 123번이구나"를 알아냅니다. 첫 번째 단계로, 세션 미들웨어를 설정합니다.
express-session은 Node.js의 표준 세션 라이브러리입니다. store 옵션으로 Redis를 사용하는데, 이는 메모리 기반 저장보다 훨씬 좋습니다.
서버를 재시작해도 세션이 유지되고, 여러 서버 인스턴스가 동일한 Redis를 공유하면 로드 밸런싱 환경에서도 작동합니다. 메모리 저장소는 개발용으로만 적합합니다.
쿠키 옵션이 보안에 매우 중요합니다. secure: true는 HTTPS 연결에서만 쿠키를 전송하도록 강제해서 네트워크 스니핑을 방지합니다.
httpOnly: true는 JavaScript에서 document.cookie로 접근할 수 없게 만들어 XSS 공격이 성공해도 세션을 훔쳐갈 수 없게 합니다. sameSite: 'strict'는 다른 도메인에서 오는 요청에 쿠키를 포함하지 않아 CSRF 공격을 차단합니다.
로그인 처리를 보면, 비밀번호를 bcrypt로 검증한 후 req.session 객체에 사용자 정보를 저장합니다. Express-session이 자동으로 이 데이터를 직렬화해서 Redis에 저장하고, 응답 헤더에 Set-Cookie를 추가해서 브라우저에게 세션 ID를 전달합니다.
민감한 정보(비밀번호 해시 등)는 세션에 저장하지 말고, 사용자 ID만 저장한 후 필요할 때 데이터베이스에서 조회하는 것이 좋습니다. 인증 미들웨어 requireAuth는 보호된 라우트 앞에 배치됩니다.
req.session.userId가 있는지 확인해서 로그인 여부를 판단합니다. 세션 ID는 브라우저가 자동으로 쿠키에 포함시켜 보내고, express-session이 자동으로 파싱해서 req.session에 채워주므로 개발자는 매우 간단하게 사용할 수 있습니다.
로그인되지 않았으면 401 응답을 반환해서 클라이언트가 로그인 페이지로 리다이렉트하도록 합니다. 로그아웃은 req.session.destroy()로 서버의 세션 데이터를 삭제하고, res.clearCookie()로 브라우저의 쿠키도 제거합니다.
이렇게 하면 클라이언트와 서버 양쪽에서 모두 세션이 사라져 완전히 로그아웃됩니다. 세션만 삭제하고 쿠키를 남겨두면 브라우저가 무효한 세션 ID를 계속 보내게 되어 불필요한 오류가 발생할 수 있습니다.
여러분이 세션 기반 인증을 구현하면 여러 이점을 얻습니다. 첫째, 즉시 로그아웃이 가능합니다.
JWT와 달리 서버에 상태가 있으므로 세션을 삭제하면 즉시 효력을 잃습니다. 둘째, 세션에 다양한 데이터를 저장할 수 있습니다.
장바구니, 임시 설정, 진행 중인 작업 등을 세션에 보관해서 UX를 향상시킬 수 있습니다. 셋째, 관리자가 특정 사용자의 세션을 강제로 종료할 수 있습니다.
보안 사고 대응이나 계정 정지 시 유용합니다. 실무에서는 세션 타임아웃을 적절히 설정합니다.
뱅킹 앱은 15분, 일반 웹사이트는 2주 정도가 일반적입니다. 또한 "Remember Me" 기능은 별도의 영구 토큰을 사용하고, 일반 세션은 짧게 유지하는 이중 전략을 씁니다.
민감한 작업(비밀번호 변경, 결제)은 세션이 있어도 비밀번호를 다시 확인하는 것이 안전합니다.
실전 팁
💡 프로덕션에서는 반드시 Redis나 Memcached 같은 외부 저장소를 사용하세요. 메모리 저장소는 서버 재시작 시 모든 사용자가 로그아웃되고, 로드 밸런싱 환경에서 작동하지 않습니다.
💡 세션 고정 공격(Session Fixation)을 방어하려면 로그인 성공 시 세션 ID를 재생성하세요. req.session.regenerate()를 호출하면 이전 세션 ID는 무효화되고 새 ID가 발급됩니다.
💡 쿠키에 sameSite: 'strict' 또는 'lax'를 설정해서 CSRF 공격을 방어하세요. Strict는 더 안전하지만 외부 링크에서 접근 시 로그인이 풀릴 수 있으므로, lax가 일반적으로 더 나은 선택입니다.
💡 세션 데이터는 최소한으로 유지하세요. 사용자 ID, 역할 정도만 저장하고, 상세 정보는 필요할 때 데이터베이스에서 조회합니다. 세션에 너무 많은 데이터를 저장하면 Redis 메모리와 네트워크 대역폭을 낭비합니다.
💡 Rolling session으로 활성 사용자의 세션을 자동 연장하세요. resave: false, rolling: true, saveUninitialized: false 설정으로 사용자가 활동할 때마다 만료 시간이 갱신되어, 사용 중에 갑자기 로그아웃되는 불편을 방지할 수 있습니다.