[개발일지 #06 | 백엔드] 초대코드 회원가입, Argon2id 암호화 & 다중 기기 JWT 인증 구현하기

안녕하세요! 카카오톡 스타일의 가족/친구용 프라이빗 크로스플랫폼 메신저 ‘딩동(DingDong)’ 만들기 프로젝트 6편입니다.

지난 [5편]에서는 메신저의 심장인 PostgreSQL 16 스키마(11개 핵심 테이블)를 구축하고, sqlc와 pgxpool을 연동해 타입 안전한 DB 레이어를 완성했습니다.
이번 6편에서는 그 데이터베이스 위에 프라이빗 초대코드 검증, 요즘 권장되는 Argon2id 비밀번호 해싱, 다중 기기(스마트폰/태블릿/PC) 동시 접속을 지원하는 JWT Access/Refresh 토큰 시스템, 그리고 Chi 인증 미들웨어를 처음부터 끝까지 구현해 보겠습니다.


1. 딩동 메신저의 4대 보안 & 인증 설계 원칙

가족과 지인끼리만 안전하게 사용하는 프라이빗 메신저이기 때문에, 아무나 가입할 수 없도록 입구부터 철저하게 설계했습니다.

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. 입력값 검증 + Argon2id 해싱"| B["트랜잭션 시작 (WithTx)"]:::nodeStyle
    B -->|"2. 하나의 트랜잭션 안에서 순서대로 실행"| C["초대코드 차감 → users → auth_identities → devices"]:::nodeStyle
    C -->|"3. 모두 성공하면 커밋 (하나라도 실패하면 롤백)"| D["JWT 발급 & 응답"]:::titleStyle
  • 초대코드 기반 폐쇄형 가입 (invite_codes): 관리자가 발급한 초대코드가 있어야만 가입할 수 있습니다. 동시에 여러 명이 같은 코드로 가입할 때 한도(max_uses)가 뚫리지 않도록 DB 레벨의 원자적 업데이트(UPDATE ... WHERE used_count < max_uses)를 적용합니다. 초대코드 차감은 회원 생성과 같은 트랜잭션 안에서 실행되므로, 가입이 중간에 실패하면 사용 횟수도 함께 되돌아갑니다.
  • 차세대 비밀번호 해싱 (Argon2id): 2015년 패스워드 해싱 대회(PHC) 우승 알고리즘인 Argon2id는 메모리 하드(Memory-hard) 방식이라, GPU/ASIC을 이용한 무차별 대입(Brute-force) 공격의 비용을 크게 높여 줍니다. (bcrypt도 여전히 안전하다고 평가받는 알고리즘이지만, 최근 신규 서비스에는 Argon2id가 우선 권장됩니다.)
  • 다중 기기 세션 분리 (devices 테이블): 스마트폰, 아이패드, 맥북에서 동시에 로그인해도 서로 튕기지 않도록 기기별로 고유한 Refresh Token을 발급하고, 원본이 아닌 SHA-256 해시값만 DB에 저장합니다. DB가 유출되어도 해시만으로는 원본 토큰을 알아낼 수 없습니다. (Refresh Token은 32바이트 암호학적 난수라서 SHA-256만으로 충분합니다.)
  • 초경량 Access Token + 안전한 Refresh Token:
    • Access Token (유효기간 15분): API 호출 및 WebSocket 연결 시 사용. DB 조회 없이 서버 메모리에서 즉시 검증(Stateless).
    • Refresh Token (유효기간 30일): Access Token 만료 시 재발급용. 기기 분실 시 특정 기기의 Refresh Token을 삭제해 원격 로그아웃할 수 있습니다.
    • ⚠️ 이미 발급된 Access Token은 최대 15분간 계속 유효하고, 이미 연결된 WebSocket도 자동으로 끊기지 않습니다. 기기를 원격 로그아웃할 때는 해당 기기의 WebSocket 연결도 서버에서 함께 끊어야 하며, 이 부분은 채팅(WebSocket) 편에서 다룹니다.

2. 필수 암호화 및 JWT 패키지 설치

내 맥(Mac) 터미널의 server/ 폴더에서 Go 공식 확장 암호화 패키지(x/crypto)와 널리 쓰이는 JWT 라이브러리(golang-jwt/jwt/v5), UUID 라이브러리를 설치합니다.

cd server

# 1. Argon2id 암호화를 위한 Go 공식 확장 패키지
go get golang.org/x/crypto/argon2

# 2. JWT 생성 및 검증 라이브러리 (v5)
go get github.com/golang-jwt/jwt/v5

# 3. UUID 라이브러리 (사용자 ID 타입)
go get github.com/google/uuid

3. 인증 관련 SQL 쿼리 작성 및 sqlc 코드 생성

5편에서 만든 server/queries/ 폴더 안에 인증 전용 쿼리 파일(auth.sql)을 추가합니다.

3.1 server/queries/auth.sql 작성

VS Code에서 server/queries/auth.sql 파일을 만들고 아래 쿼리를 붙여넣습니다.

-- name: UseInviteCode :one
-- 유효기간이 남고 사용 횟수가 초과되지 않은 초대코드만 원자적으로 1 증가시킴
UPDATE invite_codes
SET used_count = used_count + 1
WHERE code = $1
  AND expires_at > now()
  AND used_count < max_uses
RETURNING *;

-- name: CreateInviteCode :one
INSERT INTO invite_codes (code, owner_id, max_uses, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING *;

-- name: CreateAuthIdentity :one
INSERT INTO auth_identities (user_id, provider, provider_uid, password_hash)
VALUES ($1, $2, $3, $4)
RETURNING *;

-- name: GetAuthIdentityByUID :one
SELECT * FROM auth_identities
WHERE provider = $1 AND provider_uid = $2;

-- name: CreateDevice :one
INSERT INTO devices (
    user_id, platform, name, refresh_token_hash, refresh_expires_at, fcm_token, app_version, last_active_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
RETURNING *;

-- name: GetDeviceByRefreshToken :one
SELECT * FROM devices
WHERE refresh_token_hash = $1
  AND refresh_expires_at > now();

-- name: UpdateDeviceRefreshToken :one
UPDATE devices
SET refresh_token_hash = $2,
    refresh_expires_at = $3,
    last_active_at = now()
WHERE id = $1
RETURNING *;

-- name: DeleteDevice :exec
DELETE FROM devices
WHERE id = $1 AND user_id = $2;

💡 참고: expires_at > now() 조건은 expires_at이 NULL이면 항상 거짓이 되어 그 코드는 사용할 수 없습니다. 5편 스키마에서 invite_codes.expires_at이 NOT NULL인지 확인해 주세요. (만료 없는 코드를 허용하고 싶다면 AND (expires_at IS NULL OR expires_at > now())로 바꾸면 됩니다.)

3.2 sqlc generate 실행하여 Go 코드 자동 생성

터미널에서 명령어 한 줄로 방금 작성한 SQL을 Go 함수로 변환합니다!

sqlc generate

실행 즉시 internal/database/db/auth.sql.go 파일이 생성되며, UseInviteCode, CreateAuthIdentity, CreateDevice 등의 타입 안전한 메서드가 준비됩니다.


4. 보안 핵심 모듈 구현 (internal/auth/)

이제 server/internal/auth/ 폴더를 만들고 4개의 핵심 파일을 차례대로 작성합니다.

server/internal/auth/
 ├─ password.go      # 1. Argon2id 비밀번호 해싱 & 검증, SHA-256 / HMAC 해시 유틸
 ├─ jwt.go           # 2. JWT Access Token 및 랜덤 Refresh Token 생성기
 ├─ middleware.go    # 3. Chi 라우터용 JWT 인증 수문장 미들웨어
 └─ handler.go       # 4. 회원가입(/register), 로그인(/login), 토큰 갱신(/refresh) API

4.1 비밀번호 해싱 및 보안 유틸 (server/internal/auth/password.go)

Argon2id(메모리 64MB, 반복 1회, 병렬 4스레드) 설정입니다. OWASP가 제시하는 최소 권장 조합(예: 메모리 19MiB·반복 2회·병렬 1, 또는 메모리 46MiB·반복 1회·병렬 1)보다 메모리를 넉넉하게 쓰는 설정이라, 최소 기준 이상이라고 볼 수 있습니다.

⚠️ NAS 자원 참고: 해싱 한 번에 메모리 64MB를 사용합니다. 가족용 규모에서는 문제없지만, 동시에 수십 명이 가입/로그인하면 메모리가 순간적으로 커집니다. 나중에 외부에 공개한다면 동시 해싱 개수를 제한(세마포어 등)하는 것을 고려하세요.

package auth

import (
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/hex"
	"errors"
	"fmt"
	"strings"

	"golang.org/x/crypto/argon2"
)

// Argon2id 파라미터 (OWASP 최소 권장 이상, 저사양 NAS에서도 동작하는 수준)
const (
	argonTime    = 1
	argonMemory  = 64 * 1024 // 64 MB
	argonThreads = 4
	argonKeyLen  = 32
	saltLen      = 16
)

// HashPassword: 평문 비밀번호를 Argon2id 표준 문자열 포맷으로 해싱합니다.
func HashPassword(password string) (string, error) {
	salt := make([]byte, saltLen)
	if _, err := rand.Read(salt); err != nil {
		return "", err
	}

	hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)

	b64Salt := base64.RawStdEncoding.EncodeToString(salt)
	b64Hash := base64.RawStdEncoding.EncodeToString(hash)

	// 표준 PHC 문자열 포맷으로 반환
	encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
		argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash)
	return encoded, nil
}

// VerifyPassword: 입력된 평문 비밀번호와 DB의 해시값을 타이밍 공격에 안전하게 비교합니다.
// 해시 문자열 안에 저장된 파라미터(m, t, p)로 검증하므로,
// 나중에 위의 const 값을 바꿔도 기존 사용자의 비밀번호는 계속 검증됩니다.
func VerifyPassword(password, encodedHash string) (bool, error) {
	parts := strings.Split(encodedHash, "$")
	// "$argon2id$v=19$m=...,t=...,p=...$salt$hash" → 앞이 빈 문자열이라 6조각
	if len(parts) != 6 || parts[1] != "argon2id" {
		return false, errors.New("잘못된 해시 포맷입니다")
	}

	var version int
	if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
		return false, err
	}
	if version != argon2.Version {
		return false, errors.New("지원하지 않는 Argon2 버전입니다")
	}

	var memory, iterations uint32
	var threads uint8
	if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil {
		return false, err
	}

	salt, err := base64.RawStdEncoding.DecodeString(parts[4])
	if err != nil {
		return false, err
	}

	decodedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
	if err != nil {
		return false, err
	}

	comparisonHash := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(decodedHash)))

	// ConstantTimeCompare로 타이밍 공격(Timing Attack) 방지
	return subtle.ConstantTimeCompare(decodedHash, comparisonHash) == 1, nil
}

