Summary
웹 인증과 보안은 사용자 신원 확인과 권한 관리를 통해 디지털 자산을 보호하는 핵심 요소다. 세션 기반 인증과 JWT 토큰 기반 인증은 각각 상태 유지 방식과 무상태 방식으로 접근하며, OAuth 2.0은 사용자가 제3자 앱에 자신의 계정 접근 권한을 안전하게 위임할 수 있게 한다. CORS, CSRF, XSS 등 웹 보안 위협에 대한 적절한 방어 기법을 구현하는 것이 중요하며, Next.js와 NextAuth.js 같은 현대적인 프레임워크와 라이브러리는 안전한 인증 시스템 구축에 도움을 준다.
Intro
최근 SKT와 알바몬 등 대형 서비스들의 개인정보 유출 사건이 잇따라 발생하면서 웹 보안의 중요성이 다시 한번 강조되고 있다. 나 역시 새로운 프로젝트를 진행하면서 사용자 인증(Authentication)과 인가(Authorization) 시스템을 구현해야 할 필요가 생겼고, 이 기회에 관련 개념들을 정리해보고자 한다. 보안은 개발자가 항상 염두에 두어야 할 부분이지만, 실제 구현 과정에서는 종종 간과되기도 한다. 이 글을 통해 웹 보안의 기본 개념부터 실제 구현까지 체계적으로 정리해보려 한다.
1. 인증과 인가의 기본 개념
인증(Authentication)과 인가(Authorization)의 차이
인증과 인가는 웹 보안의 핵심 개념이지만 종종 혼동되곤 한다. 쉽게 말해 인증은 “당신이 누구인지” 확인하는 과정이며, 사용자의 신원을 검증하는 단계다. 반면 인가는 “당신이 무엇을 할 수 있는지” 결정하는 과정으로, 이미 인증된 사용자에게 특정 리소스나 기능에 대한 접근 권한을 부여하는 것을 의미한다.
일상에서 비유하자면, 건물에 들어갈 때 ID 카드로 자신을 확인받는 과정이 ‘인증’이라면, 그 ID 카드에 따라 건물의 특정 층이나 방에 접근할 수 있는지를 결정하는 것은 ‘인가’라고 볼 수 있다.
인증의 종류
인증 방식은 크게 네 가지로 나눌 수 있다.
- 비밀번호나 PIN, 보안 질문 같이 사용자가 알고 있는 정보를 이용하는 지식 기반 인증
- 스마트폰이나 보안 토큰, 스마트카드처럼 사용자가 물리적으로 소유한 것을 활용하는 소유 기반 인증
- 지문, 얼굴 인식, 홍채 스캔과 같은 생체 정보를 활용하는 생체 기반 인증
- 이러한 방식 중 두 가지 이상을 조합하는 다중 요소 인증(MFA)
권한 관리 모델
권한을 관리하는 방식에도 여러 모델이 있다. 사용자 역할에 따라 권한을 부여하는 역할 기반 접근 제어(RBAC)는 가장 널리 사용되는 모델이다. 이외에도 사용자, 리소스, 환경의 다양한 속성에 기반한 접근 제어 방식인 속성 기반 접근 제어(ABAC), 미리 정의된 중앙 집중식 정책에 따라 권한을 부여하는 정책 기반 접근 제어, 그리고 사용자 간의 관계를 기반으로 권한을 관리하는 관계 기반 접근 제어(ReBAC) 등 다양한 모델이 존재한다.
2. 웹 인증 메커니즘
세션 기반 인증
세션 기반 인증은 서버에서 상태를 유지하는 전통적인 인증 방식이다. 이 방식에서는 사용자가 로그인하면 서버가 세션 ID를 생성하고, 이 ID를 쿠키에 저장하여 클라이언트에 전송한다. 이후 사용자가 요청을 보낼 때마다 이 쿠키와 함께 세션 ID가 전송되며, 서버는 이 ID를 검증하여 사용자를 식별한다.
세션 기반 인증의 장점은 구현이 비교적 간단하고 세션 데이터가 서버에 안전하게 보관된다는 점이다. 그러나 여러 서버를 운영할 경우 세션 정보를 공유해야 하는 확장성 문제가 있으며, CSRF 공격에 취약할 수 있다는 단점이 있다.
토큰 기반 인증 (JWT)
JWT(JSON Web Token)는 상태를 유지하지 않는(stateless) 인증 방식이다. 사용자가 로그인하면 서버는 헤더, 페이로드, 서명으로 구성된 JWT를 생성하여 클라이언트에 반환한다. 클라이언트는 이 토큰을 저장해두었다가 이후 요청을 보낼 때마다 헤더에 포함시킨다. 서버는 받은 토큰의 유효성을 검증하여 인증을 처리한다.
JWT의 가장 큰 특징은 토큰 자체에 정보를 담을 수 있다는 점이다. 대개 사용자 ID와 권한 정보 등이 토큰의 페이로드에 포함되며, 이를 통해 서버는 별도의 세션 저장소 없이도 사용자를 식별할 수 있다. 그러나 이런 특성 때문에 민감한 정보는 토큰에 포함시키지 않아야 한다. 또한 토큰 만료 시간을 적절히 설정하는 것도 중요하다. 너무 길면 보안 위험이 증가하고, 너무 짧으면 사용자 경험이 저하될 수 있다.
JWT vs 세션 기반 인증
JWT와 세션 기반 인증의 주요 차이점:
상태 관리: 세션 기반 인증은 서버에서 상태를 유지(stateful)하는 반면, JWT는 상태를 유지하지 않는(stateless) 방식이다.
저장 위치: 세션 기반 인증은 세션 ID만 클라이언트에 저장하고 실제 데이터는 서버에 저장한다. JWT는 모든 필요한 정보를 토큰 자체에 인코딩하여 클라이언트에 저장한다.
확장성: 세션 기반 인증은 여러 서버 간에 세션 정보를 공유해야 하므로 확장하기 어렵다. JWT는 서버가 무상태로 동작할 수 있어 확장성이 뛰어나다.
성능: JWT는 모든 요청마다 토큰을 검증해야 하므로 약간의 성능 오버헤드가 있을 수 있다. 세션은 데이터베이스 조회가 필요할 수 있어 다른 방식의 오버헤드가 있다.
보안: 세션 기반 인증은 중요 데이터가 서버에 저장되어 상대적으로 안전하다. JWT는 토큰이 탈취되면 만료되기 전까지 무효화하기 어렵다.
JWT의 구조와 작동 방식
JWT의 구조:
JWT는 세 부분으로 구성된다:
헤더(Header): 토큰 유형과 사용된 암호화 알고리즘 정보를 담는다.
{ "alg": "HS256", "typ": "JWT" }
페이로드(Payload): 클레임(claim)이라 불리는 사용자 데이터와 메타데이터를 포함한다.
{ "sub": "1234567890", "name": "John Doe", "role": "admin", "iat": 1516239022, "exp": 1516242622 }
서명(Signature): 헤더와 페이로드를 Base64URL로 인코딩하고, 시크릿 키로 서명한 부분이다.
이 세 부분은 점(.)으로 구분되어 하나의 문자열로 표현된다.
JWT 검증 과정:
- 클라이언트가 JWT를 요청 헤더에 포함하여 서버에 전송
- 서버는 JWT의 서명을 비밀 키를 사용해 검증
- 서명이 유효하면 페이로드의 정보를 신뢰하고 요청 처리
- 만료 시간(exp)을 확인하여 토큰의 유효성 검사
Stateless 아키텍처
Stateless(무상태)란?
Stateless는 서버가 클라이언트의 상태 정보를 저장하지 않는 아키텍처 방식을 의미한다. 각 요청은 이전 요청과 독립적으로 처리되며, 모든 필요한 정보가 요청 자체에 포함되어야 한다.
장점:
- 여러 서버 간에 상태를 공유할 필요가 없어 수평적 확장이 용이함
- 서버 측에서 상태 관리 로직이 필요 없어 구현이 단순함
- 서버 장애 시에도 클라이언트는 다른 서버로 요청을 전송할 수 있음
단점:
- 각 요청마다 인증 정보 등 중복되는 데이터를 전송해야 함
- 토큰 폐기나 갱신 같은 관리 작업이 복잡해질 수 있음
JWT는 대표적인 stateless 인증 방식으로, 서버가 사용자 세션 상태를 유지할 필요 없이 토큰 자체에 필요한 모든 정보를 담아 인증을 처리한다.
3. OAuth 2.0과 소셜 로그인
요즘 많은 웹 애플리케이션에서는 직접 계정을 만들지 않고도 구글, 페이스북, 깃허브 등의 계정으로 로그인할 수 있는 기능을 제공한다. 이런 소셜 로그인의 기반이 되는 것이 바로 OAuth 2.0 프로토콜이다.
OAuth 2.0은 사용자가 제3자 애플리케이션에 자신의 계정 정보를 직접 입력하지 않고도 특정 서비스의 자원에 접근할 수 있게 하는 인증 프레임워크다. 간단히 설명하면, 사용자는 제3자 애플리케이션에게 자신의 계정으로 할 수 있는 일의 범위(scope)를 지정하여 권한을 위임한다.
OAuth 2.0 인증 흐름은 다음과 같이 진행된다:
-
사용자가 애플리케이션(클라이언트)에서 소셜 로그인 버튼을 클릭한다.
-
클라이언트는 사용자를 인증 서버(구글, 페이스북 등)의 로그인 페이지로 리다이렉트한다. 이때 클라이언트 ID, 요청 범위, 리다이렉트 URI 등의 정보를 함께 전달한다.
-
사용자가 인증 서버에서 로그인하고 권한을 허용하면, 인증 서버는 사용자를 다시 클라이언트로 리다이렉트하면서 인증 코드를 함께 전달한다.
-
클라이언트는 이 인증 코드와 자신의 클라이언트 비밀 키를 사용해 인증 서버에 직접 접근하여 액세스 토큰을 요청한다.
-
인증 서버는 클라이언트에게 액세스 토큰(및 선택적으로 리프레시 토큰)을 발급한다.
-
클라이언트는 이 액세스 토큰을 사용해 사용자를 대신하여 리소스 서버에 접근할 수 있다.
OAuth 2.0에는 여러 인증 플로우가 있다. 웹 애플리케이션에서는 주로 위에서 설명한 ‘인증 코드 부여(Authorization Code Grant)’ 방식을 사용한다. 모바일 앱이나 SPA(Single Page Application)에서는 ‘PKCE(Proof Key for Code Exchange)‘를 추가한 인증 코드 부여 방식이 권장된다. 백엔드 시스템 간 인증에는 ‘클라이언트 자격 증명 부여(Client Credentials Grant)’ 방식이 적합하다.
4. 웹 보안 위협과 방어책
CORS(Cross-Origin Resource Sharing)
웹 브라우저는 기본적으로 동일 출처 정책(Same-Origin Policy)을 적용하여 스크립트가 다른 출처(도메인, 프로토콜, 포트)의 리소스에 접근하는 것을 제한한다. CORS는 이러한 제한을 안전하게 완화하기 위한 메커니즘이다.
CORS의 이해
CORS는 브라우저가 다른 출처의 리소스를 안전하게 요청할 수 있도록 서버가 적절한 HTTP 헤더를 설정하는 방법이다. CORS가 필요한 상황은 주로 프론트엔드 애플리케이션이 다른 도메인의 API에 접근해야 할 때 발생한다.
CORS의 동작 방식:
간단한 요청: 특정 조건을 만족하는 요청은 별도의 예비 요청 없이 바로 전송된다.
예비 요청(Preflight): 브라우저가 OPTIONS 메서드를 사용하여 실제 요청 전에 서버가 해당 요청을 허용하는지 확인한다.
실제 요청: 예비 요청이 성공하면 실제 요청이 전송된다.
주요 CORS 헤더:
- Access-Control-Allow-Origin: 어떤 출처가 리소스에 접근할 수 있는지 지정
- Access-Control-Allow-Methods: 허용되는 HTTP 메서드 지정
- Access-Control-Allow-Headers: 허용되는 HTTP 헤더 지정
- Access-Control-Allow-Credentials: 인증 정보(쿠키, HTTP 인증)를 포함할 수 있는지 지정
CORS 설정 시 주의사항:
- ’*‘과 같은 와일드카드 대신 구체적인 도메인을 지정하는 것이 보안상 더 안전하다
- 필요한 경우에만 credentials를 허용해야 한다
- 필요한 HTTP 메서드와 헤더만 허용해야 한다
CSRF(Cross-Site Request Forgery) 공격과 방어
CSRF는 ‘사이트 간 요청 위조’라는 뜻으로, 공격자가 사용자가 의도하지 않은 요청을 대신 보내도록 하는 공격이다. 예를 들어, 사용자가 A 사이트에 로그인한 상태에서 공격자의 B 사이트를 방문하면, B 사이트에서 A 사이트로 요청을 보내게 할 수 있다. 브라우저는 쿠키를 함께 보내기 때문에, A 사이트는 이 요청이 정상적인 사용자로부터 온 것으로 판단한다.
CSRF 공격을 방어하는 방법은 다음과 같다:
-
CSRF 토큰을 사용: 서버는 폼을 생성할 때마다 고유한 토큰을 생성하여 함께 전송한다. 이후 폼 제출 시 이 토큰을 검증하여 요청의 정당성을 확인한다.
-
SameSite 쿠키 속성을 활용: SameSite=Strict 또는 SameSite=Lax 설정을 통해 크로스 사이트 요청 시 쿠키를 전송하지 않도록 제한할 수 있다.
-
중요한 작업에는 이중 인증을 적용한다. 예를 들어, 비밀번호 변경이나 송금 등의 작업에서는 추가적인 인증 단계를 거치도록 한다.
XSS(Cross-Site Scripting) 공격과 방어
XSS는 공격자가 악성 스크립트를 웹 페이지에 삽입하여 이 페이지를 방문하는 사용자의 브라우저에서 실행시키는 공격이다. 이를 통해 쿠키를 탈취하거나, 세션 하이재킹, 피싱 등 다양한 공격이 가능하다.
XSS 공격은 크게 세 가지 유형으로 나뉜다. 저장형(Stored) XSS는 공격 코드가 서버에 저장되어 여러 사용자에게 영향을 미치는 경우다. 반사형(Reflected) XSS는 URL 파라미터 등을 통해 전달된 악성 코드가 즉시 반사되어 실행되는 경우이다. DOM 기반 XSS는 클라이언트 측 스크립트가 DOM을 안전하지 않게 조작할 때 발생한다.
XSS 공격을 방어하는 방법은 다음과 같다:
-
모든 사용자 입력을 적절히 이스케이프하고 검증한다. HTML, JavaScript, URL 등 컨텍스트에 맞는 인코딩이 필요하다.
-
Content-Security-Policy(CSP) 헤더를 설정하여 어떤 소스의 스크립트가 실행될 수 있는지 제한한다.
-
HttpOnly 플래그를 사용하여 JavaScript를 통한 쿠키 접근을 차단한다.
-
최신 프레임워크와 라이브러리를 사용한다. React, Vue 등 현대적인 프레임워크는 기본적으로 XSS 방어 기능을 제공한다.
5. Next.js와 NextAuth.js를 활용한 인증 구현
Next.js는 React 기반의 풀스택 프레임워크로, 서버 사이드 렌더링과 정적 사이트 생성을 지원한다. NextAuth.js는 Next.js 애플리케이션에 인증 기능을 쉽게 추가할 수 있게 해주는 라이브러리다.
NextAuth.js 설치 및 기본 설정
NextAuth.js를 사용하기 위해서는 먼저 패키지를 설치해야 한다. npm이나 yarn을 사용해 next-auth 패키지를 설치할 수 있다.
그 다음, API 라우트를 생성한다. Next.js 프로젝트의 pages/api/auth/[...nextauth].js
파일을 생성하고 NextAuth 설정을 작성한다. 여기에는 사용할 프로바이더(GitHub, Google 등)와 필요한 환경 변수, 콜백 함수 등을 정의한다.
환경 변수는 .env.local
파일에 클라이언트 ID, 시크릿 키, NextAuth 시크릿 등을 설정한다. 이 정보들은 보안을 위해 저장소에 커밋되지 않도록 주의해야 한다.
NextAuth.js 프로바이더 설정
NextAuth.js는 다양한 인증 프로바이더를 지원한다. 소셜 로그인(구글, 페이스북, 깃허브 등)뿐만 아니라 이메일/패스워드 인증, 매직 링크, 자체 서버 등 거의 모든 인증 방식을 구현할 수 있다.
각 프로바이더를 사용하기 위해서는 해당 서비스의 개발자 콘솔에서 OAuth 애플리케이션을 등록하고 클라이언트 ID와 시크릿을 발급받아야 한다. 리다이렉트 URI는 대개 http://도메인/api/auth/callback/프로바이더명
형식이다.
세션 관리 및 보호된 라우트 구현
NextAuth.js는 기본적으로 JWT를 사용하여 세션을 관리한다. 세션 정보는 클라이언트에 안전하게 암호화되어 저장된다. 데이터베이스를 이용한 세션 관리도 지원한다.
애플리케이션 전체에서 세션 정보에 접근하기 위해 NextAuth.js의 SessionProvider를 사용한다. 이 프로바이더는 일반적으로 _app.js 파일에서 설정하여 전체 애플리케이션에서 세션 정보를 사용할 수 있게 한다.
특정 페이지나 컴포넌트에서는 useSession 훅을 사용하여 세션 정보를 가져오고, 로그인 상태에 따라 다른 UI를 표시할 수 있다. 세션이 없는 경우 로그인 버튼을 표시하고, 세션이 있는 경우 사용자 정보와 로그아웃 버튼을 표시하는 방식이다.
보호된 페이지는 서버 사이드에서 세션을 확인하여 구현할 수 있다. getServerSideProps 함수에서 세션을 확인하고, 세션이 없는 경우 로그인 페이지로 리다이렉트하는 방식으로 구현한다.
사용자 권한과 역할 관리
NextAuth.js는 기본적으로 사용자 역할이나 권한 관리 기능을 제공하지 않지만, 커스텀 콜백을 통해 이를 구현할 수 있다. JWT 콜백과 세션 콜백을 활용하여 사용자의 역할 정보를 토큰과 세션에 추가하는 방식으로 구현한다.
6. Next.js에서의 인증 시스템 구현
Next.js에서 인증 시스템을 구현할 때는 크게 세 가지 개념으로 나눠서 생각할 수 있다.
인증(Authentication) 구현
인증은 사용자가 자신이 주장하는 사람인지 확인하는 과정이다. Next.js에서는 일반적으로 다음과 같은 단계로 인증을 구현한다:
- 사용자가 로그인 폼을 통해 인증 정보(이메일, 비밀번호 등)를 제출한다.
- 이 정보는 API 라우트를 통해 서버에서 처리된다.
- 서버에서 인증 정보를 검증하고, 성공할 경우 세션이나 토큰을 생성한다.
- 실패할 경우 적절한 오류 메시지를 반환한다.
Next.js에서는 자체 API 라우트를 통해 인증 로직을 구현할 수 있어 백엔드와 프론트엔드를 하나의 코드베이스에서 관리할 수 있다는 장점이 있다.
세션 관리(Session Management)
사용자가 인증되면 그 상태를 유지하기 위해 세션 관리가 필요하다. Next.js에서의 세션 관리는 크게 두 가지 방식으로 나뉜다:
무상태(Stateless) 세션: 세션 데이터나 토큰이 브라우저의 쿠키에 저장된다. 각 요청과 함께 이 쿠키가 서버로 전송되어 세션을 검증한다. 이 방식은 구현이 간단하지만, 적절히 구현하지 않으면 보안 문제가 발생할 수 있다.
데이터베이스 세션: 세션 데이터는 데이터베이스에 저장되고, 사용자의 브라우저는 암호화된 세션 ID만 받는다. 이 방식은 더 안전하지만, 구현이 복잡하고 서버 리소스를 더 많이 사용할 수 있다.
세션 관리를 위해서는 iron-session이나 Jose와 같은 세션 관리 라이브러리를 사용하는 것이 좋다.
권한 부여(Authorization)
사용자가 인증되고 세션이 생성되면, 사용자가 애플리케이션 내에서 접근할 수 있는 리소스와 수행할 수 있는 작업을 제한하는 권한 부여를 구현할 수 있다.
Next.js에서의 권한 부여는 두 가지 유형으로 나눌 수 있다:
낙관적(Optimistic) 검사: 쿠키에 저장된 세션 데이터를 사용하여 사용자가 특정 경로에 접근하거나 작업을 수행할 권한이 있는지 확인한다. 이러한 검사는 UI 요소를 표시/숨기거나 권한이나 역할에 따라’s 사용자를 리디렉션하는 것과 같은 빠른 작업에 유용하다.
안전한(Secure) 검사: 데이터베이스에 저장된 세션 데이터를 사용하여 사용자가 특정 경로에 접근하거나 작업을 수행할 권한이 있는지 확인한다. 이러한 검사는 더 안전하며 민감한 데이터나 작업에 접근하는 작업에 사용된다.
Next.js의 미들웨어를 활용하면 낙관적 검사를 수행하고 사용자를 리디렉션하는 중앙 집중식 로직을 구현할 수 있다. 하지만 미들웨어는 모든 경로에서 실행되며, 프리패치된 경로에서도 실행되므로 성능 문제를 방지하기 위해 쿠키에서 세션만 읽고(낙관적 검사) 데이터베이스 검사는 피하는 것이 중요하다.
또한 API 라우트를 보호하기 위해 사용자의 인증 상태와 역할 기반 권한을 확인하는 보안 검사를 구현할 수 있다. 이런 방식으로 인증된 사용자만 특정 기능에 액세스할 수 있도록 하고, 인증되지 않은 사용자는 차단할 수 있다.
마치며
웹 인증과 보안은 단순히 사용자를 식별하는 것 이상의 의미를 가진다. 사용자의 데이터를 보호하고, 서비스의 신뢰성을 유지하는 데 필수적인 요소다. 특히 개인정보 유출 사고가 끊이지 않는 요즘, 개발자로서 보안에 대한 이해와 실천은 더욱 중요해지고 있다.
이 글에서 다룬 내용은 웹 보안의 기본적인 개념과 구현 방법이다. 실제 프로덕션 환경에서는 더 복잡하고 다양한 보안 이슈가 발생할 수 있으므로, 지속적인 학습과 최신 보안 동향을 파악하는 것이 중요하다. 보안은 서비스 개발의 시작부터 끝까지 고려해야 할 핵심 요소임을 항상 기억하자.