시작하기 전, Lombok 어노테이션의 정상 동작을 위해 Settings -> Annotation Processors에서 Enable annotation processing을 활성화 해주시기 바랍니다.
회원 Entity는 이후에 Update가 될 가능성이 있습니다. 하지만 ID값이 자동생성된 값이 아니라면, JPA는 이를 구분할 수 없습니다. 회원 엔티티의 아이디는 사용자가 입력한 UserId이므로 CreatedDate라는 별도의 애트리뷰트를 통해 신규 엔티티인지 판별한 후, 이후 JPA에서 save() 메서드를 호출할 때 Update를 할지 Insert를 할지 알려주어야 합니다.
참고자료
https://ttl-blog.tistory.com/807
https://leegicheol.github.io/jpa/jpa-is-new/
메인 메서드에 아래의 어노테이션을 추가합니다.
@SpringBootApplication
@EnableJpaAuditing
public class DmsApplication {
public static void main(String[] args) {
SpringApplication.run(DmsApplication.class, args);
}
}
Entity에는 필요한 부분에만 Setter를 구현하는 것이 좋습니다. DB에 직접적으로 접근하는 객체이기 때문에 잘못된 수정은 바로 DB에 저장된 데이터까지 영향을 줄 수 있기 때문입니다. 자세한 내용은 이전 글에 링크가 있으니 참고하시길 바랍니다.
DB에 생성될 Member 테이블은 이전에 설계한 ERD에 작성되어 있습니다. 이를 기반으로 Member 엔티티를 생성하겠습니다.
Domain 패키지를 아래와 같이 설계하였습니다. 생성 시 참고하시길 바랍니다.
@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 사용을 지양하지만, MemberImage
와 Role
은 Setter 사용이유가 명확하게 보여지기 때문에 사용하였습니다.
Builder 패턴을 별도의 생성자를 만들어 적용한 이유는 MemberImage
때문인데, 이미지 처리를 한 후에 별도로 저장해야하기 때문에 제외하였습니다.
Role은 List 내부에 Enum타입을 담는 형태로 저장하는데, 처음 엔티티 생성시에는 List가 존재하지 않으므로 생성해줍니다.
아래는 Member
엔티티에 있는 Role
과 MemberImage
에 대한 정의입니다.
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클래스를 만든 후, 이를 참조하는 방식으로 하였습니다.
@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;
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder
public class MemberImage extends Image {
}
Social 로그인은 추후에 설정할 예정이기 때문에 일단은 임시로 Enum클래스만 만들어두겠습니다.
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
입니다.
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequestDTO {
private String userId;
private String password;
public UsernamePasswordAuthenticationToken toAuthentication() {
return new UsernamePasswordAuthenticationToken(userId, password);
}
}
다음으로는 회원가입 및 수정 등에서 사용할 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;
}
Member
와 관련된 DTO를 클라이언트로 응답하거나, 서비스 계층 사이에서 주고받을 때 사용할 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
을 저장하기 위해 토큰 엔티티도 생성합니다.
@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
를 생성합니다.
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TokenDTO {
private String tokenType;
private String accessToken;
private String refreshToken;
private Duration duration;
}
지난 글부터 따라오셨다면 이미 생성되어 있습니다.
사용자가 로그인을 성공하는 등 인증되어 토큰을 받게 된다면 Body에 accessToken을, 헤더에 refreshToken을 넣어 보내줄 예정입니다. 이를 이용할 DTO도 생성합니다.
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TokenResponseDTO {
private String accessToken;
private boolean isNewMember;
}
엔티티와 DTO를 생성했으면 이제 서로 매핑이 되도록 설정해야합니다. 매핑 방식에는 여러가지가 있지만 여기에서는 MapStruct라는 Mapper 라이브러리를 사용하도록 하겠습니다.
//추가
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를 생성합니다.
@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라고 애트리뷰트를 정의하였습니다.
MemberRepository를 생성합니다. JpaRepository를 상속받습니다.
@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);
}
이미지 역시 엔티티 생성 때와 마찬가지로 부모, 자식 관계의 레포지토리를 생성하겠습니다.
public interface ImageRepository extends JpaRepository<Image, Long> {}
public interface MemberImageRepository extends ImageRepository{}