> ## Content Index
> Fetch the complete content index at: https://mlog.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# Spring Boot + PostgreSQL 읽기·쓰기 분리 완전 가이드: JPA·MyBatis 라우팅과 데이터 정합성
- URL: https://mlog.me/spring-boot-postgresql-read-write-splitting/
- Published: 2026-08-30T17:50:34.000Z
- Updated: 2026-08-30T17:50:34.000Z
- Description: Spring Boot에서 PostgreSQL Primary와 Read Replica를 분리하고 JPA와 MyBatis가 동일한 트랜잭션에 참여하도록 구성하는 방법을 정리합니다. @Transactional 기반 라우팅, JPA flush, POST/Redirect/GET의 복제 지연, Redis와 Pgpool-II의 역할과 한계까지 실무 관점에서 살펴봅니다.
- Author: mLog
- Tags: 개발, Spring Boot, PostgreSQL, #JPA, #MyBatis

> 읽기·쓰기 분리는 DataSource 두 개를 만드는 작업이 아니라, 트랜잭션 경계와 복제 지연, 장애 대응까지 함께 설계하는 작업이다.  
> 서비스의 조회 트래픽이 늘면 자연스럽게 이런 구성을 떠올리게 된다.

- INSERT·UPDATE·DELETE는 Primary에서 처리한다.
- SELECT는 Read Replica로 분산한다.
- 애플리케이션은 Spring Boot이고 JPA와 MyBatis를 함께 사용한다.

구조만 보면 간단하다. 하지만 막상 적용하면 저장 직후 상세 화면에서 데이터가 보이지 않거나, JPA로 저장한 값을 같은 API의 MyBatis 조회가 찾지 못하는 문제가 생긴다. 두 현상은 비슷해 보여도 원인이 완전히 다르다.

- **같은 트랜잭션의 JPA → MyBatis 조회 실패:** JPA 변경 내용이 아직 flush되지 않았을 가능성이 크다.
- **POST 성공 후 다음 GET의 조회 실패:** Primary의 변경이 Replica에 아직 replay되지 않은 복제 지연 문제다.

`flush()`는 첫 번째 문제만 해결한다. 두 번째 문제에는 아무 효과가 없다. 이 구분이 읽기·쓰기 분리 설계의 출발점이다.

## 먼저 결론부터

운영 환경에서는 다음 원칙이 가장 안전하다.

1. 트랜잭션이 없거나 판단이 모호한 요청은 **Primary**로 보낸다.
2. 쓰기 트랜잭션 안에서 실행되는 조회도 **Primary**를 사용한다.
3. 약간 오래된 결과를 허용할 수 있는 명확한 조회 전용 기능만 **Replica**로 보낸다.
4. 생성·수정 직후의 상세 조회는 한 번 **Primary**로 보내거나, 사용자·대상별로 짧게 Primary에 고정한다.
5. JPA와 MyBatis는 동일한 최종 DataSource와 Spring 트랜잭션에 참여시킨다.
6. Redis는 정합성을 보장하는 원장이 아니라, 최근 쓰기 표시나 짧은 캐시를 위한 **보조 수단**으로 사용한다.
7. 고가용성(HA), 읽기 확장, 캐시, 백업은 서로 다른 문제로 관리한다.

## Primary/Replica는 Active-Active가 아니다

PostgreSQL 물리 스트리밍 복제에서 쓰기 가능한 서버가 하나라면 정확한 표현은 **Primary/Replica 읽기 확장** 또는 **Single-writer Read Scale-out**이다. Replica가 조회 요청을 받는다고 해서 여러 노드가 동시에 쓰기를 수행하는 Multi-writer Active-Active가 되는 것은 아니다.

| 구성 요소                | 역할                                   |
| -------------------- | ------------------------------------ |
| Primary              | 쓰기와 강한 일관성이 필요한 조회를 처리하는 기준 DB       |
| Standby              | Primary의 WAL을 전달받아 장애에 대비하는 서버       |
| Read Replica         | Standby 중 애플리케이션의 읽기 요청도 처리하는 서버     |
| Read/Write Splitting | 읽기 부하를 Replica로 분산하는 확장 전략           |
| Failover             | 장애를 감지하고 Standby를 승격해 쓰기 경로를 전환하는 절차 |

