05 · Raw & getRawMany
이 문서가 답하는 질문:
getRawMany와getMany는 정확히 무엇이 다른가, 그리고 언제 엔티티 매핑을 포기하고 raw 결과로 내려가야 하는가? 한 줄 답: “getMany는 엔티티 객체를,getRawMany는 컬럼 dictionary를 준다 — 둘을 섞는 자리에서 ORM의 의미가 절반 사라진다.”
Why — 왜 raw 결과로 내려가는 자리가 필요한가
ORM은 모든 SQL 결과를 엔티티로 매핑하려고 한다. 하지만 다음 자리에서는 그 매핑이 불가능하거나 불필요하다:
- 집계 결과 —
COUNT,SUM,AVG는 엔티티 컬럼이 아니다. FROM (SELECT ...)— 서브쿼리 결과 테이블에는 엔티티 매핑이 없다.- WINDOW 함수 —
ROW_NUMBER,LAG,LEAD는 런타임 컬럼이다. - CASE WHEN / 함수 호출 —
JSON_AGG,STRING_AGG같은 raw 표현은 임의 alias만 갖는다. - 다중 엔티티 JOIN — 두 엔티티의 컬럼을 동시에 받고 싶을 때.
이 자리에서 getRawMany는 컬럼별 dictionary를 그대로 돌려준다 — 매핑은 호출자가 직접 한다.
핵심 주장:
getMany는 엔티티 트리를,getRawMany는 flat dictionary 배열을 준다.- 둘은 반환 타입과 의미가 완전히 다르다 — 섞으면 유지보수성이 무너진다.
- 그래서 함수 단위로 결정해야 한다 — “이 함수는 엔티티를 반환하나, dictionary를 반환하나”.
getRawAndEntities는 과도기에만 쓰고, 가능하면 둘 중 하나로 통일.
How — 반환 타입과 SQL의 정확한 차이
getMany — 엔티티 트리
const users = await userRepo.createQueryBuilder('u')
.leftJoinAndSelect('u.posts', 'p')
.getMany();
// 타입
type Result = User[];
// 객체 모양
users[0] = {
id: 1,
name: 'Raw',
posts: [
{ id: 11, title: 'First' },
{ id: 12, title: 'Second' },
],
};TypeORM이 내부적으로 한 일:
SELECT u.id, u.name, p.id, p.title FROM users u LEFT JOIN posts p ON p.user_id = u.id;-- raw 결과 (flat row)
| u.id | u.name | p.id | p.title |
|------|--------|------|----------|
| 1 | Raw | 11 | First |
| 1 | Raw | 12 | Second |
| 2 | Bob | NULL | NULL |TypeORM은 u.id 같은 row들을 묶어 엔티티 트리로 재구성한다 — 이게 매핑 단계다.
getRawMany — flat dictionary
const rows = await userRepo.createQueryBuilder('u')
.leftJoin('u.posts', 'p')
.select('u.id', 'userId')
.addSelect('u.name', 'userName')
.addSelect('COUNT(p.id)', 'postCount')
.groupBy('u.id')
.getRawMany();
// 타입
type Result = Record<string, any>[];
// 객체 모양
rows[0] = { userId: 1, userName: 'Raw', postCount: '2' }; // postCount는 string!핵심 차이:
- alias 이름이 컬럼 키가 된다 (snake_case로 변환되기도 함 — DB에 따라).
- 타입 정보가 없다 —
postCount가string인지number인지 DB driver의 기본값에 달려있다. - 엔티티 트리 재구성이 없다 — flat한 row 그대로.
비교 — 같은 SQL, 다른 결과
// SQL: SELECT u.id, COUNT(p.id) AS cnt FROM users u LEFT JOIN posts p ON p.user_id = u.id GROUP BY u.id
// getMany — cnt가 사라진다 (엔티티 매핑 안 됨)
const users = await qb.getMany();
console.log(users[0].cnt); // undefined
// getRawMany — cnt를 받지만 엔티티가 없다
const rows = await qb.getRawMany();
console.log(rows[0].cnt); // '5' (string!)
// getRawAndEntities — 둘 다
const { entities, raw } = await qb.getRawAndEntities();
const merged = entities.map((u, i) => ({ ...u, cnt: Number(raw[i].cnt) }));Mermaid — 반환 메서드 트리
What — 추상화를 의식적으로 깨는 시점
시점 1 — 통계/대시보드 쿼리
// "월별 매출 + 주문 수 + 평균 가격"
async function monthlyRevenue(year: number) {
return dataSource.createQueryBuilder()
.select('EXTRACT(MONTH FROM o.created_at)', 'month')
.addSelect('SUM(o.total)', 'revenue')
.addSelect('COUNT(o.id)', 'orderCount')
.addSelect('AVG(o.total)', 'avgOrder')
.from(Order, 'o')
.where('EXTRACT(YEAR FROM o.created_at) = :y', { y: year })
.groupBy('month')
.orderBy('month')
.getRawMany();
}
// 반환: { month, revenue, orderCount, avgOrder }[]이 함수는 엔티티를 반환할 이유가 없다 — 통계 객체가 명확한 의도다. 함수 반환 타입을 raw shape으로 명시하는 게 정답.
시점 2 — 매핑 비용이 너무 클 때
// 10만 row를 단순 조회 — 엔티티 매핑 오버헤드 회피
async function exportUserEmails() {
const rows = await userRepo.createQueryBuilder('u')
.select('u.id', 'id')
.addSelect('u.email', 'email')
.where('u.active = true')
.getRawMany();
return rows;
}
// 엔티티 매핑을 *건너뛰어* 메모리·CPU 절약엔티티 매핑은 비용이다 — 컬럼별 setter 호출, 데코레이터 처리, relation 채우기. 대량 export 같은 자리에선 raw가 수십 배 빠르다.
시점 3 — 임의 alias가 필요할 때
// "글 + 작성자 + 댓글 수"를 *동시에 받기*
const result = await postRepo.createQueryBuilder('p')
.innerJoin('p.user', 'u')
.leftJoin('p.comments', 'c')
.select(['p.id AS post_id', 'p.title AS post_title',
'u.id AS user_id', 'u.name AS user_name',
'COUNT(c.id) AS comment_count'])
.groupBy('p.id, u.id')
.getRawMany();
// 의도가 *post 도메인도, user 도메인도 아닌 dashboard row*임이 분명비교 테이블 — 언제 어떤 메서드?
| 상황 | 추천 메서드 | 이유 |
|---|---|---|
| 단순 엔티티 조회 | getMany | 매핑 자동, 타입 안전 |
| 1-2개 표현식 추가 (postCount 등) | getRawAndEntities 또는 @VirtualColumn | 엔티티 의미 유지 |
| 통계/집계 결과 | getRawMany | 엔티티 의미 없음 |
| 대량 export | getRawMany | 매핑 비용 회피 |
서브쿼리 from() 결과 | getRawMany | 매핑 불가능 |
| 단일 count | getCount() | 가장 효율적 |
| 단일 raw scalar | getRawOne | { value: ... } 형태 |
함정 — 타입이 string으로 온다
DB driver마다 numeric 결과를 string으로 주는 경우가 많다 (특히 pg driver의 BIGINT).
const rows = await qb.select('COUNT(*)', 'cnt').getRawMany();
console.log(typeof rows[0].cnt); // 'string' !
console.log(rows[0].cnt + 1); // '51' (문자열 연결!)해결책 — Number() 변환 또는 driver 옵션 (pg의 경우 parseInt8).
What-if — 잘못된 해석들
오해 1 — “getRawMany는 빠르니까 다 raw로 쓰자”
ORM의 가치는 엔티티 의미 유지다. 모든 곳을 raw로 바꾸면 — 컴파일 타임 타입 안전성도 relation 자동 추적도 변경 추적도 전부 잃는다. raw는 의식적인 탈출이지 기본값이 아니다.
오해 2 — “getRawAndEntities를 쓰면 두 마리 토끼를 다 잡는다”
과도기엔 맞다 — 하지만 함수 반환 타입이 복잡해진다 ({ user: User; postCount: number }). 한 함수에서 엔티티 + 집계가 자주 동시에 필요하다면 — 그건 엔티티 도메인이 잘못 잘렸다는 신호다. @VirtualColumn이나 별도 읽기 모델을 고려.
오해 3 — “getRawMany 결과를 엔티티처럼 다뤄도 된다”
const rows = await qb.getRawMany();
rows[0].posts; // undefined — relation은 없다
userRepo.save(rows[0]); // ❌ — Plain object이지 엔티티가 아니다raw 결과는 그저 dictionary다 — TypeORM의 어떤 메서드도 인식하지 않는다.
오해 4 — “addSelect 한 표현식은 자동으로 엔티티 필드가 된다”
아니다 — getMany로 받으면 사라진다. getRawAndEntities로 받거나, @VirtualColumn 데코레이터를 엔티티에 명시 선언해야 매핑된다.
Insight — 한 단락 이야기
“raw는 ORM이 자신의 한계를 시인하는 자리다”
Hibernate는 1세대 시절 모든 SQL을 HQL로 흡수하려다가 —
createNativeQuery()를 어쩔 수 없이 만들었다. ActiveRecord도find_by_sql을 못 만든 척했지만 결국 추가했다. 모든 ORM은 raw 출구를 가진다 — 가지지 않은 ORM은 현실의 SQL을 모두 표현 불가능하기 때문. TypeORM의getRawMany는 그 출구의 가장 솔직한 형태다 — “이건 엔티티가 아닙니다, 그저 컬럼 dictionary입니다”를 반환 타입으로 자백한다. 그래서 개발자는 언제 ORM의 의미를 깨는지를 코드에서 시각적으로 알 수 있다. 추상의 묘수는 “완벽한 흡수”가 아니라 “깨지는 자리를 시각적으로 드러내는 것” —getRawMany라는 이름이 그 자백의 이름이다.
요약 + Mermaid
| 메서드 | 반환 | 매핑 | 언제 |
|---|---|---|---|
getMany() | Entity[] | 자동 | 단순 엔티티 조회 |
getOne() | Entity | null | 자동 | 단일 엔티티 |
getRawMany() | Record<string, any>[] | 없음 | 통계 · 집계 · 임의 alias |
getRawOne() | Record<string, any> | 없음 | 단일 raw scalar |
getRawAndEntities() | { entities, raw } | 둘 다 | 과도기 — 함수 단위 통일 권장 |
getCount() | number | — | COUNT(*) 전용 |
getManyAndCount() | [Entity[], number] | 자동 | pagination (다음 문서) |
한 줄 결론 — getRawMany는 ORM의 의식적인 출구다 — 그 자리에선 매핑이 호출자의 책임으로 넘어간다. 다음 문서(06)는 그 결과를 페이지로 자르는 skip/take의 함정을 본다.