👋 환영합니다! 쥐뿔도 모르는 개발자가 백엔드, 인프라, 트러블슈팅 등의 경험을 공유하고 기록하는 개발 블로그입니다 ✨
DB 정규화, 안 하면 어떻게 되는지 직접 겪어봤습니다

DB 정규화, 안 하면 어떻게 되는지 직접 겪어봤습니다

2026년 2월 3일

DB 정규화의 중요성은 누구나 알고 있지만, 실무에서는 의외로 간과하기 쉬운 것 같습니다. “JOIN 한 번 줄이면 성능이 좋아지니까”, “그냥 텍스트로 저장하면 편하니까” 하는 이유로 비정규화를 택하게 되는 순간이 꽤 있죠.

저는 2025년 3월에 지금 팀에 합류했는데, 합류했을 때 이미 DB가 그런 상태였습니다. 당시에는 제가 서비스를 정확하게 파악하고 있지도 않을 뿐더러 “이미 이렇게 돌아가고 있으니까” 하고 넘어갔지만, 이번에 화성시 분구 마이그레이션을 겪으면서 비정규화가 어떤 대가를 만드는지를 뼈저리게 느끼게 되었습니다.

이 글에 작성된 서비스명, 테이블명, 컬럼명 등은 실제와 다르게 변경되어 있습니다.

화성시가 4개 구로 나뉘었습니다

저는 부동산 관련 서비스를 개발하고 있습니다. 아파트 정보, 실거래가, 매물, 중개사무소 등 주소가 들어가지 않는 테이블을 찾기가 더 어려울 정도로 주소 데이터를 많이 다루는 서비스죠.

2026년 2월 1일, 경기도 화성시가 4개의 일반구로 분구되었습니다. 인구 약 100만 명의 도시가 행정구역 개편을 거친, 꽤 큰 행정 이벤트였습니다.

행정기관(행정동) 및 관할구역(법정동) 변경내역 알림(2026.2.1. 시행) - 행정안전부

주요 관할 지역
만세구우정읍, 향남읍, 남양읍, 마도면, 송산면, 서신면, 팔탄면, 장안면, 양감면, 새솔동
효행구봉담읍, 매송면, 비봉면, 정남면, 기배동
병점구진안동, 병점1동, 병점2동, 반월동, 화산동
동탄구동탄1동 ~ 동탄9동

이로 인해 주소 체계가 바뀌게 됐죠.

변경 전: 경기도 화성시 진안동 908
변경 후: 경기도 화성시 병점구 진안동 908

변경 전: 경기도 화성시 반송동 973
변경 후: 경기도 화성시 동탄구 반송동 973

“화성시” 바로 뒤에 “구"가 하나 추가됐고, 행정구역 코드(region_code, dong_code)도 전부 변경됐습니다.

부동산 서비스에서 주소가 바뀐다는 건 곧 DB 마이그레이션이 필요하다는걸 의미합니다. 처음에는 “주소 데이터 좀 바꿔주면 되겠지” 정도로 가볍게 생각했는데 실제로 DB를 들여다보니 상황이 전혀 가볍지 않았습니다.

주소가 대체 어디에 있는 거야

저희 서비스는 주소 데이터를 마스터 테이블의 코드로 참조하는 게 아니라, 각 테이블에 주소 텍스트를 직접 저장하는 구조였습니다. region_codedong_code 같은 코드 컬럼을 가지고 Join을 통해 주소를 조회하는 테이블도 있었지만, 그와 동시에 주소 문자열을 그대로 들고 있는 컬럼이 훨씬 많았습니다.

그래서 마이그레이션의 첫 단계는 “주소가 어디어디에 저장되어 있는지 찾는 것” 이었는데, 이게 생각보다 쉽지 않았습니다.

먼저 쉽게 추측할 수 있는 addr, address, dong_code와 같은 일반적인 컬럼명으로 DB를 조사하니 38개 테이블이 나왔습니다. 이 정도면 됐겠지 싶었는데, 조사를 계속하니 놓친 컬럼들이 하나둘씩 발견되기 시작했습니다.

