본문 바로가기
잡(job)기술

데이터 무결성을 지키는 마지막 방어선 - TypeORM Entity 설계의 5가지 핵심 포인트

by 무니이구나 2026. 2. 3.

“서버 코드에서 분명히 중복 체크를 했는데, 왜 DB에는 중복 데이터가 들어가 있을까?”

백엔드 개발자라면 한 번쯤 겪어봤을 문제다. 특히 여러 서버가 동시에 요청을 처리하는 분산 환경에서는 애플리케이션 레벨의 유효성 검사가 동시성 앞에서 쉽게 무너진다.

엔티티 설계는 단순히 데이터를 담는 구조를 만드는 일이 아니다. 시스템 전체의 마지막 안전장치를 만드는 작업이다. 여기서는 데이터 무결성을 지키기 위해 반드시 고려해야 할 TypeORM 엔티티 설계 포인트 5가지를 정리한다.


1. @Unique()는 규칙이 아니라 동시성을 막는 방패다

많은 코드에서 중복 방지를 이렇게 처리한다.

if (exists(email)) {
  throw new Error('이미 존재함');
}

 

이 방식은 단일 요청에서는 문제없다. 하지만 두 요청이 거의 동시에 들어오면 다음과 같은 상황이 발생할 수 있다.

중복 체크 실패 흐름 (레이스 컨디션)

 

애플리케이션 로직은 동시에 실행될 수 있고, 그 사이를 완벽히 막기는 어렵다. 이때 @Unique()는 DB 차원에서 중복을 거부하는 최종 방어선이 된다.

@Unique(['email'])

 

실무에서는 단일 컬럼보다 복합 유니크 제약이 더 중요하다.

@Unique(['userId', 'role'])

 

이 선언은 “한 사용자는 같은 역할을 한 번만 가질 수 있다”는 규칙을 DB에 각인한다. 애플리케이션 코드는 언제든 바뀔 수 있지만, DB 제약조건은 어떤 요청 순서에서도 데이터의 유일성을 지킨다.


2. PostgreSQL과 TypeORM의 강력한 궁합, 그리고 트레이드오프

TypeORM은 여러 데이터베이스를 지원하지만 PostgreSQL과 함께 사용할 때 가장 많은 기능을 자연스럽게 활용할 수 있다. 대표적인 예는 다음과 같다.

  • uuid 네이티브 타입
  • timestamptz 기반의 명확한 시간대 처리
  • jsonb, inet, 배열 타입 등

이 기능들은 성능과 표현력을 크게 높여준다. 하지만 동시에 트레이드오프가 있다.

장점과 단점 정리

구분 내용
장점 성능 우수, 표현력 높음, 쿼리 단순화
단점 PostgreSQL 의존도 증가
결과 다른 DB로 이전 시 비용 증가

 

PostgreSQL 전용 타입을 적극적으로 쓰는 순간, DB 교체 가능성은 급격히 낮아진다. 따라서 “이 서비스는 PostgreSQL에 장기적으로 고정될 것인가?”를 먼저 판단한 뒤 선택해야 한다.


3. 엔티티의 핵심은 기본 키(PK)다

TypeORM에서 모든 엔티티는 반드시 기본 키(PK)를 가져야 한다. PK가 없으면 TypeORM은 해당 클래스를 정상적인 엔티티로 다루지 못한다. 외부 API나 리소스 식별자로 ID가 노출될 가능성이 있다면, 숫자형 PK는 보안상 위험할 수 있다. 이 경우 UUID가 더 안전하다.

@PrimaryGeneratedColumn('uuid')
id: string;

 

UUID의 장점은 다음과 같다.

  • ID 추측이 매우 어렵다
  • 여러 서버에서 동시에 생성해도 충돌 가능성이 극히 낮다
  • 분산 환경에 적합하다

성능만 놓고 보면 숫자형 PK가 유리할 수 있지만, 보안과 확장성을 함께 고려하면 UUID는 충분히 합리적인 선택이다.


4. 관계 설계의 기본값은 OneToMany다

초보 개발자는 “지금은 1:1 관계”라는 이유로 OneToOne을 선택하는 경우가 많다. 하지만 데이터 모델은 시간이 지나면서 거의 항상 확장된다. 다음 질문을 던져야 한다.

“지금은 하나지만, 나중에 여러 개가 될 가능성은 없을까?”

 

조금이라도 가능성이 있다면 OneToMany / ManyToOne 조합이 더 안전하다.

중요한 사실 한 가지

외래 키(FK)는 항상 ManyToOne 쪽에 생성된다.

@ManyToOne(() => User)
@JoinColumn()
user: User;
  • @JoinColumn()이 있는 쪽이 관계의 주인(owner)
  • 설정을 잘못하면 의도하지 않은 조인 테이블이 생성될 수 있다

관계 설계는 “지금 편한 구조”가 아니라 “나중에 바꾸기 쉬운 구조”를 기준으로 해야 한다.


5. @Unique()와 @Index({ unique: true })는 목적이 다르다

@Unique()를 사용하면 DB는 내부적으로 UNIQUE INDEX를 생성한다. 그래서 조회 성능도 함께 좋아진다. 하지만 두 데코레이터의 의도는 다르다.

구분 목적
@Unique() 비즈니스 규칙 표현
@Index({ unique: true }) 조회 성능 최적화

 

상태값처럼 중복이 자연스러운 컬럼에 유니크 제약을 걸면 데이터 삽입 자체가 실패할 수 있다. 따라서 다음 기준이 중요하다.

  • 무결성이 핵심 → @Unique()
  • 성능이 핵심 → @Index()

보강 포인트 1: 트랜잭션 없이는 무결성이 완성되지 않는다

@Unique()가 있어도 여러 작업을 나눠 실행하면 문제가 생길 수 있다. 예를 들어, 여러 테이블을 순차적으로 저장하는 과정에서 일부만 실패하면 데이터가 어긋난다. 이때는 반드시 트랜잭션을 사용해야 한다.

await queryRunner.startTransaction();
try {
  await queryRunner.manager.save(entityA);
  await queryRunner.manager.save(entityB);
  await queryRunner.commitTransaction();
} catch {
  await queryRunner.rollbackTransaction();
}

 

DB 제약조건과 트랜잭션은 함께 사용할 때 진짜 힘을 발휘한다.


보강 포인트 2: 유니크 제약 위반 에러 처리 예제

PostgreSQL에서 유니크 제약을 위반하면 에러 코드 23505가 발생한다.

try {
  await repo.save(user);
} catch (e) {
  if (e.code === '23505') {
    throw new ConflictException('이미 존재하는 데이터입니다');
  }
}

 

이 처리는 확실한 동작이며, 서비스 레벨에서 사용자에게 명확한 메시지를 전달할 수 있다.


보강 포인트 3: 운영 환경에서는 마이그레이션이 필수다

synchronize: true는 개발 단계에서는 편리하다.하지만 운영 환경에서는 의도치 않은 스키마 변경 위험이 있다.

  • 개발 환경 → synchronize 가능
  • 운영 환경 → migration 기반 관리 권장

이 원칙은 TypeORM 공식 문서에서도 일반적으로 권장되는 방식이다.


마무리

엔티티 설계는 단순한 코드 작성이 아니다. DB에 찍는 되돌릴 수 없는 도장이다. 애플리케이션 로직은 고칠 수 있다. 하지만 무너진 데이터 무결성은 복구 비용이 매우 크다.

지금의 엔티티 설계는 단순한 데이터 구조인가, 아니면 동시 요청에도 무너지지 않는 성벽인가. 이 질문을 기준으로 엔티티를 다시 한 번 점검해보는 것이 좋다.