[TYPESCRIPT] TypeORM + TypeScript 사용하기 — 엔티티부터 실무 설계까지

TypeORM은 TypeScript 친화적인 ORM(Object Relational Mapping)으로, 엔티티를 중심으로 데이터베이스 모델과 애플리케이션 코드를 자연스럽게 연결해 줍니다. 특히 NestJS와 함께 사용되는 경우가 많아, TypeScript 타입 설계 → DB 스키마 → 비즈니스 로직이 하나의 흐름으로 이어지는 것이 큰 장점입니다.

 

TypeORM이 TypeScript와 잘 맞는 이유

TypeORM은 다음 요소들을 모두 TypeScript 기반으로 제공합니다.

  • 엔티티(Entity)를 class로 정의
  • 데코레이터 기반 매핑
  • Repository / QueryBuilder 타입 지원
  • 엔티티 속성에 직접 타입 적용

즉, 엔티티 클래스 자체가 타입 + 스키마 + 도메인 모델의 역할을 동시에 수행합니다.

 

기본 Entity 정의하기

TypeORM의 핵심은 Entity입니다. 엔티티는 DB 테이블과 1:1로 매핑됩니다.

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
} from "typeorm";

@Entity("users")
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 100 })
  email: string;

  @Column({ length: 50 })
  name: string;

  @Column({ default: true })
  isActive: boolean;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

이 클래스 하나로 다음이 모두 결정됩니다.

  • 테이블 이름 (users)
  • 컬럼 타입과 제약조건
  • TypeScript 타입

 

Entity 필드 타입 설계 시 주의점

엔티티 필드는 단순히 DB 타입만 고려하면 안 됩니다.

  • TypeScript 타입과 DB 타입의 일치 여부
  • null 가능성
  • 도메인 규칙 반영 여부
@Column({ nullable: true })
nickname?: string;

nullable 컬럼은 TypeScript에서도 반드시 optional(?) 또는 null을 고려해야 합니다.

 

Repository 패턴과 타입 안정성

TypeORM은 기본적으로 Repository 패턴을 제공합니다.

import { Repository } from "typeorm";
import { User } from "./user.entity";

export class UserService {
  constructor(
    private readonly userRepository: Repository<User>
  ) {}

  async findByEmail(email: string): Promise<User | null> {
    return this.userRepository.findOne({
      where: { email },
    });
  }
}

Repository에 제네릭으로 Entity를 지정하면:

  • 조회 결과 타입이 자동 추론
  • 잘못된 필드 접근을 컴파일 단계에서 차단

 

find / findOne 사용 시 null 처리

TypeORM의 조회 메서드는 결과가 없을 수 있습니다.

const user = await repo.findOne({ where: { id } });

이때 반환 타입은:

User | null

따라서 실무에서는 다음과 같은 패턴이 일반적입니다.

if (!user) {
  throw new Error("User not found");
}

null 가능성을 무시하면, 런타임 오류로 이어집니다.

 

관계(Relation) 매핑과 타입

TypeORM은 관계 매핑도 TypeScript 타입으로 표현합니다.

@Entity("posts")
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @ManyToOne(() => User, (user) => user.posts)
  user: User;
}
@Entity("users")
export class User {
  // ...

  @OneToMany(() => Post, (post) => post.user)
  posts: Post[];
}

관계 필드는 다음을 반드시 고려해야 합니다.

  • 지연 로딩 여부
  • optional 관계 가능성
  • 조회 시 join 여부

 

Entity ≠ API 응답 (중요)

실무에서 가장 흔한 실수 중 하나는 Entity를 그대로 API 응답으로 사용하는 것입니다.

  • 민감 정보 노출 위험
  • 관계 필드 무한 참조
  • API 계약 불안정

따라서 반드시 별도의 View/DTO 타입으로 변환합니다.

type UserView = {
  id: number;
  email: string;
  name: string;
};

function toUserView(user: User): UserView {
  return {
    id: user.id,
    email: user.email,
    name: user.name,
  };
}

 

QueryBuilder와 타입 활용

복잡한 쿼리가 필요할 경우 QueryBuilder를 사용합니다.

const users = await repo
  .createQueryBuilder("user")
  .where("user.isActive = :active", { active: true })
  .getMany();

QueryBuilder 역시 결과 타입은 Entity 기준으로 추론됩니다.

단, raw query(getRawMany)를 사용할 경우 반환 타입이 느슨해지므로 별도 타입 정의가 필요합니다.

 

실무에서 자주 겪는 문제와 대응

  • 엔티티에 비즈니스 로직 과도하게 포함
  • nullable 컬럼과 TypeScript 타입 불일치
  • Entity를 DTO처럼 사용
  • Relation 로딩 전략 미설계

대부분 “엔티티의 책임 범위”를 명확히 하지 않아서 발생합니다.

 

실무 권장 설계 요약

  • Entity는 DB 매핑 + 최소한의 도메인 상태만 표현
  • API 응답은 반드시 별도 타입(View/DTO) 사용
  • Repository는 Entity 단위로만 접근
  • null 가능성은 타입으로 명확히 표현
  • 관계 매핑은 필요할 때만 로딩

 


 

TypeORM + TypeScript 조합의 핵심은 “엔티티를 타입과 설계의 중심에 두되, 경계를 명확히 나누는 것”입니다.

  • Entity는 DB 관점
  • Service는 도메인/유스케이스 관점
  • API 응답은 계약(View/DTO) 관점