[트러블 슈팅] 비관적 락 & 1차 캐시

dev_archive_v·2026년 3월 30일

스프링 부트

목록 보기
19/22
post-thumbnail

좋아요 취소 기능 테스트 중에 발생한 문제이다.

Given

  1. 20명의 가입자가 존재하는 상태
  2. 한 개의 게시글에 20명의 사람이 좋아요를 선택함

When

1.20개의 스레드가 존재 -> 20명이 동시에 좋아요를 취소함

Then

  1. 좋아요 갯수 기댓값 : 0
  2. 게시글 좋아요 목록 : null
    @OneToMany(mappedBy = "playListArticle",orphanRemoval = true,cascade = CascadeType.ALL,fetch = FetchType.EAGER)
    private List<PostLike> likeList;

테스트 코드

@Test
    @DisplayName("좋아요 동시에 취소했을 때")
    public void concurrent() throws InterruptedException {
        int threadCnt = 20;
        ExecutorService executorService = Executors.newFixedThreadPool(threadCnt);
        CountDownLatch startLatch = new CountDownLatch(1);  // 동시 출발 신호
        CountDownLatch doneLatch = new CountDownLatch(threadCnt);
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failCount = new AtomicInteger(0);



        // When - 20명 동시에 좋아요 취소
        for (int i = 0; i < threadCnt; i++) {
            final Member member = members.get(i);
            executorService.submit(() -> {
                try {
                    startLatch.await(); // 모든 스레드 대기
                    playListArticleService.decreaseLike(articleId,member.getEmail());
                    successCount.incrementAndGet();
                } catch (Exception e) {
                    failCount.incrementAndGet();
                    e.printStackTrace();
                } finally {
                    doneLatch.countDown();
                }
            });
        }
        startLatch.countDown(); // 20개 스레드 동시 출발
        doneLatch.await();      // 모든 스레드 완료 대기
        executorService.shutdown();

        // Then
        PlayListArticle result = playlistArticleRepository.findByIdWithPessimisticLock(articleId).orElseThrow();//비관적락 적용

        System.out.println("초기 좋아요 수: 20");
        System.out.println("성공한 취소 수: " + successCount.get());
        System.out.println("실패한 취소 수: " + failCount.get());
        System.out.println("최종 좋아요 수: " + result.getLikeCnt());

        assertThat(result.getLikeCnt()).isGreaterThanOrEqualTo(0); // 음수 방지
        assertThat(result.getLikeCnt()).isEqualTo(0);              // 20명 모두 취소했으므로 0
        assertThat(result.getLikeList()).isNull();                 // 좋아요 목록도 비어있어야 함
    }

Test failed

초기 좋아요 수: 20
성공한 취소 수: 0
실패한 취소 수: 20
최종 좋아요 수: 20
좋아요 누른 사람: [com.example.FifthSpring.model.PostLike@7b2637fa, com.example.FifthSpring.model.PostLike@5fea6cdb, com.example.FifthSpring.model.PostLike@5dfb0e1e, com.example.FifthSpring.model.PostLike@2f539c9b, com.example.FifthSpring.model.PostLike@6580f76c, com.example.FifthSpring.model.PostLike@6980d3b3, com.example.FifthSpring.model.PostLike@6c8eab63, com.example.FifthSpring.model.PostLike@5f0a452d, com.example.FifthSpring.model.PostLike@3dd3f237, com.example.FifthSpring.model.PostLike@52a1a375, com.example.FifthSpring.model.PostLike@4bedcff1, com.example.FifthSpring.model.PostLike@4643d88c, com.example.FifthSpring.model.PostLike@64a3d839, com.example.FifthSpring.model.PostLike@3b4675d3, com.example.FifthSpring.model.PostLike@1e71b70d, com.example.FifthSpring.model.PostLike@46ae8bf7, com.example.FifthSpring.model.PostLike@3c36f10b, com.example.FifthSpring.model.PostLike@3456e3b3, com.example.FifthSpring.model.PostLike@270cf3eb, com.example.FifthSpring.model.PostLike@15cf25ce]

PostLike 모델이 삭제되지 않았음을 알 수 있다.

왜 이런 문제가 발생하는지 Service 계층의 코드를 살펴보았다.

    @Transactional
    public PlayListDto decreaseLike(Long id,String email) {
        
        PlayListArticle targetPost = playlistArticleRepository.findByIdWithPessimisticLock(id).orElseThrow(); //비관적 락 적용
        playlistArticleRepository.decreaseLike(id);
        likeRepository.deleteByPlayListArticleIdAndMember(id,memberRepository.findByEmail(email).orElseThrow());

        return mapToPlayListDto(targetPost);

    }
