홈페이지를 처음 만드는 사람들을 위한 안내서

이온디 2025.12.01 14:02 조회 296

./layouts/el_d1/

게시판은 거부한다.

레이아웃 만으로 게시판의 데이터를 처리함.


개발 히스토리

2025.11.29 토요일 프론트 화면 구성

2025.12.01 월요일 백엔드 데이터 연결


# el_d1 레이아웃 댓글 시스템 개발 가이드

## 개요

el_d1 레이아웃에서 댓글 등록/수정/삭제를 구현하는 두 가지 방법:

| 방법 | 사용 페이지 | 핵심 기술 |
|------|-------------|-----------|
| **방법 1** | community_view | 게시판 모듈 컨텍스트 + 폼 제출 |
| **방법 2** | homepage_solution_view | REST API (`executeQuery` 직접 사용) |

---

## 방법 1: 게시판 모듈 컨텍스트 사용

### 사용 위치
`layouts/el_d1/assets/pages/community_view.blade.php`

### 원리
Rhymix 게시판 모듈이 페이지 로드 시 자동으로 `$document`, `$grant` 등의 변수를 Context에 주입합니다. 이 변수들을 그대로 사용하여 일반 폼 제출로 댓글을 등록합니다.

### 데이터 흐름
```
1. URL 접근: /community/123
2. 게시판 모듈 실행
3. Context에 $document, $grant, $category_list 자동 설정
4. 레이아웃 로드 → @include('community_view')
5. 폼 제출 → procBoardInsertComment 실행
6. 성공 시 페이지 리다이렉트
```

### 소스코드 예제

#### PHP 부분 (데이터 접근)
```php
{{-- community_view.blade.php --}}
@version(2)

@php
    // $document, $grant는 게시판 모듈이 자동 주입
    // 별도 조회 코드 불필요!

    if ($document) {
        $cat_srl = $document->get('category_srl');
        $extra_vars = $document->getExtraVars();
        $is_answered = $extra_vars['is_answered'] ?? false;
    }
@endphp

@if($document)
    <article>
        <h1>{{ $document->getTitleText() }}</h1>
        <div>{!! $document->getContent() !!}</div>

        <p>작성자: {{ $document->getNickName() }}</p>
        <p>조회수: {{ $document->get('readed_count') }}</p>
    </article>
@endif
```

#### HTML 부분 (댓글 폼)
```html
{{-- 댓글 작성 폼 --}}
@if($grant->write_comment)
    <form id="comment-form" action="{{ getUrl('', '') }}" method="post">
        {{-- 필수 hidden 필드 --}}
        <input type="hidden" name="act" value="procBoardInsertComment" />
        <input type="hidden" name="document_srl" value="{{ $document->document_srl }}" />
        <input type="hidden" name="parent_srl" value="" />
        <input type="hidden" name="_rx_csrf_token" value="{{ $__Context->csrf_token }}" />

        <textarea name="content" rows="3" placeholder="댓글을 입력하세요..." required></textarea>

        <button type="submit">댓글 등록</button>
    </form>
@else
    <p>댓글을 작성하려면 <a href="{{ getUrl('act', 'dispMemberLoginForm') }}">로그인</a>이 필요합니다.</p>
@endif
```

#### JavaScript 부분 (답글 기능)
```html
<script>
function replyComment(parentSrl) {
    const form = document.getElementById('comment-form');
    form.querySelector('input[name="parent_srl"]').value = parentSrl;
    form.querySelector('textarea').placeholder = '답글을 입력하세요...';
    form.querySelector('textarea').focus();
}
</script>
```

#### 댓글 수정/삭제 (링크 방식)
```html
@if($comment->isGranted())
    <a href="{{ getUrl('act', 'dispBoardModifyComment', 'comment_srl', $comment->comment_srl) }}">수정</a>
    <a href="{{ getUrl('act', 'dispBoardDeleteComment', 'comment_srl', $comment->comment_srl) }}">삭제</a>
@endif
```

---

## 방법 2: REST API 사용 (executeQuery 직접)

### 사용 위치
- `layouts/el_d1/assets/pages/homepage_solution_view.blade.php`
- `modules/api/rest.php`

### 원리
게시판 모듈의 자동 주입을 사용하지 않고, `getModel('document')`로 직접 데이터를 조회합니다. 댓글 CRUD는 REST API를 통해 `executeQuery`로 직접 DB 작업을 수행합니다.

