Jam's story

Spring boot 2.7.0 게시판 만들기 - 도메인, 뷰 본문

Spring

Spring boot 2.7.0 게시판 만들기 - 도메인, 뷰

애플쩀 2022. 11. 19. 16:39

사용 기술

Intellij IDEA Ultimate 2022.1.1 ~ 2022.1.3
Java 17
Gradle 7.4.1
Spring Boot 2.7.0
 

기술 세부 스택

Spring Boot

Spring Boot Actuator
Spring Web
Spring Data JPA
Rest Repositories
Rest Repositories HAL Explorer
Thymeleaf
Spring Security
H2 Database
MySQL Driver
Lombok
Spring Boot DevTools
Spring Configuration Processor

그 외

QueryDSL 5.0.0
Bootstrap 5.2.0-Beta1
Heroku

 

유스케이스

diagrams.net 에서 유즈케이스 그림 완성하여 저장함

 

erd 다이어그램

현재 도메인은 게시글, 댓글, 유저 계정으로 나누어져있다.

게시글과  댓글을 쓴 사람이 누구인지를 판별하기위해 유저 계정의 id값을 가져오기로 했다.

 

 

Article.java

연관관계 매핑

연관관계 매핑이란 객체의 참조와 테이블의 외래키를 매핑하는 것을 의미합니다.

JPA에서는 JDBC( Mybatis)를 사용했을 때와 달리 연관 관계에 있는 상대 테이블의 PK를 멤버변수로 갖지 않고,

엔티티 객체 자체를 통째로 참조합니다.

 

단방향 관계 : 두 엔티티가 관계를 맺을 때, 한 쪽의 엔티티만 참조하고 있는 것을 의미합니다.

양방향 관계 : 두 엔티티가 관계를 맺을 때, 양 쪽이 서로 참조하고 있는 것을 의미합니다.

 

3) 연관 관계의 주인 ( Owner )

연관 관계에서 주인을 결정합니다.-> 외래키를 갖는 테이블이 연관 관계의 주인이 됩니다.

연관관계의 주인만이 외래 키를 관리(등록, 수정, 삭제) 할 수 있고, 반면 주인이 아닌 엔티티는 읽기만 할 수 있습니다.

 

Many To One   - 다대일( N : 1 )

One To Many   - 일대다( 1 : N )

One To One    - 일대일( 1 : 1 )

Many To Many - 다대다( N : N ) 


-userid

@Setter @ManyToOne(optional = false) @JoinColumn(name = "userId") private UserAccount userAccount; // 유저 정보 (ID)

✔️@(optional=false)

not null로 지정하여 엔티티가 무조건 있다는 것을 보장한다.

하이버네이트는 null일 수도 있는 경우를 완벽하게 배제하기 때문에 프록시(lazy)가 동작한다.

 

✔️@JoinColumn

어노테이션은 외래 키를 매핑 할 때 사용합니다.

name 속성에는 매핑 할 외래 키 이름을 지정합니다. 

 

-댓글

댓글들은 게시글에 의해 매핑되기 때문에 mappedBy="article"

@ToString.Exclude
@OrderBy("createdAt DESC")
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL)
private final Set<ArticleComment> articleComments = new LinkedHashSet<>();

 

✔️Cascade =  영속성 전이

자신의 엔티티를 영속시킬 때, 자신과 연관돼있는 엔티티 또한 영속시키는 것

 

 

✔️callsuper=true or false

callSuper 속성을 통해 equals와 hashCode 메소드 자동 생성 시 부모 클래스의 필드까지 감안할지 안 할지에 대해서 설정할 수 있습니다.

즉, callSuper = true로 설정하면 부모 클래스 필드 값들도 동일한지 체크하며, callSuper = false로 설정(기본값)하면 자신 클래스의 필드 값들만 고려합니다.

 

✔️@GeneratedValue(strategy = GenerationType.IDENTITY)

