TypeORM에서 bigint 사용 시 주의해야 할 점(Number와 BigInt)
들어가며
TypeORM에서 Entity를 정의할 때 Column 타입을 지정할 수 있으며, 정수형의 경우 값의 범위에 따라 tinyint, smallint, mediumint, int, bigint 등을 선택할 수 있다. 그런데 bigint를 사용하는 경우, TypeScript에서 해당 property 타입을 number로 선언했음에도 불구하고 실제 조회된 값이 string으로 반환되는 상황을 겪게 된다. 이로 인해 === 비교가 의도와 다르게 동작하거나, 타입 관련 버그가 발생할 수 있다. 이번 글에서는 TypeORM에서 bigint를 사용할 때 발생하는 문제의 원인과, JavaScript의 숫자 표현 방식까지 함께 정리해보려고 한다.
1. 개요
1.1. MySQL 정수 표현 속성
MySQL에서 정수를 표현하는 타입은 다음과 같이 5가지가 있다.
- tinyint (1 byte)
- signed : -128 ~ 127
- unsigned : 0 ~ 255
- smallint (2 bytes)
- signed : –32,768 ~ 32,767
- unsigned : 0 ~ 65,535
- mediumint (3 bytes)
- signed : -8,388,608 ~ 8,388,607
- unsigned : 0 ~ 16,777,215
- int (4 bytes)
- signed : –2,147,483,648 ~ 2,147,483,647
- unsigned : 0 ~ 4,294,967,295
- bigint (8 bytes)
- signed : –9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807
- unsigned : 0 ~ 18,446,744,073,709,551,615
각 타입은 signed/unsigned 여부에 따라 표현 가능한 범위가 달라진다. 특히 bigint는 8바이트를 사용하며, 매우 큰 범위를 표현할 수 있다.
1.2. int vs bigint
DB를 설계하다 보면 가장 흔하게 고민하게 되는 부분 중 하나가 바로 ID 컬럼의 타입 선택이다. 특히 서비스 초기에 테이블을 설계할 때 INT로 할지, BIGINT로 할지를 두고 한 번쯤은 고민하게 된다. MySQL 기준으로 보면 INT UNSIGNED의 최대값은 약 42억 정도이고, BIGINT UNSIGNED는 그보다 훨씬 큰 약 184경까지 표현할 수 있다. 이 숫자만 놓고 보면 자연스럽게 '그냥 BIGINT 쓰는 게 안전하지 않을까?'라는 생각이 들 수 있다. 실제로도 초기 설계 단계에서 이런 이유로 별 고민 없이 BIGINT를 선택하는 경우가 많다.
하지만 단순히 표현 범위만 보고 BIGINT를 선택하는 것은 항상 좋은 판단이라고 보기는 어렵다. 데이터 타입이 커진다는 것은 단순히 더 큰 값을 담을 수 있다는 의미뿐만 아니라, 그만큼 더 많은 저장 공간을 사용한다는 의미이기도 하다. 특히 MySQL의 InnoDB 엔진에서는 기본 키(Primary Key)가 단순히 하나의 컬럼으로 끝나는 것이 아니라, 모든 보조 인덱스(Secondary Index)에 함께 포함되는 구조를 가지고 있다.
즉, PK가 BIGINT라면 모든 인덱스에도 그 BIGINT 값이 그대로 포함되게 된다. 이로 인해 인덱스의 크기가 커지고, 결국 메모리 사용량이나 디스크 I/O에도 영향을 줄 수 있다. 데이터가 많아질수록 이 차이는 점점 더 커지게 된다. 단순히 컬럼 하나의 크기 차이로 끝나는 문제가 아니라, 전체 테이블 구조에 영향을 주는 요소가 되는 것이다. 그렇다고 해서 무조건 INT가 더 좋은 선택이라고 말할 수도 없다. 실제 성능은 단순히 타입 하나로 결정되지 않는다. 데이터의 분포, 인덱스 설계, 쿼리 패턴, 조인 방식 등 다양한 요소가 복합적으로 작용하기 때문에 INT와 BIGINT의 차이가 체감되지 않는 경우도 많다. 특히 초기 단계의 서비스나 데이터 규모가 크지 않은 환경에서는 두 타입 간의 성능 차이를 거의 느끼지 못하는 경우도 많다.
결국 이 문제는 '지금 당장 어떤 값이 들어가는가?'를 기준으로 판단하기 보다는 '이 데이터가 앞으로 어디까지 증가할 수 있는가?'를 기준으로 판단해야 한다. 예를 들어 사용자 수가 수천만 단위로 증가할 가능성이 있는 서비스라면 INT 범위를 초과할 수도 있기 때문에 BIGINT를 고려하는 것이 맞다. 반대로 데이터의 최대 크기가 명확하게 제한되어 있는 경우라면, 굳이 BIGINT를 사용할 이유는 없다. 이처럼 ID 타입 선택은 단순한 기술적인 선택이 아니라, 서비스의 성장 방향과 데이터 증가 패턴을 함께 고려해야 하는 설계 요소라고 볼 수 있다.
1.3. 개인적 견해
이 부분은 정답이 있는 영역이라기보다는 상황에 따라 판단이 달라지는 영역이라고 생각한다. 그래서 명확한 기준을 세우기보다는 실제로 설계를 하면서 어떤 관점으로 고민했는지를 기준으로 접근하는 편이 더 현실적이었다.
처음에는 단순하게 'BIGINT가 더 크니까 안전하다'라는 생각으로 접근했던 적이 있었다. 특히 서비스가 장기적으로 운영될 것이라고 가정하면, 나중에 값이 넘치는 상황을 미리 방지하는 것이 더 중요하다고 느껴졌기 때문이다. 실제로 다른 시스템에서 정수 범위를 초과하면서 문제가 발생했다는 사례들을 접하다 보니 처음부터 여유 있게 설계하는 것이 맞지 않을까라는 생각이 들었다.
하지만 조금 더 고민해보니 모든 경우에 BIGINT를 사용하는 것도 마냥 좋은 선택은 아니라는 생각이 들었다. 데이터 타입 하나가 단순히 값의 범위만 결정하는 것이 아니라 인덱스 크기나 전체 데이터 구조에도 영향을 주기 때문이다. 특히 InnoDB에서 PK가 여러 인덱스에 포함된다는 점을 고려하면 불필요하게 큰 타입을 사용하는 것이 오히려 비용을 증가시킬 수도 있겠다는 생각이 들었다.
그래서 개인적으로는 '무조건 크게 잡자'보다는 이 데이터가 실제로 어느 정도까지 증가할지를 먼저 생각해보는 쪽으로 접근하게 되었다. 예를 들어 사용자 수처럼 계속 누적되고, 서비스의 성장에 따라 크게 증가할 가능성이 있는 데이터라면 BIGINT를 선택하는 것이 더 안전하다고 느꼈다. 반대로 특정 도메인에서 값의 범위가 어느 정도 제한되어 있거나, 증가 속도가 크지 않은 데이터라면 INT로도 충분하다고 판단했다.
결국 중요한 것은 지금 시점의 데이터 개수가 아니라, 이 값이 앞으로 어디까지 커질 수 있는지를 얼마나 현실적으로 예측할 수 있느냐라고 생각한다. 완벽하게 예측할 수는 없겠지만, 서비스의 성격과 데이터 흐름을 기준으로 어느 정도의 방향성은 잡을 수 있고, 그에 맞게 타입을 선택하는 것이 더 합리적인 접근이라고 느꼈다.
2. 실습
본 글에서는 아래와 같은 조건으로 반드시 bigint를 사용한다는 가정 하에 실습을 진행하겠다.
- 서비스를 장기간 운영할 것이다.
- 서비스 운영 기간이 늘어남에 따라 데이터의 양 또한 증가할 것으로 예상한다.
- 값의 범위가 43억을 넘는 값을 저장해야 한다.
2.1. bigint인 colume 값 출력 결과
먼저, 아래와 같은 Entity를 간단히 작성해보자.
@Entity({ name: User.name })
export class User extends Relations {
@PrimaryGeneratedColumn({
type: 'bigint',
unsigned: true,
})
readonly id: number;
@Column({
type: 'varchar',
length: 30,
})
nickname: string;
@CreateDateColumn()
readonly createdAt: Date;
@UpdateDateColumn()
readonly updatedAt: Date;
@DeleteDateColumn()
readonly deletedAt: Date | null;
}그리고 아래와 같은 코드를 작성하여 synchronize의 힘을 빌려 테이블을 만들고, User 테이블에 데이터를 입력한 후 출력해보자.
const main = async () => {
const dataSource = new DataSource({
type: 'mysql',
host: 'localhost',
port: 3306,
userame: 'root',
password: 'root_password',
database: 'test',
synchronize: true,
autoLoadEntities: true,
entities: [User],
});
await dataSource.initialize();
const userRepository = dataSource.getRepository(User);
const user = userRepository.create({ nickname: 'choewy' });
await userRepository.save(user);
console.log(user.id);
};
main();그 결과 user의 id는 1이 아닌 '1'로 출력되는 것을 알 수 있다. 이와 같은 이유는 Javascript의 Number 타입으로는 bigint의 모든 값을 제대로 나타낼 수 없기 때문이다. 실제로 아래와 같이 bigint unsigned 범위의 최대값을 출력하는 코드를 실행해보면 다음과 같은 결과를 확인할 수 있다.
>> console.log(18446744073709551615);
>> 18446744073709552000MDN 문서에 따르면 Number로 표현 가능한 값의 범위는 다음과 같다고 기재되어 있다.
// (2**53 - 1) => 9007199254740991
const biggestInt = Number.MAX_SAFE_INTEGER;
// -(2**53 - 1) => -9007199254740991
const smallestInt = Number.MIN_SAFE_INTEGER;2.2 Javascript의 BigInt
앞에서 살펴본 것처럼 JavaScript의 Number 타입은 매우 큰 정수를 정확하게 표현하는 데 한계가 있다. 이 문제를 해결하기 위해 ES2020에서 BigInt라는 새로운 타입이 추가되었다. BigInt는 이름 그대로 매우 큰 정수를 표현하기 위한 primitive 타입이다. 기존 Number와 달리, 정수의 크기에 제한 없이 정확한 값을 표현할 수 있다는 특징을 가진다. BigInt는 두 가지 방식으로 생성할 수 있다.
typeof 1n === 'bigint'; // true
typeof BigInt('1') === 'bigint'; // true가장 직관적인 방법은 숫자 뒤에 n을 붙이는 것이고, BigInt() 생성자를 통해 만들 수도 있다. 이때 생성된 값은 Number와는 완전히 다른 타입으로 취급된다.
1n === 1; // false
1n == 1; // true위 예시처럼 === 비교에서는 타입까지 비교하기 때문에 false가 나오고, == 비교에서는 타입 변환이 발생하여 true가 된다. 이 부분은 실제로 버그로 이어지기 쉬운 포인트이기 때문에 주의가 필요하다. BigInt를 사용할 때는 몇 가지 주의해야 할 점이 있다. 먼저, BigInt를 Number로 변환하는 것은 가능하지만, 이 경우 값의 정밀도가 손실될 수 있다. 특히 Number가 표현할 수 있는 안전한 범위(2^53 - 1)를 초과하는 값이라면 변환 과정에서 값이 깨질 수 있다. 또한 BigInt는 Number와 혼합하여 연산할 수 없다.
1n + 1; // TypeError
1n + 1n; // 2n산술 연산, 비트 연산 등 대부분의 연산자는 피연산자 모두가 BigInt일 때만 정상적으로 동작한다. 즉, Number와 BigInt를 함께 사용하는 순간 런타임 에러가 발생하게 된다. 이러한 특징 때문에 BigInt를 사용할 때는 애초에 이 값을 어떤 타입으로 관리할 것인지를 명확하게 정해두는 것이 중요하다. 중간에 Number와 BigInt를 섞어 사용하게 되면 예상치 못한 오류가 발생할 가능성이 높기 때문이다. 결국 BigInt는 매우 큰 정수를 정확하게 다루기 위한 강력한 도구이지만, 기존 Number와는 완전히 다른 규칙을 따르기 때문에 이를 충분히 이해한 상태에서 사용하는 것이 중요하다.
3. 해결 방법
앞에서 살펴본 것처럼, TypeORM에서 bigint를 사용할 경우 JavaScript의 한계 때문에 값이 string으로 반환되는 문제를 마주하게 된다. 정확히는 BigInt를 Number로 변환할 때 정밀도가 손실될 수 있다는 문제를 피하기 위해 MySQL 드라이버(mysql, mysql2)는 bigint를 기본적으로 string으로 반환한다. 따라서, TypeORM도 이 동작을 그대로 따르는 것이다. 이 문제는 단순히 타입 선언을 바꾸는 것으로 해결되는 것이 아니라, 애플리케이션 전반에서 해당 값을 어떻게 다룰 것인지에 대한 선택이 필요하다. 실무에서는 상황에 따라 몇 가지 방식 중 하나를 선택하게 되는데, 각각의 방식은 장단점이 분명하기 때문에 서비스의 특성에 맞게 결정하는 것이 중요하다.
4.1 string으로 처리
가장 단순하고 많이 사용되는 방식은, 아예 해당 값을 string으로 다루는 것이다.
readonly id: string;이 방식은 TypeORM이 반환하는 값을 그대로 사용하는 것이기 때문에 별도의 변환 과정이 필요 없다. 무엇보다도 값이 손실될 가능성이 없다는 점에서 가장 안전한 접근이다. 특히 ID와 같은 값은 실제로 숫자 연산을 수행하기보다는, 단순히 식별자로 사용되는 경우가 많다. 이 경우에는 굳이 숫자 타입으로 변환할 필요 없이 문자열 그대로 사용하는 것이 오히려 더 자연스러운 선택일 수 있다. 다만 단점도 존재한다. 문자열이기 때문에 숫자 기반 연산이 필요한 경우에는 별도의 변환 과정이 필요하고, 코드 상에서 타입이 직관적으로 느껴지지 않을 수도 있다. 예를 들어 'price 컬럼이 왜 string이지?'라는 의문이 생길 수 있다.
4.2 transformer 사용
두 번째 방법은 TypeORM의 transformer 기능을 활용하는 것이다. 이를 통해 DB에서는 string으로 받아오지만, 애플리케이션에서는 BigInt 타입으로 변환해서 사용할 수 있다.
@PrimaryGeneratedColumn({
type: 'bigint',
transformer: {
to: (value: bigint) => value,
from: (value: string) => BigInt(value),
},
})
id: bigint;이 방식의 장점은 JavaScript에서도 큰 정수를 정확하게 다룰 수 있다는 점이다. 특히 ID를 단순 식별자가 아니라 실제로 연산에 사용해야 하는 경우라면, BigInt를 사용하는 것이 더 적절할 수 있다. 하지만 이 방식은 주의해야 할 점이 많다. 앞에서 살펴본 것처럼 BigInt는 Number와 혼용할 수 없기 때문에, 연산 시 타입을 일관되게 유지해야 한다. 또한 일부 라이브러리나 JSON 직렬화 과정에서 BigInt를 제대로 처리하지 못하는 경우도 있기 때문에, 예상치 못한 문제가 발생할 가능성도 있다.
4.3 int 사용
세 번째 방법은 애초에 bigint를 사용하지 않는 것이다. 만약 데이터의 범위가 충분히 작고, INT의 최대 범위를 초과할 가능성이 낮다면, int 타입을 사용하는 것이 더 현실적인 선택이 될 수 있다. 이 경우에는 JavaScript의 Number 타입으로도 충분히 안전하게 값을 표현할 수 있기 때문에, 별도의 타입 변환이나 추가적인 처리 없이 자연스럽게 사용할 수 있다. 특히 대규모 서비스가 아닌 경우 대부분의 서비스에서는 실제로 ID가 수십억 단위를 넘지 않는 경우도 많기 때문에, 무조건 BIGINT를 사용하는 것보다 INT를 선택하는 것이 더 단순하고 안정적인 구조를 만들 수 있다. 결국 이 문제는 '어떤 방법이 정답이다'라기보다는 데이터의 성격과 사용 방식에 따라 선택이 달라지는 영역이라고 볼 수 있다.
- 단순 식별자라면 → string
- 큰 수 연산이 필요하다면 → BigInt
- 범위가 제한적이라면 → int
이처럼 상황에 맞게 선택하는 것이 가장 현실적인 접근이라고 생각한다.
마치며
TypeORM에서 bigint를 사용할 때 값이 string으로 반환되는 현상은 처음 접했을 때 꽤 낯설게 느껴졌다. 분명 TypeScript에서는 number로 선언했는데, 실제로는 문자열이 반환되다 보니 'ORM이 잘못된 것 아닌가?'라는 생각이 들었던 적도 있었다. 하지만 이 문제를 조금 더 깊게 들여다보니, 원인은 TypeORM이 아니라 JavaScript의 숫자 표현 방식에 있었다. JavaScript의 Number 타입이 가지는 한계 때문에, 매우 큰 정수를 그대로 다루기 어려운 구조였고, 이를 안전하게 처리하기 위해 드라이버와 ORM이 string 형태로 반환하고 있다는 점을 이해하게 되었다.
이 과정을 겪으면서 느낀 점은 단순히 ORM의 동작만 이해하는 것이 아니라 그 아래에 있는 언어의 특성까지 함께 이해해야 한다는 것이었다. 특히 데이터베이스와 애플리케이션 사이에서 타입이 어떻게 변환되는지를 정확하게 알고 있어야, 예상하지 못한 버그를 줄일 수 있다는 점도 다시 한 번 체감하게 되었다.
실무에서는 이 문제를 해결하기 위해 다양한 선택지를 고려하게 된다. string으로 그대로 사용하는 방식도 있고, BigInt로 변환해서 사용하는 방법도 있으며 아예 설계 단계에서 int로 제한하는 접근도 존재한다. 어떤 방식이 더 좋다고 단정 짓기보다는 해당 데이터가 실제로 어떻게 사용되는지를 기준으로 선택하는 것이 더 중요하다고 느꼈다. 결국 이 문제는 단순한 타입 선택의 문제가 아니라 데이터의 범위와 사용 방식, 그리고 시스템 전체에서의 일관성을 어떻게 유지할 것인지에 대한 설계 문제라고 생각한다. 특히 bigint를 사용하는 경우에는 JavaScript의 안전한 정수 범위(2^53 - 1)를 반드시 인지하고, 의도하지 않은 데이터 손실이나 비교 오류가 발생하지 않도록 미리 고려하는 것이 중요하다.
이 글을 통해 단순히 'bigint는 string으로 온다'는 사실을 아는 것을 넘어서 왜 그런 구조가 만들어졌는지까지 이해하는 데 도움이 되었으면 한다.