개발일지 · on-the-block · 9분 읽기
On-The-Block 서비스 개발기 03 : Admin 페이지 & 채팅
On-The-Block 서비스 개발기 03: Admin 페이지 & 채팅
채팅 시스템 정리 : *Chatting System* 으로 이동하기
해당 글에 초기 채팅 시스템에 대한 내용을 작성해뒀습니다.
기존 글은 2026-04-30에 작성했지만, 바뀐 부분이나 부족한 내용을 채우기 위해서 다시 작성하고자 합니다.
Admin 페이지 만들기
개발하면서, Docker에 매번 SQL 쿼리를 통해서 채팅 기록이 송수신 됬는지 확인하는게 번거로워서 기록들을 시각적으로 편하게 확인할 수 있는 웹페이지를 만들었습니다. 로컬에선 개발할 때 사용하다가, GCP를 구축하기 시작한다면 GCP에 있는 모니터링 서비스를 사용할 예정입니다.

2026-06-19
Google Cloud를 본격적으로 사용하기 시작하면서, GCP에서 제공하는 Cloud Logging과 Cloud Monitoring을 활용하여, Admin 페이지를 대체할 수 있었고, 결국에는 초기에만 사용하는 Repo가 되고 말았습니다.
이번에 클라우드를 사용하면서, 가장 비용이 많이 나온 부분이 Cloud SQL, Cloud Redis부분인데 SQL은 사용하지 않고 있어도 상시 비용이 발생하기에 비용을 아끼기 위해서는 개발을 마치면 [중지]를 해두고 개발을 한 뒤 테스팅을 진행할 때 다시 킬 수 밖에 없었습니다.
중간에 이걸 어떻게 최적화를 해야할 지, 분명히 서비스 규모에 따른 비용 산출 공식이 있을 것이고 Google Cloud를 사용하기 전에 이런 사항들을 고려해봤어야 했는데, 완성하는데 급급해서 좀 진득하게 고려하지 못한 것이 아쉽다. 처음에 한 이벤트 스토밍처럼 좀 시간을 많이 들여서 반드시 해야하는 작업이라고 생각합니다.
DevOps쪽을 공부해보면서, Google Cloud 비용 최적화나 어떻게 다뤄하는지는 다른 글에서 정리하도록 하겠습니다.
채팅 시스템
우선, 제일 처음에 이벤트 스토밍을 진행하면서 채팅 시스템을 어떻게 만들 것이지에 대한 Policy를 정하는 과정이 있었고, 개발을 진행하면서 필요한 추가적인 정책들도 생겨났습니다.
- 이 서비스는 1대1 채팅이 없고, 그룹 채팅만 담당한다.
- 방 단위로 read/unread 상태를 관리한다.
- 메시지 전송 시, 메시지의 sequence_no를 방 단위로 증가시켜서, 메시지 순서를 보장한다.
- 채팅 메시지용 device token이 반드시 필요하다.
- 채팅방 삭제는 채팅방을 만든 사람만이 가능하며, 다른 사용자가 있을 경우 삭제가 불가능하다.
- 채팅방에 참여한 사용자가 모두 나가면, 채팅방은 자동으로 삭제된다.
- 채팅방에 참여한 사용자가 모두 나가면, 채팅방은 자동으로 삭제되지만, 메시지 기록은 삭제되지 않고, 방이 재생성되면 기존 메시지 기록을 그대로 불러올 수 있다.
-> PostgreSQL에 방과 메시지 기록을 저장하고, 방이 삭제되더라도 메시지 기록은 남겨두도록 했고, 채팅 또한 현대 AI 챗봇과 같이, 메시지 기록을 기반으로 추천 시스템과 연동하여, 사용자가 이전에 어떤 메시지를 주고 받았는지에 대한 context를 제공할 수가 있었기에, 메시지 기록을 남겨두는 것이 좋다고 판단했습니다.
- 인증, 게시글 소유권, 추천, 전체 알림은 다른 서비스의 책임으로 남긴다.
-> MSA 구조를 따르려고 했기 때문에, 채팅 시스템은 오로지 채팅 그 형태의 기능으로만 책임을 한정하고자 했고, 이로 인해 “알림”에 대한 책임을 어디로 넘겨야 하는지 고민이 있었습니다.
1. 서비스 경계
- 채팅방
- 채팅방 멤버십
- 메시지
- 방 단위 read/unread 상태
- 채팅 도메인의 시스템 이벤트
- 채팅 메시지용 device token 등록 RPC
반대로 다음은 이 서비스가 소유하지 않는다.
- 인증의 source of truth
- Board 데이터와 Board 소유권 판단
- 추천 로직
- 전체 알림과 top bell notification
- 사용자 identity provider
2. Policy 문서와 Proto 규약
그 다음 docs/chat-policy-decision.md와 docs/proto-v1-decisions.md에 v1 정책을 정리했다.
핵심 정책은 다음과 같다.
- 채팅방 타입은
GENERAL_GROUP,BOARD_LINKED_GROUP두 가지다. - 게시글 하나에는 active board-linked room이 최대 하나만 존재한다.
- 방 입장은 open join이다.
- 방장이 멤버 제거, 메시지 soft delete, 방 deactivate를 할 수 있다.
- 사용자가 직접 나간
LEFT상태는 재입장이 가능하다. - 방장에게 제거된
REMOVED상태는 재입장이 불가능하다. - 메시지는 방 안에서
sequence_no로 안정적으로 정렬한다. - 삭제된 메시지는 DB에 남기고, 일반 조회에서는 원문 없이 placeholder로 반환한다.
- read/unread는 per-message receipt가 아니라 room-member의
last_read_sequence_no로 시작한다.
proto도 이 정책에 맞춰 작성했다.
proto/chat/v1/chat.proto에는 방 생성, board-linked 방 생성, board context 진입,
방 참여/나가기, 방 목록, 메시지 조회, 메시지 전송, 읽음 처리, 멤버 제거, 메시지 삭제,
방 비활성화, 실시간 스트림, 첨부파일 upload URL 생성, device token 등록/해제 RPC가 들어갔다.
3. PostgreSQL 스키마와 마이그레이션을 만들었다
초기 DB 모델은 단순하게 시작했다.
chat_roomschat_room_memberschat_messageschat_room_eventschat_device_tokensschema_migrations
migrations/001_create_chat_schema.sql에는 room, member, message, event, device token의 기본 구조가 들어갔다.
board-linked room의 중복 생성을 막기 위해 partial unique index도 추가했다.
조건은 다음과 같다.
room_type = 'BOARD_LINKED_GROUP'
AND is_active = true
AND deleted_at IS NULL
즉, 같은 board에 active room은 하나만 가능하지만, 기존 room이 deactivate되어 soft-deleted 상태가 되면 나중에 새 active room을 만들 수 있다.
이후 FILE 메시지 타입을 보강하는 migration과 chat_device_tokens migration도 추가했다.
마이그레이션 runner는 cmd/migrate에 두고, staging에서도 같은 SQL 파일 순서로 적용할 수 있게 했다.
4. repository, service, gRPC 계층을 나눴다
구현은 크게 세 계층으로 나눴다.
internal/repository: PostgreSQL과 memory repositoryinternal/service: 채팅 도메인 규칙internal/transport/grpc: proto와 service 사이의 gRPC handler
초기에는 memory repository도 같이 두었다. 이 덕분에 service-level rule test를 빠르게 돌릴 수 있었고, PostgreSQL implementation과 비교하면서 규칙을 확인할 수 있었다.
구현한 주요 도메인 규칙은 다음과 같다.
- 일반 그룹 채팅방 생성
- board-linked 채팅방 생성
GetOrCreateBoardChatRoom으로 Board에서 채팅방 진입- active board-linked room 중복 방지
- open join
- voluntary leave
LEFT멤버 재입장REMOVED멤버 재입장 차단- 방장 권한 기반 멤버 제거
- 방장 나가기 시 owner transfer
- 마지막 active member가 나가면 room deactivate
- inactive room의 join, send, get messages 차단
- soft-deleted message placeholder 반환
- 각 방마다
sequence_no발급 - unread count 계산
MarkChatRoomRead로 방 단위 읽음 처리
이 단계에서 가장 중요한 것은 PostgreSQL을 source of truth로 유지하는 것이었다. 실시간 스트림은 별도 최적화 계층으로 볼 수 있지만, 메시지의 최종 상태와 순서는 DB가 결정한다.
5. 실시간 스트림을 붙였다
Pub/Sub : Pub/Sub
StreamMessages는 DB polling 대신 pub/sub 형태로 구현했다.
하지만 Redis는 초기 의존성으로 넣지 않았다.
대신 internal/pubsub에 작은 abstraction을 만들고, 첫 구현은 in-memory pub/sub로 시작했다.
SendMessage는 메시지를 먼저 저장한 뒤 room subscriber에게 저장된 메시지 이벤트를 publish한다.
이 방식은 두 가지 장점이 있었다.
- v1에서는 Redis 없이 단순하게 동작한다.
- 나중에 Redis Pub/Sub나 다른 fan-out 방식으로 바꾸기 쉽다.
스트림에서도 멤버십 규칙은 유지했다.
사용자가 LEFT이거나 REMOVED이면 정상적인 active member stream으로 취급하지 않는다.
여기까지 작업해서 얻은 것
- 동시 메시지 전송 상황에서도 메시지 저장
- 방 목록 응답이 proto 계약과 일치
- 스트림 재연결 시 메시지가 누락되지 않기
- 메시지가 없는 방이 있어도 페이지네이션의 유지
- DB에 없는 메시지는 스트림에서 오지 않기
6. 이미지와 파일 첨부 흐름을 만들었다
채팅 미디어는 PostgreSQL에 저장하지 않는다. 대신 private GCS bucket을 사용하고, 클라이언트가 signed upload URL로 직접 object storage에 업로드한다. 에 대한 이유는 아래 글에 정리되어 있습니다.
Chatting Media : Chatting Media
흐름은 다음과 같다.
- 클라이언트가
CreateAttachmentUploadURL을 호출한다. - 서버가 trusted bucket/path 기준으로 signed upload URL을 만든다.
- 클라이언트가 해당 URL로 바이너리를 PUT한다.
- 클라이언트가
SendMessage에 object URL 또는 object reference를 보낸다. - 서버가 bucket/path를 검증하고 internal object name으로 정규화한다.
- 응답에는 클라이언트 표시용 signed read URL을 붙인다.
이 설계에서 지킨 원칙은 세 가지다.
- bucket은 private로 둔다.
- full signed URL은 로그에 남기지 않는다.
- DB에는 object reference와 metadata만 저장한다.
처음에는 image 중심이었지만 이후 FILE 메시지도 지원하도록 확장했다.
파일명, content type, object name 같은 정보는 message metadata에 들어간다.
7. 인증 연동을 붙였지만 auth 소유권은 가져오지 않았다
초기 v1 proto에는 local/dev compatibility를 위해 request body의 user_id 필드가 남아 있다.
하지만 staging runtime에서는 CHAT_AUTH_MODE=validate_token으로 동작하고,
gRPC metadata의 Authorization: Bearer <access_token>을 기준으로 사용자를 확인한다.
auth-service 연동은 internal/auth에 뒀다.
dynamic_client.go는 auth proto에 강하게 묶인 generated client 대신 dynamic protobuf descriptor를 사용해
ValidateToken과 GetMe를 호출한다.
이 접근은 현재 repo가 auth-service의 source of truth를 소유하지 않는다는 경계를 유지하기 위한 선택이었다.
chat-service는 token을 검증하고 internal user ID를 받아 사용할 뿐, 인증 정책 자체를 구현하지 않는다.
최근에는 auth contract 변화에 맞춰 dynamic client의 응답 필드도 업데이트했다. -> ValidateTokenResponse와 UserResponse의 nickname/profile 관련 필드를 맞춰 테스트를 보강했다.
그래서 채팅에서는 이제 user-id가 노출되지 않고, 자신이 설정한 nickname과 profile image를 기반으로 채팅을 진행할 수 있게 되었다.
8. 채팅 push의 최소 기반을 넣었다
채팅 메시지 push를 위해 device token RPC를 추가했다.
RegisterDeviceTokenUnregisterDeviceToken
device token은 request body의 user ID를 받지 않고 auth metadata에서 user ID를 파생한다.
SendMessage 이후에는 active room member 중 sender를 제외하고 push 대상이 될 수 있다.
다만 staging에서는 FCM dispatch를 꺼 둔 상태다. FCM은 알림 시스템으로 사용할 수 있고, 추후 문서 정리와 다른 서비스들이 완성된 뒤, 추가로 개발할 예정이다.
9. staging Cloud Run 배포를 만들었다
서비스는 staging Cloud Run 대상으로 정리했다. README와 runbook 기준 staging target은 다음과 같다.
- Project:
on-the-block-2026 - Region:
asia-northeast3 - Service:
chat-service-staging - Container gRPC port:
9090 - Client gRPC endpoint:
chat-service-staging-44649239380.asia-northeast3.run.app:443 - Staging URL:
https://chat-service-staging-44649239380.asia-northeast3.run.app - Cloud SQL instance:
on-the-block-2026:asia-northeast3:ontheblock-chat-staging - DB secret:
chat-db-dsn-staging - GCS bucket:
on-the-block-chat
배포 helper는 deploy/cloud-run-staging.sh에 있다.
중간에 Artifact Registry repository 이름이 맞지 않아 배포가 실패한 적이 있었고,
실제 staging에서는 ontheblock repository를 사용하도록 정리했다.
Cloud Run에서 중요한 설정도 문서화했다.
CHAT_SERVICE_ADDR=:9090CHAT_REPOSITORY=postgresCHAT_AUTH_MODE=validate_tokenCHAT_AUTH_SERVICE_URL=https://authorization-service-44649239380.asia-northeast3.run.appCHAT_PUSH_FCM_ENABLED=falseCHAT_GCS_BUCKET=on-the-block-chatGCP_STORAGE_BUCKET=on-the-block-chat
서비스가 외부에서 probe 가능한 :9090에 bind되어야 한다.
10. push 기반 staging CI/CD를 추가했다
마지막으로 .github/workflows/deploy-staging.yml을 추가했다.
현재 workflow는 다음 흐름으로 동작한다.
mainpush 또는 manual dispatch- Go setup
go test ./...GCP_SA_KEY로 Google Cloud 인증deploy/cloud-run-staging.sh실행
workflow env도 staging runbook과 맞췄다.
PROJECT_ID=on-the-block-2026REGION=asia-northeast3SERVICE_NAME=chat-service-stagingARTIFACT_REPOSITORY=ontheblockCHAT_DB_DSN_SECRET=chat-db-dsn-stagingCLOUD_SQL_INSTANCE=on-the-block-2026:asia-northeast3:ontheblock-chat-stagingSERVICE_ACCOUNT=chat-service-sa@on-the-block-2026.iam.gserviceaccount.comCHAT_GCS_BUCKET=on-the-block-chat
11. 검증한 것
진행 중 사용한 주요 검증은 다음과 같다.
GOCACHE=/tmp/go-build-cache go test ./...
CHAT_SERVICE_TEST_PG_DSN="postgres://<USER>:<PASSWORD>@<HOST>:<PORT>/chat_service?sslmode=disable" GOCACHE=/tmp/go-build-cache go test ./...
flutter analyze
flutter test
git diff --check
검증 범위는 다음을 포함했다.
- repository-level rule
- PostgreSQL-backed repository behavior
- service-level room/member/message rules
- gRPC handler behavior
- room summary member count
- board-linked room member count
- attachment unavailable status mapping
- migration apply/check path
- Flutter 채팅 화면의 append, unread badge refresh 흐름
GitHub Actions에서도 staging deploy 전에 go test ./...를 실행하도록 했다.
12. 현재 상태
현재 repo 기준으로 구현된 범위는 다음과 같다.
- general group chat
- board-linked group chat
GetOrCreateBoardChatRoom- text/system/image/file message
- private attachment signed URL flow
- room membership
- owner moderation
- soft delete/deactivate
- room-level read/unread
- message stream
- device-token RPC
- staging Cloud Run deployment path
- push-triggered staging CI/CD
마무리
글 내용이 제법 긴데, 이번 글에서는 채팅 시스템의 초기 설계와 구현, staging 배포까지의 과정을 정리했습니다.
고려하지 못해서 아쉬웠던 점은 보안적인 부분이나, GCP 비용이나 실제 운영 환경에서의 모니터링은 어떻게 해야하는지, 채팅 테스트를 매번 에뮬레이터를 열어서 로그인하고 하는게 아니라 실제와 같은 환경을 구축해 거기서 테스팅할 수 있는 방법을 찾아서 적용했으면 좋았을 것 같습니다. 이 부분은 프로젝트가 끝나고 채팅 서비스를 공부해보면서 따로 정리해보도록 하겠습니다.
익명 피드백
GitHub 계정 없이도 이 글에 대한 의견을 남길 수 있습니다. 남긴 내용은 공개되지 않고 작성자만 확인합니다.
댓글