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