[Spring] 트랜잭션 & 동시성 이슈 처리

easyone·2025년 11월 19일

Spring

목록 보기
16/19

UMC 4주차 시니어 미션 진행합니다.

2️⃣ 트랜잭션 & 동시성 이슈 처리

하나의 트랜잭션에서 여러 엔티티를 처리하는 비즈니스 로직 작성

  • 예) Member가 탈퇴할 경우 관련된 모든 데이터를 삭제하는 API 구현
  • @Transactional을 적용하고, @Modifying을 활용하여 Batch Delete 쿼리 최적화
  • 동시성 문제가 발생할 수 있는 시나리오를 고민하고 해결책 적용
    • 예) 같은 회원이 동시에 같은 Store를 찜하려고 할 때 중복이 발생하지 않도록 @Lock 사용
    • 다양한 락킹 전략에 대해 공부해보고, 이를 정리하기
  1. Member가 탈퇴할 경우 관련된 모든 데이터를 삭제하는 API 구현

Controller

    @DeleteMapping("/me")
    @Operation(summary = "회원 탈퇴",
            description = "로그인한 본인 계정을 탈퇴 처리합니다. 계정 정보는 30일 후에 자동 삭제됩니다.")
    public ApiResponse<String> withdrawMe(@AuthenticationPrincipal CustomUserDetails userDetails) {
        String msg = userService.withdrawUser(userDetails.getUser());
        return ApiResponse.onSuccess(msg,SuccessCode.OK);
    }

Repository

기존 구현 방식은 다음과 같다. 30일이 지나면 삭제되도록 구현을 해놓은 상태인데, @Modifying 을 사용해서 구현했다.

    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("delete from User u " +
            "where u.deletedAt is not null " +
            "and   u.deletedAt <= :threshold " +
            "and   u.status = umc.nook.users.domain.Status.INACTIVE")
    int hardDeleteUsersOlderThan(@Param("threshold") LocalDateTime threshold);
  • clearAutomatically = true : 해당 옵션은, 쿼리 실행 후에 1차 캐시를 비운다. 즉 DELETE 쿼리를 반영하고 나서 entityManager.clear()를 자동으로 호출한다. 즉 메모리가 초기화된다. 유저1을 조회하는 레포지토리 메서드를 실행 → 메모리에 캐싱되어서 유저1 정보가 아직 남아있다. 해당 메서드 실행 → 캐시를 비움 유저1을 조회하는 메서드를 다시 실행 → 아무 값도 반환되지 않는다. → userId가 1 인 사용자를 삭제하고 싶을 때, 먼저 해당 사용자가 존재하는지 조회를 해야 한다. 사용자를 조회하고, 삭제하면 캐시가 남아있으면 안되기 때문에, 데이터 수정 뒤에 바로 적용되고자 할 때 사용하는 옵션이다.
  • flushAutomatically = true : DELETE 쿼리 실행 전에, pending 변경사항을 DB에 먼저 반영한다. 즉 먼저 자동으로 UPDATE 쿼리가 실행되고, 그 다음에 DELETE를 실행한다.

두 옵션 다 사용하면 ?

  • flush() : pending 변경사항을 DB에 반영한다.
  • user delete 쿼리를 실행한다.
  • clear() : 1차 캐시를 비운다.

→ 변경사항이 있으면 반영하고, 삭제 후에 메모리를 초기화하도록 한다.

동시성 문제가 발생할 수 있는 시나리오를 고민하고 해결책 적용

