· nambak80 Blog 로그인
CodeIgniter4

CodeIgniter 4로 블로그 만들기 #18 — 댓글 표시

CodeIgniter 4로 블로그 만들기 #18 — 댓글 표시

지난 회차까지 글 CRUD와 slug 기반 URL을 마무리했습니다. 이제 글은 잘 쓰고 읽히지만, 아직 독자가 반응을 남길 곳이 없습니다. 이번 섹션에서는 댓글 기능을 만듭니다.

댓글은 한 번에 다 넣기보다 표시 → 저장 → 폼과 인증 → 삭제와 권한 순서로 잘라서 갑니다. 이번 첫 회차의 목표는 딱 하나입니다. 글과 댓글의 관계를 만들고, 글 상세 화면에 댓글 목록을 그려 내는 것입니다. 저장 폼은 다음 회차, 삭제는 다다음 회차에서 다룹니다.

준비물과 목표

  • 지난 회차까지 완성된 저장소 (글 상세 Posts::show()가 slug로 동작하는 상태)
  • Shield users 테이블이 이미 있음

이번 회차에서 만들 것:

  1. comments 테이블 마이그레이션 — post_id를 posts에 외래키(FK) 로 묶고, 글이 지워지면 댓글도 함께 지워지게(CASCADE)
  2. CommentModel / Comment 엔티티 — 특히 작성자명을 한 번에 조인해 N+1을 피하는 forPost()
  3. comments/_list.php 부분 뷰
  4. Posts::show()에서 댓글을 로드해 뷰로 전달

1. comments 마이그레이션 — 관계의 뼈대

먼저 댓글이 저장될 테이블을 만듭니다. 마이그레이션 파일명은 YYYY-MM-DD-HHMMSS_CreateCommentsTable.php 형식입니다.

<?php

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

class CreateCommentsTable extends Migration
{
    public function up()
    {
        $this->forge->addField([
            'id'         => [
                'type'           => 'INT',
                'constraint'     => 11,
                'unsigned'       => true,
                'auto_increment' => true,
            ],
            'post_id'    => [
                'type'       => 'INT',
                'constraint' => 11,
                'unsigned'   => true,
            ],
            'user_id'    => [
                'type'       => 'INT',
                'constraint' => 11,
                'unsigned'   => true,
                'null'       => true,
            ],
            'body'       => [
                'type' => 'TEXT',
            ],
            'created_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
            'updated_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
        ]);

        $this->forge->addPrimaryKey('id');
        $this->forge->addKey('post_id');
        // 글이 삭제되면 딸린 댓글도 함께 지운다.
        $this->forge->addForeignKey('post_id', 'posts', 'id', 'CASCADE', 'CASCADE');
        $this->forge->createTable('comments');
    }

    public function down()
    {
        $this->forge->dropTable('comments');
    }
}

몇 가지 설계 의도를 짚고 갑니다.

  • post_id는 FK로 posts에 묶습니다. addForeignKey('post_id', 'posts', 'id', 'CASCADE', 'CASCADE')의 뒤쪽 두 인자가 ON DELETE/ON UPDATE 동작입니다. ON DELETE CASCADE이므로 글이 삭제되면 그 글에 달린 댓글도 DB 차원에서 자동으로 함께 삭제됩니다. 컨트롤러에서 일일이 지우는 코드를 짤 필요가 없습니다.
  • user_idnull 허용입니다. 작성자 계정이 나중에 사라지는 경우(탈퇴 등)를 견디기 위해서입니다. 여기엔 users로의 FK를 걸지 않았습니다. Shield의 users 테이블과 강하게 묶기보다, 조회할 때 LEFT JOIN으로 유연하게 붙이는 쪽을 택했습니다.
  • post_id에 인덱스(addKey) 를 걸어 둡니다. 댓글은 항상 "이 글의 댓글"로 조회하므로, WHERE post_id = ?가 빨라야 합니다.

작성했으면 마이그레이션을 돌립니다.

php spark migrate

2. CommentModel — 작성자를 한 번에 조인하기

이제 모델과 엔티티입니다. 모델의 핵심은 forPost() 하나입니다.

<?php

namespace App\Models;

use App\Entities\Comment;
use CodeIgniter\Model;

class CommentModel extends Model
{
    protected $table      = 'comments';
    protected $primaryKey = 'id';

    protected $returnType    = Comment::class;
    protected $useTimestamps = true;

    protected $allowedFields = [
        'post_id',
        'user_id',
        'body',
    ];

