[GeoTools] ShapeFile, PostGIS 다루기

식빵·2023년 12월 29일
0

GeoTools

목록 보기
3/3
post-thumbnail

이번에 회사에서 기존 솔루션의 기능을 고도화하는 작업에 착수했습니다.
고도화의 내용은 ShapeFilePostGIS Tableimport 하는 작업의
속도 개선입니다. 그리고 해당 기능에 Geotools 를 사용해는 것을 추천해주셔서
한번 해보기로 했습니다.

하지만 아직 GeoTools 를 통한 Shapefile, PostGIS 를 제어해본 적이
없다보니, 제 PC 에서 먼저 테스트 코드를 작성해보기로 했습니다.

이 글은 바로 해당 테스트 코드에 대한 기록이며,
추가적으로 제가 생각하는 GeoTools 의 불편한 점들을 작성해봤습니다.

GeoTools 를 공부하시는 분들에게 부디 도움이 되길 바랍니다.



개발환경 및 세팅

개발환경

  • Window 10 Pro
  • Azul 17 JDK
  • Maven Project
  • Spring Boot Project
  • IDE: Intellij Ultimate

maven 세팅 (pom.xml)

저는 Spring Boot 기반 환경에서 작업하는 걸 좋아해서
pom.xml 에 maven 과 관련된 내용이 약간 섞여 있습니다.
Geotools 관련된 것들은 명시적으로 표기해서 구별이 가능하도록 했습니다.
GeoTools 세팅 라고 표기된 것만 확인하면 됩니다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.10</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>coding.toast</groupId>
    <artifactId>geotools</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>geotools-playground</name>
    <description>geotools-playground</description>
  
  
  
    <properties>
        <java.version>17</java.version>
      
		<!-- GeoTools 세팅(1) - geotools 버전 통일을 위한 프로퍼티 설정 -->
        <geotools.version>27.2</geotools.version>
    </properties>

  	<!-- GeoTools 세팅(2) - repository 추가 [시작] -->
    <repositories>
        <repository>
            <id>osgeo</id>
            <name>Geotools repository</name>
            <url>https://repo.osgeo.org/repository/release</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
        <repository>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
            <id>osgeo-snapshot</id>
            <name>Central Repository</name>
            <url>https://repo.osgeo.org/repository/snapshot</url>
        </repository>
    </repositories>
	<!-- GeoTools 세팅(2) - repository 추가 [끝] -->
  
  
    
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

      
      	<!-- GeoTools 세팅(3) : 의존성 추가 [시작] -->
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.geotools.jdbc</groupId>
            <artifactId>gt-jdbc-postgis</artifactId>
            <version>${geotools.version}</version>
        </dependency>

        <dependency>
            <groupId>org.geotools</groupId>
            <artifactId>gt-shapefile</artifactId>
            <version>${geotools.version}</version>
        </dependency>

        <!--https://docs.geotools.org/latest/userguide/library/referencing/index.html-->
        <dependency>
            <groupId>org.geotools</groupId>
            <artifactId>gt-epsg-hsql</artifactId>
            <version>${geotools.version}</version>
        </dependency>
        <!-- gt-epsg-hsql 는 gt-referencing API 의 구현체입니다. -->
        <!-- 
			참고로 gt-referencing API 구현체는 gf-referencinig 외에도 
 			gt-epsg-wkt 도 있습니다만 사용하지 않는 걸 추천합니다.  
			gt-epsg-wkt 는 prj 파일에 기재된 WKT 문자열을 제대로 번역하지 못하는
 			경우가 많기 때문입니다. 예를 들어서 5186 좌표계를 사용하는 prj 파일이 
			ESRI WKT 로 작성되어 있다면 EPSG 코드를 추출하지 못합니다.
		-->

        <dependency>
            <groupId>org.geotools</groupId>
            <artifactId>gt-referencing</artifactId>
            <version>${geotools.version}</version>
        </dependency>

        <dependency>
            <groupId>org.geotools</groupId>
            <artifactId>gt-metadata</artifactId>
            <version>${geotools.version}</version>
        </dependency>

        <dependency>
            <groupId>org.geotools</groupId>
            <artifactId>gt-opengis</artifactId>
            <version>${geotools.version}</version>
        </dependency>

        <dependency>
            <groupId>org.geotools</groupId>
            <artifactId>gt-wfs-ng</artifactId>
            <version>${geotools.version}</version>
        </dependency>

        <dependency>
            <groupId>org.geotools</groupId>
            <artifactId>gt-process</artifactId>
            <version>${geotools.version}</version>
        </dependency>

        <dependency>
            <groupId>org.geotools</groupId>
            <artifactId>gt-transform</artifactId>
            <version>${geotools.version}</version>
        </dependency>

        <dependency>
            <groupId>org.locationtech.jts</groupId>
            <artifactId>jts-core</artifactId>
            <version>1.19.0</version>
        </dependency>
		<!-- GeoTools 세팅(3) : 의존성 추가 [끝] -->

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>



테스트 코드

참고
여기 작성된 테스트 코드는 모두 저의 github repository 에 기재될 겁니다.
repository 주소: https://github.com/CodingToastBread/geotools-playground


ShapeFile 관련

1. 메타정보 조회

git repository 참고 링크

package coding.toast.geotools.shapefile;

