2025-1 캡스톤디자인과창업프로젝트 스타트 6팀입니다.
전통주에 관심이 많지만 관련 지식이 부족한 입문자들을 위해, 사용자의 주류 선호도를 학습하고 위치·시간 등의 맥락을 반영하여,
개인 맞춤형 전통주를 추천해주는 AI 기반의 전통주 큐레이션 서비스를 기획했습니다.
웹페이지 크롤링
네이버 전통주 지식백과의 650여가지 전통주 정보(상품명, 주종, 도수, 용량, 가격, 원재료, 제조사, 어울리는 음식)를 크롤링하여 csv 파일로 저장했습니다.
웹페이지 스크래핑 라이브러리인 cheerio.js를 사용합니다.
Crawling
async function runDemo() {
const mainListPageBaseUrl = 'https://terms.naver.com/list.naver?cid=58636&categoryId=58636&so=st3.asc&viewType=&categoryType=';
const newCsvFilePath = path.join(__dirname, 'merged_traditional_alcohol.csv');
const originalCsvFilePath = path.join(__dirname, 'traditional_liquor_df_final.csv');
const finalHeaders = [
'', 'index', '제품명', '단맛', '신맛', '청량감', '바디감', '도수%', '탄산', '주종', 'keyword',
'용량', '가격', '제조사', '원재료',
'어울리는음식', '사진URL', 'detailPageUrl', 'docId'
];
// 기존 CSV 로드
if (fs.existsSync(originalCsvFilePath)) {
console.log(`\n기존 CSV 파일 (${originalCsvFilePath}) 로드 중...`);
const fileContent = fs.readFileSync(originalCsvFilePath, 'utf8');
existingAlcoholData = await new Promise((resolve, reject) => {
parse(fileContent, { columns: true, skip_empty_lines: true }, (err, records) => {
if (err) return reject(err);
const filled = records.map(row => {
finalHeaders.forEach(h => { if (!Object.hasOwn(row, h)) row[h] = ''; });
return row;
});
resolve(filled);
});
});
console.log(`기존 데이터 ${existingAlcoholData.length}개 로드 완료.`);
// 모든 페이지 크롤링
const allCollectedDetailUrls = await crawlAllPagesUrls(mainListPageBaseUrl);
console.log(`\n총 ${allCollectedDetailUrls.length}개의 고유한 상세 페이지 URL 수집 완료.`);
const newlyCrawledData = [];
console.log(`\n총 ${allCollectedDetailUrls.length}개의 상세 페이지 데이터 크롤링 진행. (이미지는 AWS S3에 직접 업로드)`);
for (const url of allCollectedDetailUrls) {
const data = await crawlAlcoholDetails(url);
if (data) {
newlyCrawledData.push(data);
}
}
Result
index,제품명,단맛,신맛,청량감,바디감,도수%,탄산,주종,keyword,용량,가격,제조사,원재료,어울리는음식,사진URL,detailPageUrl,docId
0,1000억 막걸리 프리바이오,3.0,4.0,3.0,3,,0.0,,단맛 신맛 청량감 바디감 탁주 쌀 밀,,,,,여름에는 시원하게 생맥주 잔에 마셔도 전혀 어색하지 않은 청량감도 가지고 있다.,https://capstone-liquor-images.s3.ap-southeast-2.amazonaws.com/images/6040729-1000%EC%96%B5%20%EB%A7%89%EA%B1%B8%EB%A6%AC%20%ED%94%84%EB%A6%AC%EB%B0%94%EC%9D%B4%EC%98%A4_main.png,https://terms.naver.com/entry.naver?docId=6040729&cid=58637&categoryId=58652,6040729
1,1000억 유산균막걸리,3.0,5.0,5.0,3,,0.0,,단맛 신맛 청량감 바디감 탁주 쌀 밀 저도수,,,,,운동 전후에도 마시면 피로감이 풀릴 듯 한 감식초와 같은 식감도 느껴진다.,https://capstone-liquor-images.s3.ap-southeast-2.amazonaws.com/images/6040728-1000%EC%96%B5%20%EC%9C%A0%EC%82%B0%EA%B7%A0%EB%A7%89%EA%B1%B8%EB%A6%AC_main.png,https://terms.naver.com/entry.naver?docId=6040728&cid=58637&categoryId=58652,6040728
2,1932 새싹땅콩 햅쌀막걸리,2.0,2.0,3.0,5,,0.0,,청량감 바디감 탁주 쌀 땅콩,,,,,케이크 등 디저트 음식과 잘 어울린다.,https://capstone-liquor-images.s3.ap-southeast-2.amazonaws.com/images/3595217-1932%20%EC%83%88%EC%8B%B9%EB%95%85%EC%BD%A9%20%ED%96%85%EC%8C%80%EB%A7%89%EA%B1%B8%EB%A6%AC_main.jpg,https://terms.naver.com/entry.naver?docId=3595217&cid=58637&categoryId=58651,3595217
3,1932 새싹땅콩 흑미막걸리,3.0,1.0,1.0,4,,0.0,,단맛 바디감 탁주 쌀 땅콩,,,,,돼지 갈비 등 구은 고기와 잘 어울린다.,https://capstone-liquor-images.s3.ap-southeast-2.amazonaws.com/images/3595216-1932%20%EC%83%88%EC%8B%B9%EB%95%85%EC%BD%A9%20%ED%9D%91%EB%AF%B8%EB%A7%89%EA%B1%B8%EB%A6%AC_main.jpg,https://terms.naver.com/entry.naver?docId=3595216&cid=58637&categoryId=58651,3595216
기상청 단기예보 조회서비스(2.0) API 호출
날씨에 맞는 전통주 추천 기능을 구현하기 위해 기상청 단기예보 조회서비스 API를 활용했습니다.
API 사용을 위해 공공데이터포털에서 사용 신청을 하고 키를 발급받습니다.
날씨 정보 요청에 앞서, 위도 및 경도를 기상청 격자 좌표로 로컬에서 변환해야 합니다.
좌표 변환 코드는 기상청 단기예보 조회서비스 오픈 API 활용가이드 워드 문서를 참고했습니다. API 활용 신청 후 다운받을 수 있습니다.
// 위경도 좌표를 기상청 격자 좌표(nx, ny)로 변환하는 함수
const RE = 6371.00877;
const GRID = 5.0;
const SLAT1 = 30.0;
const SLAT2 = 60.0;
const OLON = 126.0;
const OLAT = 38.0;
const XO = 43;
const YO = 136;
const DEGRAD = Math.PI / 180.0;
export function convertLatLngToGrid(lat, lon) {
const re = RE / GRID;
const slat1 = SLAT1 * DEGRAD;
const slat2 = SLAT2 * DEGRAD;
const olon = OLON * DEGRAD;
const olat = OLAT * DEGRAD;
let sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5);
sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn);
let sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5);
sf = Math.pow(sf, sn) * Math.cos(slat1) / sn;
let ro = Math.tan(Math.PI * 0.25 + olat * 0.5);
ro = re * sf / Math.pow(ro, sn);
let ra = re * sf / Math.pow(Math.tan(Math.PI * 0.25 + (lat) * DEGRAD * 0.5), sn);
let theta = (lon) * DEGRAD - olon;
if (theta > Math.PI) theta -= 2.0 * Math.PI;
if (theta < -Math.PI) theta += 2.0 * Math.PI;
theta *= sn;
const nx = Math.floor(ra * Math.sin(theta) + XO + 0.5);
const ny = Math.floor(ro - ra * Math.cos(theta) + YO + 0.5);
return { nx: nx, ny: ny };
}
기상청에서 제공하는 API 서비스에는 초단기실황, 초단기예보, 단기예보, 예보버전 총 4가지가 있습니다.
저희 팀은 날씨에 맞는 전통주를 추천하는 기능을 구현하기 위해서, 그때그때의 날씨보다는 예보가 더 적합하다고 생각하여 '초단기예보'를 선택했습니다.
초단기예보 정보는 30분 단위로 발표되며, 매시각 45분 이후 호출할 수 있습니다.
상세 구현 과정
1. 현재 시간 정보를 바탕으로 어떤 발표 정보를 요청할지 결정합니다.
function getBaseDateTime() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
let currentHour = now.getHours();
let currentMinute = now.getMinutes();
let baseTimeHour = currentHour;
const baseTimeMinute = 30; // 초단기예보는 30분 단위로 발표
let targetDate = new Date(now);
// 예보는 매시 30분 발표 & 매시 45분부터 호출 가능
// 현재 분이 45분 미만이면 이전 시간대의 30분 발표 데이터 사용
if (currentMinute < 45) {
baseTimeHour = currentHour - 1;
if (baseTimeHour < 0) {
baseTimeHour = 23;
targetDate.setDate(targetDate.getDate() - 1);
}
}
const baseDate = `${targetDate.getFullYear()}${String(targetDate.getMonth() + 1).padStart(2, '0')}${String(targetDate.getDate()).padStart(2, '0')}`;
const baseTime = String(baseTimeHour).padStart(2, '0') + String(baseTimeMinute);
return { base_date: baseDate, base_time: baseTime };
}
2. 기상청 격자 좌표로 변환한 위치 정보, 시간 정보, API 키, 데이터 타입 등을 포함한 HTTP GET 요청을 기상청 API로 전송합니다.
초단기예보에서 요청할 수 있는 정보와 코드값은 다음과 같습니다.

이 중에서 기온, 1시간 강수량, 습도, 강수형태, 하늘상태, 풍속, 풍향 정보를 요청합니다.
(이후 추천에 사용하는 정보는 기온, 강수형태, 하늘상태 3가지이나, 추후 추천 로직을 강화하여 여러 날씨 정보를 활용할 수 있도록 구현할 예정입니다.)
try {
// 기상청 API에 HTTP GET 요청 보내기
const response = await axios.get(apiUrl);
const apiData = response.data;
// API 응답의 결과 코드 확인
if (apiData && apiData.response && apiData.response.header && apiData.response.header.resultCode === '00') {
const items = apiData.response.body.items.item;
const parsedWeather = {};
// 현재 시점에 가장 가까운 예측 데이터(fcstDate, fcstTime)를 선택하여 파싱
const now = new Date();
const targetFcstHour = now.getHours();
const targetFcstTime = String(targetFcstHour).padStart(2, '0') + '00';
const targetFcstDate = base_date;
// 목표 예측 시간의 데이터 파싱
items.forEach(item => {
if (item.fcstDate === targetFcstDate && item.fcstTime === targetFcstTime) {
switch (item.category) {
case 'T1H': parsedWeather.temperature = parseFloat(item.fcstValue); break;
case 'RN1':
parsedWeather.rainAmount = item.fcstValue === '강수없음' ? 0 : parseFloat(item.fcstValue);
break;
case 'REH': parsedWeather.humidity = parseFloat(item.fcstValue); break;
case 'PTY': parsedWeather.precipitationType = item.fcstValue; break;
case 'SKY': parsedWeather.skyStatus = item.fcstValue; break;
case 'WSD': parsedWeather.windSpeed = parseFloat(item.fcstValue); break;
case 'VEC': parsedWeather.windDirection = parseFloat(item.fcstValue); break;
}
}
});
// 파싱된 코드값을 설명으로 변환
parsedWeather.precipitationTypeDescription = getPrecipitationTypeDescription(parsedWeather.precipitationType);
parsedWeather.skyStatusDescription = getSkyStatusDescription(parsedWeather.skyStatus);
// 결과 반환
return {
location: { latitude: lat, longitude: lon, nx, ny },
baseTime: { base_date, base_time },
weather: parsedWeather
};
3. Recombee API를 사용해, 사용자 선호도와 날씨 정보를 결합하여 전통주를 추천합니다.
현재 기술 시연 단계에서는 아래와 같은 단순한 추천 기준을 사용했습니다. (이후 구체화 예정)
| 기온 | 25도 초과: 더운 날씨 | 청량감⬆️, 도수⬇️ |
| 10도 이상 25도 이하: 적당한 날씨 | 사용자 일반 선호도 기반 추천 | |
| 10도 미만: 추운 날씨 | 도수⬆️, 단맛⬆️, 청량감⬇️ | |
| 강수형태 | 비, 눈 | 탁주 |
| 하늘상태 | 맑음 | 청량감⬆️ |
// 날씨 데이터에 따라 선호도 조정
let adjustedSweetness = sweetness;
let adjustedSparkling = sparkling;
let adjustedAbv = abv;
let adjustedType = type;
let curationMessage = "추천 이유: ";
if (weatherData.temperature) {
if (weatherData.temperature > 25) { // 25도 초과: 더운 날
adjustedSparkling = Math.min(5, sparkling + 1); // 청량감 증가
adjustedAbv = Math.max(3, abv - 3); // 도수 낮춤
adjustedSweetness = Math.min(5, sweetness + 1); // 단맛 증가 (시원한 과일맛)
curationMessage += `날씨가 더워서 시원하고 청량감 있는 낮은 도수의 술을 추천합니다.`;
} else if (weatherData.temperature < 10) { // 10도 미만: 추운 날
adjustedAbv = Math.min(50, abv + 5); // 도수 높임
adjustedSweetness = Math.min(5, sweetness + 1); // 따뜻한 느낌의 단맛 증가
adjustedSparkling = Math.max(1, sparkling - 1); // 청량감 감소 (묵직한 느낌)
curationMessage += `날씨가 쌀쌀하여 몸을 따뜻하게 해 줄 높은 도수의 술을 추천합니다.`;
} else {
curationMessage += `적당한 날씨로 사용자님의 일반 선호도를 기반으로 추천합니다.`;
}
}
if (weatherData.precipitationTypeDescription === '비' || weatherData.precipitationTypeDescription === '비/눈') {
adjustedType = "탁주"; // 비 오는 날은 막걸리!
curationMessage += ` 비가 오니 부침개와 잘 어울리는 탁주를 추천합니다.`;
} else if (weatherData.skyStatusDescription === '맑음') {
adjustedSparkling = Math.min(5, sparkling + 2); // 맑은 날 청량감 강조
curationMessage += ` 맑은 날씨에는 깔끔하고 청량감 있는 술이 좋습니다.`;
}
// Recombee 필터 계산
const filter = `
'sweetness' >= ${adjustedSweetness - 1.0} and 'sweetness' <= ${adjustedSweetness + 1.0} and
'sourness' >= ${sourness - 1.0} and 'sourness' <= ${sourness + 1.0} and
'sparkling' >= ${adjustedSparkling - 1.0} and 'sparkling' <= ${adjustedSparkling + 1.0} and
'body' >= ${body - 1.0} and 'body' <= ${body + 1.0} and
'abv' >= ${Math.max(0, adjustedAbv - 5)} and 'abv' <= ${adjustedAbv + 5} and
'type' == "${adjustedType}" and
'price' >= ${minPrice} and 'price' <= ${maxPrice}
`;
4. 결과
응답받은 초단기예보 정보와, 이를 바탕으로 Recombee API로부터 반환받은 추천 리스트를 차례로 JSON 형태로 출력합니다.
{
"success": true,
"weather": {
"precipitationType": "0",
"rainAmount": 0,
"skyStatus": "3",
"temperature": 21,
"humidity": 80,
"windDirection": 259,
"windSpeed": 1,
"precipitationTypeDescription": "없음",
"skyStatusDescription": "구름많음"
},
"recommendations": [
{
"name": "g12골디락스",
"reason": "추천 이유: 적당한 날씨로 사용자님의 일반 선호도를 기반으로 추천합니다. (Recombee 추천: 주류 탁주, 가격 12000원, 단맛 2, 신맛 2, 청량감 2, 바디감 3, 도수 12 등)"
},
{
"name": "희양산막걸리9",
"reason": "추천 이유: 적당한 날씨로 사용자님의 일반 선호도를 기반으로 추천합니다. (Recombee 추천: 주류 탁주, 가격 8000원, 단맛 2, 신맛 3, 청량감 2, 바디감 3, 도수 9 등)"
},
{
"name": "홍천강탁주",
"reason": "추천 이유: 적당한 날씨로 사용자님의 일반 선호도를 기반으로 추천합니다. (Recombee 추천: 주류 탁주, 가격 10000원, 단맛 2, 신맛 3, 청량감 2, 바디감 4, 도수 11 등)"
},
{
"name": "자희향나비생탁주",
"reason": "추천 이유: 적당한 날씨로 사용자님의 일반 선호도를 기반으로 추천합니다. (Recombee 추천: 주류 탁주, 가격 8000원, 단맛 2, 신맛 1, 청량감 2, 바디감 2, 도수 8 등)"
},
{
"name": "신선주백주",
"reason": "추천 이유: 적당한 날씨로 사용자님의 일반 선호도를 기반으로 추천합니다. (Recombee 추천: 주류 탁주, 가격 15000원, 단맛 2, 신맛 3, 청량감 1, 바디감 3, 도수 10 등)"
}
],
"message": "날씨 기반 Recombee 추천 테스트 결과입니다."
}'𝐄𝐰𝐡𝐚 > 캡스톤' 카테고리의 다른 글
| Express.js + MongoDB로 리뷰 시스템 만들기 (중복 방지 + 이미지 업로드) (0) | 2025.11.18 |
|---|