[MicrosoftDataSchool] 80일차 - Azure VM, SQL Server 실습

RudinP·2026년 4월 30일

Microsoft Data School 3기

목록 보기
61/65
post-thumbnail

중고차 커뮤니티 MVP 실습

VM 생성

디스크

여기서는 안쓰지만, 디스크를 사용하여 애플리케이션 등 저장이 가능하다.

자동 종료

관리탭

이후 검토 + 만들기 하고 .pem 확장자의 ssh 키를 다운받는다.

VM 연결




SSH 명령 탭의 경로 입력하기

powershell 연결

ssh -i "키경로" azureuser@ip주소

OS 정보 확인

패키지 업데이트

sudo apt update
sudo apt upgrade -y
sudo apt install -y curl wget gnupg2 software-properties-common apt-transport-https ca-certificates

Swap 파일 생성(SQL Server 안정성)

B2s의 4GB RAM은 SQL Server + Python + OS 동시 구동에 빠듯합니다. 2GB swap을 추가하여 OOM Killer 발동을 예방합니다.

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h

free -h 출력의 Swap 행에 2.0Gi 표시되면 OK.

Swap(스왑) 파일은 물리적 메모리(RAM)가 부족할 때 디스크(HDD/SSD)의 일부를 메모리처럼 사용하는 가상 메모리 공간입니다.RAM이 가득 찼을 때 스왑 공간이 없으면 리눅스 커널은 OOM(Out of Memory) Killer를 동작시켜 중요 프로세스를 강제로 종료합니다. 스왑 파일은 이러한 갑작스러운 시스템 멈춤이나 응용 프로그램 종료를 막아줍니다.

시간대 설정

SQL Server2025 설치

Microsoft 공식 저장소 등록

# Microsoft GPG 키 등록 (Ubuntu 24.04 권장 방식)
curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | \
  sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
 
# SQL Server 2025 저장소 등록 (Ubuntu 24.04 공식)
curl -fsSL https://packages.microsoft.com/config/ubuntu/24.04/mssql-server-2025.list | \
  sudo tee /etc/apt/sources.list.d/mssql-server-2025.list
 
sudo apt update

SQL Server 패키지 설치

sudo apt install -y mssql-server

mssql-conf setup — Edition · 비밀번호 설정

sudo /opt/mssql/bin/mssql-conf setup


서비스 상태 확인

systemctl status mssql-server --no-pager

Active: active (running) 표시되면 OK.
LISTEN 0.0.0.0:1433 으로 바인딩 — 다음 단계에서 localhost-only로 변경합니다.

SQL Server를 localhost 전용으로 바인딩 (보안 핵심)

SQL Server를 외부에 노출하면 Brute force 공격의 1순위 대상이 됩니다. NSG뿐 아니라 SQL Server 자체에서도 localhost만 바인딩하도록 이중 차단합니다.

# /var/opt/mssql/mssql.conf 에 IP 바인딩 설정 추가
sudo /opt/mssql/bin/mssql-conf set network.ipaddress 127.0.0.1
sudo systemctl restart mssql-server
sudo ss -tlnp | grep 1433

ss 출력이 127.0.0.1:1433 으로만 표시되면 OK. 0.0.0.0:1433 또는 *:1433 이면 실패.

설정 제거 방법

sudo /opt/mssql/bin/mssql-conf unset network.ipaddress
sudo systemctl restart mssql-server

0.0.0.0:1433 나오면 외부 접속 가능 상태

mssql-tools, ODBC Driver, Python

# prod.list 저장소 등록 (Ubuntu 24.04용)
curl -fsSL https://packages.microsoft.com/config/ubuntu/24.04/prod.list | \
  sudo tee /etc/apt/sources.list.d/mssql-release.list
 
sudo apt update
sudo ACCEPT_EULA=Y apt install -y mssql-tools18 unixodbc-dev msodbcsql18
 
# PATH에 sqlcmd 추가
echo 'export PATH="$PATH:/opt/mssql-tools18/bin"' >> ~/.bashrc
source ~/.bashrc

