OAuth 제거, 닉네임 입력 방식으로 변경 및 관리자 페이지 추가

- 넥슨 OAuth 로그인 제거 (Redis, 세션, User 모델 등)
- 캐릭터 닉네임 입력 → API 키로 조회하는 방식으로 변경
- 관리자 페이지 추가 (/admin?key=<NEXON_API_KEY>)
- 보스 선택 UI를 3단 레이아웃(캐릭터/보스/결과)으로 리디자인
- 캐릭터 및 보스 선택 데이터 localStorage 저장

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-11 18:02:32 +09:00
parent 4bbb496724
commit 6c51e7d94d
23 changed files with 446 additions and 634 deletions

16
.env
View file

@ -5,10 +5,6 @@ DB_USER=maplestory
DB_PASSWORD=xSMK3sG9DG9Vn2dQ
DB_NAME=maplestory
# Redis (세션 저장)
REDIS_HOST=redis
REDIS_PORT=6379
# RustFS (S3 호환 스토리지)
S3_ENDPOINT=http://rustfs:9000
S3_PUBLIC_URL=https://s3.caadiq.co.kr
@ -16,16 +12,8 @@ S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET=maplestory
# 넥슨 OAuth
NEXON_CLIENT_ID=
NEXON_CLIENT_SECRET=
NEXON_REDIRECT_URI=https://maple.caadiq.co.kr/api/auth/callback
# 넥슨 API (캐릭터 상세 조회용)
NEXON_API_KEY=
# 세션
SESSION_SECRET=
# 넥슨 API
NEXON_API_KEY=test_d32f00908105a5803bf0ce5cf717747c0f06152c00f907ea7f9bb68d3541d2b6efe8d04e6d233bd35cf2fabdeb93fb0d
# 앱
NODE_ENV=development

View file

@ -1,6 +0,0 @@
import Redis from 'ioredis';
export const redis = new Redis({
host: process.env.REDIS_HOST || 'redis',
port: parseInt(process.env.REDIS_PORT || '6379'),
});

View file

@ -1,6 +0,0 @@
export function requireAuth(req, res, next) {
if (!req.session?.userId) {
return res.status(401).json({ error: '로그인이 필요합니다' });
}
next();
}

View file

@ -1,16 +0,0 @@
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { redis } from '../lib/redis.js';
export const sessionMiddleware = session({
store: new RedisStore({ client: redis, prefix: 'maple:sess:' }),
secret: process.env.SESSION_SECRET || 'dev-secret',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 14 * 24 * 60 * 60 * 1000, // 14일
sameSite: 'lax',
},
});

View file

@ -1,10 +0,0 @@
import { DataTypes } from 'sequelize';
import { sequelize } from '../lib/db.js';
export const User = sequelize.define('User', {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
nexon_uid: { type: DataTypes.STRING(50), allowNull: false, unique: true },
}, {
tableName: 'users',
underscored: true,
});

View file

@ -1,19 +0,0 @@
import { DataTypes } from 'sequelize';
import { sequelize } from '../lib/db.js';
export const UserCharacter = sequelize.define('UserCharacter', {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
user_id: { type: DataTypes.INTEGER, allowNull: false },
character_name: { type: DataTypes.STRING(50), allowNull: false },
ocid: { type: DataTypes.STRING(100) },
world_name: { type: DataTypes.STRING(20) },
job_name: { type: DataTypes.STRING(50) },
character_level: { type: DataTypes.INTEGER },
character_image: { type: DataTypes.STRING(255) },
}, {
tableName: 'user_characters',
underscored: true,
indexes: [
{ unique: true, fields: ['user_id', 'character_name'] },
],
});

View file

@ -1,17 +0,0 @@
import { DataTypes } from 'sequelize';
import { sequelize } from '../../lib/db.js';
export const UserBossSelection = sequelize.define('UserBossSelection', {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
user_id: { type: DataTypes.INTEGER, allowNull: false },
user_character_id: { type: DataTypes.INTEGER, allowNull: false },
boss_difficulty_id: { type: DataTypes.INTEGER, allowNull: false },
party_size: { type: DataTypes.TINYINT, allowNull: false, defaultValue: 1 },
}, {
tableName: 'user_boss_selections',
underscored: true,
timestamps: false,
indexes: [
{ unique: true, fields: ['user_character_id', 'boss_difficulty_id'] },
],
});

View file

