Backend

백엔드 데이터 계층 설계와 SQL/NoSQL 운영 관점

SQL, Oracle, MongoDB, Azure Storage, Cosmos DB 실습 내용을 바탕으로 백엔드 데이터 계층을 설계할 때 확인해야 할 모델링, 쿼리, 트랜잭션, 인덱스, 파티션 기준을 정리합니다.

백엔드 데이터 계층 설계와 SQL/NoSQL 운영 관점

백엔드에서 데이터 계층을 설계할 때 가장 먼저 결정해야 하는 것은 어떤 데이터베이스를 쓸지보다, 데이터가 어떤 방식으로 읽히고 변경되는지다. 같은 회원, 주문, 상품 데이터라도 강한 정합성이 필요한지, 문서 단위로 함께 읽히는지, 조회 패턴이 고정되어 있는지, 시간이 지나면 보관 계층으로 내려가도 되는지에 따라 설계가 달라진다.

SQL, Oracle, MongoDB, Azure Storage, Cosmos DB 실습을 따로 보면 각각 다른 주제처럼 보인다. 하지만 실제 애플리케이션에서는 관계형 모델, 문서 모델, 오브젝트 스토리지, 키/값 저장소가 한 시스템 안에서 함께 쓰인다. 그래서 문법을 외우는 것보다 각 저장소가 잘하는 일과 운영 중 문제가 되는 지점을 연결해서 보는 편이 훨씬 유용하다.

RDBMS는 관계와 트랜잭션을 중심으로 본다

관계형 데이터베이스의 강점은 데이터를 테이블로 나누고, 기본키와 외래키로 관계를 표현하며, 트랜잭션으로 변경 단위를 보호하는 데 있다. 회원과 주문처럼 정합성이 중요한 도메인에서는 이 장점이 뚜렷하다.

create table members (
  id bigint generated by default as identity,
  email varchar(255) not null,
  name varchar(100) not null,
  created_at timestamp not null default current_timestamp,
  primary key (id),
  unique (email)
);

create table orders (
  id bigint generated by default as identity,
  member_id bigint not null,
  status varchar(30) not null,
  total_amount numeric(12, 2) not null,
  created_at timestamp not null default current_timestamp,
  primary key (id),
  foreign key (member_id) references members(id)
);

여기서 unique (email)은 애플리케이션의 중복 회원 검증과 별개로 데이터베이스가 마지막 방어선을 맡게 한다. 서비스 코드에서 먼저 중복 여부를 확인하더라도, 동시에 두 요청이 들어오면 애플리케이션 레벨 검증만으로는 부족할 수 있다. 이런 경우 DB 제약조건과 트랜잭션이 함께 있어야 한다.

SQL은 크게 DDL, DML, DCL, TCL로 나눠 볼 수 있다. DDL은 테이블과 인덱스 같은 구조를 만들고, DML은 데이터를 조회하거나 변경한다. DCL은 권한을 제어하고, TCL은 트랜잭션 경계를 다룬다. 백엔드 개발에서 매일 쓰는 것은 SELECT, INSERT, UPDATE, DELETE이지만, 운영 환경에서는 제약조건, 권한, 인덱스, 트랜잭션을 같이 봐야 문제가 줄어든다.

SELECT는 결과보다 접근 경로가 중요하다

처음 SQL을 배울 때는 where, group by, order by, join 문법이 중심이 된다. 하지만 서비스가 커지면 같은 SELECT라도 어떤 인덱스를 타는지, 얼마나 많은 행을 읽는지, 정렬과 그룹핑이 메모리를 얼마나 쓰는지가 더 중요해진다.

select
  m.id,
  m.email,
  count(o.id) as order_count,
  sum(o.total_amount) as total_amount
from members m
left join orders o on o.member_id = m.id
where m.created_at >= timestamp '2025-01-01 00:00:00'
group by m.id, m.email
having count(o.id) >= 1
order by total_amount desc;

이 쿼리는 회원과 주문을 조인하고, 회원별 주문 수와 금액을 집계한다. 문법상으로는 어렵지 않지만 운영 관점에서는 몇 가지 질문이 따라온다. members.created_at에 인덱스가 있는가. orders.member_id는 외래키이면서 조인 인덱스로 쓰이는가. order by total_amount desc는 계산된 집계 결과를 정렬하므로 데이터가 많아질수록 비용이 커지지 않는가. 실시간 API에서 처리해야 하는 쿼리인지, 배치나 리포트 테이블로 분리해야 하는 쿼리인지도 봐야 한다.

