[개발일지 #04 | 백엔드] 유그린 NAS에 Go(Golang) 초경량 메신저 백엔드 서버 구축하기

카카오톡 같은 크로스플랫폼 메신저 ‘딩동(DingDong)’ 만들기 프로젝트 4탄!
이번 글에서는 우리 집 유그린 NAS의 제한된 하드웨어 자원(RAM, CPU)을 200% 활용하기 위해, 메모리를 30MB도 쓰지 않는 초경량 고성능 Go(Golang) 백엔드 서버 뼈대를 구축하고 유그린 NAS 도커 환경에 컨테이너로 올리는 과정을 다룹니다.


1. 왜 Node.js나 Python 대신 ‘Go(Golang)’를 선택했을까요?

개인용 NAS나 홈서버에서 24시간 백엔드 서버를 띄울 때 가장 신경 쓰이는 것은 메모리(RAM) 점유율입니다.

  1. 극도의 저전력 & 초경량 메모리:
    • Node.js나 Spring, Python 등은 런타임과 가비지 컬렉터 기본 상주 메모리 때문에 기본 150MB~500MB를 차지합니다.
    • 반면 Go는 단일 기계어 바이너리로 컴파일되어 20~50MB 수준의 메모리만 사용합니다. 4GB 저사양 NAS에서도 부담이 전혀 없습니다.
  2. 동시성 처리의 끝판왕 (고루틴, Goroutine):
    • 메신저는 수많은 사용자가 동시에 WebSocket 연결을 유지해야 합니다. Go의 경량 스레드인 고루틴은 수만 개의 실시간 연결도 CPU 낭비 없이 매끄럽게 처리합니다.
  3. 초소형 Docker 배포:
    • 의존성 라이브러리가 모두 실행 파일 하나에 압축되어 들어가므로, Docker scratch 또는 alpine 베이스를 사용하면 도커 이미지 크기가 고작 20~30MB에 불과합니다.

2. Go 백엔드 핵심 아키텍처 & 기술 스택

실제 메신저 운영에 필요한 핵심 부품들을 엄선했습니다.

flowchart LR
    A["스마트폰 앱 (Flutter)"] -->|REST / WebSocket| B["Chi 라우터 & Gorilla WS"]
    B --> C["비즈니스 로직 계층 (Service)"]
    C -->|Type-safe 쿼리| D["sqlc & pgx 드라이버"]
    D --> E[(PostgreSQL 16 DB)]
    C -->|비동기 작업 큐| F["Asynq (Redis)"]
    F -->|푸시 발송| G["FCM / APNs"]
    C -->|토큰 발급| H["LiveKit Go SDK"]

    linkStyle default stroke-width:3px,stroke:#333333
  • HTTP 라우터: go-chi/chi (Go 표준 net/http와 100% 호환되는 가장 가볍고 빠른 라우터)
  • 실시간 소켓: gorilla/websocket (채팅방 브로드캐스트 및 실시간 이벤트 중계)
  • DB 쿼리/생성기: sqlc (순수 SQL을 작성하면 타입 안전한 Go 코드를 자동 생성, ORM 오버헤드 0)
  • DB 마이그레이션: golang-migrate (SQL 버전 파일로 테이블 자동 생성/관리)
  • 비동기 작업 큐: asynq + Redis (FCM 푸시 알림 비동기 발송 및 45초 부재중 전화 체크)

3. Mac 개발 환경에 Go 및 도구 설치하기

내 컴퓨터(Mac)에서 Go 코드를 작성하고 빌드할 수 있도록 기본 도구를 설치합니다.

3.1 Go 컴파일러 설치

터미널을 열고 Homebrew로 Go 최신 버전을 설치합니다:

# Go 설치
brew install go

# 설치 확인 (go1.23 이상 권장)
go version

3.2 SQL 코드 생성기(sqlc) 및 마이그레이션 도구 설치

# SQL 코드를 Go 코드로 변환해주는 sqlc 설치
brew install sqlc

# DB 마이그레이션 도구 설치
brew install golang-migrate

4. 백엔드 프로젝트 초기화 (server/)

프로젝트 루트 폴더에 server 디렉토리를 만들고 Go 모듈을 생성합니다.

# 1. server 디렉토리 생성 및 이동
mkdir -p server
cd server

# 2. Go 모듈 초기화 (본인 깃허브 계정에 맞게 변경)
go mod init github.com/yourname/dingdong-server

# 3. 필수 라이브러리 설치
go get github.com/go-chi/chi/v5
go get github.com/go-chi/cors
go get github.com/gorilla/websocket
go get github.com/jackc/pgx/v5
go get github.com/redis/go-redis/v9
go get github.com/hibiken/asynq

5. 클린 아키텍처 폴더 구조

복잡하지 않으면서도 유지보수가 쉬운 Go 표준 패키지 레이아웃을 구성합니다:

server/
 ├─ cmd/
 │   └─ api/
 │       └─ main.go            # 서버 진입점 (설정 로드, DB 연결, 서버 실행)
 ├─ internal/
 │   ├─ config/                # 환경변수 로더
 │   ├─ database/              # PostgreSQL 연결 풀 (pgx)
 │   ├─ auth/                  # JWT 발급, 검증, 미들웨어
 │   ├─ user/                  # 회원가입, 프로필, 연락처 매칭
 │   ├─ chat/                  # 채팅방, 메시지, WebSocket 허브
 │   ├─ call/                  # LiveKit 통화 시그널링
 │   └─ push/                  # FCM / APNs 푸시 작업
 ├─ migrations/                # DB 테이블 생성용 .sql 파일
 ├─ sqlc.yaml                  # sqlc 설정 파일
 ├─ Dockerfile                 # 유그린 NAS 배포용 멀티스테이지 도커파일
 ├─ go.mod
 └─ go.sum

“※ 위 폴더 구조는 이번 시리즈를 진행하며 단계별로 하나씩 채워나갈 전체 설계도입니다. 지금 단계에서는 당장 실행에 필요한 cmd/api/main.go 파일부터 차근차근 만들어 보겠습니다.”


6. 첫 번째 헬스체크 API 만들기 (cmd/api/main.go)

서버가 잘 작동하는지 확인하기 위해 기본 HTTP 서버와 /health 엔드포인트를 만듭니다.

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"
)

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

	r := chi.NewRouter()

	// 1. 공통 기반 미들웨어
	r.Use(middleware.RequestID)
	r.Use(middleware.RealIP)
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)

	// 2. 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, // 와일드카드(*) Origin 사용 시 false가 웹 표준상 안전합니다.
		MaxAge:           300,
	}))

	// 3. 일반 REST API 라우터 그룹 (타임아웃 60초 적용)
	r.Group(func(api chi.Router) {
		api.Use(middleware.Timeout(60 * time.Second))

		api.Get("/health", func(w http.ResponseWriter, r *http.Request) {
			res := map[string]string{
				"status":  "ok",
				"server":  "dingdong-go",
				"time":    time.Now().Format(time.RFC3339),
				"version": "v0.1.0",
			}
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusOK)
			json.NewEncoder(w).Encode(res)
		})
	})

	// 4. 실시간 WebSocket 엔드포인트 그룹
	// [중요!] WebSocket은 상시 연결이 유지되어야 하므로 Timeout 미들웨어가 없는 그룹에 둡니다.
	r.Group(func(ws chi.Router) {
		ws.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("WebSocket 엔드포인트 (추후 구현)"))
		})
	})

	// 5. 서버 설정 및 Graceful Shutdown (우아한 종료)
	server := &http.Server{
		Addr:        ":" + port,
		Handler:     r,
		ReadTimeout: 15 * time.Second,
		// [중요 실무 팁!] WriteTimeout이 0이 아니면 WebSocket으로 업그레이드된 후에도
		// 정해진 시간 뒤 소켓 쓰기가 강제 차단됩니다.
		// 따라서 WriteTimeout은 0(비활성)으로 두고, 일반 REST API는 chi의 middleware.Timeout으로 제어합니다.
		WriteTimeout: 0,
		IdleTimeout:  60 * time.Second,
	}

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

	// 종료 시그널(Ctrl+C, SIGTERM 등) 대기
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
	log.Println("서버 종료 신호를 감지했습니다. 안전하게 종료합니다...")

	// 최대 10초 대기하며 진행 중인 작업 마무리 후 종료
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := server.Shutdown(ctx); err != nil {
		log.Fatalf("강제 종료 에러: %v", err)
	}
	log.Println("서버가 안전하게 종료되었습니다.")
}

