우편번호 조회 서비스 만들기 대작전

한샘·2026년 6월 7일
post-thumbnail

들어가며

처음엔 선배가 우편번호 조회 서비스를 만들면 신입 개발자로 많은 것을 경험할 수 있다고 하셔서 아무 생각없이 시작해보았다.

설계

이름은 ZipKR(Zone Improvement Plan Korea) 대충 우편번호 조회서비스 한국판이라는 뜻이다....

  • 주소 자동완성 기능
  • 우편번호 검색 기능
  • 한달에 한 번씩 변동되는 주소들을 배치 처리하는 기능

처음 이 기능들을 개발한다고 해야할 때 어려워보이는 것은 하나도 없었다.
자동완성은 그냥 검색하면 알아서 되는 거고, 우편번호 검색은 그냥 검색이고, 한달에 한 번씩 변동되는 주소들은 그냥 다운 받아서 처리하면 된다 라고 생각했다(이 때의 내가 멍청한 것 같다...)

시작부터 난관

일단 "주소" 라는 공공데이터를 사용해야했기에 주소 공공데이터를 어떻게 받아올 수 있는지 알아보았다.
주소기반산업지원서비스 라는 곳에서 공공데이터를 제공해주고 있었다.

공공데이터의 문제

내가 간과했던 사실이 있었다. 공공데이터는 생각보다 엄청 복잡하다는 것이다.

처음 공공데이터를 받고 많은 혼란을 겪었다.

지번은 뭐지?
분번은 뭐야?
또 부번은?
건축물대장건물명?

한마디로 주소에 관한 도메인지식이 전무하다는 의미였다.
그래서 난 이 공공데이터에 들어가는 지식이 필요하겠다고 생각하여 각 컬럼에 의미를 표로 작성했다.

실제로 이렇게 작성해보니 어떻게 프로젝트 DB를 설계해야할지 감이 왔다.
난 이 공공데이터를 바탕으로 road_name 이라는 테이블을 만들었다.

road_name 테이블

컬럼명 (Physical Name)데이터 타입 (DDL)Null 여부매핑되는 주소 데이터 (의미)
idBINARY(16) 또는 VARCHAR(36)No (PK)엔티티 고유 식별자 (대체키, UUID)
city_province_nameVARCHAR(40)No시도명
county_districtsVARCHAR(40)No시군구명
eup_myeon_dongVARCHAR(40)No법정읍면동명
liVARCHAR(40)Yes법정리명
main_jibun_numberINTNo지번본번(번지)
sub_jibun_numberINTNo지번부번(호)
road_nameVARCHAR(80)Yes도로명
main_building_numberINTYes건물본번
sub_building_numberINTYes건물부번
kor_full_textVARCHAR(400)No검색용 전체 주소 텍스트 (한글)
kor_initial_full_textVARCHAR(400)No초성 검색용 전체 텍스트 (예: ㅅㅇㅌㅂㅅ)
postal_codeINTNo기초구역번호 (5자리 우편번호)
building_nameVARCHAR(400)Yes건축물대장건물명 / 시군구용건물명
management_numberVARCHAR(26)No (Index)도로명주소관리번호

eup_myeon_dong(읍면동), li(리) 같은 부분이 이상해 보일 수 있으나 이런 한국어들은 영어로 번역하는 것이 이상하고, 더 어렵기 때문에 대부분에 서비스에서 이렇게 관리하는 것을 참고하여 만들었다.

전처리

나의 DB로 옮길 CSV 파일을 파이썬 코드를 이용하여 생성한 뒤 나의 DB에 넣었다.

kor_full = build_kor_full_text(split)
kor_initial = extract_chosung(kor_full)

writer.writerow([
   uuid,
   kor_full,
   kor_initial,
   postal_code
])

검색 타임아웃...

이제 데이터도 다 나의 DB에 맞게 넣었으니까 검색기능을 만들었다.
간단하게 like를 활용하여 포함된 값을 찾는 방식이였다. 참고로 대한민국에 주소 개수는 650만건이다.

그 다음 내가 좋아하는 성능 부하 테스트 툴인 Gatling을 사용하여 테스트해보았다.
3초에 걸쳐 10개의 요청을 보내도록 하였다. 그런데 너무 오래걸려서 타임아웃으로 전부 failed가 떠버렸다.

어떻게 해결하지?

