[개발일지 #08 | 백엔드] 연락처 HMAC 해시 친구 매칭 & 중복 없는 1:1 / 그룹 채팅방 API 구현

카카오톡 스타일의 가족/친구용 프라이빗 크로스플랫폼 메신저 ‘딩동(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를 완성했습니다.

  1. E.164 정규화 및 HMAC 연락처 매칭: 하이픈이나 공백이 섞인 전화번호를 표준 규격으로 통일(가입 때와 동기화 때 같은 함수 사용)한 뒤 서버 비밀키 기반 HMAC 해시로 매칭하고, 공개 프로필 컬럼만 반환하도록 분리했습니다.
  2. 원자적 1:1 방 생성: direct_key 정렬과 ON CONFLICT 구문을 결합해 중복 방 생성을 DB 레벨에서 차단했습니다.
  3. 고속 채팅방 목록 쿼리: 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 앱으로 넘어가 하단 탭 네비게이션(친구 탭 / 채팅 탭), 연락처 동기화 연동, 그리고 채팅방 생성 및 목록 화면 을 구현하겠습니다.

댓글 남기기