Spring Security + JWT + React - 04. 백엔드 - 게시판 제작: 게시판 목록

june·2022년 8월 11일
1
post-thumbnail

게시판 기능 추가

회원한정으로 게시물 작성이 가능하며, 구조는 제목, 회원, 작성일, 본문이다.

게시판 목록에서 게시물을 들어갈 수 있어야 하고

게시물에서는 로그인을 한 회원을 기준으로 댓글작성, 추천이 가능하며

게시물 또한 수정 / 삭제가 가능하게 만들 계획이다.

구현 기능

  • 게시판 목록: 페이징
  • 게시판: CRUD, 단 회원 한정으로 작성, 수정, 삭제가 가능
  • 댓글: 회원 한정으로 작성/삭제가 가능
  • 추천: 회원 한정으로 추천이 가능

Security 설정 수정

먼저 우리는 앞서 WebSecurityConfigfilterChain메소드에서 모든 Requests에 있어서 /auth/**를 제외한 모든 uri의 request는 토큰이 필요하다고 적어놨다.

하지만, 이제는 로그인을 하지 않아도 할 수 있는 다양한 기능들이 추가될 예정이므로, 이를 수정해주자.

/config/WebSecurityConfig.java

...
public class WebSecurityConfig {
    ...
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                ...

                .and()
                .authorizeRequests()
                .antMatchers( "/auth/**", "/article/**", "/recommend/**", "/comment/**").permitAll()
                .anyRequest().authenticated()

                ...

        return http.build();
    }
}

Backend

이제 엔티티부터 생성해보자.

Entity

/entity/Article.java

import lombok.Getter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "article_id")
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String body;

    @CreationTimestamp
    @Column
    private LocalDateTime createdAt = LocalDateTime.now();

    @UpdateTimestamp
    @Column
    private LocalDateTime updatedAt = LocalDateTime.now();

    @OneToMany(mappedBy = "article")
    private List<Comment> comments = new ArrayList<>();

    @OneToMany(mappedBy = "article")
    private List<Recommend> recommends = new ArrayList<>();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    public static Article createArticle (String title, String body, Member member) {
        Article article = new Article();
        article.title = title;
        article.body = body;
        article.member = member;

        return article;
    }

    public static Article changeArticle (Article article, String title, String body) {
        article.title = title;
        article.body = body;

        return article;
    }

}

게시물 엔티티다.

게시물을 구별해주는 idtitle, 본문인 body, 작성일과 수정일인 createdAt - updatedAt로 이루어져 있다.

여기서 작성일과 수정일은 @CreationTimestamp@UpdateTimestamp 어노테이션으로, Hibernate에서 엔티티 객체에 대해 INSERT, UPDATE 등의 쿼리가 발생할 때, 현재 시간을 자동으로 저장해주게 된다.

연관관계로는 Member엔티티와 다대일 연관관계를 맺고 있다. (하나의 유저가 여러가지의 글을 가지기 때문.)

반대로 Comment엔티티와 Recommend엔티티는 일대다 연관관계를 맺고 있다. (하나의 글에 여러개의 추천과 여러개의 댓글이 있기 때문.)

또한 이 아래로 Article객체를 생성해주는 함수와, 객체를 불러와 업데이트(수정)를 해주는 함수인 createArticlechangeArticle을 만들어준다.

/entity/Comment.java

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String text;

    @CreationTimestamp
    @Column
    private LocalDateTime createdAt = LocalDateTime.now();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "article_id")
    private Article article;


    @Builder
    public Comment(String text, Member member, Article article) {
        this.text = text;
        this.member = member;
        this.article = article;
    }

}

Comment 또한 id, 본문인 text 그리고 createdAt으로 이루어져 있다.

연관관계는 MemberArticle 모두에게 다대일 관계를 맺고 있다. 왜냐하면 한 멤버가 여러 댓글을 가지고, 한 글에 여러 댓글을 가질 수 있기 때문이다.

마지막으로 @Builder어노테이션을 통해 생성자를 만들었다.

/entity/Recommend.java

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Recommend {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "article_id")
    private Article article;

    public Recommend(Member member, Article article) {
        this.member = member;
        this.article = article;
    }
}

Recommend는 약간 특이한데, id를 제외하고는 연관관계 밖에 없기 때문이다.

왜냐하면, 이것은 단순히 게시글과 유저와의 관계 말고는 표시할 게 없는 엔티티이기 때문이다.

Member객체와 Article객체를 불러와 Recommend객체를 생성하는 생성자 함수를 추가했다.

ERD

엔티티간의 관계를 ERD로 그려보면 이렇게 된다.

그럼 이제 기능들을 하나하나 추가해보자.

게시판 목록 구현

게시판 목록은 4가지 요소를 포함해야한다.
게시판 id, 게시판 제목, 작성자 닉네임, 작성일.

따라서 그에 맞게 DTO를 생성한다.

/dto/PageResponseDto.java

@Getter
@Builder
public class PageResponseDto {
    private Long articleId;
    private String articleTitle;
    private String memberNickname;
    private String createdAt;


    public static PageResponseDto of(Article article) {
        return PageResponseDto.builder()
                .articleId(article.getId())
                .articleTitle(article.getTitle())
                .memberNickname(article.getMember().getNickname())
                .createdAt(article.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
                .build();
    }
}

맨 아래에는 Article객체를 DTO로 변환시켜주는 Builder메소드를 구현했다.

이후 게시판목록을 전체를 보여주는 로직은 매우 간단하다.

일단 repository에서 ArticleRepository를 만들고, JpaRepository를 상속한다.

/repository/ArticleRepository.java

import com.example.jwt.entity.Article;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ArticleRepository extends JpaRepository<Article, Long> {

}

이후, 서비스 단인 ArticleService에서 JpaRepositoryfindAll메소드를 통해 전체의 목록을 얻어낸 다음, 이를 DTO로 변환시켜 구현하면 된다.
/service/ArticleService.java

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ArticleService {
    private final ArticleRepository articleRepository;

    public List<PageResponseDto> allArticle() {
        List<Article> articles = articleRepository.findAll();
        return articles
                .stream()
                .map(PageResponseDto::of)
                .collect(Collectors.toList());
    }

페이징

그러나 이러한 방식에는 문제가 있다.

지금은 테스트 형식으로 자료가 많지 않지만, 만약 이것을 어느정도 규모가 있는 서비스로 구현한다고 생각해보자. 그렇다면, 수많은 사용자가 게시판 목록을 볼때마다 수천, 수만개가 있는 게시글 전체를 항상 불러오게 되는 것이다.

이는 서버한테 매우 가혹한 환경을 제공하게 되므로, 보통은 이러한 과정에서는 이러한 가혹한 환경을 방지하기 위에 페이징을 설정한다.

즉, 게시글의 일부만 정렬해서 가져와 보여주는 것이다.

QueryDsl

페이징은 JpaRepository의 기능을 사용해도 되지만, 차후에 검색기능을 추가함에 있어서 동적쿼리나 N+1문제를 겪을 수 있기 때문에 QueryDsl을 사용하기로 했다.

먼저 gradle의 세팅부터 변화시켜보자.
build.gradle

// queryDsl버전
buildscript {
	ext {
		queryDslVersion = "5.0.0"
	}
}

plugins {
	id 'org.springframework.boot' version '2.7.0'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'

	//querydsl 추가
	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"

	id 'java'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'

	// Querydsl 의존성
	implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
	implementation "com.querydsl:querydsl-apt:${queryDslVersion}"

	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'mysql:mysql-connector-java:8.0.29'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'

	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'

	implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'


}

tasks.named('test') {
	useJUnitPlatform()
}

//querydsl 사용 경로
def querydslDir = "$buildDir/generated/querydsl"

//querydsl 사용 설정
querydsl {
	jpa = true
	querydslSourcesDir = querydslDir
}

// build 시 사용할 sourceSet
sourceSets {
	main.java.srcDir querydslDir
}

// compileClasspath와 annotationProcessor 상속
configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
	querydsl.extendsFrom compileClasspath
}

//feature/improve-all
// querydsl 컴파일 시 사용할 옵션.
compileQuerydsl {
	options.annotationProcessorPath = configurations.querydsl
}

//feature/improve-all
// QType 정리
clean {
	delete file(querydslDir)

이후 gradle을 build해준 다음, gradle window tool에서 compileQuerydsl을 실행해준다.

이러면 자동으로 엔티티들이 QType으로 변환되어 build/generated/querydsl 안에 생성되게 된다.

이제 이 QType을 가지고 구현해보자.

내가 원하는 구현 코드를 넣으려면, 따로 내가 정의한 사용자정의 인터페이스를 작성하고 생성해야한다.

/repository/ArticleRepositoryCustom.java

import com.example.jwt.dto.PageResponseDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;


public interface ArticleRepositoryCustom {
    Page<PageResponseDto> searchAll(Pageable pageable);
}

이후 클래스를 만들어 인터페이스를 구현한 다음, 그 곳에 QueryDsl을 작성한다.
여기서 중요한 점은 이름뒤에 항상 Impl이 와야한다는 점이다.

_/repository/ArticleRepositoryImpl/

import com.example.jwt.dto.PageResponseDto;
import com.example.jwt.entity.Article;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;

import java.util.List;
import java.util.stream.Collectors;

import static com.example.jwt.entity.QArticle.article;

@RequiredArgsConstructor
public class ArticleRepositoryImpl implements ArticleRepositoryCustom{

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<PageResponseDto> searchAll(Pageable pageable) {


        List<Article> content = queryFactory
                .selectFrom(article)
                .orderBy(article.id.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        List<PageResponseDto> pages = content
                .stream()
                .map(PageResponseDto::of)
                .collect(Collectors.toList());

        int totalSize = queryFactory
                .selectFrom(article)
                .fetch()
                .size();

        return new PageImpl<>(pages, pageable, totalSize);
    }


}

위에 구현한 식을 보면 먼저 queryFactory를 통해 QueryDsl로 직관적인 SQL문을 생성해서, page에 맞는 데이터를 리스트로 가져오게 한다.

이 후 이것을 DTO로 변환시켰지만, 이러지 않고, DTO 내부에 생성자를 만든 다음, 생성자에 @QueryProjection을 붙이고, 바로 DTO로 변환시킬수도 있다. (이러면 compile할 때 자동으로 DTO의 QType이 생성된다.)

다만 여기서는 DTO의 createdAtString객체이므로 이를 변환해주기 위해 내장 static함수를 사용했다.

또한 마지막에 페이지의 총갯수를 파악하고, 모든 정보를 합쳐 새로운 Page객체를 만든다.

그리고 이제 이렇게 만든 인터페이스를 다시 ArticleRepository에 상속시키자.

/repository/ArticleRepository.java

import com.example.jwt.entity.Article;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ArticleRepository extends JpaRepository<Article, Long>, ArticleRepositoryCustom {

}

이제 이것을 서비스로 구현해보자. 이는 매우 간단하다.

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ArticleService {
    private final ArticleRepository articleRepository;

   ...
   
    public Page<PageResponseDto> pageArticle(int pageNum) {
        return articleRepository.searchAll(PageRequest.of(pageNum - 1, 20));
    }
    ...

}
profile
초보 개발자

0개의 댓글