인덱스는 조회를 빠르게 하지만 쓰기 비용을 늘린다. 모든 조건에 인덱스를 만드는 방식은 오래가지 않는다. 조회 빈도, 선택도, 정렬 조건, 조인 조건을 함께 보고 결정한다.

create index idx_orders_member_created_at
on orders(member_id, created_at desc);

복합 인덱스는 컬럼 순서가 중요하다. member_id로 먼저 좁히고 최신 주문을 조회하는 패턴이라면 위 인덱스가 도움이 된다. 반대로 날짜 범위로 전체 주문을 먼저 훑는 분석 쿼리에는 다른 인덱스나 별도 집계 테이블이 필요할 수 있다.

로컬 DB 컨테이너는 운영 DB의 축소판이 아니다

MySQL이나 PostgreSQL을 Docker로 실행하면 개발 환경을 빠르게 만들 수 있다. 로컬에서 DB를 띄우고 API를 연결해보는 경험은 중요하지만, 컨테이너 실행 명령어가 운영 설계를 대신하지는 않는다.

docker run --name local-postgres \
  -e POSTGRES_USER=app \
  -e POSTGRES_PASSWORD=app-password \
  -e POSTGRES_DB=appdb \
  -p 5432:5432 \
  -v pgdata:/var/lib/postgresql/data \
  -d postgres:16

접속 확인은 psql로 한다.

psql "postgresql://app:app-password@localhost:5432/appdb"

MySQL도 비슷하게 실행할 수 있다.

docker run --name local-mysql \
  -e MYSQL_ROOT_PASSWORD=root-password \
  -e MYSQL_DATABASE=appdb \
  -e MYSQL_USER=app \
  -e MYSQL_PASSWORD=app-password \
  -p 3306:3306 \
  -v mysqldata:/var/lib/mysql \
  -d mysql:8

로컬 컨테이너에서 확인할 수 있는 것은 SQL 문법, schema migration, 애플리케이션 연결, 간단한 인덱스 효과 정도다. 운영 환경에서는 여기에 백업, 복구, 연결 풀, TLS, secret 관리, 모니터링, replication, version upgrade, maintenance window가 추가된다.

local container
  - fast setup
  - disposable data
  - simple port mapping
  - manual inspection

managed production DB
  - backup and point-in-time restore
  - private network access
  - TLS and credential rotation
  - monitoring and slow query analysis
  - scaling and maintenance policy

그래서 개발 환경의 docker run은 시작점으로 두고, 운영에서는 Terraform, managed database 설정, migration pipeline으로 옮겨가야 한다. 중요한 것은 “어디서 실행하느냐”보다 schema와 connection 설정이 환경별로 어떻게 관리되는지다.

트랜잭션은 장애 상황을 기준으로 설계한다

트랜잭션의 ACID는 개념으로 외우기보다 실패 시나리오로 보는 것이 좋다. 송금 예제를 생각하면 한 계좌에서 차감하고 다른 계좌에 더하는 두 UPDATE는 하나의 작업 단위여야 한다. 첫 번째 UPDATE만 성공하고 두 번째가 실패하면 데이터는 비즈니스 의미를 잃는다.

begin;

update accounts
set balance = balance - 100
where id = 1
  and balance >= 100;

update accounts
set balance = balance + 100
where id = 2;

commit;

실제 서비스에서는 여기에 더 많은 고민이 붙는다. 잔액 부족일 때 첫 UPDATE가 몇 건을 변경했는지 확인해야 하고, 동시 요청에서 같은 계좌를 동시에 변경하면 잠금 경합이 생긴다. 격리 수준을 너무 낮게 잡으면 dirty read, non-repeatable read, phantom read 문제가 생길 수 있고, 너무 높게 잡으면 처리량이 떨어질 수 있다.

Spring/JPA를 사용한다면 트랜잭션 경계는 보통 서비스 메서드에 둔다. 하지만 모든 메서드에 습관적으로 @Transactional을 붙이는 것은 좋은 설계가 아니다. 읽기 전용 조회인지, 여러 aggregate를 함께 변경하는지, 외부 API 호출이 트랜잭션 안에 들어가면 안 되는지 구분해야 한다.

