Skip to content

Conversation

@scars97
Copy link
Contributor

@scars97 scars97 commented Mar 30, 2025

[3주차] 동시성 제어 & 단계별 락 적용

📝작업 내용

🔒해결하려는 문제 혹은 고민이 되었던 부분을 남겨주세요.(문제 수 만큼 복사해서 사용할 것)

JPA Lock 기능을 활용하지 못하는 문제

조회 쿼리에 락을 적용하고, 반환된 엔티티를 도메인 모델로 변환.
-> 엔티티를 도메인 모델로 변환 시 JPA 영속성이 분리되는 문제 발생.
-> 도메인 모델이 수정된 후, 엔티티로 변환해도 JPA 는 새로운 객체로 인지하여 Lock 기능 활용 X

  • AS-IS : bunsiness 모듈 에서 데이터를 수정하고, infra 모듈에 전달하는 방식
@Repository
class SeatCoreRepositoryImpl(
    private val jpaRepository: SeatJpaRepository
): SeatRepository {
    override fun getSeats(seatIds: List<Long>): MutableList<Seat> {
        val seats = jpaRepository.findByIdIn(seatIds)

        return seats.map { SeatMapper.toSeat(it) }.toMutableList()
    }

    override fun updateForReserve(updateSeats: List<Seat>) {
        val seats = updateSeats.map { SeatMapper.toEntity(it) }.toList()

        jpaRepository.saveAll(seats)
    }
}
  • TO-BE : 한 영속성 안에서 조회, 수정하도록 변경 -> 0905832
@Repository
class SeatCoreRepositoryImpl(
    private val jpaRepository: SeatJpaRepository
): SeatRepository {
    override fun getSeats(seatIds: List<Long>): List<Seat> {
        val seats = jpaRepository.findAllById(seatIds)

        return seats.map { SeatMapper.toSeat(it) }.toList()
    }

    @Transactional
    override fun updateForReserve(seatIds: List<Long>, reservationId: Long) {
        val seatEntities = jpaRepository.findBySeatIdsWithLock(seatIds)

        seatEntities.forEach { it.reserveBy(reservationId) }
    }
}

함수형 기반 분산락 적용 안되는 문제

락 획득과 트랜잭션 시작 시점 순서가 명확하지 않아 발생한 문제로 예상되었습니다.
AOP 기반 분산락은 @Order(Ordered.HIGHEST_PRECEDENCE) 을 설정하여 락을 먼저 획득한 후, 예약 로직 트랜잭션이 시작하도록 구현했습니다.
함수형 기반 분산락의 경우, 트랜잭션이 이미 시작된 메서드 내부에서 동작하기 때문에
트랜잭션과 락 획득 순서가 바뀌어서 동시성 제어가 실패한 것으로 예상됩니다.

  • AS-IS : 트랜잭션 시작 후, 락 획득 시도.
@Transactional
fun createReservation(info: ReservationInfo): ReservationResult {
    validator.validate(info)

    return lockExecutor.lock("reserve-${info.scheduleId}", 5L, 2L) {
        val seats = seatService.getSeats(info.seatIds)

        Reservation.checkExceedLimit(info.seatIds.size)

        Reservation.checkContinuousSeats(seats)

        val reservation = reservationService.createReservation(Reservation.of(info.userId))

        seatService.updateForReserve(info.seatIds, reservation.reservationId)

        // App push 이벤트 발행
        eventPublisher.publishEvent(ReservationMessageEvent.of(reservation.reservationId))

        return@lock ReservationResult.of(reservation, info.seatIds)
    }
}
  • TO-BE : 트랜잭션 propagation을 설정하여 락 메서드 내부에서 트랜잭션 시작하는 구조로 변경 -> 5ff34a6
@Component
class FunctionalForTransaction {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun <T> proceed(action: () -> T): T {
        return action()
    }
}

class DistributedLockExecutor(
    private val functionalForTransaction: FunctionalForTransaction
) {
    fun <T> lockWithTransaction(
        key: String, waitTime: Long, leaseTime: Long, action: () -> T): T {
        
        // ...
        
        return try {
            if (!rLock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS)) {
                throw RuntimeException("락 획득 실패 : $key")
            }
        
            functionalForTransaction.proceed(action)
        } catch() {
            // ...
        } finally {
            // ...
        }
    }
}

💬리뷰 요구사항(선택)

함수형 기반 분산락을 락 기능에 트랜잭션을 포함하도록 구현했습니다.
이런 경우 책임도 불분명하고, 트랜잭션이 오래 걸린다면 락이 먼저 해제될 수도 있다 생각됩니다.
개인적으로는 이 방식이 안티패턴이라 생각이 드는데,
함수형 기반 분산락을 구현할 때 락과 트랜잭션을 분리할 수 있는 방법이 있는 지 궁금합니다.


기타 사항 📌

  • 커밋이 살짝 꼬여서, 1 ~ 2주차 커밋 내역이 남아있는 것 같습니다.
  • 3주차 커밋은 '[예약 API] 예약 유효성 검증 객체 추가' : 6cc685f 부터 입니다!

scars97 added 30 commits March 16, 2025 22:28
- 영화, 상영관, 상영일정 조회
- Mapper 추가 : Entity -> Domain Model
- 아키텍처, 멀티모듈 설계, ERD, 시퀀스 다이어그램 추가
- 기존 api 모듈 -> infra 모듈로 통합
- infra 모듈 : 외부 시스템 연동 역할 수행
- AvailableMovieResult: 상영 중인 영화 정보 Dto
- MovieResult: 전체 영화 정보 Dto
- Theater, Schedule Dto 생성
- 제거한 이유에 대한 문서 작성
- QueryDsl 설정
- FullText Index 사용을 위한 MysqlDialect 커스텀
- post 요청 시 엔티티 생성되지 않는 문제로 인한 수정
- 회원, 상영일정 검증 로직 추가
- 테스트 작성
- 상태 검증, 변경 로직 작성
- 테스트 작성
- 의존성 정리
- 좌석 개수, 연속 좌석 검증
- 예약 생성
- 테스트 작성
- app push 기능 이벤트 처리
@scars97 scars97 changed the title [3주차] 동시성 제어 & 단계별 락 적용 [3주차] 예약 API 구현 & 단계별 락 적용 Mar 31, 2025
@ann-mj
Copy link

