Preview
이번 포스팅에서는 혼자 심심풀이로 만들어본 서비스를 한번 소개해볼까 합니다.
곧 있음 이사를 앞두고 있어서 부동산 매물을 찾아보던 중 내가 원하는 매물이 있으면 알림(SMS / 카카오톡 등)을 보내주는 서비스가 있으면 좋을 것 같아 개발해 보았습니다.
네이버 부동산이나, 호갱노노에는 관심지역에 청약 알림을 설정해 두거나 관심 아파트를 설정해 놓으면 청약 혹은 매물이 생기면 알림을 보내주는 서비스는 있었으나 한 지역 전체를 탐색하는 서비스는 없는 것 같더라고요. 물론 있다고 하더라도 그냥 연습 삼아 만들어보기 좋은 프로젝트 같았습니다.
Skills
Backend : NestJs (Typescript)
FrontEnd : React with Mui (Typescript)
Database : file DB - node json db
API : 공공데이터포털, Naver Cloud
Prototype
지역과 거래유형, 보증금, 평수, 알림을 받을 연락처 정도의 정보를 사용자에게 받아 조건에 맞는 아파트 매물이 있으면 SMS로 알림을 보내줍니다.
FrontEnd
제가 포스팅한 글 중에 언젠가 MUI
를 소개해드린 적이 있었는데요, 저는 이 MUI 를 알게 되면서 프런트엔드 단의 개발이 참 편리하고 쉬워졌다고 느꼈습니다. 커스터마이징을 하기 위해선 더 전문적인 지식이 필요하지만 정말 간단하게 화면만 그려본다고 한다면 사용하기에 정말 좋은 라이브러리입니다.
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)에서 LocalInfos
는 RTMSDataSvcAptRent_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
로그인 후에 사용신청 하시면 하루 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를 사용해 간단한 부동산 매물 알림 서비스를 만들어보았습니다.
간단하지만 아키텍처와 내부 로직을 최대한 간편하게 짜기 위해선 생각보다 더 많은 노력이 들었습니다.
이 내용을 참고하셔서 더 멋진 소스와 서비스를 만들어주세요. 긴 글 읽어주셔서 감사합니다.
'개발 프로젝트' 카테고리의 다른 글
[Programmers] 숫자 카드 나누기 -Javascript (1) | 2023.10.28 |
---|---|
Chrome Extension - Html Tag wrapper (0) | 2023.10.27 |
Push 서버 관리자 사이트 개발 (0) | 2023.07.06 |
NodeJS Chat Service (0) | 2023.04.21 |
[SwitfUI] 깨워줘요 앱 개발 (0) | 2023.03.07 |