산후조리원 관리프로그램 개발 기록 (1) 왜 만들었나와 전체 구조, 3개 언어 지원

왜 만들게 됐나 산후조리원 한 곳을 운영하는 데 필요한 화면은 생각보다 많다. 상담 문의가 들어오면 상담 기록을 남기고, 예약이 잡히면 객실을 배정하고, 입실과 퇴실을 처리하고, 그 사이에 결제와 환불, 신생아 체중 기록, 직원 근무와 휴가, 비품 재고, 그리고 지자체 점검 때 내야 하는 행정 서류까지 챙겨야 한다. 이 일을 한 화면에서 처리하는 기존 PHP 기반 관리 사이트가 참고 대상으로 주어졌고, 우리 팀의 목표는 이 사이트가 하는 일을 Flask로 다시 만드는 것이었다. 조건이 하나 더 붙었다. 현장에서 일하는 직원 중에는 한국어보다 베트남어나 영어가 편한 사람도 있었고, 그래서 모든 화면이 한국어·영어·베트남어로 나와야 했다. 메뉴와 버튼만이 아니라 모달 창, 셀렉트 박스 옵션, 인쇄 양식까지 포함해서다. 프로젝트는 세 명이 나눠서 진행했다. 이 시리즈는 그중 내가 맡은 부분, 즉 Flask 기본 골격, 다국어 체계, AWS 이전과 서버 상태 화면, 보안 강화와 PostgreSQL 전환, 여러 지점(조리원)을 위한 권한 구조, 삭제된 산모 차트의 보관·복원 기능에 대한 기록이다. 1편은 전체 구조와 초반에 겪은 두 가지 시행착오를 다룬다. 요구사항과 제약 화면 수가 많다. 상단 메뉴만 일정·객실·매출·발송·운영·회원·스토어·설정 8개이고, 각 메뉴 아래 좌측 사이드바 하위 페이지가 다시 여러 개 달려 있다. 행정 서류만 8종이었다. 원본과 같은 동작. 기존 사이트의 화면 흐름(신청 → 상담 → 예약 → 입실 → 퇴실 → 해약)과 페이지 구성을 그대로 재현하는 것이 기준이었다. 3개 언어. 한국어 문구를 하드코딩한 원본 HTML을 가져와서 번역 가능한 구조로 바꿔야 했다. 민감한 데이터. 산모와 신생아의 개인정보, 결제 내역을 다루므로 지점별 데이터 격리와 접근 통제가 필요했다. 설계와 기술 선택 스택은 Python 3와 Flask 3.1, Flask-SQLAlchemy, Flask-Login, Jinja2 템플릿으로 정했다. 원본 화면이 서버 렌더링 HTML에 jQuery로 동작을 붙인 구조였기 때문에, SPA로 갈아엎기보다 Jinja 템플릿으로 화면을 옮기고 기존 스크립트를 최대한 살리는 쪽이 재현 속도가 빨랐다. 처음에는 개발 편의를 위해 SQLite를 썼고, 운영 단계에서 PostgreSQL로 옮겼다(2편에서 다룬다). 데이터는 모든 테이블에 지점 식별값( company_key 또는 supply_no )을 두고, 로그인할 때 세션에 현재 지점 키를 넣어 조회 범위를 좁히는 방식으로 격리했다. 계정은 조리원 관리자(User)와 직원(StaffProfile) 두 종류이고, 같은 로그인 폼에서 둘 다 처리한다. 최종적으로 라우트는 약 93개, DB 모델은 20개 정도가 됐다. 시행착오 1: Flask다운 URL로 만들었다가 다시 원본 URL로 처음 기획서에는 Flask 관례대로 블루프린트를 나눠 /payments/ , /sms/ , /members/ , /settings/ 같은 URL을 쓰는 구조를 그렸다. 깔끔해 보였고 일정 캘린더와 객실 카드보드는 이 구조로 금방 만들어졌다. 문제는 원본 메뉴와 하나씩 대조했을 때 드러났다. 원본은 all_stats_reports.php , send_sms.php , maternal_infor_list.php 처럼 페이지마다 PHP 파일이 하나씩 있는 구조였는데, 우리 쪽은 메뉴 하나에 라우트 하나씩만 대응시켜 놓은 상태였다. 매출 메뉴 아래에만 매출보고서, 카드·현금 매출내역, 입실예정 매출, 정산 4종이 있었는데 우리는 /payments/ 하나로 뭉뚱그려 놓았다. 표로 정리해 보니 매출·발송·회원·설정은 재설계가 필요했고 운영과 스토어는 아예 없었다. 새로 만들어야 할 페이지가 21개를 넘었다. 원인은 "Flask답게" 만드는 것과 "원본을 그대로 재현하는" 것이 서로 다른 목표였다는 데 있었다. 원본 화면의 HTML과 스크립트는 xxx.php 주소로 서로를 호출하고 있었고, URL을 바꾸면 그 링크와 AJAX 호출을 전부 고쳐야 했다. 그래서 방향을 바꿨다. 원본에서 추출한 페이지 샘플을 저장소에 올려 두고, 원본 URL을 그대로 키로 쓰는 페이지 레지스트리를 만들었다. 각 항목은 "원본 파일명 → 템플릿, 상단 메뉴, 사이드바, 화면 제목"을 담는다. 특별한 로직이 없는 페이지는 이 표를 돌면서 라우트를 자동 등록하고, 저장·조회 로직이 필요한 페이지만 전용 핸들러를 따로 둔다. PAGE_MAP = { 'all_stats_reports.php': ('all_stats_reports.html', '매출', 'sales', '매출보고서'), 'send_sms.php': ('send_sms.html', '발송', 'sms', '문자발송'), 'password_change.php': ('password_change.html', '설정', 'settings', '비밀번호변경'), 'server_status.php': ('server_status.html', '설정', 'settings', '서버상황'), # ... } # 전용 핸들러가 있는 페이지는 건너뛰고 나머지는 자동 등록 for page_name in PAGE_MAP: if page_name in CUSTOM_HANDLED: continue def make_view(pn): return lambda: render_page(pn) app.add_url_rule('/' + page_name, page_name, make_view(page_name)) 이렇게 하니 원본 화면의 링크와 스크립트를 거의 손대지 않고 템플릿으로 옮길 수 있었다. 새 페이지를 추가할 때는 레지스트리에 한 줄을 넣고 템플릿을 만들면 일단 화면이 뜨고, 기능이 붙어야 할 때 전용 핸들러로 옮기면 된다. URL에 .php 가 붙어 있는 Flask 앱이 어색하긴 하지만, 이 프로젝트에서는 재현 속도와 원본 대조의 편의가 더 중요했다. 시행착오 2: 번역 키를 붙였는데도 한국어가 남는 곳 번역은 static/lang/ 아래에 ko.json , en.json , vi.json 을 두고, 템플릿의 문구 요소에 data-i18n="maternal.no_member" 같은 키를 붙이는 방식으로 했다. 화면이 열리면 선택한 언어의 JSON을 받아 키가 붙은 요소의 텍스트를 바꾼다. 선택한 언어는 브라우저에 저장해 다음 방문에도 유지한다. 템플릿 파일을 하나씩 돌며 키를 붙이고 나서 베트남어로 바꿔 보니, 화면 곳곳에 한국어가 그대로 남아 있었다. 크게 두 군데였다. 서버가 문자열로 조립하는 HTML. 산모 목록 같은 표는 원본 동작을 맞추느라 라우트 함수 안에서 <tr> 문자열을 직접 만들어 AJAX로 내려주고 있었다. 상태 배지("신청", "상담", "예약"…)나 조리 기간, 초산/경산, 태아보험 여부 같은 값이 코드표에서 한국어 라벨로 바로 바뀌어 들어가니 템플릿만 봐서는 보이지 않았다. 공통 알림 창. 확인/취소 대화상자를 띄우는 공통 스크립트가 버튼 문구 기본값을 "확인" , "취소" 로 갖고 있었다. 호출하는 쪽에서 문구를 넘기지 않으면 언제나 한국어 버튼이 나왔다. 첫 번째는 코드값마다 라벨 대신 번역 키를 대응시키는 표를 따로 두고, 조립하는 HTML에 data-i18n 속성을 함께 넣어 해결했다. 화면에 처음 찍히는 문구는 한국어지만, 번역 스크립트가 지나가면서 키를 보고 바꿔 준다. state_i18n_keys = { '1': 'maternal.state_1', '2': 'maternal.state_2', '3': 'maternal.state_3', '4': 'maternal.state_4', '5': 'maternal.state_5', '6': 'maternal.state_6', '9': 'maternal.state_9', } state_i18n = state_i18n_keys.get(state_code, 'maternal.state_1') html += (f'<span class="state {state_class}" data-i18n="{state_i18n}">' f'{state_label}</span>') 두 번째는 번역 JSON을 받은 뒤 전역에 보관하고, 스크립트에서 쓸 수 있는 t(key) 함수를 하나 노출하는 것으로 정리했다. 알림 창은 넘겨받은 문구가 없으면 t('common.confirm') 을 먼저 찾고, 그래도 없으면 한국어로 떨어진다. 같은 방식으로 "준비중입니다" 같은 스크립트 안의 메시지와, 서버 JSON 응답에 함께 실어 보내는 i18n_key 도 처리했다. 이 일을 하면서 "번역이 끝났다"의 기준을 템플릿 파일이 아니라 실제 화면으로 바꿨다. 언어를 베트남어로 놓고 모든 메뉴와 모달을 열어 한글이 보이는지 확인하는 식이었다. 전체 모달과 인덱스 화면 번역을 끝낸 뒤에도 각 페이지 오류를 고치는 커밋이 여러 번 이어진 것은 이 확인 과정에서 나온 것들이다. 결과 원본 사이트의 메뉴 구조를 URL 그대로 따라가는 Flask 앱이 만들어졌고, 일정·객실·매출·발송·운영·회원·스토어·설정 각 화면과 행정 서류 8종이 같은 레이아웃 안에서 동작하게 됐다. 화면 문구는 한국어·영어·베트남어 JSON 파일로 분리돼서, 문구를 고치거나 언어를 늘릴 때 템플릿을 열지 않고 JSON만 수정하면 된다. 회고 처음부터 원본 메뉴를 전부 표로 뽑아 놓고 시작했다면 블루프린트 구조를 만들었다가 뒤집는 일은 없었을 것이다. 재현 프로젝트에서는 "더 나은 구조"보다 "원본과 1:1로 대조할 수 있는 구조"가 먼저라는 것을 배웠다. 번역도 다시 한다면 서버에서 HTML 문자열을 조립하는 부분을 먼저 템플릿 조각으로 옮긴 다음 키를 붙였을 것이다. 한국어 라벨과 번역 키를 두 벌로 관리하는 지금 방식은 동작은 하지만, 코드표가 늘어날 때마다 두 곳을 같이 고쳐야 한다. 그리고 번역 누락은 사람 눈보다 " vi 화면에 한글 음절이 남아 있으면 실패"하는 간단한 자동 점검으로 잡는 편이 나았을 것이다. 다음 편에서는 이 앱을 AWS로 옮기고, 보안을 손보고, SQLite에서 PostgreSQL로 갈아탄 과정을 정리한다. 기간: 2026.04 ~ 2026.09 기술: Python, Flask 3.1, Flask-SQLAlchemy, Jinja2, jQuery, SQLite, PostgreSQL 관련 프로젝트: 산후조리원 관리프로그램