법정동 위치 데이터 정제

텐저린티·2023년 12월 13일
0

🥅 목적

지도 검색에 법정동 데이터를 이용해 현재 위치를 검색하고, 해당 지역의 학원을 검색하는 기능에 활용한다.

총 세 가지 정보를 통해 기능을 구현한다.

  • 주소
    • 시도 / 시군구 / 읍면동
  • 위경도 Point
  • 경계 Multipolygon

🍳 기술스택

  • Java 17
  • Spring Boot 3.1.3
  • Spring Jpa Data
    • Hibernate Spatial
  • MySQL
  • WebFlux
  • GeoJson

🏛️ 구조

RegionDataInvocatorRunner 를 실행해서 데이터를 정제한다.

@Component
public class RegionDataInvocatorRunner {

    private final AreaDataParser areaDataParser;
    private final AddressDataParser addressDataParser;
    private final PointDataParser pointDataParser;
    private final RegionService service;

}

종속성으로 알 수 있듯이,

Runner 클래스에서 영역(경계; Area), 주소(Address), 위경도(Point) 를 파싱하고

JPA 레포지토리를 주입받은 서비스를 통해 저장하는 방식이다.

🎼 코드

AreaDataParser

GeoJson 파일을 이용해서 세 개의 정보를 얻는다.

  1. 읍면동 코드
  2. 읍면동 이름
  3. 읍면동 경계 (영역) 멀티폴리곤

해당 정보로 Area 라는 DTO 를 만들어서 Runner 클래스로 가져온다.

이후 모든 정보는 Area 객체를 시작으로 가져오게 됨.

@Slf4j
@Component
public class AreaDataParser {

    public List<Area> parseData(String geoJsonFilePath) {
        List<Area> areas = Collections.synchronizedList(new ArrayList<>());

        try (SimpleFeatureIterator iterator = getSimpleFeatureIterator(geoJsonFilePath)) {
            while (iterator.hasNext()) {
                areas.add(getArea(iterator));
            }
        } catch (Exception e) {
            log.warn(e.toString());
        }

        return areas;
    }

    private Area getArea(SimpleFeatureIterator iterator) {
        SimpleFeature feature = iterator.next();

        String emdCd = feature.getAttribute("EMD_CD").toString();
        String emdNm = feature.getAttribute("EMD_NM").toString();

        Object geometry = feature.getDefaultGeometry();

        return Area.of(emdCd, emdNm, geometry);
    }