@ -1,21 +1,8 @@
import { User } from './User.js';
import { UserCharacter } from './UserCharacter.js';
import { Boss } from './boss/Boss.js';
import { BossDifficulty } from './boss/BossDifficulty.js';
import { UserBossSelection } from './boss/UserBossSelection.js';
// User <-> UserCharacter
User.hasMany(UserCharacter, { foreignKey: 'user_id', as: 'characters' });
UserCharacter.belongsTo(User, { foreignKey: 'user_id' });
// Boss <-> BossDifficulty
Boss.hasMany(BossDifficulty, { foreignKey: 'boss_id', as: 'difficulties' });
BossDifficulty.belongsTo(Boss, { foreignKey: 'boss_id' });
// UserBossSelection 관계
UserBossSelection.belongsTo(User, { foreignKey: 'user_id' });
UserBossSelection.belongsTo(UserCharacter, { foreignKey: 'user_character_id', as: 'character' });
UserBossSelection.belongsTo(BossDifficulty, { foreignKey: 'boss_difficulty_id', as: 'difficulty' });
UserCharacter.hasMany(UserBossSelection, { foreignKey: 'user_character_id', as: 'selections' });
export { User, UserCharacter, Boss, BossDifficulty, UserBossSelection };
export { Boss, BossDifficulty };

View file

@ -10,12 +10,8 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.800.0",
"axios": "^1.9.0",
"connect-redis": "^8.0.1",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"express": "^5.1.0",
"express-session": "^1.18.1",
"ioredis": "^5.6.1",
"mariadb": "^3.4.0",
"mysql2": "^3.14.1",
"sequelize": "^6.37.5"
@ -872,12 +868,6 @@
"node": ">=18.0.0"
}
},
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT"
},
"node_modules/@smithy/chunked-blob-reader": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz",
@ -1740,15 +1730,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -1761,18 +1742,6 @@
"node": ">= 0.8"
}
},
"node_modules/connect-redis": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-8.1.0.tgz",
"integrity": "sha512-Km0EYLDlmExF52UCss5gLGTtrukGC57G6WCC2aqEMft5Vr4xNWuM4tL+T97kWrw+vp40SXFteb6Xk/7MxgpwdA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"express-session": ">=1"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@ -1830,13 +1799,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crypto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz",
"integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
"deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.",
"license": "ISC"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -2020,50 +1982,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
"license": "MIT",
"dependencies": {
"cookie": "~0.7.2",
"cookie-signature": "~1.0.7",
"debug": "~2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "~5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/express-session/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express-session/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
@ -2352,30 +2270,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ioredis": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.5.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -2403,18 +2297,6 @@
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@ -2616,15 +2498,6 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -2711,15 +2584,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -2744,27 +2608,6 @@
"node": ">= 0.10"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/retry-as-promised": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
@ -2787,26 +2630,6 @@
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -3034,12 +2857,6 @@
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -3096,18 +2913,6 @@
"node": ">= 0.6"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",

View file

@ -13,11 +13,7 @@
"sequelize": "^6.37.5",
"mysql2": "^3.14.1",
"mariadb": "^3.4.0",
"express-session": "^1.18.1",
"connect-redis": "^8.0.1",
"ioredis": "^5.6.1",
"@aws-sdk/client-s3": "^3.800.0",
"axios": "^1.9.0",
"crypto": "^1.0.1"
"axios": "^1.9.0"
}
}

28
backend/routes/admin.js Normal file
View file

@ -0,0 +1,28 @@
import { Router } from 'express';
const router = Router();
// 관리자 인증 미들웨어
function requireAdmin(req, res, next) {
const key = req.headers['x-admin-key'];
if (!key || key !== process.env.NEXON_API_KEY) {
return res.status(403).json({ error: '접근 권한이 없습니다' });
}
next();
}
// 관리자 키 검증
router.post('/verify', (req, res) => {
const { key } = req.body;
if (key === process.env.NEXON_API_KEY) {
return res.json({ verified: true });
}
res.status(403).json({ error: '유효하지 않은 키입니다' });
});
// 관리자 API에 미들웨어 적용
router.use(requireAdmin);
// TODO: 보스 관리 API 추가 예정
export default router;

View file

