Skip to main content
Summary

데이터베이스는 코드 안에서 완성된다

지금까지는 MySQL 자체를 중심으로 배웠습니다. 테이블을 설계하고, 조인으로 데이터를 읽고, 인덱스로 성능을 개선하고, 트랜잭션으로 일관성을 지키는 방법을 다루었습니다.

하지만 실무에서 데이터베이스는 혼자 존재하지 않습니다. 사용자가 웹 페이지에서 회원가입을 하고, 장바구니에 상품을 담고, 결제를 요청할 때 애플리케이션 코드는 MySQL에 접속해 데이터를 읽고 씁니다.

이 장의 목표는 SQL을 코드 안에서 안전하게 사용하는 방법을 익히는 것입니다. 핵심은 네 가지입니다.

  • 커넥션을 매번 새로 만들지 않고 커넥션 풀로 재사용한다.

  • 사용자 입력값은 Prepared Statement로 전달한다.

  • ORM을 사용할 때 숨겨진 쿼리 비용을 확인한다.

  • 트랜잭션 범위를 서비스 로직과 맞춘다.

언어는 달라도 원리는 같습니다. Node.js, Java, Python, PHP, Go 중 무엇을 사용해도 애플리케이션과 MySQL 사이에는 항상 "연결, 쿼리 실행, 결과 처리, 오류 처리, 연결 반환"이라는 흐름이 있습니다.

애플리케이션과 MySQL 연동하기

애플리케이션이 MySQL에 접속하는 흐름

애플리케이션이 MySQL을 사용하려면 먼저 커넥션이 필요합니다. 커넥션은 애플리케이션과 MySQL 서버 사이에 만들어진 통신 통로입니다.

가장 단순한 흐름은 다음과 같습니다.

  1. MySQL 서버에 접속한다.

  2. SQL을 보낸다.

  3. 결과를 받는다.

  4. 접속을 종료한다.

예를 들어 웹 요청 하나가 들어올 때마다 MySQL에 새로 접속하고, 조회가 끝난 뒤 접속을 끊는 방식도 가능합니다. 하지만 이 방식은 비효율적입니다. 커넥션을 새로 만드는 과정에는 인증, 네트워크 연결, 세션 초기화 같은 비용이 들어갑니다.

사용자가 적을 때는 문제가 없어 보일 수 있습니다. 하지만 동시에 많은 요청이 들어오면 커넥션 생성 비용이 누적되고, MySQL 서버도 불필요한 접속 처리로 바빠집니다.

그래서 대부분의 웹 애플리케이션은 커넥션 풀을 사용합니다.

커넥션 풀로 연결을 재사용하기

커넥션 풀은 미리 만들어 둔 커넥션 묶음입니다. 애플리케이션은 요청이 들어올 때 풀에서 커넥션을 빌려 쓰고, 작업이 끝나면 닫는 대신 다시 풀에 돌려줍니다.

흐름은 다음과 같습니다.

  1. 애플리케이션 시작 시 커넥션 풀을 만든다.

  2. 요청이 들어오면 풀에서 사용 가능한 커넥션을 가져온다.

  3. SQL을 실행한다.

  4. 작업이 끝나면 커넥션을 풀에 반환한다.

  5. 다음 요청이 같은 커넥션을 재사용한다.

Node.js의 mysql2 라이브러리를 예로 들면 다음과 같이 풀을 만들 수 있습니다.

import mysql from 'mysql2/promise';

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

여기서 중요한 값은 connectionLimit입니다. 동시에 열어 둘 수 있는 최대 커넥션 수를 의미합니다.

값을 크게 잡는다고 항상 좋은 것은 아닙니다. 애플리케이션 서버가 여러 대라면 전체 커넥션 수는 다음처럼 계산해야 합니다.

전체 커넥션 수 = 애플리케이션 서버 수 × 서버당 커넥션 풀 크기

예를 들어 애플리케이션 서버가 5대이고 각 서버의 풀 크기가 20이면, MySQL 입장에서는 최대 100개의 커넥션이 열릴 수 있습니다. MySQL의 max_connections 값과 서버 자원을 함께 고려해야 합니다.