sqlcmd 접속

sqlcmd -S localhost -U sa -P '<SA_비밀번호>' -C -Q "SELECT @@VERSION"

Microsoft SQL Server 2025 (RTM-CU...) 또는 17.x 버전 정보 출력되면 OK.

  • -C 옵션: TrustServerCertificate (자체 서명 인증서 신뢰). 실습용.
  • 비밀번호에 특수문자가 있을 때는 작은따옴표 ' ' 로 감쌀 것.
  • 명령 히스토리에 비밀번호가 남는 것을 피하려면 -P 생략 후 프롬프트 입력 권장.

Python3 + 가상환경 + 필수 패키지 설치

sudo apt install -y python3 python3-pip python3-venv
 
# 작업 디렉토리
mkdir -p ~/carmarket && cd ~/carmarket
 
# 가상환경 생성
python3 -m venv venv
source venv/bin/activate
 
# 패키지 설치
pip install --upgrade pip
pip install flask pyodbc python-dotenv gunicorn

pip list | grep -E 'Flask|pyodbc|python-dotenv' 로 3개 패키지 출력 확인.

pyodbc 동작 검증

python3 -c "import pyodbc; print(pyodbc.drivers())"

출력에 ['ODBC Driver 18 for SQL Server'] 가 포함되면 OK.

데이터베이스, 스키마, 시드 데이터

CarMarket 데이터베이스를 만들고 Users, Cars, Inquiries 3-table 모델을 구축, 시드 데이터를 적재

스키마 SQL 파일 작성

cd ~/carmarket
nano schema.sql

schema.sql

CREATE DATABASE CarMarket;
GO
USE CarMarket;
GO
 
CREATE TABLE Users (
    UserId    INT IDENTITY(1,1) PRIMARY KEY,
    Name      NVARCHAR(100) NOT NULL,
    Email     NVARCHAR(200) NOT NULL UNIQUE,
    Phone     NVARCHAR(20),
    UserType  NVARCHAR(10) NOT NULL DEFAULT 'both'
              CHECK (UserType IN ('seller','buyer','both')),
    CreatedAt DATETIME2 DEFAULT SYSUTCDATETIME()
);
 
CREATE TABLE Cars (
    CarId       INT IDENTITY(1,1) PRIMARY KEY,
    SellerId    INT NOT NULL FOREIGN KEY REFERENCES Users(UserId),
    Brand       NVARCHAR(50) NOT NULL,
    Model       NVARCHAR(100) NOT NULL,
    Year        INT NOT NULL,
    Price       DECIMAL(12,0) NOT NULL,
    Mileage     INT NOT NULL,
    FuelType    NVARCHAR(20),
    Description NVARCHAR(MAX),
    Status      NVARCHAR(20) NOT NULL DEFAULT 'available'
                CHECK (Status IN ('available','reserved','sold')),
    CreatedAt   DATETIME2 DEFAULT SYSUTCDATETIME()
);
 
CREATE TABLE Inquiries (
    InquiryId INT IDENTITY(1,1) PRIMARY KEY,
    CarId     INT NOT NULL FOREIGN KEY REFERENCES Cars(CarId),
    BuyerId   INT NOT NULL FOREIGN KEY REFERENCES Users(UserId),
    Message   NVARCHAR(1000) NOT NULL,
    CreatedAt DATETIME2 DEFAULT SYSUTCDATETIME()
);
 
CREATE INDEX IX_Cars_Brand     ON Cars(Brand);
CREATE INDEX IX_Cars_Status    ON Cars(Status);
CREATE INDEX IX_Cars_CreatedAt ON Cars(CreatedAt DESC);
GO

시드 데이터 작성

nano seed.sql
USE CarMarket;
GO
 
