각 지자체에서 제공하는 공공시설 위치 CSV를 받아 보고 깜짝 놀랐다. 컬럼 이름은 제각각이고 좌표는 WGS84와 TM 좌표가 뒤섞여 있었다. 사용자 제보로 들어오는 위치는 주소도, 위도·경도도 빠져 있는 경우가 많아 결국 지도에 찍을 수가 없었다. “이걸 누가 다 정리해?” 싶은 순간이었는데, 결국 직접 했다. 비슷한 데이터 정제 지옥을 겪고 있다면 그때의 삽질 기록이 도움이 될지도 모른다.
CSV 업로드 파이프라인
관리자 전용 CSV 업로드 화면에서 스키마를 강제하고, 잘못된 형식이 들어올 여지를 줄였다. 파일 업로드는 Expo DocumentPicker + FileSystem 조합으로 충분했다. 당시에 다뤘던 행 수가 1,700건 수준이라 굳이 서버를 거치지 않아도 됐다. CSV 파싱은 react-native-csv를 사용했다. PapaParse API와 같아서 온보딩 비용이 거의 없었다.
관리자 메뉴에 BatchUpload 스크린을 추가했다. 파일을 고르면 CSV를 파싱하고 LocationInfo 배열로 만들어 넘겼다.
// screens/Developer/BatchUpload.tsx (reference/damta)
const result = readString(string, {
header: true,
dynamicTyping: true,
skipEmptyLines: true,
});
const dataArray = result.data as csvType[];
const locationsInfo = dataArray
.filter((data) => data.latitude && data.longitude)
.map((data) => ({
title: data.title,
memo: data.memo,
address: data.location,
latlng: {
latitude: Number(data.latitude),
longitude: Number(data.longitude),
},
locationDetails: {
placeType: convertStringToPlaceType(data.placeType),
indoor: convertStringToIndoor(data.indoorType),
...(data.closeType && { sealed: convertStringToSealed(data.closeType) }),
},
})) as LocationInfo[];
await batchUploadLocationInfo(locationsInfo, user);
CSV 헤더를 강제로 맞춰야 해서, 각 지자체 데이터는 미리 스크립트로 컬럼명을 통일한 뒤 업로드했다. 콤마가 들어간 메모 때문에 파서가 깨지곤 해서, 문제 파일은 "로 감싸도록 사전에 규칙을 정했다.
문자열을 Enum으로 치환
데이터마다 “실내/실외”, “공공기관/공공시설”처럼 표현이 제각각이라 문자열을 Enum으로 바꾸는 헬퍼를 만들어 썼다. 필터링 로직이나 클러스터 색상 변경 조건이 훨씬 간단해졌다. 말미에 공백이나 특수 문자가 섞인 경우가 많아서 업로드 직전에 트리밍을 의무화했다.
// utils/utils.ts (reference/damta)
export const convertStringToPlaceType = (placeType: string) => {
switch (placeType) {
case "카페":
return PlaceType.cafe;
case "식당":
return PlaceType.restaurant;
case "오락":
return PlaceType.entertainment;
case "기타":
return PlaceType.etc;
case "숙박":
return PlaceType.accommodation;
case "목욕":
return PlaceType.bathhouse;
case "교육":
return PlaceType.education;
case "공공시설":
return PlaceType.publicFacilities;
case "공공기관":
return PlaceType.publicInstitution;
default:
return PlaceType.etc;
}
};
대량 업로드 API와 중복 검사
CSV로 만든 데이터를 Firestore에 쓰는 로직은 batchUploadLocationInfo가 담당했다. uploadAdminLocationInfo는 기존 위치 반경 5m 내에 중복이 있는지 검사한 뒤 active를 true로 설정한다. 공공 데이터만 1,700건이 넘다 보니 비슷한 좌표가 많았고, 이 검사를 빼면 똑같은 위치가 여러 번 등록될 위험이 컸다.
// firebase/locationHandler.ts (reference/damta)
export const batchUploadLocationInfo = async (
locationsInfo: LocationInfo[],
currentUser: FirebaseAuthTypes.User
): Promise<CommonOutput<string[]>> => {
const { ok: isAdmin } = await checkAdmin(currentUser);
if (!isAdmin)
return { ok: false, error: "운영진만 대량 업로드가 가능합니다" };
const promises = locationsInfo.map(async (locationInfo, idx) => {
const { ok, data, error } = await uploadAdminLocationInfo(
locationInfo,
currentUser
);
if (ok && data) return data;
return locationInfo.title + " 업로드 실패";
});
const success = await Promise.all(promises);
return { ok: true, data: success };
};
사용자 제보 자동 지오코딩
사용자가 제보할 때는 지도에서 포인트만 찍도록 했다. 위도·경도를 받은 뒤 바로 latLngtoAddressAPI로 주소를 보강했다. 주소/좌표 변환은 Vworld와 Naver Reverse Geocoding API를 비교한 끝에, 좌표 기반 주문이 많은 우리 서비스에 Naver 응답 구조가 더 적합했다.
const gcUrl =
"https://naveropenapi.apigw.ntruss.com/map-reversegeocode/v2/gc?";
export const latLngtoAddressAPI = async (
latLng: LatLng
): Promise<CommonOutput<string>> => {
const response = await fetch(
`${gcUrl}coords=${latLng.longitude},${latLng.latitude}&sourcecrs=epsg:4326&orders=roadaddr&output=json`,
{
headers: {
"X-NCP-APIGW-API-KEY-ID": clientId,
"X-NCP-APIGW-API-KEY": clientSecret,
},
}
);
const jsonData = await response.json();
const data = jsonData?.results[0];
const address = `${data?.region?.area3.name ?? ""} ${data?.land?.name ?? ""} ${data?.land?.number1 ?? ""} ${data?.land?.number2 ?? ""} ${data?.land?.addition0.value ?? ""}`.replace(" ", " ");
return { ok: true, data: address };
};
공공 데이터 좌표와 사용자가 찍은 좌표가 5~10m 정도 오차가 날 때가 있었다. 반경 5m 중복 검사에 계속 걸려서, 좌표를 소수점 6자리로 반올림해 비교했고 필요한 경우에는 관리자 화면에서 수동 병합 기능을 추가했다.
시행착오
- 어떤 지자체는 위도·경도 대신 지번 주소만 제공했다. Kakao Local API로 좌표를 역으로 구한 뒤 Naver API로 정규화했다.
- CSV 업로드 중 앱이 백그라운드로 내려가면 작업이 끊기는 문제가 있어
keepAwake옵션으로 화면이 꺼지지 않도록 했다. - 글을 정리하면서 GPT에게 용어를 재확인했지만, 실제 데이터 검증과 변환 로직은 모두 직접 테스트했다.
스크립트 기반 CSV 정제 (ect-scripts)
지자체별로 컬럼명과 형식이 다른 공공데이터를 정제할 때는 스크립트로 파이프라인을 구성했다. fetch-non-smoking 폴더의 clean-data.js는 금연구역범위상세에서 반경 숫자를 추출하고, create-final-upload-csv.mjs는 헤더를 통일해 업로드용 CSV를 생성한다.
function extractRadiusFromText(text) {
const numbers = [];
const meterPattern = /(\d+)\s*(?:미터|m|M|m)\s*(?:이내|까지)?/gi;
let match;
while ((match = meterPattern.exec(text)) !== null) {
numbers.push(parseInt(match[1]));
}
const uniqueNumbers = [...new Set(numbers)];
const maxNumber = uniqueNumbers.length ? Math.max(...uniqueNumbers) : 10;
return [5, 10, 30, 50].includes(maxNumber) ? maxNumber : 10;
}
const transformRecord = (values) => {
const name = values[headerIndex['금연구역명']] || '';
const lat = values[headerIndex['위도']] || '';
const lon = values[headerIndex['경도']] || '';
const address = roadAddress?.trim() || jibunAddress?.trim() || '';
return {
name: name.trim(),
address,
lat: lat.trim(),
lon: lon.trim(),
radius: radius.trim() || '10',
category: zonetype.trim() || '금연구역',
features: JSON.stringify(features),
};
};
정제한 공공기관 위치 데이터는 약 1,700건, 사용자 제보는 200건 정도였고 모두 Firestore에 안정적으로 안착했다. 데이터 정제에 쓰던 시간이 하루에 30분 이하로 줄었고, 제보 승인 속도도 크게 빨라졌다. 3년이 지난 지금은 데이터 포맷이나 API 정책이 조금씩 바뀌었지만, “업로드 파이프라인을 먼저 자동화하자”는 교훈은 여전히 유효하다.