들어가기에 앞서
실제 프로젝트 진행했던 코드 내용 및 세부 내용은 일부만 업로드하였습니다.
@Getter
//@Setter : 클래스 전체적으로 걸면 안됨 -> (id) 등을 setter로 변경해버릴 수 있음
//물론 case by case로 전체적으로 거는 경우도 있음
@ToString
// 본문을 index로 하기엔 너무 큼 (MySQL 자체 기능이나 ElasticSearch 등의 검색 엔진 사용)
@Table(indexes = {
@Index(columnList = "content"),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy")
})
@Entity
public class ArticleComment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // auto_increment
private Long id;
@Setter @ManyToOne(optional = false) private Article article;
// 게시글 (ID) ManyToOne : 상관관계, optional = false : 해당 필드가 필수로 있어야 함 (casecading = none이 기본)
@Setter @Column(nullable = false, length = 500) private String content; // 본문
// meta data
@CreatedDate @Column(nullable = false) private LocalDateTime createdAt; // 생성일시
@CreatedBy @Column(nullable = false, length = 100) private String createdBy; // 생성자
@LastModifiedDate @Column(nullable = false) private LocalDateTime modifiedAt; // 수정일시
@LastModifiedBy @Column(nullable = false, length = 100) private String modifiedBy; // 수정자
protected ArticleComment() {}
public ArticleComment(Article article, String content) {
this.article = article;
this.content = content;
} // @NoArgsConstructor로 대체 가능
public static ArticleComment of(Article article, String content) {
return new ArticleComment(article, content);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ArticleComment that)) return false;
return id != null && id.equals(that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
services > +(Add service) > Run Configuration > Spring Boot
Record
타입intention
기능prefererences > intention 검색 > multiple lines
체크 되어 있는지 확인one to many
, ArticleComment : many to one
) @OrderBy("id") // id 기준 정렬
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL)
@ToString.Exclude // 위의 ToString alt+enter로 ToString.Exclude 활성화
// 실무에서는 양방향 바인딩을 푸는 경우가 많음 (운영상 이슈 등)
private final Set<ArticleComment> articleComments = new LinkedHashSet<>();
@ToString
alt+enter
로 ToString.Exclude
활성화 (순환참조 문제 등 해결)EditConfiguration > Modify options > 'optimization' 검색 시 Disable launch optimization > 활성화 > Apply
new > Spring Data Repository
(JPA Buddy 기능)import com.fastcampus.projectboard.domain.ArticleComment;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ArticleCommentRepository extends JpaRepository<ArticleComment, Long> {
}
Ctrl + shift + t
) private final ArticleRepository articleRepository;
private final ArticleCommentRepository articleCommentRepository;
public JpaRepositoryTest(@Autowired ArticleRepository articleRepository,
@Autowired ArticleCommentRepository articleCommentRepository
) {
this.articleRepository = articleRepository;
this.articleCommentRepository = articleCommentRepository;
}
@DisplayName("select 테스트")
@Test
void givenTestData_whenSelecting_thenWorksFine() {
// Given
// When
List<Article> articles = articleRepository.findAll();
// Then
assertThat(articles)
.isNotNull()
.hasSize(1000);
}
@DisplayName("insert 테스트")
@Test
void givenTestData_whenInserting_thenWorksFine() {
// Given
long previousCount = articleRepository.count(); // 현재 repository의 aritcle 개수 카운트
// When
Article savedArticle = articleRepository.save(Article.of("new article", "new content", "#spring"));
// repository에 새 article 추가
// Then
assertThat(articleRepository.count()).isEqualTo(previousCount + 1);
}
@EntityListeners(AuditingEntityListener.class)
추가@EntityListeners(AuditingEntityListener.class)
@Entity
public class ArticleComment {
@DisplayName("update 테스트")
@Test
void givenTestData_whenUpdating_thenWorksFine() {
// Given
Article article = articleRepository.findById(1L).orElseThrow();
// 현재 repository 내부에서 id가 1l인 article 불러오기, 없으면 exception throw
String updatedHashTag = "#springboot";
// 업데이트할 해시태그
article.setHashtag(updatedHashTag);
// 해시태그 수정
// When
Article savedArticle = articleRepository.saveAndFlush(article);
// save로만 할 경우 update 쿼리가 관측이 안됨
// Then
assertThat(savedArticle).hasFieldOrPropertyWithValue("hashtag", updatedHashTag);
}
@DisplayName("delete 테스트")
@Test
void givenTestData_whenDeleting_thenWorksFine() {
// Given
Article article = articleRepository.findById(1L).orElseThrow();
// 현재 repository 내부에서 id가 1l인 article 불러오기, 없으면 exception throw
long previousArticleCount = articleRepository.count();
long previousArticleCommentCount = articleCommentRepository.count();
// 연관관계가 지정되어 있어 article 삭제 시 articlecomment도 같이 지워지므로 둘 다 개수 카운트
int deletedCommentsSize = article.getArticleComments().size();
// When
articleRepository.delete(article);
// Then
assertThat(articleRepository.count()).isEqualTo(previousArticleCount - 1);
assertThat(articleCommentRepository.count()).isEqualTo(previousArticleCommentCount - deletedCommentsSize);
}
Embedded
방식MappedSuperClass
MappedSuperClass
사용@EntityListeners(AuditingEntityListener.class)
를 Article, ArticleComment에서 빼서 MappedSuperClass
에 사용@DateTimeFormat
updatable = false
로 수정이 되면 안되는 필드를 수정 불가능하게 변경 @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
@CreatedDate
@Column(nullable = false, updatable = false) private LocalDateTime createdAt; // 생성일시
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
//@Setter : 클래스 전체적으로 걸면 안됨 -> (id) 등을 setter로 변경해버릴 수 있음
//물론 case by case로 전체적으로 거는 경우도 있음
@ToString // alt+enter로 ToString.Exclude 활성화
// 본문을 index로 하기엔 너무 큼 (MySQL 자체 기능이나 ElasticSearch 등의 검색 엔진 사용)
@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) // auto_increment
private Long id;
@Setter @Column(nullable = false) private String title; // 제목
@Setter @Column(nullable = false, length = 10000) private String content; // 본문
@Setter private String hashtag; // 해시태그
// @Transient 언급이 없는 이상 @Column 적용된 것으로 인식 (nullable = true)
@OrderBy("id") // id 기준 정렬
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL)
@ToString.Exclude // 위의 ToString alt+enter로 ToString.Exclude 활성화
// 실무에서는 양방향 바인딩을 푸는 경우가 많음 (운영상 이슈 등)
private final Set<ArticleComment> articleComments = new LinkedHashSet<>();
/* AuditingFields로 이전됨
// meta data
// JPA Auditing
@CreatedDate @Column(nullable = false) private LocalDateTime createdAt; // 생성일시
@CreatedBy @Column(nullable = false, length = 100) private String createdBy; // 생성자
@LastModifiedDate @Column(nullable = false) private LocalDateTime modifiedAt; // 수정일시
@LastModifiedBy @Column(nullable = false, length = 100) private String modifiedBy; // 수정자
/* public void setArticleComments(Set<ArticleComment> articleComments) {
this.articleComments = articleComments;
} */ // 자동으로 생성된 것으로 삭제
// Entity로서의 기본 기능 추가
// Hibernate 구현체 기준으로 기본 생성자가 필요 (외부에서는 사용하지 못하도록 해야 함)
protected Article() {} // private는 안됨
private Article(String title, String content, String hashtag) {
this.title = title;
this.content = content;
this.hashtag = hashtag;
}
public static Article of(String title, String content, String hashtag) {
return new Article(title, content, hashtag);
}
// 도메인 Article을 생성하려면 위의 매개값이 필요하다는 가이드
// list, collection 에 넣고 사용할 때 동일성 / 동등성 확인 필요
// Lombok의 @EqualsAndHashCode 사용 x, 사용하게 되면 모든 필드가 동일해야 동일한 객체라고 판단하게 되는데 그럴 필요 없음
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Article article)) return false;
return id != null && id.equals(article.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.persistence.*;
import java.util.Objects;
@Getter
//@Setter : 클래스 전체적으로 걸면 안됨 -> (id) 등을 setter로 변경해버릴 수 있음
//물론 case by case로 전체적으로 거는 경우도 있음
@ToString
// 본문을 index로 하기엔 너무 큼 (MySQL 자체 기능이나 ElasticSearch 등의 검색 엔진 사용)
@Table(indexes = {
@Index(columnList = "content"),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy")
})
@Entity
public class ArticleComment extends AuditingFields {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // auto_increment
private Long id;
@Setter @ManyToOne(optional = false) private Article article;
// 게시글 (ID) ManyToOne : 상관관계, optional = false : 해당 필드가 필수로 있어야 함 (casecading = none이 기본)
@Setter @Column(nullable = false, length = 500) private String content; // 본문
/* AuditingFields로 이전됨
// meta data
@CreatedDate @Column(nullable = false) private LocalDateTime createdAt; // 생성일시
@CreatedBy @Column(nullable = false, length = 100) private String createdBy; // 생성자
@LastModifiedDate @Column(nullable = false) private LocalDateTime modifiedAt; // 수정일시
@LastModifiedBy @Column(nullable = false, length = 100) private String modifiedBy; // 수정자
*/
protected ArticleComment() {}
public ArticleComment(Article article, String content) {
this.article = article;
this.content = content;
} // @NoArgsConstructor로 대체 가능
public static ArticleComment of(Article article, String content) {
return new ArticleComment(article, content);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ArticleComment that)) return false;
return id != null && id.equals(that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
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; // 수정자
}
Spring initializr > Rest Repository, Rest Repository HAL Explorer
application.yml
수정 data.rest:
base-path: /api
detection-strategy: annotated
ANNOTATED
: 어노테이션으로 명시한 Repository만 노출@RepositoryRestResource
추가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.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
// @WebMvcTest
@DisplayName("Data REST - API 테스트") // 전체 테스트
@Transactional // spring Transactional
@AutoConfigureMockMvc
@SpringBootTest
public class DataRestTest {
private final MockMvc mvc;
public DataRestTest(@Autowired MockMvc mvc) {
this.mvc = mvc;
}
@DisplayName("[API] 게시글 리스트 조회")
@Test
void givenNothing_whenRequestingArticles_thenReturnsArticleJsonResponse() throws Exception {
// Given
// When & Then
mvc.perform(get("/api/articles")) // get 이후 ctrl+shift+space 두번, 이후 static 메소드로 호출
.andExpect(status().isOk()) // status 이후 ctrl+shift 두번, MockMvc... status
.andExpect(content().contentType(MediaType.valueOf("application/hal+json")));// MediaType 중 hal+json이 없어 직접 valueOf로 호출
}
@DisplayName("[API] 게시글 단건 조회")
@Test
void givenNothing_whenRequestingArticle_thenReturnsArticleJsonResponse() throws Exception {
// Given
// When & Then
mvc.perform(get("/api/articles/1")) // get 이후 ctrl+shift+space 두번, 이후 static 메소드로 호출
.andExpect(status().isOk()) // status 이후 ctrl+shift 두번, MockMvc... status
.andExpect(content().contentType(MediaType.valueOf("application/hal+json")));// MediaType 중 hal+json이 없어 직접 valueOf로 호출
}
@DisplayName("[API] 게시글 -> 댓글 리스트 조회")
@Test
void givenNothing_whenRequestingArticleCommentsFromArticle_thenReturnsArticleCommentsJsonResponse() throws Exception {
// Given
// When & Then
mvc.perform(get("/api/articles/1/articleComments")) // get 이후 ctrl+shift+space 두번, 이후 static 메소드로 호출
.andExpect(status().isOk()) // status 이후 ctrl+shift 두번, MockMvc... status
.andExpect(content().contentType(MediaType.valueOf("application/hal+json")));// MediaType 중 hal+json이 없어 직접 valueOf로 호출
}
@DisplayName("[API] 댓글 리스트 조회")
@Test
void givenNothing_whenRequestingArticleComments_thenReturnsArticleCommentsJsonResponse() throws Exception {
// Given
// When & Then
mvc.perform(get("/api/articleComments")) // get 이후 ctrl+shift+space 두번, 이후 static 메소드로 호출
.andExpect(status().isOk()) // status 이후 ctrl+shift 두번, MockMvc... status
.andExpect(content().contentType(MediaType.valueOf("application/hal+json")));// MediaType 중 hal+json이 없어 직접 valueOf로 호출
}
@DisplayName("[API] 댓글 단건 조회")
@Test
void givenNothing_whenRequestingArticleComment_thenReturnsArticleCommentJsonResponse() throws Exception {
// Given
// When & Then
mvc.perform(get("/api/articleComments/1")) // get 이후 ctrl+shift+space 두번, 이후 static 메소드로 호출
.andExpect(status().isOk()) // status 이후 ctrl+shift 두번, MockMvc... status
.andExpect(content().contentType(MediaType.valueOf("application/hal+json")));// MediaType 중 hal+json이 없어 직접 valueOf로 호출
}
}
@Disabled
로 제외 처리@Disabled("Spring Data REST 통합 테스트는 불필요하므로 제외시킴")
@DisplayName("Data REST - API 테스트") // 전체 테스트
@Transactional // spring Transactional
@AutoConfigureMockMvc
@SpringBootTest
public class DataRestTest {
// queryDSL 설정
implementation "com.querydsl:querydsl-jpa"
implementation "com.querydsl:querydsl-core"
implementation "com.querydsl:querydsl-collections"
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
annotationProcessor "jakarta.annotation:jakarta.annotation-api" // java.lang.NoClassDefFoundError 대응
annotationProcessor "jakarta.persistence:jakarta.persistence-api" // java.lang.NoClassDefFoundError 대응
// Querydsl 설정부
def generated = 'src/main/generated'
// querydsl QClass 파일 생성 위치를 지정
tasks.withType(JavaCompile) {
options.getGeneratedSourceOutputDirectory().set(file(generated))
}
// java source set에 querydsl QClass 위치 추가
sourceSets {
main.java.srcDirs += [ generated ]
}
// gradle clean 시에 QClass 디렉토리 삭제
clean {
delete file(generated)
}
tasks > build > build
진행@RepositoryRestResource
public interface ArticleRepository extends
JpaRepository<Article, Long>,
QuerydslPredicateExecutor<Article> // Article 안의 모든 필드에 대한 기본 검색 기능 추가
//QuerydslBinderCustomizer<QArticle>
{
}
QuerydslBinderCustomizer
를 활성화 시킨 후 Override > customize
재정의import com.fastcampus.projectboard.domain.Article;
import com.fastcampus.projectboard.domain.QArticle;
import com.querydsl.core.types.dsl.DateTimeExpression;
import com.querydsl.core.types.dsl.StringExpression;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.data.querydsl.binding.QuerydslBinderCustomizer;
import org.springframework.data.querydsl.binding.QuerydslBindings;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
@RepositoryRestResource
public interface ArticleRepository extends
JpaRepository<Article, Long>,
QuerydslPredicateExecutor<Article>, // Article 안의 모든 필드에 대한 기본 검색 기능 추가
QuerydslBinderCustomizer<QArticle> { // 커스컴 검색 기능 추가를 위해 활성화
@Override
default void customize(QuerydslBindings bindings, QArticle root) {
bindings.excludeUnlistedProperties(true); // true로 리스팅하지 않은 필드에 대해서는 검색 기능 열지 않게 함
bindings.including(root.title, root.content, root.hashtag, root.createdAt, root.createdBy); // 해당 필드에 대해서는 검색 기능 오픈
// bindings.bind(root.content).first(StringExpression::likeIgnoreCase); // like '${v}'
// 대소문자까지 일치해야 검색되는 부분 해제
bindings.bind(root.title).first(StringExpression::containsIgnoreCase); // like '%${v}%'
bindings.bind(root.content).first(StringExpression::containsIgnoreCase); // like '%${v}%'
bindings.bind(root.hashtag).first(StringExpression::containsIgnoreCase);
bindings.bind(root.createdAt).first(DateTimeExpression::eq);
bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
}
}
gitignore
추가### Querydsl
/src/main/generated
Spring Initializr > Thymeleaf
사용, 의존성 추가import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@RequestMapping("/articles")
@Controller
public class ArticleController {
}
@Disabled("쉿! 구현 중")
@DisplayName("[VIEW][GET] 게시글 리스트 (게시판 페이지 - 정상 호출")
@Test
public void givenNothing_whenRequestingArticlesView_thenReturnsArticlesView() throws Exception {
// Given
// When & Then
mvc.perform(get("/articles"))
.andExpect(status().isOk()) // 정상 호출인지
.andExpect(content().contentType(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지
.andExpect(view().name("articles/index")) // 뷰 이름 검사
.andExpect(model().attributeExists("articles")); // 내부에 값이 있는지 (이름을 articles로 지정)
}
@Disabled("쉿! 구현 중")
@DisplayName("[VIEW][GET] 게시글 상세 페이지 - 정상 호출")
@Test
public void givenNothing_whenRequestingArticleView_thenReturnsArticleView() throws Exception {
// Given
// When & Then
mvc.perform(get("/articles/1"))
.andExpect(status().isOk()) // 정상 호출인지
.andExpect(content().contentType(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지
.andExpect(view().name("articles/detail")) // 뷰 이름 검사
.andExpect(model().attributeExists("article")) // 내부에 값이 있는지 (이름을 articles로 지정)
.andExpect(model().attributeExists("articleComments")); // 댓글 리스트에도 값이 있어야 함
}
@Disabled("쉿! 구현 중")
@DisplayName("[VIEW][GET] 게시글 검색 전용 페이지 - 정상 호출")
@Test
public void givenNothing_whenRequestingArticleSearchView_thenReturnsArticleSearchView() throws Exception {
// Given
// When & Then
mvc.perform(get("/articles/search"))
.andExpect(status().isOk()) // 정상 호출인지
.andExpect(content().contentType(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지
.andExpect(model().attributeExists("articles/search"));
}
@Disabled("쉿! 구현 중")
@DisplayName("[VIEW][GET] 게시글 해시태그 검색 페이지 - 정상 호출")
@Test
public void givenNothing_whenRequestingArticleHashtagSearchView_thenReturnsArticleHashtagSearchView() throws Exception {
// Given
// When & Then
mvc.perform(get("/articles/search-hashtag"))
.andExpect(status().isOk()) // 정상 호출인지
.andExpect(content().contentType(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지
.andExpect(model().attributeExists("articles/search_hashtag"));
}
@Disabled
를 넣어 build 시 무시하게끔 해야 함 @Disabled("쉿! 구현 중")
@DisplayName("[VIEW][GET] 게시글 리스트 (게시판 페이지 - 정상 호출")
@Test
public void givenNothing_whenRequestingArticlesView_thenReturnsArticlesView() throws Exception {
// Given
// When & Then
mvc.perform(get("/articles"))
.andExpect(status().isOk()) // 정상 호출인지
.andExpect(content().contentType(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지
.andExpect(model().attributeExists("articles")); // 내부에 값이 있는지 (이름을 articles로 지정)
}