사이드프로젝트

장기요양기관 서비스에서 상세 데이터 저장 구조 마이그레이션

project-sf 2026. 3. 30. 22:05

최근 운영 중인 장기요양기관 검색 서비스에서 상세 데이터 저장 구조를 정리하는 작업을 진행했다.
기존에는 기관 기본 정보는 facilities 테이블에, 상세 정보는 facility_details 테이블에 분리해서 저장하고 있었다. 처음에는 이 방식이 자연스러워 보였지만, 실제 서비스를 운영하면서 읽기 경로와 쓰기 경로가 점점 복잡해졌고, 유지보수 비용도 꽤 커졌다.

이번 글에서는 왜 이 구조를 바꾸게 되었는지, 어떤 기준으로 마이그레이션을 설계했는지, 그리고 운영 중인 서비스에서 비교적 안전하게 전환하기 위해 어떤 순서로 접근했는지를 정리해보려고 한다.
보안이나 운영 리스크를 고려해 너무 세부적인 내부 구현보다는, 전체 흐름과 판단 기준 중심으로 적었다.

기존 구조의 문제

기존에는 기관 기본 목록과 검색에 필요한 값은 facilities에 저장하고, 평가/정원/인력/프로그램/비급여 같은 상세 정보는 facility_details.details에 JSON 형태로 저장하고 있었다.

문제는 시간이 지나면서 이 경계가 점점 흐려졌다는 점이다.

  • 어떤 값은 facilities 컬럼에도 있고 facility_details.details에도 있었다.
  • 공공 API와 웹 크롤링이 서로 다른 구조로 같은 의미의 데이터를 저장했다.
  • 읽는 쪽에서는 fallback이 많아졌다.
  • 수집 로직도 테이블 두 군데를 동시에 갱신하는 방식으로 복잡해졌다.

예를 들어 같은 “상세 정보”를 보여주더라도 실제로는 여러 경로를 fallback 하며 읽고 있었고, 그 결과 어디를 정본으로 봐야 하는지 애매한 상황이 생겼다.
이 상태에서는 기능 추가보다도 “현재 값이 왜 이렇게 나오는지”를 이해하는 데 더 많은 시간이 들었다.

정리 방향

이번에 세운 원칙은 비교적 단순했다.

첫째, 기관의 존재와 기본 식별자는 facilities가 책임진다.
둘째, 실제 서비스에서 쓰는 상세 정보의 정본도 facilities 안으로 모은다.
셋째, 상세 데이터는 facilities.details라는 JSONB 컬럼 하나를 중심으로 읽고 쓴다.

즉 구조를 완전히 새로 만드는 것이 아니라, 이미 있던 facilities를 중심으로 상세 데이터까지 흡수하는 방향으로 정리한 것이다.

여기서 중요한 판단은 “중복을 먼저 완벽하게 정리한 뒤 합칠 것인가”였다.
결론적으로는 그 반대로 갔다. 먼저 최종 구조를 정하고, 마이그레이션 과정에서 canonical 데이터만 남기도록 설계했다.
이 방식이 훨씬 현실적이었다. 기존 구조를 완벽히 청소한 뒤 다시 합치는 것은 같은 문제를 두 번 푸는 것과 비슷했기 때문이다.

어떤 데이터를 정본으로 삼았나

이 서비스는 공공 API 데이터와 웹 크롤링 데이터를 함께 사용한다.
하지만 실제 화면 품질과 상세 정보의 정확성을 기준으로 보면 웹 크롤링 데이터가 더 실용적이었다.

그래서 역할을 이렇게 나눴다.

  • 공공 API: 기관 존재 확인, 기본 식별자, 지역 코드, 행정 정보
  • 웹 크롤링: 상세 페이지에 직접 노출되는 정규화된 상세 정보

최종적으로 facilities.details 안에 남기기로 한 핵심 구조는 아래와 같은 성격이다.

  • general
  • occupancy
  • staff
  • evaluation
  • non_benefit_costs
  • programs

이 구조를 중심으로 읽기와 쓰기를 통일하기 시작했다.

주소는 예외로 봤다

이번 마이그레이션에서 흥미로웠던 부분 중 하나는 주소였다.
처음에는 주소도 중복처럼 보였지만, 실제로는 역할이 달랐다.

  • details.general.address는 상세 원문 주소
  • facilities.address는 검색, 지도, 좌표 계산용 운영 주소

