MikroORM FK 설정 이슈 디깅하기

MikroORM에서 bigint 타입 컬럼 간의 타입 불일치로 인해 발생하는 FK 설정 이슈와 그에 따른 부작용을 분석하고 해결 방안을 모색합니다.

Contents


배경

인프런에서 매달 유저들을 대상으로 정기챌린지를 진행하고 있습니다. 정기챌린지에는 몇 번의 라이브세션이 함께 진행됩니다. 라이브세션은 챌린지 참여에 필요한 정보들도 제공하고, 유저들이 같은 시간에 챌린지에 참여할 수 있는 기능입니다. 특히 라이브세션에는 유저들이 인프런에서 사용할 수 있는 랜덤포인트를 뿌리는 이벤트가 자주 진행됩니다.


현상

여느 때처럼 랜덤포인트가 지급되고 있는 상황에서, 일부 유저들에게 랜덤 포인트 지급 페이지에서 에러가 발생하는 이슈가 있었습니다.

random-point-page.png

확인해보니, DB에서는 다음과 같은 에러가 발생하고 있었습니다. 랜덤 포인트 발급 API에서 다음과 같은 쿼리를 보내고 있었습니다.

-- canceling statement due to statement timeout 
update "random_point_course" 
set "course_id" = 340457, "updated_at" = '2026-02-02T11:27:52.739Z' 
where "id" = '3490'


근데 이것은 이해할 수 없는 현상이었습니다. 왜냐하면 랜덤 포인트 발급 API에서는 해당 테이블에 UPDATE 쿼리를 사용하고 있지 않았기 때문입니다. 랜덤 포인트 발급 API를 간단한 수도코드로 나타내면, 다음과 같은 비즈니스 로직으로 이루어져 있습니다.

[1] 해당 포인트 타입이, 사전 수강 강의가 있는지 확인한다.
  - 만약 사전 수강 강의를 수강하고 있지 않으면, 에러를 발생시킨다.
[2] 랜덤 포인트 발급 (자세한 로직은 생략)

문제가 발생했던 Postgresql 테이블은 [1]에서 "사전 수강 강의" 정보를 관리하는 테이블입니다. 해당 로직에서는 사전 수강 강의 정보를 조회하는 데에만 사용되고, 실제로 해당 테이블의 내용을 수정하는 로직은 존재하지 않습니다.

그러면 왜 UPDATE 쿼리가 나타난 것일까요 ?


원인 분석

결론적으로 말씀드리면, ManyToOne 관계에서, 테이블 간의 컬럼 타입이 일치하지 않아서 발생하던 이슈였습니다. 예를 들어, 다음과 같이 테이블의 컬럼이 구성되어 있는 상황을 가정해볼 수 있습니다.

create table parent (
  id serial primary key,
  ...
);

create table child (
  id bigserial primary key,
  parent_id bigint,
  ...
)

둘 다 같은 숫자형 타입이지만 parent 테이블은 int 타입으로, child 테이블은 bigint 타입으로 테이블 DDL이 작성되어 있습니다. 물론 이러한 DDL 설정 자체는 지양되어야 합니다. 그렇다면 이런 타입 불일치가 있을 때, 왜 의도하지 않은 UPDATE가 발생하는 걸까요?


node-postgres의 bigint 처리

프로젝트에서는 MikroORM 라이브러리를 사용하고 있습니다. MikroORM과 Postgresql을 사용하려면 @mikro-orm/postgresql 라이브러리와 함께 사용해야 하고, 해당 라이브러리는 node-postgres를 의존하고 있습니다.

node-postgres 라이브러리에서는 bigint 타입의 컬럼 데이터를 문자열로 매핑합니다. (관련 코드)

...
const pgTypes = require('pg-types')
// save default parsers
const parseBigInteger = pgTypes.getTypeParser(20, 'text')
const parseBigIntegerArray = pgTypes.getTypeParser(1016, 'text')


MikroORM의 변경감지와 strict equality

MikroORM에서는 JPA와 유사하게, 로드된 엔티티가 변경되었는지를 감지한 후 DB에 반영해 주는 기능을 가지고 있습니다. 이러한 작업을 하기 위해서는 엔티티의 변경감지 기능이 중요한데, EntityComparator에 주요한 기능들이 구현되어 있습니다.

EntityComparator는 MikroORM의 ORM 내부에서 엔티티 데이터를 비교, 변환, 처리하기 위한 핵심 유틸리티 클래스입니다. 성능 최적화를 위해 동적으로 컴파일된 함수를 생성하여 사용합니다.

