Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

1장. 데이터베이스 접근 방식의 시작

초기의 애플리케이션에서 데이터베이스 접근 방식은 단순했다.
필요한 데이터를 가져오기 위해 SQL을 직접 작성하고 실행하는 형태였다.

const user = await db.query("SELECT * FROM users WHERE id = 1");

데이터가 필요하면 SQL을 작성하고, 실행 결과를 그대로 사용하는 방식이다.
별도의 구조 없이도 바로 사용할 수 있었기 때문에 자연스럽게 널리 사용되었다.


코드가 늘어나면서

기능이 하나씩 추가되기 시작하면
비슷한 형태의 쿼리들이 점점 늘어나기 시작한다.

await db.query("SELECT * FROM users WHERE id = 1");
await db.query("SELECT * FROM users WHERE email = 'test@test.com'");
await db.query("SELECT * FROM users WHERE status = 'ACTIVE'");

조건만 조금씩 다른 쿼리들이 반복되면서
SQL 문자열이 코드 곳곳에 퍼지게 된다.

이 시점까지는 아직 큰 문제가 없어 보인다.


로직 안으로 들어오는 SQL

단순 조회를 넘어서
조건에 따라 조회 로직이 달라지기 시작하면
SQL은 점점 문자열을 조합하는 형태로 변하게 된다.

let query = "SELECT * FROM users WHERE 1=1";

if (email) {
  query += ` AND email = '${email}'`;
}

if (status) {
  query += ` AND status = '${status}'`;
}

if (isActive) {
  query += ` AND is_active = 1`;
}

const users = await db.query(query);

처음에는 유연하게 조건을 처리할 수 있는 방법처럼 보이지만,
조건이 늘어날수록 코드의 복잡도는 빠르게 증가한다.


점점 드러나는 문제

이 방식은 자연스럽게 몇 가지 문제를 만들어낸다.

  • 문자열 조합 실수 (공백, AND 누락 등)
  • SQL Injection 위험
  • 조건이 많아질수록 가독성 저하
  • 디버깅 어려움

그리고 중요한 변화가 하나 생긴다.

👉 데이터 조회 코드가 점점 복잡해지기 시작한다


코드의 관심사가 뒤섞인다

처음에는 단순히 데이터를 가져오기 위한 코드였지만,
이제는 SQL을 어떻게 구성하느냐가 더 중요한 코드가 된다.

결과적으로 하나의 코드 안에

  • 어떤 데이터를 가져올지에 대한 로직과
  • SQL을 조합하는 로직이

함께 섞이게 된다.

이 시점부터는

👉 비즈니스 로직
👉 데이터 접근 로직

이 명확하게 구분되지 않는다.


다음 단계로의 흐름

이 문제를 해결하기 위해
SQL을 더 안전하고 구조적으로 다루려는 시도가 등장한다.

문자열을 직접 조합하는 방식에서 벗어나
👉 더 나은 형태로 데이터 접근을 구성하려는 흐름이 만들어지기 시작한다.

2장. Raw Query 중심 접근

데이터베이스에 접근하는 가장 기본적인 방법은
SQL을 직접 작성하고 실행하는 것이다.

이 방식은 별도의 추상화 없이
애플리케이션이 데이터베이스와 직접 통신하는 형태를 가진다.

const result = await db.query(
  "SELECT * FROM users WHERE id = $1",
  [userId]
);

SQL을 문자열로 작성하고,
파라미터를 바인딩하여 실행하는 구조다.


Raw Query의 핵심 특징

이 방식은 몇 가지 명확한 특징을 가진다.

  • SQL이 코드에 그대로 드러난다
  • 데이터베이스에 직접 의존한다
  • 별도의 추상화 계층이 거의 없다

즉, 데이터 접근에 대한 모든 제어를
개발자가 직접 가지는 구조다.


개발자가 직접 제어한다는 것

Raw Query의 가장 큰 특징은
“직접 제어한다”는 점이다.

어떤 데이터를 어떻게 가져올지,
어떤 조건을 사용할지,
어떤 조인을 수행할지 모두 SQL로 명확하게 표현된다.

이 구조에서는

  • 실행되는 쿼리가 명확하게 보이고
  • 데이터베이스의 기능을 그대로 활용할 수 있다