import coding.toast.geotools.utils.ShapeFileUtil;
import org.geotools.data.shapefile.ShapefileDataStore;
import org.geotools.feature.type.GeometryDescriptorImpl;
import org.geotools.referencing.CRS;
import org.junit.jupiter.api.Test;
import org.opengis.feature.simple.SimpleFeatureType;
import org.opengis.feature.type.AttributeDescriptor;
import org.opengis.referencing.FactoryException;
import org.opengis.referencing.crs.CoordinateReferenceSystem;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;

public class ShapeFileMetaDataReadTests {
	
	@Test
	void readShapeFileMetaData() throws IOException, FactoryException {
		ShapefileDataStore shapeFileDataStore
			= ShapeFileUtil.getShapeFileDataStore(
			"C:/shapefiles/sample/sample.shp",
			"UTF-8");
		
		CoordinateReferenceSystem shapeFileCrs
			= shapeFileDataStore.getSchema().getCoordinateReferenceSystem();
		
		
		// (1) EPSG CODE 코드 추출
		// null 이 나올 수도 있습니다. 이건 gt-reference 구현체 라이브러리가
		// 제대로 CRS 를 읽지 못하거나, 정말 shapefile prj 가 이상한 겁니다.
		Integer epsgCode = CRS.lookupEpsgCode(shapeFileCrs, false);
		System.out.println("\n(1) EPSG CODE : " + epsgCode);
		
		
		// (2) TypeName 추출
		// GeoTools 가 인식하는 shapefile 의 entry(=type) 명을 뽑아낸다.
		// 이 type 명칭은 추후에 datastore 에 있는 다양한 entry 에 대해서
		// 하나만 뽑아낼 때 사용하게 되는 중요한 값이다.
		String typeName = shapeFileDataStore.getTypeNames()[0]; // 쉐이프 파일을 하나 지정했으므로 무조건 TypeNames 가 1개이다.
		System.out.println("\n(2) TypeName 추출 : " + Arrays.toString(shapeFileDataStore.getTypeNames()));
		
		
		// (3) 스키마 정보 조회
		// SimpleFeatureType schema = shapeFileDataStore.getSchema();
		SimpleFeatureType schema = shapeFileDataStore.getSchema(typeName);
		System.out.println("\n스키마 정보 조회 =: " + schema);
		
		
		// (4) geometry attribute 명칭 추출
		// 참고: 사실 dbf 파일에는 geometry 를 위한 attribute 가 존재하지 않습니다.
		// 하지만 GeoTools 는 편의를 위해서 임의로 the_geom 이라는 이름으로 하나의 attribute 를 제공합니다.
		String geomName = schema.getGeometryDescriptor().getLocalName();
		System.out.println("\n(4) geometry attribute 명칭 추출 : " + geomName);
		
		
		// (5) geometry type 추출하기
		String geomTypeName = schema.getGeometryDescriptor().getType().getBinding().getSimpleName();
		System.out.println("\n(5) geometry type 추출하기 : " + geomTypeName);
		
		
		// (6) Feature 각각 속성 정보 조회.
		System.out.println("\n============================= (6) Feature 각각 속성 정보 조회 [START] =============================");
		List<AttributeDescriptor> attributeDescriptors = schema.getAttributeDescriptors();
		for (AttributeDescriptor attributeDescriptor : attributeDescriptors) {
			
			// 지오메트리 타입에 대한 어떤 특별한 조회를 원하면?
			if (attributeDescriptor instanceof GeometryDescriptorImpl) {
				System.out.println("\nGeometry 속성 발견!");
				GeometryDescriptorImpl geometryDescriptor = (GeometryDescriptorImpl) attributeDescriptor;
				System.out.println("Geometry Attr LocalName" + geometryDescriptor.getLocalName());
				System.out.println("Geometry Attr CoordinateReferenceSystem" + geometryDescriptor.getCoordinateReferenceSystem());
				System.out.println("Geometry Attr Type" + geometryDescriptor.getType());
				continue;
			}
			
			// 나머지 AttributeTypeImpl 타입에 대한 조회
			System.out.println("Attribute name : " + attributeDescriptor.getName()
				+ " , Attribute Type : " + attributeDescriptor.getType().getClass()
				+ " , Attribute Binding Data Type : "
				+ attributeDescriptor.getType().getBinding().getSimpleName());
		}
		System.out.println("\n============================= (6) Feature 각각 속성 정보 조회 [END] =============================");
		
	}
}

출력 예시 :




2. Feature 정보 조회

git-repository 참고 링크

package coding.toast.geotools.shapefile;

import coding.toast.geotools.utils.DataStoreUtil;
import coding.toast.geotools.utils.OpenEpsgMapUtil;
import coding.toast.geotools.utils.ShapeFileUtil;
import org.geotools.data.shapefile.ShapefileDataStore;
import org.geotools.data.simple.SimpleFeatureIterator;
import org.geotools.data.store.ContentFeatureCollection;
import org.geotools.data.store.ContentFeatureSource;
import org.junit.jupiter.api.Test;
import org.opengis.feature.simple.SimpleFeature;
import org.opengis.feature.type.AttributeDescriptor;

import java.io.IOException;
import java.util.List;

/**
 * Test Class for Reading Each Feature Info inside ShapeFile
 */
