Skip to content

메신저 (Messenger) ​

앱 안의 사내 메신저입니다. 한 컴포넌트가 두 모드로 삽니다 — 직원 앱의 전체화면과 나머지 일곱 앱의 떠 있는 채팅 위젯.

Alpine 컴포넌트 · Web · 진행 중(WIP) · 문서 포맷 시범 적용

Interactive playground ​

옵션을 바꾸면 미리보기와 코드가 함께 바뀌어요.
미리보기 테마
  • 김김간호네, 확인했습니다방금2
  • 재재활치료팀 · 8명오늘 회의 3시로 옮겨요12분 전
  • 박박원무서류 보냈어요1시간 전
껍데기만 옮겼습니다 — 파셜이 1,164줄이고, 문서가 정해야 할 것은 「대화/연락처」 전환의 정체와 hostHeader 가 무엇을 없애는가 둘입니다. 화면 상태를 「채팅 상세」로 바꾸고 hostHeader 를 켜 보세요.
// 위젯 모드 — 앱은 chatWidget 을 쓴다. 안에서 messengerComponent 를 만들어
// **spread 가 아니라 mutate 로** 위젯 속성을 얹는다(getter 를 살리려고).
const component = messengerComponent({ sectionName: "messenger" });
component.isOpen = false;   // ⚠️ { ...component, isOpen: false } 로 하면 getter 가 죽는다

<div x-data="chatWidget()">
  <!-- include "../../packages/components/html/chat-widget.html" -->
</div>

// hostHeader 는 넘기지 않는다(기본 false) — 채팅 상세가 자기 헤더 줄을 그린다

껍데기만 옮겼습니다

파셜이 1,164줄, 컴포넌트가 1,996줄입니다. 문서가 정해야 할 것은 대화 기능이 아니라 모드에 따라 무엇이 달라지는가라서, 서브내비 줄과 채팅 상세 헤더에 집중했습니다.

규칙 (강제) · 코딩 에이전트용

Messenger 강제 규칙. 상세는 아래 본문.

⚠️ 트랩 ① hostHeader 를 위젯에서 켜지 마세요 — 나올 방법이 사라집니다hostHeader: true 는 「호스트가 상단 앱바를 그린다」는 약속입니다. 켜면 채팅·공지 상세가 자기 헤더 줄을 안 그립니다(뒤로가기·제목·검색·멤버). 데스크톱 채팅 위젯에는 상단 헤더가 아예 없어서, 켜는 순간 대화방에서 빠져나올 수 없습니다. 기본값은 반드시 false 입니다.

⚠️ 트랩 ② 컴포넌트를 spread 로 감싸지 마세요 — getter 가 죽습니다ChatWidget은 messengerComponent() 결과를 mutate 해서 위젯 속성을 얹습니다. { ...component, isOpen: false } 로 하면 filteredConversations · departments · chatInviteContacts 같은 getter 가 그 자리에서 한 번 평가돼 고정값이 됩니다. 목록이 갱신되지 않는 조용한 고장이 납니다.

⚠️ 트랩 ③ 「대화 / 연락처」 전환은 SubTab 이 아니라 TabNav 입니다 이름(messengerSubTabs · messengerSubTabActive · switchMessengerSubTab)과 타입(SubTabItem[])이 전부 SubTab 이라고 말하지만, 마크업은 tabNav({ size: 'ml', divider: false }) 를 include 합니다. SubTab 규칙을 적용하려 하지 마세요 — TabNav 쪽입니다.

⚠️ 트랩 ④ 이 컴포넌트의 CSS 는 파셜 안 <style> 에 있습니다design-system.css 도, 앱 main.css 도 아닙니다 — messenger-content.html 첫머리의 <style> 블록입니다(「모든 앱에서 동작하도록 심었다」는 주석). 그래서 DS 로드 체인 밖이고 하드코딩 hex 가 그대로 남아 있습니다(아래).