커넥션 풀을 사용할 때는 다음 원칙을 지키는 것이 좋습니다.

  • 풀은 요청마다 만들지 말고 애플리케이션 시작 시 한 번 만든다.

  • 작업이 끝난 커넥션은 반드시 반환한다.

  • 트랜잭션 중인 커넥션은 다른 요청과 공유하지 않는다.

  • 풀 크기는 애플리케이션 서버 수와 MySQL 설정을 함께 보고 정한다.

일반 조회는 풀에서 바로 실행해도 됩니다.

const [rows] = await pool.execute(
  'SELECT id, name, email FROM users WHERE id = ?',
  [userId]
);

이 코드에서 pool.execute()는 풀에서 커넥션을 가져와 쿼리를 실행하고, 실행이 끝난 뒤 다시 반환합니다. 단순 조회에는 이런 방식이 충분합니다.

설정 정보는 코드에 직접 쓰지 않는다

데이터베이스 접속 정보는 민감한 정보입니다. 호스트, 사용자 이름, 비밀번호, 데이터베이스 이름을 코드에 직접 쓰면 안 됩니다.

나쁜 예는 다음과 같습니다.

const pool = mysql.createPool({
  host: 'prod-db.example.com',
  user: 'admin',
  password: 'secret1234',
  database: 'shop'
});

이런 코드는 Git 저장소에 올라갈 수 있고, 협업자나 외부에 노출될 수 있습니다. 실무에서는 보통 환경 변수를 사용합니다.

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME
});

운영 환경에서는 비밀번호를 주기적으로 교체하고, 애플리케이션 계정에는 필요한 권한만 부여해야 합니다. 예를 들어 주문 조회용 서비스는 SELECT 권한만 필요할 수 있고, 주문 생성 서비스는 특정 테이블에 대한 INSERT, UPDATE 권한이 필요할 수 있습니다.

권한은 넓게 주는 것보다 좁게 주는 것이 안전합니다. 애플리케이션 계정에 무조건 root 계정을 사용해서는 안 됩니다.

SQL 인젝션이란 무엇인가

애플리케이션에서 가장 조심해야 할 문제 중 하나는 SQL 인젝션입니다. SQL 인젝션은 사용자 입력값이 SQL 문장 일부로 섞이면서 의도하지 않은 SQL이 실행되는 공격입니다.

다음 코드는 위험합니다.

const email = req.query.email;

const sql = "SELECT id, name, email FROM users WHERE email = '" + email + "'";
const [rows] = await pool.query(sql);

사용자가 정상적으로 이메일을 입력하면 다음과 같은 SQL이 만들어집니다.

SELECT id, name, email
FROM users
WHERE email = '[email protected]';

문제는 사용자가 악의적인 문자열을 입력할 때입니다.

' OR '1' = '1

그러면 SQL이 다음처럼 바뀔 수 있습니다.

SELECT id, name, email
FROM users
WHERE email = '' OR '1' = '1';

'1' = '1'은 항상 참입니다. 의도했던 한 명의 사용자 조회가 아니라 모든 사용자를 조회하는 쿼리가 될 수 있습니다.

핵심 문제는 사용자 입력값을 SQL 문자열에 직접 이어 붙였다는 점입니다.

Prepared Statement로 값을 안전하게 전달하기

SQL 인젝션을 막는 기본 방법은 Prepared Statement를 사용하는 것입니다. Prepared Statement는 SQL 구조와 값을 분리해서 전달하는 방식입니다.

다음처럼 작성합니다.

const email = req.query.email;

const [rows] = await pool.execute(
  'SELECT id, name, email FROM users WHERE email = ?',
  [email]
);

여기서 ?는 값이 들어갈 자리입니다. 실제 값은 두 번째 인자인 배열로 전달합니다.

중요한 차이는 이것입니다.

  • SQL 문자열: SELECT id, name, email FROM users WHERE email = ?

  • 값: [email]

MySQL 드라이버는 사용자가 입력한 값을 SQL 문법으로 해석하지 않고 데이터 값으로 처리합니다. 사용자가 특수문자를 입력해도 SQL 구조가 바뀌지 않습니다.

