Go와 React로 만드는 실시간 채팅 서비스 개발기 (5) - PostgreSQL 연동 및 GKE 네트워크 트러블슈팅

Administrator||조회수 30

지난 포스팅에서는 GitHub Actions와 Argo CD를 활용하여, 코드를 푸시하면 자동으로 빌드되어 GKE 클러스터에 배포되는 GitOps CI/CD 파이프라인을 구축했다. 이로써 개발에 집중할 수 있는 환경은 마련되었으나, 서비스 관점에서는 아직 몇 가지 제한적인 문제가 존재했다.

가장 큰 문제는 데이터의 휘발성이었다. 현재 백엔드 서버는 채팅 메시지를 메모리(RAM)에 저장하고 있어, 배포를 위해 파드가 재시작되거나 스케일링이 발생하면 모든 대화 내역이 사라진다. 또한, 외부 접속 주소가 IP 형태로 노출되어 있고 HTTPS가 적용되지 않아 보안 경고가 발생하는 문제도 해결해야 했다.

이번 포스팅에서는 서비스를 실제 운영 가능한 수준으로 끌어올리기 위해 수행한 데이터베이스 연동(PostgreSQL) 과 그 과정에서 겪은 GKE 네트워크 트러블슈팅, 그리고 도메인 및 HTTPS 적용 과정을 상세히 기록한다.


1. 데이터 영속성 확보: PostgreSQL 연동

메모리에 의존하던 데이터 저장 방식을 GCP Compute Engine(VM)에 구축해 둔 PostgreSQL 데이터베이스로 이관하는 작업을 진행했다.

1.1. 데이터베이스 스키마 설계

단순히 메시지를 저장하는 것을 넘어, 향후 기능 확장을 고려한 스키마 설계가 필요했다. 현재는 단일 채팅방이지만, 추후 '게시글별 채팅'이나 '관리자 1:1 문의' 등으로 확장할 계획이므로, 유연성을 확보하기 위해 room_id 컬럼을 핵심으로 하는 테이블을 설계했다.

Messages 테이블 구조

컬럼명타입설명
idBIGSERIAL (PK)메시지 고유 ID
room_idVARCHAR채팅방 식별자 (Index 적용)
sender_idVARCHAR보낸 사람 식별자 (UUID)
sender_nicknameVARCHAR보낸 사람 닉네임
avatarVARCHAR프로필 이미지 키
contentTEXT메시지 내용
created_atTIMESTAMPTZ전송 시간

room_idcreated_at에 인덱스를 생성하여, 특정 채팅방의 최근 메시지를 조회하는 쿼리 성능을 최적화했다.

1.2. Go 백엔드 구현

Go 언어에서 PostgreSQL을 다루기 위해 성능과 기능 면에서 널리 사용되는 pgx 드라이버를 도입했다. internal/db 패키지를 신설하여 DB 연결 관리와 CRUD의 로직을 분리했다.

서버가 시작될 때 InitDB() 함수를 호출하여 DB 연결을 수립하고, 만약 테이블이 존재하지 않는다면 자동으로 생성하도록 구현하여 배포 시 별도의 DB 세팅 과정을 최소화했다.

// internal/db/database.go 예시 func InitDB() { dsn := fmt.Sprintf("postgres://%s:%s@%s:%s/%s", os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_NAME"), ) // Connection Pool 생성 config, err := pgxpool.ParseConfig(dsn) // ... 에러 처리 및 Pool 생성 ... createTables() // 테이블 자동 생성 }

1.3. 보안: Kubernetes Secret을 활용한 접속 정보 관리

DB 접속 정보(Host, Password 등)는 민감한 정보이므로 소스 코드나 Kubernetes Manifest 파일에 하드코딩해서는 안 된다. 이를 안전하게 관리하기 위해 Kubernetes Secret 오브젝트를 활용했다.

Git에 올라가는 YAML 파일에는 Secret을 참조하라는 설정만 남기고, 실제 비밀번호 값은 터미널에서 kubectl 명령어를 통해 클러스터에 직접 주입하는 방식을 택했다.

# 터미널에서 Secret 생성 (Git에 기록되지 않음) kubectl create secret generic db-secrets \ --from-literal=DB_HOST=[DB_INTERNAL_IP] \ --from-literal=DB_PASSWORD=[PASSWORD] \ ...
# k8s/backend.yaml (Deployment 설정) env: - name: DB_HOST valueFrom: secretKeyRef: name: db-secrets key: DB_HOST # ... 기타 환경 변수 설정

2. [트러블슈팅] GKE 네트워크와 방화벽 문제해결

로컬 개발 환경에서는 SSH 터널링을 통해 DB 접속이 원활했으나, 실제 GKE 클러스터에 배포한 후 백엔드 Pod가 DB에 접속하지 못하고 Dial Timeout 에러를 발생시키는 문제가 발생했다. 이 문제는 클라우드 네이티브 환경의 네트워크 구조를 명확히 이해하지 못해 발생한 이슈였다.