기본 키 생성을 데이터베이스에 위임


 

Repository 클래스 생성

repository 클래스를 따로 생성하는 이유

->jpa 기술을 이용하여 db에 접근하기 위해 ,

JpaRepository<Entity, 기본키타입>을 상속받으면 CRUD가 자동으로 생성됩니다.

예시)

public interface ArticleCommentRepository extends
        JpaRepository<ArticleComment, Long>,

 

jpa test 작성 - JpaRepositoryTest.java

기본 crud + 연관관계 매핑과 cascading 이 잘 동작하는지
보려고 작성.

 

AuditingFields.java

생성자, 생성일시, 수정자, 수정일시는 반복적으로 엔티티 클래스에 들어가는 요소이고,
도메인과 직접 연관이 없는 요소이므로 추출이 가능하다.

 

AuditingFields.java클래스를 만들어 준 후,

article.java와 articleComment.java에 있는 생성자, 생성일시, 수정자, 수정일시필드를 지워주고

extends AuditingFields

package com.fastcampus.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, updatable = false, length = 100)
    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; // 수정자

}

 

✔️@MappedSuperclass

  • 객체의 입장에서 공통 매핑 정보가 필요할 때 사용한다.
  • 공통 매핑 정보가 필요할 때, 부모 클래스 필드에 선언하고 속성만 상속 받아서 사용하고 싶을 때 
  • 즉 상속 방식으로 추출 !

✔️@EntityListeners(AuditingEntityListener.class)

해당 클래스에 Auditing 기능을 포함

 

엔티티를 DB에 저장하기전, 커스텀콜백을 요청할 수 있는 어노테이션

@EntityListeners의 인자로 커스텀 콜백을 요청할 클래스를 지정해주면 되는데 ,

Auditing을 수행할때는 JPA에서 제공하는 AuditingEntityListener.class를 인자로 넘기면 된다

 

 

 

✔️@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)

@DateTimeFormat을 통하여 날짜 출력형식 지정

국제표준형식으로 지정하였다.

 

 

✔️@CreatedDate

 해당 엔티티가 생성될 때, 생성하는 시각을 자동으로 삽입해준다.

 

✔️@CreatedBy

해당 엔티티가 생성될 때, 생성하는 사람이 누구인지 자동으로 삽입해준다.

 

✔️@LastModifiedDate

해당 엔티티가 수정될 때, 수정하는 시각을 자동으로 삽입해준다.

 

✔️@LastModifiedBy

해당 엔티티가 수정될 때, 수정하는 주체가 누구인지 자동으로 삽입해준다.

 

✔️@column(updatable=false)

해당 baseEntity를 jpa가 테이블에 접근하는 시점에만 jpa가 사용하도록 하고싶은데 만약 개발자에 의해 수정되면 안되기 때문에 updatable을 false로 해주는 것을 권장한다.


스프링 부트 2.7 (스프링 시큐리티 5.7) 부터 시큐리티 설정 방법이 바뀌었다. WebSecurityConfigurerAdapter는 deprecated되었고, SecurityFilterChain을 사용해야 함.

 

 


 

게시판 뷰 만들기 -

decoupled template logic

디자인을 진행하다보면 여러가지 구문이 붙어서 index의 코드가 많이 뚱뚱해 질 수 있다.

따라서 thymeleaf 구문을 따로 분리시켜서 작성하고, index를 순수 마크업 상태로 유지시키는 방법이다.

 

bulid.gradle에 추가

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

ThymeleafConfig.java

package com.fastcampus.projectboard.config;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConstructorBinding;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;

@Configuration
public class ThymeleafConfig {

    @Bean
    public SpringResourceTemplateResolver thymeleafTemplateResolver(
            SpringResourceTemplateResolver defaultTemplateResolver,
            Thymeleaf3Properties thymeleaf3Properties
    ) {
        defaultTemplateResolver.setUseDecoupledLogic(thymeleaf3Properties.isDecoupledLogic());

        return defaultTemplateResolver;
    }


