[NestJS] save vs upsert 데이터 정제하기

허창원·2023년 11월 2일
0
post-thumbnail

1. 코드

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)
    }
  }
}

2. 데이터 정제 과정

a. openAPI로 데이터 받아오기(fetchData 함수)

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'
}

b. 데이터 변환(transformData 함수)

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 필드에 초기 점수를 할당
}

c. 중복된 데이터 제거(removeDuplicates 함수)

  • 일식 CSV파일 내에 같은 데이터가 존재할 수 있습니다.
  • 중식 CSV파일 내에 같은 데이터가 존재할 수 있습니다.
  • 일식과 중식 CSV파일에 서로 같은 데이터가 존재할 수 있습니다.

위와 같은 raw 데이터를 정제된 데이터로 바꾸고 싶습니다. 그래서 가게 상태가 페업인 가게를 제외하고 도로명 주소, 위도, 경도가 없는 가게도 제외했습니다.(이 가게는 몇 천개 중 몇개 안됩니다.)

결과적으로 collectedData = [{ 정제된 맛집1 }, { 정제된 맛집2 }, … ] 와 같은 배열을 얻을 수 있고

await this.restaurantRepository.save(collectedData)코드를 작성하면 각 객체들이 데이터 베이스에 저장될 것이라고 예상했습니다.

d. 데이터 저장(getRestaurantData 함수)

위에서 작성한 함수들을 모두 getRestaurantData함수에 사용합니다.

2중 for문을 사용했습니다. 1페이지 데이터를 불러와서 totalPages값(for문을 몇번 돌릴지 계산)과 1페이지의 데이터를 저장합니다. 이후, 2페이지 이후의 데이터를 저장합니다.

3. 문제 및 해결

a. 문제 상황 1.

raw 데이터에 중복되는 데이터가 존재하고 정제한 데이터의 nameAddress가 중복되어 데이터베이스에 넣을 때, 에러가 발생했습니다.

b. 문제 상황 2.

중복되는 nameAddress를 제거하는 함수를 작성하고

await this.restaurantRepository.save(data)는 에러가 발생합니다.

중복되는 데이터 없이 save를 하는데 왜 에러가 발생하는 걸까요?

c. 해결방법

await this.restaurantRepository.upsert(data)는 작동됩니다.

d. 에러 발생의 원인

정제된 데이터의 unique key를 name + address로 커스텀하여 사용했습니다. 이때, 일식 CSV에 스시하루가 중복 존재 했고, 중식 CSV에 고산짜장이 중복 존재했고, 일식과 중식 CSV에 홍콩반점이 중복 존재했습니다. (이게 말이 되나… 진짜 하나씩 겹침)

4. save와 upsert

a. 데이터베이스 비어있을 때

Case 1 데이터의 중복이 없음

save와 upsert는 문제없이 데이터베이스에 들어갑니다.

Case 2 데이터의 중복이 있음

한 배열 내에 정제된 데이터 내에 똑같은 unique key가 있으면 데이터베이스에는 unique key가 1개만 존재할 수 있으므로 데이터를 한번에, 동시에 데이터베이스에 저장하면 에러가 발생합니다.

이번 프로젝트의 경우 일식 CSV파일 내에 스시하루처럼 같은 데이터가 존재하여 nameAddress를 unique key로 설정했을 때 데이터베이스에 저장하려고 시도하면 case 2번에 해당되어 에러가 발생했습니다.

b. 데이터베이스 채워져있을 때

Case 1 데이터의 중복이 없음

이번 프로젝트의 경우 일식 CSV파일과 중식 CSV파일 내에 홍콩반점처럼 같은 데이터가 각각의 CSV 파일에 존재하여 일식 데이터를 저장한 후, 중식 데이터를 저장할 때, 이미 존재하는 데이터이므로 save로 저장하면 에러가 발생하나 upsert는 업데이트가 되어 에러가 발생하지 않았습니다.

Case 2 데이터의 중복이 있음

한 배열 내에 정제된 데이터 내에 똑같은 unique key가 있으면 데이터베이스에는 unique key가 1개만 존재할 수 있으므로 데이터를 한번에, 동시에 데이터베이스에 저장하면 에러가 발생합니다.

0개의 댓글