본문 바로가기

개발 프로젝트

아파트 매물 알리미 - NestJs / ReactJs

728x90
 

Preview

이번 포스팅에서는 혼자 심심풀이로 만들어본 서비스를 한번 소개해볼까 합니다.

곧 있음 이사를 앞두고 있어서 부동산 매물을 찾아보던 중 내가 원하는 매물이 있으면 알림(SMS / 카카오톡 등)을 보내주는 서비스가 있으면 좋을 것 같아 개발해 보았습니다.

 

네이버 부동산이나, 호갱노노에는 관심지역에 청약 알림을 설정해 두거나 관심 아파트를 설정해 놓으면 청약 혹은 매물이 생기면 알림을 보내주는 서비스는 있었으나 한 지역 전체를 탐색하는 서비스는 없는 것 같더라고요. 물론 있다고 하더라도 그냥 연습 삼아 만들어보기 좋은 프로젝트 같았습니다.

 

Skills

Backend : NestJs (Typescript)

FrontEnd : React with Mui (Typescript)

Database : file DB - node json db

API : 공공데이터포털, Naver Cloud

 

Prototype

 

SMS 알림

지역과 거래유형, 보증금, 평수, 알림을 받을 연락처 정도의 정보를 사용자에게 받아 조건에 맞는 아파트 매물이 있으면 SMS로 알림을 보내줍니다. 

 

 

 

FrontEnd

제가 포스팅한 글 중에 언젠가 MUI를 소개해드린 적이 있었는데요, 저는 이 MUI 를 알게 되면서 프런트엔드 단의 개발이 참 편리하고 쉬워졌다고 느꼈습니다. 커스터마이징을 하기 위해선 더 전문적인 지식이 필요하지만 정말 간단하게 화면만 그려본다고 한다면 사용하기에 정말 좋은 라이브러리입니다.

https://mui.com/

 

MUI: The React component library you always wanted

MUI provides a simple, customizable, and accessible library of React components. Follow your own design system, or start with Material Design.

mui.com

MUI에서 제공하는 컴포넌트 만으로 구성한 화면입니다.

지역 데이터는 https://www.code.go.kr/stdcode/regCodeL.do 여기에서 다운로드하실 수 있습니다.

그럼 아래와 같은 자료를 얻을 수 있습니다.

//원본
법정동코드	법정동명	폐지여부
1100000000	서울특별시	존재
1111000000	서울특별시 종로구	존재
1111010100	서울특별시 종로구 청운동	존재
1111010200	서울특별시 종로구 신교동	존재
1111010300	서울특별시 종로구 궁정동	존재


//수정본
1100000000 서울특별시 존재
1111000000 서울특별시 종로구 존재
1114000000 서울특별시 중구 존재
1117000000 서울특별시 용산구 존재
1120000000 서울특별시 성동구 존재

저는 구 단위로 지역을 좁힐 것이기 때문에 앞 다섯 자리 코드만 사용하여 수정본 데이터를 사용했습니다.

아래는 수정하는 데 사용한 코드입니다.

//scrap.service.ts
//공공데이터포털에서 저장한 법정동코드를 시/군/구 에 따라 그룹핑해서 저장합니다.
  async setLocalCode(): Promise<void> {
    const filePath = path.join(__dirname, '../../', 'assets', 'code_ko.txt'); //원본데이터
    const data = fs.readFileSync(filePath, 'utf-8');
    const ndata = data.split('\n');
    const result = {};
    for (let i = 0; i < ndata.length; i++) {
      if (i === 0) continue;
      let [code, name, isExist] = ndata[i].split('\t');
      if (isExist) isExist = isExist.replace('\r', '');
      if (code.slice(5) === '00000' && isExist === '존재') {
        result[code] = code + ' ' + name + ' ' + isExist;
      }
    }
    const nResult = Object.values(result).join('\n');
    const nfilePath = path.join(
      __dirname,
      '../../',
      'assets',
      'code_ko_detail.txt',
    );
    fs.writeFile(nfilePath, nResult, (err) => {
      if (err) {
        console.error('파일 저장 중 오류 발생:', err);
        return;
      }
      console.log('파일이 성공적으로 저장되었습니다.');
    });
  }

 

