아동 알림 프리셋 — 생성·관리 도입 및 개별 ON/OFF 토글 변경
마지막 업데이트 2026-07-22
TL;DR
기존 일반 채팅 프리셋에 {{환경소음}} 토큰으로 하드코딩되어 있던 환경소음 안내 기능을, 독립적인 "아동 알림 프리셋"으로 생성·편집·관리할 수 있는 구조로 전환했다(1부). 운영자는 알림 텍스트·발송 문구·이미지(복수)를 등록하고, 진행자가 세션 중 프리셋을 선택하면 게스트 화면·진행자 미러뷰·모니터 대시보드에 이미지+배너가 표시된다.
이어서 "항상 정확히 1개만 활성"이던 단일활성 구조를 "프리셋마다 개별 ON/OFF"로 바꾸고, 진행자 화면을 전역 ON일 때만 노출하도록 하며, 알림 표시 시 기본 이미지 깜빡임을 제거했다(2부).
아동 알림 프리셋 생성·관리 기능 도입
하드코딩 {{환경소음}} → 프리셋 CRUD 데이터 구조로 전환.
1.1 배경 — 하드코딩 {{환경소음}}의 한계
이전 develop 구현은 환경소음 알림이 코드에 박혀 있었다.
- 안내 문구("주변이 시끄러워서 친구가 잘 못 들을 수 있어요")가 컴포넌트 상수로 고정.
- 표시 이미지가 단일 기본 이미지(
/icon_bgnoise.png)로 고정. - 트리거가 일반 채팅 프리셋 메시지에
{{환경소음}}토큰을 끼워 넣는 방식이라, 안내 문안/이미지를 바꾸려면 코드 수정·배포가 필요했다.
이 PR은 이를 데이터로 빼내어 운영자가 직접 프리셋을 만들고 관리하도록 했다.
| 구분 | BEFORE 하드코딩 토큰 | AFTER 프리셋 구조 |
|---|---|---|
| 안내 문구 | 컴포넌트 상수 고정 | 프리셋별 text 자유 입력 |
| 표시 이미지 | 기본 이미지 1장 고정 | 리소스 이미지 복수 등록 → 표시 시 랜덤 1장 |
| 트리거 | 일반 프리셋 메시지의 {{환경소음}} 토큰 | 진행자 화면의 전용 프리셋 선택 UI |
| 편성 단위 | 없음(전역 고정) | 프리셋 단위 등록·수정·삭제·활성화 |
| 문안 변경 | 코드 수정 + 배포 | 관리 화면에서 즉시 |
레거시 {{환경소음}} 토큰 트리거 경로는 호환성을 위해 남아 있다(use-guest-session-relay.ts). 신규 구조는 그 위에 프리셋 데이터 레이어와 전용 트리거 UI를 얹은 것이다.
1.2 전체 구조 — 데이터 흐름
- 등록 — 운영자가 관리 화면(
/main/message)에서 프리셋 생성: 제목·표시 텍스트·발송 문구·이미지 N장. - 선택 — 진행자가 세션 중 카드뷰 드롭다운 또는 포커스뷰 패널에서 활성 프리셋 클릭.
- 전파 —
pickAndBroadcastAmbientTemplate가 이미지 N장 중 1장을 랜덤 선택, 그 단일 ID만 소켓(HOST_SET_AMBIENT_NOISE_TEMPLATE)으로 게스트에 전파. - 표시 — 게스트는 이미지 ID를 presigned URL로 resolve해 중앙 이미지 + 하단 배너를 표시. 진행자 미러뷰/모니터도 store 구독으로 동일 이미지 표시.
- 동기화 — 활성 프리셋 스냅샷을 Redis(
settings-store)에 저장, 새로 입장하는 게스트와 다른 인스턴스에 cross-instance 전파.
1.3 데이터 모델 · DynamoDB 쿼리
프리셋을 테이블 ppi-ambient-noise-template-{stage}에 저장한다. 각 템플릿 필드:
title— 관리용 제목text— 게스트 화면 표시 텍스트(배너 문구)sendText— 트리거용 발송 문구(AI에게 전달되는 메시지)imageResourceIds— 표시 이미지 리소스 ID 목록(복수)isActive— 활성화 상태
lib/db-queries.ts의 CRUD 함수: getAmbientNoiseTemplates, createAmbientNoiseTemplate, updateAmbientNoiseTemplate, deleteAmbientNoiseTemplate, 활성화 토글. 타입은 types/db/ambient-noise-template.types.ts.
1.4 REST API 엔드포인트
| 경로 | 메서드 | 역할 |
|---|---|---|
/api/ambient-noise-templates | GET / POST | 목록 조회 / 생성 |
/api/ambient-noise-templates/[id] | GET / DELETE | 조회 / 삭제 |
/api/ambient-noise-templates/[id]/update | PATCH | 텍스트·이미지 수정 |
/api/ambient-noise-templates/[id]/activate | POST | 활성화 토글 |
이미지 등록은 /api/resources/upload-url로 발급한 업로드 URL을 사용하며, 리소스 탭의 기존 이미지를 골라 붙이는 방식이다.
1.5 프리셋 관리 화면
components/sections/ambient-noise-templates-section.tsx(신규)는 chat-preset.tsx(/main/message)에 통합된 관리 UI다.
- 폼 — 제목, 표시 텍스트, 발송 문구(선택), 이미지 검색·추가(복수).
- 이미지 — 리소스 탭 기존 이미지를 드롭다운으로 선택해 추가.
- 활성화 — 프리셋별 독립 ON/OFF 스위치 + 전역 ON/OFF 토글.
- 상태 훅:
entities/ambient-noise-template/model/use-ambient-noise-templates.ts(목록·CRUD·낙관적 업데이트).
1.6 진행자 세션 트리거
진행자가 프리셋을 선택하면 pickAndBroadcastAmbientTemplate(entities/ambient-noise-template/model/broadcast-ambient-template.ts)가 실행된다.
- 이미지 목록에서 1장 랜덤 선택.
- 선택 ID 1개만
HOST_SET_AMBIENT_NOISE_TEMPLATE로 게스트에 전파 → 모든 아동이 동일 이미지. - 발송 문구를 채팅 메시지로 전송(스냅샷이 먼저 도착하도록 200ms 지연).
- 진행자 미러뷰 store(
useAmbientNoiseAlertStore.current)에 반영.
카드뷰(features/session/ui/session-controls.tsx 드롭다운)와 포커스뷰(widgets/monitor/ui/child-alert-presets-panel.tsx 버튼 그리드)가 같은 broadcast 함수를 공유한다. 빠른 연속 클릭 시 stale 선택이 최신을 덮어쓰지 않도록 broadcastSeq 시퀀스 토큰으로 가드.
1.7 게스트 화면 표시
components/ui/ambient-noise-alert.tsx가 중앙 이미지 + 하단 배너를 렌더한다.
- 이미지 모션 —
noiseIconBounce 0.8s ease-in-out infinite바운스(scale+rotate). 레거시{{환경소음}}구현의 모션을 신규 구조에 복원(커밋id-010). - 배너 모션 —
slideUp 0.35s+bannerFloat 2.4s infinite. - 깜빡임 방지 — 커스텀 이미지 resolve 대기 중(
hasCustomImage)이면 기본 이미지를 띄우지 않음(2부 6절 참고). - 게스트 수신·resolve 로직:
entities/guest-page-session/model/use-guest-page-session.ts, 상태:stores/use-ambient-noise-alert-store.ts.
1.8 모니터 대시보드 미러뷰
- 포커스뷰(
/monitor-dashboard/[group]/[roomId]) —ChildAlertPresetsPanel활성 프리셋 버튼 그리드. - 카드뷰(
/monitor-dashboard/[group]) — 세션 카드 드롭다운으로 선택. - 미러뷰 —
shared/ui/monitor-media-display.tsx가 store 구독으로 아동 화면과 동일한 이미지+배너 표시.
1.9 소켓 · Redis cross-instance 동기화
- 신규 이벤트
HOST_SET_AMBIENT_NOISE_TEMPLATE(roomId 없음 → 전체 게스트 broadcast). - Redis
settings-store(apps/socket/src/redis/settings-store.ts)에 활성 프리셋 스냅샷 저장 → 새로 입장하는 게스트가 최신 상태 수신. - 소켓 핸들러(
room-handlers.ts,session-handlers.ts)가 Redis 업데이트를 구독해 모든 연결 인스턴스에 전파.
1.10 1부 주요 변경 파일
| 영역 | 파일 |
|---|---|
| 데이터 모델 | types/db/ambient-noise-template.types.ts, lib/db-queries.ts |
| REST API | app/api/ambient-noise-templates/route.ts 및 [id]/(route|update|activate) |
| 관리 UI | components/sections/ambient-noise-templates-section.tsx, components/pages/chat-preset.tsx, entities/ambient-noise-template/model/use-ambient-noise-templates.ts |
| 진행자 트리거 | features/session/ui/session-controls.tsx, entities/ambient-noise-template/model/broadcast-ambient-template.ts |
| 게스트 표시 | components/ui/ambient-noise-alert.tsx, entities/guest-page-session/model/use-guest-page-session.ts, stores/use-ambient-noise-alert-store.ts |
| 모니터 | shared/ui/monitor-media-display.tsx, widgets/monitor/ui/child-alert-presets-panel.tsx, monitor-control-sidebar.tsx |
| 소켓/Redis | apps/socket/src/redis/settings-store.ts, sfu-socket/handlers/(room|session)-handlers.ts, packages/shared/src/utils/constants.ts |
아동 알림 프리셋 개별 ON/OFF 토글 변경
1부 구조 위에서 진행된 후속 변경 — 단일활성 → 다중활성, 전역 OFF 시 화면 숨김, 이미지 깜빡임 제거.
아동 알림 프리셋이 "항상 정확히 1개만 활성"이던 단일활성 구조에서 "프리셋마다 개별 ON/OFF" 가능한 구조로 바뀌었다. 진행자는 여러 프리셋을 동시에 편성(ON)해 두고, 진행자 화면(포커스뷰/카드뷰)에서는 활성 프리셋만 클릭해 아동에게 전송한다.
함께 두 가지를 정리했다 — (1) 진행자 화면의 알림 프리셋 영역을 전역 ON 상태일 때만 노출, (2) 아동/진행자 화면에서 알림 표시 시 기본 이미지가 먼저 떴다가 선택 이미지로 바뀌는 깜빡임을 제거.
2.1 배경 — 왜 바꿨나
기존 구조는 DB에 isActive: true인 프리셋이 항상 정확히 1개가 되도록 강제했다. 첫 등록 시 자동 활성화하고, 활성 프리셋을 삭제하면 남은 첫 프리셋을 자동 활성화하며, 다른 것을 켜면 나머지를 모두 껐다.
- 진행자가 상황별로 여러 안내 프리셋(예: "환경소음2", "환경소음3"…)을 준비해 둬도 동시에 하나만 쓸 수 있었다.
- 전역 OFF 상태에서도 진행자 화면에 프리셋 버튼/드롭다운이 그대로 노출돼 혼란을 줬다.
- 알림 이미지가 여러 장이면 표시할 때마다 랜덤 1장을 골라 presigned URL로 resolve하는데, 그 사이 기본 이미지(
icon_bgnoise.png)가 먼저 깜빡이고 곧 실제 이미지로 교체됐다.
2.2 Before / After — 핵심 동작 변화
| 구분 | BEFORE 단일 활성 | AFTER 개별 토글 |
|---|---|---|
| 활성 개수 | 항상 정확히 1개 | 0개 ~ N개 (자유롭게 ON/OFF) |
| 신규 프리셋 기본값 | 첫 등록 시 자동 활성(ON) | 항상 비활성(OFF) — 진행자가 명시적으로 ON |
| 활성 프리셋 삭제 | 남은 첫 프리셋을 자동 활성화 | 아무것도 자동 활성화하지 않음 |
| 관리 페이지 토글 | 한 개를 켜면 나머지 모두 OFF | 각 프리셋이 독립적으로 ON/OFF |
| 진행자 화면 노출 | 전역 OFF여도 프리셋 버튼/드롭다운 노출 | 전역 ON일 때만 노출, OFF면 숨김 |
| 비활성 프리셋 클릭 | (개념 없음 — 항상 1개만 활성) | 비활성 프리셋은 회색 처리 + 클릭 불가 |
| 알림 이미지 표시 | 기본 이미지 먼저 → 선택 이미지로 교체(깜빡임) | 선택 이미지가 있으면 resolve 전까지 미표시 → 깜빡임 없음 |
전역 ON/OFF는 별개 개념이다. 프리셋별 isActive(편성 여부)와, 모든 수업에 알림 기능 자체를 켜고 끄는 전역 토글(HOST_TOGGLE_AMBIENT_NOISE_ALERT)은 서로 다른 축이다. 이번 변경은 isActive를 다중화하고, 진행자 화면 노출 조건에 전역 토글을 새로 연결한 것이다.
2.3 데이터 레이어 — 단일활성 강제 로직 제거
lib/db-queries.ts에서 "항상 1개 활성" 불변식을 만들던 코드를 걷어내고, 토글 함수로 교체했다.
- 신규 생성 —
createAmbientNoiseTemplate: 첫 등록 자동 활성화를 제거하고 항상isActive: false로 생성. - 삭제 —
deleteAmbientNoiseTemplate: 활성 프리셋 삭제 시 남은 프리셋을 자동 활성화하던 블록 제거. - 활성화 —
setActiveAmbientNoiseTemplate(전체 끄고 하나만 켜기) →toggleAmbientNoiseTemplate(지정 프리셋의isActive만 반전)으로 교체.
// AS-IS: 지정한 것만 활성, 나머지는 모두 비활성 (정확히 1개)
for (const t of templates) {
const shouldBeActive = t.id === id;
if (t.isActive !== shouldBeActive) { /* UpdateCommand ... */ }
}
// TO-BE: 지정한 프리셋의 isActive만 토글
const target = templates.find((t) => t.id === id);
const nextActive = !target.isActive;
// UpdateCommand SET isActive = nextActive
2.4 API · 상태 훅
app/api/ambient-noise-templates/[id]/activate— 핸들러가toggleAmbientNoiseTemplate를 호출하도록 변경(URL 경로는/activate유지).entities/ambient-noise-template/.../use-ambient-noise-templates.ts—activateTemplate→toggleTemplate. 낙관적 업데이트도 "전부 끄고 하나만 켜기"에서 해당 id만 반전으로 변경.
// AS-IS
return prev.map((t) => ({ ...t, isActive: t.id === id }));
// TO-BE
return prev.map((t) => (t.id === id ? { ...t, isActive: !t.isActive } : t));
2.5 진행자 화면 — 노출 조건 & 비활성 클릭 차단
포커스뷰 패널 (widgets/monitor/ui/child-alert-presets-panel.tsx)
- 전역
enabled가 OFF이거나 등록 프리셋이 없으면 섹션 전체를 렌더하지 않음(return null). - 비활성 프리셋 버튼은
disabled(회색 + 클릭 불가), title에· 비활성표기. - 진입 시 미리 전파(prebroadcast)는 활성 프리셋이 있을 때만 수행.
카드뷰 (features/session/ui/session-controls.tsx)
- 같은 알림 프리셋을 띄우는
<select>드롭다운도 전역 OFF면 숨김. - 비활성 프리셋은
<option disabled>+(비활성)표기.triggerAmbientPreset에!t.isActive방어 가드 추가.
포커스뷰와 카드뷰는 pickAndBroadcastAmbientTemplate(entities/ambient-noise-template)를 공유한다. 두 표면의 동작 규칙(전역 OFF 숨김 · 활성만 클릭)을 일치시켰다.
2.6 알림 이미지 깜빡임 제거
원인은 트리거 시점에 imageUrl: undefined로 먼저 상태를 세팅하고 동시에 알림을 표시(visible)했기 때문이다. 알림 컴포넌트는 imageUrl이 없으면 기본 이미지로 폴백하므로, presigned URL이 resolve되기 전 짧은 순간 기본 이미지가 떴다가 선택 이미지로 교체됐다.
해결: hasCustomImage 플래그를 도입해 선택 이미지가 없을 때만 기본 이미지를 표시한다.
// components/ui/ambient-noise-alert.tsx // 선택 이미지가 있으면(hasCustomImage) resolve 전까진 미표시, 없을 때만 기본 이미지 const resolvedImage = imageUrl || (hasCustomImage ? undefined : DEFAULT_IMAGE); // resolvedImage가 없으면 중앙 <img> 자체를 렌더하지 않음
| 상황 | 표시 |
|---|---|
| 선택 이미지 있음 (로딩 중) | 중앙 이미지 없음 — 기본 이미지 깜빡임 제거 |
| 선택 이미지 있음 (resolve 완료) | 랜덤 선택 이미지 |
| 선택 이미지 없음 | 기본 이미지(icon_bgnoise.png) |
| 선택 이미지 resolve 실패 | 기본 이미지로 fallback |
진행자 미러뷰에도 동일 적용
아동 화면뿐 아니라 진행자/모니터 미러뷰(monitor-media-display.tsx, host-video.tsx)도 같은 깜빡임이 있었다. 이쪽은 Zustand store(useAmbientNoiseAlertStore의 current)를 거치므로, CurrentAmbientNoiseAlert 타입과 pickAndBroadcastAmbientTemplate, 3개 렌더 사이트 모두에 hasCustomImage를 전달해 일관되게 제거했다.
연속 트리거 race 가드
알림을 빠르게 연달아 띄울 때, 이전 템플릿의 늦은 URL resolve가 최신 템플릿의 표시 상태를 덮어쓰는 race가 있었다. 게스트 훅(ambientApplySeqRef)과 broadcast 함수(broadcastSeq) 양쪽에 시퀀스 토큰을 두어, .then/.catch가 최신 시퀀스일 때만 상태를 반영하도록 했다.
2.7 2부 주요 변경 파일
| 파일 | 변경 |
|---|---|
lib/db-queries.ts | 신규 기본 비활성, 삭제 자동활성 제거, setActiveAmbientNoiseTemplate → toggleAmbientNoiseTemplate |
app/api/ambient-noise-templates/[id]/activate/route.ts | toggle 호출로 변경 |
entities/ambient-noise-template/model/use-ambient-noise-templates.ts | activateTemplate → toggleTemplate(해당 id만 반전) |
components/sections/ambient-noise-templates-section.tsx | 관리 페이지 토글 UI(applyActive → toggleActive), ON 시에만 broadcast |
widgets/monitor/ui/child-alert-presets-panel.tsx | 전역 OFF 숨김, 비활성 버튼 클릭 불가 |
features/session/ui/session-controls.tsx | 카드뷰 드롭다운 전역 OFF 숨김 + 비활성 option disabled |
components/ui/ambient-noise-alert.tsx | hasCustomImage 프롭, 로딩 중 기본 이미지 미표시 |
entities/guest-page-session/model/use-guest-page-session.ts | 게스트 hasCustomImage 상태 + 시퀀스 가드 |
stores/use-ambient-noise-alert-store.ts | CurrentAmbientNoiseAlert.hasCustomImage 추가 |
entities/ambient-noise-template/model/broadcast-ambient-template.ts | 미러뷰 hasCustomImage 반영 + broadcastSeq 가드 |
shared/ui/monitor-media-display.tsx, components/sections/host-video.tsx, widgets/guest/guest-layout/ui/guest-layout.tsx | 렌더 사이트에 hasCustomImage 전달 |
2.8 알려진 한계 — 후속 논의 필요
다중활성으로 바뀌었지만 소켓/Redis 전파 모델은 여전히 단일 스냅샷(슬롯 1개)이다. 진행자가 클릭하는 시점에 그 프리셋을 게스트에 직접 전파(HOST_SET_AMBIENT_NOISE_TEMPLATE)하므로 클릭 기반 동작은 정상이지만, 다음은 의도적으로 손대지 않았다.
- OFF 시 Redis 스냅샷 미정리 — 현재 broadcast된 프리셋을 OFF로 꺼도 Redis 스냅샷은 남아, 게스트 재접속/레거시
{{환경소음}}토큰 트리거 시 방금 끈 프리셋이 표시될 수 있다. - 다중활성 + 단일 슬롯 — 여러 개가 활성일 때 레거시 토큰 트리거가 띄우는 프리셋은 "마지막 broadcast 우선"이라 비결정적이다.
- toggle 동시성 — read-modify-write라 더블클릭/동시 토글 시 lost update 가능(관리자 UI라 빈도 낮음).
- 전역
enabled동기화 — localStorage 기반이라 같은 브라우저 탭 간만 동기화되고, 다른 기기/진행자에는 실시간 반영되지 않는다(소켓 미연동).
다중활성을 게스트까지 일관되게 반영하려면 소켓 페이로드/Redis를 활성 스냅샷 배열로 확장하는 후속 작업이 필요하다.
관련 문서
아동 알림 프리셋 트리거 race · 이미지 동기화 수정 →스냅샷이 트리거보다 늦게 도착하는 race로 첫 트리거 누락·표시 중 이미지 교체·host/guest 이미지 불일치를 잡은 후속 버그 수정.