Remove Redis and replace with in-memory caches

- Drop ioredis, docker-compose redis service, and REDIS_* env vars.

- Remove src/cache; use Map-based invite and webhook caching in services.

- Run global command registration and voice boot sync without distributed locks.

- Update README, agent rules, and docs to match the new stack.

Made-with: Cursor
This commit is contained in:
mineseo-kim 2026-04-09 08:50:53 +09:00
parent 80e104a9f4
commit a2e755b708
19 changed files with 67 additions and 200 deletions

View File

@ -9,7 +9,7 @@ description: work routine
## 기본 원칙 (Work Rules)
1. **인프라 자율 사용**: 에이전트는 프로젝트에 설정된 Docker 기반 인프라(PostgreSQL, Redis 등)를 사용자의 추가 승인 없이 자유롭게 구동(`docker-compose up -d`) 및 활용할 수 있습니다.
1. **인프라 자율 사용**: 에이전트는 프로젝트에 설정된 Docker 기반 인프라(PostgreSQL 등)를 사용자의 추가 승인 없이 자유롭게 구동(`docker-compose up -d`) 및 활용할 수 있습니다.
2. **협력적 기획, 독립적 실행**: 기능 기획과 설계(Architecture, Schema 등)는 사용자와 함께 논리적인 완결성을 갖출 때까지 충분히 논의합니다. 기획이 "완료 및 승인"된 후에는 후속 구현, 에러 디버깅, 자체 테스트를 추가적인 중간 확인 없이 에이전트가 주도를 가지고 끝마친 뒤 최종 결과를 보고합니다.
## 단계별 작업 루틴

View File

@ -5,7 +5,3 @@ DISCORD_CLIENT_ID=your_client_id_here
# Database Configuration (PostgreSQL)
# User/pass from docker-compose.yml
DATABASE_URL="postgresql://kord:password@localhost:5432/kord_db?schema=public"
# Redis Configuration
REDIS_HOST="localhost"
REDIS_PORT=6379

View File

@ -39,7 +39,7 @@
|----------|-------------|------|------------------|
| `USER_INPUT` | `E1xxx` | 잘못된 입력값 (범위 초과, 형식 오류 등) | 올바른 입력 예시 안내, 재입력 유도 |
| `PERMISSION` | `E2xxx` | 봇 또는 사용자 권한 부족 | 필요 권한 안내, 서버 관리자 문의 유도 |
| `BOT_INTERNAL` | `E3xxx` | 봇 내부 오류 (DB, Redis, 로직 오류) | 잠시 후 재시도 안내, 지속 시 관리자 문의 |
| `BOT_INTERNAL` | `E3xxx` | 봇 내부 오류 (DB, 로직 오류) | 잠시 후 재시도 안내, 지속 시 관리자 문의 |
| `DISCORD_API` | `E4xxx` | Discord API 오류 (Rate Limit, 서비스 장애 등) | 잠시 후 재시도, Discord 상태 확인 안내 |
### 주요 에러 코드 예시
@ -51,7 +51,7 @@ E2001 봇에 Manage Channels 권한 없음
E2002 사용자에게 관리자 권한 없음
E2003 채널 소유자만 사용 가능
E3001 데이터베이스 연결/쿼리 실패
E3002 캐시(Redis) 연결 실패
E3002 내부 캐시/상태 계층 오류
E4001 Discord API Rate Limit
E4002 Discord API 50013 (Missing Permissions)
E4003 Discord API 일시적 오류

View File