// HashSHA256: Refresh Token처럼 충분히 긴 암호학적 난수를 DB에 저장하기 전
// 단방향 SHA-256 해시로 변환합니다.
// ⚠️ 전화번호처럼 경우의 수가 작은 값에는 사용하지 마세요! (아래 HashHMAC 사용)
func HashSHA256(input string) string {
	sum := sha256.Sum256([]byte(input))
	return hex.EncodeToString(sum[:])
}

// HashHMAC: 전화번호(E.164)처럼 경우의 수가 작은 값을 서버 비밀키로 해싱합니다.
// 그냥 SHA-256만 쓰면 전화번호 대역(약 수천만 가지)을 전부 계산해 원래 번호를 복원할 수 있지만,
// 비밀키가 들어간 HMAC은 키 없이는 복원할 수 없습니다.
func HashHMAC(key []byte, input string) string {
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(input))
	return hex.EncodeToString(mac.Sum(nil))
}

4.2 JWT 및 Refresh 토큰 매니저 (server/internal/auth/jwt.go)

Access Token은 HMAC-SHA256(HS256)으로 서명하고, Refresh Token은 예측 불가능한 32바이트 암호학적 난수로 발급합니다. 토큰 검증 시에는 허용 알고리즘(HS256)과 발급자(Issuer)를 명시적으로 확인합니다.