컬럼명예시 데이터
place1_full_addr경기 화성시 석우동 2-4
complex_address경기도 화성시 목동 동탄2택지개발지구
jibun_addr경기 화성시 향남읍 구문천리

place1_full_addr, complex_address, jibun_addr… 컬럼명이 전부 제각각으로 주소로 저장되고 있는 테이블들이 있었습니다…

컨벤션 없이 개발자마다 다른 이름을 사용했고, 외부 API의 필드명을 그대로 가져다 쓴 경우도 있었습니다. 코드 컬럼도 마찬가지였는데, 같은 시군구 코드를 sigungu_cd, arcode, stcode, sggcode 등 제각각 다른 이름으로 저장하고 있었습니다.

네이밍 컨벤션의 부재는 장애로 이어진다.에서 다뤘던 것과 유사한 문제였죠.

표준화된 네이밍 컨벤션이 없었기 때문에, addr이나 address로 검색해서는 절대 전체를 찾을 수 없는 구조였습니다. 만약 주소 관련 컬럼은 addr_*, 코드 관련 컬럼은 region_*처럼 네이밍 규칙이 있었다면, 컬럼명 패턴만으로도 영향 범위의 대부분을 파악할 수 있었을 것 같습니다. 다시금 네이밍 컨벤션의 중요성을 느끼게 됐죠.

결국 컬럼명으로 찾는 건 한계가 있다는 걸 받아들이고, 접근 방식을 바꿨습니다. 모든 DB의 모든 TEXT/VARCHAR 컬럼에서 LIKE '%화성시%'를 직접 실행하는 방식으로요.

1
2
-- 14개 DB의 모든 텍스트 컬럼에 대해 실행
SELECT COUNT(*) FROM some_table WHERE some_column LIKE '%화성시%';

효율적인 방법은 아니었지만 확실했습니다. 제 리소스를 조금 갈아 넣더라도 정확하게 해야한다고 판단했습니다. 그리고 여기서 예상 못 한 오탐 케이스도 발견되었습니다.

apt_title: "화성시청역센트럴파크스타힐스1단지"
sector_name: "화성시청역 2블록 서희스타힐스"

“화성시"를 검색했더니 “화성시청역"이라는 아파트 브랜드명이 걸리는 겁니다. 이런 데이터는 행정구역 변경과 무관하게 아파트 이름이니 마이그레이션 대상에서 제외해야 했습니다. 결국 각 컬럼마다 샘플 데이터를 확인하고 해당 데이터가 주소를 의미하는지 파악하는 과정까지 필요했죠.

최종 결과는 이랬습니다.

구분건수
영향받는 DB8개
영향받는 테이블75개
영향받는 컬럼120개 이상
마이그레이션 대상 레코드약 280만 건

처음 컬럼명 기반 조사에서 38개였던 테이블이, 전수조사를 하니 75개까지 늘어났습니다. 처음 조사의 절반밖에 못 찾았던 셈이죠. 시간이 꽤 걸렸지만 영향 범위를 정확히 파악한 건 전체 작업의 성공을 위해 꼭 필요한 단계였습니다.

75개 테이블을 어떻게 마이그레이션할 것인가

영향 범위를 파악한 건 전체 작업의 시작에 불과했습니다. 75개 테이블, 120개 이상의 컬럼을 찾았다고 해서 바로 UPDATE를 실행할 수 있는 게 아니었거든요.

단순 치환이 안 되는 이유

처음에는 단순하게 생각했습니다. “화성시"를 “화성시 병점구"와 같이 REPLACE하면 되는 거 아닌가?

1
UPDATE apartment SET addr = REPLACE(addr, '화성시', '화성시 병점구');

하지만 이게 안 됩니다. 화성시가 4개 구로 나뉘었기 때문입니다. 진안동은 병점구, 동탄1동은 동탄구, 향남읍은 만세구… 같은 “화성시"라도 어떤 동이냐에 따라 들어가야 할 구가 다릅니다.

경기도 화성시 진안동 908     → 경기도 화성시 병점구 진안동 908
경기도 화성시 동탄1동 123    → 경기도 화성시 동탄구 동탄1동 123
경기도 화성시 향남읍 구문천리 → 경기도 화성시 만세구 향남읍 구문천리