로컬 실행 테스트

# 서버 실행
go run cmd/api/main.go

# 다른 터미널에서 헬스체크 요청
curl http://localhost:3000/health

응답 결과:

{
  "server": "dingdong-go",
  "status": "ok",
  "time": "2026-10-14T13:20:00+09:00",
  "version": "v0.1.0"
}

7. 유그린 NAS 배포용 초경량 Dockerfile 만들기

7.1 Dockerfile이 도대체 무슨 의미인가요? (쉽게 이해하기)

방금 6번에서는 내 맥북에서 go run 명령어로 서버를 띄웠습니다.
하지만 우리의 최종 목표는 맥북을 끄더라도 집에 있는 유그린 NAS에서 24시간 내내 이 서버가 돌아가게 만드는 것입니다.

그런데 유그린 NAS에는 Go 언어나 컴파일러가 깔려 있지 않습니다.
그래서 **”이 서버 프로그램을 실행할 수 있는 초소형 포장 박스(Docker 컨테이너)”**를 만들어야 합니다.

Dockerfile은 바로 그 포장 박스를 만드는 **’조립 설명서’**입니다:

  • 1단계 (Builder 작업실): “Go가 설치된 임시 작업실에서 우리가 짠 main.go 코드를 실행 파일(server) 하나로 딱 구워낸다!”
  • 2단계 (가벼운 도시락통): “다 구웠으면 무거운 Go 개발 도구들은 다 버리고, 잘 구워진 작은 빵(server) 하나만 아주 가벼운 도시락통(alpine 리눅스)에 쏙 담는다!”

