AI 기반 개발을 하면서 가장 크게 느낀 문제는 “얼마나 빨리 구현하느냐”가 아니라, “내가 의도한 방식대로 구현되었는가” 였습니다. 구현을 맡기다 보면 다음 문제가 반복적으로 발생합니다.
요구사항을 정확하게 구현하기 위해서는 "좋은 프롬포트"보다 "좋은 컨텍스트"를 제공하는 것이 중요합니다.
명확한 요구사항, 일관된 도메인 용어, 시스템 흐름, 제약 조건과 같은 명세 기반 정보를 제공해야 합니다.
팀 내에서 SDD(Spec-Driven Development) 를 도입했습니다.
SDD는 구현 전에 스펙과 설계를 먼저 정의하고, 그 명세를 기준으로 개발과 검증을 진행하는 방식입니다.
SDD를 적용하면 다음이 가능해집니다.
AI 활용 관점에서도 명세 기반 컨텍스트를 제공하기 때문에 코드 품질이 안정되고 추론 정확도가 올라갑니다.
기능 개발 시 다음 흐름을 따릅니다.
요구사항 → 시각화 → 명세 → 계획 → 구현 → 검증
| 단계 | 산출물 | 목적 |
|---|---|---|
| 요구사항 | PRD | 무엇을, 왜 만드는지 정의 |
| 시각화 | 다이어그램 (C4, ERD, Sequence 등) | 시스템 구조와 흐름 정의 |
| 명세 | Feature Spec, API Spec | 구체적인 동작 계약 정의 |
| 계획 | Task 분해 | 구현 단위로 쪼개기 |
| 구현 | 코드 | TDD 기반 개발 |
| 검증 | 테스트, AC | 명세 기준으로 검증 |
각 단계는 AI 슬래시 커맨드로 스킬화해서 사용하고 있습니다.
기능을 개발할 때 "무엇을, 왜 만들어야 하는지"를 명확히 정의하는 핵심 기획 문서입니다.
PRD가 없으면 사람마다 기능을 다르게 해석하고, 구현 중간에 스코프가 바뀌고, 완료 기준이 모호해집니다.
구성 요소
| 섹션 | 내용 |
|---|---|
| Overview | 배경, 목표, KPI |
| User Stories | 사용자 관점 기능 |
| Functional Requirements | 기능 요구사항 |
| Non-functional Requirements | 성능, 보안, 가용성 |
| Out of Scope | 이번 범위 제외 |
| Open Questions | 미확정 사항 |
User Story 초안 자동 생성
"Out of Scope" 섹션이 특히 강력 — AI가 경계 케이스를 잘 잡아냄
Open Questions 목록 생성 → 회의 전 체크리스트로 활용
프롬프트 예시:
"다음 요구사항을 바탕으로 PRD 초안을 작성해줘.
특히 이번 범위에서 제외해야 할 항목(Out of Scope)과
아직 결정되지 않은 사항(Open Questions)을 반드시 포함해줘"
So that이 없으면 스펙이 아니라 기능 목록일 뿐입니다.
So that을 쓰다 보면 "이 기능이 정말 필요한가"를 자연스럽게 검증하게 됩니다.
| ID | As a | I want to | So that |
|---|---|---|---|
| US-01 | 개인 플랜 유저 | 팀 플랜으로 업그레이드 | 팀원을 초대할 수 있다 |
| US-02 | 팀 플랜 유저 | 개인 플랜으로 다운그레이드 | 비용을 줄일 수 있다 |
| US-03 | 구독 유저 | 결제 카드를 변경 | 만료된 카드를 교체할 수 있다 |
| US-04 | 구독 유저 | 플랜 변경 이력을 조회 | 언제 변경했는지 확인할 수 있다 |
많은 팀이 기능 요구사항만 적고 비기능 요구사항을 빠뜨립니다.
비기능 요구사항은 구현 방식 전체에 영향을 주기 때문에 반드시 명시해야 합니다.
## 기능 요구사항 (Functional Requirements)
- 사용자는 플랜을 업그레이드/다운그레이드할 수 있다
- 업그레이드는 즉시 적용, 잔여 기간 일할 계산 청구
- 다운그레이드는 현재 구독 말일에 적용
| 항목 | 요구사항 | 측정 방법 |
|---|---|---|
| 성능 | 결제 API 응답 3초 이내 | P99 응답시간 모니터링 |
| 보안 | 카드 정보 직접 저장 금지 (PCI-DSS) | Stripe 위임 처리 |
| 가용성 | 결제 서비스 99.9% 업타임 | 월간 다운타임 43분 이내 |
| 감사 | 모든 플랜 변경 이벤트 1년 보관 | audit_log 테이블 |
"이번에 안 하는 것을 명시한다"
"그 기능도 되는 거 아니에요?"라는 질문이 개발 중간에 나오는 순간 스코프 크리프(scope creep)가 시작됩니다.
Out of Scope는 그 대화를 미리 끝내는 섹션입니다.
## Out of Scope
- ❌ 연간 구독 플랜 (추후 로드맵)
- ❌ 카드 외 결제 수단 (계좌이체, PayPal)
- ❌ 구독 해지 기능
- ❌ 플랜 변경 예약 취소
- ❌ 관리자의 타 유저 플랜 강제 변경
텍스트 문서(PRD, Feature Spec)만으로는 구조와 흐름을 전달하기 어렵습니다.
다이어그램은 그 간극을 메우는 시각 언어입니다.
다음 문장을 읽어보겠습니다.
"결제 요청이 들어오면 API 서버가 검증 후 Stripe에 요청을 보내고,
결과를 받아 DB에 저장한 뒤 이메일 서비스를 호출한다"
읽고 나서도 머릿속에 그림이 잘 안 그려집니다.
컴포넌트 간 순서와 의존성이 모호하고, 팀원마다 다르게 이해할 수 있습니다.
다이어그램이 하는 일은 다섯 가지입니다.
다이어그램에도 공통 언어가 있습니다. 바로 UML(Unified Modeling Language) 입니다.
| 분류 | 목적 | 대표 다이어그램 |
|---|---|---|
| 구조 다이어그램 | 시스템이 어떻게 생겼는가 (정적) | 클래스, 컴포넌트, 배포 |
| 행위 다이어그램 | 시스템이 어떻게 동작하는가 (동적) | 시퀀스, 유스케이스, 상태 전이 |
실무에서 UML을 대하는 자세
UML을 100% 정확하게 지키는 팀은 거의 없습니다.
중요한 건 팀 내 기호의 의미가 통일되는 것입니다.
표준을 알고 나서 현실에 맞게 쓰는 게 올바른 접근입니다.
언제 작성하는가
작성 시 고려해야 할 것들
alt 블록으로 분리해서 명시합니다.sequenceDiagram
participant C as Client
participant A as API Server
participant DB as PostgreSQL
participant S as Stripe
participant E as EmailService
C->>A: POST /subscriptions/{id}/upgrade
A->>DB: SELECT subscription
DB-->>A: subscription data
A->>S: 결제 요청 (proration)
alt 결제 성공
S-->>A: payment_intent (success)
A->>DB: INSERT plan_change (applied)
A->>DB: UPDATE subscription (plan_id)
A--)E: 이메일 발송 이벤트 (비동기)
A-->>C: 200 OK
else 결제 실패
S-->>A: payment_intent (failed)
A-->>C: 402 PAYMENT_FAILED
end
시스템을 구성하는 클래스, 속성, 메서드, 그리고 클래스 간의 관계를 표현합니다.
저는 두 가지 용도로 사용하고 있습니다.
| 용도 | 목적 |
|---|---|
| 도메인 모델 | 비즈니스 개념과 규칙을 표현 |
| 컴포넌트 | 코드 구조와 의존성 방향 표현 |
비즈니스 개념을 코드로 옮기기 전에 그립니다.
기술 구현과 무관하게 비즈니스 규칙을 표현하며, 메서드보다 속성과 관계에 집중합니다.
classDiagram
direction TB
class User {
-id: UUID
-email: String
-name: String
+getActiveSubscription() Subscription
}
class Subscription {
-id: UUID
-status: SubscriptionStatus
-currentPeriodStart: Date
-currentPeriodEnd: Date
+isActive() Boolean
+getRemainingDays() Int
+canDowngradeTo(plan: Plan) Boolean
}
class Plan {
-id: UUID
-name: PlanType
-priceKrw: Int
-maxMembers: Int
+isTeamPlan() Boolean
}
class PlanChange {
-id: UUID
-status: PlanChangeStatus
-applyAt: Date
-prorationAmount: Money
+isUpgrade() Boolean
+apply() void
+cancel() void
}
class Payment {
-id: UUID
-amountKrw: Money
-status: PaymentStatus
-pgTransactionId: String
+isSuccess() Boolean
+retry() void
}
class SubscriptionStatus {
<<enumeration>>
ACTIVE
PAUSED
CANCELLED
}
class PlanChangeStatus {
<<enumeration>>
SCHEDULED
APPLIED
CANCELLED
}
User "1" --> "0..*" Subscription : has
Subscription "0..*" --> "1" Plan : belongs to
Subscription "1" --> "0..*" PlanChange : has
Subscription "1" --> "0..*" Payment : has
도메인 모델 작성 팁
비즈니스 규칙을 메서드 이름으로 표현합니다.
// ❌ 기술적 CRUD만 있으면 도메인 의도가 안 보임
+ save()
+ delete()
+ update()
// ✅ 비즈니스 행위를 메서드로
+ canDowngradeTo(plan: Plan): Boolean
+ apply(): void
+ cancel(): void
도메인 모델이 무엇을 만드는지를 보여준다면, 어떻게 코드가 조직되는지를 보여줍니다.
classDiagram
direction TB
class SubscriptionController {
+upgradePlan(req, res)
+downgradePlan(req, res)
}
class PlanChangeService {
-subscriptionRepo: ISubscriptionRepository
-paymentGateway: IPaymentGateway
+requestUpgrade(command: UpgradeCommand)
+requestDowngrade(command: DowngradeCommand)
+applyScheduled()
}
class ISubscriptionRepository {
<<interface>>
+findById(id: UUID) Subscription
+save(sub: Subscription)
}
class IPaymentGateway {
<<interface>>
+charge(amount: Money) PaymentResult
+refund(transactionId: String)
}
class SubscriptionRepository {
-db: PostgresClient
+findById(id: UUID) Subscription
+save(sub: Subscription)
}
class StripeGateway {
-client: StripeClient
+charge(amount: Money) PaymentResult
+refund(transactionId: String)
}
SubscriptionController ..> PlanChangeService : uses
PlanChangeService ..> ISubscriptionRepository : uses
PlanChangeService ..> IPaymentGateway : uses
SubscriptionRepository ..|> ISubscriptionRepository : implements
StripeGateway ..|> IPaymentGateway : implements
이 다이어그램에서 확인해야 할 것은 인터페이스를 통한 의존성 역전(DIP) 입니다.
주의: 클래스 다이어그램에서 자주 하는 실수
모든 클래스, 모든 메서드, 모든 필드를 그리지 않습니다.
"이 다이어그램이 보여주는 것은 OOO이다"라는 제목이 있어야 합니다.
설명하고 싶은 개념에 필요한 것만 그리는 것이 좋은 다이어그램입니다.
DB 설계와 도메인 모델을 정의할 때 작성합니다.
엔티티 간 관계(1:1, 1:N, N:M)와 제약 조건을 명시합니다.
ERD에서 자주 빠지는 것들
deleted_at 소프트 딜리트 컬럼 정책erDiagram
USER {
uuid id PK
string email
string name
timestamp created_at
}
SUBSCRIPTION {
uuid id PK
uuid user_id FK
uuid plan_id FK
string status "active|paused|cancelled"
date current_period_start
date current_period_end
}
PLAN {
uuid id PK
string name "personal|team"
int price_krw
int max_members "NULL이면 무제한"
}
PLAN_CHANGE {
uuid id PK
uuid subscription_id FK
uuid from_plan_id FK
uuid to_plan_id FK
string status "scheduled|applied|cancelled"
date apply_at
int proration_amount
}
PAYMENT {
uuid id PK
uuid subscription_id FK
int amount_krw
string status "success|failed|refunded"
string pg_transaction_id
timestamp paid_at
}
USER ||--o{ SUBSCRIPTION : "has"
SUBSCRIPTION }o--|| PLAN : "belongs to"
SUBSCRIPTION ||--o{ PLAN_CHANGE : "has"
SUBSCRIPTION ||--o{ PAYMENT : "has"
주문, 결제, 구독처럼 상태가 바뀌는 도메인 객체를 설계할 때 작성합니다.
엣지케이스 발굴에 가장 효과적인 도구입니다.
불가능한 상태 전이를 시각적으로 식별하고, 그리다 보면 자연스럽게 "이 상태에서 저 상태로 가는 경우는 없나?"라는 질문이 나옵니다. 코드 짜다가 발견하는 것보다 훨씬 낫습니다.
stateDiagram-v2
[*] --> pending : 결제 요청
pending --> success : 결제 승인
pending --> failed : 결제 거절
failed --> pending : 재시도 (3회 이내)
failed --> permanently_failed : 재시도 초과
success --> refunded : 환불 요청
permanently_failed --> [*]
refunded --> [*]
구현 자동화 시대에 역설적이게도 설계 문서의 가치는 더욱 커지고 있습니다.
AI가 코드를 빠르게 생성할수록, "무엇을 만드는가"를 명확히 하는 것이 더 중요해집니다.
좋은 스펙은 좋은 AI 컨텍스트이고, 좋은 AI 컨텍스트는 좋은 코드를 만듭니다.
구현 전에 사고를 정리하는 과정은 느린 것처럼 보이지만, 결국 가장 빠른 길입니다.