### 왜 이 방법이 필요한가?

레이아웃 페이지에서 AJAX로 `procBoardInsertComment`를 호출하면:
```javascript
// 이 코드는 성공 응답이 오지만 실제로 저장되지 않음!
exec_json('procBoardInsertComment', params, function(ret) {
    console.log(ret);  // {error: 0, message: 'success'}
    // 하지만 새로고침하면 댓글 없음!
});
```

**원인**: 게시판 모듈 컨텍스트가 없어서 액션이 무시됨

**해결**: REST API로 `executeQuery` 직접 실행

### 데이터 흐름
```
1. URL 접근: /homepage_solution/123
2. @php에서 getModel('document')로 직접 조회
3. JavaScript에서 REST API 호출
4. rest.php에서 executeQuery로 DB 직접 작업
5. JSON 응답 → 페이지 동적 업데이트
```

### 소스코드 예제

#### PHP 부분 (직접 데이터 조회)
```php
{{-- homepage_solution_view.blade.php --}}
@version(2)

@php
    $context_document_srl = Context::get('document_srl');
    $doc = null;
    $comments = [];
    $api_grant = null;

    if ($context_document_srl) {
        // 문서 조회
        $oDocumentModel = getModel('document');
        $oDocument = $oDocumentModel->getDocument($context_document_srl);

        if ($oDocument && $oDocument->isExists()) {
            // Document 객체를 stdClass로 변환
            $doc = new stdClass();
            $doc->document_srl = $oDocument->document_srl;
            $doc->title = $oDocument->getTitleText();
            $doc->content = $oDocument->getContent();
            $doc->nick_name = $oDocument->getNickName();
            $doc->member_srl = $oDocument->get('member_srl');
            $doc->regdate = $oDocument->getRegdate();
            $doc->module_srl = $oDocument->get('module_srl');

            // 댓글 조회
            $oCommentModel = getModel('comment');
            $comment_list = $oCommentModel->getCommentList($context_document_srl);
            if ($comment_list && $comment_list->data) {
                $comments = $comment_list->data;
            }

            // 권한 조회
            $oModuleModel = getModel('module');
            $module_info = $oModuleModel->getModuleInfoByModuleSrl($doc->module_srl);
            $logged_info = Context::get('logged_info');
            $api_grant = $oModuleModel->getGrant($module_info, $logged_info);
        }
    }

    $can_write_comment = $api_grant && ($api_grant->write_comment ?? false);
@endphp
```

#### HTML 부분 (댓글 폼 - AJAX용)
```html
{{-- 댓글 작성 영역 --}}
@if($can_write_comment)
    <div class="comment-form">
        <textarea id="comment-textarea" rows="3" placeholder="댓글을 입력하세요..."></textarea>
        <button type="button" id="comment-submit-btn" onclick="submitNewComment()">
            댓글 등록
        </button>
    </div>
@else
    <p>댓글을 작성하려면 <a href="{{ getUrl('act', 'dispMemberLoginForm') }}">로그인</a>이 필요합니다.</p>
@endif
```