package auth

import (
	"crypto/rand"
	"encoding/base64"
	"errors"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"
)

const tokenIssuer = "dingdong-server"

type Claims struct {
	UserID uuid.UUID `json:"uid"`
	Role   string    `json:"role"`
	jwt.RegisteredClaims
}

type TokenManager struct {
	secretKey []byte
}

func NewTokenManager(secret string) *TokenManager {
	return &TokenManager{secretKey: []byte(secret)}
}

// GenerateAccessToken: 15분 수명의 초경량 JWT Access Token 발급
func (tm *TokenManager) GenerateAccessToken(userID uuid.UUID, role string) (string, error) {
	now := time.Now()
	claims := Claims{
		UserID: userID,
		Role:   role,
		RegisteredClaims: jwt.RegisteredClaims{
			IssuedAt:  jwt.NewNumericDate(now),
			ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
			Issuer:    tokenIssuer,
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(tm.secretKey)
}

// ValidateAccessToken: JWT 서명, 만료시간, 알고리즘, 발급자 검증
func (tm *TokenManager) ValidateAccessToken(tokenStr string) (*Claims, error) {
	token, err := jwt.ParseWithClaims(
		tokenStr,
		&Claims{},
		func(t *jwt.Token) (interface{}, error) {
			return tm.secretKey, nil
		},
		jwt.WithValidMethods([]string{"HS256"}), // HS256 외의 알고리즘은 거부
		jwt.WithIssuer(tokenIssuer),             // 우리 서버가 발급한 토큰만 허용
	)
	if err != nil {
		return nil, err
	}

	claims, ok := token.Claims.(*Claims)
	if !ok || !token.Valid {
		return nil, errors.New("유효하지 않은 토큰입니다")
	}
	return claims, nil
}

// GenerateRefreshToken: 30일 수명의 암호학적 난수 Refresh Token 생성 (원본과 DB저장용 해시 함께 반환)
func (tm *TokenManager) GenerateRefreshToken() (rawToken string, tokenHash string, expiresAt time.Time, err error) {
	b := make([]byte, 32)
	if _, err = rand.Read(b); err != nil {
		return "", "", time.Time{}, err
	}
	rawToken = base64.RawURLEncoding.EncodeToString(b)
	tokenHash = HashSHA256(rawToken)
	expiresAt = time.Now().Add(30 * 24 * time.Hour) // 30일 유지
	return rawToken, tokenHash, expiresAt, nil
}

4.3 인증 수문장 미들웨어 (server/internal/auth/middleware.go)

클라이언트가 요청 헤더에 담아 보낸 Authorization: Bearer <토큰>을 검사하고, 통과하면 요청 컨텍스트(r.Context())에 로그인한 유저의 UUID와 권한을 심어주는 미들웨어입니다. 에러 응답은 4.4에서 만들 writeJSONError를 사용해 Content-Type: application/json으로 내려갑니다.

package auth

import (
	"context"
	"net/http"
	"strings"

	"github.com/google/uuid"
)

type contextKey string

const (
	UserIDKey contextKey = "userID"
	RoleKey   contextKey = "userRole"
)

// Authenticator: 보호된 라우터 그룹 앞에 세워두는 JWT 검증 미들웨어
func Authenticator(tm *TokenManager) func(next http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			authHeader := r.Header.Get("Authorization")
			if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
				writeJSONError(w, "인증 토큰이 필요합니다", http.StatusUnauthorized)
				return
			}

			tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
			claims, err := tm.ValidateAccessToken(tokenStr)
			if err != nil {
				writeJSONError(w, "만료되었거나 유효하지 않은 토큰입니다", http.StatusUnauthorized)
				return
			}

			// 컨텍스트에 유저 ID와 Role 주입
			ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
			ctx = context.WithValue(ctx, RoleKey, claims.Role)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// GetUserID: 핸들러 내부에서 현재 로그인한 사용자의 UUID를 꺼내는 헬퍼 함수
func GetUserID(ctx context.Context) (uuid.UUID, bool) {
	id, ok := ctx.Value(UserIDKey).(uuid.UUID)
	return id, ok
}

4.4 회원가입 / 로그인 / 토큰 갱신 핸들러 (server/internal/auth/handler.go)

5편에서 만든 database.WithTx 트랜잭션 래퍼가 여기서 빛을 발합니다! 회원가입 시 ① 초대코드 차감 ➡️ ② 유저 생성 ➡️ ③ 로그인 계정 생성 ➡️ ④ 기기 세션 등록이라는 4단계 작업이 단 하나라도 실패하면 전부 깔끔하게 롤백(Rollback)됩니다.

이번 핸들러에는 두 가지 보안 장치가 들어 있습니다.

  • 전화번호는 HMAC-SHA256(서버 비밀키)로 저장: 경우의 수가 작은 전화번호는 일반 SHA-256만으로는 복원될 수 있기 때문입니다.
  • 로그인 시 존재하지 않는 아이디여도 더미 해시 검증을 한 번 수행: 응답 속도 차이로 “이 아이디는 가입되어 있다”는 사실이 노출되는 것을 막습니다.

⚠️ 주의: 아래 코드의 import 경로에 있는 github.com/yourname/dingdong-server 부분은 본인의 go.mod 모듈 이름에 맞게 변경해 주세요!

package auth

import (
	"encoding/json"
	"errors"
	"net/http"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/yourname/dingdong-server/internal/database"
	"github.com/yourname/dingdong-server/internal/database/db"
)

type Handler struct {
	pool      *pgxpool.Pool
	queries   *db.Queries
	tm        *TokenManager
	phoneKey  []byte // 전화번호 HMAC 해싱용 서버 비밀키
	dummyHash string // 로그인 시 없는 아이디의 응답 시간을 맞추기 위한 더미 해시
}

func NewHandler(pool *pgxpool.Pool, queries *db.Queries, tm *TokenManager, phoneKey []byte) *Handler {
	dummy, err := HashPassword("dingdong-dummy-password")
	if err != nil {
		panic("더미 해시 생성 실패: " + err.Error()) // 난수 생성 실패는 사실상 일어나지 않음
	}
	return &Handler{pool: pool, queries: queries, tm: tm, phoneKey: phoneKey, dummyHash: dummy}
}

// --- 요청/응답 DTO ---

type RegisterRequest struct {
	InviteCode  string  `json:"inviteCode"`
	LoginID     string  `json:"loginId"`
	Password    string  `json:"password"`
	DisplayName string  `json:"displayName"`
	Phone       *string `json:"phone,omitempty"` // 예: "+821012345678"
	Platform    string  `json:"platform"`        // "android" | "ios" | "macos"
	DeviceName  string  `json:"deviceName"`      // 예: "아빠 Mac Studio"
}

type LoginRequest struct {
	LoginID    string `json:"loginId"`
	Password   string `json:"password"`
	Platform   string `json:"platform"`
	DeviceName string `json:"deviceName"`
}

type RefreshRequest struct {
	RefreshToken string `json:"refreshToken"`
}

type AuthResponse struct {
	AccessToken  string  `json:"accessToken"`
	RefreshToken string  `json:"refreshToken"`
	User         db.User `json:"user"`
}

// 1. 초대코드 기반 회원가입 (POST /api/v1/auth/register)
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
	var req RegisterRequest
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 요청 본문 1MB 제한
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSONError(w, "잘못된 요청 형식입니다", http.StatusBadRequest)
		return
	}

	if req.InviteCode == "" || req.LoginID == "" || len(req.Password) < 8 || len(req.Password) > 128 || req.DisplayName == "" {
		writeJSONError(w, "필수 입력값이 누락되었거나 비밀번호가 너무 짧습니다(최소 8자, 최대 128자)", http.StatusBadRequest)
		return
	}
	if req.Platform == "" {
		req.Platform = "unknown"
	}

	pwHash, err := HashPassword(req.Password)
	if err != nil {
		writeJSONError(w, "비밀번호 암호화 실패", http.StatusInternalServerError)
		return
	}

	// 전화번호는 서버 비밀키를 쓰는 HMAC-SHA256으로 저장 (일반 SHA-256 사용 금지)
	var phoneHash *string
	if req.Phone != nil && *req.Phone != "" {
		hashed := HashHMAC(h.phoneKey, *req.Phone)
		phoneHash = &hashed
	}

	rawRefresh, refreshHash, refreshExp, err := h.tm.GenerateRefreshToken()
	if err != nil {
		writeJSONError(w, "토큰 생성 실패", http.StatusInternalServerError)
		return
	}

	var createdUser db.User

	// 5편에서 만든 WithTx로 4단계 DB 작업을 하나의 원자적 트랜잭션으로 묶음!
	// 초대코드 차감(①)도 이 트랜잭션 안에 있으므로, ②~④가 실패하면 사용 횟수도 함께 롤백됩니다.
	err = database.WithTx(r.Context(), h.pool, func(q *db.Queries) error {
		// ① 초대코드 검증 및 사용 횟수 +1
		// (필요 시 반환된 초대코드의 owner_id로 users.invited_by를 채우도록 확장 가능)
		_, err := q.UseInviteCode(r.Context(), req.InviteCode)
		if err != nil {
			return errors.New("유효하지 않거나 만료된 초대코드입니다")
		}

		// ② 사용자(users) 생성
		createdUser, err = q.CreateUser(r.Context(), db.CreateUserParams{
			DisplayName:   req.DisplayName,
			StatusMessage: nil,
			PhoneHash:     phoneHash,
			Role:          "user",
		})
		if err != nil {
			return errors.New("이미 등록된 전화번호이거나 사용자 생성에 실패했습니다")
		}

		// ③ 로그인 수단(auth_identities) 생성
		_, err = q.CreateAuthIdentity(r.Context(), db.CreateAuthIdentityParams{
			UserID:       createdUser.ID,
			Provider:     "password",
			ProviderUid:  req.LoginID,
			PasswordHash: &pwHash,
		})
		if err != nil {
			return errors.New("이미 사용 중인 로그인 아이디입니다")
		}

		// ④ 기기 세션(devices) 등록
		_, err = q.CreateDevice(r.Context(), db.CreateDeviceParams{
			UserID:           createdUser.ID,
			Platform:         req.Platform,
			Name:             &req.DeviceName,
			RefreshTokenHash: refreshHash,
			RefreshExpiresAt: refreshExp,
		})
		if err != nil {
			// DB 에러 원문이 클라이언트에 노출되지 않도록 메시지를 바꿔서 반환
			return errors.New("기기 세션 등록에 실패했습니다")
		}
		return nil
	})

	if err != nil {
		writeJSONError(w, err.Error(), http.StatusBadRequest)
		return
	}

	accessToken, err := h.tm.GenerateAccessToken(createdUser.ID, createdUser.Role)
	if err != nil {
		writeJSONError(w, "액세스 토큰 발급 실패", http.StatusInternalServerError)
		return
	}

	writeJSON(w, http.StatusCreated, AuthResponse{
		AccessToken:  accessToken,
		RefreshToken: rawRefresh,
		User:         createdUser,
	})
}

