8편. 테스트 전략 : Repository · Service · Route 계층별 테스트 구현하기
📚 목차
1. 계층 책임 기반 테스트 전략 설계
2. Repository 테스트 : 데이터 정책과 무결성 검증
3. Service Layer 테스트: 비즈니스 로직과 흐름 제어
4. Route Layer 테스트: HTTP 계약 및 인터페이스 보장
📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/fastify-api-rest
1. 계층 책임 기반 테스트 전략 설계
테스트는 코드 파일을 기준으로 나누기보다 각 계층이 맡은 책임(Responsibility)을 기준으로 나누는 편이 훨씬 안정적입니다.
현재 구조는 다음과 같습니다.
Route → Controller → Service → Repository → DB
여기서 Controller는 “전달/조합” 역할이 중심이므로, 테스트 전략은 대개 Route / Service / Repository를 축으로 잡으면 충분합니다.
🔷1) 각 계층의 책임과 “검증 범위”는 다릅니다
유저를 조회하거나 수정하는 기능이라도 계층마다 책임이 다르기 때문에 테스트 포인트도 달라집니다.
| 계층 | 책임 | 테스트 초점 |
| Repository | DB 접근 + 데이터 정책 강제 | 무결성, 정책이 실제로 적용되는가 |
| Service | 흐름 제어 + 데이터 변환 | 분기 처리, DTO 변환 정확성 |
| Route | HTTP 계약 보장 | Validation, 응답 형식, 상태 코드 |
🔷2) Repository Layer: 데이터 정책 강제
데이터베이스와 직접 통신하며, 시스템의 가장 기초적인 데이터 무결성 및 비즈니스 정책을 강제합니다.
검증 포인트 (Focus)
▸ Soft Delete 정책: 삭제된 행(deletedAt IS NOT NULL)이 조회 및 수정에서 제외되는가?
▸ 데이터 제약 조건: 고유 키(Unique), 필수 값(Not Null) 등이 DB 레벨에서 올바르게 작동하는가?
▸ 복잡한 쿼리: 조인(Join)이나 집계(Aggregation) 로직이 의도한 데이터 셋을 반환하는가?
테스트 예시
▸ findById 호출 시 deletedAt이 있는 유저는 null을 반환해야 함.
▸ update 시도 시 대상이 이미 삭제된 상태라면 에러가 발생하거나 0개 수정되어야 함.
🔷3) Service Layer: 흐름 제어 및 변환
Repository를 활용하여 비즈니스 로직의 흐름(Flow)을 정의하고, 데이터를 가공/변환합니다.
검증 포인트 (Focus)
▸ 분기 로직: 조건(if/else)에 따라 Repository 함수를 올바르게 호출하는가?
▸ 데이터 변환 (DTO): DB Entity를 외부 노출용 DTO로 정확하게 매핑하는가?
▸ 예외 전파: Repository에서 발생한 상황(데이터 없음 등)을 서비스 계층의 에러로 적절히 변환하는가?
테스트 예시
▸ includeProfile 파라미터가 true일 때만 프로필 조회 로직이 실행되는지 확인.
▸ 페이지네이션 skip/take 계산 로직이 정확한 수치로 Repository에 전달되는지 확인.
🔷4) Route Layer: HTTP 계약 보장
외부 요청을 받아들이는 관문으로, 입력 검증 및 출력 형식을 보장합니다.
검증 포인트 (Focus)
▸ Schema Validation: 잘못된 형식의 Body, Query, Params에 대해 400 에러를 반환하는가?
▸ Serialization: 응답 객체가 정의된 스키마(Swagger/Interface)를 준수하며 불필요한 필드는 제거되었는가?
▸ HTTP Status Code: 성공 시 200/201, 실패 시 4xx/5xx 등 규약에 맞는 코드를 반환하는가?
테스트 예시
▸ 이메일 형식이 아닌 요청에 대해 400 Bad Request 응답 확인.
▸ 권한이 없는 요청(Header 누락 등)에 대해 401/403 응답 확인.
🔷5) 테스트 순서(Mock 미사용)
1. Repository (정책)
2. Service (흐름/변환)
3. Route (계약)
4. 최소한의 E2E
▸ 하부 안정성: 하위 계층(정책)이 안정화된 상태에서 상위 계층을 쌓아 올릴 수 있습니다.
▸ 추적 용이성: 테스트 실패 시 원인이 정책(Repo)인지, 흐름(Service)인지, 계약(Route)인지 명확히 알 수 있습니다.
▸ 비용 절감: 순서를 거꾸로 하면(Route부터 작성), 하위 정책 수정 시 이미 작성한 상위 테스트가 도미노처럼 깨지는 리팩터링 비용이 발생합니다.
2. Repository 테스트 : 데이터 정책과 무결성 검증
Repository 계층은 데이터베이스와 애플리케이션 사이의 최종 방어선입니다.
서비스 로직의 실수와 상관없이 DB 레벨에서 정의된 데이터 정책이 엄격히 준수되는지 검증하는 것이 Repository 테스트의 본질입니다.
🔷1. 생성(Create) 정책: 데이터 무결성 보장
사용자를 생성할 때 프로필과 같은 연관 데이터가 누락된다면 나중에 큰 문제가 될 수 있습니다. Repository 단계에서 이를 한 번에 처리하고, 중복 데이터가 들어오지 못하도록 확실히 검증해야 합니다.
Repository 코드 예시
async create(data: Prisma.UserCreateInput) {
return this.prisma.user.create({
data: {
...data,
profile: { create: {} }, // 정책: User 생성 시 Profile 자동 생성 강제
},
select: userBaseSelect,
});
}
테스트 코드 예시
▸ 사용자를 만들었을 때, 우리가 기대한 대로 프로필 레코드가 DB에 함께 생성되었는지 확인합니다.
▸ 이미 가입된 이메일로 다시 가입하려고 할 때, DB 수준에서 정확히 거부(P2002 에러)되는지 체크합니다.
it('사용자 생성 시 프로필(Profile) 데이터도 자동으로 함께 생성됩니다', async () => {
const result = await repo.create(userInput());
const profile = await prisma.profile.findUnique({ where: { userId: result.id } });
expect(profile).toBeDefined(); // 프로필이 존재해야 합니다.
});
it('중복된 전화번호 사용시, 시스템은 에러를 발생시킵니다', async () => {
await repo.create(userInput());
const dupInput = userInput({ email: 'other@example.com' });
try {
await repo.create(dupInput);
} catch (err) {
if (err instanceof PrismaClientKnownRequestError) {
expect(err.code).toBe('P2002'); // 중복 에러 코드를 확인합니다.
}
}
});
🔷2. 수정 정책: 삭제 데이터 수정 차단
이미 삭제(Soft Delete)된 데이터를 수정하면 데이터 일관성이 깨질 수 있습니다. 수정 쿼리에 보호 조건을 추가하여 이를 원천 차단해야 합니다.
Repository 코드 예시
async update(userId: number, data: Prisma.UserUpdateInput) {
return this.prisma.user.update({
where: {
id: userId,
deletedAt: null, // 정책: 삭제되지 않은 활성 상태의 유저만 수정할 수 있습니다.
},
data: data,
select: userBaseSelect,
});
}
테스트 코드 예시
▸ 삭제된 유저를 수정하려 할 때 시스템이 대상을 찾지 못하도록 안전하게 차단하는지 확인합니다.
it('삭제된 사용자는 수정 대상에서 제외되어 에러가 발생합니다', async () => {
const created = await repo.create(userInput());
await repo.softDelete(created.id); // 삭제 상태로 먼저 만듭니다.
// 삭제된 유저 수정 시 'Record not found' 예외가 발생해야 합니다.
try {
await repo.update(created.id, { displayName: '이름업데이트' });
} catch (err) {
if (err instanceof PrismaClientKnownRequestError) {
expect(err.message).toContain('No record was found'); // 수정 대상을 찾지 못해야 함
}
}
});
🔷3. 삭제 및 복구 정책: 연관 데이터 동기화
논리 삭제(Soft Delete) 시 부모 데이터와 자식 데이터의 삭제 상태가 어긋나지 않도록 항상 한 세트로 관리해야 합니다.
Repository 코드 예시
async softDelete(userId: number) {
const date = new Date();
return this.prisma.user.update({
where: { id: userId, deletedAt: null },
data: {
deletedAt: date,
profile: { update: { deletedAt: date } }, // 정책: 유저 삭제 시 프로필도 같은 시각에 삭제합니다.
},
select: userBaseSelect,
});
}
async restore(userId: number) {
return this.prisma.user.update({
where: {
id: userId,
deletedAt: { not: null }, // 삭제된 상태인 유저만 타겟팅
},
data: {
deletedAt: null,
profile: {
update: { deletedAt: null },
},
},
select: userBaseSelect,
});
}
테스트 코드 예시
▸ 유저와 프로필의 삭제 시각이 동일하게 기록되는지 확인합니다.
▸ 복구 시 모든 연관 데이터가 활성 상태(null)로 올바르게 돌아오는지 체크합니다.
it('사용자 삭제 시 연관 프로필에도 동일한 삭제 시각이 기록됩니다', async () => {
const created = await repo.create(userInput());
await repo.softDelete(created.id);
const user = await prisma.user.findUnique({ where: { id: created.id } });
const profile = await prisma.profile.findUnique({ where: { userId: created.id } });
expect(user?.deletedAt).not.toBeNull();
expect(profile?.deletedAt).toEqual(user?.deletedAt); // 시각 일치 확인
});
it('복구 시 유저와 프로필 모두 다시 활성 상태로 돌아옵니다', async () => {
const created = await repo.create(userInput());
await repo.softDelete(created.id);
await repo.restore(created.id);
const restoredProfile = await prisma.profile.findUnique({ where: { userId: created.id } });
expect(restoredProfile?.deletedAt).toBeNull(); // null 상태 확인
});
🔷4. 조회 정책 : 데이터 격리 및 최적화
삭제된 데이터가 노출되지 않도록 격리하고, 성능을 위해 필요한 경우에만 관계 데이터를 조인하도록 구현합니다.
Repository 코드 예시
async selectOne(where: UserBaseWhere) {
const { includeProfile = false, ...searchCondition } = where;
return this.prisma.user.findUniqueOrThrow({
where: { ...searchCondition, deletedAt: null }, // 정책: 활성 데이터만 조회합니다.
select: {
...userBaseSelect,
...(includeProfile && { profile: { select: profileSelect } }), // 정책: 요청 시에만 조인합니다.
},
});
}
테스트 코드 예시
▸ 삭제된 데이터 접근 시 예외를 발생시켜 정보 유출을 차단하는지 확인합니다.
▸ 옵션에 따라 프로필 데이터 포함 여부가 정확히 결정되는지 검증합니다.
it('삭제된 상태의 사용자는 조회할 수 없습니다', async () => {
const user = await repo.create(userInput());
await repo.softDelete(user.id);
// 삭제된 유저 조회 시 예외 발생 확인
await expect(repo.selectOne({ id: user.id })).rejects.toThrow();
});
it('상세 정보 요청 시에만 프로필 데이터가 포함됩니다', async () => {
const created = await repo.create(userInput());
const user = await repo.selectOne({ id: created.id, includeProfile: true });
expect(user).toHaveProperty('profile'); // 프로필 포함 확인
});
🔷5. 목록 및 존재 여부: 조회 일관성 및 성능
목록 조회 시 데이터 개수 정합성을 보장하고, 존재 여부 확인 시에는 최소한의 데이터만 사용하여 성능을 최적화합니다
Repository 코드 예시
async selectManyWithCount(options?: UserListOptions) {
const where = { ...filters, deletedAt: null };
const [data, total] = await this.prisma.$transaction([ // 정책: 목록과 개수 조회를 동기화합니다.
this.prisma.user.findMany({ where, skip, take, orderBy }),
this.prisma.user.count({ where }),
]);
return { data, total };
}
async exists(where: UserBaseWhere): Promise<boolean> {
const user = await this.prisma.user.findFirst({
where: { ...where, deletedAt: null },
select: { id: true }, // 정책: ID 값만 조회하여 성능을 높입니다.
});
return user !== null;
}
테스트 코드 예시
▸ 삭제된 데이터를 제외한 실제 활성 데이터 수가 정확히 계산되는지 확인합니다.
▸ 삭제된 사용자에 대해 false를 정확히 반환하는지 체크합니다.
it('목록 조회 시 삭제된 유저를 제외한 정확한 개수를 반환합니다', async () => {
const { data, total } = await repo.selectManyWithCount({ skip: 0, take: 10 });
expect(data.length).toBeLessThanOrEqual(10);
expect(total).toBe(20); // 삭제된 데이터 제외 확인
});
it('삭제된 사용자는 존재하지 않는 것으로 판단합니다', async () => {
const user = await repo.create(userInput());
await repo.softDelete(user.id);
const isExist = await repo.exists({ id: user.id });
expect(isExist).toBe(false); // false 반환 확인
});

3. Service Layer 테스트: 비즈니스 로직과 흐름 제어
Service Layer는 Repository를 조합하여 실제 유스케이스(Use Case)를 완성하고, DB 데이터를 클라이언트가 기대하는 응답 규격(DTO)으로 가공하는 핵심 계층입니다.
🔷1. 흐름 제어 및 오케스트레이션: "유연한 비즈니스 분기 처리"
ervice는 클라이언트의 요청 옵션에 따라 Repository의 서로 다른 기능을 호출하여 최적의 흐름을 만들어냅니다.
특히 페이징 여부에 따라 응답에 meta 데이터를 포함할지 결정하는 흐름 제어가 핵심입니다..
Service 구현 예시
async listUsers(query: UserListQueryDto): Promise<UserListResponseDto> {
const { includeProfile, ...searchCondition } = query;
const isIncludeProfile = String(includeProfile) === 'true';
// 정책: 페이지네이션 정보(skip/take) 유무에 따라 응답 구조를 동적으로 결정합니다.
if (query.skip !== undefined || query.take !== undefined) {
const { data, total } = await this.repository.selectManyWithCount({
...searchCondition,
includeProfile: isIncludeProfile,
});
return {
data: data.map((user) => toUserDetailResponse(user, isIncludeProfile)),
meta: {
total,
...(query.skip !== undefined && { skip: query.skip }),
...(query.take !== undefined && { take: query.take }),
},
};
}
// 페이징이 없는 경우 단순 목록만 반환합니다.
const users = await this.repository.selectMany({
...searchCondition,
includeProfile: isIncludeProfile,
});
return { data: users.map((user) => toUserDetailResponse(user, isIncludeProfile)) };
}
테스트 코드 예시 : 페이징 분기 검증
서비스 계층은 단순히 데이터를 전달하는 것이 아니라, 요청 조건에 따라 응답의 '모양'을 바꾸는 책임을 집니다. 이를 위해 meta 항목의 존재 여부를 반드시 체크해야 합니다.
it('페이지 정보가 없는 사용자 목록 조회 시 meta 항목 없이 data만 반환한다', async () => {
const users = await service.listUsers({});
expect(users).toHaveProperty('data');
expect(users.meta).toBeUndefined(); // meta가 없어야 함을 명시적으로 검증
});
it('페이지 정보가 있는 사용자 목록 조회 시 data와 meta 정보를 함께 반환한다', async () => {
const users = await service.listUsers({ skip: 5, take: 10 });
expect(users).toHaveProperty('data');
expect(users).toHaveProperty('meta');
expect(users.meta).toMatchObject({ total: 20, skip: 5, take: 10 });
});
🔷2. 데이터 매핑 및 타입 안정성: "엄격한 DTO 변환 정책"
Service는 DB 원본 데이터(Entity/Row)를 클라이언트 친화적인 포맷으로 정제합니다. 날짜 객체의 문자열 변환, null 허용 필드 처리, 관계 데이터의 조건부 포함 등이 포함됩니다.
service 코드 예시: 데이터 매퍼 및 정책 대응
/**
* [Data Mapper] DB 엔티티 구조를 DTO 구조로 변환하는 순수 함수
* - Date 객체 -> ISO8601 문자열 변환
* - null 허용 필드(displayName 등)의 일관된 응답 유지
*/
function toUserDetailResponse(user: UserRow, includeProfile: boolean = false): UserDetailResponseDto {
return {
id: user.id,
email: user.email,
displayName: user.displayName, // DB의 null을 그대로 전달하거나 기본값 처리
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
...(includeProfile && user.profile && {
profile: {
bio: user.profile.bio,
avatarKey: user.profile.avatarKey,
},
}),
};
}
테스트 코드 예시 : 응답 스펙 준수 확인
값이 존재하느냐보다 "기획된 규격(Type/Format)대로 변환되었는가"를 검증하는 것이 중요합니다.
it('사용자 생성 및 조회 시 모든 날짜 데이터는 ISO8601 포맷의 문자열이어야 한다', async () => {
const result = await service.createUser(userInput());
const isDateTime = (val: string) => !Number.isNaN(Date.parse(val));
expect(isDateTime(result.createdAt)).toBe(true);
expect(isDateTime(result.updatedAt)).toBe(true);
});
it('사용자 이름(displayName)이 없는 경우에도 응답 구조에서 필드가 누락되지 않고 null을 유지한다', async () => {
const result = await service.createUser(userInput({ displayName: undefined }));
expect(result).toHaveProperty('displayName');
expect(result.displayName).toBeNull();
});
🔷3. 비즈니스 규칙 및 프로세스 통합 검증
서비스 테스트의 백미는 단일 기능이 아니라 도메인 정책이 적용된 일련의 흐름을 검증하는 것입니다.
"삭제된 사용자는 조회할 수 없다"는 비즈니스 규칙은 여러 기능의 조합으로 완성됩니다.
테스트 코드 예시
생성부터 수정, 삭제, 복구에 이르는 전체 과정을 한 번에 테스트하여 상태 전이에 따른 정책 일관성을 확인합니다.
it('사용자의 생성, 수정, 삭제, 복구에 이르는 비즈니스 정책이 일관되게 적용된다', async () => {
// 1. 생성: 초기 데이터 무결성 확인
const user = await service.createUser(userInput());
// 2. 수정: 변경 사항이 즉시 반영되는지 확인
const updated = await service.updateUser({ id: user.id }, { displayName: '수정된이름' });
expect(updated.displayName).toEqual('수정된이름');
// 3. 삭제: Soft Delete 정책 적용 확인 (조회 시 에러 발생)
await service.softDeleteUser({ id: user.id });
try {
await service.getUser({ id: user.id });
} catch (err) {
if (err instanceof PrismaClientKnownRequestError) {
expect(err.message).toContain('No record was found'); // 조회 불가 정책 검증
}
}
// 4. 복구: 데이터 재활성화 및 최종 상태 확인
const restored = await service.restoreUser({ id: user.id });
expect(restored.displayName).toEqual('수정된이름');
const finalCheck = await service.getUser({ id: user.id });
expect(finalCheck.id).toEqual(user.id);
});

4. Route Layer 테스트: HTTP 계약 및 인터페이스 보장
Route Layer는 애플리케이션의 최외곽 관문으로, 외부 요청(HTTP)을 받아들이고 시스템의 응답을 규격화하여 내보내는 인터페이스 계약(API Contract) 계층입니다.
🔷1. 인터페이스 정의 및 유효성 검증: "Strict Request Validation"
Route는 요청의 본문(Body), 파라미터(Params), 쿼리 스트링(Querystring)이 비즈니스 로직으로 진입하기 전, 정의된 스키마를 준수하는지 런타임에서 검증합니다.
route 코드 예시 : Validation & Mapping
/**
* [Route Layer: 인터페이스 및 검증 계층]
* - Body, Params, Querystring 타입을 체크하여 400 에러 즉시 차단
* - Response 스키마를 통한 민감 정보 필터링 및 데이터 규격화
*/
export const userRoutes: FastifyPluginAsyncTypebox = async (fastify) => {
fastify.post(
'/',
{
schema: {
tags: ['User'],
body: UserCreateBodySchema, // 필수 필드 유무 및 포맷 검증
response: { 200: SuccessResponseSchema(UserResponseSchema) }, // 출력 필터링
},
},
async (request, reply) => {
const result = await userController.createUser(request.body);
return reply.code(200).send(success(result));
},
);
// ... 기타 엔드포인트 생략
};
테스트 코드 예시: 입력 유효성 검증
▸ 비즈니스 로직 실행 전, 잘못된 형식이 API 수준에서 차단되는지 확인합니다.
▸ 이는 서버 리소스를 보호하고 클라이언트에게 명확한 실패 원인을 제공합니다.
it('필수 필드(phoneNumber)가 누락되면 400 VALIDATION_ERROR를 반환한다', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/users/',
payload: { email: 'test1@example.com' }, // phoneNumber 누락
});
expect(res.statusCode).toBe(400);
const json = res.json();
expect(json.success).toBe(false);
expect(json.code).toBe('VALIDATION_ERROR');
});
it('유효하지 않은 전화번호 형식 입력 시 400 에러를 반환한다', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/users/',
payload: {
email: 'test1@example.com',
phoneNumber: '+8821244invalid', // 포맷 위반
},
});
expect(res.statusCode).toBe(400);
});
🔷2. 응답 직렬화 및 캡슐화: "Response Serialization"
보안과 성능을 위해 응답 스키마(DTO)에 정의되지 않은 필드(비밀번호, 내부 상태값 등)는 제거하고, 모든 API가 동일한 구조(success, body, code)를 유지하도록 보장합니다.
공통 응답 구조 (Encapsulation)
/**
* 모든 API 응답을 통일된 구조로 캡슐화하는 유틸리티
*/
export const success = <T>(data: T) => ({
success: true,
body: data,
code: 'OK'
});
테스트 코드 예시: 응답 스펙 준수 확인
▸ 클라이언트가 기대하는 데이터 구조와 타입이 정확히 전달되는지 확인합니다.
▸ 특히 날짜 포맷팅(ISO8601)과 캡슐화된 구조를 중점적으로 체크합니다.
it('사용자 생성 시 성공 응답은 success와 body를 포함한 공통 규격을 준수한다', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/users/',
payload: { email: 'test@test.com', phoneNumber: '+821012341234' },
});
const json = res.json();
expect(json).toHaveProperty('success', true);
expect(json).toHaveProperty('body');
expect(json.body).toHaveProperty('id');
expect(isDateTime(json.body.createdAt)).toBe(true); // 날짜 직렬화 확인
});
🔷3. 통합 흐름 및 예외 코드 보장: "E2E Integration"
▸ Route 테스트는 실제 HTTP 요청부터 DB 처리까지의 전체 사이클을 검증합니다.
▸ 이때 발생하는 비즈니스 에러가 적절한 HTTP 상태 코드(409, 404 등)로 매핑되는지 확인하는 것이 핵심입니다.
테스트 코드 예시: HTTP 상태 코드 및 경로 매핑
다양한 시나리오에서 시스템이 정의한 HTTP 상태 코드를 반환하는지 검증합니다.
it('이미 가입된 이메일로 등록 시도 시 409 ALREADY_EXISTS 에러를 반환한다', async () => {
// 1. 사전 데이터 생성 (POST)
await app.inject({
method: 'POST',
url: '/api/users/',
payload: { email: 'dup@test.com', phoneNumber: '+821011112222' },
});
// 2. 중복 등록 시도
const res = await app.inject({
method: 'POST',
url: '/api/users/',
payload: { email: 'dup@test.com', phoneNumber: '+821011112222' },
});
expect(res.statusCode).toBe(409); // Conflict
expect(res.json().code).toBe('ALREADY_EXISTS');
});
it('삭제된 사용자를 복구(Restore)하면 상태 코드는 200이며 다시 조회가 가능하다', async () => {
// Soft Delete 후 복구 API(/restore) 호출 흐름 검증
const res = await app.inject({
method: 'PATCH',
url: `/api/users/${userId}/restore`,
});
expect(res.statusCode).toBe(200);
expect(res.json().success).toBe(true);
});

※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'4.Node.js > 실무익히기' 카테고리의 다른 글
| [REST API] 10편. Post 첨부파일 관리 구현: 업로드, 다운로드, 게시글 연결 구조 설계 (0) | 2026.05.20 |
|---|---|
| [REST API] 9편. Post 도메인 고급 조회 구현: 복합 조건 검색과 Keyset Pagination (0) | 2026.03.30 |
| [REST API] 7편. User API 설계와 구현:DTO,Repository,Service,Route (0) | 2026.03.26 |
| [REST API] 6편. Fastify 스키마 검증, 전역 에러 처리, 로그 설계 : try/catch 지옥 벗어나기 (0) | 2026.03.25 |
| [REST API] 5편. Layered 모듈 적용 하기: Schema-First, DTO, TypeBox (0) | 2026.03.24 |