Raw Query의 장점

이 방식은 단순하지만 강력하다.

가장 큰 장점은 다음과 같다.

  • 성능 제어가 용이하다
    → 실행되는 SQL을 그대로 다룰 수 있다

  • 복잡한 쿼리 작성이 자유롭다
    → JOIN, GROUP BY, 서브쿼리 등 제약이 없다

  • 디버깅이 명확하다
    → 실제 실행되는 SQL을 그대로 확인할 수 있다

  • 데이터베이스 기능을 100% 활용할 수 있다
    → 특정 DB의 고유 기능도 그대로 사용 가능

이 때문에 Raw Query는 지금도 여전히 많이 사용된다.


규모가 커지면서 드러나는 한계

하지만 애플리케이션이 커지기 시작하면
이 방식의 한계가 점점 드러난다.

  • 동일한 쿼리 패턴이 반복된다
  • 조건이 많아질수록 쿼리 관리가 어려워진다
  • 코드 레벨에서 구조화하기 어렵다
  • 비즈니스 로직과 분리하기 힘들다

특히 동적인 조건이 많아질수록
SQL을 문자열로 다루는 부담이 커진다.


여전히 사용되는 이유

그럼에도 불구하고 Raw Query는
여전히 중요한 선택지로 남아 있다.

다음과 같은 경우에는 오히려 더 적합하다.

  • 복잡한 통계 쿼리
  • 성능 최적화가 중요한 기능
  • 다른 방식으로 표현하기 어려운 복잡한 쿼리

즉, 이 방식은 사라진 기술이 아니라
👉 가장 직접적인 접근 방식으로 계속 사용되고 있다


다음 단계로의 흐름

하지만 반복되는 쿼리와
문자열 기반 SQL 관리의 한계를 줄이기 위해

데이터 접근을 조금 더 구조적으로 다루려는 시도가 등장한다.

특히

  • SQL을 직접 조합하지 않고
  • 코드 구조 안에서 안전하게 쿼리를 구성하려는 방향

으로 발전하게 된다.

3장. Query Builder의 등장

Raw Query 방식에서는
SQL을 문자열로 직접 작성하고 조합해야 했다.

특히 조건이 많아질수록
문자열을 이어붙이는 방식은 빠르게 복잡해졌다.

let query = "SELECT * FROM users WHERE 1=1";

if (email) {
  query += ` AND email = '${email}'`;
}

if (status) {
  query += ` AND status = '${status}'`;
}

이 방식은 유연하지만,
코드의 안정성과 가독성 측면에서 부담이 커진다.


SQL을 구조적으로 다루려는 시도

이 문제를 해결하기 위해 등장한 것이
👉 Knex.js 와 같은 Query Builder 방식이다

문자열을 직접 조합하는 대신
코드 구조를 통해 SQL을 만들어가는 방식이다.

const users = await knex('users')
  .where('email', email)
  .andWhere('status', status);

이제 SQL은 문자열이 아니라
메서드 체이닝 형태로 표현된다.


무엇이 달라졌는가

가장 큰 변화는
👉 “SQL을 만드는 방식”이다

Raw Query에서는

  • 문자열을 직접 조합했고

Query Builder에서는

  • 구조를 기반으로 쿼리를 생성한다

즉,

👉 SQL 작성 방식이 “문자열 → 구조”로 바뀐다


Query Builder의 핵심 특징

이 방식은 다음과 같은 특징을 가진다.

  • 메서드 체이닝 기반 쿼리 생성
  • 조건을 코드로 분기 처리 가능
  • 파라미터 바인딩 자동 처리

특히 중요한 점은
👉 SQL Injection을 기본적으로 방지할 수 있다는 것이다.


동적 조건 처리의 변화

1장에서 문제가 되었던 동적 조건 처리도
훨씬 자연스럽게 바뀐다.

const query = knex('users');

if (email) {
  query.where('email', email);
}

if (status) {
  query.where('status', status);
}

const users = await query;

문자열을 이어붙이지 않고
조건을 “추가”하는 형태로 바뀐다.


Query Builder의 장점

