· nambak80 Blog 로그인
CodeIgniter4

CodeIgniter 4로 블로그 만들기 #28 — 버그 수정과 코드 정리

CodeIgniter 4로 블로그 만들기 #28 — 버그 수정과 코드 정리

기능을 한참 쌓았으니, 이제 코드를 한 번 정리할 차례입니다. 이번 회차는 새 기능을 더하지 않습니다. 대신 중복을 제거하고, 빠져 있던 권한 가드를 채우고, 그 과정을 테스트로 지킵니다.

리팩터링의 대원칙은 하나입니다 — 초록 테스트 위에서 한다. 이미 통과하고 있는 테스트가 안전망이 되어, 코드를 옮기고 합치는 동안 동작이 깨지지 않았음을 매 순간 확인해 줍니다.

준비물 / 목표

  • 여기저기 흩어진 "본인 또는 관리자" 판정을 acl 헬퍼로 추출
  • 컨트롤러(Posts, Comments)와 뷰(show.php, _list.php)가 그 헬퍼를 공유
  • 수정 폼(edit)·수정 처리(update)에 빠져 있던 403 권한 가드 추가
  • 비소유자 차단을 검증하는 Feature 테스트 보강

1. 반복되던 권한 판정을 찾아내기

지금까지 "이 사람이 이걸 고치거나 지울 수 있나?"라는 판정이 여러 곳에 거의 같은 모양으로 복제돼 있었습니다.

Posts 컨트롤러:

$user = auth()->user();
if ($user === null) { return false; }
return (int) $post->user_id === (int) $user->id || $user->inGroup('admin');

Comments 컨트롤러, show.php 뷰, comments/_list.php 뷰에도 같은 뼈대의 코드가 조금씩 다른 표현으로 흩어져 있었습니다. 이런 중복은 위험합니다 — 규칙을 바꿀 때 한 군데를 빠뜨리면 그곳만 권한 구멍이 됩니다.

2. acl 헬퍼로 한곳에 모으기

CI4의 **헬퍼(helper)**는 전역 함수를 모아 두는 곳입니다. app/Helpers/acl_helper.php를 만들어 판정을 함수 하나로 모읍니다.

<?php

/**
 * 접근 권한(ACL) 헬퍼.
 *
 * "본인 또는 관리자" 판정은 글 수정/삭제·댓글 삭제 등 여러 곳(컨트롤러·뷰)에서
 * 반복되던 로직이다. 한 곳으로 모아 둔다.
 */

use CodeIgniter\Shield\Entities\User;

if (! function_exists('is_owner_or_admin')) {
    /**
     * 현재 로그인 사용자가 해당 리소스의 작성자 본인이거나 admin 그룹인지 판정한다.
     *
     * 비로그인이면 항상 false. $ownerId 가 null/0 이면(작성자 미상) 관리자만 true.
     *
     * @param int|string|null $ownerId 리소스 소유자의 user_id
     */
    function is_owner_or_admin($ownerId): bool
    {
        /** @var User|null $user */
        $user = auth()->user();

        if ($user === null) {
            return false;
        }

        return (int) $ownerId === (int) $user->id || $user->inGroup('admin');
    }
}

if (! function_exists(...))로 감싸는 것은 헬퍼의 관례입니다. 같은 헬퍼가 두 번 로드돼도 함수 재정의 에러가 나지 않게 막아 줍니다.

이 헬퍼를 프로젝트 전역에서 쓰도록, app/Config/Autoload.php의 헬퍼 목록에 acl을 등록합니다.

public $helpers = ['auth', 'setting', 'acl'];

3. 중복 코드를 헬퍼 호출로 교체

이제 흩어진 판정을 전부 한 줄로 바꿉니다.

Posts::canModify():

/**
 * 현재 사용자가 이 글을 수정/삭제할 수 있는지 판단한다.
 * 공통 규칙(작성자 본인 또는 admin)은 acl 헬퍼로 모았다.
 */
private function canModify(Post $post): bool
{
    return is_owner_or_admin($post->user_id);
}

Comments::canDelete()는 규칙이 조금 더 복잡합니다 — 댓글 작성자 본인, 글 작성자(자기 글의 댓글 정리), 또는 관리자입니다. 그것도 헬퍼 조합으로 깔끔하게 표현됩니다.

private function canDelete(Comment $comment, ?Post $post): bool
{
    // 댓글 작성자 본인, 또는 글 작성자(자기 글의 댓글 정리), 또는 관리자.
    // "본인 또는 관리자" 판정은 acl 헬퍼로 모았다(관리자는 어느 쪽이든 true).
    return is_owner_or_admin($comment->user_id)
        || ($post !== null && is_owner_or_admin($post->user_id));
}

뷰에서도 마찬가지입니다. comments/_list.php의 여러 줄짜리 인라인 판정이 한 줄이 됩니다.

<?php // 댓글 작성자 본인·글 작성자·관리자에게만 삭제 버튼을 노출한다(acl 헬퍼). ?>
<?php if (is_owner_or_admin($comment->user_id) || is_owner_or_admin($post->user_id)): ?>

posts/show.php의 수정/삭제 버튼 노출도 같은 식으로 줄어듭니다.

<?php // 작성자 본인 또는 관리자에게만 수정/삭제를 노출한다(acl 헬퍼). ?>
<?php if (is_owner_or_admin($post->user_id)): ?>

4. 빠져 있던 버그 — 수정 화면의 권한 가드

