출근 109일차 - 주소 검색엔진 도입기 (엘라스틱서치)

·2023년 2월 3일
1

회사이야기

목록 보기
109/118

오늘은 백엔드 월간 회고 시간이여서 발표를 하나 준비해갔다.

과거에 작업을 했던 엘라스틱서치를 활용한 주소검색엔진 도입기를 발표했다.
업무시간에 해당 문서를 하루종일 쓰기도 했고, 여러가지 딮한 이야기들은 블로그에 쓰기 묘해서

발표자료를 올려두려고 한다. (회사 발표자료랑은 내용물이 조금 다르긴 하다.)

도입하게 된 계기

풀필먼트 특성상 주소를 다루는 일이 매우 많다.

  • CJ택배의 경우 주소분리를 제대로 해주지 않을 경우 운송장이 미등록되는 이슈가 발생했다.
    • 미등록이 발생하는 이유는, 주소의 분리가 명확하게 되어있지 않아서 였다.
    • 이것을 코드에 의해서 분리를 할 수 있어야지, 손으로 분리하는 것은 물리적으로 불가능하다.
  • 엑셀 업로드시 카카오, 네이버 등 외부 API에 의존하는 것이 아니라 자체적인 DB를 운용하고 싶었다.

왜 이런 문제가 발생했을까?

국내주소는 위와 같은 형태를 가지고 있는데

행정단위부 + 번지부 + 기타부가 한개의 입력값에 모조리 우겨넣으면 발생하는 것으로 확인이 됐다.

실제로 들어왔던 주소를 예를 들면 이런게 있었다.

서울특별시 종로구 새문안로 89 조심히배달해주세요
서울특별시 종로구 새문안로 89 정우빌딩 9층 셀러노트

이것은 일반주소 세부주소가 나눠진 것이 아니라, 일반주소에 모두 모여진 상태로 들어왔다(....)

이런 이슈가 잦은 빈도수로 발생을 하고 있던 와중,

부트캠프에서 잠시 배웠던 엘라스틱서치(ES)가 문득 생각났다.

엘라스틱서치는 데이터의 유사성을 파악하는 SQL의 LIKE 구문과 비슷하지만 유사도에 따라 점수를 매겨서 조회를 할 수 있는 쿼리를 지원한다는 것을.

CTO님한테 한번 이야기를 꺼내봤더니 올ㅋ 하시길래 에픽은 진행중인게 있어서

그냥 개인시간에 짬짬히 해봤다.(블로그 삽질 카테고리에서 볼 수 있다.)


1. 주소정보는 어디서 받아와야하는가?

제일 큰 문제는 주소정보를 어디선가 가져와야했다.

열심히 서치를 해봤는데, 우체국에서 해당 관련된 정보를 월 단위로 업데이트하면서 텍스트 파일로 제공을 하고 있었다!

우편번호 DB와 검색기 소개 - 우편번호 안내

행정안전부에서도 제공하고 있다, 두개의 양식은 매우 흡사한 편

행정안전부 도로명주소 주소DB_일간_20211220

2. txt 파일을 DB에 어떻게 넣어야하는가?

