본문 바로가기
개발/이것저것

[JavaScript] 나만 쓰는 맛집 리스트 만들기 (카드 UI 구현) - 6. 리뷰 구현

by 슈스 2026. 4. 22.
반응형

 

드디어 마지막 단계, 리뷰 시스템을 도입해 본다.

친구들끼리 맛집 다녀와서 별점 남기는것도 소소한 재미!

 

 

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 기반의 맛집리스트+리뷰시스템을 구현했다.

구글시트만 가지고도 꽤 실용적인 페이지를 만들수 있다는점이 흥미로웠다.

구글 시트 연동하는 방법을 알았으니 다른것들도 더 만들어 볼 수 있지 않을까?! 

 

반응형