그래서 먼저 동→구 매핑 테이블을 만들고, 주소 텍스트에서 동 이름을 기준으로 어떤 구를 삽입할지 결정하는 방식으로 접근했습니다.

1
2
3
4
5
6
7
UPDATE apartment a
JOIN dong_to_gu_mapping m
  ON a.addr LIKE CONCAT('%화성시 ', m.dong_name, '%')
SET a.addr = REPLACE(a.addr, CONCAT('화성시 ', m.dong_name),
                              CONCAT('화성시 ', m.gu_name, ' ', m.dong_name))
WHERE a.addr LIKE '%화성시%'
  AND a.addr NOT LIKE '%화성시청역%';

여기까지는 나름 정리가 됐다고 생각했습니다. 매핑 테이블만 잘 만들면 나머지는 기계적으로 처리할 수 있겠다 싶었거든요. 하지만 실제로 개발 환경에서 돌려보니 생각처럼 단순하지 않았습니다.

생각보다 까다로웠던 주소 파싱

가장 먼저 부딪힌 건 주소 표기 형식이 통일되어 있지 않다는 점이었습니다.

-- 같은 주소인데 저장 형태가 다르다
'경기도 화성시 진안동 908'
'경기 화성시 진안동 908'
'경기도 화성시  진안동 908'   -- 공백이 두 개

“경기도"를 쓴 곳도 있고, “경기"로 줄여 쓴 곳도 있고, 공백이 두 개인 데이터도 있었습니다. 외부 API에서 가져온 데이터와 직접 입력한 데이터가 섞여 있다 보니 형식이 제각각이었던 거죠. LIKE '%화성시 진안동%'으로 매칭하면 공백이 두 개인 데이터는 잡히지 않았습니다.

그리고 도로명 주소에는 동 이름이 없습니다.

'경기도 화성시 병점4로 102'
'경기도 화성시 동탄중심상로 120'

동 이름 기반 매핑으로는 도로명 주소를 처리할 수 없습니다. 도로명 주소는 도로명→구 매핑을 별도로 만들어야 했는데, 화성시의 도로가 수백 개여서 이 매핑 테이블 자체를 만드는 것도 상당한 작업이었습니다.

결국 지번 주소 컬럼과 도로명 주소 컬럼의 스크립트를 완전히 분리해서 작성해야 했습니다. 거기에 행정구역 코드만 저장하는 컬럼은 또 코드 매핑 테이블을 별도로 만들어서 처리해야 했고요. 주소를 변환하기 전에 데이터 클렌징부터 해야 하는 상황이었습니다. 마이그레이션이라기보다 비정규화된 데이터를 정리하는 작업에 더 가까웠습니다.

컬럼마다 다른 스크립트, 테이블마다 다른 오탐 조건

정리하면, 컬럼의 저장 형태에 따라 최소 4가지 유형의 스크립트가 필요했습니다.

저장 형태처리 방식
전체 지번 주소동 이름 기반 매핑 → 구 삽입
도로명 주소도로명 기반 매핑 → 구 삽입
시군구명만변경 없음 (단, 서비스 로직 확인 필요)
행정구역 코드코드 매핑 테이블로 변환

그리고 이 4가지 유형을 기본으로 하되, 테이블마다 오탐 조건이 달랐습니다. 아파트 테이블에는 “화성시청역” 같은 브랜드명이 있고, 거래 테이블에는 없고, 중개사 테이블에는 또 다른 형태의 오탐이 있을 수 있었습니다. 결국 각 테이블의 샘플 데이터를 확인하면서 스크립트마다 오탐 조건을 조정해야 했습니다.

75개 테이블, 120개 컬럼에 대해 이 작업을 반복한다는 건, 비슷하지만 미묘하게 다른 스크립트를 수십 개 작성한다는 의미였습니다. 자동화할 수 있는 부분은 자동화했지만, 컬럼마다 저장 형태가 다르고 오탐 케이스가 다르다 보니 결국 하나하나 확인하면서 작성할 수밖에 없었습니다. 이쯤 되니 “어째서 주소를 왜 텍스트로 저장해둔 거지..“라는 생각이 계속 들었습니다.

검증: 끝이 안 보이는 반복 작업

