Ver3.0 ๐ OAuth 2.0๊ณผ JWT ์ธ์ฆ ์์ ์ ๋ณต! ์์ฉํ๋ก๊ทธ๋๋ฐ์์ ์ค์ ์ ์ฉํ๊ธฐ

๐ OAuth 2.0๊ณผ JWT ์ธ์ฆ ์์ ์ ๋ณต! ์์ฉํ๋ก๊ทธ๋๋ฐ์์ ์ค์ ์ ์ฉํ๊ธฐ
์๋ ! ๐ ์ค๋์ ํ๋ ์น ์ ํ๋ฆฌ์ผ์ด์ ์์ ๋น ์ง ์ ์๋ ๋ ๊ฐ์ง ํต์ฌ ์ธ์ฆ ๊ธฐ์ , OAuth 2.0๊ณผ JWT(JSON Web Token)์ ๋ํด ๊น์ด ์๊ฒ ํํค์ณ๋ณผ ๊ฑฐ์ผ. ํน์ "๋ก๊ทธ์ธ ๊ตฌํ์ด ์ด๋ ๊ฒ ๋ณต์กํ ์ค์ด์ผ..." ํ๋ฉด์ ๋จธ๋ฆฌ๋ฅผ ์ฅ์ด๋ฏ์ ์ ์์ด? ๐ ๊ฑฑ์ ๋ง! ์ด ๊ธ์ ๋ค ์ฝ๊ณ ๋๋ฉด ๋๋ ์ธ์ฆ ์ ๋ฌธ๊ฐ๊ฐ ๋ ์ ์์ ๊ฑฐ์ผ.
๐ฏ ์ OAuth 2.0๊ณผ JWT๋ฅผ ์์์ผ ํ ๊น?
์์ฆ ์น ์๋น์ค๋ฅผ ๋ณด๋ฉด "๊ตฌ๊ธ๋ก ๋ก๊ทธ์ธ", "์นด์นด์ค๋ก ์์ํ๊ธฐ" ๊ฐ์ ๋ฒํผ๋ค ๋ง์ด ๋ดค์ง? ๐ค ์ด๊ฒ ๋ฐ๋ก OAuth 2.0์ ํ์ฉํ ์์ ๋ก๊ทธ์ธ์ด์ผ. ๊ทธ๋ฆฌ๊ณ ๋ก๊ทธ์ธ ํ์ ์๋ฒ๊ฐ ๋๋ฅผ ๊ธฐ์ตํ๋ ๋ฐฉ๋ฒ ์ค ํ๋๊ฐ ๋ฐ๋ก JWT๋ฅผ ์ฌ์ฉํ๋ ๊ฑฐ์ง.
์ ํต์ ์ธ ์ธ์ ๊ธฐ๋ฐ ์ธ์ฆ์ ์๋ฒ์ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ์ ์ฅํด์ผ ํ์ด. ์ฌ์ฉ์๊ฐ ๋ง์์ง๋ฉด? ์๋ฒ ๋ฉ๋ชจ๋ฆฌ๊ฐ ํฐ์ ธ๋ฒ๋ฆฌ๋ ๊ฑฐ์ผ! ๐ฅ ํ์ง๋ง JWT๋ ํ ํฐ ์์ฒด์ ์ ๋ณด๋ฅผ ๋ด์์ ์๋ฒ ๋ถ๋ด์ ํ ์ค์ฌ์ค. OAuth 2.0์ ๋น๋ฐ๋ฒํธ๋ฅผ ์ง์ ์ฃผ๊ณ ๋ฐ์ง ์๊ณ ๋ ์์ ํ๊ฒ ์ธ์ฆํ ์ ์๊ฒ ํด์ฃผ๊ณ ๋ง์ด์ผ.
๐ ์ด ๊ธ์์ ๋ฐฐ์ธ ํต์ฌ ๋ด์ฉ
โ
OAuth 2.0์ ์๋ ์๋ฆฌ์ 4๊ฐ์ง ์ธ์ฆ ๋ฐฉ์
โ
JWT์ ๊ตฌ์กฐ์ ์์ฑ/๊ฒ์ฆ ๋ฐฉ๋ฒ
โ
์ค์ ์์ฉํ๋ก๊ทธ๋๋ฐ์์์ ๊ตฌํ ๋ฐฉ๋ฒ
โ
๋ณด์ ์ทจ์ฝ์ ๊ณผ ๋์ ์ ๋ต
โ
์ค์ ์ฝ๋ ์์ ์ ๋ฒ ์คํธ ํ๋ํฐ์ค
๐ OAuth 2.0 ์์ ํด๋ถํ๊ธฐ
OAuth 2.0์ด ๋ญ๊ธธ๋?
OAuth 2.0์ ์ธ๊ฐ(Authorization) ํ๋ ์์ํฌ์ผ. ์ฌ๊ธฐ์ ์ค์ํ ๊ฑด "์ธ์ฆ(Authentication)"์ด ์๋๋ผ "์ธ๊ฐ"๋ผ๋ ์ ! ๐ญ ์ฝ๊ฒ ๋งํ๋ฉด, "์ด ์ฌ๋์ด ๋๊ตฌ์ธ์ง"๋ฅผ ํ์ธํ๋ ๊ฒ ์๋๋ผ "์ด ์ฌ๋์ด ๋ฌด์์ ํ ์ ์๋์ง"๋ฅผ ๊ฒฐ์ ํ๋ ๊ฑฐ์ผ.
์๋ฅผ ๋ค์ด๋ณผ๊ฒ. ๋๊ฐ ์ด๋ค ์ฌ์ง ํธ์ง ์ฑ์ ์ฌ์ฉํ๋๋ฐ, ์ด ์ฑ์ด ๊ตฌ๊ธ ๋๋ผ์ด๋ธ์ ์๋ ๋์ ์ฌ์ง์ ์ ๊ทผํ๊ณ ์ถ์ด ํด. ์ด๋ OAuth 2.0์ ์ฌ์ฉํ๋ฉด ๊ตฌ๊ธ ๊ณ์ ๋น๋ฐ๋ฒํธ๋ฅผ ์ฑ์ ์๋ ค์ฃผ์ง ์๊ณ ๋, "์ด ์ฑ์ด ๋ด ๋๋ผ์ด๋ธ์ ์ฌ์ง๋ง ๋ณผ ์ ์๊ฒ ํ๋ฝํ ๊ฒ"๋ผ๊ณ ํ ์ ์๋ ๊ฑฐ์ง! ๐จ
OAuth 2.0์ ํต์ฌ ๋ฑ์ฅ์ธ๋ฌผ๋ค
| ์ญํ | ์ค๋ช | ์์ |
|---|---|---|
| Resource Owner | ๋ฆฌ์์ค์ ์ฃผ์ธ, ์ฆ ์ฌ์ฉ์ | ๊ตฌ๊ธ ๊ณ์ ์ ๊ฐ์ง ๋ |
| Client | ๋ฆฌ์์ค์ ์ ๊ทผํ๋ ค๋ ์ ํ๋ฆฌ์ผ์ด์ | ์ฌ์ง ํธ์ง ์ฑ |
| Authorization Server | ์ธ์ฆ์ ์ฒ๋ฆฌํ๊ณ ํ ํฐ์ ๋ฐ๊ธํ๋ ์๋ฒ | ๊ตฌ๊ธ OAuth ์๋ฒ |
| Resource Server | ์ค์ ๋ฆฌ์์ค๋ฅผ ๊ฐ์ง๊ณ ์๋ ์๋ฒ | ๊ตฌ๊ธ ๋๋ผ์ด๋ธ API ์๋ฒ |
OAuth 2.0์ 4๊ฐ์ง ์ธ์ฆ ๋ฐฉ์ (Grant Types)
OAuth 2.0์ ์ํฉ์ ๋ฐ๋ผ ๋ค๋ฅธ ์ธ์ฆ ๋ฐฉ์์ ์ ๊ณตํด. ๋ง์น ์ํฉ์ ๋ง๋ ์ท์ ์ ๋ ๊ฒ์ฒ๋ผ ๋ง์ด์ผ! ๐๐
1๏ธโฃ Authorization Code Grant (๊ฐ์ฅ ์์ ํ๊ณ ๋ง์ด ์ฐ์)
์ธ์ ์ฌ์ฉ? ์๋ฒ ์ฌ์ด๋ ์น ์ ํ๋ฆฌ์ผ์ด์ ์์ ์ฃผ๋ก ์ฌ์ฉํด. ๊ฐ์ฅ ๋ณด์์ด ๊ฐ๋ ฅํ ๋ฐฉ์์ด์ผ!
์ด ๋ฐฉ์์ ํ๋ฆ์ ๋จ๊ณ๋ณ๋ก ์ดํด๋ณผ๊ฒ:
Step 1: ์ฌ์ฉ์๊ฐ ํด๋ผ์ด์ธํธ ์ฑ์์ "๊ตฌ๊ธ๋ก ๋ก๊ทธ์ธ" ๋ฒํผ์ ํด๋ฆญ ๐ฑ๏ธ
Step 2: ํด๋ผ์ด์ธํธ๊ฐ ์ฌ์ฉ์๋ฅผ Authorization Server๋ก ๋ฆฌ๋ค์ด๋ ํธ
์ด๋ URL์ ์ฌ๋ฌ ํ๋ผ๋ฏธํฐ๋ฅผ ํฌํจ์์ผ:
https://accounts.google.com/o/oauth2/v2/auth?
client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&response_type=code
&scope=profile email
&state=random_string_for_security
Step 3: ์ฌ์ฉ์๊ฐ ๊ตฌ๊ธ์ ๋ก๊ทธ์ธํ๊ณ ๊ถํ ์น์ธ โ
"์ด ์ฑ์ด ๋น์ ์ ํ๋กํ๊ณผ ์ด๋ฉ์ผ์ ์ ๊ทผํ๋ ๊ฒ์ ํ์ฉํ์๊ฒ ์ต๋๊น?" ๊ฐ์ ํ๋ฉด์ด ๋์.
Step 4: Authorization Server๊ฐ Authorization Code๋ฅผ ๋ฐ๊ธํ๊ณ redirect_uri๋ก ๋ฆฌ๋ค์ด๋ ํธ
https://yourapp.com/callback?code=AUTHORIZATION_CODE&state=random_string_for_security
Step 5: ํด๋ผ์ด์ธํธ๊ฐ ์ด ์ฝ๋๋ฅผ ๊ฐ์ง๊ณ Authorization Server์ Access Token ์์ฒญ
์ด๋๋ ์๋ฒ ๊ฐ ํต์ ์ด๋ผ ์ฌ์ฉ์ ๋ธ๋ผ์ฐ์ ๋ฅผ ๊ฑฐ์น์ง ์์. ๋ ์์ ํ์ง! ๐
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
code=AUTHORIZATION_CODE
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&redirect_uri=https://yourapp.com/callback
&grant_type=authorization_code
Step 6: Authorization Server๊ฐ Access Token (๊ทธ๋ฆฌ๊ณ ๋ณดํต Refresh Token๋) ๋ฐ๊ธ ๐ซ
{
"access_token": "ya29.a0AfH6SMBx...",
"expires_in": 3600,
"refresh_token": "1//0gOZp8qT...",
"scope": "profile email",
"token_type": "Bearer"
}
Step 7: ์ด์ ์ด Access Token์ผ๋ก Resource Server(๊ตฌ๊ธ ๋๋ผ์ด๋ธ ๋ฑ)์ API ์์ฒญ! ๐
๐ก ์ ์ด๋ ๊ฒ ๋ณต์กํ๊ฒ?
Authorization Code๋ฅผ ๋จผ์ ๋ฐ๊ณ , ๊ทธ๊ฑธ๋ก ๋ค์ ํ ํฐ์ ๋ฐ๋ ์ด์ ๋ ๋ณด์ ๋๋ฌธ์ด์ผ. ๋ธ๋ผ์ฐ์ ๋ฅผ ํตํด ์ง์ ํ ํฐ์ ์ ๋ฌํ๋ฉด ํ์ทจ๋ ์ํ์ด ์๊ฑฐ๋ . ์ฝ๋๋ ํ ๋ฒ๋ง ์ฌ์ฉ ๊ฐ๋ฅํ๊ณ , ์ค์ ํ ํฐ ๊ตํ์ ์๋ฒ ๊ฐ์ ์์ ํ๊ฒ ์ด๋ฃจ์ด์ ธ!
2๏ธโฃ Implicit Grant (๋จ์ํ์ง๋ง ๋ณด์ ์ฝํจ)
์์ ์๋ SPA(Single Page Application)์์ ๋ง์ด ์ผ๋๋ฐ, ์์ฆ์ ๋ณด์ ๋ฌธ์ ๋ก ๊ถ์ฅํ์ง ์์. ๐ฐ Authorization Code ์์ด ๋ฐ๋ก Access Token์ ๋ฐ๋ ๋ฐฉ์์ด์ผ.
https://accounts.google.com/o/oauth2/v2/auth?
client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&response_type=token
&scope=profile email
๋ฆฌ๋ค์ด๋ ํธ ์ URL fragment(#)์ ํ ํฐ์ด ๋ฐ๋ก ํฌํจ๋ผ:
https://yourapp.com/callback#access_token=ya29.a0AfH6SMBx...&token_type=Bearer&expires_in=3600
โ ๏ธ ๋ณด์ ์ฃผ์!
์ด ๋ฐฉ์์ ํ ํฐ์ด ๋ธ๋ผ์ฐ์ ํ์คํ ๋ฆฌ์ ๋จ์ ์ ์๊ณ , Refresh Token๋ ๋ฐ๊ธ๋์ง ์์. ํ์ฌ๋ Authorization Code Grant + PKCE๋ฅผ ์ฌ์ฉํ๋ ๊ฒ ๊ถ์ฅ๋ผ!
3๏ธโฃ Resource Owner Password Credentials Grant
์ฌ์ฉ์๊ฐ ํด๋ผ์ด์ธํธ์ ์ง์ ์์ด๋/๋น๋ฐ๋ฒํธ๋ฅผ ์ ๋ ฅํ๋ ๋ฐฉ์. ์ ๋ขฐํ ์ ์๋ ์์ฌ ์ฑ์์๋ง ์ฌ์ฉํด์ผ ํด! ๐ข
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=password
&username=user@example.com
&password=user_password
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
์ด ๋ฐฉ์์ OAuth์ ์ฒ ํ(๋น๋ฐ๋ฒํธ๋ฅผ ๊ณต์ ํ์ง ์๊ธฐ)์ ์ด๊ธ๋์ ๋ ๊ฑฐ์ ์์คํ ๋ง์ด๊ทธ๋ ์ด์ ๊ฐ์ ํน์ํ ๊ฒฝ์ฐ๊ฐ ์๋๋ฉด ์ฌ์ฉํ์ง ์๋ ๊ฒ ์ข์.
4๏ธโฃ Client Credentials Grant
์ฌ์ฉ์๊ฐ ์๋ ์ ํ๋ฆฌ์ผ์ด์ ์์ฒด๊ฐ ์ธ์ฆ๋ฐ๋ ๋ฐฉ์. ์๋ฒ ๊ฐ ํต์ (Machine-to-Machine)์์ ์ฌ์ฉํด! ๐ค
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&scope=api.read
์๋ฅผ ๋ค์ด, ๋์ ๋ฐฑ์๋ ์๋ฒ๊ฐ ๋ค๋ฅธ ํ์ฌ์ API๋ฅผ ํธ์ถํ ๋ ์ฌ์ฉํ๋ ๊ฑฐ์ผ. ์ฌ์ฉ์ ์ปจํ ์คํธ๊ฐ ์๊ณ ์์ํ๊ฒ ์ฑ ๋ ์ฑ ํต์ ์ด์ง.
PKCE: ๋ชจ๋ฐ์ผ/SPA๋ฅผ ์ํ ๋ณด์ ๊ฐํ
PKCE(Proof Key for Code Exchange, "ํฝ์"๋ผ๊ณ ์ฝ์ด)๋ Authorization Code Grant๋ฅผ ๋ ์์ ํ๊ฒ ๋ง๋ค์ด์ฃผ๋ ํ์ฅ ๊ธฐ๋ฅ์ด์ผ. ๐ฑ ํนํ ๋ชจ๋ฐ์ผ ์ฑ์ด๋ SPA์ฒ๋ผ ํด๋ผ์ด์ธํธ ์ํฌ๋ฆฟ์ ์์ ํ๊ฒ ๋ณด๊ดํ ์ ์๋ ํ๊ฒฝ์์ ํ์์ผ!
PKCE ์๋ ์๋ฆฌ
1๋จ๊ณ: ํด๋ผ์ด์ธํธ๊ฐ ๋๋คํ code_verifier ์์ฑ (43~128์์ ๋๋ค ๋ฌธ์์ด)
2๋จ๊ณ: code_verifier๋ฅผ SHA256์ผ๋ก ํด์ฑํ์ฌ code_challenge ์์ฑ
3๋จ๊ณ: Authorization ์์ฒญ ์ code_challenge๋ฅผ ํจ๊ป ์ ์ก
4๋จ๊ณ: Token ์์ฒญ ์ ์๋ณธ code_verifier๋ฅผ ํจ๊ป ์ ์ก
5๋จ๊ณ: ์๋ฒ๊ฐ code_verifier๋ฅผ ํด์ฑํด์ ์ฒ์ ๋ฐ์ code_challenge์ ์ผ์นํ๋์ง ํ์ธ โ
// JavaScript ์์
// 1. Code Verifier ์์ฑ
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64URLEncode(array);
}
// 2. Code Challenge ์์ฑ
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64URLEncode(new Uint8Array(hash));
}
// 3. Authorization ์์ฒญ
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?
client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&response_type=code
&scope=profile email
&code_challenge=${codeChallenge}
&code_challenge_method=S256`;
// 4. Token ์์ฒญ ์ code_verifier ํฌํจ
fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code: authorizationCode,
client_id: 'YOUR_CLIENT_ID',
redirect_uri: 'https://yourapp.com/callback',
grant_type: 'authorization_code',
code_verifier: codeVerifier
})
});
๐ซ JWT(JSON Web Token) ๋ง์คํฐํ๊ธฐ
JWT๊ฐ ๋ญ์ผ?
JWT๋ JSON ํ์์ ์ ๋ณด๋ฅผ ์์ ํ๊ฒ ์ ์กํ๊ธฐ ์ํ ํ ํฐ์ด์ผ. ์ธ ๋ถ๋ถ์ผ๋ก ๋๋์ด์ ธ ์๊ณ , ์ (.)์ผ๋ก ๊ตฌ๋ถ๋ผ ์์ด:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
[Header].[Payload].[Signature]
์ด ์ด์ํ ๋ฌธ์์ด์ด ์ค์ ๋ก๋ ์์ฒญ๋ ์ ๋ณด๋ฅผ ๋ด๊ณ ์์ด! ๐ ํ๋์ฉ ๋ฏ์ด๋ณผ๊น?
JWT์ 3๊ฐ์ง ๊ตฌ์ฑ ์์
1. Header (ํค๋)
ํ ํฐ์ ํ์ ๊ณผ ํด์ฑ ์๊ณ ๋ฆฌ์ฆ ์ ๋ณด๋ฅผ ๋ด๊ณ ์์ด:
{
"alg": "HS256", // ์๊ณ ๋ฆฌ์ฆ: HMAC SHA256
"typ": "JWT" // ํ์
: JWT
}
์ด๊ฑธ Base64Url๋ก ์ธ์ฝ๋ฉํ๋ฉด eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9๊ฐ ๋ผ.
2. Payload (ํ์ด๋ก๋)
์ค์ ์ ๋ฌํ๋ ค๋ ๋ฐ์ดํฐ๊ฐ ๋ค์ด๊ฐ๋ ๋ถ๋ถ! ์ฌ๊ธฐ์๋ Claim์ด๋ผ๋ ์ ๋ณด ์กฐ๊ฐ๋ค์ด ๋ค์ด๊ฐ:
{
"sub": "1234567890", // Subject: ํ ํฐ ์ฃผ์ฒด (์ฌ์ฉ์ ID)
"name": "John Doe", // ์ฌ์ฉ์ ์ด๋ฆ
"email": "john@example.com",
"role": "admin", // ์ฌ์ฉ์ ์ญํ
"iat": 1516239022, // Issued At: ๋ฐ๊ธ ์๊ฐ
"exp": 1516242622 // Expiration: ๋ง๋ฃ ์๊ฐ
}
๐ Claim์ 3๊ฐ์ง ์ข
๋ฅ
Registered Claims (๋ฑ๋ก๋ ํด๋ ์): JWT ํ์ค์์ ์ ์ํ ๊ฒ๋ค
- iss (issuer): ๋ฐ๊ธ์
- sub (subject): ์ฃผ์
- aud (audience): ๋์
- exp (expiration): ๋ง๋ฃ ์๊ฐ
- iat (issued at): ๋ฐ๊ธ ์๊ฐ
- jti (JWT ID): ๊ณ ์ ์๋ณ์
Public Claims: ์ถฉ๋ ๋ฐฉ์ง๋ฅผ ์ํด URI ํ์์ผ๋ก ์ ์
Private Claims: ๋น์ฌ์ ๊ฐ ํฉ์ํ์ ์ฌ์ฉํ๋ ์ปค์คํ
ํด๋ ์
โ ๏ธ ์ค์ํ ๋ณด์ ํ!
Payload๋ Base64๋ก ์ธ์ฝ๋ฉ๋ง ๋์ด ์์ด์ ๋๊ตฌ๋ ๋์ฝ๋ฉํด์ ๋ณผ ์ ์์ด! ๋ฐ๋ผ์ ๋น๋ฐ๋ฒํธ๋ ๋ฏผ๊ฐํ ๊ฐ์ธ์ ๋ณด๋ ์ ๋ ๋ฃ์ผ๋ฉด ์ ๋ผ! ๐ซ
3. Signature (์๋ช )
ํ ํฐ์ด ๋ณ์กฐ๋์ง ์์์์ ์ฆ๋ช ํ๋ ๋ถ๋ถ์ด์ผ. Header์ Payload๋ฅผ ํฉ์น๊ณ , ๋น๋ฐ ํค๋ก ์๋ช ํด:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
์ด ์๋ช ๋๋ถ์ ๋๊ตฐ๊ฐ ํ ํฐ ๋ด์ฉ์ ์กฐ์ํ๋ฉด ๋ฐ๋ก ์ ์ ์์ด! ๐ ์๋ฒ๋ง ์๊ณ ์๋ ๋น๋ฐ ํค๋ก ์๋ช ํ๊ธฐ ๋๋ฌธ์, ๊ฐ์ ํค๋ก ๊ฒ์ฆํ๋ฉด ์์กฐ ์ฌ๋ถ๋ฅผ ํ์ธํ ์ ์์ง.
JWT ์ค์ ๊ตฌํํ๊ธฐ
Node.js์์ JWT ์์ฑํ๊ธฐ
// jsonwebtoken ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ
const jwt = require('jsonwebtoken');
// JWT ์์ฑ
function generateToken(user) {
const payload = {
sub: user.id,
email: user.email,
role: user.role,
iat: Math.floor(Date.now() / 1000)
};
const secret = process.env.JWT_SECRET; // ํ๊ฒฝ๋ณ์์์ ๋น๋ฐํค ๊ฐ์ ธ์ค๊ธฐ
const options = {
expiresIn: '1h', // 1์๊ฐ ํ ๋ง๋ฃ
issuer: 'myapp.com'
};
return jwt.sign(payload, secret, options);
}
// ์ฌ์ฉ ์์
const user = { id: 123, email: 'user@example.com', role: 'user' };
const token = generateToken(user);
console.log(token);
JWT ๊ฒ์ฆํ๊ธฐ
// JWT ๊ฒ์ฆ ๋ฏธ๋ค์จ์ด
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: 'ํ ํฐ์ด ์์ต๋๋ค' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'ํ ํฐ์ด ๋ง๋ฃ๋์์ต๋๋ค' });
}
return res.status(403).json({ error: '์ ํจํ์ง ์์ ํ ํฐ์
๋๋ค' });
}
// ๊ฒ์ฆ ์ฑ๊ณต! ๋์ฝ๋ฉ๋ ์ ๋ณด๋ฅผ req์ ์ ์ฅ
req.user = decoded;
next();
});
}
// ๋ณดํธ๋ ๋ผ์ฐํธ์ ์ ์ฉ
app.get('/api/profile', authenticateToken, (req, res) => {
// req.user์ ํ ํฐ์ payload ์ ๋ณด๊ฐ ๋ค์ด์์
res.json({
message: 'ํ๋กํ ์ ๋ณด',
user: req.user
});
});
Python (Flask)์์ JWT ์ฌ์ฉํ๊ธฐ
from flask import Flask, request, jsonify
from flask_jwt_extended import (
JWTManager, create_access_token,
jwt_required, get_jwt_identity
)
from datetime import timedelta
app = Flask(__name__)
app.config['JWT_SECRET_KEY'] = 'your-secret-key'
app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(hours=1)
jwt = JWTManager(app)
# ๋ก๊ทธ์ธ ์๋ํฌ์ธํธ
@app.route('/login', methods=['POST'])
def login():
username = request.json.get('username')
password = request.json.get('password')
# ์ค์ ๋ก๋ DB์์ ์ฌ์ฉ์ ํ์ธ
if username == 'test' and password == 'password':
# JWT ์์ฑ
access_token = create_access_token(
identity=username,
additional_claims={'role': 'admin'}
)
return jsonify(access_token=access_token), 200
return jsonify(error='Invalid credentials'), 401
# ๋ณดํธ๋ ๋ผ์ฐํธ
@app.route('/protected', methods=['GET'])
@jwt_required()
def protected():
current_user = get_jwt_identity()
return jsonify(logged_in_as=current_user), 200
Java (Spring Boot)์์ JWT ๊ตฌํ
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.util.Date;
public class JwtUtil {
private static final Key SECRET_KEY = Keys.secretKeyFor(SignatureAlgorithm.HS256);
private static final long EXPIRATION_TIME = 3600000; // 1์๊ฐ
// JWT ์์ฑ
public static String generateToken(String userId, String role) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + EXPIRATION_TIME);
return Jwts.builder()
.setSubject(userId)
.claim("role", role)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(SECRET_KEY)
.compact();
}
// JWT ๊ฒ์ฆ ๋ฐ ํ์ฑ
public static Claims validateToken(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(SECRET_KEY)
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
throw new RuntimeException("ํ ํฐ์ด ๋ง๋ฃ๋์์ต๋๋ค");
} catch (JwtException e) {
throw new RuntimeException("์ ํจํ์ง ์์ ํ ํฐ์
๋๋ค");
}
}
// ์ฌ์ฉ์ ID ์ถ์ถ
public static String getUserIdFromToken(String token) {
Claims claims = validateToken(token);
return claims.getSubject();
}
}
Access Token vs Refresh Token ์ ๋ต
์ค์ ์์๋ ๋ณดํต ๋ ๊ฐ์ง ํ ํฐ์ ํจ๊ป ์ฌ์ฉํด! ๐ญ
๐ซ Access Token (์ ๊ทผ ํ ํฐ)
๋ชฉ์ : ์ค์ API ์์ฒญ์ ์ฌ์ฉ
์ ํจ๊ธฐ๊ฐ: ์งง๊ฒ (15๋ถ ~ 1์๊ฐ)
์ ์ฅ ์์น: ๋ฉ๋ชจ๋ฆฌ ๋๋ ์งง์ ์๋ช
์ ์ฟ ํค
ํน์ง: ํ์ทจ๋์ด๋ ํผํด๋ฅผ ์ต์ํํ๊ธฐ ์ํด ์งง์ ์๋ช
๐ Refresh Token (๊ฐฑ์ ํ ํฐ)
๋ชฉ์ : Access Token์ด ๋ง๋ฃ๋๋ฉด ์๋ก ๋ฐ๊ธ๋ฐ๊ธฐ ์ํด ์ฌ์ฉ
์ ํจ๊ธฐ๊ฐ: ๊ธธ๊ฒ (1์ฃผ์ผ ~ 1๊ฐ์)
์ ์ฅ ์์น: HttpOnly ์ฟ ํค ๋๋ ์์ ํ ์ ์ฅ์
ํน์ง: ํ ๋ฒ๋ง ์ฌ์ฉ ๊ฐ๋ฅํ๋๋ก ๊ตฌํ (Rotation ์ ๋ต)
ํ ํฐ ๊ฐฑ์ ํ๋ก์ฐ ๊ตฌํ
// Node.js ์์
const express = require('express');
const jwt = require('jsonwebtoken');
const router = express.Router();
// Refresh Token ์ ์ฅ์ (์ค์ ๋ก๋ Redis๋ DB ์ฌ์ฉ)
const refreshTokens = new Set();
// ๋ก๊ทธ์ธ
router.post('/login', async (req, res) => {
const { username, password } = req.body;
// ์ฌ์ฉ์ ์ธ์ฆ (์ค์ ๋ก๋ DB ํ์ธ)
const user = await authenticateUser(username, password);
if (!user) {
return res.status(401).json({ error: '์ธ์ฆ ์คํจ' });
}
// Access Token ์์ฑ (15๋ถ)
const accessToken = jwt.sign(
{ sub: user.id, email: user.email },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
// Refresh Token ์์ฑ (7์ผ)
const refreshToken = jwt.sign(
{ sub: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// Refresh Token ์ ์ฅ
refreshTokens.add(refreshToken);
// Refresh Token์ HttpOnly ์ฟ ํค๋ก ์ ์ก
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7์ผ
});
res.json({ accessToken });
});
// Access Token ๊ฐฑ์
router.post('/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh Token์ด ์์ต๋๋ค' });
}
if (!refreshTokens.has(refreshToken)) {
return res.status(403).json({ error: '์ ํจํ์ง ์์ Refresh Token' });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({ error: 'Refresh Token ๊ฒ์ฆ ์คํจ' });
}
// ์๋ก์ด Access Token ๋ฐ๊ธ
const newAccessToken = jwt.sign(
{ sub: decoded.sub },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
// Refresh Token Rotation (์ ํ์ฌํญ์ด์ง๋ง ๊ถ์ฅ)
refreshTokens.delete(refreshToken);
const newRefreshToken = jwt.sign(
{ sub: decoded.sub },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
refreshTokens.add(newRefreshToken);
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken: newAccessToken });
});
});
// ๋ก๊ทธ์์
router.post('/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
refreshTokens.delete(refreshToken);
res.clearCookie('refreshToken');
res.json({ message: '๋ก๊ทธ์์ ์ฑ๊ณต' });
});
๐ OAuth 2.0 + JWT ํตํฉ ๊ตฌํํ๊ธฐ
์ด์ ์ง์ง ์ค์ ์ด์ผ! OAuth 2.0์ผ๋ก ์์ ๋ก๊ทธ์ธ์ ๊ตฌํํ๊ณ , JWT๋ก ์ธ์ ์ ๊ด๋ฆฌํ๋ ์์ ํ ์์คํ ์ ๋ง๋ค์ด๋ณผ๊ฒ. ๐
์ ์ฒด ์ํคํ ์ฒ ์ค๊ณ
์ค์ ๊ตฌํ: Express.js + Passport.js
1๋จ๊ณ: ํ๋ก์ ํธ ์ค์
// package.json ์์กด์ฑ
{
"dependencies": {
"express": "^4.18.2",
"passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0",
"jsonwebtoken": "^9.0.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.0.3"
}
}
// .env ํ์ผ
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback
JWT_ACCESS_SECRET=your_access_token_secret
JWT_REFRESH_SECRET=your_refresh_token_secret
SESSION_SECRET=your_session_secret
2๋จ๊ณ: Passport ์ค์
// config/passport.js
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const User = require('../models/User'); // ์ฌ์ฉ์ ๋ชจ๋ธ
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL
},
async (accessToken, refreshToken, profile, done) => {
try {
// ๊ธฐ์กด ์ฌ์ฉ์ ์ฐพ๊ธฐ
let user = await User.findOne({ googleId: profile.id });
if (!user) {
// ์ ์ฌ์ฉ์ ์์ฑ
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
avatar: profile.photos[0].value
});
}
return done(null, user);
} catch (error) {
return done(error, null);
}
}
));
module.exports = passport;
3๋จ๊ณ: JWT ์ ํธ๋ฆฌํฐ
// utils/jwt.js
const jwt = require('jsonwebtoken');
class JWTService {
// Access Token ์์ฑ (15๋ถ)
static generateAccessToken(user) {
return jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role || 'user'
},
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m' }
);
}
// Refresh Token ์์ฑ (7์ผ)
static generateRefreshToken(user) {
return jwt.sign(
{ sub: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
}
// Access Token ๊ฒ์ฆ
static verifyAccessToken(token) {
try {
return jwt.verify(token, process.env.JWT_ACCESS_SECRET);
} catch (error) {
throw new Error('Invalid access token');
}
}
// Refresh Token ๊ฒ์ฆ
static verifyRefreshToken(token) {
try {
return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
} catch (error) {
throw new Error('Invalid refresh token');
}
}
}
module.exports = JWTService;
4๋จ๊ณ: ์ธ์ฆ ๋ผ์ฐํธ
// routes/auth.js
const express = require('express');
const passport = require('passport');
const JWTService = require('../utils/jwt');
const router = express.Router();
// ๊ตฌ๊ธ ๋ก๊ทธ์ธ ์์
router.get('/google',
passport.authenticate('google', {
scope: ['profile', 'email'],
session: false // ์ธ์
์ฌ์ฉ ์ ํจ (JWT ์ฌ์ฉ)
})
);
// ๊ตฌ๊ธ ์ฝ๋ฐฑ
router.get('/google/callback',
passport.authenticate('google', {
session: false,
failureRedirect: '/login'
}),
(req, res) => {
// OAuth ์ธ์ฆ ์ฑ๊ณต! ์ด์ JWT ๋ฐ๊ธ
const accessToken = JWTService.generateAccessToken(req.user);
const refreshToken = JWTService.generateRefreshToken(req.user);
// Refresh Token์ HttpOnly ์ฟ ํค๋ก ์ ์ฅ
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7์ผ
});
// Access Token์ ํด๋ผ์ด์ธํธ๋ก ์ ๋ฌ (์ฌ๋ฌ ๋ฐฉ๋ฒ ๊ฐ๋ฅ)
// ๋ฐฉ๋ฒ 1: ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ก ํ๋ก ํธ์๋๋ก ๋ฆฌ๋ค์ด๋ ํธ
res.redirect(`http://localhost:3000/auth-success?token=${accessToken}`);
// ๋ฐฉ๋ฒ 2: JSON ์๋ต (SPA์ ๊ฒฝ์ฐ)
// res.json({ accessToken, user: req.user });
}
);
// Token ๊ฐฑ์
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token not found' });
}
try {
const decoded = JWTService.verifyRefreshToken(refreshToken);
const user = await User.findById(decoded.sub);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// ์ Access Token ๋ฐ๊ธ
const newAccessToken = JWTService.generateAccessToken(user);
// Refresh Token Rotation (์ ํ์ฌํญ)
const newRefreshToken = JWTService.generateRefreshToken(user);
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken: newAccessToken });
} catch (error) {
res.status(403).json({ error: 'Invalid refresh token' });
}
});
// ๋ก๊ทธ์์
router.post('/logout', (req, res) => {
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
module.exports = router;
5๋จ๊ณ: JWT ์ธ์ฆ ๋ฏธ๋ค์จ์ด
// middleware/auth.js
const JWTService = require('../utils/jwt');
const User = require('../models/User');
async function authenticateJWT(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7); // "Bearer " ์ ๊ฑฐ
try {
const decoded = JWTService.verifyAccessToken(token);
// ์ฌ์ฉ์ ์ ๋ณด ์กฐํ (์ ํ์ฌํญ, ์บ์ฑ ๊ถ์ฅ)
const user = await User.findById(decoded.sub);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
req.user = user;
req.userId = decoded.sub;
next();
} catch (error) {
if (error.message === 'jwt expired') {
return res.status(401).json({
error: 'Token expired',
code: 'TOKEN_EXPIRED'
});
}
return res.status(403).json({ error: 'Invalid token' });
}
}
// ์ญํ ๊ธฐ๋ฐ ์ ๊ทผ ์ ์ด
function authorize(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
module.exports = { authenticateJWT, authorize };
6๋จ๊ณ: ๋ณดํธ๋ API ๋ผ์ฐํธ
// routes/api.js
const express = require('express');
const { authenticateJWT, authorize } = require('../middleware/auth');
const router = express.Router();
// ์ธ์ฆ ํ์ํ ๋ผ์ฐํธ
router.get('/profile', authenticateJWT, (req, res) => {
res.json({
user: {
id: req.user.id,
email: req.user.email,
name: req.user.name,
avatar: req.user.avatar
}
});
});
// ๊ด๋ฆฌ์๋ง ์ ๊ทผ ๊ฐ๋ฅ
router.get('/admin/users',
authenticateJWT,
authorize('admin'),
async (req, res) => {
const users = await User.find().select('-password');
res.json({ users });
}
);
// ์ฌ์ฉ์ ์ ๋ณด ์์
router.put('/profile', authenticateJWT, async (req, res) => {
const { name, bio } = req.body;
try {
const updatedUser = await User.findByIdAndUpdate(
req.userId,
{ name, bio },
{ new: true }
);
res.json({ user: updatedUser });
} catch (error) {
res.status(500).json({ error: 'Update failed' });
}
});
module.exports = router;
7๋จ๊ณ: ๋ฉ์ธ ์ฑ ์ค์
// app.js
const express = require('express');
const cookieParser = require('cookie-parser');
const passport = require('./config/passport');
require('dotenv').config();
const authRoutes = require('./routes/auth');
const apiRoutes = require('./routes/api');
const app = express();
// ๋ฏธ๋ค์จ์ด
app.use(express.json());
app.use(cookieParser());
app.use(passport.initialize());
// CORS ์ค์ (ํ๋ก ํธ์๋๊ฐ ๋ค๋ฅธ ๋๋ฉ์ธ์ธ ๊ฒฝ์ฐ)
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Allow-Headers', 'Authorization, Content-Type');
next();
});
// ๋ผ์ฐํธ
app.use('/auth', authRoutes);
app.use('/api', apiRoutes);
// ์๋ฌ ํธ๋ค๋ง
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong!' });
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
ํ๋ก ํธ์๋ ํตํฉ (React ์์ )
// src/services/auth.js
class AuthService {
constructor() {
this.accessToken = null;
}
// ๊ตฌ๊ธ ๋ก๊ทธ์ธ ์์
loginWithGoogle() {
window.location.href = 'http://localhost:5000/auth/google';
}
// ์ฝ๋ฐฑ์์ ํ ํฐ ์ ์ฅ
handleAuthCallback(token) {
this.accessToken = token;
localStorage.setItem('accessToken', token);
}
// API ์์ฒญ
async fetchWithAuth(url, options = {}) {
const token = this.accessToken || localStorage.getItem('accessToken');
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
},
credentials: 'include' // ์ฟ ํค ํฌํจ (Refresh Token)
});
// ํ ํฐ ๋ง๋ฃ ์ ์๋ ๊ฐฑ์
if (response.status === 401) {
const data = await response.json();
if (data.code === 'TOKEN_EXPIRED') {
const refreshed = await this.refreshToken();
if (refreshed) {
// ์ฌ์๋
return this.fetchWithAuth(url, options);
}
}
}
return response;
}
// ํ ํฐ ๊ฐฑ์
async refreshToken() {
try {
const response = await fetch('http://localhost:5000/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const { accessToken } = await response.json();
this.accessToken = accessToken;
localStorage.setItem('accessToken', accessToken);
return true;
}
return false;
} catch (error) {
console.error('Token refresh failed:', error);
return false;
}
}
// ๋ก๊ทธ์์
async logout() {
await fetch('http://localhost:5000/auth/logout', {
method: 'POST',
credentials: 'include'
});
this.accessToken = null;
localStorage.removeItem('accessToken');
}
}
export default new AuthService();
// src/components/Login.jsx
import React from 'react';
import AuthService from '../services/auth';
function Login() {
const handleGoogleLogin = () => {
AuthService.loginWithGoogle();
};
return (
<div className="login-container">
<h1>๋ก๊ทธ์ธ</h1>
<button onClick={handleGoogleLogin}>
๐ ๊ตฌ๊ธ๋ก ๋ก๊ทธ์ธ
</button>
</div>
);
}
export default Login;
// src/components/AuthCallback.jsx
import React, { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import AuthService from '../services/auth';
function AuthCallback() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
useEffect(() => {
const token = searchParams.get('token');
if (token) {
AuthService.handleAuthCallback(token);
navigate('/dashboard');
} else {
navigate('/login');
}
}, [searchParams, navigate]);
return <div>๋ก๊ทธ์ธ ์ฒ๋ฆฌ ์ค...</div>;
}
export default AuthCallback;
// src/components/Profile.jsx
import React, { useEffect, useState } from 'react';
import AuthService from '../services/auth';
function Profile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadProfile();
}, []);
const loadProfile = async () => {
try {
const response = await AuthService.fetchWithAuth(
'http://localhost:5000/api/profile'
);
if (response.ok) {
const data = await response.json();
setUser(data.user);
}
} catch (error) {
console.error('Failed to load profile:', error);
} finally {
setLoading(false);
}
};
if (loading) return <div>๋ก๋ฉ ์ค...</div>;
if (!user) return <div>์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.</div>;
return (
<div className="profile">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
export default Profile;
๐ก๏ธ ๋ณด์ ๋ฒ ์คํธ ํ๋ํฐ์ค
์ธ์ฆ ์์คํ ์ ๋ณด์์ด ์๋ช ์ด์ผ! ๐ ์ค์ ํ๋๋ก ์ ์ฒด ์์คํ ์ด ๋ซ๋ฆด ์ ์์ผ๋ ๊ผญ ์ง์ผ์ผ ํ ์์น๋ค์ ์์๋ณด์.
1. ๋น๋ฐ ํค ๊ด๋ฆฌ
โ ์ ๋ ํ์ง ๋ง์์ผ ํ ๊ฒ๋ค
โข ์ฝ๋์ ํ๋์ฝ๋ฉํ๊ธฐ
โข Git์ ์ปค๋ฐํ๊ธฐ
โข ํด๋ผ์ด์ธํธ ์ฌ์ด๋์ ๋
ธ์ถํ๊ธฐ
โข ์ฝํ ํค ์ฌ์ฉํ๊ธฐ (์ต์ 256๋นํธ)
โ
์ฌ๋ฐ๋ฅธ ๋ฐฉ๋ฒ
โข ํ๊ฒฝ ๋ณ์ ์ฌ์ฉ (.env ํ์ผ)
โข AWS Secrets Manager, HashiCorp Vault ๊ฐ์ ๋น๋ฐ ๊ด๋ฆฌ ์๋น์ค
โข ํ๋ก๋์
๊ณผ ๊ฐ๋ฐ ํ๊ฒฝ ํค ๋ถ๋ฆฌ
โข ์ ๊ธฐ์ ์ธ ํค ๋กํ
์ด์
// ๊ฐ๋ ฅํ ๋น๋ฐ ํค ์์ฑ (Node.js)
const crypto = require('crypto');
// 256๋นํธ ๋๋ค ํค ์์ฑ
const generateSecret = () => {
return crypto.randomBytes(32).toString('hex');
};
console.log('JWT_ACCESS_SECRET=' + generateSecret());
console.log('JWT_REFRESH_SECRET=' + generateSecret());
2. HTTPS ํ์
ํ๋ก๋์ ํ๊ฒฝ์์๋ ๋ฐ๋์ HTTPS๋ฅผ ์ฌ์ฉํด์ผ ํด! ๐ HTTP๋ก ํ ํฐ์ ์ ์กํ๋ฉด ์ค๊ฐ์ ๊ณต๊ฒฉ(Man-in-the-Middle)์ ์ทจ์ฝํด์ ธ.
// Express์์ HTTPS ๊ฐ์
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'production' && !req.secure) {
return res.redirect('https://' + req.headers.host + req.url);
}
next();
});
// ์ฟ ํค ์ค์ ์ secure ํ๋๊ทธ
res.cookie('refreshToken', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS์์๋ง
sameSite: 'strict'
});
3. XSS (Cross-Site Scripting) ๋ฐฉ์ด
XSS ๊ณต๊ฒฉ์ ์ ์์ ์ธ ์คํฌ๋ฆฝํธ๋ฅผ ์ฃผ์ ํด์ ํ ํฐ์ ํ์ณ๊ฐ๋ ๊ณต๊ฒฉ์ด์ผ. ๐
๋ฐฉ์ด ์ ๋ต
1. Access Token์ localStorage์ ์ ์ฅํ์ง ์๊ธฐ
localStorage๋ JavaScript๋ก ์ ๊ทผ ๊ฐ๋ฅํด์ XSS์ ์ทจ์ฝํด. ๋์ :
โข ๋ฉ๋ชจ๋ฆฌ์๋ง ์ ์ฅ (์๋ก๊ณ ์นจ ์ ์ฌ๋ฐ๊ธ)
โข HttpOnly ์ฟ ํค ์ฌ์ฉ (JavaScript ์ ๊ทผ ๋ถ๊ฐ)
2. Content Security Policy (CSP) ์ค์
// Helmet ๋ฏธ๋ค์จ์ด๋ก CSP ์ค์
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // ๊ฐ๋ฅํ๋ฉด 'unsafe-inline' ์ ๊ฑฐ
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourapp.com"]
}
}));
// ์ฌ์ฉ์ ์
๋ ฅ sanitization
const validator = require('validator');
app.post('/api/comment', authenticateJWT, (req, res) => {
let { content } = req.body;
// HTML ํ๊ทธ ์ ๊ฑฐ
content = validator.escape(content);
// ๋๋ DOMPurify ์ฌ์ฉ (ํด๋ผ์ด์ธํธ ์ฌ์ด๋)
// import DOMPurify from 'dompurify';
// const clean = DOMPurify.sanitize(dirty);
// DB ์ ์ฅ...
});
4. CSRF (Cross-Site Request Forgery) ๋ฐฉ์ด
CSRF๋ ์ฌ์ฉ์๊ฐ ์๋ํ์ง ์์ ์์ฒญ์ ๋ณด๋ด๋๋ก ์์ด๋ ๊ณต๊ฒฉ์ด์ผ. ๐ฃ
// CSRF ํ ํฐ ์ฌ์ฉ
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.use(csrfProtection);
app.get('/form', (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
app.post('/api/transfer', csrfProtection, (req, res) => {
// CSRF ํ ํฐ์ด ์ ํจํ ๊ฒฝ์ฐ์๋ง ์คํ
// ...
});
JWT๋ฅผ ์ฌ์ฉํ ๋๋ SameSite ์ฟ ํค ์์ฑ์ผ๋ก๋ ๋ฐฉ์ดํ ์ ์์ด:
res.cookie('refreshToken', token, {
httpOnly: true,
secure: true,
sameSite: 'strict' // ๋๋ 'lax'
});
5. Rate Limiting (์๋ ์ ํ)
๋ฌด์ฐจ๋ณ ๋์ ๊ณต๊ฒฉ(Brute Force)์ ๋ง๊ธฐ ์ํด ์์ฒญ ํ์๋ฅผ ์ ํํด์ผ ํด! โฑ๏ธ
const rateLimit = require('express-rate-limit');
// ๋ก๊ทธ์ธ ์๋ํฌ์ธํธ ์ ํ
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15๋ถ
max: 5, // ์ต๋ 5๋ฒ ์๋
message: '๋๋ฌด ๋ง์ ๋ก๊ทธ์ธ ์๋์
๋๋ค. 15๋ถ ํ ๋ค์ ์๋ํด์ฃผ์ธ์.',
standardHeaders: true,
legacyHeaders: false
});
app.post('/auth/login', loginLimiter, (req, res) => {
// ๋ก๊ทธ์ธ ๋ก์ง
});
// API ์ ์ฒด ์ ํ
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
});
app.use('/api/', apiLimiter);
6. JWT ๋ธ๋๋ฆฌ์คํธ ๊ตฌํ
JWT๋ stateless๋ผ์ ๋ฐ๊ธ ํ ์ทจ์๊ฐ ์ด๋ ค์. ํ์ง๋ง ๋ก๊ทธ์์์ด๋ ๋ณด์ ์ฌ๊ณ ์ ํน์ ํ ํฐ์ ๋ฌดํจํํด์ผ ํ ๋๊ฐ ์์ง. ๐ซ
// Redis๋ฅผ ์ฌ์ฉํ ๋ธ๋๋ฆฌ์คํธ
const redis = require('redis');
const client = redis.createClient();
// ํ ํฐ ๋ธ๋๋ฆฌ์คํธ ์ถ๊ฐ
async function blacklistToken(token, expiresIn) {
const decoded = jwt.decode(token);
const remainingTime = decoded.exp - Math.floor(Date.now() / 1000);
// ๋ง๋ฃ ์๊ฐ๊น์ง๋ง ๋ธ๋๋ฆฌ์คํธ์ ๋ณด๊ด
await client.setEx(`blacklist:${token}`, remainingTime, 'true');
}
// ํ ํฐ ๊ฒ์ฆ ์ ๋ธ๋๋ฆฌ์คํธ ํ์ธ
async function isTokenBlacklisted(token) {
const result = await client.get(`blacklist:${token}`);
return result !== null;
}
// ๋ฏธ๋ค์จ์ด์ ์ ์ฉ
async function authenticateJWT(req, res, next) {
const token = extractToken(req);
if (!token) {
return res.status(401).json({ error: 'No token' });
}
// ๋ธ๋๋ฆฌ์คํธ ํ์ธ
if (await isTokenBlacklisted(token)) {
return res.status(401).json({ error: 'Token has been revoked' });
}
// JWT ๊ฒ์ฆ
try {
const decoded = JWTService.verifyAccessToken(token);
req.user = decoded;
next();
} catch (error) {
res.status(403).json({ error: 'Invalid token' });
}
}
// ๋ก๊ทธ์์ ์ ๋ธ๋๋ฆฌ์คํธ ์ถ๊ฐ
app.post('/auth/logout', authenticateJWT, async (req, res) => {
const token = extractToken(req);
await blacklistToken(token, 900); // 15๋ถ
res.clearCookie('refreshToken');
res.json({ message: 'Logged out' });
});
7. ํ ํฐ ๋ง๋ฃ ์๊ฐ ์ค์
| ํ ํฐ ํ์ | ๊ถ์ฅ ๋ง๋ฃ ์๊ฐ | ์ด์ |
|---|---|---|
| Access Token | 15๋ถ ~ 1์๊ฐ | ํ์ทจ ์ ํผํด ์ต์ํ |
| Refresh Token | 1์ฃผ์ผ ~ 1๊ฐ์ | ์ฌ์ฉ์ ํธ์์ฑ ๊ณ ๋ ค |
| Email ์ธ์ฆ ํ ํฐ | 1์๊ฐ ~ 24์๊ฐ | ๋ณด์๊ณผ ์ฌ์ฉ์ฑ ๊ท ํ |
| ๋น๋ฐ๋ฒํธ ์ฌ์ค์ ํ ํฐ | 15๋ถ ~ 1์๊ฐ | ๋์ ๋ณด์ ํ์ |
8. ๋ก๊น ๊ณผ ๋ชจ๋ํฐ๋ง
์์ฌ์ค๋ฌ์ด ํ๋์ ๊ฐ์งํ๊ธฐ ์ํด ๋ก๊น ์ ํ์์ผ! ๐
// Winston ๋ก๊ฑฐ ์ค์
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// ์ธ์ฆ ์คํจ ๋ก๊น
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await authenticateUser(email, password);
if (!user) {
logger.warn('Failed login attempt', {
email,
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date()
});
return res.status(401).json({ error: 'Invalid credentials' });
}
logger.info('Successful login', {
userId: user.id,
email: user.email,
ip: req.ip
});
// ํ ํฐ ๋ฐ๊ธ...
});
// ์์ฌ์ค๋ฌ์ด ํ๋ ๊ฐ์ง
async function detectSuspiciousActivity(userId) {
// ์ต๊ทผ 1์๊ฐ ๋์ 5๊ฐ ์ด์์ ๋ค๋ฅธ IP์์ ๋ก๊ทธ์ธ?
const recentLogins = await getRecentLogins(userId, 3600);
const uniqueIPs = new Set(recentLogins.map(l => l.ip));
if (uniqueIPs.size >= 5) {
logger.error('Suspicious activity detected', {
userId,
uniqueIPs: Array.from(uniqueIPs)
});
// ์๋ฆผ ์ ์ก, ๊ณ์ ์ ๊ธ ๋ฑ
await notifyUser(userId, 'suspicious_activity');
}
}
๐ ๊ณ ๊ธ ํจํด๊ณผ ์ต์ ํ
1. Token Refresh ์๋ํ (Silent Refresh)
์ฌ์ฉ์๊ฐ ์ฑ์ ์ฌ์ฉํ๋ ์ค์ ํ ํฐ์ด ๋ง๋ฃ๋๋ฉด ์ง์ฆ๋์์? ๐ค ์๋์ผ๋ก ๊ฐฑ์ ํด์ฃผ๋ ํจํด์ ๊ตฌํํด๋ณด์!
// React์์ ์๋ ๊ฐฑ์ ๊ตฌํ
import { useEffect, useRef } from 'react';
import AuthService from './services/auth';
function useTokenRefresh() {
const refreshTimerRef = useRef(null);
useEffect(() => {
// Access Token ๋ง๋ฃ 5๋ถ ์ ์ ๊ฐฑ์
const scheduleRefresh = () => {
const token = localStorage.getItem('accessToken');
if (!token) return;
const decoded = JSON.parse(atob(token.split('.')[1]));
const expiresAt = decoded.exp * 1000; // ๋ฐ๋ฆฌ์ด๋ก ๋ณํ
const now = Date.now();
const timeUntilRefresh = expiresAt - now - (5 * 60 * 1000); // 5๋ถ ์
if (timeUntilRefresh > 0) {
refreshTimerRef.current = setTimeout(async () => {
await AuthService.refreshToken();
scheduleRefresh(); // ๋ค์ ๊ฐฑ์ ์์ฝ
}, timeUntilRefresh);
}
};
scheduleRefresh();
return () => {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
};
}, []);
}
// App.js์์ ์ฌ์ฉ
function App() {
useTokenRefresh();
return (
// ...
);
}
2. ๋ค์ค ๋๋ฐ์ด์ค ์ธ์ ๊ด๋ฆฌ
์ฌ์ฉ์๊ฐ ์ฌ๋ฌ ๊ธฐ๊ธฐ์์ ๋ก๊ทธ์ธํ์ ๋, ๊ฐ ์ธ์ ์ ๊ด๋ฆฌํ๊ณ ํ์์ ํน์ ์ธ์ ์ ์ข ๋ฃํ ์ ์์ด์ผ ํด! ๐ฑ๐ป
// ์ธ์
๋ชจ๋ธ
const sessionSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
refreshToken: { type: String, required: true },
deviceInfo: {
userAgent: String,
ip: String,
deviceType: String // 'mobile', 'desktop', 'tablet'
},
createdAt: { type: Date, default: Date.now },
lastUsedAt: { type: Date, default: Date.now },
expiresAt: Date
});
// ๋ก๊ทธ์ธ ์ ์ธ์
์์ฑ
router.post('/login', async (req, res) => {
const user = await authenticateUser(req.body.email, req.body.password);
const accessToken = JWTService.generateAccessToken(user);
const refreshToken = JWTService.generateRefreshToken(user);
// ์ธ์
์ ์ฅ
await Session.create({
userId: user.id,
refreshToken,
deviceInfo: {
userAgent: req.headers['user-agent'],
ip: req.ip,
deviceType: detectDeviceType(req.headers['user-agent'])
},
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
res.cookie('refreshToken', refreshToken, { /* ... */ });
res.json({ accessToken });
});
// ํ์ฑ ์ธ์
๋ชฉ๋ก ์กฐํ
router.get('/sessions', authenticateJWT, async (req, res) => {
const sessions = await Session.find({
userId: req.userId,
expiresAt: { $gt: new Date() }
}).sort({ lastUsedAt: -1 });
res.json({ sessions });
});
// ํน์ ์ธ์
์ข
๋ฃ
router.delete('/sessions/:sessionId', authenticateJWT, async (req, res) => {
const session = await Session.findOne({
_id: req.params.sessionId,
userId: req.userId
});
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// ๋ธ๋๋ฆฌ์คํธ์ ์ถ๊ฐ
await blacklistToken(session.refreshToken, 604800); // 7์ผ
await session.remove();
res.json({ message: 'Session terminated' });
});
// ๋ชจ๋ ๋ค๋ฅธ ์ธ์
์ข
๋ฃ
router.post('/sessions/terminate-others', authenticateJWT, async (req, res) => {
const currentRefreshToken = req.cookies.refreshToken;
const sessions = await Session.find({
userId: req.userId,
refreshToken: { $ne: currentRefreshToken }
});
// ๋ชจ๋ ๋ค๋ฅธ ์ธ์
์ ํ ํฐ ๋ธ๋๋ฆฌ์คํธ ์ถ๊ฐ
for (const session of sessions) {
await blacklistToken(session.refreshToken, 604800);
}
await Session.deleteMany({
userId: req.userId,
refreshToken: { $ne: currentRefreshToken }
});
res.json({ message: `${sessions.length} sessions terminated` });
});
3. ์์ ๋ก๊ทธ์ธ ๊ณ์ ์ฐ๋
์ฌ์ฉ์๊ฐ ์ฌ๋ฌ ์์ ๊ณ์ ์ ํ๋์ ๊ณ์ ์ ์ฐ๊ฒฐํ ์ ์๊ฒ ํด๋ณด์! ๐
// ์ฌ์ฉ์ ๋ชจ๋ธ ํ์ฅ
const userSchema = new mongoose.Schema({
email: String,
name: String,
avatar: String,
providers: [{
provider: String, // 'google', 'facebook', 'github'
providerId: String,
email: String,
connectedAt: Date
}]
});
// ๊ณ์ ์ฐ๋ ๋ผ์ฐํธ
router.get('/connect/google',
authenticateJWT, // ์ด๋ฏธ ๋ก๊ทธ์ธ๋ ์ํ
passport.authenticate('google', {
scope: ['profile', 'email'],
session: false,
state: req.user.id // ํ์ฌ ์ฌ์ฉ์ ID ์ ๋ฌ
})
);
router.get('/connect/google/callback',
passport.authenticate('google', { session: false }),
async (req, res) => {
const userId = req.query.state; // ์๋ ์ฌ์ฉ์ ID
const googleProfile = req.user;
// ์ด๋ฏธ ๋ค๋ฅธ ๊ณ์ ์ ์ฐ๊ฒฐ๋์ด ์๋์ง ํ์ธ
const existingUser = await User.findOne({
'providers.provider': 'google',
'providers.providerId': googleProfile.id
});
if (existingUser && existingUser.id !== userId) {
return res.redirect('/settings?error=already_connected');
}
// ํ์ฌ ์ฌ์ฉ์์ ๊ตฌ๊ธ ๊ณ์ ์ฐ๊ฒฐ
await User.findByIdAndUpdate(userId, {
$push: {
providers: {
provider: 'google',
providerId: googleProfile.id,
email: googleProfile.emails[0].value,
connectedAt: new Date()
}
}
});
res.redirect('/settings?success=connected');
}
);
// ๊ณ์ ์ฐ๊ฒฐ ํด์
router.delete('/connect/:provider', authenticateJWT, async (req, res) => {
const user = await User.findById(req.userId);
// ์ต์ 1๊ฐ์ ๋ก๊ทธ์ธ ๋ฐฉ๋ฒ์ ๋จ๊ฒจ์ผ ํจ
if (user.providers.length <= 1 && !user.password) {
return res.status(400).json({
error: '์ต์ 1๊ฐ์ ๋ก๊ทธ์ธ ๋ฐฉ๋ฒ์ด ํ์ํฉ๋๋ค'
});
}
await User.findByIdAndUpdate(req.userId, {
$pull: {
providers: { provider: req.params.provider }
}
});
res.json({ message: 'Provider disconnected' });
});
4. JWT ํ์ด๋ก๋ ์ต์ ํ
JWT๋ ๋ชจ๋ ์์ฒญ์ ํฌํจ๋๋๊น ํฌ๊ธฐ๋ฅผ ์ต์ํํ๋ ๊ฒ ์ค์ํด! ๐ฆ
์ต์ ํ ํ
โ ๋์ ์:
{
"userId": "507f1f77bcf86cd799439011",
"userEmail": "user@example.com",
"userName": "John Doe",
"userRole": "admin",
"userAvatar": "https://example.com/avatar.jpg",
"userPreferences": { /* ... */ },
"issuedAt": 1516239022,
"expirationTime": 1516242622
}
โ ์ข์ ์:
{
"sub": "507f1f77bcf86cd799439011",
"rol": "adm",
"iat": 1516239022,
"exp": 1516242622
}
ํ์ํ ์ ๋ณด๋ง ๋ด๊ณ , ํค ์ด๋ฆ์ ์งง๊ฒ! ๋๋จธ์ง ์ ๋ณด๋ ์๋ฒ์์ ์กฐํํ๊ฑฐ๋ ์บ์ฑํด.
5. ์บ์ฑ ์ ๋ต
๋งค ์์ฒญ๋ง๋ค DB์์ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ์กฐํํ๋ฉด ๋๋ ค์ ธ. Redis๋ก ์บ์ฑํ์! โก
const redis = require('redis');
const client = redis.createClient();
// ์ฌ์ฉ์ ์ ๋ณด ์บ์ฑ
async function getUserFromCache(userId) {
const cached = await client.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
// ์บ์ ๋ฏธ์ค - DB์์ ์กฐํ
const user = await User.findById(userId);
if (user) {
// 5๋ถ๊ฐ ์บ์ฑ
await client.setEx(
`user:${userId}`,
300,
JSON.stringify(user)
);
}
return user;
}
// ๋ฏธ๋ค์จ์ด์ ์ ์ฉ
async function authenticateJWT(req, res, next) {
const token = extractToken(req);
try {
const decoded = JWTService.verifyAccessToken(token);
// ์บ์์์ ์ฌ์ฉ์ ์ ๋ณด ์กฐํ
const user = await getUserFromCache(decoded.sub);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (error) {
res.status(403).json({ error: 'Invalid token' });
}
}
// ์ฌ์ฉ์ ์ ๋ณด ์
๋ฐ์ดํธ ์ ์บ์ ๋ฌดํจํ
router.put('/profile', authenticateJWT, async (req, res) => {
const updatedUser = await User.findByIdAndUpdate(
req.userId,
req.body,
{ new: true }
);
// ์บ์ ์ญ์
await client.del(`user:${req.userId}`);
res.json({ user: updatedUser });
});
๐ ๋๋ฒ๊น ๊ณผ ํธ๋ฌ๋ธ์ํ
์์ฃผ ๋ฐ์ํ๋ ๋ฌธ์ ๋ค
1. "Invalid Token" ์๋ฌ
์ฆ์: ํ ํฐ์ด ์ ํจํด ๋ณด์ด๋๋ฐ ๊ณ์ Invalid Token ์๋ฌ๊ฐ ๋ฐ์
์์ธ:
โข ์๋ฒ์ ํด๋ผ์ด์ธํธ์ ๋น๋ฐ ํค๊ฐ ๋ค๋ฆ
โข ํ ํฐ์ด ์๋ชป ํ์ฑ๋จ (๊ณต๋ฐฑ, ์ค๋ฐ๊ฟ ํฌํจ)
โข ์๊ณ ๋ฆฌ์ฆ ๋ถ์ผ์น (HS256 vs RS256)
ํด๊ฒฐ:
// ํ ํฐ ๋๋ฒ๊น
const token = req.headers.authorization?.split(' ')[1];
console.log('Raw token:', token);
console.log('Token length:', token?.length);
try {
const decoded = jwt.decode(token, { complete: true });
console.log('Decoded header:', decoded.header);
console.log('Decoded payload:', decoded.payload);
// ๊ฒ์ฆ
jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
console.error('Verification error:', error.message);
}
2. CORS ์๋ฌ
์ฆ์: ๋ธ๋ผ์ฐ์ ์ฝ์์ "Access to fetch has been blocked by CORS policy"
ํด๊ฒฐ:
const cors = require('cors');
app.use(cors({
origin: 'http://localhost:3000', // ํ๋ก ํธ์๋ URL
credentials: true, // ์ฟ ํค ํ์ฉ
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Preflight ์์ฒญ ์ฒ๋ฆฌ
app.options('*', cors());
3. ์ฟ ํค๊ฐ ์ ์ก๋์ง ์์
์์ธ:
โข SameSite ์ค์ ๋ฌธ์
โข ๋๋ฉ์ธ ๋ถ์ผ์น
โข credentials ์ต์
๋๋ฝ
ํด๊ฒฐ:
// ์๋ฒ
res.cookie('refreshToken', token, {
httpOnly: true,
secure: false, // ๊ฐ๋ฐ ํ๊ฒฝ์์๋ false
sameSite: 'lax', // 'strict'๋ ๋๋ฌด ์๊ฒฉํ ์ ์์
domain: 'localhost' // ๋ช
์์ ์ผ๋ก ์ค์
});
// ํด๋ผ์ด์ธํธ
fetch('http://localhost:5000/api/profile', {
credentials: 'include' // ํ์!
});
4. ํ ํฐ ๋ง๋ฃ ์ฒ๋ฆฌ๊ฐ ์ ๋จ
๋ฌธ์ : ๋ง๋ฃ๋ ํ ํฐ์ผ๋ก๋ ๊ณ์ ์ ๊ทผ ๊ฐ๋ฅ
์์ธ: ์๋ฒ ์๊ฐ๊ณผ ํด๋ผ์ด์ธํธ ์๊ฐ ๋ถ์ผ์น, ๋๋ exp ํด๋ ์ ๋๋ฝ
ํด๊ฒฐ:
// ํ ํฐ ์์ฑ ์ ๋ช
์์ ์ผ๋ก exp ์ค์
const token = jwt.sign(
{ sub: user.id },
secret,
{
expiresIn: '15m',
// ๋๋ ์ง์ ์ค์
// exp: Math.floor(Date.now() / 1000) + (15 * 60)
}
);
// ๊ฒ์ฆ ์ ์๊ฐ ํ์ฉ ์ค์ฐจ ์ค์ (5์ด)
jwt.verify(token, secret, {
clockTolerance: 5
});
์ ์ฉํ ๋๋ฒ๊น ๋๊ตฌ
๐ ๏ธ ์ถ์ฒ ๋๊ตฌ๋ค
1. jwt.io - JWT ๋์ฝ๋/๊ฒ์ฆ๊ธฐ (https://jwt.io)
ํ ํฐ์ ๋ถ์ฌ๋ฃ์ผ๋ฉด ํค๋, ํ์ด๋ก๋, ์๋ช
์ ๋ณด์ฌ์ค
2. Postman - API ํ
์คํ
Authorization ํญ์์ Bearer Token ์๋ ์ค์ ๊ฐ๋ฅ
3. Chrome DevTools
โข Application ํญ: ์ฟ ํค, localStorage ํ์ธ
โข Network ํญ: ์์ฒญ ํค๋ ํ์ธ
4. VS Code ํ์ฅ
โข REST Client: .http ํ์ผ๋ก API ํ
์คํธ
โข JWT Debugger: ํ ํฐ ๋์ฝ๋ฉ
๐ผ ์ค์ ํ๋ก์ ํธ ์๋๋ฆฌ์ค
์ด์ ๋ฐฐ์ด ๊ฑธ ์ข ํฉํด์ ์ค์ ํ๋ก์ ํธ๋ฅผ ๋ง๋ค์ด๋ณด์! ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ์ฌ์ฉํ ์ ์๋ ์ธ์ฆ ์์คํ ์ ๊ตฌ์ถํด๋ณผ๊ฒ. ๐จ
ํ๋ก์ ํธ: "SkillShare" ์ฌ๋ฅ ๊ณต์ ํ๋ซํผ
์๊ตฌ์ฌํญ
โ
์์
๋ก๊ทธ์ธ (๊ตฌ๊ธ, ์นด์นด์ค)
โ
์ด๋ฉ์ผ/๋น๋ฐ๋ฒํธ ๋ก๊ทธ์ธ
โ
์ด๋ฉ์ผ ์ธ์ฆ
โ
๋น๋ฐ๋ฒํธ ์ฌ์ค์
โ
์ญํ ๊ธฐ๋ฐ ์ ๊ทผ ์ ์ด (์ฌ์ฉ์, ์ ๋ฌธ๊ฐ, ๊ด๋ฆฌ์)
โ
๋ค์ค ๋๋ฐ์ด์ค ์ธ์
๊ด๋ฆฌ
โ
2๋จ๊ณ ์ธ์ฆ (์ ํ์ฌํญ)
๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true,
lowercase: true
},
password: {
type: String,
select: false // ๊ธฐ๋ณธ ์ฟผ๋ฆฌ์์ ์ ์ธ
},
name: {
type: String,
required: true
},
avatar: String,
role: {
type: String,
enum: ['user', 'expert', 'admin'],
default: 'user'
},
providers: [{
provider: String,
providerId: String,
email: String,
connectedAt: Date
}],
emailVerified: {
type: Boolean,
default: false
},
twoFactorEnabled: {
type: Boolean,
default: false
},
twoFactorSecret: String,
skills: [String], // ์ฌ๋ฅ๋ท์ฒ๋ผ ๋ณด์ ์คํฌ
rating: {
type: Number,
default: 0
},
createdAt: {
type: Date,
default: Date.now
}
});
// ๋น๋ฐ๋ฒํธ ํด์ฑ
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
// ๋น๋ฐ๋ฒํธ ๋น๊ต ๋ฉ์๋
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
์ด๋ฉ์ผ ์ธ์ฆ ๊ตฌํ
// routes/auth.js
const crypto = require('crypto');
const nodemailer = require('nodemailer');
// ์ด๋ฉ์ผ ์ ์ก ์ค์
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD
}
});
// ํ์๊ฐ์
router.post('/register', async (req, res) => {
const { email, password, name } = req.body;
// ์ด๋ฏธ ์กด์ฌํ๋ ์ฌ์ฉ์ ํ์ธ
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ error: '์ด๋ฏธ ์กด์ฌํ๋ ์ด๋ฉ์ผ์
๋๋ค' });
}
// ์ฌ์ฉ์ ์์ฑ
const user = await User.create({
email,
password,
name,
emailVerified: false
});
// ์ธ์ฆ ํ ํฐ ์์ฑ (1์๊ฐ ์ ํจ)
const verificationToken = jwt.sign(
{ userId: user.id, type: 'email_verification' },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// ์ธ์ฆ ์ด๋ฉ์ผ ์ ์ก
const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`;
await transporter.sendMail({
from: process.env.EMAIL_USER,
to: email,
subject: 'SkillShare ์ด๋ฉ์ผ ์ธ์ฆ',
html: `
<h1>ํ์ํฉ๋๋ค!</h1>
<p>์๋ ๋งํฌ๋ฅผ ํด๋ฆญํ์ฌ ์ด๋ฉ์ผ์ ์ธ์ฆํด์ฃผ์ธ์:</p>
<a href="${verificationUrl}">์ด๋ฉ์ผ ์ธ์ฆํ๊ธฐ</a>
<p>์ด ๋งํฌ๋ 1์๊ฐ ๋์ ์ ํจํฉ๋๋ค.</p>
`
});
res.status(201).json({
message: 'ํ์๊ฐ์
์ฑ๊ณต! ์ด๋ฉ์ผ์ ํ์ธํด์ฃผ์ธ์.',
userId: user.id
});
});
// ์ด๋ฉ์ผ ์ธ์ฆ
router.post('/verify-email', async (req, res) => {
const { token } = req.body;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (decoded.type !== 'email_verification') {
return res.status(400).json({ error: '์ ํจํ์ง ์์ ํ ํฐ์
๋๋ค' });
}
const user = await User.findByIdAndUpdate(
decoded.userId,
{ emailVerified: true },
{ new: true }
);
if (!user) {
return res.status(404).json({ error: '์ฌ์ฉ์๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค' });
}
// ์ธ์ฆ ์๋ฃ ํ ์๋ ๋ก๊ทธ์ธ
const accessToken = JWTService.generateAccessToken(user);
const refreshToken = JWTService.generateRefreshToken(user);
res.cookie('refreshToken', refreshToken, { /* ... */ });
res.json({
message: '์ด๋ฉ์ผ ์ธ์ฆ ์๋ฃ!',
accessToken,
user: {
id: user.id,
email: user.email,
name: user.name
}
});
} catch (error) {
res.status(400).json({ error: 'ํ ํฐ์ด ๋ง๋ฃ๋์๊ฑฐ๋ ์ ํจํ์ง ์์ต๋๋ค' });
}
});
๋น๋ฐ๋ฒํธ ์ฌ์ค์
// ๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์์ฒญ
router.post('/forgot-password', async (req, res) => {
const { email } = req.body;
const user = await User.findOne({ email });
if (!user) {
// ๋ณด์์ ์ฌ์ฉ์ ์กด์ฌ ์ฌ๋ถ๋ฅผ ์๋ ค์ฃผ์ง ์์
return res.json({
message: '์ด๋ฉ์ผ์ด ๋ฐ์ก๋์์ต๋๋ค (์กด์ฌํ๋ ๊ฒฝ์ฐ)'
});
}
// ์ฌ์ค์ ํ ํฐ ์์ฑ (15๋ถ ์ ํจ)
const resetToken = jwt.sign(
{ userId: user.id, type: 'password_reset' },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`;
await transporter.sendMail({
from: process.env.EMAIL_USER,
to: email,
subject: 'SkillShare ๋น๋ฐ๋ฒํธ ์ฌ์ค์ ',
html: `
<h1>๋น๋ฐ๋ฒํธ ์ฌ์ค์ </h1>
<p>์๋ ๋งํฌ๋ฅผ ํด๋ฆญํ์ฌ ๋น๋ฐ๋ฒํธ๋ฅผ ์ฌ์ค์ ํ์ธ์:</p>
<a href="${resetUrl}">๋น๋ฐ๋ฒํธ ์ฌ์ค์ ํ๊ธฐ</a>
<p>์ด ๋งํฌ๋ 15๋ถ ๋์ ์ ํจํฉ๋๋ค.</p>
<p>์์ฒญํ์ง ์์๋ค๋ฉด ์ด ์ด๋ฉ์ผ์ ๋ฌด์ํ์ธ์.</p>
`
});
res.json({ message: '์ด๋ฉ์ผ์ด ๋ฐ์ก๋์์ต๋๋ค' });
});
// ๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์คํ
router.post('/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (decoded.type !== 'password_reset') {
return res.status(400).json({ error: '์ ํจํ์ง ์์ ํ ํฐ์
๋๋ค' });
}
const user = await User.findById(decoded.userId).select('+password');
if (!user) {
return res.status(404).json({ error: '์ฌ์ฉ์๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค' });
}
// ์ ๋น๋ฐ๋ฒํธ ์ค์
user.password = newPassword;
await user.save();
// ๋ชจ๋ ๊ธฐ์กด ์ธ์
๋ฌดํจํ
await Session.deleteMany({ userId: user.id });
res.json({ message: '๋น๋ฐ๋ฒํธ๊ฐ ์ฌ์ค์ ๋์์ต๋๋ค' });
} catch (error) {
res.status(400).json({ error: 'ํ ํฐ์ด ๋ง๋ฃ๋์๊ฑฐ๋ ์ ํจํ์ง ์์ต๋๋ค' });
}
});
์ญํ ๊ธฐ๋ฐ ์ ๊ทผ ์ ์ด (RBAC)
// middleware/rbac.js
const permissions = {
user: [
'profile:read',
'profile:update',
'skill:search',
'booking:create'
],
expert: [
'profile:read',
'profile:update',
'skill:search',
'skill:create',
'skill:update',
'booking:create',
'booking:manage'
],
admin: [
'*' // ๋ชจ๋ ๊ถํ
]
};
function hasPermission(userRole, requiredPermission) {
const userPermissions = permissions[userRole] || [];
return userPermissions.includes('*') ||
userPermissions.includes(requiredPermission);
}
function requirePermission(permission) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (!hasPermission(req.user.role, permission)) {
return res.status(403).json({
error: 'Forbidden',
message: `${permission} ๊ถํ์ด ํ์ํฉ๋๋ค`
});
}
next();
};
}
module.exports = { requirePermission, hasPermission };
// ์ฌ์ฉ ์์
const { requirePermission } = require('../middleware/rbac');
// ์คํฌ ๋ฑ๋ก (์ ๋ฌธ๊ฐ๋ง ๊ฐ๋ฅ)
router.post('/skills',
authenticateJWT,
requirePermission('skill:create'),
async (req, res) => {
const skill = await Skill.create({
...req.body,
expertId: req.userId
});
res.status(201).json({ skill });
}
);
// ์ฌ์ฉ์ ๊ด๋ฆฌ (๊ด๋ฆฌ์๋ง ๊ฐ๋ฅ)
router.get('/admin/users',
authenticateJWT,
requirePermission('user:manage'),
async (req, res) => {
const users = await User.find();
res.json({ users });
}
);
2๋จ๊ณ ์ธ์ฆ (2FA) ๊ตฌํ
// 2FA ์ค์
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
router.post('/2fa/setup', authenticateJWT, async (req, res) => {
// ์ํฌ๋ฆฟ ์์ฑ
const secret = speakeasy.generateSecret({
name: `SkillShare (${req.user.email})`
});
// ์ฌ์ฉ์์๊ฒ ์ํฌ๋ฆฟ ์ ์ฅ (์์ง ํ์ฑํ ์ ํจ)
await User.findByIdAndUpdate(req.userId, {
twoFactorSecret: secret.base32
});
// QR ์ฝ๋ ์์ฑ
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({
secret: secret.base32,
qrCode: qrCodeUrl
});
});
// 2FA ํ์ฑํ
router.post('/2fa/enable', authenticateJWT, async (req, res) => {
const { token } = req.body;
const user = await User.findById(req.userId);
// ํ ํฐ ๊ฒ์ฆ
const verified = speakeasy.totp.verify({
secret: user.twoFactorSecret,
encoding: 'base32',
token: token
});
if (!verified) {
return res.status(400).json({ error: '์ ํจํ์ง ์์ ์ฝ๋์
๋๋ค' });
}
// 2FA ํ์ฑํ
await User.findByIdAndUpdate(req.userId, {
twoFactorEnabled: true
});
res.json({ message: '2๋จ๊ณ ์ธ์ฆ์ด ํ์ฑํ๋์์ต๋๋ค' });
});
// ๋ก๊ทธ์ธ ์ 2FA ํ์ธ
router.post('/login', async (req, res) => {
const { email, password, twoFactorToken } = req.body;
const user = await User.findOne({ email }).select('+password');
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ error: '์ด๋ฉ์ผ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์๋ชป๋์์ต๋๋ค' });
}
// 2FA ํ์ฑํ๋ ๊ฒฝ์ฐ
if (user.twoFactorEnabled) {
if (!twoFactorToken) {
return res.status(200).json({
requiresTwoFactor: true,
userId: user.id
});
}
// 2FA ํ ํฐ ๊ฒ์ฆ
const verified = speakeasy.totp.verify({
secret: user.twoFactorSecret,
encoding: 'base32',
token: twoFactorToken
});
if (!verified) {
return res.status(401).json({ error: '์ ํจํ์ง ์์ 2FA ์ฝ๋์
๋๋ค' });
}
}
// ๋ก๊ทธ์ธ ์ฑ๊ณต
const accessToken = JWTService.generateAccessToken(user);
const refreshToken = JWTService.generateRefreshToken(user);
res.cookie('refreshToken', refreshToken, { /* ... */ });
res.json({ accessToken, user });
});
๐ ๋ง๋ฌด๋ฆฌํ๋ฉฐ
์! ์ฌ๊ธฐ๊น์ง ์๋ค๋ฉด ์ ๋ง ๋๋จํด! ๐ OAuth 2.0๊ณผ JWT์ ๋ํด ์ ๋ง ๋ง์ ๊ฑธ ๋ฐฐ์ ์ด. ์ด์ ๋๋ ํ๋์ ์ธ ์ธ์ฆ ์์คํ ์ ๊ตฌ์ถํ ์ ์๋ ์ง์์ ๊ฐ์ท์ด.
๐ฏ ํต์ฌ ์์ฝ
OAuth 2.0:
โข ์ธ๊ฐ ํ๋ ์์ํฌ๋ก, ๋น๋ฐ๋ฒํธ ์์ด ์์ ํ๊ฒ ๊ถํ ๋ถ์ฌ
โข 4๊ฐ์ง Grant Type: Authorization Code, Implicit, Password, Client Credentials
โข PKCE๋ก ๋ชจ๋ฐ์ผ/SPA ๋ณด์ ๊ฐํ
JWT:
โข Header, Payload, Signature๋ก ๊ตฌ์ฑ๋ ํ ํฐ
โข Statelessํ์ฌ ์๋ฒ ๋ถ๋ด ๊ฐ์
โข Access Token (์งง์ ์๋ช
) + Refresh Token (๊ธด ์๋ช
) ์กฐํฉ
๋ณด์:
โข HTTPS ํ์, HttpOnly ์ฟ ํค ์ฌ์ฉ
โข XSS, CSRF ๋ฐฉ์ด
โข Rate Limiting, ๋ก๊น
, ๋ชจ๋ํฐ๋ง
โข ํ ํฐ ๋ธ๋๋ฆฌ์คํธ, ์ธ์
๊ด๋ฆฌ
๐ก ๋ค์ ๋จ๊ณ๋ก
1. ์ค์ตํ๊ธฐ: ์ง์ ์์ ํ๋ก์ ํธ๋ฅผ ๋ง๋ค์ด๋ด. ์ด๋ก ๋ง์ผ๋ก๋ ๋ถ์กฑํด!
2. ์คํ์์ค ๋ถ์: ์ ๋ช
ํ ํ๋ก์ ํธ๋ค์ ์ธ์ฆ ๊ตฌํ์ ์ดํด๋ด
3. ์ต์ ํธ๋ ๋ ํ๋ก์ฐ: OAuth 2.1, WebAuthn, Passkeys ๋ฑ ์๋ก์ด ๊ธฐ์ ๋ ๊ณต๋ถํด๋ด
4. ๋ณด์ ๋ด์ค ๊ตฌ๋
: ์๋ก์ด ์ทจ์ฝ์ ๊ณผ ๋์ ๋ฐฉ๋ฒ์ ๊ณ์ ํ์ตํด
์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์๋ ์ด๋ฐ ์ธ์ฆ ์์คํ ์ด ํต์ฌ์ด์ผ. ์ฌ์ฉ์๋ค์ด ์์ ํ๊ฒ ๋ก๊ทธ์ธํ๊ณ , ์์ ์ ์ฌ๋ฅ์ ๊ณต์ ํ๊ณ , ๋ค๋ฅธ ์ฌ๋์ ์ฌ๋ฅ์ ์ด์ฉํ ์ ์๊ฒ ํด์ฃผ๋ ๊ธฐ๋ฐ์ด ๋ฐ๋ก ์ธ์ฆ์ด๊ฑฐ๋ ! ๐
์์ฉํ๋ก๊ทธ๋๋ฐ์์ ์ธ์ฆ์ ์ ํ์ด ์๋๋ผ ํ์์ผ. ํ์ง๋ง ์ด์ ๋๋ ์ด ๋ณต์กํ ์ฃผ์ ๋ฅผ ์ ๋ณตํ์ด! ์์ ๊ฐ์ ๊ฐ์ง๊ณ ์ค์ ์ ์ ์ฉํด๋ด. ์ฒ์์ ์ด๋ ค์ธ ์ ์์ง๋ง, ๊ณ์ ์ฐ์ตํ๋ค ๋ณด๋ฉด ์ด๋์ ์ ๋ฌธ๊ฐ๊ฐ ๋์ด ์์ ๊ฑฐ์ผ. ๐ช
๊ถ๊ธํ ์ ์ด ์๊ฑฐ๋ ๋งํ๋ ๋ถ๋ถ์ด ์๋ค๋ฉด, ์ปค๋ฎค๋ํฐ์ ์ง๋ฌธํ๋ ๊ฒ๋ ์ข์ ๋ฐฉ๋ฒ์ด์ผ. ๊ฐ๋ฐ์๋ค์ ์๋ก ๋๋ ๋ฌธํ๊ฐ ์์ผ๋๊น! ๊ทธ๋ฆฌ๊ณ ๋ณด์์ ๋์ด ์๋ ์ฌ์ ์ด๋ผ๋ ๊ฑธ ๊ธฐ์ตํด. ํญ์ ๋ฐฐ์ฐ๊ณ , ๊ฐ์ ํ๊ณ , ์ ๋ฐ์ดํธํ๋ ์์ธ๊ฐ ์ค์ํด.
์, ์ด์ ๋๊ฐ์ ๋ฉ์ง ์ ํ๋ฆฌ์ผ์ด์ ์ ๋ง๋ค์ด๋ด! ํ์ดํ ! ๐โจ
๊ด๋ จ ํค์๋
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.

๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.