diff --git a/Makefile b/Makefile index c6ec458c08fa07bbabc476780adc5caeb7ac3aee..168b34dcaf3e900bf20fab262418fee078578e2d 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ down-frontend: .PHONY: run-apiserver run-apiserver: docker-compose build apiserver - docker-compose up -d apiserver mysql minio + docker-compose up -d apiserver mysql minio redis .PHONY: logs-apiserver logs-apiserver: diff --git a/docker-compose.yml b/docker-compose.yml index eae925417ed08a2d0727fff6b943a78849815a0c..1fe7682a6487419fb3ae83bf34c36527424e9a4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: MYSQL_PASSWORD: localadmin ports: - "3307:3306" + command: ["mysqld", "--require-secure-transport=OFF"] volumes: - ./webapp/backend/ddl:/docker-entrypoint-initdb.d/ - ./mysql:/var/lib/mysql:delegated @@ -24,6 +25,11 @@ services: - ./minio/data:/data command: server --address ":9000" --console-address ":9001" /data + redis: + image: redis + ports: + - "6380:6379" + frontend: image: frontend build: @@ -44,7 +50,8 @@ services: MYSQL_PORT: 3306 MYSQL_USER: admin MYSQL_PASSWORD: localadmin - + REDIS_HOST: redis + REDIS_PORT: 6379 ports: - "8180:8080" volumes: diff --git a/webapp/backend/apiserver/controllers/crewController.js b/webapp/backend/apiserver/controllers/crewController.js index 2bbf38a2418cf70106d47ae2441a93cef7efbd3d..2d93c922a6aaa57ec59a13223117b883e062f285 100644 --- a/webapp/backend/apiserver/controllers/crewController.js +++ b/webapp/backend/apiserver/controllers/crewController.js @@ -1,12 +1,15 @@ const Crew = require('../models/Crew.js'); const Event = require('../models/Event.js'); const User = require('../models/User.js'); -const UserCrew = require('../models/UserCrew.js'); -const UserEvent = require('../models/UserEvent.js'); +const UserCrew = require('../models/UserCrew.js'); +const UserEvent = require('../models/UserEvent.js'); +const { redisClient, capCheckLuaScript } = require('../datastore/redis.js'); // 한 페이지당 기본 아이템 수 const itemsPerPage = 10; +const countKeyPattern = 'crew:${crewID}:member_count'; + // 크루 생성 컨트롤러 exports.createCrew = async (req, res) => { try { @@ -46,38 +49,61 @@ exports.createCrew = async (req, res) => { } }; - // 크루 가입 컨트롤러 exports.joinCrew = async (req, res) => { - try { - const { crewID } = req.params; - const { role } = req.body; + const {crewID} = req.params; + const {role} = req.body; - const userID = req.user.userID; + const userID = req.user.userID; - if (!crewID) { - return res.status(400).json({ error: '필수 데이터가 누락되었습니다.' }); - } + if (!crewID) { + return res.status(400).json({error: '필수 데이터가 누락되었습니다.'}); + } - const crew = await Crew.findByPk(crewID); - if (!crew) { - return res.status(404).json({ error: '해당 크루가 존재하지 않습니다.' }); - } + const crew = await Crew.findByPk(crewID); + if (!crew) { + return res.status(404).json({error: '해당 크루가 존재하지 않습니다.'}); + } - const userCrew = await UserCrew.create({ - crewID, - userID, - role: role || 'General', - }); + const existingUserCrew = await UserCrew.findOne({where: {crewID, userID}}); + if (existingUserCrew) { + return res.status(409).json({error: '이미 해당 크루에 가입되어 있습니다.'}); + } - res.status(201).json({ - crewID: userCrew.crewID, - userID: userCrew.userID, - role: userCrew.role, - }); + 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, + userID, + role: role || 'General', + }); + + return res.status(201).json({ + crewID: newUserCrew.crewID, + userID: newUserCrew.userID, + role: newUserCrew.role, + }); + } else { + return res.status(409).json({error: '크루의 최대 인원 수를 초과했습니다.'}); + } } catch (error) { + // 가입 실패시 가입 카운트를 감소시킴. + await redisClient.decr(countKeyPattern); + console.error('크루 가입 중 오류:', error); - res.status(500).json({ error: '크루 가입 중 오류가 발생했습니다.' }); + + res.status(500).json({error: '크루 가입 중 오류가 발생했습니다.'}); } }; @@ -103,6 +129,8 @@ exports.leaveCrew = async (req, res) => { } await userCrew.destroy(); + // 가입 인원 카운트 감소 + await redisClient.decr(countKeyPattern); res.status(200).json({}); } catch (error) { @@ -123,9 +151,14 @@ exports.getCrew = async (req, res) => { return res.status(404).json({ error: '해당 크루가 존재하지 않습니다.' }); } - // UserCrew에서 해당 크루에 가입한 인원 수 계산 - const currentMemberCount = await UserCrew.count({ - where: { crewID }, + const currentMemberCount = redisClient.get(countKeyPattern).then((result) => { + return parseInt(result, 10); + }).catch((error) => { + console.error('크루 가입 수 조회중 오류', error); + + return UserCrew.count({ + where: { crewID }, + }); }); // 크루 데이터에 currentMemberCount 추가 diff --git a/webapp/backend/apiserver/controllers/eventController.js b/webapp/backend/apiserver/controllers/eventController.js index e968d6a0c295605b600fbb76da6bcd919f353878..a12cbad451185f4a9d54d0f55b0d6d137f0684a0 100644 --- a/webapp/backend/apiserver/controllers/eventController.js +++ b/webapp/backend/apiserver/controllers/eventController.js @@ -2,11 +2,15 @@ const Event = require('../models/Event'); const EventParticipants = require('../models/EventParticipants'); const {Op} = require('sequelize'); 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 countKeyPattern = 'event:${eventID}:participant_count'; + // 이벤트 목록 조회 (단일 엔드포인트) exports.getEvents = async (req, res) => { console.log('이벤트 목록 조회 컨트롤러 실행'); // 로그 확인용 @@ -82,45 +86,51 @@ exports.createEvent = async (req, res) => { // 특정 이벤트 조회 컨트롤러 exports.getEventById = async (req, res) => { try { - const { eventID } = req.params; - - // 이벤트 정보 조회 - const event = await Event.findByPk(eventID, { - attributes: [ - 'eventID', - 'regionID', - 'name', - 'sportTypeId', - 'eventDate', - 'capacity', - 'feeCondition', - 'userID', - 'createdDate', - ], - }); - - if (!event) { - return res.status(404).json({ error: '해당 이벤트가 존재하지 않습니다.' }); - } - - // 현재 참여한 인원 수 계산 - const currentMemberCount = await UserEvent.count({ - where: { eventID: event.eventID }, - }); - - // 이벤트 데이터에 현재 참여한 인원 수 추가 - const response = { - ...event.toJSON(), - currentMemberCount, // 현재 참여한 인원 수 - }; - - res.status(200).json(response); + const {eventID} = req.params; + + // 이벤트 정보 조회 + const event = await Event.findByPk(eventID, { + attributes: [ + 'eventID', + 'regionID', + 'name', + 'sportTypeId', + 'eventDate', + 'capacity', + 'feeCondition', + 'userID', + 'createdDate', + ], + }); + + if (!event) { + return res.status(404).json({error: '해당 이벤트가 존재하지 않습니다.'}); + } + + // 현재 참여한 인원 수 계산 + const currentMemberCount = redisClient.get(countKeyPattern).then((result) => { + return parseInt(result, 10); + }).catch((error) => { + console.error('이벤트 참여자 수 조회중 오류', error); + + return UserEvent.count({ + where: {eventID: event.eventID}, + }); + }); + + // 이벤트 데이터에 현재 참여한 인원 수 추가 + const response = { + ...event.toJSON(), + currentMemberCount, // 현재 참여한 인원 수 + }; + + res.status(200).json(response); } catch (error) { - console.error('이벤트 조회 중 오류:', error); - res.status(500).json({ error: '이벤트 조회 중 오류가 발생했습니다.' }); + console.error('이벤트 조회 중 오류:', error); + res.status(500).json({error: '이벤트 조회 중 오류가 발생했습니다.'}); } - }; - +}; + // 이벤트 수정 컨트롤러 exports.updateEvent = async (req, res) => { @@ -176,40 +186,54 @@ exports.deleteEvent = 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; - if (!eventID || !userID) { - return res.status(400).json({error: '필수 데이터가 누락되었습니다.'}); - } - - const event = await Event.findByPk(eventID); - if (!event) { - return res.status(404).json({error: '해당 이벤트가 존재하지 않습니다.'}); - } + if (!eventID || !userID) { + return res.status(400).json({error: '필수 데이터가 누락되었습니다.'}); + } - const existingParticipant = await EventParticipants.findOne({where: {eventID, userID}}); - if (existingParticipant) { - return res.status(409).json({error: '이미 이벤트에 참여 중입니다.'}); - } + const event = await Event.findByPk(eventID); + if (!event) { + return res.status(404).json({error: '해당 이벤트가 존재하지 않습니다.'}); + } - const participantCount = await EventParticipants.count({where: {eventID, status: 'attending'}}); - if (event.capacity && participantCount >= event.capacity) { - return res.status(409).json({error: '이벤트 정원이 초과되었습니다.'}); - } + const existingParticipant = await EventParticipants.findOne({where: {eventID, userID}}); + if (existingParticipant) { + return res.status(409).json({error: '이미 이벤트에 참여 중입니다.'}); + } - const newParticipant = await EventParticipants.create({ - userID, - eventID, - participationDate: new Date(), - status: 'attending', - }); + const capacity = parseInt(event.capacity, 10); - res.status(201).json(newParticipant); + try { + const result = await redisClient.sendCommand([ + 'EVAL', + capCheckLuaScript, + '1', + countKeyPattern, + capacity.toString() + ]); + + // 인원이 초과하지 않은 경우, 참여 진행. + if (result === 1) { + const newParticipant = await EventParticipants.create({ + userID, + eventID, + participationDate: new Date(), + status: 'attending', + }); + + res.status(201).json(newParticipant); + } else { + return res.status(409).json({error: '이벤트의 최대 참여 인원 수를 초과했습니다.'}); + } } catch (error) { + // 참여 실패시 가입 카운트를 감소시킴. + await redisClient.decr(countKeyPattern); + console.error('이벤트 참여 중 오류:', error); + res.status(500).json({error: '이벤트 참여 중 오류가 발생했습니다.'}); } }; @@ -241,6 +265,8 @@ exports.cancelParticipation = async (req, res) => { // 상태 변경 (참여 취소) participant.status = 'canceled'; await participant.save(); + // 참여 인원 카운트 감소 + await redisClient.decr(countKeyPattern); res.status(200).json({ message: '이벤트 참여가 성공적으로 취소되었습니다.', diff --git a/webapp/backend/apiserver/controllers/userController.js b/webapp/backend/apiserver/controllers/userController.js index 162bb7564ce90aca4b5d85550dfffc034ff20f3c..6cd7b7f444cf6503ed9bf38317a3ac1aa212473f 100644 --- a/webapp/backend/apiserver/controllers/userController.js +++ b/webapp/backend/apiserver/controllers/userController.js @@ -106,7 +106,7 @@ exports.signUp = async (req, res) => { } catch (error) { await tx.rollback(); console.error('사용자 생성 중 오류:', error); - res.status(500).json({error: '사용자 생성 중 오류가 발생생했습니다.'}); + res.status(500).json({error: '사용자 생성 중 오류가 발생했습니다.'}); } } catch (error) { console.error('사용자 생성 중 오류:', error); diff --git a/webapp/backend/apiserver/datastore/redis.js b/webapp/backend/apiserver/datastore/redis.js new file mode 100644 index 0000000000000000000000000000000000000000..088e58d2549ca7006d09b540fae04436a8da29ca --- /dev/null +++ b/webapp/backend/apiserver/datastore/redis.js @@ -0,0 +1,24 @@ +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; diff --git a/webapp/backend/config/redis.js b/webapp/backend/config/redis.js new file mode 100644 index 0000000000000000000000000000000000000000..a791de9788ec00ee044c6b776cb8b3dcf42ffde3 --- /dev/null +++ b/webapp/backend/config/redis.js @@ -0,0 +1,7 @@ +// 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 diff --git a/webapp/backend/package-lock.json b/webapp/backend/package-lock.json index ec114290230492995b3d3774d5bf18a81c5c5032..936ed4a6e4dbca62e3594e3dbe7e89aa15baa790 100644 --- a/webapp/backend/package-lock.json +++ b/webapp/backend/package-lock.json @@ -11,6 +11,7 @@ "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", "mysql2": "^3.11.4", + "redis": "^4.7.0", "sequelize": "^6.37.5", "uuid": "^11.0.3" }, @@ -18,6 +19,65 @@ "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": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -304,6 +364,15 @@ "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": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -700,6 +769,15 @@ "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": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -1541,6 +1619,23 @@ "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": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz", @@ -2111,6 +2206,12 @@ "engines": { "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" } } } diff --git a/webapp/backend/package.json b/webapp/backend/package.json index 4ec4ab7393e00ca826de7c4acf7d922c60a81310..333c8e2f494f8cadf7b845bf64da29f544807247 100644 --- a/webapp/backend/package.json +++ b/webapp/backend/package.json @@ -6,6 +6,7 @@ "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", "mysql2": "^3.11.4", + "redis": "^4.7.0", "sequelize": "^6.37.5", "uuid": "^11.0.3" },