알림은 DS 다이얼로그를 씁니다askConfirm · showAlert · showError (../dialog). 네이티브 confirm()/alert() 를 되살리지 마세요.

  • 다크 모드(2026-10-19) — 공용 messenger-content.html 의 팔레트 유틸 204곳을 시멘틱으로 옮겼다: 루트 흰 면 bg-white → bg-bg-container(이 한 줄이 직원 앱 메신저 탭 전체를 흰색으로 남겼었다), 대화방 바탕 · 날짜 구분선(bg-line-alternative) · 탭 줄 .messenger-subnav-bar(--ds-bw-white → bg-container) · 아바타 · 공지 배지. 남은 7곳은 보내기 버튼의 브랜드 주황과 주황 말풍선 위 반투명 hover(bg-white/20)로 테마 불변 자리다. 직원 앱 셸이 메신저 탭에서 하단 내비 그라데이션 끝 색으로 넘기던 고정 흰색도 bg-container 로(10-20). 상태 면도 테마를 따른다(10-20): 연결 상태 띠(연결 중 = status-warning-bg/-fg, 끊김 = status-error-bg · status-error), 방장 나가기 경고 원(status-error-bg · -icon), 「나가기」 hover(status-error-bg), 공지 목록 중요도 타일(긴급 = status-error-*, 보통 = status-warning-*), 필수(부서) 방 썸네일(bg-secondary-purple-bg · text-secondary-purple), 공지 상세 「확인 완료」 띠(status-success-*). 연한 팔레트 50단계 면(bg-red-50 등)을 직접 쓰면 다크에서 파스텔 면으로 튄다. 확인: Playground 「미리보기 테마 → 다크」.

두 모드 ​

한 컴포넌트가 두 껍데기 안에서 삽니다.

전체화면떠 있는 위젯
쓰는 앱직원 앱 하나나머지 7개
껍데기앱 셸(app-shell.html)이 직접 includeChatWidget — FAB + 패널
등록 이름messengerShared / 앱의 _messengerchatWidget
sectionName"messenger" (뒤로가기 라우팅)"messenger"
hostHeadertrue — 앱바가 있음false — 앱바가 없음
추가로 하는 일pull-to-refresh · 라우팅FAB · 미확인 배지 · 겹침 방어

hostHeader 가 위험한 이유 ​

위젯에서 켜면 대화방에서 못 빠져나옵니다

hostHeader: true 는 **「뒤로가기·제목·검색·멤버는 호스트 헤더가 맡는다」**는 뜻이라, 채팅·공지 상세가 자기 헤더 줄을 그리지 않습니다.

전체화면(직원 앱)에는 그 앱바가 실제로 있어서 정상입니다. 위젯에는 상단 헤더가 아예 없습니다 — 켜는 순간 대화방에 갇힙니다.

그래서 기본값이 false 이고, 위젯 쪽은 이 옵션을 아예 넘기지 않습니다.

위 Playground 의 모아보기에서 네 번째와 다섯 번째가 같은 옵션인데 한쪽만 고장 난 것을 나란히 볼 수 있습니다.

위젯이 컴포넌트를 감싸는 법 ​

ChatWidget은 상속이나 spread 가 아니라 mutate 로 얹습니다.

ts
const component = messengerComponent({ sectionName: "messenger" });
component.isOpen = false;          // ✅ getter 살아 있음
// { ...component, isOpen: false } // ❌ getter 가 한 번 평가돼 고정값이 된다

spread 는 getter 를 죽입니다

filteredConversations · departments · chatInviteContacts 처럼 매번 다시 계산돼야 하는 값들이 getter 입니다. 스프레드하면 그 자리에서 한 번 평가된 정적 값이 되어, 대화가 와도 목록이 안 바뀝니다. 에러는 안 납니다.

init 도 덮어쓰지 않고 원래 것을 먼저 부른 뒤(baseInit?.call(this)) 위젯 일을 더합니다.

이름은 전부 SubTab 인데 실제로 렌더되는 것은 TabNav 입니다.

보이는 이름실제
messengerSubTabs: SubTabItem[]tabNav({ getTabs: … }) 에 넘어감
messengerSubTabActivegetActive
switchMessengerSubTab(id)onSelect

옵션은 size: 'ml' · divider: false 이고, nav 크롬(마진·배경·하단선)은 파셜 <style> 이 걷어냅니다 — 활성 탭 밑줄만 남기고 「그룹 대화」 버튼과 한 줄에 앉히기 위해서입니다. 줄 자체(.messenger-subnav-bar)가 자기 border-b 를 갖기 때문에 divider: false 입니다.

SubTabItem 과 TabNavItem 이 구조가 비슷해 타입 검사를 통과할 뿐이니, SubTab 규칙을 여기에 적용하지 마세요.

알림 처리 ​