#### JavaScript 부분 (REST API 호출)
```html
<script>
// 전역 변수
var documentSrl = {{ $doc->document_srl ?? 0 }};

// 댓글 등록
window.submitNewComment = async function() {
    var content = document.getElementById('comment-textarea').value.trim();
    if (!content) {
        alert('댓글 내용을 입력하세요.');
        return;
    }

    var btn = document.getElementById('comment-submit-btn');
    btn.disabled = true;
    btn.textContent = '등록 중...';

    try {
        var response = await fetch('/modules/api/rest.php?type=comment_insert&document_srl=' + documentSrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ content: content }),
            credentials: 'same-origin'  // 세션 쿠키 전송 필수!
        });

        var ret = await response.json();

        if (ret.status === 1) {
            alert('댓글이 등록되었습니다.');
            location.reload();  // 또는 동적으로 댓글 추가
        } else {
            alert(ret.message || '댓글 등록에 실패했습니다.');
        }
    } catch (error) {
        console.error('Error:', error);
        alert('오류가 발생했습니다.');
    } finally {
        btn.disabled = false;
        btn.textContent = '댓글 등록';
    }
};

// 댓글 수정
window.submitEditComment = async function(commentSrl) {
    var content = document.getElementById('comment-edit-content-' + commentSrl).value.trim();
    if (!content) {
        alert('댓글 내용을 입력하세요.');
        return;
    }

    try {
        var response = await fetch('/modules/api/rest.php?type=comment_update', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ comment_srl: commentSrl, content: content }),
            credentials: 'same-origin'
        });

        var ret = await response.json();

        if (ret.status === 1) {
            // 성공 - UI 업데이트
            document.getElementById('comment-content-' + commentSrl).innerHTML =
                content.replace(/\n/g, '<br>');
            cancelEditComment(commentSrl);
        } else {
            alert(ret.message || '수정에 실패했습니다.');
        }
    } catch (error) {
        alert('오류가 발생했습니다.');
    }
};

// 댓글 삭제
window.deleteComment = async function(commentSrl) {
    if (!confirm('댓글을 삭제하시겠습니까?')) return;

    try {
        var response = await fetch('/modules/api/rest.php?type=comment_delete', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ comment_srl: commentSrl }),
            credentials: 'same-origin'
        });

        var ret = await response.json();

        if (ret.status === 1) {
            // 성공 - DOM에서 댓글 제거
            var commentEl = document.querySelector('[data-comment-srl="' + commentSrl + '"]');
            if (commentEl) commentEl.remove();
        } else {
            alert(ret.message || '삭제에 실패했습니다.');
        }
    } catch (error) {
        alert('오류가 발생했습니다.');
    }
};
</script>
```

#### REST API 서버 코드 (`modules/api/rest.php`)
```php
<?php
// rest.php 중 comment_insert 부분

case 'comment_insert':
    // 댓글 작성 (executeQuery 직접 사용)
    if (!$document_srl) {
        jsonResponse(-1, 'document_srl이 필요합니다.');
    }

    // 로그인 체크
    $logged_info = Context::get('logged_info');
    if (!$logged_info || !$logged_info->member_srl) {
        jsonResponse(-1, '로그인이 필요합니다.');
    }

    // 문서 존재 확인
    $oDocumentModel = getModel('document');
    $oDocument = $oDocumentModel->getDocument($document_srl);
    if (!$oDocument || !$oDocument->isExists()) {
        jsonResponse(-1, '존재하지 않는 게시글입니다.');
    }

    $module_srl = $oDocument->get('module_srl');
    $content = $_POST['content'] ?? '';
    $parent_srl = $_POST['parent_srl'] ?? 0;

    if (empty($content)) {
        jsonResponse(-1, '댓글 내용을 입력하세요.');
    }

    // 댓글 권한 체크
    $oModuleModel = getModel('module');
    $module_info = $oModuleModel->getModuleInfoByModuleSrl($module_srl);
    $grant = $oModuleModel->getGrant($module_info, $logged_info);

    if (!$grant->write_comment) {
        jsonResponse(-1, '댓글 작성 권한이 없습니다.');
    }

    // comment_srl 생성
    $comment_srl = getNextSequence();

    // 댓글 삽입
    $args = new stdClass();
    $args->comment_srl = $comment_srl;
    $args->module_srl = $module_srl;
    $args->document_srl = $document_srl;
    $args->parent_srl = $parent_srl;
    $args->content = $content;
    $args->member_srl = $logged_info->member_srl;
    $args->nick_name = $logged_info->nick_name;
    $args->user_id = $logged_info->user_id;
    $args->user_name = $logged_info->user_name;
    $args->email_address = $logged_info->email_address;
    $args->regdate = date('YmdHis');
    $args->last_update = date('YmdHis');
    $args->ipaddress = $_SERVER['REMOTE_ADDR'];
    $args->list_order = $parent_srl ? $parent_srl : ($comment_srl * -1);
    $args->status = 1;

    $output = executeQuery('comment.insertComment', $args);

    if ($output->toBool()) {
        // 문서의 댓글 수 업데이트
        $oCommentController = getController('comment');
        $oCommentController->updateCommentCount($document_srl);

        jsonResponse(1, '댓글이 등록되었습니다.', ['comment_srl' => $comment_srl]);
    } else {
        jsonResponse(-1, '댓글 등록에 실패했습니다.');
    }
    break;
```

---

## 보안 비교