이제 어쩌지? 난 처음에는 검색은 ElasticeSearch를 사용하는 것이 가장 좋다고 생각했지만, 다시 생각해보니 어차피 단순 검색이고, 오히려 제품에 복잡도가 상승할 것이라고 생각했다. 그래서 간단하게 해결할 방법을 찾아보았다.

full-text Index

그렇게 AI와 웹 검색을 해본 결과 full-text Index를 사용하면 된다고 한다. full-text Index란 긴 문자 텍스트를 빠르게 조회하기 위한 MySQL의 기능이다. full-text Index는 문자열을 토큰 단위로 분해하여 역색인을 구성한다. 따라서 LIKE '%검색어%' 방식보다 훨씬 빠르게 검색할 수 있었다.

그래서 일단 full-text Index를 만들어준 뒤 JPQL로 사용하였다. 여기서 full-text Index가 궁금하다면 아래 링크로 이동하면 도움이 될 것 같다.
https://exuberant-slouch-14c.notion.site/full-text-index-3695faa7b0308051a103ebb78e669c6e


실제로 full-text Index를 사용한 뒤 gatling으로 성능 부하 테스트를 해본 결과 평균 반환시간이 60000ms(타임아웃) → 309ms로 약 99.5% 성능 개선을 하였다.

변동되는 주소들...

다른 문제가 발생했다.
만약에 내일 당장 대전광역시와 충청남도가 합쳐져서 "대충특별시"가 생긴다면?
대략 160만건에 데이터가 수정되어야한다. 이 문제를 어떻게 해결할까?

사실 해결하는 방법은 간단하다. 그냥 주소기반산업지원서비스에서 변동되는 주소들을 가져와서 처리하는 배치 처리를 하면 된다.

배치 처리

배치 처리란 데이터를 모아서 한 번에 처리하는 행위를 의미한다.
그래서 나는 변동되는 주소들을 월에 한 번씩 배치 처리를 하도록 프로그래밍 하였다. 도메인을 분석해보니 "이동 사유 코드" 라는 컬럼을 공공데이터에서 제공한다. 말 그대로 어떤 사유에 따라 이동했는지를 보여주는 코드이다. 그리고 관리번호라는 컬럼을 주소가 이동되어도 수정되지 않는 코드이다. 이 코드를 보고 수정되거나 삭제되는 데이터를 확인하면 된다.

31번은 새로 생긴 주소,
34번은 수정,
63번은 삭제라고 한다.

그래서 난 DELETE_CODE(63)같은 경우 deleteBatch라는 리스트에 관리번호들을 저장한뒤 삭제한다. 그리고 다른 31,34번 코드 같은 경우 upsert이기 때문에 upsertBatch에 fields를 저장하고, upsert()를 수행하도록 로직을 설계했다.

여기서 중요한 점은 BATCH_SIZE 를 정해 일정 개수마다 flush/clear를 하는 것이다. 만약 모든 데이터를 한 번에 메모리에 적재하거나 영속성 컨텍스트에 쌓아두면 수만 건 이상의 엔티티가 메모리에 유지되어 메모리 사용량이 급격하게 증가할 수 있다(OOM 발생 가능성).
따라서 일정 개수마다 배치를 실행하고 리스트를 비워 메모리 사용량을 제한하였다.

테스트

그래서 난 이 배치 처리를 수행하는데 걸리는 시간이 알고 싶어서 전북특별자치도 주소를 전부 삭제한뒤 전북특별자치도 주소만 새로 추가되도록 테스트를 해보았다.

시간은 627초로 약 10분이 걸린 것으로 확인되었다. 하지만 난 여기에서 만족하지 않고, 10분이 아닌 더 빠르게 배치 처리를 하고 싶어서 좀 더 좋은 방법을 알아보았다.

그래서 쿼리를 분석해보았다. 그런데 이상한 점은 분명히 새로운 값을 insert만 하면 되는 문제인데 SELECT문를 수행하고 있는 것을 볼 수 있다. JPA에서는 기본적으로 PK 값이 null이 아니면 기존 데이터가 있는지 확인하기 위해 SELECT를 먼저 수행한 뒤 INSERT를 수행하게 된다.

SELECT (기존 데이터 존재 여부 확인)INSERT (데이터 저장)