이 방식은 Raw Query의 여러 문제를 개선한다.

  • SQL Injection 방지
  • 동적 조건 처리 용이
  • 코드 가독성 향상
  • 쿼리 생성 로직을 구조적으로 관리 가능

특히 조건이 많은 쿼리에서
차이가 크게 드러난다.


하지만 여전히 남아있는 한계

Query Builder는 많은 문제를 해결했지만
완전히 새로운 접근 방식은 아니다.

여전히 다음과 같은 특징을 가진다.

  • SQL 구조를 이해해야 한다
  • 데이터베이스에 대한 의존이 남아 있다
  • 복잡한 쿼리는 오히려 더 읽기 어려워질 수 있다

예를 들어 JOIN이나 서브쿼리가 많아질 경우
코드가 오히려 더 길어지기도 한다.


여전히 SQL 중심이다

중요한 점은
이 방식이 SQL을 없앤 것이 아니라

👉 SQL을 “조금 더 안전하게 만드는 것”에 가깝다는 점이다

즉,

  • 사고 방식은 여전히 SQL 중심이고
  • 데이터 접근 방식 자체가 바뀐 것은 아니다

다음 단계로의 흐름

개발자들은 다시 고민하게 된다.

  • SQL을 계속 알아야 하는 구조가 맞는가?
  • 데이터를 객체처럼 다룰 수는 없는가?
  • 비즈니스 로직과 데이터 접근을 더 분리할 수는 없는가?

이 고민에서 등장한 것이
👉 객체 중심의 데이터 접근 방식

이다.

ORM(Object Relational Mapping)

Query Builder는 SQL을 더 안전하고 구조적으로 만들었지만,
여전히 데이터 접근의 기준은 SQL이었다.

개발자는 계속해서

  • 어떤 테이블을 조회할지
  • 어떤 조건을 사용할지
  • 어떤 JOIN을 구성할지

를 SQL 관점에서 생각해야 했다.


관점의 변화

여기서 하나의 질문이 등장한다.

👉 “데이터를 굳이 SQL로 다뤄야 할까?”

애플리케이션은 객체 기반으로 동작하지만,
데이터베이스는 테이블 구조를 가진다.

이 둘의 차이를 줄이기 위해 등장한 것이
👉 ORM(Object Relational Mapping) 이다.


데이터 구조를 코드로 정의한다

ORM에서는 데이터베이스의 구조를
코드 레벨에서 정의한다.

예를 들어 Prisma 를 보면
다음과 같이 모델을 정의한다.

// Prisma schema
model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
}

이제 데이터베이스의 테이블 구조는
단순한 외부 리소스가 아니라
👉 애플리케이션 코드의 일부가 된다.


객체를 통해 데이터를 다룬다

데이터 조회 방식도 달라진다.

const user = await prisma.user.findUnique({
  where: { email: "test@test.com" }
});

여기서는 더 이상

  • SELECT 문을 작성하지 않고
  • WHERE 조건을 문자열로 만들지 않는다

👉 객체 구조를 통해 데이터를 조회한다


무엇이 달라졌는가

Query Builder와의 가장 큰 차이는
👉 “데이터를 표현하는 방식”이다

  • Query Builder → SQL 구조 기반
  • ORM → 객체 구조 기반

즉,

👉 SQL이 코드에 드러나지 않는다


자동완성과 타입 안정성

ORM에서는 스키마를 기반으로
코드 작성 시 다양한 이점이 생긴다.

const user = await prisma.user.findUnique({
  where: {
    email: "test@test.com"
  }
});

이 코드에서

  • email 필드는 자동완성으로 제공되고
  • 잘못된 필드명을 사용하면 오류가 발생한다

👉 데이터 구조가 코드에 반영되기 때문이다


동적 조건 처리 방식의 변화

조건 처리도 객체 기반으로 표현된다.

const users = await prisma.user.findMany({
  where: {
    status: "ACTIVE",
    email: {
      contains: "@test.com"
    }
  }
});

문자열을 조합하는 것이 아니라
👉 객체를 구성하는 방식으로 조건을 표현한다


데이터베이스 추상화

ORM은 특정 데이터베이스에 대한 의존도를 줄여준다.

  • MySQL
  • PostgreSQL
  • SQLite