// 2. 로그인 (POST /api/v1/auth/login)
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
	var req LoginRequest
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSONError(w, "잘못된 요청 형식입니다", http.StatusBadRequest)
		return
	}

	identity, err := h.queries.GetAuthIdentityByUID(r.Context(), db.GetAuthIdentityByUIDParams{
		Provider:    "password",
		ProviderUid: req.LoginID,
	})
	if err != nil || identity.PasswordHash == nil {
		// 없는 아이디여도 더미 해시 검증을 돌려서 응답 시간 차이로 가입 여부가 드러나지 않게 함
		_, _ = VerifyPassword(req.Password, h.dummyHash)
		writeJSONError(w, "아이디 또는 비밀번호가 일치하지 않습니다", http.StatusUnauthorized)
		return
	}

	match, err := VerifyPassword(req.Password, *identity.PasswordHash)
	if err != nil || !match {
		writeJSONError(w, "아이디 또는 비밀번호가 일치하지 않습니다", http.StatusUnauthorized)
		return
	}

	user, err := h.queries.GetUserByID(r.Context(), identity.UserID)
	if err != nil {
		writeJSONError(w, "활성화되지 않은 계정입니다", http.StatusForbidden)
		return
	}

	rawRefresh, refreshHash, refreshExp, err := h.tm.GenerateRefreshToken()
	if err != nil {
		writeJSONError(w, "토큰 생성 실패", http.StatusInternalServerError)
		return
	}

	if req.Platform == "" {
		req.Platform = "unknown"
	}

	_, err = h.queries.CreateDevice(r.Context(), db.CreateDeviceParams{
		UserID:           user.ID,
		Platform:         req.Platform,
		Name:             &req.DeviceName,
		RefreshTokenHash: refreshHash,
		RefreshExpiresAt: refreshExp,
	})
	if err != nil {
		writeJSONError(w, "기기 세션 등록 실패", http.StatusInternalServerError)
		return
	}

	accessToken, err := h.tm.GenerateAccessToken(user.ID, user.Role)
	if err != nil {
		writeJSONError(w, "액세스 토큰 발급 실패", http.StatusInternalServerError)
		return
	}

	writeJSON(w, http.StatusOK, AuthResponse{
		AccessToken:  accessToken,
		RefreshToken: rawRefresh,
		User:         user,
	})
}

