블로그 목록
AI / Backend

아키텍처만 잡아주면 AI가 코드를 짠다: Claude Code로 백엔드 개발하기

2024년 12월 1일
12분
아키텍처만 잡아주면 AI가 코드를 짠다: Claude Code로 백엔드 개발하기

핵심 아이디어

아키텍처를 명확하게 설계하고, 테스트 인프라를 갖추면, AI에게 구현을 맡기고 테스트만 검수하면 된다.

실제로 이 방식으로 261개 TypeScript 파일, 50개 테스트 파일을 가진 NestJS 백엔드를 개발했습니다. 이 글에서는 그 경험과 핵심 패턴을 공유합니다.

프로젝트 개요

Wagzak API는 어린이 교육 앱의 백엔드입니다:

항목내용
프레임워크NestJS 11 + TypeORM
데이터베이스MySQL
아키텍처Domain-Driven Design (DDD)
테스트Jest + 격리된 DB 인스턴스
배포Docker → Google Cloud Run

워크플로우

  1. 아키텍처 설계 (사람)
모듈 구조, 패턴, 규칙 정의
↓
  1. 테스트 인프라 구축 (사람)
setupTest(), 격리 DB, 이벤트 캡처
↓
  1. 기능 구현 (Claude Code)
패턴을 따라 서비스, 엔티티, 컨트롤러 생성
↓
  1. 테스트 검수 (사람)
테스트 실행, 엣지 케이스 확인, 승인

AI가 따를 수 있는 아키텍처

1. DDD 모듈 구조

기술 레이어가 아닌 비즈니스 도메인으로 폴더를 구성합니다:

폴더도메인
auth/인증
user/사용자 관리
mission/미션/레슨
reward/보상 시스템
growth/경험치/레벨업
sticker/스티커 수집
star/재화 (별)
payment/결제/구독
infrastructure/공통 인프라

각 도메인 모듈 내부도 일관된 구조:

파일역할
reward.service.ts비즈니스 로직
reward.controller.tsHTTP 엔드포인트
reward.entity.tsTypeORM 엔티티
reward-item.vo.tsValue Object
dto/Data Transfer Objects
*.test.ts테스트 (소스와 동일 위치)

왜 이게 중요한가? Claude Code가 새 기능을 추가할 때 "reward 모듈처럼 만들어"라고 하면 정확히 같은 구조로 만듭니다.

2. 도메인 이벤트 패턴

엔티티가 자신의 상태 변경을 이벤트로 발행합니다:

base-entity.entity.ts

export abstract class BaseEntity {
  private _domainEvents: any[] = [];

  protected registerEvent<T>(event: T): T {
    this._domainEvents.push(event);
    return event;
  }

  domainEvents() {
    return this._domainEvents || [];
  }
}

user.entity.ts

static create(email: string, name: string) {
  const user = new User();
  user.email = email;
  user.name = name;

  user.registerEvent(
    new UserRegistered(user.id, user.email)
  );

  return user;
}

AI에게 주는 이점: "사용자 가입 시 웰컴 보상을 주세요"라고 하면, Claude는 UserRegistered 이벤트 리스너를 만들어 처리합니다. 기존 코드를 수정할 필요 없이 새 기능을 추가할 수 있습니다.

3. 트랜잭션 데코레이터

복잡한 트랜잭션 관리를 데코레이터로 추상화:

@Transactional()
async grantRewardFromPolicy(
  userId: string,
  policy: RewardPolicy,
  reason: string,
): Promise<RewardResult> {
  await this.growthService.addExperience(userId, xp);
  await this.starService.grantStar(userId, star);
  await this.stickerService.grantStickers(userId, stickers);
  return result;
}

AI에게 주는 이점: "이 작업은 트랜잭션으로 묶어주세요"라고 하면 @Transactional() 데코레이터만 붙입니다. 트랜잭션 시작/커밋/롤백 로직을 직접 작성할 필요 없습니다.

4. Value Object 패턴

불변 객체로 도메인 개념을 표현:

reward-item.vo.ts

export class RewardItem {
  constructor(
    public readonly type: RewardType,
    public readonly reason: string,
    public readonly xp?: number,
    public readonly star?: number,
  ) {
    if (xp !== undefined && xp < 0) {
      throw new Error("경험치는 0 이상이어야 합니다.");
    }
  }

  static levelUp(level: number, xp: number): RewardItem {
    return new RewardItem(
      RewardType.LEVEL_UP,
      `레벨 ${level} 달성`,
      xp
    );
  }

  static mission(missionId: string, xp: number, star: number): RewardItem {
    return new RewardItem(
      RewardType.MISSION,
      `미션 ${missionId} 완료`,
      xp,
      star
    );
  }
}

AI에게 주는 이점: "레벨업 보상을 만들어"라고 하면 RewardItem.levelUp()을 사용합니다. 잘못된 방식으로 객체를 생성할 수 없습니다.

테스트 인프라가 핵심이다

AI가 작성한 코드를 신뢰하려면 강력한 테스트 인프라가 필수입니다.

setupTest() 유틸리티