public class ShapeFileFeatureReadTest {
	@Test
	void readFeatureTest() throws IOException {
		
		ShapefileDataStore shapeFileDataStore
			= ShapeFileUtil.getShapeFileDataStore(
			"src/test/resources/sample/sample.shp",
			"UTF-8");
		
		
		// (1) Shapefile Iterator Loop
		// Note: For loop iteration, three steps are needed.
		//      1. Extract FeatureSource from dataStore
		//      2. Extract FeatureCollection from FeatureSource
		//      3. Extract Iterator from FeatureCollection
		ContentFeatureSource featureSource = shapeFileDataStore.getFeatureSource();
		ContentFeatureCollection featureCollection = featureSource.getFeatures();
		try (SimpleFeatureIterator shpFileFeatureIterator = featureCollection.features()) {
			
			while (shpFileFeatureIterator.hasNext()) {
				SimpleFeature feature = shpFileFeatureIterator.next();
				int attributeCount = feature.getAttributeCount();
				
				List<AttributeDescriptor> attributeDescriptors
					= feature.getFeatureType().getAttributeDescriptors();
				
				for (int i = 0; i < attributeCount; i++) {
					System.out.println(
						attributeDescriptors.get(i).getLocalName()  // key
							+ " : " + feature.getAttribute(i)       // value
					);
				}
				
				System.out.println("=========================================");
			}
		}
		
		DataStoreUtil.closeDataStores(shapeFileDataStore);
		
		// If you want to see the actual location of the POINT (=the_geom) displayed,
		// uncomment the line below and check!
		// OpenEpsgMapUtil.showMap(5186, 317806.88781702635, 563786.2655425679, 14);
	}
}




PostGIS 관련

1. 테이블 메타정보 조회

git repository 참고 링크

package coding.toast.geotools.postgis;

import coding.toast.geotools.utils.DataStoreUtil;
import coding.toast.geotools.utils.PostGisUtil;
import org.geotools.feature.type.GeometryDescriptorImpl;
import org.geotools.jdbc.JDBCDataStore;
import org.geotools.referencing.CRS;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.opengis.feature.simple.SimpleFeatureType;
import org.opengis.feature.type.AttributeDescriptor;
import org.opengis.referencing.FactoryException;

import java.io.IOException;
import java.util.Arrays;
import java.util.Random;

/**
 * Reading PostGIS Table Meta Info
 */
public class PostGisMetaDataReadTest {
	private static JDBCDataStore postGisDataStore;
	
	@BeforeAll
	static void beforeAll() throws IOException {
		postGisDataStore = PostGisUtil.getPostGisDataStore(
			"postgis", // db type
			"localhost",   // db server host
			"5432",        // db server port
			"postgres",    // database name
			"public",      // db schema name
			"postgres",    // db connection user id
			"root"         // db connection password
		);
	}
	
	@AfterAll
	static void afterAll() {
		DataStoreUtil.closeDataStores(postGisDataStore);
	}
	
	@Test
	void createSimpleGeometryTableTest() throws IOException, FactoryException {
		
		// read All Table inside schema that i config on [PostGisUtil.getPostGisDataSource] method
		String[] tableNames = postGisDataStore.getTypeNames();
		
		if (tableNames.length == 0) {
			System.out.println("No Table Information found in this database schema : "
				+ postGisDataStore.getDatabaseSchema());
			return;
		}
		
		System.out.println("Database Schema : " + postGisDataStore.getDatabaseSchema());
		System.out.println("Table List : " + Arrays.toString(tableNames));
		
		// get Any database Table schema info
		int randomIdx = new Random().nextInt(0, tableNames.length);
		String randomSelectedTable = tableNames[randomIdx];
		SimpleFeatureType tableSchema = postGisDataStore.getSchema(randomSelectedTable);
		
		// read table schema info
		for (AttributeDescriptor attributeDescriptor : tableSchema.getAttributeDescriptors()) {
			System.out.println("\ntable column name : " + attributeDescriptor.getType().getName());
			System.out.println("java attribute Type : " + attributeDescriptor.getType().getBinding().getSimpleName());
			if (attributeDescriptor instanceof GeometryDescriptorImpl geometryDescriptor) {
				System.out.println("geometry column CRS Name : " + geometryDescriptor.getCoordinateReferenceSystem().getName());
				System.out.println("geometry column SRID (=EPSG Code) : " + CRS.lookupEpsgCode(geometryDescriptor.getCoordinateReferenceSystem(), false));
			}
			System.out.println("get meta info : " + attributeDescriptor.getUserData());
		}
	}
}

출력 예시




2. geotools 로 테이블 만들기

경고! geotools 만으로 생성하는 테이블에는 상당히 많은 약점이 있습니다.
이건 쓰다보면 스스로 느낄 겁니다. 테이블 생성은 최대한 순수한 jdbc 기능만으로
작업하는 게 편합니다.(제 경험상)

git repository 참고링크

package coding.toast.geotools.postgis;

import coding.toast.geotools.utils.DataStoreUtil;
import coding.toast.geotools.utils.PostGisUtil;
import org.geotools.data.DataUtilities;
import org.geotools.feature.AttributeTypeBuilder;
import org.geotools.feature.SchemaException;
import org.geotools.feature.simple.SimpleFeatureTypeBuilder;
import org.geotools.jdbc.JDBCDataStore;
import org.geotools.referencing.CRS;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.locationtech.jts.geom.Point;
import org.opengis.feature.simple.SimpleFeatureType;
import org.opengis.feature.type.AttributeDescriptor;
import org.opengis.referencing.FactoryException;

import java.io.IOException;

/**
 * Geotools table creation test class.
 * Warning! Avoid using GeoTools for table creation unless necessary.
 * There are many limitations compared to using pure JDBC libraries.
 */
public class CreateTableUsingGeoToolsTest {
	
	private static JDBCDataStore postGisDataStore;
	
	@BeforeAll
	static void beforeAll() throws IOException {
		postGisDataStore = PostGisUtil.getPostGisDataStore(
			"postgis", // db type
			"localhost", // db server host
			"5432", // db server port
			"postgres", // database 명
			"public", // db 스키마 명
			"postgres", // db connection user id
			"root" // db connection 비번
		);
	}
	
	
	@AfterAll
	static void afterAll() {
		DataStoreUtil.closeDataStores(postGisDataStore);
	}
	
	
	
	// Reading https://docs.geotools.org/stable/userguide/library/main/feature.html
	// and https://stackoverflow.com/questions/52554587/add-new-column-attribute-to-the-shapefile-and-save-it-to-database-using-geotools
	// and https://gis.stackexchange.com/questions/303709/how-to-set-srs-to-epsg4326-in-geotools
	// before this test code will be very helpful!
	@Test
	@DisplayName("Create table via SimpleFeatureTypeBuilder")
	void createTableUsingGeotoolsTest() throws IOException, FactoryException {
		
		SimpleFeatureTypeBuilder featureTypeBuilder = new SimpleFeatureTypeBuilder();
		
		featureTypeBuilder.setName("geotools_create_table"); // table name to create
		
		// setting CRS, if you have multiple geometry columns and want to apply the same
		// CRS, this code will be useful.
		featureTypeBuilder.setCRS(CRS.decode("EPSG:5186"));
		
		// you can see the comment in the middle of the code below.
		// this kind of crs config is useful when you have multiple geometry columns
		// and need to configure different crs.
		featureTypeBuilder.add("geom", Point.class/*, CRS.decode("EPSG:5186")*/);
		
		// the code below is only useful when you have multiple geometry type columns
		featureTypeBuilder.setDefaultGeometry("geom");
		
		// you can create normal attribute info very detail like below
		AttributeTypeBuilder nameAttrBuilder = new AttributeTypeBuilder();
		nameAttrBuilder.setNillable(false);
		nameAttrBuilder.setBinding(String.class);
		nameAttrBuilder.setLength(255);
		AttributeDescriptor nameAttr = nameAttrBuilder.buildDescriptor("name");
		featureTypeBuilder.add(nameAttr);
		// name varchar (15) not null
		
		AttributeTypeBuilder ageAttrBuilder = new AttributeTypeBuilder();
		ageAttrBuilder.setNillable(true);
		ageAttrBuilder.setBinding(Integer.class);
		AttributeDescriptor ageAttr = ageAttrBuilder.buildDescriptor("age");
		featureTypeBuilder.add(ageAttr);
		// ==> age integer
		
		// GeoTools table creation functionality has limitations!
		// You can create a column of numeric type using BigDecimal,
		// but specifying detailed types like numeric(10,2) is not possible.
		// If I'm wrong or if you know a way, please let me know via email.
		
		// Create the table
		postGisDataStore.createSchema(featureTypeBuilder.buildFeatureType());
		
		DataStoreUtil.closeDataStores(postGisDataStore);
	}
	
	@Test
	@DisplayName("Create table via DataUtilities")
	void createTableUsingGeotoolsDataUtilsTest2() throws IOException, FactoryException, SchemaException {
		String createTableName = "geotools_create_table2"; // table name to create
		
		// Specify the schema of the new table.
		// Note (1): typeSpecForPostGIS is a String like "id:java.lang.Long,name:java.lang.String,geom:Point".
		// DataUtilities.createType API documentation provides detailed usage, so please refer to it.
		// Note (2): By default, a spatial index using GIST is created for the geometry column!
		SimpleFeatureType targetSchema
			= DataUtilities.createType(createTableName, "id:java.lang.Long,name:java.lang.String,geom:Point");
		targetSchema = DataUtilities.createSubType(targetSchema, null, CRS.decode("EPSG:5186"));
		
		// Create the table
		postGisDataStore.createSchema(targetSchema);
		
		DataStoreUtil.closeDataStores(postGisDataStore);
	}
}




3. ShapeFile 기반 테이블 생성

git repository 참고 링크

참고: https://stackoverflow.com/questions/52554587/add-new-column-attribute-to-the-shapefile-and-save-it-to-database-using-geotools

package coding.toast.geotools.postgis;

import coding.toast.geotools.utils.DataStoreUtil;
import coding.toast.geotools.utils.PostGisUtil;
import coding.toast.geotools.utils.ShapeFileUtil;
import org.geotools.data.DataUtilities;
import org.geotools.data.shapefile.ShapefileDataStore;
import org.geotools.feature.SchemaException;
import org.geotools.jdbc.JDBCDataStore;
import org.geotools.referencing.CRS;
import org.junit.jupiter.api.Test;
import org.opengis.feature.simple.SimpleFeatureType;
import org.opengis.referencing.FactoryException;
import org.opengis.referencing.crs.CoordinateReferenceSystem;

import java.io.IOException;
import java.util.Arrays;

/**
 * Test for reading feature attribute information from a shapefile and creating a PostGIS table using that information.
 */
public class CreateTableViaShapeFileTest {
	
	@Test
	void test() throws IOException, FactoryException, SchemaException {
		// Get ShapefileDataStore from the ShapeFileUtil
		ShapefileDataStore shapeFileDataStore = ShapeFileUtil.getShapeFileDataStore(
			"src/test/resources/sample/sample.shp",
			"UTF-8");
		
		// Get the schema and CRS information from the shapefile
		SimpleFeatureType shapeFileSchema = shapeFileDataStore.getSchema();
		CoordinateReferenceSystem shapeFileCrs = shapeFileDataStore.getSchema().getCoordinateReferenceSystem();
		
		// Uncommon, but if there's an error in the prj file of the shapefile,
		// the EPSG code might not be present. It's better to handle it beforehand.
		// If there's no EPSG code, it may cause issues later.
		// So, taking preemptive action is advisable; otherwise, you might regret it later!
		String userDefaultEpsgCodeInput = "5186";
		Integer epsgCode = CRS.lookupEpsgCode(shapeFileCrs, false);
		if (epsgCode == null || epsgCode == 0) {
			shapeFileDataStore.forceSchemaCRS(CRS.decode("EPSG:" + userDefaultEpsgCodeInput));
		}
		
		// Get PostGIS DataStore using PostGisUtil
		JDBCDataStore postGisDataStore = PostGisUtil.getPostGisDataStore(
			"postgis",        // db type
			"localhost",      // db server host
			"5432",           // db server port
			"postgres",       // database name
			"public",         // db schema name
			"postgres",       // db connection user id
			"root"            // db connection password
		);
		
		// Specify the table name to be created
		String tableToCreate = "new_table";
		
		// Check if the table already exists
		boolean isAlreadyExists = Arrays.asList(postGisDataStore.getTypeNames()).contains(tableToCreate);
		if (isAlreadyExists) {
			System.out.println("Already Existing Table!");
			return;
		}
		
		// Read attribute information from the shapefile and create the typeSpec string
		String typeSpecForPostGIS = PostGisUtil.getTypeSpecForPostGIS(shapeFileSchema);
		
		// Create PostGIS Table Schema
		SimpleFeatureType targetSchema
			= DataUtilities.createType(tableToCreate, typeSpecForPostGIS);
		targetSchema = DataUtilities.createSubType(targetSchema, null, shapeFileCrs);
		
		// Create the table
		postGisDataStore.createSchema(targetSchema);
		
		// Close the data stores
		DataStoreUtil.closeDataStores(shapeFileDataStore, postGisDataStore);
	}
}




4. ShapeFile 데이터를 PostGIS 테이블에 넣기

git repository 참고 링크

package coding.toast.geotools.postgis;

import coding.toast.geotools.utils.PostGisUtil;
import coding.toast.geotools.utils.ShapeFileUtil;
import org.geotools.data.DataStore;
import org.geotools.data.DataUtilities;
import org.geotools.data.DefaultTransaction;
import org.geotools.data.Transaction;
import org.geotools.data.shapefile.ShapefileDataStore;
import org.geotools.data.simple.SimpleFeatureIterator;
import org.geotools.data.simple.SimpleFeatureSource;
import org.geotools.data.simple.SimpleFeatureStore;
import org.geotools.data.store.ContentFeatureCollection;
import org.geotools.data.store.ContentFeatureSource;
import org.geotools.feature.simple.SimpleFeatureTypeBuilder;
import org.geotools.referencing.CRS;
import org.junit.jupiter.api.Test;
import org.opengis.feature.simple.SimpleFeature;
import org.opengis.feature.simple.SimpleFeatureType;
import org.opengis.referencing.FactoryException;
import org.opengis.referencing.crs.CoordinateReferenceSystem;

import java.io.IOException;
import java.util.Arrays;
import java.util.Objects;

/**
 * Test class for appending shapefile data to a PostGIS table.<br>
 * There are a few important points to note here:<br>
 * <strong>The target table for data injection must have a numeric primary key.</strong>
 * @see <a href="https://sourceforge.net/p/geotools/mailman/geotools-devel/thread/40DF7C1A.1080802%40refractions.net/">Handling tables with no primary</a>
 */
public class ShapeFileToDatabaseTableAppendingTest {
	
