Skip to content

feat(be): implement participant presence in study rooms#3541

Open
westsunh wants to merge 35 commits into
mainfrom
t2640-study-group-2depth-socket
Open

feat(be): implement participant presence in study rooms#3541
westsunh wants to merge 35 commits into
mainfrom
t2640-study-group-2depth-socket

Conversation

@westsunh
Copy link
Copy Markdown

@westsunh westsunh commented Apr 25, 2026

Description

Study Room(Study Group 2depth)에서 사용자의 실시간 출입 상태 처리 기능을 구현합니다.
WebSocket(Socket.IO) + Redis + BullMQ 기반으로 입장/퇴장/재연결/세션 종료를 처리합니다.

Additional context

인프라 설정

  • @libs/redis: Redis 모듈 및 RedisIoAdapter 추가
  • main.ts: RedisIoAdapter 등록 (멀티 서버 환경 대응)
  • JwtAuthGuard: WebSocket 컨텍스트에서 handshake.auth.token으로 JWT 인증 처리
  • study.module.ts: StudyGateway, StudyRoomService, StudyRoomProcessor 등록

구현

입장 (room:join)

  • Redis SET NX로 race condition 방지 — 동시 입장 시 단 한 명만 첫 번째 입장자로 처리
  • 신규 입장 / 재연결 / 중복 탭을 Redis 상태 기반으로 구분
  • 세션 시간은 2시간 고정 -> DB에서 endTime 가져오는 것으로 수정

퇴장

  • 정상 퇴장(room:leave): ack 응답으로 처리, 잔류 인원에게 room:participantsChanged emit
  • 비정상 끊김(handleDisconnect): 30초 유예 후 완전 퇴장 처리
  • 재연결: reconnectKey DEL 반환값으로 원자적 복구 판단 (TOCTOU 방지)

세션 종료 (BullMQ)

  • 종료 10분 전: room:reminder emit
  • 종료 시: room:ended emit → 소켓 강제 퇴장 → Redis 초기화
  • jobId 고정(room-end:{groupId})으로 중복 job 방지

이벤트 목록

방향 이벤트 설명
C→S room:join 룸 입장
C→S room:leave 정상 퇴장
S→C room:started 세션 시작
S→C room:participantsChanged 참여자 목록 변경
S→C room:participantReconnecting 재연결 대기 중
S→C room:participantReconnected 재연결 완료
S→C room:reminder 종료 10분 전 알림
S→C room:ended 세션 종료

Before submitting the PR, please make sure you do the following

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a real-time study room feature using NestJS WebSockets, Redis, and BullMQ for session management. It introduces a StudyRoomService, a StudyGateway, and a global RedisModule. Feedback highlights a critical issue where modifying socket data via fetchSockets() fails to persist across clustered nodes, and identifies a bug where a code property is missing from join validation results. Further improvements are suggested to make the Redis database index configurable, remove the redundant redis dependency, and provide specific error codes for consistent client-side handling.

