kakasoo

3. Repository find 메서드 완벽 정리 본문

프로그래밍/TypeORM

3. Repository find 메서드 완벽 정리

카카수(kakasoo) 2023. 1. 7. 15:54
반응형

TAKE으로 데이터를 n개만 조회하기

const [user] = await this.userRepository.find({
  take: 1,
});
const user = await this.userRepository.findOne();

Repository Pattern에서는 데이터 조회를 위해 find, findOne의 메서드를 지원한다.

find는 배열을, findOne은 조건이 일치하는 첫번째 행을 가져온다는 점에서 차이가 있다.

하지만 findOne은 조금 심각한(?) 문제가 있기 때문에 가급적 익숙하지 않다면 사용을 피하는 게 낫다.

따라서 find를 사용하되 take 프로퍼티 값으로 1을 준 후 구조분해할당으로 값을 받는 편이 더 안전하다.

findOne에는 어떤 문제가 있는가?

TypeORM의 Where 절은 유니크한 값을 찾는 게 아니라 조건에 일치하는 값을 찾기 위한 프로퍼티이다.
쿼리에서는 이게 당연히 같은 의미지만, 코드 레벨에서는 Where절의 프로퍼티로 undefined가 들어갈 수 있다.

undefined가 값으로 들어가면 그 프로퍼티는 NULL처럼 대상이 비어있는 경우를 찾는 게 아니라 무시된다.

따라서 그냥 어떤 조건도 사용되지 않은 채, 그냥 첫번째 row를 반환할 뿐인 메서드가 되고 만다.
코드 레벨에서 Where 절에 들어가는 조건들은 대개 클라이언트로부터 온다.
그리고 이 클라이언트의 값은 단순 문자열에서 추측되는 것들이기 때문에 때로 개발자들의 실수를 낳곤 하는데,

이렇게 되면 유저에게 그저 첫번째 가입한 유저의 데이터, 첫 번째 상품의 데이터 등 잘못된 데이터를 제공하게 된다.

SKIP으로 필요한 만큼 넘기기

const [user] = await this.userRepository.find({
  skip: 1,
  take: 1,
});

TAKE와 동일한 방식으로 작성하며, 몇 개의 데이터를 넘길 것인지를 의미한다.

Repository pattern에서 데이터를 가져오는 것은 어디까지나 ‘객체 기준’이다.

따라서 여기서의 skip, take는 객체 기준으로 몇 개의 객체를 건너띄고 몇 개의 객체를 가져올 것인가를 의미한다.

Relations로 쉽고 빠르게 JOIN하기

const users = await this.userRepository.find({
  relations: {
    cars: true,
  },
})

위 메서드는 user entity로 구성된 배열을 반환하되, 각 요소의 user에 cars 라는 프로퍼티 명으로 객체를 준다.
그래서 user.cars는 ( 이름으로 보아 아마도 car entity의 배열이 담길 것이다. ),
user entity에 명시되어 있는 OneToMany, ManyToOne, ManyToMany 조건에 명시된 대상이 담기게 된다.
raw query나 query builder에서 일일히 조건을 명시해서 합쳐야 하는 것에 비해 매우 간편한 조회가 된다.

만약 cars에 이어서, 새로운 Entity를 더 조인해나가고 싶다면 cars의 값으로 프로퍼티 대신 객체를 주면 된다.

const users = await this.userRepository.find({
  relations: {
    cars: {
      company: true,
    },
  },
})

이런 식의 객체 구조로 값을 표현하는 것은 Relation 프로퍼티 외에도, Repository pattern에서 전부 쓰인다.

INNER JOIN은?

그런 거 없다.
아쉽게도 Repository pattern에서 innerJoin은 불가능하다.
대신에 ON절을 WHERE절로 옮겨주면 동일한 쿼리를 날릴 수는 있지만, 가독성 부분은 조금 훼손될 수 있겠다.
단, 이렇게 한다고 해서 성능이 떨어지지는 않으니 그 부분에 대해서는 걱정할 필요없다.

현대적인 데이터베이스 시스템에서 LEFT JOIN과 WHERE절을 쓰는 것은 INNER JOIN과 동일한 성능이다.

APP JOIN은?

그런 건 있다.