INSERT INTO Users (Name, Email, Phone, UserType) VALUES
(N'김판매', 'seller1@test.com', '010-1111-1111', 'seller'),
(N'이판매', 'seller2@test.com', '010-2222-2222', 'seller'),
(N'박판매', 'seller3@test.com', '010-3333-3333', 'seller'),
(N'최구매', 'buyer1@test.com',  '010-4444-4444', 'buyer'),
(N'정구매', 'buyer2@test.com',  '010-5555-5555', 'buyer');
 
INSERT INTO Cars (SellerId, Brand, Model, Year, Price, Mileage, FuelType, Description) VALUES
(1, N'현대',   N'쏘나타 DN8',     2021, 18500000, 45000, N'가솔린', N'무사고, 1인 소유, 정기점검 완료'),
(1, N'기아',   N'K5 3세대',        2020, 16000000, 62000, N'가솔린', N'썬루프, 어라운드뷰 옵션'),
(2, N'BMW',   N'520d (G30)',      2019, 28000000, 78000, N'디젤',   N'풀옵션, 가죽시트, 무사고'),
(2, N'벤츠',   N'E300 (W213)',     2020, 38000000, 55000, N'가솔린', N'AMG 패키지, 1인 소유'),
(3, N'제네시스', N'G80 (RG3)',      2022, 45000000, 28000, N'가솔린', N'신차급, 출고 1년');
GO

sql 파일 실행

read -s -p "SA Password: " SA_PWD
export SA_PWD
 
sqlcmd -S localhost -U sa -P "$SA_PWD" -C -i schema.sql
sqlcmd -S localhost -U sa -P "$SA_PWD" -C -i seed.sql

데이터 검증

sqlcmd -S localhost -U sa -P "$SA_PWD" -C -d CarMarket -Q \
  "SELECT c.Brand, c.Model, c.Year, c.Price, u.Name AS Seller
   FROM Cars c JOIN Users u ON c.SellerId = u.UserId
   ORDER BY c.Price DESC"

Flask 백엔드 구현(REST API)

Python Flask로 5개의 REST 엔드포인트(/health, GET·POST /cars, POST /inquiries, GET /users)를 구현하고 SQL Server와 연결

.env파일로 비밀번호 분리

nano .env

SA_PASSWORD=YourStrongP@ssw0rd
DB_SERVER=localhost
DB_NAME=CarMarket
FLASK_PORT=5000

# 권한 600 — 본인만 읽기·쓰기
chmod 600 .env
ls -la .env

app.py

nano app.py
import os
from contextlib import contextmanager
import pyodbc
from flask import Flask, request, jsonify, render_template_string
from dotenv import load_dotenv
 
load_dotenv()
 
app = Flask(__name__)
 
DB_SERVER   = os.environ.get("DB_SERVER", "localhost")
DB_NAME     = os.environ.get("DB_NAME", "CarMarket")
DB_USER     = "sa"
DB_PASSWORD = os.environ.get("SA_PASSWORD")
FLASK_PORT  = int(os.environ.get("FLASK_PORT", 5000))
 
CONN_STR = (
    "DRIVER={ODBC Driver 18 for SQL Server};"
    f"SERVER={DB_SERVER};DATABASE={DB_NAME};"
    f"UID={DB_USER};PWD={DB_PASSWORD};"
    "TrustServerCertificate=yes;Encrypt=yes;"
)
 
@contextmanager
def db():
    conn = pyodbc.connect(CONN_STR, autocommit=False)
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()
 
# ====== Health check ======
@app.route("/health")
def health():
    try:
        with db() as conn:
            cur = conn.cursor()
            cur.execute("SELECT 1")
            cur.fetchone()
        return jsonify({"status": "ok", "db": "connected"}), 200
    except Exception as e:
        return jsonify({"status": "error", "db": str(e)}), 500
 
# ====== Users ======
@app.route("/api/users", methods=["GET"])
def list_users():
    with db() as conn:
        cur = conn.cursor()
        cur.execute("SELECT UserId, Name, Email, Phone, UserType FROM Users ORDER BY UserId")
        rows = cur.fetchall()
    return jsonify([
        {"id": r[0], "name": r[1], "email": r[2], "phone": r[3], "type": r[4]}
        for r in rows
    ])
 