여러 값을 전달할 때도 같은 방식입니다.

const [rows] = await pool.execute(
  `SELECT id, name, price
   FROM products
   WHERE category_id = ?
     AND price BETWEEN ? AND ?`,
  [categoryId, minPrice, maxPrice]
);

INSERT와 UPDATE도 동일합니다.

await pool.execute(
  `INSERT INTO users (name, email, created_at)
   VALUES (?, ?, NOW())`,
  [name, email]
);
await pool.execute(
  `UPDATE users
   SET name = ?, updated_at = NOW()
   WHERE id = ?`,
  [name, userId]
);

Prepared Statement는 보안만을 위한 기능이 아닙니다. SQL 구조가 고정되기 때문에 같은 형태의 쿼리를 반복 실행할 때 드라이버나 데이터베이스가 더 효율적으로 처리할 여지도 있습니다. 하지만 이 장에서는 성능보다 안전성이 더 중요합니다.

실무에서 기억할 규칙은 간단합니다.

사용자 입력값은 SQL 문자열에 직접 붙이지 않는다. 항상 파라미터로 전달한다.

동적 SQL을 만들 때 조심할 점

검색 화면에서는 조건이 매번 달라집니다. 예를 들어 이름, 이메일, 가입일 조건이 있을 수도 있고 없을 수도 있습니다. 이때 동적 SQL이 필요합니다.

위험한 방식은 조건 문자열에 사용자 입력값을 직접 붙이는 것입니다.

let sql = 'SELECT id, name, email FROM users WHERE 1=1';

if (name) {
  sql += " AND name LIKE '%" + name + "%'";
}

안전한 방식은 SQL 조각과 파라미터 배열을 함께 관리하는 것입니다.

let sql = 'SELECT id, name, email FROM users WHERE 1=1';
const params = [];

if (name) {
  sql += ' AND name LIKE ?';
  params.push(`%${name}%`);
}

if (email) {
  sql += ' AND email = ?';
  params.push(email);
}

sql += ' ORDER BY id DESC LIMIT ? OFFSET ?';
params.push(limit, offset);

const [rows] = await pool.execute(sql, params);

정렬 조건도 주의해야 합니다. 컬럼명이나 정렬 방향은 Prepared Statement의 값 자리로 처리하기 어렵습니다. 이때는 허용 목록을 사용합니다.

const allowedSortColumns = ['id', 'name', 'created_at'];
const allowedDirections = ['ASC', 'DESC'];

const sortColumn = allowedSortColumns.includes(req.query.sort)
  ? req.query.sort
  : 'id';

const direction = allowedDirections.includes(req.query.direction)
  ? req.query.direction
  : 'DESC';

const sql = `
  SELECT id, name, email
  FROM users
  ORDER BY ${sortColumn} ${direction}
  LIMIT ? OFFSET ?
`;

const [rows] = await pool.execute(sql, [limit, offset]);

사용자가 입력한 컬럼명을 그대로 SQL에 넣으면 위험합니다. 정렬 컬럼, 정렬 방향, 검색 대상 컬럼처럼 SQL 구조에 들어가는 값은 반드시 허용된 값 중에서만 선택하게 해야 합니다.

결과를 그대로 믿지 말고 형태를 정리한다

MySQL에서 가져온 결과를 그대로 응답으로 내보내는 습관도 조심해야 합니다.

예를 들어 users 테이블에 password_hash, reset_token, admin_memo 같은 컬럼이 있다고 가정해 보겠습니다. 다음 코드는 위험할 수 있습니다.

const [rows] = await pool.execute('SELECT * FROM users WHERE id = ?', [userId]);
res.json(rows[0]);

SELECT *는 편해 보이지만, 응답에 포함하면 안 되는 컬럼까지 외부에 노출할 수 있습니다.

더 안전한 방식은 필요한 컬럼만 조회하고, 응답 형태를 명확하게 만드는 것입니다.

const [rows] = await pool.execute(
  `SELECT id, name, email, created_at
   FROM users
   WHERE id = ?`,
  [userId]
);

if (rows.length === 0) {
  return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
}

