결론부터 말하면 규칙은 두 줄이다. 컨트롤러는 "검증 → 서비스 호출 → 응답"만 한다. 트랜잭션은 유스케이스를 대표하는 서비스 메서드 하나가 연다. 나머지는 이 두 줄을 지키기 위한 장치다.

왜 이 규칙을 정했나

지금 나는 CodeIgniter 4로 SaaS 제품 7개를 혼자 만들고 운영한다. 전부 같은 CI4 API 보일러플레이트(JWT 인증, 소셜 로그인, API 버저닝, 통합 테스트 149개)를 공유한다. 코드는 대부분 Claude Code가 쓰고 나는 설계와 리뷰를 한다.

이 구조에서 계층 규칙이 흐려지면 비용이 일곱 배로 든다. 제품마다 컨트롤러에 로직이 조금씩 새고, 트랜잭션 위치가 제각각이 되면 나중에 회수할 방법이 없다. AILicet처럼 CI3 코드를 CI4로 옮길 때 가장 먼저 부딪힌 것도 "이 로직은 어느 계층에 둬야 하나"였다.

그래서 규칙을 문서로 정하고, 그 문서를 CLAUDE.md와 스킬에 그대로 박아 AI가 같은 판단을 반복하게 만들었다. 이 글은 그 규칙과, 실제 프로젝트(웹 채팅 상담 솔루션)에서 트랜잭션 위치를 정하며 겪은 함정을 정리한 기록이다.

계층별 책임: 무엇을 하고, 무엇을 하지 않는가

호출은 한 방향으로만 흐른다. Controller → Service → Repository(또는 Model) → DB. 역방향 호출과 계층 건너뛰기는 금지다.

계층

위치

하는 일

하지 않는 일

Controller

app/Controllers/Api/V1/

요청 파싱, 입력 검증, 서비스 호출, 응답 포맷

비즈니스 판단, 트랜잭션, 여러 모델 조합

Service

app/Services/

유스케이스 1개 = 메서드 1개, 트랜잭션 소유, 도메인 예외 발생

$this->request 접근, HTTP 상태 코드 결정

Repository / Model

app/Repositories/, app/Models/

쿼리 캡슐화, 스키마·$allowedFields·검증 규칙

다른 모델 호출, 트랜잭션 시작

DTO

app/DTO/

계층 간 데이터 전달(readonly class)

로직

Exception

app/Exceptions/

도메인 예외 → Config\Exceptions에서 HTTP 코드로 변환

응답 직접 출력

Filter

app/Filters/

인증·권한·테넌트·Rate limit 같은 횡단 관심사

도메인 로직

핵심은 서비스가 HTTP를 모른다는 점이다. 서비스는 DTO를 받고 엔티티나 DTO를 돌려주며, 실패는 도메인 예외로 알린다. 그래서 같은 서비스를 API 컨트롤러, CLI 커맨드(php spark), 큐 워커에서 그대로 재사용할 수 있다. 채팅 상담 솔루션의 자동 배정 로직이 API 요청과 큐 Job 양쪽에서 같은 AssignmentService를 쓰는 것이 그 예다.

얇은 컨트롤러 규칙 5가지

  1. 메서드 하나는 세 줄 흐름이다. 입력을 DTO로 만들고, 서비스 하나를 부르고, 공통 응답 메서드로 돌려준다.
  2. 서비스는 액션당 하나만 부른다. 컨트롤러가 서비스 두 개를 순서대로 부르기 시작하면, 그 순서 자체가 비즈니스 로직이다. 묶는 서비스 메서드를 새로 만든다.
  3. if는 입력 형태 검사에만 쓴다. "이 상담이 배정 가능한가" 같은 판단은 서비스로 보낸다.
  4. 응답 포맷은 BaseApiController의 respondOk() / respondError()만 쓴다. 제품 7개가 같은 응답 모양을 갖게 하는 유일한 방법이다.
  5. try/catch를 쓰지 않는다. 서비스가 던진 도메인 예외는 전역 핸들러가 RFC 9457 형식의 에러 응답으로 바꾼다.
<?php

declare(strict_types=1);

namespace App\Controllers\Api\V1\Widget;

use App\Controllers\Api\BaseApiController;
use App\DTO\SendMessageRequest;
use CodeIgniter\HTTP\ResponseInterface;