@ -1,66 +0,0 @@
import { Router } from 'express';
import crypto from 'crypto';
import { exchangeToken, getUserInfo, refreshToken } from '../services/nexon.js';
import { User } from '../models/index.js';
const router = Router();
router.get('/login', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.NEXON_CLIENT_ID,
redirect_uri: process.env.NEXON_REDIRECT_URI,
scope: 'maplestory.characterlist',
state,
});
res.redirect(`https://openid.nexon.com/oauth2/authorize?${params}`);
});
router.get('/callback', async (req, res) => {
const { code, state } = req.query;
if (!code || state !== req.session.oauthState) {
return res.status(400).json({ error: '잘못된 요청입니다' });
}
delete req.session.oauthState;
try {
const tokens = await exchangeToken(code);
const userInfo = await getUserInfo(tokens.access_token);
const [user] = await User.findOrCreate({
where: { nexon_uid: userInfo.uid },
});
req.session.userId = user.id;
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
req.session.tokenExpiresAt = Date.now() + tokens.expires_in * 1000;
res.redirect('/');
} catch (err) {
console.error('OAuth 콜백 오류:', err.message);
res.status(500).json({ error: '로그인 처리 중 오류가 발생했습니다' });
}
});
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: '로그아웃 실패' });
res.clearCookie('connect.sid');
res.json({ success: true });
});
});
router.get('/me', (req, res) => {
if (!req.session?.userId) {
return res.json({ authenticated: false });
}
res.json({ authenticated: true, userId: req.session.userId });
});
export default router;

View file

