로그인 / 회원가입 구현(1) - Mapper 적용 및 Domain, Repository구현

Jongwon·2023년 2월 9일
1

DMS

목록 보기
5/18

시작하기 전, Lombok 어노테이션의 정상 동작을 위해 Settings -> Annotation Processors에서 Enable annotation processing을 활성화 해주시기 바랍니다.



EnableJpaAuditing

회원 Entity는 이후에 Update가 될 가능성이 있습니다. 하지만 ID값이 자동생성된 값이 아니라면, JPA는 이를 구분할 수 없습니다. 회원 엔티티의 아이디는 사용자가 입력한 UserId이므로 CreatedDate라는 별도의 애트리뷰트를 통해 신규 엔티티인지 판별한 후, 이후 JPA에서 save() 메서드를 호출할 때 Update를 할지 Insert를 할지 알려주어야 합니다.

참고자료
https://ttl-blog.tistory.com/807
https://leegicheol.github.io/jpa/jpa-is-new/

메인 메서드에 아래의 어노테이션을 추가합니다.

✅DmsApplication

@SpringBootApplication
@EnableJpaAuditing
public class DmsApplication {

	public static void main(String[] args) {
		SpringApplication.run(DmsApplication.class, args);
	}
}


Entity, DTO 생성

Entity에는 필요한 부분에만 Setter를 구현하는 것이 좋습니다. DB에 직접적으로 접근하는 객체이기 때문에 잘못된 수정은 바로 DB에 저장된 데이터까지 영향을 줄 수 있기 때문입니다. 자세한 내용은 이전 글에 링크가 있으니 참고하시길 바랍니다.


DB에 생성될 Member 테이블은 이전에 설계한 ERD에 작성되어 있습니다. 이를 기반으로 Member 엔티티를 생성하겠습니다.


Domain 패키지를 아래와 같이 설계하였습니다. 생성 시 참고하시길 바랍니다.

Member

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@EntityListeners(AuditingEntityListener.class)
@DynamicUpdate
public class Member implements Persistable<String> {

    @Id
    @NonNull
    @Column(updatable = false, unique = true)
    private String userId;     //사용자 ID값
    private String password;
    private String username;
    private String nickname;
    private int gender;   //0기타 1남성 2여성
    private LocalDate birth;
    @NonNull
    @Column(unique = true)
    private String email;
    private String phoneNo;
    private boolean social;
    @Enumerated(EnumType.STRING)
    private Provider provider;
    private String zipcode;
    private String street;
    private String addressDetail;
    @Enumerated(EnumType.STRING)
    private List<Role> roles;
    @OneToOne(fetch = FetchType.LAZY)
    private MemberImage memberImage;    //프로필 사진

    @CreatedDate
    @Column(updatable = false)
    @NonNull
    private LocalDateTime createdDate;

    @Builder
    public Member(@NonNull String userId, String password, String username, String nickname, int gender, LocalDate birth, @NonNull String email, String phoneNo, boolean social, Provider provider, String zipcode, String street, String addressDetail, List<Role> roles, LocalDateTime createdDate) {
        this.userId = userId;
        this.password = password;
        this.username = username;
        this.nickname = nickname;
        this.gender = gender;
        this.birth = birth;
        this.email = email;
        this.phoneNo = phoneNo;
        this.social = social;
        this.provider = provider;
        this.zipcode = zipcode;
        this.street = street;
        this.addressDetail = addressDetail;
        this.roles = roles;
        this.createdDate = createdDate;
    }

    public void updateMemberImage(MemberImage memberImage) {
        this.memberImage = memberImage;
    }

    public void updateRole(Role role) {
        if(this.roles == null) {
            this.roles = new ArrayList<>();
        }

        if(this.roles.contains(role)) {
            this.roles.remove(role);
        }
        else {
            this.roles.add(role);
        }
    }

    public void updateSocial(Provider provider) {
        this.social = true;
        this.provider = provider;
    }

    public void updatePassword(String password) {
        this.password = password;
    }

    @Override
    public String getId() {
        return userId;
    }

    @Override
    public boolean isNew() {
        return createdDate == null;
    }
}
  • MemberImage와 같은 경우에는 이후 AWS에 올린 뒤에 방식을 변경하겠지만, 현재는 로컬 스토리지에 사진을 저장하고, 해당 파일명과 경로를 가진 별도의 테이블로 생성할 예정입니다. Member 엔티티는 MemberImage를 참조하도록 합니다.

  • 엔티티 내부에는 Setter 사용을 지양하지만, MemberImageRole은 Setter 사용이유가 명확하게 보여지기 때문에 사용하였습니다.

  • Builder 패턴을 별도의 생성자를 만들어 적용한 이유는 MemberImage 때문인데, 이미지 처리를 한 후에 별도로 저장해야하기 때문에 제외하였습니다.

  • Role은 List 내부에 Enum타입을 담는 형태로 저장하는데, 처음 엔티티 생성시에는 List가 존재하지 않으므로 생성해줍니다.