ann-mj commented Mar 31, 2025

안녕하세요~ 리뷰 시작하겠습니다.

@ann-mj
Copy link

ann-mj commented Mar 31, 2025

함수형 기반 분산락을 구현할 때 락과 트랜잭션을 분리할 수 있는 방법이 있는 지 궁금합니다.

  • 저는 지금형태도 괜찮은 것 같은데요 :) leaseTime 이 transaction 실행시간보다는 넉넉하게 설정해주기만 해도 괜찮다고 생각이 듭니다.
  • DistributedLockExecutor 에서 lock 관리, FunctionalForTransaction 에서 transactional 관리를 하고, useCase 에서 Reservation 로직을 실행하는게 아니라 reservationService 에 위임하게 되기만 해도 코드가 깔끔해질 것 같습니다.
  • 추가로) useCase 내에서 lock 획득 실패 시, 재처리 로직을 추가하고, transaction 실행하는 로직은 반드시 정합성이 유지되어야하는 로직만 실행하는 형태로 하게 되면 좋을 것 같습니다.



@RestController
@RequestMapping("/api/movies")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

필요하시다면, api versioning 도 추가해도 좋을 듯 하네요 :) 실무에서는 버저닝을 /api/v1/movies 이런 형태로도 주로 하곤 합니다.

data class CreateReservationRequest(
@field:NotNull(message = "회원 ID는 필수 입력 값입니다.")
@field:Positive(message = "회원 ID 는 0보다 큰 값이어야 합니다.")
val userId: Long,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

각 필드들에 대한 validation 까지 잘 구현해주셨네요 :)


fun validate(info: ReservationInfo) {
if (!userService.isUserExists(info.userId)) {
throw BusinessException(USER_NOT_FOUND, "존재하지 않는 회원 ID: ${info.userId}")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외처리까지 꼼꼼하게 해주셨습니다 💯

}
}

fun checkContinuousSeats(seats: List<Seat>) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거는 seat 에 두지 않고 reservation 에 두셨던 이유가 있을까요?

Copy link
Contributor Author

@scars97 scars97 Mar 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seat과 Reservation 중 어떤 곳에 둬야할 지, 아직까지도 고민 중인 부분입니다..

Reservation에 넣은 이유는
좌석 개수 제한, 연속된 좌석인지 검증하는 주체는 Reservation이라 생각했습니다.
좌석 데이터를 기준으로 검증하고 있지만 결국 예약이라는 문제를 해결하기 위한 수단이라 생각하여,
구현 당시에는 Reservation 안에 있는 게 맞지 않을까 생각했습니다..!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 이유로서는 괜찮은 것 같습니다 :!!

class MovieApplication

fun main(args: Array<String>) {
runApplication<MovieApplication>(*args)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

특이하게 infrastructure 를 Application 으로 설정하셨네요 :) 저는 application 을 생각했었는데, 명확하게 presentation 계층으로 해도 명확하지 않을까? 생각해봤습니다.

seatService.updateForReserve(info.seatIds, reservation.reservationId)

// App push 이벤트 발행
eventPublisher.publishEvent(ReservationMessageEvent.of(reservation.reservationId))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

publishEvent 의 경우 비동기로 처리해도 좋을 것 같습니다 !

const val REDISSON_LOCK_PREFIX = "LOCK:"
}

fun <T> lockWithTransaction(key: String, waitTime: Long, leaseTime: Long, action: () -> T): T {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확장성 있게, wait, lease, action 을 잘 분리해주신 것 같고, 이렇게 해주셨다면 common 모듈 하위에 있는게 좋은 것 같습니다 :)

현 서비스는 인기 영화 상영으로 인해 **동시 요청이 많을 것으로 가정**한다.

경쟁이 심한 상황이기 때문에 대기 시간을 타이트하게 가져가야 한다.<br>
앞서 설정한 leaseTime보다 더 긴 3초가 적절하다 생각된다.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

버퍼 + 네트워크 지연을 고려해서 2초면 되게 좋은 것 같습니다. 전체적으로 논리적으로 잘 정리해주셨다고 생각됩니다 💯

@ann-mj
Copy link

ann-mj commented Mar 31, 2025

@scars97 님, 고생하셨습니다 👍 👍

좋았던 점

  • MessageService 로직도 잘 구현해주셨습니다.
  • request 요청과 validation이 모두 적절하게 잘 구현되어있습니다.
  • 실제 문제를 잘 해결하기 위해 노력하신 부분이 너무 좋았습니다.
  • leaseTime, waitTime 적절히 설정 + 이유가 명확하셔서 좋았습니다.

아쉬웠던 점

  • 구현은 전체적으로 잘 해주셨는데, 도메인 영역과 엔티티 영역의 책임을 어떻게 잘 분리할 수 있을지? 도메인 관점에서 바라보셔도 좋을 것 같습니다.

Deep Dive Point

  • GC Stop the world 같은 상황이 발생하는 경우 Lock 을 사용하더라도 Lost Update 가 발생할 수 있습니다. 이 문제를 어떻게 해결할 수 있을까요?

- as-is : 인프라 모듈에서 좌석 조회 후 상태 변경
- to-be : 도메인 모델에서 상태 변경 후 전달
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants