들어가며
처음 TypeScript를 시작했을 때만 해도 interface, type, class의 차이점을 명확히 이해하지 못했습니다. 특히 NestJS로 백엔드 개발을 시작하면서는 더 큰 혼란이 왔죠.
"어? 근데 NestJS에서는 왜 class를 이렇게 많이 쓰지?"
"interface랑 class가 왜 이렇게 따로 노는 것 같지?"
기본부터 차근차근: 세 가지 방식의 차이
// 1. interface: 타입 정의의 기본
interface User {
id: string;
name: string;
email: string;
}
// 2. type: 유연한 타입 정의
type UserResponse = {
user: User;
token: string;
} | null;
// 3. class: 실제 구현체
class UserService {
private users: User[] = [];
async findById(id: string): Promise<User | undefined> {
return this.users.find(user => user.id === id);
}
}
NestJS에서의 재미있는 발견: "공존하지 않는 듯한 class와 interface"
// 🤔 이런 건 안 되고
@Injectable()
interface UserService {
findOne(id: string): Promise<User>;
}
// ✅ 이런 건 되는 이유가 뭘까?
@Injectable()
class UserService {
findOne(id: string): Promise<User> {
// 구현
}
}
1. 데코레이터와 메타데이터의 세계
// 실제 프로젝트에서 자주 보는 모습
@Controller('users')
class UserController {
@Get(':id')
@UseGuards(AuthGuard)
async getUser(@Param('id') id: string) {
// ...
}
}
NestJS는 이런 데코레이터로 거의 모든 것을 제어합니다. 그런데 interface는 데코레이터를 사용할 수 없어요. 왜냐구요? interface는 컴파일 후에 사라지거든요!
2. 의존성 주입(DI)의 비밀
여기서 재미있는 부분이 나옵니다. 처음에는 이런 구조를 이해하는 게 쉽지 않았는데요. 실제 프로젝트에서 어떻게 사용되는지 같이 살펴볼까요?
// user.types.ts
// 1. 타입 정의: 엔티티와 Repository 인터페이스
interface User {
id: number;
name: string;
email: string;
}
// interface Repository
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
interface IUserRepository {
findOne(id: number): Promise<User>;
save(user: Omit<User, 'id'>): Promise<User>;
}
// user.repository.ts
// 2. Repository 구현체
@Injectable()
export class UserRepository implements IUserRepository {
async findOne(id: number): Promise<User> {
// 실제 DB 조회 로직
return { id, name: 'John Doe', email: 'john@example.com' };
}
async save(user: Omit<User, 'id'>): Promise<User> {
// 실제 DB 저장 로직
return { id: 1, ...user };
}
}
// user.service.ts
// 3. Service에서 Repository 사용
// service에서 interface repository를 사용하는 이유는 실제 repo와 service의 결합성을 낮추기 위해서
@Injectable()
export class UserService {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: IUserRepository
) {}
async getUserById(id: number): Promise<User> {
return this.userRepository.findOne(id);
}
async createUser(name: string, email: string): Promise<User> {
return this.userRepository.save({ name, email });
}
}
// user.module.ts
// 4. Module에서 의존성 설정
@Module({
providers: [
UserService,
{
provide: USER_REPOSITORY,
useClass: UserRepository
}
],
exports: [UserService]
})
export class UserModule {}
이런 구조가 가능한 이유는 NestJS의 DI가 두 가지 시점에서 작동하기 때문입니다:
- 컴파일 타임: TypeScript가 타입을 체크하는 시점
- IUserRepository의 메서드들이 제대로 구현되었는지 확인
- 메서드의 파라미터와 반환 타입이 일치하는지 검사
- 런타임: 실제 JavaScript가 실행되는 시점
- interface는 이미 사라지고 없음
- USER_REPOSITORY 토큰을 보고 실제 UserRepository 클래스의 인스턴스를 주입
3. Type의 강력한 활용
// 1. 유니온 타입으로 명확한 값 제한
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type UserRole = 'admin' | 'manager' | 'user' | 'guest';
type ValidationStatus = 'idle' | 'pending' | 'success' | 'error';
// 2. 상태 관리를 위한 차별화된 타입
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// 3. 유틸리티 타입을 활용한 타입 변환
interface User {
id: number;
name: string;
email: string;
password: string;
role: UserRole;
createdAt: Date;
}
// 생성할 때는 id와 createdAt이 필요 없죠
type CreateUserDto = Omit<User, 'id' | 'createdAt'>;
// 수정할 때는 일부 필드만 변경 가능하도록
type UpdateUserDto = Partial<Pick<User, 'name' | 'email'>>;
// 비밀번호 제외한 사용자 정보
type SafeUser = Omit<User, 'password'>;
특히 NestJS에서는 Type을 이렇게 활용하면 꿀이더라구요:
// 1. 이벤트 타입 정의
type UserCreatedEvent = {
type: 'USER_CREATED';
userId: number;
timestamp: Date;
}
type UserDeletedEvent = {
type: 'USER_DELETED';
userId: number;
reason: string;
timestamp: Date;
}
type UserEvent = UserCreatedEvent | UserDeletedEvent;
@Injectable()
class UserEventEmitter {
@OnEvent('user.*')
handleUserEvent(payload: UserEvent) {
switch(payload.type) {
case 'USER_CREATED':
// payload가 UserCreatedEvent로 타입 추론됨
break;
case 'USER_DELETED':
// payload가 UserDeletedEvent로 타입 추론됨
break;
}
}
}
// 2. 복잡한 상태 관리
type CacheState<T> = {
data: T | null;
lastUpdated: Date | null;
isLoading: boolean;
error: Error | null;
};
@Injectable()
class CacheService<T> {
private state: CacheState<T> = {
data: null,
lastUpdated: null,
isLoading: false,
error: null
};
}
Interface vs Type: 실전에서의 선택 기준
처음에는 그저 "이건 interface로 하고, 저건 type으로 하면 되겠지" 하고 단순하게 생각했는데요. 시간이 지나면서 각각의 도구가 가진 고유한 강점들이 보이기 시작했습니다.
Interface의 강점: 확장과 상속
제가 Interface를 선택하는 주요 상황들입니다:
// 1. API 응답처럼 확장 가능성이 높은 객체 구조
interface ApiResponse {
status: number;
message: string;
}
interface DetailedApiResponse extends ApiResponse {
data: unknown;
timestamp: Date;
}
interface PaginatedApiResponse extends DetailedApiResponse {
page: number;
totalPages: number;
hasNext: boolean;
}
// 2. 플러그인이나 외부에 공개되는 API
interface Plugin {
init(): void;
destroy(): void;
}
// 3. DB 엔티티처럼 명확한 스키마가 있는 객체
interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}
// 4. DTO (Data Transfer Object)
interface CreateUserDto {
name: string;
email: string;
password: string;
}
Type의 강점: 유연성과 정교한 타입 정의
반면, Type은 이런 상황에서 더 빛을 발했습니다:
// 1. 튜플 타입 정의
type Point = [number, number];
type RGB = [number, number, number];
type StateChange = [string, any]; // [key, value]
// 2. 유니온 타입으로 정확한 값 제한
type Status = 'idle' | 'loading' | 'success' | 'error';
type Theme = 'light' | 'dark' | 'system';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
// 3. 유틸리티 타입을 활용한 변환
interface User {
id: number;
name: string;
email: string;
password: string;
}
type CreateUser = Omit<User, 'id'>;
type UpdateUser = Partial<Pick<User, 'name' | 'email'>>;
type SafeUser = Omit<User, 'password'>;
// 4. 템플릿 리터럴 타입
type CssUnit = `${number}px` | `${number}rem` | `${number}em`;
type EventName = `user:${string}`;
NestJS에서의 실전 활용
이러한 이해를 바탕으로, NestJS 프로젝트에서는 이렇게 사용하고 있습니다:
// Interface 사용
interface UserRepository {
findOne(id: number): Promise<User>;
save(user: CreateUserDto): Promise<User>;
update(id: number, user: UpdateUserDto): Promise<User>;
}
interface CacheService {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl?: number): Promise<void>;
}
// Type 사용
type CacheOptions = {
ttl?: number;
invalidateOnUpdate?: boolean;
} & Record<string, unknown>;
type EventPayload<T extends string> = {
type: T;
timestamp: Date;
data: unknown;
};
type UserEvent =
| EventPayload<'user.created'>
| EventPayload<'user.updated'>
| EventPayload<'user.deleted'>;
결론: 각자의 자리가 있다
지금까지의 경험을 통해 깨달은 것은 이렇습니다:
- Interface는 주로:
- 객체의 구조를 정의할 때
- 확장 가능성이 있는 API를 설계할 때
- 명확한 계약(contract)이 필요할 때
- Repository나 Service의 명세를 정의할 때
- Type은 주로:
- 정확한 값의 집합을 정의할 때
- 타입 변환이나 조합이 필요할 때
- 튜플이나 유니온 타입이 필요할 때
- 기존 타입을 변형해서 새로운 타입을 만들 때
- Class는:
- 실제 구현체가 필요할 때
- NestJS의 데코레이터를 사용해야 할 때
- 상태와 행위를 함께 관리해야 할 때
- DI 시스템에서 주입할 대상일 때
결국 중요한 것은 각 도구의 특성을 이해하고, 상황에 맞게 적절한 도구를 선택하는 것입니다. 처음에는 어려울 수 있지만, 경험이 쌓이면서 자연스럽게 "이럴 때는 이걸 써야겠다"라는 감각이 생기더라구요.
특히 NestJS에서는 interface와 class가 "공존하지 않는" 것이 아니라, 각자가 다른 시점에서 다른 역할을 수행하고 있다는 것을 이해하는 게 중요합니다. interface는 컴파일 타임에 타입 안정성을 제공하고, class는 런타임에 실제 동작을 구현하는 거죠.
제 경험상 가장 효과적인 방법은:
- 기본적으로 interface로 계약을 정의하고
- 복잡한 타입이나 변환이 필요할 때는 type을 활용하고
- 실제 구현이 필요한 곳에서는 class를 사용하는 것이었습니다.
여러분은 어떤 경험이 있으신가요? Type과 Interface, Class를 선택할 때 어떤 기준을 가지고 계신지 궁금합니다. 댓글로 공유해주세요! 🙌
'개발 > NestJS' 카테고리의 다른 글
JavaScript/TypeScript의 for...of와 for await...of 완벽 가이드 (0) | 2024.11.27 |
---|---|
[NestJS] Queue 완벽 가이드: 기초 개념부터 실전 활용까지 (0) | 2024.11.25 |
NestJS JWT 인증 시 발생하는 'secretOrPrivateKey must have value' 에러 완벽 해결하기 🔐 (1) | 2024.11.21 |
TypeORM에서 insert() 사용 시 @BeforeInsert가 동작하지 않는 문제 해결하기 🤔 (0) | 2024.11.20 |
NestJS 설정 관리의 진화: nestjs-library-config 도입기 (4) | 2024.11.18 |
댓글