Overview
채팅 Microservice Archiecture를 고도화하는 사이드 프로젝트를 진행하고 있는데요.
사이드 프로젝트 진행 전, NestJS 백엔드 프레임워크와 Nest의 대표적인 ORM인 TypeORM 사용법을 정리하고자 합니다.
프로젝트의 아키텍처는 다음과 같고, 이 아키텍처를 기반으로 "실시간성 + 트래픽 제어 + DB 최적화"와 같은 성능 개선 위주로 프로젝트를 진행해보려고 합니다.
NestJS는 NodeJS 런타임 기반의 웹 프레임워크로, TypeScript(권장) 또는 JavaScript로 개발할 수 있습니다. 의존성 주입(Dependency Injection)과 구조화된 아키텍처를 제공하여 협업에 용이하며, NodeJS의 비동기 처리 특성을 활용해 마이크로서비스 개발에 적합합니다.
본 문서는 채팅 기능을 중심으로 한 마이크로서비스를 개발하기 위한 가이드입니다. 아래 지침을 따라 개발 환경을 구성하고 사용 방법을 익힐 수 있습니다.
Table of Contents
- NestJS 기본 환경 세팅
- PostgreSQL DB 설치 (Local)
- TypeORM 연결
- NestJS의 주요 요소 (Request Lifecycle)
- 채팅 서비스 API 명세
- 테스트 코드 작성 방법
1. NestJS 기본 환경 세팅 (VSCode 기준)
- NodeJS를 다운로드 합니다.
- 파일을 열고 아래 명령어를 통해 관련 패키지를 설치해줍니다.
npm install
- 아래 명령어를 통해 서버를 실행할 수 있습니다.
npm run start:dev
- (옵션) 서버를 실행하면서 수정된 코드를 반영하기 위해 Hot Reload를 사용할 수 있습니다.
- DB 연결을 완료하면 아래와 같은 메세지 창을 해당 url로 접속할 수 있습니다.
http://localhost:3000/?roomId=1&userId=1
2. PostgreSQL DB 설치 (Local)
- PostgreSQL을 컴퓨터에 설치해줍니다.
- DBeaver / pgAdmin과 같은 데이터베이스 관리 툴을 열어 DB를 local에서 생성해줍니다.
- DB에 대한 정보를
dev.env
파일에 저장합니다. (민감 정보이므로,.gitignore
에 파일명을 포함해줍니다)// dot.env DB\_HOST=localhost DB\_USER=postgres DB\_PW=password DB\_NAME=chat_db DB\_PORT=5432
3. TypeORM 연결
TypeORM은 TypeScript와 함께 사용되는 객체-관계 매핑(ORM) 라이브러리로, 클래스 기반으로 데이터베이스를 쉽게 다룰 수 있게 해줍니다.
data.module.ts
파일에서 TypeORM과 PostgreSQL을 연결해줍니다.import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; // 환경변수에서 DB 정보를 받아올 수 있습니다 import { TypeOrmModule } from '@nestjs/typeorm'; import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; // import entities 생략 @Module({ imports: [ TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => { return { type: 'postgres', database: config.get<string>('DB\_NAME'), host: config.get<string>('DB\_HOST'), username: config.get<string>('DB\_USER'), password: config.get<string>('DB\_PW'), port: config.get<number>('DB\_PORT'), synchronize: config.get<string>('NODE\_ENV') !== 'production', // 개발시에만 DB가 업데이트되도록 설정합니다. logging: false, namingStrategy: new SnakeNamingStrategy(), ssl: false, // 로컬 DB를 사용하고 있어 SSL이 필요없습니다. (다만, RDS 등 클라우드 사용시 SSL 사용할 수 있도록 설정 필요) entities: [ UserEntity, ChatJoinEntity, ChatRoomEntity, ChatEntity, ChatLikeEntity, ContentEntity, ContentLikeEntity, ], // 관련된 entity들을 모두 연결해줍니다. }; }, }), \], }) export class DataModule {}
TypeORM 간단한 문법/패턴
1. 기본 Entity 정의
@Entity('user')
export class UserEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ default: true })
isActive: boolean;
}
2. 기본 CRUD 문법 (Repository 기준)
// Repository 주입
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}
// Create (삽입)
await this.userRepository.save({ name: 'username' });
// Read (조회)
const user = await this.userRepository.findOne({ where: { id: 1 } });
const users = await this.userRepository.find({ where: { isActive: true } });
// Update (수정) 또는 `save()`로도 업데이트 가능 (PK 기준)
await this.userRepository.update({ id: 1 }, { name: 'updated name' });
await this.userRepository.save({ id: 1, name: 'updated again' });
// Delete (삭제)
await this.userRepository.delete({ id: 1 });
3. 조건 조회 (find
옵션들)
await this.userRepository.find({
where: {
id: In([1, 2, 3]), // 여러 ID
name: Like('%username%'), // 부분 검색
},
order: {
id: 'DESC',
},
take: 10, // LIMIT
skip: 0, // OFFSET
});
4. 관계 설정 (Entity 간)
// User - OneToMany - Posts
@OneToMany(() => PostEntity, (post) => post.user)
posts: PostEntity\[\];
// Posts - ManyToOne - User
@ManyToOne(() => UserEntity, (user) => user.posts)
user: UserEntity;
5. Transaction (트랜잭션 처리)
await this.dataSource.transaction(async (manager) => {
const user = await manager.save(UserEntity, { name: 'new' });
await manager.update(UserEntity, user.id, { isActive: false });
});
6. 쿼리빌더 (복잡한 쿼리)
const user = await this.userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.where('user.id = :id', { id: 1 })
.getOne();
4. NestJS의 주요 요소 (Request Lifecycle)
NestJS의 요청 흐름: Middleware -> Guards -> Interceptors (전처리) -> Pipes (요청 값 검증/변환) -> Controller -> Service -> Interceptors (후처리) -> Exception Filters (예외 시)
Modules
NestJS 애플리케이션의 기본 단위이자 DI 컨테이너입니다. 각 기능 영역을 독립적인 모듈로 나누어 관리하며, @Module()
데코레이터로 정의됩니다.
- 역할: 관련된 Controller, Provider, Service 등을 그룹화
- 특징: 계층적 DI 스코프 제공 (전역/지역 모듈)
@Module({ imports: [], controllers: [ChatController], providers: [ChatService], }) export class ChatModule {}
Controllers
클라이언트의 HTTP 요청을 수신하고 응답을 반환하는 클래스입니다. 라우팅 역할을 하며, 요청 처리 로직은 주로 서비스로 위임합니다.
데코레이터:
@Controller()
,@Get()
,@Post()
등역할: 라우터 + 엔드포인트 진입점
@Controller('chat') export class ChatController { constructor( // service 의존성 주입 private readonly chatService: ChatService ) {} @Get('/room') async getRoom( @Req() req: any, @Query('roomId') roomId: number, ): Promise<ChatRoomModel> { return await this.chatService.getRoom(roomId); } }
Providers (Service 포함)
NestJS에서 비즈니스 로직을 수행하는 핵심 계층입니다. 서비스, 유틸리티, 리포지토리 등은 모두 Provider로 등록되며, 의존성 주입 가능합니다.
- 데코레이터: 없음 (
@Injectable()
로 주입 대상 명시) - 특징: 싱글톤 객체로 관리됨
@Injectable() export class ChatService { constructor( // repository 의존성 주입 @InjectRepository(UserEntity) private userRepository: Repository<UserEntity>, ) {} async getRoom(id: number): Promise<ChatRoomModel> { const roomEntity = await this.getRoomEntity(id); if (!roomEntity) { throw new CommonException(999, 'invalid room id'); } const userIds = roomEntity.chatJoins.map((e) => e.userId); return { ...roomEntity, users: await this.userRepository.find({ where: { id: In(userIds), }, }), }; } }
Middleware
Request lifecycle에서 Controller 진입 전에 실행되는 함수입니다. Express-style 미들웨어와 유사하며, 로깅, 인증, 요청 가공 등에 사용됩니다.
- 위치:
configure()
메서드에서 설정 - 특징:
@Middleware
데코레이터는 없음Exception Filters
Nest의 전역/지역 예외 처리기입니다. 기본HttpException
외에 커스텀 예외도 처리할 수 있으며, 통일된 에러 응답 포맷을 제공할 수 있습니다. - 데코레이터:
@Catch()
- 인터페이스:
ExceptionFilter
@Catch(CommonException) export class CommonExceptionFilter implements ExceptionFilter { catch(exception: CommonException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const resp = ctx.getResponse<Response>(); resp.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ code: exception.code, message: exception.message, }); } }
Pipes
요청 파라미터를 검증, 변환, 정제하는 계층입니다. DTO 기반 유효성 검사 (class-validator
) 또는 타입 변환에 자주 사용됩니다.
- 데코레이터:
@UsePipes()
,@Body(ValidationPipe)
- 인터페이스:
PipeTransform
@Injectable() export class ParseIntPipe implements PipeTransform { transform(value: any) { const val = parseInt(value, 10); if (isNaN(val)) throw new BadRequestException('Not a number'); return val; } }
Guards
라우트 핸들러에 접근하기 전에 권한을 체크하는 계층입니다. 인증, 역할 기반 접근 제어(Role-based access control)에 사용됩니다.
- 데코레이터:
@UseGuards()
- 인터페이스:
CanActivate
@Injectable() export class AuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); return !!request.user; // 로그인 여부 검사 } }
Interceptors
요청/응답 흐름을 가로채서 전/후 처리하는 계층입니다. 로깅, 응답 캐싱, 변환, 지연 삽입 등에 사용됩니다.
- 데코레이터:
@UseInterceptors()
- 인터페이스:
NestInterceptor
@Injectable() export class LoggingInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const now = Date.now(); return next.handle().pipe( tap(() => console.log(\`After... ${Date.now() - now}ms\`)), ); } }
Custom Decorators
NestJS는 @Body()
, @Param()
같은 기본 데코레이터 외에도, 개발자가 직접 커스텀 데코레이터를 정의할 수 있습니다.
- 내부적으로
ExecutionContext
를 사용해 요청 정보를 추출합니다.export const CurrentUser = createParamDecorator( (data: unknown, ctx: ExecutionContext) => { const req = ctx.switchToHttp().getRequest(); return req.user; }, );
5. 채팅 서비스 API 명세
Postman Collection 기반 API Documentation으로 채팅 서비스의 API 명세서를 확인할 수 있습니다. 링크를 통해 자세한 사항을 확인해주세요.
6. 테스트 코드 작성 방법
NestJS에서는 테스트를 하기에도 편리하게 아키텍쳐가 구성되어 있습니다. 특히, 유닛테스트의 경우 각 controller와 service 파일을 생성하면 .spec
파일이 자동으로 생겨 테스트 파일을 관리하기 편리합니다.
종류 | 설명 | 라이브러리 |
---|---|---|
Unit Test | 클래스 하나의 동작만 검증 (의존성은 mock) | Jest |
E2E Test | 실제 HTTP 요청을 통해 전체 흐름 테스트 | Jest + SuperTest |
NestJS에는 기본적으로 Jest가 내장되어 있어, 테스트 코드만 작성하고 아래의 명령어를 통해 테스트를 바로 실행할 수 있습니다.
- 유닛테스트 실행:
npm run test chat.service
(chat service 클래스를 테스트합니다) - 테스트 커버리지 확인:
npm run test:cov
- E2E 테스트:
npm run test:e2e
(HTML 리포트 제공)
ChatService 클래스의 createRoom()
에 대해 유닛테스트를 실행해보겠습니다.
실제 service에서는
1) 채팅방 id를 통해 채팅방 존재 여부를 확인합니다.
2) 채팅방이 존재한다면, 채팅방에 참여 중인 user들을 DB에서 꺼내옵니다.
3) 채팅방 정보와 user들의 정보를 반환합니다.
// chat.service.ts
@Injectable()
export class ChatService {
constructor(
@InjectRepository(ChatRoomEntity)
private chatRoomRepository: Repository<ChatRoomEntity>,
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) {}
@InjectRepository(ChatJoinEntity)) {}
async getRoom(id: number): Promise<ChatRoomModel> {
const roomEntity = await this.getRoomEntity(id);
if (!roomEntity) {
throw new CommonException(999, 'invalid room id');
}
const userIds = roomEntity.chatJoins.map((e) => e.userId);
return {
...roomEntity,
users: await this.userRepository.find({
where: {
id: In(userIds),
},
}),
};
}
}
해당 실제 service에 구현되어 있는 함수를 테스트하기 위해, 똑같은 환경을 mock합니다.
// chat.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ChatService } from './chat.service';
import { UserRepository } from '../data/repositories/user.repository';
import { CommonException } from '../exception/common.exception';
import { In } from 'typeorm';
describe('ChatService', () => {
let service: ChatService;
let userRepository: Partial<UserRepository>; // 실제 DB 대신 사용할 mock 리포지토리 객체
// 테스트마다 새로 실행되는 설정 블록
beforeEach(async () => {
userRepository = {
find: jest.fn(), // userRepository.find() 함수를 재정의합니다.
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ChatService,
{ provide: UserRepository, useValue: userRepository },
],
}).compile(); // 테스트 모듈을 생성하고, 관련 Providers를 주입합니다.
service = module.get<ChatService>(ChatService);
});
describe('getRoom', () => {
it('성공: 채팅방 조회', async () => {
const mockRoomEntity = {
id: 1,
name: 'test room',
chatJoins: [{ userId: 10 }, { userId: 20 }],
}; // 반환될 가짜 room을 정의합니다.
const mockUsers = [
{ id: 10, name: 'UserA' },
{ id: 20, name: 'UserB' },
]; // 반환될 가짜 users를 정의합니다.
// getRoomEntity를 직접 mock합니다 (메서드 override)
service.getRoomEntity = jest.fn().mockResolvedValue(mockRoomEntity);
(userRepository.find as jest.Mock).mockResolvedValue(mockUsers);
const result = await service.getRoom(1);
// Assert - 함수 호출에 대한 검증을 합니다.
expect(service.getRoomEntity).toHaveBeenCalledWith(1);
expect(userRepository.find).toHaveBeenCalledWith({
where: { id: In([10, 20]) },
});
expect(result).toEqual({
...mockRoomEntity,
users: mockUsers,
});
});
it('에러: 채팅방 조회', async () => {
service.getRoomEntity = jest.fn().mockResolvedValue(null);
// 존재하지 않는 roomId의 경우 에러를 반환합니다.
await expect(service.getRoom(999)).rejects.toThrow(CommonException);
});
});
});
E2E 테스트의 경우 supertest
모듈을 통해 실제 HTTP 요청을 시뮬레이션할 수 있습니다. root에 e2e 테스트를 위한 파일을 만들고 Nest 애플리케이션 전체를 bootstrap 합니다.
테스트의 범위와 구체성은 담당 개발자의 판단과 프로젝트의 성격에 따라 달라집니다. 모든 예외 케이스를 전부 포함하는 테스트가 반드시 최선은 아니며, 오히려 비즈니스적으로 중요한 흐름과 장애 가능성이 높은 지점에 집중하는 것이 효율적일 수 있습니다.
따라서 테스트를 작성할 때는 단순히 코드 커버리지를 채우는 것이 아니라, 다음과 같은 기준을 고려해야 합니다:
- 실제 서비스 흐름을 얼마나 잘 반영하는가?
- 외부 의존성(DB, API 등)을 적절히 mock하고 있는가?
- 실제로 실패할 가능성이 높은 시나리오를 방어하고 있는가?
- 유지보수가 가능한 테스트 구조를 갖추고 있는가?
특히 NestJS처럼 의존성 주입이 구조화된 프레임워크에서는, 각 계층(Controller → Service → Repository)을 분리하여 테스트할 수 있는 여지가 큽니다. 의존성을 효과적으로 mock하고, 실패 시 예외 처리를 검증하는 테스트 구조를 갖추는 것이 중요합니다.
'Software Engineering' 카테고리의 다른 글
생성형 AI 학습 방식 - Zero-shot, One-shot, Few-shot Learning (0) | 2024.08.06 |
---|