[Spring] User Entity 책임 분리 - SJ Coding Helper 프로젝트 리펙토링 (2)

TaesunPark·2023년 1월 12일
0

User Entity 책임 분리하기

우선적으로 유저 Entity를 리펙토링 해보겠다.

package com.example.testlocal.module.user.domain.entity;

import com.example.testlocal.domain.dto.UserDTO2;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.*;
import java.util.Collection;
import java.util.Collections;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity(name = "user")
@Table(name = "user")
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private long id;

    @Column(name = "student_number", nullable = false)
    private String studentNumber;

    @Column(name = "password", nullable = false)
    private String password;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "email", nullable = false)
    private String email;

    public User(UserDTO2 userDTO) {
        this.studentNumber = userDTO.getStudentNumber();
        this.password = userDTO.getPassword();
        this.name = userDTO.getName();
        this.email = userDTO.getEmail();
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.emptyList();
    }

    @Override
    public String getUsername() {
        return studentNumber;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

}

위 코드는 리펙토링 전 유저 Entity 코드이다.

스프링 시큐리티에서 사용자 정보를 불러오기 위해 UserDetails를 상속 받았다.

의문점이 든다. 왜 Entity에 UserDetails의 구현체를 만들었는지?

→ User Entity는 역할이 Entitiy인데 스프링 시큐리티에서 사용자 정보를 주는 역할까지 하면 책임이 너무 많다고 생각이 든다. 그러므로 밖으로 빼주겠다.

package com.example.testlocal.core.security;

import com.example.testlocal.module.user.domain.entity.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Collections;

public class CustomUserDetails implements UserDetails {

    private User user;

    public CustomUserDetails(User user){
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.emptyList();
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getName();
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }
}

위 코드는 분리시켜준 UserDetails의 구현체이다.

package com.example.testlocal.module.user.application.service;

import com.example.testlocal.core.security.CustomUserDetails;
import com.example.testlocal.module.user.domain.repository.UserRepository2;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
    private final UserRepository2 userRepository;

    @Override
    public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {

        return new CustomUserDetails(userRepository.findById(id)
                .orElseThrow(
                        () -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")
                )
        );
    }
}

위 코드는 UserDetails를 반환하는 코드인데, 원래는 userRepository만 참조했지만 그러면 entity가 무거워져서 따로 분리시켜줬다.

@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
    private final UserRepository2 userRepository;

    @Override
    public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {

        return new CustomUserDetails(userRepository.findById(id)
                .orElseThrow(
                        () -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")
                )
        );
    }
}

테스트 코드도 만들어주겠다.

package com.example.testlocal.module.user.application.service;

import com.example.testlocal.module.user.domain.VO.UserVO;
import com.example.testlocal.module.user.domain.entity.User;
import com.example.testlocal.module.user.domain.repository.UserRepository2;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.test.context.web.WebAppConfiguration;

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

@WebAppConfiguration
@SpringBootTest
class CustomUserDetailServiceTest {
    @Autowired
    private CustomUserDetailService customUserDetailService;

    @Autowired
    private UserRepository2 userRepository;

    @DisplayName("UserDetail 반환하는 비교를 한다.")
    @Test
    void UserDetail_반환() {
        User user = User.builder().id(1).email("tovbskvb@daum.net").name("박태순").password("1234567").studentNumber("17011526").build();
        userRepository.save(user);
        UserDetails userDetails = customUserDetailService.loadUserByUsername("17011526");
        assertThat(userDetails.getUsername()).isEqualTo("박태순");
    }
}
package com.example.testlocal.module.user.domain.entity;

import com.example.testlocal.domain.dto.UserDTO2;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.util.Collection;
import java.util.Collections;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity(name = "user")
@Table(name = "user")
public class User{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private long id;

    @Column(name = "student_number", nullable = false)
    private String studentNumber;

    @Column(name = "password", nullable = false)
    private String password;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "email", nullable = false)
    private String email;

    public User(UserDTO2 userDTO) {
        this.studentNumber = userDTO.getStudentNumber();
        this.password = userDTO.getPassword();
        this.name = userDTO.getName();
        this.email = userDTO.getEmail();
    }

}

의문점이 한 가지 더 생겼다. 왜 Entity만 있고, 순수 도메인 VO는 없는 지? 안정성을 위해서 VO를 생성해주겠다.

package com.example.testlocal.module.user.domain.VO;

import com.example.testlocal.module.user.domain.entity.User;
import lombok.Builder;
import lombok.Getter;

import java.util.Objects;
@Getter
@Builder
public class UserVO {

    private String studentNumber;

    private String password;

    private String name;

    private String email;

    @Override
    public int hashCode() {
        return Objects.hash(studentNumber, password, name, email);
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == this) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        UserVO userVO = (UserVO) obj;
        return  (userVO.studentNumber == ((UserVO) obj).studentNumber)
                && (userVO.password == ((UserVO) obj).password)
                && (userVO.name == ((UserVO) obj).name)
                && (userVO.email == ((UserVO) obj).email);
    }

}

