블로그 본문
[복합 조인 연산] 상품 상세페이지(prodDetail.js) 조립을 위한 Products-Customers-Prod_Evals 3중 테이블 INNER JOIN 및 평점 통계 쿼리
니꼴라 클라우드 테크 9월 15, 2026📌 목차 바로가기
지난 포스팅에서는 서버 사이드 렌더링(SSR)과 클라이언트 사이드 렌더링(CSR)을 유기적으로 결합하여, SEO와 미려한 동적 UX를 모두 수호하는 메인 쇼핑몰 화면(Main.js)과 비동기 상품 목록 로딩 기술을 완성했습니다.
이번 포스팅에서는 단일 테이블 조회를 넘어, RDBMS 본연의 꽃이라 불리는 복합 조인(JOIN) 연산과 평점 산술 평균 집계, 별점 HTML 시각화, 그리고 개인정보 보호 마스킹 기법을 적용한 라우터 조립 기술을 정밀하게 해부해 드리겠습니다.
1. RDBMS 관계형 데이터베이스 조인(JOIN) 연산 설계 사상
상품 상세 페이지를 구성하기 위해서는 각기 다른 목적을 지닌 3개 테이블의 데이터가 하나의 맥락으로 유기적으로 결합되어야 합니다.
Products(상품 마스터 테이블): 상품명, 판매 단가, 상품 소재, S3 이미지 URL(prod_img), 그리고 HTML 태그로 구성된 대용량 소개문(prod_intro)을 보유합니다.Prod_evals(상품평/리뷰 테이블): 실구매자가 부여한 평점 점수(eval_score), 한줄평 본문(eval_comment), 리뷰 작성 일자(eval_date), 평가 상품 코드(prod_cd), 및 작성 고객 ID(cust_id)를 보관합니다.Customers(고객 마스터 테이블): 리뷰 작성자의 성명(cust_name) 및 계정 식별 정보를 보유합니다.
■ INNER JOIN vs LEFT JOIN 의사결정
만약 단순 INNER JOIN만을 적용하여 3개 테이블을 결합하면, 리뷰 데이터가 단 한 건도 적재되지 않은 신상품의 경우 Prod_evals 테이블과의 교집합 레코드가 존재하지 않아 상품 마스터 정보까지 통째로 조회 결과에서 소멸하는 치명적인 결함이 발생합니다.
따라서 신상품이거나 리뷰가 0건인 상태에서도 상품 마스터 정보(Products)는 온전히 노출하면서, 존재하는 리뷰만을 선택적으로 결합해 가져올 수 있도록 LEFT JOIN 구문을 활용하거나, 상품 마스터 단일 조회 쿼리와 리뷰-작성자 조인 조회 쿼리를 이원화하여 안전하게 조립하는 방어적 백엔드 설계가 필수적입니다.
2. 평점 통계 계산 및 개인정보 보호 마스킹 헬퍼 함수 구현
상세 페이지의 품질을 결정짓는 핵심 요소는 실구매자들의 평점 통계 시각화와 리뷰 작성자의 정보보호 처리입니다.
① 산술 평균 평점 및 리뷰 수 통계 집계
Prod_evals 테이블의 평점 항목(eval_score)에 대해 SQL 집계 함수인 AVG(eval_score)와 COUNT(eval_seq_no)를 가동합니다.
백엔드 연산 단계에서 Number(avgScore).toFixed(1)을 적용하여 소수점 첫째 자리(예: 4.7점)로 정돈된 산술 평균 지표를 추출해냅니다.
② 별점 HTML 변환 헬퍼 함수 (getStarRatingHtml)
수치로 계산된 평균 평점(1~5점)을 사용자가 직관적으로 인식할 수 있도록 시각적인 별 모양 문자열(★, ☆)로 변환합니다:
// 평점 점수를 별점 HTML로 변환하는 헬퍼 함수
function getStarRatingHtml(score) {
const roundedScore = Math.round(Number(score) || 0);
let stars = '';
for (let i = 1; i <= 5; i++) {
if (i <= roundedScore) {
stars += '<span class="star filled">★</span>';
} else {
stars += '<span class="star empty">☆</span>';
}
}
return `<div class="star-rating">${stars} <span class="score-num">(${Number(score).toFixed(1)})</span></div>`;
}
③ 작성자 식별 정보 마스킹 (maskUserId)
리뷰 리스트에 작성자의 이메일 아이디(cust_id)나 실명을 그대로 노출하는 것은 개인정보 보호법 및 보안 컴플라이언스에 위배됩니다. 아이디의 앞 2자리만 노출하고 나머지는 마스킹(**) 처리하는 유틸리티 함수를 대입합니다:
// 작성자 아이디 마스킹 처리 헬퍼 함수 (예: user123@email.com -> us***)
function maskUserId(userId) {
if (!userId) return '익명';
const prefix = userId.substring(0, 2);
return `${prefix}***`;
}
3. 3중 테이블 조인 및 상세페이지 SSR 수송 아키텍처 다이어그램
AWS RDS MySQL에 보관된 Products, Prod_evals, Customers 테이블이 LEFT JOIN 결합을 통해 정밀 조회되고, prodDetail.js 라우터가 산술 평균 통계 산출, 별점 시각화, 아이디 마스킹을 거쳐 SSR HTML로 조립 반환하는 전체 수송 구조도입니다.
4. [코드 분석] prodDetail.js 전체 소스 코드
실제 애플리케이션에서 특정 상품 상세 정보와 실구매자 별점 한줄평 리뷰 리스트를 조립해 SSR HTML로 반환하는 prodDetail.js 라우터의 핵심 구현 코드입니다.
/**
* prodDetail.js - 상품 상세 정보 및 리뷰 3중 조인 SSR 라우터
*/
const express = require('express');
const router = express.Router();
const db = require('./db');
const authCheck = require('./authCheck');
// 별점 HTML 변환 헬퍼 함수
function getStarRatingHtml(score) {
const rounded = Math.round(Number(score) || 0);
let stars = '';
for (let i = 1; i <= 5; i++) {
stars += (i <= rounded) ? '<span class="star filled">★</span>' : '<span class="star empty">☆</span>';
}
return `<div class="star-rating">${stars} <span class="score-num">(${Number(score).toFixed(1)})</span></div>`;
}
// 아이디 마스킹 헬퍼 함수
function maskUserId(id) {
if (!id) return '익명';
return id.substring(0, 2) + '***';
}
// 상품 상세 페이지 라우트 (GET /prod/product/:id)
router.get('/product/:id', async (req, res) => {
// 인증 체크
if (!authCheck.isOwner(req, res)) {
return res.redirect('/auth/login');
}
const prodCd = req.params.id;
try {
// 1. 상품 마스터 상세 정보 단일 조회
const [products] = await db.query('SELECT * FROM Products WHERE prod_cd = ?', [prodCd]);
if (products.length === 0) {
return res.status(404).send('<script>alert("존재하지 않는 상품입니다."); history.back();</script>');
}
const product = products;
// 2. Prod_evals + Customers 2중 JOIN 리뷰 및 평점 집계 조회
const [reviews] = await db.query(`
SELECT
E.eval_seq_no, E.eval_score, E.eval_comment, E.eval_date,
C.cust_id, C.cust_name
FROM Prod_evals E
INNER JOIN Customers C ON E.cust_id = C.cust_id
WHERE E.prod_cd = ?
ORDER BY E.eval_date DESC
`, [prodCd]);
// 3. 평점 통계 산술 평균 계산
let totalScore = 0;
reviews.forEach(r => { totalScore += Number(r.eval_score); });
const avgScore = reviews.length > 0 ? (totalScore / reviews.length) : 0;
// 4. 리뷰 목록 HTML 카드 조립
let reviewListHtml = '';
if (reviews.length === 0) {
reviewListHtml = '<p class="no-review">아직 작성된 상품평이 없습니다. 첫 리뷰의 주인공이 되어보세요!</p>';
} else {
reviews.forEach(r => {
reviewListHtml += `
<div class="review-card">
<div class="review-header">
<span class="review-author">${maskUserId(r.cust_id)}</span>
<span class="review-date">${r.eval_date.toISOString().split('T')}</span>
</div>
${getStarRatingHtml(r.eval_score)}
<p class="review-comment">${r.eval_comment}</p>
</div>
`;
});
}
// 5. 전체 SSR HTML 완성 및 전송
const authUI = authCheck.statusUI(req, res);
const html = `
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>${product.prod_name} - FASHION STORE</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="main-header">
<div class="logo"><a href="/main"><h1>FASHION STORE</h1></a></div>
<div class="user-status">${authUI}</div>
</header>
<main class="detail-container">
<div class="product-detail-box">
<div class="detail-media">
<img src="${product.prod_img}" alt="${product.prod_name}" onerror="this.src='/images/no-image.png'" />
</div>
<div class="detail-specs">
<span class="category">${product.prod_type || '의류'}</span>
<h2>${product.prod_name}</h2>
<p class="price">${Number(product.price).toLocaleString()}원</p>
<p class="material">소재: ${product.material || '정보 없음'}</p>
<div class="avg-rating-box">
<span>평균 평점:</span>
${getStarRatingHtml(avgScore)}
<span class="review-count">(${reviews.length}개의 리뷰)</span>
</div>
<!-- 장바구니 담기 폼 -->
<form action="/cart/add" method="post" class="cart-form">
<input type="hidden" name="prod_cd" value="${product.prod_cd}" />
<div class="option-group">
<label for="prod_size">사이즈 선택:</label>
<select name="prod_size" id="prod_size" required>
<option value="">-- 선택하세요 --</option>
<option value="S">S (Small)</option>
<option value="M">M (Medium)</option>
<option value="L">L (Large)</option>
<option value="XL">XL (X-Large)</option>
</select>
</div>
<div class="option-group">
<label for="ord_qty">수량:</label>
<input type="number" name="ord_qty" id="ord_qty" value="1" min="1" max="10" required />
</div>
<button type="submit" class="btn-cart">장바구니 담기</button>
</form>
</div>
</div>
<!-- 대용량 HTML 상품 소개 영역 -->
<section class="product-intro-section">
<h3>상품 상세 설명</h3>
<div class="intro-content">
${product.prod_intro || '<p>상세 설명 준비 중입니다.</p>'}
</div>
</section>
<!-- 상품평 리뷰 섹션 -->
<section class="reviews-section">
<h3>실구매자 상품평 (${reviews.length})</h3>
<div class="review-list">
${reviewListHtml}
</div>
</section>
</main>
</body>
</html>
`;
res.send(html);
} catch (err) {
console.error('상품 상세페이지 조회 중 에러 발생:', err);
res.status(500).send('서버 내부 오류가 발생했습니다.');
}
});
module.exports = router;
마치며: 복합 데이터의 유기적 조립을 무결하게 정복했습니다!
오늘 우리는 백엔드 데이터베이스 활용의 꽃이라 불리는 RDBMS 복합 조인(JOIN) 연산과 산술 평균 집계, 별점 HTML 시각화, 그리고 작성자 개인정보 마스킹을 적용하여 완성도 높은 상품 상세 페이지 라우터(prodDetail.js)를 정복했습니다!
"각기 다른 목적으로 나뉘어 있던 상품, 리뷰, 고객 테이블의 레코드들을 RDBMS 조인 연산으로 엮어내고, 별점 통계와 개인정보 보호 방패를 융합하여 상용 이커머스 수준의 정밀한 상품 상세페이지 서비스를 완성해 낸 것입니다."
상품 상세 정보를 확인하고 사이즈와 수량을 선택해 [장바구니 담기] 버튼을 누르는 기반이 완료되었으니, 이제 전자상거래 시스템의 핵심 관문이자 RESTful API 규격으로 수량 수정 및 개별 삭제를 처리하는 장바구니 연동 및 결제 트랜잭션 구현으로 나아갈 차례입니다.
다음 포스팅에서는 [RESTful API 설계] 장바구니 관리(cartView.js)를 위한 HTTP Methods(GET, PUT, DELETE) 엔드포인트 설계 및 실시간 데이터 갱신 편에서는 장바구니 목록 조회, 수량 동적 업데이트(PUT /cart/:id), 개별 아이템 삭제(DELETE /cart/:id), 그리고 비동기 Fetch 통신을 통한 실시간 총액 계산 로직을 설명하겠습니다.
오늘 3중 조인 쿼리 연산 중 외래키 참조 에러가 발생했거나, 별점 시각화 헬퍼 함수 적용에 관한 궁금증이 있으시다면 주저하지 마시고 운영자 메일로 질문해 주세요. 오늘도 안전하고 똑똑한 클라우딩 라이프 하세요. 감사합니다! 😉
출처
- 이현호. 실무 프로젝트로 완성하는 클라우드 환경에서 DB 구축과 웹 개발. 길벗캠퍼스. 2026.04. (8.3장 '상품 상세 페이지(prodDetail.js)', 210-218페이지 참조)
- 이현호. "08장. 애플리케이션 개발 - 메인페이지_상품상세정보_설명오디오.m4a" 설명 오디오 가이드 스크립트 기반 Products-Customers-Prod_Evals 조인 원리, 신상품 리뷰 0건 누락 방지 방어적 쿼리 설계, AVG 평균 평점 계산 및 작성자 아이디 마스킹 기술 비하인드 대화록 완벽 반영.
- MySQL 8.0 Reference Manual - JOIN Syntax (https://dev.mysql.com/doc/refman/8.0/en/join.html) 및 MDN String Manipulation Guide.
자주 묻는 질문 (FAQ)
Q. 상품 상세페이지 구축 시 Products와 Prod_evals, Customers 테이블을 INNER JOIN 대신 LEFT JOIN으로 묶어 조회하는 실무적 이유는 무엇인가요?
A. 리뷰가 없는 신상품의 조회 누락을 방어하기 위해서입니다. INNER JOIN 적용 시 리뷰(Prod_evals)가 0건인 상품은 교집합 레코드가 존재하지 않아 상품 마스터 정보까지 통째로 조회 결과에서 소멸하므로, LEFT JOIN을 통해 마스터 정보는 온전히 유지하면서 리뷰만을 선택적으로 결합해야 합니다.
Q. 리뷰 데이터베이스 조회 시 산술 평균 평점(AVG) 집계 및 소수점 정돈 처리 시 고려해야 할 사항은 무엇인가요?
A. 리뷰 0건 시의 0 나누기(Zero Division) 예외 방지와 소수점 표기 정돈입니다. 리뷰가 없을 경우 평균 평점이 NaN으로 붕괴되지 않도록 삼항 연산자로 0점 예외 처리를 수행하고, 'toFixed(1)'을 대입하여 소수점 첫째 자리 수치로 정돈해 시각화해야 합니다.
Q. 상품평 리스트 노출 시 리뷰 작성자의 아이디나 이메일을 그대로 노출하지 않고 마스킹 처리하는 이유는 무엇인가요?
A. 개인정보 보호법 준수 및 계정 정보 유출 방지입니다. 작성자의 식별 정보를 무단 노출할 경우 스팸 및 타겟팅 공격의 표적이 될 수 있으므로, 아이디 앞 2자리만 노출하고 나머지는 마스킹('**') 처리하는 유틸리티 헬퍼 함수를 적용해야 합니다.
