* 지도 API 사용 방법 - mapx, mapy 좌표 마커가 왜 이상하게 찍히는가?
https://tiny-immj.tistory.com/129
네이버 지도 api로 '우리동네 장소 찾기' 만들기 1 - 네이버 지도 api 좌표 문제 해결 방법
1. 진행 순서 시나리오 → 아키텍처 → ERD → 샘플 데이터 검증 → PHP 앱(관리자, 사용자) 시나리오 : 사람이 하는 일을 "트리거-입력-규칙-출력-저장"으로 변경한다아키텍처 : 액터/시스템/데이터
tiny-immj.tistory.com
앱 만들기 (관리자, 사용자)
1. neighborhood-place 전체 구조
index.php
logout.php
/public
├── index.php
├── search.php
├── place_detail.php
├── favorites.php
├── recent.php
├── test_recent_log.php
├── _header.php
├── _footer.php
/admin
├── dashboard.php
├── keyword_list.php
├── keyword_save.php
├── keyword_toggle.php
├── auth.php
├── batch_list.php
├── batch_detail.php
├── header.php
├── footer.php
/api
├── search_place.php
├── recent_log.php
├── favorite_toggle.php
/config
├── db.php
2. 기능 구현
2-1. 공통
√ index에서 로그인 처리
- 세션 기반 인증
- role = admin 체크
2-2. 관리자
√ 기능 구현 요약
- 인증
- 키워드 CRUD
- 배치 실행 로그
2-3. 사용자 기능 구현
√ 검색 API 연동
① 키워드 입력
② 서버(PHP)에서 네이버 검색 API 호출
③ JSON 응답 파싱
④ place_cache 저장
⑤ 지도에 마커 표시
3. 설계
3-1. PHP
왜 PHP를 선택했는가?
이 프로젝트는 단순 프론트엔드 실습이 아니라 서버 중심의 프로젝트입니다
브라우저에서 직접 API를 호출하는 방식이 아니라 서버에서 API를 호출하고 데이터를 통제하는 구조가 필요해서 선택하였습니다
- 네이버 검색 API는 Client ID / Secret을 사용합니다 이를 브라우저에서 직접 호출하면 보안 문제가 발생하므로
PHP서버에서 API 호출 → JSON 파싱 → DB 저장 → 프론트에 전달
- 별도의 빌드 과정이 필요하지 않아 빠른 실습, 구조 검증이 가능합니다
3-2. 구조
구조를 public, admin, api, config로 나눠놓았습니다
나눴을 때의 장점 : 유지보수 용이, 권한 관리 명확, api 확장 가능
① public
사용자가 접속하는 화면 영역
검색, 상세보기, 즐겨찾기, 최근 검색, 공통 레이아웃 분리
☞ 화면 로직과 API 호출을 분리해 유지보수성을 높였습니다
② admin
운영 데이터를 관리하는 영역
키워드 CRUD, 배치 실행 로그, 인증
③ api
서버 처리 영역
장소 검색 API 호출, 최근 검색 기록 처리, 즐겨찾기 토글 처리
☞ public과 로직(api)를 분리하여 AJAX 기반 구조로 확장 가능하도록 설계
④ config
DB 연결
db.php 단일 책임 유지
4. mvp 개발
4-1. 공통
① index.php
- login
<?php
require_once __DIR__ . '/config/db.php
/* 이미 로그인한 경우 → 권한별 이동 */
if (isset($_SESSION['user'])) {
if ($_SESSION['user']['role'] === 'admin') {
header("Location: /neighborhood-place/admin/dashboard.php");
} else {
header("Location: /neighborhood-place/public/index.php");
}
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email']);
$password = $_POST['password'];
$stmt = $conn->prepare("SELECT * FROM users WHERE email = ?");
$stmt->bind_param("s", $email);
$stmt->execute();
$user = $stmt->get_result()->fetch_assoc();
/* 평문 비밀번호 비교 (현재 단계) */
if ($user && $password === $user['password']) {
$_SESSION['user'] = [
'id' => $user['id'],
'email' => $user['email'],
'role' => $user['role']
];
if ($user['role'] === 'admin') {
header("Location: /neighborhood-place/admin/dashboard.php");
} else {
header("Location: /neighborhood-place/public/index.php");
}
exit;
} else {
$error = "이메일 또는 비밀번호가 올바르지 않습니다.";
}
}
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>로그인 | 우리동네 장소 찾기</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color:#f5f6f8;
}
.login-wrapper {
min-height: 100vh;
}
.login-card {
width: 100%;
max-width: 420px;
}
.brand-title {
font-weight: 700;
letter-spacing: -0.5px;
}
</style>
</head>
<body>
<div class="container login-wrapper d-flex justify-content-center align-items-center">
<div class="card shadow-sm login-card">
<div class="card-body p-4">
<h4 class="text-center mb-4 brand title">
우리동네 장소 찾기
</h4>
<?php if($error):?>
<div class="alert alert-danger">
<?= htmlspecialchars($error) ?>
</div>
<?php endif?>
<form method="post" autocomplete="off">
<div class="mb-3">
<label class="form-label">이메일</label>
<input
type="email"
name="email"
class="form-control"
placeholder="admin@test.com"
required
>
</div>
<div class="mb-4">
<label class="form-label">비밀번호</label>
<input
type="password"
name="password"
class="form-control"
placeholder="비밀번호 입력"
required
>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
로그인
</button>
</div>
</form>
<div class="text-center mt-4 text-muted" style="font-size:0.9rem;">
관리자 | 사용자 공용 로그인
</div>
</div>
</div>
</div>
</body>
</html>
② logout.php
<?php
/* 세션 시작 */
session_start();
/* 모든 세션 변수 제거 */
$_SESSION=[];
/* 세션 쿠키 제거 (있다면) */
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params["path"],
$params["domain"],
$params["secure"],
$params["httponly"]
);
}
/* 세션 파기 */
session_destroy();
/* 로그인 페이지로 이동 */
header("Location: /neighborhood-place/index.php");
exit;
4-2. config
① db.php
<?php
/* ===================
DB 설정(aiven MySQL)
======================*/
define('DB_HOST', YOURHOSTNAME)
define('DB_PORT', PORTNUMBER)
define('DB_USER', USERNAME)
define('DB_PASS', PASSWORD)
define('DB_NAME', SCHEMANAME)
/*======================
DB 연결(SSL 필수)
========================*/
$conn = mysqli_init();
/*
Aiven MySQL은 SSL 연결 필수
(CA 인증서 파일을 쓰지 않는 기본 SSL 방식)
*/
mysqli_ssl_set(
$conn,
NULL, // key
NULL, // cert
NULL, // ca
NULL, // capath
NULL // cipher
);
mysqli_real_connect(
$conn,
DB_HOST,
DB_USER,
DB_PASS,
DB_NAME,
DB_PORT,
NULL,
MYSQLI_CLIENT_SSL
);
if ($conn->connect_error) {
die("DB 연결 실패: " . $conn->connect_error);
}
/* 문자셋 */
$conn->set_charset("utf8mb4");
/* 세션 시작 */
if (session_status() === PHP_SESSION_NOTE) {
session_start();
}
?>
4-3. admin
① auth.php
<?php
require_once __DIR__ . '/../config/db.php';
/* 로그인 안 한 경우 */
if(!isset($_SESSION['user'])) {
header("Location:/neighborhood-place/index.php");
exit;
}
/* 관리자 아니면 */
if ($_SESSION['user']['role']!=='admin') {
header("Location/neighborhood-place/index.php")
exit;
}
② dashboard.php
<?php
require_once __DIR__ . '/../config/db.php';
require_once 'header.php';
/* -----------------------------
최신 batch_run 조회
------------------------------ */
$runSql = "SELECT * FROM batch_run ORDER BY executed_at DESC LIMIT 1";
$runResult = $conn->query($runSql);
$run = $runResult ? $runResult->fetch_assoc() : null;
$runKey = '-';
$executedAt = '-';
$resultCount = 0;
$keywordCount = 0;
if ($run) {
$runKey = $run['run_key'];
$executedAt = $run['executed_at'];
$resultCount = $conn->query("SELECT COUNT(*) AS cnt FROM batch_result WHERE run_id = {$run['id']}")->fetch_assoc()['cnt'];
$keywordCount = $conn->query("SELECT COUNT(DISTINCT keyword) AS cnt FROM batch_result WHERE run_id = {$run['id']}")->fetch_assoc()['cnt'];
}
/* 오늘 실행 요약 */
$today = date('Y-m-d');
$todayRow = $conn->query("
SELECT COUNT(*) AS run_count,
COALESCE(SUM((SELECT COUNT(*) FROM batch_result WHERE run_id=batch_run.id)),0) AS total_results
FROM batch_run
WHERE DATE(executed_at)='{$today}'
")->fetch_assoc();
$todayRunCount = $todayRow['run_count'] ?? 0;
$todayResultCount = $todayRow['total_results'] ?? 0;
/* 마지막 실행 Top3 키워드 */
$topKeywords = [];
if ($run) {
$topResult = $conn->query("
SELECT keyword, COUNT(*) AS cnt
FROM batch_result
WHERE run_id = {$run['id']}
GROUP BY keyword
ORDER BY cnt DESC
LIMIT 3
");
while ($row = $topResult->fetch_assoc()) $topKeywords[] = $row;
}
?>
<style>
/* 카드 공통 스타일 */
.card-modern {
border-radius: 15px;
box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.05);
padding: 25px;
background-color: #ffffff;
transition: transform 0.2s;
}
.card-modern .card-title {
font-weight: 600;
font-size: 1rem;
color: #6c757d;
margin-bottom: 0.5rem;
}
.card-modern .card-text {
font-size: 0.85rem;
color: #495057;
line-height: 1.5;
}
/* 오늘/Top3 카드 */
.card-static {
max-width: 500px;
min-height: 300px; /* 높이 넉넉하게 */
margin: 20px 0;
padding: 25px;
border-radius: 15px;
box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.05);
}
.card-static .card-title {
font-weight: 600;
font-size: 1rem;
color: #6c757d;
margin-bottom: 0.75rem;
}
.card-static .card-text {
font-size: 0.85rem;
line-height: 1.5;
color: #495057;
}
/* 반응형 */
@media (max-width: 768px) {
.card-static { max-width: 90%; }
}
</style>
<h3 class="mb-4 text-center">대시보드</h3>
<!-- 카드 3개: 마지막 실행 / 처리 키워드 수 / 저장된 장소 수 -->
<div class="row g-4">
<div class="col-md-4 col-12 d-flex">
<div class="card-modern w-100 text-center">
<div class="card-title">마지막 실행</div>
<div class="card-text"><?= htmlspecialchars($runKey) ?></div>
<div class="card-text text-muted"><?= htmlspecialchars($executedAt) ?></div>
</div>
</div>
<div class="col-md-4 col-12 d-flex">
<div class="card-modern w-100 text-center">
<div class="card-title">처리 키워드 수</div>
<div class="card-text"><?= $keywordCount ?></div>
</div>
</div>
<div class="col-md-4 col-12 d-flex">
<div class="card-modern w-100 text-center">
<div class="card-title">저장된 장소 수</div>
<div class="card-text"><?= $resultCount ?></div>
</div>
</div>
</div>
<!-- 오늘 실행 요약 -->
<div class="card-static text-start">
<div class="card-title">오늘 실행 요약</div>
<div class="card-text">
실행 횟수: <strong><?= $todayRunCount ?></strong><br>
처리 장소 수: <strong><?= $todayResultCount ?></strong>
</div>
</div>
<!-- 마지막 실행 Top3 키워드 -->
<div class="card-static text-start">
<div class="card-title">마지막 실행 Top3 키워드</div>
<div class="card-text">
<?php if ($topKeywords): ?>
<ol>
<?php foreach ($topKeywords as $k): ?>
<li><?= htmlspecialchars($k['keyword']) ?> (<?= $k['cnt'] ?>)</li>
<?php endforeach; ?>
</ol>
<?php else: ?>
<div class="text-muted">데이터 없음</div>
<?php endif; ?>
</div>
</div>
<?php if (!$run): ?>
<div class="alert alert-secondary mt-4 text-center">
아직 자동화 실행 이력이 없습니다.
</div>
<?php endif; ?>
<?php require_once 'footer.php'; ?>
③ keyword_list.php
<?php
require_once 'header.php';
/* 키워드 목록 조회 */
$result = $conn->query("
SELECT *
FROM admin_keyword
ORDER BY id DESC
");
?>
<style>
.table-hover tbody tr {
transition: background-color 0.15s ease-in-out;
}
.table-hover tbody tr:hover {
background-color: rgba(13, 110, 253, 0.05);
}
/* 상태 뱃지 */
.badge-active {
background-color: #198754;
}
.badge-inactive {
background-color: #6c757d;
}
</style>
<h3 class="mb-4">추천 검색 키워드 관리</h3>
<!-- 키워드 추가 -->
<div class="card mb-4">
<div class="card-body">
<form method="post" action="keyword_save.php" class="row g-2 align-items-center">
<div class="col-md-6">
<input
type="text"
name="keyword"
class="form-control"
placeholder="추가할 키워드를 입력하세요"
required
>
</div>
<div class="col-md-auto">
<button type="submit" class="btn btn-primary">
키워드 추가
</button>
</div>
</form>
</div>
</div>
<!-- 키워드 테이블 -->
<div class="card">
<div class="card-body p-0">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width: 80px;">ID</th>
<th>키워드</th>
<th style="width: 120px;">상태</th>
<th style="width: 160px;">활성화</th>
</tr>
</thead>
<tbody>
<?php if ($result && $result->num_rows > 0): ?>
<?php while ($row = $result->fetch_assoc()): ?>
<tr>
<td class="text-muted"><?= $row['id'] ?></td>
<td><?= htmlspecialchars($row['keyword']) ?></td>
<td>
<?php if ($row['is_active']): ?>
<span class="badge badge-active">활성</span>
<?php else: ?>
<span class="badge badge-inactive">비활성</span>
<?php endif; ?>
</td>
<td>
<!-- 토글 스위치 -->
<form method="post" action="keyword_toggle.php">
<input type="hidden" name="id" value="<?= $row['id'] ?>">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
onchange="this.form.submit()"
<?= $row['is_active'] ? 'checked' : '' ?>
>
</div>
</form>
</td>
</tr>
<?php endwhile; ?>
<?php else: ?>
<tr>
<td colspan="4" class="text-center text-muted py-4">
등록된 키워드가 없습니다.
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php require_once 'footer.php'; ?>
④ keyword_save.php
<?php
require_once __DIR__ . "/../config/db.php";
$keyword = trim($_POST['keyword']);
if ($keyword === '') {
die("키워드를 입력하세요.");
}
$stmt = $conn->prepare("
INSERT INTO admin_keyword (keyword, is_active)
VALUES (?, 1)
");
$stmt->bind_param("s", $keyword);
$stmt->execute();
header("Location: keyword_list.php");
⑤ keyword_toggle.php
<?php
require_once 'header.php';
if (!isset($_POST['id'])) {
header("Location: keyword_list.php");
exit;
}
$id = (int)$_POST['id'];
/* 현재 상태 조회 */
$stmt = $conn->prepare("SELECT is_active FROM admin_keyword WHERE id = ?");
$stmt->bind_param("i", $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
if ($row) {
$newStatus = $row['is_active'] ? 0 : 1;
$update = $conn->prepare("
UPDATE admin_keyword
SET is_active = ?
WHERE id = ?
");
$update->bind_param("ii", $newStatus, $id);
$update->execute();
}
/* 다시 목록으로 */
header("Location: keyword_list.php");
exit;
⑥ batch_list.php
<?php
require_once 'header.php';
/* 페이지네이션 설정 */
$perPage = 10;
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$offset = ($page - 1) * $perPage;
/* 전체 개수 조회 */
$totalResult = $conn->query("SELECT COUNT(*) AS cnt FROM batch_run");
$totalRow = $totalResult->fetch_assoc();
$total = (int)$totalRow['cnt'];
$totalPages = ceil($total / $perPage);
/* 로그 목록 조회 */
$sql = "
SELECT
b.id,
b.run_key,
b.executed_at,
COUNT(r.id) AS cnt
FROM batch_run b
LEFT JOIN batch_result r ON b.id = r.run_id
GROUP BY b.id
ORDER BY b.executed_at DESC
LIMIT {$perPage} OFFSET {$offset}
";
$result = $conn->query($sql);
?>
<style>
/* 테이블 hover 파란 포인트 */
.table-hover tbody tr {
transition: background-color 0.15s ease-in-out;
}
.table-hover tbody tr:hover {
background-color: rgba(13, 110, 253, 0.05);
}
/* 상세 링크 */
.action-link {
color: #0d6efd;
text-decoration: none;
font-weight: 500;
}
.action-link:hover {
text-decoration: underline;
}
</style>
<h3 class="mb-4">자동화 실행 로그</h3>
<div class="card">
<div class="card-body p-0">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>run_key</th>
<th style="width: 200px;">실행시간</th>
<th style="width: 120px;">결과 수</th>
<th style="width: 100px;">상세</th>
</tr>
</thead>
<tbody>
<?php if ($result && $result->num_rows > 0): ?>
<?php while ($row = $result->fetch_assoc()): ?>
<tr>
<td>
<?= htmlspecialchars($row['run_key']) ?>
</td>
<td class="text-muted">
<?= $row['executed_at'] ?>
</td>
<td>
<?= $row['cnt'] ?>
</td>
<td>
<a
href="batch_detail.php?id=<?= $row['id'] ?>"
class="action-link"
>
보기
</a>
</td>
</tr>
<?php endwhile; ?>
<?php else: ?>
<tr>
<td colspan="4" class="text-center text-muted py-4">
실행 로그가 없습니다.
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- 페이지네이션 -->
<?php if ($totalPages > 1): ?>
<nav class="mt-4">
<ul class="pagination justify-content-center">
<!-- 이전 -->
<li class="page-item <?= $page <= 1 ? 'disabled' : '' ?>">
<a class="page-link" href="?page=<?= $page - 1 ?>">이전</a>
</li>
<?php for ($i = 1; $i <= $totalPages; $i++): ?>
<li class="page-item <?= $i === $page ? 'active' : '' ?>">
<a class="page-link" href="?page=<?= $i ?>">
<?= $i ?>
</a>
</li>
<?php endfor; ?>
<!-- 다음 -->
<li class="page-item <?= $page >= $totalPages ? 'disabled' : '' ?>">
<a class="page-link" href="?page=<?= $page + 1 ?>">다음</a>
</li>
</ul>
</nav>
<?php endif; ?>
<?php require_once 'footer.php'; ?>
⑦ batch_detail.php
<?php
include "header.php";
$run_id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
/* Top3 키워드 조회 */
$result = $conn->query("
SELECT
r.keyword,
MAX(CASE WHEN r.rank_no=1 THEN p.title END) AS r1,
MAX(CASE WHEN r.rank_no=2 THEN p.title END) AS r2,
MAX(CASE WHEN r.rank_no=3 THEN p.title END) AS r3
FROM batch_result r
JOIN place_cache p ON r.place_id = p.place_id
WHERE r.run_id = $run_id
GROUP BY r.keyword
");
?>
<h3 class="mb-4">키워드별 Top3</h3>
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-bordered mb-0 align-middle text-center">
<thead class="table-light">
<tr>
<th>키워드</th>
<th>1위</th>
<th>2위</th>
<th>3위</th>
</tr>
</thead>
<tbody>
<?php if ($result && $result->num_rows > 0): ?>
<?php while ($row = $result->fetch_assoc()): ?>
<tr>
<td class="text-start ps-3"><?= htmlspecialchars($row['keyword']) ?></td>
<td><?= htmlspecialchars($row['r1'] ?? '-') ?></td>
<td><?= htmlspecialchars($row['r2'] ?? '-') ?></td>
<td><?= htmlspecialchars($row['r3'] ?? '-') ?></td>
</tr>
<?php endwhile; ?>
<?php else: ?>
<tr>
<td colspan="4" class="text-center text-muted py-4">
데이터가 없습니다.
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<a href="batch_list.php" class="btn btn-outline-secondary mt-3">← 이전 페이지로</a>
<?php include "footer.php"; ?>
⑧ header.php
<?php
/* DB 연결 + 세션 */
require_once __DIR__ . '/../config/db.php';
/* 관리자 로그인 체크 */
if (!isset($_SESSION['user']) || $_SESSION['user']['role'] !== 'admin') {
header("Location: /neighborhood-place/index.php");
exit;
}
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>우리동네 장소 찾기 – 관리자</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #f5f6f8;
color: #212529;
}
/* ===== 상단 헤더 ===== */
.admin-navbar {
background-color: #ffffff;
border-bottom: 1px solid #e5e7eb;
}
.admin-navbar .navbar-brand {
font-weight: 700;
color: #212529;
}
.admin-navbar .navbar-brand span {
color: #0d6efd; /* 포인트 파랑 */
}
/* ===== 로그아웃 버튼 ===== */
.btn-logout {
border-color: #dee2e6;
color: #212529;
}
.btn-logout:hover {
background-color: #f1f3f5;
}
/* ===== 사이드바 ===== */
.admin-sidebar {
min-height: 100vh;
background-color: #ffffff;
border-right: 1px solid #e5e7eb;
}
.admin-sidebar .nav-link {
color: #495057;
padding: 12px 16px;
border-left: 3px solid transparent;
}
.admin-sidebar .nav-link:hover {
background-color: #f8f9fa;
color: #212529;
}
/* 현재 메뉴 */
.admin-sidebar .nav-link.active {
background-color: #eef4ff;
color: #0d6efd;
font-weight: 600;
border-left-color: #0d6efd;
}
/* ===== 콘텐츠 ===== */
.admin-content {
padding: 24px;
}
</style>
</head>
<body>
<!-- 상단 네비게이션 -->
<nav class="navbar admin-navbar">
<div class="container-fluid">
<span class="navbar-brand">
우리동네 장소 찾기 <span>관리자</span>
</span>
<a href="/neighborhood-place/logout.php"
class="btn btn-sm btn-logout">
로그아웃
</a>
</div>
</nav>
<!-- 본문 -->
<div class="container-fluid">
<div class="row">
<!-- 사이드바 -->
<aside class="col-md-2 col-lg-2 admin-sidebar p-0">
<nav class="nav flex-column mt-3">
<a class="nav-link <?= basename($_SERVER['PHP_SELF']) === 'dashboard.php' ? 'active' : '' ?>"
href="/neighborhood-place/admin/dashboard.php">
대시보드
</a>
<a class="nav-link <?= basename($_SERVER['PHP_SELF']) === 'keyword_list.php' ? 'active' : '' ?>"
href="/neighborhood-place/admin/keyword_list.php">
키워드 관리
</a>
<a class="nav-link <?= basename($_SERVER['PHP_SELF']) === 'batch_list.php' ? 'active' : '' ?>"
href="/neighborhood-place/admin/batch_list.php">
자동화 로그
</a>
</nav>
</aside>
<!-- 콘텐츠 -->
<main class="col-md-10 col-lg-10 admin-content">
⑨ footer.php
</main>
</div>
</div>
</body>
</html>
4-4. public
① index.php
<?php
require_once __DIR__ . '/_header.php';
/* =====================
관리자 추천 키워드
===================== */
$keywordResult = $conn->query("
SELECT keyword
FROM admin_keyword
WHERE is_active = 1
ORDER BY created_at DESC
LIMIT 6
");
/* =====================
오늘 TOP 3 검색어
===================== */
$topResult = $conn->query("
SELECT keyword, COUNT(*) AS cnt
FROM search_log
WHERE DATE(searched_at) = CURDATE()
GROUP BY keyword
ORDER BY cnt DESC
LIMIT 3
");
?>
<div class="container mt-5" style="max-width: 720px;">
<!-- 타이틀 -->
<h2 class="fw-bold mb-4 text-center">
Neighborhood Place
</h2>
<!-- 검색 -->
<form action="search.php" method="get" class="mb-5">
<div class="input-group input-group-lg">
<input
type="text"
name="keyword"
class="form-control"
placeholder="어디를 찾고 계신가요?"
required
>
<button class="btn btn-primary px-4">
검색
</button>
</div>
</form>
<!-- 추천 키워드 -->
<div class="mb-5">
<h5 class="mb-3"> 추천 키워드</h5>
<div class="d-flex flex-wrap gap-2">
<?php while ($row = $keywordResult->fetch_assoc()): ?>
<a href="search.php?keyword=<?= urlencode($row['keyword']) ?>"
class="badge rounded-pill text-bg-light border keyword-badge">
<?= htmlspecialchars($row['keyword']) ?>
</a>
<?php endwhile; ?>
</div>
</div>
<!-- 오늘 TOP 3 -->
<div>
<h5 class="mb-3"> 오늘 많이 검색된 키워드</h5>
<ul class="list-group">
<?php if ($topResult->num_rows === 0): ?>
<li class="list-group-item text-muted">
아직 검색 데이터가 없습니다.
</li>
<?php else: ?>
<?php $rank = 1; ?>
<?php while ($row = $topResult->fetch_assoc()): ?>
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
<?= $rank ?>. <?= htmlspecialchars($row['keyword']) ?>
</span>
<span class="badge bg-secondary">
<?= $row['cnt'] ?>회
</span>
</li>
<?php $rank++; ?>
<?php endwhile; ?>
<?php endif; ?>
</ul>
</div>
</div>
<style>
/* ===== 포인트 컬러 전략 ===== */
.keyword-badge {
cursor: pointer;
transition: all .2s ease;
}
.keyword-badge:hover {
background-color: #0d6efd;
color: #fff;
border-color: #0d6efd;
}
</style>
<?php require_once __DIR__ . '/_footer.php';
② search.php
<?php
session_start();
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/_header.php';
/* 로그인 체크 */
if (!isset($_SESSION['user']['id'])) {
header("Location: login.php");
exit;
}
$userId = (int)$_SESSION['user']['id'];
$keyword = trim($_GET['keyword'] ?? '');
$display = (int)($_GET['display'] ?? 5);
if ($display <= 0) $display = 5;
$results = [];
/* =========================
검색 로직
========================= */
if ($keyword !== '') {
/* 1 DB 캐시 검색 */
$stmt = $conn->prepare("
SELECT *
FROM place_cache
WHERE keyword LIKE CONCAT('%', ?, '%')
ORDER BY created_at DESC
LIMIT ?
");
$stmt->bind_param("si", $keyword, $display);
$stmt->execute();
$results = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
/* 2 없으면 네이버 API 호출 */
if (empty($results)) {
$clientId = "네이버검색 API CLIENTID";
$clientSecret = "네이버검색 API SECRET";
$apiUrl = "https://openapi.naver.com/v1/search/local.json?query="
. urlencode($keyword) . "&display=" . $display;
$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"X-Naver-Client-Id: {$clientId}",
"X-Naver-Client-Secret: {$clientSecret}"
]
]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
if (!empty($data['items'])) {
foreach ($data['items'] as $item) {
$placeId = md5($item['link']);
/* bind_param 오류 방지용 변수 */
$title = strip_tags($item['title']);
$address = $item['address'];
$roadAddress = $item['roadAddress'];
$telephone = $item['telephone'];
$link = $item['link'];
$mapx = $item['mapx'] / 10000000;
$mapy = $item['mapy'] / 10000000;
$stmt = $conn->prepare("
INSERT IGNORE INTO place_cache
(place_id, title, address, road_address, telephone, link, mapx, mapy, keyword)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
");
$stmt->bind_param(
"sssssssss",
$placeId,
$title,
$address,
$roadAddress,
$telephone,
$link,
$mapx,
$mapy,
$keyword
);
$stmt->execute();
}
/* 3 다시 DB 조회 */
$stmt = $conn->prepare("
SELECT *
FROM place_cache
WHERE keyword LIKE CONCAT('%', ?, '%')
LIMIT ?
");
$stmt->bind_param("si", $keyword, $display);
$stmt->execute();
$results = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
}
}
}
/* =========================
즐겨찾기
========================= */
$favoriteIds = [];
if (!empty($results)) {
$placeIds = array_map(fn($row) => $row['place_id'], $results);
$placeholder = implode(',', array_fill(0, count($placeIds), '?'));
$types = str_repeat('s', count($placeIds));
$stmt = $conn->prepare("
SELECT place_id
FROM favorite_place
WHERE user_id = ? AND place_id IN ($placeholder)
");
$stmt->bind_param("i".$types, $userId, ...$placeIds);
$stmt->execute();
$res = $stmt->get_result();
while ($row = $res->fetch_assoc()) {
$favoriteIds[] = $row['place_id'];
}
}
?>
<style>
#map{
width:100%;
height:450px;
min-height:450px;
border-radius:12px;
}
.place-card{transition:.2s;cursor:pointer;}
.place-card:hover{border-color:#0d6efd;box-shadow:0 4px 12px rgba(13,110,253,.15);}
.place-title{font-weight:600;}
.place-meta{font-size:.9rem;color:#6c757d;}
</style>
<h3 class="mb-4">장소 검색</h3>
<form method="get" class="row g-2 mb-4" id="searchForm">
<div class="col-md-6">
<input type="text" name="keyword" class="form-control"
placeholder="예: 광교 카페"
value="<?= htmlspecialchars($keyword) ?>" required>
</div>
<div class="col-md-2">
<select name="display" class="form-select">
<option value="5" <?= $display === 5 ? 'selected' : '' ?>>5개</option>
<option value="10" <?= $display === 10 ? 'selected' : '' ?>>10개</option>
<option value="20" <?= $display === 20 ? 'selected' : '' ?>>20개</option>
</select>
</div>
<div class="col-md-auto">
<button class="btn btn-primary">검색</button>
</div>
</form>
<div id="map" class="mb-4"></div>
<?php if ($keyword === ''): ?>
<div class="text-muted">키워드를 입력해 검색해 주세요.</div>
<?php elseif (empty($results)): ?>
<div class="alert alert-warning">검색 결과가 없습니다.</div>
<?php else: ?>
<div class="mb-3 text-muted">검색 결과 <?= count($results) ?>개</div>
<div class="row g-3">
<?php foreach ($results as $row):
$placeId = $row['place_id'];
$isFavorite = in_array($placeId, $favoriteIds);
?>
<div class="col-md-6">
<div class="card place-card h-100">
<div class="card-body">
<div class="place-title mb-1"><?= htmlspecialchars($row['title']) ?></div>
<div class="place-meta mb-1"><?= htmlspecialchars($row['road_address'] ?: $row['address']) ?></div>
<?php if ($row['telephone']): ?>
<div class="place-meta"> <?= htmlspecialchars($row['telephone']) ?></div>
<?php endif; ?>
<div class="mt-3">
<a href="place_detail.php?place_id=<?= urlencode($placeId) ?>"
class="btn btn-sm btn-outline-primary">상세보기</a>
<button type="button"
class="btn btn-sm btn-outline-warning"
onclick="toggleFavorite('<?= addslashes($placeId) ?>', this)">
<?= $isFavorite ? '★ 즐겨찾기 삭제' : '☆ 즐겨찾기 추가' ?>
</button>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<script>
function initMap(){
const center = new naver.maps.LatLng(37.5665,126.9780);
const map = new naver.maps.Map("map",{
center:center,
zoom:12
});
const places = <?= json_encode($results, JSON_UNESCAPED_UNICODE) ?>;
if(places.length>0){
places.forEach(place=>{
if(!place.mapx || !place.mapy) return;
new naver.maps.Marker({
position:new naver.maps.LatLng(place.mapy,place.mapx),
map:map,
title:place.title
});
});
const first = places[0];
if(first.mapx && first.mapy){
map.setCenter(new naver.maps.LatLng(first.mapy,first.mapx));
}
}
}
/* 네이버 지도 SDK */
const s = document.createElement("script");
s.src="https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=네이버MAP API CLIENTID";
s.onload=initMap;
document.head.appendChild(s);
function toggleFavorite(placeId,btn){
const fd=new FormData();
fd.append("place_id",placeId);
fetch("../api/favorite_toggle.php",{
method:"POST",
body:fd,
credentials:"same-origin"
})
.then(r=>r.json())
.then(d=>{
if(d.success){
btn.textContent=d.action==="added"
?"★ 즐겨찾기 삭제"
:"☆ 즐겨찾기 추가";
}
});
}
</script>
<?php require_once __DIR__ . '/_footer.php'; ?>
ncpKeyId 값은 NAVER MAP API의 CLIENTID 값으로 바꿔주기
$clientId, $clientSecret 두개의 값은 NAVER 검색 API 값으로 바꿔주기
~만약 방법을 모르신다면 아래 블로그 참고해서 만들어주세요
네이버 지도 api로 '우리동네 장소 찾기' 만들기 1 - 네이버 지도 api 좌표 문제 해결 방법
1. 진행 순서 시나리오 → 아키텍처 → ERD → 샘플 데이터 검증 → PHP 앱(관리자, 사용자) 시나리오 : 사람이 하는 일을 "트리거-입력-규칙-출력-저장"으로 변경한다아키텍처 : 액터/시스템/데이터
tiny-immj.tistory.com
③ favorites.php
<?php
require_once '_header.php';
/* 로그인 체크 */
if (!isset($_SESSION['user'])) {
header("Location: /neighborhood-place/public/login.php");
exit;
}
$userId = $_SESSION['user']['id'];
/* 즐겨찾기 목록 조회 */
$stmt = $conn->prepare("
SELECT
p.place_id,
p.title,
p.address,
p.road_address,
p.telephone,
p.created_at
FROM favorite_place f
JOIN place_cache p ON f.place_id = p.place_id
WHERE f.user_id = ?
ORDER BY f.created_at DESC
");
$stmt->bind_param("i", $userId);
$stmt->execute();
$favorites = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
?>
<style>
.place-card {
transition: all 0.2s ease;
}
.place-card:hover {
border-color: #dc3545;
box-shadow: 0 4px 12px rgba(220,53,69,0.15);
}
.place-title {
font-weight: 600;
}
.place-meta {
font-size: 0.9rem;
color: #6c757d;
}
</style>
<div class="container py-4">
<h3 class="mb-4"> 내 즐겨찾기</h3>
<?php if (empty($favorites)): ?>
<div class="alert alert-secondary">
아직 즐겨찾기한 장소가 없습니다.
</div>
<?php else: ?>
<div class="mb-3 text-muted">
총 <?= count($favorites) ?>곳
</div>
<div class="row g-3">
<?php foreach ($favorites as $row): ?>
<div class="col-md-6">
<div class="card place-card h-100">
<div class="card-body">
<div class="place-title mb-1">
<?= htmlspecialchars($row['title']) ?>
</div>
<div class="place-meta mb-1">
<?= htmlspecialchars($row['road_address'] ?: $row['address']) ?>
</div>
<?php if ($row['telephone']): ?>
<div class="place-meta">
<?= htmlspecialchars($row['telephone']) ?>
</div>
<?php endif; ?>
<div class="mt-3">
<a
href="place_detail.php?place_id=<?= urlencode($row['place_id']) ?>"
class="btn btn-sm btn-outline-primary"
>
상세보기
</a>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php require_once '_footer.php'; ?>
④ place_detail.php
<?php
require_once '_header.php';
/* place_id 체크 */
$placeId = $_GET['place_id'] ?? '';
if ($placeId === '') {
echo "<div class='container py-4'>잘못된 접근입니다.</div>";
require_once '_footer.php';
exit;
}
/* 장소 조회 */
$stmt = $conn->prepare("
SELECT *
FROM place_cache
WHERE place_id = ?
");
$stmt->bind_param("s", $placeId);
$stmt->execute();
$place = $stmt->get_result()->fetch_assoc();
if (!$place) {
echo "<div class='container py-4'>존재하지 않는 장소입니다.</div>";
require_once '_footer.php';
exit;
}
/* 로그인 여부 */
$isLogin = isset($_SESSION['user']);
$isFavorite = false;
/* 로그인 상태면 즐겨찾기 여부 확인 */
if ($isLogin) {
$userId = $_SESSION['user']['id'];
$favStmt = $conn->prepare("
SELECT 1
FROM favorite_place
WHERE user_id = ? AND place_id = ?
");
$favStmt->bind_param("is", $userId, $placeId);
$favStmt->execute();
$isFavorite = (bool)$favStmt->get_result()->fetch_row();
}
?>
<style>
.place-title {
font-weight: 700;
}
.place-meta {
color: #6c757d;
font-size: 0.95rem;
}
.favorite-btn {
border-radius: 20px;
}
</style>
<div class="container py-4">
<a href="javascript:history.back()" class="text-decoration-none text-muted">
← 뒤로가기
</a>
<div class="card mt-3">
<div class="card-body">
<h4 class="place-title mb-2">
<?= htmlspecialchars($place['title']) ?>
</h4>
<div class="place-meta mb-1">
<?= htmlspecialchars($place['road_address'] ?: $place['address']) ?>
</div>
<?php if ($place['telephone']): ?>
<div class="place-meta mb-2">
<?= htmlspecialchars($place['telephone']) ?>
</div>
<?php endif; ?>
<?php if ($place['link']): ?>
<div class="mb-3">
<a href="<?= htmlspecialchars($place['link']) ?>" target="_blank">
네이버 장소 보기 →
</a>
</div>
<?php endif; ?>
<!-- 즐겨찾기 버튼 -->
<?php if ($isLogin): ?>
<button
id="favoriteBtn"
class="btn <?= $isFavorite ? 'btn-outline-danger' : 'btn-outline-primary' ?> favorite-btn"
data-favorite="<?= $isFavorite ? '1' : '0' ?>"
onclick="toggleFavorite()"
>
<?= $isFavorite ? '★ 즐겨찾기 해제' : '☆ 즐겨찾기 추가' ?>
</button>
<?php else: ?>
<div class="text-muted">
즐겨찾기는 로그인 후 이용할 수 있어요
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php if ($isLogin): ?>
<script>
function toggleFavorite() {
const btn = document.getElementById('favoriteBtn');
fetch('/neighborhood-place/api/favorite_toggle.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'place_id=<?= urlencode($placeId) ?>'
})
.then(res => res.json())
.then(data => {
if (!data.success) {
alert(data.message || '처리 실패');
return;
}
// 서버 응답 기준으로 UI 즉시 반영
if (data.is_favorite) {
btn.dataset.favorite = '1';
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-outline-danger');
btn.textContent = '★ 즐겨찾기 해제';
} else {
btn.dataset.favorite = '0';
btn.classList.remove('btn-outline-danger');
btn.classList.add('btn-outline-primary');
btn.textContent = '☆ 즐겨찾기 추가';
}
})
.catch(() => {
alert('서버 오류');
});
}
</script>
<?php endif; ?>
<?php require_once '_footer.php'; ?>
⑤ recent.php
<?php
session_start();
require_once __DIR__ . '/../config/db.php';
/* 로그인 확인 */
if (!isset($_SESSION['user']['id'])) {
header('Location: login.php');
exit;
}
$userId = (int)$_SESSION['user']['id'];
/* 최근 검색 기록 조회 (최대 20개) */
$stmt = $conn->prepare("
SELECT id, keyword, display_count, searched_at
FROM search_log
WHERE user_id = ?
ORDER BY searched_at DESC
LIMIT 20
");
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
$logs = $result->fetch_all(MYSQLI_ASSOC);
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>최근 검색 기록</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
button { padding: 4px 8px; cursor: pointer; }
</style>
<script>
function redoSearch(keyword, displayCount) {
// search.php로 GET 요청
const form = document.createElement('form');
form.method = 'GET';
form.action = 'search.php';
const kInput = document.createElement('input');
kInput.type = 'hidden';
kInput.name = 'keyword';
kInput.value = keyword;
const dInput = document.createElement('input');
dInput.type = 'hidden';
dInput.name = 'display_count';
dInput.value = displayCount;
form.appendChild(kInput);
form.appendChild(dInput);
document.body.appendChild(form);
form.submit();
}
</script>
</head>
<body>
<h2>최근 검색 기록</h2>
<table>
<thead>
<tr>
<th>검색 시간</th>
<th>키워드</th>
<th>표시 개수</th>
<th>다시 검색</th>
</tr>
</thead>
<tbody>
<?php if (!empty($logs)): ?>
<?php foreach ($logs as $log): ?>
<tr>
<td><?= htmlspecialchars($log['searched_at']) ?></td>
<td><?= htmlspecialchars($log['keyword']) ?></td>
<td><?= htmlspecialchars($log['display_count']) ?></td>
<td>
<button onclick="redoSearch('<?= addslashes($log['keyword']) ?>', <?= (int)$log['display_count'] ?>)">
검색
</button>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="4">검색 기록이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</body>
</html>
⑥ _header.php
<?php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once __DIR__ . '/../config/db.php';
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>우리동네 장소 찾기</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #f5f6f8;
}
.navbar-brand {
font-weight: 700;
letter-spacing: -0.5px;
}
.nav-link {
font-weight: 500;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
<div class="container">
<!-- 로고 -->
<a class="navbar-brand" href="/neighborhood-place/public/index.php">
우리동네 장소 찾기
</a>
<!-- 메뉴 -->
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="/neighborhood-place/public/index.php">홈</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/neighborhood-place/public/search.php">검색</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/neighborhood-place/public/favorites.php">즐겨찾기</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/neighborhood-place/public/recent.php">최근검색</a>
</li>
</ul>
<!-- 로그인 영역 -->
<ul class="navbar-nav">
<?php if (isset($_SESSION['user'])): ?>
<li class="nav-item">
<span class="nav-link text-muted">
<?= htmlspecialchars($_SESSION['user']['email']) ?>
</span>
</li>
<li class="nav-item">
<a class="nav-link" href="/neighborhood-place/logout.php">로그아웃</a>
</li>
<?php else: ?>
<li class="nav-item">
<a class="nav-link" href="/neighborhood-place/public/login.php">로그인</a>
</li>
<?php endif; ?>
</ul>
</div>
</nav>
<div class="container py-4">
⑦_footer.php
</div>
</body>
</html>
4-5. api
① recent_log.php
<?php
session_start();
require_once __DIR__ . '/../config/db.php'; // DB 연결
header('Content-Type:application/json');
/*로그인 확인*/
if(!isset($_SESSION['user']['id'])) {
http_response_code(401);
echo json_encode(['error' => '로그인이 필요합니다.']);
exit;
}
$userId = (int)$_SESSION['user']['id'];
$keyword = trim($_POST['keyword'] ?? '');
$displayCount = (int)($POST['display_count'] ?? 5);
if ($keyword === ") {
http_response_code(400);
echo json_encode(['error' => '키워드를 입력해주세요.']);
exit;
}
/* 검색 로그 저장 */
$stmt = $conn->prepare("
INSERT INTO search_log (user_id, keyword, display_count, searched_at)
VALUES (?, ?, ?, NOW())
");
$stmt->bind_param("isi", $userId, $keyword, $displayCount);
if ($stmt->execute()) {
echo json_encode(['success' => true]);
} else {
http_response_code(500);
echo json_encode(['error' => 'DB 저장 실패']);
}
② favorite_toggle.php
<?php
session_start();
require_once __DIR__ . '/../config/db.php';
header('Content-Type: application/json');
/* 로그인 체크 */
if (!isset($_SESSION['user'])) {
echo json_encode([
'success' => false,
'message' => '로그인이 필요합니다.'
]);
exit;
}
$userId = $_SESSION['user']['id'];
$placeId = $_POST['place_id'] ?? '';
if ($placeId === ? '') {
echo json_encode([
'success' => false,
'message' => 'place_id 누락'
]);
exit;
}
/* 이미 즐겨찾기인지 확인 */
$stmt = $conn->prepare("
SELECT id
FROM favorite_place
WHERE user_id = ? AND place_id = ?
");
$stmt->bind_param("is", $userId, $placeId);
$stmt->execute();
$exists = $ stmt->get_result()->fetch_assoc();
if ($exists) {
/* 이미 있으면 삭제 */
$del = $conn->prepare("
DELETE FROM favorite_place
WHERE user_id = ? AND place_id = ?
");
$del->bind_param("is", $userId, $placeId);
$del->execute();
echo json_encode([
'success' => true,
'is_favorite' => false
]);
} else {
/* 없으면 추가 */
$ins = $conn->prepare("
INSERT INTO favorite_place (user_id, place_id)
VALUES (?, ?)
");
$ins->bind_param("is", $userId, $placeId);
$ins->executed();
echo json_encode([
'success' => true,
'is_favorite' => true
]);
}
③ search_place.php
<?php
session_start();
require_once __DIR__ . '/../config/db.php'; // DB 연결
header('Content-Type:application/json');
/* 로그인 확인 */
if(!isset($_SESSION['user']['id'])) {
http_response_code(401);
echo json_encode(['error' => '로그인이 필요합니다.']);
exit;
}
$userId = (int)$_SESSION['user']['id'];
/* GET 파라미터 */
$keyword = trim($_GET['keyword'] ?? '');
$displayCount = (int)($_GET['display_count'] ?? 5);
if ($displayCount <= 0) $displayCount = 5;
if ($keyword === '') {
http_response_code(400);
echo json_encode(['error' => '검색 키워드를 입력해주세요.']);
exit;
}
/* DB에서 장소 검색 */
$stmt = $conn->prepare("
SELECT *
FROM place_cache
WHERE keyword LIKE CONCAT('%', ?, '%')
ORDER BY created_at DESC
LIMIT ?
");
$stmt->bind_params
5. 구현 화면



이번 프로젝트에서는 단순히 API를 호출하는게 아니라
설계 → DB → 검증 → 앱구현 → 문제 해결까지 흐름을 기반으로 개발을 진행했습니다