생성자가 4개 이상이라 빌더를 적용해주었고,

VO는 주소 값이 달라도 속성 값들이 같으면 같은 객체라고 여기기 때문에 hashCode와 equals 메소드를 구현해서 사용하였다.

UserVO를 만들었으니, 테스트 코드를 만들어 보겠다.

package com.example.testlocal.module.user.domain.VO;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

class UserVOTest {

    @DisplayName("VO 동등비교를 한다.")
    @Test
    void isSameObjects() {
        UserVO user1 = UserVO.builder().name("박태순").email("tovbs111@daum.net").password("1234").studentNumber("17011526").build();
        UserVO user2 = UserVO.builder().name("박태순").email("tovbs111@daum.net").password("1234").studentNumber("17011526").build();

        assertThat(user1).isEqualTo(user2);
        assertThat(user1).hasSameHashCodeAs(user2);
    }

    @DisplayName("VO 같지 않다. 테스트")
    @Test
    void isNotSameObjects() {
        UserVO user1 = UserVO.builder().name("박태리아").email("tovbs111@daum.net").password("1234").studentNumber("17011527").build();
        UserVO user2 = UserVO.builder().name("박태순").email("tovbs111@daum.net").password("1234").studentNumber("17011527").build();

        assertThat(user1).isNotSameAs(user2);
    }
}

VO에 관련된 테스트 코드를 추가해주었다.

다시 User Entity를 보겠다.

일단 User Entity를 리펙토링 하기 전에 테스트 코드부터 추가해주겠다.

JPA를 사용해서 테이블과 매핑하기 위해 사용된 클래스이므로 주의사항을 한 번 보겠다.

@Entity 적용시 주의사항

  1. 기본 생성자는 필수다.
  2. final 클래스, enum, interface
  3. 저장할 필드에 final을 사용하면 안된다.

드디어 User Entity의 책임이 분리가 되었다. 테스트 코드를 만들기 전에 명세를 다시 짜보겠다.

유저 명세 정의

당시 프로젝트할 땐 명세를 정확하게 적지 않아 현재 리펙토링할 때 고생이다.

명세를 적어두면 유지 보수할 때 편하게 진행할 수 있는 걸 느꼈다.

User 명세 

1. 관리자, 조교, 일반 회원으로 타입을 구분해야 한다.
2. 유저는 학번, 비밀번호, 이름, 이메일을 가지고 있다.
3. 회원 가입일과 수정일이 있어야 한다.

User Entity Test

package com.example.testlocal.module.user.domain.entity;

import com.example.testlocal.module.user.domain.repository.UserRepository2;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserTest {
    @Autowired
    private UserRepository2 userRepository;

    private User user1;
    private User user2;

    @BeforeEach
    public void init(){
        user1 = User.builder().email("tovbskvb@daum.net").studentNumber("17011526").password("1234").name("손민기").build();
    }

    @DisplayName("유저 생성, 테스트")
    @Test
    public void create(){
        user2 = userRepository.save(user1);
        assertThat(user1.getName()).isEqualTo(user2.getName());
        assertThat(user1.getId()).isEqualTo(user2.getId());
        assertThat(user1.getPassword()).isEqualTo(user2.getPassword());
        assertThat(user1.getRoleType()).isEqualTo(user2.getRoleType());
        assertThat(user1.getStudentNumber()).isEqualTo(user2.getStudentNumber());
        assertThat(user1.getEmail()).isEqualTo("tovbskvb@daum.net");
    }

    @DisplayName("유저 업데이트, 테스트")
    @Test
    public void update(){
        user2 = userRepository.save(user1);
        user1.setEmail("tovbskvb1@daum.net");
        user1.setName("발민기");
        user1.setStudentNumber("17011568");
        user2 = userRepository.save(user1);
        assertThat(user1.getName()).isEqualTo(user2.getName());
        assertThat(user1.getId()).isEqualTo(user2.getId());
        assertThat(user1.getPassword()).isEqualTo(user2.getPassword());
        assertThat(user1.getRoleType()).isEqualTo(user2.getRoleType());
        assertThat(user1.getStudentNumber()).isEqualTo(user2.getStudentNumber());
        assertThat(user1.getEmail()).isEqualTo("tovbskvb1@daum.net");
    }

    @DisplayName("유저 삭제")
    @Test
    public void delete(){
        userRepository.delete(user1);
        assertThat(userRepository.findByEmail("tovbskvb@daum.net")).isEmpty();
    }
    
}

1번 명세 관리자, 조교, 일반 회원으로 타입 구분하기

관리자, 조교, 일반 회원을 나누어줘야하는데, 쪽팔리지만 전에는 조교 테이블을 따로 만들어서 관리를 해주었다.

하지만 그럴 필요가 없기에 enum 타입으로 나누어 주겠다.

상수를 담당하기에 config 패키지에 넣어놨다.

package com.example.testlocal.config;

public enum RoleType {
    ADMIN, USER, ASSISTANT
}
package com.example.testlocal.module.user.domain.entity;