와 같은 다양한 데이터베이스를
👉 동일한 코드 구조로 사용할 수 있다

이로 인해 데이터베이스 변경 시
코드 수정 범위를 줄일 수 있다.


ORM의 장점

이 방식은 개발 생산성과 구조 측면에서 큰 이점을 가진다.

  • 반복 코드 감소
  • 비즈니스 로직과 데이터 접근 분리
  • 코드 일관성 향상
  • 객체 중심 설계 가능
  • 타입 기반 개발 가능

특히 서비스 규모가 커질수록
이러한 장점이 더 크게 드러난다.


하지만 완전한 해결은 아니다

ORM은 많은 부분을 추상화하지만,
그만큼 새로운 문제도 발생한다.

  • 실제 실행되는 SQL을 바로 확인하기 어렵다
  • 성능을 세밀하게 제어하기 어렵다
  • 복잡한 쿼리에서 표현 한계가 있다

대표적인 문제로는

  • N+1 문제
  • Lazy Loading / Eager Loading 이슈

등이 있다.


추상화의 대가

ORM은 SQL을 숨겨준다.

하지만 그만큼

👉 내부 동작을 이해하지 못하면
👉 예상하지 못한 성능 문제가 발생할 수 있다

즉,

  • 편의성이 증가한 만큼
  • 제어력은 일부 줄어든다

정리

지금까지의 흐름을 보면

  • Raw Query → 직접 제어
  • Query Builder → 구조적 개선
  • ORM → 객체 기반 추상화

로 발전해왔다.

👉 데이터 접근의 중심이
👉 SQL에서 객체로 이동한 것이다


다음 단계로의 흐름

하지만 하나의 방식이 모든 문제를 해결하지는 못한다.

상황에 따라

  • 직접 SQL이 필요한 경우도 있고
  • 구조적인 쿼리 생성이 필요한 경우도 있으며
  • 객체 중심 접근이 유리한 경우도 있다

5장. 현실적인 선택

지금까지 데이터베이스 접근 방식은 다음과 같이 발전해왔다.

  • Raw Query → 직접 제어
  • Query Builder → 구조적 개선
  • ORM → 객체 기반 추상화

각 방식은 이전의 문제를 해결하기 위해 등장했지만
👉 하나의 방식이 모든 상황을 해결하지는 못한다


하나로 통일할 수 없는 이유

이론적으로는 하나의 방식으로 대부분의 쿼리를 처리할 수 있다.

특히 ORM의 경우
👉 대부분의 데이터 접근을 표현하는 것이 가능하다

하지만 실제 서비스에서는 다른 문제가 발생한다.

👉 복잡해질수록 코드가 더 어려워진다


기능 vs 현실

ORM은 내부적으로 SQL을 생성하기 때문에
기능적으로는 대부분의 쿼리를 처리할 수 있다.

하지만 중요한 차이는 따로 있다.

👉 표현 방식과 제어력


복잡한 쿼리에서의 차이

다음과 같은 요구사항을 생각해보자.

  • 사용자별 주문 수 집계
  • 특정 개수 이상 필터링
  • 주문 수 기준 정렬

SQL

SELECT u.id, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id
HAVING COUNT(o.id) >= 3
ORDER BY order_count DESC;

👉 구조가 한눈에 보인다


Query Builder

const query = knex('users as u')
  .leftJoin('orders as o', 'u.id', 'o.user_id')
  .groupBy('u.id');

if (minOrderCount) {
  query.havingRaw('COUNT(o.id) >= ?', [minOrderCount]);
}

if (sortBy === 'orderCount') {
  query.orderByRaw('COUNT(o.id) DESC');
}

const result = await query.select('u.*');

👉 SQL 구조를 유지하면서 코드로 제어 가능


ORM (Prisma)

const users = await prisma.user.groupBy({
  by: ['id'],
  _count: {
    orders: true
  },
  having: {
    orders: {
      _count: {
        gte: 3
      }
    }
  },
  orderBy: {
    _count: {
      orders: 'desc'
    }
  }
});

무엇이 문제인가

