← 모든 글

ORM 을 우리가 쓰지 않는 도메인

HEDVION은 결제·정산·금융 계산 도메인에서 ORM 대신 raw SQL을 선택한다. 하루 100만 건 처리에서 1원 오차가 감사 이슈가 되는 환경, 추상화가 숨기는 것들이 너무 크다.

ORM에 대한 우리의 기본 입장: 부정이 아니라 선택

시작 전제를 분명히 해두자. HEDVION 팀은 ORM을 나쁜 기술이라고 생각하지 않는다. Prisma, TypeORM, Drizzle 같은 도구들은 분명한 가치를 갖는다. 반복적인 CRUD를 줄여주고, 스키마와 코드 사이의 타입 안전성을 확보하고, 마이그레이션을 버전 관리 흐름 속으로 끌어들인다. 우리 팀 내에서도 어드민 패널, 사용자 계정 관리, 상품 마스터 데이터 조회 같은 영역에서는 ORM을 그대로 쓴다. 그게 더 빠르고, 그게 더 안전하다.

문제는 "어디서나 ORM"이라는 관성이다. 많은 팀이 ORM을 기술 스택의 기본값으로 설정하고, 예외적인 상황에서만 raw SQL로 내려간다. 우리는 반대 방향으로 접근한다. 도메인의 성격을 먼저 파악하고, 그 성격이 ORM의 추상화와 충돌할 때는 처음부터 SQL을 선택한다. 이 판단이 어느 도메인에서 어떻게 이루어지는지가 이 글의 핵심이다.

결제·정산 도메인에서 ORM이 위험한 이유

금융 계산에서 오차는 단순한 버그가 아니다. 우리 팀이 운영하는 정산 파이프라인은 가맹점별 매출을 집계하고, 수수료를 차감하고, 세금을 분리하고, 최종 지급액을 산출한다. 이 과정에서 소수점 반올림이 어느 시점에 적용되느냐에 따라 결과가 달라진다.

구체적인 사례를 들자. 수수료율 3.5%를 적용할 때 ORM이 내부적으로 JavaScript의 Number 부동소수점 타입으로 중간값을 처리하면 1000 * 0.035 = 34.999999...가 될 수 있다. 이걸 행 단위로 ROUND하면 35원, 집계 후에 반올림하면 합산 기준이 달라진다. 단건으로는 1원 차이지만 하루 100만 건을 처리하면 최대 100만 원의 누적 오차가 생긴다. 이 오차가 분기 감사에서 발견되면 단순 버그가 아니라 장부 불일치 이슈가 된다. 우리가 직접 SQL로 ROUND(amount * CAST('0.035' AS DECIMAL(5,3)), 0)을 명시하는 이유가 여기에 있다. DECIMAL 타입과 ROUND 함수의 적용 순서를 쿼리 파일에서 눈으로 확인할 수 있어야 한다.

ORM 추상화가 실제로 무너지는 지점

ORM이 추상화를 잘 해주는 영역과 오히려 복잡성을 키우는 영역은 명확히 구분된다. 단일 테이블 CRUD, 간단한 조인, 페이지네이션 — 이건 ORM이 압도적으로 유리하다. 하지만 정산 쿼리는 성격이 다르다.

가맹점별 주간 정산을 실제 예시로 들면 이런 구조가 필요하다: 결제 테이블과 취소 테이블을 UNION ALL로 합치고, 가맹점·날짜 기준 GROUP BY로 일별 매출을 집계하고, 누적 지급 여부를 판단하기 위해 SUM() OVER (PARTITION BY merchant_id ORDER BY settled_date) 형태의 윈도우 함수를 붙이고, 최소 지급 기준(예: 1만 원) 미달 건은 다음 주로 이월하는 CASE WHEN 로직을 추가한다. TypeORM이나 Prisma로 이걸 표현하면 결국 .query() 탈출구로 빠지거나, 빌더 체이닝이 SQL보다 오히려 읽기 어려운 코드가 된다. 결국 팀원이 "이 빌더가 어떤 SQL을 만드는가"를 역추적해야 하는 순간이 온다. 우리에겐 SQL 파일이 더 명시적인 문서이자 더 빠른 디버깅 도구다.

트레이드오프를 직면하기: 우리가 직접 떠안는 비용

raw SQL 선택은 공짜가 아니다. ORM이 자동으로 처리해주는 것들을 직접 관리해야 한다. 우리 팀이 실제로 겪는 비용을 구체적으로 나열하면 이렇다.

