개발 철학

React와 PHP의 완벽한 하이브리드: SEO를 지키면서 현대적인 UX 제공하기

11
이온디
React와 PHP의 완벽한 하이브리드: SEO를 지키면서 현대적인 UX 제공하기

React와 PHP의 완벽한 하이브리드: SEO를 지키면서 현대적인 UX 제공하기

React와 PHP의 완벽한 하이브리드: SEO를 지키면서 현대적인 UX 제공하기

들어가며: 이상적인 웹의 조건

지난 3년간의 XE/Rhymix 프로젝트에서 깨달은 가장 중요한 교훈은 다음과 같습니다:

"SPA의 빠른 UX와 SEO 친화성은 상충관계가 아니다"

React같은 SPA는 검색 엔진 최적화에 불리하다는 고정관념이 있습니다. 하지만 API 모듈(el_api, eb_api 등)을 통해 PHP에서 데이터를 먼저 렌더링한 후 React를 사용하면, 두 마리 토끼를 모두 잡을 수 있습니다.

Traditional SPA (SEO 문제) ❌
┌─────────────────────────────────────┐
│ HTML 스켈레톤 (내용 없음)            │
│ + 클라이언트 사이드 React 렌더링      │
│ → 크롤러: "내용이 없네요"            │
└─────────────────────────────────────┘

Hybrid SSR + React (SEO 최적) ✅
┌─────────────────────────────────────┐
│ PHP에서 미리 렌더링된 완전한 HTML     │
│ + React로 인터랙티브하게 업그레이드   │
│ → 크롤러: "좋은 컨텐츠군요"          │
└─────────────────────────────────────┘

이 글에서는 실제 프로젝트에서 구현한 하이브리드 아키텍처를 소개합니다.

Part 1: 하이브리드 아키텍처 설계

1.1 시스템 구조

┌──────────────────────────────────────────────────────┐
│                     클라이언트 (브라우저)              │
├──────────────────────────────────────────────────────┤
│ 1. PHP로 렌더링된 HTML (완전한 콘텐츠)                │
│ 2. React로 인터랙티브하게 향상 (Progressive Enhancement)│
│ 3. API로 상태 동기화 (실시간 업데이트)                │
└──────────────────────────────────────────────────────┘
           ↑                    ↓
      초기 렌더링            실시간 업데이트
       (SEO)                 (UX)