    private SimpleFeatureIterator getSimpleFeatureIterator(final String geojsonFilePath) {
        try {
            File geoJsonFile = new File(geojsonFilePath);

            SimpleFeatureCollection collection = (SimpleFeatureCollection) new FeatureJSON()
                    .readFeatureCollection(geoJsonFile);

            return collection.features();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

}

AddressDataParser

제가 사용하는 GeoJson 데이터에는 한 가지 단점이 있다.

읍면동 이름만 있다는 것.

제 플젝에는 읍면동 이름 뿐만 아니라 시도, 시군구 이름도 필요하다.

따라서 해당 읍면동 코드를 이용해서 법정동 이름을 조회해야 한다.

그게 이 클래스의 역할.

공공데이터 OpenAPI 를 활용해서 Address DTO를 만든다.

@Slf4j
@Component
public class AddressDataParser {

    private final OpenApiConfig openApiConfig;
    private final WebClientConfig webClientConfig;

    public AddressDataParser(
            OpenApiConfig openApiConfig,
            WebClientConfig webClientConfig
    ) {
        this.openApiConfig = openApiConfig;
        this.webClientConfig = webClientConfig;
    }

    public Address parseData(Area area) {
        try {
            AddressResponse addressResponse = requestData(area);

            return Address.of(addressResponse.admCodeNm());
        } catch (Exception e) {
            log.info("주소 정보 요청 실패 {}", e.getMessage());
            throw e;
        }
    }

    private AddressResponse requestData(Area area) {
        return webClientConfig.webClient()
                .get()
                .uri(openApiConfig.buildApiUrl(area.code()))
                .retrieve()
                .bodyToMono(AddressResponse.class)
                .doOnError(Throwable::getCause)
                .doOnSuccess(addressResponse -> validateData(area, addressResponse))
                .block();
    }

    private void validateData(Area area, AddressResponse addressResponse) {
        if (area == null) {
            throw new IllegalStateException("영역 정보가 없습니다.");
        }

        if (addressResponse == null) {
            throw new IllegalStateException("법정동 응답이 없습니다.");
        }

        if (area.code() != addressResponse.admCode()) {
            throw new IllegalStateException("법정동 코드가 일치하지 않습니다.");
        }

        if (!Objects.equals(area.name(), addressResponse.lowestAdmCodeNm())) {
            throw new IllegalStateException("읍면동 이름이 일치하지 않습니다.");
        }
    }

}

PointDataParser

네이버 GeoCode API 를 활용해서 주소의 위경도를 찾는다.

지역 선택 시 초기 화면 위치를 잡아주기 위해서 위경도가 필요하다.

네이버 지오코드는 효자라서, 주소를 멍청이 같이 넣어도 찰떡 같이 찾아준다.

해당 지역의 가운데 위경도라면 좋았겠지만, 행정동 주민센터 위경도를 알려주기 때문에

화면 중앙이 해당 지역의 구석에 있을 가능성도 있다.

@Slf4j
@Component
public class PointDataParser {

    private final GeocodeConfig geocodeConfig;
    private final WebClientConfig webClientConfig;

    public PointDataParser(final GeocodeConfig geocodeConfig, final WebClientConfig webClientConfig) {
        this.geocodeConfig = geocodeConfig;
        this.webClientConfig = webClientConfig;
    }

    public Point parseData(final Address address) {
        try {
            PointResponse pointResponse = requestData(address.getFullAddress())
                    .addresses()
                    .get(0);

            return getPoint(pointResponse);
        } catch (Exception e) {
            log.info("위경도 조회 실패 - address : {}", address);
            throw e;
        }
    }

    private PointResponses requestData(String address) {
        return webClientConfig.webClient()
                .get()
                .uri(geocodeConfig.buildApiUrl(address))
                .header(geocodeConfig.getClientIdProperty(), geocodeConfig.getClientId())
                .header(geocodeConfig.getClientSecretProperty(), geocodeConfig.getClientSecret())
                .retrieve()
                .bodyToMono(PointResponses.class)
                .doOnError(Throwable::getCause)
                .doOnSuccess(this::validateResponse)
                .block();
    }

    private void validateResponse(PointResponses pointResponses) {
        if (pointResponses == null || pointResponses.addresses() == null || pointResponses.addresses().isEmpty()) {
            throw new IllegalStateException("위치 정보 요청에 실패했습니다.");
        }
    }

    private Point getPoint(PointResponse pointResponse) {
        GeometryFactory geometryFactory = new GeometryFactory();

        double latitude = Double.parseDouble(pointResponse.y());
        double longitude = Double.parseDouble(pointResponse.x());

        Coordinate coordinate = new Coordinate(longitude, latitude);

        Point point = geometryFactory.createPoint(coordinate);
        point.setSRID(4326);

        return point;
    }

}

Service VS. Repository

먼저 Repository 가 아닌 Service를 주입받은 이유.

트랜잭션 범위를 좁게 하고 싶었다.

Runner 클래스에서 직접 Repository 를 주입받으면, 메소드 레벨로 @Transactional 어노테이션으로 트랜잭션을 설정해야함.

2번의 외부 API 호출과, GeoJson 데이터를 파싱 작업이 트랜잭션 내부에 있는 것이 합당하지 않다고 생각함.

그래서 모든 파싱을 완료한 후 Region 엔티티를 저장하는 코드만 트랜잭션이 동작하도록 범위를 좁히기 위해 서비스를 주입받았다.

🎯 성능 개선

병렬 스트림

Java 병렬 스트림을 이용해서 엄청난 성능 향상을 얻었다!!

결론부터 말하면 1,200 건의 데이터를 처리하는데 총 7분이 걸리던 기존 로직을 병렬 스트림 활용하는 방식으로 변경해 22초로 줄일 수 있었다.

BeforeAfter
public void invocateData(final String geoJsonFilePath) {
    log.info("데이터 파싱 시작");

    areaDataParser.parseData(geoJsonFilePath)
            .stream()
            .parallel()
            .forEach(area -> {
                try {
                    Address address = addressDataParser.parseData(area);
                    Point point = pointDataParser.parseData(address);

                    Region region = Region.of(area, address, point);

                    service.saveRegion(region);
                } catch (Exception e) {
                    log.info("{} 데이터는 파싱 오류로 건너뜀", area);
                }
            });

    log.info("데이터 파싱 종료");
}

GeoJson 데이터를 한 번에 모두 파싱해서 얻은 결과를 병렬 스트림으로 처리했다.

병렬 스트림이 실행될 때는 멀티 스레드로 코드가 실행되기 때문에 속도가 상당히 많이 올라간다.

또한 멀티 스레드가 스트림 내부에서 동작하는 원리이므로, 다른 코드와의 동시성 문제는 차치해도 된다.

다만, 병렬 스트림은..

내 프로젝트, 내 문제에서 병렬 스트림은 적절했다.

이유는 세 가지.

  1. 분할에 적합한 ArrayList 자료구조를 사용함
  2. 병합 연산을 수행하지 않음
  3. 데이터 순서가 중요치 않음

이러한 이유로 내 문제에서는 병렬 스트림으로 위와같이 좋은 결과를 얻을 수 있었다.

하고 싶은 말의 뼈는 병렬 스트림이 꼭 순차 스트림보다 빠를 수는 없다는 것.

물론, 대부분 빨라지긴 한다.

하지만,

  1. LinkedList 와 같이 분할에 적합하지 않은 자료구조를 사용하는 경우
  2. 병합 연산을 수행하는 경우
  3. 데이터 순서가 중요한 경우

이런 악재가 겹겹이 겹치면 병렬 스트림은 안 쓰느니만 못하게 된다.

그래서 비동기는?

나는 서울, 경기 데이터를 파싱해야 했다.

서울, 경기 데이터 파싱을 비동기로 동시에 실행해보려고 했다.

결과를 기다리지 않고, 바로바로 DB에 적재하면 실행속도가 더 빨라질 거라 생각했기 때문.

하지만 이 방식은 데이터 파싱에 정해진 순서가 있는 내 로직 특성 상 사용할 수 없는 방식이다.

내 로직은 Area → Address → Point → Region 순서로 데이터를 파싱해야 한다.

이 중에서 Address, Point 데이터 파싱에는 WebClient 로 웹 통신을 해야했다.

비동기로 로직을 수행하기 위해서는 네트워크 통신도 비동기로 할 필요가 있었다.

하지만, 이렇게 되면 Point 를 위해 Address 가 꼭 필요하고, Region 을 만들기 위해 Point 가 꼭 필요한 내 로직에서는 너무나 위험한 방법이었다.

그래서 이 방법은 패스했다.

🎳 이런거 해봤음

QGIS

shp 파일이라는 게 있다.

공간 정보를 저장한 파일인데, 나는 이걸 이용해서 GeoJson 파일로 만들어야 했다.

GeoJson 데이터

나는 이런게 있는 줄도 몰랐다.

사실 멀티폴리곤이라는게 있는 줄도 몰랐고,

MySQL 에 위치 정보를 담는 기능이 있다는 것은 알았지만, 별다른 지식은 없었다.

하지만 이번에 잘 맛봤다.

"features": [
		{ 
			"type": "Feature", 
			"properties": { 
					"EMD_CD": "41171103", 
					"COL_ADM_SE": "41170", 
					"EMD_NM": "박달동", 
					"SGG_OID": 2002 
			}, 
			"geometry": { 
					"type": "MultiPolygon",
				  "coordinates": [ [ [ 
							[ 126.870175755075564, 37.408166267693026 ],
							[ 126.870635378400735, 37.408793070949613 ],
							[ 126.870646695669549, 37.408819311941201 ],
							[ 126.870852764860558, 37.409280767965221 ],
							...
					] ] ]
			}

이렇게 생긴 데이터다.

geometry.coordinates 필드를 보면 알 수 있듯이, 해당 지역에 대해서 여러 개의 Coordinate 로 폴리곤(다각형)을 만들어 해당 지역의 구역(경계)를 표현하는 방법이다.

MySQL에서는 Multipolygon, Point, Polygon 같이 다양한 공간 데이터 타입이 있다.

포함여부, 일치 여부 같은 여러 함수도 지원하고 있어서 이걸 MySQL에 적절히 넣어주면 된다.

  • DB

  • 실제 값을 조회하고 싶으면
select code, sido, sigungu, upmyeondong, ST_AsText(point), ST_AsText(area) from regions;

🔗 참조

읍면동 조회 Open API

Geocoding API

(센서스경계)행정동경계 - 오픈마켓

QGIS 프로젝트에 오신 것을 환영합니다!

profile
개발하고 말테야

0개의 댓글