요구사항 : 통화 기능을 구현하면서, 통화 종료 시에 메모리에서 관리되는 통화 세션을 동시에 접근하지 않도록 해야 한다. 즉 사용자가 여러 브라우저에서 접근하는 경우에는 통화를 한쪽에서만 관리하도록 설정해야 한다.

  1. StartCall, EndCall 메서드에서 synchronized 를 사용했다.
    public synchronized void endCall(String userId) {
        CallSession session = activeCalls.get(userId);
        if (session == null) {
            log.warn("[CallManager-endCall] FAILED: No session found for userId={}", userId);
            return;
        }

        String callerId = session.getCallerId();
        String receiverId = session.getReceiverId();
        Status statusBeforeEnd = session.getCurrentStatus();

        session.markEnded();
        activeCalls.remove(callerId);
        activeCalls.remove(receiverId);

        log.info("[CallManager-endCall] SESSION REMOVED: caller={}, receiver={}, lastStatus={}, endTime={}",
                callerId, receiverId, statusBeforeEnd, LocalDateTime.now());
        log.info("[CallManager-endCall] ACTIVE SESSIONS NOW: {}", activeCalls.size());
    }
  1. ReentrantLock 사용 : 자바 메모리 락

    CallManger 클래스 안에 존재하는 메서드들이다. 메모리에서 통화 정보를 관리하기 때문에, 메모리에서 스레드를 직접 제어하고자 해서 사용했다.

    1. lock.lock(); : 락을 획득
    2. try 블록은 임계 영역으로, 해당 코드를 실행하는 동안 lockd이 걸려있는 것이다. 종료 후에 finally 블록으로 진입하면, unlock()을 해준다.
     private final ReentrantLock lock = new ReentrantLock();
     public void markConnected() {
                lock.lock(); // 락을 획득 
                try {
                    if (this.connectedTime != null) {
                        log.warn("[CallSession-markConnected] Already connected at {}, ignoring duplicate call",
                                this.connectedTime);
                        return;
                    }
                    this.connectedTime = LocalDateTime.now();
                    Status oldStatus = this.currentStatus;
                    this.currentStatus = Status.IN_CALL;
                    cancelTimeout();
                    log.info("[CallSession-markConnected] CONNECTED: caller={}, receiver={}, connectedTime={}, status: {} -> {}",
                            callerId, receiverId, this.connectedTime, oldStatus, Status.IN_CALL);
                } finally {
                    lock.unlock();
                }
            }
    
            public void markTimedOut() {
                lock.lock();
                try {
                    this.wasTimedOut = true;
                    Status oldStatus = this.currentStatus;
                    this.currentStatus = Status.MISSED;
                    log.warn("[CallSession-markTimedOut] TIMED OUT after {}s: caller={}, receiver={}, status: {} -> {}",
                            CALL_TIMEOUT_SECONDS, callerId, receiverId, oldStatus, Status.MISSED);
                } finally {
                    lock.unlock();
                }
            }
    
            public void markRejected() {
                lock.lock();
                try {
                    this.wasRejected = true;
                    Status oldStatus = this.currentStatus;
                    this.currentStatus = Status.REJECTED;
                    log.info("[CallSession-markRejected] REJECTED: caller={}, receiver={}, status: {} -> {}",
                            callerId, receiverId, oldStatus, Status.REJECTED);
                } finally {
                    lock.unlock();
                }
            }
    
            public void markCancelled() {
                lock.lock();
                try {
                    this.wasCancelled = true;
                    Status oldStatus = this.currentStatus;
                    this.currentStatus = Status.CANCELLED;
                    log.info("[CallSession-markCancelled] CANCELLED: caller={}, receiver={}, status: {} -> {}",
                            callerId, receiverId, oldStatus, Status.CANCELLED);
                } finally {
                    lock.unlock();
                }
            }
    
            public void markEnded() {
                lock.lock();
                try {
                    Status oldStatus = this.currentStatus;
                    this.currentStatus = Status.ENDED;
                    log.info("[CallSession-markEnded] ENDED: caller={}, receiver={}, status: {} -> {}",
                            callerId, receiverId, oldStatus, Status.ENDED);
                } finally {
                    lock.unlock();
                }
            }
    

다른 락킹 전략 종류

  1. Pessimistic Write Lock (비관적 락)

    조회 시에 보통 사용하는 락으로, 행을 잠그고, 다른 스레드의 진입을 차단한다.

    사용 방법

    		@Lock(LockModeType.PESSIMISTIC_WRITE)
        @Query("SELECT w FROM Wishlist w WHERE w.user.id = :userId AND w.store.id = :storeId")
        Optional<Wishlist> findByUserAndStoreWithWriteLock(
                @Param("userId") Long userId,
                @Param("storeId") Long storeId);

    가게 찜 리스트를 조회하려고 할 때, 이 시점에 조회쿼리를 다른 스레드에서 실행하려는 접근이 있더라도 막아준다. 다음과 같은 SQL문이 실행된다.

    장점: 충돌이 없고, 순서가 보장된다.

    단점 : 스레드2는 스레드1의 조회 쿼리가 끝날때까지 대기하기 때문에 성능이 저하되거나 데드락 상황이 발생할 수 있다.

    -- 스레드1
    SELECT w.* FROM wishlist w 
    WHERE w.user_id = 1 AND w.store_id = 100 
    FOR UPDATE;  -- 해당 행을 LOCK
    
    -- 스레드2
    SELECT w.* FROM wishlist w 
    WHERE w.user_id = 1 AND w.store_id = 100 
    FOR UPDATE;  -- 대기 중
    
    -- 스레드1 커밋
    COMMIT;
    
    -- 스레드2 획득 → 계속 진행
  2. Pessimistic Read Lock (비관적 읽기 락)

    쓰기 중에 읽기를 하지 못하도록 하는 락이다. 읽기가 많고, 쓰기 충돌이 적을 때 사용한다.

  3. Optimistic Lock (낙관적 락)

    충돌 시에만 처리하는 락이다. 엔티티 코드에서 보통 사용한다. wishlist 엔티티에 다음 코드를 추가하면,

    WishList 를 save하는 작업 실생 시, version이 자동으로 증가한다. 만약 다른 스레드에서 먼저 저장할 경우, 이 버전 필드가 충돌하기 때문에 저장되지 못하도록 OptimisticLockingFailureException 을 던진다.

profile
백엔드 개발자 지망 대학생

0개의 댓글