const user = rows[0];

res.json({
  id: user.id,
  name: user.name,
  email: user.email,
  createdAt: user.created_at
});

이 방식은 코드가 조금 길어지지만, API가 어떤 데이터를 반환하는지 분명해집니다.

ORM은 SQL을 대신 써 주는 도구다

ORM은 Object-Relational Mapping의 줄임말입니다. 객체와 관계형 데이터베이스를 연결해 주는 도구입니다.

애플리케이션 코드에서는 보통 객체를 다룹니다.

user.name
user.email

반면 MySQL은 테이블과 행을 다룹니다.

SELECT id, name, email FROM users;

ORM은 이 둘 사이의 변환을 도와줍니다. ORM을 사용하면 다음과 같은 코드로 데이터를 조회할 수 있습니다.

const user = await User.findByPk(userId);

또는 다음처럼 새 데이터를 저장할 수 있습니다.

await User.create({
  name: 'Kim',
  email: '[email protected]'
});

ORM의 장점은 분명합니다.

  • 반복적인 SQL 작성이 줄어든다.

  • 테이블과 객체 사이의 변환을 자동으로 처리한다.

  • CRUD 코드를 빠르게 만들 수 있다.

  • 마이그레이션, 관계 설정, 검증 기능을 함께 제공하는 경우가 많다.

하지만 ORM을 사용해도 MySQL을 몰라도 되는 것은 아닙니다. ORM은 SQL을 없애는 도구가 아니라 SQL을 대신 만들어 주는 도구입니다. 그래서 ORM이 실제로 어떤 SQL을 실행하는지 확인할 수 있어야 합니다.

ORM의 대표적인 함정: N+1 쿼리

ORM을 사용할 때 자주 발생하는 성능 문제가 N+1 쿼리입니다.

예를 들어 주문 목록을 조회하고, 각 주문의 상품 목록을 함께 보여 주는 화면이 있다고 가정해 보겠습니다.

주문이 10개라면 개발자는 다음처럼 생각할 수 있습니다.

  1. 주문 목록을 조회한다.

  2. 각 주문마다 주문 상품을 조회한다.

코드로는 자연스러워 보입니다.

const orders = await Order.findAll({ where: { userId } });

for (const order of orders) {
  order.items = await OrderItem.findAll({
    where: { orderId: order.id }
  });
}

하지만 실제 SQL은 다음처럼 실행될 수 있습니다.

주문 목록 조회 1번
주문 1의 상품 조회 1번
주문 2의 상품 조회 1번
주문 3의 상품 조회 1번
...
주문 10의 상품 조회 1번

총 11번의 쿼리가 실행됩니다. 그래서 N+1 쿼리라고 부릅니다. N개의 주문을 가져온 뒤, 각 주문마다 추가 쿼리 1번씩 실행되는 구조입니다.

주문이 10개일 때는 괜찮아 보일 수 있습니다. 하지만 주문이 1,000개라면 쿼리가 1,001번 실행됩니다. 응답 속도는 급격히 느려지고 MySQL 서버에도 부담이 커집니다.

해결 방법은 관련 데이터를 한 번에 가져오거나, 필요한 범위만 묶어서 조회하는 것입니다.

SQL로는 조인을 사용할 수 있습니다.

SELECT o.id AS order_id,
       o.order_number,
       o.created_at,
       oi.id AS item_id,
       oi.product_id,
       oi.quantity
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.user_id = ?
ORDER BY o.id DESC;

ORM에서는 보통 include, join fetch, preload, eager loading 같은 기능을 사용합니다. 이름은 도구마다 다르지만 목적은 같습니다. 관련 데이터를 나중에 하나씩 가져오지 말고, 필요한 시점에 함께 가져오는 것입니다.

ORM을 사용할 때는 다음 습관이 중요합니다.

  • 개발 환경에서 실행 SQL 로그를 확인한다.

  • 목록 화면에서 쿼리 개수가 데이터 개수에 비례해 늘어나는지 본다.

  • 조인이 필요한 곳과 별도 조회가 나은 곳을 구분한다.

  • 페이지네이션 없이 대량 데이터를 한 번에 가져오지 않는다.

