최근에 지인에게 테스트코드에서 Transactional 어노테이션을 붙이면 테스트가 성공하고, 어노테이션을 제거하면 테스트가 실패하는데 그 이유를 모르겠다는 질문을 받았다.

문제상황

당시 문제상황을 간단한 샘플코드로 재현해 보았다.

@Entity  
@Table(name = "orders")  
class Order(  
    id: Long? = null,  
) {  

    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    var id: Long? = id  
        private set  

    var isParcelRegistered: OrderParcelStatus = OrderParcelStatus.PADDING  
        private set  


    fun registerParcel() {  
        isParcelRegistered = OrderParcelStatus.REGISTERED  
    }  

    fun registerParcelFailed() {  
        isParcelRegistered = OrderParcelStatus.REGISTER_FAILED  
    }  


}

@Service  
class OrderService(  
    private val orderRepository: OrderRepository,  
) {  

    @Transactional  
    fun applyParcelEvent(parcelEvent: ParcelEvent) {  
        when (parcelEvent) {  
            is ParcelEvent.Success -> {  
                val order: Order = orderRepository.findById(parcelEvent.orderId).get()  
                order.registerParcel()  
            }  

            is ParcelEvent.Failure -> {  
                val order: Order = orderRepository.findById(parcelEvent.orderId).get()  
                order.registerParcelFailed()  
            }  
        }  
    }  

}

@SpringBootTest  
class TestCode(  
    @Autowired  
    private val orderService: OrderService,  
    @Autowired  
    private val orderRepository: OrderRepository,  
) {  

    @Test  
    @DisplayName("택배 등록 실패 이벤트가 발생하면, 주문의 택배 등록 상태가 실패로 변경된다.")  
    fun checkOrderStatus() {  
        // given  
        val order = Order()  
        val savedOrder: Order = orderRepository.save(order)  
        val failedParcelEvent = ParcelEvent.Failure(savedOrder.id!!)  


        // when  
        orderService.applyParcelEvent(failedParcelEvent)  

        // then  
        Assertions.assertThat(savedOrder.isParcelRegistered).isEqualTo(OrderParcelStatus.REGISTER_FAILED)  
    }  
}

 

처음 Order객체를 생성하면 택배 등록 상태를 나타내는 OrderParcelStatus 의 값이 PADDING인채로 생성된다. 이때 OrderService에 외부에서 발생한 택배 등록 이벤트(PercelEvent)를 전달해 주면 이벤트의 성공 실패 종류에 따라 Order객체의 OrderParcelStatus의 값을 변경한다.

테스트 코드는 택배 등록 이벤트가 전달되면 Order 객체의 값을 변경하는지 검증한다. 우선 택배 등록 실패이벤트가 발생한것을 가정하고 이를 orderService에 전달한다. 그러면 기존에 저장되어있던 Order의 택배 등록 상태가 실패로 변경되기를 기대하고 테스트를 수행한다.

이때 테스트는 실패한다. 택배 등록 실패이벤트를 전달했음에도 불구하고, 택배의 등록상태가 변경되지 않는다. 왜 그런걸까?

savedOrder 객체는 영속성 컨텍스트에서 관리되지 않는다

@SpringBootTest  
class TestCode(  
    @Autowired  
    private val entityManager: EntityManager,  
    @Autowired  
    private val orderService: OrderService,  
    @Autowired  
    private val orderRepository: OrderRepository,  
) {  

    @Test  
    @DisplayName("택배 등록 실패 이벤트가 발생하면, 주문의 택배 등록 상태가 실패로 변경된다.")  
    fun checkOrderStatus() {  
        // given  
        val order = Order()  
        val savedOrder: Order = orderRepository.save(order)  
        val failedParcelEvent = ParcelEvent.Failure(savedOrder.id!!)  

        println(entityManager.contains(savedOrder)) // false

        // when  
        orderService.applyParcelEvent(failedParcelEvent)  

        // then  
        Assertions.assertThat(savedOrder.isParcelRegistered).isEqualTo(OrderParcelStatus.REGISTER_FAILED)  
    }  
}

테스트 코드에서 위와같이 entityManager를 통해 현재 영속성 컨텍스트에 savedOrder가 있는지 여부를 조회하면 false값이 나온다. 따라서 JPA의 영속성 컨텍스트에서 관리되지 않고있다는 점을 확인할 수 있다. 영속성 컨텍스트에서 관리되지 않으니, 당연히 savedOrder 객체는 더티체킹의 효과를 볼 수 없다.

영속성 컨텍스트는 트랜잭션내에서 관리된다. 때문에 orderRepository로 가져온 savedOrder객체는 트랜잭션 밖, 즉 영속성 컨텍스트 밖이기에 영속성 컨텍스트에 의해 관리되지 않는 객체이다.

때문에 이후 orderService.applyParcelEvent(...)를 통해 DB에 저장된 order의 OrderParcelStatus를 변경하더라도, savedOrder의 값은 변화가 없다.

영속성 컨텍스트는 트랜잭션 범위 내에서 관리된다


영속성 컨텍스트의 종류는 두가지가 있다.

  • Transaction-scoped persistence context
  • Extended-scoped persistence context

Transaction-scoped persistence context의 경우 트랜잭션 단위로 영속성 컨텍스트가 유지되는 반면, Extended-scoped persistence context의 경우 컨테이너가 관리하는 영속성 컨텍스트로 여러 트랜잭션에 걸쳐 사용될 수 있다.

확장된 퍼시스턴스 컨텍스트를 갖는 EntityManager는 트랜 잭션 스코프의 퍼시스턴스 컨텍스트에서 사용되는 EntityManager 처럼 멀티스레드에서 안전한 프록시 오브젝트가 아니라 멀티스레드에서 안전하지 않은 실제 EntityManager다.
- 토비의 스프링 3.1 Vol.2 289p

public @interface PersistenceContext {  

    ...

    /**  
     * (Optional) Specifies whether a transaction-scoped persistence context     * or an extended persistence context is to be used.  
     */     
    PersistenceContextType type() default PersistenceContextType.TRANSACTION;  
    ...
}

PersistenceContext의 어노테이션을 직접 확인해보면 Transaction-scoped persistence context를 기본값으로 사용한다. Transaction-scoped persistence context를 사용하면 여러 측면에서 다음과 같은 장점이 있다.

효율성
transaction-scoped 영속성 컨텍스트는 트랜잭션이 끝나면 자동으로 종료되므로, 불필요한 리소스 사용을 줄일 수 있다. 반면에 Extended 영속성 컨텍스트는 트랜잭션이 끝나도 종료되지 않고 계속 유지되므로, 리소스 사용이 더 많을 수 있다.

