스케일러블 애플리케이션을 위해 모든 개발자가 마스터해야 할 7가지 필수 데이터 액세스 패턴

발행: (2025년 12월 13일 오후 10:49 GMT+9)
6 min read
원문: Dev.to

Source: Dev.to

Repository Pattern

앱에서 주요 데이터 유형마다 상자를 하나씩 가지고 있다고 상상해 보세요: User 상자, Order 상자, Product 상자. 상자 안에 무엇이 들어 있는지, 어떻게 정리되는지는 신경 쓰지 않고 “이 이메일을 가진 사용자를 줘” 혹은 “새 주문을 저장해” 라고만 말하면 됩니다. 상자는 나머지를 처리합니다.

코드에서 Repository는 데이터에 대해 컬렉션과 같은 인터페이스를 제공하는 클래스입니다.

// UserRepository.ts
interface UserRepository {
  findById(id: string): Promise;
  findByEmail(email: string): Promise;
  save(user: User): Promise;
  delete(id: string): Promise;
}

인터페이스는 계약입니다: 모든 구현은 이 메서드들을 제공해야 합니다.

// PostgresUserRepository.ts
import { Pool } from 'pg';

class PostgresUserRepository implements UserRepository {
  constructor(private pool: Pool) {}

  async findById(id: string): Promise {
    const result = await this.pool.query(
      'SELECT * FROM users WHERE id = $1',
      [id]
    );
    return result.rows[0] || null;
  }

  async findByEmail(email: string): Promise {
    const result = await this.pool.query(
      'SELECT * FROM users WHERE email = $1',
      [email]
    );
    return result.rows[0] || null;
  }

  async save(user: User): Promise {
    await this.pool.query(
      `INSERT INTO users (id, email, name) 
       VALUES ($1, $2, $3)
       ON CONFLICT (id) DO UPDATE SET
       email = $2, name = $3`,
      [user.id, user.email, user.name]
    );
  }

  async delete(id: string): Promise {
    const result = await this.pool.query(
      'DELETE FROM users WHERE id = $1',
      [id]
    );
    return result.rowCount > 0;
  }
}

애플리케이션 코드는 UserRepository에만 의존합니다. PostgreSQL에서 MySQL로 전환하려면 같은 인터페이스를 만족하는 새로운 구현을 제공하면 됩니다.


Query Builder

복잡한 쿼리를 위해 원시 SQL 문자열을 직접 작성하면 가독성이 떨어지고 유지보수가 어려워집니다. Query Builder는 메서드 체이닝으로 쿼리를 구성하게 해 주어 가독성과 안전성을 높여 줍니다.

Knex.js 예시

// Get a paginated list of active users
const activeUsers = await knex('users')
  .select('id', 'email', 'created_at')
  .where('status', 'active')
  .whereBetween('created_at', [startDate, endDate])
  .orderBy('created_at', 'desc')
  .limit(20)
  .offset(40);

조인과 집계

// Find users who have spent more than $1000 total
const bigSpenders = await knex('orders')
  .join('users', 'orders.user_id', 'users.id')
  .select([
    'users.email',
    knex.raw('SUM(orders.total_amount) as lifetime_value')
  ])
  .groupBy('users.email')
  .having('lifetime_value', '>', 1000);

Builder는 서로 다른 방언(PostgreSQL, MySQL, SQLite)의 차이를 추상화하고 값을 자동으로 파라미터화하여 SQL 인젝션을 방지합니다.


Data Mapper

데이터베이스에 저장된 데이터 형태와 애플리케이션에서 사용하는 객체 형태가 다를 때가 많습니다. Data Mapper 패턴은 이러한 변환 로직을 중앙에 모아 관리합니다.

// UserMapper.ts
class UserMapper {
  toEntity(row: UserRecord): User {
    // Transform snake_case to camelCase and parse dates
    return new User(
      row.id,
      row.email,
      row.full_name,
      new Date(row.created_at)
    );
  }

  toPersist(user: User): UserRecord {
    // Prepare object for persistence
    return {
      id: user.id,
      email: user.email,
      full_name: user.name,
      created_at: user.joinedDate.toISOString(),
    };
  }
}

서비스 레이어에서 사용하기

// UserService.ts
class UserService {
  constructor(
    private repository: UserRepository,
    private mapper: UserMapper
  ) {}

  async getUserProfile(id: string): Promise {
    const record = await this.repository.findById(id);
    if (!record) throw new Error('User not found');
    const userEntity = this.mapper.toEntity(record);
    return this.buildProfile(userEntity);
  }

  private buildProfile(user: User): UserProfile {
    // Build and return a profile DTO
    // ...
  }
}

도메인 엔티티(User)는 순수하게 유지되며, 영속성에 대한 고민이 없습니다.


Connection Pooling

각 쿼리마다 데이터베이스 연결을 열고 닫는 것은 비효율적입니다. 커넥션 풀은 재사용 가능한 연결 집합을 유지합니다.

// pool.js
const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20,                // Maximum number of connections
  idleTimeoutMillis: 30000, // Close idle connections after 30 s
});

module.exports = pool;

풀 사용 예시

// orders.js
const pool = require('./pool');

async function getUserOrders(userId) {
  const client = await pool.connect(); // Checkout a connection
  try {
    const result = await client.query(
      'SELECT * FROM orders WHERE user_id = $1',
      [userId]
    );
    return result.rows;
  } finally {
    client.release(); // Return the connection to the pool
  }
}

풀링은 특히 부하가 걸릴 때 지연 시간과 자원 소비를 크게 줄여 줍니다.


Database Migrations

애플리케이션이 성장함에 따라 스키마도 반복 가능하고 버전 관리된 방식으로 변경되어야 합니다(컬럼 추가, 인덱스 생성, 테이블 분할 등). 데이터베이스 마이그레이션 도구는 환경마다 점진적인 스키마 변화를 체계적으로 적용할 수 있게 해 줍니다.

일반적인 워크플로:

  1. 마이그레이션 파일 생성 – 변경 내용을 기술합니다(예: add_users_last_login.sql).
  2. 마이그레이션 실행 – CLI 또는 프로그래밍 API를 사용하고, 도구가 적용된 마이그레이션을 기록합니다.
  3. 배포 – 동일한 마이그레이션을 스테이징, 테스트, 프로덕션에 적용해 일관성을 유지합니다.

주요 마이그레이션 라이브러리로는 Knex migrations, Flyway, Liquibase, Prisma Migrate 등이 있습니다. 스택에 맞는 것을 선택하고 CI/CD 파이프라인에 통합하세요.

Back to Blog

관련 글

더 보기 »