파일을 텍스트 파일로 받았기 때문에, 이것을 DB에 넣는 과정이 필요했다.

  • 제일 먼저 양식에 맞게 테이블을 만들었다. (토글 있어요)
    CREATE TABLE Main_address ( 
    `ZIP_NO` VARCHAR(5) NULL COMMENT '우편번호',
    `SIDO` VARCHAR(20) NULL COMMENT '시도',
    `SIDO_ENG` VARCHAR(40) NULL COMMENT '시도(영문)',
    `SIGUNGU` VARCHAR(20) NULL COMMENT '시군구',
    `SIGUNGU_ENG` VARCHAR(40) NULL COMMENT '시군구(영문)',
    `EUPMYUN` VARCHAR(20) NULL COMMENT '읍면',
    `EUPMYUN_ENG` VARCHAR(40) NULL COMMENT '읍면(영문)',
    `DORO_CD` VARCHAR(12) NULL COMMENT '도로명코드',
    `DORO` VARCHAR(80) NULL COMMENT '도로명',
    `DORO_ENG` VARCHAR(80) NULL COMMENT '도로명(영문)',
    `UNDERGROUND_YN` CHAR(1) NULL COMMENT '지하여부',
    `BUILD_NO1` INT NULL COMMENT '건물번호본번',
    `BUILD_NO2` INT NULL COMMENT '건물번호부번',
    `BUILD_NO_MANAGE_NO` VARCHAR(25) NULL COMMENT '건물관리번호',
    `DARYANG_NM` VARCHAR(40) NULL COMMENT '다량배달처명',
    `BUILD_NM` VARCHAR(200) NULL COMMENT '시군구용건물명',
    `DONG_CD` VARCHAR(10) NULL COMMENT '법정동코드',
    `DONG_NM` VARCHAR(20) NULL COMMENT '법정동명',
    `RI` VARCHAR(20) NULL COMMENT '리명',
    `H_DONG_NM` VARCHAR(40) NULL COMMENT '행정동명',
    `SAN_YN` VARCHAR(1) NULL COMMENT '산여부',
    `ZIBUN1` INT NULL COMMENT '지번본번',
    `EUPMYUN_DONG_SN` VARCHAR(2) NULL COMMENT '읍면동일련번호',
    `ZIBUN2` INT NULL COMMENT '지번부번' ,
    `ZIP_NO_OLD` VARCHAR(4) NULL COMMENT '구우편번호' ,
    `ZIP_SN` VARCHAR(2) NULL COMMENT '우편일련번호',
    `CREATED_AT` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `UPDATED_AT` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY ) 
    ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 AUTO_INCREMENT=1;
  • 그리고 txt 파일을 테이블에 밀어넣기 위하여 LOAD DATA라는 것을 적용했다. (토글 있어요)
    # 1. 서울특별시
    LOAD DATA LOCAL INFILE "var/lib/seoul.txt" INTO TABLE Main_address CHARACTER SET 'UTF8MB4' FIELDS TERMINATED BY '|' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES
    (@ZIP_NO, @SIDO, @SIDO_ENG, @SIGUNGU, @SIGUNGU_ENG,
    @EUPMYUN, @EUPMYUN_ENG,
    @DORO_CD, @DORO, @DORO_ENG,
    @UNDERGROUND_YN, @BUILD_NO1, @BUILD_NO2,
    @BUILD_NO_MANAGE_NO, @DARYANG_NM,@BUILD_NM,
    @DONG_CD, @DONG_NM,@RI, @H_DONG_NM, @SAN_YN,
    @ZIBUN1, @EUPMYUN_DONG_SN, @ZIBUN2, @ZIP_NO_OLD, @ZIP_SN)
    SET
    ZIP_NO = NULLIF(@ZIP_NO,'0'),
    SIDO = NULLIF(@SIDO,'0'),
    SIDO_ENG = NULLIF(@SIDO_ENG,'0'),
    SIGUNGU = NULLIF(@SIGUNGU,'0'),
    SIGUNGU_ENG = NULLIF(@SIGUNGU_ENG,'0'),
    EUPMYUN = NULLIF(@EUPMYUN,'0'),
    EUPMYUN_ENG = NULLIF(@EUPMYUN_ENG,'0'),
    DORO_CD = NULLIF(@DORO_CD,'0'),
    DORO = NULLIF(@DORO,'0'),
    DORO_ENG = NULLIF(@DORO_ENG,'0'),
    UNDERGROUND_YN = NULLIF(@UNDERGROUND_YN,'0'),
    BUILD_NO1 = NULLIF(@BUILD_NO1,'0'),
    BUILD_NO2 = NULLIF(@BUILD_NO2,'0'),
    BUILD_NO_MANAGE_NO = NULLIF(@BUILD_NO_MANAGE_NO,'0'),
    DARYANG_NM = NULLIF(@DARYANG_NM,'0'),
    BUILD_NM = NULLIF(@BUILD_NM,'0'),
    DONG_CD = NULLIF(@DONG_CD,'0'),
    DONG_NM = NULLIF(@DONG_NM,'0'),
    RI = NULLIF(@RI,'0'),
    H_DONG_NM = NULLIF(@H_DONG_NM,'0'),
    SAN_YN = NULLIF(@SAN_YN,'0'),
    ZIBUN1 = NULLIF(@ZIBUN1,'0'),
    EUPMYUN_DONG_SN = NULLIF(@EUPMYUN_DONG_SN,'0'),
    ZIBUN2 = NULLIF(@ZIBUN2,'0'),
    ZIP_NO_OLD = NULLIF(@ZIP_NO_OLD,'0'),
    ZIP_SN  = NULLIF(@ZIP_SN,'0');

라고 잘 해결이 됐습니다, 짜잔! 이라고 하고 싶었지만 인코딩의 문제가 발생했다.