스프링 데이터 JPA에서 save() 메서드를 실행하면 새로 생긴 Entity라면 persist()를 수행하고, 아니라면 merge()를 수행한다. 그렇다면 JPA는 어떤 Entity를 새로 생긴 Entity라고 판단할까?

스프링 데이터 JPA에서는 PK의 값이 null이면 새로 생긴 Entity라고 판단한다. 정리하자면 PK가 null이면 insert문 한 번만 실행되는 것이다. 그러면 PK를 null로 만들면 insert문이 한 번 실행되면서 쿼리가 절반으로 줄어든 다는 것이다. 하지만 내 프로젝트에는 한가지 예외가 있었다.

@Transactional
    public <S extends T> S save(S entity) {
        Assert.notNull(entity, "Entity must not be null");
        if (this.entityInformation.isNew(entity)) {
            this.entityManager.persist(entity);
            return entity;
        } else {
            return (S)this.entityManager.merge(entity);
        }
    }
@Transient
    public boolean isNew() {
        return this.getId() == null;
    }

UUID의 문제점

일반적으로 JPA는 PK 생성 전략을 통해 신규 엔티티를 관리하지만, 나의 프로젝트에서는 UUID를 직접 생성하고 있었다. 이 때문에 저장 시점에 이미 PK 값이 존재했고, Spring Data JPA는 이를 신규 엔티티가 아닌 것으로 판단하여 불필요한 SELECT를 수행했다.

// Long: DB가 자동생성 → 저장 전 PK가 null → persist
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long

// UUID: 직접 생성 → 저장 전에 이미 PK 존재 → merge (SELECT 발생)
@Id
val id: UUID = UUID.randomUUID()

그렇다면 어떻게 해결해?

방법은 간단하다. isNew() 메서드를 내가 직접 만들어서 사용하는 것이다.
RoadNameEntity를 Persistable 인터페이스의 구현체로 만든 뒤, isNew라는 상태를 만들면 된다. 참고로 여기서 @Transient는 Entity에 선언은 되어있으나 컬럼과는 관계없는 변수이고, @PostLoad는 DB에서 조회(또는 로드)했을 때 실행되는 함수이다. 나는 DB에서 찾은 엔티티는 _isNew = false로 두어서 SELECT문이 나가지 않도록 하였다.

: Persistable<UUID> {

    @Transient
    private var _isNew: Boolean = true

    override fun getId(): UUID = _id

    override fun isNew(): Boolean = _isNew

    @PostLoad
    fun markNotNew() {
        _isNew = false
    }

이렇게 하면 새로 생성된 Entity는 persist() 경로를 타기 때문에 불필요한 SELECT 없이 INSERT만 수행하게 된다.

그렇게 쿼리를 절반으로 만들고 똑같이 배치 처리를 진행한 결과 627초(약 10분) → 360초(약 6분)
약 40% 정도 처리 시간을 단축할 수 있었다.

마무리

우여곡절 끝에 성능과 배치 최적화까지 모두 마치고 나니, 화면(AI의 도움을 받아 만든 웹)에서도 끊김 없이 부드럽게 주소가 자동완성되는 것을 확인할 수 있었다.

처음엔 선배의 말에 가벼운 마음으로 시작한 우편번호 서비스였지만, 공공데이터 도메인 분석부터 대용량 데이터 조회 성능 최적화, 그리고 JPA의 내부 동작까지 깊게 파고들며 신입 개발자로서 해보기 어려운 값진 경험을 할 수 있었던 좋은 프로젝트였다.

profile
BE-Engineer

12개의 댓글

comment-user-thumbnail
2026년 6월 7일

좋은 인사이트 얻고갑니다

1개의 답글
comment-user-thumbnail
2026년 6월 7일

잘 읽었습니다!

1개의 답글
comment-user-thumbnail
2026년 6월 7일

멋있는 글이네요 응원합니다🔥

1개의 답글
comment-user-thumbnail
2026년 6월 8일

좋은 프로젝트인것 같네요.

1개의 답글
comment-user-thumbnail
2026년 6월 12일

너무 좋은 인사이트 얻고 갑니다! 저도 한 번 해봐야겠네요

1개의 답글
comment-user-thumbnail
2026년 6월 18일

좋은 글이네요 감사합니다.

답글 달기
comment-user-thumbnail
2026년 6월 19일

저도 회사에서 검색 서비스 구현 중에 있는데, 좋은 인사이트 감사합니다 ㅎㅎ

답글 달기