선착순 쿠폰 시스템을 만드는 과정에서, Producer와 Consumer를 분리해 서버를 설계하면서 도메인을 중복으로 작성하게 되어 관리포인트가 늘어난다는 느낌을 받았습니다. 이를 해결하기 위해 헥사고날 아키텍처를 활용해 도메인 모듈을 따로 분리하고 하나의 도메인 의존성을 Producer모듈과 Consumer모듈에서 의존하게 하여 도메인 관리포인트를 하나로 줄이는 방식으로 설계해 보았습니다.

아래 내용을 주로 담고있습니다.

  • 헥사고날 아키텍처와 멀티모듈을 활용해 설계한 선착순 쿠폰 시스템의 구조를 설명합니다.
  • 멀티모듈, 헥사고날 아키텍처의 개념
  • 구현 방법
    • Component Scan의 패키지 범위설정
    • Component Scan의 LazyInit 옵션
    • application.yaml 파일의 include

전체 코드는 https://github.com/waterfogSW/coupon-service 에서 확인하실 수 있습니다.

헥사고날 아키텍처

헥사고날 아키텍처는 계층형 아키텍처의 대안으로 Alistair Cockburn에 의해 고안되었습니다. 기존 계층형 아키텍처의 경우 모든 계층이 영속성 계층을 토대로 만들어 지기 때문에 비즈니스 로직의 변경이 어렵고, 테스트 또한 영속성 컴포넌트에 의존성이 생기기 때문에 테스트의 복잡도의 높이는 등 여러 문제점이 존재합니다.

업무규칙은 사용자 인터페이스나 데이터베이스와 같은 저수준의 관심사로 인해 오염되어서는 안되며, 원래 그대로의 모습으로 남아 있어야 한다. 이상적으로는 업무 규칙을 표현하는 코드는 반드시 시스템의 심장부에 위치해야 하며, 덜 중요한 코드는 이 심장부에 플러그인 되어야 한다. 업무 규칙은 시스템에서 가장 독립적이며 가장 많이 재사용할 수 있는 코드여야 한다.
- 로버트 C. 마틴, 클린 아키텍처

 

헥사고날 아키텍처는 이러한 문제점을 의존역전을 통에 의존성이 도메인을 향하게 하면서 이러한 문제를 해결합니다. 애플리케이션의 핵심 로직을 외부 시스템으로 부터 격리시켜 외부 요소의 변화에 의해 핵심 로직이 영향을 받지 않도록 합니다. 이를 통해 핵심 로직을 테스트하기 쉽고, 변경하기 쉽게 만듭니다.

헥사고날 아키텍처는 포트(Port)와 어댑터(Adapter) 아키텍처라고도 불리며, 포트와 어댑터는 다음과 같은 특징을 가지고 있습니다.

포트 (Port)

포트는 애플리케이션의 핵심 로직과 외부 세계 사이의 인터페이스 역할을 합니다.

Primary/Driving Ports

외부 시스템(예: 사용자 인터페이스, 웹 요청 등)으로부터 애플리케이션의 핵심 로직으로의 데이터 흐름을 다룹니다. 외부 요소들은 외부 포트를 통해 애플리케이션의 핵심 기능을 활용하게 됩니다.

Secondary/Driven Ports

애플리케이션 핵심 로직으로부터 외부 시스템이나 인프라스트럭처(예: Database, Message Queue, 외부 API)로의 데이터 흐름을 다룹니다. 애플리케이션은 Secondary Port를 통해 외부 자원을 필요로 할 때 접근합니다.

어댑터 (Adapter)

어댑터는 포트와 외부 세계 사이에서 데이터 형식을 변환하고, 호출을 중개하는 역할을 합니다. 포트와 외부 시스템 간의 중간자로서, 서로 다른 시스템 간의 통신을 가능하게 합니다.

Primary/Driving Adapters

애플리케이션의 핵심 로직에 접근하는 외부 시스템(예: 웹 서버, GUI 클라이언트 등)을 다룹니다. 외부 요청을 애플리케이션의 포트에 맞는 형식으로 변환합니다.

Secondary/Driven Adapters

애플리케이션에서 필요한 외부 자원(예: 데이터베이스, 파일 시스템, 외부 API 등)을 다룹니다. 핵심 로직의 요청을 외부 자원에 맞는 형식으로 변환합니다.

쿠폰 시스템 멀티모듈 구성

 

앞서 설명한 헥사고날 아키텍처의 각 레이어를 차용하여 모듈을 설계하였습니다.

Domain Hexagon

