UMC 4주차 시니어 미션 진행합니다.
하나의 트랜잭션에서 여러 엔티티를 처리하는 비즈니스 로직 작성
Member가 탈퇴할 경우 관련된 모든 데이터를 삭제하는 API 구현@Transactional을 적용하고, @Modifying을 활용하여 Batch Delete 쿼리 최적화Store를 찜하려고 할 때 중복이 발생하지 않도록 @Lock 사용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를 실행한다.두 옵션 다 사용하면 ?
→ 변경사항이 있으면 반영하고, 삭제 후에 메모리를 초기화하도록 한다.
동시성 문제가 발생할 수 있는 시나리오를 고민하고 해결책 적용
요구사항 : 통화 기능을 구현하면서, 통화 종료 시에 메모리에서 관리되는 통화 세션을 동시에 접근하지 않도록 해야 한다. 즉 사용자가 여러 브라우저에서 접근하는 경우에는 통화를 한쪽에서만 관리하도록 설정해야 한다.
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());
}
ReentrantLock 사용 : 자바 메모리 락
CallManger 클래스 안에 존재하는 메서드들이다. 메모리에서 통화 정보를 관리하기 때문에, 메모리에서 스레드를 직접 제어하고자 해서 사용했다.
lock.lock(); : 락을 획득 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();
}
}
다른 락킹 전략 종류
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 획득 → 계속 진행
Pessimistic Read Lock (비관적 읽기 락)
쓰기 중에 읽기를 하지 못하도록 하는 락이다. 읽기가 많고, 쓰기 충돌이 적을 때 사용한다.
Optimistic Lock (낙관적 락)
충돌 시에만 처리하는 락이다. 엔티티 코드에서 보통 사용한다. wishlist 엔티티에 다음 코드를 추가하면,
WishList 를 save하는 작업 실생 시, version이 자동으로 증가한다. 만약 다른 스레드에서 먼저 저장할 경우, 이 버전 필드가 충돌하기 때문에 저장되지 못하도록 OptimisticLockingFailureException 을 던진다.