CodeIgniter 4로 블로그 만들기 #14 — 글 수정
앞선 회차에서 글을 새로 쓰는 흐름을 완성했습니다. 검증·CSRF·작성자 연결까지 갖춘 저장 파이프라인이 있으니, 이번엔 그 반대편인 글 수정을 붙일 차례입니다.
수정은 새로 쓰기와 90% 닮았지만, 딱 하나 결정적인 차이가 있습니다. 폼을 열었을 때 기존 값이 이미 채워져 있어야 한다는 것입니다. 이번 회차의 핵심 학습 포인트가 바로 이 "기존 값 바인딩"입니다.
이번 회차의 목표
- 글 수정 폼을 여는 라우트(
GET posts/(:num)/edit)와 저장 라우트(POST posts/(:num))를 로그인 그룹 안에 추가한다. - 컨트롤러에
edit()(폼 표시)과update()(저장)를 구현한다. - 수정 폼에 기존 값을 바인딩하고, 검증 실패 시에는
withInput()으로 입력을 보존한다. - 이 모든 걸 테스트 우선으로 — 실패하는 테스트를 먼저 쓰고, 구현으로 초록불을 켠다.
작성자/관리자 권한 체크(남의 글은 못 고치게 막는 것)는 이번 회차 범위가 아닙니다. 다음 #15에서 삭제와 함께 다룹니다. 지금은 "로그인한 사용자면 수정 화면에 들어갈 수 있다"까지입니다.
1. 실패하는 테스트부터 — PostUpdateTest
수정 기능에서 우리가 지키고 싶은 계약은 네 가지입니다.
- 비로그인 사용자는 수정할 수 없다 (세션 필터가 막아 리다이렉트)
- 수정 폼에는 기존 값이 채워져 있다
- 로그인 사용자가 유효한 값을 넣으면 글이 실제로 바뀐다
- 검증에 실패하면(예: 제목 비움) 기존 값이 그대로 유지된다
이걸 그대로 tests/Feature/PostUpdateTest.php로 옮깁니다. 먼저 준비 코드부터 봅니다.
final class PostUpdateTest extends CIUnitTestCase
{
use DatabaseTestTrait;
use FeatureTestTrait;
use AuthenticationTesting;
protected $namespace = null;
protected $refresh = true;
protected function setUp(): void
{
parent::setUp();
// 앞선 테스트의 로그인 세션(actingAs)이 auth 싱글톤에 캐시된 채
// 새 나가지 않도록, 매 테스트 전에 세션/인증 상태를 비운다.
$_SESSION = [];
\Config\Services::resetSingle('session');
\Config\Services::resetSingle('auth');
}
여기서 setUp()의 세 줄이 이번 회차의 숨은 함정 해결책입니다. Shield의 actingAs()로 로그인시킨 상태가 auth 싱글톤과 $_SESSION에 캐시되는데, 이게 다음 테스트로 새어 나가면 "비로그인이어야 할 테스트가 실은 로그인 상태"가 되어 엉뚱하게 통과하거나 실패합니다. 그래서 매 테스트 시작 전에 세션과 싱글톤을 명시적으로 초기화합니다.
사용자와 글을 만드는 헬퍼는 이렇게 둡니다.
private function makeUser(): User
{
$users = auth()->getProvider();
$user = new User([
'username' => 'editor',
'email' => 'editor@example.com',
'password' => 'secret-password-123',
]);
$users->save($user);
return $users->findById($users->getInsertID());
}
private function makePost(int $userId): int
{
$model = model(PostModel::class);
$model->insert([
'user_id' => $userId,
'title' => '원래 제목',
'body' => '원래 본문',
'slug' => 'post-original',
]);
return $model->getInsertID();
}
이제 네 가지 계약을 각각 테스트로 씁니다.
public function testGuestCannotUpdatePost(): void
{
$id = $this->makePost(1);
$result = $this->call('POST', "posts/{$id}", [
'title' => '게스트 수정',
'body' => '바뀌면 안 된다.',
]);
$result->assertRedirect();
$this->dontSeeInDatabase('posts', ['title' => '게스트 수정']);
}
public function testEditFormShowsExistingValues(): void
{
$user = $this->makeUser();
$id = $this->makePost($user->id);
$result = $this->actingAs($user)->call('GET', "posts/{$id}/edit");
$result->assertStatus(200);
$result->assertSee('원래 제목');
$result->assertSee('원래 본문');
}
public function testLoggedInUserCanUpdatePost(): void
{
$user = $this->makeUser();
$id = $this->makePost($user->id);
$result = $this->actingAs($user)->call('POST', "posts/{$id}", [
'title' => '수정된 제목',
'body' => '수정된 본문',
]);
$result->assertRedirect();
$this->seeInDatabase('posts', [
'id' => $id,
'title' => '수정된 제목',
]);
}
public function testUpdateValidationFailsWithEmptyTitle(): void
{
$user = $this->makeUser();
$id = $this->makePost($user->id);
$result = $this->actingAs($user)->call('POST', "posts/{$id}", [
'title' => '',
'body' => '제목 비움',
]);
$result->assertRedirect();
// 검증 실패 시 기존 값이 그대로 남아 있어야 한다.
$this->seeInDatabase('posts', [
'id' => $id,
'title' => '원래 제목',
]);
}
testEditFormShowsExistingValues가 "기존 값 바인딩"을 검사하는 테스트입니다. 폼을 GET으로 열었을 때 응답 HTML 안에 원래 제목·원래 본문이 보여야 통과합니다.
지금 실행하면 라우트도 edit()/update()도 없으니 전부 빨간불입니다. 이제 초록불을 켜러 갑니다.
2. 라우트 추가
수정은 로그인이 필요한 쓰기 작업이므로, ep11에서 만든 session 필터 그룹 안에 넣습니다. 이미 있던 작성 라우트 아래에 두 줄을 더합니다.
$routes->group('', ['filter' => 'session'], static function ($routes) {
$routes->get('posts/new', 'Posts::new'); // 글 작성 폼
$routes->post('posts', 'Posts::create'); // 글 저장
$routes->get('posts/(:num)/edit', 'Posts::edit/$1'); // 글 수정 폼
$routes->post('posts/(:num)', 'Posts::update/$1'); // 글 수정 저장
});
(:num)은 숫자만 매칭하는 플레이스홀더입니다.$1로 컨트롤러 메서드에 그 값이 넘어갑니다.- 수정 폼은
GET .../edit, 저장은 같은 자원에 대한POST posts/(:num)로 구분합니다. HTML 폼은 PUT/PATCH를 직접 못 보내므로 실무에서 흔히 이렇게 POST로 처리합니다.
3. 컨트롤러 — edit()와 update()
app/Controllers/Posts.php에 두 메서드를 추가합니다. 먼저 폼을 여는 edit().
/**
* 글 수정 폼을 보여 준다. 기존 값을 폼에 채운다.
*/
public function edit(int $id): string
{
$post = model(PostModel::class)->find($id);
if ($post === null) {
throw PageNotFoundException::forPageNotFound();
}
return view('posts/edit', [
'post' => $post,
]);
}
find($id)로 글을 찾아 없으면 404를 던지고, 있으면 $post를 뷰로 넘깁니다. 뷰는 이 $post로 폼의 초기값을 채웁니다.
다음은 저장하는 update().
/**
* 수정된 값을 검증하고 저장한다.
*/
public function update(int $id): RedirectResponse
{
$model = model(PostModel::class);
$post = $model->find($id);
if ($post === null) {
throw PageNotFoundException::forPageNotFound();
}
$data = $this->request->getPost(['title', 'body']);
// 검증 실패 시: 입력값을 들고 수정 폼으로 되돌아간다.
if (! $model->update($id, $data)) {
return redirect()->back()
->withInput()
->with('errors', $model->errors());
}
// 수정 성공 시: 해당 글 상세로 이동한다.
return redirect()->to('posts/' . $post->slug);
}
작성(create())과 구조가 똑같습니다. getPost(['title', 'body'])로 허용된 필드만 추리고, $model->update()가 모델의 검증 규칙을 자동 적용합니다. 검증이 실패하면 false를 반환하니, 그때 withInput()으로 방금 친 값을 세션에 실어 폼으로 되돌립니다. 성공하면 그 글의 상세 페이지(posts/{slug})로 보냅니다.
user_id를 여기서 다시 세팅하지 않는 점에 주목하세요. 작성자는 글을 만들 때 한 번 정해지면 되고, 수정에서 바꿀 이유가 없습니다.
4. 수정 폼 뷰 — 기존 값 바인딩
이번 회차의 하이라이트입니다. app/Views/posts/edit.php를 만듭니다.
<?= $this->extend('layouts/default') ?>
<?= $this->section('title') ?>글 수정<?= $this->endSection() ?>
<?= $this->section('content') ?>
<h1 class="page-title">글 수정</h1>
<?php $errors = session('errors') ?? []; ?>
<?php if ($errors !== []): ?>
<ul class="form-errors">
<?php foreach ($errors as $error): ?>
<li><?= esc($error) ?></li>
<?php endforeach ?>
</ul>
<?php endif ?>
<form class="form" action="<?= site_url('posts/' . $post->id) ?>" method="post">
<?= csrf_field() ?>
<div>
<label for="title">제목</label>
<?php // old() 가 없으면(검증 통과 직후 첫 진입) 기존 값으로 바인딩한다. ?>
<input type="text" name="title" id="title" value="<?= esc(old('title', $post->title)) ?>">
</div>
<div>
<label for="body">본문</label>
<textarea name="body" id="body" rows="12"><?= esc(old('body', $post->body)) ?></textarea>
</div>
<button type="submit" class="btn">수정</button>
<a class="btn btn-ghost" href="<?= site_url('posts/' . $post->slug) ?>">취소</a>
</form>
<?= $this->endSection() ?>
디자인은 이전 회차의 작성 폼과 완전히 같은 .form 클래스를 쓰므로 별도 CSS는 필요 없습니다. 눈여겨볼 건 값 바인딩 부분입니다.
value="<?= esc(old('title', $post->title)) ?>"
old()의 두 번째 인자가 기본값이라는 점이 핵심입니다.
- 폼에 처음 들어왔을 때(수정 화면을 여는 GET) — 세션에
old값이 없으니 두 번째 인자$post->title, 즉 DB의 기존 값이 채워집니다. - 검증 실패로 되돌아왔을 때 —
update()가withInput()으로 세션에 방금 친 값을 넣어 뒀으니old('title')이 그 값을 돌려주고, 기존 값 대신 사용자가 방금 입력한 값이 유지됩니다.
이 한 줄로 "처음엔 기존 값, 실패 후엔 입력값"이라는 두 시나리오가 자연스럽게 모두 처리됩니다. esc()로 감싸 출력 시 XSS를 막는 것도 잊지 마세요.
핵심 개념 — 왜 이렇게 하는가
하나. old(key, default)의 폴백 구조. 수정 폼을 새 폼과 다르게 만드는 유일한 지점이 여기입니다. 억지로 "GET이면 기존값, POST 실패면 입력값" 같은 분기를 짜지 않아도, old()의 기본값 인자 하나로 두 경우가 우아하게 갈립니다. CI4의 관용구로 기억해 두면 좋습니다.
둘. 모델이 검증의 주인. 컨트롤러는 $model->update()의 반환값(성공/실패)만 보고 분기합니다. 검증 규칙은 이전 회차에서 PostModel에 한 번 정의해 뒀고, insert든 update든 그 규칙이 자동으로 적용됩니다. 규칙이 한 곳에 모여 있으니 작성과 수정의 검증이 어긋날 일이 없습니다.
셋. 테스트 격리. setUp()의 세션 초기화는 "이 테스트만 잘 돌면 된다"가 아니라 "다른 테스트와 함께 돌 때도 안전해야 한다"를 지키는 장치입니다. 인증이 얽힌 Feature 테스트에서 특히 중요합니다.
마무리 — 커밋과 태그
이번 회차에서 한 일:
- 실패하는
PostUpdateTest작성(게스트 차단·기존 값 바인딩·수정 성공·검증 실패 시 원값 유지) - 로그인 그룹에 수정 폼/저장 라우트 추가
edit()/update()구현으로 테스트를 초록불로old('title', $post->title)폴백으로 기존 값 바인딩
빨간불(테스트) → 초록불(구현)을 같은 커밋에 담아 태그를 답니다.
git add .
git commit -m "feat: 글 수정"
git tag ep14
다음 회차
다음 글에서는 글 삭제와 작성자 권한을 다룹니다. 지금까지는 "로그인만 하면 누구나" 수정·삭제에 접근할 수 있는데, 이제 작성자 본인 또는 관리자만 가능하도록 컨트롤러 가드를 두고, Shield의 그룹(admin)을 활용해 권한을 나눕니다. 상세 화면의 수정/삭제 버튼도 권한 있는 사람에게만 보이게 만듭니다.
이번 회차 요약
- 다루는 파일:
app/Views/posts/edit.php(생성) /tests/Feature/PostUpdateTest.php(생성) /app/Controllers/Posts.php(edit()·update()추가) /app/Config/Routes.php(수정 라우트 추가)- 핵심: 수정 폼은
old('field', $post->field)로 기존 값을 바인딩한다 — 첫 진입엔 DB 값, 검증 실패 후엔 입력값이 유지된다. 검증은 모델이, 실패 시withInput().
다음: 글 삭제와 작성자 권한
댓글 0
아직 댓글이 없습니다.