산후조리원 관리프로그램 개발 기록 (3) 다지점 권한 분리와 삭제 차트 보관·복원

한 지점에서 여러 지점으로 1편과 2편에서 다룬 대로 화면을 옮기고 서버와 보안을 정리한 뒤, 다음 과제는 두 가지였다. 하나는 한 시스템 안에서 조리원(지점)을 여러 개 운영하는 것, 즉 지점을 추가하고 지점마다 관리자를 따로 두는 구조다. 다른 하나는 산모 차트를 지웠을 때 되돌릴 수 있게 하는 것이었다. 그때까지 차트 삭제는 결제와 신생아 기록까지 함께 지워 버리는 되돌릴 수 없는 작업이었다. 두 요청은 성격이 달라 보이지만 결국 같은 질문으로 모인다. 누가 어느 지점의 어떤 데이터에 손댈 수 있고, 손댄 흔적은 어떻게 남는가. 이 편은 그 두 기능을 만들면서 겪은 일을 정리한다. 요구사항과 제약 지점 추가와 지점 관리자 계정 발급은 본사 쪽 한 사람(슈퍼관리자)만 할 수 있어야 한다. 일반 관리자는 자기가 배속된 지점의 데이터만 보고, 다른 지점으로 전환할 수 없어야 한다. 삭제된 산모 차트는 결제·메모·신생아 정보까지 함께 되살릴 수 있어야 하고, 누가 되살렸는지 남아야 한다. 이미 운영 중인 PostgreSQL DB에 컬럼을 추가해야 한다. 기존 데이터를 날리고 새로 만들 수 없다. 설계 데이터 격리는 이미 모든 테이블의 지점 키로 하고 있었으므로, 바꿀 것은 "누가 어떤 지점 키를 쓸 수 있는가"였다. 사용자 테이블에 is_super_admin 컬럼을 추가하고, 슈퍼관리자 전용 조리원 계정 관리 화면을 만들었다. 이 화면에서 새 지점을 만들면 서로 겹치지 않는 지점 식별번호를 난수로 발급하고, 기존 지점에 일반 관리자를 배속하면 그 지점의 사업자 정보와 주소, 계좌 정보를 새 계정에 복사한다. 운영 중인 DB에 슈퍼관리자가 아무도 없는 상태로 배포되면 아무도 이 화면에 들어갈 수 없다. 그래서 컬럼을 채우는 시점에 "승인된 관리자 중 가장 먼저 만들어진 계정 하나를 슈퍼관리자로 올리되, 이미 슈퍼관리자가 있으면 아무것도 하지 않는" 갱신문을 함께 넣었다. 배포를 몇 번 반복해도 결과가 같다. UPDATE users SET is_super_admin = TRUE WHERE id = ( SELECT MIN(id) FROM users WHERE LOWER(COALESCE(role, '')) = 'admin' AND COALESCE(is_approved, FALSE) = TRUE ) AND NOT EXISTS ( SELECT 1 FROM users WHERE COALESCE(is_super_admin, FALSE) = TRUE ) 여기서 2편의 결정이 발목을 잡았다. PostgreSQL 전환 때 레거시 컬럼 백필을 SQLite에서만 돌게 해 두었는데, 운영 DB는 PostgreSQL이라 새 컬럼이 추가되지 않는다. 사용자 컬럼 백필만큼은 DB 종류와 관계없이 항상 돌도록 빼냈다. 계정 관리 화면 자체에는 CSRF 검사와 분당 요청 제한, 아이디·이메일 형식 검사, 중복 검사를 걸었고, 슈퍼관리자가 자기 계정을 비활성화하지 못하게 막았다. 사용이 중지된 계정은 비밀번호가 맞아도 로그인되지 않는다. 시행착오 1: 화면에서 숨겼는데 전환이 됐다 다음 단계로 일반 관리자 배속과 조리원 이름 수정 기능까지 붙이고 나서 권한 경계를 다시 점검했다. 상단의 지점 전환 셀렉트 박스는 선택지가 둘 이상일 때만 보이게 되어 있었는데, 지점이 여러 개가 되자 일반 관리자에게도 모든 지점이 선택지로 잡히는 구조였다. 원인을 따라가 보니 "이 사용자가 접근할 수 있는 지점 목록"을 구하는 함수가 직원 계정일 때만 배속 지점으로 좁히고, 관리자 계정이면 무조건 전체 지점을 돌려주고 있었다. 지점이 하나뿐이던 시절에는 관리자 = 그 지점의 주인이었기 때문에 문제가 없었던 코드다. 그리고 이 함수는 화면의 셀렉트 박스뿐 아니라 지점 전환 API의 허용 여부 판단에도 쓰이고 있었다. 셀렉트 박스만 숨겼다면 API를 직접 호출해서 다른 지점으로 넘어갈 수 있었을 것이다. 그래서 서버 쪽 판단부터 고쳤다. 관리자 계정이라도 슈퍼관리자가 아니면 자기 지점 키 하나만 돌려준다. 템플릿의 셀렉트 박스는 슈퍼관리자일 때만 그리고, 지점 전환 API에는 CSRF 검사를 추가했다. def accessible_company_keys(): staff = get_current_staff_record() if staff: return parse_staff_company_keys(staff) current_user = get_current_user_record() if current_user and current_user.is_super_admin: return [option['key'] for option in all_company_options()] own = normalize_company_key(current_user.mb_company_key) if current_user else '' return [own] if own else [] 이 수정에는 테스트를 같이 넣었다. 일반 관리자로 설정 화면을 열면 셀렉트 박스가 없어야 하고, 같은 계정으로 전환 API를 호출하면 403이 나와야 한다. 슈퍼관리자는 둘 다 반대다. 화면 확인과 API 확인을 한 테스트 안에 묶어 둔 이유는, 이번 일이 정확히 그 둘이 어긋나서 생긴 문제였기 때문이다. 시행착오 2: 삭제를 보관으로 바꾸자 새는 곳들 기존 차트 삭제는 말 그대로 삭제였다. 차트를 지우면 그 차트에 딸린 결제 내역, 메모, 신생아 정보를 먼저 지우고 예약 행을 지웠다. 한 번 지우면 되돌릴 방법이 없었다. 이를 "보관"으로 바꾸는 것은 간단해 보였다. 예약 테이블에 deleted_at , deleted_by 를 추가하고, 삭제 대신 이 두 값을 채운다. 딸린 데이터는 건드리지 않으니 복원할 때 deleted_at 만 비우면 전부 돌아온다. 지점별 예약을 조회하는 공통 함수에 deleted_at=None 조건을 넣으면 목록에서도 사라진다. 그런데 딸린 데이터를 남겨 둔다는 것은, 예약을 거치지 않고 그 데이터를 직접 조회하는 곳에서는 보관된 차트의 결제와 아기 정보가 계속 보인다는 뜻이었다. 확인해 보니 이런 곳이 여러 군데였다. 결제 조회 함수는 "결제 행의 지점 키가 맞거나, 예약이 지점에 속하면" 통과시키고 있어서 보관된 차트의 결제가 매출에 그대로 잡혔다. 예약이 살아 있는 결제만 남도록 조건을 하나 더 걸었다. 신생아 단건 조회는 아기 행의 지점 키만 맞으면 바로 돌려주고 있었다. 연결된 예약이 살아 있는지도 확인하게 했다. 일일 업무 집계의 출생아 수도 아기 테이블을 직접 세고 있어서 같은 조건을 추가했다. 차트 수정 API는 폼으로 넘어온 필드를 이름이 같은 모델 속성에 그대로 넣는 구조였다. deleted_at 을 폼에 실어 보내면 보관 상태를 조작할 수 있으므로 건너뛸 필드 목록에 추가했다. 복원에도 함정이 있었다. 차트가 보관된 사이에 같은 객실, 같은 기간에 다른 예약이 잡혔을 수 있다. 그 상태로 복원하면 한 방에 두 산모가 배정된다. 복원하려는 차트가 예약이나 입실 상태이고 객실이 배정되어 있으면, 같은 객실의 살아 있는 예약과 기간이 겹치는지 확인하고 겹치면 409로 거절한다. 같은 차트를 두 사람이 동시에 복원하는 경우를 막기 위해 조회할 때 행 잠금도 걸었다. rv = query.filter_by(rv_id=rv_id).with_for_update().first() if not rv: return jsonify(result='fail', msg='삭제된 차트를 찾을 수 없습니다.'), 404 if rv.rv_rooming_no and rv.rv_medical_state in (3, 4): start, end = stay_range(rv) for other in active_reservations_in_room(rv.rv_rooming_no): o_start, o_end = stay_range(other) if not all((start, end, o_start, o_end)) or (o_start <= end and start <= o_end): return jsonify(result='fail', msg='같은 객실에 겹치는 예약이 있습니다.'), 409 rv.deleted_at = None rv.deleted_by = None 처음에는 조리원 관리자만 복원할 수 있게 했다. 직원별 차트 수정·삭제 권한은 바로 앞 작업에서 이미 만들어 둔 상태였으므로, 다음 작업에서 "차트 수정과 삭제 권한이 모두 켜진 직원"도 복원할 수 있게 넓혔다. 권한을 넓히는 대신 기록을 강화했다. 처음 버전은 복원을 커밋한 뒤 감사 로그를 따로 저장했는데, 이러면 복원은 됐는데 기록은 실패하는 경우가 생길 수 있다. 복원과 감사 기록을 같은 트랜잭션에 넣고, 기록에는 수행자 이름과 계정 종류(관리자/직원), 차트 이름을 담았다. 삭제 차트 화면 아래에는 이 기록을 읽어 "누가 언제 어떤 차트를 복원했는지" 목록으로 보여 준다. 결과 슈퍼관리자는 한 화면에서 새 조리원을 만들고 관리자 계정을 발급하거나 기존 조리원에 배속할 수 있고, 일반 관리자는 자기 조리원 데이터만 다룬다. 지점 전환은 화면과 API 양쪽에서 슈퍼관리자로 제한된다. 삭제된 산모 차트는 결제·메모·신생아 정보와 함께 보관되었다가 관리자 또는 권한을 가진 직원이 복원할 수 있고, 객실 기간이 겹치면 복원이 거절되며, 복원 이력이 화면에 남는다. 이 동작들은 각각 단위 테스트로 묶어 두었다. 회고 두 시행착오 모두 "한 곳에서만 막았다"는 데서 시작했다. 지점 전환은 화면 조건과 API 판단이 같은 함수를 공유하고 있었는데 그 함수가 옛 가정을 품고 있었고, 차트 보관은 예약 조회 한 곳만 막고 딸린 데이터를 직접 읽는 경로를 놓쳤다. 다시 한다면 소프트 삭제를 도입하기 전에 해당 테이블을 참조하는 쿼리를 전부 목록으로 뽑고 하나씩 지워 나가는 방식으로 시작했을 것이다. 가능하다면 결제·아기 테이블에도 보관 상태를 전파하거나, 조회를 반드시 공통 함수로만 하도록 강제하는 편이 더 안전하다. 권한 쪽에서는 테스트를 "화면에서 안 보인다"와 "API가 거절한다"를 한 묶음으로 쓰는 습관이 생겼다. 사람이 확인하는 것은 대개 화면이고, 실제로 뚫리는 것은 대개 API다. 기간: 2026.04 ~ 2026.09 기술: Python, Flask 3.1, SQLAlchemy, PostgreSQL, Jinja2, unittest 관련 프로젝트: 산후조리원 관리프로그램