
백엔드 개발에서 가장 큰 비중을 차지하는 작업 중 하나가 데이터베이스(DB)와의 통신입니다. 처음에는 게시글 하나를 불러오는 단순한 SELECT 쿼리로 시작하지만, 서비스가 커질수록 요구사항은 빠르게 복잡해집니다.
"특정 사용자가 작성한 글 중, 좋아요가 10개 이상이고, 최근 일주일 내에 작성된 글만 가져오되, 작성자의 프로필 이미지까지 같이 조인해서 가져와 주세요."
이런 요구사항을 마주하면 코드를 어떻게 작성해야 할까요? 문자열로 SQL을 길게 이어 붙이다 보면 오타로 인한 런타임 에러에 시달립니다. 반대로 지나치게 강력한 도구에만 의존하면 쿼리 성능이 나빠지기도 합니다.
결국 두 가지 근본적인 고민에 빠집니다. 하나는 "어떤 도구로 쿼리를 작성할 것인가"이고, 다른 하나는 "이 쿼리 작성 코드를 프로젝트 어디에 둘 것인가"입니다.
백엔드 생태계는 이 문제를 크게 두 축으로 풀어 왔습니다. 하나는 데이터베이스 접근 도구(수단)의 발전이고, 다른 하나는 코드 아키텍처(구조)의 발전입니다.
코드로 DB에 쿼리를 보낼 때 사용하는 도구는 추상화(Abstraction) 수준에 따라 크게 세 가지로 나뉩니다.
도구가 아무리 좋아져도, 서비스 로직(Service Layer) 곳곳에 데이터베이스 접근 코드가 섞여 있으면 유지보수가 어려워집니다. 이를 해결하려고 DB에 접근하는 코드만 따로 모아 두는 전담 클래스를 만드는 설계가 바로 Repository 패턴(Repository Pattern)입니다.
Repository 패턴을 적용하면 비즈니스 로직(Service)은 실제 DB가 MySQL인지 PostgreSQL인지, Prisma를 쓰는지 Kysely를 쓰는지 알 필요가 없습니다. 그저 Repository라는 창구를 통해 데이터를 요청할 뿐입니다.
위 다이어그램처럼 Repository 계층 내부에서 어떤 도구(수단)를 사용할지는 개발자의 선택입니다. 이제 각 도구의 특징을 코드로 살펴보겠습니다.
TypeScript 환경에서 동일한 결과를 가져오는 코드를 세 가지 방식으로 구현해 보겠습니다. 요구사항은 "나이가 20살 이상이고 활성화된 사용자의 이름과 이메일을 가져온다"입니다.
가장 원초적이고 빠른 방식입니다.
// 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[]; } }
name을 naem으로 오타를 내도 코드를 실행하기 전(런타임)까지는 에러를 잡을 수 없습니다(타입 안정성 부족). 동적 쿼리(조건에 따라 WHERE절이 바뀌는 경우)를 작성할 때 문자열을 자르고 붙이는 과정이 매우 고통스럽습니다.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(); } }
if문으로 조건을 동적으로 추가(Dynamic Query)하기가 매우 쉽습니다.테이블을 아예 객체로 취급합니다. Node.js 진영에서 널리 쓰이는 Prisma를 예로 들겠습니다.
// 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, } }); } }
이 세 가지 도구에는 절대적인 승자가 없습니다. 프로젝트의 성격과 상황에 따라 적절한 Trade-off를 고려해야 합니다.
| 비교 항목 | Raw SQL | Query Builder | ORM |
|---|---|---|---|
| 개발 생산성 | 낮음 | 중간 | 매우 높음 |
| 타입 안정성 | 없음 | 높음 (도구에 따라 다름) | 완벽함 |
| 성능 및 제어력 | 최상 | 높음 | 중간~낮음 |
| 학습 곡선 (SQL 외) | 낮음 | 중간 | 높음 |
| 동적 쿼리 작성 | 매우 힘듦 | 매우 쉬움 | 쉬움 |
모던 백엔드 개발에서는 하나의 도구만 고집하지 않습니다. 대부분의 CRUD와 단순 조회(전체 쿼리의 80% 정도)는 생산성을 위해 Prisma(ORM)를 사용하고, 복잡한 통계 쿼리나 동적 JOIN이 필요한 부분(나머지 20%)은 Kysely(Query Builder)나 Raw SQL을 섞어 쓰는 하이브리드 방식을 많이 채택합니다. 이를 위해 prisma-extension-kysely 같은 도구가 함께 쓰입니다.
결국 중요한 것은 "어떤 도구를 쓰느냐"가 아니라 "코드를 얼마나 유연하게 설계하느냐"입니다.
앞서 살펴본 Repository 패턴을 잘 적용해 두었다면, 초기에는 개발 속도를 위해 Prisma(ORM)로 빠르게 기능을 구현하고, 추후 트래픽이 몰려 병목이 생기는 특정 쿼리만 Kysely(Query Builder)나 Raw SQL로 교체하는 유연한 대응이 가능합니다. 비즈니스 로직(Service)의 코드는 한 줄도 수정하지 않고 말이죠.
도구의 장단점을 명확히 이해하고, 아키텍처라는 울타리 안에서 적재적소에 무기를 꺼내 쓰는 유연한 백엔드 개발자가 되기를 바랍니다.