본문 바로가기
카테고리 없음

네이버 지도 API로 '우리동네 장소 찾기' 2 - API를 사용해 관리자, 사용자 기능 개발

by jeong11 2026. 2. 23.

* 지도 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 → 검증 → 앱구현 → 문제 해결까지 흐름을 기반으로 개발을 진행했습니다