비영리 스타트업의 아키텍처 설계기 — TDD와 DDD로 비즈니스를 코드로 번역하는 여정
· Architecture · 11 min
예이린 사회적협동조합 기술블로그 시리즈 EP.2 — Medium에 함께 게재된 글입니다.
들어가며: 테스트가 설계를 이끈다
지난 글에서 저는 예이린 사회적협동조합의 CTO가 되기까지의 여정을 공유했습니다. 아동 정서 돌봄이라는 가치 있는 문제, 그리고 그것을 기술로 해결하고자 하는 예이린의 비전에 공감해 이 자리에 서게 되었다고 말씀드렸죠.
오늘은 조금 더 깊이 들어가 보겠습니다.
비즈니스 요구사항을 어떻게 기술적으로 풀어냈는지, 그 설계 과정을 상세히 공유하려 합니다. 특히 비영리 조직의 현실적 제약 속에서 어떻게 확장 가능하고 유지보수하기 쉬운 시스템을 설계했는지, 그 고민의 흔적들을 담았습니다.
이 글에서 다룰 핵심 주제는 TDD(Test-Driven Development)와 DDD(Domain-Driven Design)의 결합입니다. 단순히 “테스트를 작성한다”거나 “도메인을 분리한다”는 수준이 아니라, 테스트가 도메인 설계를 이끌고, 도메인 모델이 테스트를 통해 검증되는 선순환 구조를 어떻게 만들었는지 이야기하겠습니다.
1. TDD + DDD: 왜 이 조합인가
1인 개발의 현실
비영리 조직에서 1인 CTO로 개발한다는 것은 곧 모든 실수의 대가를 혼자 감당한다는 의미입니다. 코드 리뷰어도 없고, QA 팀도 없습니다. 버그가 프로덕션에 나가면 제가 직접 새벽에 일어나 고쳐야 합니다.
이런 환경에서 저를 지켜준 것은 자동화된 테스트였습니다. 하지만 무작정 테스트를 많이 작성한다고 좋은 것은 아닙니다. 핵심은 무엇을 테스트하느냐입니다.
TDD가 DDD를 만났을 때
TDD의 핵심은 “테스트를 먼저 작성한다”가 아닙니다. 테스트를 통해 설계를 발견한다는 것입니다.
DDD의 핵심도 “도메인 계층을 분리한다”가 아닙니다. 비즈니스 규칙을 코드로 명확하게 표현한다는 것입니다.
이 둘이 만나면 강력한 시너지가 발생합니다:
┌─────────────────────────────────────────────────────────────────┐
│ TDD + DDD 선순환 │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 비즈니스 │──── 요구사항을 ────►│ 테스트 │ │
│ │ 요구사항 │ 테스트로 표현 │ (Spec) │ │
│ └─────────────┘ └──────┬──────┘ │
│ ▲ │ │
│ │ │ 테스트가 │
│ │ 도메인 모델이 │ 도메인 설계를 │
│ │ 요구사항을 반영 │ 이끈다 │
│ │ ▼ │
│ ┌─────┴───────┐ ┌─────────────┐ │
│ │ 도메인 │◄─── 테스트 통과를 ─── │ 도메인 │ │
│ │ 모델 │ 위한 구현 │ 구현 │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ "테스트 이름이 곧 비즈니스 명세서다" │
└─────────────────────────────────────────────────────────────────┘
테스트 이름이 곧 비즈니스 명세서
저희 프로젝트에서 테스트 이름은 한글로 비즈니스 요구사항을 명시합니다. 이것은 단순한 가독성 문제가 아닙니다. 테스트 파일을 열면 비즈니스 명세서를 읽는 것과 같은 효과를 내기 위함입니다.
describe('Email Value Object', () => {
describe('생성', () => {
it('올바른 이메일 형식이면 Email을 생성한다', () => {
// Given
const validEmail = 'user@example.com';
// When
const result = Email.create(validEmail);
// Then
expect(result.isSuccess).toBe(true);
expect(result.getValue().value).toBe(validEmail);
});
it('이메일 형식이 올바르지 않으면 실패한다', () => {
// Given
const invalidEmail = 'not-an-email';
// When
const result = Email.create(invalidEmail);
// Then
expect(result.isFailure).toBe(true);
expect(result.getError()).toBe('올바른 이메일 형식이 아닙니다');
});
it('이메일이 비어있으면 실패한다', () => {
const result = Email.create('');
expect(result.isFailure).toBe(true);
});
});
});이 테스트 파일을 보면 Email이라는 도메인 개념이 어떤 규칙을 가져야 하는지 명확히 알 수 있습니다. 코드를 읽지 않아도 됩니다.
개발 순서: 테스트 → 도메인 → 유스케이스 → 인프라
저희 프로젝트의 개발 순서는 철저하게 정해져 있습니다:
1. 테스트 먼저 작성 (TDD)
└─ 비즈니스 요구사항을 테스트로 표현
2. 도메인 모델 구현
└─ 테스트를 통과하기 위한 최소한의 구현
3. 유스케이스 구현
└─ 도메인 모델을 조합한 비즈니스 흐름
4. 인프라 구현
└─ Repository 구현체, 외부 API 연동 등
이 순서를 지키면 도메인 모델이 인프라에 오염되지 않습니다. 테스트 단계에서 이미 도메인 모델의 인터페이스가 결정되고, 인프라는 그저 그 인터페이스를 구현할 뿐입니다.
2. 전체 그림: MSA로 분리한 세 개의 심장
예이린의 서비스는 크게 세 가지 핵심 기능으로 나뉩니다:
- 아이와의 대화 — AI 챗봇이 아이의 정서 상태를 파악
- 적합한 기관 연결 — AI가 상담기관을 추천
- 상담 과정 관리 — 상담 진행, 결과지 전달, 데이터 축적
이 세 기능을 하나의 모놀리식 애플리케이션으로 만들 수도 있었습니다. 하지만 저는 **MSA(Microservices Architecture)**를 선택했습니다.
왜 MSA인가?
비영리 조직에서 MSA라니, 오버엔지니어링 아니냐고 생각하실 수 있습니다. 하지만 제가 MSA를 선택한 이유는 명확합니다.
첫째, 기술 스택의 이질성입니다. AI 관련 기능은 Python 생태계(FastAPI, PyTorch, LangChain 등)가 압도적으로 유리합니다. 반면 비즈니스 로직 중심의 백엔드는 TypeScript와 NestJS의 타입 안전성과 구조화된 아키텍처가 적합합니다.
둘째, 독립적인 배포와 확장입니다. ‘소울이’ 챗봇의 LLM 호출은 비용과 latency가 민감합니다. 추천 AI는 벡터 연산 부하가 큽니다. 메인 백엔드는 안정적인 트랜잭션 처리가 중요합니다. 이 세 가지가 서로 영향을 주지 않고 독립적으로 스케일링할 수 있어야 합니다.
셋째, 테스트 격리입니다. 각 서비스가 독립적으로 테스트될 수 있어야 합니다. MSA 구조에서는 서비스 간 계약(Contract)만 지키면 내부 구현을 자유롭게 변경할 수 있습니다.