이 기법을 **멀티스테이지 빌드(Multi-stage Build)**라고 부르며, NAS에 이것저것 설치할 필요 없이 이 가벼운 포장 박스 하나만 NAS에 올려주면 알아서 24시간 돌아가게 됩니다.


7.2 어디에, 어떻게 만드나요?

  1. VS Code의 좌측 탐색기에서 server 폴더를 우클릭 ➡️ **[새 파일]**을 누릅니다.
  2. 파일 이름으로 확장자 없이 대소문자를 정확히 지켜 Dockerfile 이라고 입력합니다.
    (최종 파일 위치: server/Dockerfile)
  3. 열린 빈 파일에 아래 코드를 그대로 붙여넣고 저장(Cmd + S)합니다.
# 1단계: 빌드 환경 (Builder 작업실)
FROM golang:1.23-alpine AS builder

WORKDIR /app
RUN apk add --no-cache git ca-certificates

# 의존성 캐시 활용
COPY go.mod go.sum ./
RUN go mod download

# 소스코드 복사 및 단일 정적 바이너리로 컴파일
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /app/server cmd/api/main.go

# 2단계: 실제 실행 환경 (가벼운 도시락통)
FROM alpine:3.20

WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata
ENV TZ=Asia/Seoul

COPY --from=builder /app/server /app/server

EXPOSE 3000
ENTRYPOINT ["/app/server"]

💡 도커 이미지 크기의 기적: Node.js 기반 이미지가 보통 300MB~1GB인 반면, 위 Dockerfile로 빌드된 Go 이미지는 단 25MB에 불과합니다! 유그린 NAS에 배포할 때 다운로드와 업데이트가 눈 깜짝할 사이에 끝납니다.


7.3 이걸 만들고 나면 어떻게 쓰이나요?

이렇게 server/Dockerfile을 만들어 두면, 바로 다음 **8번의 docker-compose.yml**에서:

api:
    build:
      context: ./server
      dockerfile: Dockerfile

와 같이 연결됩니다. 나중에 유그린 NAS에서 docker compose up -d 명령어 하나만 딱 치면, 도커가 이 Dockerfile 조립 설명서를 읽고 알아서 메신저 서버를 25MB 초경량 컨테이너로 뚝딱 조립해서 24시간 켜주게 됩니다!


8. docker-compose.yml 파일은 어디에 만들고 어떻게 추가하나요?

8.1 파일 위치 안내 (내 맥북 vs 유그린 NAS)

5번에서 본 폴더 구조는 server/ 안의 순수 Go 코드 구조였기 때문에 docker-compose.yml이 보이지 않았던 것입니다.
배포 및 인프라 파일들은 다음과 같이 배치합니다:

내 맥(Mac) 프로젝트 위치: deploy/ 폴더를 만들어 관리합니다.

  dingdong/ (최상위 루트)
   ├─ server/                     # Go 백엔드 코드 (main.go, Dockerfile)
   └─ deploy/                     # 👈 [여기에 만듭니다!]
       ├─ docker-compose.yml      # 통합 실행 파일
       ├─ .env                    # 환경변수 파일
       └─ caddy/
           └─ Caddyfile           # Caddy 설정 파일

유그린 NAS 위치: 나중에 NAS로 배포할 때는 SSD 작업 폴더(/volume1/docker/dingdong/)에 그대로 복사해 넣게 됩니다.


8.2 맥북 VS Code에서 파일 만들기

  1. VS Code 좌측 탐색기 빈 공간을 우클릭 ➡️ [새 폴더] ➡️ 이름으로 deploy 입력.
  2. deploy 폴더 우클릭 ➡️ [새 파일] ➡️ 이름으로 docker-compose.yml 입력.
    (최종 경로: deploy/docker-compose.yml)
  3. 파일에 아래의 완성된 YAML 코드를 붙여넣고 저장(Cmd + S)합니다.

8.3 완성된 docker-compose.yml 코드

지난 [3편]의 Caddy, PostgreSQL에 이어 방금 만든 Go API 서버와 백그라운드 큐를 위한 Redis까지 연결된 최종 파일입니다:

version: '3.8'

services:
  caddy:
    image: caddy:2-alpine
    container_name: dingdong-caddy
    restart: unless-stopped
    env_file: .env
    ports:
      - "8080:80"
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy/data:/data
      - ./caddy/config:/config
    networks:
      - app-network

  # [추가됨!] Go 백엔드 API 서버
  api:
    build:
      context: ./server
      dockerfile: Dockerfile
    container_name: dingdong-api
    restart: unless-stopped
    env_file: .env
    environment:
      - PORT=3000
      - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
      - REDIS_URL=redis:6379
    depends_on:
      - postgres
      - redis
    networks:
      - app-network

  postgres:
    image: postgres:16-alpine
    container_name: dingdong-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports:
      - "5432:5432"
    volumes:
      - ./postgres/data:/var/lib/postgresql/data
    networks:
      - app-network

  # [추가됨!] 백그라운드 작업 큐(Asynq) 및 캐시용 Redis
  redis:
    image: redis:7-alpine
    container_name: dingdong-redis
    restart: unless-stopped
    volumes:
      - ./redis/data:/data
    networks:
      - app-network

networks:
  app-network:
    name: dingdong-network

💡 Redis 추가 팁: Asynq를 통한 푸시 알림 발송 및 접속 상태 관리를 위해 Redis 7을 함께 구성했습니다. 포트를 외부에 열지 않고 내부 네트워크(app-network)에서만 통신하므로 안전합니다.


8.4 [중요!] 3편에서 나스에 미리 만들어 둔 파일들도 수정해야 하나요?

네, 맞습니다! 3편을 따라 이미 유그린 나스에 세팅해 두셨다면 아래 3가지를 확인 및 업데이트해 주시면 됩니다:

1. 파일 확장자 안내: docker-compose.yml vs docker-compose.yaml

  • 유그린 UGOS Pro Docker 앱이나 파일 관리자에서 확장자가 .yaml로 바뀌었더라도 걱정하지 마세요!
  • 도커 엔진과 Docker Compose는 기본적으로 .yml과 .yaml을 100% 동일한 파일로 인식합니다. 둘 중 어느 쪽이든 정상 동작합니다.

2. 유그린 나스의 Compose 파일 갱신

  • 위 8.3 코드처럼 api 서비스와 redis 서비스 블록을 추가해 주어야 합니다.
  • api 서비스의 depends_on에 redis가 적혀 있기 때문에, 나스에 redis 컨테이너 설정이 빠져 있으면 도커가 에러를 출력하며 실행을 거부합니다.

3. 유그린 나스의 .env 파일에 3줄 추가

  • 기존의 도메인/DB 설정 아래에 Go 서버, Redis 연결, 그리고 로그인 토큰 암호화용 JWT_SECRET을 추가합니다:
# [이번 4편에서 추가된 항목]
PORT=3000
REDIS_URL=redis:6379
# JWT 인증 토큰 암호화용 비밀키 (최소 32자 이상의 임의 문자열 권장)
JWT_SECRET=dingdong_super_secret_jwt_key_2026_change_this_to_random_string

💡 JWT_SECRET 발급 팁: 맥북이나 리눅스 터미널에서 openssl rand -base64 32 명령어를 입력하면 아주 안전하고 긴 무작위 문자열을 생성할 수 있습니다. 생성된 문자열을 복사해서 붙여넣으시면 됩니다.

(※ deploy/caddy/Caddyfile은 3편에서 작성했던 설정 그대로 유지하시면 됩니다!)


9. 다음 편 예고

이제 유그린 NAS에서 메모리를 30MB도 쓰지 않는 강력한 Go 백엔드 API 엔진의 뼈대가 완성되었습니다!

다음 **[개발일지 #05]**에서는 golang-migrate와 sqlc를 사용해 메신저의 핵심 데이터베이스 스키마(회원 정보, 전화번호 해시, 채팅방, 메시지 순번)를 작성하고, 타입 안전한 SQL 쿼리 코드를 자동으로 생성하는 과정을 실습 위주로 다루겠습니다!

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

📂 Part 2. 홈서버 & 인프라 구축 (UGREEN + Synology)

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

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

  • ⏳ [#07 | 앱-공통] 모바일 앱 프로젝트 생성 및 로그인/채팅방 UI 구현 (예정)
  • ⏳ [#08 | 앱-공통] 300MB 대용량 원본 사진/영상 전송 및 미디어 처리 (예정)
  • ⏳ [#09 | 앱-푸시] 앱이 꺼져 있어도 울리는 푸시 알림(FCM/APNs) 연동 (예정)

📂 Part 5. 정식 배포 & 운영

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

댓글 남기기