BackEnd

Architecture

3단계 아키텍처(Controller - Service - Repository)로 구성했으며 Database는 node-json-db를 사용해 법정동 코드와 사용자 정보를 json 객체로 저장했습니다.

네 개의 서비스로 분리되어 각 서비스는 맡은 역할만 하는 MSA 구조를 지향합니다.

 

app.controller.ts (Batch)

특정 시간(예제는 10시간으로 설정) 마다 배치를 돌면서 사용자가 등록한 매물 조건에 맞는 매물 정보를 문자로 보내줍니다.

*중복된 조회를 막기 위해 LocalInfos라는 객체에 지역코드(법정동코드)를 키 값으로 데이터를 저장해 둡니다.

*사용자가 등록한 매물 조건에 맞게 필터링 후 SMS를 전송합니다.

//app.controller.ts
@Cron(CronExpression.EVERY_10_HOURS)
  async run() {
    this.logger.log('10시간 마다 정기적으로 실행되는 코드 입니다.');
    const users = await this.userService.getAll();    // (1)
    let LocalInfos: { [key: string]: RTMSDataSvcAptRent_Inf[] } = {}; //(2)

    for (const phoneNumber of Object.keys(users)) {
      const user = users[phoneNumber];
      let localInfo =
        LocalInfos[user.code] ||
        (await this.scrapService.getUserRTMSDataSvcApartRent<RTMSDataSvcAptRent_Inf>(
          user,
        )); //(3)
      LocalInfos[user.code] = localInfo;

      const filteredHomes =
        this.scrapService.getFilteredRTMSData<RTMSDataSvcAptRent_Inf>(
          user,
          localInfo,
        ); // (4)
      if (filteredHomes.length > 0) {
        this.smsService.sendSms(
          phoneNumber,
          filteredHomes
            .map((home) => home.apart[0].toString())
            .join('/')
            .toString(),
        ); //(5)
      }
    }
  }

User의 정보node-json-db에 아래와 같은 객체로 저장되어 있습니다. 휴대전화 번호를 키값으로 법정동코드와 거래유형, 가격, 평수의 정보를 담고 있습니다. (1)에서 user.service.ts - user.repository.ts - users.json을 거쳐 사용자 정보를 조회합니다. 

{"01087265402":{"code":"1117000000","tradeType":{"all":true,"buy":false,"rentForYear":false,"rentForMonth":false},"price":[10,100],"size":3,"phoneNumber":"01087265402"}}

(2)에서 LocalInfosRTMSDataSvcAptRent_Inf[]의 데이터 타입을 값으로 가지고 있습니다.

공공데이터포털 API에서 제공하는 response는 아래와 같습니다.

이 데이터를 우리 서비스에 맞게 사용하기 위해서는 적절하게 변환된 interface 가 필요합니다. RTMSDataSvcAptRent_Inf는 아파트 매물 API의 interface입니다.

export interface RTMSDataSvcAptRent_Inf {
  useOfRenewalRequestRights: string | undefined;// "갱신요구권사용"
  yearOfConstruction: string | undefined;// "건축년도"
  contractClassification: string | undefined;// "계약구분"
  term: string | undefined; // "계약기간"
  size: string | undefined; // <전용면적>
  year: number | undefined;// "년"
  beopjeongDong: string | undefined;// "법정동"
  depositAmount: string | undefined;// "보증금액"
  apart: string | undefined; // <아파트>
  month: number | undefined;// "월"
  monthlyRentAmount: number | undefined;// "월세금액"
  day: number | undefined; // "일"
  previousContractDeposit: number; // "종전계약보증금"
  previousContractMonthlyRent: number;// "종전계약월세
  jibun: number | undefined; //지번
  floor: number | undefined; //층
  code: string;
}