# ====== Cars: 목록 + 검색 ======
@app.route("/api/cars", methods=["GET"])
def list_cars():
    brand = request.args.get("brand")
    max_price = request.args.get("max_price", type=int)
 
    sql = """
        SELECT c.CarId, u.Name, c.Brand, c.Model, c.Year, c.Price,
               c.Mileage, c.FuelType, c.Description, c.Status, c.CreatedAt
          FROM Cars c
          JOIN Users u ON c.SellerId = u.UserId
         WHERE c.Status = 'available'
    """
    params = []
    if brand:
        sql += " AND c.Brand = ?"
        params.append(brand)
    if max_price:
        sql += " AND c.Price <= ?"
        params.append(max_price)
    sql += " ORDER BY c.CreatedAt DESC"
 
    with db() as conn:
        cur = conn.cursor()
        cur.execute(sql, params)
        rows = cur.fetchall()
 
    return jsonify([
        {
            "id": r[0], "seller": r[1], "brand": r[2], "model": r[3],
            "year": r[4], "price": int(r[5]), "mileage": r[6],
            "fuel": r[7], "desc": r[8], "status": r[9],
            "created_at": r[10].isoformat() if r[10] else None
        } for r in rows
    ])
 
# ====== Cars: 등록 ======
@app.route("/api/cars", methods=["POST"])
def create_car():
    data = request.get_json(silent=True) or {}
    required = ["seller_id", "brand", "model", "year", "price", "mileage"]
    missing = [k for k in required if k not in data]
    if missing:
        return jsonify({"error": f"missing fields: {missing}"}), 400
 
    with db() as conn:
        cur = conn.cursor()
        cur.execute("""
            INSERT INTO Cars (SellerId, Brand, Model, Year, Price, Mileage, FuelType, Description)
            OUTPUT INSERTED.CarId
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        """, data["seller_id"], data["brand"], data["model"], int(data["year"]),
             int(data["price"]), int(data["mileage"]),
             data.get("fuel", ""), data.get("desc", ""))
        new_id = cur.fetchone()[0]
    return jsonify({"car_id": new_id}), 201
 
# ====== Inquiries ======
@app.route("/api/inquiries", methods=["POST"])
def create_inquiry():
    data = request.get_json(silent=True) or {}
    for k in ("car_id", "buyer_id", "message"):
        if k not in data:
            return jsonify({"error": f"missing {k}"}), 400
 
    with db() as conn:
        cur = conn.cursor()
        cur.execute("""
            INSERT INTO Inquiries (CarId, BuyerId, Message)
            OUTPUT INSERTED.InquiryId
            VALUES (?, ?, ?)
        """, int(data["car_id"]), int(data["buyer_id"]), data["message"])
        new_id = cur.fetchone()[0]
    return jsonify({"inquiry_id": new_id}), 201
 
# ====== UI: 단일 페이지 (Step 8에서 추가) ======
INDEX_HTML = ""  # Step 8에서 채움
 
@app.route("/")
def index():
    return render_template_string(INDEX_HTML)
 
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=FLASK_PORT, debug=False)

로컬 테스트

source venv/bin/activate
python app.py
# 다른 터미널 또는 같은 세션에서:
curl -s http://localhost:5000/health | python3 -m json.tool

API 동작 테스트

curl -s http://localhost:5000/api/cars | python3 -m json.tool

# 브랜드 필터
curl -s "http://localhost:5000/api/cars?brand=BMW" | python3 -m json.tool
 
# 차량 등록
curl -s -X POST http://localhost:5000/api/cars \
  -H "Content-Type: application/json" \
  -d '{"seller_id":1,"brand":"기아","model":"카니발","year":2022,"price":35000000,"mileage":15000,"fuel":"디젤","desc":"하이리무진 풀옵"}' \
  | python3 -m json.tool
 
# 문의 등록
curl -s -X POST http://localhost:5000/api/inquiries \
  -H "Content-Type: application/json" \
  -d '{"car_id":1,"buyer_id":4,"message":"실차 확인 가능한가요?"}' \
  | python3 -m json.tool

백그라운드 프로세스 종료

ps aux | grep "[p]ython app.py"
kill <PID>

프론트엔드

Bootstrap 5 CDN으로 차량 목록·등록·문의 UI를 구현. 단일 HTML 문자열을 Flask render_template_string으로 제공

app.py의 INDEX_HTML = "" 라인을 아래 내용으로 교체

INDEX_HTML = """
<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>중고차 마켓 MVP</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
  <style>
    body { background:#f4f8fa; }
    .navbar { background:#21295C !important; }
    .card-car { transition: transform .15s; }
    .card-car:hover { transform: translateY(-3px); box-shadow:0 6px 20px rgba(0,0,0,.08);}
    .price { color:#065A82; font-weight:700; }
    .badge-status { font-size: .75rem; }
  </style>
</head>
<body>
<nav class="navbar navbar-dark px-4">
  <span class="navbar-brand mb-0 h4">🚗 중고차 마켓 MVP</span>
  <div>
    <button class="btn btn-light btn-sm me-2" onclick="showRegister()">+ 매물 등록</button>
    <span id="health" class="badge bg-secondary">checking…</span>
  </div>
</nav>
 
<div class="container my-4">
  <div class="row g-2 mb-3 align-items-end">
    <div class="col-md-3">
      <label class="form-label small">브랜드</label>
      <select id="filterBrand" class="form-select form-select-sm">
        <option value="">전체</option>
        <option>현대</option><option>기아</option><option>제네시스</option>
        <option>BMW</option><option>벤츠</option>
      </select>
    </div>
    <div class="col-md-3">
      <label class="form-label small">최대 가격(원)</label>
      <input id="filterPrice" type="number" class="form-control form-control-sm" placeholder="예: 30000000">
    </div>
    <div class="col-md-2">
      <button class="btn btn-primary btn-sm w-100" onclick="loadCars()">검색</button>
    </div>
    <div class="col-md-4 text-end">
      <small class="text-muted" id="count"></small>
    </div>
  </div>
 
  <div id="cars" class="row g-3"></div>
</div>
 
<!-- 등록 모달 -->
<div class="modal fade" id="regModal" tabindex="-1">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header bg-primary text-white"><h5 class="modal-title">매물 등록</h5></div>
      <div class="modal-body">
        <div class="row g-2">
          <div class="col-6"><label class="form-label">판매자</label>
            <select id="r_seller" class="form-select"></select></div>
          <div class="col-6"><label class="form-label">브랜드</label>
            <input id="r_brand" class="form-control" placeholder="현대"></div>
          <div class="col-6"><label class="form-label">모델</label>
            <input id="r_model" class="form-control"></div>
          <div class="col-3"><label class="form-label">연식</label>
            <input id="r_year" type="number" class="form-control" value="2022"></div>
          <div class="col-3"><label class="form-label">연료</label>
            <input id="r_fuel" class="form-control" placeholder="가솔린"></div>
          <div class="col-6"><label class="form-label">가격(원)</label>
            <input id="r_price" type="number" class="form-control"></div>
          <div class="col-6"><label class="form-label">주행(km)</label>
            <input id="r_mile" type="number" class="form-control"></div>
          <div class="col-12"><label class="form-label">설명</label>
            <textarea id="r_desc" rows="2" class="form-control"></textarea></div>
        </div>
      </div>
      <div class="modal-footer">
        <button class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
        <button class="btn btn-primary" onclick="submitCar()">등록</button>
      </div>
    </div>
  </div>
</div>
 
<!-- 문의 모달 -->
<div class="modal fade" id="inqModal" tabindex="-1">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header bg-info text-white"><h5 class="modal-title">문의하기</h5></div>
      <div class="modal-body">
        <p class="text-muted small mb-2">차량 ID: <span id="inq_car_id"></span></p>
        <label class="form-label">구매자</label>
        <select id="inq_buyer" class="form-select mb-2"></select>
        <label class="form-label">메시지</label>
        <textarea id="inq_msg" class="form-control" rows="3"></textarea>
      </div>
      <div class="modal-footer">
        <button class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
        <button class="btn btn-info text-white" onclick="submitInquiry()">전송</button>
      </div>
    </div>
  </div>
</div>
 
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
let users = [];
 
async function checkHealth() {
  try {
    const r = await fetch('/health');
    const j = await r.json();
    document.getElementById('health').className = 'badge ' + (j.status==='ok' ? 'bg-success' : 'bg-danger');
    document.getElementById('health').textContent = 'DB ' + j.status;
  } catch (e) {
    document.getElementById('health').className = 'badge bg-danger';
    document.getElementById('health').textContent = 'DB error';
  }
}
 
async function loadUsers() {
  const r = await fetch('/api/users');
  users = await r.json();
  const sellerSel = document.getElementById('r_seller');
  const buyerSel  = document.getElementById('inq_buyer');
  sellerSel.innerHTML = users.filter(u => u.type !== 'buyer')
    .map(u => '<option value="' + u.id + '">' + u.name + ' (' + u.email + ')</option>').join('');
  buyerSel.innerHTML  = users.filter(u => u.type !== 'seller')
    .map(u => '<option value="' + u.id + '">' + u.name + '</option>').join('');
}
 
async function loadCars() {
  const brand = document.getElementById('filterBrand').value;
  const price = document.getElementById('filterPrice').value;
  const params = new URLSearchParams();
  if (brand) params.append('brand', brand);
  if (price) params.append('max_price', price);
  const r = await fetch('/api/cars?' + params);
  const cars = await r.json();
  document.getElementById('count').textContent = '검색 결과: ' + cars.length + '건';
  document.getElementById('cars').innerHTML = cars.map(function(c) {
    return '' +
      '<div class="col-md-6 col-lg-4">' +
        '<div class="card card-car h-100"><div class="card-body">' +
          '<div class="d-flex justify-content-between">' +
            '<h5 class="card-title mb-0">' + c.brand + ' ' + c.model + '</h5>' +
            '<span class="badge bg-success badge-status">' + c.status + '</span>' +
          '</div>' +
          '<p class="text-muted small mt-1">' + c.year + '년식 · ' + c.mileage.toLocaleString() + 'km · ' + (c.fuel||'-') + '</p>' +
          '<p class="price h5 mb-2">' + c.price.toLocaleString() + ' 원</p>' +
          '<p class="small mb-2">' + (c.desc||'') + '</p>' +
          '<p class="small text-muted mb-2">판매자: ' + c.seller + '</p>' +
          '<button class="btn btn-sm btn-outline-info"token operator">+ c.id + ')">문의하기</button>' +
        '</div></div>' +
      '</div>';
  }).join('');
}
 
function showRegister() { new bootstrap.Modal(document.getElementById('regModal')).show(); }
 
async function submitCar() {
  const body = {
    seller_id: parseInt(document.getElementById('r_seller').value),
    brand:   document.getElementById('r_brand').value.trim(),
    model:   document.getElementById('r_model').value.trim(),
    year:    parseInt(document.getElementById('r_year').value),
    price:   parseInt(document.getElementById('r_price').value),
    mileage: parseInt(document.getElementById('r_mile').value),
    fuel:    document.getElementById('r_fuel').value.trim(),
    desc:    document.getElementById('r_desc').value.trim(),
  };
  const r = await fetch('/api/cars', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  });
  if (r.ok) {
    alert('등록 완료!');
    bootstrap.Modal.getInstance(document.getElementById('regModal')).hide();
    loadCars();
  } else {
    alert('실패: ' + (await r.text()));
  }
}
 
function openInquiry(carId) {
  document.getElementById('inq_car_id').textContent = carId;
  document.getElementById('inq_msg').value = '';
  new bootstrap.Modal(document.getElementById('inqModal')).show();
}
 
async function submitInquiry() {
  const body = {
    car_id:   parseInt(document.getElementById('inq_car_id').textContent),
    buyer_id: parseInt(document.getElementById('inq_buyer').value),
    message:  document.getElementById('inq_msg').value.trim(),
  };
  if (!body.message) { alert('메시지를 입력하세요'); return; }
  const r = await fetch('/api/inquiries', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  });
  if (r.ok) {
    alert('문의 전송 완료!');
    bootstrap.Modal.getInstance(document.getElementById('inqModal')).hide();
  } else {
    alert('실패: ' + (await r.text()));
  }
}
 
checkHealth();
loadUsers();
loadCars();
</script>
</body>
</html>
"""