// 3. 토큰 재발급 - Refresh Token Rotation 적용 (POST /api/v1/auth/refresh)
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
	var req RefreshRequest
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RefreshToken == "" {
		writeJSONError(w, "리프레시 토큰이 필요합니다", http.StatusBadRequest)
		return
	}

	inputHash := HashSHA256(req.RefreshToken)
	device, err := h.queries.GetDeviceByRefreshToken(r.Context(), inputHash)
	if err != nil {
		writeJSONError(w, "만료되었거나 유효하지 않은 리프레시 토큰입니다 (다시 로그인해 주세요)", http.StatusUnauthorized)
		return
	}

	user, err := h.queries.GetUserByID(r.Context(), device.UserID)
	if err != nil {
		writeJSONError(w, "사용자를 찾을 수 없습니다", http.StatusUnauthorized)
		return
	}

	// [보안 꿀팁: Refresh Token Rotation]
	// 리프레시 토큰을 한 번 사용할 때마다 새 리프레시 토큰으로 교체하여 탈취 위험을 줄입니다.
	newRawRefresh, newRefreshHash, newRefreshExp, err := h.tm.GenerateRefreshToken()
	if err != nil {
		writeJSONError(w, "토큰 갱신 실패", http.StatusInternalServerError)
		return
	}

	_, err = h.queries.UpdateDeviceRefreshToken(r.Context(), db.UpdateDeviceRefreshTokenParams{
		ID:               device.ID,
		RefreshTokenHash: newRefreshHash,
		RefreshExpiresAt: newRefreshExp,
	})
	if err != nil {
		writeJSONError(w, "세션 갱신 실패", http.StatusInternalServerError)
		return
	}

	newAccessToken, err := h.tm.GenerateAccessToken(user.ID, user.Role)
	if err != nil {
		writeJSONError(w, "액세스 토큰 발급 실패", http.StatusInternalServerError)
		return
	}

	writeJSON(w, http.StatusOK, AuthResponse{
		AccessToken:  newAccessToken,
		RefreshToken: newRawRefresh,
		User:         user,
	})
}