테이블에 집어넣기 위해선 txt 파일을 UTF-8으로 인코딩을 해줬어야했는데, 이것에 문제가 있었다.

기존에 사용하는 명령어로 했는데 들어먹질 않던 것(…)

iconv -c -f cp949 -t utf-8 경기도.txt > gyeonggi.txt ← 안됐다.

그러던 와중 CTO님께서 방법을 찾아주셨고, 조금 반복작업이긴 했지만 정상적으로 데이터를 넣을 수 있었다.

더 편한 방법…알려주실분…?

3. docker 컨테이너 속 DB에 txt 파일은 어떻게 넣어야하는가?

해당 파일을 엘라스틱서치에 밀어넣기 위해서는 도커 컨테이너 내부의 데이터베이스에 넣을 필요성이 있었다.

그렇지만 이것을 반복할 때 마다 수기로 넣는 작업은 최악이라고 생각해서

컨테이너를 켰을 때, 바로 LOAD DATA가 되는 것을 만들고 싶었기에 찾아봤다.

폴더구조는 위처럼 생겼고, 실제 도커에 마운트하는 volumes은 이렇게 생겼다.

version: '3.7'

services:
  database:
    image: mysql:latest
    environment:
      MYSQL_DATABASE: 'root'
      MYSQL_ROOT_PASSWORD: 'root'
    ports:
      - 3308:3306
    volumes:
      - /Users/Desktop/address/db/conf.d:/etc/mysql/conf.d
      - /Users/Desktop/address/db/initdb.d:/docker-entrypoint-initdb.d

이리저리 찾아보면서 신기한 것을 알았는데, mysql같은 경우에는 conf.d 파일 하나로 모든 것을 글로벌하게 관리를 한다는 것이였다.

그래서 저거 로컬에도 있다.

아마 path는 공통인 것 같은데, /usr/local/etc 속에 들어가있고 vi my.cnf < 로 들어가면 볼 수 있다.

그래서 해당하는 파일을 만들기 위하여 이렇게 작성했다.

파일 이름 : my.cnf

[client]
default-character-set = utf8mb4
local_infile          = 1

[mysql]
default-character-set = utf8mb4
local_infile          = 1

[mysqld]
character-set-client-handshake = FALSE
character-set-server           = utf8mb4
collation-server               = utf8mb4_unicode_ci
local_infile                   = 1

이제 테이블을 생성하는 파일과

생성된 테이블에 데이터를 밀어넣는 파일 두개를 만들어야 됐다.

파일 이름 : create_table.sql (도커 시작시 테이블 생성)

/Users/Desktop/address/db/initdb.d:/docker-entrypoint-initdb.d

  • 여기서도 신기한 점이 있었는데 docker 컨테이너 내부에 초기화하는 path가
    docker-entrypoint ← 라는 것이였다.

파일 이름 : load_data.sql (도커 시작시 데이터 로드 실행)

/Users/Desktop/address/db/initdb.d:/docker-entrypoint-initdb.d

  • 여기서도 동일한 path에 집어넣는다.

여기서 이상한 에러가 발생을 했는데, 최초의 실행을 할 경우에는 에러가 발생하면서 컨테이너가 강제종료된다.

그렇지만 다시 실행을 할 경우에는 정상적으로 데이터가 들어가있는 것을 볼 수 있다 ← ? ?

4. EL(-K) STACK 설정하기

왜 -K를 적었냐면, E 엘라스틱서치 | L 로그스태시 | K 키바나 중 키바나는 서비스에 포함하지 않기 때문에 적어놨다.

최종 폴더 구조

엘라스틱서치를 적용하는데에도 큰 문제점이 많았는데

일단 데이터를 밀어넣는 것이 제일 큰 문제였다.

주소데이터는 이 글을 작성하는 2023년 2월 2일자로 640만개정도 들어가있다.

이 데이터를 한번에 밀어넣으려고 때려박았더니 로그스태시의 메모리가 폭발하는 에러를 볼 수 있었다.

→ 아는 자바 개발자분이 말하시기를 잘 터지지도 않는데 대단하다고(….)

그래서 여러개의 파이프라인을 만들어서 넣는 것으로 작업을 해봤다.

이 상태로 실행을 하면 아래처럼 여러개의 파이프라인이 돌아가는 것을 볼 수 있다.


그런데 또 다시 터지는 문제가 발생했다.

왜 터지는지 계속 찾아봤더니,한개의 JVM 위에서 돌아가는 것이라 여러개의 파이프라인으로 분리하더라도 차이가 없다는 것.

