
서론: 왜 Terraform으로 Azure를 자동화해야 하는가?
클라우드 환경이 복잡해지면서 인프라를 수동으로 관리하는 방식은 한계에 부딪혔습니다. 작은 실수 하나가 서비스 전체의 장애로 이어질 수 있으며, 여러 환경에 걸쳐 동일한 구성을 유지하는 것은 거의 불가능에 가깝습니다. 이러한 문제를 해결하기 위해 등장한 것이 바로 **인프라 자동화(Infrastructure as Code, IaC)**입니다. IaC는 인프라 구성을 코드로 정의하고 관리함으로써, 마치 애플리케이션 코드를 다루듯 인프라의 버전 관리, 테스트, 배포를 가능하게 합니다.
그중에서도 Terraform은 특정 클라우드에 종속되지 않는 유연성과 강력한 기능으로 가장 주목받는 도구입니다. Terraform을 사용하여 Azure 인프라를 코드로 관리하면, 클릭 몇 번으로 끝내던 작업을 일관성 있고 반복 가능하며 안정적인 프로세스로 전환할 수 있습니다. 이 글에서는 Terraform을 활용하여 외부 접속이 가능한 기본적인 Azure 가상 머신(VM)을 배포하는 것부터 시작해, 실무 환경에 필수적인 변수 관리와 Azure Bastion을 이용한 최고 수준의 보안 아키텍처를 구축하는 과정까지 단계별로 안내합니다.
--------------------------------------------------------------------------------
1. 기본 단계: 외부 접속 가능한 Azure VM 만들기
Terraform을 이용한 Azure 자동화의 첫걸음은 가장 기본적인 구성, 즉 외부에서 원격(SSH/RDP)으로 접속할 수 있는 가상 머신(VM)을 만드는 것입니다. 이 과정은 단순히 VM 하나를 생성하는 것을 넘어, VM을 둘러싼 모든 핵심 네트워크 구성 요소(가상 네트워크, 서브넷, 공인 IP, 방화벽 등)를 코드로 정의하고 그 관계를 이해하는 기반이 됩니다. 이 단계를 마스터하면 Azure 인프라 자동화의 기본 원리를 확실히 파악할 수 있습니다.
main.tf 코드 분석 및 해설
아래 코드는 외부 접속이 가능한 Linux VM을 생성하는 데 필요한 모든 리소스를 정의한 main.tf 파일입니다. 여기서는 개념을 명확히 하기 위해 모든 설정값을 코드에 직접 입력(하드코딩)했습니다. 각 resource 블록이 Azure Portal에서 수동으로 생성하던 개별 구성 요소에 해당합니다.
provider "azurerm" {
features {}
}
# 1) Resource Group 생성: 모든 Azure 리소스를 묶어 관리하는 논리적인 컨테이너
resource "azurerm_resource_group" "rg" {
name = "basic-vm-rg"
location = "koreacentral"
}
# 2) Virtual Network(VNet) 생성: VM이 속할 격리된 사설 네트워크 공간
resource "azurerm_virtual_network" "vnet" {
name = "basic-vnet"
address_space = ["10.0.0.0/16"]
location = "koreacentral"
resource_group_name = azurerm_resource_group.rg.name
}
# 3) Subnet 생성: VNet 내에서 VM이 실제로 배치되는 더 작은 네트워크 영역
resource "azurerm_subnet" "subnet" {
name = "basic-subnet"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = ["10.0.1.0/24"]
}
# 4) Public IP 생성: VM에 외부 인터넷 접속을 가능하게 하는 공인 IP 주소
resource "azurerm_public_ip" "public_ip" {
name = "basic-public-ip"
location = "koreacentral"
resource_group_name = azurerm_resource_group.rg.name
allocation_method = "Static"
}
# 5) Network Security Group (NSG) 생성: 방화벽 규칙을 담는 컨테이너
resource "azurerm_network_security_group" "nsg" {
name = "basic-nsg"
location = "koreacentral"
resource_group_name = azurerm_resource_group.rg.name
}
# 6) NSG 규칙 생성 (SSH 허용): 외부에서 VM에 접속할 특정 포트를 허용
resource "azurerm_network_security_rule" "ssh" {
name = "allow-ssh"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "Internet"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.rg.name
network_security_group_name = azurerm_network_security_group.nsg.name
}
# 7) 네트워크 인터페이스(NIC) 생성: VM을 네트워크와 연결하는 가상 네트워크 어댑터
resource "azurerm_network_interface" "nic" {
name = "basic-nic"
location = "koreacentral"
resource_group_name = azurerm_resource_group.rg.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.subnet.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.public_ip.id
}
}
# 8) NIC에 NSG 연결: 생성한 NIC에 방화벽 규칙(NSG)을 적용
resource "azurerm_network_interface_security_group_association" "nic_nsg" {
network_interface_id = azurerm_network_interface.nic.id
network_security_group_id = azurerm_network_security_group.nsg.id
}
# 9) 가상머신(VM) 생성: OS, 크기, 계정 정보 등 VM의 모든 사양을 정의
resource "azurerm_linux_virtual_machine" "vm" {
name = "basic-vm"
location = "koreacentral"
resource_group_name = azurerm_resource_group.rg.name
size = "Standard_B2s"
network_interface_ids = [azurerm_network_interface.nic.id]
admin_username = "azureuser"
admin_password = "VerySecureP@ssw0rd123!"
disable_password_authentication = false
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-focal"
sku = "20_04-lts"
version = "latest"
}
}
- azurerm_resource_group: 모든 Azure 리소스를 묶어 관리하는 논리적인 컨테이너입니다.
- azurerm_virtual_network (VNet): VM이 속할 격리된 사설 네트워크 공간을 정의합니다.
- azurerm_subnet: VNet 내에서 VM이 실제로 배치되는 더 작은 네트워크 영역입니다.
- azurerm_public_ip: VM에 외부 인터넷 접속을 가능하게 하는 공인 IP 주소입니다. allocation_method를 "Static"으로 설정하는 것이 중요합니다. "Dynamic"을 사용하면 VM 재부팅 시 IP가 변경될 수 있어 DNS 레코드나 클라이언트 접속 정보가 깨지는 심각한 운영 문제를 야기할 수 있습니다.
- azurerm_network_security_group (NSG): 서브넷이나 네트워크 인터페이스(NIC)에 적용되는 방화벽 규칙을 담는 컨테이너입니다.
- azurerm_network_security_rule: 외부에서 VM에 접속할 수 있도록 SSH(22) 또는 RDP(3389) 포트를 허용하는 인바운드 방화벽 규칙입니다. 운영 환경에서는 보안을 위해 source_address_prefix를 "Internet" 대신 특정 IP 대역으로 반드시 제한해야 합니다.
- azurerm_network_interface (NIC): VM을 가상 네트워크, 서브넷, 공인 IP와 연결하는 가상의 네트워크 어댑터입니다.
- azurerm_network_interface_security_group_association: 위에서 생성한 NIC에 방화벽 규칙(NSG)을 적용합니다.
- azurerm_linux_virtual_machine: OS, 크기, 관리자 계정, NIC 연결 등 VM의 모든 사양을 정의하는 최종 결과물입니다.
핵심 요약: Terraform 리소스와 Azure 메뉴 매핑
Terraform 코드가 Azure의 어떤 기능을 자동화하는지 한눈에 파악할 수 있도록 정리하면 다음과 같습니다.
| Terraform 리소스 블록 | 실제 Azure 메뉴 경로 |
| azurerm_resource_group | Resource groups → Create |
| azurerm_virtual_network | Virtual networks → Create |
| azurerm_subnet | VNet → Subnets |
| azurerm_public_ip | Public IP addresses → Create |
| azurerm_network_security_group | Network security groups → Create |
| azurerm_network_security_rule | NSG → Inbound security rules |
| azurerm_network_interface | (VM 생성 시 간접적으로 생성됨) |
| azurerm_network_interface_security_group_association | NIC → Network security group |
| azurerm_linux_virtual_machine | Virtual machines → Create |
이 기본 구성은 훌륭한 출발점이지만, 모든 설정값이 코드에 직접 하드코딩되어 있어 재사용성이 떨어지고 환경별 관리가 어렵습니다. 이제 코드를 더 유연하고 효율적으로 관리하기 위한 실무 베스트 프랙티스를 알아보겠습니다.
--------------------------------------------------------------------------------
2. 실무 베스트 프랙티스: 코드 분리를 통한 효율적인 관리
하나의 main.tf 파일에 모든 것을 담는 방식은 간단한 테스트에는 유용하지만, 실무 환경에서는 여러 문제점을 야기합니다. 환경(개발, 스테이징, 운영)마다 달라지는 설정값을 변경하기 위해 매번 코드를 수정해야 하고, 관리자 비밀번호와 같은 민감 정보가 코드에 그대로 노출될 위험이 있습니다.
이러한 문제를 해결하기 위해 Terraform에서는 코드를 기능별로 파일을 분리하는 방식을 권장합니다. 핵심은 main.tf(리소스 정의), variables.tf(변수 선언), terraform.tfvars(변수 값 할당) 세 파일로 역할을 나누는 것입니다. 이는 코드의 유연성과 재사용성을 극대화하는 표준적인 접근 방식입니다.
변수의 청사진: variables.tf
variables.tf 파일은 Terraform 코드에서 사용할 변수들의 이름, 타입(문자열, 숫자 등), 설명, 기본값 등을 정의하는 '설계도'와 같습니다. 이 파일을 통해 코드의 구조적 안정성을 유지하고, 어떤 값들이 외부에서 주입되어야 하는지 명확하게 알 수 있습니다. 특히 sensitive = true 속성은 매우 중요합니다. 이 속성을 변수에 추가하면 terraform plan이나 terraform apply 실행 시 콘솔 출력에 해당 값이 일반 텍스트로 노출되는 것을 방지하여, 비밀번호와 같은 민감 정보가 로그 파일이나 화면에 기록되지 않도록 보호합니다.
# 리소스를 배포할 Azure 리전(region)
variable "location" {
type = string
description = "리소스를 생성할 Azure 지역 (예: koreacentral, eastus 등)"
}
# 리소스 그룹 이름
variable "resource_group_name" {
type = string
description = "Azure Resource Group의 이름"
}
# 가상 네트워크(VNet)의 이름
variable "vnet_name" {
type = string
description = "생성할 Virtual Network 이름"
}
# 가상 네트워크 주소 범위
variable "vnet_address_space" {
type = list(string)
description = "VNet의 IP CIDR 범위 (예: [\"10.0.0.0/16\"])"
}
# 서브넷 이름
variable "subnet_name" {
type = string
description = "VNet 내부의 Subnet 이름"
}
# 서브넷 주소 범위
variable "subnet_prefix" {
type = string
description = "서브넷의 CIDR 범위 (예: \"10.0.1.0/24\")"
}
# 생성할 VM 이름
variable "vm_name" {
type = string
description = "배포할 VM의 이름"
}
# VM 크기(스펙)
variable "vm_size" {
type = string
description = "VM 사양 (예: Standard_B2s)"
}
# 관리자 계정 사용자 이름
variable "admin_username" {
type = string
description = "VM 관리자 계정의 사용자 이름"
}
# 관리자 계정 비밀번호 (민감정보)
variable "admin_password" {
type = string
sensitive = true
description = "VM 관리자 계정 비밀번호 (민감정보)"
}
실제 값의 창고: terraform.tfvars
terraform.tfvars 파일은 variables.tf에 선언된 변수들에 실제 값을 할당하는 '값의 창고'입니다. 개발 환경과 운영 환경은 VM 크기나 리소스 이름이 다를 수 있는데, 이 파일을 환경별로 따로 관리하면 main.tf 코드를 전혀 수정하지 않고도 다른 구성의 인프라를 배포할 수 있습니다.
특히, 관리자 비밀번호와 같은 민감 정보는 이 파일에만 저장하고 .gitignore에 *.tfvars를 추가하여 Git 저장소에 올라가지 않도록 관리하는 것이 보안의 핵심입니다.
# Azure 리전
location = "koreacentral"
# 리소스 그룹 이름
resource_group_name = "demo-rg"
# 가상 네트워크(VNet) 이름
vnet_name = "demo-vnet"
# VNet 주소 공간 (리스트 형태)
vnet_address_space = ["10.0.0.0/16"]
# Subnet 이름
subnet_name = "demo-subnet"
# Subnet 주소 범위
subnet_prefix = "10.0.1.0/24"
# VM 이름
vm_name = "demo-vm"
# VM 크기(스펙)
vm_size = "Standard_B2s"
# VM 관리자 계정
admin_username = "azureuser"
# 관리자 비밀번호(민감정보)
# [보안 중요] 실제 운영 환경에서는 이 파일을 절대 Git에 커밋하지 않습니다.
admin_password = "P@ssw0rd123!"
파일 분리 이유 종합
각 파일의 역할과 분리 이유를 정리하면 다음과 같습니다. 이는 HashiCorp의 공식 문서에서도 권장하는 베스트 프랙티스입니다.
| 파일명 | 역할 | 분리하는 이유 |
| main.tf | 리소스 구성 정의 | 인프라의 구조와 로직에만 집중 |
| variables.tf | 변수 이름·형식 정의 | 코드의 구조적 안정성 유지 및 가독성 향상 |
| terraform.tfvars | 실제 환경 값 입력 | 환경별(dev/prod) 구성 분리 및 민감 정보 보안 강화 |
이제 코드가 효율적으로 구성되었으니, 다음 단계로 인프라의 보안을 대폭 강화하는 방법을 알아보겠습니다.
--------------------------------------------------------------------------------
3. 보안 강화: Azure Bastion을 이용한 안전한 접속 환경 구축
지금까지의 구성은 VM에 공인(Public) IP를 직접 할당하고, 방화벽(NSG)을 통해 원격 접속 포트(SSH/RDP)를 인터넷에 개방하는 방식이었습니다. 이는 편리하지만 심각한 보안 위협에 노출되는 구조입니다. 무차별 대입 공격(Brute-force attack)의 표적이 되기 쉬우며, 관리 포트가 항상 외부에 열려 있다는 것 자체만으로도 큰 부담입니다.
이러한 위험을 원천적으로 차단하기 위해 Azure는 Azure Bastion이라는 관리형 점프 서버(Jump Server) 서비스를 제공합니다. Bastion을 사용하면 VM에서 공인 IP를 완전히 제거하고, 오직 Azure Portal을 통해 암호화된 Private 네트워크로만 안전하게 접속할 수 있습니다. 이제 Terraform 코드를 수정하여 이 강력한 보안 아키텍처를 구축해 보겠습니다.
보안 강화 main.tf 코드 분석 및 해설
아래 코드는 기존 구성에서 VM의 공인 IP를 제거하고 Azure Bastion을 추가하여 보안을 극대화한 버전입니다.
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "rg" {
name = var.resource_group_name
location = var.location
}
resource "azurerm_virtual_network" "vnet" {
name = var.vnet_name
address_space = var.vnet_address_space
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
}
resource "azurerm_subnet" "subnet" {
name = var.subnet_name
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = [var.subnet_prefix]
}
resource "azurerm_subnet" "bastion_subnet" {
name = "AzureBastionSubnet"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = ["10.0.255.0/27"]
}
resource "azurerm_public_ip" "bastion_pip" {
name = "bastion-pip"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_bastion_host" "bastion" {
name = "bastion-host"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
ip_configuration {
name = "bastion-ipconfig"
subnet_id = azurerm_subnet.bastion_subnet.id
public_ip_address_id = azurerm_public_ip.bastion_pip.id
}
}
resource "azurerm_network_security_group" "vm_nsg" {
name = "vm-nsg"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
security_rule {
name = "Allow-Bastion-SSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "AzureBastion"
destination_address_prefix = "*"
}
security_rule {
name = "Allow-Bastion-RDP"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "3389"
source_address_prefix = "AzureBastion"
destination_address_prefix = "*"
}
security_rule {
name = "Deny-Internet-RDP-SSH"
priority = 200
direction = "Inbound"
access = "Deny"
protocol = "Tcp"
source_address_prefix = "Internet"
destination_port_ranges = ["22", "3389"]
destination_address_prefix = "*"
}
}
resource "azurerm_network_interface" "nic" {
name = "${var.vm_name}-nic"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
ip_configuration {
name = "nic-ipconfig"
subnet_id = azurerm_subnet.subnet.id
private_ip_address_allocation = "Dynamic"
}
}
resource "azurerm_network_interface_security_group_association" "nsg_association" {
network_interface_id = azurerm_network_interface.nic.id
network_security_group_id = azurerm_network_security_group.vm_nsg.id
}
resource "azurerm_linux_virtual_machine" "vm" {
name = var.vm_name
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
size = var.vm_size
admin_username = var.admin_username
admin_password = var.admin_password
network_interface_ids = [azurerm_network_interface.nic.id]
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-focal"
sku = "20_04-lts"
version = "latest"
}
}
이전 코드와의 핵심적인 차이점은 다음과 같습니다.
- VM의 Public IP 제거: 가장 중요한 변화는 VM의 네트워크 인터페이스(azurerm_network_interface)에서 public_ip_address_id 항목을 완전히 제거한 것입니다. 이로써 VM은 더 이상 인터넷에서 직접 접근할 수 있는 공인 IP 주소를 갖지 않게 되어 외부 공격 표면이 원천적으로 사라집니다.
- Azure Bastion 전용 리소스 추가: 안전한 접속 경로를 만들기 위해 세 가지 핵심 리소스가 추가되었습니다.
- AzureBastionSubnet: Bastion 서비스는 반드시 AzureBastionSubnet이라는 이름의 전용 서브넷에 배포되어야 합니다. 이것은 Azure의 엄격한 요구사항입니다.
- azurerm_public_ip: Bastion 서비스 자체를 위한 공인 IP입니다. 이 IP는 Standard SKU와 Static 할당 방식을 필수로 요구하며, 사용자는 이 IP를 통해 Azure Portal에 접속하여 Bastion 세션을 시작하게 됩니다.
- azurerm_bastion_host: 실제 Bastion 서비스를 정의하는 리소스로, 위에서 만든 전용 서브넷과 공인 IP를 연결하는 역할을 합니다.
- 강화된 NSG 규칙: 방화벽 규칙이 대폭 강화되었습니다.
- Allow-Bastion-SSH/RDP: source_address_prefix에 "Internet" 대신 "AzureBastion"이라는 서비스 태그를 사용합니다. 이 태그는 Azure가 관리하는 Bastion 서비스의 IP 대역을 동적으로 의미하므로, 오직 Bastion을 통해서 들어오는 트래픽만 허용하게 됩니다.
- Deny-Internet-RDP-SSH: 보안을 한층 더 강화하기 위해, 인터넷("Internet")에서 출발하는 SSH(22)와 RDP(3389) 포트로의 모든 접속을 명시적으로 거부(Deny)하는 규칙을 추가했습니다. 이는 '최소 허용 원칙'을 넘어, 혹시 모를 설정 실수에도 직접적인 외부 접속을 차단하는 강력한 안전장치 역할을 합니다.
보안 구성 요약
이 아키텍처의 핵심 보안 강화 요소와 그 이유는 다음과 같습니다.
| 구성 상태 | 이유 |
| VM에 공인 IP 없음 | 인터넷에서 VM으로의 직접적인 접근 경로를 원천 차단합니다. |
| Azure Bastion 사용 | 암호화된 Private 네트워크를 통해서만 VM에 접속하게 합니다. |
| NSG: Bastion 접속만 허용 | '최소 허용 원칙'에 따라 필요한 최소한의 포트만 개방합니다. |
| NSG: 인터넷 접속 명시적 차단 | 혹시 모를 설정 오류를 방지하기 위해 명시적으로 차단합니다. |
| Bastion 전용 서브넷 | Bastion 서비스가 동작하기 위한 Azure의 필수 요구사항입니다. |
기본적인 VM 배포에서 시작하여 파일 분리를 통한 코드 관리, 그리고 Azure Bastion을 활용한 보안 아키텍처 구축까지, Terraform 코드의 발전 과정을 살펴보았습니다.
--------------------------------------------------------------------------------
결론: 자동화를 넘어 신뢰할 수 있는 인프라로
이 글에서는 Terraform을 사용하여 Azure VM 인프라를 구축하는 여정을 단계별로 탐색했습니다. 단순히 VM을 생성하는 초기 단계를 넘어, variables.tf와 terraform.tfvars를 활용하여 코드를 체계적이고 재사용 가능하게 관리하는 실무 기법을 알아보았습니다. 더 나아가, VM에서 공인 IP를 제거하고 Azure Bastion을 도입함으로써 외부 공격에 대한 노출을 최소화하고 안전한 접속 환경을 구축하는 방법을 확인했습니다.
Terraform을 통한 인프라 자동화는 단순한 편의성 향상을 의미하지 않습니다. 이것은 인프라를 예측 가능하고, 일관되며, 신뢰할 수 있는 자산으로 만드는 과정입니다. 오늘 배운 내용을 바탕으로 실제 업무에 IaC를 자신감 있게 적용해 보십시오. 코드로 정의된 인프라는 실수를 줄이고, 보안을 강화하며, 궁극적으로는 비즈니스의 변화에 빠르고 안정적으로 대응할 수 있는 강력한 기반이 될 것입니다.
'인공지능,프로그래밍 > MS Azure' 카테고리의 다른 글
| Azure AI 서비스 탐험: 당신의 AI 조수는 무엇을 할 수 있을까? (0) | 2025.11.27 |
|---|---|
| 신규 데브옵스 엔지니어_를 위한 테라폼(Terraform) 실무 시작 가이드 (0) | 2025.11.21 |
| Azure에 NGINX 서버 구축 실무 함정 완벽 해부 (0) | 2025.11.19 |
| 라우터가 뭐지? Express Router, Router, Express Router Gateway 비교 (4) | 2025.07.23 |
| ARM(Azure Resource Manager) (2) | 2025.07.13 |