ORM도 동일한 기능을 표현할 수 있다.
하지만 코드의 특성이 달라진다.

  • SQL 구조가 직접적으로 보이지 않는다
  • ORM 문법을 따로 이해해야 한다
  • 복잡해질수록 가독성이 떨어진다

👉 즉, 표현은 가능하지만 이해 비용이 증가한다


성능 제어의 한계

ORM은 편리하지만
실행되는 SQL에 대한 제어가 제한된다.

예를 들어

  • 어떤 JOIN 방식이 사용되는지
  • 어떤 인덱스가 선택되는지
  • 쿼리가 몇 번 실행되는지

를 코드만 보고 파악하기 어렵다.

👉 특히 N+1 문제처럼
👉 의도하지 않은 쿼리 증가가 발생할 수 있다

또한

  • index hint
  • 실행 계획 제어
  • 특정 DB 기능 활용

과 같은 세밀한 튜닝은 어렵거나 제한적이다.


결국 다시 SQL로 돌아간다

실무에서는 다음과 같은 흐름이 자주 발생한다.

  • ORM으로 구현은 가능하지만 코드가 복잡해짐
  • 성능 문제가 발생함
  • 실행되는 SQL을 직접 제어해야 하는 상황 발생

이 시점에서 개발자는 자연스럽게 선택한다.

👉 “이건 그냥 SQL로 쓰는 게 낫다”


그래서 등장하는 현실적인 구조

이러한 이유로 실제 서비스에서는
하나의 방식이 아니라 여러 방식을 함께 사용한다.

👉 혼합 전략


역할에 따른 선택

각 방식은 서로 다른 역할을 가진다.

일반적인 데이터 처리 → ORM

const user = await prisma.user.findUnique({
  where: { id: userId }
});
  • 코드가 간결하다
  • 생산성이 높다
  • 구조적으로 관리하기 쉽다

👉 대부분의 CRUD 작업에 적합하다


복잡한 쿼리 구성 → Query Builder

const query = knex('users as u')
  .leftJoin('orders as o', 'u.id', 'o.user_id')
  .groupBy('u.id');

if (minOrderCount) {
  query.havingRaw('COUNT(o.id) >= ?', [minOrderCount]);
}

if (sortBy === 'orderCount') {
  query.orderByRaw('COUNT(o.id) DESC');
}
  • SQL 구조를 유지하면서 코드로 제어 가능
  • 동적인 쿼리 구성에 유리
  • 복잡한 조건과 구조 처리에 적합

성능 / 특수 기능 → Raw Query

const result = await db.query(`
  SELECT u.id, COUNT(o.id) as order_count
  FROM users u
  LEFT JOIN orders o ON u.id = o.user_id
  GROUP BY u.id
`);
  • 데이터베이스 기능을 그대로 활용 가능
  • 성능 튜닝에 유리
  • 복잡한 쿼리 표현에 가장 강력

핵심은 역할이다

이 세 가지 방식은 서로 경쟁 관계가 아니다.

👉 각각의 역할이 다르다

  • ORM → 생산성
  • Query Builder → 유연성
  • Raw Query → 제어력

잘못된 접근 방식

다음과 같은 접근은 오히려 문제를 만든다.

  • “ORM만 사용해야 한다”
  • “Raw Query는 나쁜 방식이다”
  • “하나로 통일해야 한다”

이런 선택은

👉 특정 상황에서 비효율적인 구조를 만들게 된다


선택 기준

실제 선택은 다음 기준으로 이루어진다.

  • 쿼리의 복잡도
  • 성능 요구사항
  • 유지보수 용이성
  • 팀의 이해도

정리

데이터베이스 접근 방식은
하나의 정답이 있는 문제가 아니다.

👉 중요한 것은 기술 자체가 아니라
👉 상황에 맞는 선택이다

6장. 아키텍처 관점에서의 DB 접근 계층

지금까지 데이터베이스 접근 방식의 흐름을 살펴보았다.

  • Raw Query
  • Query Builder
  • ORM

이제 중요한 것은
👉 이들을 “어떻게 코드 구조 안에 배치할 것인가”이다.


1. 왜 구조가 필요한가

서비스 규모가 커질수록 데이터 접근 코드는 빠르게 증가한다.