스크립트를 작성한 뒤에는 당연히 바로 운영에 실행할 수는 없었습니다. 280만 건의 데이터를 변경하는 작업에서 스크립트에 오류가 있으면 되돌리기가 어렵기 때문입니다.

개발 환경에서 먼저 돌려보고, 변경 전후 데이터를 비교 검증했습니다.

1
2
3
4
5
6
7
8
9
-- 변경 전 스냅샷
CREATE TABLE apartment_backup AS SELECT id, addr FROM apartment WHERE addr LIKE '%화성시%';

-- 스크립트 실행 후 비교
SELECT b.addr AS before_addr, a.addr AS after_addr
FROM apartment a
JOIN apartment_backup b ON a.id = b.id
WHERE a.addr != b.addr
LIMIT 100;

이 검증 과정에서 놓쳤던 문제들이 발견되기도 했습니다. 예를 들어, 동 이름이 다른 동 이름의 일부인 경우가 있었습니다.

'반월동' 매핑을 적용하니 → '반월동', '반월1동' 둘 다 매칭됨

LIKE 패턴의 매칭 순서나 정확도를 조정해야 하는 케이스들이 검증 과정에서 계속 나왔고, 그때마다 스크립트를 수정하고 다시 돌리고 다시 검증하는 과정을 반복했습니다.

75개 테이블 각각에 대해 스냅샷 → 실행 → 비교 → 수정 → 재실행 사이클을 돌린 겁니다. 솔직히 이 과정이 가장 시간이 많이 들었고, 가장 지치는 작업이었습니다.

운영 환경 적용

개발 환경 검증을 마치고 나서도 한숨 돌릴 수 없었습니다. 진짜 긴장되는 건 운영 환경 적용이었거든요.

개발 환경은 실수해도 다시 하면 그만이지만, 운영 환경은 다릅니다. 스크립트에 오류가 있으면 서비스에 잘못된 주소가 바로 노출됩니다. “경기도 화성시 병점구 병점구 진안동” 같은 이중 삽입이라도 발생하면 사용자께서 바로 알아차리시는 문제니까요.

280만 건을 한 번에 UPDATE하면 테이블 락이 오래 잡힐 수 있기 때문에, 테이블 단위로 나누어 실행하되 데이터가 많은 테이블은 배치 단위로 끊어서 실행했습니다. 트래픽이 적은 시간대를 골라 진행했고, 한 테이블 실행할 때마다 서비스에서 변경된 주소가 정상적으로 노출되는지 확인한 뒤에 다음 테이블로 넘어갔습니다. 돌이켜보면 이 과정이 가장 신경이 곤두섰던 시간이었던 것 같습니다.

전체 과정을 정리하면 이랬습니다.

  1. 영향 범위 파악 — 컬럼명 기반 조사 + LIKE 전수조사
  2. 매핑 테이블 구축 — 동→구 매핑, 도로명→구 매핑, 행정코드 매핑
  3. 오탐 필터링 — 테이블별 샘플 확인 후 브랜드명 등 제외 조건 설정
  4. 컬럼 유형별 스크립트 작성 — 지번 주소, 도로명 주소, 행정코드 등 각각 다른 로직
  5. 개발 환경 반복 검증 — 스냅샷 비교 → 오류 발견 → 수정 → 재검증 반복
  6. 운영 환경 적용 — 테이블 단위 분할, 대용량 테이블은 배치 처리, 트래픽 적은 시간대 실행
  7. 사후 검증 — 서비스에서 변경된 주소가 정상 노출되는지 확인

정규화가 되어 있었다면

전수조사에, 매핑 테이블 구축에, 컬럼별 스크립트 작성에, 반복 검증에, 운영 적용까지. 이 작업을 하는 내내 드는 생각이 있었습니다. 정규화가 되어 있었다면 이 작업이 얼마나 간단했을까?

만약 행정구역 마스터 테이블에 주소 정보를 한 곳에서 관리하고, 각 서비스 테이블은 코드만 참조하는 구조였다면 어떨까요.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
-- 마스터 테이블 (Single Source of Truth)
CREATE TABLE area_code (
    region_code VARCHAR(10) PRIMARY KEY,
    sido VARCHAR(20),       -- 경기도
    sigungu VARCHAR(20),    -- 화성시
    dong VARCHAR(20),       -- 진안동
    full_address VARCHAR(100)
);

