wisheasy project(2) - CSV 데이터는 DB에 어떻게 올릴까?

1편 보러가기 클릭

현재 개발 환경

이 프로젝트는 Django 기반 백엔드 서버로 구성되어 있으며, 개발과 배포 환경을 명확히 분리해 관리하고 있다.

아래는 전체적인 개발 구조와 환경 설정 개요이다.

  • 사용 기술 스택
    • Backend Framework : Django
    • Database :
      • 개발(local) 환경: SQLite (db.sqlite3)
      • 운영(prod) 환경: MySQL
    • 배포 환경 : AWS EC2 (Ubuntu 기반)
    • 환경 변수 관리 : .env 파일 (비공개)
    • 버전 관리 : Git + GitHub
    • CI/CD : GitHub Actions (Ruff 자동 린트 및 포맷 적용)
    • 운영 서버 실행 : Gunicorn + Nginx 조합
  • 프로젝트 디렉토리 구조
    project-wisheasy/
    ├── apps/                         # Django 앱 폴더
    │   ├── accounts/                 # 사용자 인증 관련 앱
    │   ├── stations/                 # 역 정보, 노선 데이터 관리 앱
    │   ├── journeys/                 # 경로 탐색 관련 앱
    │   └── common/                   # 공용 유틸, 상수 등
    │
    ├── config/                       # Django 전역 설정
    │   ├── settings/
    │   │   ├── base.py               # 공통 설정
    │   │   ├── local.py              # 개발 환경용 설정 (SQLite)
    │   │   ├── prod.py               # 운영 환경용 설정 (MySQL)
    │   │   └── __init__.py
    │   ├── urls.py
    │   ├── wsgi.py
    │   └── asgi.py
    │
    ├── data_create/                  # 기타 정적 CSV/DB 초기 데이터
    ├── manage.py                     # Django 실행 스크립트
    ├── .env                          # 환경 변수 (비공개)
    ├── .gitignore
    └── requirements.txt              # Python 패키지 목록
    

서버의 DB는 비어있다

“로컬 DB(db.sqlite3)는 .gitignore로 제외했는데, 배포하면 DB가 비어 있는 거 아닌가?” => 맞음!

db.sqlite3는 개발용으로만 사용하는 SQLite 데이터베이스라 GitHub에 올리지 않는다.
환경마다 파일 경로나 내용이 다르고, 개인 테스트 데이터가 들어 있을 수 있기 때문이다.

그래서 대부분의 Django 프로젝트에서는 .gitignoredb.sqlite3를 등록하고, Git에는 코드와 설정만 관리한다.

배포 서버에서는 보통 MySQL이나 PostgreSQL 같은 운영용 데이터베이스를 사용하고,
이 DB 연결 정보는 .env 파일과 config/settings/prod.py에 설정한다.

해당 프로젝트는 서버용으로로 MySQL, 로컬용으로 sqlite를 사용한다.

# config/settings/prod.py
from .base import *

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.mysql",
        "NAME": env.str("MYSQL_DB"),
        "USER": env.str("MYSQL_USER"),
        "PASSWORD": env.str("MYSQL_PASSWORD"),
        "HOST": env.str("MYSQL_HOST"),
        "PORT": env.int("MYSQL_PORT", default=3306),
        "OPTIONS": {"charset": "utf8mb4"},
    }
}

그럼 고정적인 csv,xlxs 같은 파일은 어떻게 DB에 넣지?

배포 서버는 처음엔 비어 있는 상태이기 때문에 한 번은 반드시 CSV 데이터를 읽어서 DB에 적재해야 한다.

이 방법에는 두 가지가 있다.

다음과 같은 예시로 설명하겠다.

  1. 수동 실행 (가장 많이 사용되는 방식) => 서버에 올리기 위해서 이 과정을 할 것

    배포 후 서버에서 다음 명령을 한 번 실행한다.

     python manage.py makemigrations --settings=config.settings.prod
     python manage.py migrate --settings=config.settings.prod
     python manage.py load_csv --settings=config.settings.prod
    

    이렇게 하면 MySQL에 CSV 데이터가 삽입되고, 이후에는 다시 실행하지 않아도 된다.

  2. 자동 실행 (앱 시작 시 자동 로딩)

    apps/stations/apps.py의 ready() 메서드에 아래 로직을 추가하면 앱이 처음 구동될 때 DB가 비어 있을 경우 자동으로 CSV 데이터를 넣는다.

     from django.apps import AppConfig
     import csv
     from pathlib import Path
     from django.db.utils import OperationalError
    
     class StationsConfig(AppConfig):
         default_auto_field = 'django.db.models.BigAutoField'
         name = 'apps.stations'
    
         def ready(self):
             from apps.stations.models import Station
             csv_path = Path(__file__).resolve().parent / 'data' / 'stations.csv'
             try:
                 if Station.objects.count() == 0:
                     with open(csv_path, newline='', encoding='utf-8') as f:
                         reader = csv.DictReader(f)
                         for row in reader:
                             Station.objects.create(**row)
             except OperationalError:
                 pass
    

    이 방식은 서버가 처음 실행될 때 자동으로 데이터를 채워주지만, 마이그레이션이 끝난 뒤에 실행돼야 하므로 주의가 필요하다.

방식 추천 환경 이유
(1)수동 실행 (명시적 로딩) 운영 서버 한번만 실행하면 끝, 안전함
(2)자동 실행 (ready()) 개발 환경 초기화 자동화에 편리

CSV 파일은 Git에 올리기

CSV 파일은 고정된 공통 데이터이기 때문에 Git에 포함시켜도 문제 없다. 오히려 운영 서버와 로컬에서 동일한 데이터를 보장하는 데 도움이 된다.


