블로그 목록
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");

위 코드는 컴파일 에러 발생: stringnumber가 아님

4. 테스트 우선

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

수치로 보는 결과

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

이 방식의 장단점

장점

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

단점

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

결론

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

핵심 인사이트:

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

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