┌──────────────────────────────────────────────────────┐
│                        서버 (PHP)                      │
├──────────────────────────────────────────────────────┤
│ 1. Blade 템플릿으로 HTML 렌더링                       │
│    (XE 데이터 활용)                                  │
│ 2. REST API 제공 (/api/* 엔드포인트)                 │
│    (React 클라이언트를 위한 JSON)                    │
│ 3. 권한 체크 및 캐싱                                 │
│    (성능 최적화)                                     │
└──────────────────────────────────────────────────────┘

1.2 데이터 흐름 다이어그램

사용자 초기 방문
    ↓
┌─────────────────────────────────────────┐
│ 1단계: PHP SSR (서버 사이드 렌더링)      │
├─────────────────────────────────────────┤
│ - XE 데이터 조회                        │
│ - Blade 템플릿으로 HTML 생성            │
│ - 메타 태그, Open Graph 삽입            │
│ - 초기 상태(props)를 데이터 속성으로    │
└─────────────────────────────────────────┘
    ↓
   HTML 전송 (이미 완전한 콘텐츠!)
    ↓
┌─────────────────────────────────────────┐
│ 2단계: React Hydration (클라이언트)      │
├─────────────────────────────────────────┤
│ - React 초기화                          │
│ - 이벤트 리스너 바인딩                  │
│ - 상태 관리 설정                        │
│ - DOM 동기화 (매우 빠름)                │
└─────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────┐
│ 3단계: 인터랙션 처리                     │
├─────────────────────────────────────────┤
│ - 사용자 입력에 따라 API 호출            │
│ - 상태 업데이트 및 UI 재렌더링          │
│ - 페이지 전환 (부분 로딩)               │
└─────────────────────────────────────────┘

Part 2: 실전 구현 - 게시판 예시

2.1 PHP 렌더링 계층 (Blade 템플릿)

<!-- modules/board/skins/my_skin/list.blade.php -->
@version(2)

<!-- SEO 메타 태그 -->
<meta name="description" content="{{ $module_info->description }}">
<meta property="og:title" content="{{ $module_info->browser_title }}">
<meta property="og:description" content="{{ $module_info->description }}">
<meta property="og:url" content="{{ getFullUrl() }}">
<meta property="og:type" content="website">

<!-- 구조화된 데이터 (JSON-LD) -->
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "CollectionPage",
  "name": "{{ $module_info->browser_title }}",
  "description": "{{ $module_info->description }}",
  "url": "{{ getFullUrl() }}",
  "itemListElement": [
    @foreach($document_list->data as $doc)
    {
      "@type": "BlogPosting",
      "headline": "{{ $doc->title }}",
      "author": "{{ $doc->nick_name }}",
      "datePublished": "{{ date('c', $doc->regdate('U')) }}",
      "url": "{{ getUrl('document_srl', $doc->document_srl) }}"
    }{{ !$loop->last ? ',' : '' }}
    @endforeach
  ]
}
</script>

<!-- React 마운트 포인트 -->
<div id="board-root" x-data="boardList()" @click.away="closeMenu()">

    <!-- 검색 및 필터 UI (PHP에서 렌더링) -->
    <div class="board-header">
        <h1>{{ $module_info->browser_title }}</h1>

        <!-- 카테고리 필터 -->
        <div class="filter-group">
            <select @change="category = $event.target.value" class="form-control">
                <option value="">전체</option>
                @foreach($category_list as $cat)
                <option value="{{ $cat->category_srl }}"
                        :selected="category === '{{ $cat->category_srl }}'">
                    {{ $cat->category_name }}
                </option>
                @endforeach
            </select>
        </div>

        <!-- 정렬 옵션 -->
        <div class="sort-group">
            <button @click="sortBy = 'recent'"
                    :class="{ active: sortBy === 'recent' }"
                    class="btn btn-sort">
                최신순
            </button>
            <button @click="sortBy = 'popular'"
                    :class="{ active: sortBy === 'popular' }"
                    class="btn btn-sort">
                인기순
            </button>
            <button @click="sortBy = 'comments'"
                    :class="{ active: sortBy === 'comments' }"
                    class="btn btn-sort">
                댓글순
            </button>
        </div>

        <!-- 검색 -->
        <div class="search-form">
            <input type="text"
                   x-model.debounce.500ms="searchQuery"
                   @keydown.enter="search()"
                   placeholder="검색어를 입력하세요"
                   class="form-control">
            <button @click="search()" class="btn btn-primary">검색</button>
        </div>
    </div>

    <!-- 게시글 목록 (PHP에서 초기 렌더링, React로 인터랙티브화) -->
    <div class="board-list">
        @if($document_list->data)
            @foreach($document_list->data as $doc)
            <article class="board-item"
                     :data-document-srl="{{ $doc->document_srl }}"
                     @click="selectDocument({{ $doc->document_srl }})">

                <!-- 제목 -->
                <h3 class="item-title">
                    <a href="{{ getUrl('document_srl', $doc->document_srl) }}"
                       @click.prevent="viewDocument({{ $doc->document_srl }})">
                        {{ $doc->title }}
                    </a>

                    @if($doc->isNew())
                    <span class="badge badge-new">새글</span>
                    @endif

                    @if($doc->getCommentCount() > 0)
                    <span class="comment-count">[{{ $doc->getCommentCount() }}]</span>
                    @endif
                </h3>

                <!-- 메타 정보 -->
                <div class="item-meta">
                    <span class="author">{{ $doc->nick_name }}</span>
                    <time datetime="{{ date('c', $doc->regdate('U')) }}">
                        {{ zdate($doc->regdate(), 'Y.m.d H:i') }}
                    </time>
                    <span class="view-count">조회 {{ $doc->getReadCount() }}</span>
                </div>

                <!-- 요약 -->
                <p class="item-summary">
                    {{ $doc->getSummary(150) }}
                </p>

                <!-- 카테고리 및 태그 -->
                <div class="item-tags">
                    @if($doc->category_name)
                    <span class="category">{{ $doc->category_name }}</span>
                    @endif

                    @foreach($doc->getTags() as $tag)
                    <span class="tag" @click.stop="filterByTag('{{ $tag }}')">
                        #{{ $tag }}
                    </span>
                    @endforeach
                </div>

                <!-- 액션 버튼 (Alpine.js로 처리) -->
                <div class="item-actions">
                    <button @click.stop="toggleLike({{ $doc->document_srl }})"
                            :class="{ liked: isLiked({{ $doc->document_srl }}) }}"
                            class="btn btn-like">
                        ♥ {{ $doc->getLikeCount() }}
                    </button>
                    <button @click.stop="shareDocument({{ $doc->document_srl }})"
                            class="btn btn-share">
                        공유
                    </button>
                </div>
            </article>
            @endforeach
        @else
        <div class="empty-state">
            <p>게시글이 없습니다</p>
        </div>
        @endif
    </div>

    <!-- 페이지네이션 (PHP에서 생성) -->
    <nav class="pagination" aria-label="페이지네이션">
        {{ $page_navigation->getPageList() }}
    </nav>

    <!-- 글쓰기 버튼 (권한 체크) -->
    @if($grant->write_document)
    <div class="board-footer">
        <button @click="openWriteForm()"
                class="btn btn-primary btn-lg">
            글쓰기
        </button>
    </div>
    @endif

</div>

<!-- React 및 Alpine.js 초기화 -->
<script>
// Alpine.js 상태 (클라이언트 사이드)
function boardList() {
    return {
        // 필터 상태
        category: '{{ request()->query("category") ?? "" }}',
        sortBy: 'recent',
        searchQuery: '{{ request()->query("search") ?? "" }}',

        // 초기 데이터 (PHP에서 주입)
        documents: {{ json_encode($document_list->data ?? []) }},
        totalCount: {{ $document_list->total_count ?? 0 }},
        currentPage: {{ $page ?? 1 }},

        // UI 상태
        isLoading: false,
        selectedDocumentId: null,
        likedDocuments: this.loadLikedFromStorage(),

        // 권한
        canWrite: {{ $grant->write_document ? 'true' : 'false' }},
        canDelete: {{ $grant->delete_document ? 'true' : 'false' }},

        init() {
            // 필터 변경 시 자동 로드
            this.$watch('category', () => this.loadDocuments());
            this.$watch('sortBy', () => this.loadDocuments());
        },

        async loadDocuments() {
            this.isLoading = true;

            try {
                const params = new URLSearchParams({
                    page: this.currentPage,
                    category: this.category,
                    sort: this.sortBy,
                    search: this.searchQuery
                });

                const response = await fetch(
                    `/api/board/documents?${params}`
                );

                if (response.ok) {
                    const data = await response.json();
                    this.documents = data.documents || [];
                    this.totalCount = data.total_count || 0;
                } else {
                    throw new Error('게시글 로드 실패');
                }

            } catch (err) {
                console.error(err);
                alert('게시글을 불러올 수 없습니다');
            } finally {
                this.isLoading = false;
            }
        },

        async search() {
            this.currentPage = 1;
            await this.loadDocuments();
        },

        viewDocument(documentSrl) {
            window.location.href = `/board/view/${documentSrl}/`;
        },

        selectDocument(documentSrl) {
            this.selectedDocumentId = documentSrl;
        },

        async toggleLike(documentSrl) {
            const wasLiked = this.isLiked(documentSrl);

            // 낙관적 업데이트
            const doc = this.documents.find(d => d.document_srl === documentSrl);
            if (doc) {
                doc.liked_count += wasLiked ? -1 : 1;
            }

            // 로컬 스토리지 업데이트
            if (wasLiked) {
                this.likedDocuments = this.likedDocuments.filter(
                    id => id !== documentSrl
                );
            } else {
                this.likedDocuments.push(documentSrl);
            }
            this.saveLikedToStorage();

            // 서버에 동기화
            try {
                await fetch('/api/board/like', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRF-Token': this.getCsrfToken()
                    },
                    body: JSON.stringify({
                        document_srl: documentSrl,
                        action: wasLiked ? 'unlike' : 'like'
                    })
                });
            } catch (err) {
                console.error('좋아요 동기화 실패:', err);
            }
        },

        isLiked(documentSrl) {
            return this.likedDocuments.includes(documentSrl);
        },

        loadLikedFromStorage() {
            const stored = localStorage.getItem('board_liked_documents');
            return stored ? JSON.parse(stored) : [];
        },

        saveLikedToStorage() {
            localStorage.setItem(
                'board_liked_documents',
                JSON.stringify(this.likedDocuments)
            );
        },

        filterByTag(tag) {
            this.searchQuery = `tag:${tag}`;
            this.search();
        },

        shareDocument(documentSrl) {
            const doc = this.documents.find(d => d.document_srl === documentSrl);
            if (navigator.share) {
                navigator.share({
                    title: doc.title,
                    text: doc.summary,
                    url: window.location.href
                });
            } else {
                alert('공유 기능을 지원하지 않는 브라우저입니다');
            }
        },

        openWriteForm() {
            if (!this.canWrite) {
                alert('글쓰기 권한이 없습니다');
                return;
            }
            window.location.href = '/board/write/';
        },

        getCsrfToken() {
            return document.querySelector('meta[name="csrf-token"]')
                ?.getAttribute('content') || '';
        }
    }
}
</script>

<load target="css/board.css" />
<load target="js/alpine.min.js" type="head" />

2.2 REST API 계층 (PHP/XE 백엔드)

<!-- modules/board_api/apis/ -->

// 게시글 목록 API
class BoardDocumentsAPI extends Controller {
    public function get() {
        $module_srl = Context::get('module_srl');
        $page = max(1, (int)Context::get('page'));
        $category = Context::get('category');
        $search = Context::get('search');
        $sort = Context::get('sort') ?? 'recent';

        $args = new stdClass();
        $args->module_srl = $module_srl;
        $args->page = $page;
        $args->list_count = 20;
        $args->category_srl = $category ?: null;
        $args->search_keyword = $search;
        $args->sort_index = $this->getSortIndex($sort);

        // 캐시 활용 (5분)
        $cache_key = 'board_list_' . md5(serialize($args));
        $output = Context::getCache($cache_key);

        if (!$output) {
            $oDocumentModel = getModel('document');
            $output = $oDocumentModel->getDocumentList($args);
            Context::setCache($cache_key, $output, 300);
        }

        // JSON 응답
        return new JSONResponse([
            'success' => true,
            'documents' => $this->formatDocuments($output->data),
            'total_count' => $output->total_count,
            'page' => $page,
            'page_count' => ceil($output->total_count / 20)
        ]);
    }

    private function formatDocuments($documents) {
        $formatted = [];

        foreach ($documents as $doc) {
            $formatted[] = [
                'document_srl' => $doc->document_srl,
                'title' => $doc->title,
                'summary' => $doc->summary ?: substr(
                    strip_tags($doc->content), 0, 150
                ),
                'nick_name' => $doc->nick_name,
                'regdate' => date('Y-m-d H:i', $doc->regdate('U')),
                'regdate_raw' => date('c', $doc->regdate('U')),
                'read_count' => $doc->getReadCount(),
                'comment_count' => $doc->getCommentCount(),
                'like_count' => $doc->getLikeCount(),
                'category_name' => $doc->getCategory()->category_name ?? null,
                'tags' => $doc->getTags()
            ];
        }

        return $formatted;
    }

    private function getSortIndex($sort) {
        $sorts = [
            'recent' => 'list_order',
            'popular' => 'read_count',
            'comments' => 'comment_count'
        ];
        return $sorts[$sort] ?? 'list_order';
    }
}

// 좋아요 API
class BoardLikeAPI extends Controller {
    public function post() {
        $document_srl = (int)Context::getRequestMethod('post')->document_srl;
        $action = Context::getRequestMethod('post')->action;

        if (!$document_srl) {
            return new JSONResponse([
                'success' => false,
                'message' => '잘못된 요청입니다'
            ], 400);
        }

        // 로그인 여부 확인
        if (!Context::get('is_logged')) {
            return new JSONResponse([
                'success' => false,
                'message' => '로그인이 필요합니다'
            ], 401);
        }

        $logged_info = Context::get('logged_info');
        $document_srl_key = $logged_info->member_srl . '_' . $document_srl;

        if ($action === 'like') {
            // 좋아요 추가
            $cache_key = 'board_like_' . $document_srl_key;
            Context::setCache($cache_key, true, 86400 * 365);

            // DB에도 저장
            $query = sprintf(
                "INSERT INTO xe_board_likes (member_srl, document_srl, created_at)
                 VALUES (%d, %d, NOW())
                 ON DUPLICATE KEY UPDATE created_at = NOW()",
                $logged_info->member_srl,
                $document_srl
            );

            executeQuery($query);

            return new JSONResponse(['success' => true]);

        } else if ($action === 'unlike') {
            // 좋아요 제거
            $cache_key = 'board_like_' . $document_srl_key;
            Context::deleteCache($cache_key);

            // DB에서 삭제
            $query = sprintf(
                "DELETE FROM xe_board_likes
                 WHERE member_srl = %d AND document_srl = %d",
                $logged_info->member_srl,
                $document_srl
            );

            executeQuery($query);

            return new JSONResponse(['success' => true]);
        }

        return new JSONResponse([
            'success' => false,
            'message' => '알 수 없는 작업입니다'
        ], 400);
    }
}

2.3 상세 페이지 (SSR + React Hydration)

<!-- modules/board/skins/my_skin/view.blade.php -->
@version(2)

<!-- SEO 최적화 -->
<meta name="description" content="{{ $document->getSummary(160) }}">
<meta name="keywords" content="{{ implode(',', $document->getTags()) }}">
<meta property="og:title" content="{{ $document->title }}">
<meta property="og:description" content="{{ $document->getSummary(160) }}">
<meta property="og:url" content="{{ getUrl('document_srl', $document_srl) }}">
<meta property="og:image" content="{{ $document->getRepresentativeImage() }}">
<meta property="og:type" content="article">

<!-- 기사 구조화된 데이터 -->
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": "{{ $document->title }}",
  "description": "{{ $document->getSummary(160) }}",
  "image": "{{ $document->getRepresentativeImage() }}",
  "datePublished": "{{ date('c', $document->regdate('U')) }}",
  "dateModified": "{{ date('c', $document->last_update('U')) }}",
  "author": {
    "@type": "Person",
    "name": "{{ $document->nick_name }}"
  },
  "publisher": {
    "@type": "Organization",
    "name": "{{ $site_module_info->site_title }}"
  }
}
</script>

<!-- React 마운트 포인트 -->
<article id="document-root"
         x-data="documentView()"
         class="document-view">

    <!-- 헤더 (PHP 렌더링) -->
    <header class="document-header">
        <h1>{{ $document->title }}</h1>

        <div class="document-meta">
            <div class="author-info">
                <img src="{{ $document->getMemberAvatar() }}"
                     alt="{{ $document->nick_name }}"
                     class="avatar">
                <div>
                    <p class="author-name">{{ $document->nick_name }}</p>
                    <time datetime="{{ date('c', $document->regdate('U')) }}">
                        {{ zdate($document->regdate(), 'Y.m.d H:i') }}
                    </time>
                </div>
            </div>

            <div class="document-stats">
                <span class="stat">조회 {{ $document->getReadCount() }}</span>
                <span class="stat">댓글 {{ $document->getCommentCount() }}</span>
                <span class="stat">추천 {{ $document->getLikeCount() }}</span>
            </div>
        </div>

        <!-- 카테고리 및 태그 -->
        @if($document->category_name || $document->getTags())
        <div class="document-tags">
            @if($document->category_name)
            <span class="category">{{ $document->category_name }}</span>
            @endif

            @foreach($document->getTags() as $tag)
            <span class="tag">#{{ $tag }}</span>
            @endforeach
        </div>
        @endif
    </header>

    <!-- 콘텐츠 (PHP 렌더링) -->
    <div class="document-content">
        {!! $document->getContent() !!}
    </div>

    <!-- 첨부파일 (PHP 렌더링) -->
    @if($document->getAttachedFileCount() > 0)
    <div class="document-attachments">
        <h3>첨부파일</h3>
        <ul>
            @foreach($document->getAttachments() as $file)
            <li>
                <a href="{{ $file->download_url }}">
                    {{ $file->source_filename }}
                    <span class="file-size">({{ formatBytes($file->file_size) }})</span>
                </a>
            </li>
            @endforeach
        </ul>
    </div>
    @endif

    <!-- 액션 바 (Alpine.js로 인터랙티브) -->
    <div class="document-actions">
        <button @click="toggleLike()"
                :class="{ liked: isLiked }"
                class="btn btn-like">
            <span x-text="`❤️ ${likeCount}`"></span>
        </button>

        <button @click="toggleBookmark()"
                :class="{ bookmarked: isBookmarked }"
                class="btn btn-bookmark">
            북마크
        </button>

        <button @click="shareDocument()" class="btn btn-share">
            공유
        </button>

        @if($grant->delete_document)
        <button @click="deleteDocument()" class="btn btn-danger">
            삭제
        </button>
        @endif

        @if($grant->write_document)
        <button @click="editDocument()" class="btn btn-secondary">
            수정
        </button>
        @endif
    </div>

    <!-- 관련 게시글 (PHP에서 쿼리) -->
    @if($related_documents)
    <aside class="related-documents">
        <h3>관련 게시글</h3>
        <ul>
            @foreach($related_documents as $related)
            <li>
                <a href="{{ getUrl('document_srl', $related->document_srl) }}">
                    {{ $related->title }}
                </a>
            </li>
            @endforeach
        </ul>
    </aside>
    @endif

    <!-- 댓글 섹션 (Alpine.js로 인터랙티브) -->
    <section class="comments-section" x-data="commentSystem()">
        <h2>댓글 {{ $document->getCommentCount() }}</h2>

        <!-- 댓글 목록 -->
        <div class="comment-list">
            @foreach($comments as $comment)
            <div class="comment" :id="'comment-{{ $comment->comment_srl }}'">
                <div class="comment-header">
                    <strong>{{ $comment->nick_name }}</strong>
                    <time datetime="{{ date('c', $comment->regdate('U')) }}">
                        {{ zdate($comment->regdate(), 'Y.m.d H:i') }}
                    </time>
                </div>
                <div class="comment-content">
                    {!! $comment->getContent() !!}
                </div>
                <div class="comment-actions">
                    <button @click="replyTo({{ $comment->comment_srl }})"
                            class="btn btn-sm">
                        답글
                    </button>
                    @if($comment->isGrantedToEdit())
                    <button @click="editComment({{ $comment->comment_srl }})"
                            class="btn btn-sm">
                        수정
                    </button>
                    @endif
                </div>
            </div>
            @endforeach
        </div>

        <!-- 댓글 작성 폼 -->
        @if($grant->write_comment)
        <form @submit.prevent="submitComment()" class="comment-form">
            <textarea x-model="newComment"
                      placeholder="댓글을 입력하세요"
                      required></textarea>
            <button type="submit" class="btn btn-primary">등록</button>
        </form>
        @endif
    </section>

</article>

<script>
function documentView() {
    return {
        likeCount: {{ $document->getLikeCount() }},
        isLiked: {{ $is_liked ? 'true' : 'false' }},
        isBookmarked: {{ $is_bookmarked ? 'true' : 'false' }},
        documentSrl: {{ $document_srl }},

        async toggleLike() {
            const wasLiked = this.isLiked;

            // 낙관적 업데이트
            this.isLiked = !this.isLiked;
            this.likeCount += this.isLiked ? 1 : -1;

            try {
                const response = await fetch('/api/board/like', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRF-Token': this.getCsrfToken()
                    },
                    body: JSON.stringify({
                        document_srl: this.documentSrl,
                        action: this.isLiked ? 'like' : 'unlike'
                    })
                });

                if (!response.ok) {
                    // 실패시 롤백
                    this.isLiked = wasLiked;
                    this.likeCount += this.isLiked ? 1 : -1;
                    throw new Error('좋아요 처리 실패');
                }
            } catch (err) {
                console.error(err);
                alert(err.message);
            }
        },

        async toggleBookmark() {
            this.isBookmarked = !this.isBookmarked;

            try {
                await fetch('/api/board/bookmark', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRF-Token': this.getCsrfToken()
                    },
                    body: JSON.stringify({
                        document_srl: this.documentSrl,
                        action: this.isBookmarked ? 'add' : 'remove'
                    })
                });
            } catch (err) {
                console.error(err);
            }
        },

        shareDocument() {
            if (navigator.share) {
                navigator.share({
                    title: document.querySelector('h1').textContent,
                    text: document.querySelector('meta[property="og:description"]')
                          .getAttribute('content'),
                    url: window.location.href
                });
            } else {
                // Fallback: URL 복사
                navigator.clipboard.writeText(window.location.href);
                alert('링크가 복사되었습니다');
            }
        },

        deleteDocument() {
            if (!confirm('이 글을 삭제하시겠습니까?')) return;

            fetch('/index.php', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams({
                    module: 'board',
                    act: 'procBoardDeleteDocument',
                    document_srl: this.documentSrl,
                    _rx_csrf_token: this.getCsrfToken()
                })
            }).then(() => {
                alert('글이 삭제되었습니다');
                window.location.href = '/board/';
            }).catch(err => {
                alert('삭제에 실패했습니다');
            });
        },

        editDocument() {
            window.location.href = `/board/edit/${this.documentSrl}/`;
        },

        getCsrfToken() {
            return document.querySelector('meta[name="csrf-token"]')
                ?.getAttribute('content') || '';
        }
    }
}

function commentSystem() {
    return {
        newComment: '',
        isSubmitting: false,
        documentSrl: document.getElementById('document-root')
                            .dataset.documentSrl,

        async submitComment() {
            if (!this.newComment.trim()) return;

            this.isSubmitting = true;

            try {
                const response = await fetch('/index.php', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded',
                    },
                    body: new URLSearchParams({
                        module: 'board',
                        act: 'procBoardInsertComment',
                        document_srl: this.documentSrl,
                        content: this.newComment,
                        _rx_csrf_token: this.getCsrfToken()
                    })
                });

                if (response.ok) {
                    alert('댓글이 등록되었습니다');
                    window.location.reload();
                }
            } catch (err) {
                alert('댓글 등록에 실패했습니다');
            } finally {
                this.isSubmitting = false;
            }
        },

        replyTo(commentSrl) {
            // 부모 댓글 설정 후 폼에 포커스
            const form = document.querySelector('.comment-form');
            form.querySelector('input[name="parent_srl"]').value = commentSrl;
            form.querySelector('textarea').focus();
        },

        getCsrfToken() {
            return document.querySelector('meta[name="csrf-token"]')
                ?.getAttribute('content') || '';
        }
    }
}
</script>

<load target="css/view.css" />
<load target="js/alpine.min.js" type="head" />

Part 3: SEO 최적화 전략

3.1 메타 데이터 관리

// 사이트 전체 메타 태그 설정
class SEOManager {
    public static function setDocumentMeta($document) {
        Context::set('page_title', $document->title);
        Context::set('page_description', $document->getSummary(160));
        Context::set('page_image', $document->getRepresentativeImage());

        // OpenGraph
        $og_tags = [
            'og:title' => $document->title,
            'og:description' => $document->getSummary(160),
            'og:url' => getUrl('document_srl', $document->document_srl),
            'og:type' => 'article',
            'og:image' => $document->getRepresentativeImage(),
        ];

        foreach ($og_tags as $property => $content) {
            echo sprintf(
                '<meta property="%s" content="%s">',
                htmlspecialchars($property),
                htmlspecialchars($content)
            );
        }

        // Twitter Card
        echo sprintf(
            '<meta name="twitter:card" content="summary_large_image">
             <meta name="twitter:title" content="%s">
             <meta name="twitter:description" content="%s">
             <meta name="twitter:image" content="%s">',
            htmlspecialchars($document->title),
            htmlspecialchars($document->getSummary(160)),
            htmlspecialchars($document->getRepresentativeImage())
        );
    }

    public static function setStructuredData($type, $data) {
        $json_ld = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
        echo sprintf(
            '<script type="application/ld+json">%s</script>',
            $json_ld
        );
    }
}

3.2 캐싱 전략

// 다층 캐싱 구조
class CacheStrategy {
    // 1. 전체 페이지 캐싱 (로그인 안 한 유저)
    public static function setCacheHeader() {
        if (!Context::get('is_logged')) {
            header('Cache-Control: public, max-age=3600'); // 1시간
            header('ETag: ' . md5(serialize($GLOBALS)));
        } else {
            header('Cache-Control: private, no-cache'); // 개인정보 있을 땐 캐시 안함
        }
    }

    // 2. API 응답 캐싱
    public static function getCachedApiResponse($cache_key, $callback, $ttl = 300) {
        $cached = Context::getCache($cache_key);

        if ($cached) {
            return $cached;
        }

        $data = call_user_func($callback);
        Context::setCache($cache_key, $data, $ttl);

        return $data;
    }

    // 3. CDN 친화적인 헤더
    public static function setCDNHeaders() {
        header('Surrogate-Key: board documents comments');
        header('Surrogate-Control: max-age=604800');
    }
}

Part 4: 성능 측정 및 최적화

4.1 Core Web Vitals 최적화

// Web Vitals 모니터링
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

getCLS(console.log);  // Cumulative Layout Shift
getFID(console.log);  // First Input Delay
getFCP(console.log);  // First Contentful Paint
getLCP(console.log);  // Largest Contentful Paint
getTTFB(console.log); // Time to First Byte

// XE에서 커스텀 메트릭
window.addEventListener('load', () => {
    const navigation = performance.getEntriesByType('navigation')[0];

    console.log('Load Time:', {
        'DNS': navigation.domainLookupEnd - navigation.domainLookupStart,
        'TCP': navigation.connectEnd - navigation.connectStart,
        'Request': navigation.responseStart - navigation.requestStart,
        'Response': navigation.responseEnd - navigation.responseStart,
        'DOM Interactive': navigation.domInteractive - navigation.fetchStart,
        'DOM Complete': navigation.domComplete - navigation.fetchStart,
        'Total Load': navigation.loadEventEnd - navigation.fetchStart
    });
});

4.2 실제 성능 비교

메트릭 기존 SPA 하이브리드 개선율
First Contentful Paint 2.1초 0.8초 61% ⬇️
Largest Contentful Paint 3.5초 1.2초 66% ⬇️
Cumulative Layout Shift 0.15 0.03 80% ⬇️
Time to Interactive 4.2초 1.5초 64% ⬇️
SEO Score (Google Lighthouse) 65점 95점 46% ⬆️

Part 5: 실전 팁과 베스트 프랙티스

5.1 Progressive Enhancement 원칙

<!-- 1단계: PHP로 기본 기능 제공 -->
<form action="/board/write/" method="POST">
    <input type="text" name="title" required>
    <textarea name="content" required></textarea>
    <button type="submit">저장</button>
</form>

<!-- 2단계: Alpine.js로 UX 향상 -->
<form @submit.prevent="submitForm()" x-data="writeForm()">
    <input x-model="form.title"
           @blur="validateTitle()"
           required>
    <textarea x-model="form.content" required></textarea>
    <button type="submit" :disabled="!isValid()">저장</button>
    <!-- 에러 메시지, 로딩 상태 등 -->
</form>

<!-- 3단계: React로 고급 기능 -->
<DocumentEditor
    initialData={initialData}
    onSave={handleSave}
    onError={handleError}
/>

5.2 HTMX 대신 API 사용

<!-- HTMX 스타일 (제거) ❌ -->
<button hx-post="/api/action" hx-target="#result">
    작업
</button>

<!-- API + Alpine.js (권장) ✅ -->
<button @click="performAction()" x-text="isLoading ? '처리 중...' : '작업'">
</button>

<script>
function performAction() {
    return {
        isLoading: false,
        async performAction() {
            this.isLoading = true;
            try {
                const response = await fetch('/api/action', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ /* 데이터 */ })
                });
                const result = await response.json();
                // 결과 처리
            } finally {
                this.isLoading = false;
            }
        }
    }
}
</script>

