DB설계 시부터 테이블명을 단수로 할 것인지 복수로 할 것인지 고민하다가 통일성 있게 한다면 문제가 되지 않는다는 글을 보고 저는 단수로 만들기로 하였습니다. (관련 내용은 여기를 눌러주세요.)
의존성 주입과 DB 설계했던 것과 동일하게 엔티티 클래스를 만들어 줍니다.
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3307/board
username: jisu
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
mysql 기본 포트는 3306인데 저는 mariaDB가 3306포트를 사용중이여서 3307로 설정해주었습니다.
UserAccount
package com.jisu.projectboard.domain;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.persistence.*;
import java.util.Objects;
@Getter
@ToString
@Table(indexes = {
@Index(columnList = "userId"),
@Index(columnList = "email", unique = true),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy")
})
@Entity
public class UserAccount extends AuditingFields {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter
@Column(nullable = false, length = 50) private String userId;
@Setter
@Column(nullable = false) private String userPassword;
@Setter
@Column(length = 100) private String email;
@Setter
@Column(length = 100) private String nickname;
@Setter
private String memo;
protected UserAccount() {}
private UserAccount(String userId, String userPassword, String email, String nickname, String memo) {
this.userId = userId;
this.userPassword = userPassword;
this.email = email;
this.nickname = nickname;
this.memo = memo;
}
public static UserAccount of(String userId, String userPassword, String email, String nickname, String memo) {
return new UserAccount(userId, userPassword, email, nickname, memo);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
UserAccount that = (UserAccount) o;
return getId() != null && getId().equals(that.getId());
}
@Override
public int hashCode() {
return Objects.hash(getId());
}
}
Article
package com.jisu.projectboard.domain;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.persistence.*;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
@Getter
@ToString
@Table(indexes = {
@Index(columnList = "title"),
@Index(columnList = "hashtag"),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy")
})
@Entity
public class Article extends AuditingFields{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter
@ManyToOne(optional = false)
private UserAccount userAccount; // 유저 정보 (ID)
@Setter
@Column(nullable = false)
private String title; // 제목
@Setter
@Column(nullable = false, length = 10000)
private String content; // 내용
@Setter
private String hashtag; // 해시태그
@OrderBy("createdAt DESC")
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL)
@ToString.Exclude
private final Set<ArticleComment> articleComments = new LinkedHashSet<>();
protected Article() {}
private Article(UserAccount userAccount, String title, String content, String hashtag) {
this.userAccount = userAccount;
this.title = title;
this.content = content;
this.hashtag = hashtag;
}
public static Article of(UserAccount userAccount, String title, String content, String hashtag) {
return new Article(userAccount, title, content, hashtag);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Article article = (Article) o;
return getId() != null && getId().equals(article.getId());
}
@Override
public int hashCode() {
return Objects.hash(getId());
}
}
jpa 하이버네이트를 사용 시에는 기본 생성자가 있어야 합니다.
기본적으로 자바 클래스안에 생성자를 적어주지 않으면 클래스와 동일한 접근 제한자로 compiler가 기본 생성자를 생성합니다.
하이버네이트는 protected와 public 생성자를 지원하는데 다른 클래스에서 new 로 무분별한 객체 생성을 막기 위해 protected로 하였습니다.
그리고 private 생성자와 정적 팩토리 메서드를 사용하여 Article 객체를 리턴해줍니다.
엔티티는 해쉬코드와 이퀄스를 재 정의해줘야 합니다.
기본적으로 equals()는 모든것(Article의 모든 필드)을 같다고를 비교했을 때 같을 때 true가 나옵니다.
예를 들어 게시글(Article)을 리스트에 담아서 게시글들을 가져와 게시판 화면을 구성할 때 게시글들을 리스트에 넣거나 리스트에 있는 중복요소를 제거하거나 정렬을 할 때 동등성 동일성 검사를 해야 합니다.
동등성 검사를 하기 위해 모든 필드를 검사할 필요가 없습니다.
왜냐하면 db와 연동된 엔티티가 두 개가 있는데 두개가 같다는 것을 뭐로 증명할 수 있을까요?
당연히 우리 프로젝트에서는 프라이머리 키인 id 값이 같으면 두 엔티티가 같다고 할 수 있습니다.
1번 아이디의 엔티티와 1번 아이디의 엔티티는 당연히 그 내용도 똑같을 것입니다.
그리고 아직 db에 들어가지 않은 id면 null이겠죠 그래서 getId()!= null을 넣어줘서 NPE를 막아주고 동등성을 탈락하게 해 줍니다.
동일성 검사는 아이디만 가지고 해싱을 하면 됩니다.
@ToString. Exclude = toString에서 결과를 제외시킵니다. 제외시키지 않으면 무한 반복하다가 서버가 뻗습니다.
ArticleComment
package com.jisu.projectboard.domain;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.persistence.*;
import java.util.Objects;
@Getter
@ToString
@Table(indexes = {
@Index(columnList = "content"),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy")
})
@Entity
public class ArticleComment extends AuditingFields{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter
@ManyToOne(optional = false)
private Article article; // 게시글 (ID)
@Setter
@ManyToOne(optional = false)
private UserAccount userAccount; // 유저 정보 (ID)
@Setter @Column(nullable = false, length = 500)
private String content; // 댓글 내용
protected ArticleComment() {}
private ArticleComment(Article article, UserAccount userAccount,String content) {
this.article = article;
this.userAccount = userAccount;
this.content = content;
}
public static ArticleComment of(Article article, UserAccount userAccount, String content) {
return new ArticleComment(article, userAccount, content);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ArticleComment that = (ArticleComment) o;
return getId() != null && getId().equals(that.getId());
}
@Override
public int hashCode() {
return Objects.hash(getId());
}
}
JPA를 쓴다면 객체 지향적으로 연관관계를 맺는 것에 집중해야 됩니다.
그래서 게시글의 댓글이라면 댓글 테이블에는 게시글의 id가 있어야 합니다.
게시글과 댓글이 있습니다. 댓글은 자신이 속해있는 게시글이 있습니다. 하지만 게시글은 댓글을 여러 개 가지고있을 수도있고 하나만 가지고 있을수도 있겠죠. 게시글 쪽에서 댓글을 바라본다면 @OneToMany 관계지만
댓글이 게시글을 바라본다면 @ManyToOne 이 되는 겁니다.
그러므로 게시글의 id를 articleId가 아닌 Article 객체로 @ManyToOne 연관관계를 맺어줍니다.
AuditingFields
package com.jisu.projectboard.domain;
import lombok.Getter;
import lombok.ToString;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import org.springframework.format.annotation.DateTimeFormat;
import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;
@Getter
@ToString
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public class AuditingFields {
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt; // 생성일시
@CreatedBy
@Column(nullable = false, length = 100, updatable = false)
private String createdBy; // 생성자
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime modifiedAt; // 수정일시
@LastModifiedBy
@Column(nullable = false, length = 100)
private String modifiedBy; // 수정자
}
JpaConfig
package com.jisu.projectboard.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import java.util.Optional;
@EnableJpaAuditing
@Configuration
public class JpaConfig {
@Bean
public AuditorAware<String> auditorAware() {
return () -> Optional.of("jisu"); //스프링 시큐리티로 인증 기능을 구현할 때, 수정해야함.
}
}
Ariticle과 AriticleComment를 보시면 AuditingFields를 상속받는 구조입니다.
AuditingFields를 보면 생성 일시, 생성자, 수정 일시, 수정자 이렇게 4개의 필드가 있습니다. 이런 것들은 게시글과 댓글에 모두 적용하려고 합니다. 그러면 Ariticle과 AriticleComment의 필드로 써줄 수도 있지만 만약 엔티티가 늘어난다면 개발자는 계속 적으면 안 됩니다.
@MappedSuperclass 어노테이션을 달고 공통 매핑 정보라고 알려줍시다.
그리고 이런 반복을 줄이기 위해 Spring JPA에서 제공하는 @EnableJpaAuditing(AuditingEntityListener.class)를 추가하여 줍니다.
그리고 Auditing에서 데이터가 생성될 때 업데이트해주길 바라는 변수에 @CreatedDate,@LastModifiedDate,@CreatedBy, @LastModifiedBy를 써줍니다.
JpaConfig는 createby, 와 lastmodifiedby에 일단 인증 전이기 때문에 임의로 jisu로 넣어줍니다.
이렇게 일단 도메인 설계를 하였습니다. 이제 DB에 접근할수있는 Repository 와 테스트 코드를 작성해보도록하겠습니다.
ArticleRepository
public interface ArticleRepository extends JpaRepository<Article, Long> {
}
ArticleCommentRepository
public interface ArticleCommentRepository extends JpaRepository<ArticleComment, Long> {
}
UserAccountRepository
public interface UserAccountRepository extends JpaRepository<UserAccount, Long> {
}
JpaRepository 인터페이스를 상속받아 객체와, id값을 넣어줍니다.
이러면 JPA에서 제공하는 키워드와 쿼리를 제공해줘 기본적인 CRUD는 따로 적어주지 않아도 됩니다.
JpaRepositoryTest
package com.jisu.projectboard.repository;
import com.jisu.projectboard.config.JpaConfig;
import com.jisu.projectboard.domain.Article;
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.data.jdbc.DataJdbcTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
@DisplayName("JPA 연결 테스트")
@Import(JpaConfig.class)
@DataJpaTest
class JpaRepositoryTest {
private final ArticleRepository articleRepository;
private final ArticleCommentRepository articleCommentRepository;
private final UserAccountRepository userAccountRepository;
public JpaRepositoryTest(
@Autowired ArticleRepository articleRepository,
@Autowired ArticleCommentRepository articleCommentRepository,
@Autowired UserAccountRepository userAccountRepository
) {
this.articleRepository = articleRepository;
this.articleCommentRepository = articleCommentRepository;
this.userAccountRepository = userAccountRepository;
}
@DisplayName("select 테스트")
@Test
void givenTestData_whenSelecting_thenWorksFine(){
//given
//when
List<Article> articles = articleRepository.findAll();
//then
assertThat(articles).isNotNull().hasSize(5);
}
@DisplayName("insert 테스트")
@Test
void givenTestData_whenInserting_thenWorksFine(){
//given
long count = articleRepository.count();
UserAccount userAccount = userAccountRepository.save(UserAccount.of("jisu", "pw", null, null, null));
Article article = Article.of(userAccount, "new article", "new content", "#spring");
//when
articleRepository.save(article);
//then
assertThat(articleRepository.count()).isEqualTo(count+1);
}
@DisplayName("update 테스트")
@Test
void givenTestData_whenUpdating_thenWorksFine(){
//given
Article findArticle = articleRepository.findById(1L).orElseThrow();
String updatedHashtag = "#springboot";
findArticle.setHashtag(updatedHashtag);
//when
Article savedArticle= articleRepository.saveAndFlush(findArticle);
//update쿼리 보기위해 flush해줌
//then
assertThat(findArticle).hasFieldOrPropertyWithValue("hashtag", updatedHashtag);
}
@DisplayName("delete 테스트")
@Test
void givenTestData_whenDeleting_thenWorksFine(){
//given
Article findArticle = articleRepository.findById(1L).orElseThrow();
long articleCount = articleRepository.count();
long articleCommentCount = articleCommentRepository.count();
long deletedCommentSize = findArticle.getArticleComments().size();
//when
articleRepository.delete(findArticle);
//then
assertThat(articleRepository.count()).isEqualTo(articleCount -1);
assertThat(articleCommentRepository.count()).isEqualTo(articleCommentCount -deletedCommentSize);
}
}
@DataJpaTest는 JPA관련 테스트 설정만 로드하기 때문에 다른 설정들 없이 DataSource의 설정이 정상인지. JPA를 사용하여 CRUD가 되는지 테스트가 가능합니다. 그리고 가장 좋은점은 내장 DB를 사용하여 실제 디비를 사용하지 않고 테스트하여 데이터베이스를 테스트할수있는 좋은 친구입니다..
![[Project] 도메인 설계 및 DB 접근 로직 - JpaRepositoryTest [Project] 도메인 설계 및 DB 접근 로직 - JpaRepositoryTest](https://blog.kakaocdn.net/dn/b9gVzC/btrGQotj9ug/u2qWWyhb69WKkdUpnXZZck/img.png)
테스트를 해보면 위와 같이 통과 되는 모습입니다.
감사합니다 :)
'Project > project-board' 카테고리의 다른 글
[Project] DTO 설계 (0) | 2022.07.14 |
---|---|
[Project] QueryDSL 적용 (0) | 2022.07.11 |
[Project] API 설계 (0) | 2022.07.11 |
[Project] 깃허브 이슈 정리 및 브랜치 전략 (0) | 2022.07.07 |
[Project] 프로젝트 명세서 (0) | 2022.07.06 |
댓글