세 서비스는 각자의 책임을 가지고, HTTP 통신으로 협력합니다. 특히 ‘소울이’에서 생성된 상담의뢰지는 Webhook을 통해 메인 백엔드로 전달되고, 추천 요청은 메인 백엔드에서 AI 서비스로 REST API 호출됩니다.
3. 비즈니스 흐름: 상담 연계의 여정
이제 가장 핵심적인 비즈니스 흐름을 살펴보겠습니다. 아이가 ‘소울이’와 대화를 시작해서, 상담이 완료되기까지의 전체 여정입니다.

5단계 프로세스
1단계 — 정서 상태 파악: 아이가 ‘소울이’와 대화합니다. LLM이 대화 내용을 분석하고, 정서 상태를 파악합니다. 상담이 필요하다고 판단되면 상담의뢰지를 자동 생성하여 예이린 메인 백엔드로 전송합니다.
2단계 — AI 기반 기관 매칭: 보호자가 추천을 요청하면, 상담의뢰지 내용을 기반으로 AI가 적합한 상담기관 5곳을 추천합니다. 텍스트 임베딩과 벡터 유사도 계산을 통해 아이의 상황에 가장 적합한 기관을 찾습니다.
3단계 — 기관 선택 및 매칭: 보호자가 추천 목록을 확인하고 원하는 기관을 선택합니다. 선택 즉시 해당 기관에 알림이 전송되고 매칭이 완료됩니다.
4단계 — 상담 진행: 기관에서 상담사를 배정하고, 실제 상담이 진행됩니다. 상담사는 각 세션 후 면담결과지를 작성하고, 이는 보호자에게 전달됩니다.
5단계 — 피드백 및 데이터 축적: 상담 완료 후 보호자는 리뷰를 작성할 수 있습니다. 모든 데이터는 축적되어 아이의 발달 추이 리포트로 제공됩니다.
상담 상태(Status) 전이
상담의뢰지는 생성부터 완료까지 여러 상태를 거칩니다. 이 상태 전이는 도메인 모델에서 엄격하게 관리됩니다.