리팩터링을 하며 발견한 실제 버그가 있었습니다. 뷰에서는 수정 버튼을 작성자에게만 보여 주고 있었지만, 정작 서버 쪽 edit(수정 폼)과 update(수정 처리)에는 권한 가드가 없었습니다. 즉 남의 글이라도 URL을 직접 치면 수정 폼이 열리고, POST를 보내면 수정이 됐습니다. 버튼을 숨기는 것은 보안이 아닙니다 — 서버에서 막아야 합니다.

editupdate 양쪽에 가드를 넣습니다.

public function edit(int $id): string|ResponseInterface
{
    $post = model(PostModel::class)->find($id);

    if ($post === null) {
        throw PageNotFoundException::forPageNotFound();
    }

    // 작성자 본인 또는 관리자만 수정할 수 있다.
    if (! $this->canModify($post)) {
        return $this->response->setStatusCode(403, '수정 권한이 없습니다.');
    }

    return view('posts/edit', ['post' => $post]);
}

update에도 find() 직후 똑같은 가드를 넣습니다. 반환 타입은 이제 뷰 문자열이나 리다이렉트뿐 아니라 403 응답도 낼 수 있으므로 string|ResponseInterface, RedirectResponse|ResponseInterface로 넓혔습니다.

5. 안전망 — 비소유자 차단 테스트

버그를 고쳤으면 그 버그가 다시 살아나지 않도록 테스트로 못 박습니다. tests/Feature/PostUpdateTest.php에 침입자(intruder) 시나리오를 더합니다. 그러려면 서로 다른 사용자를 두 명 만들 수 있어야 해서, makeUser가 이름·이메일을 인자로 받도록 살짝 고칩니다.

private function makeUser(string $username = 'editor', string $email = 'editor@example.com'): User
{
    // ...
}

public function testNonOwnerCannotSeeEditForm(): void
{
    $owner    = $this->makeUser();
    $id       = $this->makePost($owner->id);
    $intruder = $this->makeUser('intruder', 'intruder@example.com');

    // 남의 글 수정 폼은 403 으로 막힌다.
    $this->actingAs($intruder)->call('GET', "posts/{$id}/edit")->assertStatus(403);
}

public function testNonOwnerCannotUpdatePost(): void
{
    $owner    = $this->makeUser();
    $id       = $this->makePost($owner->id);
    $intruder = $this->makeUser('intruder', 'intruder@example.com');

    $result = $this->actingAs($intruder)->call('POST', "posts/{$id}", [
        'title' => '침입자 수정',
        'body'  => '바뀌면 안 된다.',
    ]);

    $result->assertStatus(403);
    $this->dontSeeInDatabase('posts', ['title' => '침입자 수정']);
    // 같은 글이 원래 값을 그대로 유지하는지도 확인한다.
    $this->seeInDatabase('posts', [
        'id'    => $id,
        'title' => '원래 제목',
        'body'  => '원래 본문',
    ]);
}

두 번째 테스트에서 눈여겨볼 점이 있습니다. dontSeeInDatabase로 "침입자 수정"이 저장되지 않았음을 확인하는 것만으로는 간접적입니다. 그래서 같은 글이 원래 제목·본문을 그대로 유지하는지 seeInDatabase로 직접 확인합니다. "바뀌지 않았어야 할 것이, 정확히 원래 값 그대로인가"를 긍정적으로 단정하는 것이 더 튼튼한 테스트입니다.

composer test

리팩터링과 버그 수정 후에도 전체 테스트가 초록이면, 우리가 코드를 옮기고 합치는 동안 동작을 깨지 않았다는 증거입니다.

핵심 개념 — 왜 이렇게 리팩터링하는가

중복 제거의 진짜 목적은 "고칠 곳을 하나로 만드는 것"입니다. 권한 규칙이 여러 곳에 복제돼 있으면, 규칙이 바뀔 때 하나만 빠뜨려도 그곳이 보안 구멍이 됩니다. is_owner_or_admin 하나로 모으면 규칙은 한 곳에서만 산다.

뷰의 버튼 숨김은 UX이지 보안이 아닙니다. 이번에 찾은 버그가 그 교훈입니다. 권한 판정은 반드시 서버(컨트롤러)에서 강제해야 하고, 뷰의 조건부 노출은 그 위의 편의일 뿐입니다.

리팩터링은 초록 위에서. 테스트가 없는 상태에서 코드를 크게 옮기면, 뭔가 깨져도 알아채지 못합니다. 통과하는 테스트가 있어야 "동작은 그대로, 구조만 개선"이라는 리팩터링의 정의가 성립합니다.

마무리 — 커밋과 태그

git add .
git commit -m "refactor: 버그 수정과 코드 정리"
git tag ep28

다음 회차

다음 글에서는 CI4 버전 업그레이드를 다룹니다. 프레임워크 의존성 제약을 점검·상향하고, composer update로 잠금 파일을 갱신한 뒤, 전체 테스트로 회귀를 검증하는 안전한 업그레이드 절차를 밟습니다. 여기서도 이번에 쌓아 둔 테스트가 안전망이 됩니다.


이번 회차 요약

  • 다루는 파일: app/Helpers/acl_helper.php(신규, is_owner_or_admin) / app/Config/Autoload.php(acl 등록) / app/Controllers/Posts.php·Comments.php(헬퍼 사용 + edit/update 403 가드) / app/Views/posts/show.php·comments/_list.php(헬퍼 사용) / tests/Feature/PostUpdateTest.php(비소유자 차단 테스트)
  • 핵심: 흩어진 "본인 또는 관리자" 판정을 헬퍼로 추출. 뷰의 버튼 숨김이 아니라 서버 가드로 권한을 강제. 리팩터링은 초록 테스트 위에서.

다음: CI4 버전 업그레이드

댓글 0

아직 댓글이 없습니다.

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

← 목록으로