개발 공부/프로젝트

Part 1. GCP에서 Oracle Cloud로 이전하기

baby-t 2026. 4. 28. 13:42

1. 이전 배경

기존에 GCP e2-micro 인스턴스(RAM 1GB)에서 운영하던 가상 자산 거래 플랫폼을 Oracle Cloud Free Tier로 이전했습니다.

이유는 단순해요. GCP 무료 티어는 RAM 1GB라서 Kafka, Redis, MySQL, Zookeeper를 동시에 올리면 메모리가 터져요. Oracle Cloud A1.Flex 인스턴스는 무료로 CPU 4코어, RAM 24GB를 제공합니다. 성능 차이가 압도적이에요.

2. 인스턴스 생성 - 자동 재시도 스크립트

Oracle Cloud 무료 인스턴스는 전 세계 개발자들이 노리는 만큼 항상 "Out of capacity" 오류가 떠요. 그래서 PowerShell로 자동 재시도 스크립트를 작성했습니다.

처음에 두 가지 오류가 있었어요.

오류 1 - JSON 키 이름 오류 (camelCase → snake_case):

PowerShell
# 잘못된 것
--shape-config '{"ocpus": 4, "memoryInGBs": 24}'

# 올바른 것
--shape-config '{"ocpus": 4, "memory_in_gbs": 24}'

 

OCI CLI는 snake_case를 요구하는데 camelCase로 넣어서 계속 실패했어요.

 

오류 2 - SSH 키 경로 오류:

oci_api_key_public.pem은 OCI API 인증용 키예요. 인스턴스 SSH 접속용 키는 별도로 생성해야 해요.

PowerShell
ssh-keygen -t rsa -b 2048 -f C:\Users\user\.ssh\oci_ssh_key

이 두 가지 수정 후 약 3일 만에 인스턴스 생성에 성공했습니다.

Plaintext
"lifecycle-state": "PROVISIONING"
Success! Instance created.

3. 서버 기본 세팅

Bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y docker.io docker-compose git openjdk-17-jdk
sudo usermod -aG docker ubuntu
newgrp docker

4. 트러블슈팅 1 - ARM 아키텍처 호환성 문제

Oracle A1.Flex는 ARM64 아키텍처인데, 기존에 쓰던 wurstmeister/kafka와 wurstmeister/zookeeper는 x86 전용 이미지예요.

Plaintext
exec /bin/sh: exec format error

 

Zookeeper는 Restarting (255) 상태로 계속 죽었어요.

해결: ARM64 Multi-platform 빌드가 지원되는 confluentinc 이미지로 교체했습니다.

버전 고정이 중요한 이유: confluentinc/cp-kafka:latest는 Zookeeper 없이 KRaft 모드를 강제해요. 기존 Zookeeper 기반 설정을 유지하려면 7.4.0 버전을 명시적으로 고정해야 안정적으로 배포할 수 있어요.

Plaintext
# latest 사용 시 에러
Error: environment variable "KAFKA_PROCESS_ROLES" is not set

5. 트러블슈팅 2 - Kafka InconsistentClusterIdException

docker-compose를 내렸다가 다시 올릴 때 이런 에러가 났어요.

Plaintext
InconsistentClusterIdException: The Cluster ID doesn't match stored clusterId

Kafka 볼륨에 이전 클러스터 ID가 남아있어서 충돌하는 거예요.

 

해결:

Bash
docker-compose down
docker volume rm virtual-exchange_kafka-data
docker-compose up -d

6. 트러블슈팅 3 - Kafka ADVERTISED_LISTENERS 오류

배포 도중 docker-compose.yml에서 이 설정이 바뀌면서 문제가 생겼어요.

YAML
# 잘못된 것 (외부 IP)
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://144.24.76.180:9092

# 올바른 것
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092

Kafka가 Restarting 무한 루프에 빠졌던 이유는 네트워크 바인딩 오류였어요. 내부 통신을 위한 KAFKA_LISTENERS(0.0.0.0:9092)와 외부 클라이언트(Spring Boot 등)가 찾아오기 위한 주소인 KAFKA_ADVERTISED_LISTENERS를 분리 설정함으로써, Docker 내부 네트워크와 외부 네트워크 간의 통신 병목을 해결했습니다.

Plaintext
# LISTENERS: 카프카가 실제로 귀를 여는 주소 (모든 인터페이스)
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092

# ADVERTISED_LISTENERS: 외부 클라이언트에게 알려주는 주소
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092

이 에러가 발생했어요.

Plaintext
InvalidReplicationFactorException: Replication factor: 1 larger than available brokers: 0

7. Docker Compose 최종 구성 (데이터 영속성 포함)

처음에 volumes 설정 없이 컨테이너를 올렸는데, 이 상태에서 docker-compose down을 치면 MySQL 데이터가 전부 날아갑니다.

로컬에서 docker-compose down 명령으로 파괴 실험을 직접 진행해봤어요. Named Volume이 설정된 컨테이너는 본체가 삭제되어도 외장 하드처럼 데이터를 유지하지만, 설정되지 않은 컨테이너는 즉시 데이터가 영구 소실된다는 도커의 핵심 원리를 몸소 체감했습니다.