(3)에서 scrap.service.ts를 통해 매물 정보를 가져오는 데 사용한 API는 https://www.data.go.kr/iim/api/selectAPIAcountView.do입니다.

사용방법과 자세한 사항은 링크를 참고해 주시길 바랍니다.

//scrap.service.ts

/**
모든 사용자의 매물을 조회합니다.
@Param user { UserInterface } 
@Return T[]
*/
async getUserRTMSDataSvcApartRent<T>(user: UserInterface): Promise<T[]> {
    const [YYYY, MM, DD] = new Date().toISOString().slice(0, 10).split('-'); //오늘날짜의 년/월
    let localDataFromApi: any = (
      await this.getRTMSData('apart', user.code.slice(0, 5), YYYY + MM, 0)
    ).response.body[0];
    if (localDataFromApi.totalCount == 0) return []; //데이터가 없는 경우
    localDataFromApi = localDataFromApi.items[0].item;
    localDataFromApi = localDataFromApi.map((item) => {
      return {
        code: user.code,
        useOfRenewalRequestRights: item['갱신요구권사용'],
        contractClassification: item['계약구분'],
        yearOfConstruction: item['건축년도'],
        term: item['계약기간'],
        size: item['전용면적'],
        year: item['년'],
        beopjeongDong: item['법정동'],
        depositAmount: item['보증금액'],
        apart: item['아파트'],
        month: item['월'],
        monthlyRentAmount: item['월세금액'],
        day: item['일'],
        jibun: item['지번'],
        floor: item['층'],
        previousContractDeposit: item['종전계약보증금'],
        previousContractMonthlyRent: item['종전계약월세'],
      };
    });
    return localDataFromApi;
  }
  
  /**
국토교통부 단독/다가구 | 아파트 전월세 자료. API조회 실패 시 최대 5번(MAX_TRY_COUNT) 재조회합니다.
@Param type { villa | apart }, CODE { string }, YYYYMM { string }, TRY_COUNT {number}  
@Return {any}
  */
  async getRTMSData(
    type: string,
    CODE: string,
    YYYYMM: string,
    TRY_COUNT: number,
  ): Promise<any> {
    const url = `http://openapi.molit.go.kr:8081/OpenAPI_ToolInstallPackage/service/rest/RTMSOBJSvc/${
      type === 'villa' ? 'getRTMSDataSvcSHRent' : 'getRTMSDataSvcAptRent'
    }`;
    let queryParams =
      '?' +
      encodeURIComponent('serviceKey') +
      '=' +
      `${this.configService.get('ENCODED_API_KEY')}`; /* Service Key*/
    queryParams +=
      '&' +
      encodeURIComponent('LAWD_CD') +
      '=' +
      encodeURIComponent(CODE); /* */
    queryParams +=
      '&' +
      encodeURIComponent('DEAL_YMD') +
      '=' +
      encodeURIComponent(YYYYMM); /* */

    return new Promise((res, rej) => {
      request(
        {
          url: url + queryParams,
          method: 'GET',
        },
        (error, response, body) => {
          if (error) rej(error);
          else {
            parseStringPromise(body).then((result) => {
              if (
                result.response.body[0].totalCount[0] === '0' &&
                TRY_COUNT < this.MAX_TRY_COUNT
              ) {
                res(
                  this.getRTMSData(
                    'villa',
                    CODE,
                    getOneMonthBefore(YYYYMM),
                    TRY_COUNT + 1,
                  ),
                );
              } else res(result);
            });
          }
        },
      );
    });
  }

(4)에선 조회된 매물 중에서 사용자가 등록한 조건에 맞는 매물만 필터링합니다.

*우선 위 예제에서는 아파트 전/월세 API 자료만 사용했기 때문에 거래 유형 필터링은 제외했습니다.

//scrap.service.ts

