티스토리 뷰

실무 개발

2PC (2-Phase-Commit)은 무엇인가?

Jason of the Argos 2024. 9. 26. 00:01

1. 2PC의 개념

2PC는 분산환경에서 트랜잭션의 원자성을 지키기 위해 사용하는 프로토콜이다.

 

무엇이 문제인가

  • 흔히 말하는 MSA 환경: 서비스 단위로 DB와 서버 어플리케이션이 분리되어 있는 환경에서
  • 여러 인스턴스 & 각 DB를 거치는 트랜잭션(이를 분산트랜잭션이라 한다)을 어떻게 all or nothing으로 처리할 수 있는가?
  • 이런 환경에서는 db transaction (begin, end) 또는 서버 어플리케이션 코드 단위의 트랜잭션 기능(ex. @Transactional)은 원자성을 보장해주지 못한다.

이를 해결하기 위해 등장한 것이 2PC이다.

우선 알아야 할 단어가 있는데 바로 노드다.

 

노드란: 분산 시스템 내에서 트랜잭션에 참여하는 개별 인스턴스 or 서버 (주로 DB)를 지칭한다.

노드는 두 가지 종류가 있는데,

  1. Coordinator (조정자): 분산 트랜잭션이 제대로 진행되는지 관리하는 노드
  2. Participator (참여자): 조정자 외 분산 트랜잭션에 참여하는 다른 모든 노드

 

2PC는 그 이름처럼 2 단계(phase)로 나뉘어 진행된다.

 

1) 준비단계 Prepare Phase

  • Coordinator가 트랜잭션 Participator들에게 트랜잭션에 참여할 수 있는지 확인한다.
  • 각 Participator는 트랜잭션을 로컬에서 준비하고, 성공할 수 있는지 여부를 응답한다. (Y/N)
  • 모든 Participator들이 Y를 보내야 다음 단계로 넘어간다.

2) 커밋 단계 Commit Phase

  • 모든 Participator가 YES를 보냈다면 조정다는 Commit 명령을 내린다.
  • Participator가 commit이 제대로 되지 않았다면 전체 Rollback 한다.

 

과정을 하나의 다이어그램으로 표현하면 다음과 같다.

 

이론적 설명은 직관적이고 쉽다.
하지만 실제 구현은 어떤 방식으로 이뤄질까?


2. 2PC의 구현

구현 레벨에서의 조정자는

  • 트랜잭션을 관리하는 주체로, 어플리케이션 서버 내에서 작동할 수도 있고, 별도의 트랜잭션 관리 시스템일 수도 있다.
  • 2가지의 방법이 있는데, 분산된 각 노드별로 있거나 중앙화 시켜서 한 곳에서 있게 할 수 있다.

Spring, MySQL 기술스택을 사용하는 MSA환경에서

서버 A, B, C가 각각 DB 1,2,3 을 사용하고 있다고 가정해보자.

 

이때 이 2PC를 구현하기 위해 필요한 기술은 3가지가 있다.

 

1) Spring Transaction Manager

  • JTA (Java Transaction API)
  • JtaTransactionManager

2) JTA Coordinator

  • JTA 트랜잭션 관리자는 각 DB와의 트랜잭션을 조정한다.
  • 트랜잭션이 시작되면 MySQL DB에 Prepare, Commit, Rollback을 조정한다.

3) MySQL의 XA Transaction

  • MySQL에서 지원하는 분산 트랜잭션의 표준이다.

 

여기서 별개의 이슈가 발생하는데 바로 각 노드들이 참여자들에 대한 정보를 모두 알아야한다는 것이다.

 

1) 트랜잭션 조정자가 모든 노드를 알아야하는 이유

  1. 조정자는 트랜잭션을 시작하고, 그 트랜잭션에 어떤 데이터베이스가 참여할지를 알고 있어야 함
  2. 각 참여노드는 조정자와 협력하여 트랜잭션 상태(준비 완료 또는 실패)를 공유
  3. 조정자는 모든 참여 노드에서 Prepare 메시지에 대한 응답을 받은 후 최종적으로 커밋 또는 롤백을 결정

이 과정에서, 조정자는 트랜잭션에 참여하는 모든 데이터베이스(노드)를 알아야 올바른 트랜잭션 상태를 유지할 수 있다.

 

2) 어떻게 애플리케이션이 모든 노드를 인식하게 될까?

  1. 사전 구성:
    • 보통 분산 트랜잭션에 참여하는 데이터베이스 정보는 사전에 구성
    • Spring 같은 프레임워크에서는 각 데이터베이스에 대한 트랜잭션 설정을 명시적으로 구성
  2. JTA와 XA 트랜잭션:
    • JTA 트랜잭션 관리자는 분산 트랜잭션의 참여 데이터베이스를 인식. 각 데이터베이스는 XA 트랜잭션을 통해 트랜잭션에 참여
    • 애플리케이션 코드에서는 주로 트랜잭션 관리자가 해당 트랜잭션에 포함된 데이터베이스들과 상호작용하도록 설정
  3. 트랜잭션 범위에서 자동으로 참여:
    • Spring에서는 여러 데이터베이스에 접근하는 로직이 있으면, 트랜잭션 관리자(JtaTransactionManager)가 그 트랜잭션에 참여하는 각 데이터베이스를 자동으로 인식하고 관리
    • 즉, 비즈니스 로직에서 여러 데이터베이스를 접근하면 트랜잭션 관리자는 각 데이터베이스와 트랜잭션을 조정할 수 있도록 준비