final class MessageController extends BaseApiController
{
    public function create(int $conversationId): ResponseInterface
    {
        if (! $this->validate('sendMessage')) {
            return $this->respondValidationErrors($this->validator->getErrors());
        }

        $dto     = SendMessageRequest::fromRequest($this->request);
        $message = service('messageService')->send($conversationId, $dto);

        return $this->respondCreated($message->toArray());
    }
}

예외: 단순 조회는 모델 직접 호출을 허용한다

원칙대로라면 조회도 서비스를 거쳐야 한다. 하지만 find($id) 한 줄을 감싸기만 하는 서비스 메서드는 파일만 늘린다. 그래서 채팅 상담 솔루션에서는 규칙을 이렇게 완화했다.

  • 쓰기는 무조건 서비스를 거친다.
  • 단건·목록 조회는 컨트롤러가 모델을 직접 불러도 된다. 단, 조건이 붙는 순간(권한 필터, 집계, 조인) 서비스나 리포지토리로 옮긴다.

테넌트 격리는 컨트롤러가 아니라 TenantScopedModel이 강제하기 때문에, 이 완화가 보안 구멍이 되지 않는다. 컨트롤러가 테넌트 조건을 깜빡해도 모델이 막는다.

CI4에서 서비스를 컨트롤러에 넣는 법

Laravel 습관으로 생성자 주입을 쓰면 CI4에서는 깨진다. CI4는 컨트롤러를 인자 없이 생성한 뒤 initController()를 호출한다. 자동 와이어링 컨테이너가 없다.

// ❌ CI4에서는 ArgumentCountError
public function __construct(private readonly UserService $userService) {}

내가 예전에 정리한 CLAUDE.md 템플릿의 컨트롤러 예제에도 이 생성자 주입 코드가 들어 있었다. 규칙 문서의 예제 코드는 Claude Code에게 곧 정답이 되므로, 예제는 반드시 실제로 돌아가는 CI4 코드여야 한다. 이번 글을 쓰면서 템플릿도 함께 고친다.

해법: Config\Services에 등록하고 service()로 꺼낸다

// app/Config/Services.php
public static function messageService(bool $getShared = true): MessageService
{
    if ($getShared) {
        return static::getSharedInstance('messageService');
    }

    return new MessageService(
        db_connect(),
        model(MessageModel::class),
        static::realtimeBridge(),
    );
}

서비스 자체는 평범한 생성자 주입을 쓴다. 조립 책임만 Services가 갖는다. 이렇게 하면 세 가지가 해결된다.

  • 컨트롤러는 service('messageService') 한 줄로 쓴다.
  • 테스트에서는 Services::injectMock('messageService', $mock)으로 갈아 끼운다.
  • 큐 워커, spark 커맨드도 같은 방식으로 같은 인스턴스를 얻는다.

속성으로 들고 싶다면 initController()를 오버라이드해서 그 안에서 service()로 채우면 된다. 생성자에는 손대지 않는다.

트랜잭션은 서비스 메서드가 소유한다

트랜잭션의 경계는 "함께 성공하거나 함께 실패해야 하는 일의 묶음"이다. 그 묶음을 아는 계층은 유스케이스를 표현하는 서비스뿐이다.

  • 컨트롤러에 두면 HTTP 계층이 DB를 알게 된다. 같은 유스케이스를 큐 워커에서 돌릴 때 트랜잭션이 사라진다.
  • 모델에 두면 모델은 자기 테이블만 안다. 메시지 INSERT와 대화의 last_seq UPDATE가 한 묶음이라는 사실을 모델은 알 수 없다.
  • 서비스에 두면 메서드 이름이 곧 트랜잭션 이름이 된다. send() 하나가 원자 단위다.

실전: 채팅 메시지 seq 채번

웹 채팅 상담 솔루션에서 메시지는 대화별로 단조 증가하는 seq를 가진다. 클라이언트는 이 값으로 정렬하고, 재접속하면 after_seq로 빠진 구간을 다시 받는다. 동시 요청 20개가 와도 중복이나 누락이 있으면 안 된다. Redis INCR은 빠르지만 DB와 어긋날 수 있어서, 이 규모에서는 DB 행 잠금을 택했다.