@ -173,11 +173,10 @@ enum EventStatus {
### 중복 방지
- 전송 직후 `remindedOneHour`, `remindedTenMinutes` 업데이트
- 다중 인스턴스 환경에서는 Redis lock 또는 `INSTANCE_ID` 기반 단일 실행 제어 고려
- 다중 인스턴스 환경에서는 DB advisory lock 또는 `INSTANCE_ID` 기반 단일 실행 제어 고려
> [!NOTE]
> 현재 프로젝트는 글로벌 커맨드 동기화 시 Redis lock을 사용하므로,
> 이벤트 리마인더도 같은 방식으로 확장하기 좋습니다.
> 글로벌 커맨드 등록 등은 애플리케이션 레벨에서 처리하며, 이벤트 리마인더도 유사한 단일 실행 패턴으로 확장할 수 있습니다.
---

View File

@ -71,7 +71,7 @@
- **State Management (상태 관리)**:
- 마법사는 ephemeral 메시지로 띄워지며, 세션 식별은 `customId` 릴레이 방식을 권장합니다.
- 예시: `setup_action_next_2` (현재 Step 2, 다음으로 이동) 등 Stateless하게 관리하거나,
- 사용자/서버별 임시 Map/Redis`SetupSession` (진행 단계 등) 보관. (구현 편의상 `customId` payload에 스텝 인덱스를 담는 Stateless 방식 채택)
- 사용자/서버별 임시 Map`SetupSession` (진행 단계 등) 보관. (구현 편의상 `customId` payload에 스텝 인덱스를 담는 Stateless 방식 채택)
- **i18n 통합**:
- 버튼 레이블 및 Embed 내용은 모두 `t()` 함수를 거쳐야 합니다.
- Step 1에서 언어가 변경되면, 즉시 그 언어 기준의 `t()`를 이용해 현재 뷰(View)를 갱신합니다.

View File

@ -13,7 +13,7 @@
#### 절대 금지 대상 예시:
- 디스코드 봇 토큰 (Discord Bot Tokens)
- 데이터베이스 비밀번호 및 접속 주소 (e.g. `postgresql://user:password@host/db`)
- Redis 비밀번호, API 인증 키(Keys), 비밀 암호 해시, 사내용 중요 자격증명 리스트
- 외부 서비스 비밀번호, API 인증 키(Keys), 비밀 암호 해시, 사내용 중요 자격증명 리스트
#### 올바른 해결 방법
1. **환경 변수 파일 (`.env`) 사용**:

View File

@ -7,7 +7,7 @@
### 1. 기술 스택 확정 및 초기화
- **언어 및 런타임**: Node.js, TypeScript
- **프레임워크**: discord.js (v14+)
- **데이터베이스**: Prisma (PostgreSQL) + Redis (캐싱 및 동기화)
- **데이터베이스**: Prisma (PostgreSQL)
- **빌드 도구**: ts-node, tsx, tsc
### 2. 프로젝트 기본 구조 설계

View File

@ -38,6 +38,6 @@
- **퇴장 시 채널 미삭제 및 유령 방 버그**:
- *문제*: 채널 내에 음악봇 등 봇만 남았을 때 삭제 조건이 작동하지 않고, 권한 문제로 삭제가 실패했을 때 DB 무결성이 깨지는 버그.
- *해결*: 휴먼 카운트(`humanCount`) 도입 및 삭제 롤백 검증 처리. 자세한 사항은 [handleLeave_ghost_channel.md](../Troubleshooting/handleLeave_ghost_channel.md) 참조.
- **멀티 인스턴스 대응 및 봇 재부팅 복구 (Boot Recovery)**:
- *사유*: 봇 재시작, 크래시, 혹은 다중 노드(Multi-Instance) 환경에서 DB와 디스코드 실제 채널 상태가 어긋나게 될 경우를 방지.
- *해결*: `ioredis` 분산 락(Distributed Lock)과 결합된 `VoiceService.syncChannels` 메서드를 구현하여, 부팅 시 딱 하나의 인스턴스만 DB를 훑으며 삭제되어야 할 유령 방들을 크로스체크 및 안전하게 청소(Garbage Collection)하도록 반영.
- **봇 재부팅 복구 (Boot Recovery)**:
- *사유*: 봇 재시작·크래시 등으로 DB와 디스코드 실제 채널 상태가 어긋나게 될 경우를 방지.
- *해결*: `VoiceService.syncChannels`로 부팅 시 DB를 기준으로 유령 방을 크로스체크 및 청소(Garbage Collection)하도록 반영. (다중 인스턴스 동시 실행 시 동일 작업이 겹칠 수 있으나 작업은 멱등에 가깝게 설계됨.)

View File

@ -4,14 +4,13 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
## 1. 개요 (Overview)
**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)와 Redis를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다.
**Kord**는 효율적인 서버 운영을 위해 설계된 Discord 봇입니다. TypeScript와 Discord.js를 기반으로 구축되었으며, Prisma(PostgreSQL)를 활용하여 안정적이고 확장 가능한 아키텍처를 제공합니다. 임시 음성 채널 관리, 상세 감사 로그, 권한 진단 등의 핵심 기능을 통해 서버 관리자의 부담을 줄여줍니다.
## 2. 요구사항 (Requirements)
- **Runtime**: Node.js v20 이상
- **Package Manager**: Yarn v4 (Berry)
- **Database**: PostgreSQL (Prisma 사용)
- **Cache**: Redis (다중 인스턴스 동기화 및 캐싱)
- **Discord**: Bot Token 및 Client ID (Slash Command 등록용)
## 3. 테스트 방법 (Test Methods)
@ -59,7 +58,7 @@ Kord는 Discord 서버 관리를 돕는 강력하고 유연한 다기능 봇입
1. **빌드**: `yarn build`
2. **실행**: `yarn start`
3. **Docker**: `docker-compose up -d`를 통해 전체 스택(Bot, DB, Redis)을 실행할 수 있습니다.
3. **Docker**: `docker-compose up -d`를 통해 PostgreSQL 등 로컬 인프라를 실행할 수 있습니다.
## 5. 기능 목록 (Feature List)