로컬 테스트

source venv/bin/activate
python app.py &

curl -s http://localhost:5000/ | head -20

외부 접근 설정(NSG) + systemd 서비스화

NSG에 5000 포트를 열어 외부 브라우저에서 접근 가능하게 하고, Flask 앱을 systemd 서비스로 등록해 SSH 종료 후에도 동작하게 설정

NSG 규칙 추가 - Flask 5000 포트

로컬 개발 머신(VM이 아닌, 로컬 PC)에서 실행

# 5000 포트 외부 노출
az vm open-port \
  --resource-group $RG \
  --name $VM \
  --port 5000 \
  --priority 1010
 
# 현재 NSG 규칙 확인
az network nsg rule list \
  --resource-group $RG \
  --nsg-name ${VM}NSG \
  --output table

  • 1433 포트는 절대 열지 마세요. SQL Server는 Step 4에서 127.0.0.1 바인딩했지만 NSG도 이중 차단입니다.
  • 우선순위(priority) 1010은 SSH 1000과 충돌하지 않도록 100 이상 차이를 둡니다.
  • 끝나면 5000도 다시 닫는것 필수

외부 차단 검증

# 외부에서 SQL Server 접속 시도 — 반드시 timeout 발생해야 정상
sqlcmd -S $PUBIP,1433 -U sa -P 'dummy' -C -l 5
# 출력 예: Login timeout expired ... (10 ~ 60초 후)
 