도메인 모델을 정의하는 모듈이므로, Domain Hexagon으로 명명하였습니다.

애플리케이션의 핵심 로직을 담당하는 모듈로, 도메인을 정의하고 있습니다.

  • POJO로 구현되어 있습니다.
  • common 모듈내 라이브러리 외 의존성을 가지지 않습니다.

Use Case Hexagon

도메인에 대한 유스케이스를 정의하는 모듈이므로, UseCase Hexagon으로 명명하였습니다.

도메인에 대한 Use Case를 정의하는 모듈입니다.

  • 외부 시스템과의 통신을 위한 Port 인터페이스를 정의합니다.
  • Domain 외 Spring Boot, Common 모듈내 라이브러리 의존성을 가집니다.

Infrastructure Hexagon

외부 인프라에대한 의존성을 정의하는 모듈이므로, Infrastructure Hexagon으로 명명하였습니다.

  • 외부 인프라와의 통신을 위한 Secondary Adapter를 정의합니다.
    • Kafka Producer Adapter, Persistence Adapter, Redis Adapter 등
  • Domain, Use Case 외 Spring Boot, Common 모듈내 라이브러리 의존성을 가집니다.
  • 외부 인프라별로 Module을 분리해 관리합니다
    • coupon-infrastructure/kafka
    • coupon-infrastructure/persistence
    • coupon-infrastructure/redis
  • 각 모듈별로 config class를 정의하며, application-{module name}.yaml 파일을 통해 각 모듈별로 설정을 관리합니다.
    • application-kafka.yaml
    • application-persistence.yaml
    • application-redis.yaml

Bootstrap Hexagon

여러 의존성을 조합해 하나의 애플리케이션 서버를 구성하는 모듈이므로 Bootstrap Hexagon으로 명명하였습니다.

  • 외부 요청을 받아 Use Case를 실행하기 위한 Primary Adapter를 정의합니다.
    • RestController, Kafka Consumer 등
  • Domain, Use Case, Infrastructure 외 Spring Boot, Common 모듈내 라이브러리 의존성을 가집니다.
  • Spring Boot Application을 정의합니다.
  • UseCase Hexagon과 Infrastructure Hexagon을 의존합니다.

Infrastructure 모듈과 같이 애플리케이션이 제공할 각 서비스별로 Module을 분리해 제공하는 방법도 고려해보았지만, 애플리케이션 서버마다 제공하는 API가 서로 다른경우가 훨씬 많기 때문에 모듈분리의 효용성이 떨어진다고 판단하여 Bootstrap 모듈에 Primary Adapter를 정의하였습니다.

구현 과정

Component Scan에 Lazy 옵션을 적용하기

UseCase 모듈의 UseCaseConfig

@Configuration
@ComponentScan(basePackages = ["com.waterfogsw.coupon.usecase"], lazyInit = true)
class UseCaseConfig

UseCase 모듈은 API Server, Worker Server모두 공통적으로 의존하는 모듈입니다.

하지만, API서버의 경우 Persistence모듈 의존성이 존재하지 않아, 쿠폰을 생성하고 DB에 저장하는 CreateCouponUseCase를 컴포넌트 스캔으로 등록하면 에러가 발생합니다. 마찬가지로 Worker 서버의 경우 Redis 모듈 의존성이 존재하기 않기 때문에 발행된 쿠폰의 개수를 Redis에서 조회하고 발행 이벤트를 Kafka에 전달하는 IssueCouponUseCase 를 컴포넌트 스캔으로 등록하면 에러가 발생합니다.

이러한 문제를 해결하기 위해 Usecase 모듈의 ComponentScan에 lazyInit 속성을 true로두어, UseCase를 사용하는 시점에 Bean을 생성하도록 하였습니다. 이렇게 하면 API서버는 CreateCouponUseCase에 의존성을 갖는 클래스가 존재하지 않기때문에 CreateCouponUseCase를 빈으로 등록하지 않습니다. 마찬가지로 Worker 서버는 IssueCouponUseCase를 의존성을 갖는 클래스가 존재하지 않아 IssueCouponUseCase를 빈으로 등록하지 않습니다.

Component Scan을 각 모듈내에서 수행되도록 하기위해서 각 모듈의 ComponentScan어노테이션의 패키지 위치를 잘 지정해 주어야 합니다.

