안녕하세요! 카카오톡 스타일의 가족/친구용 프라이빗 크로스플랫폼 메신저 ‘딩동(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편을 통해 딩동 메신저는 탄탄한 인증의 뼈대를 갖추었습니다:
- 프라이빗 초대제 완성: 초대코드 없이는 가입할 수 없는 가족/지인 전용 폐쇄형 구조 확립. 초대코드 차감과 회원 생성이 한 트랜잭션이라 중간에 실패해도 깔끔하게 롤백됩니다.
- Argon2id + 용도별 해시 저장: 비밀번호는 메모리 하드 해싱(Argon2id)으로, 전화번호는 서버 비밀키를 쓰는 HMAC-SHA256으로, 리프레시 토큰은 SHA-256 단방향 해시로 저장해 DB가 유출되었을 때의 피해를 줄였습니다.
- 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. 기획 & 아키텍처 설계
- ✅ [개발일지 #01 | 기획] 유그린 NAS와 Flutter로 만드는 나만의 프라이빗 메신저 ‘딩동’ (기획 & 아키텍처 편)
📂 Part 2. 홈서버 & 인프라 구축
- ✅ [개발일지 #02 | 인프라] Mac에서 Flutter & VS Code 개발 환경 구축하기 (초보자 완벽 가이드)
- ✅ [개발일지 #03 | 인프라] 유그린 NAS에 Caddy & PostgreSQL 구축하기
📂 Part 3. 백엔드(API) & 실시간 통화 서버
- ✅ [개발일지 #04 | 백엔드] 유그린 NAS에 Go(Golang) 초경량 메신저 백엔드 서버 구축하기
- ✅ [개발일지 #05 | 백엔드] 메신저 DB 스키마 설계 & Go sqlc 연동하기
- ✅ [개발일지 #06 | 백엔드] 초대코드 회원가입, Argon2id 암호화 & 다중 기기 JWT 인증 구현하기
- ⏳ [#08 | 백엔드] 연락처 HMAC 해시 친구 매칭 & 1:1 / 그룹 채팅방 생성 API (예정)
- ⏳ [#10 | 백엔드] Gorilla WebSocket 실시간 채팅 허브 & 단조 증가 순번(
seq) 동기화 (예정)
📂 Part 4. 모바일 앱 개발 (iOS / Android)
- ✅ [개발일지 #07 | 클라이언트] Flutter 앱 첫 연동: Secure Storage 토큰 관리, Dio 자동 갱신 & 로그인 화면 구현
- ⏳ [#09 | 클라이언트] 친구 목록 동기화 & 채팅방 목록 UI 연동하기 (예정)
- ⏳ [#11 | 클라이언트] 실시간 WebSocket 채팅방 화면 & 끊김 없는 대화 동기화 구현 (예정)
- ⏳ [#12 | 앱-공통] 300MB 대용량 원본 사진/영상 전송 및 미디어 처리 (예정)
- ⏳ [#13 | 앱-푸시] 앱이 꺼져 있어도 울리는 푸시 알림(FCM/APNs) 및 보이스톡 연동 (예정)
📂 Part 5. 정식 배포 & 운영
- ⏳ [#14 | 배포] 백엔드 서버 도커 이미지 빌드 및 유그린 NAS 정식 배포 (예정)
- ⏳ [#15 | 운영] DB 자동 백업 스크립트 구성 및 가족 실사용 후기 (예정)