@Transactional
public Long placeOrder(Long memberId, List<OrderLineCommand> lines) {
    Member member = memberRepository.findById(memberId)
        .orElseThrow(() -> new IllegalArgumentException("member not found"));

    Order order = Order.create(member, lines);
    orderRepository.save(order);
    return order.getId();
}

트랜잭션 안에서 외부 결제 API를 호출하면 DB 잠금 시간이 길어지고, 외부 호출 성공 후 DB commit이 실패하는 불일치도 생길 수 있다. 이런 흐름은 outbox pattern, 상태 기반 재시도, 보상 트랜잭션 같은 구조로 풀어야 한다.

Oracle 실습에서 남는 것은 도구가 아니라 운영 감각이다

Oracle 19c 설치와 scott 계정 실습은 설치 절차 자체보다 RDBMS 운영 환경을 만져보는 의미가 있었다. SQL*Plus로 접속하고, 계정을 활성화하고, emp, dept, salgrade 같은 예제 테이블을 확인하는 과정은 데이터베이스가 애플리케이션 밖의 독립된 런타임이라는 사실을 보여준다.

sqlplus sys/orcl as sysdba

show user;
alter session set "_ORACLE_SCRIPT" = true;
@C:\Database\WINDOWS.X64_193000_db_home\rdbms\admin\scott.sql

conn scott/tiger
select * from dept;
select * from emp;

개발자가 관리형 DB만 쓰더라도 계정, 권한, 스키마, 접속 도구, 백업, 패치, 버전 호환성은 계속 마주친다. 로컬 Oracle 설치 경험은 PostgreSQL, MySQL, Azure SQL, RDS를 다룰 때도 비슷한 질문으로 이어진다. 애플리케이션 계정에 어떤 권한만 줄 것인가. DDL 권한과 DML 권한을 분리할 것인가. migration은 어떤 계정으로 실행할 것인가. 운영 DB 접속은 감사 로그가 남는 경로로 제한되어 있는가.

grant select, insert, update, delete on members to app_user;
revoke drop any table from app_user;

권한은 편의를 위해 넓게 주기 쉽지만, 장애나 침해 상황에서는 권한 범위가 곧 피해 범위가 된다.

MongoDB는 문서 경계를 먼저 설계한다

MongoDB 실습에서는 emp, dept, salgrade, book 같은 컬렉션을 만들고, JSON 문서를 삽입한 뒤 조건 조회, projection, 정렬, skip, limit, update 연산자를 다뤘다. RDBMS와 달리 MongoDB에서는 테이블 간 관계보다 문서 하나가 어떤 데이터를 함께 들고 있어야 하는지가 중요하다.

db.book.insertOne({
  title: "Node.js",
  author: {
    name: "Tom",
  },
  price: 50000,
  categories: ["IT", "Backend"],
});

문서 모델은 함께 읽히는 데이터를 한 문서에 넣을 때 강하다. 예를 들어 게시글과 작성자 표시명, 태그 목록처럼 조회 시 항상 같이 필요한 정보는 embed가 유리할 수 있다. 반대로 주문과 결제, 재고처럼 각각 독립적인 생명주기와 정합성 요구사항이 있으면 무리하게 한 문서에 넣는 것이 더 위험할 수 있다.

조회 연산은 SQL과 표현만 다를 뿐 같은 질문을 던진다.

db.emp.find(
  { deptno: 30, job: "MANAGER" },
  { deptno: 1, ename: 1, job: 1 }
);

db.emp.find({
  $or: [{ sal: 1500 }, { sal: 1600 }],
});

db.emp.find({ ename: { $regex: /^S/ } });

MongoDB에서도 인덱스는 필수다. 자주 조회하는 필드에 인덱스가 없으면 컬렉션 스캔이 발생한다. 정렬 조건까지 포함하면 복합 인덱스 순서를 함께 고려해야 한다.

db.emp.createIndex({ deptno: 1, job: 1, ename: 1 });
db.emp.find({ deptno: 30, job: "MANAGER" }).sort({ ename: 1 });

업데이트 연산자도 문서 모델의 특징을 보여준다.

