반응형

이번 시간에는 로그인 기능을 구현해보겠습니다.

로그인 기능은 Json Web Token을 이용한 토큰 인증 방식을 택하겠습니다.

이를 위해 토큰 발급과 사용자 인증을 위한 별도의 서버를 구축하려는 것은 아니고, 하나의 애플리케이션 내에서 사용자 인증과 토큰 발급을 모두 수행하도록 하겠습니다.

 

* 아직 프로젝트 초기 단계이기에, 로그인 기능 외의 설정 작업도 함께 진행될 수 있습니다.

 

프로젝트의 패키지 구조는 계층형(controller, service, repository)을 유지하도록 하겠습니다.

 

로그인 기능을 구현하기에 앞서, 사용자에 관한 요구사항은 다음과 같습니다.

- 이메일, 비밀번호, 사용자 이름, 닉네임을 입력받아서 사용자 정보를 생성한다.

- 이메일과 닉네임의 중복은 허용되지 않는다.

- 사용자는 여러 개의 권한 등급을 가질 수 있다.

- 비밀번호는 날 것 그대로 저장하지 않는다.

- 닉네임은 변경할 수 있다.

 

이제 각각의 요구사항을 염두에 두고, 엔티티를 설계해보겠습니다.

 

먼저 entity.member 패키지를 생성하고, 사용자를 나타내는 Member 엔티티를 작성해줍니다.

package kukekyakya.kukemarket.entity.member;

import kukekyakya.kukemarket.entity.common.EntityDate;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.List;
import java.util.Set;

import static java.util.stream.Collectors.toSet;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 3
public class Member extends EntityDate { // 5

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    @Column(nullable = false, length = 30, unique = true) // 1
    private String email;

    private String password; // 2

    @Column(nullable = false, length = 20)
    private String username;

    @Column(nullable = false, unique = true, length = 20) // 1
    private String nickname;

    @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) // 4
    private Set<MemberRole> roles;

    public Member(String email, String password, String username, String nickname, List<Role> roles) {
        this.email = email;
        this.password = password;
        this.username = username;
        this.nickname = nickname;
        this.roles = roles.stream().map(r -> new MemberRole(this, r)).collect(toSet());
    }

    public void updateNickname(String nickname) { // 6
        this.nickname = nickname;
    }
}

요구사항에 맞게 필요한 필드들을 선언해주었습니다.

1. email과 nickname은 unique로 설정해주었습니다. 이로 인해 email과 nickname에는 인덱스가 형성되고, 중복을 허용하지 않는 제약조건이 추가되었습니다.

2. password에는 NOT NULL 제약 조건이 걸려있지 않은데, 나중에 추가될 수 있는 소셜 로그인을 염두에 두고 그대로 두었습니다.

3. Member는 위와 같이 필드가 지정된 생성자를 사용하여 생성할 수 있습니다. 인스턴스가 불완전한 상태에 있음을 방지하기 위해, 기본 생성자는 외부로 노출할 필요가 없습니다. 하지만 JPA 명세에서는 엔티티에 기본 생성자를 요구하기 때문에, 기본 생성자는 접근 제어 레벨을 PROTECTED로 설정해두었습니다. (이에 관한 내용은 더 이상 언급하지 않겠습니다.)

4. 사용자를 나타내는 Member 엔티티와 권한 등급을 나타내는 Role 엔티티 간의 브릿지 테이블을 MemberRole 엔티티로 정의하였습니다. 한 명의 사용자는 여러 개의 권한을 가질 수 있고, 여러 개의 권한은 여러 사용자가 가지고 있을 수 있습니다. 이를 Member와 Role 간에 @ManyToMany로 설정하면 브릿지 테이블을 위한 엔티티를 별도로 선언하지 않아도 나타낼 수도 있지만, 사용자가 가진 권한에 대해 어떤 추가적인 데이터가 기록될지 모르기 때문에, 이에 대한 유연성을 위해 @OneToMany로 직접 선언하여 명시하였습니다. Member가 저장될 때 MemberRole 또한 연쇄적으로 저장되거나 제거될 수 있도록 cascade 옵션을 ALL로, orphanRemoval=true로 설정해줍니다. Member가 제거되거나 연관 관계가 끊어져서 MemberRole이 고아 객체가 되었을 때, MemberRole은 Member와 생명 주기를 함께하며 제거될 것입니다. 실제로 각 사용자가 가질 수 있는 권한 등급은, 그렇게 많지는 않겠지만, 우리의 애플리케이션으로 조회 했을 때의 검색 성능 향상을 위해 Set으로 선언하였습니다.