2.1. 문제 현상 및 원인 분석

  • 현상: 백엔드 파드가 실행되다가 DB 연결 시간 초과로 인해 CrashLoopBackOff 상태에 빠짐.
  • 로그 분석: dial tcp 10.10.10.10:5432: i/o timeout 에러 확인. 이는 연결 거부가 아니라 응답이 없는Timeout 상태로, 전형적인 방화벽 차단 증상이었다.

원인: Node IP vs Pod IP Terraform으로 인프라를 구축할 때, DB 서버(VM)의 방화벽 규칙을 "같은 서브넷(10.10.10.0/24)에서의 접근 허용" 으로 설정했었다. GKE의 노드(VM)들은 이 대역에 속하므로 문제가 없어 보였다.

하지만 Kubernetes의 파드(Pod)는 노드의 IP를 사용하지 않고, 별도의 가상 네트워크 대역(Pod CIDR)을 할당받아 통신한다. 로그를 확인해 보니 백엔드 파드는 10.128.x.x 대역의 IP를 사용하고 있었고, DB 서버의 방화벽 입장에서는 허용되지 않은 낯선 IP 대역이었기 때문에 패킷을 차단한 것이다.

alt text

2.2. 해결: Terraform 방화벽 규칙 수정

문제를 해결하기 위해 Terraform 코드(network.tf)를 수정하여 방화벽의 허용 범위를 확장했다. GKE 파드 대역을 포함할 수 있도록 VPC 내부의 모든 사설 IP 대역(10.0.0.0/8)에서의 접근을 허용하도록 변경했다.

# infra/network.tf 수정 resource "google_compute_firewall" "allow_internal_gke" { name = "allow-internal-gke" network = google_compute_network.vpc_network.name # ... 프로토콜 설정 ... # [수정 전] source_ranges = ["10.10.10.0/24"] (노드 대역만 허용) # [수정 후] VPC 내부의 모든 사설 IP 대역 허용 (Pod 대역 포함) source_ranges = ["10.0.0.0/8"] }

terraform apply를 통해 변경된 방화벽 규칙을 적용하자, 백엔드 파드가 DB에 정상적으로 접속하여 테이블을 생성하고 Running 상태로 전환되는 것을 확인할 수 있었다. 이를 통해 데이터가 영구적으로 저장되는 안정적인 백엔드 환경이 구축되었다.


3. 서비스 외부 노출: Ingress와 HTTPS 적용

DB 연동을 통해 내실을 다졌다면, 다음은 사용자가 안전하고 편리하게 접근할 수 있도록 서비스의 입구를 정비할 차례다. 기존에는 프론트엔드와 백엔드 각각에 LoadBalancer 타입의 서비스를 할당하여 별도의 IP를 사용했으나, 이는 비용 효율적이지 않고 SSL/TLS 인증서 관리가 복잡하다는 단점이 있었다.

따라서 Ingress를 도입하여 단일 진입점을 구축하고, 도메인 연결 및 HTTPS를 적용하는 아키텍처로 전환했다.

3.1. 아키텍처 변경: Ingress 도입

GKE의 Ingress는 Google Cloud Load Balancer(GCLB)를 프로비저닝하여 L7 로드밸런싱을 수행한다. 이를 위해 기존의 Service 리소스 타입을 LoadBalancer에서 NodePort 로 변경하여 클러스터 내부에서만 접근 가능하도록 수정하고, Ingress가 트래픽을 라우팅하도록 구성했다.

3.2. 도메인 연결 및 SSL 인증서 발급

안전한 HTTPS 통신을 위해 구글이 관리하는 인증서 Google-managed SSL certificate를 사용했다.

  1. Static IP 예약: Terraform을 통해 전역 고정 IP(google_compute_global_address)를 예약했다. Ingress는 이 IP를 사용하여 외부 트래픽을 수신한다.
  2. DNS 설정: 도메인을 관리하는 AWS Route53에서 chat.jungyu.store A 레코드를 생성하여 예약한 GCP IP를 가리키도록 설정했다. (멀티클라우드 DNS 구성)
  3. ManagedCertificate 생성: 쿠버네티스 매니페스트로 ManagedCertificate 리소스를 정의하여 구글에게 해당 도메인에 대한 인증서 발급을 요청했다.
# k8s/certificate.yaml apiVersion: networking.gke.io/v1 kind: ManagedCertificate metadata: name: chat-ssl-certificate spec: domains: - chat.jungyu.store
  1. Ingress 설정: Ingress 리소스에 주석(Annotation)을 추가하여 앞서 생성한 고정 IP와 인증서를 연결했다.
# k8s/ingress.yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: chat-ingress annotations: kubernetes.io/ingress.global-static-ip-name: realtime-chat-ingress-ip networking.gke.io/managed-certificates: chat-ssl-certificate spec: rules: - http: paths: - path: /api pathType: Prefix backend: service: name: backend-service port: number: 80 # ... (WebSocket 및 프론트엔드 라우팅 규칙)