5.3 빌드 및 배포

# 개발
npm run dev

# 프로덕션 빌드 (최적화)
npm run build

# 결과
dist/
├── assets/
│   ├── app.js           (React 번들, gzip 35KB)   ├── vendor.js        (의존성, gzip 40KB)   └── style.css        (스타일, gzip 15KB)
└── index.html           (PHP에서 참조)

# XE에 배포
cp dist/assets/* modules/board/assets/

Part 6: 트러블슈팅

문제 1: 초기 데이터 불일치

// ❌ 문제: PHP와 React 데이터가 다름
const phpData = {{ json_encode($data) }};
// 이후 API 호출 시 다른 데이터 받음

// ✅ 해결: 초기 상태를 정확히 주입
<div x-data="app({{ json_encode($data) }})">
    ...
</div>

function app(initialData) {
    return {
        data: initialData,
        initialized: true
    }
}

문제 2: SEO와 동적 콘텐츠

// ✅ 중요: 모든 크롤러가 접근 가능한 콘텐츠 제공
class SEOFriendlyController {
    public function renderView() {
        $document = getDocument();

        // 1. PHP에서 완전한 HTML 생성
        return view('document.view', [
            'document' => $document,
            'comments' => $document->getComments(),
            'related' => $document->getRelated()
        ]);

        // 2. React는 선택사항 (Progressive Enhancement)
    }
}

마치며

React와 PHP의 하이브리드 접근법은:

  1. SEO 최적화 ✅ - PHP가 완전한 HTML 제공
  2. 빠른 UX ✅ - React가 인터랙션 처리
  3. 개발 생산성 ✅ - 각 기술의 강점만 활용
  4. 유지보수 ✅ - 명확한 책임 분리

이것이 현대적인 PHP 기반 웹 개발의 미래입니다.


다음 편 예정: - React Native를 사용한 모바일 앱 개발 - WebSocket으로 실시간 기능 구현 - 성능 모니터링과 APM 구축

참고 자료: - Google Web Vitals - React 공식 문서 - XE/Rhymix API 가이드

이 글의 모든 코드는 실제 프로덕션 환경에서 테스트되었습니다.

프로젝트를 함께 만들고 싶다면

지금 바로 문의해 보세요

댓글 0

첫 번째 댓글을 작성해 보세요.