5. EntityDate는 데이터가 생성된 시간, 수정된 시간을 자동으로 업데이트해주기 위해 사용하였습니다. 자세한 구조는 아래에서 살펴보도록 하겠습니다.

6. 닉네임을 업데이트할 수 있는 요구사항을 충족하기 위해, 업데이트 메소드를 작성해주었습니다.

 

 

이번에는 사용자의 권한 등급을 나타내는 Role 엔티티를 살펴보겠습니다.

Role의 연관 관계는 Member(실제로는 MemberRole)와만 맺어지기 때문에, 이와 관련된 엔티티들은 entity.member 패키지에 작성하겠습니다.

package kukekyakya.kukemarket.entity.member;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Role {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "role_id")
    private Long id;

    @Enumerated(EnumType.STRING) // 1
    @Column(nullable = false, unique = true) // 2
    private RoleType roleType;

    public Role(RoleType roleType) {
        this.roleType = roleType;
    }
}

Role 또한, MemberRole과 OneToMany 관계를 가지지만, Role에서 MemberRole을 조회할 필요는 없기 때문에 별도로 관계를 명시해주지 않았습니다.

1. RoleType은 어떤 권한 등급이 있는지 나타내는 Enum클래스입니다. EnumType.STRING으로 지정해줌으로써 데이터베이스에 저장할 때, 문자열로 저장하게 됩니다.

2. 권한 등급을 나타내는 RoleType은 실제로 몇개 되지는 않아서 굳이 인덱스를 생성할 필요는 없다고 생각됩니다. 하지만 중복된 이름의 RoleType이 생성되는 것을 방지하기 위해, unique 제약 조건을 걸어주었습니다.

 

 

사용자 권한을 나타내는 RoleType 은 다음과 같습니다.

package kukekyakya.kukemarket.entity.member;

public enum RoleType {
    ROLE_NORMAL, ROLE_SPECIAL_SELLER, ROLE_SPECIAL_BUYER, ROLE_ADMIN
}

위와 같은 네 가지 권한만 지정해두도록 하겠습니다.

 

 

 

이번에는 Member와 Role 간의 브릿지 테이블로 사용될 MemberRole 엔티티를 살펴보겠습니다.

package kukekyakya.kukemarket.entity.member;

import lombok.*;

import javax.persistence.*;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@EqualsAndHashCode // 1
@IdClass(MemberRoleId.class) // 2
public class MemberRole {

    @Id
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @Id
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "role_id")
    private Role role;

}

1. MemberRole은 Member에서 Set으로 저장되기 때문에, equals와 hashcode를 재정의해주었습니다.

2. 여러 개의 필드를 primary key로 사용하기 위해 @IdClass를 선언해주었습니다. MemberRoleId 클래스에 정의된 필드와 동일한 필드를, MemberRole에서 @Id로 선언해주면, composite key로 만들어낼 수 있습니다.

 

 

MemberRoleId는 다음과같습니다.

@Embeddable
@EqualsAndHashCode
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class MemberRoleId implements Serializable {

    private Member member;
    private Role role;

}

@IdClass로 사용될 클래스는 Serializable을 구현해주고, 엔티티 내에서 composite key로 사용될 필드들을 동일하게 정의해주면 됩니다.

 

***

- Composite Key를 만들 때 주의할 점

composite key를 만들 때는, 기본적으로 알파벳 순으로 key가 만들어지게 됩니다.

이 때문에, 위 예시에서는 member, role의 순서로 key가 만들어집니다.

composite key에서는 key들의 순서가 중요합니다.

인덱스 구조가 첫번째 필드로 정렬된 뒤에, 그 다음으로 두번째 필드로 정렬되기 때문에,

만약 중복도가 높은 필드가 첫번째로 생성된다면, 필터링되는 레코드가 적어서 인덱스의 효과를 보지 못하게 됩니다.

우리가 진행하고 있는 프로젝트에서는, Role은 몇개밖에 생성되지 않기 때문에 중복도가 높고, Member는 계속해서 생성될 수 있기 때문에 중복도가 낮습니다.

따라서 role, member 순으로 인덱스가 생성된다면, member의 id로 레코드를 검색할 때 인덱스의 효과를 얻을 수 없습니다.

이러한 까닭에, composite key의 순서를 제어하려면, schema를 구성하는 스크립트를 직접 작성하거나 알파벳 순으로 필드의 이름을 변경하는 방법이 있겠습니다.

사실, 저는 원래 member 대신 user로 이름을 설계했는데, composite key의 순서를 제어하기 위해 member로 이름을 변경하게 되었습니다.

***

 

***

- composite key를 만드는 또 다른 방법

위에서 사용된 방법 외에도, composite key를 만드는 다른 방법이 있습니다.

ID로 사용될 필드들을 별도의 @Embeddable 클래스로 작성하고, 엔티티의 필드에서 이 클래스의 인스턴스를 @EmbeddedId로 가지고 있는 방법입니다.

https://www.baeldung.com/spring-jpa-embedded-method-parameters

사용 예시는 위 링크에서 만나볼 수 있습니다.

이 방법은, 조금 더 객체지향적으로 클래스 구조를 설계할 수 있다는 장점이 있지만,

composite key로 사용된 필드에 접근할 때, 여러 번 getter를 사용해서 접근해야하기 때문에 불필요하게 코드가 길어지고, 가독성이 떨어진다는 단점이 있습니다.

반면에 @IdClass를 이용한 방법은, composite key로 사용될 필드들을 어노테이션으로 선언만 해두면 되기 때문에, key 필드에 접근할 때 불필요하게 getter를 연속해서 사용할 필요가 없습니다.

이러한 까닭에 @IdClass를 이용하여 composite key를 생성하는 방법을 택하게 되었습니다. 

***

 

 

***

- 다대다를 풀어내는 테이블을 직접 제어?

Role과 Member 사이의 MemberRole 엔티티를 직접 제어할 일이 없다면, 그냥 @ManyToMany 관계로 선언하여 사용하는게 제일 깔끔합니다.

Member에서 Role을 꺼내올 때, 중간의 다대다를 풀어내는 테이블을 통해 알아서 조인하여 조회해주기 때문입니다.

지금처럼 직접 제어하는 상황에서의 제약 사항은 19편에서 다룹니다.

***

 

 

이번에는 Member 엔티티에서 상속받은 EntityDate를 살펴보겠습니다.

여러 엔티티에서 사용될 수 있기 때문에, entity.common 패키지에 작성하였습니다.

package kukekyakya.kukemarket.entity.common;

import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public abstract class EntityDate {
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime modifiedAt;

}

 

EntityDate 자체로 인스턴스가 생성될 이유는 없기 때문에, 추상 클래스로 선언하였습니다.

이렇게 @MappedSuperClass가 선언된 클래스를, 엔티티에서 상속받게 되면 createdAt 필드와 modifiedAt 필드를 추가하게 됩니다.

@EntityListeners를 등록하고, 각각의 필드에 @CreatedDate와 @LastModifiedDate를 지정해주면,

엔티티가 생성되거나 업데이트 될 때, 해당 필드의 데이터도 자동으로 업데이트됩니다.

이를 활성하기 위해, 우리의 스프링부트 애플리케이션에 다음과 같이 @EnableJpaAuditing 어노테이션을 추가해주면 됩니다.

@EnableJpaAuditing
@SpringBootApplication
public class KukemarketApplication {
    ...

 

 

 

이렇게 해서 사용자에 관한 엔티티 설계를 끝마쳤습니다.

다음으로 리포지토리를 만들어보겠습니다.

 

 

repository.member 패키지에 MemberRepository 인터페이스를 작성해줍니다.

package kukekyakya.kukemarket.repository.member;

import kukekyakya.kukemarket.entity.member.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByEmail(String email); // 1
    Optional<Member> findByNickname(String nickname); // 2

    boolean existsByEmail(String email); // 3
    boolean existsByNickname(String nickname); // 4
}

엔티티를 위해 사용될 리포지토리 인터페이스에 JpaRepository<Entity, ID>를 상속받으면,

다양한 쿼리를 사용할 수 있는 리포지토리 구현체를 자동으로 만들어줍니다.

또, 1~4번과 같이 일정한 규칙에 맞춰서 메소드를 작성해주면, 그에 대한 쿼리도 자동으로 생성해줍니다.

이메일과 닉네임은 unique 제약 조건이 걸려있기 때문에, 사용자 검색이나 중복 검사 등 다양한 곳에서 활용될 수 있을 것이라 보고, 미리 필요한 기능들을 만들어두겠습니다.

 

***

Optional이 낯설다면, 

2021.11.11 - [Java] - Java(자바) Optional 클래스

위 링크를 참조바랍니다.

***

 

repository.role 패키지에 RoleRepository도 만들어주겠습니다.

package kukekyakya.kukemarket.repository.role;

import kukekyakya.kukemarket.entity.member.Role;
import kukekyakya.kukemarket.entity.member.RoleType;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface RoleRepository extends JpaRepository<Role, Long> {
    Optional<Role> findByRoleType(RoleType roleType);
}

RoleType으로 검색하는 기능만 정의해두었습니다.

 

 

 

이제 작성된 엔티티와 리포지토리를 테스트해보겠습니다.

아직은 엔티티에서 수행하는 특정한 로직이 있는 것도 아니고, 결국 데이터베이스 CRUD나 제약 조건에 관한 코드만 작성되어있으므로 리포지토리를 이용하여 테스트를 수행할 것입니다.

 

 

test 디렉토리에서, 지금과 동일한 패키지 경로로 MemberRepositoryTest 클래스를 만들어줍니다.

먼저 전체 소스코드를 살펴보고, 각 테스트에 대해서 살펴보도록 하겠습니다.

package kukekyakya.kukemarket.repository.member;

import kukekyakya.kukemarket.entity.member.Member;
import kukekyakya.kukemarket.entity.member.MemberRole;
import kukekyakya.kukemarket.entity.member.Role;
import kukekyakya.kukemarket.entity.member.RoleType;
import kukekyakya.kukemarket.exception.MemberNotFoundException;
import kukekyakya.kukemarket.repository.role.RoleRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.dao.DataIntegrityViolationException;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import static java.util.Collections.emptyList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@DataJpaTest
class MemberRepositoryTest {

    @Autowired MemberRepository memberRepository;
    @Autowired RoleRepository roleRepository;
    @PersistenceContext EntityManager em;

    @Test
    void createAndReadTest() {
        // given
        Member member = createMember();

        // when
        memberRepository.save(member);
        clear();

        // then
        Member foundMember = memberRepository.findById(member.getId()).orElseThrow(MemberNotFoundException::new);
        assertThat(foundMember.getId()).isEqualTo(member.getId());
    }

    @Test
    void memberDateTest() {
        // given
        Member member = createMember();

        // when
        memberRepository.save(member);
        clear();

        // then
        Member foundMember = memberRepository.findById(member.getId()).orElseThrow(MemberNotFoundException::new);
        assertThat(foundMember.getCreatedAt()).isNotNull();
        assertThat(foundMember.getModifiedAt()).isNotNull();
        assertThat(foundMember.getCreatedAt()).isEqualTo(foundMember.getModifiedAt());
    }

    @Test
    void updateTest() {
        // given
        String updatedNickname = "updated";
        Member member = memberRepository.save(createMember());
        clear();

        // when
        Member foundMember = memberRepository.findById(member.getId()).orElseThrow(MemberNotFoundException::new);
        foundMember.updateNickname(updatedNickname);
        clear();

        // then
        Member updatedMember = memberRepository.findById(member.getId()).orElseThrow(MemberNotFoundException::new);
        assertThat(updatedMember.getNickname()).isEqualTo(updatedNickname);
    }

    @Test
    void deleteTest() {
        // given
        Member member = memberRepository.save(createMember());
        clear();

        // when
        memberRepository.delete(member);
        clear();

        // then
        assertThatThrownBy(() -> memberRepository.findById(member.getId()).orElseThrow(MemberNotFoundException::new))
                .isInstanceOf(MemberNotFoundException.class);
    }

    @Test
    void findByEmailTest() {
        // given
        Member member = memberRepository.save(createMember());
        clear();

        // when
        Member foundMember = memberRepository.findByEmail(member.getEmail()).orElseThrow(MemberNotFoundException::new);

        // then
        assertThat(foundMember.getEmail()).isEqualTo(member.getEmail());
    }

    @Test
    void findByNicknameTest() {
        // given
        Member member = memberRepository.save(createMember());
        clear();

        // when
        Member foundMember = memberRepository.findByNickname(member.getNickname()).orElseThrow(MemberNotFoundException::new);

        // then
        assertThat(foundMember.getNickname()).isEqualTo(member.getNickname());
    }

    @Test
    void uniqueEmailTest() {
        // given
        Member member = memberRepository.save(createMember("email1", "password1", "username1", "nickname1"));
        clear();

        // when, then
        assertThatThrownBy(() -> memberRepository.save(createMember(member.getEmail(), "password2", "username2", "nickname2")))
                .isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void uniqueNicknameTest() {
        // given
        Member member = memberRepository.save(createMember("email1", "password1", "username1", "nickname1"));
        clear();

        // when, then
        assertThatThrownBy(() -> memberRepository.save(createMember("email2", "password2", "username2", member.getNickname())))
                .isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void existsByEmailTest() {
        // given
        Member member = memberRepository.save(createMember());
        clear();

        // when, then
        assertThat(memberRepository.existsByEmail(member.getEmail())).isTrue();
        assertThat(memberRepository.existsByEmail(member.getEmail() + "test")).isFalse();
    }

    @Test
    void existsByNicknameTest() {
        // given
        Member member = memberRepository.save(createMember());
        clear();

        // when, then
        assertThat(memberRepository.existsByNickname(member.getNickname())).isTrue();
        assertThat(memberRepository.existsByNickname(member.getNickname() + "test")).isFalse();
    }

    @Test
    void memberRoleCascadePersistTest() {
        // given
        List<RoleType> roleTypes = List.of(RoleType.ROLE_NORMAL, RoleType.ROLE_SPECIAL_BUYER, RoleType.ROLE_ADMIN);
        List<Role> roles = roleTypes.stream().map(roleType -> new Role(roleType)).collect(Collectors.toList());
        roleRepository.saveAll(roles);
        clear();

        Member member = memberRepository.save(createMemberWithRoles(roleRepository.findAll()));
        clear();

        // when
        Member foundMember = memberRepository.findById(member.getId()).orElseThrow(MemberNotFoundException::new);
        Set<MemberRole> memberRoles = foundMember.getRoles();

        // then
        assertThat(memberRoles.size()).isEqualTo(roles.size());
    }
    
    @Test
    void memberRoleCascadeDeleteTest() {
        // given
        List<RoleType> roleTypes = List.of(RoleType.ROLE_NORMAL, RoleType.ROLE_SPECIAL_BUYER, RoleType.ROLE_ADMIN);
        List<Role> roles = roleTypes.stream().map(roleType -> new Role(roleType)).collect(Collectors.toList());
        roleRepository.saveAll(roles);
        clear();

        Member member = memberRepository.save(createMemberWithRoles(roleRepository.findAll()));
        clear();

        // when
        memberRepository.deleteById(member.getId());
        clear();

        // then
        List<MemberRole> result = em.createQuery("select mr from MemberRole mr", MemberRole.class).getResultList();
        assertThat(result.size()).isZero();
    }

    private void clear() {
        em.flush();
        em.clear();
    }

    private Member createMemberWithRoles(List<Role> roles) {
        return new Member("email", "password", "username", "nickname", roles);
    }

    private Member createMember(String email, String password, String username, String nickname) {
        return new Member(email, password, username, nickname, emptyList());
    }

    private Member createMember() {
        return new Member("email", "password", "username", "nickname", emptyList());
    }

}

 

이제 세부적인 부분을 살펴보겠습니다.

@DataJpaTest
class MemberRepositoryTest {

    @Autowired MemberRepository memberRepository;
    @Autowired RoleRepository roleRepository;
    @PersistenceContext EntityManager em;
    ...

Jpa 관련된 부분만 테스트할 것이기 때문에, 클래스에 @DataJpaTest를 설정해줍니다.

이를 설정하면, Jpa 관련된 설정이나 Repository들만 스프링 빈으로 등록되고, @Autowired로 주입받을 수 있게 됩니다.

Jpa를 이용하여 쿼리를 수행하면, 상황에 따라 즉시 쿼리가 수행되는 것이 아니라 필요한 시점에 쿼리가 수행됩니다.

또한, 영속성 컨텍스트라는 곳에 엔티티를 캐시해두기 때문에,

어떤 엔티티를 조회하거나 저장했을 때, 데이터베이스에서 꺼내오는 것이 아니라 캐시해둔 엔티티를 꺼내오게 됩니다.

우리는 데이터베이스와 연동하여 리포지토리를 테스트하기 위해 EntityManager를 주입받아서,

쿼리를 즉시 날리거나 캐시를 비우는 용도로 사용할 것입니다.

 

 

이제 각각의 테스트 내용을 살펴보겠습니다.

대부분의 테스트는 given, when, then 절을 이용하여 작성되었습니다.

given : 테스트에 필요한 데이터 또는 상황이 주어집니다.

when : 테스트를 수행합니다.

then : 테스트의 결과(상태 또는 행위)를 검증합니다.

 

먼저 각 테스트를 작성하는데 도움을 줄 메소드들 먼저 살펴보겠습니다.

    private Member createMemberWithRoles(List<Role> roles) {
        return new Member("email", "password", "username", "nickname", roles);
    }

    private Member createMember(String email, String password, String username, String nickname) {
        return new Member(email, password, username, nickname, emptyList());
    }

    private Member createMember() {
        return new Member("email", "password", "username", "nickname", emptyList());
    }

단순히 각 파라미터에 따라서 임의의 Member 엔티티를 생성하여 반환해주는 메소드입니다.

 

    private void clear() {
        em.flush(); // 1
        em.clear(); // 2
    }

1. EntityManager.flush()는 쿼리를 즉시 수행시키고,

2. EntityManager.clear()는 캐시를 비워주는 메소드로 보면 되겠습니다.

 

 

이제 정말로 각각의 테스트 내용을 살펴보겠습니다.

 

 

    @Test
    void createAndReadTest() {
        // given
        Member member = createMember();

        // when
        memberRepository.save(member);
        clear();

        // then
        Member foundMember = memberRepository.findById(member.getId()).orElseThrow(MemberNotFoundException::new);
        assertThat(foundMember.getId()).isEqualTo(member.getId());
    }

리포지토리를 이용하여 Member를 저장하고, 저장된 Member를 데이터베이스에서 다시 조회하여 검증하는 테스트입니다. CREATE와 READ를 한번에 검증하였습니다.

 

 

 

    @Test
    void memberDateTest() {
        // given
        Member member = createMember();

        // when
        memberRepository.save(member);
        clear();

        // then
        Member foundMember = memberRepository.findById(member.getId()).orElseThrow(MemberNotFoundException::new);
        assertThat(foundMember.getCreatedAt()).isNotNull();
        assertThat(foundMember.getModifiedAt()).isNotNull();
        assertThat(foundMember.getCreatedAt()).isEqualTo(foundMember.getModifiedAt());
    }

@MappedSuperClass로 선언하여 상속받았던, EntityDate 클래스에 작성된 필드들이 자동으로 추가되어 생성되는지 확인하는 테스트입니다.

createdAt와 modifiedAt가 null이 아닌지 확인하고, 처음 생성된 엔티티이기에 생성 시점과 수정 시점이 동일한 값을 가지고 있는지 확인하였습니다.

 

 

    @Test
    void updateTest() {
        // given
        String updatedNickname = "updated";
        Member member = memberRepository.save(createMember());
        clear();

        // when
        Member foundMember = memberRepository.findById(member.getId()).orElseThrow(MemberNotFoundException::new);
        foundMember.updateNickname(updatedNickname);
        clear();

        // then
        Member updatedMember = memberRepository.findById(member.getId()).orElseThrow(MemberNotFoundException::new);
        assertThat(updatedMember.getNickname()).isEqualTo(updatedNickname);
    }

Member.updateNickname 메소드를 이용하여 업데이트를 검증해보겠습니다.

조회된 엔티티의 필드를 업데이트하면, 트랜잭션이 끝나거나 강제적으로 쿼리를 수행(flush)시킬 때,

엔티티에서 업데이트된 필드를 보고, 데이터베이스로 업데이트 쿼리를 날려줍니다.

 

 

    @Test
    void deleteTest() {
        // given
        Member member = memberRepository.save(createMember());
        clear();

        // when
        memberRepository.delete(member);
        clear();

        // then
        assertThatThrownBy(() -> memberRepository.findById(member.getId()).orElseThrow(MemberNotFoundException::new))
                .isInstanceOf(MemberNotFoundException.class);
    }

이미 삭제된 데이터를 조회했을 때, 반환되는 Optional을 이용하여 객체가 없을 때 예외를 발생시켜주고, 어떤 예외가 발생하였는지 테스트해주었습니다.

assertThatThrownBy(() -> {수행할 테스트}).isInstanceOf(던져지는예외.class) 와 같은 형태로 작성됩니다.

 

    @Test
    void findByEmailTest() {
        // given
        Member member = memberRepository.save(createMember());
        clear();

        // when
        Member foundMember = memberRepository.findByEmail(member.getEmail()).orElseThrow(MemberNotFoundException::new);

        // then
        assertThat(foundMember.getEmail()).isEqualTo(member.getEmail());
    }

    @Test
    void findByNicknameTest() {
        // given
        Member member = memberRepository.save(createMember());
        clear();

        // when
        Member foundMember = memberRepository.findByNickname(member.getNickname()).orElseThrow(MemberNotFoundException::new);

        // then
        assertThat(foundMember.getNickname()).isEqualTo(member.getNickname());
    }

MemberRepository에 직접 선언했던, findByEmail과 findByNickname을 이용한 조회 테스트입니다.

 

 

    @Test
    void uniqueEmailTest() {
        // given
        Member member = memberRepository.save(createMember("email1", "password1", "username1", "nickname1"));
        clear();

        // when, then
        assertThatThrownBy(() -> memberRepository.save(createMember(member.getEmail(), "password2", "username2", "nickname2")))
                .isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void uniqueNicknameTest() {
        // given
        Member member = memberRepository.save(createMember("email1", "password1", "username1", "nickname1"));
        clear();

        // when, then
        assertThatThrownBy(() -> memberRepository.save(createMember("email2", "password2", "username2", member.getNickname())))
                .isInstanceOf(DataIntegrityViolationException.class);
    }

email과 nickname은 unique 제약 조건이 걸려있기 때문에,

중복된 데이터가 들어갔을 때 DataIntegrityViolationException이 발생해야합니다.

 

 

    @Test
    void existsByEmailTest() {
        // given
        Member member = memberRepository.save(createMember());
        clear();

        // when, then
        assertThat(memberRepository.existsByEmail(member.getEmail())).isTrue();
        assertThat(memberRepository.existsByEmail(member.getEmail() + "test")).isFalse();
    }

    @Test
    void existsByNicknameTest() {
        // given
        Member member = memberRepository.save(createMember());
        clear();

        // when, then
        assertThat(memberRepository.existsByNickname(member.getNickname())).isTrue();
        assertThat(memberRepository.existsByNickname(member.getNickname() + "test")).isFalse();
    }

nickname과 email을 가진 레코드가 이미 있는지 테스트합니다.

이미 있다면 true, 없다면 false를 반환합니다.

 

 

    @Test
    void memberRoleCascadePersistTest() {
        // given
        List<RoleType> roleTypes = List.of(RoleType.ROLE_NORMAL, RoleType.ROLE_SPECIAL_BUYER, RoleType.ROLE_ADMIN);
        List<Role> roles = roleTypes.stream().map(roleType -> new Role(roleType)).collect(Collectors.toList());
        roleRepository.saveAll(roles);
        clear();

        Member member = memberRepository.save(createMemberWithRoles(roleRepository.findAll()));
        clear();

        // when
        Member foundMember = memberRepository.findById(member.getId()).orElseThrow(MemberNotFoundException::new);
        Set<MemberRole> memberRoles = foundMember.getRoles();

        // then
        assertThat(memberRoles.size()).isEqualTo(roleTypes.size());
    }

이어서 Member 엔티티가 @OneToMany 관계로 갖고 있는 MemberRole이 cascade하게(연달아서) persist되는지(저장되는지) 검증하기 위한 테스트입니다.

먼저 사용될 Role들을 데이터베이스에 저장하고, 저장된 Role들을 Member 생성자의 인자로 전달해줍니다.

이제 Member를 저장하면, MemberRole 또한 데이터베이스에 저장되어야합니다.

저장된 Member의 MemberRole을 조회하여, 인자로 전달해줬던 Role의 개수만큼 저장되었는지 검증해줍니다.

 

 

    @Test
    void memberRoleCascadeDeleteTest() {
        // given
        List<RoleType> roleTypes = List.of(RoleType.ROLE_NORMAL, RoleType.ROLE_SPECIAL_BUYER, RoleType.ROLE_ADMIN);
        List<Role> roles = roleTypes.stream().map(roleType -> new Role(roleType)).collect(Collectors.toList());
        roleRepository.saveAll(roles);
        clear();

        Member member = memberRepository.save(createMemberWithRoles(roleRepository.findAll()));
        clear();

        // when
        memberRepository.deleteById(member.getId());
        clear();

        // then
        List<MemberRole> result = em.createQuery("select mr from MemberRole mr", MemberRole.class).getResultList();
        assertThat(result.size()).isZero();
    }

Member를 제거할 때, MemberRole 또한 함께 제거되는지 테스트해주었습니다.

cascade는 ALL로, orphanRemoval=true로 설정되어있기 때문에, Member와 MemberRole은 생명 주기를 함께할 것입니다.

* 해당 테스트는 뒤늦게 추가된 것이므로, 앞으로 등장하는 테스트 이미지와 개수의 차이가 있을 수 있습니다.

 

 

 

이번에는 RoleRepository를 테스트해보겠습니다.

테스트할 내용이 많지 않고, MemberRepositoryTest와 동일한 내용이 많기 때문에, 전체 소스코드만 첨부하여 살펴보도록 하겠습니다.

package kukekyakya.kukemarket.repository.role;

import kukekyakya.kukemarket.entity.member.Role;
import kukekyakya.kukemarket.entity.member.RoleType;
import kukekyakya.kukemarket.exception.RoleNotFoundException;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.dao.DataIntegrityViolationException;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@DataJpaTest
class RoleRepositoryTest {
    @Autowired RoleRepository roleRepository;
    @PersistenceContext EntityManager em;

    @Test
    void createAndReadTest() { // 1
        // given
        Role role = createRole();

        // when
        roleRepository.save(role);
        clear();

        // then
        Role foundRole = roleRepository.findById(role.getId()).orElseThrow(RoleNotFoundException::new);
        assertThat(foundRole.getId()).isEqualTo(role.getId());
    }

    @Test
    void deleteTest() { // 2
        // given
        Role role = roleRepository.save(createRole());
        clear();

        // when
        roleRepository.delete(role);

        // then
        assertThatThrownBy(() -> roleRepository.findById(role.getId()).orElseThrow(RoleNotFoundException::new))
                .isInstanceOf(RoleNotFoundException.class);
    }

    @Test
    void uniqueRoleTypeTest() { // 3
        // given
        roleRepository.save(createRole());
        clear();

        // when, then
        assertThatThrownBy(() -> roleRepository.save(createRole()))
                .isInstanceOf(DataIntegrityViolationException.class);

    }

    private Role createRole() {
        return new Role(RoleType.ROLE_NORMAL);
    }

    private void clear() {
        em.flush();
        em.clear();
    }
}

1~2. CREATE, READ, DELETE에 대한 테스트입니다.

3. RoleType의 unique 제약 조건 테스트입니다.

Role는 필드를 수정할 수 있는 기능을 제공하지 않기 때문에, 업데이트 테스트는 생략되었습니다.

 

 

테스트 중에 사용된 Exception들은, exception 패키지에 정의되어 있습니다.

package kukekyakya.kukemarket.exception;

public class MemberNotFoundException extends RuntimeException {

}
package kukekyakya.kukemarket.exception;

public class RoleNotFoundException extends RuntimeException {

}

RuntimeException을 상속받고 있습니다.

 

 

 

 

이번 시간에는, 사용자와 권한에 대한 엔티티 설계와 테스트를 끝마쳤습니다.

다음 시간에는 사용자가 회원가입하고, 로그인할 수 있는 서비스 로직을 작성해보도록 하겠습니다.

 

 

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

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

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

반응형

+ Recent posts