# 또는 nc(netcat) / Test-NetConnection
# Linux/Mac:
nc -zv $PUBIP 1433
# 결과: "Connection refused" 또는 "timed out" → OK
 
# Windows PowerShell:
Test-NetConnection -ComputerName $env:PUBIP -Port 1433
# TcpTestSucceeded : False → OK

1433 포트가 외부에서 timeout 또는 refused — 정상. 만약 connect 성공하면 즉시 NSG와 SQL Server bind 설정 재점검.

Flask앱 systemd 서비스 등록

sudo nano /etc/systemd/system/carmarket.service

[Unit]
Description=CarMarket Flask App
After=network.target mssql-server.service
Requires=mssql-server.service
 
[Service]
Type=simple
User=azureuser
WorkingDirectory=/home/azureuser/carmarket
EnvironmentFile=/home/azureuser/carmarket/.env
ExecStart=/home/azureuser/carmarket/venv/bin/gunicorn \
  --bind 0.0.0.0:5000 \
  --workers 2 \
  --access-logfile - \
  app:app
Restart=on-failure
RestartSec=5
 
[Install]
WantedBy=multi-user.target

# 서비스 활성화 및 시작
sudo systemctl daemon-reload
sudo systemctl enable carmarket
sudo systemctl start carmarket
sudo systemctl status carmarket --no-pager

Active: active (running) 표시되면 OK. 실패 시 journalctl -u carmarket -n 50 으로 로그 확인.

외부 브라우저로 접속

로컬 PC 브라우저에서 http://<PUBIP>:5000/ 로 접근
(azure portal의 vm 리소스에 기본 NIC 공용 IP 사용)

정리

리소스 삭제하거나, vm의 인바운드 포트 규칙의 5000 삭제

profile
성장하기 위한 기록

0개의 댓글