/**
   * 사용자 정보에 맞는 매물을 반환합니다.
   * @param user 
   * @param homes 
   * @returns homes
*/
getFilteredRTMSData<T extends RTMSDataSvcAptRent_Inf>(
    user: UserInterface,
    homes: T[],
  ) {
    homes = homes.filter(
      (home: T) =>
        Number(home.size[0]) < user.size * 10 + 10 &&
        Number(home.depositAmount[0].replace(',', '')) >=
          user.price[0] * 1000 &&
        Number(home.depositAmount[0].replace(',', '')) <= user.price[1] * 1000,
    );
    return homes;
  }

이제 매물을 다 찾았으니 사용자에게 알림을 보내야겠죠.

(5)에서는 사용자에게 매물 정보를 문자로 보냅니다. 문자(SMS) 서비스는 네이버 클라우드의 sens 서비스를 이용했습니다.

https://www.ncloud.com/product/applicationService/sens

 

NAVER CLOUD PLATFORM

cloud computing services for corporations, IaaS, PaaS, SaaS, with Global region and Security Technology Certification

www.ncloud.com

로그인 후에 사용신청 하시면 하루 50건 정도의 문자를 무료로 이용하실 수 있습니다.

*API 조회하는데 저는 axios 를 사용했고 hash 값 암호화를 위해 CryptoJS 라이브러리를 사용했습니다.

//sms.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import * as CryptoJS from 'crypto-js';

@Injectable()
export class SmsService {
  constructor(private configService: ConfigService) {}
  async sendSms(to: string, content: string) {
    const serviceId = this.configService.get('SENS_SERVICE_ID'); //API KEY
    const url = `https://sens.apigw.ntruss.com/sms/v2/services/${serviceId}/messages`;
    const date = Date.now().toString();

    axios
      .post(
        url,
        {
          type: 'SMS',
          contentType: 'COMM',
          countryCode: '82',
          from: '01087265402',
          content: content,
          messages: [
            {
              to: to,
            },
          ],
        },
        {
          headers: {
            'Content-Type': 'application/json; charset=utf-8',
            'x-ncp-apigw-timestamp': date,
            'x-ncp-iam-access-key': '7xA30SkAVKnoWzpU0HLr',
            'x-ncp-apigw-signature-v2': this.makeSignature(),
          },
        },
      )
      .then((res) => {
        if (res.data.statusCode == '202')
          console.log(
            `${to} 에게 ${content.length} 건을 정상적으로 전송했습니다.`,
          );
      });
  }
  makeSignature() {
    const url2 =
      '/sms/v2/services/ncp:sms:kr:288109456790:land-alarmy/messages';
    const space = ' '; // one space
    const newLine = '\n'; // new line
    const method = 'POST'; // method
    const timestamp = Date.now().toString(); // current timestamp (epoch)
    const accessKey = '7xA30SkAVKnoWzpU0HLr'; // access key id (from portal or Sub Account)
    const secretKey = 'ddG0h1hkOp4wAUe0uGNRD3EquaYRLbsFqzbnrUBr'; // secret key (from portal or Sub Account)

    const hmac = CryptoJS.algo.HMAC.create(CryptoJS.algo.SHA256, secretKey);
    hmac.update(method);
    hmac.update(space);
    hmac.update(url2);
    hmac.update(newLine);
    hmac.update(timestamp);
    hmac.update(newLine);
    hmac.update(accessKey);

    const hash = hmac.finalize();
    return hash.toString(CryptoJS.enc.Base64);
  }
}

 

마치며

이렇게 NestJs 와 ReactJS를 사용해 간단한 부동산 매물 알림 서비스를 만들어보았습니다.

간단하지만 아키텍처와 내부 로직을 최대한 간편하게 짜기 위해선 생각보다 더 많은 노력이 들었습니다.

이 내용을 참고하셔서 더 멋진 소스와 서비스를 만들어주세요. 긴 글 읽어주셔서 감사합니다.