	@Test
	void appendShapeFileDataToTable() throws IOException, FactoryException {
		
		// Create Shapefile DataStore
		ShapefileDataStore shapeFileDataStore = ShapeFileUtil.getShapeFileDataStore(
			"src/test/resources/sample/sample.shp",
			"UTF-8");
		// this shapefile have 3 attributes
		// id(number)
		// name(string)
		// geom(Point, 5186)
		
		// Extract CRS in advance
		CoordinateReferenceSystem shapeFileCrs = shapeFileDataStore.getSchema().getCoordinateReferenceSystem();
		
		// Uncommon, but if there's an error in the prj file of the shapefile,
		// the EPSG code might not be present. It's better to handle it beforehand.
		// If there's no EPSG code, it may cause issues later. So, taking preemptive action is advisable.
		String userDefaultEpsgCodeInput = "5186";
		Integer epsgCode = CRS.lookupEpsgCode(shapeFileCrs, false);
		if (epsgCode == null || epsgCode == 0) {
			shapeFileDataStore.forceSchemaCRS(CRS.decode("EPSG:" + userDefaultEpsgCodeInput));
		}
		
		// Create PostGIS DataStore
		DataStore postGisDataStore = PostGisUtil.getPostGisDataStore(
			"postgis",
			"localhost",
			"5432",
			"postgres",
			"public",
			"postgres",
			"root"
		);
		
		// Target table name for data insert
		String targetTableName = "data_insert_test";
		// Caution! There must be a numeric primary key in the table where you want to insert data!!!
        /*
        -- table DDL
		create table public.data_appending_test
		(
		    fid  integer not null
		        constraint new_table_pkey
		            primary key,
		    id   bigint,
		    name varchar,
		    geom geometry(Point, 5186)
		);
		
		create index spatial_new_table_geom
		    on public.data_appending_test using gist (geom);
         */
		
		// Check if the table really exists in the database
		if (!Arrays.asList(postGisDataStore.getTypeNames()).contains(targetTableName)) {
			System.err.println("No Table Found!!!!!!");
		}
		
		// Create a new schema based on the existing postGIS schema
		// Retrieve the FeatureType (= schema) of a specific table.
		SimpleFeatureType postGisSchema = postGisDataStore.getSchema(targetTableName);
		
		SimpleFeatureTypeBuilder builderForPostGIS = new SimpleFeatureTypeBuilder();
		
		builderForPostGIS.setName(postGisSchema.getName());
		
		builderForPostGIS.setSuperType((SimpleFeatureType) postGisSchema.getSuper());
		
		builderForPostGIS.addAll(postGisSchema.getAttributeDescriptors());
		// Note1: postGisSchema.getAttributeDescriptors() retrieves only columns excluding the primary key.
		// Note2: If needed, add additional attributes like builderForPostGIS.add("new_attr", String.class);
		// However, this should be a column actually present in the PostGIS table!
		
		// Create a FeatureType containing column information for the postGIS table
		SimpleFeatureType postGISFeatureType = builderForPostGIS.buildFeatureType();
		
		// Declare a transaction; assignment will be done inside the try-catch block.
		Transaction transaction = null;
		
		// Amount of data to be sent to the database at once
		final int BATCH_SIZE = 1000;
		
		// Counting the number of data accumulating in one transaction
		int count = 0;
		
		try {
			
			// Set the transaction to the FeatureStore where we want to perform the write operation.
			transaction = new DefaultTransaction("POSTGIS_DATA_APPENDING");
			
			// Extract the FeatureSource where the actual data will be inserted
			SimpleFeatureSource tableFeatureSource = postGisDataStore.getFeatureSource(targetTableName);
			// typename == table name specified
			// Casting SimpleFeatureSource to SimpleFeatureStore for setting the transaction
			// A crucial caution!
			// If there is no numeric primary key in the table where you want to insert data,
			// an error will occur in the following Class Casting! Make sure to check the presence
			// of a numeric primary key in the table where you want to insert data!
			SimpleFeatureStore tableFeatureStore = (SimpleFeatureStore) tableFeatureSource;
			tableFeatureStore.setTransaction(transaction);
			
			// Create an iterator to read features from the shapefile
			ContentFeatureSource featureSource = shapeFileDataStore.getFeatureSource();
			ContentFeatureCollection featuresCollection = featureSource.getFeatures();
			try (SimpleFeatureIterator features = featuresCollection.features()) {
				
				// Start iterating through the features
				while (features.hasNext()) {
					
					SimpleFeature shapeFileFeature = features.next();
					SimpleFeature transformedFeature = DataUtilities.template(postGISFeatureType);
					
					// Warning! Never set Attribute for a database table numeric primary key!
					// transformedFeature.setAttribute("fid", shapeFileFeature.getAttribute("????")); ==> don't do this!
					
					// transform shapefile data to table data
					transformedFeature.setAttribute("id", shapeFileFeature.getAttribute("id"));
					transformedFeature.setAttribute("name", shapeFileFeature.getAttribute("name"));
					transformedFeature.setDefaultGeometryProperty(shapeFileFeature.getDefaultGeometryProperty());
					tableFeatureStore.addFeatures(DataUtilities.collection(transformedFeature));
					
					count++;
					if (count % BATCH_SIZE == 0) {
						transaction.commit();
						transaction.close();
						tableFeatureStore.setTransaction(null);
						transaction = new DefaultTransaction("POSTGIS_DATA_APPENDING");
						tableFeatureStore.setTransaction(transaction);
					}
				}
				
				// Features added with tableFeatureStore.addFeature might not have been committed yet even
				// after the while loop. Commit them.
				if ((count % BATCH_SIZE) != 0) {
					transaction.commit();
				}
			}
			
		} catch (Exception e) {
			if (Objects.nonNull(transaction)) transaction.rollback();
			e.printStackTrace(System.err);
		} finally {
			if (Objects.nonNull(transaction)) transaction.close();
			postGisDataStore.dispose();
			shapeFileDataStore.dispose();
		}
	}
}

데이터 입력 후 QGIS 에서 PostGIS 테이블을 조회하면 아래처럼
point 정보가 지도에 표출됩니다. 참고로 좌표계에 맞게 데이터가 들어간 덕에
이렇게 우리나라 Boundary 안에 Point 가 찍히는 겁니다!