db.dept.updateOne(
  { deptno: 55 },
  {
    $set: { dname: "Web Dev" },
    $unset: { pno: "" },
  }
);

$set, $unset, $inc, $rename은 문서 일부만 바꿀 수 있게 해준다. 하지만 스키마가 느슨하다는 것은 아무렇게나 저장해도 된다는 뜻이 아니다. 운영에서는 JSON Schema validation, application-level DTO validation, migration script가 함께 필요하다.

DynamoDB는 Query와 Scan의 차이를 먼저 이해해야 한다

DynamoDB는 managed NoSQL이라 서버 운영 부담이 낮지만, 설계를 잘못하면 비용과 성능 문제가 빠르게 드러난다. 특히 QueryScan의 차이를 이해해야 한다. Query는 partition key를 기준으로 필요한 항목을 찾는 방식이고, Scan은 테이블을 넓게 훑는 방식이다. 데이터가 적을 때는 둘 다 동작하지만, 데이터가 늘면 Scan은 비용과 지연이 커진다.

Query
  - partition key required
  - efficient access path
  - predictable cost when key design is good

Scan
  - reads broad table range
  - expensive as data grows
  - useful for admin jobs or small tables, not core request path

예를 들어 사용자별 알림 목록을 조회한다면 partition key를 userId, sort key를 createdAt으로 설계할 수 있다.

Table: notifications
PK: userId
SK: createdAt

Query:
  userId = "usr_01"
  createdAt between "2025-12-01" and "2025-12-31"

상태별 알림을 조회해야 한다면 기본 키만으로는 부족하다. 이때 GSI(Global Secondary Index)를 추가할 수 있다.

GSI1
  PK: status
  SK: createdAt

하지만 GSI는 공짜가 아니다. 쓰기 비용과 저장 공간이 늘고, 모든 조회 요구사항을 인덱스로 해결하려 하면 테이블 설계가 복잡해진다. DynamoDB를 사용할 때는 “나중에 아무렇게나 조회하면 되겠지”가 아니라, 먼저 접근 패턴을 적어야 한다.

Access patterns
1. userId로 최근 알림 20개 조회
2. unread 상태의 알림 개수 조회
3. 특정 notificationId 상세 조회
4. 30일 지난 알림 만료 처리

이 접근 패턴에 따라 partition key, sort key, GSI, TTL, item shape가 결정된다. RDBMS처럼 JOIN으로 나중에 조합하는 방식이 아니라, 읽을 모양에 맞춰 데이터를 저장하는 쪽에 가깝다.

Azure Storage와 Cosmos DB는 접근 패턴 중심으로 나눈다

Azure Storage 실습에서는 Blob, Files, Tables 성격의 저장소를 확인했다. Blob Storage는 이미지, 로그, 백업, 문서처럼 파일 단위 객체에 적합하다. Azure Files는 SMB/NFS 기반 파일 공유가 필요할 때 쓰기 좋고, Table Storage는 PartitionKey와 RowKey 기반의 단순 키/값 조회에 맞다.

{
  "id": "1",
  "productname": "Bearing Ball",
  "productnumber": "BA-8327",
  "quantityinstock": 1109,
  "productcategory": {
    "subcategory": "Parts",
    "category": "Components"
  }
}

이런 JSON 객체를 Blob이나 Cosmos DB에 넣을 수는 있지만, 저장소 선택 기준은 다르다. 파일 자체를 저장하고 가끔 읽는다면 Blob이 맞고, 문서 필드 기준으로 자주 조회하고 변경한다면 Cosmos DB 같은 문서 DB가 더 자연스럽다. Table Storage를 쓴다면 PartitionKey 설계가 성능과 확장성을 좌우한다.

Cosmos DB for NoSQL은 SQL 비슷한 문법으로 JSON 문서를 조회할 수 있다.

SELECT *
FROM c
WHERE CONTAINS(c.name, "Helmet")

하지만 Cosmos DB에서 더 중요한 것은 partition key와 RU다. 같은 쿼리라도 partition key를 타는지에 따라 비용이 크게 달라진다. 사용자별 대시보드라면 userId, 테넌트 기반 SaaS라면 tenantId, 시간 기반 이벤트라면 조회 패턴에 맞춘 복합 설계를 고민해야 한다.

