From 16011f42d0da4d0d3544adcac998b33ceb9864f5 Mon Sep 17 00:00:00 2001 From: Algoruu Date: Mon, 23 Dec 2024 00:07:14 +0900 Subject: [PATCH 01/12] =?UTF-8?q?[Feat]=20Redis=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 250 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 +- 2 files changed, 252 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8417616..521cb9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@aws-sdk/client-s3": "^3.705.0", + "@nestjs-modules/ioredis": "^2.0.2", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", @@ -21,6 +22,7 @@ "class-validator": "^0.14.1", "dotenv": "^16.4.7", "googleapis": "^144.0.0", + "ioredis": "^5.4.2", "mysql2": "^3.11.5", "passport": "^0.4.1", "passport-google-oauth20": "^2.0.0", @@ -1853,6 +1855,11 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2543,6 +2550,19 @@ "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", "license": "MIT" }, + "node_modules/@nestjs-modules/ioredis": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@nestjs-modules/ioredis/-/ioredis-2.0.2.tgz", + "integrity": "sha512-8pzSvT8R3XP6p8ZzQmEN8OnY0yWrJ/elFhwQK+PID2zf1SLBkAZ18bDcx3SKQ2atledt0gd9kBeP5xT4MlyS7Q==", + "optionalDependencies": { + "@nestjs/terminus": "10.2.0" + }, + "peerDependencies": { + "@nestjs/common": ">=6.7.0", + "@nestjs/core": ">=6.7.0", + "ioredis": ">=5.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.8", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.8.tgz", @@ -2890,6 +2910,76 @@ } } }, + "node_modules/@nestjs/terminus": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-10.2.0.tgz", + "integrity": "sha512-zPs98xvJ4ogEimRQOz8eU90mb7z+W/kd/mL4peOgrJ/VqER+ibN2Cboj65uJZW3XuNhpOqaeYOJte86InJd44A==", + "optional": true, + "dependencies": { + "boxen": "5.1.2", + "check-disk-space": "3.4.0" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@grpc/proto-loader": "*", + "@mikro-orm/core": "*", + "@mikro-orm/nestjs": "*", + "@nestjs/axios": "^1.0.0 || ^2.0.0 || ^3.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "@nestjs/microservices": "^9.0.0 || ^10.0.0", + "@nestjs/mongoose": "^9.0.0 || ^10.0.0", + "@nestjs/sequelize": "^9.0.0 || ^10.0.0", + "@nestjs/typeorm": "^9.0.0 || ^10.0.0", + "@prisma/client": "*", + "mongoose": "*", + "reflect-metadata": "0.1.x", + "rxjs": "7.x", + "sequelize": "*", + "typeorm": "*" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@grpc/proto-loader": { + "optional": true + }, + "@mikro-orm/core": { + "optional": true + }, + "@mikro-orm/nestjs": { + "optional": true + }, + "@nestjs/axios": { + "optional": true + }, + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/mongoose": { + "optional": true + }, + "@nestjs/sequelize": { + "optional": true + }, + "@nestjs/typeorm": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "sequelize": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "10.4.13", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.13.tgz", @@ -4706,6 +4796,15 @@ } } }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "optional": true, + "dependencies": { + "string-width": "^4.1.0" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -5261,6 +5360,57 @@ "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", "license": "MIT" }, + "node_modules/boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "optional": true, + "dependencies": { + "ansi-align": "^3.0.0", + "camelcase": "^6.2.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.1", + "string-width": "^4.2.2", + "type-fest": "^0.20.2", + "widest-line": "^3.1.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -5491,6 +5641,15 @@ "dev": true, "license": "MIT" }, + "node_modules/check-disk-space": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz", + "integrity": "sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==", + "optional": true, + "engines": { + "node": ">=16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -5576,6 +5735,18 @@ "validator": "^13.9.0" } }, + "node_modules/cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "optional": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -5750,6 +5921,14 @@ "node": ">=0.8" } }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -7920,6 +8099,29 @@ "node": ">=12.0.0" } }, + "node_modules/ioredis": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.2.tgz", + "integrity": "sha512-0SZXGNGZ+WzISQ67QDyZ2x0+wVxjjUndtD8oSeik/4ajifeiRufed8fCb8QW8VMyi4MXcS+UO1k/0NGhvq1PAg==", + "dependencies": { + "@ioredis/commands": "^1.1.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", @@ -9247,6 +9449,11 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "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==" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -9254,6 +9461,11 @@ "dev": true, "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==" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -10619,6 +10831,25 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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==", + "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==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -11212,6 +11443,11 @@ "node": ">=8" } }, + "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==" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -11985,7 +12221,7 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, + "devOptional": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -12556,6 +12792,18 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "optional": true, + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index c4a8a71..c799390 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "private": true, "license": "UNLICENSED", "scripts": { - "build": "node --max-old-space-size=4096 node_modules/.bin/nest build", + "build": "node --max-old-space-size=4096 node_modules/.bin/nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "node --max-old-space-size=4096 dist/main.js", "start:dev": "node --max-old-space-size=4096 node_modules/.bin/nest start --watch", @@ -23,6 +23,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.705.0", + "@nestjs-modules/ioredis": "^2.0.2", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", @@ -34,6 +35,7 @@ "class-validator": "^0.14.1", "dotenv": "^16.4.7", "googleapis": "^144.0.0", + "ioredis": "^5.4.2", "mysql2": "^3.11.5", "passport": "^0.4.1", "passport-google-oauth20": "^2.0.0", From 73ed7f1553eab114747faac670a3661723148863 Mon Sep 17 00:00:00 2001 From: Algoruu Date: Mon, 23 Dec 2024 00:07:39 +0900 Subject: [PATCH 02/12] =?UTF-8?q?[Feat]=20Redis=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=A0=84=EC=97=AD=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app.module.ts b/src/app.module.ts index 58816ba..3b74b32 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,9 +8,19 @@ import { VideoModule } from './video/video.module'; import { SearchModule } from './search/search.module'; import { PrismaModule } from './prisma/prisma.module'; import { Module } from '@nestjs/common'; +import { RedisModule } from './redis/redis.module'; @Module({ - imports: [LikeModule, PlaylistModule, VideoModule, SearchModule, PrismaModule, AuthModule, UserModule], + imports: [ + LikeModule, + PlaylistModule, + VideoModule, + SearchModule, + PrismaModule, + AuthModule, + UserModule, + RedisModule, + ], controllers: [AppController], providers: [AppService], }) From e2a5b4c830651aeddb57d220210e2c25077391f0 Mon Sep 17 00:00:00 2001 From: Algoruu Date: Mon, 23 Dec 2024 00:07:49 +0900 Subject: [PATCH 03/12] =?UTF-8?q?[Feat]=20Redis=20=EC=BA=90=EC=8B=B1?= =?UTF-8?q?=EC=9D=84=20=ED=99=9C=EC=9A=A9=ED=95=9C=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?API=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/playlist/playlist.controller.ts | 41 +++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/playlist/playlist.controller.ts b/src/playlist/playlist.controller.ts index 5dcbd9c..e9f1478 100644 --- a/src/playlist/playlist.controller.ts +++ b/src/playlist/playlist.controller.ts @@ -10,6 +10,7 @@ import { Req, UseGuards, UnauthorizedException, + BadRequestException, } from '@nestjs/common'; import { PlaylistService } from './playlist.service'; import { CreatePlaylistDto } from './dto/create-playlist.dto'; @@ -21,7 +22,10 @@ import { AddVideoDto } from './dto/add-video.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { Request } from 'express'; import { Public } from 'src/auth/auth.decorator'; +import { RedisService } from '../redis/redis.service'; import { ApiOperation, ApiResponse, ApiQuery, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; +import { NotFoundException } from '@nestjs/common'; + @ApiBearerAuth() @Controller('/playlists') @@ -29,6 +33,7 @@ export class PlaylistController { constructor( private readonly playlistService: PlaylistService, private readonly videoService: VideoService, + private readonly redisService: RedisService, ) {} @Get('popular') @@ -396,7 +401,7 @@ export class PlaylistController { @Get('videos/search') - @ApiOperation({ summary: '유튜브 동영상 검색', description: '유튜브 API를 통해 동영상을 검색합니다.' }) + @ApiOperation({ summary: '유튜브 동영상 검색', description: '유튜브 API를 통해 동영상을 검색하며, Redis 캐싱을 활용합니다.' }) @ApiQuery({ name: 'keyword', required: true, description: '검색 키워드' }) @ApiQuery({ name: 'maxResults', required: false, description: '검색 결과 수 (기본값: 5)' }) @ApiResponse({ @@ -410,6 +415,7 @@ export class PlaylistController { channelName: '채널 이름', thumbnailUrl: 'https://example.com/thumbnail.jpg', duration: 180, + source: 'cache', // 캐시에서 가져온 경우 }, ], }, @@ -423,15 +429,46 @@ export class PlaylistController { status: 404, description: '검색 결과 없음', schema: { example: { message: '검색 결과를 찾을 수 없습니다.' } }, - }) + }) async searchVideos(@Query() query: SearchVideoDto) { + const { keyword, maxResults } = query; + + if (!keyword) { + throw new BadRequestException('검색어는 필수입니다.'); + } + + // Redis 키 생성 + const cacheKey = `videos:search:${keyword}:${maxResults || 5}`; + + // Redis에서 캐싱된 결과 조회 + const cachedVideos = await this.redisService.get(cacheKey); + if (cachedVideos) { + const cachedResult = JSON.parse(cachedVideos); + return cachedResult.map((video) => ({ + ...video, + source: 'cache', // Redis 캐시에서 가져온 경우 + })); + } + + // Redis에 결과가 없으면 YouTube API를 호출 const videos = await this.videoService.searchVideos(query); + + // 결과가 없으면 404 응답 + if (!videos || videos.length === 0) { + throw new NotFoundException('검색 결과를 찾을 수 없습니다.'); + } + + // Redis에 검색 결과 캐싱 (TTL: 60분) + await this.redisService.set(cacheKey, JSON.stringify(videos), 3600); + + // 검색 결과 반환 return videos.map((video) => ({ youtubeId: video.youtubeId, title: video.title, channelName: video.channelName, thumbnailUrl: video.thumbnailUrl, duration: video.duration, + source: 'youtube', // YouTube API에서 가져온 경우 })); } From adf15a7c021ff02174baaa18c7e6192c116aac74 Mon Sep 17 00:00:00 2001 From: Algoruu Date: Mon, 23 Dec 2024 00:08:01 +0900 Subject: [PATCH 04/12] =?UTF-8?q?[Feat]=20Redis=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=9D=84=20Playlist=20=EB=AA=A8=EB=93=88=EC=97=90=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/playlist/playlist.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/playlist/playlist.module.ts b/src/playlist/playlist.module.ts index df3f103..ec8067d 100644 --- a/src/playlist/playlist.module.ts +++ b/src/playlist/playlist.module.ts @@ -5,9 +5,10 @@ import { PrismaModule } from '../prisma/prisma.module'; import { AuthModule } from '../auth/auth.module'; import { VideoModule } from '../video/video.module'; import { VideoService } from '../video/video.service'; +import { RedisModule } from '../redis/redis.module'; @Module({ - imports: [PrismaModule, AuthModule, VideoModule], + imports: [PrismaModule, AuthModule, VideoModule, RedisModule], controllers: [PlaylistController], providers: [PlaylistService, VideoService], }) From fca684c5a5c999e74fa0a83e278927d0776a1848 Mon Sep 17 00:00:00 2001 From: Algoruu Date: Mon, 23 Dec 2024 00:08:10 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[Feat]=20Redis=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/redis/redis.module.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/redis/redis.module.ts diff --git a/src/redis/redis.module.ts b/src/redis/redis.module.ts new file mode 100644 index 0000000..60ec7b0 --- /dev/null +++ b/src/redis/redis.module.ts @@ -0,0 +1,21 @@ +import { Module, Global } from '@nestjs/common'; +import { RedisModule as NestRedisModule } from '@nestjs-modules/ioredis'; +import { RedisService } from './redis.service'; + +@Global() +@Module({ + imports: [ + NestRedisModule.forRoot({ + type: 'single', + options: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT, 10) || 6379, + password: process.env.REDIS_PASSWORD || undefined, // 필요 시 비밀번호 추가 + db: parseInt(process.env.REDIS_DB, 10) || 0, // 데이터베이스 선택 (기본값: 0) + }, + }), + ], + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} From 562e5289511717b568431394d3f653918a040e2b Mon Sep 17 00:00:00 2001 From: Algoruu Date: Mon, 23 Dec 2024 00:13:10 +0900 Subject: [PATCH 06/12] =?UTF-8?q?[Test]=20Redis=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/redis/redis.service.spec.ts | 53 +++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/redis/redis.service.spec.ts diff --git a/src/redis/redis.service.spec.ts b/src/redis/redis.service.spec.ts new file mode 100644 index 0000000..fa92589 --- /dev/null +++ b/src/redis/redis.service.spec.ts @@ -0,0 +1,53 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RedisService } from './redis.service'; +import { RedisModule } from '@nestjs-modules/ioredis'; + +describe('RedisService', () => { + let redisService: RedisService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + RedisModule.forRoot({ + type: 'single', // RedisModuleOptions 타입에 따라 수정 + options: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT, 10) || 6379, + password: process.env.REDIS_PASSWORD || undefined, // 필요 시 비밀번호 추가 + db: parseInt(process.env.REDIS_DB, 10) || 0, // 데이터베이스 선택 (기본값: 0) + }, + }), + ], + providers: [RedisService], + }).compile(); + + redisService = module.get(RedisService); + }); + + afterAll(async () => { + // 테스트 종료 후 Redis 연결 정리 + await redisService.del('testKey'); + }); + + it('should set and get a value from Redis', async () => { + await redisService.set('testKey', 'testValue', 60); // 60초 TTL 설정 + const value = await redisService.get('testKey'); + expect(value).toBe('testValue'); + }); + + it('should check if a key exists in Redis', async () => { + const exists = await redisService.exists('testKey'); + expect(exists).toBe(true); + }); + + it('should delete a key from Redis', async () => { + await redisService.del('testKey'); + const exists = await redisService.exists('testKey'); + expect(exists).toBe(false); + }); + + it('should return null for a non-existent key', async () => { + const value = await redisService.get('nonExistentKey'); + expect(value).toBeNull(); + }); +}); From 2054346be75e2bb5313663220f3be44a4db56024 Mon Sep 17 00:00:00 2001 From: Algoruu Date: Mon, 23 Dec 2024 00:13:24 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[Feat]=20Redis=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=BA=90=EC=8B=B1?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/redis/redis.service.ts | 53 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/redis/redis.service.ts diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts new file mode 100644 index 0000000..dc76ba4 --- /dev/null +++ b/src/redis/redis.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { Redis } from 'ioredis'; +import { InjectRedis } from '@nestjs-modules/ioredis'; + +@Injectable() +export class RedisService { + constructor(@InjectRedis() private readonly redis: Redis) {} + + // Redis GET 메서드 + async get(key: string): Promise { + try { + return await this.redis.get(key); + } catch (error) { + console.error(`Redis GET error for key "${key}": ${error.message}`); + return null; // 반환값이 필요하므로 null을 반환 + } + } + + // Redis SET 메서드 (TTL 설정 가능) + async set(key: string, value: string, ttl?: number): Promise { + try { + if (ttl) { + await this.redis.set(key, value, 'EX', ttl); + } else { + await this.redis.set(key, value); + } + } catch (error) { + console.error(`Redis SET error for key "${key}": ${error.message}`); + throw new Error(`Failed to set key "${key}" in Redis`); // 오류 전달 + } + } + + // Redis 데이터 삭제 + async del(key: string): Promise { + try { + await this.redis.del(key); + } catch (error) { + console.error(`Redis DEL error for key "${key}": ${error.message}`); + throw new Error(`Failed to delete key "${key}" in Redis`); + } + } + + // Redis 키 존재 여부 확인 + async exists(key: string): Promise { + try { + const result = await this.redis.exists(key); + return result === 1; // Redis에서 1을 반환하면 존재 + } catch (error) { + console.error(`Redis EXISTS error for key "${key}": ${error.message}`); + return false; // 오류가 발생하면 키가 없다고 간주 + } + } +} From fddce831e454acccb611558f6862ba6c0b77bc31 Mon Sep 17 00:00:00 2001 From: Algoruu Date: Mon, 23 Dec 2024 00:13:34 +0900 Subject: [PATCH 08/12] =?UTF-8?q?[Feat]=20Redis=20=EC=BA=90=EC=8B=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20=EB=B9=84=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/video/video.service.ts | 39 +++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/video/video.service.ts b/src/video/video.service.ts index 1841e72..33f1e9b 100644 --- a/src/video/video.service.ts +++ b/src/video/video.service.ts @@ -4,12 +4,16 @@ import { AddVideoDto } from '../playlist/dto/add-video.dto'; import { UpdateOrderDto } from './dto/update-order.dto'; import { SearchVideoDto } from './dto/search-video.dto'; import { google } from 'googleapis'; +import { RedisService } from '../redis/redis.service'; @Injectable() export class VideoService { private readonly youtube; - constructor(private readonly prisma: PrismaService) { + constructor( + private readonly prisma: PrismaService, + private readonly redisService: RedisService, // RedisService 주입 + ) { this.youtube = google.youtube({ version: 'v3', auth: process.env.YOUTUBE_API_KEY, // .env에서 YouTube API 키 가져오기 @@ -28,12 +32,20 @@ export class VideoService { return hours * 3600 + minutes * 60 + seconds; // 초 단위로 변환 } - // 1. 유튜브 동영상 검색 - 음악 카테고리 및 duration 포함 + // 1. 유튜브 동영상 검색 - 음악 카테고리 및 duration 포함 + Redis 캐시 추가 async searchVideos(query: SearchVideoDto) { if (!query.keyword) { throw new Error('검색 키워드가 필요합니다.'); } + // Redis 키 생성 + const redisKey = `search:${query.keyword}:${query.maxResults || 5}`; + const cachedResults = await this.redisService.get(redisKey); + + if (cachedResults) { + return JSON.parse(cachedResults); // 캐시된 결과 반환 + } + // Step 1: search.list로 videoId 가져오기 const searchResponse = await this.youtube.search.list({ part: ['id'], @@ -57,7 +69,7 @@ export class VideoService { }); // Step 3: 결과 필터링 (제목에만 키워드 포함) 및 duration 값 변환 - return videoResponse.data.items + const results = videoResponse.data.items .filter((item) => item.snippet.title.toLowerCase().includes(query.keyword.toLowerCase()) ) // 제목에만 키워드 포함된 경우만 필터링 @@ -68,10 +80,22 @@ export class VideoService { thumbnailUrl: item.snippet.thumbnails?.default?.url, duration: this.parseDuration(item.contentDetails.duration), })); + + // 결과 Redis에 캐싱 (TTL: 1시간) + await this.redisService.set(redisKey, JSON.stringify(results), 3600); + + return results; } - // 2. 서비스와 유튜브 검색 결과 통합 - source 필드 추가 + // 2. 서비스와 유튜브 검색 결과 통합 - source 필드 추가 + Redis 캐싱 포함 async searchServiceAndYoutube(query: SearchVideoDto) { + const redisKey = `combined_search:${query.keyword}:${query.maxResults || 5}`; + const cachedResults = await this.redisService.get(redisKey); + + if (cachedResults) { + return JSON.parse(cachedResults); // 캐시된 결과 반환 + } + // 유튜브 검색 결과 const youtubeResults = await this.searchVideos(query); @@ -100,10 +124,15 @@ export class VideoService { source: 'service', })); - return { + const combinedResults = { servicePlaylists: servicePlaylists, youtubePlaylists: youtubePlaylists, }; + + // 결과 Redis에 캐싱 (TTL: 1시간) + await this.redisService.set(redisKey, JSON.stringify(combinedResults), 3600); + + return combinedResults; } // 3. 유튜브 동영상의 duration 값 가져오기 From 3df56639784a7bf81798d2e7381ad3204cea4027 Mon Sep 17 00:00:00 2001 From: Algoruu Date: Mon, 23 Dec 2024 02:50:16 +0900 Subject: [PATCH 09/12] =?UTF-8?q?[Feat]=20=EA=B0=84=EC=86=8C=ED=99=94?= =?UTF-8?q?=EB=90=9C=20=ED=94=8C=EB=A0=88=EC=9D=B4=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=83=9D=EC=84=B1=20=EC=9D=91=EB=8B=B5=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/playlist/playlist.controller.ts | 38 +++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/playlist/playlist.controller.ts b/src/playlist/playlist.controller.ts index 5dcbd9c..1524cd8 100644 --- a/src/playlist/playlist.controller.ts +++ b/src/playlist/playlist.controller.ts @@ -184,17 +184,34 @@ export class PlaylistController { @UseGuards(JwtAuthGuard) @ApiOperation({ summary: '플레이리스트 생성', - description: `새로운 플레이리스트를 생성합니다.`, + description: `새로운 플레이리스트를 생성합니다. 플레이리스트 생성 시 동영상을 함께 추가할 수 있습니다.`, }) - @ApiResponse({ - status: 201, - description: '플레이리스트 생성 성공', + @ApiBody({ + description: '새로운 플레이리스트 생성 요청 데이터', schema: { example: { - id: 1, title: 'My Favorite Songs', description: '즐겨듣는 노래 모음', tags: ['Pop', 'K-Pop'], + videos: [ + { + youtubeId: 'abc123', + title: '노래 제목', + thumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg', + channelName: '채널 이름', + duration: 180, + order: 1, + }, + ], + }, + }, + }) + @ApiResponse({ + status: 201, + description: '플레이리스트 생성 성공', + schema: { + example: { + playlistId: 1, message: '플레이리스트 생성 성공', }, }, @@ -202,7 +219,7 @@ export class PlaylistController { @ApiResponse({ status: 400, description: '필수 데이터 누락', - schema: { example: { message: '제목과 태그, 첫번째 비디오 값은 필수입니다.' } }, + schema: { example: { message: '제목, 태그 또는 비디오 데이터가 누락되었습니다.' } }, }) @ApiResponse({ status: 401, @@ -211,8 +228,15 @@ export class PlaylistController { }) async createPlaylist(@Body() dto: CreatePlaylistDto, @Req() req): Promise { const userId = req.user?.userId; + + // 서비스 로직 호출 const playlist = await this.playlistService.createPlaylist(dto, userId); - return playlist; + + // 반환 데이터 간소화 + return { + playlistId: playlist.id, + message: '플레이리스트 생성 성공', + }; } From e3b59ac4572c297f375e3158791b2af26c741dfe Mon Sep 17 00:00:00 2001 From: Algoruu Date: Mon, 23 Dec 2024 02:50:30 +0900 Subject: [PATCH 10/12] =?UTF-8?q?[Refactor]=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=83=9D=EC=84=B1=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EA=B0=84=EC=86=8C?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/playlist/playlist.service.ts | 92 +++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/src/playlist/playlist.service.ts b/src/playlist/playlist.service.ts index fbc4fab..aa33cdf 100644 --- a/src/playlist/playlist.service.ts +++ b/src/playlist/playlist.service.ts @@ -21,43 +21,73 @@ export class PlaylistService { } // 1. 플레이리스트 생성 - async createPlaylist(dto: CreatePlaylistDto, userId: number): Promise { - const { title, description, tags = [] } = dto; + async createPlaylist(data: CreatePlaylistDto, userId: number) { + const { title, description, tags, videos } = data; - // 태그 유효성 검사 - const validatedTags = tags.filter((tag) => tag && tag.trim() !== ""); - - const playlist = await this.prisma.playlist.create({ - data: { - title, - description, - user: { connect: { id: userId } }, - tags: { - create: validatedTags.map((tag) => ({ - tag: { - connectOrCreate: { - where: { name: tag }, - create: { name: tag }, + // 트랜잭션 사용 + return this.prisma.$transaction(async (prisma) => { + // 1. 플레이리스트 생성 + const playlist = await prisma.playlist.create({ + data: { + title, + description, + userId, + tags: { + create: tags.map((tag) => ({ + tag: { + connectOrCreate: { + where: { name: tag }, + create: { name: tag }, + }, }, - }, - })), + })), + }, }, - }, - include: { - tags: { - include: { tag: true }, + include: { + tags: { include: { tag: true } }, // 연결된 tags 데이터 반환 }, - }, - }); + }); - return { - id: playlist.id, - title: playlist.title, - description: playlist.description, - tags: playlist.tags.map((playlistTag) => playlistTag.tag.name), - message: "플레이리스트 생성 성공", - }; + // 2. 곡 추가 (videos 배열이 있을 경우 처리) + let createdVideos = []; + if (videos && videos.length > 0) { + const videoData = videos.map((video, index) => ({ + playlistId: playlist.id, + youtubeId: video.youtubeId, + title: video.title, + thumbnailUrl: video.thumbnailUrl, + channelName: video.channelName, + duration: video.duration, + order: video.order ?? index + 1, + })); + + // videos를 저장하고 저장된 결과를 반환 + await prisma.video.createMany({ data: videoData }); + + // 저장된 videos를 조회 + createdVideos = await prisma.video.findMany({ + where: { playlistId: playlist.id }, + select: { + id: true, + youtubeId: true, + title: true, + thumbnailUrl: true, + channelName: true, + duration: true, + order: true, + }, + }); + } + + // playlist와 함께 videos, tags 데이터를 반환 + return { + ...playlist, + videos: createdVideos, + tags: playlist.tags.map((tag) => tag.tag.name), // tags 데이터를 변환 + }; + }); } + // 2. 플레이리스트 수정 From 3a263f3a9e5619110b81c92e292c107c6b4a745a Mon Sep 17 00:00:00 2001 From: Algoruu Date: Mon, 23 Dec 2024 02:50:43 +0900 Subject: [PATCH 11/12] =?UTF-8?q?[Feat]=20CreatePlaylistDto=EC=97=90=20?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=ED=95=84=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/playlist/dto/create-playlist.dto.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/playlist/dto/create-playlist.dto.ts b/src/playlist/dto/create-playlist.dto.ts index c617333..7add5e6 100644 --- a/src/playlist/dto/create-playlist.dto.ts +++ b/src/playlist/dto/create-playlist.dto.ts @@ -1,5 +1,7 @@ -import { IsString, IsOptional, IsArray, ValidateNested } from 'class-validator'; +import { IsString, IsOptional, IsArray, IsNotEmpty, ValidateNested } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { AddVideoDto } from './add-video.dto' export class CreatePlaylistDto { @@ -8,6 +10,7 @@ export class CreatePlaylistDto { description: '플레이리스트 제목', }) @IsString() + @IsNotEmpty() title: string; @ApiProperty({ @@ -28,4 +31,10 @@ export class CreatePlaylistDto { @IsString({ each: true }) @IsOptional() tags?: string[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AddVideoDto) + @IsOptional() + videos?: AddVideoDto[]; // 추가된 필드 } From 393c7b866e3efd33251f11eb0161b68c73540f16 Mon Sep 17 00:00:00 2001 From: Algoruu Date: Mon, 23 Dec 2024 05:44:40 +0900 Subject: [PATCH 12/12] =?UTF-8?q?[Docs]=20Redis=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/redis/redis.module.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/redis/redis.module.ts b/src/redis/redis.module.ts index 60ec7b0..982b39e 100644 --- a/src/redis/redis.module.ts +++ b/src/redis/redis.module.ts @@ -8,10 +8,10 @@ import { RedisService } from './redis.service'; NestRedisModule.forRoot({ type: 'single', options: { - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT, 10) || 6379, - password: process.env.REDIS_PASSWORD || undefined, // 필요 시 비밀번호 추가 - db: parseInt(process.env.REDIS_DB, 10) || 0, // 데이터베이스 선택 (기본값: 0) + host: process.env.REDIS_HOST, // AWS ElastiCache 엔드포인트 + port: parseInt(process.env.REDIS_PORT, 10), + password: process.env.REDIS_PASSWORD || undefined, // Auth Token 설정 + db: parseInt(process.env.REDIS_DB, 10) || 0, }, }), ],