Skip to content

3-4주차 과제#94

Closed
hongs429 wants to merge 29 commits intohanghae-skillup:mainfrom
hongs429:3주차-과제

Hidden character warning

The head ref may contain hidden characters: "3\uc8fc\ucc28-\uacfc\uc81c"
Closed

3-4주차 과제#94
hongs429 wants to merge 29 commits intohanghae-skillup:mainfrom
hongs429:3주차-과제

Conversation

@hongs429
Copy link

@hongs429 hongs429 commented Feb 3, 2025

[예약 시스템 구현 및 동시성 제어]


작업 내용

  • 예약과 관련된 application, infra 모듈 작업 (presentation 미완성)

  • 동시성 해결을 위한 이중 방어 적용

    • 1차: ReservationCommandAdapter.reserve (MySQL) → Unique 제약 조건으로 동시성 제어
          @Entity
        @Builder
        @Table(name = "reservation_seat", uniqueConstraints = {
                @UniqueConstraint(
                        name = "UK_screening_seat",
                        columnNames = {"screening_id", "seat_id"}
                )
        })
        @Getter
        @AllArgsConstructor
        @NoArgsConstructor(access = AccessLevel.PROTECTED)
        public class ReservationSeatJpaEntity extends BaseJpaEntity {
        
            @Id
            @GeneratedValue(generator = "uuid")
            @UuidGenerator
            @Column(name = "reservation_seat_id", columnDefinition = "BINARY(16)")
            private UUID id;
        
            @ManyToOne(fetch = FetchType.LAZY)
            @JoinColumn(name = "reservation_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
            private ReservationJpaEntity reservation;
        
            @ManyToOne
            @JoinColumn(name = "screening_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
            private ScreeningJpaEntity screening;
        
            @ManyToOne
            @JoinColumn(name = "seat_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
            private SeatJpaEntity seat;
        }
    • 2차: ReservationLockPort (Redisson) → Application 레벨에서 분산 락 적용
        @Slf4j
      @Component
      @RequiredArgsConstructor
      public class ReservationLockAdapter implements ReservationLockPort {
      
          private final RedissonClient redissonClient;
      
          @Override
          public boolean tryLock(String lockKey, long waitTimeMils, long releaseTimeMils) {
              RLock lock = redissonClient.getLock(lockKey);
              try {
                  return lock.tryLock(waitTimeMils, releaseTimeMils, TimeUnit.MILLISECONDS);
              } catch (InterruptedException e) {
                  Thread.currentThread().interrupt();
                  return false;
              }
          }
      
          @Override
          public boolean tryScreeningSeatLock(List<String> lockKeys, long waitTimeMils, long releaseTimeMils) {
              RLock[] locks = lockKeys.stream()
                      .map(redissonClient::getLock)
                      .toArray(RLock[]::new);
      
              RLock multiLock = redissonClient.getMultiLock(locks);
      
              try {
                  log.info("Trying to acquire multi-lock for keys: {}", lockKeys);
                  boolean acquired = multiLock.tryLock(waitTimeMils, releaseTimeMils, TimeUnit.MILLISECONDS);
                  log.info("Multi-lock acquired: {}", acquired);
                  return acquired;
              } catch (InterruptedException e) {
                  Thread.currentThread().interrupt();
                  return false;
              }
          }
      
          @Override
          public void releaseLock(String lockKey) {
              RLock lock = redissonClient.getLock(lockKey);
              if (lock.isHeldByCurrentThread()) {
                  log.info("Release lock ...{}", lockKey);
                  lock.unlock();
              }
          }
      
          @Override
          public void releaseMultiLock(List<String> lockKeys) {
              lockKeys.forEach(lockKey -> {
                  RLock lock = redissonClient.getLock(lockKey);
                  if (lock.isHeldByCurrentThread()) {
                      log.info("Release lock ...{}", lockKey);
                      lock.unlock();
                  }
              });
          }
      }
  • 테스트 전략

    • application: 단위 테스트 (입력 모델 검증 + 비즈니스 로직 검증)
    • infra: ReservationCommandAdapter.reserve 단위 테스트
    • 통합 테스트: application -> infra 경로를 거치는 통합 테스트 진행
    • TestContainer 활용: 동일한 환경에서 테스트 수행
        @SpringBootApplication(scanBasePackages = {"project.redis.infrastructure", "project.redis.application"})
        public class IntegrationTestConfiguration {
        }
    
    
    
    
    
        public abstract class TestContainerSupport {
        
        @Container
        public static final MySQLContainer<?> mysqlContainer
                = new MySQLContainer<>("mysql:9.1.0")
                .withDatabaseName("db")
                .withUsername("user")
                .withPassword("1234")
                .withCommand(
                        "--character-set-server=utf8mb4",
                        "--collation-server=utf8mb4_unicode_ci"
                );
        
        @Container
        public static final GenericContainer<?> redisContainer
                = new GenericContainer<>("redis")
                .withExposedPorts(6379);
        
        static {
            mysqlContainer.start();
            redisContainer.start();
        }
        
        @DynamicPropertySource
        static void setDatasourceProperties(DynamicPropertyRegistry registry) {
            System.out.println("mysqlContainer = " + mysqlContainer.getJdbcUrl());
            registry.add("spring.datasource.url", ()-> mysqlContainer.getJdbcUrl() + "?useSSL=false&allowPublicKeyRetrieval=true");
            registry.add("spring.datasource.username", mysqlContainer::getUsername);
            registry.add("spring.datasource.password", mysqlContainer::getPassword);
            registry.add("spring.datasource.driver-class-name", mysqlContainer::getDriverClassName);
        
            registry.add("redis.host", redisContainer::getHost);
            registry.add("redis.port", () -> redisContainer.getMappedPort(6379).toString());
        }
        }
    
    
    
    
    
    
        @Slf4j
        @SpringBootTest
        @ContextConfiguration(classes = IntegrationTestConfiguration.class)
        @TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
        @RequiredArgsConstructor
        public class ReservationCommandServiceIntegrationTest extends TestContainerSupport {
        
        private final ReservationCommandService reservationCommandService;
        
        private final ReservationJpaRepository reservationJpaRepository;
        private final ReservationSeatJpaRepository reservationSeatJpaRepository;
        private final ScreeningJpaRepository screeningJpaRepository;
        private final SeatJpaRepository seatJpaRepository;
        
        @AfterEach
        void tearDown() {
            reservationSeatJpaRepository.deleteAll();
            reservationJpaRepository.deleteAll();
        }
        
        @DisplayName("상영 예약 동시성 테스트")
        @Test
        void testReservationConcurrency() throws InterruptedException {}
    

발생했던 문제와 해결 과정

🚨 문제 1: 통합 테스트를 어떤 모듈에서 작성해야 하는지 고민

  • ⚠️ 고민: applicationinfra를 모르므로, infra에서 작성해야 하는지 모호함
  • ✅ 해결:
    • infraapplication을 알고 있고 DB 관련 내용도 포함하므로, 통합 테스트는 infra에서 작성하는 것으로 결정
    • infra에서는 application -> infra가 자연스럽게 연결될 수 있음

🚨 문제 2: 락을 applicationinfra 중 어디서 적용할지 고민

  • ⚠️ 고민: DB 트랜잭션(persistence)에서 락을 걸어야 할까? application 레벨에서 비즈니스 로직을 보호해야 할까?
  • ✅ 해결:
    • ReservationLockPortapplicationinfra에서 분리
    • application에서 비즈니스 로직 전에 락을 먼저 시도, 이후 infra에서 DB 트랜잭션 처리

🚨 문제 3: Redisson의 waitTime이 길 경우, 이미 예약된 좌석이 있어도 계속 예약 시도

  • ⚠️ 문제:
    • 락 대기 시간(waitTime)이 길면, 이미 예약된 좌석에 대해서도 중복 예약 시도가 발생
    • 결국 RDB 제약조건이 중복을 막지만, 불필요한 DB I/O가 발생
  • ✅ 해결:
    • 락을 획득한 요청만 실행 → waitTime을 짧게 설정
    • DB에 접근하는 동시 요청을 줄여, 불필요한 IO 최소화

이번 주차에서 고민되었던 지점이나, 어려웠던 점

  • 멀티 모듈 환경에서 통합 테스트를 어디서 작성해야 하는지 고민
    • 결론: infra에서 application -> infra 흐름을 테스트하는 것이 적절
  • 1회 예약 시 여러 좌석을 예약하는 시스템에서, 비즈니스 로직의 위치 고민
    • 도메인 로직을 application에 둘지 infra에 둘지 판단하는 것이 어려웠음
  • 트랜잭션의 범위 고민
    • 현재: 예약 및 좌석 등록을 하나의 트랜잭션으로 처리 → 큰 문제 없음
    • 하지만 경우에 따라 application까지 트랜잭션 전파가 필요할 가능성 존재

리뷰 포인트

  • infra에서 통합 테스트를 작성한 것이 적절한 판단인지?
  • application에서는 트랜잭션을 전파하지 않도록 구현했는데, 이는 올바른 설계인가?
    • 현재: 예약 & 예약-좌석 트랜잭션을 묶어도 문제 없음
    • 미래: 특정 상황에서는 application까지 트랜잭션을 잡아야 할 가능성 존재

기타 질문

  • Redisson의 waitTime이 필요한 이유가 명확하지 않음
    • 현재 가정: "락을 해제할 때까지 동일한 요청은 반드시 차단되어야 한다"
    • **따라서 waitTime을 짧게 설정하여 동시 요청을 제한

- module 구분: presentation, application, infrastructure, domain
- 각 모듈이 독립적으로 작업가능하도록 SRP 적용
- Port-Adapter 패턴으로 명확한 의존관계 설정
- 프로잭트 설명은 루트의 README.md 에 기재
- 각 모듈의 기능과 설명은 README.md 에 명확하게 기재
- 급하게 하다보니 커밋을 나누지 못했습니다. 대신, 그만큼 자세하게 README.md에 기재했으니 참고 부탁드립니다
- module 구분: presentation, application, infrastructure, domain
- 각 모듈이 독립적으로 작업가능하도록 SRP 적용
- Port-Adapter 패턴으로 명확한 의존관계 설정
- 프로잭트 설명은 루트의 README.md 에 기재
- 각 모듈의 기능과 설명은 README.md 에 명확하게 기재
- 급하게 하다보니 커밋을 나누지 못했습니다. 대신, 그만큼 자세하게 README.md에 기재했으니 참고 부탁드립니다
- CinemaCommandAdapter 에 TODO 남김(exception 처리를 위한 공통 모듈 필요성)
- 각 모듈 별로 독립적인 cinema 생성 코드 작성
- 간단하게 application 테스트 코드 추가
- 각 모듈 별 테스트 환경 구축
- 최소한의 도메인 생성 시, 검증 로직 추가
- 이번 프로잭트의 취지에 맞도록 foreign key 참조는 전부 제거
    - flyway 로 foreign key 제거 후 이력 관리
- application 레벨에서 키를 관리
- application 레벨에서 키를 관리함을 명시적으로 설정
    -  foreignKey = @foreignkey(value = ConstraintMode.NO_CONSTRAINT)
- 곁다리로 임시 데이터 생성 스크립트 주석 처리(지우긴 아쉬워)
- 간단한 테스트 실행.
- 그라파나 대시보드 기본적인 것 간단하게 구성
- 아직은 k6의 메트릭에 대한 이해가 필요...
- 모듈 간의 의존관계
    - presentation -> application <- infrastructure (큰 흐름)
    - presentation -> domain : application의 응답을 도메인 모델로 받기 위함
    - application -> domain : application 에서는 프로그램의 흐름을 제어하는 서비스로직 담당, 도메인의 로직(도메인 내부 메서드), 도메인 간의 상호 로직(추 후, domain service로 명명)은 해당 모듈에서 가지고 오도록 하여, applicaiton은 오로지 프로그램의 흐름을 제어하는 로직이 들어가도록 설정
    - infrastructure -> domain : applicaton 모듈의 요청을 특정기술의 모델(ex> jpaEntity)이 아닌 domain 엔티티로 전달하기 위함
    - presentation -> infrastructure : 단순히 infrastructure의 빈들을 application에서도 사용가능하도록 등록하기 위함.

- 해당 구조에 맞게 클래스 파일의 이동
- controller 검증항목 : 알맞은 파라미터 바인딩이 되는가 && 결과를 응답에 맞게 만드는가 && 응답 코드는 일치하는가
- Q클래스는 기본적으로 $buildDir/generated/sources/annotationProcessor를 따라감. 때문에 별도의 설정 필요없음.
- 8.11.1 문법에 맞게  tasks.withType(JavaCompile) { =>  tasks.withType(JavaCompile).configureEach { 로 변경
- 현재 deserialization이 정상적으로 이루어지지 않음.
…is 포트 수정 && 부하테스트 스크립트 작성(load_test.js)

- 리스트 데이터를 serialize 하는 경우, custom objectmapper를 사용하므로써 해결
- 명확한 이유는 모르겠으나, redis 포트 변경 후 정상 작동
- 부하테스트 확인
- 도메인 로직, application 로직 분리
- Reservation 관련 도메인, jpa 엔티티 만들기
- Flyway로 DDL 반영
- 예외 정의
- 테스트 코드 작성
…key로 동시성 해결

- 테스트 코드로 동시성 검증
- Infra 테스트 환경 구축( test container )
- redission 세팅
- 테스트 코드로 동시성 검증
@hongs429 hongs429 closed this Feb 3, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant