
드디어 마지막 단계, 리뷰 시스템을 도입해 본다.
친구들끼리 맛집 다녀와서 별점 남기는것도 소소한 재미!
1-1.구글 시트 생성
리뷰를 받아둘 구글 시트를 새로 생성한다.

기존에 쓰던 문서에 reviews 시트를 새로 생성하고, 첫번째행(=컬럼명)은 다음과 같이 작성했다.
id : 고유값
restaurantId : restaurants시트의 식당 ID
rating : 별점
comment : 한줄평
nickname : 닉네임
password : 수정/삭제할때 쓰일 비밀번호
createdAt : 리뷰 작성 날짜
맛집 한개에 리뷰 여러개 달리는 1:N 구조로 작성될것이다.
1-2. Apps Script 수정
리뷰 데이터를 저장하기 위해 doPost함수 안에 리뷰를 받아오는 코드를 다음과 같이 추가한다.
if (data.type === "review" && data.action === "create") {
const rvSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("reviews");
rvSheet.appendRow([
data.id,
data.restaurantId,
data.rating,
data.comment,
data.nickname,
data.password,
data.createdAt
]);
"reviews" 시트에 받아온 데이터를 넣겠다는 뜻이다.
리뷰 데이터 조회를 위해 doGet함수도 다음 코드를 추가한다
const reviewSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("reviews");
const reviewdata = reviewSheet.getDataRange().getValues();
const reviewHeaders = reviewdata[0];
const reviewRows = reviewdata.slice(1);
const reviewResult = reviewRows.map(row => {
let obj = {};
reviewHeaders.forEach((header, i) => {
obj[header] = row[i];
});
return obj;
});
restaurant 정보를 받을때와 동일한 구조이다.
doGet의 return값도 식당정보와 리뷰값을 모두 리턴하기 위해 아래와 같이 바꾼다
return ContentService
.createTextOutput(JSON.stringify({
restaurants: result,
reviews: reviewResult
}))
.setMimeType(ContentService.MimeType.JSON);
(참고) 전체 Code.gs
function doGet() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("restaurants");
const data = sheet.getDataRange().getValues();
const headers = data[0]; // 컬럼명
const rows = data.slice(1);
const result = rows.map(row => {
let obj = {};
headers.forEach((header, i) => {
obj[header] = row[i];
});
return obj;
});
const reviewSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("reviews");
const reviewdata = reviewSheet.getDataRange().getValues();
const reviewHeaders = reviewdata[0];
const reviewRows = reviewdata.slice(1);
const reviewResult = reviewRows.map(row => {
let obj = {};
reviewHeaders.forEach((header, i) => {
obj[header] = row[i];
});
return obj;
});
return ContentService
.createTextOutput(JSON.stringify({
restaurants: result,
reviews: reviewResult
}))
.setMimeType(ContentService.MimeType.JSON);
}
function doPost(e) {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("restaurants");
const data = JSON.parse(e.postData.contents);
if (data.type === "review" && data.action === "create") {
const rvSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("reviews");
rvSheet.appendRow([
data.id,
data.restaurantId,
data.rating,
data.comment,
data.nickname,
data.password,
data.createdAt
]);
return ContentService
.createTextOutput(JSON.stringify({ result: "review_created" }))
.setMimeType(ContentService.MimeType.JSON);
}
// 수정
if (data.action === "update") {
const rows = sheet.getDataRange().getValues();
for (let i = 1; i < rows.length; i++) {
if (String(rows[i][0]) === String(data.id)) {
sheet.getRange(i + 1, 2).setValue(data.name);
sheet.getRange(i + 1, 3).setValue(data.category);
sheet.getRange(i + 1, 4).setValue(data.price);
sheet.getRange(i + 1, 5).setValue(data.comment);
sheet.getRange(i + 1, 7).setValue(data.createdBy);
break;
}
}
return ContentService
.createTextOutput(JSON.stringify({ result: "updated" }))
.setMimeType(ContentService.MimeType.JSON);
}
// 삭제
if (data.action === "delete") {
const rows = sheet.getDataRange().getValues();
for (let i = 1; i < rows.length; i++) {
if (rows[i][0] == data.id) { // id 비교
sheet.deleteRow(i + 1);
break;
}
}
return ContentService
.createTextOutput(JSON.stringify({ result: "deleted" }))
.setMimeType(ContentService.MimeType.JSON);
}
sheet.appendRow([
data.id,
data.name,
data.category,
data.price,
data.comment,
data.createdAt,
data.createdBy
]);
return ContentService
.createTextOutput(JSON.stringify({ result: "success" }))
.setMimeType(ContentService.MimeType.JSON);
}
2. script.js 수정
script.js 상단에 에 리뷰를 받아올 전역변수 reviews를 선언한다.
let reviews = [];
카드에 리뷰를 추가하기 위해 renderCards를 수정한다.
리뷰를 추가 하면서 수정 삭제버튼과 같은 것들도 보기 편하게 구조를 조금 바꾸었다.
function renderCards(list) {
const container = document.getElementById("cardList");
container.innerHTML = "";
list.forEach((item) => {
const card = document.createElement("div");
const reviewList = reviews.filter(
(r) => String(r.restaurantId) === String(item.id),
);
const avg = reviewList.length
? (
reviewList.reduce((sum, r) => sum + Number(r.rating), 0) /
reviewList.length
).toFixed(1)
: "0.0";
card.className = "card";
card.classList.add(item.category);
card.innerHTML = `
<div class="card-header">
<div class="title">${item.name}</div>
<div class="action">
<button class="delete-btn" onclick="deleteItem('${item.id}')">삭제</button>
<button class="edit-btn" onclick="editItem('${item.id}')">수정</button>
</div>
</div>
<div class="meta">
${categoryLabel[item.category]}
${item.price ? `· <span class="price">${item.price}원</span>` : ""}
</div>
<div class="desc">${item.comment || ""}</div>
<div class="rating">⭐ ${avg} (${reviewList.length})</div>
<div class="review-area" id="review-${item.id}"></div>
`;
// 별점 클릭시 리스트 보여주기
const cardRating = card.querySelector(".rating");
cardRating.addEventListener("click", () => toggleReview(item.id));
container.appendChild(card);
});
}
카드 렌더링이 끝난 직후에 별점을 클릭하면 리뷰 리스트가 열리도록 했다.
리뷰를 보여주는 toggleReview 함수는 다음과 같다.
function toggleReview(id) {
const el = document.getElementById(`review-${id}`);
// 이미 열려있으면 닫기
if (el.innerHTML) {
el.innerHTML = "";
return;
}
const list = reviews.filter((r) => String(r.restaurantId) === String(id));
el.innerHTML = `
<div class="review-box">
${list.length === 0 ? "<p>아직 리뷰 없음</p>" : ""}
${list
.map(
(r) => `
<div class="review-item">
<div>⭐ ${r.rating} - ${r.comment}</div>
<div class="review-meta">${r.nickname}</div>
</div>
`,
)
.join("")}
<div class="review-form">
<input type="number" id="rating-${id}" placeholder="별점(1~5)" min="1" max="5" />
<input type="text" id="comment-${id}" placeholder="한줄평" />
<input type="text" id="nick-${id}" placeholder="닉네임(선택)" />
<input type="password" id="pw-${id}" placeholder="비밀번호(4자리)" />
<button onclick="event.stopPropagation(); addReview('${id}')">
등록
</button>
</div>
</div>
`;
}
리뷰 별점은 간단하게 1~5 사이 숫자로 입력받도록 했다.
리뷰 등록 함수 addReview는 다음과 같다.
async function addReview(id) {
const data = {
id: Date.now().toString(),
restaurantId: id,
rating: document.getElementById(`rating-${id}`).value,
comment: document.getElementById(`comment-${id}`).value,
nickname: document.getElementById(`nick-${id}`).value || "익명",
password: document.getElementById(`pw-${id}`).value,
createdAt: new Date().toISOString(),
type: "review",
action: "create",
};
await fetch(API_URL, {
method: "POST",
body: JSON.stringify(data),
});
alert("리뷰 등록 완료!");
renderList();
}
(참고) 전체 style.css는 다음과 같다
* {
box-sizing: border-box;
}
body {
padding: 16px;
font-family: Arial, sans-serif;
background: #f7f7f7;
margin: 0;
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 10px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.btn-area {
text-align: center;
margin-bottom: 20px;
}
button {
padding: 10px 15px;
border: none;
border-radius: 8px;
cursor: pointer;
margin: 5px;
}
.random-btn {
background: #ff6b6b;
color: white;
}
.reset-btn {
background: #ddd;
}
#result {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 25px;
}
.result-title {
font-size: 22px;
font-weight: bold;
margin-bottom: 12px;
color: #333;
}
.result .card {
width: 100%;
max-width: 420px;
padding: 22px;
border-radius: 16px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
transform: scale(1.03);
border-left-width: 8px; /* 강조 */
transition: 0.3s;
}
.result .card:hover {
transform: scale(1.05);
}
@media (max-width: 600px) {
.result .card {
padding: 18px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
}
}
.card-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.card {
background: #fff;
border-radius: 12px;
padding: 15px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transition: 0.2s;
border-left: 6px solid transparent;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 4px;
}
.card:hover {
transform: translateY(-3px);
}
.title {
font-size: 16px;
font-weight: bold;
margin-bottom: 5px;
}
.category {
font-size: 13px;
color: #888;
margin-bottom: 10px;
}
.desc {
font-size: 14px;
color: #333;
}
.card.korean {
border-left-color: #ff6b6b;
}
.card.bunsik {
border-left-color: #ffa94d;
}
.card.japanese {
border-left-color: #4dabf7;
}
.card.western {
border-left-color: #51cf66;
}
.card.cafe {
border-left-color: #845ef7;
}
@media (max-width: 600px) {
h1 {
font-size: 20px;
}
.card {
padding: 12px;
border-radius: 10px;
}
.card .title {
font-size: 16px;
}
.card .desc {
font-size: 13px;
}
.result .card {
max-width: 100%;
transform: none; /* 모바일에서는 확대 제거 */
}
button {
width: 100%;
margin-bottom: 8px;
}
}
.loading {
text-align: center;
padding: 20px;
font-size: 16px;
color: #666;
}
/* 전체 패널 */
.add-panel {
margin-bottom: 25px;
}
/* 아코디언 */
.add-accordion {
background: #fff;
border-radius: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
/* summary */
.add-accordion__summary {
cursor: pointer;
list-style: none;
padding: 18px 20px;
}
/* 기본 화살표 제거 */
.add-accordion__summary::-webkit-details-marker {
display: none;
}
/* 타이틀 영역 */
.summary-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.summary-title h2 {
margin: 0;
font-size: 18px;
}
.summary-title p {
margin: 4px 0 0;
font-size: 13px;
color: #777;
}
/* 힌트 */
.accordion-hint {
font-size: 12px;
color: #aaa;
}
/* 펼쳐진 영역 */
.add-accordion__content {
border-top: 1px solid #eee;
padding: 20px;
}
/* 폼 */
.add-form {
width: 100%;
}
/* grid */
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
}
/* 전체 width */
.full-width {
grid-column: span 2;
}
/* 입력 그룹 */
.form-group {
display: flex;
flex-direction: column;
}
/* 라벨 */
.form-group label {
font-size: 13px;
margin-bottom: 5px;
color: #555;
}
/* input / select */
.form-group input,
.form-group select {
padding: 10px;
border-radius: 8px;
border: 1px solid #ddd;
font-size: 14px;
transition: 0.2s;
}
.form-group input:focus,
.form-group select:focus {
border-color: #4dabf7;
outline: none;
}
/* 버튼 영역 */
.form-actions {
margin-top: 20px;
text-align: right;
}
/* 버튼 */
.primary-btn {
padding: 10px 18px;
border-radius: 10px;
border: none;
background: #4dabf7;
color: white;
font-weight: bold;
cursor: pointer;
transition: 0.2s;
}
.primary-btn:hover {
background: #339af0;
}
@media (max-width: 600px) {
.form-grid {
grid-template-columns: 1fr;
}
.full-width {
grid-column: span 1;
}
.form-actions {
text-align: center;
}
.primary-btn {
width: 100%;
}
}
.delete-btn {
padding: 5px 8px;
margin: 0px;
font-size: 11px;
border: none;
border-radius: 6px;
background: #ff6b6b;
color: white;
cursor: pointer;
}
.delete-btn:hover {
background: #fa5252;
}
.price {
font-size: 12px;
font-weight: normal;
color: #999;
background: none;
padding: 0;
}
.edit-btn {
margin: 0px;
padding: 5px 8px;
font-size: 11px;
border: none;
border-radius: 6px;
background: #4dabf7;
color: white;
cursor: pointer;
}
.edit-btn:hover {
background: #339af0;
}
.edit-status {
margin-bottom: 10px;
font-size: 13px;
font-weight: bold;
color: #ff6b6b;
display: none;
}
.edit-mode {
border: 2px solid #ff6b6b;
background: #fff5f5;
padding: 15px;
border-radius: 10px;
}
.controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
margin: 20px 0;
border-radius: 10px;
}
.controls input,
.controls select {
height: 50px;
padding: 0 12px;
font-size: 13px;
border-radius: 8px;
border: 1px solid #ddd;
transition: all 0.2s ease;
}
#searchInput {
width: 100%;
}
.controls input:focus,
.controls select:focus {
outline: none;
border-color: #4dabf7;
box-shadow: 0 0 0 2px rgba(77, 171, 247, 0.2);
}
.title-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.rating {
font-size: 13px;
color: #f59f00;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 0px;
border-radius: 6px;
transition: all 0.2s ease;
}
.rating:hover {
background: #fff3bf; /* 연한 노랑 */
transform: scale(1.05);
}
.meta {
font-size: 13px;
color: #888;
margin-bottom: 8px;
}
.review-area {
margin-top: 10px;
}
.review-box {
background: #f8f9fa;
padding: 10px;
border-radius: 8px;
}
.review-item {
font-size: 13px;
margin-bottom: 6px;
}
.review-meta {
font-size: 11px;
color: #999;
}
.review-form {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.review-form input {
padding: 6px;
border-radius: 6px;
border: 1px solid #ddd;
}
.review-form button {
background: #4dabf7;
color: white;
border-radius: 6px;
padding: 6px;
}
.card-header {
cursor: pointer;
}
이렇게 해서 간단하게 구글 시트를 이용해서 CRUD 기반의 맛집리스트+리뷰시스템을 구현했다.
구글시트만 가지고도 꽤 실용적인 페이지를 만들수 있다는점이 흥미로웠다.
구글 시트 연동하는 방법을 알았으니 다른것들도 더 만들어 볼 수 있지 않을까?!
'개발 > 이것저것' 카테고리의 다른 글
| [JavaScript] 나만 쓰는 맛집 리스트 만들기 (카드 UI 구현) - 5. 필터링 구현 (0) | 2026.04.19 |
|---|---|
| [JavaScript] 나만 쓰는 맛집 리스트 만들기 (카드 UI 구현) - 4. 구글 시트 UPDATE (1) | 2026.04.18 |
| [JavaScript] 나만 쓰는 맛집 리스트 만들기 (카드 UI 구현) - 3. 구글 시트로 CRUD 만들기 (0) | 2026.04.17 |
| [JavaScript] 나만 쓰는 맛집 리스트 만들기 (카드 UI 구현) - 2. 구글 시트 연동하기 (1) | 2026.04.16 |
| [JavaScript] 나만 쓰는 맛집 리스트 만들기 (카드 UI 구현) - 1. 기본 레이아웃 (0) | 2026.04.15 |