3) 문제점 및 해결책

이 방식에서는 조정자가 모든 데이터베이스에 대한 정보를 사전에 알아야 하고, 그로 인해 확장성 문제가 발생한다.

새로운 데이터베이스가 추가되면 해당 정보를 조정자가 알아야 하고, 이때마다 트랜잭션 참여 구성이 업데이트가 필요하게 된다. 

이 확장성 문제를 동적 서비스 디스커버리로 해결한다.

이는 각 서버나 데이터베이스 노드가 자동으로 트랜잭션에 참여할 수 있도록 하고, 중앙화 된 조정자가 동적으로 참여 노드를 관리할 수 있도록 지원한다. 그 중 하나인 Eureka를 예시로 들겠다.

 

4) Eureka를 사용한 2PC 구현 예시

Eureka 서버 설정

// EurekaServerApplication.java
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaServerApplication.class, args);
    }
}

 

서버 A,B,C의 Spring Boot 설정 (Eureka Client)
application.yml (서버 A의 경우, B와 C도 유사하게 설정)

server:
  port: 8081  # 서버 A의 포트

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/  # Eureka 서버 URL
  instance:
    hostname: server-a  # 서버 A의 호스트 이름

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/db_a  # 서버 A의 데이터베이스
    username: root
    password: password
  jta:
    enabled: true

logging:
  level:
    org.springframework: DEBUG

서버 B와 C는 각각 포트, 호스트명, 데이터베이스 URL을 다르게 설정하고 Eureka 서버에 연결되도록 설정

 

MySQL 데이터베이스에서 XA 트랜잭션 활성화

SET GLOBAL innodb_support_xa=1;

 

Spring에서 JTA 트랜잭션 설정

각 어플리케이션서버는 JTA 트랜잭션 사용해서 2PC 트랜잭션 관리

pom.xml 

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
    <groupId>javax.transaction</groupId>
    <artifactId>javax.transaction-api</artifactId>
</dependency>
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-entitymanager</artifactId>
</dependency>

 

JTA 트랜잭션 관리 설정

@Configuration
@EnableTransactionManagement
public class TransactionConfig {
    @Bean
    public PlatformTransactionManager transactionManager(UserTransaction userTransaction, TransactionManager transactionManager) throws Throwable {
        return new JtaTransactionManager(userTransaction, transactionManager);
    }

    @Bean
    public UserTransaction userTransaction() throws Throwable {
        com.arjuna.ats.jta.UserTransactionImp userTransactionImp = new com.arjuna.ats.jta.UserTransactionImp();
        return userTransactionImp;
    }

    @Bean
    public TransactionManager transactionManager() throws Throwable {
        com.arjuna.ats.jta.TransactionManagerImple transactionManagerImple = new com.arjuna.ats.jta.TransactionManagerImple();
        return transactionManagerImple;
    }
}

 

분산 트랜잭션 코드

서버 A에서 분산 트랜잭션을 관리하고, 서버 B와 C에 대한 트랜잭션을 실행한다고 가정

@RestController
public class DistributedTransactionController {

    @Autowired
    private DataSource dataSourceA;

    @Autowired
    private RestTemplate restTemplate;

    @Transactional
    @PostMapping("/distributed-transaction")
    public String distributedTransaction() throws Exception {
        // 서버 A의 데이터베이스에 트랜잭션 수행
        try (Connection connectionA = dataSourceA.getConnection()) {
            connectionA.setAutoCommit(false);
            PreparedStatement stmt = connectionA.prepareStatement("INSERT INTO table_a (data) VALUES (?)");
            stmt.setString(1, "Data for Server A");
            stmt.executeUpdate();

            // 서버 B와 C로 REST 요청을 보내 트랜잭션을 처리함
            String serverBResponse = restTemplate.postForObject("http://server-b:8082/transaction", null, String.class);
            String serverCResponse = restTemplate.postForObject("http://server-c:8083/transaction", null, String.class);

            if ("SUCCESS".equals(serverBResponse) && "SUCCESS".equals(serverCResponse)) {
                connectionA.commit();
                return "Distributed transaction committed successfully";
            } else {
                connectionA.rollback();
                throw new Exception("Transaction failed on server B or C");
            }
        } catch (Exception e) {
            throw new Exception("Distributed transaction failed", e);
        }
    }
}

 


3. 한계점

  • 분산 트랜잭션을 사용할 수 있는 application이면 어떤 DB든 가능
  • NoSQL X
  • 이종간 DB 구현 어려움
  • DB 이중화 구조와 비슷하기 때문에 하나의 API 서버에 요청이 들어오고, 내부적으로 DB가 분산되어 있을 때 사용하는 형태
  • 성능 오버헤드
    • 2단계로 이뤄지고 트랜잭션 참여 노드 <-> 코디네이터 간의 네트워크 통신 발생
    • 레이턴시 발생 -> 트랜잭션 시간 증가 -> bottle neck 유발 (처리량 & 타임아웃)
  • 리소스 락
    • 2PC에서는 트랜잭션이 완료될 때까지 모든 참여 노드가 데이터를 락(Lock) 상태 유지
    • Prepare 단계에서 모든 리소스가 OK를 반환하면 트랜잭션이 끝날 때까지 해당 리소스는 다른 트랜잭션이 접근하지 못함
    • 데드락 발생 할 수 있음