JOIN이 늘어남에 따라 DB의 성능은 괴랄한 수준으로까지 떨어지게 된다.
이 경우에는 APP JOIN이라고 하여, 서버에서 쿼리를 날리고 그걸 객체 상에서 직접 조인해주는 방식을 쓸 수 있다.
즉, APP JOIN은 서버 코드 상에서의 객체 조인을 말한다.

TypeORM도 이런 성능 문제를 해결하고자 APP JOIN을 지원한다.

const users = await this.userRepository.find({
  relationLoadStrategy: 'query',
  relations: {
    cars: true,
  },
})

다만, 서브 쿼리를 짤 수 없는 Repository pattern 특성 상 일부 쿼리만 앱 조인할 수는 없다.

따라서 relationLoadStrategy를 query 로 지정하게 되면 수많은 쿼리가 DB로 날라가게 된다.

성능 최적화가 필요하다면, 결국에는 Repository pattern을 포기해야겠지만, 초기에는 큰 무리없이 쓸 수 있다.

여기서 말하는 초기는, 아마 상당 수의 기업들의 비즈니스 로직을 커버하고도 남을 것이다.

Select로 원하는 칼럼만 뽑기

const users = await this.userRepository.find({
  select: {
    id: true,
    name: true,
  },
});

select 문은 원하는 칼럼만을 true로 지정하여, 그 칼럼으로만 구성된 객체들을 반환하게 할 수 있다.
만약 relations으로 조인을 했다면, 그 조인한 결과에 대해서도 칼럼을 지정할 수 있다.

const users = await this.userRepository.find({
  select: {
    id: true,
    name: true,
    cars: {
      id: true,
      licensePlate: true,
    },
  },
  realtions: {
    cars: true,
  },
});

이렇게 되면 자바스크립트 객체에서는 id, name, 그리고 cars에 담긴 객체들은 id, licensePlate로 구성된다.

Repository.find의 객체는 유니크해야 한다