    /**
     * 특정 글의 댓글을 작성자명과 함께 오래된 순으로 가져온다.
     *
     * users 를 한 번에 조인해 N+1 을 피한다. (댓글마다 작성자를 따로 조회하지 않음)
     *
     * @return Comment[]
     */
    public function forPost(int $postId): array
    {
        return $this
            ->select('comments.*, users.username')
            ->join('users', 'users.id = comments.user_id', 'left')
            ->where('comments.post_id', $postId)
            ->orderBy('comments.created_at', 'ASC')
            ->findAll();
    }
}

여기서 학습 포인트는 N+1 회피입니다.

만약 댓글을 그냥 where('post_id', $postId)->findAll()로 가져온 뒤, 화면에서 댓글마다 "작성자 이름"을 뽑으려고 userModel->find($comment->user_id)를 반복하면, 댓글이 20개일 때 쿼리가 1(댓글) + 20(작성자) = 21번 나갑니다. 이게 바로 N+1 문제입니다(이전 회차에서 목록을 최적화할 때 이미 한 번 다뤘습니다).

forPost()usersLEFT JOIN으로 한 번에 붙여, 딱 한 번의 쿼리로 댓글 본문과 작성자명(users.username)을 함께 가져옵니다. JOINLEFT로 둔 이유는, user_idnull이거나 계정이 사라진 댓글도 목록에서 빠지지 않게 하기 위해서입니다(INNER JOIN이면 그런 댓글이 결과에서 사라집니다).

정렬은 created_at ASC — 댓글은 오래된 것이 위, 최신이 아래로 쌓이는 게 대화 흐름상 자연스럽습니다.

3. Comment 엔티티 — 작성자명 접근자

조인으로 딸려 온 username을 화면에서 안전하게 쓰기 위한 접근자를 엔티티에 둡니다.

<?php

namespace App\Entities;

use CodeIgniter\Entity\Entity;

/**
 * 댓글 한 건을 나타내는 도메인 객체.
 *
 * forPost() 조인으로 들어온 작성자명은 $comment->username 으로 접근한다.
 */
class Comment extends Entity
{
    protected $dates = ['created_at', 'updated_at'];

    /**
     * 작성자명. 작성자 계정이 없으면(탈퇴 등) 대체 문구를 돌려준다.
     */
    public function getAuthorName(): string
    {
        $username = $this->attributes['username'] ?? null;

        return $username !== null && $username !== ''
            ? (string) $username
            : '알 수 없음';
    }
}

CI4 엔티티의 접근자 규칙을 그대로 씁니다. getAuthorName()이라는 메서드를 정의하면 뷰에서 $comment->authorName(스네이크/카멜 자동 매핑)으로 읽을 수 있습니다.

이 접근자 하나로 뷰가 지저분해지는 것을 막습니다. username이 비어 있는지, 계정이 사라졌는지 같은 조건을 뷰에서 if로 늘어놓지 않고, 엔티티가 "없으면 알 수 없음"이라는 도메인 규칙을 책임집니다. $dates에 타임스탬프 컬럼을 등록해 두면 created_atTime 객체로 들어와 ->format('Y-m-d')를 바로 쓸 수 있습니다.

4. 댓글 목록 부분 뷰

목록을 그리는 부분 뷰입니다. 파일명에 언더스코어(_list.php)를 붙여 "부분 뷰"임을 표시합니다.

<?php // 글 상세에 끼워 넣는 댓글 목록 부분 뷰. $comments(Comment[])를 받는다. ?>
<section class="comments">
    <h2 class="comments-title">댓글 <?= count($comments) ?></h2>

    <?php if ($comments === []): ?>
        <p class="comments-empty">아직 댓글이 없습니다.</p>
    <?php else: ?>
        <ul class="comment-list">
            <?php foreach ($comments as $comment): ?>
                <li class="comment">
                    <div class="comment-meta">
                        <span class="comment-author"><?= esc($comment->authorName) ?></span>
                        <?php if ($comment->created_at !== null): ?>
                            <time datetime="<?= esc($comment->created_at->format('Y-m-d')) ?>">
                                <?= esc($comment->created_at->format('Y-m-d')) ?>
                            </time>
                        <?php endif ?>
                    </div>
                    <div class="comment-body"><?= nl2br(esc($comment->body)) ?></div>
                </li>
            <?php endforeach ?>
        </ul>
    <?php endif ?>
</section>

눈여겨볼 점:

  • 댓글 수는 count($comments) 로 표시합니다. 이미 배열로 다 받아 왔으니 별도의 count 쿼리가 필요 없습니다.
  • 빈 상태를 먼저 처리합니다($comments === []). "아직 댓글이 없습니다"는 첫 방문자에게 중요한 UX입니다.
  • 본문은 nl2br(esc($comment->body))먼저 esc()로 이스케이프한 뒤 nl2br()로 줄바꿈만 <br>로 바꿉니다. 순서가 중요합니다. 사용자 입력을 그대로 HTML로 뿌리면 XSS 위험이 있으므로, esc()가 먼저 위험 문자를 무력화하고, 그 안전한 결과에 대해서만 줄바꿈을 되살립니다.
  • 작성자명도 esc($comment->authorName)로 이스케이프합니다.