아래는 Member 엔티티에 있는 RoleMemberImage에 대한 정의입니다.

Role

public enum Role {
    ROLE_USER("ROLE_USER"),
    ROLE_ADMIN("ROLE_ADMIN");

    private final String type;

    Role(String type) {
        this.type = type;
    }

    public String getType() {
        return type;
    }
}

이미지는 회원 이외에도 여러 곳(게시물 이미지 등)에서 사용될 것으로 예상되어 별도의 super클래스를 만든 후, 이를 참조하는 방식으로 하였습니다.

Image

@MappedSuperclass
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder
@AllArgsConstructor
public abstract class Image {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NonNull
    private String uuid;
    @NonNull
    private String fileName;
    @NonNull
    private String fileUrl;
}

MemberImage

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder
public class MemberImage extends Image {
}

Social 로그인은 추후에 설정할 예정이기 때문에 일단은 임시로 Enum클래스만 만들어두겠습니다.

Provider

public enum Provider {
    GOOGLE("google");

    private final String provider;

    Provider(String provider) {
        this.provider = provider;
    }

    public static Provider of(String provider) {
        switch (provider) {
            case "google" :
                return Provider.GOOGLE;
            default:
                return null;
        }
    }
}


다음으로는 회원가입 및 로그인 시 필요한 DTO를 정의하겠습니다.


먼저 로그인 시 사용할 LoginRequestDTO입니다.

LoginRequestDTO

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequestDTO {

    private String userId;
    private String password;

    public UsernamePasswordAuthenticationToken toAuthentication() {
        return new UsernamePasswordAuthenticationToken(userId, password);
    }
}
  • toAuthentication메서드는 클라이언트로부터 받은 정보를 기반으로 인증 토큰을 생성하는 메서드입니다.


다음으로는 회원가입 및 수정 등에서 사용할 MemberRequestDTO입니다.

MemberRequestDTO

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MemberRequestDTO {

    private String userId;
    private String username;
    private String password;
    private String nickname;
    private int gender;
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd", timezone = "Asia/Seoul")
    private LocalDate birth;
    private String email;
    private String phoneNo;
    private int zipcode;
    private String street;
    private String addressDetail;
    private MultipartFile memberImage;
}
  • 클라이언트에서 이미지파일을 받아오는데, multipart/form-data 형태로 넘어올 예정이기 때문에 memberImage 타입을 MultipartFile이라고 지정하였습니다.


Member와 관련된 DTO를 클라이언트로 응답하거나, 서비스 계층 사이에서 주고받을 때 사용할 MemberDTO도 생성합니다.

MemberDTO

@Data
@NoArgsConstructor
public class MemberDTO {

    private String userId;
    private String username;
    private String nickname;
    private int gender;
    private LocalDate birth;
    private String email;
    private String phoneNo;
    private String zipcode;
    private boolean social;
    private Provider provider;
    private String street;
    private String addressDetail;
    private MemberImage memberImage;
    private List<Role> roles;
    private LocalDateTime createdDate;

    @Builder
    public MemberDTO(String userId, String username, String nickname, int gender, LocalDate birth, String email, String phoneNo, String zipcode, boolean social, Provider provider, String street, String addressDetail, MemberImage memberImage, List<Role> roles, LocalDateTime createdDate) {
        this.userId = userId;
        this.username = username;
        this.nickname = nickname;
        this.gender = gender;
        this.birth = birth;
        this.email = email;
        this.phoneNo = phoneNo;
        this.zipcode = zipcode;
        this.social = social;
        this.provider = provider;
        this.street = street;
        this.addressDetail = addressDetail;
        this.memberImage = memberImage;
        this.roles = roles;
        this.createdDate = createdDate;
    }
}



이번 어플리케이션에서는 Client-Server 통신을 Session 방식이 아닌 Token방식을 이용하여 진행할 예정입니다. 먼저 토큰 엔티티와 DTO만 생성해두고, 바로 다음 글에서 토큰 처리를 진행하겠습니다.

✔️ 현재 RedisDB를 Spring Boot에 적용시키지 않아 Refresh Token을 RDB(MySQL)에 저장하고 있습니다. 이후에 RedisDB로 옮기는 작업을 진행할 예정입니다.