Named Volume을 추가해서 데이터 영속성을 보장했습니다.

환경변수 주입 방식: docker-compose 실행 시 ${DB_PASSWORD} 변수를 찾지 못해 빈값으로 설정되는 문제가 있었어요. .env 파일로 해결하는 방법도 있지만 파일 자체가 서버에 남으면 보안 위험이 있어서, 실행 명령어 앞에 직접 주입하는 방식을 선택했습니다.

Bash
DB_PASSWORD=5678 docker-compose up -d
YAML
version: '3'
services:
  mysql:
    image: mysql:8.0
    container_name: stock-mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: stock_db
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    restart: always

  redis:
    image: redis
    container_name: my-redis
    ports:
      - "6379:6379"
    command: redis-server --appendonly yes
    volumes:
      - redis-data:/data
    restart: always

  zookeeper:
    image: confluentinc/cp-zookeeper:7.4.0
    container_name: zookeeper
    ports:
      - "2181:2181"
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000
    restart: always

  kafka:
    image: confluentinc/cp-kafka:7.4.0
    container_name: kafka
    ports:
      - "9092:9092"
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
    volumes:
      - kafka-data:/var/lib/kafka/data
    depends_on:
      - zookeeper
    restart: always

  mongo:
    image: mongo:latest
    container_name: stock-mongo
    ports:
      - "27017:27017"
    volumes:
      - mongo-data:/data/db
    restart: always

volumes:
  mysql-data:
  redis-data:
  kafka-data:
  mongo-data:

restart: always 설정으로 서버 재시작 시 컨테이너가 자동으로 올라옵니다.

8. Spring Boot 빌드 및 실행

Bash
git clone https://github.com/dkrmddkrmd/virtual-exchange.git
cd virtual-exchange
chmod +x gradlew
mkdir -p build/generated-snippets
./gradlew build -x test -x asciidoctor

-x asciidoctor를 추가한 이유: 테스트 없이 빌드하면 REST Docs 스니펫이 생성되지 않아서 asciidoctor 태스크가 실패하기 때문이에요.

백그라운 실행:

Bash
DB_PASSWORD=5678 JWT_SECRET=... MAIL_USERNAME=... MAIL_PASSWORD=... SLACK_WEBHOOK_URL=... \
nohup java -jar build/libs/virtual-exchange-0.0.1-SNAPSHOT.jar > app.log 2>&1 &

9. Nginx + SSL 설정

Bash
sudo apt install -y nginx certbot python3-certbot-nginx
Nginx
server {
    listen 80;
    server_name virtual-exchange.kro.kr;

    location / {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

10. 트러블슈팅 4 - DNS 이전 문제

certbot SSL 발급 시 이런 에러가 났어요.

Plaintext
35.247.83.127: Fetching http://virtual-exchange.kro.kr/...
Timeout during connect (likely firewall problem)

원인은 DNS에 GCP IP(35.247.83.127)와 Oracle IP(144.24.76.180)가 동시에 등록되어 있었던 거예요. Let's Encrypt가 GCP IP로 접근을 시도했고 당연히 실패했죠.

Bash
nslookup virtual-exchange.kro.kr
# GCP IP, Oracle IP 두 개 동시에 나옴

내도메인.한국에서 GCP IP 레코드를 삭제하고 Oracle IP만 남기니 바로 해결됐어요.

11. CI/CD 재연동

GitHub Secrets 변경:

  • ORACLE_HOST → 144.24.76.180
  • ORACLE_USERNAME → ubuntu
  • ORACLE_SSH_KEY → RSA 개인키 전체 내용
  • MAIL_USERNAME, MAIL_PASSWORD, SLACK_WEBHOOK_URL 추가
YAML
- name: Execute JAR on Oracle
  uses: appleboy/ssh-action@master
  with:
    host: ${{ secrets.ORACLE_HOST }}
    username: ${{ secrets.ORACLE_USERNAME }}
    key: ${{ secrets.ORACLE_SSH_KEY }}
    script: |
      sudo fuser -k -n tcp 8080 || true
      cd ~/virtual-exchange/virtual-exchange
      DB_PASSWORD="${{ secrets.DB_PASSWORD }}" \
      JWT_SECRET="${{ secrets.JWT_SECRET }}" \
      MAIL_USERNAME="${{ secrets.MAIL_USERNAME }}" \
      MAIL_PASSWORD="${{ secrets.MAIL_PASSWORD }}" \
      SLACK_WEBHOOK_URL="${{ secrets.SLACK_WEBHOOK_URL }}" \
      nohup java -jar build/libs/virtual-exchange-0.0.1-SNAPSHOT.jar > app.log 2>&1 &

12. 이전 결과

항목 GCP (e2-micro) Oracle A1.Flex
RAM 1GB 24GB
CPU 2코어 (공유) 4코어 (전용)
비용 무료 (제한적) 무료
아키텍처 x86 ARM64

 

주식 시세 조회, 매수/매도 처리 속도가 눈에 띄게 빨라졌고, 메모리 부족으로 서버가 뻗는 일이 사라졌습니다.