궤도
[Spring] [면적면적[3]] JPA 도메인 설계 본문
북적북적의 백엔드를 클론하고 있다.
build.gradle
plugins {
id 'org.springframework.boot' version '2.5.2'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'jpa'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
all {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-log4j2'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.projectlombok:lombok:1.18.18'
runtimeOnly 'mysql:mysql-connector-java'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
test {
useJUnitPlatform()
}
(현 시점에서 유저 API까지 구현한 상태인지라 dependencies가 많다.)
src/main/resources/application.yml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/db_name?serverTimezone=UTC&characterEncoding=UTF-8
username: root
password: pwd
jpa:
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
open-in-view: false
show-sql: true
hibernate:
ddl-auto: none
properties:
hibernate.format_sql: true
할 일
지난번에 그린 ER diagram을 기반으로 도메인을 설게할 것이다.
왜 '기반'이냐면 수정사항이 많기 때문이다. 그니까 저 글은 그냥...아 저런식으로 구상을 했구나~ 정도로만 보세요
현재는 위와 같다.
초기설정
난 MySQL을 사용한다. 이건 뭐 여기저기 찾아보면 정보가 많고
위 게시물은 내가 한 삽질의 기록이다.
아무튼 현재 나의 설정은 위에 있는 build.gradle과 application.yml을 참고하면 된다.
User domain
user 테이블은 이렇게 생겼다. 그리고 book, user_character 테이블과 1:N 관계에 있다.
당연히 fk는 N쪽인 book, user_character 테이블에 있다.
src/main/java/jpa/myunjuk/module/model/domain/User.java
package jpa.myunjuk.module.model.domain;
import com.sun.istack.NotNull;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class User implements UserDetails {
@Id
@Column(name = "user_sn")
@GeneratedValue
private Long id;
@Column(unique = true)
@NotNull
private String email;
@Column(length = 300)
@NotNull
private String password;
@NotNull
private String nickname;
@Column(name = "user_img")
private String img;
@Builder.Default
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<Book> books = new ArrayList<>();
@Builder.Default
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<UserCharacter> userCharacters = new ArrayList<>();
@Setter
private String refreshTokenValue;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
jwt 인증을 위해 UserDetails 인터페이스를 상속해서 이것저것 많다.
Setter를 최대한 사용하지 않고 Builder를 사용하기로 했다.
Builder를 사용하려면 NoArgsConstructor와 AllArgsConstructor가 필요하다네요.
근데 NoArgsConstructor를 그대로 두면 객체 생성을 맘대로 할 수 있으니 AccessLevel을 Protected로 한다.
@Id
@Column(name = "user_sn")
@GeneratedValue
private Long id;
@Column(unique = true)
@NotNull
private String email;
@Column(length = 300)
@NotNull
private String password;
@NotNull
private String nickname;
@Column(name = "user_img")
private String img;
ER diagram에서 등장한 부분만 자르면 이렇게 된다.
API 명세서를 작성하면서 생각해보니 user table이 book table과 user_character 테이블을 참조할 일이 많을 것 같았다.
@Builder.Default
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<UserCharacter> userCharacters = new ArrayList<>();
@Builder.Default
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<Book> books = new ArrayList<>();
그래서 양방향 매핑을 해주었다. Builder를 쓸 때 이렇게 컬렉션을 사용하는 곳 위에 Builder.Default를 안넣어주면 초기화에 문제가 생겨서 NullPointerException이 뜬다. 사실 확실히는 모르겠는데 검색해보니까 그렇다더라
Book domain
book 테이블은 이렇게 생겼다. 그리고 user 테이블과는 N:1 관계, memo 테이블과는 1:N 관계에 있다.
book의 상태는 다 읽음 / 읽는 중 / 읽고 싶음으로 나눌 수 있는데, 처음엔 이 부분을 single table 전략의 상속으로 구현하려고 했다.
그런데 책의 상태는 바뀔 수 있으므로 이렇게 구현하면 책의 상태를 바꿀때마다 원래 있던 객체를 복사하고 삭제해서 새로운 엔티티에...아무튼 엄청 복잡하고 검색해보니 그러지 말라고 하더라
(native sql로 처리할 수 있는데 권장하지 않는 방법이라고 한다.)
그래서 뭐 엄청 크게 다른 것도 아니고 enum으로 처리하는게 맞다 싶었다. validation은 dto로 하면 되니까...
이런 의문이 들 수 있다.
Q. 사용자1, 사용자2가 모두 해리포터를 보유하고 있다면 book 테이블에 중복 저장되는거 아닌가? 그냥 책 정보를 담은 테이블을 따로 만들어서 N:N으로 묶으면 안되나?
나도 처음엔 그렇게 생각했는데, 앱을 보니까 저장한 책에 대해서 사용자가 책 정보를 수정할 수 있었다.
그니까 저장할 땐 같은 해리포터였는데 사용자1은 페이지 수를 300으로 수정하고 사용자2는 페이지 수를 500으로 수정할 수 있다는 것이다. 그래서...다른 책 취급하기로 했다.
src/main/java/jpa/myunjuk/module/model/domain/BookStatus.java
package jpa.myunjuk.module.model.domain;
public enum BookStatus {
DONE, READING, WISH
}
그래서 enum으로 3개의 상태를 나타내고
src/main/java/jpa/myunjuk/module/model/domain/Book.java
package jpa.myunjuk.module.model.domain;
import com.sun.istack.NotNull;
import lombok.*;
import javax.persistence.*;
import java.time.LocalDate;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Book {
@Id
@Column(name = "book_sn")
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_sn")
@NotNull
private User user;
@NotNull
private String title;
private String thumbnail;
private String author;
private String publisher;
@Lob
private String description;
@NotNull
private Integer isbn;
private Integer totPage;
@Enumerated(EnumType.STRING)
private BookStatus bookStatus;
private LocalDate startDate;
private LocalDate endDate;
private Integer score;
private Integer readPage;
@Lob
private String expectation;
}
description이나 expectation 같은 글은 varchar(255)를 넘어갈테니 @Lob을 붙여준다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_sn")
@NotNull
private User user;
user 테이블과의 관계를 설정한다. fetchType은 기본적으로 EAGER인데 성능 이슈때문에 웬만하면 지연로딩인 LAZY를 사용해야 한다고 한다.
대충 조인하면 관련 테이블을 전부 조인해서 데이터를 다 끌어오는 애랑 요청할 때 끌어오는 애와의 차이인 듯하다.
Memo domain
memo 테이블은 이렇게 생겼다. book 테이블과 N:1 관계에 있다.
다른 테이블의 날짜 정보는 다 date인데 얘만 datetime인 이유는 시간분초도 저장하길래 그렇다.
src/main/java/jpa/myunjuk/module/model/domain/Memo.java
package jpa.myunjuk.module.model.domain;
import com.sun.istack.NotNull;
import lombok.*;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Memo {
@Id
@Column(name = "memo_sn")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_sn")
@NotNull
private Book book;
@Lob
@NotNull
private String content;
@NotNull
private LocalDateTime saved;
}
딱히 설명할건 없는듯 하다.
Characters domain
characters 테이블은 이렇게 생겼다. 왜 character로 하지 않았냐면 예약어에 걸려서 그렇다.
user_character 테이블과 1:N 관계에 있다.
src/main/java/jpa/myunjuk/module/model/domain/Characters.java
package jpa.myunjuk.module.model.domain;
import com.sun.istack.NotNull;
import lombok.*;
import javax.persistence.*;
import java.time.LocalDate;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Characters {
@Id
@Column(name = "character_sn")
@GeneratedValue
private Long id;
@Column(name = "character_name")
@NotNull
private String name;
@Column(name = "character_img")
@NotNull
private String img;
private Integer height;
@NotNull
private LocalDate birthday;
@NotNull
private String shortDescription;
@Lob
@NotNull
private String longDescription;
}
이 테이블의 가장 큰 특징은...유저의 비즈니스 로직상 유저가 이 테이블에 관여할 수 있는 방법이 없다는 것이다.
그니까 캐릭터 정보를 미리 다 만들어서 데베에 넣어줘야 하는데 겨우 8개만 넣었는데 귀찮아 죽는줄 알았다.
여기서 이미지 휘뚜루마뚜루 받아서
대충 이런식으로 sql문 날려서 저장했다. 글은 한글입숨 여기서 생성해왔다.
UserCharacter domain
user_character 테이블이다. 얼핏보면 그냥 user와 characters 테이블을 N:N으로 엮어주는 mapping table 같지만
획득 날짜와 대표 캐릭터 여부도 있고, 암튼 N:N을 1:N - N:1으로 풀어놓은 것이다.
src/main/java/jpa/myunjuk/module/model/domain/UserCharacter.java
package jpa.myunjuk.module.model.domain;
import com.sun.istack.NotNull;
import jpa.myunjuk.infra.converter.BooleanToYNConverter;
import lombok.*;
import org.springframework.util.Assert;
import javax.persistence.*;
import java.time.LocalDate;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class UserCharacter {
@Id
@Column(name = "uc_sn")
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_sn")
@NotNull
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_sn")
@NotNull
private Characters characters;
@NotNull
private LocalDate achieve;
@Convert(converter = BooleanToYNConverter.class)
@NotNull
private boolean representation;
}
그냥저냥 평범하게 생겼다.
@Convert(converter = BooleanToYNConverter.class)
@NotNull
private boolean representation;
다만 이게 좀 특이해보일 수 있는데, boolean 값을 데베에 Y/N로 저장해주는 것이다.
src/main/java/jpa/myunjuk/infra/converter/BooleanToYNConverter.java
package jpa.myunjuk.infra.converter;
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
@Converter
public class BooleanToYNConverter implements AttributeConverter<Boolean, String> {
@Override
public String convertToDatabaseColumn(Boolean attribute) {
return (attribute != null && attribute) ? "Y" : "N";
}
@Override
public Boolean convertToEntityAttribute(String dbData) {
return "Y".equals(dbData);
}
}
굳이 안해도 되는데 그냥 한번 써보고 싶어서 넣었다.
실행 후 workbench로 테이블 생성을 확인해보니 잘 들어가 있다.