@ -1,62 +1,32 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/auth.js';
import { getCharacterList, getCharacterOcid, getCharacterBasic } from '../services/nexon.js';
import { UserCharacter } from '../models/index.js';
import { getCharacterOcid, getCharacterBasic } from '../services/nexon.js';
const router = Router();
// 내 캐릭터 목록 조회
router.get('/', requireAuth, async (req, res) => {
try {
const characters = await UserCharacter.findAll({
where: { user_id: req.session.userId },
order: [['character_level', 'DESC']],
});
res.json(characters);
} catch (err) {
console.error('캐릭터 목록 조회 오류:', err.message);
res.status(500).json({ error: '캐릭터 목록 조회 실패' });
// 캐릭터 닉네임으로 정보 조회
router.get('/search', async (req, res) => {
const { name } = req.query;
if (!name) {
return res.status(400).json({ error: '캐릭터 닉네임을 입력해주세요' });
}
});
// 캐릭터 목록 갱신 (넥슨 API에서 다시 가져오기)
router.post('/refresh', requireAuth, async (req, res) => {
try {
const charList = await getCharacterList(req.session.accessToken);
const ocid = await getCharacterOcid(name);
const basic = await getCharacterBasic(ocid);
if (!charList?.account_list) {
return res.status(400).json({ error: '캐릭터 목록을 가져올 수 없습니다' });
}
const results = [];
for (const account of charList.account_list) {
for (const char of account.character_list || []) {
try {
const ocid = await getCharacterOcid(char.character_name);
const basic = await getCharacterBasic(ocid);
const [userChar] = await UserCharacter.upsert({
user_id: req.session.userId,
character_name: char.character_name,
ocid,
world_name: basic.world_name,
job_name: basic.character_class,
character_level: basic.character_level,
character_image: basic.character_image,
});
results.push(userChar);
} catch (err) {
console.error(`캐릭터 조회 실패: ${char.character_name}`, err.message);
}
}
}
res.json(results);
res.json({
character_name: basic.character_name,
world_name: basic.world_name,
job_name: basic.character_class,
character_level: basic.character_level,
character_image: basic.character_image,
});
} catch (err) {
console.error('캐릭터 갱신 오류:', err.message);
res.status(500).json({ error: '캐릭터 목록 갱신 실패' });
if (err.response?.status === 400) {
return res.status(404).json({ error: '존재하지 않는 캐릭터입니다' });
}
console.error('캐릭터 조회 오류:', err.message);
res.status(500).json({ error: '캐릭터 조회 실패' });
}
});

View file

@ -1,11 +1,8 @@
import express from 'express';
import cors from 'cors';
import { sessionMiddleware } from './middleware/session.js';
import authRoutes from './routes/auth.js';
import characterRoutes from './routes/characters.js';
import bossRoutes from './routes/boss/bosses.js';
import selectionRoutes from './routes/boss/selections.js';
import calculateRoutes from './routes/boss/calculate.js';
import adminRoutes from './routes/admin.js';
import { sequelize } from './lib/db.js';
const app = express();
@ -18,13 +15,10 @@ app.use(cors({
credentials: true,
}));
app.use(express.json());
app.use(sessionMiddleware);
app.use('/api/auth', authRoutes);
app.use('/api/characters', characterRoutes);
app.use('/api/boss', bossRoutes);
app.use('/api/boss/selections', selectionRoutes);
app.use('/api/boss/calculate', calculateRoutes);
app.use('/api/admin', adminRoutes);
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });

View file

@ -1,49 +1,6 @@
import axios from 'axios';
const NEXON_API_BASE = 'https://open.api.nexon.com';
const NEXON_OPENID_BASE = 'https://openid.nexon.com';
export async function exchangeToken(code) {
const { data } = await axios.post(
`${NEXON_OPENID_BASE}/oauth2/token`,
new URLSearchParams({
grant_type: 'authorization_code',
client_id: process.env.NEXON_CLIENT_ID,
client_secret: process.env.NEXON_CLIENT_SECRET,
code,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
return data;
}
export async function refreshToken(refreshToken) {
const { data } = await axios.post(
`${NEXON_OPENID_BASE}/oauth2/token`,
new URLSearchParams({
grant_type: 'refresh_token',
client_id: process.env.NEXON_CLIENT_ID,
client_secret: process.env.NEXON_CLIENT_SECRET,
refresh_token: refreshToken,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
return data;
}
export async function getUserInfo(accessToken) {
const { data } = await axios.get(`${NEXON_OPENID_BASE}/api/v1/user/info`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return data.result;
}
export async function getCharacterList(accessToken) {
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/list`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return data;
}
export async function getCharacterOcid(characterName) {
const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/id`, {

View file

@ -28,17 +28,9 @@ services:
- db
- app
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
networks:
- app
volumes:
frontend_modules:
backend_modules:
redis-data:
networks:
caddy:

View file

@ -2,6 +2,7 @@ import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Home from './pages/Home'
import BossPage from './features/boss/BossPage'
import Admin from './pages/Admin'
export default function App() {
return (
@ -9,6 +10,7 @@ export default function App() {
<Route element={<Layout />}>
<Route index element={<Home />} />
<Route path="/boss" element={<BossPage />} />
<Route path="/admin" element={<Admin />} />
</Route>
</Routes>
)

View file

@ -1,5 +1,4 @@
import { Outlet, Link } from 'react-router-dom'
import LoginButton from './LoginButton'
export default function Layout() {
return (
@ -9,7 +8,6 @@ export default function Layout() {
<Link to="/" className="text-xl font-bold">메이플스토리 도우미</Link>
<nav className="flex items-center gap-6">
<Link to="/boss" className="text-gray-400 hover:text-white transition">보스 계산기</Link>
<LoginButton />
</nav>
</div>
</header>

View file

@ -1,27 +0,0 @@
import { useAuth } from '../hooks/useAuth'
export default function LoginButton() {
const { authenticated, loading, logout } = useAuth()
if (loading) return null
if (authenticated) {
return (
<button
onClick={logout}
className="rounded bg-gray-700 px-4 py-2 text-sm hover:bg-gray-600 transition"
>
로그아웃
</button>
)
}
return (
<a
href="/api/auth/login"
className="rounded bg-blue-600 px-4 py-2 text-sm hover:bg-blue-500 transition"
>
넥슨 로그인
</a>
)
}

View file

@ -1,51 +1,59 @@
import { useState } from 'react'
import { api } from '../../api/client'
const DIFF_KEYS = { '이지': 'easy', '노말': 'normal', '하드': 'hard', '카오스': 'chaos', '익스트림': 'extreme' }
const DIFF_COLORS = {
'이지': 'text-green-400 border-green-400/30 bg-green-400/10',
'노말': 'text-gray-300 border-gray-500/30 bg-gray-500/10',
'하드': 'text-rose-400 border-rose-400/30 bg-rose-400/10',
'카오스': 'text-amber-400 border-amber-400/30 bg-amber-400/10',
'익스트림': 'text-red-500 border-red-500/30 bg-red-500/10',
}
const DUMMY_BOSSES = [
{
id: 1, name: '자쿰', imgId: 1,
difficulties: [
{ name: '이지', crystal: 6_612_500, defaultParty: 1 },
{ name: '노말', crystal: 16_200_000, defaultParty: 1 },
{ name: '카오스', crystal: 81_000_000, defaultParty: 1 },
{ name: '이지', crystal: 6_612_500, maxParty: 1 },
{ name: '노말', crystal: 16_200_000, maxParty: 1 },
{ name: '카오스', crystal: 81_000_000, maxParty: 1 },
],
},
{
id: 2, name: '힐라', imgId: 3,
difficulties: [
{ name: '노말', crystal: 6_612_500, defaultParty: 1 },
{ name: '하드', crystal: 56_250_000, defaultParty: 1 },
{ name: '노말', crystal: 6_612_500, maxParty: 1 },
{ name: '하드', crystal: 56_250_000, maxParty: 1 },
],
},
{
id: 3, name: '매그너스', imgId: 10,
difficulties: [
{ name: '이지', crystal: 7_200_000, defaultParty: 1 },
{ name: '노말', crystal: 19_012_500, defaultParty: 1 },
{ name: '하드', crystal: 95_062_500, defaultParty: 1 },
{ name: '이지', crystal: 7_200_000, maxParty: 1 },
{ name: '노말', crystal: 19_012_500, maxParty: 1 },
{ name: '하드', crystal: 95_062_500, maxParty: 1 },
],
},
{
id: 4, name: '파풀라투스', imgId: 22,
difficulties: [
{ name: '이지', crystal: 4_012_500, defaultParty: 1 },
{ name: '노말', crystal: 13_012_500, defaultParty: 1 },
{ name: '카오스', crystal: 79_012_500, defaultParty: 1 },
{ name: '이지', crystal: 4_012_500, maxParty: 1 },
{ name: '노말', crystal: 13_012_500, maxParty: 1 },
{ name: '카오스', crystal: 79_012_500, maxParty: 1 },
],
},
{
id: 5, name: '듄켈', imgId: 27,
difficulties: [
{ name: '노말', crystal: 92_450_000, defaultParty: 1 },
{ name: '하드', crystal: 231_125_000, defaultParty: 6 },
{ name: '노말', crystal: 92_450_000, maxParty: 1 },
{ name: '하드', crystal: 231_125_000, maxParty: 6 },
],
},
{
id: 6, name: '림보', imgId: 33,
difficulties: [
{ name: '노말', crystal: 140_000_000, defaultParty: 1 },
{ name: '하드', crystal: 350_000_000, defaultParty: 6 },
{ name: '노말', crystal: 140_000_000, maxParty: 1 },
{ name: '하드', crystal: 350_000_000, maxParty: 6 },
],
},
]
@ -60,69 +68,324 @@ function formatMeso(n) {
return n.toLocaleString()
}
function BossRowList({ boss, selections, onChange }) {
return (
<div className="rounded-lg border border-gray-800 overflow-hidden">
<div className="flex items-center gap-3 bg-gray-900 px-3 py-2">
<img src={`/boss-images/icon/${boss.imgId}.png`} alt={boss.name} className="w-10 h-10 rounded object-cover" />
<span className="font-medium">{boss.name}</span>
</div>
<div className="divide-y divide-gray-800/50">
{boss.difficulties.map((diff, i) => {
const key = `${boss.id}-${i}`
const sel = selections[key] || { enabled: false, party: diff.defaultParty }
return (
<label key={i} className={`flex items-center gap-3 px-3 py-2 cursor-pointer transition ${sel.enabled ? '' : 'opacity-50'}`}>
<input type="checkbox" checked={sel.enabled} onChange={(e) => onChange(key, { ...sel, enabled: e.target.checked })} className="accent-emerald-500 w-4 h-4 shrink-0" />
<img src={`/boss-images/diff-badge/${DIFF_KEYS[diff.name]}.png`} alt={diff.name} className="h-5 shrink-0" />
<div className="flex-1 text-sm text-gray-400">{formatMeso(diff.crystal)}</div>
<select value={sel.party} onChange={(e) => { e.stopPropagation(); onChange(key, { ...sel, party: Number(e.target.value) }) }} className="bg-gray-800 border border-gray-700 rounded px-1.5 py-0.5 text-xs text-gray-300 outline-none w-14 shrink-0" onClick={(e) => e.stopPropagation()}>
{[1, 2, 3, 4, 5, 6].map((n) => <option key={n} value={n}>÷{n}</option>)}
</select>
<div className={`text-sm font-medium w-20 text-right shrink-0 ${sel.enabled ? 'text-green-400' : 'text-gray-600'}`}>
{sel.enabled ? formatMeso(Math.floor(diff.crystal / sel.party)) : '-'}
</div>
</label>
)
})}
</div>
</div>
)
}
/* ── 좌측: 캐릭터 패널 ── */
function CharacterPanel({ characters, selectedChar, onSelect, onAdd, onRemove }) {
const [name, setName] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
export default function BossPage() {
const [selections, setSelections] = useState({})
const handleChange = (key, sel) => setSelections((prev) => ({ ...prev, [key]: sel }))
const entries = Object.entries(selections).filter(([, s]) => s.enabled)
const totalCrystals = entries.length
const totalRevenue = entries.reduce((sum, [key, sel]) => {
const [bossId, diffIdx] = key.split('-').map(Number)
const boss = DUMMY_BOSSES.find((b) => b.id === bossId)
return sum + Math.floor(boss.difficulties[diffIdx].crystal / sel.party)
}, 0)
const handleSearch = async (e) => {
e.preventDefault()
if (!name.trim()) return
setLoading(true)
setError('')
try {
const data = await api(`/api/characters/search?name=${encodeURIComponent(name.trim())}`)
onAdd(data)
setName('')
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">주간 보스 수익 계산기</h1>
<div className="space-y-3">
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">1. 캐릭터 등록</h2>
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="닉네임 입력"
className="flex-1 min-w-0 rounded border border-gray-700 bg-gray-900 px-3 py-1.5 text-sm outline-none focus:border-emerald-500 transition"
/>
<button type="submit" disabled={loading} className="rounded bg-emerald-600 px-3 py-1.5 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50 transition shrink-0">
{loading ? '...' : '등록'}
</button>
</form>
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="flex gap-6 rounded-lg border border-gray-800 bg-gray-900/50 p-4">
<div>
<div className="text-xs text-gray-500">결정석</div>
<div className="text-lg font-bold">{totalCrystals}<span className="text-gray-500 text-sm">/12</span></div>
</div>
<div>
<div className="text-xs text-gray-500">예상 수익</div>
<div className="text-lg font-bold text-green-400">{formatMeso(totalRevenue)} <span className="text-sm text-gray-400">메소</span></div>
</div>
</div>
<div className="space-y-3">
{DUMMY_BOSSES.map((boss) => (
<BossRowList key={boss.id} boss={boss} selections={selections} onChange={handleChange} />
<div className="space-y-1">
{characters.map((char) => (
<div
key={char.character_name}
onClick={() => onSelect(char.character_name)}
className={`flex items-center gap-2 rounded-lg px-2 py-2 cursor-pointer transition group ${
selectedChar === char.character_name
? 'bg-emerald-500/10 border border-emerald-500/50'
: 'hover:bg-gray-800/50 border border-transparent'
}`}
>
{char.character_image && (
<img src={char.character_image} alt="" className="w-10 h-10 rounded bg-gray-800" />
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{char.character_name}</div>
<div className="text-xs text-gray-500">Lv.{char.character_level} {char.job_name}</div>
</div>
<span
onClick={(e) => { e.stopPropagation(); onRemove(char.character_name) }}
className="text-gray-700 hover:text-red-400 opacity-0 group-hover:opacity-100 transition cursor-pointer text-lg"
>
×
</span>
</div>
))}
</div>
</div>
)
}
/* ── 중앙: 보스 선택 패널 ── */
function BossPanel({ selectedChar, selections, onChange }) {
if (!selectedChar) {
return (
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
캐릭터를 선택해주세요
</div>
)
}
return (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">2. 보스 선택</h2>
<div className="rounded-lg border border-gray-800 overflow-hidden">
{/* 헤더 */}
<div className="grid grid-cols-[2fr_1fr_1fr_1fr] gap-2 px-3 py-2 bg-gray-900/80 text-xs text-gray-500 border-b border-gray-800">
<div>보스</div>
<div>난이도</div>
<div>파티원 </div>
<div className="text-right">수익</div>
</div>
{/* 보스 행 */}
<div className="divide-y divide-gray-800/50">
{DUMMY_BOSSES.map((boss) => {
//
const selectedDiffIdx = boss.difficulties.findIndex((_, i) => {
const key = `${boss.id}-${i}`
return selections[key]?.enabled
})
const sel = selectedDiffIdx >= 0 ? selections[`${boss.id}-${selectedDiffIdx}`] : null
const diff = selectedDiffIdx >= 0 ? boss.difficulties[selectedDiffIdx] : null
const isSelected = !!sel?.enabled
return (
<div key={boss.id} className={`grid grid-cols-[2fr_1fr_1fr_1fr] gap-2 px-3 py-2 items-center transition ${isSelected ? '' : 'opacity-40'}`}>
{/* 보스 이름 + 아이콘 */}
<div className="flex items-center gap-2">
<img src={`/boss-images/icon/${boss.imgId}.png`} alt={boss.name} className="w-8 h-8 rounded object-cover shrink-0" />
<span className="text-sm font-medium truncate">{boss.name}</span>
</div>
{/* 난이도 선택 */}
<div className="flex flex-wrap gap-1">
{boss.difficulties.map((d, i) => {
const key = `${boss.id}-${i}`
const active = selections[key]?.enabled
return (
<button
key={i}
onClick={() => {
// :
const newSelections = { ...selections }
boss.difficulties.forEach((_, j) => {
const k = `${boss.id}-${j}`
if (j === i) {
newSelections[k] = { enabled: !active, party: active ? d.maxParty : (selections[k]?.party || d.maxParty) }
} else {
newSelections[k] = { ...newSelections[k], enabled: false }
}
})
onChange(newSelections)
}}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition ${
active ? DIFF_COLORS[d.name] : 'text-gray-600 border-gray-700/50 hover:border-gray-600'
}`}
>
{d.name}
</button>
)
})}
</div>
{/* 파티원 수 */}
<div>
{isSelected && (
<select
value={sel.party}
onChange={(e) => {
const key = `${boss.id}-${selectedDiffIdx}`
onChange({ ...selections, [key]: { ...sel, party: Number(e.target.value) } })
}}
className="bg-gray-800 border border-gray-700 rounded px-1.5 py-0.5 text-xs text-gray-300 outline-none"
>
{Array.from({ length: diff.maxParty }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
)}
</div>
{/* 수익 */}
<div className={`text-right text-sm font-medium ${isSelected ? 'text-green-400' : ''}`}>
{isSelected ? formatMeso(Math.floor(diff.crystal / sel.party)) : '-'}
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
/* ── 우측: 결과 패널 ── */
function ResultPanel({ characters, allSelections }) {
let totalCrystals = 0
let totalRevenue = 0
const charResults = characters.map((char) => {
const charSel = allSelections[char.character_name] || {}
let crystals = 0
let revenue = 0
Object.entries(charSel).forEach(([key, sel]) => {
if (!sel.enabled) return
const [bossId, diffIdx] = key.split('-').map(Number)
const boss = DUMMY_BOSSES.find((b) => b.id === bossId)
if (!boss) return
crystals++
revenue += Math.floor(boss.difficulties[diffIdx].crystal / sel.party)
})
totalCrystals += crystals
totalRevenue += revenue
return { name: char.character_name, crystals, revenue }
})
return (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">3. 결과</h2>
<div className="rounded-lg border border-gray-800 bg-gray-900/50 p-4 space-y-4">
{/* 합산 */}
<div className="flex items-baseline justify-between">
<div>
<span className="text-sm text-gray-400">보유 결정석</span>
<div className="text-2xl font-bold">{totalCrystals}<span className="text-gray-500 text-base">/90</span></div>
</div>
<div className="text-right">
<span className="text-sm text-gray-400"> 수익</span>
<div className="text-2xl font-bold text-green-400">{formatMeso(totalRevenue)}</div>
<div className="text-xs text-gray-500">메소</div>
</div>
</div>
{/* 결정석 게이지 */}
<div className="w-full bg-gray-800 rounded-full h-2">
<div
className="bg-emerald-500 h-2 rounded-full transition-all"
style={{ width: `${Math.min((totalCrystals / 90) * 100, 100)}%` }}
/>
</div>
{/* 캐릭터별 소계 */}
{charResults.length > 0 && (
<div className="space-y-1 pt-2 border-t border-gray-800">
<div className="text-xs text-gray-500 mb-2">캐릭터별</div>
{charResults.map((r) => (
<div key={r.name} className="flex items-center justify-between text-sm">
<span className="text-gray-400">{r.name}</span>
<div className="flex items-center gap-3">
<span className="text-gray-500 text-xs">{r.crystals}/12</span>
<span className={r.revenue > 0 ? 'text-green-400' : 'text-gray-600'}>{r.revenue > 0 ? formatMeso(r.revenue) : '-'}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
/* ── 메인 ── */
export default function BossPage() {
const [characters, setCharacters] = useState(() => {
const saved = localStorage.getItem('maple-characters')
return saved ? JSON.parse(saved) : []
})
const [selectedChar, setSelectedChar] = useState(null)
const [allSelections, setAllSelections] = useState(() => {
const saved = localStorage.getItem('maple-boss-selections')
return saved ? JSON.parse(saved) : {}
})
const saveCharacters = (chars) => {
setCharacters(chars)
localStorage.setItem('maple-characters', JSON.stringify(chars))
}
const saveSelections = (sels) => {
setAllSelections(sels)
localStorage.setItem('maple-boss-selections', JSON.stringify(sels))
}
const handleAddCharacter = (charData) => {
if (characters.find((c) => c.character_name === charData.character_name)) return
saveCharacters([...characters, charData])
setSelectedChar(charData.character_name)
}
const handleRemoveCharacter = (name) => {
saveCharacters(characters.filter((c) => c.character_name !== name))
if (selectedChar === name) setSelectedChar(null)
const newSelections = { ...allSelections }
delete newSelections[name]
saveSelections(newSelections)
}
const handleBossChange = (charSelections) => {
if (!selectedChar) return
saveSelections({ ...allSelections, [selectedChar]: charSelections })
}
const currentSelections = selectedChar ? (allSelections[selectedChar] || {}) : {}
return (
<div className="space-y-6 lg:space-y-0 lg:grid lg:grid-cols-[240px_1fr_280px] lg:gap-6">
{/* 좌측 */}
<div className="lg:border-r lg:border-gray-800 lg:pr-6">
<CharacterPanel
characters={characters}
selectedChar={selectedChar}
onSelect={setSelectedChar}
onAdd={handleAddCharacter}
onRemove={handleRemoveCharacter}
/>
</div>
{/* 중앙 */}
<div className="min-w-0">
<BossPanel
selectedChar={selectedChar}
selections={currentSelections}
onChange={handleBossChange}
/>
</div>
{/* 우측 */}
<div className="lg:border-l lg:border-gray-800 lg:pl-6">
<ResultPanel
characters={characters}
allSelections={allSelections}
/>
</div>
</div>
)
}

View file

@ -1,21 +0,0 @@
import { useState, useEffect } from 'react'
import { api } from '../api/client'
export function useAuth() {
const [authenticated, setAuthenticated] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
api('/api/auth/me')
.then((data) => setAuthenticated(data.authenticated))
.catch(() => setAuthenticated(false))
.finally(() => setLoading(false))
}, [])
const logout = async () => {
await api('/api/auth/logout', { method: 'POST' })
setAuthenticated(false)
}
return { authenticated, loading, logout }
}

View file

@ -1,35 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { api } from '../api/client'
export function useCharacters() {
const [characters, setCharacters] = useState([])
const [loading, setLoading] = useState(true)
const fetchCharacters = useCallback(async () => {
setLoading(true)
try {
const data = await api('/api/characters')
setCharacters(data)
} catch {
setCharacters([])
} finally {
setLoading(false)
}
}, [])
const refreshCharacters = async () => {
setLoading(true)
try {
const data = await api('/api/characters/refresh', { method: 'POST' })
setCharacters(data)
} catch {
// 무시
} finally {
setLoading(false)
}
}
useEffect(() => { fetchCharacters() }, [fetchCharacters])
return { characters, loading, refreshCharacters }
}

View file

@ -0,0 +1,55 @@
import { useState, useEffect } from 'react'
import { useSearchParams, Navigate } from 'react-router-dom'
import { api } from '../api/client'
export default function Admin() {
const [searchParams] = useSearchParams()
const [verified, setVerified] = useState(null) // null=, true=, false=
useEffect(() => {
const keyFromUrl = searchParams.get('key')
const keyFromStorage = localStorage.getItem('maple-admin-key')
const key = keyFromUrl || keyFromStorage
if (!key) {
setVerified(false)
return
}
api('/api/admin/verify', { method: 'POST', body: { key } })
.then(() => {
localStorage.setItem('maple-admin-key', key)
setVerified(true)
})
.catch(() => {
localStorage.removeItem('maple-admin-key')
setVerified(false)
})
}, [searchParams])
if (verified === null) {
return <div className="text-center text-gray-400 pt-16">인증 ...</div>
}
if (!verified) {
return <Navigate to="/" replace />
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">관리자</h1>
<button
onClick={() => { localStorage.removeItem('maple-admin-key'); setVerified(false) }}
className="text-sm text-gray-500 hover:text-gray-300 transition"
>
로그아웃
</button>
</div>
<div className="rounded-lg border border-gray-800 bg-gray-900/50 p-8 text-center text-gray-500">
관리자 페이지 준비
</div>
</div>
)
}