Django로 csv를 DB에 적재하는 전체 과정 정리

1. 폴더 구조 구축

apps/
└── journeys/
    ├── management/
    │    └── commands/
    │         └── load_csv.py    ← csv를 DB에 업로드하는 파일
    ├── models.py
    ├── views.py
    ├── ...

2. load_csv.py 작성

import csv
import os
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from apps.journeys.models import Lines, Edges, Nodes


class Command(BaseCommand):
    help = "static/data 폴더의 CSV 파일(line2_7_edges.csv, line2_7_lines.csv, line2_7_nodes.csv)을 DB에 로드"

    def add_arguments(self, parser):
        parser.add_argument(
            '--file',
            type=str,
            help='불러올 CSV 파일 이름 (예: line2_7_lines.csv)',
        )

    def handle(self, *args, **options):
        csv_file = options['file']

        if not csv_file:
            raise CommandError("CSV 파일 이름을 '--file' 옵션으로 지정해야 합니다. 예: python manage.py load_csv --file line2_7_lines.csv")

        # static/data 폴더 기준 경로 설정
        csv_path = os.path.join(settings.BASE_DIR, 'static', 'data', csv_file)
        if not os.path.exists(csv_path):
            raise CommandError(f"파일을 찾을 수 없습니다: {csv_path}")

        self.stdout.write(f"📂 '{csv_file}' 불러오는 중...")

        with open(csv_path, newline='', encoding='utf-8-sig') as csvfile:
            reader = csv.DictReader(csvfile)

            # 파일 이름으로 분기
            if 'lines' in csv_file:
                self.load_lines(reader)
            elif 'edges' in csv_file:
                self.load_edges(reader)
            elif 'nodes' in csv_file:
                self.load_nodes(reader)
            else:
                raise CommandError(f"⚠️ '{csv_file}'은(는) 인식되지 않는 파일입니다.")

    # --- Lines ---
    def load_lines(self, reader):
        count_new = 0
        count_update = 0

        for row in reader:
            obj, created = Lines.objects.update_or_create(
                line=row["line"],
                order_in_line=row["order_in_line"],
                defaults={
                    "station": row["station"],
                },
            )
            if created:
                count_new += 1
            else:
                count_update += 1

        self.stdout.write(
            self.style.SUCCESS(f"✅ line2_7_lines.csv 업로드 완료: {count_new}개 추가, {count_update}개 업데이트됨")
        )

    # --- Edges ---
    def load_edges(self, reader):
        count_new = 0
        count_update = 0

        for row in reader:
            obj, created = Edges.objects.update_or_create(
                edge_key=row["edge_key"],  # PK 기준으로 찾음
                defaults={
                    "relation": row["relation"],
                    "escalator": row.get("escalator", 0),
                    "out_of_order": row.get("out_of_order", 0),
                    "is_escalator": row.get("is_escalator") or None,
                    "source": row["source"],
                    "target": row["target"],
                },
            )
            if created:
                count_new += 1
            else:
                count_update += 1

        self.stdout.write(
            self.style.SUCCESS(f"✅ edges.csv 업로드 완료: {count_new}개 추가, {count_update}개 업데이트됨")
        )


    # --- Nodes ---
    def load_nodes(self, reader):
        count_new = 0
        count_update = 0

        def parse_str(value):
            return value.strip() if value else None

        for row in reader:
            obj, created = Nodes.objects.update_or_create(
                node_id=row["node_id"],  # PK 기준으로 중복 체크
                defaults={
                    "line": parse_str(row.get("line")),
                    "node_name": parse_str(row.get("node_name")),
                    "floor": parse_str(row.get("floor")),
                    "type": parse_str(row.get("type")),
                    "station": parse_str(row.get("station")),
                },
            )
            if created:
                count_new += 1
            else:
                count_update += 1

        self.stdout.write(
            self.style.SUCCESS(
                f"✅ nodes.csv 업로드 완료: {count_new}개 추가, {count_update}개 업데이트됨"
            )
        )

3. 로컬에 csv파일 업로드 (makemigrations , migrate 전제 조건)

python manage.py load_csv --file line2_7_edges.csv --settings=config.settings.local
python manage.py load_csv --file line2_7_stations.csv --settings=config.settings.local
python manage.py load_csv --file line2_7_lines.csv --settings=config.settings.local

근데, 이떄 두가지 에러가 발생했다.

첫번째는 primary-key error, 두번째는 type error

해결 과정은 따로 log 포스팅 업로드하였다.

에러 해결 일지 보러가기

해결 완료!

그럼 이제 내 로컬 sqlite에 csv파일이 잘 들어간 것을 확인할 수 있다. screenshot screenshot

.github/workflows/cicd_web-db-nginx.yml 파일을 보면 makemigrations를 하면 자동으로 MySQL에 반영하게 되어있다.

4. 커밋메세지 작성하고 dev에 push 하기

5. 서버 MySQL에도 CSV 파일 올려주기

python manage.py load_csv --file line2_7_edges.csv --settings=config.settings.prod
python manage.py load_csv --file line2_7_nodes.csv --settings=config.settings.prod
python manage.py load_csv --file line2_7_lines.csv --settings=config.settings.prod

그런데, 이 코드를 실행하면 django.db.utils.OperationalError: (2006, "Can't connect to MySQL server on ...) 이런 오류가 발생한다.

EC2 보안그룹에서 3306 포트가 닫혀 있어서 올릴 수 없다.

컨테이너 관리자가 위의 3줄 코드를 한번 실행해주면 잘 올라갈 것임.

끝 !