RDB에 임시로 Refresh Token을 저장하기 위해 토큰 엔티티도 생성합니다.

RefreshToken

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long tokenId;

    @OneToOne(fetch = FetchType.LAZY)
    private Member member;

    private String token;

    @Builder
    public RefreshToken(Member member, String token) {
        this.member = member;
        this.token = token;
    }

    public RefreshToken updateValue(String token) {
        this.token = token;
        return this;
    }
}

RedisDB에 저장을 한다면 일정 시간이 지났을 때(토큰 만료 시간) 자동으로 DB에서 삭제되도록 설정할 수 있기 때문에 적용해야 하지만, 현재는 프로젝트 진행이 우선이므로 우선순위를 미뤘습니다. 현재 RDB에서는 Refresh Token을 삭제하지 않습니다.


클라이언트로부터 토큰 갱신 요청을 받을때나, 서비스계층에서 컨트롤러 계층으로 토큰을 전송할 때 사용할 TokenDTO를 생성합니다.

TokenDTO

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TokenDTO {

    private String tokenType;
    private String accessToken;
    private String refreshToken;
    private Duration duration;
}

지난 글부터 따라오셨다면 이미 생성되어 있습니다.


사용자가 로그인을 성공하는 등 인증되어 토큰을 받게 된다면 Body에 accessToken을, 헤더에 refreshToken을 넣어 보내줄 예정입니다. 이를 이용할 DTO도 생성합니다.

TokenResponseDTO

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TokenResponseDTO {

    private String accessToken;
    private boolean isNewMember;
}

엔티티와 DTO를 생성했으면 이제 서로 매핑이 되도록 설정해야합니다. 매핑 방식에는 여러가지가 있지만 여기에서는 MapStruct라는 Mapper 라이브러리를 사용하도록 하겠습니다.

MapStruct

build.gradle

//추가
implementation 'org.mapstruct:mapstruct:1.5.3.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'

💥 반드시 mapstruct가 Lombok 이후에 정의되어 있어야 합니다. @Builder 어노테이션의 동작이 먼저 진행되어야 mapstruct가 생성하는 구현클래스에서 Setter를 사용하지 않고 Mapping을 할 수 있습니다.

MapStruct 최신버전과 Gradle 작성방식을 확인할 수 있습니다. https://mapstruct.org/documentation/installation/


다음으로는 Mapper Class를 생성합니다.

MemberMapper

@Mapper(componentModel = "spring")
public interface MemberMapper {

    MemberMapper INSTANCE = Mappers.getMapper(MemberMapper.class);

    MemberDTO memberToMemberDTO(Member member);

    @Mapping(target = "password", ignore = true)
    Member memberDTOToMember(MemberDTO memberDTO);

    @Mapping(target = "social", ignore = true)
    @Mapping(target = "provider", ignore = true)
    Member memberRequestDTOToMember(MemberRequestDTO memberRequestDTO);

    @Mapping(target = "social", ignore = true)
    @Mapping(target = "provider", ignore = true)
    @Mapping(target = "createdDate", ignore = true)
    MemberDTO requestDTOToMemberDTO(MemberRequestDTO memberRequestDTO);
}

componentModel = spring으로 지정하게 되면 Mapper클래스가 Bean으로 등록됩니다.

boolean으로 보통 설정하는 is{Boolean}을 매핑하려고 하면 매핑되지 않는 문제가 있습니다. Daeyoung Kim님 글에서 잘 설명해주고 있는데, JAVA의 표준으로 is...은 Getter 메서드 정의 방식으로 Mapper는 해당 메서드를 찾으려고 하지만, 찾지 못해 매핑을 하지 않는 채로 두는 것이라고 합니다.

따라서 Member에서 isSocial을 사용하지 않고 social라고 애트리뷰트를 정의하였습니다.




Repository 생성

MemberRepository를 생성합니다. JpaRepository를 상속받습니다.

MemberRepository

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByUserId(String userId);

    @Query("select m from Member m left join fetch m.memberImage where m.userId = :userId")
    Optional<Member> findByUserIdEagerLoadImage(String userId);
    Optional<Member> findByEmail(String email);
    boolean existsByUserId(String userId);
}
  • 이미지는 Lazy Loading이기 때문에 이미지도 필요한 경우를 위해서 Eager Loading 쿼리를 작성합니다.

이미지 역시 엔티티 생성 때와 마찬가지로 부모, 자식 관계의 레포지토리를 생성하겠습니다.

ImageRepository

public interface ImageRepository extends JpaRepository<Image, Long> {}

MemberImageRepository

public interface MemberImageRepository extends ImageRepository{}
profile
Backend Engineer

0개의 댓글