4-1. 메모리 모니터링

메모리는 테스트를 해봤는데, 다행히 GC 가 잘 잡네요.

초반:


한 30분 후쯤...


4-2. 문제점

음... batch 사이즈를 1000 으로 하건, 10000 으로 하건...
느립니다! GDAL 에 비해서 턱없이 느립니다.

제가 코딩을 좀 잘못한 걸지도 모르지만... 그렇다 해도 좀 심한 거 같습니다.

지금 연속지적도_서울 (총 Feature 수 : 90만개)을 돌려보는데 현재 40분째 돌리는데
24만개의 데이터가 들어가네요.

혹시 해결법 아시는 분 있으면 댓글 부탁드립니다. 커피 쏩니다 😂




5. PostGIS 테이블 데이터 넣을 시 주의할 점

딱 잘라 결론부터 말하겠습니다.

GeoTools 를 통해서 데이터를 넣으려는 DB Table
반드시 한 개 이상의 Primary Key 를 갖고 있어야 합니다.

제가 위 4번 목차에서 테스트한 ShapeFile 과 DB Table 의 구조를
보면서 설명을 해보겠습니다..



먼저 테스트용 shapefile 은 다음과 같은 Feature 속성이 있습니다.

  1. id (number type)
  2. name (String type)
  3. 지오메트리 (Point Type, SRID=5186)

테스트용 PostGIS Table DDL 은 다음과 같습니다.

create table public.data_appending_test
(
    fid  integer primary key, -- serial 타입이여도 상관없음!
    id   bigint,
    name varchar,
    geom geometry(Point, 5186)
);

create index spatial_data_appending_test_geom
    on public.data_appending_test using gist (geom);

만약에 제가 여기서 DB Table 의 primary key(=fid) 를 지우면 어떻게 될까요??

예외가 발생합니다 !
예외 발생 코드는 아래와 같습니다.


코드 :

SimpleFeatureStore tableFeatureStore = (SimpleFeatureStore) tableFeatureSource;

에러 메시지 :

java.lang.ClassCastException: class org.geotools.jdbc.JDBCFeatureSource cannot be cast to class org.geotools.data.simple.SimpleFeatureStore


아주 세세한 이유는 모르겠지만,
primary key 가 단 하나도 없으면 100% 이런 예외를 뱉습니다.

그렇기 때문에 (계속 말씀드리지만)
반드시 data 를 넣으려는 테이블에 primary key 가 최소 1개 이상은 있어야 합니다.



그러면 Primary Key 는 어떤 타입을 써야될까요?

일단 제가 테스트해본 결과로는 2가지 Type 을 조합 또는 복수로 PK 로 지정할 수 있습니다.
아, 물론 복수가 아닌 1개의 column 으로 PK 를 잡아도 잘됩니다.

  • 숫자형
  • 문자형

숫자형 + 숫자형 PK 테이블

예를 들어서, 숫자 2개를 조합한 테이블을 아래처럼 생성하고,
위 목차 4번의 코드를 돌려보겠습니다.

create table public.geotools_pk_test1
(
    fid bigint,
    fid2 integer,
    id   bigint,
    name varchar,
    geom geometry(Point, 5186)
);

alter table public.geotools_pk_test1
    add constraint geotools_pk_test1_mixed_pk primary key (fid, fid2);

데이터 insert 결과:

  • 보면 알겠지만, geotools 가 알아서 primary key 값을 채워서 넣어줍니다.
  • 참고로 여러번 코드를 돌리면 geotools 가 알아서 계속 primary key 를 새롭게 주입합니다.
  • 그렇기 때문에 여러번 코드를 실행해도 괜찮습니다.



숫자형 + 문자형 PK 테이블

create table public.geotools_pk_test2
(
    fid bigint,
    fid2 varchar, -- 길이 제한을 주고 싶다면, 최소 31 로 해야합니다.
    id   bigint,
    name varchar,
    geom geometry(Point, 5186)
);

alter table public.geotools_pk_test2
    add constraint geotools_pk_test2_mixed_pk primary key (fid, fid2);

데이터 insert 결과:

  • 숫자형은 예상대로 increment 하면서 넣는 게 확인됩니다.
  • 문자형은 뭔가... UUID 스러운(?) 느낌의 문자열을 넣어주네요.
  • 여러번 코드를 돌리면 매번 새로운 PK 값을 넣어줍니다. 코드 중복 실행 가능!



문자형 + 문자형 PK 테이블

create table public.geotools_pk_test3
(
    fid varchar,
    fid2 varchar,
    id   bigint,
    name varchar,
    geom geometry(Point, 5186)
);

alter table public.geotools_pk_test3
    add constraint geotools_pk_test3_mixed_pk primary key (fid, fid2);

데이터 insert 결과:

  • 뭔가 예상한 대로 들어갔네요.
  • 마찬가지로 코드 중복 실행 가능! Geotools 가 매번 새로운 PK 생성해줍니다!





글을 마치며...

1. 개인적으로 느낀 GeoTools 의 불편한 점들

제가 GDAL 을 개인 PC 에서 주로 사용하고,
회사에서도 많은 공간 데이터들을 GDAL 을 통해서 PostgreSQL DB 로 업로드 하다보니,
원치 않게 GDAL 과 Geotools 를 계속 비교하게 되네요.