먼저 엔티티를 로드할 때에 수행되는 함수(getResultMapper)를 살펴봅시다. 이 함수는 ResultMapper 함수를 동적으로 생성하는데, boolean, Date, Embedded Class 등에 대한 처리가 있지만, bigint나 integer 타입에 대한 처리는 없습니다. 즉, DB Driver에서 반환해 주는 타입을 그대로 사용하고 있는 것을 알 수 있습니다. 정리하면, bigint 타입의 컬럼은 엔티티에 로드되었을 때에 문자열 타입으로 매핑됩니다.

스냅샷 생성

변경감지를 하기 위해서, MikroORM은 엔티티의 상태를 스냅샷으로 관리합니다. ManyToOne PK의 경우, 다음과 같은 코드가 스냅샷 생성 로직으로 사용됩니다.

class EntityComparator {

  /**
   * @internal Highly performance-sensitive method.
   */
  getSnapshotGenerator<T>(entityName: EntityName<T>): SnapshotGenerator<T> {
    ...

    // copy all comparable props, ignore collections and references, process custom types
    meta.comparableProps
      .filter(prop => {
        const root = getRootProperty(prop);
        return prop === root || root.kind !== ReferenceKind.EMBEDDED;
      })
      .forEach(prop => lines.push(this.getPropertySnapshot(meta, prop, context, this.wrap(prop.name), this.wrap(prop.name), [prop.name])));

    ...
  }

  private getPropertySnapshot<T>(...): string {

  ...

    this.setToArrayHelper(context);
    context.set('EntityIdentifier', EntityIdentifier);
    ret += `    if (entity${entityKey} === null) {\n`;
    ret += `      ret${dataKey} = null;\n`;
    ret += `    } else if (entity${entityKey}?.__helper.__identifier && !entity${entityKey}.__helper.hasPrimaryKey()) {\n`;
    ret += `      ret${dataKey} = entity${entityKey}?.__helper.__identifier;\n`;
    ret += `    } else if (typeof entity${entityKey} !== 'undefined') {\n`;
    ret += `      ret${dataKey} = toArray(entity${entityKey}.__helper.getPrimaryKey(true));\n`;
    ret += `    }\n`;

  ...

    return ret + '  }\n';

  }
}


  // 생성되는 코드 예시
  if (entity.course === null) {
    ret.course = null;
  } else if (entity.course?.__helper.__identifier && !entity.course.__helper.hasPrimaryKey()) {
    /** 주로 아래의 코드가 실행된다. */
    ret.course = entity.course?.__helper.__identifier;
  } else if (typeof entity.course !== 'undefined') {
    ret.course = toArray(entity.course.__helper.getPrimaryKey(true));
  }


위에서 예시로 들었던 Child Entity에서는 parent_id 가 string 타입으로 로드되지만, Identity Map에는 parent의 Identifier 타입인 number 타입으로 저장됨을 알 수 있습니다.

변경감지 비교

다음으로 실제로 엔티티가 변경되었음을 비교하는 코드를 살펴보아야 합니다. 변경감지는 persist, commit 시에 수행되므로 UnitOfWork의 commit 함수를 살펴보면 됩니다.

UnitOfWork 클래스를 살펴보면, 엔티티 간의 변경감지는 EntityComparator의 diffEntities 함수를 사용함을 알 수 있습니다. 해당 함수를 살펴봅시다.


  /**
   * Computes difference between two entities.
   */
  diffEntities<T extends object>(entityName: EntityName<T>, a: EntityData<T>, b: EntityData<T>, options?: { includeInverseSides?: boolean }): EntityData<T> {
    const comparator = this.getEntityComparator(entityName);
    return Utils.callCompiledFunction(comparator, a as T, b as T, options);
  }

  /**
   * @internal Highly performance-sensitive method.
   */
  getEntityComparator<T extends object>(entityName: EntityName<T>): Comparator<T> {
    const meta = this.metadata.find<T>(entityName)!;
    const exists = this.comparators.get(meta);

    if (exists) {
      return exists;
    }

    const lines: string[] = [];

    ...

    for (const prop of meta.comparableProps) {
      lines.push(this.getPropertyComparator(prop, context));
    }

    ...

    lines.push(`}`);

    ...

    const comparator = Utils.createFunction(context, code, this.config?.get('compiledFunctions'), fnKey);

    return comparator;
  }


  private getPropertyComparator<T>(prop: EntityProperty<T>, context: Map<string, any>): string {
    let type = prop.type.toLowerCase();

    ...

    /** 중요 */
    if (['string', 'number', 'bigint'].includes(type)) {
      return this.getGenericComparator(this.wrap(prop.name), `last${this.wrap(prop.name)} !== current${this.wrap(prop.name)}`);
    }

    ...
  }


코드를 살펴보면 스냅샷의 값과 현재 값의 strict equality (!==) 로 비교하고 있음을 알 수 있습니다. 즉 Parent Entity 와 Child Entity의 컬럼이 다른 타입으로 선언되어 있는 경우, 항상 해당 컬럼이 변경되었다고 감지됨을 알 수 있습니다.