// 4. 내 정보 조회 - 보호된 API 테스트용 (GET /api/v1/users/me)
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
	userID, ok := GetUserID(r.Context())
	if !ok {
		writeJSONError(w, "인증 정보가 없습니다", http.StatusUnauthorized)
		return
	}

	user, err := h.queries.GetUserByID(r.Context(), userID)
	if err != nil {
		writeJSONError(w, "사용자 정보를 불러올 수 없습니다", http.StatusNotFound)
		return
	}

	writeJSON(w, http.StatusOK, map[string]interface{}{
		"user":       user,
		"serverTime": time.Now().Format(time.RFC3339),
	})
}

// 공통 JSON 응답 헬퍼
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)

이제 5편에서 작성했던 cmd/api/main.go에 TokenManager와 auth.Handler를 연결해 줍니다.

이번 버전부터는 비밀키를 코드에 적어두지 않습니다. JWT_SECRET(JWT 서명용)과 PHONE_HASH_SECRET(전화번호 해싱용)을 환경변수로 반드시 지정해야 서버가 시작되며, 둘 다 최소 32자 이상의 랜덤 값이어야 합니다.

⚠️ PHONE_HASH_SECRET을 나중에 바꾸면 이미 저장된 전화번호 해시가 전부 무효가 됩니다. 한 번 정한 뒤에는 안전한 곳에 백업해 두세요.

package main

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/go-chi/cors"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/yourname/dingdong-server/internal/auth"
	"github.com/yourname/dingdong-server/internal/database/db"
)