final class MessageService
{
    public function __construct(
        private readonly BaseConnection $db,
        private readonly MessageModel $messages,
        private readonly RealtimeBridge $bridge,
    ) {}

    public function send(int $conversationId, SendMessageRequest $dto): array
    {
        // 1) 멱등: 같은 client_msg_id 재전송이면 기존 메시지를 그대로 돌려준다
        $existing = $this->messages->findByClientMsgId($conversationId, $dto->clientMsgId);
        if ($existing !== null) {
            return $existing;
        }

        // 2) 트랜잭션: seq 채번 → INSERT → last_seq 갱신
        $this->db->transStart();

        $row = $this->db->query(
            'SELECT last_seq FROM conversations WHERE id = ? FOR UPDATE',
            [$conversationId],
        )->getRowArray();

        $seq = (int) $row['last_seq'] + 1;

        $id = $this->messages->insert([
            'conversation_id' => $conversationId,
            'client_msg_id'   => $dto->clientMsgId,
            'content'         => $dto->content,
            'seq'             => $seq,
        ]);

        $this->db->table('conversations')
            ->where('id', $conversationId)
            ->update(['last_seq' => $seq, 'last_message_at' => date('Y-m-d H:i:s')]);

        $this->db->transComplete();

        if ($this->db->transStatus() === false) {
            throw new MessagePersistException($conversationId);
        }

        // 3) 커밋 이후에만 외부로 알린다
        $message = $this->messages->find($id);
        $this->bridge->publish("conv:{$conversationId}", 'message.created', $message);

        return $message;
    }
}

FOR UPDATE가 같은 대화의 동시 요청을 줄 세운다. 다른 대화끼리는 서로 막지 않으므로 처리량 손해도 작다. client_msg_id의 UNIQUE 제약은 사전 조회를 빠져나간 경합을 잡는 마지막 안전망이고, 이 위반은 에러가 아니라 재전송으로 취급해 200을 돌려준다.

트랜잭션을 서비스에 둬도 생기는 함정 4가지

1. 부수효과는 커밋 이후에

Redis 발행, 큐 적재, 웹훅, 메일 발송은 DB가 아니라서 롤백되지 않는다. 트랜잭션 안에서 발행하면 롤백된 메시지가 상담원 화면에 뜬다. 큐 Job을 트랜잭션 안에서 넣으면, 워커가 커밋보다 먼저 Job을 집어 "없는 레코드"를 조회하는 일도 생긴다.

규칙은 단순하다. transComplete() 다음 transStatus()가 참인 것을 확인한 뒤에만 바깥 세계에 알린다. 채팅 솔루션 마일스톤의 완료 조건에 "트랜잭션 롤백 시 Redis 발행이 일어나지 않음"을 테스트 항목으로 넣은 이유다.

2. 중첩 트랜잭션: 주인은 하나다

ConversationService::close()가 내부에서 MessageService::send()로 시스템 메시지를 남긴다고 하자. 둘 다 transStart()를 부르면, CI4는 깊이만 세고 가장 바깥 호출에서 커밋한다. 동작은 하지만 "어디서 커밋되는지"가 코드에서 안 보인다.

그래서 규칙을 하나 더 뒀다. 트랜잭션은 컨트롤러·큐·CLI가 직접 부르는 진입 메서드만 연다. 다른 서비스가 재사용하는 로직은 트랜잭션 없는 내부 메서드(appendInTransaction() 같은 이름)로 분리하고, 진입 메서드가 그것을 감싼다.

3. 트랜잭션 도중 던진 예외는 자동 롤백되지 않는다

transStart() 모드에서 CI4가 알아서 롤백하는 건 쿼리가 실패했을 때다. CI4 4.3부터는 DBDebug가 켜져 있어도 트랜잭션 안의 쿼리 오류는 기본적으로 예외를 던지지 않고 transStatus()만 false가 된다. 반대로 transStart()와 transComplete() 사이에서 내가 도메인 예외를 던지면, 커넥션이 트랜잭션을 연 채로 남는다.

대응은 두 가지 중 하나를 고른다.

  • 검증과 조회는 트랜잭션 시작 전에 끝낸다. 트랜잭션 안에는 쓰기만 남긴다. (위 send()가 이 방식)
  • 예외 흐름이 필요하면 $db->transException(true)->transStart()로 쿼리 오류를 예외로 받고, try/catch에서 transRollback() 후 다시 던진다.