즉 주소는 단순한 중복이 아니라 용도 분리가 있는 데이터였다.
그래서 이 부분은 억지로 하나로 합치지 않고, 역할을 분리한 채 유지하는 쪽으로 정리했다.
이런 예외를 인정하지 않으면 마이그레이션이 오히려 더 부자연스러워진다.

운영 DB 하나일 때 가장 중요했던 점

이번 작업은 운영 DB가 하나뿐인 상황에서 진행했다.(약간의 귀차니즘)
그래서 기술적인 구현보다도 “어떤 순서로 안전하게 옮길 것인가”가 더 중요했다.

가장 먼저 한 일은 파괴적인 변경을 미루는 것이었다.

  • 기존 테이블은 바로 삭제하지 않음
  • 새 컬럼만 추가
  • 먼저 백필
  • 그 다음 읽기 경로 전환
  • 마지막에 쓰기 경로 전환
  • 실제 테이블 삭제는 보류

이 순서를 지키면 문제가 생겨도 되돌아갈 수 있는 여지가 많아진다.
특히 운영 DB 하나뿐일 때는 “삭제하지 않는 것” 자체가 강력한 안전장치가 된다.

실제 전환 순서

전체 흐름은 대략 아래와 같았다.

1. facilities에 새 컬럼 추가

먼저 details, lastUpdated, lastSyncedAt 같은 최소 컬럼만 추가했다.
이 단계에서는 새로운 구조를 담을 공간만 확보하고, 기존 로직은 그대로 두었다.

2. 기존 상세 데이터를 백필

기존 facility_details.details에서 필요한 canonical 정보만 골라 facilities.details로 옮겼다.
여기서 중요한 건 레거시 구조 전체를 그대로 복사하지 않는 것이었다.
최종적으로 남길 구조만 옮겨야 이후 정리가 쉬워진다.

3. 저위험 읽기 경로부터 전환

처음에는 단건 상세 조회나 비교 조회처럼 영향 범위가 작은 곳부터 facilities.details를 읽게 바꿨다.
이후 확인이 끝난 뒤 메인 목록 조회까지 전환했다.

4. 수집 write-path 변경

읽기 경로가 안정화된 뒤에는 수집 로직도 facility_details 대신 facilities.details를 갱신하도록 바꿨다.
이 단계는 실제 수집 실행 검증이 중요해서, 코드는 먼저 바꿔두고 실사용 직전에 검증하는 전략으로 갔다.

5. 레거시 fallback 제거

가장 마지막에는 generalJson, staffing, nonBenefit, details.waitingCount 같은 레거시 fallback도 정리했다.
이렇게 해야 코드가 진짜로 단순해진다.

느낀 점

이번 작업에서 가장 크게 느낀 건, 마이그레이션은 단순히 스키마를 바꾸는 작업이 아니라 “정본이 무엇인지 결정하는 작업”이라는 점이었다.

데이터가 여러 군데에 존재할 때 어려운 건 기술이 아니라 기준이다.
어디를 믿을 것인지, 어떤 구조를 앞으로의 표준으로 삼을 것인지가 먼저 정리돼야 나머지 코드가 깔끔해진다.

또 하나 느낀 점은 운영 환경에서는 완벽함보다 단계성이 중요하다는 것이다.
처음부터 한 번에 끝내는 구조보다, 작은 검증을 반복하면서 넘어가는 방식이 훨씬 안정적이었다.
특히 운영 DB가 하나뿐인 상황에서는 이 접근이 거의 필수에 가깝다.

마무리

정리하면 이번 마이그레이션의 핵심은 이렇다.

  • 분리된 상세 테이블 구조를 단순화했다.
  • facilities.details를 상세 정보의 정본으로 삼았다.
  • 공공 API와 웹 크롤링의 역할을 분리했다.
  • 운영 환경을 고려해 단계적으로 전환했다.
  • 코드에서 레거시 fallback을 걷어내며 구조를 명확하게 만들었다.

아직 수집 실행 검증과 장기적인 정리 작업은 남아 있지만, 적어도 읽기/쓰기의 중심축을 facilities로 모았다는 점에서 유지보수성은 확실히 좋아졌다.
서비스를 운영하다 보면 처음 설계가 영원히 유지되는 경우는 거의 없다. 중요한 건 바뀐 현실에 맞게 구조를 다시 단순하게 만드는 일인 것 같다.