ORM은 편리하지만, 성능 책임까지 대신 져 주지는 않습니다.

트랜잭션은 서비스 로직 단위로 잡는다

트랜잭션은 여러 SQL을 하나의 작업처럼 묶는 기능입니다. 앞에서 배운 것처럼 트랜잭션 안의 작업은 모두 성공하면 커밋하고, 중간에 문제가 생기면 롤백합니다.

애플리케이션에서는 트랜잭션 범위를 서비스 로직과 맞추는 것이 중요합니다.

예를 들어 주문 생성 로직을 생각해 보겠습니다.

  1. 주문을 생성한다.

  2. 주문 상품을 생성한다.

  3. 상품 재고를 차감한다.

  4. 결제 대기 상태를 기록한다.

이 작업 중 하나라도 실패하면 전체를 취소해야 합니다. 주문은 생성됐는데 재고 차감에 실패하면 데이터가 어긋납니다. 반대로 재고는 줄었는데 주문이 생성되지 않아도 문제가 됩니다.

따라서 이 로직은 하나의 트랜잭션으로 묶어야 합니다.

const conn = await pool.getConnection();

try {
  await conn.beginTransaction();

  const [orderResult] = await conn.execute(
    `INSERT INTO orders (user_id, status, created_at)
     VALUES (?, 'PENDING', NOW())`,
    [userId]
  );

  const orderId = orderResult.insertId;

  for (const item of items) {
    await conn.execute(
      `INSERT INTO order_items (order_id, product_id, quantity, price)
       VALUES (?, ?, ?, ?)`,
      [orderId, item.productId, item.quantity, item.price]
    );

    const [stockResult] = await conn.execute(
      `UPDATE products
       SET stock = stock - ?
       WHERE id = ?
         AND stock >= ?`,
      [item.quantity, item.productId, item.quantity]
    );

    if (stockResult.affectedRows === 0) {
      throw new Error('재고가 부족합니다.');
    }
  }

  await conn.commit();

  return orderId;
} catch (err) {
  await conn.rollback();
  throw err;
} finally {
  conn.release();
}

이 코드에서 중요한 부분은 네 가지입니다.

첫째, 트랜잭션을 시작한 커넥션으로 모든 SQL을 실행해야 합니다.

const conn = await pool.getConnection();
await conn.beginTransaction();

트랜잭션은 커넥션 단위로 동작합니다. pool.execute()를 섞어 쓰면 다른 커넥션에서 SQL이 실행될 수 있습니다. 그러면 같은 트랜잭션에 포함되지 않습니다.

둘째, 성공하면 commit()을 호출합니다.

await conn.commit();

셋째, 오류가 나면 rollback()을 호출합니다.

await conn.rollback();

넷째, 마지막에는 반드시 커넥션을 풀에 반환합니다.

conn.release();

finally 블록에서 반환하는 이유는 성공하든 실패하든 커넥션을 돌려줘야 하기 때문입니다. 커넥션을 반환하지 않으면 풀의 커넥션이 점점 고갈되고, 결국 새 요청이 데이터베이스를 사용할 수 없게 됩니다.

트랜잭션 안에서 오래 머물지 않는다

트랜잭션은 데이터를 안전하게 지켜 주지만, 너무 오래 열려 있으면 성능 문제가 됩니다. 트랜잭션이 열린 동안에는 잠금이 유지될 수 있고, 다른 요청이 기다려야 할 수 있습니다.

특히 다음 작업을 트랜잭션 안에 넣는 것은 조심해야 합니다.

  • 외부 API 호출

  • 이메일 발송

  • 파일 업로드

  • 사용자 입력을 기다리는 작업

  • 오래 걸리는 대량 계산

예를 들어 결제 API를 호출하는 동안 트랜잭션을 열어 둔다면, 외부 결제사의 응답이 늦어질 때 MySQL의 잠금도 오래 유지될 수 있습니다.