public interface PlaylistArticleRepository extends JpaRepository<PlayListArticle,Long> {
	
       @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("Select p from PlayListArticle p where p.id = :id")
    @Transactional
    Optional<PlayListArticle> findByIdWithPessimisticLock(@Param(value = "id") Long id); //비관적 락을 통해 cnt 문제 해결
    
  @Modifying
    @Query("Update PlayListArticle p SET p.likeCnt = p.likeCnt - 1 where p.id = :articleId")
    void decreaseLike(@Param(value = "articleId") Long articleId);
  

}
public interface LikeRepository extends JpaRepository<PostLike,Long> {

	 @Transactional
     void deleteByPlayListArticleIdAndMember(@Param(value="id") Long id, @Param(value="member") Member member);
}
public interface MemberRepository extends JpaRepository<Member,Long> {
    Optional<Member> findByEmail(String email);
}

해결 방법

서비스 계층의 접근 순서는 PlaylistArticleRepository->likeRepository->memberRepository이다.
1. PlaylistArticleRepository에서는 findByIdWithPessimisticLock는 비관적 락을 적용하여 정합성을 유지하도록 하였다.
2.DB에서 삭제했지만 영속성 컨텍스트에 과거에 삭제된 데이터가 남아있었다.
→ @Modifying(clearAutomatically = true, flushAutomatically = true) 추가
→ 테스트에서 em.clear() 후 재조회

public interface LikeRepository extends JpaRepository<PostLike,Long> {
    @Transactional
    @Modifying(clearAutomatically = true, flushAutomatically = true) //  1차 캐시 초기화
    @Query("Delete FROM PostLike p WHERE p.playListArticle.id = :id AND p.member.id = :memberId") 
     void deleteByPlayListArticleIdAndMember(@Param(value="id") Long id, @Param(value="memberId") Long memberId);
}
  1. Could not initialize proxy [com.example.FifthSpring.model.Member#1] - no session와 같은 에러가 있었다.
    디버깅을 해보니까
        return PlayListDto.builder().id(playListArticle.getId()).userEmail(playListArticle.getMember().getEmail()).latitude(playListArticle.getLatitude()).longitude(playListArticle.getLongitude()).songList(songList).tagList(tagList).likeList(postLikeList).created(playListArticle.getCreated()).updated(playListArticle.getUpdated()).address(playListArticle.getAddress()).viewCnt(playListArticle.getViewCnt()).likeCnt(playListArticle.getLikeCnt()).build();

->여기서 no session의 오류가 발생했다.
userEmail(playListArticle.getMember().getEmail())
PlayListArticle을 살펴보면
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="member_id") private Member member;
fetch가 Lazy로 연결되어있다. 지연 로딩이 발생하여 NO SESSION이 된 것이다. 우선 지연로딩을 제거하여 SESSION문제를 해결했다.

해결


Modifying

  1. 벌크 연산을 진행할 때에 영속성 컨텍스트를 고려하지 않고 DB에만 쿼리가 반영된다.
  2. 영속성 컨텍스트에 있는 1차 캐시가 엔티티를 캐싱하게 된다. 이는 변경이 실행된 후의 실제 db와 같지 않은 값을 가져올 수 있다.
  3. 이를 위해 @Modifying에서 제공하는 clearAutomatically를 이용해 영속성 컨텍스트clear하게 한다.flushAutomatically는 쿼리가 실행 되기 전에 지연 저장소의 쿼리를 Flush하게 한다.

비관적 락

  1. DB데이터에 LOCK을 걸어서 다른 Transaction에서 데이터를 접근하지 못하게 한다.
  2. 충돌을 막기 때문에 정합성을 보장할 수 있다.
  3. 데드락(무한 대기 어떤 작업도 완료되지 못하고 멈춘 상태)이 발생함.
    • 데드락 필수조건 4가지
      1. 상호배제: 한 번에 한 개의 프로세스만이 공유자원을 사용
      1. 점유대기: 프로세스가 할당된 자원을 가진 상태에서 다른 자원 대기
      2. 비선점: 프로세스가 작업을 마친 후에 자원을 자발적으로 반환하기까지 대기
      3. 순환 대기: 프로세스의 자원 점유 & 점유된 자원의 요구관계가 원형을 이루며 대기 -> 순환적으로 요구하는 자원을 서로가 가지고 있음

0개의 댓글