| 항목 | 방법 1 (모듈 컨텍스트) | 방법 2 (REST API) |
|------|------------------------|-------------------|
| **CSRF 방지** | `_rx_csrf_token` 자동 검증 | 세션 기반 (credentials: same-origin) |
| **권한 체크** | 게시판 모듈이 자동 처리 | 직접 구현 (`$grant->write_comment`) |
| **SQL Injection** | executeQuery (prepared statement) | executeQuery (prepared statement) |
| **XSS 방지** | Rhymix 기본 필터링 | 직접 이스케이프 필요 |
| **인증** | 세션 자동 확인 | 세션 수동 확인 필요 |

### 방법 1 보안 장점
- Rhymix 코어가 모든 보안 처리 담당
- 별도 구현 불필요
- 검증된 보안 로직 사용

### 방법 2 보안 주의사항
```php
// rest.php에서 반드시 구현해야 할 보안 체크

// 1. 로그인 체크
$logged_info = Context::get('logged_info');
if (!$logged_info || !$logged_info->member_srl) {
    jsonResponse(-1, '로그인이 필요합니다.');
}

// 2. 게시글 존재 확인
$oDocument = $oDocumentModel->getDocument($document_srl);
if (!$oDocument || !$oDocument->isExists()) {
    jsonResponse(-1, '존재하지 않는 게시글입니다.');
}

// 3. 권한 체크
$grant = $oModuleModel->getGrant($module_info, $logged_info);
if (!$grant->write_comment) {
    jsonResponse(-1, '댓글 작성 권한이 없습니다.');
}

// 4. 수정/삭제 시 작성자 확인
$oComment = $oCommentModel->getComment($comment_srl);
if (!$oComment->isGranted()) {
    jsonResponse(-1, '권한이 없습니다.');
}
```

---

## 사용성 비교

| 항목 | 방법 1 (모듈 컨텍스트) | 방법 2 (REST API) |
|------|------------------------|-------------------|
| **페이지 리로드** | 항상 발생 | 없음 (AJAX) |
| **사용자 경험** | 전통적 웹 | SPA 스타일 |
| **로딩 속도** | 전체 페이지 로드 | 부분 업데이트 |
| **에러 피드백** | 페이지 이동 후 표시 | 즉시 알림 |
| **구현 복잡도** | 간단 | 복잡 |
| **htmx 호환** | 제한적 | 완벽 호환 |

### 방법 1 사용성 장점
- 구현이 간단함
- 브라우저 뒤로가기 자연스러움
- SEO 친화적

### 방법 2 사용성 장점
- 빠른 응답 (페이지 리로드 없음)
- 부분 UI 업데이트 가능
- 현대적인 사용자 경험
- htmx, Alpine.js 등과 잘 연동

---

## 선택 가이드

### 방법 1 선택 시
- 간단한 게시판 구현
- 빠른 개발 필요
- Rhymix 기본 기능 활용
- 보안 구현에 자신 없을 때

### 방법 2 선택 시
- SPA 스타일 동적 UI
- htmx 사용
- 커스텀 데이터 처리 필요
- 페이지 리로드 없이 UX 개선

---

## 문제 해결

### "exec_json 성공인데 댓글이 안 보여요"
**원인**: 레이아웃에서 AJAX로 procBoardInsertComment 호출 시 모듈 컨텍스트 없음
**해결**: REST API 방식 사용

### "credentials 빠뜨렸더니 로그인 안 된다고 해요"
```javascript
// 잘못된 코드
fetch('/modules/api/rest.php?type=comment_insert', { method: 'POST' });

// 올바른 코드
fetch('/modules/api/rest.php?type=comment_insert', {
    method: 'POST',
    credentials: 'same-origin'  // 필수!
});
```

### "권한이 없습니다"
1. 게시판 관리 → 권한 설정에서 댓글 권한 확인
2. 로그인 상태 확인
3. rest.php에서 권한 체크 로직 확인

---

## 참고 파일

| 파일 | 설명 |
|------|------|
| `layouts/el_d1/assets/pages/community_view.blade.php` | 방법 1 구현 예제 |
| `layouts/el_d1/assets/pages/homepage_solution_view.blade.php` | 방법 2 구현 예제 |
| `modules/api/rest.php` | REST API 서버 |
| `modules/api/CLAUDE.md` | API 상세 문서 |

---

*최종 업데이트: 2025-12-01*

댓글 0

첫 댓글을 남겨보세요.

댓글을 작성하려면 로그인이 필요합니다.