4. 트랜잭션은 커넥션 단위다

사보드 프로젝트처럼 어드민 DB를 분리하면 모델마다 $DBGroup이 달라진다. 서로 다른 커넥션에 걸친 쓰기는 하나의 트랜잭션으로 묶이지 않는다. 서비스가 db_connect('admin')으로 트랜잭션을 열어도, default 그룹 모델의 INSERT는 그 밖에서 커밋된다.

  • 한 유스케이스의 쓰기는 한 DB 그룹 안에서 끝나도록 경계를 설계한다.
  • 서비스가 쓰는 커넥션과 모델의 커넥션을 일치시킨다: model(MessageModel::class, true, $db)처럼 커넥션을 넘긴다.
  • 그룹을 넘나드는 일관성이 필요하면 트랜잭션이 아니라 아웃박스 테이블 + 큐로 푼다. MSA에서 서비스 간 일관성을 다루는 방식과 같다.

규칙을 Claude Code에 박아두기

사람끼리는 코드 리뷰로 규칙을 맞추지만, AI에게는 매번 같은 문맥을 줘야 한다. 나는 세 겹으로 나눴다.

  1. CLAUDE.md: 짧고 단정적인 규칙만 둔다. 긴 설명은 넣지 않는다.
  2. 스킬: 상세 규약과 예제 코드는 .claude/skills/로 뺀다. 에러 응답 규약(api-error-contract)을 먼저 스킬로 만들었다. 계층 규칙도 같은 방식으로 스킬로 빼기 좋은 후보다.
  3. 훅: .claude/settings.json의 훅으로 테스트를 자동 실행한다. 규칙 위반이 리뷰 전에 빨간불로 돌아온다.

CLAUDE.md에 실제로 넣는 계층 규칙은 이 정도다.

## 계층 규칙 (CI4)
- 컨트롤러: 검증 → service() 호출 1회 → respondOk/respondError. 비즈니스 로직 금지.
- 컨트롤러 생성자 주입 금지. 서비스는 Config\Services에 등록하고 service()로 꺼낸다.
- 쓰기는 반드시 서비스 경유. 단순 단건/목록 조회만 컨트롤러 → 모델 직접 허용.
- 트랜잭션은 진입 서비스 메서드만 연다. 모델·컨트롤러에서 transStart 금지.
- Redis 발행·큐 적재·웹훅은 transStatus() 확인 후 커밋 이후에만.
- 한 유스케이스의 쓰기는 한 DB 그룹 안에서 끝낸다.
- 새 기능 순서: 마이그레이션 → 모델 → 서비스 → 컨트롤러 → 라우트 → 테스트.

"하지 말 것"을 명시적으로 적어두는 편이 낫다. "컨트롤러는 얇게"처럼 해석 여지가 있는 문장보다, 금지 대상이 구체적인 문장이 리뷰할 때도 위반을 바로 짚기 쉽다.

마무리: PR 리뷰 체크리스트

계층 분리는 "폴더를 나눴다"로 끝나지 않는다. 경계가 지켜지는지 매 PR에서 확인해야 한다. 내가 리뷰할 때 보는 항목은 이것이다.

  • 컨트롤러 메서드가 검증 → 서비스 1회 호출 → 공통 응답, 이 흐름을 벗어나지 않는가
  • 컨트롤러 생성자에 의존성 인자가 없는가 (service() 사용)
  • 서비스가 $this->request나 HTTP 상태 코드를 참조하지 않는가
  • 모델·컨트롤러에 transStart()가 없는가
  • 트랜잭션 안에서 도메인 예외를 던지지 않는가 (검증은 트랜잭션 전에)
  • Redis 발행·큐 적재·웹훅이 transStatus() 확인 이후에 있는가
  • 한 유스케이스의 쓰기가 하나의 DB 그룹 안에서 끝나는가
  • 롤백 시 외부 발행이 없다는 테스트가 있는가

얇은 컨트롤러와 서비스 소유 트랜잭션은 결국 같은 이야기다. 유스케이스의 경계를 코드 한 곳에 드러내는 것. 그 한 곳이 분명하면, 사람이 쓰든 AI가 쓰든 코드가 같은 모양으로 수렴한다.