그러다보니 도대체 왜 쓰는건데? 라는 생각이 들었다.

이런 삽질을 하던차에 아는 SRE분께서 고민상담을 해주시더니 방법을 알려주고 가셨다.

SRE 지인분 : docker에서 ps -ef | grep logstash 쳐보실래요?

(기존에는 이 값이 1g였는데, 값을 수정해서 4로 늘어난 것이다.)

SRE 지인분 : 그 값이 로그스태시의 기본값이라서, 그것만 늘려주시면 데이터 밀어넣을 수 있을거에요.

적용방법은 엄청 단순했다.

파일 이름 : jvm.options

./logstash/config/jvm.options:/usr/share/logstash/config/jvm.options

-Xms4g
-Xmx4g

이렇게 진행을 했더니 자연스럽게 데이터가 들어갈 수 있게 됐다! (ES도 2씩은 할당을 해줘야한다.)

  • 실제 EC2에서 돌아가고 있는 docker-compose.yaml 정보 (토글)
    version: '3.7'
    
    services:
      elasticsearch:
        image: elasticsearch:8.4.3
        container_name: es_postal_code_shipda
        volumes:
          - esdata:/usr/share/elasticsearch/data
        environment:
          - cluster.name= postal-shipda
          - network.host=_site_
          - discovery.type= single-node
          - "ES_JAVA_OPTS=-Xms2g -Xmx2g"
          - bootstrap.memory_lock=true
          - xpack.license.self_generated.type=basic
          - xpack.security.enabled=false
          - ingest.geoip.downloader.enabled=false
        ports:
          - 9200:9200
        ulimits:
          memlock:
            soft: -1
            hard: -1
    
    volumes:
      esdata:
        driver: local

엘라스틱서치는 8.0 버전부터 보안속성이 기본으로 들어가게 된다.

그래서 8.0 버전을 사용할 경우, 로컬에서도 비밀번호를 입력하지 않으면 접근을 할 수 없는데

이때 인증서를 발급하여 https도 붙이고 이런저런 작업을 할 수 있지만

현재 시스템에서는 클러스터 내의 ip에서만 접근이 가능할 수 있도록 보안그룹이 적용되어있기에

간단하게 자동비밀번호만 등록하는 식으로 적용을 해놨다.

  1. 도커 컨테이너로 접근 docker exec -it elasticsearch bash
  2. 비밀번호 자동설정 /usr/share/elasticsearch/bin/elasticsearch-setup-passwords auto

만약 비밀번호를 바꾸거나, 새롭게 적용하려면 아래 명령어를 쓰면 된다.

  • /usr/share/elasticsearch/bin/elasticsearch-reset-password -u elastic -i
    • 이건 엘라스틱서치 비번 내맘대로 바꾸기
  • /usr/share/elasticsearch/bin/elasticsearch-reset-password -u kibana -i
    • 이건 키바나 비번 내맘대로 바꾸기

5. 휴먼에러를 코드로 고쳐보기

주소데이터를 ES에 정상적으로 넣었지만, 판별하는 과정이 문제가 됐다.

최초에는 유사한 값으로 반환을 해준다. 라는 것으로 진행이 됐지만

만약 유사한 값이 실제 배송 요청지와 다르다면 오출고가 발생하기 때문에 정확하게 일치하는 것을 찾아야했다.

그래서 사용자의 입력을 최대한 보정하는 작업이 진행됐다.

  • ~도를 보정하는 코드
    if (address[0].match('경기') !== null) {
          address[0] = '경기도';
        } else if (address[0].match('전남') !== null) {
          address[0] = '전라남도';
        } else if (address[0].match('전북') !== null) {
          address[0] = '전라북도';
        } else if (address[0].match('경남') !== null) {
          address[0] = '경상남도';
        } else if (address[0].match('경북') !== null) {
          address[0] = '경상북도';
        } else if (address[0].match('충북') !== null) {
          address[0] = '충청북도';
        } else if (address[0].match('충남') !== null) {
          address[0] = '충청남도';
        } else if (address[0].match('강원') !== null) {
          address[0] = '강원도';
        } else if (address[0].match('서울') !== null) {
          address[0] = '서울특별시';
        } else if (address[0].match('서울시') !== null) {
          address[0] = '서울특별시';
        } else if (address[0].match('대전') !== null) {
          address[0] = '대전광역시';
        } else if (address[0].match('대구') !== null) {
          address[0] = '대구광역시';
        } else if (address[0].match('인천') !== null) {
          address[0] = '인천광역시';
        } else if (address[0].match('부산') !== null) {
          address[0] = '부산광역시';
        } else if (address[0].match('울산') !== null) {
          address[0] = '울산광역시';
        } else if (address[0].match('세종') !== null) {
          address[0] = '세종특별자치시';
        } else if (address[0].match('광주') !== null) {
          const isMetropolitanCity = address.some((val) => {
            return val === '광산구' || '북구' || '서구' || '동구' || '남구';
          });
          if (isMetropolitanCity) {
            address[0] = '광주광역시';
          } else {
            address[0] = '광주시';
          }
        }
  • 도로명주소인지 지번주소인지 확인하는 코드
    const result = address.split(' ').some((val) => {
          if (val[val.length - 1] === '동' || val[val.length - 1] === '리') {
            return true;
          } else {
            return false;
          }
        });

