HooneyLog
© 2026 Seunghoon Shin. All rights reserved.
모든 게시글
Backend
2026. 3. 28.•
4

백엔드 개발자의 영원한 고민: Raw SQL, Query Builder, ORM 완벽 비교 및 레포지토리 패턴

Seunghoon Shin
작성자 Seunghoon Shin풀스택 개발자

백엔드 개발자의 영원한 고민: Raw SQL, Query Builder, ORM 완벽 비교 및 레포지토리 패턴

1. 문제의 배경

백엔드 개발을 하다 보면 가장 큰 비중을 차지하는 작업 중 하나가 바로 데이터베이스(DB)와의 통신입니다. 처음에는 단순히 게시글을 하나 불러오는 간단한 SELECT 쿼리로 시작하지만, 서비스가 커질수록 요구사항은 기하급수적으로 복잡해집니다.

"특정 사용자가 작성한 글 중, 좋아요가 10개 이상이고, 최근 일주일 내에 작성된 글만 가져오되, 작성자의 프로필 이미지까지 같이 조인해서 가져와주세요!"

이런 요구사항을 마주했을 때, 우리는 코드를 어떻게 작성해야 할까요? 단순히 문자열로 SQL을 길게 이어서 작성하다 보면 오타로 인한 런타임 에러에 시달리게 됩니다. 반대로 지나치게 강력한 도구에 의존하면 쿼리의 성능이 엉망이 되기도 하죠.

결국 우리는 "어떤 도구를 사용해서 쿼리를 작성할 것인가?"라는 질문과, "이 쿼리 작성 코드를 프로젝트 어디에 둘 것인가?"라는 두 가지 근본적인 고민에 빠지게 됩니다.

2. 해결 방안 탐색

이 문제를 해결하기 위해 백엔드 생태계는 크게 두 가지 축으로 발전해 왔습니다. 하나는 데이터베이스 접근 도구(수단)의 발전이고, 다른 하나는 코드 아키텍처(구조)의 발전입니다.

2-1. 데이터베이스 접근 도구의 3단계

우리가 코드로 DB에 쿼리를 날릴 때 사용하는 도구는 추상화(Abstraction) 단계에 따라 크게 3가지로 나뉩니다.

  1. Raw SQL (원시 쿼리): SQL 문자열을 직접 작성하여 DB에 던지는 날것 그대로의 방식.
  2. Query Builder (쿼리 빌더): 프로그래밍 언어의 함수(메서드 체이닝)를 이용해 SQL 문법을 조립하는 방식.
  3. ORM (객체 관계 매핑): DB의 테이블을 코드 상의 '객체(Class/Object)'와 1:1로 매핑하여, SQL을 몰라도 객체를 다루듯 DB를 다루게 해주는 방식.

2-2. 구조적 해결 방안: Repository 패턴

도구가 아무리 좋아져도, 서비스 로직(Service Layer) 중간중간에 데이터베이스 접근 코드가 섞여 있으면 유지보수가 지옥이 됩니다. 이를 해결하기 위해 DB에 접근하는 코드만 따로 모아두는 전담 클래스를 만드는 설계 방식이 바로 레포지토리 패턴(Repository Pattern)입니다.

3. 핵심 개념 및 아키텍처

레포지토리 패턴을 적용하면 비즈니스 로직(Service)은 실제 DB가 MySQL인지 PostgreSQL인지, 혹은 우리가 Prisma를 쓰는지 Kysely를 쓰는지 알 필요가 없습니다. 그저 Repository라는 창구를 통해서 데이터를 요청할 뿐입니다.

위 다이어그램처럼 레포지토리 계층 내부에서 어떤 도구(수단)를 사용할지는 개발자의 선택입니다. 이제 각 도구별 특징을 코드로 살펴보겠습니다.

4. 구현 및 트러블슈팅

TypeScript 환경에서 동일한 결과를 가져오는 코드를 3가지 방식으로 구현해 보겠습니다. 요구사항: "나이가 20살 이상이고 활성화된 사용자의 이름과 이메일을 가져온다."

1) Raw SQL (mysql2, pg 등)

가장 원초적이고 빠른 방식입니다.

// UserRepository.ts import db from './db'; // mysql2 connection pool export class UserRepository { async getActiveAdultUsers(): Promise<User[]> { // SQL을 문자열로 직접 작성합니다. const query = ` SELECT id, name, email FROM users WHERE age >= ? AND is_active = ? `; // 배열로 파라미터를 넘겨 SQL Injection을 방지합니다. const [rows] = await db.query(query, [20, true]); return rows as User[]; } }
  • 장점: 쿼리 성능을 극한으로 끌어올릴 수 있고, DB 고유의 복잡한 윈도우 함수 등을 100% 사용할 수 있습니다.
  • 단점/트러블슈팅: name을 naem으로 오타를 내도 코드를 실행하기 전(런타임)까지는 에러를 잡을 수 없습니다. (타입 안정성 부족) 동적 쿼리(조건에 따라 WHERE절이 바뀌는 경우)를 작성할 때 문자열을 자르고 붙이는 과정이 매우 고통스럽습니다.