{
  "id": "daily-2025-12-23",
  "userId": "user-123",
  "type": "dailySummary",
  "createdAt": "2025-12-23T09:00:00Z",
  "items": [
    {
      "category": "cloud",
      "title": "AKS release note"
    }
  ]
}

이 문서의 조회가 항상 userId 기준이라면 partition key는 userId가 자연스럽다. 반대로 전체 사용자의 특정 날짜 데이터를 자주 집계한다면 별도 분석 저장소나 materialized view 성격의 컬렉션이 필요할 수 있다.

Azure SQL과 Flexible Server는 관리형이라는 말 뒤의 경계를 봐야 한다

Azure SQL Database, Azure Database for MySQL Flexible Server, Azure Database for PostgreSQL Flexible Server는 VM에 직접 DB를 설치하는 방식보다 운영 부담을 줄여준다. 하지만 관리형 DB라고 해서 데이터 모델, 접속 경계, 백업, 성능 튜닝이 사라지는 것은 아니다. 서버 패치와 일부 고가용성은 클라우드가 맡지만, 스키마 설계와 쿼리 품질, 인덱스, 연결 풀, 권한, 네트워크 경계는 여전히 애플리케이션 팀의 책임이다.

같은 RDBMS 계열이라도 선택 기준은 다르다. Azure SQL은 Microsoft SQL Server 생태계, T-SQL, SSMS/Azure Data Studio, Managed Instance 마이그레이션과 잘 맞는다. PostgreSQL은 표준 SQL, 확장 기능, JSONB, 오픈소스 생태계가 강하고, MySQL은 웹 애플리케이션과 CMS 계열에서 익숙하다. 중요한 것은 “어떤 DB가 더 좋다”가 아니라 애플리케이션이 어떤 트랜잭션과 조회를 갖는지다.

relational database selection notes

Azure SQL
  - SQL Server compatibility
  - migration from Windows / MS SQL workloads
  - T-SQL, SSMS, Azure Data Studio workflow

PostgreSQL Flexible Server
  - strong relational modeling
  - JSONB and extension ecosystem
  - good fit for product backend services

MySQL Flexible Server
  - familiar web application stack
  - simple operational model
  - broad framework support

Azure Data Studio의 migration extension은 이 경계를 확인하는 데 도움이 된다. 마이그레이션은 데이터를 옮기는 작업처럼 보이지만, 실제로는 호환성 평가, 지원되지 않는 기능 확인, 다운타임 계획, 네트워크 연결, 인증 방식, rollback 전략을 함께 결정하는 작업이다.

migration review

1. source engine and version
2. unsupported features and compatibility issues
3. schema size and data volume
4. online or offline migration
5. network path: public, VPN, ExpressRoute, private endpoint
6. application connection string cutover
7. rollback and validation plan

작은 실습에서는 퍼블릭 엔드포인트로 DB에 접속해 결과를 확인하기 쉽다. 운영에서는 이 구성을 그대로 가져가면 안 된다. 최소한 허용 IP를 좁히고, 가능하면 private endpoint와 VNet integration을 검토한다. 접속 계정도 관리자 계정을 애플리케이션에서 그대로 쓰지 않고, 읽기/쓰기 권한과 스키마 권한을 나눠야 한다.

-- example: separate application role
CREATE USER app_writer WITH PASSWORD = 'use-secret-store';
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO app_writer;

CREATE USER app_reader WITH PASSWORD = 'use-secret-store';
GRANT SELECT ON SCHEMA::dbo TO app_reader;

비밀번호 예시는 구조 설명용일 뿐이고, 실제 환경에서는 Key Vault나 Secret Manager에 저장해야 한다. DB 보안은 “방화벽을 열지 않는다”에서 끝나지 않는다. 어떤 계정이 어떤 테이블을 읽고 쓸 수 있는지, 마이그레이션 스크립트가 어느 권한으로 실행되는지, 감사 로그가 남는지까지 봐야 한다.

분석 저장소는 운영 DB를 대신하지 않는다

Synapse Analytics, Databricks, Data Lake Storage, Stream Analytics 같은 서비스는 운영 API의 트랜잭션 DB를 대체하기보다, 데이터를 분석과 처리 흐름으로 옮기는 역할에 가깝다. 운영 DB는 짧은 요청과 정합성에 집중하고, 분석 저장소는 대량 읽기, 집계, 머신러닝, 리포팅에 맞춰 설계한다.