-- 서비스 테이블은 코드만 참조
CREATE TABLE apartment (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100),
    region_code VARCHAR(10),  -- FK 참조
    FOREIGN KEY (region_code) REFERENCES area_code(region_code)
);

이 구조에서 화성시 분구 마이그레이션은 이렇게 됩니다.

1
2
3
4
-- 마스터 테이블 40여 개 행 UPDATE. 끝.
UPDATE area_code
SET region_code = '4159551000', sigungu = '병점구'
WHERE region_code = '4159052000';

마스터 테이블 한 곳만 수정하면, 참조하는 모든 테이블이 자동으로 변경된 주소를 보여주게 됩니다. 앞서 이야기한 7단계 — 전수조사, 매핑 테이블, 오탐 필터링, 컬럼별 스크립트, 반복 검증, 운영 적용, 사후 검증 — 가 이 UPDATE 하나로 끝나는 겁니다.

처음부터 이런 구조였다면 얼마나 좋았을까, 라는 생각이 들었습니다. 물론 일부는 그렇게 돼 있었지만, 주소 텍스트를 직접 저장하는 테이블이 너무 많아서 결국 이 작업을 피할 수 없었죠.

하나의 사실이 한 곳에만 존재해야 한다는 정규화의 기본 원칙, Single Source of Truth가 단순히 교과서적인 이야기가 아니라 실제로 지키지 않으면 큰 대가를 치를 수 있다는 걸 이번에 제대로 체감했습니다.

그렇다고 무조건 정규화?

앞서 이야기했듯이, 마이그레이션을 하는 동안에는 비정규화된 구조에 대한 아쉬움밖에 없었습니다. 그런데 작업을 마치고 나서 좀 더 냉정하게 생각해보니, 처음에 텍스트로 저장한 선택에도 나름의 이유가 있었을 거라는 생각이 들었습니다.

저희 서비스는 매물 목록, 실거래가 조회 같은 읽기 작업이 압도적으로 많습니다. 매물 목록 API 하나만 봐도 한 번에 수십 개의 주소를 보여줘야 하는데, 만약 주소가 정규화되어 있다면 매물을 조회할 때마다 행정구역 마스터 테이블에 JOIN을 해야 합니다. 트래픽이 많은 서비스에서 거의 모든 조회 API에 JOIN이 하나씩 추가된다면, 성능에 적지 않은 부담이 되거든요.

저장하는 쪽도 사정이 비슷합니다. 저희 서비스는 국토교통부, 행정안전부 등 외부 API에서 데이터를 받아와서 저장하는 경우가 많은데, 이 API 응답에는 이미 완성된 주소 텍스트가 들어있습니다. "경기도 화성시 진안동 908" 이런 식으로요. 이걸 그대로 저장하면 한 줄이지만, 정규화 구조에 맞추려면 주소를 파싱해서 시/도, 시/군/구, 동 각각의 코드를 찾고 매핑해서 저장해야 합니다. 외부 API 연동이 하나둘이 아닌 상황에서 매번 이 파싱 로직을 거치는 건 꽤 번거로운 작업이었을 겁니다.

그리고 정규화도 과하면 그 자체로 문제가 됩니다. 이건 실제로 제가 “그럼 주소를 어디까지 정규화해야 하지?“를 고민하면서 느낀 건데, 만약 시/도, 시/군/구, 읍/면/동, 리, 번지까지 전부 별도 테이블로 분리했다고 가정해보겠습니다.

1
2
3
4
5
6
7
SELECT s.name, sg.name, d.name, r.name, a.bungi
FROM apartment a
JOIN dong d ON a.dong_id = d.id
JOIN sigungu sg ON d.sigungu_id = sg.id
JOIN sido s ON sg.sido_id = s.id
LEFT JOIN ri r ON a.ri_id = r.id
WHERE a.id = 1;