const users = await this.userRepository.find({
  select: {
    name: true,
});

name이 동명이인도 있을 수 있다면 결국 name으로는 사람을 구별할 수 없다.

const response = [{ name: 'kakasoo' }, { name: 'kakasoo' }];

그러므로 id가 없이 name만 조회하는 것은, raw query에서는 가능해도 ORM에서는 사용이 불가능하다.
ORM의 추가적인 기능은, 객체와 DB Table간의 매핑인데 이 매핑이 망가지는 결과가 되기 때문이다.
아래와 같은 코드가 가능하다고 해보자.

const [user] = await this.userRepository.find({
  select: {
    name: true,
  },
  take: 1,
})

매핑이 망가지면 조회한 유저에 대해 아래와 같은 코드가 불가능해진다.

user.name = 'kakasoo2';
await user.save();

이전에는 user 객체의 값을 바꾸고 save 메서드를 호출하는 것만으로도 바로 값을 DB에 반영할 수 있었다.
하지만 user의 id가 없으면 이 객체는 다룰 수 있는 범주를 넘어서기 때문에 save() 메서드는 호출될 수 없다.
이런 문제를 해결하고자 TypeORM은 조회 시 객체를 식별할 수 있을 최소한의 칼럼을 포함하게끔 강제한다.

Where문으로 조건 다루기

const usersNamedKakasoo = await this.userRepository.find({
  where: {
    name: 'kakasoo',
  },
})

이름이 ‘kakasoo’인 유저들을 모두 조회하도록 하는 함수 표현이다.
Repository 패턴에서는 where문 안에서 서브쿼리를 작성할 수 없기 때문에 여러 메서드들이 지원된다.

const usersNamedKakasoo = await this.userRepository.find({
  where: {
    name: ILIKE('kakasoo'),
  },
})

이름이 ‘kakasoo’ 라는 문자열을 포함한 모든 유저들을 조회한다.

이 외에도 Any, Between, MoreThan, LessThan, MoreThanEqual, LessThanEqual, Not, IsNull이 있다.

만약 필요하다면 Raw 를 이용해 특정 프로퍼티만 Raw한 쿼리로 조건을 걸 수도 있다.

다만 이런 메서드의 도움을 받아도 Where문을 쿼리빌더나 raw query만큼 복잡하게 짤 수 없는 게 사실이다.
아직은 TypeORM에 지원되는 메서드의 수도 적거니와, Repository는 그 메서드들이 있어도 자유도가 떨어진다.

soft-delete된 결과도 보고 싶다면 withDeleted

const allUsers = await this.userRepository.find({
  withDeleted: true
});

withDeleted는 deletedAt ( = DeleteDateColumn이 가리키는 프로퍼티 ) 가 NOT NULL인 경우도 포함한다.

ORM의 특장점은 deletedAt IS NULL을 모든 구문에 포함시켜줘서 불필요한 반복을 제거하는 부분인데,

히스토리를 조회할 때는 삭제된 데이터들도 조회해야 하기 때문에 오히려 방해가 되는 순간이 온다.
이 경우에는 withDeleted를 true로 할당해서, 자동으로 들어가는 이 삭제 포함 구문을 모두 제거할 수 있다.

다만 이렇게 될 경우 JOIN하는 대상에서도 삭제된 것들이 모두 포함되기 때문에 조심해야 한다.

WithDeleted와 Relations가 함께 있을 경우

const allUsers = await this.userRepository.find({
  withDeleted: true,
  relations: {
    cars: true,
  },
  where: {
    cars: {
      deletedAt: Not(IsNull()),
    },
  },
});

특정한 부분은 삭제되지 않은 내역들로 조회를 하고 싶다면 where문을 통해 다시금 deletedAt를 체크하면 된다.

상당히 불편할 수 있겠지만, 이 부분은 Repository 자체가 부족한 탓보다는 Entity 설계가 더 중요해보인다.

예컨대 결제 수단이 삭제되었다고 해서 과거 결제 이력의 결제 내역을 지워서는 안 되는 법이다.

이런 경우에는 결제 내역에는 fixed된 결제 수단 정보를 기입해두거나, 아니면 삭제와는 별도의 상태를 둬야 한다.

soft-delete 상태는 어떠한 경우에도 재조회가 불가능한 수준, 사실 상의 삭제와 같은 의미임을 명심하자.

Order를 통한 정렬

const users = await this.userRepository.find({
  order: {
    cretedAt: 'DESC',
  },
})

order 프로퍼티는 정렬에 대한 정보를 기입해준다.
어떤 칼럼으로, 어떤 기준으로 정렬할 것인지를 명시한다.

다만, 시간의 경우에는 그럴 경우가 극히 드물겠지만, 간혹 정렬 시 기준 값이 동일한 경우가 있을 수도 있다.

또한, 정렬한 객체에 조인된 다른 프로퍼티가 배열인 경우, 배열 내부까지 정렬 기준이 영향을 주진 않는다.

무슨 말이면 정확히 같은 시간이 생성된 두 객체에 대해서는 정렬이 항상 일정하지 않을 수도 있단 것이고,
보통 이럴 경우에는 id를 기준으로 조회된다고 생각하면 된다.

const users = await this.userRepository.find({
  relations: {
    cars: true, // 배열의 각 요소가 어떤 순서로 조회될 지는 알 수 없다.
  },
  order: {
    cretedAt: 'DESC',
  },
})

하지만 두번째의 경우에는, 배열 내부의 id로는 기준이 될 수 없기 때문에 조회 때마다 순서가 달라진다.

const users = await this.userRepository.find({
  relations: {
    cars: true,
  },
  order: {
    cretedAt: 'DESC',
    cars: {
      createdAt: 'DESC',
    },
  },
})

이 경우에는 위와 같이 내부 프로퍼티에 대한 정렬을 추가로 줘야 할 수도 있다.
프론트에서 해당 데이터를 받아 렌더링해야 하는 경우라면 사실 상 필수적이라고 볼 수 있다.

이런 부분은 조인하는 시점에 정렬 기준을 줄 수 없다는 점에서 상당히 아쉬움이 남는다.

ORDER, 하지만 NULL이 있다면?

정렬을 할 때 가장 피곤한 것은 NULL이다.
NULL이 있는 칼럼의 경우, 정렬 시 생각과 다르게 NULL이 가장 위로 쌓이는 문제가 생긴다.

따라서 raw query에서는 order by문을 줄 때, NULLS LAST, NULLS FIRST 구문을 추가로 작성한다.

이 부분은 Repository pattern에서도 동일한데, 아래와 같이 작성할 수 있다.

const users = await this.userRepository.find({
  order: {
    cretedAt: {
      direction: 'DESC', // OR 'ASC'
      nulls: 'LAST', // OR 'FIRST'
    },
  },
})

이 외의 FindOptions

  • comment : 쿼리를 날릴 때, 디버깅하기 편하도록 주석을 추가할 수 있지만, logging이 켜져 있어야 한다.
  • cache: 해당 쿼리에 대해서 캐시를 추가할 수 있다.
  • lock: 쿼리가 동작하는 동안 해당 Table이나 Column에 대한 Lock 기능을 제공한다.

그 외에도 몇 가지 설정들이 더 있지만, 이 부분은 TypeORM의 세세한 설정을 알아야 사용이 가능하다.

TypeORM Repository 패턴에 대한 개인적인 생각

const users = await this.userRepository.find({
  withDelete:true,
  select: {
    id: true,
    name: true,
  },
  where: {
    name: ILIKE('kakasoo'),
  },
  order: {
    createdAt: 'DESC',
  }
})

Repository 패턴은 TypeORM에서 Table이 JavaScript 객체로 매핑된 Entity를 이용한 방식으로,
쉽고 빠르게 작성하면서도 타입이 추론되어 쿼리빌더보다 더 안전하게 데이터를 다룰 수 있게 해준다.
물론 여기에는 함축적인 의미가 많다.

타입이 추론된다고는 했지만, 그 결과까지 type-safe한 결과로 반환되지는 않는다는 점에서 완벽하지는 않다.

예를 들어 위 메서드의 결과는 UserEntity의 id, name만으로 구성된 유저 배열이 반환되어야만 한다.

const [user] = await this.userRepository.find({
  select: {
    id: true,
  },
  take: 1,
});

console.log(user.name); // undefined, 하지만 컴파일 시점에는 알 수 없다.

하지만 타입이 완벽하게 추론되지 않기에 id, name 외 다른 프로퍼티에 접근하더라도 에러 표시가 나지 않는다.

이런 에러는 결국 런타임 시점에서 발견되기 때문에 개발자 개인의 집중력이 필요하다.

당장 위 예시는 객체의 프로퍼티에 접근한 게 고작이지만, relations된 대상에 접근할 경우도 생각하면 더 치명적일 수 있다.

const users = await this.userRepository.find({
  relations: {
    cars: true,
  },
})

두번째 문제는 relations 가 오직 LEFT JOIN 만을 지원한다는 것이다.

INNER JOIN을 하고자 한다면 Repository 패턴 내에서는 해결 방법이 없어, ON 절을 WHERE문으로 빼야한다.
LEFT JOIN에 WHERE문을 쓰는 것은 INNER JOIN과 성능적인 차이는 없지만, 코드를 이해하기 어렵게 한다.
안그래도 TypeORM은 쿼리의 작성 시 각 select, relations 프로퍼티를 따로 작성하는 게 가독성을 해친다.
여기에, 쿼리빌더에서만 지원한다는 이유로, 동일 쿼리를 다른 방식으로 짜게 하는 건 패턴의 문제라 생각된다.

그 외에 복잡한 쿼리를 작성하는 데에는 아직 지원되는 메서드의 수가 현저히 적다는 것도 문제다.

가령 COALESCE 같은 함수는 무척이나 자주 사용되는 데 비해 Repository에서 지원되는 메서드는 아니다.


TypeORM이 더 type-safe하게 변경되면 좋겠지만 이건 근간을 흔드는 일인지라 아무래도 소원해보인다.
이게 가능하다고 하더라도 TypeORM의 업데이트 속도로 봐서는 이게 언제 달성될 일인지도 알 수 없다.
Node.js 서버 개발 진영에서의 선택지가 몇 종류 없다는 점에서 무척이나 아쉬운 점이다.

https://kscodebase.tistory.com/530

Repository 최대한 예쁘게 써보기 ( 검색, 정렬, 필터링 )

// NOTE: 예시로 작성한 코드 async searchUser(search: string, orderName: string, order: 'DESC' | 'ASC') { let query = this.userRepository .createQueryBuilder('user') .leftJoinAndSelect('user.department', 'user') .where('user.isBot = false'); if (sea

kscodebase.tistory.com


위의 글은 예전에 쓴 글인데, 지금 말하려는 내용 이후의 내용이라 함께 보면 좋다.

반응형