좋은 트랜잭션은 짧고 명확합니다.

  • 데이터 일관성에 꼭 필요한 SQL만 포함한다.

  • 외부 시스템 호출은 가능하면 트랜잭션 밖으로 분리한다.

  • 잠금이 필요한 행을 좁게 잡는다.

  • 실패 가능성이 큰 검증은 트랜잭션 시작 전에 먼저 처리한다.

물론 모든 작업을 완전히 분리할 수는 없습니다. 결제, 재고, 주문처럼 외부 시스템과 데이터베이스 상태를 함께 다뤄야 하는 경우에는 별도의 보상 처리나 상태 관리가 필요합니다. 이 책에서는 깊게 다루지 않지만, 기본 원칙은 같습니다. 데이터베이스 트랜잭션은 짧게 유지하고, 외부 작업 실패에 대비한 상태를 명확히 남겨야 합니다.

오류 처리는 숨기지 말고 분류한다

데이터베이스 오류는 여러 종류가 있습니다.

  • SQL 문법 오류

  • 중복 키 오류

  • 외래키 제약 조건 오류

  • 커넥션 오류

  • 타임아웃

  • 데드락

  • 권한 오류

모든 오류를 같은 메시지로 처리하면 문제를 찾기 어렵습니다. 반대로 내부 오류를 사용자에게 그대로 보여 주면 보안상 좋지 않습니다.

좋은 방식은 내부 로그와 사용자 응답을 분리하는 것입니다.

try {
  await createUser(name, email);
  res.status(201).json({ message: '가입이 완료되었습니다.' });
} catch (err) {
  logger.error({ err }, '사용자 생성 실패');

  if (err.code === 'ER_DUP_ENTRY') {
    return res.status(409).json({ message: '이미 사용 중인 이메일입니다.' });
  }

  res.status(500).json({ message: '일시적인 오류가 발생했습니다.' });
}

사용자에게는 이해할 수 있는 메시지를 보여 주고, 서버 로그에는 원인 분석에 필요한 정보를 남깁니다. 단, 로그에 비밀번호, 토큰, 주민등록번호 같은 민감 정보를 남기면 안 됩니다.

데드락이나 일시적인 잠금 대기는 재시도 전략이 필요할 수 있습니다. 예를 들어 같은 상품의 재고를 동시에 차감하는 요청이 많으면 일부 요청은 데드락을 만날 수 있습니다. 이때는 짧은 지연 후 제한된 횟수만 재시도하는 방식이 사용됩니다.

하지만 모든 오류를 재시도해서는 안 됩니다. SQL 문법 오류나 권한 오류는 재시도해도 해결되지 않습니다. 재시도는 일시적인 충돌이나 네트워크 문제처럼 다시 시도하면 성공할 가능성이 있는 경우에만 적용해야 합니다.

페이지네이션은 처음부터 고려한다

웹 애플리케이션은 목록 화면을 자주 만듭니다. 사용자 목록, 주문 목록, 게시글 목록, 상품 목록이 대표적입니다.

처음에는 데이터가 적기 때문에 다음 쿼리도 빠르게 실행됩니다.

SELECT id, title, created_at
FROM posts
ORDER BY id DESC;

하지만 데이터가 수십만 건이 되면 전체 목록을 한 번에 가져오는 방식은 문제가 됩니다. 애플리케이션 메모리도 많이 쓰고, 네트워크 전송량도 커지고, MySQL도 많은 데이터를 읽어야 합니다.

기본적인 페이지네이션은 LIMIT과 OFFSET을 사용합니다.

SELECT id, title, created_at
FROM posts
ORDER BY id DESC
LIMIT 20 OFFSET 40;

이 쿼리는 3페이지를 가져오는 예입니다. 한 페이지에 20개씩 보여 준다면 OFFSET 40은 앞의 40개를 건너뛰라는 뜻입니다.

OFFSET 방식은 이해하기 쉽지만 뒤쪽 페이지로 갈수록 비효율적일 수 있습니다. MySQL은 건너뛸 행도 일정 부분 읽어야 하기 때문입니다.

대량 데이터에서는 커서 기반 페이지네이션을 고려할 수 있습니다.

SELECT id, title, created_at
FROM posts
WHERE id < ?
ORDER BY id DESC
LIMIT 20;