    @RequiredArgsConstructor
    @Getter
    @ConstructorBinding
    @ConfigurationProperties("spring.thymeleaf3")
    public static class Thymeleaf3Properties {
        /**
         * Use Thymeleaf 3 Decoupled Logic
         */
        private final boolean decoupledLogic;
    }

}

application.yaml

  thymeleaf3.decoupled-logic: true

 

✔️@Getter,@RequiredArgsConstructor

- 롬복 어노테이션

 

✔️@ConstructorBinding

생성자를 이용해 properties의 값을 바인딩하도록

 

✔️@ConfigurationPropertiesScan

@ConfigurationPropertiesScan 어노테이션은 @ComponentScan과 상당히 유사하다.

@ConfigurationPropertiesScan 어노테이션은 패키지를 기반으로 @ConfigurationProperties가 등록된 클래스들을 찾아 값들을 주입하고 빈으로 등록해준다.

 

📍게시판 페이지에 decoupled logic 적용

.html 파일과 .th.xml 파일로 나누기

 

스프링 시큐리티 디펜던시 추가

인증 구현할 때 넣으려고 했었는데,
뷰를 간편하게 구성하기 위해 여기서 이용한다.

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'

 

시큐리티 설정을 모든 요청에 인증이 열리도록 수정

지금은 테스트 개발 단계고,
아직 인증 기능 구현 단계에 이르지 않았으므로,
모든 인증 설정을 오픈한다.
그리고 폼 로그인을 활성화해서 로그인 뷰가 그려지게끔 함
최신 스프링 부트 2.7 의 시큐리티 설정 방법을 활용함

package com.fastcampus.projectboard.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
                .formLogin().and()
                .build();
    }

}

 


 

ArticleController.java

ArticleController 에서 매핑을 위한 코드를 작성

 

✔️Model addAttribute(String name, Object value).

- value 객체를 name 이름으로 추가한다.

 


UserAccount.java - 회원 계정 도메인 구현
package com.fastcampus.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 instanceof UserAccount userAccount)) return false;
        return id != null && id.equals(userAccount.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

}

그에따라 db에 접근하기위해 UserAccountRepository 인터페이스도 만들어줌


api

 

 

build.gradle

spring initializr 에서  Rest Repositories와 Rest Repositories HAL Explorer 추가

implementation 'org.springframework.boot:spring-boot-starter-data-rest'
implementation 'org.springframework.data:spring-data-rest-hal-explorer'

application.yaml 추가

data.rest:
    base-path: /api
    detection-strategy: annotated

 

repository 모든 하위 파일에 어노테이션@RepositoryRestResource 추가

@RepositoryRestResource

 

@RepositoryRestResource는 스프링 부트 데이터 레스트에서 지원하는 어노테이션
별도의 컨트롤러와 서비스 영역 없이 미리 내부적으로 정의되어 있는 로직을 따라 처리됨
그 로직은 해당 도메인의 정보를 매핑하여 REST API를 제공하는 역할을 함

 

 

 

DataRestTest.java

 

 ✔️@WebMvcTest

controller와 연관된 내용만 테스트하는 목적을 가지고 있기때문에,  controller 와 연관된 auto configuration 들을 읽습니다.

컨트롤러 빈만 읽으려고 해서는 data rest 가 만들어준 api를 찾아서 읽어낼 수 없어서 @SpringBootTest(실제 구동되는 어플리케이션의 설정, 모든 bean을 로드)

 

✔️MockMvc의 존재를 알기 위한 @AutoConfigureMockMvc도 추가

 

✔️ @Transactional

테스트를 한뒤 롤백을 해주어 DB에 영향이 가지 않도록

 

테스트 통과 완료후

DataRestTest.java는 통합테스트이고, db에 영향을 끼치기 때문에

@Disabled("Spring Data REST 통합테스트는 불필요하므로 제외시킴")

Query DSL

 querydsl은 자동으로 Q클래스를 생성한다

 

옵션추가

// 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)
}

ArticleRepository.java와 ArticleCommentRepository.java 수정

 

QuerydslBinderCustomizer - 일부분만 입력해도 검색결과에 나타나도록

package com.fastcampus.projectboard.repository;


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>,
        QuerydslBinderCustomizer<QArticle> {

    @Override
    default void customize(QuerydslBindings bindings, QArticle root) {
        //현재 이 기능에 의해서 article에 있는 모든 필드들에 대한 검색이 열려있는데 ,
        //선택적인 필드에 대한 검색이 가능하게
        //리스팅을 하지 않은 프로퍼티는 검색이 되지 않도록 - 기본값은 false
        bindings.excludeUnlistedProperties(true);
        //원하는 필드를 춛가하는 것
        bindings.including(root.title, root.content, root.hashtag, root.createdAt, root.createdBy);
        //검색파라미터는 하나만 받기때문에 first+람다식 이용
        bindings.bind(root.title).first(StringExpression::containsIgnoreCase);
        // likeIgnoreCase는 ' ' containsIgnoreCase는 와일드카드를 쓰기때문에  % % 더 편리함
        bindings.bind(root.content).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.hashtag).first(StringExpression::containsIgnoreCase);
        //더 크거나 작거나 검색하는 것이아닌, 원하는 날짜만 검색하기 때문에 eq
        bindings.bind(root.createdAt).first(DateTimeExpression::eq);

        bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
    }

}

뷰 엔드 포인트를 테스트

ArticleControllerTest.java

@DisplayName("View 컨트롤러 - 게시글")
@WebMvcTest(ArticleController.class)
class ArticleControllerTest {

    private final MockMvc mvc;

    public ArticleControllerTest(@Autowired MockMvc mvc) {
        this.mvc = mvc;
    }
    @Disabled("구현 중")
    @DisplayName("[view][GET] 게시글 리스트 (게시판) 페이지 - 정상 호출")
    @Test
    public void givenNothing_whenRequestingArticlesView_thenReturnsArticlesView() throws Exception {
        // Given

        // When & Then
        mvc.perform(get("/articles"))
                .andExpect(status().isOk()) // 정상 호출
                .andExpect(result -> content().contentType(MediaType.TEXT_HTML)) // 데이터 확인
                .andExpect(view().name("articles/index")) // 뷰의 존재여부 검사
                .andExpect(model().attributeExists("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(result -> content().contentType(MediaType.TEXT_HTML)) // 데이터 확인
                .andExpect(view().name("articles/detail")) // 뷰의 존재여부 검사
                .andExpect(model().attributeExists("article")) // 뷰에 모델 어트리뷰트로 넣어준 데이터존재 여부 검사
                .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(result -> content().contentType(MediaType.TEXT_HTML)) // 데이터 확인
                .andExpect(view().name("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(result -> content().contentType(MediaType.TEXT_HTML)) // 데이터 확인
                .andExpect(view().name("articles/search-hashtag")); // 뷰의 존재여부 검사
    }
}

지금 이 테스트는 현재 데이터가 존재하지 않기 때문에 오류가 나타난다.

그래서 깃에 올릴 수 없으니 (같은 협업자들에게 에러를 떠안게하는 상황을 만드는 것이므로)

 

✔️@DisplayName("View 컨트롤러 - 게시글")

테스트 클래스 혹은 테스트 메서드의 이름을 지정할 수 있습니다.

 

✔️@Disabled

빌드에 영향을 미치지 않게 조치 -> 실패하는 테스트 들을 ignore 처리하는 애노테이션


✔️@WebMvcTest(ArticleController.class)

WebMvcTest는 테스트 실행시 다른 webMvcTest를 전부 실행하므로 해당 테스트만 작동하도록 클래스를 지정한다.

 

Comments