최근 블로그 문의 시스템을 구축하면서 Oracle Cloud Infrastructure(OCI)의 HeatWave MySQL을 사용하게 되었습니다. MongoDB로 충분할 수도 있었지만, 관계형 데이터베이스가 더 적합한 문의-답변 구조를 다루면서 새로운 기술 스택을 경험해보고 싶다는 생각이 들었습니다.
이 글에서는 OCI Bastion을 통해 Private Subnet에 있는 HeatWave MySQL에 로컬에서 안전하게 접속하고, NestJS + TypeORM으로 연동하는 전체 과정을 정리했습니다.
HeatWave는 Oracle Cloud에서 제공하는 완전 관리형 MySQL 서비스입니다. 몇 가지 이유로 선택하게 되었습니다:
Always Free Tier 지원: 소규모 프로젝트에 적합한 무료 티어 제공
보안: Private Subnet에 배치되어 외부에서 직접 접근 불가
관리 편의성: 백업, 패치 등 자동 관리
관계형 데이터: 문의-답변처럼 명확한 관계가 있는 데이터에 적합
다만, Private Subnet에 있다는 것은 로컬 개발 환경에서 직접 접속이 불가능하다는 의미이기도 합니다. 여기서 Bastion이 필요해집니다.
Bastion은 Private Subnet의 리소스에 안전하게 접근할 수 있게 해주는 "징검다리" 서비스입니다. SSH 터널을 통해 로컬 컴퓨터에서 Private Subnet의 MySQL에 접속할 수 있게 됩니다.
[로컬 PC] → [SSH 터널] → [OCI Bastion] → [Private Subnet의 HeatWave MySQL]이 구조 덕분에 MySQL 포트를 인터넷에 직접 노출하지 않으면서도 개발 작업이 가능합니다.
Bastion 연결에는 SSH 키가 필요합니다. 기존에 생성해둔 키가 있다면 재사용해도 됩니다.
# 새로 생성하는 경우
ssh-keygen -t rsa -b 4096
# 기존 키 확인
cat ~/.ssh/id_rsa.pub공개 키(id_rsa.pub)는 OCI Console에 등록하고, 개인 키(id_rsa)는 SSH 연결 시 사용합니다.
OCI Console에서 Bastion을 생성합니다:
Identity & Security → Bastion 메뉴로 이동
Create Bastion 클릭
HeatWave MySQL이 있는 VCN과 Subnet 선택
CIDR 허용 목록에 0.0.0.0/0 또는 본인 IP 추가
생성 후 Bastion의 상태가 Active가 될 때까지 잠시 기다립니다.
Bastion이 활성화되면 Session을 생성합니다. Session은 실제 SSH 터널을 맺을 연결 정보입니다.
생성한 Bastion 클릭 → Sessions 탭
Create Session 클릭
Session Type: SSH Port Forwarding Session
Session Name: heatwave-mysql (원하는 이름)
IP Address: HeatWave MySQL의 Private IP (예: 10.0.1.xxx)
Port: 3306
SSH Key: 앞서 준비한 공개 키 업로드
중요: Session의 TTL(Time To Live)은 최대 3시간입니다. 이후에는 새 Session을 생성해야 합니다. 자주 사용한다면 스크립트로 자동화하는 것을 권장합니다.
Session이 Active 상태가 되면 SSH 명령어를 복사할 수 있습니다. OCI Console에서 제공하는 명령어는 대략 이런 형태입니다:
ssh -i ~/.ssh/id_rsa -N -L 3306:10.0.1.xxx:3306 \
-p 22 ocid1.bastionsession.oc1...@host.bastion.ap-seoul-1.oci.oraclecloud.com명령어를 실행하면 터미널에 아무 출력 없이 대기 상태가 됩니다. 이것이 정상입니다. 터널이 열린 상태로 유지되고 있는 것입니다.
이제 localhost:3306이 Private Subnet의 MySQL로 포워딩됩니다.
처음 연결을 시도했을 때 다음과 같은 오류가 발생했습니다:
ERROR 2013 (HY000): Lost connection to MySQL server at 'reading initial communication packet'이 오류는 MySQL 서버까지 패킷이 도달하지 못한다는 의미입니다. 원인은 Security List 설정 누락이었습니다.
OCI Console에서:
Networking → Virtual Cloud Networks → 해당 VCN 선택
Subnets → MySQL이 있는 Subnet 클릭
Security Lists → 해당 Security List 클릭
Add Ingress Rules로 다음 규칙 추가:
Source CIDR: 10.0.0.0/16 (VCN 내부 통신 허용)
IP Protocol: TCP
Destination Port Range: 3306
이 설정을 추가하면 VCN 내부(Bastion 포함)에서 MySQL 포트로 접근이 가능해집니다.
터널이 연결된 상태에서 MySQL에 접속하려면 클라이언트가 필요합니다:
# Homebrew로 설치
brew install mysql-client
# PATH에 추가 (zsh 기준)
echo 'export PATH="/opt/homebrew/opt/mysql-client/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc설치 후 연결 테스트:
mysql -h 127.0.0.1 -P 3306 -u your_username -p비밀번호를 입력하면 MySQL 프롬프트가 나타납니다.
연결에 성공했다면 필요한 데이터베이스와 테이블을 생성합니다:
-- 데이터베이스 생성
CREATE DATABASE blog_inquiries CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE blog_inquiries;
-- 문의 테이블
CREATE TABLE inquiries (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
name VARCHAR(100),
inquiry_type ENUM('general', 'collaboration', 'consulting', 'other') DEFAULT 'general',
content TEXT NOT NULL,
status ENUM('pending', 'in_progress', 'resolved', 'closed') DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 답변 테이블
CREATE TABLE inquiry_replies (
id INT AUTO_INCREMENT PRIMARY KEY,
inquiry_id INT NOT NULL,
content TEXT NOT NULL,
sent_by_email TINYINT(1) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (inquiry_id) REFERENCES inquiries(id) ON DELETE CASCADE
);이제 NestJS 애플리케이션에서 MySQL에 연결합니다.
pnpm add @nestjs/typeorm typeorm mysql2# .env
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USERNAME=your_username
MYSQL_PASSWORD=your_password
MYSQL_DATABASE=blog_inquiriesimport { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
type: 'mysql',
host: configService.get('MYSQL_HOST', '127.0.0.1'),
port: configService.get('MYSQL_PORT', 3306),
username: configService.get('MYSQL_USERNAME'),
password: configService.get('MYSQL_PASSWORD'),
database: configService.get('MYSQL_DATABASE'),
entities: [__dirname + '/**/entities/*.entity{.ts,.js}'],
synchronize: false, // 프로덕션에서는 반드시 false
logging: configService.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
// ... 다른 모듈들
],
})
export class AppModule {}// inquiry.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { InquiryReply } from './inquiry-reply.entity';
export enum InquiryType {
GENERAL = 'general',
COLLABORATION = 'collaboration',
CONSULTING = 'consulting',
OTHER = 'other',
}
export enum InquiryStatus {
PENDING = 'pending',
IN_PROGRESS = 'in_progress',
RESOLVED = 'resolved',
CLOSED = 'closed',
}
@Entity('inquiries')
export class Inquiry {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 255 })
email: string;
@Column({ length: 100, nullable: true })
name: string;
@Column({ type: 'enum', enum: InquiryType, default: InquiryType.GENERAL })
inquiryType: InquiryType;
@Column('text')
content: string;
@Column({ type: 'enum', enum: InquiryStatus, default: InquiryStatus.PENDING })
status: InquiryStatus;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@OneToMany(() => InquiryReply, (reply) => reply.inquiry)
replies: InquiryReply[];
}개발 중에는 SSH 터널이 계속 연결되어 있어야 합니다. 터미널 창을 닫거나 네트워크가 끊기면 연결이 해제됩니다. 별도의 터미널 탭에서 터널을 실행해두는 것이 편합니다.
Bastion Session은 최대 3시간까지만 유지됩니다. 긴 작업이 예상된다면 OCI CLI를 이용해 Session 생성을 자동화하는 스크립트를 만들어두면 편리합니다.
TypeORM의 synchronize 옵션은 개발 편의를 위해 Entity 변경을 자동으로 DB에 반영하지만, 프로덕션에서는 데이터 손실 위험이 있습니다. 반드시 false로 설정하고 마이그레이션을 사용하세요.
프로덕션 환경(예: Coolify, EC2 등)에서는 같은 VCN 내부에 있다면 Bastion 없이 Private IP로 직접 연결할 수 있습니다. 로컬 개발 환경에서만 Bastion이 필요합니다.
처음에는 Bastion 설정이 복잡하게 느껴졌습니다. 하지만 한 번 세팅해두면 보안성과 편의성을 모두 갖춘 개발 환경이 됩니다. Security List 설정 누락으로 한참을 헤맸던 경험이 있어, 비슷한 상황에 있는 분들에게 도움이 되었으면 합니다.
결국 클라우드 환경에서의 데이터베이스 연결은 네트워크 구조를 이해하는 것이 핵심이라는 생각이 들었습니다. VCN, Subnet, Security List, Bastion의 관계를 파악하면 어떤 연결 문제든 원인을 추적할 수 있습니다.