아키텍처만 잡아주면 AI가 코드를 짠다: Claude Code로 백엔드 개발하기
핵심 아이디어
아키텍처를 명확하게 설계하고, 테스트 인프라를 갖추면, AI에게 구현을 맡기고 테스트만 검수하면 된다.
실제로 이 방식으로 261개 TypeScript 파일, 50개 테스트 파일을 가진 NestJS 백엔드를 개발했습니다. 이 글에서는 그 경험과 핵심 패턴을 공유합니다.
프로젝트 개요
Wagzak API는 어린이 교육 앱의 백엔드입니다:
| 항목 | 내용 |
|---|---|
| 프레임워크 | NestJS 11 + TypeORM |
| 데이터베이스 | MySQL |
| 아키텍처 | Domain-Driven Design (DDD) |
| 테스트 | Jest + 격리된 DB 인스턴스 |
| 배포 | Docker → Google Cloud Run |
워크플로우
- 아키텍처 설계 (사람)
- 테스트 인프라 구축 (사람)
- 기능 구현 (Claude Code)
- 테스트 검수 (사람)
AI가 따를 수 있는 아키텍처
1. DDD 모듈 구조
기술 레이어가 아닌 비즈니스 도메인으로 폴더를 구성합니다:
| 폴더 | 도메인 |
|---|---|
auth/ | 인증 |
user/ | 사용자 관리 |
mission/ | 미션/레슨 |
reward/ | 보상 시스템 |
growth/ | 경험치/레벨업 |
sticker/ | 스티커 수집 |
star/ | 재화 (별) |
payment/ | 결제/구독 |
infrastructure/ | 공통 인프라 |
각 도메인 모듈 내부도 일관된 구조:
| 파일 | 역할 |
|---|---|
reward.service.ts | 비즈니스 로직 |
reward.controller.ts | HTTP 엔드포인트 |
reward.entity.ts | TypeORM 엔티티 |
reward-item.vo.ts | Value 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가, 검증은 테스트가.
핵심 인사이트:
- DDD 구조가 AI에게 명확한 가이드라인 제공
- 도메인 이벤트로 기능 간 결합도 낮춤
- Value Object로 잘못된 객체 생성 방지
- 트랜잭션 데코레이터로 복잡성 숨김
- 격리된 테스트 DB로 신뢰할 수 있는 검증
잘 설계된 아키텍처 위에서 AI는 생산성 도구가 아니라 진짜 개발 파트너가 됩니다.