func main() {
	ctx := context.Background()

	port := os.Getenv("PORT")
	if port == "" {
		port = "3000"
	}

	// DB 접속 주소(비밀번호 포함)는 코드에 적지 않고 환경변수로만 받습니다. (5편과 같은 방식)
	dbURL := os.Getenv("DATABASE_URL")
	if dbURL == "" {
		log.Fatal("DATABASE_URL 환경변수가 필요합니다. 예: postgres://dingdong:비밀번호@localhost:5432/dingdong?sslmode=disable")
	}

	// 비밀키는 코드에 기본값을 두지 않고, 없거나 짧으면 서버를 시작하지 않습니다.
	jwtSecret := os.Getenv("JWT_SECRET")
	if len(jwtSecret) < 32 {
		log.Fatal("JWT_SECRET 환경변수가 없거나 너무 짧습니다(최소 32자). 예: export JWT_SECRET=$(openssl rand -base64 48)")
	}
	phoneSecret := os.Getenv("PHONE_HASH_SECRET")
	if len(phoneSecret) < 32 {
		log.Fatal("PHONE_HASH_SECRET 환경변수가 없거나 너무 짧습니다(최소 32자). 예: export PHONE_HASH_SECRET=$(openssl rand -base64 48)")
	}

	// 1. PostgreSQL 커넥션 풀 초기화
	config, err := pgxpool.ParseConfig(dbURL)
	if err != nil {
		log.Fatalf("DB 설정 파싱 실패: %v", err)
	}
	config.MaxConns = 25
	config.MinConns = 5
	config.MaxConnIdleTime = 5 * time.Minute

	pool, err := pgxpool.NewWithConfig(ctx, config)
	if err != nil {
		log.Fatalf("PostgreSQL 연결 풀 생성 실패: %v", err)
	}
	defer pool.Close()

	if err := pool.Ping(ctx); err != nil {
		log.Fatalf("PostgreSQL Ping 실패: %v", err)
	}
	log.Println("🐘 PostgreSQL 16 연결 풀 초기화 성공!")

	// 2. 의존성 주입 (sqlc Queries, TokenManager, AuthHandler)
	queries := db.New(pool)
	tokenManager := auth.NewTokenManager(jwtSecret)
	authHandler := auth.NewHandler(pool, queries, tokenManager, []byte(phoneSecret))

	// 3. Chi 라우터 설정
	r := chi.NewRouter()
	r.Use(middleware.RequestID)
	r.Use(middleware.RealIP)
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)

	// 개발 단계용 느슨한 CORS 설정입니다. (네이티브 앱은 CORS와 무관하며,
	// 웹 클라이언트를 만들게 되면 허용할 도메인만 지정하도록 좁혀야 합니다.)
	r.Use(cors.Handler(cors.Options{
		AllowedOrigins:   []string{"https://*", "http://*"},
		AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
		ExposedHeaders:   []string{"Link"},
		AllowCredentials: false,
		MaxAge:           300,
	}))

	// 4. 일반 REST API 라우터 그룹
	r.Group(func(api chi.Router) {
		api.Use(middleware.Timeout(60 * time.Second))

		// 헬스체크
		api.Get("/health", func(w http.ResponseWriter, r *http.Request) {
			// DB 확인이 오래 걸려 헬스체크 전체가 멈추지 않도록 2초로 제한 (5편과 같은 방식)
			pingCtx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
			defer cancel()

			status, dbStatus, code := "ok", "ok", http.StatusOK
			if err := pool.Ping(pingCtx); err != nil {
				status, dbStatus, code = "degraded", "unreachable", http.StatusServiceUnavailable
			}

			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(code)
			json.NewEncoder(w).Encode(map[string]string{
				"status":  status,
				"db":      dbStatus,
				"version": "v0.3.0",
			})
		})

		// [공개 API] 회원가입, 로그인, 토큰 재발급 (인증 불필요)
		api.Route("/api/v1/auth", func(r chi.Router) {
			r.Post("/register", authHandler.Register)
			r.Post("/login", authHandler.Login)
			r.Post("/refresh", authHandler.Refresh)
		})

		// [보호된 API] JWT 인증 미들웨어 통과 필수!
		api.Group(func(protected chi.Router) {
			protected.Use(auth.Authenticator(tokenManager))

			protected.Get("/api/v1/users/me", authHandler.GetMe)
		})
	})

	// 5. 서버 실행 및 Graceful Shutdown
	server := &http.Server{
		Addr:              ":" + port,
		Handler:           r,
		ReadHeaderTimeout: 10 * time.Second, // 큰 파일 업로드를 막지 않도록 ReadTimeout 대신 사용 (4편 참고)
		WriteTimeout:      0,
		IdleTimeout:       60 * time.Second,
	}

	go func() {
		log.Printf("🚀 딩동 Go 백엔드 서버(v0.3.0)가 포트 %s에서 시작되었습니다!", port)
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("서버 시작 실패: %v", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	_ = server.Shutdown(shutdownCtx)
	log.Println("서버가 안전하게 종료되었습니다.")
}

작성이 완료되었으면 터미널에서 패키지 정리를 한 번 실행해 줍니다. (설치하지 않은 chi, cors, pgx 같은 패키지도 이 명령이 자동으로 받아 줍니다.)

go mod tidy

5.1 NAS 배포 때 .env에 비밀키 추가하기

맥에서 테스트를 마치고 NAS에 배포할 때는 4편에서 만든 /volume1/docker/dingdong/.env 파일에 비밀키 두 줄을 추가해야 합니다. 4편의 compose가 .env 파일을 api 컨테이너에 그대로 전달하므로 docker-compose.yml은 고칠 필요가 없습니다. (DATABASE_URL도 compose가 자동으로 만들어 넣어 줍니다.)

값을 직접 지어내지 말고, NAS 터미널(SSH)에서 아래 명령으로 랜덤 값을 만들어 바로 파일 끝에 추가하세요.

cd /volume1/docker/dingdong
echo "JWT_SECRET=$(openssl rand -base64 48)" >> .env
echo "PHONE_HASH_SECRET=$(openssl rand -base64 48)" >> .env

⚠️ 서버는 값의 길이만 검사하므로, 설명용 문구(“최소_32자_이상…”)를 그대로 넣어도 시작은 됩니다. 그러면 비밀키가 사실상 공개된 문장이 되니 꼭 위 명령으로 만든 랜덤 값을 쓰세요.
.env 파일 마지막 줄 끝에 줄바꿈이 없으면 echo로 추가한 값이 윗줄에 붙어 버릴 수 있습니다. 추가한 뒤 tail -n 3 .env로 줄이 나뉘어 있는지 확인하세요.


6. 실전 테스트: 첫 초대코드 생성부터 회원가입 & 내 정보 조회까지!

닭이 먼저냐 달걀이 먼저냐의 문제!
초대코드(invite_codes)가 있어야 회원가입을 할 수 있는데, 아직 회원이 0명이라 초대코드를 만든 사람(owner_id)도 없습니다. 그래서 최초 1회, 시스템 관리자 계정과 첫 번째 마스터 초대코드(DING26)를 DB에 직접 심어주는 부트스트랩(Bootstrap) SQL을 실행합니다.

6.1 마스터 초대코드(DING26) 생성하기

유그린 NAS 터미널(또는 DBeaver)에서 아래 명령어를 한 줄 실행하여 마스터 초대코드를 만듭니다:

docker exec -i dingdong-postgres psql -U dingdong -d dingdong << 'EOF'
WITH admin_user AS (
    INSERT INTO users (display_name, role)
    VALUES ('시스템 관리자', 'admin')
    RETURNING id
)
INSERT INTO invite_codes (code, owner_id, max_uses, expires_at)
SELECT 'DING26', id, 100, now() + interval '365 days'
FROM admin_user;
EOF

이제 1년 동안 가족 100명이 가입할 수 있는 황금 초대코드 DING26이 준비되었습니다!

💡 DING26은 연습용으로 외우기 쉽게 만든 코드입니다. 초대코드가 짧고 추측하기 쉬우면 무차별 대입으로 뚫릴 수 있으니, 실제 운영할 때는 영문 대문자+숫자를 섞은 추측하기 어려운 코드로 바꾸고, 사용 횟수(max_uses)도 가족 수에 맞게 작게 잡으세요.

6.2 회원가입 API 호출 테스트 (curl)

먼저 서버가 사용할 비밀키 두 개를 환경변수로 설정합니다. (터미널을 새로 열면 다시 설정해야 합니다.)

export JWT_SECRET=$(openssl rand -base64 48)
export PHONE_HASH_SECRET=$(openssl rand -base64 48)

NAS에서 docker compose로 실행한다면, 위 두 값(JWT_SECRET, PHONE_HASH_SECRET)을 /volume1/docker/dingdong/.env 파일에 같은 이름으로 추가해야 합니다. (4편의 compose가 .env를 api 컨테이너에 전달하고, DATABASE_URL은 compose가 자동으로 만들어 넣어 줍니다.) 이 값이 없으면 서버가 시작 직후 종료됩니다.

맥에서 go run ./cmd/api로 직접 실행한다면, 위 두 값에 더해 5편 3.4절처럼 SSH 터널을 열고 DATABASE_URL도 지정해야 합니다.

그다음 서버를 띄운 뒤(go run ./cmd/api 또는 NAS docker compose up -d --build), 터미널에서 회원가입 요청을 보내봅니다:

curl -X POST http://localhost:3000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "inviteCode": "DING26",
    "loginId": "daddy",
    "password": "my_secure_password_123!",
    "displayName": "우리집 대장",
    "phone": "+821012345678",
    "platform": "macos",
    "deviceName": "Mac Studio M4"
  }'

