Slack을 그대로 쓸 수 없었던 이유 — 제조 도메인 풀스택 채팅 0→1 개발기
Slack을 그대로 쓸 수 없었던 이유 — 제조 도메인 풀스택 채팅 0→1 개발기
2026.04 · MES(Manufacturing Execution System) 모노레포에 신규 백엔드 모듈
mes-chat과 프론트엔드 채팅 콘솔OROGX_CLIENT/app/smartmf/chat을 함께 부트스트랩한 작업의 회고. 서버 측 Kotlin 본문 5,755 LOC + 테스트 3,123 LOC + SQL 1,067 LOC, 클라이언트 측 +5,242 / −2,147 LOC, 두 영역 합쳐 24개 커밋으로 정제했다. 코드 자랑이 아니라, 왜 일반 채팅을 그대로 가져다 쓸 수 없었는지와, 만들면서 마주친 시스템 레벨 함정들을 풀스택 양쪽에서 어떻게 풀어왔는지를 적어 둔다.
목차
- 0. 우리는 왜 또 채팅을 만들었나
- 1. 풀스택 한눈에 보기 — 서버와 클라이언트의 분담
- 2. MES 채팅의 차별화 — 일반 채팅과 무엇이 다른가
- 3. 풀스택 디버깅 일지 — 양쪽이 만나서 풀어낸 문제들
- 3.1 OSIV ↔ 멀티테넌시 정합성 (서버)
- 3.2 메시지 송수신의 멱등성 — clientMessageId 협약 (양쪽)
- 3.3 STOMP silent drop & pending subscribe (클라)
- 3.4 두 토픽 이중 delivery — 서버 fan-out × 클라 LRU dedup
- 3.5 재연결 catch-up — Outbox(서버) × REST snapshot(클라)
- 3.6 Tomcat 11의 숨은 CVE 한도 × 클라 batch 업로드
- 3.7 SSRF 방어(서버) × 인증 프록시 OG 카드(클라)
- 3.8 이모지 리액션 — UNIQUE 제약과 정규화 비대칭
- 3.9 핀 영속화 — 클라 점검 → 서버 협업 → 양쪽 구현
- 3.10 Invitation 2-phase 트랜잭션 (서버)
- 3.11 Presence race condition × 본인 ONLINE 즉시 마킹
- 3.12 Strict Mode 더블 connect & 콘솔 markRead 동작 (클라)
- 4. 시나리오로 따라가는 풀스택 트레이스
- 5. 0→1 부트스트랩의 해부 — 서버 12 + 클라 12 커밋
- 6. 회고와 다음
0. 우리는 왜 또 채팅을 만들었나
채팅은 세상에서 가장 많이 만들어진 애플리케이션이다. Slack, MS Teams, Discord, 그리고 무수한 클론들. 그래서 새로 또 만들었다고 하면 가장 먼저 받는 질문이 “왜 또?”다. 한 줄로 답하면, MES 도메인에는 일반 채팅으로 풀리지 않는 마찰점들이 있고, 그게 누적되면 외부 SaaS를 쓰는 것보다 직접 만드는 쪽이 결국 더 저렴해지기 때문이다.
시나리오 하나로 풀어보자. 야간 11시 30분, 라인 #3이 정지했다. 작업자 A가 모바일로 사진을 찍고 라인장 B에게 알리려 한다.
- 외부 SaaS 채팅을 쓴다면: 작업자가 “BOM-0042의 5번 항목 부품이 안 맞음” 이라고 텍스트를 친다. 라인장은 BOM 코드가 무엇인지 알지만, 그 BOM이 어떤 품목으로 구성되는지는 우리 MDM 시스템에 들어가서 따로 검색해야 한다. 작업자가 BOM 코드를 잘못 적었다면 라인장은 잘못된 BOM을 검색하느라 시간을 더 쓴다. 라인 정지의 비용은 분당 수십만 원이다.
- 자체 채팅을 쓴다면: 작업자가
@BOM-까지 치는 순간 자동완성에 BOM 후보가 뜨고, 정확한 BOM을 클릭으로 선택한다. 메시지에 박힌 멘션 토큰은 라인장에게 클릭 가능한 라이브 링크로 렌더된다. 라인장이 클릭하면 같은 화면 안에서 BOM 상세가 새 탭으로 열린다. 라인장은 즉시 “@VND-0017 대체 부품 가능?” 이라고 답하며 거래처를 멘션한다.
이 차이가 일회성이면 SaaS 도입이 맞다. 그러나 라인 정지는 매일 일어나고 사업장이 100개라면 분당 수십만 원이 누적된다. “일반 채팅에 BOM 코드를 텍스트로 끼워넣는 정도”의 회피책으로는 풀리지 않는다. 클릭했을 때 우리 시스템 으로 진입해야 하기 때문이다.
여기에 더해 도메인이 강제하는 제약이 더 쌓인다.
- 한 사용자가 여러 사업장(테넌트)에 소속되고, 각 사업장의 채팅방은 격리되어야 한다.
- 작업자는 dwg(2D 도면), stp/iges(3D 어셈블리), gcode/qif(CAM/품질) 같은 파일을 공유한다. 200MB도 흔하다. 일반 채팅의 25MB 한도로는 어림없다.
- 외국인 노동자가 같은 라인에서 일하고 해외 공장도 있다. “홍길동 외 3명” 같은 한국어 라벨을 영어 모바일에서 어떻게 보여줄 것인가.
- 라인 정지 같은 운영 사건은 언제 누가 무엇을 요청했는지 가 추적 가능해야 한다. 채팅 자체가 사실상 인시던트 로그가 되는 셈이다. SaaS는 export가 제한된다.
이 글에서 이야기하는 채팅은 두 부분으로 이뤄져 있다. 한쪽은 Spring Boot 기반 신규 마이크로서비스 mes-chat, 다른 한쪽은 SmartMF 콘솔 안에서 동작하는 Next.js 프론트엔드 채팅 모듈이다. 두 영역을 한 사람이 만들면서, 같은 결정이 양쪽에서 어떻게 다른 모양으로 자리잡는지를 자주 보게 됐다. 이 회고는 그 풀스택 시점에서 남긴 기록이다.
1. 풀스택 한눈에 보기 — 서버와 클라이언트의 분담
본격적인 이야기로 들어가기 전에, 서버와 클라이언트가 무엇을 어디까지 책임지는지부터 정리해 둔다.
시스템 아키텍처
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
┌─────────────────────────────────────────────────┐
│ Client (Next.js 16 — OROGX_CLIENT) │
│ Zustand store + @stomp/stompjs + axios │
└───────────────────┬─────────────────────────────┘
│ HTTPS / WS-STOMP
▼
┌─────────────────────────────────────────────────┐
│ Istio Ingress → /api/chat │
└───────────────────┬─────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ mes-chat (Spring Boot, port 8094, gRPC 9090) │
│ REST 12개 + STOMP 3개 컨트롤러 │
│ Service: Room/Message/Reaction/Read/Pin/File/Search/Mention/ │
│ LinkPreview/Presence │
└─────┬────────┬─────────┬────────────┬────────────┬─────────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌────────┐ ┌──────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Postgres│ │Redis │ │ Kafka │ │ gRPC → │ │ HTTP → 외부 │
│ tenant_*│ │ │ │ outbox │ │ mes-mdm │ │ 링크 fetch │
│ schema │ │ │ │ CDC │ │ mes-auth │ │ (SSRF guard) │
└─────────┘ └──────┘ └──────────┘ │ mes-wo │ └──────────────┘
│ mes-order│
└──────────┘
책임 분담 — 어디까지 누가 책임지는가
| 영역 | 서버 (mes-chat) | 클라이언트 (app/smartmf/chat) |
|---|---|---|
| 데이터 진실 | 메시지/방/멤버/리액션/핀의 SoT, Postgres 저장 | 화면 표시용 정규화 store(zustand), persist |
| 실시간 전송 | STOMP broker (4개 토픽 fan-out), outbox/CDC | STOMP 구독, dedup, pending subscribe queue |
| 인증/인가 | JWT 검증, schema-per-tenant 라우팅 | Bearer 헤더 + WS query 인증, ID 어댑터(long↔string) |
| 멘션 검증 | gRPC로 5개 도메인 모듈에 위임, permissive 폴백 | 자동완성 popover(2그룹), 칩 클릭 → 새 탭 라우팅 |
| 파일 | 66 포맷/200MB/배치, SSRF·확장자 차단 | 자체 SVG 파일 아이콘(35+ 확장자), blob URL 생명주기, abort 가드 |
| 링크 미리보기 | 5겹 SSRF 가드 + jsoup OG 파싱 + Redis 3-tier TTL | URL 단위 캐시 × 메시지×URL 단위 dismiss |
| i18n | nameKey + nameArgs 데이터만 송출 | 자기 로케일로 템플릿 포맷 |
| Presence | Redis SET/STRING + TTL, 세션 합집합 판정 | idle/active 신호 발행, 본인 ONLINE 즉시 마킹 |
| 마크다운 | 저장 시 sanitize 없음(원문 보존) | 자체 CommonMark 서브셋 파서 + 멘션 인라인 + sanitizeHref |
숫자로 보는 작업
| 영역 | 항목 | 값 |
|---|---|---|
| 서버 | Kotlin main / test / SQL | 5,755 / 3,123 / 1,067 LOC |
| 서버 | DB 테이블 / REST 컨트롤러 / STOMP 컨트롤러 / 서비스 | 8 / 12 / 3 / 13 |
| 서버 | 멘션 종류 / 파일 포맷 / 카테고리 / 최대 크기 | 8종 / 66개 / 16개 / 200MB |
| 서버 | 의도적 분할 커밋 | 12개 (gitignore + 11 기능) |
| 클라 | Total LOC 변경 | +5,242 / −2,147 |
| 클라 | 변경 파일 / 신규 / 삭제(mock 제거) | 66 / 38 / 2 |
| 클라 | store 단일 파일 / REST 래퍼 / STOMP 클라이언트 | 2,352 / 1,114 / 945 LOC |
| 클라 | 멘션 종류(MES + USER) / 파일 아이콘 메타 | 11종 / 35+ 확장자 |
| 클라 | 의도적 분할 커밋 | 12개 |
서버 12 커밋 + 클라 12 커밋 = 24 커밋. 이 글에서 이야기할 디테일들이 들어가는 자리다.
데이터 흐름 한 줄로
- REST는 “지금 이 순간의 사진” 이다. 페이지 진입, 재연결, 페이지네이션 같은 결정적 시점에 호출한다.
- STOMP는 “그 사진이 찍힌 후 일어난 변화” 다. 메시지 도착, 타이핑, 읽음 포인터 갱신, 방 멤버 변경이 실시간으로 푸시된다.
- Store(클라) 는 둘을 합쳐 화면이 봐야 할 최종 상태로 정규화한다. 뷰는 store만 구독한다.
- Outbox(서버) 는 메시지 INSERT와 같은 트랜잭션 안에 행을 박아 두고, 별도 워커가 Kafka로 반출한다. STOMP가 끊긴 동안의 이벤트가 사라지지 않게 만드는 안전장치다.
이 패턴이 중요한 이유는 STOMP는 durable queue가 아니어서 끊기는 동안의 이벤트가 사라지기 때문이다. 재연결되면 클라이언트는 REST snapshot으로 catch-up 하고, 서버는 outbox로 유실 없이 재발행한다. 어느 한쪽만 갖춰서는 안 되는 양면 가드다.
2. MES 채팅의 차별화 — 일반 채팅과 무엇이 다른가
기능 카탈로그를 늘어놓기보다, “일반 채팅 항목을 그대로 가져왔다면 누가 불편해졌을까” 라는 질문에 다섯 번 답한다.
2.1 도메인 멘션 — 사람만이 아니다, BOM도 멘션한다
일반 채팅에서는 사람(@user), 채널(#general), 이모지(:tada:) 정도가 멘션 대상이다.
MES에서는 위에 더해 도메인 객체 가 멘션 대상이어야 한다. 라인장이 “@WO-2026-0001 진척 어떻게?” 라고 적었을 때, 그 토큰은 단순 텍스트가 아니라 작업지시 상세로 클릭 이동 가능한 라이브 링크 여야 한다.
서버가 정의하는 8종:
| Kind | Prefix 예시 | 검증 위임 모듈 |
|---|---|---|
USER | @홍길동 | mes-auth |
ITEM | @ITEM-0042 | mes-mdm |
EQUIPMENT | @EQP-CNC-01 | mes-mdm |
VENDOR | @VND-0017 | mes-mdm |
ORGANIZATION | @ORG-PRD-2 | mes-mdm |
BOM | @BOM-0001 | mes-mdm |
WORK_ORDER | @WO-2026-0001 | mes-wo |
ORDER | @ORD-2026-001 | mes-order |
서버 측 MentionValidator 는 멘션 토큰을 받아 kind 별 *MentionResolver 로 라우팅하고, 각 resolver는 자기 도메인 모듈에 gRPC로 검증을 위임한다. 외부 모듈 장애로 검증이 일시 실패하면 메시지 송신 자체는 막지 않는 Permissive 폴백 을 두었다. 채팅의 본질은 전달이고, 멘션이 잠시 깨진 토큰으로 남는 편이 메시지가 막히는 것보다 덜 나쁘다고 봤다.
1
2
3
4
5
6
7
8
9
10
11
12
// mes-chat/src/main/kotlin/com/gmin/meschat/mention/MentionValidator.kt
fun validate(targetKind: ChatMentionTargetKind, targetId: String) =
when (targetKind) {
USER -> userMentionResolver.resolve(targetId)
ITEM -> itemMentionResolver.resolve(targetId)
EQUIPMENT -> equipmentMentionResolver.resolve(targetId)
VENDOR -> vendorMentionResolver.resolve(targetId)
ORGANIZATION -> organizationMentionResolver.resolve(targetId)
BOM -> bomMentionResolver.resolve(targetId)
WORK_ORDER -> workOrderMentionResolver.resolve(targetId)
ORDER -> orderMentionResolver.resolve(targetId)
}
검색이 더 어렵다. 사용자가 @BO 까지만 쳤을 때 BOM 후보가 뜨고, @홍 까지 쳤을 때 사람 후보가 떠야 한다. 사용자는 자신이 BOM을 부르는지 사람을 부르는지 매번 종류를 지정하지 않는다. 클라이언트가 원하는 건 키워드 하나로 사람 + 5개 MES 도메인 모두에서 매칭되는 후보를 한꺼번에 받는 것이다.
이를 위해 mes-mdm 에 신규 gRPC 서비스 MdmSearchService 를 추가했다. 5개 도메인을 한 RPC에서 집계하고, 각 repository의 searchForMention 쿼리는 DB 측 4단계 ranking(code exact → code prefix → name prefix → fuzzy)을 적용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// mes-mdm — 모든 도메인 repository에 같은 패턴으로 5번 반복
@Query("""
SELECT i FROM ItemEntity i
WHERE i.isDeleted = false
AND (LOWER(i.itemCode) LIKE LOWER(CONCAT('%', :keyword, '%'))
OR LOWER(i.itemName) LIKE LOWER(CONCAT('%', :keyword, '%')))
ORDER BY
CASE
WHEN LOWER(i.itemCode) = LOWER(:keyword) THEN 0
WHEN LOWER(i.itemCode) LIKE LOWER(CONCAT(:keyword, '%')) THEN 1
WHEN LOWER(i.itemName) LIKE LOWER(CONCAT(:keyword, '%')) THEN 2
ELSE 3
END ASC,
i.itemName ASC
""")
fun searchForMention(keyword: String, pageable: Pageable): List<ItemEntity>
클라이언트는 이걸 두 그룹 popover로 나눠 보여준다. 컴포저에서 @를 입력하면 자동완성 popover가 뜨는데, “사람” 그룹과 “MES” 그룹이 시각적으로 분리되어 렌더된다. 사람 행에는 presence 점 + 부서/직책, MES 행에는 모노스페이스 코드 + 도메인 라벨이 붙는다.
1
2
3
// app/smartmf/chat/components/active/composer/composer-mention-popover.tsx
const peopleGroup = suggestions.filter(({ s }) => s.kind === "member");
const mesGroup = suggestions.filter(({ s }) => s.kind === "mes");
사용자 입장에서는 11종이지만(사람 1 + MES 10), 백엔드 입장에서는 8종(USER + 7개 MES)이다. 차이는 클라이언트가 같은 도메인을 더 세분화해서 뷰에 노출하기 때문이다(예: 사람을 분리, BOM 자식 표시 등). 그러나 토큰 본질은 같은 (kind, code) 페어다.
메시지에 박힌 칩의 클릭 행동도 클라이언트가 결정한다. 사람 칩은 DM을 시작하고, MES 칩은 새 탭으로 해당 도메인 상세를 연다. 굳이 새 탭으로 여는 건 대화의 흐름을 끊지 않기 위해서다. 채팅 콘솔이 닫히지 않은 채로 작업지시 상세를 옆에 띄워 두고, 다시 채팅으로 답장하는 흐름이 만들어진다. 이건 외부 메신저가 만들기 어려운 동선인데, 결국 메신저 바깥의 도메인 페이지가 같은 시스템 안에 존재해야 가능한 일이라 그렇다.
1
2
// 클라이언트 멘션 칩 클릭 핸들러
window.open(`/smartmf/${route}?id=${refId}`, "_blank");
2.2 MDM 코드 기반 식별 — ID가 아니라 사람이 읽는 코드
일반 채팅 의 멘션 토큰 내부 식별자는 user_id 같은 정수(internal id)다. 사람이 읽지 않는다.
MES 에서는 사용자가 직접 코드를 친다. 그리고 그 코드가 식별자가 된다.
이 차이가 왜 중요한지 네 가지로 정리한다.
- 일관성 — MDM 모든 도메인이 이미 code(BOM-0001, ITEM-0042, EQP-001, VND-0017, ORG-PRD-2)를 1차 키처럼 다룬다. 채팅 멘션만 internal id를 쓰면 채팅에 남은 멘션 토큰의 의미와 BOM 모듈의 검색 키가 어긋나게 된다.
- 메시지 사본/검색 — 채팅을 텍스트로 export 했을 때
@BOM-0001은 사람이 그대로 읽지만@bom:7421은 의미를 잃는다. 인시던트 후 grep 시나리오에서는 코드 기반이 압도적이다. - 마이그레이션 친화 — internal id가 바뀌어도 code는 그대로다. 멘션 토큰이 마이그레이션에 종속되지 않는다.
- URL 친화 —
/api/mdm/v1/bom/by-code/BOM-0001같은 라우팅이 자연스럽다.
여기서 한 가지 결정이 필요했다. 처음에는 WORK_ORDER 와 ORDER 만 internal id를 썼다. 이미 GetOrderById, GetWorkOrderById RPC가 있었기 때문이다. 하지만 멘션 8종 중 6종이 code, 2종만 id면 클라이언트는 멘션 kind에 따라 분기 코드를 작성해야 한다. 결국 일관성 쪽으로 기울어 proto에 GetOrderByCode, GetWorkOrderByCode RPC를 추가하고 repository에 findBy*CodeAndIsDeletedFalse 를 새로 만들었다(서버 커밋 2, 3).
비용은 RPC 2개 + repository 메서드 2개 + 코드는 이미 unique 제약이라 인덱스 추가 비용 없음. 얻은 것은 클라이언트의 멘션 kind 분기 코드를 0줄로 줄인 것, 그리고 채팅 메시지의 읽힘성을 보존한 것이다.
1
2
3
4
5
// 클라이언트 — 분기가 사라진 후
function resolveMention(kind: MentionKind, code: string) {
// 모든 kind가 code 기반. 분기 없음
return mesGrpcByCode(kind, code);
}
클라이언트 측 ID 어댑터. 서버가 메시지/방/멤버 PK를 모두 BIGINT(long)로 발급하는데, JavaScript의 Number 는 53비트 안전 정수까지만 표현한다. 단순 JSON.parse 로는 큰 ID가 정확도를 잃을 위험이 있고, persist에 직렬화될 때도 string이 디버깅·로깅이 쾌적하다. 그래서 모든 ID는 클라이언트 도메인에 들어오는 순간 services/chat.ids.ts 의 toClientId / toServerId / toClientMemberId 를 거쳐 정규화한다.
1
2
3
4
5
// services/chat.ids.ts (요지)
export function toClientId(v: number | string): string {
if (v == null) return "";
return typeof v === "string" ? v : String(v);
}
도메인 모델은 모두 string을 쓰고, 서버로 보낼 때만 toServerId로 number로 바꾼다. 작은 결정이긴 하지만, 이걸 잡아 두지 않으면 64비트 ID와 51비트 number의 정밀도 차이가 가끔 디버깅하기 까다로운 형태로 새어 나온다.
2.3 CAD/CAM 파일 — 200MB와 자체 SVG 아이콘 시스템
일반 채팅 은 이미지/PDF/Office 위주에 25MB 한도가 흔하다. .dwg 첨부하면 “지원하지 않는 파일” 이다.
MES 에서는 dwg(2D 도면), stp/step/iges(3D 어셈블리), stl/obj/3mf/ply/gltf/glb(3D mesh), gcode/nc/tap/ngc(CAM/G-code), qif(품질 측정 결과) 같은 제조 전용 파일이 일상적으로 오간다. 200MB CAD 어셈블리도 흔하다.
서버 — 66 포맷 / 16 카테고리 / 200MB
| 카테고리 | 포맷 | 비고 |
|---|---|---|
| Images | jpg/jpeg/png/gif/webp/heic/heif/svg | SVG는 XSS 방어로 강제 attachment disposition |
| Video | mp4/mov/webm/mkv/avi/flv | mkv/avi/flv는 브라우저 native 재생 안 됨 → 다운로드 폴백 |
| Audio | mp3/wav/m4a/ogg | |
| Docs | pdf/xlsx/xls/doc/docx/pptx/ppt/hwp/hwpx/txt/csv/md | hwp/hwpx 한국 사무 문서 |
| Design | psd/ai/sketch | |
| CAD-2D | dwg/dxf | |
| CAD-3D | stp/step/igs/iges | |
| 3D-Mesh | stl/obj/3mf/ply/gltf/glb | |
| CAM/G-code | nc/gcode/tap/ngc | 머시닝 센터 직접 입력용 |
| Quality | qif | Quality Information Framework |
| 그 외 | json/xml/yml, vsd/bpmn, mpp, eps, xsd, zip/7z/rar |
의도적으로 뺀 것들도 있다. 실행 파일/웹 콘텐츠(exe/msi/sh/html/htm/js)는 멀웨어·XSS 표면을 줄이기 위해 처음부터 거부. SVG는 받되 인라인 렌더는 허용하지 않고 Content-Disposition: attachment 를 강제로 붙여 안에 <script> 가 들어 있어도 브라우저가 페이지 컨텍스트에서 실행하지 못하게 한다. ZIP/7z/RAR은 들이되 AV 스캔은 v2.
1
2
3
4
5
6
7
8
9
10
11
12
13
// mes-chat/src/main/kotlin/com/gmin/meschat/service/ChatFileService.kt 발췌
private fun classifyKind(filename: String): ChatAttachmentKind {
val ext = filename.substringAfterLast('.', "").lowercase()
return when (ext) {
in IMAGE_EXTS -> IMAGE
in VIDEO_EXTS -> VIDEO
in AUDIO_EXTS -> AUDIO
in DOC_EXTS, in CAD_2D_EXTS, in CAD_3D_EXTS,
in MESH_EXTS, in CAM_EXTS, in QUALITY_EXTS,
in ARCHIVE_EXTS -> DOCUMENT
else -> OTHER
}
}
클라이언트 — 자체 SVG 파일 아이콘 시스템
서버가 카테고리/MIME을 결정하는 동안, 클라이언트는 그것을 사용자에게 어떻게 시각화할지를 결정한다. lucide의 generic FileText 한 종류로는 제조 현장의 다양한 확장자를 변별할 수 없었다. 그래서 메타데이터 테이블 + 단일 SVG 컴포넌트로 자체 브랜드 아이콘 시스템을 만들었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// lib/file-icon-meta.ts
export interface FileIconMeta {
color: string;
label: string;
textColor?: string;
labelFontSize?: number;
style?: "band" | "centered";
}
const RULES = [
// Microsoft Office
{ exts: ["doc","docx","odt"], meta: { color: "#2B579A", label: "W" } },
{ exts: ["xls","xlsx","csv","ods"], meta: { color: "#1D6F42", label: "X" } },
{ exts: ["ppt","pptx","odp"], meta: { color: "#D04423", label: "P" } },
// Hangul Word Processor — 하늘색 + 'ㅎ' centered 스타일
{ exts: ["hwp","hwpx"], meta: { color: "#00B0F0", label: "ㅎ", style: "centered" } },
// Adobe — 검정 본체 + 브랜드 컬러 라벨
{ exts: ["ai"], meta: { color: "#330000", label: "Ai", textColor: "#FF9A00" } },
{ exts: ["psd"], meta: { color: "#001E36", label: "Ps", textColor: "#31A8FF" } },
// 제조 현장 전용
{ exts: ["dwg","dxf"], meta: { color: "#E51937", label: "CAD" } },
{ exts: ["stp","step","igs","stl",...],meta: { color: "#1E5DA8", label: "3D" } },
{ exts: ["nc","gcode","tap","ngc"], meta: { color: "#1F2937", label: "NC", textColor: "#22D3EE" } },
// ...
];
SVG 자체도 Office/Adobe 공식 아이콘이 공유하는 시각 언어(우상단 코너폴드 + 하단 라벨 밴드)를 따라 단일 컴포넌트로 통합했다. 추가로 다듬은 것들:
- 다크모드 카드 경계 — 안쪽 1px 흰 18% alpha stroke ring을 그려서 어두운 배경에서도 카드 형태가 보이도록.
- 코너폴드 가시성 — 어두운 본체(AI/Ps/EPS/NC)에서 폴드가 안 보였음. 흰 32% alpha 면 + 검정 28% alpha stroke로 모든 본체에서 폴드 윤곽이 명확하도록.
- HWP centered 스타일 — ‘ㅎ’은 라틴 1글자보다 종횡비가 크다. 라벨 밴드 안에 욱여넣으면 위아래로 눌린다. centered 모드는 라벨 밴드 없이 카드 중앙에 큰 ㅎ을 배치한다(이 디테일에 도달한 과정은 3.x 디버깅 일지에서 다시 본다).
이 한 시스템이 Office, Adobe, 한글, CAD, 3D 모델링, NC 코드, 압축, 코드/스키마까지 35종 이상의 확장자를 같은 디자인 언어로 처리한다. 새 확장자가 추가되더라도 메타데이터 한 줄만 등록하면 된다.
서버 batch endpoint × 클라 blob URL 생명주기
여러 첨부를 한 번에 보내기 위해 서버에 명시적인 batch endpoint(POST /api/chat/v1/files/upload/batch, MAX_BATCH_SIZE=20)를 두었다. 서버는 best-effort라 일부만 성공해도 201로 uploaded[], failed[] 를 나눠 반환한다.
클라이언트 측은 그 위에 미리보기 / blob URL 해제 / 진행률 / abort 의 책임을 진다. 사용자가 파일을 드래그하면 URL.createObjectURL 로 미리보기를 만들고, 다음 시점에 해제한다.
- 첨부를 개별 제거하면 → 즉시 revoke
- 컴포저를 비우면(취소) → 모두 revoke
- 메시지로 전송이 성공하면 → 메시지가 서버 URL로 교체된 시점에 revoke
- 페이지 이동/언마운트 → 진행 중 업로드가 있으면
beforeunload가드
이 분기들은 store(addDraftAttachments / removeDraftAttachment / clearDraftAttachments)가 단일 책임으로 떠안고, 컴포저 컴포넌트는 렌더링만 한다. 이 책임 분리가 흐트러지면 React 리렌더 타이밍에 따라 blob URL이 슬슬 새기 시작한다 — 실제로 초반에 그 문제로 한참 헤맸다. 진행률만 store state에 두는 것도 핵심인데, AbortController 를 store에 넣으면 zustand persist가 직렬화를 시도하면서 깨진다. 그래서 모듈 레벨 Map 에 보관한다.
1
2
3
const uploadAborters = new Map<string, AbortController>();
// store state:
uploadProgressByMessageId: Record<string, number>; // 0~100
2.4 i18n-ready — 서버는 데이터, 클라이언트가 표현
처음 구현은 일반 채팅의 표준 패턴을 그대로 따랐다. 그룹 채팅방 이름이 비어 있으면 서버에서 “홍길동 외 3명” 같은 문자열을 만들어 보내는 방식이다.
다국가 운영 시나리오를 검토하다 이 패턴이 깨진다는 걸 알아챘다. “이름 외 N명”은 한국어에만 들어맞는 패턴이다. 외국인 노동자가 같이 일하는 라인이 있고 해외 공장도 있는데, 한국어 PC와 영어 모바일이 같은 채팅방을 동시에 보고 있다고 생각해 보자. 서버가 “홍길동 외 3명”을 통째로 박아서 보내면, 영어 클라이언트는 그 한국어를 그대로 노출하거나 한국어 패턴을 정규식으로 분해해 영어로 다시 조립해야 한다. 어느 쪽도 깨진다.
해법은 단순했다. 서버는 데이터(템플릿 키 + 인자)만 보내고, 표현은 클라이언트가 자기 로케일로 만든다.
서버 측 — nameKey + nameArgs
1
2
3
4
5
6
7
8
9
// mes-chat/src/main/kotlin/com/gmin/meschat/dto/model/ChatRoom.kt
data class ChatRoom(
val roomId: Long,
val roomType: String,
val name: String?, // 사용자 지정 이름 (없으면 null)
val nameKey: String, // CUSTOM/GROUP_AUTO/GROUP_SOLO/DM_PEER/DM_SELF/ME/EMPTY
val nameArgs: Map<String, String>, // primary, othersCount, custom 등
...
)
7개의 nameKey가 모든 케이스를 커버한다.
CUSTOM— 사용자가 직접 지정 →{"custom": "제조2팀"}GROUP_AUTO— 그룹 자동 이름 →{"primary": "홍길동", "othersCount": "3"}GROUP_SOLO— 그룹에 혼자만 남음 →{"primary": "홍길동"}DM_PEER/DM_SELF— DM 상대 / DM인데 viewer 혼자ME/EMPTY— 본인 스크랩 / 멤버 0명 안전장치
서버 측 ChatMapper.resolveNameHint 가 멤버 정보로 7개 키 중 하나를 결정해 내려보낸다.
클라이언트 측 — i18n 리소스 + format
1
2
3
4
5
6
// 클라이언트 i18n 리소스
ko: { "GROUP_AUTO": "{primary} 외 {othersCount}명" }
en: { "GROUP_AUTO": "{primary} and {othersCount} others" }
ja: { "GROUP_AUTO": "{primary} 他{othersCount}名" }
zh: { "GROUP_AUTO": "{primary} 等{othersCount}人" }
vi: { "GROUP_AUTO": "{primary} và {othersCount} người khác" }
새 언어 추가는 서버 변경 없이 클라이언트 i18n 리소스만 추가하면 된다.
같은 패턴이 다른 곳에도 그대로 들어맞는다. 시스템 메시지(“X 님이 입장했습니다”), 알림 본문, 빈 상태 라벨, 사용자에게 보이는 에러 메시지 등 서버가 사용자에게 보일 문자열을 만드는 모든 자리 가 동일하다. 한 곳에서 발견한 결함이 모듈 전체의 규약으로 자리 잡은 사례가 됐다.
2.5 멀티테넌시 — 한 사용자가 여러 사업장에 있다
일반 SaaS 채팅 에서는 워크스페이스 = 회사. 한 사용자가 여러 워크스페이스에 있을 수 있지만 각 워크스페이스는 독립된 계정 으로 관리된다.
MES 에서는 한 사용자(users.id)가 여러 사업장(tenant_<id>)에 멤버 로 들어간다. 각 사업장의 채팅방/메시지는 격리되어야 하고, 사용자가 사업장을 전환하면 그 사업장의 채팅방 목록 만 보여야 한다.
서버 — 스키마-퍼-테넌트 + ThreadLocal TenantContext
Hibernate의 multi-tenancy SCHEMA 모드를 쓴다.
public스키마: 테넌트 간 공유.users,companies,user_companies,tenant_module_subscriptions.tenant_<id>스키마: 사업장 격리.members,items,chat_rooms,chat_messages, …
1
2
3
4
HTTP request → JwtAuthFilter → TenantContext.setTenantId("tenant_dev00001")
→ @Transactional 시작 → Hibernate Session opens with tenantIdentifier
→ SET search_path TO tenant_dev00001 (connection 첫 사용 시)
→ JPA query lands in tenant_dev00001 schema
gRPC 호출 시에는 메타데이터를 통해 tenant를 전파한다. mes-chat 이 mes-mdm 에 멘션 검색 RPC를 보낼 때, 호출 측 TenantContext를 메타데이터로 직렬화하고 수신 측 인터셉터가 다시 ThreadLocal에 주입한다. 이게 안 되면 mes-mdm 입장에서는 “누구의 BOM을 검색해야 하는지” 를 알 수 없다.
클라이언트 — WS query 인증 + 자동 헤더 주입
REST는 axios 인터셉터에서 Authorization: Bearer ..., X-Tenant-ID 헤더를 자동 주입한다. 그런데 WebSocket 핸드셰이크는 다르다. 브라우저 WebSocket API는 Sec-WebSocket-* 외의 임의 헤더를 붙일 수 없다.
해결은 쿼리스트링 인증 이다. 서버 ChatJwtHandshakeInterceptor 가 query에서 token, tenantId 를 읽어 JWT 테넌트와 매칭한다.
1
2
3
4
5
6
7
// services/chat.stomp.ts
function buildBrokerUrl(token: string, tenantId: string): string {
const params = new URLSearchParams({ token, tenantId });
const apiKey = readApiKey();
if (apiKey) params.set("apiKey", apiKey);
return `${resolveGatewayUrl()}?${params.toString()}`;
}
핸드셰이크가 끝나면 STOMP CONNECT 프레임에서 Authorization 을 다시 보내, 서버 측 STOMP 채널 인터셉터가 인증 컨텍스트를 잡을 수 있게 한다. 이중 보호 + 헤더 제약 우회를 동시에 만족시키는 패턴이다.
채팅 차원에서 이 모델이 의미하는 것
- 채팅방 ID는
tenant_<id>스키마의 시퀀스에서 발급되어 다른 테넌트와 절대 겹치지 않는다. - 한 사용자가 사업장 A와 B 모두에 속해 있어도, 각 사업장에서 별도의
members.id를 갖는다. 채팅방 멤버십은members.id기준이라, A의 채팅방에 B의 동일 사용자가 자동 노출되는 일은 일어날 수 없다. - WebSocket handshake 시점에 JWT에서
tenantId를 꺼내 sessionAttribute에 넣는다. 그 세션에서 들어오는 모든 STOMP 프레임은 그 tenant 안에서만 처리된다. - 클라이언트 zustand store는 사업장 전환 시 전체를 reset하고 새 tenant로 다시 hydrate 한다.
이 모델을 정합성 있게 굴러가게 만드는 일이 의외로 까다롭다. OSIV/Hibernate Session/REQUIRES_NEW로 이어지는 일화는 3.1장에서 자세히 다룬다.
3. 풀스택 디버깅 일지 — 양쪽이 만나서 풀어낸 문제들
기능을 만드는 일보다, 만들면서 마주친 시스템 레벨 함정을 풀어낸 경험이 더 오래 기억에 남는다. 12개를 골라 디버깅 일지처럼 정리한다. 서버 측 함정과 클라이언트 측 함정, 그리고 양쪽이 만나서 풀린 함정을 섞어 둔다. 각 항목은 대체로 증상 → 가설 → 진짜 원인 → 해결 → 배운 것 순서로 따라간다.
3.1 OSIV ↔ 멀티테넌시 정합성 (서버)
증상 — 회원가입 후 자동 사업장 생성 흐름에서, tenant 스키마에 members INSERT 가 public 스키마로 가서 실패했다.
1
2
ERROR ... org.postgresql.util.PSQLException:
ERROR: relation "members" does not exist
회원가입 → UserCreated → MdmEventConsumer → companyService.createSampleCompany → Phase 1 (provisioning) → Phase 2 (saveSampleCompanyData on public) → Phase 3 (createInitialMember on tenant) — 마지막 Phase 3에서 터졌다.
가설 1 (TenantContext) — 로그에 [tenant_xxx][createInitialMember] 가 찍혔다. TenantContext는 바뀌어 있었다. 기각.
가설 2 (@Transactional 시점) — Spring AOP 프록시가 트랜잭션을 시작할 때 Hibernate Session을 연다. Session을 여는 시점에 CurrentTenantIdentifierResolver 가 호출되고 우리는 tenant_xxx 를 반환한다. 호출은 정상이었다. 그런데 INSERT가 public 으로 갔다.
진짜 원인 — spring.jpa.open-in-view=true Spring Boot에서 OSIV(Open Session In View)는 기본값이 true다. 이게 켜져 있으면 HTTP 요청이 들어올 때 Hibernate Session이 한 번 열리고, 응답이 끝날 때까지 그 Session이 유지된다. 그 사이의 모든 @Transactional 메서드는 기존 Session에 묻어가는 트랜잭션을 쓴다 — 새 Session이 열리지 않는다.
문제는 Session의 tenantIdentifier 가 Session이 열릴 때 한 번 결정된다는 것이다. 회원가입 요청이 들어올 때 TenantContext는 public 이었으니, Session도 public 으로 잡혀 있다. 메서드 안에서 TenantContext를 tenant_xxx 로 바꿔도 Session의 tenantIdentifier 는 그대로고, SET search_path 는 다시 호출되지 않는다. INSERT가 public 으로 가는 게 당연했던 셈이다.
여기까지 도달하는 데 네 시간을 썼다. Hibernate 디버그 로그를 켜고 tenantIdentifier 가 메서드 호출 사이에 변하지 않는다는 걸 직접 확인한 뒤에야 알았다.
해결 — 3중 방어
1
2
# OSIV disabled: required for multi-tenancy correctness.
spring.jpa.open-in-view=false
OSIV를 끄면 각 @Transactional 마다 새 Session 이 열린다. 새 Session은 그 시점의 TenantContext를 본다. 1차 방어.
1
2
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun createInitialMember(userId: Long, tenantId: String, traceId: String) { ... }
REQUIRES_NEW 는 호출자에게 트랜잭션이 이미 있어도 무시하고 새 트랜잭션 + 새 Session을 연다. 호출자가 public 스키마에 묶여있더라도 이 메서드만은 tenant 스키마로 들어간다. 2차 방어.
1
2
3
4
5
6
7
8
9
10
fun createInitialMember(userId: Long, tenantId: String, traceId: String) {
require(tenantId.isNotBlank() && tenantId != "public") {
"createInitialMember must be invoked against a tenant schema, got tenantId='$tenantId'"
}
val resolved = TenantContext.getTenantId()
check(resolved == tenantId) {
"TenantContext mismatch: expected '$tenantId' but resolver sees '$resolved'."
}
// ...
}
메서드 첫 줄에 fail-fast 가드. 호출 규약을 코드에 못 박는 셈이다. 미래에 누군가 잘못 호출하면 이 require/check가 즉시 터지면서, 조용히 잘못된 schema에 쓰이던 silent bug가 시끄러운 startup-style 에러로 바뀐다. 3차 방어.
배운 것 — 프레임워크의 잘 알려진 기본값이 잘 알려지지 않은 위험을 만들 때가 있다. OSIV의 위험은 multi-tenancy 환경에서만 발현되고, 단일 schema 환경에서는 잘 보이지 않는다. 기본값이 우리 환경에 맞는지를 의심하는 습관, 그리고 프레임워크 동작을 끝까지 따라가는(이 경우 Hibernate Session 생애주기 + multitenancy resolver 호출 시점까지) 끈기가 답이었다.
3.2 메시지 송수신의 멱등성 — clientMessageId 협약 (양쪽)
채팅의 체감 속도 는 거의 전부 한 가지에 달려 있다. 메시지 전송 버튼을 누른 순간 → 화면에 메시지가 보이는 시점까지의 지연이 0이어야 한다. 서버 응답을 기다리는 패턴은 절대 안 된다.
해결은 낙관적 append + 서버 echo로 멱등 치환 이다.
클라이언트 — 즉시 슬라이스 추가, clientMessageId 동봉
1
2
3
4
5
6
7
8
9
10
11
12
// hooks/use-chat-store.ts (sendMessage 요지)
const localId = genLocalId(); // crypto.randomUUID()
const localMsg: ChatMessage = {
id: localId,
clientMessageId: localId, // ← 핵심: 서버에 이 값을 함께 보낸다
status: "sending",
// ... attachments, mentions 포함
};
appendLocal(localMsg);
// REST POST 후, 서버가 같은 clientMessageId 로 echo
// → applyRealtimeEvent(ChatMessageSent)가 이 값을 키로
// 로컬 메시지를 서버 메시지로 멱등 치환
서버 — clientMessageId를 그대로 echo
서버는 메시지를 INSERT 한 뒤 broadcast하는 ChatMessageSent 이벤트 페이로드에 clientMessageId 를 그대로 실어 보낸다. 클라이언트 store는 이 값을 키로 내가 보낸 그 로컬 메시지 를 찾아 서버 메시지로 교체한다. 다른 사람이 보낸 메시지는 clientMessageId 가 없거나 unknown이라 그냥 append 된다.
이 두 줄짜리 협약 덕분에 다음이 모두 보장된다.
- 사용자는 항상 즉시 자기 메시지를 본다.
- 네트워크 실패 시 status=”failed”로 떨어져 재시도/취소 UI가 뜬다.
- REST 응답이 STOMP echo보다 늦게 와도 이중 추가가 일어나지 않는다.
mergeSortedMessages — 슬라이스 보강의 진실
ensureRoomLoaded(REST 페이지 페치)와 STOMP 이벤트(낙관 append)가 동시에 일어나면 같은 방의 메시지 슬라이스에 같은 메시지가 두 번 들어갈 수 있다. STOMP가 REST보다 먼저 도착해서 슬라이스 초기화 전에 메시지가 추가되는 케이스도 있다.
이걸 모두 처리하는 것이 mergeSortedMessages 유틸이다 — id 중복 제거 + createdAt 오름차순. 한 함수가 적어도 4가지 시나리오를 동시에 해결한다.
- REST tail이 빈 슬라이스에 들어옴 → 그냥 b
- STOMP 낙관 append 후 REST tail 도착 → 중복 제거
- STOMP가 REST보다 먼저 도착(신규 방의 첫 메시지) → 그래도 정합 유지
- 사용자가 위로 스크롤해서 older 페이지를 당김 → prepend 후 정렬
서버 측 Outbox — 메시지 유실 0
서버는 DB INSERT + Kafka publish 가 아니라 DB INSERT + outbox INSERT (한 트랜잭션) → 별도 워커가 outbox → Kafka 흐름을 쓴다. Kafka 컨슈머가 잠시 다운돼도 outbox 행이 남아있어 유실이 없고, publish 실패 시에는 DLQ로 라우팅돼 운영자 수동 재시도가 가능하다. clientMessageId 멱등 치환이 클라이언트 측 멱등성을 책임진다면, outbox는 서버 측 멱등성/내구성 을 책임진다. 한쪽만 갖춰서는 안 되는 양면 가드다.
3.3 STOMP silent drop & pending subscribe (클라)
증상 — 활성 방을 보고 있을 때만 unread가 갱신되고, 다른 방에 메시지가 와도 사이드바에 표시되지 않았다.
1차 가설 — 활성 방만 구독했나? 코드 확인 → setActiveRoom 시점에 subscribeRoom 호출. 부트스트랩 시점에 참여 중인 모든 방을 선구독 해야 했다. 그리고 hydrate 후 새 방이 추가될 때마다 diff 구독.
1
2
3
4
5
6
7
8
9
10
11
12
13
// chat-bootstrap.tsx
useEffect(() => {
for (const rid of Object.keys(useChatStore.getState().rooms)) {
chatStomp.subscribeRoom(rid);
}
return useChatStore.subscribe((s, prev) => {
if (s.rooms === prev.rooms) return;
const cur = new Set(Object.keys(s.rooms));
const old = new Set(Object.keys(prev.rooms));
for (const id of cur) if (!old.has(id)) chatStomp.subscribeRoom(id);
for (const id of old) if (!cur.has(id)) chatStomp.unsubscribeRoom(id);
});
}, [user, realConnected]);
여기까진 어렵지 않았다. 그런데 그다음에 다른 증상이 보였다.
2차 증상 — ChatRoomCreated 이벤트 직후 그 방의 후속 메시지가 안 옴.
원인은 STOMP 클라이언트의 silent drop 이었다. Client.subscribe 는 client.connected === false 면 조용히 drop 한다. 에러도 없다. ChatRoomCreated 가 connect resolve보다 먼저 호출되는 경우(React Strict Mode + 빠른 첫 렌더 + 재연결이 겹치면 발생) subscribeRoom 이 호출되지만 SUBSCRIBE 프레임은 전송되지 않는다.
해결 — Pending subscribe queue + onConnect flush.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// services/chat.stomp.ts
subscribeRoom(roomId: string): void {
if (this.roomSubscriptions.has(roomId)) return;
if (this.client?.connected) {
this.doSubscribeRoom(roomId);
} else {
this.pendingRoomSubscribes.add(roomId); // ← 큐에 쌓아 둠
}
}
onConnect: () => {
// 재연결 시 기존 + pending 합집합 구독
const toSubscribe = new Set([...priorRooms, ...this.pendingRoomSubscribes]);
this.pendingRoomSubscribes.clear();
for (const rid of toSubscribe) this.doSubscribeRoom(rid);
}
silent drop은 증상이 없는 버그 라서 발견이 늦었다. 콘솔에 SUBSCRIBE 프레임 디버그 로그를 켜고서야 패턴이 보였다.
3차 증상 — fan-out 순서 역전. 누가 나를 새 방에 초대하면서 곧바로 메시지를 보내면, 메시지가 슬라이스에 못 들어가는 일이 있었다. 방이 아직 store에 없으니 applyRealtimeEvent 가 어디다 append할지 모르는 것이다. 서버는 fan-out 순서를 보장하지 않으므로, 개인 inbox 토픽으로 두 이벤트가 동시 푸시되면 어느 게 먼저 도착할지 모른다.
해결 — ChatMessageSent 처리 시 rooms[roomId] 가 없으면 즉시 rehydrateRooms() + ensureRoomLoaded(roomId) 트리거. 머지 단계에서 자연 동기화된다.
1
2
3
4
5
6
if (event.type === "ChatMessageSent") {
if (!s.rooms[event.roomId]) {
void s.rehydrateRooms().then(() => s.ensureRoomLoaded(event.roomId));
}
// 이미 있으면 슬라이스에 낙관 append, mergeSortedMessages가 dedup
}
3.4 두 토픽 이중 delivery — 서버 fan-out × 클라 LRU dedup
서버는 네 종류의 토픽으로 이벤트를 fan-out한다.
1
2
3
4
/topic/tenants/{tid}/rooms/{roomId} — 방 이벤트 (메시지/반응/핀/룸업데이트…)
/topic/tenants/{tid}/rooms/{roomId}/typing — 타이핑 인디케이터
/topic/tenants/{tid}/presence — 전역 Presence
/topic/tenants/{tid}/members/{memberId} — 개인 inbox (RoomCreated/초대/강퇴 fan-out)
이 중 가장 까다로운 게 마지막 — 개인 inbox 다. 내가 새로 초대받은 방은 아직 내 rooms 목록에 없어서 방 토픽을 구독할 수가 없다. 그러면 ChatRoomCreated 같은 이벤트를 어떻게 받을까? 답은 개인 토픽이다. 로그인 직후 /topic/tenants/{tid}/members/{내memberId} 를 한 번만 구독해 두면, 내 채팅 inbox 자체에 변화가 생기는 모든 이벤트(방 생성, 초대, 강퇴, 그리고 첫 메시지까지)가 이 채널로 들어온다.
서버 SUBSCRIBE 인가가 까다로워서 이 패턴을 안 쓰면 시도조차 거부당한다(rooms/{id} 토픽은 이미 멤버여야 허용). 인가 정책과 fan-out 패턴이 정확히 맞물려 있는 의도된 설계임을 알아채는 데까지 시간이 좀 걸렸다.
증상 — 이 구조의 부수효과로, 가끔 메시지가 두 번 추가되는 일이 있었다. 내가 받은 메시지는 그 방 토픽 과 내 개인 inbox 양쪽으로 fan-out된다. 두 토픽 다 구독 중이면 같은 이벤트가 두 번 온다.
해결 — LRU dedup.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const DEDUP_MAX = 256;
private readonly dedupQueue: string[] = [];
private readonly dedupSet = new Set<string>();
private markSeen(key: string): boolean {
if (this.dedupSet.has(key)) return false;
this.dedupSet.add(key);
this.dedupQueue.push(key);
if (this.dedupQueue.length > DEDUP_MAX) {
const evicted = this.dedupQueue.shift()!;
this.dedupSet.delete(evicted);
}
return true;
}
// 이벤트 핸들러
const key = `${eventType}:${aggregateId}:${createdAt}`;
if (!this.markSeen(key)) return; // 이미 본 이벤트
키를 eventType:aggregateId:createdAt 로 잡은 이유는, 같은 메시지의 다른 이벤트(예: 같은 메시지의 ReactionAdded)는 별개로 취급해야 하기 때문이다. 256-element LRU set으로 제한해서 메모리 누수를 막았다.
3.5 재연결 catch-up — Outbox(서버) × REST snapshot(클라)
증상 — WiFi가 잠깐 끊겼다 돌아오면, 그 사이에 누가 보낸 메시지나 생성한 방이 안 보였다.
원인 — STOMP는 기본 durable queue가 없다. 재연결되어도 그 사이의 이벤트 는 영영 사라진다.
서버 측 — Outbox/CDC
서버는 메시지를 INSERT 할 때 outbox 테이블에도 같은 트랜잭션으로 행을 박는다. 별도 워커가 outbox → Kafka 로 반출하고, Kafka 컨슈머가 STOMP broadcast로 fan-out한다. 이 구조에서는 Kafka 컨슈머가 잠시 다운되더라도 outbox 행이 남아 있어 메시지 자체가 영구히 보관된다. 클라이언트 입장에서 끊긴 동안의 이벤트는 outbox에 누적돼 있다는 뜻이다.
클라이언트 측 — REST snapshot 재조회
1
2
3
4
5
6
7
8
// chat-bootstrap.tsx
const disposeReconnect = chatStomp.onReconnect(() => {
void (async () => {
await refreshPresenceSnapshot(); // /presence 재조회
if (!cancelled) markSelfOnline();
})();
void useChatStore.getState().rehydrateRooms(); // /rooms 재조회
});
firstConnect 플래그로 첫 연결 과 재연결 을 구분해 부트스트랩 시 이미 받은 초기 snapshot과 중복 호출되지 않게 한다(onReconnect 는 재연결에서만 호출).
1
2
3
4
5
6
7
8
9
10
// services/chat.stomp.ts
onConnect: () => {
const toSubscribe = new Set<string>([...priorRooms, ...this.pendingRoomSubscribes]);
this.pendingRoomSubscribes.clear();
for (const rid of toSubscribe) this.doSubscribeRoom(rid);
if (!this.firstConnect) {
for (const h of this.reconnectHandlers) h(); // catch-up 콜백
}
this.firstConnect = false;
}
서버 outbox와 클라이언트 catch-up이 같은 빈자리를 양쪽에서 메우는 구조다. 한쪽만 있으면 결함이 남는다 — 서버 outbox만 있으면 클라이언트 store가 끊긴 시점의 사진을 못 갱신하고, 클라이언트 catch-up만 있으면 서버가 broadcast 하지 못한 이벤트는 영영 사라진다.
3.6 Tomcat 11의 숨은 CVE 한도 × 클라 batch 업로드
증상 (서버) — 클라이언트가 36개 파일을 한 번에 업로드 → 서버 응답:
1
2
500 Internal Server Error
"Failed to parse multipart servlet request"
배치 endpoint가 동작하긴 하는데 36개에서 무너졌다. 1~10개는 멀쩡했다.
가설들 (모두 빗나감) — Spring 멀티파트 한도 → 200MB로 진작 풀어둠. max-request-size → 500MB. 디스크/권한 → 무관.
진짜 원인 — Tomcat 11이 새로 도입한 maxPartCount=10. Spring Boot 4.0.1은 Tomcat 11을 임베드한다. Tomcat 11이 CVE 대응으로 추가한 새 기본값이 있었다.
maxPartCount = 10— 한 multipart 요청에 담길 수 있는 part의 최대 개수. 11번째부터 잘린다.
이걸 모르면 “Spring 한도는 다 풀었는데 왜 안 되지” 의 늪에 빠진다. CHANGELOG를 끝까지 읽지 않으면 보이지 않는다. 결국 Tomcat 디버그 로그(org.apache.tomcat.util.http.fileupload)를 켜고 11번째 파트부터 잘리는 메시지를 직접 본 뒤에야 답이 나왔다.
서버 해결
1
2
3
4
# Tomcat 11.x multipart 보안 기본값 우회
server.tomcat.max-part-count=200
server.tomcat.max-part-header-size=8KB
server.tomcat.max-parameter-count=10000
세 가지를 모두 푼 이유는 이렇다.
max-part-count=200— 배치 한도가 20이지만 미래 마진과 동시성을 함께 고려.max-part-header-size=8KB— 한글 파일명은 multipartContent-Disposition: filename*=UTF-8''...형식으로 들어오는데, 한국어 UTF-8 인코딩이 ASCII 대비 약 3배 부풀어 헤더가 길어진다. 기본 4KB로는 모자랐다.max-parameter-count=10000— multipart의 form field도 카운트되는데, 메타데이터 필드(filenames[],descriptions[])가 배치당 N개 추가된다.
연장선의 결정 — batch endpoint 분리.
처음에는 단건 endpoint(POST /upload)를 클라이언트가 36번 호출하면 되는 줄 알았다. 그런데 클라이언트 통합 테스트에서는 다른 증상이 나왔다 — “Content-Type ‘application/json’ is not supported”. 단건 endpoint는 단일 file part만 받도록 되어 있고, 클라이언트가 multipart로 여러 file part를 한 요청에 담아 보내자 첫 번째만 인식한 뒤, 클라이언트 라이브러리가 그 다음 파트를 fallback으로 JSON 직렬화하려다 저 에러를 낸 것이다. 서버는 두 번째 이후 파일을 조용히 버리고 있었던 셈이다.
이 문제를 풀기 위해 명시적인 batch endpoint를 추가했다.
1
2
3
4
POST /api/chat/v1/files/upload/batch
multipart field name: "files"
MAX_BATCH_SIZE = 20
MAX_ATTACHMENTS_PER_MESSAGE = 20
배치를 단건과 분리하면 (1) 단건은 실수로 여러 파일을 받지 않고 (2) 배치는 한도(20)를 명시적으로 거절할 수 있으며 (3) 클라이언트가 의도를 분명히 표현하게 된다.
클라이언트 측의 짝맞춤 — 클라이언트는 이 batch endpoint를 한 번 호출하면서 진행률을 화면에 표시하고, 사용자가 페이지를 떠나면 beforeunload 가드로 막아 낸다. 한도(20)를 넘는 드롭이 들어오면 컴포저 위에 영구 배너로 드롭 노티스 를 띄워, “X개 파일이 빠졌다”는 사실을 송신 직전까지 잊지 않게 한다.
배운 것 — 메이저 버전 업의 보안 기본값은 항상 의심해야 한다. 잘 알려진 한도(max-file-size)는 다 풀어 봤지만, 잘 알려지지 않은 새 한도가 진짜 범인이었다. 메이저 버전 release notes / CVE 항목을 빠르게 훑어보거나, 요청을 직접 net-capture 해서 어디까지 들어왔는지 를 확인하는 게 가장 확실하다.
3.7 SSRF 방어(서버) × 인증 프록시 OG 카드(클라)
채팅에 링크를 붙이면 서버가 그 URL을 fetch해서 Open Graph 메타를 뽑고 미리보기 카드를 그린다. 이 “서버가 fetch한다” 는 한 줄이 SSRF(Server-Side Request Forgery)의 발동 지점이다.
서버 측 5겹 방어
위협 시나리오는 명확하다.
http://192.168.1.1/admin— 내부 라우터 / 프린터 / NAShttp://169.254.169.254/latest/meta-data/iam/security-credentials/— AWS IMDS, IAM credential 노출http://localhost:8080/actuator/env— 같은 pod / 같은 노드의 다른 서비스 환경변수http://internal-svc.cluster.local/admin— 클러스터 내부 서비스- DNS rebinding: 처음 resolve 시점엔 외부 IP, 검증 통과 후 fetch 시점엔 내부 IP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// mes-chat/src/main/kotlin/com/gmin/meschat/service/linkpreview/SsrfGuard.kt 골격
fun assertSafe(url: URI) {
// 1) 스키마 화이트리스트
val scheme = url.scheme?.lowercase()
require(scheme == "http" || scheme == "https") { "Disallowed scheme: $scheme" }
// 2) 호스트 검증
val host = url.host ?: throw IllegalArgumentException("Missing host")
// 3) DNS resolution → 모든 IP 검증 (DNS rebinding 방지)
val addresses = InetAddress.getAllByName(host)
addresses.forEach { addr ->
require(!addr.isLoopback) { "Loopback IP blocked: $addr" }
require(!addr.isLinkLocalAddress) { "Link-local blocked: $addr" }
require(!addr.isMulticastAddress) { "Multicast blocked: $addr" }
require(!addr.isAnyLocalAddress) { "Any-local blocked: $addr" }
require(!isPrivateCidr(addr)) { "Private CIDR blocked: $addr" }
}
// 4, 5) SafeHttpClient에서 redirect 추적 + 응답 크기 캡 + timeout
}
핵심 포인트.
- DNS resolution 후 IP를 검증한다 — 호스트명 화이트리스트나 블랙리스트는 의미가 없다. DNS는 동적이고, 같은 호스트가 외부 IP에서 내부 IP로 바뀔 수 있다. 검증은 IP 단계에서 해야 한다.
- 모든 IP를 검증한다 —
getAllByName은 round-robin / multi-record DNS의 모든 답을 돌려준다. 한 IP만 통과시키면 다른 IP가 우회 경로가 된다. - redirect도 추적한다 — 첫 요청은 외부 정상 URL이지만 응답이
301 Location: http://169.254.169.254/...일 수 있다.HttpClient.followRedirects(NEVER)로 자동 추적을 끄고 hop 마다 재검증한다. - 응답 크기 캡 + timeout — HTML 5MB, 이미지 10MB. connect 3초 / response 8초 / total 12초.
캐시는 3-tier TTL을 쓴다.
1
2
3
mes.chat.link-preview.cache-ttl-success-days=7 # 성공 7일
mes.chat.link-preview.cache-ttl-negative-hours=24 # 결정적 실패 24시간
mes.chat.link-preview.cache-ttl-error-minutes=60 # 일시적 실패 60분
같은 채팅방에 같은 URL이 100명에게 노출되면 첫 요청만 fetch하고 나머지 99명은 캐시 히트로 끝난다(stampede 방지).
위협 모델은 단위 테스트로 못 박아 두었다 — loopback IPv4/IPv6 blocked, link-local blocked, private 10x/172x/192x blocked, IPv6 ULA blocked, non-http scheme blocked. 미래에 누군가 SsrfGuard를 순진하게 리팩토링하면 이 테스트가 빨갛게 터지면서 같은 함정을 다시 밟지 않게 막아 준다.
클라이언트 측 — 인증 프록시 OG 카드 + 두 차원 dismiss
서버가 OG 메타를 안전하게 추출해 내리면, 클라이언트는 그것을 카드로 그리고 사용자가 닫을 수 있게 만든다. 처음에는 단순했는데 두 가지 디테일에서 막혔다.
디테일 1 — 인증 프록시. og:image URL이 외부 도메인이면 토큰 노출 없이 안전하게 표시해야 한다. 그래서 클라이언트는 OG 이미지를 직접 fetch하지 않고, 서버 프록시를 경유한다. 이미지/favicon은 서버가 받아서 다시 내려주는 흐름.
디테일 2 — 두 차원의 dismiss.
1
2
3
// store
linkPreviewByUrl: Record<string, LinkPreview | "loading" | null>;
dismissedPreviewByMessage: Record<string, Record<string, boolean>>;
- URL 단위 캐시 — 같은 URL이 100번 등장해도 1번만 페치
- dismiss는 messageId × url 단위 — 사용자가 X로 닫으면 그 메시지 안의 그 URL만 카드가 사라짐. 다른 메시지의 같은 URL은 그대로 보임.
이 두 차원이 다르다는 걸 처음에는 못 봤다. URL 단위로 dismiss를 관리했더니 “한 번 닫으면 다시는 안 보임” 같은 비직관적 동작이 났다. 메시지는 사용자가 보고 있는 단위, URL은 페치 단위 — 두 책임을 분리하니 자연스러워졌다.
3.8 이모지 리액션 — UNIQUE 제약과 정규화 비대칭
서버 — soft-delete의 한계
증상 — 같은 메시지에 같은 이모지를 두 번째 추가할 때 duplicate key violates unique constraint. (message_id, member_id, emoji) 가 UNIQUE인데 soft-delete 모델이라 is_deleted=true 인 행이 그대로 남아 있다.
선택지가 셋이었다.
- (A)
(message_id, member_id, emoji, is_deleted)복합 UNIQUE — 인덱스가 비대해지고 soft-delete 행이 영원히 누적 - (B) PostgreSQL 부분 UNIQUE (
WHERE is_deleted = FALSE) — 깔끔 - (C) hard-delete — 가장 단순
(C)를 골랐다. 리액션의 감사 가치가 낮고, 빠른 UX가 핵심이고, 미래에 감사 요구가 생기면 별도 audit log 테이블을 추가하는 편이 깨끗하기 때문이다.
부수 발견 1 — trim 비대칭 (서버)
추가 흐름은 emoji 그대로 INSERT 하고, 제거 흐름은 emoji.trim() 으로 DELETE 했다. 클라이언트가 "👍 " (뒤에 공백) 로 보내면 INSERT는 "👍 " 로 들어가는데 DELETE는 "👍" 를 찾아 매칭이 실패한다. 결과적으로 추가는 되는데 제거가 안 된다.
normalizeEmoji(emoji) 헬퍼를 만들어 입구에서 한 번만 정규화하도록 바꿨다. 작은 코드 한 줄이 시스템 동작을 비대칭하게 만들 수 있다는 점이 이 일화의 핵심이다.
부수 발견 2 — VARCHAR(16)이 ZWJ 결합 이모지에 부족 (서버)
👨👩👧👦 (가족 이모지) 같은 ZWJ(zero-width joiner) 결합 이모지는 시각적으로는 하나지만 내부적으로 여러 코드포인트로 구성된다. UTF-8로 인코딩하면 25바이트에 가깝다. VARCHAR(16)은 4글자 이상으로 결합된 ZWJ 시퀀스를 거부한다. 마이그레이션에서 ALTER COLUMN emoji TYPE VARCHAR(64) 로 늘렸다.
부수 발견 3 — URL 경로 인코딩 깨짐 (양쪽)
당시 API는 이모지가 path-param에 들어가는 형태였다.
1
2
PUT /api/chat/v1/messages/{messageId}/reactions/{emoji}
DELETE /api/chat/v1/messages/{messageId}/reactions/{emoji}
이모지가 path-param에 들어가면 URL 인코딩이 매우 다양하게 들어온다. 클라이언트마다 다르게 인코딩하고, Spring의 디코더가 일관되게 풀지 못한다. 결국 본문(body)으로 옮겼다.
1
2
POST /api/chat/v1/messages/{messageId}/reactions { "emoji": "👍" }
DELETE /api/chat/v1/messages/{messageId}/reactions { "emoji": "👍" }
클라이언트도 path-param 인코딩 분기를 지웠다. 이모지처럼 유니코드, UI 입력, 인덱스 키가 만나는 자리는 항상 의심해 봐야 한다.
3.9 핀 영속화 — 클라 점검 → 서버 협업 → 양쪽 구현
이건 사용자가 점검만 요청한 버그를 클라이언트 → 서버 → 클라이언트 단계로 풀어낸, 이번 작업에서 의외로 기억에 남는 사례다.
Step 1. 사용자 보고. “방 고정 후 새로고침하면 고정이 풀려요. 클라이언트 문제인지만 점검해 주세요.”
Step 2. 클라이언트 점검 (수정 X). 다음 두 가지가 문제임을 진단했다.
persistRoomPatch는name/avatarUrl만 업데이트하고pinnedBy는 무시fromRoom어댑터가pinnedBy: []로 하드코딩 (서버 DTO에서 핀 정보를 못 가져옴)- 따라서 새로고침 후 REST snapshot에는 항상 핀이 비어 있음
진단 결과를 보고. 이건 서버에 핀 영속 엔드포인트가 없어서 생긴 문제이며, 서버 협업이 필요합니다.
Step 3. 서버팀 협업. 다음을 요청.
PUT /api/chat/v1/rooms/{roomId}/pin-setting—{ pinned: boolean }body,ServerRoomDto반환ServerRoomDto.pinned,ServerRoomDto.pinnedAt필드 추가 (호출자 본인 기준)ServerRoomMemberDto.pinned,pinnedAt추가 (전체 멤버 핀, mute-parallel)
핀과 mute는 공통 방 속성 이 아니라 멤버별 속성 이다. 내가 핀한 방이 다른 사람한테는 핀 안 된 채로 보인다(mute-parallel pin). 방 DTO에 members[].pinned(전체 멤버) 와 pinned(호출자 본인) 가 둘 다 있는 이유는 권한이다 — 다른 사람의 핀 정보는 나에게 노출되지 않을 수 있어서, 본인 정보는 별도 필드로 보장된다.
Step 4. 클라이언트 구현.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// services/chat.api.ts
export async function persistRoomPin(
roomId: string, pinned: boolean, meId?: number,
): Promise<ChatRoom> {
const { data } = await api.put<ApiResponse<ServerRoomDto>>(
`${BASE_URL}/rooms/${toServerId(roomId)}/pin-setting`, { pinned });
return fromRoom(unwrap<ServerRoomDto>(data), meId);
}
// fromRoom — mute-parallel pin 시드
const pinnedMemberIds: number[] = [];
for (const m of dto.members ?? []) {
if (m.pinned) pinnedMemberIds.push(toClientMemberId(m.memberId));
}
if (meId != null && dto.pinned && !pinnedMemberIds.includes(meId)) {
pinnedMemberIds.push(meId);
}
return { ..., pinnedBy: pinnedMemberIds, pinnedAt: dto.pinnedAt ?? null };
Step 5. Optimistic + 응답 override.
1
2
3
4
5
6
7
8
9
10
11
12
pinRoom: (roomId) => {
const optimisticAt = new Date().toISOString();
set((s) => ({ rooms: { ...s.rooms, [roomId]: { ...room, pinnedBy, pinnedAt: optimisticAt } } }));
void persistRoomPin(roomId, true, me).then((updated) => {
set((s) => {
const cur = s.rooms[roomId];
if (!cur) return {};
// 서버가 부여한 정확한 pinnedAt 으로 덮어씀
return { rooms: { ...s.rooms, [roomId]: { ...cur, pinnedBy: updated.pinnedBy, pinnedAt: updated.pinnedAt ?? null } } };
});
});
},
Step 6. 정렬 반영. useSortedRooms 에 핀 그룹 안에서 pinnedAt DESC 추가. 옵티미스틱 시각은 클라이언트 now()라 서버 시각과 미세하게 다를 수 있는데, 응답 오면 정확한 값으로 덮어씌워지니 결국 일관된다.
이 사례가 보여주는 건 점검 단계에서 멈출 수 있어야 한다는 점이다. 클라이언트만 고친다고 안 풀리는 문제임을 알았을 때, 바로 서버를 추측해서 mock을 만들지 않고 진단 결과만 사용자에게 보고하고 협업 단계로 넘어갔다. 이렇게 분리해 두면 변경이 부담스러워질 만큼 커진 상황에서도 부분 롤백 여지를 남길 수 있다.
3.10 Invitation 2-phase 트랜잭션 (서버)
문제 — 초대 수락 흐름에는 두 가지 데이터 변경이 섞여 있다.
- public 스키마:
user_companies매핑 INSERT +invitations.status = 'ACCEPTED' - tenant 스키마:
membersINSERT (사용자가 그 사업장의 멤버가 됨)
처음에는 둘을 한 @Transactional 안에 그대로 묶어 두었다. 3.1장의 OSIV 문제와 뿌리가 같다. 한 트랜잭션 = 한 Hibernate Session = 한 schema. 아무리 TenantContext를 바꿔도 같은 Session에서 schema는 안 바뀐다. tenant 쪽 작업이 public 스키마로 가서 “members 테이블이 없다” 에러를 냈다.
1차 시도 — createInitialMember 만 @Transactional(REQUIRES_NEW) 로 분리하면 동작은 한다. 하지만 부분 실패 시 운영 측면에서의 가시성이 떨어졌다.
가령 Phase 1 commit 성공 → Phase 2 실패(tenant DB 일시 장애) 시나리오를 보자.
- Phase 1은 이미 commit.
user_companies에 매핑 행이 박혔고,invitations.status도 ACCEPTED. - Phase 2 실패.
members테이블에는 아무 행이 없다. - 사용자는 “초대를 수락했는데 사업장에 들어가니 Member not found” 라는 미스터리 상황을 만난다.
최종 — 명시적 2-phase + 운영 가시성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
fun acceptInvitation(tokenHeader: String, inviteToken: String): InvitationAcceptResult {
val currentUserId = resolveUserIdFromToken(tokenHeader)
val traceId = getTraceId()
// Phase 1: public 스키마 트랜잭션
val context = invitationTxHelper.acceptForExistingUser(currentUserId, inviteToken)
// Phase 2: tenant 스키마에 Member 생성
createInitialMemberInTenantSchema(context, traceId)
log.info("[${context.tenantId}][$traceId] Invitation accepted: ${context.invitationId}")
return toAcceptResult(context)
}
private fun createInitialMemberInTenantSchema(context: InvitationAcceptContext, traceId: String) {
val previousTenant = TenantContext.getTenantId()
try {
TenantContext.setTenantId(context.tenantId)
companyTxHelper.createInitialMember(context.userId, context.tenantId, traceId)
} catch (e: Exception) {
log.error(
"[${context.tenantId}][$traceId] Failed to create initial member " +
"after invitation accept (userId=${context.userId}, invitationId=${context.invitationId})",
e
)
throw e
} finally {
TenantContext.setTenantId(previousTenant ?: "public")
}
}
세 가지 핵심.
- Phase 1 분리 helper —
InvitationTransactionHelper별도 빈. Spring AOP self-call 제한을 우회하면서 트랜잭션 경계가 코드에서 명시적으로 보이게 된다. - Phase 2 실패 시 ERROR 로깅 + rethrow — Phase 1이 이미 commit 된 상태에서 Phase 2가 실패한 건 진짜 부분 실패다. 운영자가 로그에서 “이 invitationId의 Member가 없음” 을 보고 수동 복구할 수 있어야 한다. 클라이언트에는 500을 그대로 돌려준다. half-applied state를 아닌 척하는 것보다 솔직히 실패로 알리는 편이 낫다.
- TenantContext finally 복원 — 호출자의 컨텍스트를 오염시키지 않는다.
배운 것 — 트랜잭션은 정합성을 위해 합쳐 두는 것이 보통이지만, 어떤 경우에는 부분 실패의 가시성을 위해 일부러 분리해 두어야 한다. 두 schema에 걸친 트랜잭션은 어차피 distributed transaction이고 Saga로 피하는 게 표준이다. 핵심은 단순하다 — 부분 실패가 났을 때 운영자가 그 사실을 알 수 있느냐, 없느냐.
3.11 Presence race condition × 본인 ONLINE 즉시 마킹
서버 — Redis 자료구조와 race
1
2
presence:sessions:{tid}:{memberId} → SET<sessionId>, TTL none
presence:session:{tid}:{memberId}:{sessionId} → STRING(ONLINE|AWAY), TTL 60s
상태 전이 규칙은 onConnect → SADD + SET, onHeartbeat → EXPIRE 60, onIdle/onActive → SET + TTL, onDisconnect → 5초 grace.
증상 — 사용자가 분명히 채팅방에 접속해 있는데 다른 사용자에게는 OFFLINE으로 표시되는 경우가 있었다.
의심 가는 후보가 다섯 개. 솔직히 이 절은 아직 진단이 진행 중인 상태로 두고 있다.
- session-ttl=60s vs heartbeat-interval=30s — 비율이 2배밖에 안 된다. heartbeat 한 번만 누락(네트워크 지연, GC pause)돼도 곧장 만료된다.
- heartbeat가 application-level STOMP 메시지 (
/app/presence/heartbeat) — 클라이언트가 이걸 안 보내면 WebSocket 연결이 멀쩡해도 TTL이 만료된다. 연결 상태와 presence가 사실상 분리되어 있다. computeStatus의 stale cleanup race —members()호출 시점과multiGet()호출 시점 사이에 새 session이 추가되거나 기존 session이 만료되면 결과가 미세하게 어긋난다.PresenceRedisSubscriber의 originInstanceId 중복 가능성 — 인스턴스 ID 생성 방식에 따라 동일 ID가 두 pod에 발급될 가능성이 0이 아니다.- DisconnectGraceScheduler 5초 — 모바일에서 4G ↔ Wi-Fi 전환처럼 7~15초 단위 끊김에는 너무 짧다.
권장 수정 방향: session-ttl 120초로, WS frame heartbeat 도입, computeStatus를 Lua 스크립트로 묶기, instanceId UUID 강제, DisconnectGrace 12~15초.
클라이언트 — 본인 ONLINE 즉시 마킹
여기서 클라이언트가 메우는 짧은 빈자리가 있다. 서버 /presence REST 스냅샷이 Redis write-lag 또는 세션 등록 race로 내 자신 을 OFFLINE으로 반환하는 순간이 짧게 있을 수 있다. 그러면 사용자는 자기 아바타가 잠깐 회색으로 보였다가 초록으로 돌아오는 현상을 본다.
해결은 본인에 한해 클라이언트가 권위 있는 값을 가짐.
1
2
3
4
5
// chat-bootstrap.tsx
function markSelfOnline(): void {
const { currentMemberId, setMemberStatus } = useChatStore.getState();
if (currentMemberId != null) setMemberStatus(currentMemberId, "ONLINE");
}
이후 서버 ChatPresence 이벤트가 AWAY/OFFLINE을 push하면 자연스럽게 덮어쓰여진다. 서버를 신뢰하되, 서버가 자기 자신에 대해 거짓말할 수 있는 짧은 순간만 클라이언트가 책임진다 — 이 균형이 정확하다.
세션 단위 집계는 다중 탭/다중 디바이스 동시 사용에 강하다. 한 탭에서 자리를 비우고 다른 탭에서 작업하고 있으면 ONLINE으로 정확히 잡힌다. 이 정도의 정확도가 “지금 물어볼 수 있는 사람인가” 를 짧은 시간에 판단해야 하는 현장에서 의외로 자주 의미를 갖는다.
클라 측 신호 발행 — usePresenceActivity
1
2
3
4
5
6
7
8
// hooks/use-presence-activity.ts
const IDLE_THRESHOLD_MS = 5 * 60 * 1000; // 5분 무입력
const USER_EVENTS = ["mousemove","mousedown","keydown","wheel","touchstart"];
document.addEventListener("visibilitychange", () => {
if (hidden) goIdle();
else { goActive(); armIdleTimer(); }
});
서버가 ONLINE/AWAY 판정을 하므로 클라이언트는 신호만 보내고 상태 저장은 안 한다. 30초 heartbeat은 chat.stomp 가 독립 주기로 처리해서, 이 훅은 그 부분에 관여하지 않는 느슨한 결합 구조다.
배운 것 — 분산 presence는 정확함과 가용성의 트레이드오프를 명시적으로 결정해야 한다. 항상 정확한 presence는 매 프레임마다 round-trip을 요구하니 비싸고, 현실적인 답은 대체로 정확한 쪽이다. 그 “대체로”의 경계를 어디로 잡을지가 설계자의 선택이다.
3.12 Strict Mode 더블 connect & 콘솔 markRead 동작 (클라)
Strict Mode 더블 마운트. 개발 모드에서 콘솔에 [chat.stomp] activating client 로그가 두 번 찍혔다. React 18 Strict Mode가 useEffect를 두 번 실행 하기 때문이다.
해결은 didConnect ref로 두 번째 호출 가드.
1
2
3
4
5
6
7
8
9
// chat-bootstrap.tsx
const didConnect = useRef(false);
if (didConnect.current) {
setRealConnected(chatStomp.connected);
return () => { /* 재구독 cleanup만 */ };
}
didConnect.current = true;
chatStomp.connect()...
cleanup은 두 번째 unmount(실제 정리 시점)에서만 disconnect 를 호출하도록 분리. 이게 없으면 개발 모드에서 연결 → 끊김 → 재연결 이 무한 반복되어 디버깅 자체가 불가능했다.
콘솔 닫힘/재오픈 시 markRead. 채팅 콘솔은 항상 떠 있는 게 아니라 사이드바에서 토글한다. 그러면 다음 시나리오가 까다로워진다.
| 시점 | 콘솔 상태 | 활성 방 | 새 메시지 | 기대 동작 |
|---|---|---|---|---|
| ① | 열림 | 있음 | 활성 방으로 도착 | 자동 markRead, unread 0 |
| ② | 열림 | 있음 | 다른 방으로 도착 | unread 카운트 +1, 토스트 |
| ③ | 닫힘 | 있음 | 활성 방으로 도착 | unread 카운트 +1, 토스트 (자동 markRead 금지) |
| ④ | 닫힘 → 열림 | 있음 | (없음) | 재오픈 시점에 활성 방 markRead |
③: 콘솔이 닫혀 있는데 바로 markRead 되면 사용자가 메시지를 본 적이 없는데 읽음 처리됨. 안 된다. ④: 사용자가 콘솔을 다시 열었을 때, 활성 방의 누적된 unread는 바로 정리 되어야 한다.
해결은 두 부분.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1) bumpUnread / 자동 markRead 가드
if (!s.isOpen || s.activeRoomId !== roomId) {
// 콘솔 닫힘 또는 다른 방 활성 → unread +1
}
// 2) open() 액션이 활성 방 markRead 트리거
open: () => {
set({ isOpen: true });
const { activeRoomId, messages, markRead, ensureRoomLoaded } = get();
if (activeRoomId == null) return;
const msgs = messages[activeRoomId] ?? [];
const last = msgs[msgs.length - 1];
if (last) { markRead(activeRoomId, last.id); return; }
// 슬라이스가 비어 있으면 fallback
void ensureRoomLoaded(activeRoomId).then(() => {
const s = get();
if (!s.isOpen || s.activeRoomId !== activeRoomId) return; // race-condition 가드
const m = s.messages[activeRoomId] ?? [];
const l = m[m.length - 1];
if (l) s.markRead(activeRoomId, l.id);
});
}
ensureRoomLoaded resolve 직전에 콘솔이 다시 닫히거나 다른 방으로 전환되는 경우를 막아주는 race-condition 가드(!s.isOpen || s.activeRoomId !== activeRoomId)가 핵심이다. 이걸 빼면 닫힌 콘솔이 뒤늦게 markRead를 부르는 같은 버그가 다시 살아난다. 기능 자체는 작은데, 빠지면 곧바로 “왜 이런 게 안 되지?” 싶어지는 종류의 동작이라 시간을 들여 다듬은 부분이다.
이 케이스는 요구사항이 발견되는 순서가 구현이 진행되는 순서와 일치하지 않음을 보여주기도 한다. 첫 구현은 일반 메신저의 기본 기대, 두 번째는 우리 콘솔의 토글 특성에 맞춘 보정. 두 번째 요구가 나오기 전엔 첫 번째가 옳았다.
4. 시나리오로 따라가는 풀스택 트레이스
여기까지가 코드와 도식 위주였다면, 이 장에서는 실제 사용자가 어떻게 쓰는지를 풀스택 트레이스로 따라간다.
Scenario A — 야간 라인 정지: BOM 멘션과 사진
11:30 PM, 라인 #3 정지. 작업자 A가 모바일로 사진을 찍는다. 같은 채팅방의 라인장 B에게 보낼 메시지를 친다.
1
@BOM-0042 5번 항목 부품이 안 맞음
클라이언트가 하는 일
- 컴포저에서
@BOM-까지 친 순간 →useMentionAutocomplete훅이searchChatMentions(keyword)호출 (300ms debounce) - 서버 응답에서 BOM-0042가 1순위로 옴 (DB측 priority ranking — code prefix=1)
- 클릭하면 텍스트 영역에 라이브 토큰이 박힘
- 사진 첨부 →
URL.createObjectURL로 미리보기, batch 업로드 endpoint 호출 - 전송 →
genLocalId()로 clientMessageId 발급, store에 즉시 append (status=”sending”) POST /api/chat/v1/messages호출
서버에서 일어나는 일
MessageController.sendMessage진입, JwtAuthFilter가 TenantContext 설정MentionValidator가 BOM 토큰을BomMentionResolver로 라우팅 → mes-mdm gRPC 호출 → 검증 OKchat_messagesINSERT (tenant_xxx schema)chat_message_attachmentsINSERT (kind=IMAGE, mime_type=image/jpeg)chat_message_mentionsINSERT (target_kind=BOM, target_id=’BOM-0042’)chat_outbox_eventsINSERT (한 트랜잭션 안)- 응답 200 + ChatMessage DTO 반환 (clientMessageId 포함)
- 별도 Outbox 워커 → Kafka publish → STOMP broadcast
/topic/tenants/{tid}/rooms/{id}로 ChatMessage DTO 푸시
클라이언트가 받는 echo
- REST 응답: store가 localId를 서버 ID로 멱등 치환 (status=”sent”)
- STOMP echo:
applyRealtimeEvent(ChatMessageSent)가 같은 clientMessageId를 보고 이미 처리됨 판정 - LRU dedup: 같은 이벤트가 개인 inbox 토픽으로 또 와도 두 번째는 떨어뜨림
라인장 B의 화면
- STOMP 푸시 → store에 새 메시지 append, mergeSortedMessages로 정렬
- 멘션 토큰
@BOM-0042가 클릭 가능한 칩으로 렌더 (mention-entity-chip.tsx) - 사이드바 unread +1, 활성 방이 아니면 사이드바 dot 켜짐
- 라인장이 OS 알림 권한 허용해 둔 상태 →
lib/browser-notification이 OS 알림 발송
라인장이 메시지를 읽기 위해 콘솔 → 해당 방으로 진입하면, 클라이언트는 자동으로 markRead 호출. 서버는 chat_room_members.last_read_message_id 갱신 + STOMP /topic/tenants/{tid}/rooms/{id} 로 ChatReadAdvanced broadcast → 다른 멤버 화면의 읽음 표시 가 갱신된다.
라인장이 BOM 칩을 클릭 → window.open('/smartmf/bom?id=BOM-0042', '_blank') → 새 탭에서 BOM 상세. “이 BOM의 5번 항목” 이 어떤 부품인지 즉시 확인. 채팅방으로 돌아와 답신:
1
@VND-0017 대체 부품 가능?
같은 흐름이 한 번 더 반복된다.
Mention unread 별도 트래킹
라인장이 다음 날 “내가 멘션된 채팅방” 을 빠르게 보려면 일반 unread와 mention unread가 구분돼 있어야 한다.
- 서버:
chat_room_members.unread_mention_count컬럼,last_mentioned_at컬럼.chat/V6__mention_unread_lookup_index.sql에서 인덱스 추가. - 클라이언트: 채팅방 목록 정렬 시 mention unread 우선 표시. 일반 unread와 다른 색/모양의 배지.
Scenario B — 품질 이슈: CAD 도면 + 측정 결과
오전 10시, QM(품질) 담당이 인스펙션에서 불량 발견. 측정값과 함께 채팅방에 공유한다.
첨부 파일:
bracket_v3.dwg(15MB) — 2D 도면bracket_assembly.stp(87MB) — 3D 어셈블리inspection_2026-04-15.qif(2MB) — 품질 측정 결과defect_photo.jpg(3MB) — 결함 사진
일반 채팅이었다면 25MB와 포맷 제한에 걸려 거의 다 막힌다. mes-chat은 200MB / 66 포맷 지원이라 전부 한 번에 들어간다.
클라이언트 — batch 호출 + 진행률
1
2
3
4
5
6
7
8
9
// 컴포저에서 4개 파일 드래그
const aborter = new AbortController();
uploadAborters.set(messageId, aborter);
const fd = new FormData();
files.forEach(f => fd.append("files", f));
await axios.post('/api/chat/v1/files/upload/batch', fd, {
signal: aborter.signal,
onUploadProgress: (p) => store.setProgress(messageId, p.loaded / p.total * 100)
});
각 파일은 클라이언트가 자체 SVG 아이콘으로 표시한다. dwg → CAD(빨강), stp → 3D(파랑), qif → Quality(틸), jpg → 이미지 썸네일. 사용자는 다운로드 받기 전에 어떤 파일이 들어왔는지 한눈에 분간한다.
서버 응답
1
2
3
4
5
6
7
8
9
{
"uploaded": [
{"attachmentId": 4501, "kind": "DOCUMENT", "filename": "bracket_v3.dwg", "url": "...", "sizeBytes": 15728640, ...},
{"attachmentId": 4502, "kind": "DOCUMENT", "filename": "bracket_assembly.stp", "url": "...", "sizeBytes": 91234560, ...},
{"attachmentId": 4503, "kind": "DOCUMENT", "filename": "inspection_2026-04-15.qif", ...},
{"attachmentId": 4504, "kind": "IMAGE", "filename": "defect_photo.jpg", ...}
],
"failed": []
}
외부 ERP URL과 SSRF 방어
QM 담당이 외부 ERP URL을 함께 붙임:
1
참고: https://erp.example.com/quality/inspection/2026-04-15
서버가 백그라운드에서 OG 메타 fetch:
SsrfGuard.assertSafe(url)— DNS resolve, 모든 IP가 public인지 확인- SafeHttpClient로 fetch, redirect 매 hop 재검증
- jsoup으로 OG 파싱
- Redis에 7일 TTL로 캐시
- STOMP broadcast
ChatLinkPreviewReady
클라이언트는 이 이벤트를 받아 메시지 아래에 카드 렌더. URL 단위로 캐시되니 같은 URL이 다른 메시지에 또 등장해도 1회 fetch로 끝난다.
Scenario C — 다국가 운영: 같은 채팅방, 다른 로케일
같은 그룹 채팅방을 한국어 PC와 영어 모바일과 베트남어 모바일이 동시에 보고 있다.
서버가 모두에게 같은 ChatRoom DTO를 보낸다:
1
2
3
4
5
6
7
{
"roomId": 5,
"roomType": "GROUP",
"name": null,
"nameKey": "GROUP_AUTO",
"nameArgs": {"primary": "홍길동", "othersCount": "3"}
}
각 클라이언트가 자기 로케일로 포맷:
1
2
3
4
5
6
ko: i18n.t("chat.roomName.GROUP_AUTO", {primary: "홍길동", othersCount: "3"})
// → "홍길동 외 3명"
en: i18n.t("chat.roomName.GROUP_AUTO", {primary: "Hong Gildong", othersCount: "3"})
// → "Hong Gildong and 3 others"
vi: i18n.t("chat.roomName.GROUP_AUTO", {primary: "Hong Gildong", othersCount: "3"})
// → "Hong Gildong và 3 người khác"
새 언어 추가는 서버 변경 없이 클라이언트 i18n 리소스만 추가하면 끝. 시스템 메시지(MEMBER_JOINED, MEMBER_LEFT) 도 같은 패턴으로, 서버가 systemMeta {key, args} 만 내려보내고 클라이언트가 자기 언어로 풀어 표시한다.
Scenario D — 운영자(IT) 관점: observability + 사고 격리
Prometheus 대시보드 (사내 Grafana):
chat_link_preview_cache_hit_total/chat_link_preview_cache_miss_total— 캐시 hit ratio. 일반적으로 80%+ 가 정상.chat_link_preview_fetch_outcome_total{outcome="ssrf_block"}— 갑자기 튀면 공격 시도. 알람 셋업.chat_link_preview_fetch_duration_seconds— p99 가 12초 timeout 한도에 닿으면 외부 도메인 이슈.chat_websocket_active_sessions{tenant=""}— 사업장별 활성 WS 연결 수.chat_message_send_total{tenant=""}— 사업장별 메시지 송신 수.
모든 메트릭에 tenant="" 라벨이 붙어 있어 한 사업장의 트래픽 폭증이 다른 사업장 메트릭에 영향을 주지 않는다. 모든 로그에는 MDC tenantId + traceId 가 붙어 ELK에서 tenantId:tenant_dev00001 AND status>=500 같은 쿼리가 가능하다. DB connection pool은 글로벌 + multi-tenancy resolver 조합인데, 한 테넌트의 느린 쿼리 가 풀을 점유하면 다른 테넌트도 영향을 받는 건 v2에서 per-tenant connection budget 으로 격리 강화 검토 중이다.
5. 0→1 부트스트랩의 해부 — 서버 12 + 클라 12 커밋
신규 마이크로서비스를 0에서 1로 만든다는 건 코드만 짠다는 뜻이 아니다. 이 장에서는 24개 커밋이 왜 그 순서로 들어갔는지를 따라간다.
서버 측 — 신규 모듈 부트스트랩의 5계층
1
2
3
4
5
1. 도메인 코드 mes-chat/src/main/...
2. 빌드/실행 의존성 build.gradle.kts, settings.gradle.kts, scripts/build-service.sh
3. 인프라 (Helm/K8s) charts/mes-chat/, skaffold.yaml
4. 라우팅 (Istio) charts/mes-istio/values.yaml
5. 모듈 등록 (mes-common) MesModule enum, common-public seed
이 5계층을 동시에 모두 닿아야 신규 서비스가 실제로 배포 가능 + 다른 서비스에서 인지 가능 + 사용자 트래픽 수신 가능.
서버 12 커밋 의존 순서
원칙: 각 커밋 직후의 상태에서 빌드와 배포가 깨지지 않을 것.
| # | 커밋 | 의도 |
|---|---|---|
| 0 | chore(.gitignore) | 로컬 메타 / 빌드 산출물 / k6 결과 dumps 제외 |
| 1 | feat(mes-common) | CHAT 모듈 등록 + OSIV=false + 파일/링크프리뷰 설정 |
| 2 | feat(mes-grpc-interface) | MdmSearch + GetOrderByCode + GetWorkOrderByCode proto |
| 3 | feat(mes-order, mes-wo) | code 기반 gRPC 구현체 |
| 4 | feat(mes-mdm) | MdmSearch gRPC 서비스 + searchForMention 5도메인 |
| 5 | refactor(mes-auth, mes-mdm) | 회원가입 시 자동 sample-company 제거 + users public schema 명시 |
| 6 | feat(mes-mdm) | EMP-XXXX 자동 채번 + REQUIRES_NEW 가드 |
| 7 | refactor(mes-mdm) | InvitationService 2-phase 분할 + 테스트 |
| 8 | feat(mes-chat) | mes-chat 본체 부트스트랩 (가장 큰 커밋) |
| 9 | feat(infra) | Helm/Skaffold/Istio + Gradle/build 스크립트 |
| 10 | chore(mes-common) | CHAT 모듈 구독 시드 (root + 기존 테넌트) |
| 11 | chore(dev-seed) | tenant_dev00001 데모 데이터 (100명 + 1개월 운영) |
만약 9를 8보다 먼저 넣으면? — Helm chart는 있지만 도커 이미지가 없다. 만약 10을 9보다 먼저 넣으면? — DB에 CHAT 구독 행은 있지만 mes-chat 인스턴스가 없어 reconciler가 마이그레이션을 돌릴 수 없다. 만약 1을 마지막에 넣으면? — 그동안 모든 커밋이 컴파일 안 됨 상태.
이 순서를 강제하는 자동화 도구는 따로 없다. 의도적 작업 순서이고, 코드 리뷰 단계에서 검증되어야 하는 부분이다. 의도적 분할 커밋이 부담하는 작은 비용이다.
클라이언트 12 커밋
1
2
3
4
5
6
7
8
9
10
11
12
fa16698 알림센터/채팅 i18n 한·영 메시지 키 추가
c7d67a5 알림센터 인프라 강화 — 모델/DTO/API/SSE/스토어/디스패처
039aa46 알림센터 UI 갱신 — 인박스 페이지/툴바/리스트/상세
a10324b 인탭 OS 알림 공용 유틸 추출 — lib/browser-notification
75d5a4c 채팅 mock 제거 + 실서버 REST/STOMP 연동 인프라 도입
b5a22f5 채팅 공용 유틸/훅 확장 — 마크다운/멘션/이모지/링크프리뷰
d59e65a 채팅 store — 실서버 마이그레이션 + 다수 정합/버그 수정
b783dab 채팅 UI 컴포넌트 갱신 — 활성 패널/컴포저/메시지/정보/레일
b9492eb 채팅 토스트/런처/부트스트랩 — 슬랙 인앱 알림 토스트
befa024 채팅 파일 아이콘 리팩터 — 메타데이터 분리 + 단일 SVG
6ec4c5d 글로벌 레이아웃/사이드바/스타일 통합
06c0caf 의존성 추가 — @stomp/stompjs ^7.3.0
이 순서도 의존 관계가 있다. i18n 키와 알림 인프라가 먼저 들어가야 채팅 토스트가 깔끔히 붙고, mock 제거 + REST/STOMP 인프라가 들어가야 store 마이그레이션과 컴포넌트 갱신이 무너지지 않는다. UI 컴포넌트 → 토스트/런처/부트스트랩 → 파일 아이콘 리팩터 순서로 가야 PR마다 빌드가 깨지지 않는다.
더미 데이터 — 16개 SQL, ~3,000 LOC
tenant_dev00001 한 데모 사업장 가동 1개월의 모습을 16개 SQL로 그렸다. 가장 큰 두 개가 mdm/V34 (428 LOC, 조직 9 / 멤버 100 / 품목 100 / BOM 10+상세 60 / 거래처 50 / 설비 30 / 공지 20) 와 chat/V7 (730 LOC, 멘션 모든 종류 사용한 시연 채팅 + 첨부 + 리액션 + 핀)이다.
왜 이렇게 큰가 하면, 데모 시나리오 자체가 도메인 이해의 산물이기 때문이다.
- 100명을 9개 부서에 분산. 생산 1팀 12명, 생산 2팀 11명, 품질 8명, 자재/구매 6명, 영업/수주 7명, 설비/유지 5명, 정보팀 3명, 안전/환경 4명, 경영/인사 4명. 부서 분포가 있어야 계급 구조와 멘션 시연이 자연스럽다.
- 품목 100개를 완제품 20, 반제품 30, 원자재 40, 부자재 5, 포장 5로 나눠 BOM 트리가 그럴듯하게 형성되도록.
- 거래처 50개를 매입 25, 매출 15, 양방향 10으로 분배해 procurement / sales 시연 가능하게.
- 1개월치의 시간 분포를 가짜 timestamp로 흩어 두어 차트와 대시보드에 시계열이 그려지도록.
chat/V7 이 730 LOC인 이유는, 멘션 모든 종류(USER × N + ITEM + EQUIPMENT + VENDOR + ORGANIZATION + BOM + WORK_ORDER + ORDER)가 적어도 한 번씩 등장하는 시연 채팅을 만들기 위해서다. 첫 시드는 일반 텍스트 위주로 짰는데, 짜고 보니 FE 멘션 렌더링이 한 페이지에서 검수되지 않는다는 문제가 보였다. 모든 멘션 kind가 등장하는 메시지를 의도적으로 박아 넣어, 단순 더미를 FE 검수 데이터셋으로 격상시켰다. 풀스택 작업의 부수효과 중 하나다.
테스트 전략
| 영역 | 종류 | 개수/규모 | 목적 |
|---|---|---|---|
| 서버 | 단위 테스트 | 13 파일, 3,123 LOC | 핵심 로직(mapper / mention validator / SSRF guard / OG parser / presence / file / room) |
| 서버 | 통합 (부분) | gRPC client mock 기반 | invitation 2-phase, mdm search |
| 서버 | 부하 (수동) | k6 + chat-upload-samples.zip | 36 파일 배치 업로드 시나리오 |
| 클라 | 컴포넌트 (수동 QA) | 데모 시드 기반 | 멘션 렌더, 파일 아이콘, i18n 표시 |
| 클라 | 통합 (실서버) | dev tenant | end-to-end 송수신, presence, 핀, 알림 |
솔직히 말해, 통합 테스트 커버리지는 아직 충분하지 않다. WebSocket / Kafka / gRPC가 모두 엮이는 end-to-end 자동 테스트는 v2 작업으로 미뤄 두었다. 현재는 핵심 변환 로직과 위협 모델을 단위 테스트로 막아 둔 수준이다.
6. 회고와 다음
잘된 점
1. 도메인 모델과 일관된 멘션 시스템 (양쪽)
8가지 서버 멘션 + 11가지 클라이언트 멘션을 모두 MDM code 기반으로 통일한 것이 가장 좋은 결정이었다. 사용자는 BOM-0001 을 외우고, 백엔드는 code를 1차 키처럼 쓰고, 클라이언트는 분기 없이 코드 한 줄로 라우팅한다. WORK_ORDER/ORDER에 추가 RPC를 만들어 통일한 것은 지금 돌아봐도 가장 작은 기술 부채만 남긴 결정이다.
2. 의도적 24 커밋 분할 (서버 12 + 클라 12)
신규 모듈 부트스트랩에서는 한 번에 거대한 커밋을 만드는 유혹이 강하다. 24개로 나누는 데 든 추가 시간은 두세 시간 정도였지만, 그 시간이 코드 리뷰 가능성, 부분 롤백 가능성, git 히스토리의 의미를 함께 만들어 줬다. 작업의 흐름이 그대로 git log에 남는다는 게 의외로 큰 자산이라는 걸 다시 확인했다.
3. 양면 가드 패턴 — 서버 outbox × 클라 catch-up, 서버 SSRF × 클라 dismiss
같은 빈자리를 양쪽에서 메우는 가드들이 가장 안정적이다. 서버 outbox만 있으면 클라이언트 store가 끊긴 시점의 사진을 못 갱신하고, 클라이언트 catch-up만 있으면 서버가 broadcast 하지 못한 이벤트는 영영 사라진다. 서버 SSRF만 있으면 사용자가 노출 사실을 통제할 수 없고, 클라이언트 dismiss만 있으면 노출 자체를 막지 못한다. 풀스택을 한 사람이 만들면서 이런 양면 가드를 자연스럽게 함께 설계할 수 있었던 것이 이 작업의 가장 큰 이점이었다.
4. SSRF / CVE-aware 방어
링크 미리보기의 SSRF 방어는 운영 환경에 그대로 올려도 될 만큼 챙겨 두었다고 본다. Tomcat 11 maxPartCount 같은 숨은 한도를 식별해서 우회한 것도 같은 결의 작업이었다. 결국 프레임워크 기본값을 의심하는 습관이 가장 큰 수확이다.
5. i18n-ready 패턴의 일관 적용
nameKey + nameArgs 패턴이 채팅방 이름뿐 아니라 시스템 메시지로도 확장됐다. “서버는 데이터, 클라이언트가 표현” 이라는 규약이 모듈 전반에 자리 잡으면서, 다른 모듈로 넘어갈 때도 같은 약속을 그대로 가져갈 수 있게 됐다.
6. 멀티테넌시 정합성
OSIV 문제를 발견하고 OSIV=false + REQUIRES_NEW + fail-fast 가드의 3중 방어로 안정화했다. 같은 함정을 미래에 다시 밟지 않도록, 호출 규약을 코드 안에 못 박아 둔 점이 마음에 든다.
아쉬운 점 / 다음 단계
1. Presence WS-frame heartbeat 미구현 (3.11장)
진단까지는 도달했지만 적용은 다음 작업으로 미뤘다. 60초 TTL + STOMP application heartbeat 조합이 만든 마진이 너무 좁다. v2에서 WS frame heartbeat 도입과 TTL 마진 확장을 같이 한다.
2. 파일 AV 스캔 없음
ZIP/7z/RAR을 받아 들이지만 ClamAV 같은 스캐너는 아직 붙이지 못했다. 사내망 가정이 깨질 때 위험이 노출되는 부분이라 v2에서 필수다.
3. 메시지 검색은 LIKE 기반
ChatSearchService.searchMessages 가 PostgreSQL ILIKE '%keyword%' 로 동작한다. 메시지가 천만 건 단위로 쌓이면 명확히 느려진다. v2에서 ElasticSearch나 OpenSearch 연동을 검토 중이다.
4. E2E 암호화 미검토
작업장 채팅 특성상 서버에서 메시지를 그대로 읽을 수 있어야 인시던트 후 검수가 되는 면이 있다. 다만 옵션으로 E2E 암호화 채팅방을 두는 요구가 향후에 들어올 가능성도 있는데, 이쪽은 아직 검토 전이다.
5. 음성/영상 통화
라인 정지 같은 긴급 상황에서는 텍스트보다 음성이 빠르다. 향후 Janus나 LiveKit 같은 SFU 도입을 검토하고 있다.
6. 통합 테스트 커버리지 확장
현재는 단위 테스트 위주다. WS + Kafka + gRPC가 한 번에 엮이는 통합 테스트가 약하다. testcontainers와 WebSocket 클라이언트 시뮬레이션을 묶어 v2에서 보강한다.
7. 클라 측 STL/glTF 인라인 뷰어
CAD/3D 파일은 현재 모두 다운로드 카드로 떨어진다. STL/OBJ/glTF는 WebGL 뷰어로 인라인 미리보기가 가능한 포맷이라, 채팅 안에서 바로 도면을 돌려 볼 수 있게 만드는 작업이 다음 단계다.
가장 크게 배운 한 가지
기능 카탈로그가 아니라 도메인을 먼저 본다.
채팅이라는 표면적 기능 카테고리는 어디서나 똑같다. 메시지, 방, 멘션, 파일, 미리보기, 알림. 그러나 MES 도메인이 그 위에 강제하는 비대칭 — 도메인 객체 멘션, CAD 파일, 멀티테넌시, i18n, 인시던트 추적 — 이 기능을 다시 설계하게 만든다. 일반 채팅을 그대로 가져오면 그 비대칭의 무게를 사용자가 떠안는다. 도메인을 먼저 보면 그 무게를 시스템이 떠안고, 사용자에게는 자연스러운 채팅처럼 보이게 된다.
이 차이가 SaaS 도입과 자체 구축을 가르는 기준이다. 우리 도메인이 강제하는 비대칭을 시스템이 흡수할 수 있는 수준인가, 아니면 사용자에게 떠넘겨야 하는가. 흡수 가능하다면 자체 구축이 결국 더 싸진다. 그렇지 않다면 SaaS가 답이다. 우리 경우, 멘션이 우리 도메인 객체와 결합한다 는 한 가지만으로도 자체 구축이 정당화됐다.
그리고 풀스택을 한 사람이 만들었다는 사실이 이 작업의 마감을 단단하게 만들었다. 같은 결정이 양쪽에서 어떻게 다른 모양으로 자리잡는지를 매번 직접 봤기 때문에, 한쪽에서 결함이 나오면 다른 쪽에서 자연스럽게 보완 패턴을 짤 수 있었다. 핀 영속화의 클라 점검 → 서버 협업 → 양쪽 구현 사이클이 가장 단적인 예다.
마치며
신규 마이크로서비스와 그 클라이언트 모듈을 0에서 1로 만든다는 건 코드를 짜는 일이라기보다, 도메인 / 빌드 / 인프라 / 라우팅 / 모듈 등록 / UI / 상태 / 실시간 통신을 동시에 만지는 일이었다. 그 과정에서 만나는 시스템 레벨 함정들은 한 번 알면 다시는 안 당하는 종류라, 이번에 만난 함정들은 가능하면 코드와 테스트로 못 박아 두려 했다. 이 글이 비슷한 결정 자리에 선 누군가에게(혹은 미래의 나에게), 어디서부터 의심해야 하는지에 대한 단서로 남으면 좋겠다.
요약
- 일반 채팅과 다른 5가지 축: 도메인 객체 멘션 / MDM code 기반 식별 / CAD·CAM 파일 / i18n-ready / 멀티테넌시.
- 풀스택 분담: 서버는 SoT/검증/fan-out/SSRF, 클라이언트는 정규화/낙관성/표현/dismiss. 양면 가드.
- 만나서 풀어낸 12가지 함정: OSIV ↔ multi-tenancy, clientMessageId 멱등 협약, STOMP silent drop, 두 토픽 dedup, outbox × catch-up, Tomcat 11 max-part-count, SSRF × 인증 프록시, 이모지 UNIQUE × 정규화, 핀 영속화, invitation 2-phase, presence × self-online, Strict Mode × markRead.
- 잘된 결정: 멘션 일관성, 의도적 24 커밋 분할, 양면 가드, SSRF 방어, i18n 패턴, multi-tenancy 가드.
- 남은 숙제: presence 안정화, 파일 AV 스캔, 메시지 검색 ES, 통합 테스트 보강, 음성·영상, STL/glTF 뷰어.
글에 인용한 코드는 모두 실제 mes-chat 모듈과 OROGX_CLIENT/app/smartmf/chat 모듈의 커밋 히스토리에서 발췌했다. ./images/*.png 이미지는 별도 캡처해 채울 예정이다.