주소 하나를 보여주기 위해 4~5개 테이블을 JOIN해야 합니다. 저희처럼 읽기 비중이 높은 서비스에서 이런 구조는 현실적이지 않습니다. 정규화를 안 하면 이번처럼 마이그레이션 지옥을 겪게 되고, 과하게 하면 조회 성능 지옥을 겪게 됩니다. 결국 어디에서 균형을 잡을 것이냐의 문제라고 생각합니다.

그래서 적절한 균형은 어디인가

이번 경험을 겪고 나서, 만약 처음부터 다시 설계한다면 어떻게 할까를 고민해봤습니다.

제가 내린 결론은 코드 참조를 기본으로 하되, 성능이 필요한 곳에서는 의도적으로 반정규화하는 방식이었습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- 기본은 코드 참조 (정규화)
CREATE TABLE area_code (
    region_code VARCHAR(10) PRIMARY KEY,
    full_address VARCHAR(100)
);

-- 자주 조회되는 테이블에는 주소 텍스트도 함께 저장 (의도적 반정규화)
-- 단, region_code도 함께 가지고 있어 변경 시 업데이트 가능
CREATE TABLE apartment (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100),
    region_code VARCHAR(10),          -- 코드 참조 (정규화)
    cached_address VARCHAR(200),      -- 성능을 위한 캐시 (반정규화)
    FOREIGN KEY (region_code) REFERENCES area_code(region_code)
);

이 구조가 이전의 완전 비정규화 방식과 뭐가 다른지 의문이 드실 수 있는데, 핵심적인 차이가 하나 있습니다. region_code라는 업데이트의 기준점이 존재한다는 겁니다.

텍스트만 저장했을 때는 “이 주소가 어느 동에 속하는지"를 텍스트 파싱으로 추론해야 했지만, region_code가 있으면 코드 기준으로 정확하게 매핑할 수 있습니다. 조회 시에는 JOIN 없이 cached_address를 바로 사용하고, 행정구역이 변경되면 region_code를 기준으로 cached_address를 일괄 업데이트하면 됩니다.

실제로 일부 테이블들은 위와 같은 방식으로 설계되어 있었는데, 이번 마이그레이션 작업에서는 이 테이블들은 비교적 수월하게 처리되었습니다. region_code 기준으로 매핑이 명확했기 때문에, 오탐 걱정 없이 단순 치환 스크립트로 바로 처리할 수 있었거든요.

하지만 많은 테이블이 region_code 없이 주소 텍스트만 저장하고 있었기 때문에, 이번처럼 복잡한 작업이 불가피했습니다. 모든 테이블이 위와같은 구조였다면 이번 작업이 훨씬 수월했을 거라는 생각이 듭니다.

정리

이번 작업을 통해 가장 크게 느낀 건, 정규화의 가치는 평소에는 잘 보이지 않는다는 점이었습니다. 텍스트로 저장하면 조회도 빠르고 저장도 편합니다. 그 편리함이 대가로 돌아오는 건, 이번처럼 데이터가 변경되는 순간이거든요. 그리고 그 대가는 75개 테이블 전수조사와 280만 건 데이터 클렌징이라는 형태로 제게 한꺼번에 찾아왔습니다.

그렇다고 무조건 정규화가 답이라고는 생각하지 않습니다. 주소 하나 보여주려고 5개 테이블을 JOIN하는 구조도, 외부 API 응답을 매번 파싱해서 정규화하는 것도 현실적이지 않거든요. 결국 정규화와 비정규화 사이 어딘가에서 균형을 잡아야 하는 문제라고 생각합니다.

다만 이번 경험을 겪고 나서 한 가지 확실해진 게 있습니다. 비정규화를 선택할 때, 그 이유가 “귀찮아서"여서는 안 된다고 생각합니다. 성능 때문에 의도적으로 텍스트를 중복 저장하더라도, region_code 같은 업데이트의 기준점은 함께 가지고 있어야 하지 않나 싶습니다. 그래야 다음에 변경이 생겼을 때, 전수조사가 아니라 코드 기준의 일괄 업데이트로 대응할 수 있으니까요.

다음에 비슷한 설계 결정을 내릴 때, 이번 전수조사의 기억이 꽤 오래 남아 있을 것 같습니다.

참고 자료

화성시 분구

데이터베이스 정규화