이 과정에서 자연스럽게 다음과 같은 문제가 발생한다.

  • 쿼리가 여러 곳에 흩어진다
  • 동일한 로직이 반복된다
  • 수정 시 영향 범위를 파악하기 어렵다

👉 즉, 데이터 접근을 체계적으로 관리할 구조가 필요하다


2. 데이터 접근 계층 구조

일반적으로 데이터 접근은 다음과 같은 계층 구조로 구성된다.

[ Controller ]
      ↓
[  Service  ]
      ↓
[ Repository ]
      ↓
[ Database ]

각 계층은 명확한 역할을 가진다.


3. 계층별 역할

Controller

app.get('/users/:id', async (req, res) => {
  const user = await userService.getUser(req.params.id);
  res.json(user);
});
  • 요청/응답 처리
  • 비즈니스 로직 없음

Service

class UserService {
  async getUser(userId: number) {
    return await userRepository.findById(userId);
  }
}
  • 비즈니스 로직 담당
  • 데이터 접근 방식에 의존하지 않음

Repository

class UserRepository {
  async findById(userId: number) {
    return await prisma.user.findUnique({
      where: { id: userId }
    });
  }
}
  • 데이터 접근 전담
  • ORM / Query Builder / Raw Query 사용

Database

  • 실제 데이터 저장소
  • MySQL, PostgreSQL 등

4. 데이터 흐름

계층 간 데이터는 역할에 맞게 형태가 달라진다.

Repository → Entity

type UserEntity = {
  id: number;
  email: string;
  name: string;
};

👉 데이터베이스 구조 그대로 표현

Service → DTO 변환

type UserDTO = {
  id: number;
  name: string;
};
class UserService {
  async getUser(userId: number): Promise<UserDTO> {
    const user = await userRepository.findById(userId);

    return {
      id: user.id,
      name: user.name
    };
  }
}

👉 외부에 필요한 데이터만 전달


5. 이 구조가 가지는 장점

이 구조는 단순한 계층 분리가 아니라
👉 실제 개발 과정에서 발생하는 문제를 해결하기 위한 설계 방식이다.

1) 유지보수성 – 변경 영향 최소화

❌ 구조가 없는 경우

app.get('/users/:id', async (req, res) => {
  const user = await db.query("SELECT * FROM users WHERE id = 1");
  res.json(user);
});

👉 문제:

  • SQL이 여러 곳에 퍼짐
  • 테이블 변경 시 전부 수정

⭕ 구조가 있는 경우

class UserRepository {
  async findById(id: number) {
    return db.query("SELECT * FROM users WHERE id = ?", [id]);
  }
}

👉 변경 발생:

// users → members 변경
return db.query("SELECT * FROM members WHERE id = ?", [id]);

👉 Repository만 수정하면 끝

👉 핵심:
변경이 한 곳에 집중된다


2) 테스트 용이성 – DB 없이 테스트

❌ 구조가 없는 경우

class UserService {
  async getUser(id: number) {
    return db.query("SELECT * FROM users WHERE id = ?", [id]);
  }
}

👉 문제:

  • DB 필요
  • 테스트 느림

⭕ 구조가 있는 경우

interface UserRepository {
  findById(id: number): Promise<UserEntity>;
}
class MockUserRepository implements UserRepository {
  async findById(id: number) {
    return { id, name: "test-user", email: "test@test.com" };
  }
}
const service = new UserService(new MockUserRepository());
const user = await service.getUser(1);

👉 DB 없이 테스트 가능

👉 핵심:
비즈니스 로직을 독립적으로 검증할 수 있다


3) 데이터 보호 – Entity vs DTO

❌ 구조가 없는 경우

res.json(user);

👉 문제:

  • email 등 민감 정보 그대로 노출

⭕ DTO 적용

return {
  id: user.id,
  name: user.name
};

👉 핵심: 외부로 나가는 데이터는 통제된다


4) 기술 변경 유연성

상황

ORM → Raw Query 변경

⭕ 구조가 있는 경우

// 기존
return prisma.user.findUnique(...);

// 변경
return db.query("SELECT * FROM users WHERE id = ?", [id]);

👉 Service / Controller 수정 없음

👉 핵심:
기술 변경이 전체 코드에 영향을 주지 않는다


