카카오톡 스타일의 가족/친구용 프라이빗 크로스플랫폼 메신저 ‘딩동(DingDong)’ 만들기 프로젝트 8편입니다.
지난 [7편]에서는 Flutter 앱에서 로그인 및 회원가입을 수행하고 토큰을 자동으로 갱신하는 클라이언트 인증 흐름을 연결했습니다.
이번 8편에서는 다시 Go 백엔드로 돌아와, 로그인한 이후 앱 화면을 채우는 데 필요한 연락처 기반 친구 동기화 API 와 1:1 및 그룹 채팅방 생성·목록 조회 API 를 구현합니다.
1. 핵심 설계 원칙 및 추가 폴더 구조
메신저에서 친구를 찾고 대화방을 열 때 발생할 수 있는 개인정보 노출과 동시성(Race Condition) 문제를 다음과 같이 방지합니다.
flowchart LR
classDef titleStyle fill:#0f172a,stroke:#0284c7,stroke-width:2px,color:#ffffff;
classDef nodeStyle fill:#1e293b,stroke:#475569,stroke-width:2px,color:#ffffff;
A["연락처 전화번호 배열"]:::titleStyle -->|"1. E.164 정규화 & HMAC 해싱"| B["users 테이블 매칭"]:::nodeStyle
B -->|"2. friendships 등록 (WithTx)"| C["친구 목록 반환"]:::nodeStyle
C -->|"3. direct_key 정렬 (작은UUID:큰UUID)"| D["1:1 채팅방 생성 / 조회"]:::titleStyle- 서버 비밀키 기반 연락처 매칭 (
HMAC-SHA256): 클라이언트가 주소록의 전화번호 목록을 보내면, 서버가 번호를 E.164 포맷(+8210...)으로 정규화한 뒤 [6편]에서 설정한PHONE_HASH_SECRET으로 HMAC 해시를 계산해users.phone_hash와 비교합니다. 한 번에 보낼 수 있는 전화번호 수는 최대 500개로 제한합니다. (호출 빈도 제한은 아래 “아직 적용하지 않은 것”에서 다룹니다.) - 민감 정보 분리 (프로필 전용 프로젝션): 6편의 로그인·내 정보 응답은
db.User전체를 반환해phone_hash가 포함됩니다. 이번에 만드는 친구 목록 및 채팅방 API에서는 SQL 조회 단계부터 공개 프로필 컬럼(id,display_name,status_message,avatar_file_id)만 선택하여 반환합니다. (6편 응답의 정리는 아직 남아 있습니다.) - 1:1 채팅방 중복 생성 원천 차단 (
direct_key+ON CONFLICT): 두 사용자 A, B가 동시에 서로에게 1:1 대화방을 열더라도 방이 2개 생기지 않도록, 두 UUID를 사전순으로 정렬해"작은UUID:큰UUID"형태의direct_key를 만들고 PostgreSQL의ON CONFLICT (direct_key) DO UPDATE구문으로 원자적(Atomic)으로 처리합니다. last_seq인덱스를 활용한 고속 방 목록 조회: 5편에서 설계한rooms.last_seq와messages테이블의UNIQUE (room_id, seq)인덱스를 조인하여, 별도 서브쿼리 없이 각 방의 마지막 메시지와 안 읽은 메시지 수(last_seq - last_read_seq)를 한 번에 조회합니다.
백엔드 추가 파일 구조 (server/)
server/ ├─ queries/ │ ├─ users.sql │ ├─ messages.sql │ ├─ auth.sql │ ├─ friends.sql # 👈 [신규] 친구 매칭 및 목록 조회 SQL │ └─ rooms.sql # 👈 [신규] 1:1 / 그룹 채팅방 생성 및 목록 조회 SQL ├─ internal/ │ ├─ auth/ │ │ └─ handler.go # [수정] 가입 시 전화번호도 같은 방식으로 정규화 │ ├─ database/ │ ├─ phone/ │ │ └─ phone.go # 👈 [신규] 전화번호 E.164 정규화 (auth와 friend가 함께 사용) │ ├─ friend/ │ │ └─ handler.go # 👈 [신규] 친구 동기화 핸들러 │ └─ room/ │ └─ handler.go # 👈 [신규] 1:1 / 그룹 채팅방 핸들러 └─ cmd/api/main.go # [수정] 친구 및 채팅방 라우터 연결
2. 친구 및 채팅방 SQL 쿼리 작성 (sqlc)
2.1 친구 관련 쿼리 (server/queries/friends.sql)
server/queries/friends.sql 파일을 생성하고 아래 쿼리를 작성합니다. 배열 파라미터에 sqlc.arg('phone_hashes')::text[] 문법을 사용하면 Go에서 깔끔한 []string 슬라이스를 인자로 받을 수 있습니다.
-- name: FindUsersByPhoneHashes :many
-- 전화번호 해시 배열과 일치하는 활성 사용자를 찾되, 자기 자신은 제외하고 공개 컬럼만 반환
SELECT
id,
display_name,
status_message,
avatar_file_id
FROM users
WHERE phone_hash = ANY(sqlc.arg('phone_hashes')::text[])
AND id != sqlc.arg('user_id')
AND status = 'active';
-- name: UpsertFriendship :exec
-- 친구 관계 등록 (이미 등록된 친구면 무시)
INSERT INTO friendships (user_id, friend_id, tag)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, friend_id) DO NOTHING;
-- name: ListFriends :many
-- 내 친구 목록 조회 (차단하지 않은 활성 상태의 친구만 이름순 정렬)
SELECT
u.id,
u.display_name,
u.status_message,
u.avatar_file_id,
f.tag,
f.nickname,
f.blocked,
f.created_at
FROM friendships f
JOIN users u ON u.id = f.friend_id
WHERE f.user_id = $1
AND f.blocked = false
AND u.status = 'active'
ORDER BY COALESCE(f.nickname, u.display_name) ASC;
-- name: IsBlockedByUser :one
-- 상대방(target_id)이 나(user_id)를 차단했는지 여부 확인
SELECT EXISTS (
SELECT 1 FROM friendships
WHERE user_id = sqlc.arg('target_id')
AND friend_id = sqlc.arg('user_id')
AND blocked = true
) AS is_blocked;
2.2 채팅방 관련 쿼리 (server/queries/rooms.sql)
server/queries/rooms.sql 파일을 생성하고 아래 쿼리를 작성합니다.
💡 팁: PostgreSQL의
COALESCE나GREATEST같은 함수 결과에는::varchar,::bigint처럼 명시적 타입 캐스팅을 붙여주어야sqlc가 모호한interface{}대신 정확한string,int64타입으로 Go 구조체를 생성합니다.
-- name: UpsertDirectRoom :one
-- 1:1 채팅방 생성 또는 기존 방 반환 (동시 요청 시에도 중복 생성 없이 기존 방을 반환)
INSERT INTO rooms (type, direct_key, created_by)
VALUES ('direct', $1, $2)
ON CONFLICT (direct_key)
DO UPDATE SET updated_at = rooms.updated_at
RETURNING *;
-- name: CreateGroupRoom :one
-- 그룹 채팅방 신규 생성
INSERT INTO rooms (type, name, created_by)
VALUES ('group', $1, $2)
RETURNING *;
-- name: AddRoomMember :exec
-- 채팅방 멤버 추가 (이미 참여 중이면 무시)
INSERT INTO room_members (room_id, user_id, role, last_read_seq, joined_seq)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (room_id, user_id) DO NOTHING;
-- name: ListMyRooms :many
-- 내가 참여 중인 채팅방 목록 + 상대방 이름(1:1 방) + 안 읽은 개수 + 마지막 메시지 조회
SELECT
r.id,
r.type,
COALESCE(r.name, du.display_name, '알 수 없는 대화상대')::varchar AS display_title,
COALESCE(r.avatar_file_id, du.avatar_file_id) AS display_avatar_id,
r.last_seq,
rm.last_read_seq,
GREATEST(r.last_seq - rm.last_read_seq, 0)::bigint AS unread_count,
rm.pinned,
rm.muted,
m.type AS last_message_type,
m.text AS last_message_text,
m.created_at AS last_message_at,
r.updated_at
FROM room_members rm
JOIN rooms r ON r.id = rm.room_id
LEFT JOIN room_members dm
ON dm.room_id = r.id AND r.type = 'direct' AND dm.user_id != $1
LEFT JOIN users du
ON du.id = dm.user_id
LEFT JOIN messages m
ON m.room_id = r.id AND m.seq = r.last_seq AND m.deleted_at IS NULL
WHERE rm.user_id = $1
ORDER BY rm.pinned DESC, r.updated_at DESC;
2.3 sqlc generate 실행
터미널의 server/ 경로에서 Go 코드를 생성합니다.
cd server sqlc generate
internal/database/db/ 폴더 안에 friends.sql.go와 rooms.sql.go 파일이 생성됩니다.
💡 시각(
created_at등)을 위한sqlc.yaml설정은 따로 바꿀 필요가 없습니다. 5편 설정 그대로 두면 sqlc가 pgx 전용 타입으로 만들어 주고, JSON으로 내려갈 때는"2026-10-31T09:30:00Z"같은 문자열이 됩니다.
3. 전화번호 정규화 및 친구 매칭 구현
스마트폰 주소록에는 전화번호가 010-1234-5678, 01012345678, +82 10-1234-5678, +82 010-1234-5678 등 제각각의 형태로 저장되어 있습니다. 해시값은 글자 하나만 달라도 완전히 달라지므로, 회원가입 시점과 주소록 동기화 시점 모두 동일한 정규화 함수를 거쳐야 매칭이 성립합니다.
정규화 함수는 auth(회원가입)와 friend(동기화) 두 곳에서 쓰므로 별도 패키지로 둡니다. (friend 패키지가 auth를 이미 가져다 쓰고 있어서, 정규화 함수를 friend에 두고 auth가 다시 가져오면 서로 참조하는 순환 import 에러가 납니다.)
3.1 전화번호 정규화 (server/internal/phone/phone.go)
package phone
import "strings"
// NormalizeE164: 전화번호를 E.164(+821012345678) 형태로 정규화합니다.
// 변환할 수 없는 값이면 빈 문자열("")을 반환합니다.
//
// 지원하는 형태
// - 한국 휴대폰 로컬 번호: 010-1234-5678, 01012345678
// - 국가번호가 붙은 번호: +82 10-1234-5678, +82 010-1234-5678, 0082 10 1234 5678
func NormalizeE164(raw string) string {
raw = strings.TrimSpace(raw)
hasPlus := strings.HasPrefix(raw, "+")
// 숫자(0-9)만 남깁니다. (하이픈, 공백, 괄호, 전각 숫자 등은 모두 버림)
var b strings.Builder
for _, r := range raw {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
digits := b.String()
// 국제전화 접두어 "00"은 "+"와 같은 뜻으로 처리합니다. (예: 0082...)
if !hasPlus && strings.HasPrefix(digits, "00") {
hasPlus = true
digits = digits[2:]
}
if hasPlus {
// 한국 번호를 "+82 010-1234-5678"처럼 0을 붙여 적는 경우가 많아 그 0을 제거합니다.
if strings.HasPrefix(digits, "820") {
digits = "82" + digits[3:]
}
if len(digits) < 9 || len(digits) > 15 {
return ""
}
return "+" + digits
}
// 한국 로컬 휴대폰 번호 (010, 011 ...) → +82
if strings.HasPrefix(digits, "01") && len(digits) >= 10 && len(digits) <= 11 {
return "+82" + digits[1:]
}
return ""
}
💡 한국 휴대폰 번호(010 등)와 국가번호가 붙은 번호만 지원합니다.
02-123-4567같은 지역 유선번호는 빈 문자열이 되어 매칭에서 제외됩니다. 가족 메신저에서는 휴대폰 번호면 충분하다고 보고 단순하게 만들었습니다.
3.2 6편 회원가입 핸들러 보완 (server/internal/auth/handler.go)
가입할 때 저장하는 전화번호 해시도 같은 함수를 거치도록 고칩니다. 안 고치면 010-9876-5432로 가입한 사람은 주소록 동기화에서 영원히 매칭되지 않습니다.
먼저 import 목록에 strings와 phone 패키지를 추가합니다.
import ( "encoding/json" "errors" "net/http" "strings" // 👈 추가 "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/yourname/dingdong-server/internal/database" "github.com/yourname/dingdong-server/internal/database/db" "github.com/yourname/dingdong-server/internal/phone" // 👈 추가 )
그다음 Register 함수 안의 전화번호 해싱 부분을 아래처럼 바꿉니다.
// 전화번호는 E.164로 정규화한 뒤, 서버 비밀키를 쓰는 HMAC-SHA256으로 저장
var phoneHash *string
if req.Phone != nil && strings.TrimSpace(*req.Phone) != "" {
normalized := phone.NormalizeE164(*req.Phone)
if normalized == "" {
writeJSONError(w, "전화번호 형식이 올바르지 않습니다 (예: 010-1234-5678)", http.StatusBadRequest)
return
}
hashed := HashHMAC(h.phoneKey, normalized)
phoneHash = &hashed
}
⚠️ 6편에서 이미 가입시켜 둔 테스트 계정이
+821012345678처럼 처음부터 E.164 형식이었다면 그대로 써도 됩니다. 하이픈이 섞인 번호로 가입했다면 해시가 달라져 있으니, 계정을 새로 만드세요.
3.3 친구 동기화 핸들러 (server/internal/friend/handler.go)
⚠️ 주의:
import경로의github.com/yourname/dingdong-server부분은 본인의go.mod모듈 이름에 맞게 변경하세요.
package friend
import (
"encoding/json"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yourname/dingdong-server/internal/auth"
"github.com/yourname/dingdong-server/internal/database"
"github.com/yourname/dingdong-server/internal/database/db"
"github.com/yourname/dingdong-server/internal/phone"
)
const maxSyncPhones = 500 // 1회 동기화 시 허용할 최대 전화번호 개수
type Handler struct {
pool *pgxpool.Pool
queries *db.Queries
phoneKey []byte
}
func NewHandler(pool *pgxpool.Pool, queries *db.Queries, phoneKey []byte) *Handler {
return &Handler{pool: pool, queries: queries, phoneKey: phoneKey}
}
type SyncContactsRequest struct {
Phones []string `json:"phones"`
}
// 1. 연락처 동기화 및 친구 자동 추가 (POST /api/v1/friends/sync)
func (h *Handler) SyncContacts(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.GetUserID(r.Context())
if !ok {
writeJSONError(w, "인증 정보가 없습니다", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 256<<10) // 요청 본문 256KB 제한
var req SyncContactsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, "잘못된 요청 형식입니다", http.StatusBadRequest)
return
}
if len(req.Phones) > maxSyncPhones {
writeJSONError(w, "한 번에 동기화할 수 있는 연락처는 최대 500개입니다", http.StatusBadRequest)
return
}
// 전화번호 정규화 및 중복 제거 후 HMAC-SHA256 해시 계산
seen := make(map[string]struct{})
hashes := make([]string, 0, len(req.Phones))
for _, rawPhone := range req.Phones {
normalized := phone.NormalizeE164(rawPhone)
if normalized == "" {
continue
}
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
hashes = append(hashes, auth.HashHMAC(h.phoneKey, normalized))
}
// 매칭할 유효 번호가 있으면 DB에서 조회 후 friendships 테이블에 등록
if len(hashes) > 0 {
err := database.WithTx(r.Context(), h.pool, func(q *db.Queries) error {
matchedUsers, err := q.FindUsersByPhoneHashes(r.Context(), db.FindUsersByPhoneHashesParams{
PhoneHashes: hashes,
UserID: userID,
})
if err != nil {
return err
}
for _, u := range matchedUsers {
err := q.UpsertFriendship(r.Context(), db.UpsertFriendshipParams{
UserID: userID,
FriendID: u.ID,
Tag: "friend",
})
if err != nil {
return err
}
}
return nil
})
if err != nil {
writeJSONError(w, "연락처 동기화 중 오류가 발생했습니다", http.StatusInternalServerError)
return
}
}
// 동기화 완료 후 최신 친구 목록 반환
friends, err := h.queries.ListFriends(r.Context(), userID)
if err != nil {
writeJSONError(w, "친구 목록 조회 실패", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"friends": friends,
})
}
// 2. 내 친구 목록 조회 (GET /api/v1/friends)
func (h *Handler) ListFriends(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.GetUserID(r.Context())
if !ok {
writeJSONError(w, "인증 정보가 없습니다", http.StatusUnauthorized)
return
}
friends, err := h.queries.ListFriends(r.Context(), userID)
if err != nil {
writeJSONError(w, "친구 목록 조회 실패", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"friends": friends,
})
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeJSONError(w http.ResponseWriter, msg string, status int) {
writeJSON(w, status, map[string]string{"error": msg})
}
4. 1:1 및 그룹 채팅방 핸들러 구현 (internal/room/handler.go)
server/internal/room/handler.go 파일을 생성하고 채팅방 생성 및 조회 로직을 작성합니다.
1:1 채팅방 생성 시에는 ① 자기 자신과의 대화 요청인지, ② 상대방 계정이 유효하고 활성 상태인지, ③ 상대방이 나를 차단하지 않았는지를 먼저 검증한 뒤 트랜잭션으로 방과 멤버를 등록합니다. 상대방이 나를 차단했을 때도 “상대를 찾을 수 없다”는 같은 응답을 주어, 차단당한 사실이 드러나지 않게 했습니다.
package room
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yourname/dingdong-server/internal/auth"
"github.com/yourname/dingdong-server/internal/database"
"github.com/yourname/dingdong-server/internal/database/db"
)
// 초대 대상 중 존재하지 않거나 비활성인 사용자가 있을 때 쓰는 에러 (사용자에게 그대로 보여 줘도 되는 메시지)
var errInvalidMember = errors.New("존재하지 않거나 비활성화된 사용자가 포함되어 있습니다")
type Handler struct {
pool *pgxpool.Pool
queries *db.Queries
}
func NewHandler(pool *pgxpool.Pool, queries *db.Queries) *Handler {
return &Handler{pool: pool, queries: queries}
}
type CreateDirectRoomRequest struct {
TargetUserID uuid.UUID `json:"targetUserId"`
}
type CreateGroupRoomRequest struct {
Name string `json:"name"`
MemberIDs []uuid.UUID `json:"memberIds"`
}
// makeDirectKey: 두 UUID를 사전순으로 정렬해 "작은UUID:큰UUID" 키를 생성합니다.
func makeDirectKey(a, b uuid.UUID) string {
s1, s2 := a.String(), b.String()
if s1 < s2 {
return fmt.Sprintf("%s:%s", s1, s2)
}
return fmt.Sprintf("%s:%s", s2, s1)
}
// 1. 1:1 채팅방 생성 또는 기존 방 열기 (POST /api/v1/rooms/direct)
func (h *Handler) CreateDirectRoom(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.GetUserID(r.Context())
if !ok {
writeJSONError(w, "인증 정보가 없습니다", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req CreateDirectRoomRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TargetUserID == uuid.Nil {
writeJSONError(w, "유효한 대화 상대 ID(targetUserId)가 필요합니다", http.StatusBadRequest)
return
}
if req.TargetUserID == userID {
writeJSONError(w, "자기 자신과는 1:1 채팅방을 만들 수 없습니다", http.StatusBadRequest)
return
}
// 상대방 계정 존재 여부 확인
targetUser, err := h.queries.GetUserByID(r.Context(), req.TargetUserID)
if errors.Is(err, pgx.ErrNoRows) {
writeJSONError(w, "대화 상대를 찾을 수 없습니다", http.StatusNotFound)
return
}
if err != nil {
writeJSONError(w, "대화 상대를 확인하지 못했습니다", http.StatusInternalServerError)
return
}
if targetUser.Status != "active" {
writeJSONError(w, "대화 상대를 찾을 수 없습니다", http.StatusNotFound)
return
}
// 상대방이 나를 차단했는지 확인 (차단 사실을 알려주지 않기 위해 위와 같은 응답을 사용)
blocked, err := h.queries.IsBlockedByUser(r.Context(), db.IsBlockedByUserParams{
TargetID: req.TargetUserID,
UserID: userID,
})
if err != nil {
writeJSONError(w, "대화 상대를 확인하지 못했습니다", http.StatusInternalServerError)
return
}
if blocked {
writeJSONError(w, "대화 상대를 찾을 수 없습니다", http.StatusNotFound)
return
}
directKey := makeDirectKey(userID, req.TargetUserID)
var room db.Room
err = database.WithTx(r.Context(), h.pool, func(q *db.Queries) error {
// ① direct_key 기준으로 방 생성 (이미 있으면 기존 방 반환)
rCreated, err := q.UpsertDirectRoom(r.Context(), db.UpsertDirectRoomParams{
DirectKey: &directKey,
CreatedBy: &userID,
})
if err != nil {
return err
}
room = rCreated
// ② 나 자신과 상대방을 멤버로 등록 (이미 등록되어 있으면 무시)
if err := q.AddRoomMember(r.Context(), db.AddRoomMemberParams{
RoomID: room.ID,
UserID: userID,
Role: "member",
LastReadSeq: room.LastSeq,
JoinedSeq: 0,
}); err != nil {
return err
}
return q.AddRoomMember(r.Context(), db.AddRoomMemberParams{
RoomID: room.ID,
UserID: req.TargetUserID,
Role: "member",
LastReadSeq: 0,
JoinedSeq: 0,
})
})
if err != nil {
writeJSONError(w, "1:1 채팅방 생성에 실패했습니다", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"room": room,
})
}
// 2. 그룹 채팅방 생성 (POST /api/v1/rooms/group)
func (h *Handler) CreateGroupRoom(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.GetUserID(r.Context())
if !ok {
writeJSONError(w, "인증 정보가 없습니다", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req CreateGroupRoomRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, "잘못된 요청 형식입니다", http.StatusBadRequest)
return
}
// 한글은 글자당 3바이트이므로, len()이 아니라 글자 수(rune)로 길이를 셉니다.
roomName := strings.TrimSpace(req.Name)
if n := utf8.RuneCountInString(roomName); n == 0 || n > 50 {
writeJSONError(w, "그룹 채팅방 이름은 1~50자 사이여야 합니다", http.StatusBadRequest)
return
}
if len(req.MemberIDs) == 0 || len(req.MemberIDs) > 100 {
writeJSONError(w, "초대할 멤버는 1명 이상 100명 이하여야 합니다", http.StatusBadRequest)
return
}
// 빈 ID, 나 자신, 중복된 ID를 걸러 냅니다.
seen := map[uuid.UUID]struct{}{userID: {}}
members := make([]uuid.UUID, 0, len(req.MemberIDs))
for _, id := range req.MemberIDs {
if id == uuid.Nil {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
members = append(members, id)
}
if len(members) == 0 {
writeJSONError(w, "나 자신 외에 초대할 멤버가 필요합니다", http.StatusBadRequest)
return
}
var room db.Room
err := database.WithTx(r.Context(), h.pool, func(q *db.Queries) error {
rCreated, err := q.CreateGroupRoom(r.Context(), db.CreateGroupRoomParams{
Name: &roomName,
CreatedBy: &userID,
})
if err != nil {
return err
}
room = rCreated
// 방장(생성자) 등록
if err := q.AddRoomMember(r.Context(), db.AddRoomMemberParams{
RoomID: room.ID,
UserID: userID,
Role: "owner",
LastReadSeq: 0,
JoinedSeq: 0,
}); err != nil {
return err
}
// 초대된 멤버들 등록
for _, memberID := range members {
// 실제 존재하는 활성 유저인지 확인
u, err := q.GetUserByID(r.Context(), memberID)
if errors.Is(err, pgx.ErrNoRows) {
return errInvalidMember
}
if err != nil {
return err
}
if u.Status != "active" {
return errInvalidMember
}
if err := q.AddRoomMember(r.Context(), db.AddRoomMemberParams{
RoomID: room.ID,
UserID: memberID,
Role: "member",
LastReadSeq: 0,
JoinedSeq: 0,
}); err != nil {
return err
}
}
return nil
})
if err != nil {
if errors.Is(err, errInvalidMember) {
writeJSONError(w, err.Error(), http.StatusBadRequest)
return
}
// 그 밖의 에러는 DB 에러 원문이 클라이언트에 노출되지 않도록 일반 메시지로 바꿉니다.
writeJSONError(w, "그룹 채팅방 생성에 실패했습니다", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"room": room,
})
}
// 3. 내 채팅방 목록 조회 (GET /api/v1/rooms)
func (h *Handler) ListMyRooms(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.GetUserID(r.Context())
if !ok {
writeJSONError(w, "인증 정보가 없습니다", http.StatusUnauthorized)
return
}
rooms, err := h.queries.ListMyRooms(r.Context(), userID)
if err != nil {
writeJSONError(w, "채팅방 목록을 불러오지 못했습니다", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"rooms": rooms,
})
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeJSONError(w http.ResponseWriter, msg string, status int) {
writeJSON(w, status, map[string]string{"error": msg})
}
5. main.go 에 친구 및 채팅방 라우터 연결하기 (server/cmd/api/main.go)
6편에서 작성한 server/cmd/api/main.go를 세 군데만 고칩니다. 나머지(DB 연결, /health, CORS, 서버 종료 처리 등)는 6편 코드 그대로 둡니다.
① import에 두 패키지 추가
"github.com/yourname/dingdong-server/internal/friend" // 👈 추가 "github.com/yourname/dingdong-server/internal/room" // 👈 추가
② 핸들러 초기화 — authHandler := ... 줄 바로 아래에 두 줄 추가
friendHandler := friend.NewHandler(pool, queries, []byte(phoneSecret)) roomHandler := room.NewHandler(pool, queries)
③ 라우터 등록 — 6편의 protected 그룹 안, /api/v1/users/me 아래에 추가
protected.Get("/api/v1/users/me", authHandler.GetMe)
// 친구 동기화 및 목록 조회 👈 추가
protected.Post("/api/v1/friends/sync", friendHandler.SyncContacts)
protected.Get("/api/v1/friends", friendHandler.ListFriends)
// 채팅방 생성 및 목록 조회 👈 추가
protected.Get("/api/v1/rooms", roomHandler.ListMyRooms)
protected.Post("/api/v1/rooms/direct", roomHandler.CreateDirectRoom)
protected.Post("/api/v1/rooms/group", roomHandler.CreateGroupRoom)
버전 표시를 올리고 싶다면 /health 응답과 시작 로그의 v0.3.0을 v0.4.0으로 바꾸면 됩니다.
6. 실전 연동 테스트 (curl)
서버를 재시작한 뒤(go run ./cmd/api 또는 NAS docker compose up -d --build), 두 번째 가족 계정(mommy)을 가입시키고 첫 번째 계정(daddy)에서 연락처 동기화 및 1:1 채팅방 생성을 테스트합니다.
daddy의 accessToken이 필요하면 6편에서 받아 둔 값을 쓰거나, 아래처럼 다시 로그인해서 받습니다. (유효기간이 15분이라 만료되었을 수 있습니다.)
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"loginId": "daddy", "password": "my_secure_password_123!", "platform": "macos", "deviceName": "Mac Studio M4"}'
6.1 두 번째 가족 계정(mommy) 회원가입
curl -X POST http://localhost:3000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"inviteCode": "DING26",
"loginId": "mommy",
"password": "mom_secure_password_123!",
"displayName": "안방마님",
"phone": "+821098765432",
"platform": "ios",
"deviceName": "iPhone 16 Pro"
}'
6.2 첫 번째 계정(daddy)으로 연락처 동기화 호출
daddy 계정의 accessToken을 헤더에 넣고, 하이픈이 포함된 로컬 전화번호(010-9876-5432)와 같은 번호를 다른 형식(+82 10-9876-5432)으로 적은 값, 가입자가 아닌 번호를 함께 보내 봅니다.
curl -X POST http://localhost:3000/api/v1/friends/sync \
-H "Authorization: Bearer DADDY_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phones": ["010-9876-5432", "+82 10-9876-5432", "010-0000-0000"]
}'
응답 결과 (200 OK):
{
"friends": [
{
"id": "0192c9b1-3d44-7000-9abc-1234567890ab",
"display_name": "안방마님",
"status_message": null,
"avatar_file_id": null,
"tag": "friend",
"nickname": null,
"blocked": false,
"created_at": "2026-10-31T09:30:00Z"
}
]
}
두 가지 표기가 모두 서버에서 +821098765432로 정규화되어 하나로 합쳐지고, HMAC 해시가 일치하는 안방마님 계정이 친구로 등록됩니다. 가입자가 아닌 번호는 조용히 무시되며, 응답에는 phone_hash가 들어 있지 않습니다.
6.3 1:1 채팅방 생성 및 채팅방 목록 조회
위에서 찾은 친구의 id 를 targetUserId 에 넣어 1:1 방을 생성한 뒤 목록을 조회합니다.
# 1:1 채팅방 생성 (여러 번 호출해도 동일한 방 ID가 반환됨)
curl -X POST http://localhost:3000/api/v1/rooms/direct \
-H "Authorization: Bearer DADDY_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"targetUserId": "0192c9b1-3d44-7000-9abc-1234567890ab"
}'
# 내 채팅방 목록 조회
curl -X GET http://localhost:3000/api/v1/rooms \
-H "Authorization: Bearer DADDY_ACCESS_TOKEN"
목록 조회 응답 결과 (200 OK):
{
"rooms": [
{
"id": "a7f8d9e0-1234-4567-89ab-cdef01234567",
"type": "direct",
"display_title": "안방마님",
"display_avatar_id": null,
"last_seq": 0,
"last_read_seq": 0,
"unread_count": 0,
"pinned": false,
"muted": false,
"last_message_type": null,
"last_message_text": null,
"last_message_at": null,
"updated_at": "2026-10-31T09:32:00Z"
}
]
}
1:1 채팅방은 별도의 방 이름(r.name)이 없더라도 상대방의 이름(안방마님)이 display_title로 내려옵니다. mommy의 토큰으로 같은 목록을 조회하면 같은 방이 우리집 대장이라는 제목으로 보입니다.
7. 이번 편 요약 및 다음 단계
이번 8편에서는 메신저의 소셜 그래프(친구 관계)와 대화방 관리 백엔드 API를 완성했습니다.
- E.164 정규화 및 HMAC 연락처 매칭: 하이픈이나 공백이 섞인 전화번호를 표준 규격으로 통일(가입 때와 동기화 때 같은 함수 사용)한 뒤 서버 비밀키 기반 HMAC 해시로 매칭하고, 공개 프로필 컬럼만 반환하도록 분리했습니다.
- 원자적 1:1 방 생성:
direct_key정렬과ON CONFLICT구문을 결합해 중복 방 생성을 DB 레벨에서 차단했습니다. - 고속 채팅방 목록 쿼리:
rooms.last_seq와messages인덱스를 조인해 안 읽은 메시지 개수와 마지막 메시지 미리보기를 단일 쿼리로 가져왔습니다.
아직 적용하지 않은 것 (추후 보완 사항)
- 연락처 동기화 호출 빈도 제한 (Rate Limit): 1회 요청당 500개로 제한했으나, 짧은 시간에 수만 번 호출하면 여전히 번호 대역을 탐색할 수 있습니다. 사용자별 일일 호출 횟수 제한(예: 하루 10회)을 추가하는 것이 좋습니다.
- 단방향 친구 등록 구조: 현재
/api/v1/friends/sync는 요청한 사람의friendships에만 상대방을 추가합니다(카카오톡과 동일한 단방향 주소록 모델). 따라서 그룹 채팅방에 초대할 때 상대방도 나를 친구로 등록했는지 확인하는 옵션은 아직 없으며, 활성 사용자면 초대할 수 있습니다. - 전화번호 소유 인증 없음: 가입할 때 입력한 번호가 정말 본인 것인지 확인하지 않습니다(SMS 인증 등). 초대받은 가족끼리만 쓰는 지금은 괜찮지만, 남의 번호를 적어 가입하면 그 사람의 연락처에 내 이름이 뜹니다. 외부에 공개하기 전에 반드시 보완해야 합니다.
- 차단 기능의 API가 아직 없음:
friendships.blocked를 바꾸는 API가 없어서 지금은 항상false입니다. 1:1 방을 만들 때의 차단 확인(IsBlockedByUser)은 차단 API를 만든 뒤에야 실제로 동작합니다. - 로그인·내 정보 응답의
phone_hash: 6편의/auth/*와/users/me응답은 아직db.User전체를 내려보내므로 응답 전용 구조체로 분리해야 합니다. - 채팅방 나가기 및 재입장 처리: 1:1 방에서 한쪽이 나갔다가 다시 메시지가 오면 방이 재활성화되고
joined_seq이후 메시지만 보이게 하는 세밀한 상태 관리는 추후 메시지 편에서 다룹니다.
백엔드에 친구와 채팅방 API가 준비되었으니, 다음 [개발일지 #09 | 클라이언트]에서는 다시 Flutter 앱으로 넘어가 하단 탭 네비게이션(친구 탭 / 채팅 탭), 연락처 동기화 연동, 그리고 채팅방 생성 및 목록 화면 을 구현하겠습니다.