모든 테스트의 보일러플레이트를 제거하는 핵심 유틸리티:

const test = setupTest();

it("보상을 지급할 수 있다", async () => {
  const account = await test.randomJoinedAccount();
  const rewardService = test.get(RewardService);

  await rewardService.grantReward(account.userId, 100, 10);

  const user = await test.getRepo(User).findOne({
    where: { id: account.userId }
  });
  expect(user.xp).toBe(100);
  expect(user.star).toBe(10);
});

격리된 데이터베이스

각 테스트마다 새 데이터베이스를 생성합니다:

beforeEach(async () => {
  dbName = genId();
  await dataSource.query(`CREATE DATABASE IF NOT EXISTS ${dbName}`);

  const moduleRef = await Test.createTestingModule({
    imports: [TypeOrmModule.forRoot({ database: dbName }), AppModule],
  }).compile();
});

afterEach(async () => {
  await app.close();
});

왜 이게 중요한가? 테스트 간 데이터 오염이 없습니다. AI가 만든 코드가 다른 테스트에 영향을 주지 않습니다.

이벤트 캡처

도메인 이벤트가 올바르게 발행되는지 검증:

it("사용자 가입 시 이벤트가 발행된다", async () => {
  await test.request()
    .post("/auth/register")
    .send({ email: "test@test.com", password: "1234" });

  test.expectEventEmitted(UserRegistered, {
    email: "test@test.com"
  });
});

it("실패 시 이벤트가 발행되지 않는다", async () => {
  await test.request()
    .post("/auth/register")
    .send({ email: "invalid" });

  test.expectEventNotEmitted(UserRegistered);
});

실제 개발 세션 예시

Claude Code와의 대화(예시):

사람: DailyRewardService를 만들어줘. 하루에 받을 수 있는 보상에 상한선이 있어.
      미션 타입별로 따로 관리되어야 하고, 자정에 리셋돼.

Claude: DailyRewardService를 생성하겠습니다.

1. daily-reward.entity.ts - 일일 누적 보상 엔티티
2. daily-reward.service.ts - 누적 조회/추가 로직
3. daily-reward.service.test.ts - 유닛 테스트

[엔티티 코드 작성]
[서비스 코드 작성]
[테스트 코드 작성]

테스트를 실행하겠습니다...

✓ 오늘 누적 보상을 조회할 수 있다
✓ 레코드가 없으면 0을 반환한다
✓ 미션 타입별로 따로 누적된다
✓ 어제 기록은 조회되지 않는다
✓ 상한선 초과 시 제한된다

5 passed

내가 한 일: 요구사항 설명 (2분) Claude가 한 일: 엔티티, 서비스, 테스트 작성 및 검증 (10분) 내가 검수한 것: 테스트 케이스가 요구사항을 다 커버하는지 확인 (3분)

AI 친화적 아키텍처의 조건

이 경험에서 배운 AI가 잘 따를 수 있는 아키텍처의 조건:

1. 명확한 규칙

CLAUDE.md 파일에 규칙 명시:

rules:
  - 모든 엔티티는 BaseEntity를 상속
  - 서비스 메서드는 @Transactional() 사용
  - 테스트는 소스 파일 옆에 *.test.ts로 생성
  - DTO는 dto/ 폴더에 분리

2. 일관된 패턴

모든 모듈이 같은 구조를 따릅니다. Claude가 한 모듈을 보면 다른 모듈도 같은 방식으로 만들 수 있습니다.

3. 강력한 타입 시스템

TypeScript의 타입이 AI의 실수를 잡아줍니다:

rewardService.grantReward("userId", "100");

위 코드는 컴파일 에러 발생: string은 number가 아님

4. 테스트 우선

테스트가 스펙 역할을 합니다. "이 테스트를 통과하게 구현해줘"라고 하면 됩니다.

수치로 보는 결과

지표값
TypeScript 파일261개
테스트 파일50개
도메인 모듈11개
인프라 컴포넌트15개+
테스트 커버리지~19% (파일 기준)

이 방식의 장단점

장점

  • 개발 속도: 구현 시간 대폭 단축
  • 일관성: AI가 패턴을 정확히 따름
  • 품질: 테스트로 검증된 코드만 머지
  • 문서화: 코드 자체가 패턴의 예시

단점

  • 초기 투자: 아키텍처 설계와 테스트 인프라 구축에 시간 필요
  • 학습 곡선: 팀원들이 패턴을 이해해야 함
  • 엣지 케이스: AI가 놓치는 부분은 사람이 보완

결론

아키텍처는 사람이, 구현은 AI가, 검증은 테스트가.

핵심 인사이트:

  1. DDD 구조가 AI에게 명확한 가이드라인 제공
  2. 도메인 이벤트로 기능 간 결합도 낮춤
  3. Value Object로 잘못된 객체 생성 방지
  4. 트랜잭션 데코레이터로 복잡성 숨김
  5. 격리된 테스트 DB로 신뢰할 수 있는 검증

잘 설계된 아키텍처 위에서 AI는 생산성 도구가 아니라 진짜 개발 파트너가 됩니다.