일관성
transaction-scoped 영속성 컨텍스트는 트랜잭션 범위 내에서 일관성을 보장한다. 즉, 트랜잭션 내에서 수행된 모든 데이터베이스 작업은 일관된 상태를 유지한다. 반면에 Extended 영속성 컨텍스트는 여러 트랜잭션에 걸쳐 사용될 수 있으므로, 일관성을 보장하기 어려울 수 있다.

간단함
transaction-scoped 영속성 컨텍스트는 트랜잭션을 시작하고 종료하는 것만으로 영속성 컨텍스트를 관리할 수 있다. 반면에 Extended 영속성 컨텍스트는 수동으로 관리해야 하므로, 사용하기 복잡할 수 있다.

실제 트랜잭션을 부여하는 JpaTransactionManager를 살펴보면 EntityManager를 생성해 트랜잭션 단위로 관리하는것을 볼 수 있다.

우선 PlatformTransactionManager의 기본적인 동작 일부를 구현하고 있는 AbstractPlatformTransactionManager를 살펴보면 startTransaction부분에서 TransactionManager의 doBegin메서드를 호출하고있는것을 볼 수 있다.

private TransactionStatus startTransaction(TransactionDefinition definition, Object transaction,  
       boolean nested, boolean debugEnabled, @Nullable SuspendedResourcesHolder suspendedResources) {  

    boolean newSynchronization = (getTransactionSynchronization() != SYNCHRONIZATION_NEVER);  
    DefaultTransactionStatus status = newTransactionStatus(  
          definition, transaction, true, newSynchronization, nested, debugEnabled, suspendedResources);  
    this.transactionExecutionListeners.forEach(listener -> listener.beforeBegin(status));  
    try {  
       doBegin(transaction, definition);  
    }  
    catch (RuntimeException | Error ex) {  
       this.transactionExecutionListeners.forEach(listener -> listener.afterBegin(status, ex));  
       throw ex;  
    }  
    prepareSynchronization(status, definition);  
    this.transactionExecutionListeners.forEach(listener -> listener.afterBegin(status, null));  
    return status;  
}

doBegin메서드는 TransactionManager를 구현하고 있는 JpaTransactionManager에서 확인할 수 있는데, entityManager를 생성하고 있는것을 볼 수 있다.