제가 생각하는 ShapeFile + PostGIS 관련 기능에서 GeoTools 의 불편한 점을 작성해봅니다.


첫째. 애매한 제약점들

이미 말했지만, 데이터를 넣으려는 테이블에 반드시 PK 가 있어야 된다는 점이...
솔직히 좀 불편합니다. GDAL 에서는 딱히 이런 제약은 없었거든요.

그리고 Table 생성할 때도 numeric 같은 타입에 더 상세하게
numberic(10,2) 이런 식으로 타입을 지정하고 싶어도,
이건 방법 자체를 GeoTools 에서 제공하지 않더군요 (제가 찾아본 바로는!).

진짜 조금조금씩 애매하게 기능을 제공하는 느낌을 받았습니다.


둘째. 해결법을 찾기가 하늘에 별따기

위에서 제가 테이블 data insert 시에 반드시 PK 가 있어야 된다고 한 건 기억하죠?
그런데 사실 이거 문제 해결하는데 5시간 걸렸습니다 (그것도 주말에...)

이 말은? 검색을 해도 여러분이 원하는 해결법을 찾는 건 정말 하늘에 별따기 라는 겁니다.

StackOverFlow 를 계속 돌아다녀서 운좋게 해결법을 찾는다 해도 족히 몇 시간은 걸릴 겁니다.
심지어 어떤 건 아예 찾지 못해서 결국 포기하는 경우도 빈번할 겁니다.
이건 직접 코딩하다보면 많이 느끼게 되실거라 생각합니다.

물론 GeoTools 프로젝트 email list 를 통해서 이메일을 보내면 되지만...
영어로 보내야 하며, 답장을 꼭 주리라는 보장이 없습니다.

아 그리고 "ChatGPT 쓰면 되지 않냐?"는 분들을 위해 한마디 하자면,
ChatGPT 가 힌트를 주려고 노력은 하지만, 존재하지도 않는 API 를 사용하라고
계속 코드 예시를 보여줍니다. 여러분들도 한번 ChatGPT 사용해서 해보시기 바랍니다.

답변은 잘쓰는데, 코드로 옮기면 IDE 가 빨간줄을 뱉어낼 겁니다.

GeoTools 와 관련된 정보를 인터넷에서 찾아보는 거 자체가 참 힘든 거 같습니다.


셋째. GDAL 에 비해 너무 장황하다

GDAL 은 CLI 에서 쓰기 위해서 최적화되다 보니
어찌보면 당연한 걸 수도 있지만, 그렇다 해도 GeoTools 는 너무나도 장황합니다.

GeoTools 에서 datastore A => datastore B 같이 간단하게 데이터를
옮기는 코드를 좀 더 추상화한 API 를 제공하면 좋지 않을까 싶네요.



2. GeoTools 을 현업에서 사용하는 게 실용적인가?

이 부분은 지극히 제 개인적인 생각입니다.
추후에 제가 GeoTools 를 더 잘 쓰게 되면 달라질 수도 있겠죠?
하지만 지금은? 어림없죠! GDAL 이 최곱니다.

솔직히 제가 GeoTools 를 완벽하게 아는 것도, 쓸 줄 아는 것도 아니지만...
개인적으로 ShapeFile, PostGIS 에 대한 처리는 GDAL 을 쓰는 게 좋은 거 같습니다.

특히 현업에서는 더더욱 유연하고 다양한 기능 구현이 필요하기에,
GDAL 이 좀 더 맞지 않나 싶네요.

그리고 다음 인수인계를 할때도 GDAL 을 통해 구현하면 방대한 GDAL 관련 정보를 인터넷에서
아주 쉽게 찾을 수 있어서 유지보수에도 많은 도움이 될듯합니다.

그렇다면 GDAL 을 Java 에서 사용할 수 있는 방법이 없을까요?
있습니다!



3. Java 와 GDAL 을 사용하기 위한 방법

  • gdal-java
    • gdal 기능을 그대로 java 에서 사용할 수 있는 라이브러리
    • 다만 세팅법이 상당히 난해함. 참고로 본인은 linux 설정은 시도도 못해봄.
    • linux 와 window 간의 세팅 방식이 너무 다름
    • 제가 이전에 Window OS 에 Gdal-java 를 설정하는 방법을 작성한 글은 있습니다.
      맛보기로 써보실 분들은 참고하세요.
  • zt-exec + gdal binary
    • zt-exec 는 java 내부에서 프로세스를 생성해서 명령어를 날릴 수 있는 라이브러리
    • zt-exec 에서 gdal 명령어를 사용하는 방식
    • 좋긴 하다만 적절한 자원 관리를 해야 하고, 프로세스가 정상적으로 끝났는지
      잘 모니터링해야 함.
  • 두 방법의 공통 에러사항: linux 에 gdal 설치 하는 것 자체가 겁나게 어렵습니다.
  • 두번째 방법은 Naver D2 - Java에서 외부 프로세스를 실행할 때 게시물을 한번 읽고 시작하는 것을 추천합니다. 의외로 놓치기 쉬운 부분에 대해서 자세히 알려줍니다.

이번 글은 여기까지만 작성하겠습니다.
어휴 주말에 이거 조사하느라 쉬지도 못했네요



Reference

profile
백엔드를 계속 배우고 있는 개발자입니다 😊

0개의 댓글