import com.example.testlocal.config.RoleType;
import com.example.testlocal.domain.dto.UserDTO2;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.util.Collection;
import java.util.Collections;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity(name = "user")
@Table(name = "user")
public class User{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private long id;

    @Column(name = "student_number", nullable = false)
    private String studentNumber;

    @Column(name = "password", nullable = false)
    private String password;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "email", nullable = false)
    private String email;

    public User(UserDTO2 userDTO) {
        this.studentNumber = userDTO.getStudentNumber();
        this.password = userDTO.getPassword();
        this.name = userDTO.getName();
        this.email = userDTO.getEmail();
    }

    // 추가
    @Enumerated(EnumType.STRING)
    private RoleType roleType;
    

}

@Enumerated(EnumType.STRING)

EnumType.STRING 타입으로 설정하면 DB 저장할 때 이름으로 저장됨.

예를 들어 ADMIN 타입 → roleType 컬럼에 ADMIN 저장

테스트 코드 추가

package com.example.testlocal.module.user.domain.entity;

import com.example.testlocal.config.RoleType;
import com.example.testlocal.module.user.domain.repository.UserRepository2;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserTest {
    @Autowired
    private UserRepository2 userRepository;

    private User user1;
    private User user2;

    @BeforeEach
    public void init(){
        user1 = User.builder().email("tovbskvb@daum.net").studentNumber("17011526").password("1234").name("손민기").roleType(RoleType.ADMIN).build();
    }

    @DisplayName("유저 생성, 테스트")
    @Test
    public void create(){
        user2 = userRepository.save(user1);
        assertThat(user1.getName()).isEqualTo(user2.getName());
        assertThat(user1.getId()).isEqualTo(user2.getId());
        assertThat(user1.getPassword()).isEqualTo(user2.getPassword());
        assertThat(user1.getRoleType()).isEqualTo(user2.getRoleType());
        assertThat(user1.getStudentNumber()).isEqualTo(user2.getStudentNumber());
        assertThat(user1.getEmail()).isEqualTo("tovbskvb@daum.net");
        assertThat(user1.getRoleType()).isEqualTo(RoleType.ADMIN);
    }

    @DisplayName("유저 업데이트, 테스트")
    @Test
    public void update(){
        user2 = userRepository.save(user1);
        user1.setEmail("tovbskvb1@daum.net");
        user1.setName("발민기");
        user1.setStudentNumber("17011568");
        user1.setRoleType(RoleType.USER);

        user2 = userRepository.save(user1);
        assertThat(user1.getName()).isEqualTo(user2.getName());
        assertThat(user1.getId()).isEqualTo(user2.getId());
        assertThat(user1.getPassword()).isEqualTo(user2.getPassword());
        assertThat(user1.getRoleType()).isEqualTo(user2.getRoleType());
        assertThat(user1.getStudentNumber()).isEqualTo(user2.getStudentNumber());
        assertThat(user1.getEmail()).isEqualTo("tovbskvb1@daum.net");
        assertThat(user1.getRoleType()).isEqualTo(RoleType.USER);
    }

    @DisplayName("유저 삭제")
    @Test
    public void delete(){
        userRepository.delete(user1);
        assertThat(userRepository.findByEmail("tovbskvb@daum.net")).isEmpty();
    }

}

명세 2번은 이미 작성돼있어서 건너뛰겠다.

3. 회원 가입일과 수정일이 있어야 한다.

package com.example.testlocal.config;

import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.MappedSuperclass;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import java.time.LocalDateTime;

@Data
@MappedSuperclass
@NoArgsConstructor
public class DateTime {
    private LocalDateTime createAt;
    private LocalDateTime updateAt;

    @PrePersist 
    public void prePersist() {
        this.createAt = LocalDateTime.now();
        this.updateAt = this.createAt;
    }

    @PreUpdate 
    public void preUpdate() {
        this.updateAt = LocalDateTime.now();
    }
}

이런 식으로 DateTime을 만들어서 엔티티 클래스에서 상속받도록 설계했다.

앞으로 수정할 테이블들도 다 DateTime이 필요해서 따로 클래스로 빼 주었다.

이렇게 User Entity 책임을 많이 덜어주고, 회원 관리를 생각해서 회원 타입과 수정일을 추가해주었다.

결론

  1. 이전에는 User Entity에 UserDetail 구현체로도 쓰였던 걸 분리해줌으로써 책임을 덜어주었고,
  2. UserVO를 만듦으로써 Entity, VO, DTO에 대한 책임 구분을 명확하게 시켜주었다.
  3. 테스트 코드를 만들어줌으로써 변화에 대응할 수 있고, 안정감 있는 코드를 짜주었다.

다음 할 일

  1. 회원가입 리펙토링 및 테스트 추가

참고

자바 ORM 표준 JPA 프로그래밍 - 김영한

1개의 댓글

comment-user-thumbnail
2023년 12월 11일

잘 정리하셨네요~! 잘보고갑니다

답글 달기