테스트용 데이터 저장 중 Invocation of init method failed error

bin1225·2024년 12월 11일
0

Project_Only

목록 보기
8/9

test용 데이터를 생성해두기 위해 해당 역할을 실행하는 class와 메서드를 정의했다.

@Component
@RequiredArgsConstructor
public class TestDataInit {

    private final MemberRepository memberRepository;
    private final DiaryService diaryService;
    private final PageService pageService;

    @PostConstruct
    public void init() {
        //member
        Member member = Member.of(SignUpRequest.builder()
                .loginId("test")
                .password("test1234")
                .name("hello").build());
        memberRepository.save(member);

        //diary
        try {
            int diaryCount = 5;
            int pageCount = 5;
            for (int i = 1; i <= diaryCount; i++) {
                DiaryRequest diaryRequest = DiaryRequest.builder()
                        .title("diary" + i)
                        .subTitle("this is diary" + i)
                        .build();
                Diary diary = diaryService.createDiary(member.getId(), diaryRequest);
                for(int j=1; j<=pageCount; j++) {
                    PageRequest pageRequest = PageRequest.builder()
                            .title("page"+j)
                            .content("this is diary"+i+" of page"+j)
                            .diaryId(diary.getId())
                            .build();
                    pageService.savePage(pageRequest);
                }
            }
        }
        catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("test data init error");
        }
    }
  • @Component어노테이션을 정의하여 spring bean으로 등록하고, @PostConstruct어노테이션으로 어플리케이션 실행 전에 호출되도록 하였다.

error code

InvalidDataAccessApiUsageException

org.springframework.dao.InvalidDataAccessApiUsageException: detached entity passed to persist: com.project.only.domain.member.Member

전에 본 적 있는 에러다.
Member 객체의 아이디를 설정한 채로 persist()를 호출했을 때 발생했다.
jpa는 아이디가 존재하는 객체가 영속성이 없으면 detached 상태로 판단하기 때문에 merge()를 호출해줘야 한다.

에러 메시지를 더 따라가보니

	at com.project.only.service.DiaryService.createDiary(DiaryService.java:31)

diaryService의 crateDiary()메서드에서 발생했음을 알 수 있다.

createDiary()

    public Diary createDiary(Long memberId, DiaryRequest diaryRequest) {
        Diary diary = Diary.builder()
                .title(diaryRequest.getTitle())
                .subTitle(diaryRequest.getSubTitle())
                .build();

        Member member = memberRepository.findById(memberId);
        MemberDiary memberDiary = new MemberDiary(member, diary);
        diary.setMember(memberDiary);

        return diaryRepository.save(diary);
    }

MemberDiaryMemberDiary의 다대다 관계를 매핑하기 위한 매핑 테이블의 역할을 한다. 이 정보를 diary에 저장하는데, 이 부분에서 문제가 발생했을 가능성이 보였다.

        //Member member = memberRepository.findById(memberId);
        //MemberDiary memberDiary = new MemberDiary(member, diary);
        //diary.setMember(memberDiary);

일부분을 주석 처리 후 실행하니까 또 다른 부분에서 오류가 난다.

detached entity passed to persist: com.project.only.domain.diary.Diary

이번엔 Member가 아닌 Diary가 detached entity라고 한다.

	at com.project.only.service.PageService.savePage(PageService.java:35)

PageService에서 발생

entity의 영속성 전이 쪽에서 문제가 발생한 게 아닌가 의심이 됐다.

    public Page savePage(PageRequest pageRequest) {
        Diary diary = diaryRepository.findOne(pageRequest.getDiaryId());
        log.info("diaryService find diary by id {}", diary.getId());

        Page page = Page.builder()
                .title(pageRequest.getTitle())
                .content(pageRequest.getContent())
                //.diary(diary)
                .build();

        return pageRepository.save(page);
    }

PageService에서 diary 저장에 관한 부분을 주석처리 하니 테스트가 정상적으로 작동했다.

결국 연관된 엔티티를 저장하는 과정에서 문제가 있다는 것을 알았다.

영속성 전이 문제 의심

영속성 전이에 대해서 찾아봤을 때, 영속성 전이란 영속성 전이를 설정한 연관 엔티티에게 영속성을 전이하는 것이다.

즉 A엔티티와 B엔티티가 영속성 전이가 설정돼있고, A가 영속성의 주인이라면
A가 영속될 때 B도 함께 영속되는 것이다.

의심되는 상황은 여기서 B가 이미 영속상태인 경우 문제가 발생하는가이다.

MemeberDiary

memberDiary 생성 시 Member와 Diary를 설정한다.
이때 Member객체는 이미 생성된 상태이다.

Diary를 persist하면 영속성 전이에 의해 memberDiary도 함께 영속화 되는데 이때 memberDiary내부에 있는 Member가 다시 한 번 저장되기 때문에 문제가 발생한다.

그래서 cascade 옵션도 제거하고 실행해봤는데, 옵션을 제거하면 실행은 되지만 정상적으로 작동하지 않는다.
Diary와 Member사이의 연관관계가 제대로 저장이 안되기 때문에 Member의 다이어리 목록을 조회할 수 없다.

의문인 점은 api를 개발할 때는 정상적으로 작동되고 실행도 됐다는 것이다.

즉 영속성 전이 과정에서 문제가 발생했지만, 그게 cascade 옵션 문제는 아니다.

원인 및 해결

문제는 @PostConstruct에 있었다.

@PostConstruct를 이용하면 Transaction을 흭득할 수 없다.
스프링부트에서 초기화 코드를 먼저 호출하고, 그 다음에 트랜잭션 AOP가 적용하기 때문이다. 따라서 초기화 시점에는 해당 메서드에서 트랜잭션을 획득할 수 없다.

JPA는 한 트랜잭션이 끝나면 영속상태에 있던 Entity를 db에 flush한다.

그런데 Transaction의 경계가 명시되지 않았다면 데이터베이스 작업이 개별적으로 실행된다.

        Member member = memberRepository.findById(memberId);
        MemberDiary memberDiary = new MemberDiary(member, diary);
        diary.setMember(memberDiary);

diary생성 메서드의 일부인데, member를 저장한 후 다시 diary와의 연관관계를 세팅 후 diary를 저장한다.

여기서 위의 메서드가 하나의 원자단위로 묶이지 않는다면 member객체 저장 후 member는 db에 저장되고 영속성 상태가 아니게 된다. (detached상태)

이 경우 merge를 호출해야하는데 cascade옵션에 의해 persist가 호출되면서 에러가 발생한 것이다.

참고: https://engineerinsight.tistory.com/71

확인

실제 Transaction이 원인인지 확인하기 위해 직접 Transaction의 경계를 설정해줬다.

@Slf4j
@Component
@RequiredArgsConstructor
public class TestDataInit {

    private final MemberRepository memberRepository;
    private final DiaryService diaryService;
    private final PageService pageService;

    @Autowired
    private PlatformTransactionManager transactionManager;

    @PostConstruct
    public void init() {
        DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
        definition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
        definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
        
        //트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction(definition);

        try {
            Member member = Member.of(SignUpRequest.builder()
                    .loginId("test")
                    .password("test1234")
                    .name("hello").build());
            memberRepository.save(member);

            log.info("!!!! memberId:: {}", member.getId());
            int diaryCount = 5;
            int pageCount = 5;
            for (int i = 1; i <= diaryCount; i++) {
                DiaryRequest diaryRequest = DiaryRequest.builder()
                        .title("diary" + i)
                        .subTitle("this is diary" + i)
                        .build();
                Diary diary = diaryService.createDiary(member.getId(), diaryRequest);
                log.info("!!!! diaryId:: {}", diary.getId());

                for (int j = 1; j <= pageCount; j++) {
                    PageRequest pageRequest = PageRequest.builder()
                            .title("page" + j)
                            .content("this is diary" + i + " of page" + j)
                            .diaryId(diary.getId())
                            .build();
                    pageService.savePage(pageRequest);
                }
            }
            //트랜잭션 끝
            transactionManager.commit(status);
        } catch (Exception e) {
            // 오류 발생 시 롤백
            transactionManager.rollback(status);
            throw e;
        }
    }
}

정상 작동 확인

@EventListener vs @PostConstruct

    @EventListener(ApplicationReadyEvent.class)
    @Transactional

@PostConstruct와 달리 @EventListenr 어노테이션을 이용하면 @Transaction어노테이션을 사용할 수 있다.

@PostConstruct은 Spring AOP작동 이전에 실행되기 때문에 @Transaction`어노테이션을 적용할 수 없다.

따라서 트랜잭션 단위로 작업이 필요한 경우 @EventListenr를 사용하는 것이 더 바람직한 것 같다.

0개의 댓글

Powered by GraphCDN, the GraphQL CDN