useFactory: async (config: ConfigService) => {
const host = config.get<string>('REDIS_HOST')
const port = config.get<number>('REDIS_PORT')
const db = 1
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Redis database index is hardcoded to 1. It is better to make this configurable via ConfigService to allow for different environments or shared Redis instances.

Suggested change
const db = 1
const db = config.get<number>('REDIS_DB', 1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이것도 뭐임! app.module.ts도 확인부탁

Copy link
Copy Markdown
Author

@westsunh westsunh May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

admin 단에서 사용하던 Redis 설정을 그대로 가져온 코드였어서 확인해보니

이전에 BullMQ와 Cache 간 Redis DB 충돌로 장애가 발생해서 (#3069) DB를 분리한 이력이 있는데, 해당 설정을 그대로 차용했습니다.

현재 서비스 상 Redis DB 구조는 다음과 같은데

db 0 → CacheModule
db 1 → BullMQ (client: prefix 'bull-client', admin: prefix 'bull')
db 1 → RedisModule (웹소켓 어댑터, 스터디룸 기능)

BullMQ는 client/admin 각각 prefix로 키를 구분하고 있어서 같은 db 1을 사용해도 충돌은 없을 것 같습니다. 근데 충돌이 있을 수도 있어서 분리하는 것이 좋을 것 같기도 합니다.

또한 RedisModule(웹소켓 어댑터 + 스터디룸)은 prefix 없이 db 1을 그대로 사용하고 있어서 다른 db로 분리하는 게 안전할 것 같은데, 이 부분은 제가 임의로 결정하기보다 같이 논의하고 싶은데 어떻게 생각하시나요?

Comment thread apps/backend/package.json
"pino-http": "^10.5.0",
"pino-pretty": "^11.3.0",
"prisma-graphql-type-decimal": "^3.0.1",
"redis": "^5.11.0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The redis package is added as a dependency, but the implementation uses ioredis (line 86). Unless another library specifically requires the redis package, it should be removed to keep dependencies clean.

@skkuding skkuding deleted a comment from gemini-code-assist Bot Apr 25, 2026
@skkuding skkuding deleted a comment from gemini-code-assist Bot Apr 25, 2026
@skkuding skkuding deleted a comment from gemini-code-assist Bot Apr 25, 2026
@nhjbest22
Copy link
Copy Markdown
Contributor

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a real-time study room system using WebSockets, Redis, and BullMQ, including a new Redis library and updates to the authentication guard. Feedback focused on improving robustness and maintainability by implementing safe JSON parsing for Redis data, standardizing user object property access, and making the Redis database index configurable.

Comment thread apps/backend/apps/client/src/study/study-room.service.ts Outdated
Comment thread apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts Outdated
useFactory: async (config: ConfigService) => {
const host = config.get<string>('REDIS_HOST')
const port = config.get<number>('REDIS_PORT')
const db = 1
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Redis database index is hardcoded to '1'. This might conflict with other services or environments that expect to use a different database or the default database '0'. Consider making the database index configurable via environment variables.

Suggested change
const db = 1
const db = config.get<number>('REDIS_DB') ?? 1

Copy link
Copy Markdown
Contributor

@Choi-Jung-Hyeon Choi-Jung-Hyeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저 질문도 몇개 달았는데 그거 진짜 몰라서 물어본 것도 있어요!! 일단 컨플릿 해결 부탁해요!!

)

const sockets = await this.server.in(roomKey(groupId)).fetchSockets()
await Promise.all(sockets.map((s) => s.leave(roomKey(groupId))))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.leave()로 호출하면 같은 서버 인스턴스 소켓만 동작하고 다른 노드 소켓은 room에서 안나가요
disconnectSockets() 사용하는게 나을듯?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

궁금해서 저도 찾아봤는데요.
fetchSockets 메서드를 살펴보면 다음과 같이 나와요

Returns the matching socket instances. This method works across a cluster of several Socket.IO servers.

Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible [Adapter]

저희는 redis-adapter를 사용해서 pub/sub 기능을 구현하니깐 다른 pod들의 소켓까지 leave 처리를 할 수 있는 것 같아요.

Comment thread apps/backend/apps/client/src/study/study-room.service.ts Outdated
Comment thread apps/backend/apps/client/src/study/study.service.ts Outdated
Comment thread apps/backend/apps/client/src/study/study-room.service.ts
Comment thread apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts Outdated
useFactory: async (config: ConfigService) => {
const host = config.get<string>('REDIS_HOST')
const port = config.get<number>('REDIS_PORT')
const db = 1
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이것도 뭐임! app.module.ts도 확인부탁

Comment thread apps/backend/libs/redis/src/redis.module.ts Outdated
cors: {
// TODO 실제 서버 환경에 맞는 'cors' RedisIoAdapter 내부에 설정
// origin: ['ws://localhost:3002/api/room']
origin: true,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 보안 이슈 없을까유?

port: config.get<number>('REDIS_PORT'),
db: 1
},
prefix: 'bull-client'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefix 충돌 안하져?

Comment thread apps/backend/apps/client/src/study/study-room.service.ts
Copy link
Copy Markdown
Contributor

@nhjbest22 nhjbest22 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

너무 많아서 한번에 Review가 힘드네요.

일단 우선적으로 보이는 것 먼저 리뷰 진행했으니 참고해서 수정해주시면 감사하겠습니다.
아마, 별도로 한번 더 리뷰를 진행할 것 같습니다!

Comment thread apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts Outdated
Comment thread apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts Outdated
Comment thread apps/backend/apps/client/src/study/study.gateway.ts Outdated
Comment thread apps/backend/apps/client/src/study/study.gateway.ts Outdated
Comment thread apps/backend/apps/client/src/study/study.gateway.ts Outdated
Comment thread apps/backend/apps/client/src/study/study-room.service.ts Outdated
Comment thread apps/backend/apps/client/src/study/study-room.service.ts
Comment thread apps/backend/apps/client/src/study/study-room.service.ts Outdated
)

const sockets = await this.server.in(roomKey(groupId)).fetchSockets()
await Promise.all(sockets.map((s) => s.leave(roomKey(groupId))))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

궁금해서 저도 찾아봤는데요.
fetchSockets 메서드를 살펴보면 다음과 같이 나와요

Returns the matching socket instances. This method works across a cluster of several Socket.IO servers.

Note: this method also works within a cluster of multiple Socket.IO servers, with a compatible [Adapter]

저희는 redis-adapter를 사용해서 pub/sub 기능을 구현하니깐 다른 pod들의 소켓까지 leave 처리를 할 수 있는 것 같아요.

Comment thread apps/backend/apps/client/src/study/study-room.service.ts Outdated
@nhjbest22
Copy link
Copy Markdown
Contributor

혹시 study-room 내부에 호스트를 별도로 지정한 이유가 있을까요?
이전 notion 회의록에서는 공용 타이머의 임의 조작을 막기 위해서 추가했다고 했는데, 호스트를 별도로 관리하지 않고도 가능할 것 같거든요.

예를 들면 공용 타이머는 그룹 리더만 설정 및 수정이 가능하도록 하면, 호스트 없이도 간단하게 해결될 것 같아요

@nhjbest22
Copy link
Copy Markdown
Contributor

그리고 아직 study-room.service 파일 내부에 타이머를 설정 및 수정하거나 소스 코드를 공유하는 기능은 아직 없는 것 같은데, 별도 PR로 추가될 예정일까요?

@nhjbest22
Copy link
Copy Markdown
Contributor

그리고 마지막으로 고생 많으셨다는 이야기를 드리고 싶어요.
다중 pod 환경을 고려하면서 개발하는게 되게 까다로우셨을 것 같은데, 최선을 다해주신게 눈에 보여서 감사합니당

@westsunh
Copy link
Copy Markdown
Author

혹시 study-room 내부에 호스트를 별도로 지정한 이유가 있을까요? 이전 notion 회의록에서는 공용 타이머의 임의 조작을 막기 위해서 추가했다고 했는데, 호스트를 별도로 관리하지 않고도 가능할 것 같거든요.

예를 들면 공용 타이머는 그룹 리더만 설정 및 수정이 가능하도록 하면, 호스트 없이도 간단하게 해결될 것 같아요

원래는 그룹 리더를 host로 두려고 했는데, 그룹 리더가 room에서 먼저 나가는 상황이 발생할 수 있다고 생각해서 hostUserId를 별도로 관리했습니다.
그룹 리더가 나가더라도 room 자체는 유지될 수 있으니, 남아있는 참여자 중 한 명에게 host를 넘겨 room 내부 제어 흐름을 유지하려는 의도였습니다.

이 부분은 정책에 따라 달라질 것 같은데, 그룹 리더가 나간 이후에도 room 내부 대표 참여자가 필요하다고 보면 hostUserId를 유지하고, 타이머 권한을 그룹 리더로만 제한한다면 제거하는 방향이 좋을 것 같습니다.
어떤 방법이 더 적절하다고 보시나요?

그리고 아직 study-room.service 파일 내부에 타이머를 설정 및 수정하거나 소스 코드를 공유하는 기능은 아직 없는 것 같은데, 별도 PR로 추가될 예정일까요?

네, 맞습니다. 현재 PR에서는 study room의 입장/퇴장/재연결/세션 종료 등 participant presence 처리에 집중했습니다.
챗, 타이머 설정/수정과 코드 공유 기능까지 함께 포함하면 PR 범위가 너무 커질 것 같아, 이 PR이 정리된 이후 별도 PR로 분리해서 작업하려고 합니다.

@nhjbest22

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants