궤도

[Spring] [면적면적[3]] JPA 도메인 설계 본문

💻 현생/📕 면적면적(스프링 실습)

[Spring] [면적면적[3]] JPA 도메인 설계

영이오 2021. 7. 22. 17:00

북적북적의 백엔드를 클론하고 있다.

 

Github

 

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을 사용한다. 이건 뭐 여기저기 찾아보면 정보가 많고

JPA+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로 테이블 생성을 확인해보니 잘 들어가 있다.

Comments