정리

지금까지의 분석을 정리하면, phantom UPDATE가 발생하는 흐름은 다음과 같습니다.

  1. node-postgres: bigint 컬럼 값을 string으로 반환
  2. MikroORM ResultMapper: DB Driver의 반환 타입을 변환 없이 그대로 사용
  3. 스냅샷 생성: Identity Map의 PK를 number 타입으로 저장
  4. 변경감지: string !== number → strict equality로 인해 항상 "변경됨"으로 판정


추가 부작용

지금까지 parent 테이블과 child 테이블에서 컬럼 타입을 잘못 설정했을 때, 두 테이블에서 잘못된 변경감지가 발생할 수 있음을 살펴보았습니다. 그런데 이슈를 디깅하던 중, 잘못된 컬럼 타입 설정이 전혀 다른 테이블에도 부작용을 일으킬 수 있다는 사실을 알게 되었습니다. 위의 예제에서 확장하여, 다음의 세 테이블이 있다고 가정해 봅시다.


create table parent (
  id serial primary key,
  ...
);

create table child (
  id bigserial primary key,
  parent_id bigint,
  ...
)

create table sibling (
  id bigserial primary key,
  parent_id integer,
  ...
)


위의 예제에 sibling이라는 테이블이 추가되었습니다. sibling 테이블은 child 테이블과 다르게, 부모와 동일한 타입(integer)으로 데이터가 설정되었음을 알 수 있습니다. 그런데 실험을 해본 결과, sibling 테이블에도 잘못된 쿼리가 실행될 수 있음을 발견하였습니다.

select ... from "child" where "id" = 3
update "sibling" set "parent_id" = '10' where "id" = 5 [took 26 ms, 1 row affected] 

실행되는 UPDATE 쿼리를 보니, parent_id 를 '10'(bigint) 형식으로 수정하려고 함을 보니, child 테이블의 잘못된 데이터 타입 설정과 연관되어 있음을 추정할 수 있습니다. 왜 이런 현상이 발생하는 것일까요 ?


Identity Map 오염 메커니즘

결론적으로 원인을 분석해 보면, child 테이블의 잘못된 데이터타입 설정이 Identity Map를 오염시켰기 때문입니다. 다음의 코드를 살펴봅시다.


const child = await em.find(Child, {
  id: ...
})
const sibling = await em.find(Sibling, {
  id: ...
});

em.flush();


위의 코드는 독립적인 두 Entity 인스턴스를 로드하는 코드이지만, Identity Map에서는 복합적인 현상이 발생합니다.

첫 번째 코드를 실행하면, child 인스턴스는 Identity Map(영속성 컨택스트)에 등록됩니다. 그런데 여기에서 흥미로운 점은, child와 연결된 parent 엔티티 인스턴스도 영속성 컨텍스트에 등록된다는 점입니다. MikroORM에서는 실제로 로드되지 않은 Reference 상태여도 영속성 컨텍스트에 등록됩니다.

그런데 문제는, child.parent_id의 타입대로 Identity Map에 등록됩니다. 위에서 살펴봤듯이, node-postgres DB Driver는 bigint 타입을 문자열 타입으로 매핑하기 때문입니다.

[1] Child 로드
    └─ parent (bigint) → "1" (bigint -> string)
    └─ Parent 스텁 {id: "1"} → Identity Map 등록

두 번째 코드를 실행하면, sibling 인스턴스도 Identity Map에 등록됩니다. 그런데 첫 번째 쿼리에서 이미 parent를 로드해 놓았으므로, sibling 인스턴스의 parent는 기존에 Identity Map에 등록되어 있는 레퍼런스를 그대로 사용합니다. 공교롭게도, MikroORM의 EntityComparator.getPkSerializer에 따르면, Entity의 PK는 동일한 숫자를 나타낸다면 문자열과 숫자 타입을 동일하게 인식합니다. (관련 코드)

현재까지 발생한 일을 정리해 보면 다음과 같습니다.

[1] Child 로드
    └─ parent (bigint) → "1" (bigint -> string)
    └─ Parent 스텁 {id: "1"} → Identity Map 등록

[2] Sibling 로드
└─ parent_id (integer) → 1 (number) → 스냅샷 저장
└─ Identity Map에서 Parent 찾음 → {id: "1"} 스텁 참조