2) Query Builder (Kysely, Knex.js 등)

SQL 문법을 자바스크립트 함수로 감싼 방식입니다. 최근 TypeScript 생태계에서는 타입 안정성이 뛰어난 Kysely가 많은 사랑을 받고 있습니다.

// UserRepository.ts import { db } from './kysely-db'; export class UserRepository { async getActiveAdultUsers() { // 메서드 체이닝으로 쿼리를 조립합니다. return await db.selectFrom('users') .select(['id', 'name', 'email']) .where('age', '>=', 20) .where('isActive', '=', true) .execute(); } }
  • 장점: IDE의 자동완성 지원을 받아 오타를 방지할 수 있습니다. if문을 사용해 조건을 동적으로 추가(Dynamic Query)하기가 매우 쉽습니다.
  • 단점: 여전히 SQL 문법과 구조(JOIN, GROUP BY 등)에 대한 깊은 이해가 필요합니다.

3) ORM (Prisma, TypeORM 등)

테이블을 아예 객체로 취급합니다. Node.js 진영의 대세인 Prisma를 예로 들겠습니다.

পুরা JSON 객체를 다루듯 작성합니다.

// UserRepository.ts import prisma from './prisma-client'; export class UserRepository { async getActiveAdultUsers() { // SQL 냄새가 완전히 사라지고, JSON 객체를 다루듯 작성합니다. return await prisma.user.findMany({ select: { id: true, name: true, email: true, }, where: { age: { gte: 20 }, // gte: greater than or equal isActive: true, } }); } }
  • 장점: 생산성이 압도적으로 좋습니다. 코드가 직관적이고, 관계형 데이터(1:N JOIN)를 중첩된 객체(Nested Object) 형태로 매우 쉽게 가져올 수 있습니다. 완벽한 타입 추론을 제공합니다.
  • 단점/트러블슈팅: 도구 자체가 꽤 무겁습니다. ORM이 생성하는 쿼리를 완벽히 통제할 수 없어서, 잘못 사용하면 N+1 문제 같은 치명적인 성능 저하가 발생할 수 있습니다. 극도로 복잡한 통계 쿼리는 작성이 불가능하거나 오히려 SQL보다 문법이 기괴해집니다.

5. 결과 및 Trade-off

이 3가지 도구는 절대적인 승자가 없습니다. 프로젝트의 성격과 상황에 따라 적절한 Trade-off를 고려해야 합니다.

비교 항목Raw SQLQuery BuilderORM
개발 생산성낮음중간매우 높음
타입 안정성없음높음 (도구에 따라 다름)완벽함
성능 및 제어력최상높음중간~낮음
학습 곡선 (SQL 외)낮음중간높음
동적 쿼리 작성매우 힘듦매우 쉬움쉬움

💡 최근의 실무 트렌드 (하이브리드 접근법) 최근 모던 백엔드 개발에서는 하나의 도구만 고집하지 않습니다. 대부분의 CRUD와 단순 조회(전체 쿼리의 80%)는 생산성을 위해 Prisma (ORM)를 사용하고, 복잡한 통계 쿼리나 다이나믹 JOIN이 필요한 부분(나머지 20%)은 Kysely (Query Builder)나 Raw SQL을 섞어서 사용하는 하이브리드 방식을 가장 많이 채택하고 있습니다. (이를 위해 prisma-extension-kysely 같은 도구가 활발히 사용됩니다.)

6. 마치며

결국 중요한 것은 "어떤 도구를 쓰느냐"가 아니라, "코드를 얼마나 유연하게 설계하느냐"입니다.

우리가 앞서 살펴본 레포지토리 패턴을 잘 적용해 두었다면, 초기에는 개발 속도를 위해 Prisma(ORM)로 빠르게 기능 구현을 하고, 추후 트래픽이 몰려 병목이 발생하는 특정 쿼리만 Kysely(Query Builder)나 Raw SQL로 교체하는 유연한 대응이 가능해집니다. 비즈니스 로직(Service)의 코드는 단 한 줄도 수정할 필요 없이 말이죠.

도구의 장단점을 명확히 이해하고, 아키텍처라는 울타리 안에서 적재적소에 무기를 꺼내 쓰는 유연한 백엔드 개발자가 되기를 바랍니다.

← 이전 글Prisma에는 임베디드 엔티티가 없다? 복합 객체를 다루는 최선의 방법
다음 글 →DB 성능 최적화: N+1 문제부터 데드락(Deadlock)까지 완벽 정복하기