여기서 중요한 경계는 운영 데이터와 분석 데이터의 시간차다. 운영 화면에서 바로 보여줘야 하는 주문 상태, 결제 상태, 권한 상태는 OLTP DB가 담당한다. 반면 하루 단위 매출 집계, 사용자 행동 분석, 로그 기반 장애 패턴 분석은 데이터 레이크나 분석 엔진으로 넘기는 편이 낫다. 분석 쿼리를 운영 DB에 직접 붙이면 트랜잭션 지연과 잠금, 비용 문제가 생길 수 있다.

OLTP path
  - small request
  - transaction consistency
  - indexed lookup
  - low latency

analytics path
  - batch or streaming ingestion
  - large scan and aggregation
  - schema evolution
  - report and model training

Cosmos DB나 Blob Storage에 JSON을 저장했다고 해서 그것이 곧 분석 플랫폼이 되는 것은 아니다. 데이터를 다시 읽고 조인하고 집계해야 한다면 파일 포맷, 파티션, 스키마 진화, 재처리 전략이 필요하다. 예를 들어 일별 이벤트를 저장한다면 날짜와 테넌트 기준으로 경로를 나눠두는 것이 나중에 비용과 성능에 영향을 준다.

raw/events/year=2026/month=07/day=05/tenant=a/*.json
curated/events/year=2026/month=07/day=05/tenant=a/*.parquet

처음부터 모든 데이터를 완벽한 레이크하우스로 만들 필요는 없다. 다만 운영 DB에서 분석 요구가 반복되기 시작하면, 그때는 API 데이터 모델과 분석 데이터 모델을 분리할 시점이다.

데이터 접근 기술은 문제의 모양에 따라 고른다

Spring에서 DB에 접근하는 방식은 여러 단계로 나뉜다. 순수 JDBC, JdbcTemplate, MyBatis, JPA, Spring Data JPA는 모두 “DB에 연결한다”는 점에서는 같지만, 감추는 복잡도와 드러내는 제어 지점이 다르다.

순수 JDBC는 연결 획득, Statement 생성, ResultSet 처리, 예외 처리, 리소스 반환을 모두 직접 다뤄야 한다. 이 방식은 DB 접근이 실제로 어떤 순서로 일어나는지 이해하기 좋지만, 반복 코드가 많고 실수할 여지도 크다.

public Member save(Member member) {
    String sql = "insert into member(name) values (?)";

    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        conn = dataSource.getConnection();
        pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
        pstmt.setString(1, member.getName());
        pstmt.executeUpdate();

        rs = pstmt.getGeneratedKeys();
        if (rs.next()) {
            member.setId(rs.getLong(1));
        }
        return member;
    } catch (SQLException e) {
        throw new IllegalStateException(e);
    } finally {
        close(conn, pstmt, rs);
    }
}

JdbcTemplate은 이 반복을 줄인다. SQL은 직접 쓰되, 연결과 리소스 정리는 프레임워크가 맡는다. SQL의 형태를 명확히 유지하면서 Java 코드의 잡음을 줄이고 싶을 때 적합하다.

public Optional<Member> findById(Long id) {
    List<Member> result = jdbcTemplate.query(
        "select id, name from member where id = ?",
        memberRowMapper(),
        id
    );
    return result.stream().findAny();
}

private RowMapper<Member> memberRowMapper() {
    return (rs, rowNum) -> {
        Member member = new Member();
        member.setId(rs.getLong("id"));
        member.setName(rs.getString("name"));
        return member;
    };
}

MyBatis는 SQL을 더 전면에 둔다. 동적 검색 조건, 관리자 화면의 복잡한 필터, 리포트성 조회, 기존 DB 스키마와 맞춰야 하는 SI 환경에서는 MyBatis가 현실적인 선택이 되는 경우가 많다. SQL을 숨기지 않기 때문에 튜닝과 원인 분석이 빠르지만, 쿼리 조각이 많아질수록 XML이나 mapper 관리 규칙이 필요하다.

<select id="findNotifications" resultType="Notification">
  select id, title, target_group, status, created_at
  from notifications
  where deleted = false
  <if test="targetGroup != null">
    and target_group = #{targetGroup}
  </if>
  <if test="status != null">
    and status = #{status}
  </if>
  order by created_at desc
  limit #{limit} offset #{offset}
</select>

JPA는 객체 모델과 변경 감지를 중심으로 본다. 단순 CRUD에서는 생산성이 높고, aggregate 단위의 상태 변경을 코드로 표현하기 쉽다. 다만 JPA가 SQL을 대신 만들어준다는 점은 장점이자 위험이다. N+1, 예상보다 넓은 update, 영속성 컨텍스트 범위, lazy loading 문제를 이해하지 못하면 운영 중 쿼리를 추적하기 어려워진다.

@Transactional
public void approveRefund(Long refundId) {
    Refund refund = refundRepository.findById(refundId)
        .orElseThrow(() -> new IllegalArgumentException("refund not found"));

    refund.approve();
}

이 코드는 짧지만, 실제로는 트랜잭션 안에서 엔티티를 조회하고 상태를 바꾸며 commit 시점에 update SQL이 나간다. 이런 흐름을 이해해야 JPA를 “마법”이 아니라 도구로 쓸 수 있다.

선택 기준은 기술 선호가 아니라 문제의 모양이어야 한다.

JdbcTemplate
- SQL은 단순하지만 직접 제어하고 싶을 때
- 반복 코드를 줄이되 쿼리 형태를 숨기고 싶지 않을 때

MyBatis
- 동적 검색 조건이 많을 때
- 기존 스키마와 복잡한 조인/리포트 쿼리가 중요할 때
- 운영 중 SQL 튜닝과 추적 가능성이 중요할 때

JPA / Spring Data JPA
- 도메인 모델 중심으로 상태 전이를 표현하고 싶을 때
- aggregate 변경과 트랜잭션 경계를 코드로 관리하고 싶을 때
- 단순 CRUD 생산성이 중요할 때

실무에서는 하나만 고집하기보다 역할을 나누는 경우도 있다. 핵심 도메인 상태 변경은 JPA로 처리하고, 복잡한 관리자 검색과 리포트 조회는 MyBatis나 JdbcTemplate으로 분리할 수 있다. 중요한 것은 같은 테이블을 여러 방식으로 만질 때 트랜잭션, 캐시, 변경 책임이 충돌하지 않도록 경계를 명확히 두는 것이다.

한 시스템 안에서 저장소를 나눠 쓰는 기준

백엔드 데이터 계층은 하나의 DB로 모든 문제를 해결하려고 할 때 복잡해진다. 각 저장소의 역할을 좁게 가져가면 설계가 선명해진다.

RDBMS는 정합성이 중요한 핵심 거래 데이터에 둔다. Redis는 캐시, rate limit, 세션처럼 짧은 수명의 상태에 둔다. Object Storage는 파일과 대용량 객체에 둔다. Document DB는 문서 단위로 함께 읽히는 데이터나 유연한 속성이 많은 데이터에 둔다. 검색 엔진은 전문 검색과 복잡한 필터링을 맡긴다.

중요한 것은 저장소가 늘어날수록 동기화와 장애 시나리오도 늘어난다는 점이다. RDB에 주문이 저장되었지만 검색 인덱스 반영이 실패할 수 있다. Blob 업로드는 성공했지만 DB metadata 저장이 실패할 수 있다. 이런 상황을 고려해 idempotency key, outbox table, retry, dead letter queue, cleanup job을 설계해야 한다.

create table outbox_events (
  id bigint generated by default as identity,
  aggregate_type varchar(100) not null,
  aggregate_id varchar(100) not null,
  event_type varchar(100) not null,
  payload text not null,
  created_at timestamp not null default current_timestamp,
  processed_at timestamp null,
  primary key (id)
);

애플리케이션은 트랜잭션 안에서 비즈니스 데이터와 outbox 이벤트를 함께 저장하고, 별도 worker가 이벤트를 읽어 검색 인덱스나 문서 DB에 반영한다. 이렇게 하면 외부 시스템 호출 실패가 핵심 DB 트랜잭션을 직접 깨뜨리지 않는다.

상태 전이가 있는 업무에서는 이 구조가 더 중요해진다. 예를 들어 결제와 환불, 주차권 배정, 알림 발송 같은 기능은 단순 CRUD가 아니라 “어떤 상태에서 어떤 상태로 이동할 수 있는가”가 핵심이다.

create table payment_requests (
  id bigint generated by default as identity,
  member_id bigint not null,
  amount numeric(12, 2) not null,
  status varchar(30) not null,
  requested_at timestamp not null default current_timestamp,
  approved_at timestamp null,
  canceled_at timestamp null,
  primary key (id)
);

create table payment_status_history (
  id bigint generated by default as identity,
  payment_id bigint not null,
  from_status varchar(30),
  to_status varchar(30) not null,
  reason varchar(255),
  changed_by varchar(100) not null,
  changed_at timestamp not null default current_timestamp,
  primary key (id),
  foreign key (payment_id) references payment_requests(id)
);

payment_requests.status만 보면 현재 상태는 알 수 있지만, 왜 바뀌었는지는 알기 어렵다. 운영자가 확인해야 하는 것은 “현재 승인됨”뿐 아니라 “누가, 언제, 어떤 사유로 승인했는가”다. 그래서 핵심 테이블에는 현재 상태를 두고, 이력 테이블에는 상태 전이를 남기는 편이 추적에 유리하다.

서비스 코드에서는 상태 변경을 아무 곳에서나 허용하지 않아야 한다.

@Transactional
public void approvePayment(Long paymentId, String operatorId) {
    Payment payment = paymentRepository.findByIdForUpdate(paymentId)
        .orElseThrow(() -> new IllegalArgumentException("payment not found"));

    payment.approve();

    paymentHistoryRepository.save(
        PaymentStatusHistory.of(paymentId, "REQUESTED", "APPROVED", operatorId)
    );

    outboxRepository.save(
        OutboxEvent.paymentApproved(paymentId)
    );
}

여기서 findByIdForUpdate처럼 잠금을 거는 방식은 상황에 따라 필요하다. 동시에 두 명의 운영자가 같은 요청을 처리하거나, API 재시도가 같은 상태 변경을 반복할 수 있기 때문이다. 모든 기능에 비관적 잠금을 걸 필요는 없지만, 돈, 재고, 배정, 권한처럼 중복 처리 비용이 큰 영역은 동시성 모델을 명확히 해야 한다.

상태 전이는 API 응답 설계에도 영향을 준다.

{
  "id": 101,
  "status": "APPROVED",
  "availableActions": ["cancel", "refund"],
  "lastChangedAt": "2025-12-23T10:15:00Z"
}

프론트엔드가 상태값을 임의로 해석해 버튼을 만들기보다, 백엔드가 현재 상태에서 가능한 action을 함께 내려주면 화면과 정책이 어긋날 가능성이 줄어든다. 결국 데이터 계층 설계는 테이블 설계에서 끝나지 않고 서비스 메서드, 트랜잭션, API 응답, 운영 화면까지 이어진다.

정리

SQL 문법, Oracle 설치, MongoDB CRUD, Azure Storage와 Cosmos DB 실습은 각각 따로 보면 도구 사용법에 가깝다. 하지만 백엔드 데이터 계층으로 묶어 보면 공통 질문이 드러난다. 어떤 데이터가 강한 정합성을 필요로 하는가. 어떤 조회가 가장 자주 발생하는가. 인덱스는 읽기와 쓰기 비용 사이에서 어떤 균형을 잡는가. 파티션 키는 트래픽을 고르게 나누는가. 장애가 났을 때 일부 저장소만 성공한 상태를 어떻게 복구할 것인가.

데이터베이스 선택은 기술 선호가 아니라 경계 설정의 문제다. RDBMS는 관계와 트랜잭션을, MongoDB와 Cosmos DB는 문서와 접근 패턴을, Object Storage는 파일과 대용량 객체를, Redis는 짧은 수명의 빠른 상태를 맡긴다. 백엔드 설계는 이 경계가 서로 섞이지 않도록 만들고, 섞일 수밖에 없는 지점에는 트랜잭션, 이벤트, 재시도, 관찰 가능성을 배치하는 일에 가깝다.

이 글의 무단 복제, 재배포, 자동 수집을 허용하지 않습니다. 인용이 필요한 경우 출처와 원문 링크를 함께 남겨주세요.