각 상태 전이에는 비즈니스 규칙이 적용됩니다:
// CounselRequest 도메인 모델 (일부)
class CounselRequest {
private status: CounselRequestStatus;
// 상태 전이 메서드 - 비즈니스 규칙 캡슐화
public requestRecommendation(): Result<void, DomainError> {
if (this.status !== CounselRequestStatus.PENDING) {
return Result.fail(
new InvalidStatusTransitionError('PENDING 상태에서만 추천을 요청할 수 있습니다')
);
}
this.status = CounselRequestStatus.RECOMMENDATION_REQUESTED;
this.addDomainEvent(new RecommendationRequestedEvent(this.id));
return Result.ok();
}
public complete(): Result<void, DomainError> {
if (this.status !== CounselRequestStatus.IN_PROGRESS) {
return Result.fail(
new InvalidStatusTransitionError('진행 중인 상담만 완료할 수 있습니다')
);
}
this.status = CounselRequestStatus.COMPLETED;
this.addDomainEvent(new CounselCompletedEvent(this.id));
return Result.ok();
}
}이렇게 상태 전이 로직을 도메인 모델 안에 캡슐화하면, 잘못된 상태 전이가 발생할 수 없습니다. 그리고 이 모든 규칙은 테스트로 먼저 정의됩니다:
describe('CounselRequest 상태 전이', () => {
it('PENDING 상태에서 추천을 요청하면 RECOMMENDATION_REQUESTED로 전이된다', () => {
// Given
const request = CounselRequest.create({ /* ... */ }).getValue();
// When
const result = request.requestRecommendation();
// Then
expect(result.isSuccess).toBe(true);
expect(request.status).toBe(CounselRequestStatus.RECOMMENDATION_REQUESTED);
});
it('PENDING이 아닌 상태에서 추천을 요청하면 실패한다', () => {
// Given
const request = createMatchedRequest(); // MATCHED 상태
// When
const result = request.requestRecommendation();
// Then
expect(result.isFailure).toBe(true);
});
it('COMPLETED 상태에서는 취소할 수 없다', () => {
// Given
const request = createCompletedRequest();
// When
const result = request.cancel();
// Then
expect(result.isFailure).toBe(true);
expect(result.getError().message).toContain('완료된 상담은 취소할 수 없습니다');
});
});4. DDD로 복잡성 다스리기: 4개의 계층
의존성의 방향
DDD에서 가장 중요한 원칙은 의존성의 방향입니다.

Presentation → Application → Domain ← Infrastructure
주목할 점은 Infrastructure가 Domain을 의존한다는 것입니다. 일반적인 계층형 아키텍처에서는 상위 계층이 하위 계층을 의존하지만, DDD에서는 Domain이 가장 안쪽에 위치하고 모든 것이 Domain을 향합니다.
이것이 의미하는 바는 명확합니다: Domain 계층은 그 어떤 외부 의존성도 가지지 않습니다. 프레임워크도, ORM도, HTTP도 모릅니다. 오직 순수한 비즈니스 로직만 존재합니다.
계층별 역할
Domain Layer (핵심)
- Entity, Value Object, Aggregate Root
- Repository Interface (구현체 아님!)
- Domain Service, Domain Event
- 어떤 프레임워크 데코레이터도 사용하지 않음
Application Layer
- Use Case (비즈니스 유스케이스)
- Application Service (흐름 조율)
- DTO (계층 간 데이터 전달)
Infrastructure Layer
- Repository 구현체 (TypeORM)
- 외부 API 클라이언트
- 이메일, 파일 저장 등 기술적 구현
Presentation Layer
- Controller (HTTP 요청 처리)
- Request/Response DTO
- Swagger 문서화
폴더 구조
src/
├── domain/ # 순수 비즈니스 로직
│ ├── common/
│ │ ├── result.ts # Result<T, E> 타입
│ │ ├── aggregate-root.ts # Aggregate Root 베이스
│ │ └── domain-event.ts # Domain Event 인터페이스
│ ├── user/
│ │ ├── model/
│ │ │ ├── user.ts # User Aggregate Root
│ │ │ └── value-objects/
│ │ │ ├── email.vo.ts
│ │ │ ├── password.vo.ts
│ │ │ └── user-role.vo.ts
│ │ ├── repository/
│ │ │ └── user.repository.ts # 인터페이스만!
│ │ └── events/
│ │ └── user-registered.event.ts
│ └── counsel-request/
│ ├── model/
│ │ └── counsel-request.ts
│ └── repository/
│ └── counsel-request.repository.ts
│
├── application/ # 유스케이스
│ ├── auth/
│ │ ├── use-cases/
│ │ │ ├── register-user/
│ │ │ │ ├── register-user.use-case.ts
│ │ │ │ └── register-user.use-case.spec.ts # 테스트!
│ │ │ └── login/
│ │ │ ├── login.use-case.ts
│ │ │ └── login.use-case.spec.ts
│ │ └── dto/
│ └── counsel/
│ └── use-cases/
│ └── request-recommendation/
│
├── infrastructure/ # 기술 구현체
│ ├── persistence/
│ │ └── typeorm/
│ │ ├── entity/
│ │ │ └── user.entity.ts # TypeORM Entity
│ │ ├── repository/
│ │ │ └── user.repository.impl.ts # 구현체
│ │ └── mapper/
│ │ └── user.mapper.ts # Domain ↔ Entity 변환
│ └── external/
│ └── recommendation-ai/
│
└── presentation/ # API 계층
├── auth/
│ ├── auth.controller.ts
│ └── auth.module.ts
└── counsel/
Repository 네이밍 컨벤션
네이밍 컨벤션도 명확한 기준이 있습니다:
// Domain Layer - 인터페이스
// I prefix 사용 금지 (TypeScript/NestJS 표준)
export interface UserRepository {
findById(id: UserId): Promise<User | null>;
findByEmail(email: Email): Promise<User | null>;
save(user: User): Promise<void>;
}
// Infrastructure Layer - 구현체
// Impl suffix 사용 (Google, Netflix, Uber 표준)
// TypeOrm, Mongo 등 기술명 제외 (프레임워크 독립성)
export class UserRepositoryImpl implements UserRepository {
constructor(
@InjectRepository(UserEntity)
private readonly ormRepository: Repository<UserEntity>,
) {}
async findById(id: UserId): Promise<User | null> {
const entity = await this.ormRepository.findOne({
where: { id: id.value }
});
return entity ? UserMapper.toDomain(entity) : null;
}
// ...
}왜 이런 컨벤션인가?
- I prefix는 Java/C# 레거시입니다. TypeScript에서는 불필요합니다.
- TypeOrmUserRepository 같은 이름은 기술에 종속됩니다. 나중에 Prisma로 바꾸면 이름도 바꿔야 합니다.
- Impl suffix는 빅테크 표준이며, 도메인 인터페이스가 핵심이고 구현체는 세부사항임을 명확히 합니다.
5. Value Object: 원시값에 의미를 부여하다
원시값의 문제
코드에서 가장 흔히 보는 안티패턴 중 하나가 **원시값 집착(Primitive Obsession)**입니다.
// 나쁜 예: 원시값 사용
interface UserProps {
email: string; // 아무 문자열이나 가능
password: string; // 유효성 검증이 어디서?
phoneNumber: string; // 형식이 맞는지 어떻게 알지?
}
// 이메일 검증 로직이 여기저기 흩어짐
function validateEmail(email: string): boolean {
// ...
}
// 컨트롤러에서도 검증
// 서비스에서도 검증
// 어디서 검증 빠뜨리면 버그 발생Value Object로 해결
Value Object는 생성 시점에 유효성을 검증하고, 한 번 생성되면 **불변(immutable)**합니다.
// 좋은 예: Value Object
export class Email {
private readonly _value: string;
private constructor(value: string) {
this._value = value;
}
// 정적 팩토리 메서드 - 생성 시점에 검증
public static create(value: string): Result<Email, string> {
if (!value || value.trim().length === 0) {
return Result.fail('이메일은 필수입니다');
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
return Result.fail('올바른 이메일 형식이 아닙니다');
}
return Result.ok(new Email(value.toLowerCase().trim()));
}
get value(): string {
return this._value;
}
// 값 객체는 값으로 비교
public equals(other: Email): boolean {
return this._value === other._value;
}
}이제 Email 타입이 존재하는 곳은 항상 유효한 이메일임이 보장됩니다. 더 이상 여기저기서 이메일 형식을 검증할 필요가 없습니다.
테스트가 설계를 이끈다
Value Object를 만들 때도 테스트를 먼저 작성합니다:
describe('Email Value Object', () => {
// 성공 케이스
describe('유효한 이메일', () => {
it('일반적인 이메일 형식이면 생성에 성공한다', () => {
const result = Email.create('user@example.com');
expect(result.isSuccess).toBe(true);
});
it('이메일을 소문자로 정규화한다', () => {
const result = Email.create('User@Example.COM');
expect(result.getValue().value).toBe('user@example.com');
});
it('앞뒤 공백을 제거한다', () => {
const result = Email.create(' user@example.com ');
expect(result.getValue().value).toBe('user@example.com');
});
});
// 실패 케이스
describe('유효하지 않은 이메일', () => {
it('빈 문자열이면 실패한다', () => {
const result = Email.create('');
expect(result.isFailure).toBe(true);
});
it('@가 없으면 실패한다', () => {
const result = Email.create('userexample.com');
expect(result.isFailure).toBe(true);
});
it('도메인이 없으면 실패한다', () => {
const result = Email.create('user@');
expect(result.isFailure).toBe(true);
});
});
// 동등성
describe('동등성 비교', () => {
it('같은 값이면 동등하다', () => {
const email1 = Email.create('user@example.com').getValue();
const email2 = Email.create('user@example.com').getValue();
expect(email1.equals(email2)).toBe(true);
});
it('대소문자가 달라도 동등하다', () => {
const email1 = Email.create('User@Example.com').getValue();
const email2 = Email.create('user@example.com').getValue();
expect(email1.equals(email2)).toBe(true);
});
});
});테스트를 먼저 작성하면 Email이 어떻게 동작해야 하는지가 명확해집니다. 구현은 그저 이 테스트를 통과하게 만들면 됩니다.
주요 Value Objects
| Value Object | 검증 규칙 | 정규화 |
|---|---|---|
Email | 이메일 형식 | 소문자 변환, trim |
Password | 최소 8자, 대소문자 + 숫자 + 특수문자 | 해시 저장 |
PhoneNumber | 한국 전화번호 형식 | 하이픈 제거 |
UserRole | GUARDIAN, COUNSELOR, INSTITUTION_ADMIN, ADMIN | — |
RealName | 2자 이상, 한글/영문 | trim |
6. Result 타입: 예외를 던지지 않는 에러 핸들링
예외의 문제점
전통적인 예외 처리 방식에는 문제가 있습니다:
// 문제점 1: 함수 시그니처만 보고는 실패 가능성을 알 수 없음
function createUser(email: string): User {
// 어딘가에서 throw new Error() 할 수 있음
// 호출하는 쪽에서는 모름
}
// 문제점 2: try-catch 지옥
try {
const user = createUser(email);
try {
await saveUser(user);
try {
await sendEmail(user);
} catch (e) {
// 이메일 에러 처리
}
} catch (e) {
// 저장 에러 처리
}
} catch (e) {
// 생성 에러 처리
}Result 타입으로 해결
Railway-Oriented Programming의 핵심 아이디어를 적용합니다. 모든 연산은 성공 또는 실패 중 하나를 반환합니다.
export class Result<T, E = string> {
private readonly _isSuccess: boolean;
private readonly _value?: T;
private readonly _error?: E;
private constructor(isSuccess: boolean, value?: T, error?: E) {
this._isSuccess = isSuccess;
this._value = value;
this._error = error;
}
// 성공 결과 생성
public static ok<T>(value?: T): Result<T, never> {
return new Result<T, never>(true, value);
}
// 실패 결과 생성
public static fail<E>(error: E): Result<never, E> {
return new Result<never, E>(false, undefined, error);
}
get isSuccess(): boolean {
return this._isSuccess;
}
get isFailure(): boolean {
return !this._isSuccess;
}
public getValue(): T {
if (!this._isSuccess) {
throw new Error('Cannot get value from failed result');
}
return this._value as T;
}
public getError(): E {
if (this._isSuccess) {
throw new Error('Cannot get error from successful result');
}
return this._error as E;
}
}사용 예시
// 함수 시그니처만 봐도 실패 가능성을 알 수 있음
function createUser(props: CreateUserProps): Result<User, DomainError> {
// 이메일 생성 (실패 가능)
const emailResult = Email.create(props.email);
if (emailResult.isFailure) {
return Result.fail(emailResult.getError());
}
// 비밀번호 생성 (실패 가능)
const passwordResult = Password.create(props.password);
if (passwordResult.isFailure) {
return Result.fail(passwordResult.getError());
}
// 모든 검증 통과, User 생성
return Result.ok(new User({
email: emailResult.getValue(),
password: passwordResult.getValue(),
// ...
}));
}
// 호출하는 쪽
const result = createUser({ email, password, name });
if (result.isFailure) {
// 에러 처리 - 어떤 에러인지 명확함
return response.badRequest(result.getError().message);
}
const user = result.getValue();
// 여기서 user는 항상 유효함테스트에서의 Result
Result 타입 덕분에 테스트도 명확해집니다:
describe('User 생성', () => {
it('모든 값이 유효하면 User를 생성한다', () => {
const result = User.create({
email: 'valid@email.com',
password: 'ValidP@ss1',
realName: '홍길동',
phoneNumber: '010-1234-5678',
role: 'GUARDIAN',
});
expect(result.isSuccess).toBe(true);
expect(result.getValue()).toBeInstanceOf(User);
});
it('이메일이 유효하지 않으면 실패한다', () => {
const result = User.create({
email: 'invalid-email', // 잘못된 이메일
password: 'ValidP@ss1',
realName: '홍길동',
phoneNumber: '010-1234-5678',
role: 'GUARDIAN',
});
expect(result.isFailure).toBe(true);
expect(result.getError()).toContain('이메일');
});
});7. 역할(Role) 기반 설계
네 가지 사용자, 네 가지 관점
예이린 플랫폼에는 네 가지 역할이 존재합니다:
| 역할 | 설명 | 주요 기능 |
|---|---|---|
GUARDIAN | 보호자 (부모, 교사) | 아동 등록, 상담 요청, 결과지 열람 |
COUNSELOR | 상담사 | 상담 진행, 면담결과지 작성 |
INSTITUTION_ADMIN | 기관 관리자 | 소속 상담사 관리, 기관 정보 관리 |
ADMIN | 시스템 관리자 | 전체 시스템 관리, 기관 승인 |
역할별 접근 권한

각 역할은 접근할 수 있는 리소스가 명확히 구분됩니다. 이 권한은 Guard에서 체크됩니다:
// Role Guard
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.includes(user.role);
}
}
// 사용
@Controller('institutions')
export class InstitutionController {
@Post()
@Roles(UserRole.ADMIN) // ADMIN만 기관 생성 가능
create(@Body() dto: CreateInstitutionDto) {
// ...
}
@Get()
@Public() // 누구나 기관 목록 조회 가능
findAll() {
// ...
}
}역할별 프로필: 1:1 관계
각 역할은 추가적인 프로필 정보를 가집니다. User와 Profile은 1:1 관계로 연결됩니다.
┌──────────────────────────────────────────────────────────────────────────┐
│ User (기본 정보) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ id | email | password | realName | phoneNumber | role │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ │ role=GUARDIAN │ role=COUNSELOR │ role=INSTITUTION │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ GuardianProfile │ │ CounselorProfile│ │VoucherInstitution│ │
│ │─────────────────│ │─────────────────│ │─────────────────│ │
│ │ userId (FK) │ │ userId (FK) │ │ adminUserId(FK) │ │
│ │ organization │ │ institutionId │ │ name │ │
│ │ guardianType │ │ specialization │ │ address │ │
│ │ (PARENT/TEACHER)│ │ licenseNumber │ │ isActive │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │ │ │
│ └────────────────────┘ │
│ 소속 관계 (N:1) │
└──────────────────────────────────────────────────────────────────────────┘
트랜잭션 기반 원자적 생성
회원가입 시 User와 Profile은 하나의 트랜잭션으로 생성됩니다:
// 보호자 회원가입 Use Case
@Injectable()
export class RegisterGuardianUseCase {
constructor(
private readonly userRepository: UserRepository,
private readonly guardianProfileRepository: GuardianProfileRepository,
private readonly dataSource: DataSource,
) {}
async execute(dto: RegisterGuardianDto): Promise<Result<void, DomainError>> {
// 트랜잭션 시작
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 1. User 생성
const userResult = User.create({
email: dto.email,
password: dto.password,
realName: dto.realName,
phoneNumber: dto.phoneNumber,
role: UserRole.GUARDIAN,
});
if (userResult.isFailure) {
return Result.fail(userResult.getError());
}
const user = userResult.getValue();
await this.userRepository.save(user, queryRunner.manager);
// 2. GuardianProfile 생성
const profileResult = GuardianProfile.create({
userId: user.id,
organization: dto.organization,
guardianType: dto.guardianType,
});
if (profileResult.isFailure) {
await queryRunner.rollbackTransaction();
return Result.fail(profileResult.getError());
}
await this.guardianProfileRepository.save(
profileResult.getValue(),
queryRunner.manager
);
// 커밋
await queryRunner.commitTransaction();
return Result.ok();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}User 생성에 성공하고 Profile 생성에 실패하면 전체가 롤백됩니다. User만 덩그러니 남는 일이 없습니다.
8. 도메인 모델 관계도
전체 도메인 모델의 관계를 정리하면 다음과 같습니다. 물론, 향후 계속 된 기획 및 개발 회의 진행내용에 따라 관계도는 계속 확장됩니다.

핵심 관계 정리
1:1 관계
- User ↔ GuardianProfile (role=GUARDIAN일 때)
- User ↔ CounselorProfile (role=COUNSELOR일 때)
- User ↔ VoucherInstitution (role=INSTITUTION_ADMIN일 때)
- CounselSession ↔ CounselReport
1:N 관계
- Guardian → Children
- Child → CounselRequests
- CounselRequest → Recommendations
- CounselRequest → CounselSessions
- VoucherInstitution → CounselorProfiles
- VoucherInstitution → Reviews
Aggregate 경계
DDD에서 Aggregate는 트랜잭션의 경계입니다. 각 Aggregate는 독립적으로 일관성을 유지합니다.
┌─ User Aggregate ─────────────────────────────────────────────────┐
│ User (Root) │
│ └── Role-specific Profile (GuardianProfile 등) │
└──────────────────────────────────────────────────────────────────┘
┌─ Child Aggregate ────────────────────────────────────────────────┐
│ Child (Root) │
│ └── 아동 관련 정보만 포함 │
└──────────────────────────────────────────────────────────────────┘
┌─ CounselRequest Aggregate ───────────────────────────────────────┐
│ CounselRequest (Root) │
│ ├── Recommendation (추천 결과) │
│ ├── CounselSession (상담 세션) │
│ │ └── CounselReport (면담결과지) │
│ └── Status 전이 로직 │
└──────────────────────────────────────────────────────────────────┘
9. 기술 스택 선정 이유
마지막으로, 각 기술 스택을 선정한 이유를 정리하겠습니다.
메인 백엔드: NestJS
- 구조화된 아키텍처: Module, Provider, Controller 패턴이 DDD 계층과 잘 맞습니다.
- DI(의존성 주입) 기본 제공: Repository 패턴 구현에 필수적입니다.
- TypeScript 네이티브: 타입 안전성이 도메인 모델 설계에 중요합니다.
- 테스트 지원: @nestjs/testing으로 유닛 테스트, E2E 테스트 모두 지원합니다.
ORM: TypeORM
- Data Mapper 패턴 지원: Entity와 Domain Model을 분리할 수 있습니다.
- 마이그레이션 도구: 스키마 버전 관리가 가능합니다 (현재는 개발 단계라 synchronize 사용).
- Relation 매핑: 1:1, 1:N 관계 표현이 직관적입니다.
데이터베이스: PostgreSQL
- ACID 트랜잭션: 상담 매칭 같은 중요한 비즈니스 로직에 필수입니다.
- JSONB 타입: 유연한 스키마 확장이 가능합니다 (검사 결과 데이터 등).
- Full-text search: 향후 상담 내용 검색에 활용 가능합니다.
AI 서비스: FastAPI
- Python 생태계: LangChain, PyTorch 등 AI 라이브러리가 풍부합니다.
- 비동기 처리: LLM API 호출은 I/O 바운드이므로 async가 중요합니다.
- Pydantic: 타입 검증이 강력해 Request/Response 명세가 명확합니다.
마치며: 테스트가 자신감을 준다
이번 글에서는 예이린 플랫폼의 아키텍처 설계를 공유했습니다.
MSA로 분리한 세 개의 서비스, DDD의 4개 계층, Value Object와 Result 타입을 활용한 견고한 도메인 모델, 역할 기반의 권한 설계까지. 이 모든 것이 **”취약계층 아동에게 적합한 상담 서비스를 연결한다”**는 단 하나의 비즈니스 가치를 기술로 구현하기 위한 설계입니다.
하지만 무엇보다 중요한 것은 TDD와 DDD의 결합입니다.
테스트를 먼저 작성하면 도메인 설계가 명확해집니다. 도메인 모델이 견고하면 테스트가 단순해집니다. 이 선순환이 1인 개발자인 저에게 자신감을 줍니다. 새로운 기능을 추가하거나 기존 코드를 수정할 때, 테스트가 저를 지켜줍니다.
다음 편에서는 더 실질적인 이야기를 해보려 합니다. 제한된 리소스 속에서 Claude Code와 LLM 도구들을 어떻게 활용해 개발 생산성을 높였는지, 그 구체적인 방법론과 시행착오를 공유하겠습니다.
예이린 사회적협동조합은 기술로 돌봄을 새롭게, 아이들의 내일을 따뜻하게 만들어갑니다.
다음 편: “예이린은 MSA를 어떻게 풀어나아가는가”