View File

@ -14,15 +14,5 @@ services:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: kord-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
postgres_data:
redis_data:

View File

@ -10,7 +10,6 @@
"discord.js": "^14.25.1",
"dotenv": "^17.3.1",
"ffmpeg-static": "^5.3.0",
"ioredis": "^5.10.1",
"pg": "^8.20.0",
"prism-media": "^1.3.5",
"sharp": "^0.34.5",

23
src/cache/index.ts vendored
View File

@ -1,23 +0,0 @@
import Redis from 'ioredis';
import { env } from '../config/env';
import { logger } from '../utils/logger';
export const redis = new Redis({
host: env.REDIS_HOST,
port: env.REDIS_PORT,
lazyConnect: true,
});
redis.on('error', (err) => {
logger.error('Redis Error:', err);
});
export const connectRedis = async () => {
try {
await redis.connect();
logger.info('Connected to Redis successfully.');
} catch (error) {
logger.error('Failed to connect to Redis:', error);
process.exit(1);
}
};

View File

@ -5,7 +5,6 @@ import { loadCommands } from '../handlers/CommandLoader';
import { loadEvents } from '../handlers/EventLoader';
import { handleGlobalExceptions } from '../utils/errorHandler';
import { connectDB } from '../database';
import { connectRedis } from '../cache';
import { FeverService } from '../services/FeverService';
export class KordClient extends Client {
@ -29,7 +28,6 @@ export class KordClient extends Client {
// Connect to external services
await connectDB();
await connectRedis();
// Load Handlers
await loadCommands(this);

View File

@ -11,8 +11,6 @@ export const env = {
DISCORD_TOKEN: process.env.DISCORD_TOKEN || '',
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID || '',
DATABASE_URL: process.env.DATABASE_URL || '',
REDIS_HOST: process.env.REDIS_HOST || 'localhost',
REDIS_PORT: parseInt(process.env.REDIS_PORT || '6379', 10),
VOICE_WAITING_ROOM_ID: process.env.VOICE_WAITING_ROOM_ID || '',
VOICE_CATEGORY_ID: process.env.VOICE_CATEGORY_ID || '',
INSTANCE_ID: generateInstanceId(),

View File

@ -6,7 +6,6 @@ import { VoiceService } from '../services/VoiceService';
import { PresenceService } from '../services/PresenceService';
import { EventService } from '../services/EventService';
import { auditLogService } from '../services/AuditLogService';
import { redis } from '../cache';
import { env } from '../config/env';
export default {
@ -20,17 +19,9 @@ export default {
EventService.startReminderLoop(client);
try {
const lockKey = 'commands:sync:lock';
// EX 300 = 5 minutes lock. Only one instance needs to do this per boot cycle.
const acquired = await redis.set(lockKey, '1', 'EX', 300, 'NX');
if (acquired) {
const commandsData = Array.from(client.commands.values()).map(c => c.data.toJSON());
await client.application?.commands.set(commandsData);
logger.info(`Successfully registered ${commandsData.length} global application commands.`);
} else {
logger.info('Global commands registration skipped (already handled by another instance).');
}
} catch (e) {
logger.error('Failed to register global commands', e);
}

View File

@ -1,9 +1,11 @@
import { Client, Guild, Invite, GuildMember } from 'discord.js';
import { redis } from '../cache';
import { prisma } from '../database';
import { logger } from '../utils/logger';
export class InviteService {
/** In-process invite snapshot per guild for join attribution. */
private static readonly inviteCache = new Map<string, string>();
public static async cacheAllInvites(client: Client) {
for (const [, guild] of client.guilds.cache) {
await this.cacheGuildInvites(guild);
@ -18,7 +20,7 @@ export class InviteService {
code: inv.code,
uses: inv.uses || 0
}));
await redis.set(`invites:${guild.id}`, JSON.stringify(inviteData));
this.inviteCache.set(guild.id, JSON.stringify(inviteData));
} catch (error) {
logger.error(`InviteMaster: Failed to cache invites for guild ${guild.id}:`, error);
}
@ -41,7 +43,7 @@ export class InviteService {
try {
// Fetch current active invites
const newInvites = await guild.invites.fetch();
const cachedData = await redis.get(`invites:${guild.id}`);
const cachedData = this.inviteCache.get(guild.id);
let usedInvite: Invite | undefined;

View File

@ -1,7 +1,6 @@
import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js';
import { prisma } from '../database';
import { logger } from '../utils/logger';
import { redis } from '../cache';
import { ErrorDefs } from '../errors/ErrorCodes';
import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n';
import { getContextLocale } from '../i18n/localeHelper';
@ -9,15 +8,6 @@ import { auditLogService } from './AuditLogService';
export class VoiceService {
public static async syncChannels(client: Client) {
const lockKey = 'voice:sync:lock';
// NX = only set if not exists, EX = expire in 60s
const acquired = await redis.set(lockKey, '1', 'EX', 60, 'NX');
if (!acquired) {
logger.info('VoiceService: Another instance is already syncing channels. Skipping.');
return;
}
try {
logger.info('VoiceService: Starting channel synchronization...');
const channels = await prisma.tempVoiceChannel.findMany();
@ -55,10 +45,6 @@ export class VoiceService {
}
}
logger.info('VoiceService: Channel synchronization complete.');
} finally {
// Free the lock just in case, though EX ensures it doesn't hang forever
await redis.del(lockKey).catch(() => {});
}
}
public static async handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
const member = newState.member;

View File

@ -1,18 +1,21 @@
import { TextChannel, WebhookClient } from 'discord.js';
import { logger } from '../utils/logger';
import { redis } from '../cache';
export class WebhookService {
private static readonly MAX_WEBHOOKS = 10;
private static readonly WEBHOOK_NAME = 'Kord Mimic Webhook';
private static readonly WEBHOOK_CACHE_TTL_MS = 86400 * 1000;
private static readonly webhookCache = new Map<
string,
{ id: string; token: string; expiresAt: number }
>();
public static async getWebhookClient(channel: TextChannel): Promise<WebhookClient | null> {
try {
// 1. Check cache
const cachedData = await redis.get(`webhook:${channel.id}`);
if (cachedData) {
const { id, token } = JSON.parse(cachedData);
return new WebhookClient({ id, token });
const now = Date.now();
const cached = this.webhookCache.get(channel.id);
if (cached && now < cached.expiresAt) {
return new WebhookClient({ id: cached.id, token: cached.token });
}
// 2. Fetch from Discord API
@ -40,14 +43,12 @@ export class WebhookService {
logger.info(`Created new webhook for channel ${channel.id}`);
}
// 3. Save to Redis Cache (expire in 1 day to ensure token freshness)
if (kordWebhook.token) {
await redis.set(
`webhook:${channel.id}`,
JSON.stringify({ id: kordWebhook.id, token: kordWebhook.token }),
'EX',
86400
);
this.webhookCache.set(channel.id, {
id: kordWebhook.id,
token: kordWebhook.token,
expiresAt: Date.now() + this.WEBHOOK_CACHE_TTL_MS,
});
return new WebhookClient({ id: kordWebhook.id, token: kordWebhook.token });
}

View File

@ -1120,13 +1120,6 @@ __metadata:
languageName: node
linkType: hard
"@ioredis/commands@npm:1.5.1":
version: 1.5.1
resolution: "@ioredis/commands@npm:1.5.1"
checksum: 10c0/cb8f6d13cff0753e3e7ef001fb895491985d9a623248192538f13bc2fd9bfdfde3c18cf2ba6f20ec8ceaa681b0771070d3a09b82eed044c798bcfef5e3ae54b3
languageName: node
linkType: hard
"@isaacs/cliui@npm:^8.0.2":
version: 8.0.2
resolution: "@isaacs/cliui@npm:8.0.2"
@ -3017,13 +3010,6 @@ __metadata:
languageName: node
linkType: hard
"cluster-key-slot@npm:^1.1.0":
version: 1.1.2
resolution: "cluster-key-slot@npm:1.1.2"
checksum: 10c0/d7d39ca28a8786e9e801eeb8c770e3c3236a566625d7299a47bb71113fb2298ce1039596acb82590e598c52dbc9b1f088c8f587803e697cb58e1867a95ff94d3
languageName: node
linkType: hard
"co@npm:^4.6.0":
version: 4.6.0
resolution: "co@npm:4.6.0"
@ -4110,23 +4096,6 @@ __metadata:
languageName: node
linkType: hard
"ioredis@npm:^5.10.1":
version: 5.10.1
resolution: "ioredis@npm:5.10.1"
dependencies:
"@ioredis/commands": "npm:1.5.1"
cluster-key-slot: "npm:^1.1.0"
debug: "npm:^4.3.4"
denque: "npm:^2.1.0"
lodash.defaults: "npm:^4.2.0"
lodash.isarguments: "npm:^3.1.0"
redis-errors: "npm:^1.2.0"
redis-parser: "npm:^3.0.0"
standard-as-callback: "npm:^2.1.0"
checksum: 10c0/d0507b52520d3bdd5dacaa33aed9dd3133794d8633b43a6b7fc3199a5e73f92cb77409f6904abe68e3221a95a630d97073b8c1c9e2c0c7613124db67e97c0eb0
languageName: node
linkType: hard
"ip-address@npm:^10.0.1":
version: 10.1.0
resolution: "ip-address@npm:10.1.0"
@ -4808,7 +4777,6 @@ __metadata:
dotenv: "npm:^17.3.1"
eslint: "npm:^10.1.0"
ffmpeg-static: "npm:^5.3.0"
ioredis: "npm:^5.10.1"
jest: "npm:^30.3.0"
pg: "npm:^8.20.0"
prettier: "npm:^3.8.1"
@ -4864,20 +4832,6 @@ __metadata:
languageName: node
linkType: hard
"lodash.defaults@npm:^4.2.0":
version: 4.2.0
resolution: "lodash.defaults@npm:4.2.0"
checksum: 10c0/d5b77aeb702caa69b17be1358faece33a84497bcca814897383c58b28a2f8dfc381b1d9edbec239f8b425126a3bbe4916223da2a576bb0411c2cefd67df80707
languageName: node
linkType: hard
"lodash.isarguments@npm:^3.1.0":
version: 3.1.0
resolution: "lodash.isarguments@npm:3.1.0"
checksum: 10c0/5e8f95ba10975900a3920fb039a3f89a5a79359a1b5565e4e5b4310ed6ebe64011e31d402e34f577eca983a1fc01ff86c926e3cbe602e1ddfc858fdd353e62d8
languageName: node
linkType: hard
"lodash.memoize@npm:^4.1.2":
version: 4.1.2
resolution: "lodash.memoize@npm:4.1.2"
@ -5847,22 +5801,6 @@ __metadata:
languageName: node
linkType: hard
"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0":
version: 1.2.0
resolution: "redis-errors@npm:1.2.0"
checksum: 10c0/5b316736e9f532d91a35bff631335137a4f974927bb2fb42bf8c2f18879173a211787db8ac4c3fde8f75ed6233eb0888e55d52510b5620e30d69d7d719c8b8a7
languageName: node
linkType: hard
"redis-parser@npm:^3.0.0":
version: 3.0.0
resolution: "redis-parser@npm:3.0.0"
dependencies:
redis-errors: "npm:^1.0.0"
checksum: 10c0/ee16ac4c7b2a60b1f42a2cdaee22b005bd4453eb2d0588b8a4939718997ae269da717434da5d570fe0b05030466eeb3f902a58cf2e8e1ca058bf6c9c596f632f
languageName: node
linkType: hard
"remeda@npm:2.33.4":
version: 2.33.4
resolution: "remeda@npm:2.33.4"
@ -6183,13 +6121,6 @@ __metadata:
languageName: node
linkType: hard
"standard-as-callback@npm:^2.1.0":
version: 2.1.0
resolution: "standard-as-callback@npm:2.1.0"
checksum: 10c0/012677236e3d3fdc5689d29e64ea8a599331c4babe86956bf92fc5e127d53f85411c5536ee0079c52c43beb0026b5ce7aa1d834dd35dd026e82a15d1bcaead1f
languageName: node
linkType: hard
"std-env@npm:3.10.0":
version: 3.10.0
resolution: "std-env@npm:3.10.0"