@Override  
protected void doBegin(Object transaction, TransactionDefinition definition) {  
    JpaTransactionObject txObject = (JpaTransactionObject) transaction;  

    if (txObject.hasConnectionHolder() && !txObject.getConnectionHolder().isSynchronizedWithTransaction()) {  
       throw new IllegalTransactionStateException(  
             "Pre-bound JDBC Connection found! JpaTransactionManager does not support " +  
             "running within DataSourceTransactionManager if told to manage the DataSource itself. " +  
             "It is recommended to use a single JpaTransactionManager for all transactions " +  
             "on a single DataSource, no matter whether JPA or JDBC access.");  
    }  

    try {  
       if (!txObject.hasEntityManagerHolder() ||  
             txObject.getEntityManagerHolder().isSynchronizedWithTransaction()) {  
          EntityManager newEm = createEntityManagerForTransaction();  
          if (logger.isDebugEnabled()) {  
             logger.debug("Opened new EntityManager [" + newEm + "] for JPA transaction");  
          }  
          txObject.setEntityManagerHolder(new EntityManagerHolder(newEm), true);  
       }  

       EntityManager em = txObject.getEntityManagerHolder().getEntityManager();
       ...

JpaTransactionManager의 doBegin 메서드는 새로운 트랜잭션을 시작할 때 호출된다. 이 메서드에서는 EntityManager를 생성하고 이를 JpaTransactionObject에 저장한다.

JpaTransactionObject는 현재 트랜잭션의 상태를 추적하는 데 사용되며, 트랜잭션 범위 내에서 사용되는 EntityManager를 보유하고 있다. 이렇게 하면 트랜잭션 범위 내에서 동일한 EntityManager 인스턴스가 사용될 수 있다. 이렇게 JpaTransactionManager는 트랜잭션 단위로 EntityManager를 관리한다.

하지만 항상 영속성 컨텍스트의 생존 범위가 무조건 트랜잭션 범위 내 인것은 아니다. open session in view를 사용하면 영속성 컨텍스트의 범위를 트랜잭션 범위 밖까지 확장할 수 있다.

Reference

한 테이블에 여러개의 데이터를 한번에 생성해야하는 API를 설계하면서, 성능을 개선하기 위해 각 데이터를 별개의 트랜잭션으로 나누어 DB에 병렬적으로 삽입을 요청하는 과정에서 데드락 이슈를 만나게 되었습니다.
이를 간단한 예시코드와 함께 해결해 나가는 과정을 다루어 보겠습니다.

초기 구현

요구 사항

  • 제품 배치 생성 API
  • 배치 내의 각 제품 생성 요청은 별개의 트랜잭션으로 처리되어야 한다.
  • 제품명의 중복은 허용되지 않는다.

테이블 설계

CREATE TABLE product  
(  
    id            BIGINT            NOT NULL AUTO_INCREMENT PRIMARY KEY,  
    name        VARCHAR(255)     NOT NULL,  
    description    TEXT            NOT NULL  
);  
CREATE UNIQUE INDEX Product_name_uindex ON product (name);

위와 같은 요구사항을 해결하기 위해 다음과 같이 구현을 진행했습니다.

배치 생성 UseCase

interface ProductBatchCreateUseCase {  

    fun invoke(commands: List<Command>): List<Result>  

    data class Command(  
        val name: String,  
        val description: String,  
    )  

    sealed class Result {  
        data class Success(val postId: PostId) : Result()  
        data class Failure(  
            val name: String,  
            val message: String  
        ) : Result()  
    }  
}

@Service  
class ProductBatchCreate(  
    private val productCreateUseCase: ProductCreateUseCase  
) : ProductBatchCreateUseCase {  

    override fun invoke(commands: List<ProductBatchCreateUseCase.Command>): List<ProductBatchCreateUseCase.Result> {  
        val results: List<ProductCreateUseCase.Result> = commands.map {  
            productCreateUseCase.invoke(  
                command = ProductCreateUseCase.Command(  
                    name = it.name,  
                    content = it.description  
                )  
            )  
        }  

        return results.map {  
            when (it) {  
                is ProductCreateUseCase.Result.Success -> mapToSuccess(it)  
                is ProductCreateUseCase.Result.Failure -> mapToFailure(it)  
            }  
        }  
    }  

    private fun mapToSuccess(result: ProductCreateUseCase.Result.Success): ProductBatchCreateUseCase.Result.Success {  
        return ProductBatchCreateUseCase.Result.Success(postId = result.id)  
    }  

    private fun mapToFailure(result: ProductCreateUseCase.Result.Failure): ProductBatchCreateUseCase.Result.Failure {  
        return ProductBatchCreateUseCase.Result.Failure(  
            name = result.title,  
            message = result.message,  
        )  
    }  
}

단건 생성 UseCase

interface ProductCreateUseCase {  

    fun invoke(command: Command): Result  

    data class Command(  
        val name: String,  
        val content: String,  
    )  

    sealed class Result {  
        data class Success(val id: PostId) : Result()  
        data class Failure(  
            val title: String,  
            val message: String  
        ) : Result()  
    }  
}

@Service  
class ProductCreate(  
    private val productRepository: ProductRepository  
) : ProductCreateUseCase {  

    @Transactional(propagation = Propagation.REQUIRES_NEW)  
    override fun invoke(command: ProductCreateUseCase.Command): ProductCreateUseCase.Result {  
        val product: Product = Product.create(  
            name = command.name,  
            content = command.content,  
        )  

        if (isDuplicateTitle(product.name)) {  
            return ProductCreateUseCase.Result.Failure(  
                title = product.name,  
                message = "중복된 상품 명입니다."  
            )  
        }  

        val savedProduct: Product = productRepository.save(product)  

        return ProductCreateUseCase.Result.Success(id = savedProduct.id)  
    }  

    private fun isDuplicateTitle(title: String): Boolean {  
        return productRepository.existsByName(title)  
    }  
}

단건 생성의 경우 별개의 트랜잭션으로 처리됨을 보장하고 명시하기 위해 Propagation을 REQUIRES_NEW로 두었습니다.

중복여부는 Duplicate Key 에러로도 확인할 수 있지만, DataIntegrityViolationException 안에 포함된 메시지를 파싱해 중복으로 인한 에러인지 혹은 다른 에러인지 판단해야하고 DB에 의존적이라는 문제가 있습니다.

때문에 제품의 중복 여부를 애플리케이션 레벨에서도 확인할 수 있어야 한다는 판단에 중복확인을 위한 validation 로직을 작성하게 되었습니다. 배치 생성이 정상적으로 이루어 지는지 통합 테스트를 통해 확인해 보았습니다.

@SpringBootTest  
@ContextConfiguration(classes = [IntegrationTestSetup::class])  
class ProductBatchCreateTest(  
    private val sut: ProductBatchCreateUseCase  
) : FunSpec({  

    test("제품 배치 생성") {  
        // given  
        val commands: List<ProductBatchCreateUseCase.Command> = (0 until 10).map {  
            ProductBatchCreateUseCase.Command(  
                name = "제품",  
                description = "제품 $it 설명"  
            )  
        }  

        // when  
        val results: List<ProductBatchCreateUseCase.Result> = sut.invoke(commands)  

        // then  
        results.filterIsInstance<ProductBatchCreateUseCase.Result.Success>().size shouldBe 10  
    }

    test("제품 배치 생성 시간 측정") {  
    // given  
    val commands: List<ProductBatchCreateUseCase.Command> = (0 until 1000).map {  
        ProductBatchCreateUseCase.Command(  
            name = "제품 $it",  
            description = "제품 $it 설명"  
        )  
    }  

    // when, then  
    measureTimeMillis { sut.invoke(commands) }  
        .also { time -> println("제품 배치 생성 시간: $time ms") }
    }
})
제품 배치 생성 시간: 6998 ms

통합테스트의 경우 TestContainer를 통해 운영 코드와 동일한 환경에서 테스트 했습니다. 제품 배치 생성의 경우 한개 생성 요청을 처리하면 그다음 생성 요청을 순차적으로 처리하는 방식으로 구현되어 있는데, 이러한 방식의 구현은 효율적이지 않습니다.

 

코루틴 병렬처리 적용

각 생성 요청은 하나의 트랜잭션으로 묶여있을 필요가 없기 때문에 병렬적으로 처리 가능합니다. 이를 위해 배치 생성 요청을 코루틴을 활용한 병렬 처리 방식으로 개선하고 생성 시간을 측정해 보았습니다.

    override suspend fun invoke(commands: List<ProductBatchCreateUseCase.Command>): List<ProductBatchCreateUseCase.Result> =  
    coroutineScope {  
        val deferredResults: List<Deferred<ProductCreateUseCase.Result>> = commands.map { command ->  
            async(Dispatchers.IO) {  
                productCreateUseCase.invoke(  
                    ProductCreateUseCase.Command(  
                        name = command.name,  
                        content = command.description  
                    )  
                )  
            }  
        }  

        deferredResults.awaitAll().map { result ->  
            when (result) {  
                is ProductCreateUseCase.Result.Success -> mapToSuccess(result)  
                is ProductCreateUseCase.Result.Failure -> mapToFailure(result)  
            }  
        }  
    }
제품 배치 생성 시간: 1593 ms

1000개의 데이터를 생성하는 테스트로 확인해본 결과 수행시간이 6998ms에서 1593ms으로 개선되었습니다. 오차를 감안하더라도 크게 개선된 수치입니다.

성능은 개선되었지만, 새로운 문제점이 발생했습니다. 만약 배치 생성 요청 내에서 중복된 제품명이 존재하는 경우 ProductCreate의 isDuplicateTitle 메서드가 제품명의 중복을 정상적으로 확인하지 못하고, productRepository.save(product)를 호출하게 됨으로써, DB의 DataIntegrityViolationException을 발생시키게 된다는 점입니다.

A 트랜잭션
select * from product where name = "중복이름"
insert into product (name, description) values ('중복이름', 'test');


B 트랜잭션
select * from product where name = "중복이름"
insert into product (name, description) values ('중복이름', 'test');

현재 MySQL의 트랜잭션 격리 수준은 기본값인 REPETABLE_READ격리 수준인데, 병렬적으로 수행되는 두 트랜잭션이 트랜잭션 수행전 스냅샷을 기준으로 select 쿼리를 수행하기 때문에 여러 트랜잭션이 중복된 name을 가지고 있더라도 select시에는 조회가 되지 않기 때문에 insert query는 수행되게 됩니다.

이러한 문제를 해결하기 위해 isDuplicateTitle메서드의 쿼리를 select .. for update를 사용해 쓰기잠금을 걸어 개선해 보려 시도해 보았습니다.

 

데드락

could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [insert into product (description,name) values (?,?)]; SQL [insert into product (description,name) values (?,?)]

org.springframework.dao.CannotAcquireLockException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [insert into product (description,name) values (?,?)]; SQL [insert into product (description,name) values (?,?)]

테스트를 수행해본 결과 위와 같은 오류의 데드락을 확인할 수 있었습니다. MySQL 콘솔에서는 SHOW ENGINE INNODB STATUS 명령어를 통해 최근에 발생한 데드락에 대한 정보를 확인할 수 있었습니다.

**트랜잭션 (1)**

(1) HOLDS THE LOCK(S):

RECORD LOCKS space id 2 page no 5 n bits 80 index Product_name_uindex of table `test`.`product` trx id 2984 lock_mode X locks gap before rec
Record lock, heap no 8 PHYSICAL RECORD: n_fields 2; compact format; info bits 0

(1) WAITING FOR THIS LOCK TO BE GRANTED:

RECORD LOCKS space id 2 page no 5 n bits 80 index Product_name_uindex of table `test`.`product` trx id 2984 lock_mode X locks gap before rec insert intention waiting_
Record lock, heap no 8 PHYSICAL RECORD: n_fields 2; compact format; info bits 0

**트랜잭션 (2)**

(2) HOLDS THE LOCK(S):

RECORD LOCKS space id 2 page no 5 n bits 80 index Product_name_uindex of table `test`.`product` trx id 2992 lock_mode X locks gap before rec
Record lock, heap no 8 PHYSICAL RECORD: n_fields 2; compact format; info bits 0_

(2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 5 n bits 80 index Product_name_uindex of table `test`.`product` trx id 2992 lock_mode X locks gap before rec insert intention waiting

Record lock, heap no 8 PHYSICAL RECORD: n_fields 2; compact format; info bits 0_

 

발생한 로그를 분석해 보면 다음과 같습니다.

트랜잭션 1

  • 상태: 삽입 중, 락 대기 중
  • 행위: product 테이블에 insert 쿼리 실행
  • 락 정보:
    • 보유 중인 락: Product_name_uindex에 대한 X 락 및 갭 락(gap lock)
    • 대기 중인 락: 동일한 인덱스에 대한 X 갭 락 및 삽입 의도 락(insert intention lock)

트랜잭션 2

  • 상태: 삽입 중, 락 대기 중
  • 행위: product 테이블에 insert 쿼리 실행
  • 락 정보:
    • 보유 중인 락: Product_name_uindex에 대한 X 락 및 갭 락(gap lock)
    • 대기 중인 락: 동일한 인덱스에 대한 X 갭 락 및 삽입 의도 락(insert intention lock)

여기서 한가지 의문이 들 수 도 있는데, 두 트랜잭션이 보유중인 락이 베타적 락(lock_mode : X)이라는 점입니다. 일반적으로 베타적 락은 동시에 소유할수 없다고 알고 있는데, 로그엔 두 트랜잭션이 동일한 위치에 베타적 락을 소유하고 있는것으로 보입니다. 여기에 대한 답은 MySQL의 공식문서에서 확인해 볼 수 있습니다.

MySQL 공식문서 - Gap lock
Gap locks in InnoDB are “purely inhibitive”, which means that their only purpose is to prevent other transactions from inserting to the gap. Gap locks can co-exist. A gap lock taken by one transaction does not prevent another transaction from taking a gap lock on the same gap. There is no difference between shared and exclusive gap locks. They do not conflict with each other, and they perform the same function.

갭 락(gap lock)의 경우 여러 트랜잭션이 동일한 갭에 대해 갭락을 가질 수 있으며, 충돌하지 않는다고 설명하고 있습니다. 이러한 의문점이 해소가 된다면 위의 로그를 통해 데드락의 발생원인을 명확히 파악할 수 있습니다.

실제로 존재하지 않는 데이터에 대해 select * for update를 쿼리를 날려 갭락이 발생했으며, 갭락은 여러 트랜잭션에서 공존할 수 있기 때문에 두 트랜잭션이 동시에 획득한 상태가 됩니다.

이때 각 트랜잭션은 이후 삽입쿼리를 위해 삽입 의도 락(insert intention lock)을 획득하려 하는데 이는 갭락과 호환되지 않기 때문에 두 트랜잭션이 서로의 갭 락을 기다리게 되고, 트랜잭션이 끝나지 않으므로 gap lock을 획득하지 못한 상태가 유지되며 데드락이 발생하게 된 것 입니다.

단순히 Gap lock으로 인한 데드락을 없애기 위해서는 Repeatable Read격리수준을 사용해 Gap락을 명시적으로 사용하지 않도록 하면 됩니다. Repeatable Read 격리수준에서는 트랜잭션이 시작될 때 읽은 데이터가 트랜잭션이 종료될 때까지 변경되지 않음을 보장합니다.

이를 위해서는 다른 트랜잭션이 특정 간격에 데이터를 삽입 하지 않음이 보장되어야 하는데, MySQL에서는 이를 갭락으로 해결합니다.

때문에 Read Committed 격리수준을 사용하면 갭 락의 사용을 명시적으로 해제할 수 있습니다. 다만 binary log format을 row로 설정하는 등의 격리수준 하향에 따른 부수효과에 대한 대응도 염두에 두어야 합니다.

 

Synchronized 키워드 사용

하지만 select ... for update는 Read Committed 레벨에서 어떠한 잠금도 발생시키지 않기때문에, 여전히 중복된 값을 삽입하여 DataIntegrityViolationException을 발생시키게 됩니다. 또한 Repeatable Read 레벨에서는 앞서 보았던 바와 같이 Deadlock을 발생시켰습니다.

애플리케이션 레벨에서 완전히 로직을 제어하기 위해 validation로직을 구현한 것이니 분산락을 활용하거나, synchronized 키워드를 사용하는것이었는데, 현재는 단일 노드에서 발생하는 동시성 처리가 주된 관심사이기에 synchronized 키워드를 통해 애플리케이션 레벨의 락을 잡는것이 좋겠다는 생각도 들었습니다.

하지만 @Synchronized와 @Transactional을 같이 사용하는 경우 몇가지 잠재적인 문제가 발생합니다.

 

  • 트랜잭션 전파 문제:
    • @Synchronized는 메소드 진입 시점에 락을 획득하고 메소드 종료 시점에 락을 해제합니다.
    • @Transactional은 실제로는 프록시를 통해 동작하며, 메소드 호출 전에 트랜잭션을 시작하고 메소드 완료 후 커밋/롤백합니다.
    • 이 두 어노테이션의 순서와 동작 방식의 차이로 인해 의도한 대로 동작하지 않을 수 있습니다.
  • 동시성 제어 수준의 불일치:
    • @Synchronized는 JVM 레벨의 동시성을 제어합니다.
    • @Transactional은 데이터베이스 레벨의 트랜잭션을 제어합니다.
    • 두 레벨의 동시성 제어를 혼용하면 복잡성이 증가하고 예상치 못한 동작이 발생할 수 있습니다.

 

요구사항 재분석

사실 이쯤에서 처음부터 다시 생각해보면, 우리가 해결하고자 했던 핵심 요구사항을 되짚어볼 필요가 있습니다.

  1. 제품 배치 생성 API 구현
  2. 각 제품 생성은 별개의 트랜잭션으로 처리
  3. 제품명 중복 불가
  4. 성능 최적화 필요

우리는 성능 최적화를 위해 코루틴을 활용한 병렬 처리를 시도했고, 이 과정에서 데드락과 같은 동시성 문제에 직면했습니다. 이를 해결하기 위해 여러 방식을 시도했지만, 각각의 접근 방식은 한계점을 보였습니다:

Select For Update 시도

  • Repeatable Read에서 Gap Lock으로 인한 데드락 발생
  • Read Committed에서는 잠금이 제대로 동작하지 않음

격리 수준 조정 시도

  • Read Committed로 낮추면 동시성 문제 발생 가능
  • Binary log format 설정 변경 등 부가적인 설정 필요

Synchronized 키워드 시도

  • @Transactional과의 조합에서 예상치 못한 동작 가능성
  • JVM 레벨과 DB 레벨의 동시성 제어 불일치

 

이러한 시행착오를 거치면서, 결국 두 가지 현실적인 해결방안으로 좁혀볼 수 있습니다:

Batch Insert 방식

@Service
class ProductBatchCreate(
    private val productRepository: ProductRepository,
    private val jdbcTemplate: JdbcTemplate,
) : ProductBatchCreateUseCase {

    companion object {

        private const val BATCH_SIZE = 1000
        private const val INSERT_QUERY = """
            INSERT INTO product (name, description) 
            VALUES (?, ?)
        """
    }

    @Transactional
    override fun invoke(commands: List<ProductBatchCreateUseCase.Command>): List<ProductBatchCreateUseCase.Result> {
        if (commands.isEmpty()) return emptyList()

        val existingNames = findExistingProductNames(commands)
        val validProducts = filterValidProducts(commands, existingNames)

        // 배치 사이즈로 나누어 처리
        val batchResults = validProducts.chunked(BATCH_SIZE).flatMap { batch ->
            batchInsertProducts(batch)
        }

        // 실패 및 성공 결과 생성
        val failures = existingNames.map {
            ProductBatchCreateUseCase.Result.Failure(
                name = it,
                message = "중복된 상품 명입니다."
            )
        }

        val successes = batchResults.map {
            ProductBatchCreateUseCase.Result.Success(postId = it)
        }

        return failures + successes
    }

    private fun batchInsertProducts(products: List<Product>): List<Long> {
        if (products.isEmpty()) return emptyList()

        val keyHolder = GeneratedKeyHolder()

        jdbcTemplate.batchUpdate(
            PreparedStatementCreator { connection ->
                connection.prepareStatement(INSERT_QUERY, Statement.RETURN_GENERATED_KEYS)
            },
            object : BatchPreparedStatementSetter {
                override fun setValues(
                    ps: PreparedStatement,
                    i: Int
                ) {
                    val product = products[i]
                    ps.setString(1, product.name)
                    ps.setString(2, product.description)
                }

                override fun getBatchSize() = products.size
            },
            keyHolder
        )

        return keyHolder.keyList.map {
            (it["GENERATED_KEY"] as Number).toLong()
        }
    }

    private fun findExistingProductNames(commands: List<ProductBatchCreateUseCase.Command>): Set<String> {
        val names = commands.map { it.name }
        return productRepository.findAllByNameIn(names)
            .asSequence()
            .map { it.name }
            .toSet()
    }

    private fun filterValidProducts(
        commands: List<ProductBatchCreateUseCase.Command>,
        existingNames: Set<String>
    ): List<Product> {
        return commands.asSequence()
            .filterNot { existingNames.contains(it.name) }
            .map { command ->
                Product.create(
                    name = command.name,
                    description = command.description,
                )
            }
            .toList()
    }
}
제품 배치 생성 시간: 416 ms

 

 

장점:

  • 현저히 빠른 성능
  • 데드락 위험 없음
  • 중복 체크의 효율성
  • 단일 트랜잭션으로 일관성 보장

단점:

  • 개별 트랜잭션 요구사항 충족 못함
  • 대량 데이터의 경우 메모리 사용량 증가
  • 배치 처리를 위한 추가 로직 구현 및 DB Connection 설정 필요 (jpa saveAll 메서드는 batch insert가 아님!)

 

분산 락을 활용한 병렬 처리

// 트랜잭션 처리를 위한 별도 서비스
@Service
class ProductCreateTransactionService(
    private val productRepository: ProductRepository
) {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun createProduct(command: Command): Result {
        if (productRepository.existsByName(command.name)) {
            return Result.Failure(command.name, "중복된 상품명입니다.")
        }
        
        val product = Product.create(command.name, command.content)
        val savedProduct = productRepository.save(product)
        return Result.Success(savedProduct.id)
    }
}

// 배치 처리 서비스
@Service
class ProductBatchCreate(
    private val productCreateTransactionService: ProductCreateTransactionService,
    private val redisLockRegistry: RedisLockRegistry
) : ProductBatchCreateUseCase {
    
    override suspend fun invoke(commands: List<Command>): List<Result> = coroutineScope {
        commands.map { command ->
            async(Dispatchers.IO) {
                val lock = redisLockRegistry.obtain("product:${command.name}")
                try {
                    if (lock.tryLock(1, TimeUnit.SECONDS)) {
                        productCreateTransactionService.createProduct(command) // 프록시를 통한 호출
                    } else {
                        Result.Failure(command.name, "락 획득 실패")
                    }
                } finally {
                    lock.unlock()
                }
            }
        }.awaitAll()
    }
}

장점:

  • 개별 트랜잭션 요구사항 충족
  • 확장성 있는 동시성 제어
  • 병렬 처리를 통한 성능 향상

단점:

  • 추가 인프라(Redis) 필요
  • 구현 복잡도 증가
  • 락 타임아웃 설정의 어려움

 

최종적으로, 시스템의 요구사항과 제약사항을 고려할 때 다음과 같은 선택이 가능합니다:

  1. 만약 개별 트랜잭션 요구사항이 절대적이라면:
    • 분산 락 방식을 선택
    • 적절한 타임아웃과 재시도 정책 수립
    • 모니터링 체계 구축
  2. 만약 개별 트랜잭션이 권장사항 수준이라면:
    • Batch Insert 방식을 선택
    • 배치 사이즈 최적화
    • 메모리 사용량 모니터링

 

이러한 고민을 통해, 우리는 단순히 기술적인 해결책을 찾는 것을 넘어서서, 비즈니스 요구사항과 시스템의 제약사항 사이에서 최적의 균형점을 찾아가는 과정을 경험할 수 있었습니다. 특히 동시성 처리에서는 완벽한 해결책보다는 상황에 맞는 적절한 타협점을 찾는 것이 중요하다는 점을 배울 수 있었습니다.

 

전체 코드

Reference

결론 : Terraform으로 Public ECR을 정의할때는 provider의 region을 us-east-1으로 설정해야 한다.

resource "aws_ecrpublic_repository" "example_ecrpublic" {
  provider = aws.us-east-1
  repository_name = "example_ecrpublic"
}

K8s 클러스터를 구성하기 위해 AWS Container registry를 Terraform으로 정의하고 있었는데, 다음과 같은 오류가 발생했다.

no such host라기에 웹 콘솔에서는 정상적으로 생성되는지 확인해 보았는데 정상적으로 생성되었다.

관리자도구를 켜서 생성시 API요청이 어떻게 날아가는지 확인해보니, terraform을 통해 생성 요청 URI경로와, 웹 콘솔 생성 요청 URI를 의 region이 서로 달랐다.

Terraform : api.ecr-public.ap-northeast-2.amazonaws.com

AWS console : ecr-public.us-east-1.amazonaws.com/

private repository를사용할때는 ap-northeast-2로 정상적으로 리소스가 생성이 되었는데,

public repository를 사용하니 us-east-1로 region을 지정해야 리소스가 정상적으로 생성된다.

https://github.com/hashicorp/terraform/issues/5137

 

provider/aws: ECR region support · Issue #5137 · hashicorp/terraform

Hi, Currently AWS ECR only supports running from us-east-1 which is unfortunate as I have to use it from eu-central-1. While this is possible from the Interface, it's not currently possible from Te...

github.com

관련 내용을 terraform issue에서 확인할 수 있었는데, aws는 public ecr을 us-east-1에서만 지원하기 때문에, terraform에서는 provider의 region을 us-east-1로 지정해야 한다.

제네릭(Generic)은 데이터 타입을 파라미터로 만들어, 여러 데이터 타입에 대해 작동하는 메소드나 클래스를 만들 수 있게 하는 기능이다.

대표적인 컬렉션타입인 List는 타입파라미터 T를 갖는다. List라는 타입이 있을때 String이라는 타입 인자를 사용하여 List<String>이라 정의하면 String객체를 담기위한 리스트라고 말할 수 있다.

Java와 다른점은 Kotlin에서는 반드시 타입 인자를 명시하거나 컴파일러가 추론할 수 있도록 해주어야 한다는 점이다.

Java

List list = new ArrayList();
list.add("hello");
Integer integer = (Integer) list.get(0); // Throws ClassCastException at runtime

Java에서는 raw type을 허용하기 때문에 타입인자를 명시하지 않고 사용이 가능하지만, 런타임시점에 예외가 발생할 수 있다.

Kotlin

val list = ArrayList() // Compile error in Kotlin, as type inference goes for ArrayList<Nothing>
list.add("hello")
val integer: Int = list[0] // Wouldn't even compile

동일한 케이스에서 Kotlin의 경우 타입 인자를 명시하지 않으면 컴파일 시점에 에러가 발생한다.

왜 Java에서는 Raw타입을 허용할까?

Java에서 제네릭이 도입된 것은 2004년에 출시된 Java 5부터이다. 그 이전에는 제네릭이 존재하지 않았기 때문에, 특정 컬렉션에는 모든 종류의 객체를 담을 수 있었고, 런타임에 ClassCastException을 일으키는 상황이 만들어 졌기에 제네릭이 도입되었다.

하지만 제네릭이 도입된 이후에도 Java는 호환성을 유지하기 위해 raw 타입을 사용할 수 있게 남겨두었다.

 

변성(Variance) : 공변과 불공변

제네릭하면 빠질수 없는 개념이 변성이다. 변성은 List<String>과 List<Any>와 같이 기저타입이 같고 타입 인자가 다른 타입이 어떤 관계가 있는지 설명하는 개념이다.

불공변(Invariance)

다음의 코드를 살펴보자

open class Fruit
class Apple : Fruit()
class Orange : Fruit()

fun addFruit(fruitBasket: MutableList<Fruit>) {
    fruitBasket.add(Orange())
    fruitBasket.add(Apple())
}

fun main() {
    val appleBasket: MutableList<Apple> = mutableListOf(Apple(), Apple())
    addFruit(appleBasket) //Compile Error: Type mismatch, MutableList<Apple> cannot be assigned to MutableList<Fruit>
    addFruit(mutableListOf())
}

Apple은 Fruit을 상속하고 있지만 MutableList<Fruit> 타입의 파라미터를 요구하는 자리에는 MutableList<Apple>타입의 객체를 인자로 넘겨줄 수 없다. 이렇게 타입 파라미터가 상속관계에 있지만, 그 상속관계가 제네릭 타입에 영향을 주지 않는것을 불공변(Invariance)이라고 한다.

불공변(Invariance)

  • Apple은 Fruit의 하위타입이다 → True
  • List<Apple>은 List<Fruit>의 하위타입이다 → False

왜 불공변으로 만들어서 직관적으로 이해하기 어렵게 만들어 두었을까?

예를 들어 MutableList<Apple>을 MutableList<Fruit>로 사용할 수 있도록 허용한다고 가정해보자.

fun addFruit(fruitBasket: MutableList<Fruit>) {
    fruitBasket.add(Orange())  // Apple의 MutableList에 Orange를 추가하는 상황
}

val appleBasket: MutableList<Apple> = mutableListOf(Apple())
addFruit(appleBasket)  // mutableList<Fruit>는 Apple의 리스트를 허용하도록 허용

addFruit은 Basket에 어떤 종류의 Fruit라도 넣을 수 있으므로, Orange가 appleBasket에 추가될 수 있다. 그렇지만 appleBasket은 명시적으로 Apple만을 저장하기 위해 구현을 해두었기때문에 안정성을 깨뜨릴 수 있다.

따라서 Kotlin은 제네릭을 불공변으로 기본 설정한다. 이를 통해 부적절한 타입 변환으로 인한 런타임 에러를 방지하고, 안정성을 강화한다.

물론 개발자의 의도에 맞게 공변성(covariant), 반공변성(contravariant)을 지정할 수도 있게 개발자에게 선택의 폭을 제공한다.

 

공변(Covariant)과 반공변(Contravariant)

앞서 언급했듯 Kotlin에서는 out 키워드를 사용하여 공변(covariance)을, in 키워드를 사용하여 반공변(contravariant)을 지정할 수 있다.

공변(Covariance)

Kotlin에서 out 키워드를 통해 타입 인자를 공변(covariant)으로 지정할 수 있다.

즉 S가 T의 하위타입일때, Class<S>가 Class<T>의 하위타입임을 나타낸다.

open class Fruit {
    open fun name() : String {
        return "Fruit"
    }
}

class Apple : Fruit() {
    override fun name() : String {
        return "Apple"
    }
}

class Orange : Fruit() {
    override fun name() : String {
        return "Orange"
    }
}

fun printFruit(fruitBasket: MutableList<out Fruit>) {
    for (fruit in fruitBasket) {
        println(fruit.name())
    }
}

fun main() {
    val appleBasket: MutableList<Apple> = mutableListOf(Apple(), Apple())
    val orangeBasket: MutableList<Orange> = mutableListOf(Orange(), Orange())

    printFruit(appleBasket)  // Allowed, prints "Apple"
    printFruit(orangeBasket)  // Allowed, prints "Orange"
}

여기서 printFruit 함수는 MutableList<out Fruit>을 인자로 받아 그 안에 있는 과일의 이름을 출력한다. out 키워드가 있기 때문에 MutableList<Apple>과 MutableList<Orange>을 MutableList<out Fruit>에 할당할 수 있다.

즉, 함수 내부에서 fruitBasket: MutableList<out Fruit>은 Fruit을 생산하는 역할만 수행하게 되며, 이를 통해 Apple과 Orange 모두 출력할 수 있게 된다.

눈치빠른 분들은 눈치 채셨을수도 있겠지만, MutableList<out Fruit>대신 List<Fruit>를 사용해도 위의 코드는 정상동작한다.

fun printFruit(fruitBasket: List<Fruit>) {
    for (fruit in fruitBasket) {
        println(fruit.name())
    }
}

실제 List 인터페이스 코드를 살펴보면 out키워드가 붙어있는것을 확인할 수 있다. Kotlin에서 List는 불변컬렉션으로 생산자로서의 역할을 수행하기 때문에 위와 같이 사용할 수 있는것이다.

public interface List<out E> : Collection<E> {
    ...


조금더 나아가서, 여기 fruitBasket에 Apple을 추가하는 함수를 작성한다 가정하자.

fun addBasketApple(fruitBasket: MutableList<out Fruit>) {
    fruitBasket.add(Apple()) //Type mismatch
}

fun main() {
    val appleBasket: MutableList<Apple> = mutableListOf(Apple(), Apple())
    val orangeBasket: MutableList<Orange> = mutableListOf(Orange(), Orange())
    addBasketApple(appleBasket)
}

main함수 호출시에는 공변으로 인해 문제가 없지만, 메서드 내에서 인자를 소비하는 과정에서 Type mismatch 컴파일 에러가 발생한다.

쓰기시에는 fruitBasket이 MutableList<Apple>인지, MutableList<Orange>인지 알 수 없기때문에 코틀린에서는 Type mismatch 컴파일 에러를 발생시키기 때문이다.

그러면 MutableList<Apple>의 상위타입이 인자로 들어와야함을 명시해줄순 없는걸까?

반공변(Contravariant)

Kotlin에서 in 키워드를 통해 타입 인자를 반공변(cotravariant)으로 지정할 수 있다.

즉 S가 T의 상위타입일때, Class<S>가 Class<T>의 상위타입임을 나타낸다.

fun addBasketApple(fruitBasket: MutableList<in Apple>) {
    fruitBasket.add(Apple())
}

fun main() {
    val appleBasket: MutableList<Apple> = mutableListOf(Apple(), Apple())
    val orangeBasket: MutableList<Orange> = mutableListOf(Orange(), Orange())
    addBasketApple(appleBasket)
    addBasketApple(orangeBasket) // Error
}

위와 같이 fruitBasket: MutableList<in Apple>에서 in 키워드를 통해 MutableList<Apple>의 상위타입 객체만 전달되도록 명시할 수 있다.

메서드 밖에서는 MutableList<Apple>의 상위타입이 아닌 MutableList<Orange>타입의 객체를 전달하면 컴파일 오류를 발생시킨다.

때문에 메서드 내에서는 MutableList<Orange>일 가능성을 배제하고 안전하게 인자를 소비할 수 있다.

이처럼 생산자(Producer)시나리오에서는 out 키워드를 사용한 공변성을 이용하며, 소비자(Consumer)시나리오에서는 in 키워드를 사용한 반공변성을 이용한다. - Producer In Consumer Out

이를 Java에서는 'PECS(Producer Extends, Consumer Super)' 원칙이라고도 부른다.

 

PECS(Producer Extends, Consumer Super)

PECS는 "Producer Extends, Consumer Super"의 약자로 Joshua Bloch이 Effective Java에서 제네릭에서 공변성(covariance)와 반공변성(contravariance)을 사용할 방향을 제안한 원칙이다.

https://stackoverflow.com/questions/2723397/what-is-pecs-producer-extends-consumer-super

PE (Producer Extends)

  • "extends" 키워드는 공변성(covariance)을 의미한다.
  • 데이터를 "생산"하는 경우에 사용된다.

CS (Consumer Super)

  • "super" 키워드는 반공변성 (contravariant)을 의미한다.
  • 데이터를 "소비"하는 경우에 사용된다.

바이너리 로그의 포맷에 대해 이야기하기에 앞서서 바이너리 로그에 대해 정의해야 한다.

 

바이너리 로그(Binary Log)


바이너리 로그(binary log)는 MySQL에서 사용하는 기능 중 하나로, 데이터베이스에서 수행된 모든 데이터 변경 작업(INSERT, UPDATE, DELETE 등)을 기록하는 데 사용된다.

바이너리 로그에는 다음과 같은 중요정보들이 포함된다.

  • SQL 쿼리문(또는 변화의 결과물 - ROW)
  • 쿼리의 실행시각 및 쿼리 대상 데이터베이스에 대한 정보

그렇다면 왜 바이너리 로그를 사용할까?

  • 복제(Replication): 바이너리 로그는 MySQL Replication의 핵심 요소이다. Master 서버의 바이너리 로그에 쓰인 내용이 Slave 서버에 반영되어 복제 프로세스가 이루어진다.
  • 데이터 복구(Point-In-Time Recovery): 예기치 못한 데이터 손실이 발생했을 경우, Binary Log에 기록된 작업들을 재실행 함으로써 특정 시점으로 데이터베이스 상태를 되돌릴 수 있다.

바이너리 로그를 사용함으로써 데이터 복구와 복제라는 두 가지 중요한 일을 수행할 수 있지만, 디스크 공간을 소비하고 I/O 성능에 약간의 부하를 준다는 점을 유의해야한다.

 

바이너리 로그 포맷(Binary Log Format)


MySQL의 바이너리 로그(Binary Log)는 세 가지 주요 로깅 포맷을 제공한다.

Statement-based (--binlog-format=STATEMENT)

  • 데이터 변경 작업을 수행하는 SQL 쿼리문을 바이너리 로그에 저장한다.
  • Replication시 복제본 서버는 원본 서버의 바이너리 로그를 읽어들여 그 안에 기록된 SQL 문장들을 순서대로 재실행하여 Replication을 수행한다.
  • SQL 쿼리문을 바이너리 로그에 저장하므로 감사에 용이하다.
  • 트랜잭션 격리수준이 Repeatable Read 이상이어야 한다.

Row-based (--binlog-format=ROW (기본값))

  • 바이너리 로그에서 개별 테이블 행이 어떻게 영향을 받는지를 로그에 기록한다.
  • Replication시 SQL 문장을 해석하거나 재실행하는 것이 아니라, 단순히 특정 레코드의 변경 사항을 적용하여 Replication을 수행한다.
바이너리 로그의 내용은 일반 텍스트 형태로 기록되지 않고 바이너리 형식으로 보관되기 때문에, 직접적으로 내용을 읽을 수는 없지만, mysqlbinlog 도구를 사용하면 바이너리 로그 파일의 내용을 텍스트 형태로 변환하여 확인할 수 있다.

Statement-based
# at 141
#191024 15:58:08 server id 1  end_log_pos 236   Execute    load data local infile '/tmp/sbtest1.txt' REPLACE into table sbtest1 fields terminated by '\t'​
위와 같이, Statement-based 로깅의 경우 실행된 SQL 쿼리 문장(load data local infile '/tmp/sbtest1.txt' REPLACE into table sbtest1 fields terminated by '\t')이 직접 로그에 기록된다.

Row-based
### UPDATE `test`.`sbtest1`
### WHERE
###   @1=7292
###   @2='33381779362-68792378251-25015017183-69472446453-71422697075'
### SET
###   @1=7292
###   @2='33381779362-68792378251-97250707473-96610169832-15182108387'

Row-based 로깅의 경우 SQL 쿼리의 변화된 결과가 바이너리 로그에 기록된다.

위 예시에서는 test.sbtest1 테이블의 한 레코드가 UPDATE 되었음을 표시하고 있다. WHERE 절 이후에 원래 값(@1=7292, @2='33381779362-68792378251-25015017183-69472446453-71422697075')이 나타나며, SET 절 이후에는 변화된 값(@1=7292, @2='33381779362-68792378251-97250707473-96610169832-15182108387')이 나타난다.

이와 같이 바이너리 로그의 형태는 설정된 모드에 따라 크게 달라진다.

Mixed (--binlog-format=MIXED)

  • 기본적으로 Statement-based 로깅을 사용하지만, Statement-based로 안전하게 복제되지 않을 수 있을 때에는 자동으로 Row-based 방식으로 전환하여 로깅을 진행한다.
  • 안전하게 복제되지 않을수 있을 때란 비확정적 쿼리(non-deterministic query)가 발생했을 때가 대표적이다. 비확정적 쿼리는 항상 같은 결과를 반환하지 않는 쿼리를 말하는데, 예를 들어 UUID()나 RAND()와 같은 함수를 사용하는 쿼리, 혹은 데이터베이스의 특정 상태(예: AUTO_INCREMENT 값, 시스템 변수 등)에 의존하는 쿼리 등이 해당한다.
MySQL의 Mixed 방식에서의 로깅 방식 결정

다음의 경우 Statement-based 바이너리 로깅이 아닌 Row-based 바이너리 로깅을 사용한다.
  • 함수에 UUID()가 포함되어 있을 때.
  • AUTO_INCREMENT 열이 있는 하나 이상의 테이블이 업데이트되고 트리거나 저장된 함수가 호출될 때.
  • 뷰의 본문이 row-based 복제를 필요로 할 때, 뷰를 생성하는 문장도 그것을 사용합니다. 예를 들어, 뷰를 생성하는 문장이 UUID() 함수를 사용할 때 이런 경우가 발생합니다.
  • 로드 가능한 함수 호출이 포함되어 있을 때.
  • FOUND_ROWS() 또는 ROW_COUNT()가 사용될 때.
  • USER(), CURRENT_USER(), 또는 CURRENT_USER가 사용될 때. (참조: Bug #28086)
  • 관련된 테이블 중 하나가 mysql 데이터베이스의 로그 테이블일 때.
  • LOAD_FILE() 함수가 사용될 때.
  • 명령문이 하나 이상의 시스템 변수를 참조할 때. 

https://dev.mysql.com/doc/refman/8.0/en/binary-log-mixed.html

비확정적 쿼리가 문제가 되는경우 row-based나, mixed-based를 사용하는것이 좋다는것은 알겠다. 그럼 mixed-based를 사용하면 되지 왜 row-based를 사용해야 할까?

핵심은 STATEMENT 포맷SQL 쿼리 문장 그대로를 기록하는 반면, ROW 포맷쿼리의 결과(변경된 레코드)를 기록한다는 점에 있다. 

복제(Replication)


앞서 바이너리 로그는 MySQL Replication의 핵심요소라고 설명했다. MySQL에서 복제는 어떻게 수행되길래 바이너리 로그를 필요로 하는걸까?

데이터베이스 복제작업은 다음과 같은 과정을 거친다.

  1. 소스 MySQL서버의 전체 데이터 스냅샷을 레플리카 서버에 이관한다. (mysqldump와 같은 툴 활용)
  2. 스냅샷이 생성되는 동안 소스 서버에서 발생하는 모든 데이터 변경 작업은 바이너리 로그에 기록된다.
  3. 스냅샷 이관이 완료되면 레플리카 서버는 소스 서버의 바이너리 로그를 읽어 릴레이 로그에 저장하고, 릴레이로그에서 읽어 DB에 반영한다.

복제과정에서의 바이너리 로그 포맷


Statement-based 바이너리 로그 포맷의 경우 복제과정에서 소스서버에서 수행된 쿼리를 그대로 실행하게 된다. 때문에 소스서버에서 불필요하게 많은 락을 유발한 쿼리들이 그대로 동일하게 수행될 수 있다.

반면 Row-based 바이너리 로그포맷은 변경된 데이터 자체가 전달되어 복제가 수행되기 때문에 소스서버에서 발생한 락을 재생산하지 않는다.

Suvivor 영역이 2개여서 얻는 이점이 뭐가있을까?

https://docs.oracle.com/en/java/javase/17/gctuning/hotspot-virtual-machine-garbage-collection-tuning-guide.pdf

 

HotSpot VM 의 Minor GC의 동작과정

1. Eden 영역에 새로 생성된 객체들을 할당한다.

2. Eden 영역이 꽉차면 Minor GC를 수행한다.
  - Eden 영역에서 할당된 객체 중 Minor GC에서 살아남은(참조되고있는) 객체들은 Survivor 영역중 하나로 이동한다.

  - Survivor영역에서 일정 Minor GC횟수만큼 살아남은 객체들은 Old Generation으로 이동한다. 
3. 이후 다시 Minor GC가 발생하면, Eden 영역뿐만 아니라 Survivor 영역에 존재하는 객체 중 아직도 참조되는 객체들을 다른 Survivor 영역으로 이동한다. 

 

Survivor 영역이 1개일 경우

Survivor영역이 1개일 경우 Old Generation으로 이동한 객체들에 의해 Survivor영역은 메모리가 파편화 된 상태로 남게 된다.

이러한 문제를 해결하기 위해서는

Minor GC가 발생할 경우 파편화된 공간 사이로 Eden영역에서 살아남은 객체들을 이동시키거나, 기존 Survivor공간의 객체들을 특정 공간으로 이동시켜 메모리 파편화를 막아야 한다.

 

Survivor 영역이 2개일 경우

Minor GC가 일어나면 다른 Survivor 영역으로 객체를 복사하고, 기존 Survivor 영역을 초기화하여 메모리를 해제하는 과정을 반복하여 메모리 파편화 문제를 방지한다.

+ Recent posts