이 문서는 DDD_BE 프로젝트의 코드 작성 기준이다.
목표는 "가독성", "책임 분리", "일관성"을 최우선으로 유지하는 것이다.
return문에는 복잡한 연산(삼항 중첩, 메서드 체이닝 2단계 이상, 복합 조건식)을 넣지 않는다. 단순 참조나 단일 표현식은 허용한다.- 기본은
const기반 화살표 함수를 사용한다. - 단,
function선언이 더 읽기 쉽거나(재귀, 호이스팅, 명확한 의도 표현) 구조상 유리하면function을 사용한다. if분기가 복잡해지면ts-pattern의match를 우선 검토한다.- 함수/모듈은 고수준 로직에서 저수준 구현 순서로 작성한다(Top-Down).
- DDD + Layered Architecture를 사용하고 계층 간 책임을 명확히 분리한다.
- 계층 의존 방향은 항상 바깥(Interface) -> 안쪽(Domain)으로만 향한다.
- 외부 API는 하나의 독립된 도메인으로 취급하고, DDD Layered Architecture 규칙을 동일하게 적용한다.
enum은 도메인 용어를 최우선으로 하고, 영어가 오히려 의미 전달을 해치면 한국어를 허용한다.- 불필요한 주석은 작성하지 않는다. 필요한 설명, 미해결 이슈, 의사결정 배경만 주석으로 남긴다.
- 공통 Response 형태를 유지한다.
- 기존 컨벤션(네이밍, 포맷, 린트, 폴더 구조)을 우선 준수한다.
- API/서버 코드는 NestJS의 관용적 방식(Nest스럽게)과 RESTful 설계를 지향한다.
- Domain Layer는 프레임워크/외부 라이브러리에 의존하지 않는다.
- 예외: Entity 클래스의 영속성 매핑용 데코레이터(
typeorm의@Entity,@Column,@Index,@ManyToOne등)는 본 프로젝트에서 허용한다. 별도 매퍼 레이어를 두지 않고 Entity 자체가 영속성 매핑을 겸한다. - 예외: Domain Repository의
@Injectable()데코레이터는 허용한다. Domain Repository는 비즈니스 의미를 가진 메서드를 제공하며, 실제 DB 접근은 Infrastructure의 Write Repository에 위임한다. - 금지: Domain Entity/Repository가
@nestjs/common외 NestJS 모듈(HTTP, Swagger 등), 외부 API 클라이언트, 프레임워크 인프라에 의존하는 것은 여전히 금지한다.
- 예외: Entity 클래스의 영속성 매핑용 데코레이터(
- Application Layer는 유스케이스를 조합하며 트랜잭션 경계를 가진다. 트랜잭션의 시작/커밋/롤백은 Application Layer에서 선언하고, 실제 구현은 Infrastructure Layer(Unit of Work 등)에 위임한다.
- Infrastructure Layer는 DB, 외부 API, 메시징 등 구현 세부사항을 담당한다.
- Controller(Interface Layer)에는 비즈니스 로직을 넣지 않는다.
- Repository는 영속성 책임만 가지며, 도메인 정책 판단을 하지 않는다.
- Repository는 서로 참조하지 않는다. 복합 도메인 간 협력이 필요한 경우 Aggregate를 통해 처리한다.
- 큐(Queue) 처리가 필요한 경우 메시지를 발행하는 Provider와 메시지를 처리하는 Consumer를 명확히 분리한다. 각각 별도 클래스/모듈로 구성하고, Consumer는 Infrastructure Layer에 위치한다.
-
any사용은 금지에 가깝게 관리하고, 불가피할 때만 범위를 최소화한다. -
DTO/Request 입력 검증은 명시적으로 수행한다(형식, 범위, 필수값).
-
예외는 도메인/애플리케이션 의미를 담은 커스텀 예외로 던지고, 응답 변환은 전역 레벨에서 일괄 처리한다.
-
Promise반환 함수는async/await스타일을 일관되게 유지한다. -
의존성 생성은 DI 컨테이너를 사용하고
new직접 생성은 최소화한다. -
Import 순서는 자동 정렬 규칙(
simple-import-sort)을 따른다. -
반환 타입은 인라인 객체(
{ data: string }) 대신type/interface로 명시해 재사용 가능하게 관리한다. -
Pick/Omit/중첩 유틸리티 타입 등 복잡한 타입 조합은 지양하고, 의도가 드러나는 명시 타입을 우선한다. -
if문은 단일 라인이더라도 항상 중괄호({})를 사용한다. (❌if (x) return;→ ✅if (x) { return; }) -
try-catch블록 밖에서는return await를 생략한다.try-catch블록 안에서는 rejection이 catch에서 잡히도록 반드시return await를 사용한다.// ❌ try-catch 안에서 await 생략 — rejection이 catch에서 잡히지 않음 async function bad() { try { return someAsyncFn(); } catch (e) { ... } } // ✅ try-catch 안에서 return await 사용 async function good() { try { return await someAsyncFn(); } catch (e) { ... } }
-
함수 파라미터는 읽기 쉽고 확장이 용이하도록 객체 구조 분해 할당(
{}) 방식을 권장한다.단, Passport의
validate메서드처럼 라이브러리에서 인자 순서(Positional Arguments)를 강제하는 경우는 예외로 한다. -
함수/메서드가 값을 반환한다면 원칙적으로 반환 타입을 명시해 인터페이스(계약)를 명확히 한다.
- 명시 대상:
Promise<RegisterResult>,Promise<ApiResponse<T>>,Promise<DTO>등 사용자 정의 복합 타입(Custom Type) 반환. - 생략 허용:
void/Promise<void>,boolean/number/string등 원시 타입(Primitive Type) 반환. - 생략 허용:
Promise<User>,Promise<Cohort>,Promise<User | null>처럼 도메인 **엔티티(Entity)**를 그대로 반환하는 Repository/Service 메서드(추론이 자명한 경우). - 생략 허용:
Controller(Interface Layer) 엔드포인트 라우터 핸들러 메서드(추론 및 Swagger 데코레이터 명세 우선).
- 명시 대상:
-
TypeORM
@Column()의type옵션은 생략을 기본으로 한다. TypeScript 필드 타입(Date,string,number등)으로 자동 추론되며, PostgreSQL 전용 타입이 필요한 경우에만 명시한다.// ✅ 타입 생략 (TypeScript 타입으로 추론) @Column() recruitStartAt: Date; // ✅ PostgreSQL 전용 타입이 필요한 경우에만 명시 @Column({ type: 'jsonb' }) metadata: Record<string, unknown>; // ❌ 불필요하게 장황한 타입 명시 @Column({ type: 'timestamp with time zone' }) recruitStartAt: Date;
| 대상 | 규칙 | 예시 |
|---|---|---|
| 클래스/인터페이스/타입/Enum | PascalCase | UserService, OrderStatus |
| 변수/함수/메서드 | camelCase | findUserById, isActive |
| 상수 (모듈 스코프) | UPPER_SNAKE_CASE | MAX_RETRY_COUNT |
| 파일명 | kebab-case | user.repository.ts |
| NestJS 파일 | {name}.{role}.ts |
user.service.ts, order.controller.ts |
| 테스트 파일 | {name}.spec.ts / {name}.e2e-spec.ts |
user.service.spec.ts |
- Domain Repository: 도메인 이름을 포함하여 작성한다.
- 파일명:
[도메인명].repository.ts(예:user.repository.ts) - 클래스명:
[도메인명]Repository(예:UserRepository)
- 파일명:
- Write Repository (인프라): 도메인 이름 접두사를 생략한다.
- 파일명:
write.repository.ts - 클래스명:
WriteRepository
- 파일명:
- Write Repository (인프라): 실제 DB 접근만 담당한다.
- 함수명에 비즈니스 의미를 담지 않는다. (
save,findOne,delete등) - 파라미터는 구조분해 할당으로 받아, TypeORM 타입(
FindOneOptions등)이 외부로 노출되지 않게 한다. where조건 조합은 Write Repository 내부에서 처리한다.
// ✅ async findOne({ email }: { email: string }): Promise<User | null> { return this.repository.findOne({ where: { email } }); }
- 함수명에 비즈니스 의미를 담지 않는다. (
- Domain Repository: 비즈니스 의미가 드러나는 함수명을 사용한다. (
findByEmail,register등)- 파라미터는 구조분해 할당으로 받는다.
where객체를 직접 조립하지 않고, 분해된 필드를 그대로 넘긴다.
// ✅ async findByEmail({ email }: { email: string }): Promise<User | null> { return this.writeRepository.findOne({ email }); } async register({ email, name, picture }: Pick<User, 'email' | 'name' | 'picture'>): Promise<User> { return this.writeRepository.create({ email, name, picture }); }
- 파라미터는 구조분해 할당으로 받는다.
- 변수명에 진행형(-ing)을 사용하지 않는다. 조회된 엔티티/값은 명사형을 사용한다. (❌
existing→ ✅found,record,target) - 변수명에 줄임말을 사용하지 않는다. (❌
repo,req,res,err→ ✅repository,request,response,error) - 도메인 용어의 경우 영문 풀네임이 오히려 의미 전달을 해친다면 한글 명칭을 사용하는 것도 고려한다.
- Enum 값이 UI/DB에 그대로 노출되는 도메인 상태어인 경우 한글을 허용한다. 예:
ApplicationStatus.서류합격,UserRole.운영자. - Enum 값이 기술 식별자/플래그인 경우 영문 대문자를 사용한다. 예:
CohortStatus.RECRUITING,ProjectPlatform.IOS,EmailLogStatus.SUCCESS. - Enum 파일명은 도메인 엔티티/맥락 + 속성명 조합으로
kebab-case를 사용한다. 예:application.status.ts,cohort.status.ts,user.role.ts,project-platform.ts,cohort-part-name.ts.
FindOptionsWhere<T>객체를 반환하는 변환 함수는buildWhere로 통일한다.- QueryBuilder(
createQueryBuilder())에 조건을 직접 주입하는 함수는applyFilter또는applyWhere로 명명한다. (시그니처가 다르므로 단일 이름으로 통합하지 않는다.)
src/
{domain}/ # 도메인 단위로 분리 (외부 API도 독립 도메인으로 동일하게 취급)
domain/ # Entity, Value Object, Repository Interface, Domain Service, Aggregate
application/ # UseCase, Command, Query, DTO
infrastructure/ # Repository 구현체, Queue Consumer
interface/ # Controller, Resolver, DTO(Request/Response), Queue Provider
{domain}.module.ts
common/ # 공통 응답, 필터, 인터셉터, 데코레이터, 유틸
config/ # 환경 설정
프로젝트에는 목적이 다른 두 가지 BaseEntity가 존재한다. 새 도메인 엔티티를 추가할 때 아래 기준으로 선택한다.
| 클래스 | 위치 | 포함 필드 | 사용 기준 |
|---|---|---|---|
BaseEntity |
common/entity/base.entity.ts |
createdAt, updatedAt, deletedAt |
id를 도메인 엔티티에서 직접 선언하는 경우 |
BaseEntity (확장) |
common/core/base.entity.ts |
id, uuid, createdAt, updatedAt, deletedAt + 복합 인덱스 |
id/uuid를 공통으로 관리하고 커서 기반 조회 인덱스가 필요한 경우 |
하나의 프로젝트에서 두 BaseEntity가 혼재하면 테이블 컬럼 구성과 소프트 삭제 정책이 도메인마다 달라진다. 새 도메인은
common/core/base.entity.ts를 기본으로 사용하고, 기존 도메인(user,user-role)은 현행 유지한다.
테스트는 아래 세 가지에 집중한다. 그 외 영역은 테스트를 강제하지 않는다.
| 대상 | 예시 |
|---|---|
| Util 함수 | 날짜 변환, 문자열 처리, 계산 유틸 등 |
| Parsing 로직 | 외부 API 응답 파싱, DTO 변환, 데이터 매핑 |
| UseCase | Application Layer의 유스케이스 단위 |
- 테스트는
Given-When-Then구조로 작성한다. - UseCase 테스트에서 외부 의존성(Repository, 외부 도메인 서비스)은 Mock/Stub으로 처리한다.
- 회귀 버그는 재발 방지 테스트를 우선 추가한다.
성공/실패 판단은 HTTP Status Code를 기준으로 한다. 공통 응답은 아래 형태를 기본으로 한다.
code는 비즈니스 결과 코드로 HTTP Status Code와 별개로 세부 결과를 표현한다.
성공 시 "SUCCESS", 실패 시 도메인 의미를 담은 UPPER_SNAKE_CASE를 사용한다. (예: "USER_NOT_FOUND", "ORDER_ALREADY_CANCELLED")
type ApiResponse<T> = {
code: string;
message: string;
data: T | null;
meta?: {
requestId?: string;
};
};// 400 Bad Request
{
"code": "INVALID_INPUT",
"message": "이메일 형식이 올바르지 않습니다.",
"data": null
}
// 404 Not Found
{
"code": "USER_NOT_FOUND",
"message": "사용자를 찾을 수 없습니다.",
"data": null
}- URI는 리소스 중심 명사로 설계하고, 동작은 HTTP Method로 표현한다.
- URI는 소문자 kebab-case를 사용한다. (
/user-profiles,/order-items) - HTTP Status Code는 의미에 맞게 사용한다.
- 엔드포인트/DTO 네이밍은 일관된 REST 규칙을 따른다.
| 상황 | Status Code |
|---|---|
| 조회 성공 | 200 |
| 생성 성공 | 201 |
| 삭제/업데이트 후 반환 없음 | 204 |
| 잘못된 입력 | 400 |
| 인증 실패 | 401 |
| 권한 없음 | 403 |
| 리소스 없음 | 404 |
| 서버 오류 | 500 |
- URI 경로에 버전을 포함한다. (
/v1/users,/v2/orders) - 하위 호환이 불가능한 변경은 버전을 올린다. 필드 추가 등 하위 호환 변경은 동일 버전에서 허용한다.
- 목록 조회 기본 방식은 커서 기반 페이지네이션을 사용한다.
- 관리자 페이지 등 페이지 번호가 명시적으로 필요한 경우에 한해 offset 기반을 허용한다.
// 커서 기반 요청 파라미터
type CursorPaginationQuery = {
cursor?: string; // 마지막으로 받은 아이템의 커서값
limit?: number; // 기본값: 20, 최대: 100
};
// 커서 기반 응답 meta
type PaginationMeta = {
nextCursor: string | null;
hasNext: boolean;
};커밋 메시지는 아래 형식을 따른다.
<type>(<scope>): <subject>
[body - 선택]
[footer - 선택]
| type | 용도 |
|---|---|
feat |
새로운 기능 추가 |
fix |
버그 수정 |
refactor |
동작 변경 없는 코드 개선 |
test |
테스트 추가/수정 |
docs |
문서 변경 |
chore |
빌드, 설정, 의존성 변경 |
style |
포맷, 세미콜론 등 코드 의미 없는 변경 |
- subject는 명령형 현재 시제로 작성한다. (
사용자 추가,인증 버그 수정) - subject는 50자 이내로 작성한다.
- Prettier/ESLint를 통과하지 못하는 코드는 커밋하지 않는다(pre-commit hook으로 강제).
- 시크릿, DB 접속 정보, 외부 API 키 등 모든 환경 의존 값은 환경변수로 관리한다. 코드에 하드코딩하지 않는다.
- 환경변수는 NestJS
ConfigModule을 통해 접근하고, 직접process.env를 참조하지 않는다. 단, TypeORM CLI 등 NestJS DI 컨텍스트 밖에서 실행되는 경우(data-source.ts)는dotenv+process.env직접 접근을 허용한다. - 환경변수는
config/에서 스키마(class-validator기반)로 검증한다. 앱 구동 시 필수 환경변수가 없으면 즉시 실패한다. .env파일은 git에 커밋하지 않는다. 필요한 키 목록은 문서에 명시해 관리한다.- 환경변수 이름은
UPPER_SNAKE_CASE를 사용한다.
- NestJS 기본
Logger를 사용하고, 운영 환경에서는 JSON 구조화 로그로 출력한다. - 로그 레벨 기준:
| 레벨 | 용도 |
|---|---|
error |
처리되지 않은 예외, 시스템 장애 |
warn |
비정상이지만 복구 가능한 상황 |
log |
주요 비즈니스 이벤트 (주문 생성, 결제 완료 등) |
debug |
개발 시 흐름 추적용 (운영 환경에서는 비활성화) |
verbose |
상세 디버깅 (로컬 전용) |
- 로그는 Application Layer 이하(UseCase, Repository)에서 출력한다. Controller에서는 요청/응답 로그를 인터셉터로 처리한다.
- 로그에 개인정보(비밀번호, 토큰, 주민번호 등)를 포함하지 않는다.
- 에러 로그에는 stack trace를 포함한다.
- 모든 외부 입력(Request Body, Query, Param)은
class-validator로 검증한다. 타입만으로 신뢰하지 않는다. - 인증(Authentication)은 Guard로, 인가(Authorization)는 Policy/Guard + Decorator 조합으로 처리한다.
- SQL 쿼리는 ORM의 파라미터 바인딩을 사용한다. Raw query에 사용자 입력을 직접 삽입하지 않는다(SQL Injection 방지).
- 응답에서 민감 정보(비밀번호 해시, 내부 ID 체계, 스택 트레이스 등)를 노출하지 않는다.
- 시크릿/API 키는 코드와 로그에서 마스킹한다.
- 패키지 취약점은 주기적으로
npm audit으로 점검한다.
- 가독성
- 도메인 정확성
- 아키텍처 일관성
- 성능 최적화
성능 최적화는 측정/프로파일링 근거 없이 가독성을 해치지 않는다.