제일 큰 벽은 행정단위부 + 번지부와 그 나머지를 분리하는 작업이였다. (문제의 원인)

주소라는 것은 상당히 복잡한 구조를 가지고 있다.

여기서 일반적으로 주소는 행정단위부 + 번지부 / 기타부로 2개를 분리해서 보는 편이다.

(이것은 네이버쇼핑에서의 주소검색 설명, 자세하게 적어야한다는 것을 볼 수 있다.)

여기서 정말 중요한 것은 번지부다.

어떤 동네인지는 행정단위부로 확인할 수 있지만, 어떤 건물인지 판단하는 것은 번지부이기 때문인데....

문제는 입력값에, 행정단위부와 번지부가 붙어있는 경우다.

행정단위부는 100% 5개 중 한개로 끝난다.

번지부는 무조건 숫자로 시작하고, 숫자로 끝난다.

  • 15
  • 2724
  • 792-791

어! 뭐야, 그럼 그냥 위에 다섯개 중에 숫자가 붙어있으면 걍 떨어트리면 안돼요?

이런 예외가 있어서 안된다.

심지어는 3·1대로길 같은 특수문자가 들어가있는 주소도 존재한다. (주소체계를 너무 잘못 만들었다..)

  • 도로명주소같은 경우에는  혹은  뒤에 건물 번호가 붙는다.
    • 그 뒤에 건물 부번이 존재할 경우 하이픈이 붙지만, 절대로 한글이 붙을 순 없다.
    • 하지만 중간 도로명에 길, 로, ·, 등 중복해서 붙을 수 있다. (이 부분은 해결을 못했다..)
      • 예시) 3·1대로길 /
  • 지번주소같은 경우에는  혹은  뒤에 지번 번호가 붙는다.
    • 도로명과 마찬가지로 건물 부번이 존재할 경우 하이픈이 붙지만, 절대로 한글이 붙을 수 없다.

그래서 결국은 완벽하게 모든 것을 대응할 순 없다. (약 1~2%정도)

되는 것은 되도록 만들자! 라는 결론을 내서 아래 정규식을 만들게 됐다.

new RegExp(/(^0-9*?가-힣|\s){1,}[0-9$-?0-9$?]{1,10}[^\|)|가-힣|(|a-zA-Z|,]/)

실제로 사용하는 방법

이것으로 세부주소를 분리하는 것으로 주소문제로 발생하는 미등록 이슈를 99% 정도 줄일 수 있게 됐다.

6. 앞으로 해야하는 것

현재 아쉬운 점이 몇가지가 있는데, 이 부분은 당근마켓에서도 똑같이 적용되고 있더라.

https://medium.com/daangn/주소-인식을-위한-삽질의-기록-df2d8f82d25

앞으로 해야하는 것들은 세가지 정도가 되는 것 같은데

  1. 한달단위로 업데이트되는 주소정보를 자동으로 갱신해주는 프로그램 만들기
    1. 현재는 10~15일 사이에 우체국에 들어가서 손으로 다운로드받고, 로그스태시로 밀어넣는 작업을 해주고 있다.
  2. 정확하게 나눠지지 않고 있는 몇가지 사항에 대해서 정규식을 새로 짜놓기
  3. 띄어쓰기를 하지 않는 주소에 대해서 분리해보기 ← 이게 제일 어려울 것 같다..

아무튼 조금만 먼저 검색을 해봤더라면 삽질을 덜 했을텐데 라는 아쉬운 마음도 들긴 했지만

원인을 파악하고, 썩 괜찮은 해결책을 찾아서 정말로 해결이 됐을 때의 성취감은 상당해서 즐거웠다.

profile
물류 서비스 Backend Software Developer

0개의 댓글