Standby를 만들었다고 자동 장애조치가 완성되는 것은 아니다. 장애 감지, 승격, 기존 Primary 차단(fencing), 접속 엔드포인트 변경, 커넥션 풀 재연결까지 별도로 준비해야 한다. Replica는 실수로 삭제된 데이터와 논리적 손상도 그대로 복제하므로 백업과 PITR도 따로 필요하다.

PostgreSQL 공식 문서도 Primary와 Standby를 이 용어로 구분한다. 자세한 내용은 [High Availability, Load Balancing, and Replication](https://www.postgresql.org/docs/current/high-availability.html?ref=mlog.me)에서 확인할 수 있다.

## 복제 지연을 전제로 설계해야 한다

일반적인 PostgreSQL 스트리밍 복제는 비동기다. Primary에서 커밋이 성공한 시점과 Replica가 해당 WAL을 replay해 조회 결과에 반영한 시점은 같지 않다.

예를 들어 게시글을 생성한 뒤 상세 페이지로 이동하는 흐름을 보자.

1. POST 요청이 Primary에 게시글을 저장한다.
2. Primary에서 트랜잭션이 커밋된다.
3. 서버가 상세 URL로 Redirect한다.
4. 브라우저의 새 GET 요청이 `readOnly = true` 트랜잭션으로 실행된다.
5. GET이 Replica로 라우팅되지만 WAL replay가 아직 끝나지 않았다.
6. 사용자는 404 또는 수정 전 데이터를 보게 된다.

PRG(Post/Redirect/Get)는 중복 제출을 방지하는 웹 패턴일 뿐, read-after-write 정합성을 보장하지 않는다. 고정된 시간만큼 `sleep`하거나 한두 번 재시도하는 방법도 지연 시간이 가변적이기 때문에 보장이 아니다.

## Spring Boot의 안전한 라우팅 구조

Spring Framework 6.1.2 미만이거나 Replica 선택 규칙이 복잡하다면 다음 계층이 이해하기 쉽다.

| 순서 | 구성 요소                         | 역할                         |
| -- | ----------------------------- | -------------------------- |
| 1  | JPA / MyBatis                 | 같은 Spring 트랜잭션에 참여         |
| 2  | LazyConnectionDataSourceProxy | 첫 SQL 실행 시점까지 물리 커넥션 획득 지연 |
| 3  | AbstractRoutingDataSource     | 현재 트랜잭션의 읽기 전용 여부로 경로 선택   |
| 4  | Writer / Reader HikariCP      | 각 PostgreSQL 서버의 실제 커넥션 풀  |

여기서 `LazyConnectionDataSourceProxy`의 위치가 중요하다. Spring이 트랜잭션의 `readOnly` 속성을 설정하기 전에 커넥션을 얻어 버리면, 라우팅 판단보다 먼저 특정 DB가 선택될 수 있다. 이 프록시는 첫 `Statement` 생성 시점까지 실제 JDBC 커넥션 획득을 미룬다. 동작은 [Spring 공식 Javadoc](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.html?ref=mlog.me)에 설명돼 있다.

### 1\. Writer와 Reader 설정

```yaml
app:
  datasource:
    writer:
      url: jdbc:postgresql://primary-db:5432/app
      username: app_user
      password: ${DB_PASSWORD}
      driver-class-name: org.postgresql.Driver
      configuration:
        maximum-pool-size: 20
        minimum-idle: 5
    reader:
      url: jdbc:postgresql://replica-db:5432/app
      username: app_readonly
      password: ${DB_READONLY_PASSWORD}
      driver-class-name: org.postgresql.Driver
      configuration:
        maximum-pool-size: 30
        minimum-idle: 5

```

Writer와 Reader 풀 크기를 무조건 같은 값으로 둘 필요는 없다. 연결 수는 각 DB 엔드포인트별로 계산해야 한다. 또한 장애 시 Reader 트래픽이 Primary로 몰릴 수 있으므로, 제한적 fallback을 적용하더라도 Primary가 감당할 수 있는 여유 용량을 별도로 검증해야 한다.

### 2\. 트랜잭션 상태로 라우팅하기

```java
public enum DataSourceRole {
    WRITER, READER
}

```

```java
public class TransactionRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        if (!TransactionSynchronizationManager.isActualTransactionActive()) {
            return DataSourceRole.WRITER;
        }

        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
                ? DataSourceRole.READER
                : DataSourceRole.WRITER;
    }
}

```

핵심은 **트랜잭션이 없을 때 Writer를 기본값으로 선택하는 것**이다. 트랜잭션 밖의 조회를 무조건 Replica로 보내면 강한 일관성이 필요한 코드까지 조용히 잘못된 경로를 탈 수 있다.

### 3\. 최종 DataSource 구성

```java
@Configuration
@EnableTransactionManagement
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties("app.datasource.writer")
    DataSourceProperties writerDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean(name = "writerDataSource")
    @ConfigurationProperties("app.datasource.writer.configuration")
    HikariDataSource writerDataSource(
            @Qualifier("writerDataSourceProperties") DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder()
                .type(HikariDataSource.class)
                .build();
    }

    @Bean
    @ConfigurationProperties("app.datasource.reader")
    DataSourceProperties readerDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean(name = "readerDataSource")
    @ConfigurationProperties("app.datasource.reader.configuration")
    HikariDataSource readerDataSource(
            @Qualifier("readerDataSourceProperties") DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder()
                .type(HikariDataSource.class)
                .build();
    }

    @Bean(name = "routingDataSource")
    DataSource routingDataSource(
            @Qualifier("writerDataSource") DataSource writer,
            @Qualifier("readerDataSource") DataSource reader) {

        TransactionRoutingDataSource routing = new TransactionRoutingDataSource();
        routing.setTargetDataSources(Map.of(
                DataSourceRole.WRITER, writer,
                DataSourceRole.READER, reader
        ));
        routing.setDefaultTargetDataSource(writer);
        routing.afterPropertiesSet();
        return routing;
    }

    @Bean
    @Primary
    DataSource dataSource(
            @Qualifier("routingDataSource") DataSource routingDataSource) {
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
}

```

여기서 `@Primary`는 Spring Bean 선택의 우선순위를 의미한다. PostgreSQL의 Primary 서버라는 뜻과는 관계가 없다.

Spring Framework 6.1.2 이상이고 Reader가 하나라면 더 간단히 구성할 수도 있다.

```java
LazyConnectionDataSourceProxy dataSource =
        new LazyConnectionDataSourceProxy(writerDataSource);
dataSource.setReadOnlyDataSource(readerDataSource);

```

프로젝트의 Spring Framework 버전을 확인한 뒤 두 방식 중 하나를 선택하면 된다.

## `@Transactional(readOnly = true)`의 정확한 의미

서비스 계층에서는 다음처럼 사용한다.

```java
@Transactional
public void updatePost(UpdatePostCommand command) {
    // Writer 사용
}

@Transactional(readOnly = true)
public PostResponse findPost(long postId) {
    // Reader 사용
}

```

하지만 `readOnly = true` 자체에 Replica 라우팅 기능이 있는 것은 아니다. 앞에서 만든 DataSource가 이 속성을 **라우팅 신호**로 사용할 뿐이다. 또한 모든 환경에서 쓰기를 강제로 차단하는 안전장치도 아니다. Spring Data JPA와 Hibernate에서는 주로 flush 관련 최적화 힌트로 사용된다.

물론 실제 PostgreSQL Hot Standby에 쓰기 SQL을 보내면 DB가 거절한다. 애플리케이션에서도 읽기 전용을 강제하려면 Reader 계정에 쓰기 권한을 주지 않는 것이 방어에 도움이 된다. Spring JDBC 트랜잭션 매니저의 `setEnforceReadOnly(true)`처럼 DB 수준의 `SET TRANSACTION READ ONLY`를 사용하는 선택지도 있지만, 사용하는 트랜잭션 매니저와 JPA·MyBatis 구성 전체를 함께 검증해야 한다.

### 바깥쪽 트랜잭션이 경로를 결정한다

기본 전파 방식인 `PROPAGATION_REQUIRED`에서는 안쪽 메서드가 바깥쪽 트랜잭션에 합류한다.

- 쓰기 트랜잭션 안에서 `readOnly = true` 메서드를 호출해도 연결은 Reader로 바뀌지 않는다.
- 반대로 읽기 전용 트랜잭션 안에서 쓰기 메서드를 호출한다고 자동으로 Writer로 전환되지 않는다.
- 같은 클래스 내부의 자기 호출은 기본 프록시 방식에서 `@Transactional`을 우회할 수 있다.
- `@Async`, 새 스레드, Reactive 흐름에는 일반적인 Thread-bound 트랜잭션 문맥이 그대로 전달되지 않는다.

따라서 트랜잭션 경계는 public 서비스 메서드에 명확히 두고, Command와 Query 책임을 서로 다른 Bean으로 분리하는 편이 안전하다. `SELECT ... FOR UPDATE`, 시퀀스 증가, 부작용이 있는 함수 호출도 Writer에서 수행해야 한다.

## JPA와 MyBatis를 함께 쓸 때

JPA와 MyBatis가 같은 애플리케이션에 있다고 해서 자동으로 같은 트랜잭션을 공유하는 것은 아니다.

두 프레임워크는 다음 조건을 만족해야 한다.

- JPA의 `EntityManagerFactory`와 MyBatis의 `SqlSessionFactoryBean`이 동일한 최종 DataSource를 사용한다.
- 동일한 Spring 트랜잭션 매니저가 트랜잭션 경계를 관리한다.
- MyBatis는 Spring이 관리하는 트랜잭션 연결을 사용한다.
- 서비스 계층에서 `@Transactional` 경계를 선언한다.

Spring Boot 자동 설정을 사용한다면 `@Primary`로 지정한 최종 DataSource가 양쪽에 주입되는지 확인한다. 직접 구성한다면 MyBatis에도 같은 Bean을 명시한다.

```java
@Bean
SqlSessionFactory sqlSessionFactory(
        @Qualifier("dataSource") DataSource dataSource) throws Exception {
    SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
    factory.setDataSource(dataSource);
    return factory.getObject();
}

```

MyBatis-Spring은 트랜잭션 매니저와 `SqlSessionFactoryBean`이 같은 DataSource를 사용해야 한다고 안내한다. 자세한 조건은 [MyBatis-Spring 트랜잭션 문서](https://mybatis.org/spring/transactions.html?ref=mlog.me)에서 확인할 수 있다.

### JPA 저장 후 MyBatis로 바로 조회하기

```java
@Transactional
public PostDetail createAndRead(CreatePostCommand command) {
    Post post = postRepository.save(Post.create(command));

    entityManager.flush();

    return postMapper.selectDetail(post.getId());
}

```

JPA의 `save()`는 즉시 커밋한다는 뜻이 아니다. Hibernate가 SQL 실행을 flush 시점까지 미룰 수 있기 때문에, MyBatis 조회 전에 `EntityManager.flush()` 또는 `saveAndFlush()`가 필요할 수 있다.

이때 `flush()`는 커밋이 아니다. 같은 물리 트랜잭션과 연결을 사용하는 MyBatis가 미커밋 변경을 볼 수 있게 DB에 DML을 전달할 뿐이다. 별도 트랜잭션의 Replica 조회에는 영향을 주지 않는다.

반대 방향도 주의해야 한다. MyBatis로 UPDATE한 뒤 JPA가 이미 로딩한 Entity를 다시 조회하면 1차 캐시의 이전 값이 반환될 수 있다. 이때는 `flush()`가 아니라 상황에 따라 `EntityManager.clear()` 또는 `refresh()`가 필요하다.

## 저장 직후 조회를 안전하게 처리하는 네 가지 방법

| 전략                      | 정합성                    | 난도 | 적합한 상황               |
| ----------------------- | ---------------------- | -- | -------------------- |
| 저장 직후 특정 조회만 Writer 사용  | 높음                     | 낮음 | 생성·수정 완료 화면          |
| 사용자·대상별 Sticky Primary  | TTL·fallback 정책에 따라 다름 | 중간 | PRG, 여러 API가 이어지는 UI |
| 커밋 WAL 위치와 replay 위치 확인 | 높음                     | 높음 | 엄격한 인과적 정합성          |
| 동기식 복제와 remote\_apply   | 설정 범위에서 높음             | 높음 | 인프라 수준의 강한 보장        |

### 1\. 생성·수정 직후 한 번만 Writer에서 읽기

가장 단순하고 예측 가능한 방법이다. 일반 조회 메서드와 강한 일관성이 필요한 조회 메서드를 분리한다.

```java
@Service
public class PostWriterQueryService {

    @Transactional
    public PostResponse findLatest(long postId) {
        return loadPost(postId); // Writer에서 읽음
    }
}

@Service
public class PostReplicaQueryService {

    @Transactional(readOnly = true)
    public PostResponse find(long postId) {
        return loadPost(postId); // Reader에서 읽음
    }
}

```

조회인데 `readOnly = false`인 점이 어색해 보여도, 여기서는 Writer 라우팅이 더 중요한 업무 요구사항이다. 별도의 `@WriterRead` 애너테이션과 AOP를 만들어 의도를 더 분명하게 표현할 수도 있다.

### 2\. Redis에는 결과보다 ‘최근 쓰기 마커’를 저장하기

PRG의 Redirect 뒤 GET은 새로운 요청이며 다른 스레드나 다른 애플리케이션 인스턴스에서 처리될 수 있다. 따라서 `ThreadLocal`만으로는 요청 간 Primary 고정을 유지할 수 없다.

여러 인스턴스에서 짧은 고정이 필요하다면 DB 커밋이 성공한 뒤 Redis에 다음과 같은 마커를 저장할 수 있다.

```text
primary-read:{userId}:{postId} = true

```

다음 GET은 트랜잭션을 시작하기 전에 이 마커를 확인한다. 마커가 있으면 Writer 조회 서비스, 없으면 Replica 조회 서비스를 호출한다. 조회 서비스는 서로 다른 Bean이어야 Spring 트랜잭션 프록시가 정상적으로 적용된다.

```java
public PostResponse find(long userId, long postId) {
    if (primaryReadMarker.exists(userId, postId)) {
        return writerQueryService.findLatest(postId);
    }
    return replicaQueryService.find(postId);
}

```

마커는 반드시 DB 커밋 이후에 기록해야 한다.

```java
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void markPrimaryRead(PostChangedEvent event) {
    primaryReadMarker.mark(event.userId(), event.postId());
}

```

TTL은 무조건 3초나 5초로 정하지 말고, 실제 관측한 replay lag의 p99와 서비스의 정합성 요구를 기준으로 정한다. 시간이 지났다는 사실은 Replica가 따라잡았다는 증명이 아니다. Redis 장애 시 최신값이 중요한 요청은 Writer로 보내되, Primary 여유 용량과 동시 요청 제한, 회로 차단을 함께 둔 제한적 fallback 정책이 필요하다.

최신 DTO를 Redis에 짧게 저장하는 Look-aside 방식도 단일 상세 객체에는 쓸 수 있다. 그러나 목록, 집계, 조인 결과 전체의 정합성 문제로 일반화하기 어렵고 DB와 캐시의 이중 쓰기 문제도 생긴다. Redis를 진실의 원천처럼 취급하면 안 된다.

### 3\. WAL 위치를 이용한 고급 방식

강한 인과적 정합성이 필요하다면 쓰기 커밋이 포함된 WAL 위치를 추적하고, 읽을 Replica의 replay 위치가 그 지점 이상인지 확인한 뒤 조회할 수 있다. 정확도는 높지만 토큰 전달, 대기 시간 제한, Replica 선택, 장애 시 Writer fallback까지 설계해야 하므로 구현 난도가 높다.

### 4\. 동기식 복제의 `remote_apply`

PostgreSQL의 `synchronous_commit = remote_apply`는 설정된 동기식 Standby가 커밋 레코드를 실제로 replay해 쿼리에 보이게 될 때까지 Primary의 커밋 완료를 기다릴 수 있다. 기본적인 동기 커밋의 원격 flush와 실제 replay 완료는 서로 다르다.

대신 쓰기 지연 시간이 늘고 동기 Standby 상태에 따라 가용성에도 영향을 준다. 여러 Replica 중 다른 서버로 읽기를 보낸다면 그 서버까지 최신이라는 보장도 별도로 확인해야 한다. 자세한 동작은 [PostgreSQL Warm Standby 문서](https://www.postgresql.org/docs/current/warm-standby.html?ref=mlog.me)를 참고하자.

## Pgpool-II를 쓰면 더 간단할까?

Pgpool-II는 SQL을 분석해 쓰기는 Primary, 조건을 만족하는 SELECT는 Standby로 보낼 수 있다. 같은 명시적 트랜잭션에서 쓰기가 발생한 뒤 이어지는 SELECT를 Primary에 유지하는 정책도 제공한다.

하지만 프록시가 모든 업무 의미를 알 수 있는 것은 아니다.

- `SELECT ... FOR UPDATE`, 쓰기 함수, DML을 포함한 CTE 등은 일반 조회와 다르게 취급해야 한다.
- 기본적인 write 후 load balance 비활성화 범위가 같은 트랜잭션이라면, PRG의 다음 GET은 보호되지 않을 수 있다.
- 지연 임계값은 심하게 늦은 Replica를 피하는 장치이지 특정 커밋의 replay 완료 증명이 아니다.
- 프록시 세션 고정은 웹 사용자의 요청 간 고정과 같은 개념이 아니다.
- 애플리케이션 라우팅과 Pgpool-II 라우팅을 동시에 켜면 장애 분석과 책임 경계가 복잡해진다.

| 기준        | 애플리케이션 라우팅                  | Pgpool-II 라우팅         |
| --------- | --------------------------- | --------------------- |
| 업무 의미 반영  | 강한 일관성이 필요한 조회를 코드로 구분하기 쉬움 | SQL과 트랜잭션 규칙을 중심으로 판단 |
| 애플리케이션 변경 | DataSource와 트랜잭션 설계 필요      | 단일 DB 엔드포인트 유지 가능     |
| 운영 책임     | 각 애플리케이션이 담당                | 프록시에 중앙화              |
| 디버깅       | 서비스 메서드의 의도가 비교적 명확         | 프록시 규칙과 세션 상태까지 함께 추적 |
| PRG 정합성   | 별도 Writer 조회·마커로 설계 가능      | 기본 라우팅만으로 보장되지 않음     |

Spring에서 업무 의미에 따라 강한 조회와 지연 허용 조회를 구분할 수 있다면 애플리케이션 라우팅이 명확하다. 여러 서비스의 접속 정책을 중앙화해야 한다면 Pgpool-II가 유리할 수 있다. 어느 쪽이든 **라우팅 책임은 한 계층에 두는 것**이 좋다. 세부 규칙은 [Pgpool-II Load Balancing 문서](https://www.pgpool.net/docs/latest/en/html/runtime-config-load-balancing.html?ref=mlog.me)에서 확인할 수 있다.

## 읽기·쓰기 분리를 서두르지 않아도 되는 경우

Replica가 이미 있다는 이유만으로 읽기 트래픽을 보내는 것은 좋은 근거가 아니다. 다음 조건이라면 기존 Active-Standby 구조를 유지하는 편이 더 단순하고 안전할 수 있다.

- Primary 한 대의 CPU, I/O, 커넥션 사용률에 충분한 여유가 있다.
- 실제 병목이 DB 조회가 아니라 외부 API, 잠금, 잘못된 인덱스 또는 애플리케이션 코드다.
- 대부분의 화면이 저장 직후 최신 데이터를 요구한다.
- 읽기보다 쓰기 비중이 높다.
- 복제 지연과 두 개의 커넥션 풀을 운영할 인력과 관측 체계가 부족하다.
- 목적이 읽기 확장이 아니라 장애 대비뿐이다.

먼저 느린 쿼리, 인덱스, N+1, 불필요한 조회, 캐시 가능 영역을 확인하자. Primary가 실제 읽기 병목이라는 측정 결과가 있을 때, 오래된 값을 허용할 수 있는 목록·검색·통계부터 Replica로 옮기는 것이 좋다.

## 운영 전에 반드시 확인할 항목

### 모니터링

- Primary의 `pg_stat_replication`에서 send·write·flush·replay 위치와 lag를 관측한다.
- 초 단위 lag만 보지 말고 WAL byte 차이와 replay 중단 여부도 함께 본다.
- Writer와 Reader HikariCP의 사용률, 대기 시간, timeout을 분리해 수집한다.
- 요청별 라우팅 대상, `readOnly` 여부, Writer fallback 횟수를 기록한다.
- Replica의 recovery conflict와 장시간 쿼리 취소를 감시한다.
- Redis 마커 hit·miss, TTL, 장애 시 fallback 경로를 확인한다.

DB 역할은 다음 SQL로 간단히 확인할 수 있다.

```sql
SELECT pg_is_in_recovery();

```

`false`면 Primary, `true`면 복구 중인 Standby다. 애플리케이션의 라우팅 로그와 함께 검증하면 잘못된 접속 정보도 빠르게 찾을 수 있다.

### 통합 테스트

H2만으로는 PostgreSQL 복제 지연과 트랜잭션 연결 공유를 검증할 수 없다. 실제 PostgreSQL Primary/Replica 환경에서 적어도 다음 시나리오를 테스트해야 한다.

- 일반 쓰기는 Writer로 가는가?
- 명시적인 읽기 전용 트랜잭션만 Reader로 가는가?
- 트랜잭션 없는 요청은 Writer로 가는가?
- 쓰기 트랜잭션 안의 조회가 Writer에 남는가?
- 바깥 쓰기 트랜잭션과 안쪽 읽기 메서드 조합이 Writer에 남는가?
- JPA 저장 후 flush 전·후 MyBatis 조회 결과가 예상과 같은가?
- MyBatis 수정 후 JPA 1차 캐시를 어떻게 처리하는가?
- Replica 지연 상태에서 PRG가 올바른 경로를 선택하는가?
- Redis와 Reader 장애 시 Primary 여유 용량·동시 요청 제한·회로 차단을 포함해 제한적으로 fallback하는가?
- Primary 장애, Standby 승격, 커넥션 풀 재연결이 목표 RTO 안에 끝나는가?
- 롤백된 트랜잭션이 Redis 마커나 캐시를 남기지 않는가?

## 단계적으로 도입하는 방법

1. 모든 요청을 Primary로 보내는 기존 경로를 유지한다.
2. Writer·Reader 풀과 라우팅 로그를 먼저 구성한다.
3. 플래그를 이용해 모든 조회를 즉시 Primary로 되돌릴 수 있게 한다.
4. 오래된 결과를 허용할 수 있는 조회 전용 기능 하나부터 Replica로 보낸다.
5. 저장 직후 조회에는 명시적인 Writer 정책을 적용한다.
6. 복제 지연, 오류율, fallback, Primary 부하 감소량을 측정한다.
7. 효과가 확인된 기능만 점진적으로 확대한다.

운영 중 문제가 생겼을 때 코드 배포 없이 전체 조회를 Primary로 되돌릴 수 있는 kill switch를 두는 것이 좋다.

## 마무리 체크리스트

- \[ \] 트랜잭션이 없거나 판단이 모호할 때 Writer를 선택하는가?
- \[ \] 명확한 `readOnly = true` 요청만 Reader로 가는가?
- \[ \] 실제 커넥션 획득이 라우팅 판단 이후로 지연되는가?
- \[ \] JPA와 MyBatis가 동일한 최종 DataSource와 트랜잭션을 사용하는가?
- \[ \] JPA flush 문제와 Replica lag 문제를 구분했는가?
- \[ \] 쓰기 직후 조회를 위한 Writer 경로가 있는가?
- \[ \] PRG와 ThreadLocal을 정합성 해결책으로 오해하지 않았는가?
- \[ \] Redis 변경은 DB 커밋 이후에 수행되는가?
- \[ \] Reader 장애 또는 lag 초과 시 Primary 여유 용량·동시 요청 제한·회로 차단을 포함한 fallback 정책이 있는가?
- \[ \] Failover, fencing, 백업, PITR을 별도로 준비했는가?
- \[ \] 모든 조회를 Primary로 되돌리는 운영 스위치가 있는가?

읽기·쓰기 분리의 목적은 DataSource 수를 늘리는 것이 아니라, Primary의 읽기 병목을 줄이면서도 사용자가 신뢰할 수 있는 결과를 제공하는 것이다. 부하를 실제로 측정하고, 지연을 허용할 수 있는 조회만 Replica로 보내며, 강한 일관성이 필요한 흐름은 Primary에 남겨 두자. 이 원칙이 지켜지면 Spring Boot, JPA, MyBatis를 함께 사용하는 환경에서도 확장성과 정합성 사이의 균형을 예측 가능하게 관리할 수 있다.

## 참고 자료

- [Spring Framework — AbstractRoutingDataSource](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/jdbc/datasource/lookup/AbstractRoutingDataSource.html?ref=mlog.me)
- [Spring Framework — LazyConnectionDataSourceProxy](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/jdbc/datasource/LazyConnectionDataSourceProxy.html?ref=mlog.me)
- [Spring Data JPA — Transactionality](https://docs.spring.io/spring-data/jpa/reference/jpa/transactions.html?ref=mlog.me)
- [MyBatis-Spring — Transactions](https://mybatis.org/spring/transactions.html?ref=mlog.me)
- [PostgreSQL — Warm Standby](https://www.postgresql.org/docs/current/warm-standby.html?ref=mlog.me)
- [PostgreSQL — Monitoring Statistics](https://www.postgresql.org/docs/current/monitoring-stats.html?ref=mlog.me)
- [Pgpool-II — Load Balancing](https://www.pgpool.net/docs/latest/en/html/runtime-config-load-balancing.html?ref=mlog.me)