마지막 라인의 코드를 실행할 때, 어떤 부분이 문제가 되는지를 예상해 보실 수 있으실 것입니다. em.flush()가 실행될 때, UnitOfWork는 EntityComparator를 사용하여 변경이 발생한 Entity 인스턴스들을 조사합니다. 이 때, sibling의 스냅샷에는 sibling.parent_id가 1로 저장되어 있지만, Identity Map에는 parent.id가 "1"로 저장되어 있습니다. 스냅샷과 Identity Map의 필드 값이 일치하지 않으므로, Entity Manager는 Sibling 테이블에 UPDATE 쿼리를 실행합니다.

지금까지 관찰한 현상을 다시 정리해 보면, 다음과 같습니다.

[1] Child 로드
    └─ parent (bigint) → "1" (bigint -> string)
    └─ Parent 스텁 {id: "1"} → Identity Map 등록

[2] Sibling 로드
└─ parent_id (integer) → 1 (number) → 스냅샷 저장
└─ Identity Map에서 Parent 찾음 → {id: "1"} 스텁 참조

[3] transactional → flush
    └─ Sibling 변경 감지: 스냅샷 1 (number) vs 현재 "1" (string)
    └─ 불일치 → UPDATE SQL 실행



Hibernate에서는 이런 일이 발생할까 ?

결론적으로는, Hibernate에서는 위와 같은 의도하지 않은 동작이 발생하지 않을 것입니다. 아래와 같은 이유로, MikroORM과 Hibernate는 동작의 차이가 있습니다.


Postgresql Driver 동작의 차이점

위의 문제의 근본적인 원인은 node-postgres DB Driver가 bigint 타입의 컬럼을 문자열로 매핑하는 부분에서 시작되었습니다. Postgresql JDBC Driver에서는 bigint 타입을 long으로, integerint로 변환됩니다. 더하여 Hibernate에서는 DB의 컬럼 타입이 다르더라도, Entity 타입에 표기된 타입대로 매핑이 이루어집니다.

@Entity
public class Parent {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;  // serial → Integer
}

@Entity
public class Child {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;     // bigserial → Long

    @ManyToOne
    @JoinColumn(name = "parent_id")  // DB에서는 bigint지만...
    private Parent parent;
}

Child.parent의 FK 타입은 DB 컬럼 타입(bigint)이 아니라 Parent 엔티티의 @Id 타입(Integer)을 따릅니다. Hibernate는 FK 값을 읽을 때 resultSet.getInt()를 호출하여 항상 Integer로 변환합니다. 따라서 DB의 컬럼 타입이 bigint이든 integer이든, Java 레벨에서는 동일한 Integer 타입으로 처리됩니다. 동일한 타입으로 변환하는 동작이 ORM 단에서 일어나므로, FK 참조 시 타입 불일치가 발생할 수 없습니다.


Identity Map 과 1차 캐시 동작의 차이

위에서 살펴보았듯이, MikroORM의 Identity Map 은 인스턴스의 키값으로 문자열 PK("339664")를 사용합니다. 다른 타입의 컬럼 데이터가 동일한 영속성 컨택스트에 저장되므로, 의도하지 않은 영속성 컨택스트 오염이 발생합니다.

Hibernate의 1차 캐시는 EntityKey로 엔티티를 관리합니다.

// Hibernate EntityKey (단순화)
public class EntityKey {
    private final Object identifier;     // 항상 엔티티 @Id의 Java 타입
    private final EntityPersister persister;
}

문제가 되었던 코드를 Hibernate에서 실행한다면, 다음과 같이 동작할 것입니다.

Child child = em.find(Child.class, 1L);
Sibling sibling = em.find(Sibling.class, 2L);
em.flush();
[1] Child 로드
    └─ parent_id (bigint) → JDBC가 long으로 반환 → Hibernate가 Integer로 변환
    └─ Parent 프록시 {id: Integer(1)} → 1차 캐시 등록

[2] Sibling 로드
    └─ parent_id (integer) → JDBC가 int로 반환 → Hibernate가 Integer로 변환
    └─ 1차 캐시에서 Parent 찾음 → {id: Integer(1)} 프록시 참조

[3] flush
    └─ Sibling 스냅샷: Integer(1) vs 현재: Integer(1)
    └─ 동일 → UPDATE 없음

즉, Hibernate에서는 엔티티 속성에 맞는 값으로 변환해주는 로직이 있기 때문에, 의도하지 않은 변경감지 기능이 발생하지 않습니다. 또한 설사 다른 타입의 값으로 엔티티에 로드되더라도, 1차 캐시에 저장되는 Key가 동일하지 않으므로, 변경 감지가 되지 않습니다.


마무리

이번 이슈는 DDL의 컬럼 타입 불일치라는 단순한 원인에서 시작하여, DB Driver → ORM 내부 → Identity Map까지 여러 레이어를 관통하는 복합적인 문제로 확대되었습니다. 이번 이슈에서 얻은 교훈을 정리해 보면 다음과 같습니다.


이것도 읽어보세요