5) 혼합 전략 적용

class UserRepository {
  async findById(id: number) {
    return prisma.user.findUnique({ where: { id } });
  }

  async searchUsers(...) {
    return knex('users')...;
  }

  async getStatistics() {
    return db.query("SELECT ...");
  }
}

👉 하나의 구조 안에서

  • ORM
  • Query Builder
  • Raw Query

공존 가능

👉 핵심:
상황에 맞는 기술 선택이 가능하다


6. 핵심 정리

이 구조의 핵심은 다음과 같다.

  • 데이터 접근은 반드시 분리해야 한다
  • 각 계층은 명확한 역할을 가진다
  • 내부 구현은 자유롭게 변경 가능하다

그리고 가장 중요한 점은

👉 이 구조는 코드를 나누기 위한 것이 아니라
변화에 대응하기 위한 설계다

7장. 트랜잭션과 데이터 일관성

1. 하나의 작업처럼 보이지만

서비스에서는 여러 데이터 변경이
하나의 작업처럼 묶이는 경우가 많다.

예를 들어 주문을 생성하는 로직을 보면 다음과 같다.

await orderRepository.create(order);
await userRepository.decreasePoint(userId, amount);

이 코드는 자연스럽게 읽힌다.

  • 주문을 생성하고
  • 포인트를 차감한다

👉 하나의 작업처럼 보인다


2. 하지만 실제로는 두 개의 작업이다

문제는 이 두 작업이
서로 다른 DB 작업이라는 점이다.

만약 다음과 같은 상황이 발생하면 어떻게 될까?

await orderRepository.create(order);     // 성공
await userRepository.decreasePoint(...); // 실패

👉 결과

  • 주문은 생성됨
  • 포인트는 차감되지 않음

3. 데이터가 깨지는 순간

이 상태는 비즈니스적으로 문제가 된다.

  • 돈은 안 빠졌는데 주문은 완료됨
  • 혹은 반대로 돈만 빠지고 주문 실패

👉 데이터 일관성이 깨진다


4. 그래서 트랜잭션이 필요하다

이 문제를 해결하기 위해 등장한 개념이
👉 트랜잭션(Transaction) 이다

트랜잭션은 여러 작업을 하나로 묶는다.

await db.transaction(async (trx) => {
  await orderRepository.create(order, trx);
  await userRepository.decreasePoint(userId, amount, trx);
});

👉 결과

  • 둘 다 성공 → 반영
  • 하나라도 실패 → 전체 롤백

5. 그런데 구조를 나누면 문제가 생긴다

6장에서 구조를 이렇게 나눴다.

Controller → Service → Repository

이 구조에서 트랜잭션은 어디에서 관리해야 할까?


6. 자연스럽게 잘못된 선택

처음에는 Repository에서 처리하고 싶어진다.

class OrderRepository {
  async create(order) {
    await db.transaction(async () => {
      await db.query(...);
    });
  }
}

👉 하지만 이 구조는 문제가 있다

  • 다른 작업과 묶을 수 없다
  • 트랜잭션이 분리된다

👉 결국 의미가 없다


7. 해결은 Service에 있다

트랜잭션은 “데이터 저장”이 아니라
👉 “작업의 흐름”을 기준으로 묶어야 한다

그래서 Service에서 관리한다.

await db.transaction(async (trx) => {
  await orderRepository.create(order, trx);
  await userRepository.decreasePoint(userId, amount, trx);
});

👉 핵심

  • Service → 전체 흐름 제어
  • Repository → 실행만 담당

8. Repository는 트랜잭션을 전달받는다

class UserRepository {
  async decreasePoint(userId, amount, trx?) {
    return trx
      ? trx.query(...)
      : db.query(...);
  }
}

👉 트랜잭션이 있으면 사용
👉 없으면 기본 DB 사용


9. 핵심은 위치다

트랜잭션을 어디에 두느냐가 중요하다.

위치결과
Repository분리됨 (문제 발생)
Service하나로 묶임 (정상)

10. 정리

이 장의 핵심은 단순하다.

👉 트랜잭션은 DB 기능이지만
👉 DB 레이어에서 관리하면 안 된다