Skip to content
Snippets Groups Projects
Commit 8747d151 authored by MinJae Kwon's avatar MinJae Kwon
Browse files

refactor: prevent the potential member count inconsistency issue

parent 3e3f9bcc
No related branches found
No related tags found
1 merge request!34refactor: prevent the potential member count inconsistency issue
...@@ -20,7 +20,7 @@ down-frontend: ...@@ -20,7 +20,7 @@ down-frontend:
.PHONY: run-apiserver .PHONY: run-apiserver
run-apiserver: run-apiserver:
docker-compose build apiserver docker-compose build apiserver
docker-compose up -d apiserver mysql minio docker-compose up -d apiserver mysql minio redis
.PHONY: logs-apiserver .PHONY: logs-apiserver
logs-apiserver: logs-apiserver:
......
...@@ -8,6 +8,7 @@ services: ...@@ -8,6 +8,7 @@ services:
MYSQL_PASSWORD: localadmin MYSQL_PASSWORD: localadmin
ports: ports:
- "3307:3306" - "3307:3306"
command: ["mysqld", "--require-secure-transport=OFF"]
volumes: volumes:
- ./webapp/backend/ddl:/docker-entrypoint-initdb.d/ - ./webapp/backend/ddl:/docker-entrypoint-initdb.d/
- ./mysql:/var/lib/mysql:delegated - ./mysql:/var/lib/mysql:delegated
...@@ -24,6 +25,11 @@ services: ...@@ -24,6 +25,11 @@ services:
- ./minio/data:/data - ./minio/data:/data
command: server --address ":9000" --console-address ":9001" /data command: server --address ":9000" --console-address ":9001" /data
redis:
image: redis
ports:
- "6380:6379"
frontend: frontend:
image: frontend image: frontend
build: build:
...@@ -44,7 +50,8 @@ services: ...@@ -44,7 +50,8 @@ services:
MYSQL_PORT: 3306 MYSQL_PORT: 3306
MYSQL_USER: admin MYSQL_USER: admin
MYSQL_PASSWORD: localadmin MYSQL_PASSWORD: localadmin
REDIS_HOST: redis
REDIS_PORT: 6379
ports: ports:
- "8180:8080" - "8180:8080"
volumes: volumes:
......
...@@ -3,10 +3,13 @@ const Event = require('../models/Event.js'); ...@@ -3,10 +3,13 @@ const Event = require('../models/Event.js');
const User = require('../models/User.js'); const User = require('../models/User.js');
const UserCrew = require('../models/UserCrew.js'); const UserCrew = require('../models/UserCrew.js');
const UserEvent = require('../models/UserEvent.js'); const UserEvent = require('../models/UserEvent.js');
const { redisClient, capCheckLuaScript } = require('../datastore/redis.js');
// 한 페이지당 기본 아이템 수 // 한 페이지당 기본 아이템 수
const itemsPerPage = 10; const itemsPerPage = 10;
const countKeyPattern = 'crew:${crewID}:member_count';
// 크루 생성 컨트롤러 // 크루 생성 컨트롤러
exports.createCrew = async (req, res) => { exports.createCrew = async (req, res) => {
try { try {
...@@ -46,10 +49,8 @@ exports.createCrew = async (req, res) => { ...@@ -46,10 +49,8 @@ exports.createCrew = async (req, res) => {
} }
}; };
// 크루 가입 컨트롤러 // 크루 가입 컨트롤러
exports.joinCrew = async (req, res) => { exports.joinCrew = async (req, res) => {
try {
const {crewID} = req.params; const {crewID} = req.params;
const {role} = req.body; const {role} = req.body;
...@@ -64,19 +65,44 @@ exports.joinCrew = async (req, res) => { ...@@ -64,19 +65,44 @@ exports.joinCrew = async (req, res) => {
return res.status(404).json({error: '해당 크루가 존재하지 않습니다.'}); return res.status(404).json({error: '해당 크루가 존재하지 않습니다.'});
} }
const userCrew = await UserCrew.create({ const existingUserCrew = await UserCrew.findOne({where: {crewID, userID}});
if (existingUserCrew) {
return res.status(409).json({error: '이미 해당 크루에 가입되어 있습니다.'});
}
const capacity = parseInt(crew.capacity, 10);
try {
const result = await redisClient.sendCommand([
'EVAL',
capCheckLuaScript,
'1',
countKeyPattern,
capacity.toString()
]);
// 인원이 초과하지 않은 경우, 가입 진행.
if (result === 1) {
const newUserCrew = await UserCrew.create({
crewID, crewID,
userID, userID,
role: role || 'General', role: role || 'General',
}); });
res.status(201).json({ return res.status(201).json({
crewID: userCrew.crewID, crewID: newUserCrew.crewID,
userID: userCrew.userID, userID: newUserCrew.userID,
role: userCrew.role, role: newUserCrew.role,
}); });
} else {
return res.status(409).json({error: '크루의 최대 인원 수를 초과했습니다.'});
}
} catch (error) { } catch (error) {
// 가입 실패시 가입 카운트를 감소시킴.
await redisClient.decr(countKeyPattern);
console.error('크루 가입 중 오류:', error); console.error('크루 가입 중 오류:', error);
res.status(500).json({error: '크루 가입 중 오류가 발생했습니다.'}); res.status(500).json({error: '크루 가입 중 오류가 발생했습니다.'});
} }
}; };
...@@ -103,6 +129,8 @@ exports.leaveCrew = async (req, res) => { ...@@ -103,6 +129,8 @@ exports.leaveCrew = async (req, res) => {
} }
await userCrew.destroy(); await userCrew.destroy();
// 가입 인원 카운트 감소
await redisClient.decr(countKeyPattern);
res.status(200).json({}); res.status(200).json({});
} catch (error) { } catch (error) {
...@@ -123,10 +151,15 @@ exports.getCrew = async (req, res) => { ...@@ -123,10 +151,15 @@ exports.getCrew = async (req, res) => {
return res.status(404).json({ error: '해당 크루가 존재하지 않습니다.' }); return res.status(404).json({ error: '해당 크루가 존재하지 않습니다.' });
} }
// UserCrew에서 해당 크루에 가입한 인원 수 계산 const currentMemberCount = redisClient.get(countKeyPattern).then((result) => {
const currentMemberCount = await UserCrew.count({ return parseInt(result, 10);
}).catch((error) => {
console.error('크루 가입 수 조회중 오류', error);
return UserCrew.count({
where: { crewID }, where: { crewID },
}); });
});
// 크루 데이터에 currentMemberCount 추가 // 크루 데이터에 currentMemberCount 추가
const response = { const response = {
......
...@@ -3,10 +3,14 @@ const EventParticipants = require('../models/EventParticipants'); ...@@ -3,10 +3,14 @@ const EventParticipants = require('../models/EventParticipants');
const {Op} = require('sequelize'); const {Op} = require('sequelize');
const moment = require('moment'); // 날짜 포맷팅 라이브러리 const moment = require('moment'); // 날짜 포맷팅 라이브러리
const UserEvent = require('../models/UserEvent.js'); const UserEvent = require('../models/UserEvent.js');
const {redisClient, capCheckLuaScript} = require("../datastore/redis");
const UserCrew = require("../models/UserCrew");
// 한 페이지당 기본 아이템 수 // 한 페이지당 기본 아이템 수
const itemsPerPage = 10; const itemsPerPage = 10;
const countKeyPattern = 'event:${eventID}:participant_count';
// 이벤트 목록 조회 (단일 엔드포인트) // 이벤트 목록 조회 (단일 엔드포인트)
exports.getEvents = async (req, res) => { exports.getEvents = async (req, res) => {
console.log('이벤트 목록 조회 컨트롤러 실행'); // 로그 확인용 console.log('이벤트 목록 조회 컨트롤러 실행'); // 로그 확인용
...@@ -104,9 +108,15 @@ exports.getEventById = async (req, res) => { ...@@ -104,9 +108,15 @@ exports.getEventById = async (req, res) => {
} }
// 현재 참여한 인원 수 계산 // 현재 참여한 인원 수 계산
const currentMemberCount = await UserEvent.count({ const currentMemberCount = redisClient.get(countKeyPattern).then((result) => {
return parseInt(result, 10);
}).catch((error) => {
console.error('이벤트 참여자 수 조회중 오류', error);
return UserEvent.count({
where: {eventID: event.eventID}, where: {eventID: event.eventID},
}); });
});
// 이벤트 데이터에 현재 참여한 인원 수 추가 // 이벤트 데이터에 현재 참여한 인원 수 추가
const response = { const response = {
...@@ -176,7 +186,6 @@ exports.deleteEvent = async (req, res) => { ...@@ -176,7 +186,6 @@ exports.deleteEvent = async (req, res) => {
// 이벤트 참여 컨트롤러 // 이벤트 참여 컨트롤러
exports.participateEvent = async (req, res) => { exports.participateEvent = async (req, res) => {
try {
const {eventID} = req.params; const {eventID} = req.params;
const userID = req.user.userID; const userID = req.user.userID;
...@@ -195,11 +204,19 @@ exports.participateEvent = async (req, res) => { ...@@ -195,11 +204,19 @@ exports.participateEvent = async (req, res) => {
return res.status(409).json({error: '이미 이벤트에 참여 중입니다.'}); return res.status(409).json({error: '이미 이벤트에 참여 중입니다.'});
} }
const participantCount = await EventParticipants.count({where: {eventID, status: 'attending'}}); const capacity = parseInt(event.capacity, 10);
if (event.capacity && participantCount >= event.capacity) {
return res.status(409).json({error: '이벤트 정원이 초과되었습니다.'});
}
try {
const result = await redisClient.sendCommand([
'EVAL',
capCheckLuaScript,
'1',
countKeyPattern,
capacity.toString()
]);
// 인원이 초과하지 않은 경우, 참여 진행.
if (result === 1) {
const newParticipant = await EventParticipants.create({ const newParticipant = await EventParticipants.create({
userID, userID,
eventID, eventID,
...@@ -208,8 +225,15 @@ exports.participateEvent = async (req, res) => { ...@@ -208,8 +225,15 @@ exports.participateEvent = async (req, res) => {
}); });
res.status(201).json(newParticipant); res.status(201).json(newParticipant);
} else {
return res.status(409).json({error: '이벤트의 최대 참여 인원 수를 초과했습니다.'});
}
} catch (error) { } catch (error) {
// 참여 실패시 가입 카운트를 감소시킴.
await redisClient.decr(countKeyPattern);
console.error('이벤트 참여 중 오류:', error); console.error('이벤트 참여 중 오류:', error);
res.status(500).json({error: '이벤트 참여 중 오류가 발생했습니다.'}); res.status(500).json({error: '이벤트 참여 중 오류가 발생했습니다.'});
} }
}; };
...@@ -241,6 +265,8 @@ exports.cancelParticipation = async (req, res) => { ...@@ -241,6 +265,8 @@ exports.cancelParticipation = async (req, res) => {
// 상태 변경 (참여 취소) // 상태 변경 (참여 취소)
participant.status = 'canceled'; participant.status = 'canceled';
await participant.save(); await participant.save();
// 참여 인원 카운트 감소
await redisClient.decr(countKeyPattern);
res.status(200).json({ res.status(200).json({
message: '이벤트 참여가 성공적으로 취소되었습니다.', message: '이벤트 참여가 성공적으로 취소되었습니다.',
......
...@@ -106,7 +106,7 @@ exports.signUp = async (req, res) => { ...@@ -106,7 +106,7 @@ exports.signUp = async (req, res) => {
} catch (error) { } catch (error) {
await tx.rollback(); await tx.rollback();
console.error('사용자 생성 중 오류:', error); console.error('사용자 생성 중 오류:', error);
res.status(500).json({error: '사용자 생성 중 오류가 발생했습니다.'}); res.status(500).json({error: '사용자 생성 중 오류가 발생했습니다.'});
} }
} catch (error) { } catch (error) {
console.error('사용자 생성 중 오류:', error); console.error('사용자 생성 중 오류:', error);
......
const redis = require('redis');
const RedisConfig = require('../../config/redis');
const redisClient = redis.createClient({
url: `redis://${RedisConfig.HOST}:${RedisConfig.PORT}`
});
redisClient.connect();
// Lua 스크립트 정의
exports.capCheckLuaScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local current = tonumber(redis.call("GET", key) or "0")
if current < capacity then
redis.call("INCR", key)
return 1
else
return 0
end
`;
exports.redisClient = redisClient;
// NOTE: we do not consider the cluster for now.
const RedisConfig = {
HOST: process.env.REDIS_HOST || 'localhost',
PORT: process.env.REDIS_PORT || '6380',
}
module.exports = RedisConfig;
\ No newline at end of file
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.11.4", "mysql2": "^3.11.4",
"redis": "^4.7.0",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"uuid": "^11.0.3" "uuid": "^11.0.3"
}, },
...@@ -18,6 +19,65 @@ ...@@ -18,6 +19,65 @@
"nodemon": "^3.1.7" "nodemon": "^3.1.7"
} }
}, },
"node_modules/@redis/bloom": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/client": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz",
"integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
"yallist": "4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@redis/graph": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/json": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/search": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/time-series": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@types/debug": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
...@@ -304,6 +364,15 @@ ...@@ -304,6 +364,15 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"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/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
...@@ -700,6 +769,15 @@ ...@@ -700,6 +769,15 @@
"is-property": "^1.0.2" "is-property": "^1.0.2"
} }
}, },
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
...@@ -1541,6 +1619,23 @@ ...@@ -1541,6 +1619,23 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/redis": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz",
"integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==",
"license": "MIT",
"workspaces": [
"./packages/*"
],
"dependencies": {
"@redis/bloom": "1.2.0",
"@redis/client": "1.6.0",
"@redis/graph": "1.1.1",
"@redis/json": "1.0.7",
"@redis/search": "1.2.0",
"@redis/time-series": "1.1.0"
}
},
"node_modules/retry-as-promised": { "node_modules/retry-as-promised": {
"version": "7.0.4", "version": "7.0.4",
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz", "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz",
...@@ -2111,6 +2206,12 @@ ...@@ -2111,6 +2206,12 @@
"engines": { "engines": {
"node": ">=0.4" "node": ">=0.4"
} }
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
} }
} }
} }
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.11.4", "mysql2": "^3.11.4",
"redis": "^4.7.0",
"sequelize": "^6.37.5", "sequelize": "^6.37.5",
"uuid": "^11.0.3" "uuid": "^11.0.3"
}, },
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment