본문 바로가기
개발/NestJS

[NestJS] interface, type, class 도입기 (언제 뭘쓰지??)

by coking 2025. 1. 22.

들어가며

처음 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가 두 가지 시점에서 작동하기 때문입니다:

  1. 컴파일 타임: TypeScript가 타입을 체크하는 시점
    • IUserRepository의 메서드들이 제대로 구현되었는지 확인
    • 메서드의 파라미터와 반환 타입이 일치하는지 검사
  2. 런타임: 실제 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'>;

결론: 각자의 자리가 있다

지금까지의 경험을 통해 깨달은 것은 이렇습니다:

  1. Interface는 주로:
    • 객체의 구조를 정의할 때
    • 확장 가능성이 있는 API를 설계할 때
    • 명확한 계약(contract)이 필요할 때
    • Repository나 Service의 명세를 정의할 때
  2. Type은 주로:
    • 정확한 값의 집합을 정의할 때
    • 타입 변환이나 조합이 필요할 때
    • 튜플이나 유니온 타입이 필요할 때
    • 기존 타입을 변형해서 새로운 타입을 만들 때
  3. Class는:
    • 실제 구현체가 필요할 때
    • NestJS의 데코레이터를 사용해야 할 때
    • 상태와 행위를 함께 관리해야 할 때
    • DI 시스템에서 주입할 대상일 때

결국 중요한 것은 각 도구의 특성을 이해하고, 상황에 맞게 적절한 도구를 선택하는 것입니다. 처음에는 어려울 수 있지만, 경험이 쌓이면서 자연스럽게 "이럴 때는 이걸 써야겠다"라는 감각이 생기더라구요.

특히 NestJS에서는 interface와 class가 "공존하지 않는" 것이 아니라, 각자가 다른 시점에서 다른 역할을 수행하고 있다는 것을 이해하는 게 중요합니다. interface는 컴파일 타임에 타입 안정성을 제공하고, class는 런타임에 실제 동작을 구현하는 거죠.

제 경험상 가장 효과적인 방법은:

  • 기본적으로 interface로 계약을 정의하고
  • 복잡한 타입이나 변환이 필요할 때는 type을 활용하고
  • 실제 구현이 필요한 곳에서는 class를 사용하는 것이었습니다.

여러분은 어떤 경험이 있으신가요? Type과 Interface, Class를 선택할 때 어떤 기준을 가지고 계신지 궁금합니다. 댓글로 공유해주세요! 🙌

댓글