@SpringBootApplication어노테이션이 @ComponentScan어노테이션을 내장하고 있기 때문에 위치를 잘 지정해 두어야 합니다. 예를들어 @SpringBootApplication어노테이션이있는 Application 클래스를 com.waterfogsw.coupon 패키지에 위치시키면, 다른 모듈에 있는 클래스라 하더라도 com.waterfogsw.coupon.* 패키지 하위의 모든 클래스들은 컴포넌트 스캔의 대상이 되기 때문에 의도치 않은 빈이 등록될 수 있습니다.

모듈별 Config, application.yaml

@Configuration
@Import(
  value = [
    KafkaProducerConfig::class,
    RedisConfig::class,
    UseCaseConfig::class
  ]
)
class ApiConfig 
@Configuration
@Import(
    PersistenceConfig::class,
    UseCaseConfig::class,
)
class WorkerConfig

위와 같이 각 모듈별로 Config 클래스를 정의하고, BootStrap Hexagon에 위치하는 API, Worker모듈의 Config는 각 모듈이 의존하고있는 모듈의 Config를 Import하도록 구현하였습니다.

또한 application-{module name}.yaml 파일을 통해 각 모듈별로 설정을 관리하기 때문에 API 모듈과 Worker 모듈의 application.yaml 파일은 다음과 같이 각 하위 의존성의 application.yaml을 include하도록 구현하였습니다.

API 모듈의 application.yaml

server:
  port: 8080
  shutdown: graceful
spring:
  profiles:
    active: local
    include:
      - kafka
      - redis
      - usecase

Worker 모듈의 application.yaml

server:
  port: 8081
  shutdown: graceful
spring:
  profiles:
    active: local
    include:
      - persistence
      - usecase

 

DB 채번을 줄이기 위해 Ulid 사용하기

보통 JPA를 사용하면 Primary Key를 @GeneratedValue 어노테이션을 통해 자동으로 생성합니다. 이런 전략을 사용하면 데이터베이스에서 자동으로 채번을 해주기 때문에 개발자는 신경쓰지 않아도 됩니다. 하지만 이런 전략은 데이터 베이스에 대한 채번을 유발하며, 영속화 되기 전까진 id값을 null로 유지해야한다는, 다소 데이터 베이스에 의존적으로 코드를 작성하게 되는 단점이 있습니다.

이런 단점을 해결하기 위해 UUID를 사용하는 방법이 있습니다. UUID는 데이터베이스에 의존적이지 않고, 영속화 되기 전까지 id값을 null로 유지할 필요가 없습니다. 하지만 UUID는 생성 순서를 보장하지 않기 때문에 목록 조회 시 정렬기준으로 삼기에는 적합하지 않아 성능적인 이점을 가져갈 수 없습니다. (UUIDv6, v7의 경우 시간순 정렬이 가능합니다)

이때 ULID를 활용할 수 있습니다. ULID는 UUID와 호환성을 가지면서 시간순으로 정렬할 수 있는 특징을 가지고 있습니다. 물론 ULID도단점이 있습니다. UUID가 나노초까지 시간순을 보장해주는 반면 ULID는 밀리초까지만 시간순을 보장해줍니다. 이를 보완하기위해 ULID Creator 라이브러리는 Monotonic ULID를 제공합니다. Monotonic ULID는 동일한 밀리초가 있다면 다음에 생성되는 ULID의 밀리초를 1 증가시켜서 생성하여 앞서 말한 단점을 보완합니다.

DB에 Primary Key를 채번하지 않고 도메인에서 직접 생성해서 사용하는 이러한 방식이 도메인이 외부에 의존하지 않고 직접 식별자를 생성할 수 있어서 클린 아키텍처에서는 더 큰 장점으로 느껴졌습니다.

 

부록

카프카를 사용하는 이유

선착순 이벤트의 경우 수많은 유저가 동시에 요청을 보내게 됩니다. 이때 API Server 에서 DB에 쿠폰 row를 생성하는 작업을 직접 처리를 한다면 DB에 부하가 몰리게 되어 다른 요청을 처리할 수 없는 상태가 됩니다.

이러한 문제점을 해결하기 위해 API서버가 직접 DB에 생성을 요청하는 대신 쿠폰 생성 이벤트를 발행해 카프카에 전달하고 이벤트를 작업 서버가 전달받아 DB에 쿠폰 row를 생성하게 합니다.

API 서버는 DB에 직접 접근하지 않고 카프카에 이벤트를 발행하고 응답을 전달하는 역할만 하게 되어 API 서버의 부하를 줄일 수 있으며, 작업서버는 카프카에 발행된 이벤트를 순차적으로 처리하기 때문에 DB에 동시에 부하가 몰리는 것을 방지할 수 있습니다.

Reference

최근에 지인에게 테스트코드에서 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

결론 : 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