마이그레이션 관리. db/migrations/ 디렉토리에 번호 붙은 SQL 파일을 직접 관리한다. 0041_add_settlement_lock_status.sql, 0042_add_index_merchant_settled_date.sql 형태로 순번을 붙이고, 배포 파이프라인에서 자동 실행한다. 간단하지만 충돌 처리와 롤백 시나리오는 직접 설계해야 하고, ORM이 제공하는 diff 기반 자동 생성 같은 편의는 없다. 결과 매핑. SQL 쿼리 결과를 애플리케이션 도메인 객체로 변환하는 코드를 수동으로 작성한다. snake_casecamelCase 변환, DECIMAL 타입의 문자열 변환 처리, null 안전 처리까지 보일러플레이트가 생긴다. N+1 감지. ORM처럼 자동 경고 도구가 없으므로 코드 리뷰와 EXPLAIN ANALYZE 실행계획 확인으로 직접 잡아야 한다. 이 비용을 감수하면서 얻는 것은 쿼리 실행 결과의 완전한 예측 가능성이다. 정산 파이프라인에서 "왜 이 가맹점 지급액이 다르지?"라는 질문이 나왔을 때, SQL 파일을 그대로 DB 클라이언트에 붙여넣어 재현할 수 있다. 디버깅 경로가 짧다.

팀 내 경계: 혼용의 혼란을 막는 규칙

ORM과 raw SQL을 같은 프로젝트 안에서 혼용할 때 가장 큰 위험은 암묵적 섞임이다. "이 쿼리는 ORM으로 썼으니 저 쿼리도"라는 관성이 모듈 경계를 흐린다. 처음에는 실용적인 이유로 섞이지만, 6개월 뒤에는 "이 집계가 ORM 쿼리인지 raw SQL인지"를 파악하는 것 자체가 작업이 된다.

우리 팀은 모듈 경계로 이 문제를 해결한다. src/admin/ — ORM 전용, src/settlement/ — raw SQL 전용, src/payment/ — 트랜잭션과 잠금 처리는 raw SQL, 단순 상태 조회는 ORM. 이 규칙은 모듈 내 README.md 첫 줄에 명시하고, PR 리뷰에서 경계 침범 여부를 확인한다. "왜 settlement 모듈에 Prisma 쿼리가 있지?"는 리뷰에서 바로 걸린다. 명시적 규칙이 없으면 새 팀원이 합류할 때마다 같은 판단을 팀 전체가 다시 해야 한다. 규칙은 한 번 쓰고, 판단은 반복하지 않는다.

실무에서 바로 써먹을 수 있는 판단 기준

이 글을 읽는 팀이 자기 프로젝트에 적용하려면 다음 순서대로 시작하면 된다.

1. 도메인을 먼저 분류하라. 금액, 수수료, 세금, 환불이 개입되는 모든 쿼리는 raw SQL 후보다. CRUD와 목록 조회는 ORM으로 간다. 이 분류를 팀이 공유하고 문서화하는 것이 먼저다.

2. ORM 쿼리의 실제 SQL을 항상 눈으로 확인하라. ORM을 쓰더라도 .toSQL()이나 쿼리 로그로 실제 SQL을 출력하고, 그 SQL을 DB 클라이언트에서 직접 실행해 결과를 확인하라. 이 확인이 불편하게 느껴지는 쿼리라면 raw SQL 전환 신호다.

3. 마이그레이션은 ORM 의존 없이 관리하라. raw SQL 도메인에서는 마이그레이션도 수동 SQL 파일로 버전 관리한다. Flyway, golang-migrate, 또는 직접 만든 간단한 runner면 충분하다. ORM 마이그레이션 기능에 묶이면 raw SQL 도메인의 스키마 변경이 ORM 설정에 종속된다.

4. 팀 규칙을 코드 옆에 남겨라. 어느 모듈이 어떤 방식을 쓰는지 CLAUDE.md나 모듈 README에 한 줄이라도 남겨라. 구두 합의는 팀원이 바뀌면 사라진다. 문서화된 규칙은 온보딩 비용을 줄이고, 리뷰 기준을 명확하게 만든다.

기술 선택은 트렌드의 문제가 아니다. "이 도메인에서 틀렸을 때 비용이 얼마인가"가 기준이 되어야 한다. 금융 계산에서 1원 오차는 감사 이슈가 된다. 그 가능성만으로도 raw SQL을 선택할 충분한 이유가 된다. 우리가 ORM을 부정하지 않으면서도 정산 모듈에서는 SQL 파일을 직접 열어보는 이유가 이것이다.

— by slecs

* 위 링크는 인프런 affiliate 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

📚 추천 강의
한 입 크기로 잘라먹는 바이브코딩 (with Claude Code)
Claude Code로 바이브코딩, 개발자라면 꼭 들어야 할 필수 강의
강의 보러가기 →

* 위 추천 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.