마지막으로 본 게시글의 id를 기준으로 그보다 작은 데이터를 가져오는 방식입니다. 인덱스를 잘 활용하면 깊은 페이지에서도 안정적인 성능을 얻기 쉽습니다.

목록 API를 만들 때는 다음을 기본값으로 생각하면 좋습니다.

  • 한 번에 가져올 개수를 제한한다.

  • 정렬 기준에 맞는 인덱스를 준비한다.

  • SELECT * 대신 필요한 컬럼만 가져온다.

  • 전체 개수 조회가 필요한지 다시 생각한다.

읽기와 쓰기를 구분하는 애플리케이션 구조

앞 장에서 복제와 읽기 분산을 다루었습니다. 애플리케이션에서도 이를 의식해야 합니다.

기본 원칙은 다음과 같습니다.

  • 쓰기 작업은 주 서버로 보낸다.

  • 읽기 작업은 상황에 따라 복제 서버로 보낼 수 있다.

예를 들어 상품 목록 조회, 게시글 조회, 통계 조회는 복제 서버에서 처리할 수 있습니다. 반면 주문 생성, 결제 상태 변경, 재고 차감은 반드시 주 서버에서 처리해야 합니다.

주의할 점은 복제 지연입니다. 사용자가 글을 작성한 직후 복제 서버에서 글 목록을 조회하면 방금 작성한 글이 아직 보이지 않을 수 있습니다.

이런 문제를 줄이기 위해 다음 전략을 사용할 수 있습니다.

  • 쓰기 직후 일정 시간 동안은 주 서버에서 읽는다.

  • 사용자 본인이 방금 변경한 데이터는 주 서버에서 읽는다.

  • 약간 늦어도 되는 통계성 조회만 복제 서버로 보낸다.

  • 복제 지연을 모니터링하고 지연이 크면 읽기 분산을 일시적으로 줄인다.

애플리케이션 코드는 데이터의 성격을 알아야 합니다. 모든 읽기를 무조건 복제 서버로 보내는 방식은 안전하지 않습니다.

API 하나를 예로 전체 흐름 보기

회원의 주문 목록을 조회하는 API를 만든다고 가정해 보겠습니다. 좋은 구현은 지금까지 배운 원칙을 함께 사용합니다.

app.get('/users/:userId/orders', async (req, res) => {
  const userId = Number(req.params.userId);
  const limit = Math.min(Number(req.query.limit) || 20, 50);
  const lastOrderId = req.query.lastOrderId ? Number(req.query.lastOrderId) : null;

  if (!Number.isInteger(userId) || userId <= 0) {
    return res.status(400).json({ message: '잘못된 사용자 ID입니다.' });
  }

  try {
    const params = [userId];
    let sql = `
      SELECT id, status, total_price, created_at
      FROM orders
      WHERE user_id = ?
    `;

    if (lastOrderId) {
      sql += ' AND id < ?';
      params.push(lastOrderId);
    }

    sql += ' ORDER BY id DESC LIMIT ?';
    params.push(limit);

    const [rows] = await pool.execute(sql, params);

    res.json({
      items: rows.map(row => ({
        id: row.id,
        status: row.status,
        totalPrice: row.total_price,
        createdAt: row.created_at
      })),
      nextCursor: rows.length === limit ? rows[rows.length - 1].id : null
    });
  } catch (err) {
    logger.error({ err }, '주문 목록 조회 실패');
    res.status(500).json({ message: '주문 목록을 불러오지 못했습니다.' });
  }
});

이 코드에는 여러 원칙이 들어 있습니다.

  • 사용자 입력값을 숫자로 검증한다.

  • SQL 값은 Prepared Statement 파라미터로 전달한다.

  • LIMIT에 최대값을 둔다.

  • 필요한 컬럼만 조회한다.

  • 응답 형태를 명확하게 만든다.

  • 오류는 로그로 남기고 사용자에게는 일반화된 메시지를 보낸다.

좋은 애플리케이션 코드는 SQL만 잘 쓰는 코드가 아닙니다. 입력, 권한, 성능, 오류, 응답 형태를 함께 고려하는 코드입니다.