응답 결과 (201 Created):

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "8fK3x9_pLmN2qR7vW1zA4bC6dE0gH5jK9lM3nP6qR8s",
  "user": {
    "id": "0192c8a4-7b12-7000-8123-456789abcdef",
    "display_name": "우리집 대장",
    "status_message": null,
    "avatar_file_id": null,
    "phone_hash": "a1b2c3d4e5f6...",
    "role": "user",
    "status": "active",
    "invited_by": null,
    "created_at": "2026-10-28T06:15:00Z",
    "updated_at": "2026-10-28T06:15:00Z"
  }
}

전화번호는 평문 대신 서버 비밀키가 들어간 HMAC-SHA256 해시(phone_hash)로 저장되었고, accessToken과 refreshToken이 동시에 발급되었습니다!

6.3 발급받은 JWT로 보호된 API(/api/v1/users/me) 호출하기

위 응답에서 받은 accessToken 값을 복사해 Bearer 헤더에 넣어서 호출해 봅니다:

curl -X GET http://localhost:3000/api/v1/users/me \
  -H "Authorization: Bearer 여기에_발급받은_accessToken_붙여넣기"

미들웨어가 DB 조회 없이 토큰 서명만으로 즉시 검증하고, 내 프로필 정보를 정확히 반환하는 것을 확인할 수 있습니다!


7. 이번 편 요약 및 다음 단계

이번 6편을 통해 딩동 메신저는 탄탄한 인증의 뼈대를 갖추었습니다:

  1. 프라이빗 초대제 완성: 초대코드 없이는 가입할 수 없는 가족/지인 전용 폐쇄형 구조 확립. 초대코드 차감과 회원 생성이 한 트랜잭션이라 중간에 실패해도 깔끔하게 롤백됩니다.
  2. Argon2id + 용도별 해시 저장: 비밀번호는 메모리 하드 해싱(Argon2id)으로, 전화번호는 서버 비밀키를 쓰는 HMAC-SHA256으로, 리프레시 토큰은 SHA-256 단방향 해시로 저장해 DB가 유출되었을 때의 피해를 줄였습니다.
  3. Refresh Token Rotation & 다중 기기 지원: 기기마다 독립된 세션을 부여하고, 토큰을 갱신할 때마다 새 리프레시 토큰으로 교체합니다.

아직 적용하지 않은 것 (외부에 공개하기 전에 꼭 챙기세요)

  • 요청 횟수 제한(Rate Limit): /register와 /login에 아직 제한이 없어 초대코드나 비밀번호를 무차별 대입할 수 있습니다. IP별 요청 제한 미들웨어를 추가해야 합니다.
  • Refresh Token 동시 사용: 같은 리프레시 토큰으로 요청 두 개가 동시에 들어오면 둘 다 성공할 수 있습니다. 또한 이미 교체된 옛 토큰이 다시 사용되었을 때 세션 전체를 폐기하는 탈취 감지 로직은 아직 없습니다.
  • 로그인할 때마다 기기 행이 새로 생김: 같은 기기에서 로그아웃 없이 다시 로그인하면 devices에 행이 계속 쌓입니다. 기기 식별자를 도입해 정리하는 것을 고려하세요.
  • 응답 객체 분리: 지금은 db.User를 그대로 응답하므로 phone_hash 같은 내부 필드도 클라이언트에 내려갑니다. 응답 전용 구조체를 따로 만드는 것이 좋습니다.
  • 기기 원격 로그아웃 시 WebSocket 끊기: 1장에서 설명한 대로, 기기를 삭제해도 연결된 WebSocket은 서버가 직접 끊어야 합니다.

다음 [개발일지 #07]에서는 스마트폰(Flutter)에서 이 로그인 API를 연동해 자동 로그인(Secure Storage)을 구현하고, 메신저의 꽃인 친구 추가(연락처 해시 매칭) 및 1:1 / 그룹 채팅방 생성 API를 만들어 보겠습니다!

📌 7편 예고 보충: 연락처 매칭은 클라이언트가 전화번호를 서버로 보내면 서버가 같은 비밀키로 HMAC 해시를 계산해 비교하는 방식으로 구현합니다. (클라이언트는 서버 비밀키를 알 수 없으므로, 해시를 클라이언트에서 계산하지 않습니다.)

📂 Part 1. 기획 & 아키텍처 설계

📂 Part 2. 홈서버 & 인프라 구축

📂 Part 3. 백엔드(API) & 실시간 통화 서버

📂 Part 4. 모바일 앱 개발 (iOS / Android)

  • ✅ [개발일지 #07 | 클라이언트] Flutter 앱 첫 연동: Secure Storage 토큰 관리, Dio 자동 갱신 & 로그인 화면 구현
  • ⏳ [#09 | 클라이언트] 친구 목록 동기화 & 채팅방 목록 UI 연동하기 (예정)
  • ⏳ [#11 | 클라이언트] 실시간 WebSocket 채팅방 화면 & 끊김 없는 대화 동기화 구현 (예정)
  • ⏳ [#12 | 앱-공통] 300MB 대용량 원본 사진/영상 전송 및 미디어 처리 (예정)
  • ⏳ [#13 | 앱-푸시] 앱이 꺼져 있어도 울리는 푸시 알림(FCM/APNs) 및 보이스톡 연동 (예정)

📂 Part 5. 정식 배포 & 운영

  • ⏳ [#14 | 배포] 백엔드 서버 도커 이미지 빌드 및 유그린 NAS 정식 배포 (예정)
  • ⏳ [#15 | 운영] DB 자동 백업 스크립트 구성 및 가족 실사용 후기 (예정)

댓글 남기기