부분 뷰에 맞춰 app.css에 댓글 관련 스타일도 더했습니다(.comments, .comment, .comment-author 등). 디자인은 이전 회차에서 깔아 둔 토큰(--color-line, --color-prose 등)을 그대로 입습니다.

/* comments */
.comments { max-width: 720px; margin: 48px auto 0; border-top: 1px solid var(--color-line); padding-top: 28px; }
.comments-title { font-size: 18px; font-weight: 700; margin: 0 0 20px; }
.comments-empty { color: var(--color-ink-3); }
.comment-list { list-style: none; padding: 0; margin: 0; }
.comment { padding: 16px 0; border-bottom: 1px solid var(--color-line-soft); }
.comment-meta { display: flex; gap: 10px; align-items: baseline; margin-bottom: 6px; }
.comment-author { font-weight: 600; font-size: 14px; color: var(--color-ink); }
.comment-meta time { font-family: var(--font-mono); font-size: 12px; color: var(--color-ink-3); }
.comment-body { color: var(--color-prose); line-height: 1.7; }

5. 컨트롤러에서 댓글 로드

마지막으로 상세 컨트롤러가 댓글을 실어 보내게 합니다. Posts::show()에 두 줄을 더합니다.

use App\Models\CommentModel;
// ...

// 이 글의 댓글을 작성자명과 함께 한 번에 로드한다(N+1 회피).
$comments = model(CommentModel::class)->forPost((int) $post->id);

return view('posts/show', [
    'post'     => $post,
    'comments' => $comments,
]);

그리고 상세 뷰(posts/show.php)의 본문 아래에서 부분 뷰를 include합니다.

    <?php // 댓글 목록(부분 뷰). 저장 폼은 다음 회차에서 추가한다. ?>
    <?= $this->include('comments/_list', ['comments' => $comments]) ?>

$this->include('뷰경로', ['키' => 값])는 지정한 부분 뷰를 현재 위치에 끼워 넣으면서 데이터를 넘겨주는 CI4 뷰 API입니다. 컨트롤러가 넘긴 $comments를 그대로 부분 뷰에 전달합니다.

확인

개발 서버를 띄우고 아무 글의 상세 페이지에 들어가 봅니다.

php spark serve

아직 댓글을 DB에 넣는 UI가 없으니 화면에는 "아직 댓글이 없습니다"가 보일 것입니다. 여기서 디버그 툴바의 쿼리 수를 열어 보세요. 댓글을 여러 개 심어 두고 봐도, 댓글 로드는 forPost()조인 한 방으로 끝납니다 — N+1이 없다는 걸 눈으로 확인할 수 있습니다(빠르게 확인하려면 DB에 댓글 몇 줄을 직접 INSERT해 보면 됩니다).

마무리 — 커밋과 태그

이번 회차에서 한 일:

  • comments 마이그레이션(post_id FK CASCADE, user_id nullable, body)
  • CommentModelforPost()로 작성자명 조인(N+1 회피), Comment 엔티티의 authorName 접근자
  • comments/_list.php 부분 뷰 + 댓글 스타일
  • Posts::show()에서 댓글 로드 → 상세 뷰 include
git add .
git commit -m "feat: 댓글 표시"
git tag ep18

다음 회차

지금은 화면에 댓글을 보여 주기만 합니다. 다음 글에서는 댓글 저장 백엔드를 만듭니다. 이번엔 테스트 우선(TDD)입니다 — "비로그인 차단 / 유효 입력 저장 / 빈 내용 거부"를 실패하는 테스트로 먼저 못 박고, 컨트롤러와 검증규칙으로 초록불을 켭니다.


이번 회차 요약

  • 다루는 파일: app/Database/Migrations/..._CreateCommentsTable.php(생성) / app/Models/CommentModel.php·app/Entities/Comment.php(생성) / app/Views/comments/_list.php(생성) / app/Controllers/Posts.php·app/Views/posts/show.php·public/assets/css/app.css(수정)
  • 핵심: post_id를 FK CASCADE로 묶어 글 삭제 시 댓글 자동 정리. 작성자명은 forPost()LEFT JOIN으로 한 번에 가져와 N+1을 피한다. 표시는 esc()nl2br() 순서로 XSS 방지.

다음: 댓글 저장 백엔드

댓글 0

아직 댓글이 없습니다.

로그인 후 댓글을 남길 수 있습니다.

← 목록으로