실무에서 자주 하는 실수

애플리케이션과 MySQL을 연동할 때 자주 나오는 실수는 반복됩니다.

첫째, 요청마다 커넥션 풀을 새로 만듭니다. 풀은 애플리케이션 전체에서 재사용해야 합니다. 요청마다 풀을 만들면 커넥션 관리가 더 나빠집니다.

둘째, SQL 문자열에 사용자 입력값을 직접 붙입니다. 작은 검색 기능에서도 SQL 인젝션은 발생할 수 있습니다. 항상 Prepared Statement를 사용해야 합니다.

셋째, 트랜잭션 중에 다른 커넥션을 사용합니다. 트랜잭션은 커넥션 단위입니다. 트랜잭션을 시작한 커넥션으로 모든 SQL을 실행해야 합니다.

넷째, ORM이 만든 SQL을 확인하지 않습니다. ORM 코드는 짧아도 실제 SQL은 많거나 비효율적일 수 있습니다. 특히 목록 화면에서 N+1 쿼리를 확인해야 합니다.

다섯째, 운영 데이터베이스에 관리자 계정으로 접속합니다. 애플리케이션 계정은 필요한 권한만 가져야 합니다.

여섯째, 타임아웃을 설정하지 않습니다. 쿼리나 네트워크 문제가 생겼을 때 요청이 무한정 대기하면 장애가 커집니다. 애플리케이션, 드라이버, 프록시, MySQL 설정에서 적절한 제한 시간을 두어야 합니다.

연동 코드 점검 체크리스트

애플리케이션에서 MySQL을 사용할 때는 다음 항목을 점검해 보세요.

  • 커넥션 풀을 사용하고 있는가?

  • 풀 크기는 애플리케이션 서버 수와 함께 계산했는가?

  • DB 접속 정보는 환경 변수나 안전한 설정 저장소에서 읽는가?

  • 사용자 입력값을 SQL에 직접 붙이지 않는가?

  • 모든 조건값은 Prepared Statement 파라미터로 전달하는가?

  • 정렬 컬럼처럼 SQL 구조에 들어가는 값은 허용 목록으로 제한하는가?

  • SELECT * 대신 필요한 컬럼만 조회하는가?

  • 목록 API에 LIMIT이 있는가?

  • ORM 사용 시 실제 실행 SQL을 확인하는가?

  • N+1 쿼리가 발생하지 않는가?

  • 트랜잭션은 하나의 커넥션에서 실행되는가?

  • 오류 발생 시 롤백하고 커넥션을 반환하는가?

  • 외부 API 호출을 트랜잭션 안에서 오래 기다리지 않는가?

  • 데이터베이스 오류를 로그로 남기되 민감 정보는 제외하는가?

  • 애플리케이션 계정에 필요한 권한만 부여했는가?

이 체크리스트는 단순하지만 강력합니다. 대부분의 실무 문제는 복잡한 기술 부족보다 기본 원칙을 놓쳐서 생깁니다.

마무리

MySQL을 잘 배운다는 것은 SQL 문법을 많이 아는 것만을 의미하지 않습니다. 실제 애플리케이션 코드에서 MySQL을 안전하고 효율적으로 사용하는 능력까지 포함합니다.

커넥션 풀은 연결 비용을 줄이고 서버 자원을 안정적으로 사용하게 해 줍니다. Prepared Statement는 사용자 입력값을 안전하게 전달해 SQL 인젝션을 막습니다. ORM은 개발 속도를 높여 주지만, 실제 실행되는 SQL을 확인하지 않으면 N+1 쿼리 같은 성능 문제가 생길 수 있습니다. 트랜잭션은 서비스 로직의 일관성을 지키는 도구이며, 짧고 명확하게 유지해야 합니다.

다음 장에서는 지금까지 배운 내용을 바탕으로 실전 프로젝트를 구성합니다. 테이블을 설계하고, 데이터를 입력하고, 조회 API를 만들고, 인덱스와 트랜잭션을 적용하면서 MySQL 학습을 하나의 흐름으로 완성해 보겠습니다.

책 목록

처음부터 실무까지 배우는 MySQL

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기