From 520607ff8295714b55d9bcab0e2bf6efdbd43709 Mon Sep 17 00:00:00 2001 From: Azin Date: Sun, 26 Oct 2025 14:31:50 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix/#194:=20=EC=BA=90=EC=8B=9C=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberServiceImpl의 findById(), findMemberByEmail() 메서드에 @Transactional(readOnly = true) 적용 - LogBookService의 getLogBooksByYearAndStatus(), getLogBooks() 메서드에 @Transactional(readOnly = true) 적용 근본 원인: - Member 엔티티는 @Version 필드를 가짐 (optimistic locking) - 클래스 레벨 @Transactional로 인해 읽기 메서드도 write transaction에서 실행됨 - Write transaction 내에서 @Cacheable로 캐싱된 Member 엔티티가 detached되면서 version 필드 초기화 문제 발생 - 에러: "Detached entity with generated id has an uninitialized version value 'null'" 해결 방법: - 읽기 전용 메서드에 명시적으로 @Transactional(readOnly = true) 적용 - 캐시와 JPA 트랜잭션 간 호환성 향상 - 불필요한 write lock 방지로 성능 최적화 --- .../domain/logbase/logbook/service/LogBookService.java | 5 +++-- .../com/divary/domain/member/service/MemberServiceImpl.java | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/divary/domain/logbase/logbook/service/LogBookService.java b/src/main/java/com/divary/domain/logbase/logbook/service/LogBookService.java index d4f8ad7..8d5bd8f 100644 --- a/src/main/java/com/divary/domain/logbase/logbook/service/LogBookService.java +++ b/src/main/java/com/divary/domain/logbase/logbook/service/LogBookService.java @@ -58,11 +58,12 @@ public class LogBookService { .build(); } - @Transactional + @Transactional(readOnly = true) public List getLogBooksByYearAndStatus(int year, SaveStatus status, Long userId) { List logBaseInfoList; + Member member = memberService.findById(userId); if (status == null) { @@ -84,7 +85,7 @@ public List getLogBooksByYearAndStatus(int year, SaveStatu }//연도에 따라, 저장 상태(임시저장,완전저장)에 따라 로그북베이스정보 조회 - @Transactional + @Transactional(readOnly = true) public List getLogBooks(Long userId) { List logBaseInfoList = logBaseInfoRepository.findByMemberId(userId); diff --git a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java index 1e1277d..f189864 100644 --- a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java @@ -39,11 +39,13 @@ public class MemberServiceImpl implements MemberService { private int gracePeriodDays; @Override + @Transactional(readOnly = true) public Member findMemberByEmail(String email) { return memberRepository.findByEmail(email).orElseThrow(()-> new BusinessException(ErrorCode.EMAIL_NOT_FOUND)); } @Override + @Transactional(readOnly = true) @Cacheable(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#id") public Member findById(Long id) { return memberRepository.findById(id).orElseThrow(()-> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); From e9db239fb6b98edc05c50432d116d3c7b890bcf0 Mon Sep 17 00:00:00 2001 From: Azin Date: Sun, 26 Oct 2025 23:49:19 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix/#194:=20saveMember=EC=97=90=EC=84=9C=20?= =?UTF-8?q?detached=20entity=20version=20null=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캐시에서 가져온 detached Member 엔티티를 저장할 때 @Version 필드가 null이어서 ObjectOptimisticLockingFailureException이 발생하는 문제 수정 변경 사항: - findById(): @Transactional(readOnly=true) 제거하여 managed entity 반환 - saveMember(): detached entity 감지 시 DB에서 재조회 후 필드 복사 - updateLevel/requestToDeleteMember/cancelDeleteMember: @CacheEvict에 beforeInvocation=false 명시하여 캐시 무효화 타이밍 명확화 --- .../member/service/MemberServiceImpl.java | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java index f189864..f680897 100644 --- a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java @@ -45,7 +45,6 @@ public Member findMemberByEmail(String email) { } @Override - @Transactional(readOnly = true) @Cacheable(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#id") public Member findById(Long id) { return memberRepository.findById(id).orElseThrow(()-> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); @@ -54,18 +53,35 @@ public Member findById(Long id) { @Override @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#member.id", condition = "#member.id != null") public Member saveMember(Member member) { + // If member has an ID, it might be a detached entity from cache + // Re-fetch from DB to get managed entity with proper version + if (member.getId() != null) { + Member managedMember = memberRepository.findById(member.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + // Copy all fields from detached member to managed member + managedMember.setEmail(member.getEmail()); + managedMember.setLevel(member.getLevel()); + managedMember.setRole(member.getRole()); + managedMember.setStatus(member.getStatus()); + managedMember.setDeactivatedAt(member.getDeactivatedAt()); + + return managedMember; // JPA dirty checking will save this + } + + // New entity - just save directly return memberRepository.save(member); } - @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#userId") + @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#userId", beforeInvocation = false) public void updateLevel(Long userId, MyPageLevelRequestDTO requestDTO) { Levels level = EnumValidator.validateEnum(Levels.class, requestDTO.getLevel().name()); - Member member = memberRepository.findById(userId).orElseThrow(()-> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); member.setLevel(level); + // Cache evicted after successful update } @Override @@ -85,7 +101,7 @@ public MyPageImageResponseDTO uploadLicense(MultipartFile image, Long userId) { @Override @Transactional - @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#memberId") + @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#memberId", beforeInvocation = false) public DeactivateResponse requestToDeleteMember(Long memberId) { Member member = memberRepository.findById(memberId).orElseThrow(()-> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); @@ -98,11 +114,12 @@ public DeactivateResponse requestToDeleteMember(Long memberId) { .plusDays(gracePeriodDays); return new DeactivateResponse(scheduledDeletionAt); + // Cache evicted after successful update } @Override @Transactional - @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#memberId") + @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#memberId", beforeInvocation = false) public void cancelDeleteMember(Long memberId) { Member member = memberRepository.findById(memberId).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); @@ -110,6 +127,7 @@ public void cancelDeleteMember(Long memberId) { if (member.getStatus() == Status.DEACTIVATED) { member.cancelDeletion(); } + // Cache evicted after successful update } @Override @Transactional