askConfirm · showAlert · showError — DS 다이얼로그입니다. 방 나가기·강퇴·해산처럼 되돌릴 수 없는 동작이 많은 화면이라 확인 팝업이 여럿 있습니다. 네이티브 confirm() 으로 되돌리지 마세요(금지).

화면 ​

activeView 하나가 다섯 화면을 가릅니다.

값화면
conversations대화 목록 (기본)
contacts연락처 — 팀별 묶음
chat채팅 상세
noticeDetail공지 상세
contactProfile연락처 프로필 — 1:1 채팅 · 전화하기 (직원 앱 2depth) → 모바일 대화 화면

대화 표시명 ​

buildConversationItem()(messenger-helpers.ts 의 순수 함수)이 정합니다. 우선순위가 있습니다.

customName > title > (종류별 기본값)
종류기본값보조 텍스트
direct상대방 이름 (없으면 「알 수 없음」)없음
grouptitle 또는 「그룹」N명
departmenttitle 또는 「부서 단톡방」N명

방 이름을 바꾸면(customName) 그 값이 무조건 이깁니다 — 상대 이름이 바뀌어도 표시는 그대로입니다.

미확인 배지 ​

위젯 모드에만 있습니다. 60초 주기 조회로 따라갑니다.

왜 폴링이 필요한가

실시간 구독(WebSocket)은 loadData() 안에서 걸리는데, 그건 패널을 한 번 열어야 돕니다. 한 번도 안 연 사용자는 실시간 증가를 못 받으므로 그동안은 주기 조회가 배지를 맡습니다. NotificationBell과 같은 주기입니다.

숫자는 99 를 넘으면 +99 로 줄입니다.

스타일이 파셜 안에 있습니다 ​

design-system.css 도 앱 main.css 도 아닙니다

messenger-content.html 첫머리의 <style> 블록입니다 — 주석에 **「모든 앱에서 동작하도록 심었다(embedded so they work in every app)」**고 적혀 있습니다. 여덟 앱이 같은 파셜을 include 하므로 결과적으로는 동작하지만, DS 로드 체인 밖이라 토큰 정리에서 빠집니다.

이 블록은 레이어 밖이라 앱 CSS 와 Tailwind 유틸을 «모두 이깁니다». 그래서 여기 값이 있으면 정본이 조용히 밀립니다 — 말풍선이 정확히 그 상태였습니다.

말풍선·입력줄·접속 표시는 2026-09-09 에 빠져나갔습니다

.chat-bubble-* · .status-* 는 하드코딩 hex 6개를 쓰고 있었고, employee-app main.css 의 사본은 이 블록에 밀려 한 번도 그려지지 않았습니다. 지금 정본은 design-system.css 의 .ds-bubble · .ds-composer · .ds-presence 하나입니다 → 말풍선 · 입력줄

여기에 다시 쓰지 마세요. 되살리는 순간 정본이 또 밀립니다.

남아 있는 것은 메신저 레이아웃 전용 규칙들입니다(.messenger-subnav* 등). 배경은 var(--color-bg-container)(다크에서 neutral-800, 2026-10-19)로 토큰화돼 있고, 색 하드코딩은 이제 없습니다.

전체화면 (직원 앱) ​

전체화면에서만 켜지는 모양(캡슐형 입력줄 · 연락처 프로필 · 하단 내비와의 여백)은 → 모바일 대화 화면.

ts
Alpine.data("_messenger", () => messengerComponent({
  sectionName: "messenger",   // 뒤로가기 라우팅용
  hostHeader: true,           // 앱 셸이 상단 앱바를 그린다
}));
html
<div id="section-messenger" x-show="activeSection === 'messenger'" x-data="_messenger()">
  <!-- include "../../packages/components/html/messenger-content.html" -->
</div>

위젯 (나머지 7개 앱) ​

컴포넌트를 직접 만들지 말고 ChatWidget 을 쓰세요.

html
<div x-data="chatWidget()">
  <!-- include "../../packages/components/html/chat-widget.html" -->
</div>

chat-widget.html 이 안에서 messenger-content.html 을 include 합니다. hostHeader 를 넘기지 마세요.

함께 필요한 것 ​

  • hereby 스토어 — 문구(t()), sdk, 날짜·시간 포맷(formatTime · formatDateCustom).
  • DS 다이얼로그 — ds-dialog.html 이 앱에 있어야 확인 팝업이 뜹니다.
  • TabNav 파셜 — 서브내비 줄이 tab-nav.html 을 include 합니다.
  • (직원 앱만) messenger 스토어 — pendingConversationId · setUnreadCount · decrementUnread. 다른 앱은 등록하지 않아도 됩니다.
  • (선택) toast 스토어 — 없으면 안내가 조용히 생략됩니다. 치명적이지 않은 알림에만 씁니다.

API ​

messengerShared(opts) / 내부적으로 messengerComponent(opts).

Prop타입기본값설명
sectionNamestring—뒤로가기 라우팅용 섹션 이름. 위젯 모드에서는 생략 가능
hostHeaderbooleanfalse호스트가 상단 앱바를 그리는가. ⚠️ 위젯에서 켜지 마세요

주요 상태 ​

멤버설명
activeViewconversations · contacts · chat · noticeDetail
useHostHeaderopts.hostHeader === true — 마크업이 이 값으로 헤더 줄을 가림
conversations · contacts · notices목록
totalUnread대화별 미확인 합계 (getter)
messengerSubTabs · messengerSubTabActive⚠️ 이름과 달리 TabNav 에 넘어감

주요 메서드 ​

멤버설명
loadData()목록 로드 + 실시간 구독 시작 (패널을 열어야 돕니다)
openConversation() · loadMessages() · loadOlderMessages()대화 열기 · 메시지 페이징
sendMessage() · sendAttachments() · resendMessage()보내기 · 첨부 · 실패 재전송
searchMessages() · jumpToSearchResult() · scrollToMessage()검색과 이동
createGroupChat() · submitChatInvite() · kickMember() · leaveConversation() · confirmDissolveRoom() · transferOwnershipTo()방 관리
viewNotice() · acknowledgeNotice()공지
timeAgo(d)「방금 · N분 전 · N시간 전 · N일 전」, 7일 넘으면 날짜

순수 헬퍼 ​

messenger-helpers.ts 의 buildConversationItem(c, currentUserId) — 표시명 규칙을 담은 순수 함수입니다. 테스트가 이 함수와 mapMessageItem · updateNoticeReadEnd · filteredConversations 정렬을 고정하고 있습니다.

사용처 ​

방식앱
전체화면employee-app
위젯 (ChatWidget)admin · general-affairs · hr-system · inventory · management-dashboard · module-manager · team-manager

단일 출처 ​

파트파일
Alpine 컴포넌트packages/components/src/components/Messenger.ts
HTML partial (+ 스타일)packages/components/html/messenger-content.html
순수 헬퍼messenger-helpers.ts
위젯 껍데기ChatWidget.ts · chat-widget

검토 필요 사항 ​

  • 스타일이 파셜 안 <style> 에 있습니다 (위). DS 로드 체인 밖이라 토큰 정리에서 빠지고, 하드코딩 hex 6종이 남아 있습니다. 같은 블록에서 배경 하나만 토큰화돼 있어 절반쯤 옮기다 만 상태입니다.
  • 이름이 실제와 다릅니다 — messengerSubTabs · SubTabItem 인데 TabNav 로 렌더됩니다. 타입이 구조적으로 호환돼 검사를 통과할 뿐이라, 읽는 사람이 SubTab 규칙을 적용하기 쉽습니다.
  • hostHeader 를 잘못 켜면 화면이 막히는데 가드가 없습니다. 위젯 껍데기가 이 옵션을 넘기지 않는 것에만 기대고 있습니다 — ChatWidget 쪽에서 강제로 false 로 덮을지 검토가 필요합니다.
  • 미확인 배지가 60초 폴링에 기대고 있습니다 (위). 패널을 한 번도 안 연 사용자에게는 최대 1분 지연이 있습니다.
  • 한 컴포넌트가 1,996줄입니다. 대화·연락처·공지·방 관리·검색·첨부가 한곳에 있어, 순수 로직만 messenger-helpers.ts 로 조금씩 빠져나오는 중입니다.

변경 이력 ​

날짜내용
2026-09-08Messenger 페이지 신규 작성 — 모드 × 화면 상태 × hostHeader Playground + 모아보기 5상태(같은 옵션이 모드에 따라 정상/고장인 것을 나란히). spread↔mutate 함정, SubTab↔TabNav 이름 불일치, 파셜 내장 스타일 포함. 파셜(1,164줄)은 재현하지 않았습니다