반응형

* 글 마지막에 작성된 코드로 InitDB 클래스만 수정하고 넘어가도 됩니다. 테스트를 위한 더미 데이터가 추가되고, 초기화 방법만 바뀌었습니다.

 

이번 시간에는 다시 로그인 구현으로 돌아와서, 각 사용자의 권한이나 인증 여부에 따라 리소스 접근을 제한해볼 예정이었습니다.

 

 

 

일단 그 전에, 프로젝트를 진행하며 만나게 된 에러와 그로 인한 변경 사항을 먼저 검토해보겠습니다.

단순히 그냥 넘어가도 좋지만, 에러의 원인과 해결책을 찾는데 많은 고민을 하게 되어서 공유해보려고합니다.

 

변경을 생각하게 된 상황은 다음과 같습니다.

바로 이전 시간에 데이터베이스에 더미 데이터 초기화 목적으로 생성한 InitDB에서, 추가적인 초기화 코드를 작성하다가 에러를 만나게 되었습니다.

앞에서는 Role만 데이터베이스에 초기화해두었지만, 이제 권한에 따른 접근 제어를 테스트해볼 예정이니,

미리 관리자 권한과 일반 권한을 가진 테스트 계정을 삽입해둘 계획이었습니다.

MemberRepositoryTest에서 작성했던 CascadeType.PERSIST으로 설정한 MemberRole 저장 테스트와 유사한 방식으로 코드를 작성하였습니다.

Role을 미리 저장해두고, Member를 저장할 때는 필요한 Role을 다시 조회해서 MemberRole도 cascade하게 저장하는 방식이었습니다.

 

구체적인 코드는 다음과 같습니다.

@Component
@RequiredArgsConstructor
@Slf4j
@Profile("local")
public class InitDB {
    private final RoleRepository roleRepository;
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @PostConstruct
    public void initDB() {
        log.info("initialize database");
        initRole();
        initTestAdmin();
        initTestMember();
    }

    private void initRole() {
        roleRepository.saveAll(
                List.of(RoleType.values()).stream().map(roleType -> new Role(roleType)).collect(Collectors.toList())
        );
    }

    private void initTestAdmin() {
        memberRepository.save(
                new Member("admin@admin.com", passwordEncoder.encode("123456a!"), "admin", "admin",
                        List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new),
                                roleRepository.findByRoleType(RoleType.ROLE_ADMIN).orElseThrow(RoleNotFoundException::new)))
        );
    }

    private void initTestMember() {
        memberRepository.saveAll(
                List.of(
                        new Member("member1@member.com", passwordEncoder.encode("123456a!"), "member1", "member1",
                                List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new))),
                        new Member("member2@member.com", passwordEncoder.encode("123456a!"), "member2", "member2",
                                List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new))))
        );
    }

}

테스트를 위해 데이터베이스를 초기화해주던 InitDB 클래스입니다. 

 

위 코드를 실행하면 다음과 같은 에러가 발생합니다.

org.springframework.beans.factory.BeanCreationException: Error creating bean with \
  name 'initDB': Invocation of init method failed; nested exception is \
  org.springframework.dao.InvalidDataAccessApiUsageException: detached entity \
  passed to persist: kukekyakya.kukemarket.entity.member.Role; nested exception is \
  org.hibernate.PersistentObjectException: detached entity passed to persist: \
  kukekyakya.kukemarket.entity.member.Role

persist를 위해 전달된, 분리된 엔티티 Role이 문제라고 합니다.

 

관련 내용을 찾아보니 CascadeType.PERSIST를 제거하면 해결된다고 합니다.

실제로, 우리의 코드에서도 Member 엔티티에서 @OneToMany 관계로 roles의 CascadeType.PERSIST를 설정해주고 있었습니다.

@OneToMany(mappedBy = "member", cascade = CascadeType.PERSIST)
private Set<MemberRole> roles;

이를 제거해주면 오류는 사라지게 됩니다.

하지만 이렇게 될 경우 Member를 저장할 때, MemberRole이 cascade하게 저장되지 않게 됩니다. (이전에 작성했던 cacade 저장 테스트도 실패하게 됩니다.)

결국 CascadeType.PERSIST를 제거한다고 모든 문제가 해결되는게 아니기 때문에, 본질적인 문제를 파악하고 해결책을 찾아야했습니다.

 

 

먼저, @PostConstruct에는 직접적으로 @Transactional을 적용할 수 없습니다. (이유는 바로 이전 포스트에서 간단히 언급하였습니다.)

그렇기에 리포지토리를 호출하는 메소드는, 각각의 리포지토리에서 적용되는 독립적인 트랜잭션에 의해 수행되고 있습니다.

우리의 코드에서는, Member가 @OneToMany 관계를 가지는 MemberRole에 대해 cascade 설정이 PERSIST로 되어있습니다.

따라서, MemberRepository.save를 호출할 때, MemberRole도 같이 저장되어야합니다.

하지만, 리포지토리는 각 메소드 단위로 독립적인 트랜잭션을 유지하고있기 때문에,

RoleRepository.findByRoleType을 호출하여 조회된 Role을 Member의 role에 적용하고 저장하려면,

실제로 이건 JPA가 관리해주는 컨텍스트에서 분리된(attached) 상태입니다.

이러한 까닭에, cascade하게 PERSIST를 하려고 해도, Role은 이미 컨텍스트에서 분리되어있기 때문에,

MemberRole을 저장하려고 해도 Role에 대해 제대로 된 인식을 못하고 오류가 발생하게 되는 것입니다.

 

결국, 가장 큰 원인은 트랜잭션을 하나로 묶어주지 않았다는 것이었습니다. (테스트 단계에서는 @DataJpaTest를 이용하였는데, 여기에는 @Transactional이 포함되어있습니다. 이를 망각했기에, 동일한 코드인줄 착각하고 있던 것입니다.)

 

이를 해결하기 위해 떠오른 방법에는 몇 가지가 있습니다.

1. 직접 쿼리를 작성해서 날려준다.

2. 트랜잭션 처리를 위해 트랜잭션이 적용된 별도의 서비스를 만들어서 호출한다.

3. 직접 트랜잭션을 열어준다.

4. @Transactional이 적용될 수 있도록 초기화 방법을 바꿔준다.

이 외에도 여러 방법이 있겠지만, 저는 4번 방법을 선택하게 되었습니다.

직접 쿼리를 작성하거나 트랜잭션을 열어주기는 귀찮고, 아직 Member에게 새로운 Role을 부여하는 서비스는 작성되어있지 않습니다.

지금은 단순히, 테스트를 위해 더미 데이터를 삽입해주는 과정일 뿐입니다.

당장 구현이 필요한 기능도 아닌데, 지금 단계에서 새로운 서비스를 구성하는 것은 너무 앞서나가는 과정이라고 보았습니다.

 

그래서 그냥 초기화 방법을 바꿔주었습니다.

해당하는 빈만 초기화한 뒤에 호출되는 @PostConstruct를 고집할 이유는 없었습니다.

 

 

결국 @EventListener 어노테이션을 이용하여 초기화를 진행하는 방식으로 바꾸게 되었고, 수정된 InitDB 클래스는 다음과 같습니다.

@Component
@RequiredArgsConstructor
@Slf4j
@Profile("local")
public class InitDB {
    private final RoleRepository roleRepository;
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @EventListener(ApplicationReadyEvent.class)
    @Transactional
    public void initDB() {
        log.info("initialize database");

        initRole();
        initTestAdmin();
        initTestMember();
    }

    private void initRole() {
        roleRepository.saveAll(
                List.of(RoleType.values()).stream().map(roleType -> new Role(roleType)).collect(Collectors.toList())
        );
    }

    private void initTestAdmin() {
        memberRepository.save(
                new Member("admin@admin.com", passwordEncoder.encode("123456a!"), "admin", "admin",
                        List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new),
                                roleRepository.findByRoleType(RoleType.ROLE_ADMIN).orElseThrow(RoleNotFoundException::new)))
        );
    }

    private void initTestMember() {
        memberRepository.saveAll(
                List.of(
                        new Member("member1@member.com", passwordEncoder.encode("123456a!"), "member1", "member1",
                                List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new))),
                        new Member("member2@member.com", passwordEncoder.encode("123456a!"), "member2", "member2",
                                List.of(roleRepository.findByRoleType(RoleType.ROLE_NORMAL).orElseThrow(RoleNotFoundException::new))))
        );
    }

}

ApplicationReadyEvent가 발생하면 initDB 메소드를 호출해줍니다.

모든 준비가 완료되었을 때 발생하는 이벤트이므로, @Transactional을 이용한 AOP를 적용할 수 있습니다.

 

사실 원인과 해결책은 정말 간단했지만, 이를 찾아내는 과정을 기록하고 싶어서 길게 남겨봤습니다.

 

 

프로젝트 진행 와중에 이야기가 잠깐 다른 곳으로 새었는데,

다음 시간에는 다시 로그인 기능을 구현하며, 인증 및 권한에 따른 리소스 접근 제어를 다뤄보도록 하겠습니다.

 

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

반응형

+ Recent posts