alt text


4. [트러블슈팅] Ingress 도입 시 발생한 이슈들

Ingress를 적용하는 과정에서 로컬 환경에서는 겪지 못했던 두 가지 주요 이슈가 발생했다.

4.1. 502 Bad Gateway와 헬스 체크

  • 현상: 도메인으로 접속 시 간헐적 혹은 지속적으로 502 Bad Gateway 오류가 발생했다.
  • 원인: GKE Ingress(로드밸런서)는 백엔드 파드가 정상인지 확인하기 위해 루트 경로(/)로 주기적인 헬스 체크(Health Check) 요청을 보낸다. 하지만 백엔드 API 서버는 /api/ws 경로만 처리하고 있어, / 요청에 대해 404 Not Found를 반환했고, 로드밸런서는 이를 장애로 판단하여 트래픽을 차단했다.
  • 해결: 백엔드 코드(main.go)에 루트 경로(/)에 대한 핸들러를 추가하여 200 OK를 반환하도록 수정했다.
// GKE Ingress Health Check용 핸들러 router.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "Health Check OK") })

4.2. WebSocket 연결 끊김 (Timeout)

  • 현상: 채팅방 입장 후 약 30초가 지나면 활동 여부와 관계없이 WebSocket 연결이 강제로 끊어지는 현상이 발생했다.
  • 원인: GCP 로드밸런서의 기본 백엔드 서비스 타임아웃 설정이 30초로 되어 있기 때문이다. 일반적인 HTTP 요청에는 적절하지만, 지속적인 연결이 필요한 WebSocket에는 턱없이 부족한 시간이다.
  • 해결: BackendConfig 라는 CRD(Custom Resource Definition)를 사용하여 로드밸런서의 설정을 커스터마이징했다. 타임아웃을 3600초(1시간)로 늘리고, 이를 백엔드 Service 리소스에 주석으로 연결하여 해결했다.
# k8s/backend-config.yaml apiVersion: cloud.google.com/v1 kind: BackendConfig metadata: name: backend-config spec: timeoutSec: 3600 # 타임아웃을 1시간으로 연장

5. 최종 통합: 블로그에 채팅 심기

모든 백엔드 및 인프라 작업이 완료되었으므로, 마지막으로 기존 운영 중인 Next.js 기반 블로그에 채팅 서비스를 통합하는 작업을 진행했다.

5.1. Iframe을 활용한 마이크로 프론트엔드 전략

블로그(AWS 호스팅)와 채팅 서비스(GCP 호스팅)는 서로 다른 인프라와 기술 스택을 사용한다. 이 둘을 느슨하게 결합하기 위해 Iframe 방식을 선택했다. 이를 통해 블로그의 스타일이나 로직에 영향을 주지 않으면서 독립적인 채팅 애플리케이션을 임베딩할 수 있었다.

5.2. ChatWidget 컴포넌트 구현

블로그 프로젝트 내에 ChatWidget 컴포넌트를 생성했다. Tailwind CSS를 활용하여 우측 하단(모바일은 좌측 하단)에 고정된 플로팅 버튼(FAB)을 구현하고, 버튼 클릭 시 부드러운 애니메이션과 함께 채팅창이 열리도록 했다.

// ChatWidget.tsx 발췌 <div className={`... transition-all duration-300 ... ${isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 ...'}`}> <iframe src="https://chat.jungyu.store" title="Realtime Chat" className="w-full h-full border-none" allow="clipboard-read; clipboard-write" /> </div>

5.3. 통합 프로필 시스템

별도의 회원가입 없이도 사용자에게 개인화된 경험을 제공하기 위해, 백엔드에서 anonymousId를 기반으로 랜덤 닉네임과 아바타를 생성하는 로직을 구현했다. 사용자가 '입장하기' 버튼만 누르면 "총명한 고슴도치"와 같은 닉네임과 그에 매칭되는 아바타가 부여되며, 이 정보는 JWT 토큰에 담겨 세션이 유지되는 동안 일관되게 사용된다.


마치며

이제 데이터는 안전하게 DB에 저장되고, 사용자는 도메인을 통해 보안 연결로 접속할 수 있으며, 블로그 방문자는 언제든 실시간 채팅을 사용할 수 있게 되었다.

하지만 서비스가 잘 돌아가고 있는지 확인하고 서버의 리소스 사용량이나 에러 발생 여부를 실시간으로 파악할 수 있도록 모니터링 환경을 구축할 예정이다. 다음 포스팅에서는 Prometheus와 Grafana를 활용하여 GKE 클러스터에 모니터링 시스템을 구축하는 과정을 자세히 다룰 예정이다.

Administrator
Written by

Administrator

안녕하세요! Deep Dive! 블로그 제작자 입니다.

댓글을 불러오는 중...
대화방 참여하기 👋