import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { HttpService } from '@nestjs/axios'
import { lastValueFrom } from 'rxjs'
import { RestaurantRepository } from './restaurant.repository'
import { Cron } from '@nestjs/schedule'
@Injectable()
export class ScheduleService {
private readonly logger = new Logger(ScheduleService.name)
constructor(
private configService: ConfigService,
private httpService: HttpService,
private restaurantRepository: RestaurantRepository,
) {}
@Cron('0 1 * * 5')
async sendRequest() {
this.logger.debug('Called every Friday at 1 AM')
try {
await this.getRestaurantData()
this.logger.log('Data updated successfully')
} catch (error) {
this.logger.error(`Failed to update data : ${error}`)
}
}
private async fetchData(
food: string,
index: number,
): Promise<{ data: any[]; totalCount: number }> {
// 공공데이터 api 호출, data와 총 row수 반환
const key = await this.configService.get<string>('schedule.apiKey')
const pSize = 1000
const apiUrl = `https://openapi.gg.go.kr/Genrestrt${food}?KEY=${key}&Type=json&pIndex=${index}&pSize=${pSize}`
const response = await lastValueFrom(this.httpService.get(apiUrl))
const GenrestrtFood = 'Genrestrt' + food
const data = response.data[GenrestrtFood][1].row
const totalCount = response.data[GenrestrtFood][0].head[0].list_total_count
return { data, totalCount }
}
private transformData(data: any[]): any[] {
// 데이터 전처리
return data
.filter(
(data) =>
data.REFINE_ROADNM_ADDR !== null &&
data.REFINE_WGS84_LAT !== null &&
data.REFINE_WGS84_LOGT !== null,
)
.map((data) => ({
nameAddress:
`${data.BIZPLC_NM}${data.REFINE_ROADNM_ADDR}${data.SANITTN_BIZCOND_NM}`.replace(
/\s/g,
'',
), // nameAddress 필드에 BIZPLC_NM 값과 띄어쓰기를 제거한 REFINE_ROADNM_ADDR 값을 조합하여 할당
countyName: data.SIGUN_NM || '미확인', // countyName 필드에 SIGUN_NM 값을 할당
name: data.BIZPLC_NM || '미확인', // name 필드에 BIZPLC_NM 값을 할당
type: data.SANITTN_BIZCOND_NM || '미확인', // type 필드에 SANITTN_BIZCOND_NM 값을 할당
address: data.REFINE_ROADNM_ADDR, // address 필드에 REFINE_ROADNM_ADDR 값을 할당
status: data.BSN_STATE_NM || '미확인', // status 필드에 BSN_STATE_NM 값을 할당. 없을 시 미확인
lat: data.REFINE_WGS84_LAT, // lat 필드에 REFINE_WGS84_LAT 값을 할당
lon: data.REFINE_WGS84_LOGT, // lon 필드에 REFINE_WGS84_LOGT 값을 할당
score: 0, // score 필드에 초기 점수를 할당
}))
}
private removeDuplicates(data: any[]): any[] {
// 중복 데이터 삭제
return Array.from(new Set(data.map((item) => item.nameAddress))).map(
(nameAddress) => {
return data.find((item) => item.nameAddress === nameAddress)
},
)
}
async getRestaurantData() {
try {
const foodList = ['jpnfood', 'chifood', 'lunch']
for (const food of foodList) {
const { data: initialData, totalCount } = await this.fetchData(food, 1) // 초기 1000개의 데이터 호출 (totalCount 반환을 위해 분리)
const totalPages = Math.ceil(totalCount / 1000)
let data = this.transformData(initialData)
let uniqueData = this.removeDuplicates(data) // null값 처리 + 중복 제거 데이터
await this.restaurantRepository.upsert(uniqueData, ['nameAddress'])
for (let index = 2; index <= totalPages; index++) {
const fetchedData = await this.fetchData(food, index)
data = this.transformData(fetchedData.data)
console.log('중복 제거 전 데이터 수', data.length)
uniqueData = this.removeDuplicates(data)
console.log('중복 제거 후 데이터 수', uniqueData.length)
await this.restaurantRepository.upsert(uniqueData, ['nameAddress'])
}
}
} catch (error) {
console.error(error)
}
}
}
openAPI의 key를 발급받고 요구하는 형식으로 요청하면 아래와 같은 데이터를 얻을 수 있습니다.
경기도 공공데이터는 1회 요청하여 1000개의 데이터를 얻을 수 있었습니다.
ex) https://openapi.gg.go.kr/Genrestrtlunch?key={key값 입력}&type=json&pIndex=1&pSize=1000
{
SIGUN_NM: '부천시 ',
SIGUN_CD: null,
BIZPLC_NM: '진참치',
LICENSG_DE: '20130809',
BSN_STATE_NM: '영업',
CLSBIZ_DE: null,
LOCPLC_AR: null,
GRAD_FACLT_DIV_NM: null,
MALE_ENFLPSN_CNT: null,
YY: null,
MULTI_USE_BIZESTBL_YN: null,
GRAD_DIV_NM: null,
TOT_FACLT_SCALE: null,
FEMALE_ENFLPSN_CNT: null,
BSNSITE_CIRCUMFR_DIV_NM: null,
SANITTN_INDUTYPE_NM: null,
SANITTN_BIZCOND_NM: '일식',
TOT_EMPLY_CNT: null,
REFINE_LOTNO_ADDR: '경기도 부천시 상동 545-14번지 로데오타운 101호',
REFINE_ROADNM_ADDR: '경기도 부천시 소향로37번길 13-20 (상동, 로데오타운 101호)',
REFINE_ZIP_CD: '14544',
REFINE_WGS84_LOGT: '126.7508951886',
REFINE_WGS84_LAT: '37.5044450942'
}
{
SIGUN_NM: '시흥시 ',
SIGUN_CD: null,
BIZPLC_NM: '변사또화로구이',
LICENSG_DE: '20000202',
BSN_STATE_NM: '영업',
CLSBIZ_DE: null,
LOCPLC_AR: null,
GRAD_FACLT_DIV_NM: null,
MALE_ENFLPSN_CNT: 0,
YY: null,
MULTI_USE_BIZESTBL_YN: null,
GRAD_DIV_NM: null,
TOT_FACLT_SCALE: null,
FEMALE_ENFLPSN_CNT: 0,
BSNSITE_CIRCUMFR_DIV_NM: '아파트지역',
SANITTN_INDUTYPE_NM: null,
SANITTN_BIZCOND_NM: '일식',
TOT_EMPLY_CNT: 0,
REFINE_LOTNO_ADDR: '경기도 시흥시 정왕동 1855-12',
REFINE_ROADNM_ADDR: '경기도 시흥시 중심상가로 46 (정왕동)',
REFINE_ZIP_CD: '15040',
REFINE_WGS84_LOGT: '126.7230643022',
REFINE_WGS84_LAT: '37.3523057472'
}
filter함수를 이용해 음식점 도로명, 위도, 경도가 null인 것은 제외했습니다.
map 함수를 이용해 raw데이터에서 필요한 데이터를 추출하고 아래와 같이 정제했습니다.
{
nameAddress:
`${data.BIZPLC_NM}${data.REFINE_ROADNM_ADDR}${data.SANITTN_BIZCOND_NM}`.replace(
/\s/g,
'',
), // nameAddress 필드에 BIZPLC_NM 값과 띄어쓰기를 제거한 REFINE_ROADNM_ADDR 값을 조합하여 할당
countyName: data.SIGUN_NM || '미확인', // countyName 필드에 SIGUN_NM 값을 할당
name: data.BIZPLC_NM || '미확인', // name 필드에 BIZPLC_NM 값을 할당
type: data.SANITTN_BIZCOND_NM || '미확인', // type 필드에 SANITTN_BIZCOND_NM 값을 할당
address: data.REFINE_ROADNM_ADDR, // address 필드에 REFINE_ROADNM_ADDR 값을 할당
status: data.BSN_STATE_NM || '미확인', // status 필드에 BSN_STATE_NM 값을 할당. 없을 시 미확인
lat: data.REFINE_WGS84_LAT, // lat 필드에 REFINE_WGS84_LAT 값을 할당
lon: data.REFINE_WGS84_LOGT, // lon 필드에 REFINE_WGS84_LOGT 값을 할당
score: 0, // score 필드에 초기 점수를 할당
}
위와 같은 raw 데이터를 정제된 데이터로 바꾸고 싶습니다. 그래서 가게 상태가 페업인 가게를 제외하고 도로명 주소, 위도, 경도가 없는 가게도 제외했습니다.(이 가게는 몇 천개 중 몇개 안됩니다.)
결과적으로 collectedData = [{ 정제된 맛집1 }, { 정제된 맛집2 }, … ] 와 같은 배열을 얻을 수 있고
await this.restaurantRepository.save(collectedData)
코드를 작성하면 각 객체들이 데이터 베이스에 저장될 것이라고 예상했습니다.
위에서 작성한 함수들을 모두 getRestaurantData함수에 사용합니다.
2중 for문을 사용했습니다. 1페이지 데이터를 불러와서 totalPages값(for문을 몇번 돌릴지 계산)과 1페이지의 데이터를 저장합니다. 이후, 2페이지 이후의 데이터를 저장합니다.
raw 데이터에 중복되는 데이터가 존재하고 정제한 데이터의 nameAddress가 중복되어 데이터베이스에 넣을 때, 에러가 발생했습니다.
중복되는 nameAddress를 제거하는 함수를 작성하고
await this.restaurantRepository.save(data)
는 에러가 발생합니다.
중복되는 데이터 없이 save를 하는데 왜 에러가 발생하는 걸까요?
await this.restaurantRepository.upsert(data)
는 작동됩니다.
정제된 데이터의 unique key를 name + address로 커스텀하여 사용했습니다. 이때, 일식 CSV에 스시하루가 중복 존재 했고, 중식 CSV에 고산짜장이 중복 존재했고, 일식과 중식 CSV에 홍콩반점이 중복 존재했습니다. (이게 말이 되나… 진짜 하나씩 겹침)
save와 upsert는 문제없이 데이터베이스에 들어갑니다.
한 배열 내에 정제된 데이터 내에 똑같은 unique key가 있으면 데이터베이스에는 unique key가 1개만 존재할 수 있으므로 데이터를 한번에, 동시에 데이터베이스에 저장하면 에러가 발생합니다.
이번 프로젝트의 경우 일식 CSV파일 내에 스시하루처럼 같은 데이터가 존재하여 nameAddress를 unique key로 설정했을 때 데이터베이스에 저장하려고 시도하면 case 2번에 해당되어 에러가 발생했습니다.
이번 프로젝트의 경우 일식 CSV파일과 중식 CSV파일 내에 홍콩반점처럼 같은 데이터가 각각의 CSV 파일에 존재하여 일식 데이터를 저장한 후, 중식 데이터를 저장할 때, 이미 존재하는 데이터이므로 save로 저장하면 에러가 발생하나 upsert는 업데이트가 되어 에러가 발생하지 않았습니다.
한 배열 내에 정제된 데이터 내에 똑같은 unique key가 있으면 데이터베이스에는 unique key가 1개만 존재할 수 있으므로 데이터를 한번에, 동시에 데이터베이스에 저장하면 에러가 발생합니다.