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):
# 잘못된 것
--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 접속용 키는 별도로 생성해야 해요.
ssh-keygen -t rsa -b 2048 -f C:\Users\user\.ssh\oci_ssh_key
이 두 가지 수정 후 약 3일 만에 인스턴스 생성에 성공했습니다.
"lifecycle-state": "PROVISIONING"
Success! Instance created.
3. 서버 기본 세팅
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 전용 이미지예요.
exec /bin/sh: exec format error
Zookeeper는 Restarting (255) 상태로 계속 죽었어요.
해결: ARM64 Multi-platform 빌드가 지원되는 confluentinc 이미지로 교체했습니다.
버전 고정이 중요한 이유: confluentinc/cp-kafka:latest는 Zookeeper 없이 KRaft 모드를 강제해요. 기존 Zookeeper 기반 설정을 유지하려면 7.4.0 버전을 명시적으로 고정해야 안정적으로 배포할 수 있어요.
# latest 사용 시 에러
Error: environment variable "KAFKA_PROCESS_ROLES" is not set
5. 트러블슈팅 2 - Kafka InconsistentClusterIdException
docker-compose를 내렸다가 다시 올릴 때 이런 에러가 났어요.
InconsistentClusterIdException: The Cluster ID doesn't match stored clusterId
Kafka 볼륨에 이전 클러스터 ID가 남아있어서 충돌하는 거예요.
해결:
docker-compose down
docker volume rm virtual-exchange_kafka-data
docker-compose up -d
6. 트러블슈팅 3 - Kafka ADVERTISED_LISTENERS 오류
배포 도중 docker-compose.yml에서 이 설정이 바뀌면서 문제가 생겼어요.
# 잘못된 것 (외부 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 내부 네트워크와 외부 네트워크 간의 통신 병목을 해결했습니다.
# LISTENERS: 카프카가 실제로 귀를 여는 주소 (모든 인터페이스)
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
# ADVERTISED_LISTENERS: 외부 클라이언트에게 알려주는 주소
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
이 에러가 발생했어요.
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 파일로 해결하는 방법도 있지만 파일 자체가 서버에 남으면 보안 위험이 있어서, 실행 명령어 앞에 직접 주입하는 방식을 선택했습니다.
DB_PASSWORD=5678 docker-compose up -d
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 빌드 및 실행
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 태스크가 실패하기 때문이에요.
백그라운 실행:
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 설정
sudo apt install -y nginx certbot python3-certbot-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 발급 시 이런 에러가 났어요.
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로 접근을 시도했고 당연히 실패했죠.
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 추가
- 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 |
주식 시세 조회, 매수/매도 처리 속도가 눈에 띄게 빨라졌고, 메모리 부족으로 서버가 뻗는 일이 사라졌습니다.
'개발 공부 > 프로젝트' 카테고리의 다른 글
| Part 3. MongoDB 로그 시스템 구축 (Polyglot Persistence) (0) | 2026.04.30 |
|---|---|
| Part 2. Slack 알림 연동 (0) | 2026.04.30 |
| 이상 거래 탐지 트러블슈팅 - StackOverflowError부터 @Transactional 롤백까지 (0) | 2026.04.24 |
| 이상 거래 탐지 구현 - Redis Sliding Window와 이메일 알림 (0) | 2026.04.24 |
| 이상 거래 탐지 시스템 설계 - 금융권은 어떻게 이상 거래를 막는가? (클로드 코드) (0) | 2026.04.24 |