포트폴리오
프로젝트 목록
개발 서버에서 운영 서버로 1편에서 원본 사이트의 화면을 Flask로 옮기는 과정을 정리했다. 화면이 어느 정도 갖춰지자 실제 조리원에서 써 볼 수 있도록 서버를 옮겨야 했다. 새 도메인을 연결한 AWS EC2 인스턴스에 올리고, gunicorn을 systemd 서비스로 띄워 운영하는 구성이다. 이 편은 그 이전 과정과, 운영을 시작하면서 손본 보안, 그리고 SQLite에서 PostgreSQL로 옮기면서 생긴 문제를 다룬다. 산모와 신생아의 개인정보, 결제 금액이 들어가는 시스템이라 "일단 띄우고 나중에 막자"는 선택지는 없었다. 그렇다고 별도 보안 장비나 관리형 서비스를 붙일 규모도 아니었다. 서버 한 대와 Flask 앱 안에서 할 수 있는 것부터 겹겹이 쌓기로 했다. 요구사항과 제약 로컬(Windows)과 서버(Linux)에서 같은 코드가 설정만 바꿔 돌아가야 한다. 관리자가 서버에 SSH로 들어가지 않고도 디스크·메모리·프로세스 상태와 보안 상태를 화면에서 확인할 수 있어야 한다. 공지사항처럼 관리자가 HTML을 입력하는 화면, 로그인 화면은 공격 입력을 전제로 막아야 한다. 이미 운영 데이터가 쌓이기 시작한 상태에서 DB 엔진을 바꿔야 한다. 서버 이전과 서버 상태 화면 이전 작업에서 가장 먼저 한 일은 설정을 코드에서 떼어 내는 것이었다. .env 를 읽어 SECRET_KEY 와 DATABASE_URL 을 환경변수로 받게 하고, .env , 키 파일, DB 파일이 들어 있는 폴더, 업로드 폴더를 .gitignore 에 넣었다. 로그인도 이 시점에 이메일 대신 아이디로 받도록 바꿨다. 배포는 스크립트 두 개로 정리했다. 최초 셋업 스크립트는 venv를 만들고 의존성을 설치한 뒤 gunicorn systemd 유닛을 등록하고, 업로드 스크립트는 .git , venv , instance 같은 폴더를 빼고 rsync로 올린 다음 서비스를 재시작한다. 중간에 서버 OS가 Amazon Linux로 정해지면서 접속 계정 이름이 바뀌었는데, 스크립트 곳곳에 계정명이 박혀 있어서 변수 하나로 묶어 두었다. 같은 커밋에서 .env 를 올린 직후 권한을 600 으로 바꾸는 줄도 추가했다. 서버 상태 화면은 설정 메뉴 아래에 두었다. 디스크 사용량은 shutil.disk_usage , 메모리와 가동 시간은 /proc/meminfo , /proc/uptime 을 직접 읽고, gunicorn 워커 수는 pgrep 으로 센다. 항목마다 따로 try 로 감싸서, 로컬 Windows처럼 /proc 이 없는 환경에서는 해당 값만 비고 화면은 그대로 뜨게 했다. 처음에는 이 코드를 라우트 함수 안에 바로 썼다가, 보안 상태 수집 코드가 생기면서 둘 다 services/ 모듈로 옮겼다. 시행착오 1: 보안을 다섯 겹으로 공지사항 화면은 관리자가 굵게, 링크 같은 서식을 넣을 수 있게 되어 있어서, 저장된 HTML이 그대로 다른 사람의 화면에 그려진다. 입력값 정화, 작성 권한 확인, 위조 요청 검사, 요청 횟수 제한 중 어느 것이 빠져도 구멍이 되는 구조였다. 로그인 화면도 비밀번호 시도 횟수에 제한이 없었다. 한 가지 장치로 다 막을 수 없는 문제라서 층을 나눴다. 층 막는 것 방법 XSS 스크립트 삽입 bleach 화이트리스트(b, i, a, br 등)로 저장 시 정화 권한 직원의 공지 작성 관리자 역할만 작성·수정·삭제 허용 CSRF 외부 사이트의 위조 요청 세션별 32바이트 난수 토큰, 상수 시간 비교 입력 검증 비정상 길이·타입 서버에서 제목 200자, 본문 20,000자 등 재검증 레이트리밋 반복 요청 Flask-Limiter로 IP당 분·시간 단위 제한 CSRF는 Flask-WTF 폼을 쓰지 않는 AJAX 화면이 대부분이라 직접 구현했다. 세션에 토큰을 만들어 두고 모든 템플릿에 주입한 뒤, 상태를 바꾸는 요청에서 헤더나 폼 필드로 받은 값을 비교한다. def verify_csrf_token(): submitted = (request.headers.get('X-CSRFToken') or request.form.get('_csrf_token', '')) expected = session.get('_csrf_token', '') if not expected or not submitted: return False return hmac.compare_digest(str(expected), str(submitted)) def require_csrf(view_func): @wraps(view_func) def wrapped(*args, **kwargs): if request.method in ('GET', 'HEAD', 'OPTIONS'): return view_func(*args, **kwargs) if not verify_csrf_token(): return text_json({'result': 'fail', 'msg': 'CSRF 토큰이 유효하지 않습니다.'}), 400 return view_func(*args, **kwargs) return wrapped 앱 바깥에서는 fail2ban을 붙였다. 로그인에 실패하면 앱이 전용 인증 로그 파일에 LOGIN_FAIL ip=... reason=... 형식으로 한 줄을 남기고, fail2ban이 이 파일을 감시하다가 같은 IP에서 실패가 반복되면 iptables로 차단한다. 로그 디렉터리를 만들 수 없는 로컬 환경에서는 조용히 넘어가도록 해서 개발 환경을 깨뜨리지 않게 했다. 이와 별도로 로그인·로그아웃과 주요 변경 작업은 DB의 감사 로그 테이블에 누가, 어디서, 무엇을 했는지 남긴다. 세션 쿠키는 HttpOnly , SameSite=Lax 로 고정하고 운영에서는 Secure 를 켰다. 여기서 주의할 점이 하나 있었는데, Flask가 SESSION_COOKIE_SAMESITE 키를 기본값 None 으로 미리 등록해 두기 때문에 "키가 없으면 넣는다" 식의 기본값 처리로는 적용되지 않는다. 그래서 설정을 직접 대입했다. 세션 만료는 마지막 활동 기준 8시간으로 두고 요청마다 연장되게 했다. 이 모든 것을 확인하는 보안 관리 화면도 만들었다. fail2ban 감시 목록별 차단 IP, HTTPS 인증서 만료일과 자동 갱신 타이머 상태, 민감 파일 권한이 기대값과 같은지, 다섯 층이 각각 켜져 있는지를 한 화면에 보여 주고, 관리자가 IP를 차단·해제하거나 영구 예외로 등록할 수 있다. 화면에서 받은 IP와 감시 목록 이름은 ipaddress 모듈과 정규식으로 검증한 뒤에만 명령에 넘긴다. 시행착오 2: SQLite에서 PostgreSQL로 운영 서버로 옮긴 뒤 DB도 SQLite 파일에서 PostgreSQL로 옮기기로 했다. SQLAlchemy를 쓰고 있었으니 DATABASE_URL 만 바꾸면 될 거라 생각했지만, 앱 시작 단계에서 실행되는 코드부터 걸렸다. 원인은 ORM 바깥에 있었다. 개발 중에 모델에 컬럼을 계속 추가하면서, 기존 DB에 없는 컬럼을 앱 시작 때 채워 넣는 함수를 여럿 만들어 두었다. 이 함수들이 컬럼 목록을 SQLite 전용 구문인 PRAGMA table_info(...) 로 읽고 있었고, 기본값도 BOOLEAN DEFAULT 1 처럼 SQLite가 너그럽게 받아 주는 형태였다. PostgreSQL에는 PRAGMA 가 없고, 불리언 컬럼에 정수 기본값을 주는 구문도 받아 주지 않는다. 고친 방법은 세 가지다. 컬럼 조회를 SQLAlchemy의 inspect 로 바꿔 두 DB에서 같은 코드가 돌게 했다. DEFAULT 1/0 을 DEFAULT TRUE/FALSE 로, COALESCE(view_pc, 1) 같은 갱신문도 불리언 리터럴로 바꿨다. 레거시 컬럼 채우기는 SQLite일 때만 돌게 했다. PostgreSQL은 빈 DB에서 create_all() 로 모든 컬럼이 처음부터 만들어지기 때문이다. def _existing_columns(table_name): """SQLite/PostgreSQL 양쪽 모두 지원하는 컬럼 목록 조회.""" try: inspector = inspect(db.engine) if table_name not in inspector.get_table_names(): return set() return {col['name'] for col in inspector.get_columns(table_name)} except Exception: return set() with app.app_context(): db.create_all() if db.engine.dialect.name == 'sqlite': ensure_user_columns() ensure_reservation_columns() # ... 그런데 3번은 나중에 대가를 치렀다. 몇 달 뒤 슈퍼관리자 여부 컬럼을 추가했을 때, 이미 데이터가 들어 있는 PostgreSQL 테이블에는 create_all() 이 새 컬럼을 만들어 주지 않는다. "PostgreSQL은 처음부터 다 만들어진다"는 가정은 처음 한 번만 맞는 말이었다. 그래서 사용자 테이블 백필은 DB 종류와 상관없이 항상 돌게 옮겼고, 이후 차트 보관용 컬럼을 추가할 때는 PostgreSQL에서 ADD COLUMN IF NOT EXISTS 를 쓰도록 했다. 이 이야기는 3편에서 이어진다. 로컬 개발에서도 문제가 하나 있었다. 서버용 .env 를 그대로 가져오면 DATABASE_URL 이 리눅스 절대경로라서 Windows에서 DB 파일을 만들지 못했다. SQLite 절대경로인데 상위 폴더가 없으면 로컬 instance/app.db 로 대신 연결하게 했다. 이 작업들과 함께 HTTPS 인증서 자동 갱신과 자동 백업도 설정했다. 한 커밋에 묶어 들어간 만큼 범위가 넓었다. 결과 앱은 AWS EC2에서 gunicorn 4 워커, systemd 서비스로 돌고, 배포는 업로드 스크립트 한 번으로 끝난다. 관리자는 설정 메뉴에서 서버 자원 상태와 보안 상태를 확인하고 차단 IP를 직접 관리할 수 있다. 공지 입력과 로그인에는 다섯 겹의 방어와 fail2ban 차단, 감사 로그가 걸려 있다. 회고 DB 전환에서 배운 것은 "ORM을 쓰니까 DB에 독립적"이라는 말이 ORM 밖에서 쓴 SQL 한 줄에 쉽게 깨진다는 점이다. 컬럼을 앱 시작 때 손으로 채우는 방식은 SQLite 하나일 때는 편했지만, 엔진이 둘이 되자 분기가 생기고 그 분기가 나중에 버그가 됐다. 다시 한다면 PostgreSQL로 옮기는 시점에 Flask-Migrate(Alembic) 마이그레이션 체계로 바꿨을 것이다. 의존성 목록에는 이미 들어 있었는데 쓰지 않았다. 보안 쪽은 다섯 겹을 공지사항과 로그인에 먼저 적용했고, CSRF 데코레이터를 다른 쓰기 API로 넓혀 가는 일은 이후 작업으로 이어졌다. 처음부터 "상태를 바꾸는 모든 POST에는 기본으로 CSRF 검사"를 걸고 예외를 명시하는 쪽으로 설계했다면 빠뜨린 곳을 찾는 수고가 줄었을 것이다. 기간: 2026.04 ~ 2026.09 기술: Python, Flask 3.1, SQLAlchemy, PostgreSQL, SQLite, gunicorn, systemd, AWS EC2, fail2ban, iptables, bleach, Flask-Limiter, Let's Encrypt 관련 프로젝트: 산후조리원 관리프로그램
프로젝트 목록
공개된 기록과 소식
프로필과 소개