본문 바로가기
BackEnd/Project

[Board] Ch03. 게시글 페이지 기능 테스트 정의(2)

by 개발 Blog 2024. 8. 9.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

루트 경로 설정부터 게시글 서비스 기능까지 개발 과정

이번 작업에서는 웹 애플리케이션의 루트 경로를 게시판 페이지로 설정하는 것부터 시작하여, 게시글과 댓글 서비스 기능을 구현하고 테스트하는 과정을 다룬다. 이 과정은 전반적인 서비스 계층을 구축하는 데 필요한 필수 요소들을 포함하며, 애플리케이션의 핵심 기능이 제대로 동작하는지 확인하기 위해 다양한 테스트를 작성하고 수행하는 것에 중점을 두었다.

루트 경로를 게시판 페이지로 forward 하도록 컨트롤러 구현

1. MainController 구현

루트 경로("/")로 요청이 들어왔을 때, 이를 게시판 페이지("/articles")로 forward 하도록 MainController를 구현했다. forward는 요청된 URL을 유지한 채 다른 페이지를 보여주는 방식으로, 게시판 페이지를 기본으로 하고 URL을 유지하기 위한 목적으로 선택되었다.

package org.example.projectboard.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class MainController {

    @GetMapping("/")
    public String root() {
        return "forward:/articles";
    }
}

 

2. 테스트 코드 작성: MainControllerTest

MainController의 동작을 확인하기 위해 MainControllerTest를 작성했다. MockMvc를 사용하여 루트 경로로의 요청이 게시판 페이지로 제대로 forward 되는지 테스트한다. 테스트에서는 forward 된 결과가 "/articles"인지, HTTP 상태 코드가 200(OK)인지 확인한다.

package org.example.projectboard.controller;

import org.example.projectboard.config.SecurityConfig;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Import(SecurityConfig.class)
@WebMvcTest(MainController.class)
class MainControllerTest {

    private final MockMvc mvc;

    MainControllerTest(@Autowired MockMvc mvc) {
        this.mvc = mvc;
    }

    @Test
    void givenNothing_whenRequestingRootPage_thenRedirectsToArticlesPage() throws Exception {
        // given

        // when & then
        mvc.perform(get("/"))
                .andExpect(status().isOk())
                .andExpect(view().name("forward:/articles"))
                .andExpect(forwardedUrl("/articles"))
                .andDo(MockMvcResultHandlers.print());
    }
}

 

3. index.html 페이지 (임시)

기존의 루트 페이지는 더 이상 사용되지 않지만, 유지보수의 관점에서 나중에 삭제를 고려해야 한다.

<!doctype html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="">
    <meta name="author" content="Eunchan Kim">
    <title>Welcome page</title>
    <title>Root page</title>
</head>
<body>
Welcome!
<!-- TODO: 루트 패쓰는 게시판으로 redirect 되므로, 이 페이지는 이제 쓰이지 않는다. 나중에 삭제 검토. -->
</body>
</html>

루트 경로("/")로의 요청을 게시판 페이지("/articles")로 forward 하도록 MainController를 구현하고, 해당 기능이 제대로 작동하는지 확인하는 테스트를 작성했다.

forward 방식을 선택한 이유는 URL을 유지하면서도 기본 페이지를 게시판 페이지로 설정하기 위함이다. 이 과정에서 기존의 루트 페이지는 사용되지 않으나, 유지보수 차원에서 추후 삭제를 검토한다.

 

DTO: 데이터 전송 객체 (DTO)의 구성 및 역할

DTO(Data Transfer Object)는 계층 간 데이터 교환을 위해 사용되는 객체이다. 이 패키지에서는 도메인 엔티티에서 데이터를 추출하여 전송하기 위한 다양한 DTO 클래스를 정의한다. DTO는 데이터를 단순히 전달하는 역할을 하며, 비즈니스 로직을 포함하지 않는다.

 

1. ArticleCommentDto

ArticleCommentDto는 게시글의 댓글 데이터를 전송하기 위한 DTO이다. 이 DTO는 댓글 ID, 관련된 게시글 ID, 댓글 작성자 정보, 댓글 내용, 생성 및 수정 정보를 포함한다.

public record ArticleCommentDto(
        Long id,
        Long articleId,
        UserAccountDto userAccountDto,
        String content,
        LocalDateTime createdAt,
        String createdBy,
        LocalDateTime modifiedAt,
        String modifiedBy
) {
    public static ArticleCommentDto of(Long id, Long articleId, UserAccountDto userAccountDto, String content, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
        return new ArticleCommentDto(id, articleId, userAccountDto, content, createdAt, createdBy, modifiedAt, modifiedBy);
    }

    public static ArticleCommentDto from(ArticleComment entity) {
        return new ArticleCommentDto(
                entity.getId(),
                entity.getArticle().getId(),
                UserAccountDto.from(entity.getUserAccount()),
                entity.getContent(),
                entity.getCreatedAt(),
                entity.getCreatedBy(),
                entity.getModifiedAt(),
                entity.getModifiedBy()
        );
    }

    public ArticleComment toEntity(Article entity) {
        return ArticleComment.of(
                entity,
                userAccountDto.toEntity(),
                content
        );
    }
}
  • from(ArticleComment entity): 도메인 객체인 ArticleComment로부터 ArticleCommentDto를 생성한다.
  • toEntity(Article entity): ArticleCommentDto를 Article 도메인 객체와 함께 ArticleComment 엔티티로 변환한다.

 

2. ArticleDto

ArticleDto는 게시글 데이터를 전송하기 위한 DTO이다. 게시글 ID, 작성자 정보, 제목, 내용, 해시태그, 생성 및 수정 정보를 포함한다.

public record ArticleDto(
        Long id,
        UserAccountDto userAccountDto,
        String title,
        String content,
        String hashtag,
        LocalDateTime createdAt,
        String createdBy,
        LocalDateTime modifiedAt,
        String modifiedBy
) {
    public static ArticleDto of(Long id, UserAccountDto userAccountDto, String title, String content, String hashtag, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
        return new ArticleDto(id, userAccountDto, title, content, hashtag, createdAt, createdBy, modifiedAt, modifiedBy);
    }

    public static ArticleDto from(Article entity) {
        return new ArticleDto(
                entity.getId(),
                UserAccountDto.from(entity.getUserAccount()),
                entity.getTitle(),
                entity.getContent(),
                entity.getHashtag(),
                entity.getCreatedAt(),
                entity.getCreatedBy(),
                entity.getModifiedAt(),
                entity.getModifiedBy()
        );
    }

    public Article toEntity() {
        return Article.of(
                userAccountDto.toEntity(),
                title,
                content,
                hashtag
        );
    }
}
  • from(Article entity): 도메인 객체인 Article로부터 ArticleDto를 생성한다.
  • toEntity(): ArticleDto를 Article 엔티티로 변환한다.

3. ArticleWithCommentsDto

ArticleWithCommentsDto는 댓글이 포함된 게시글 데이터를 전송하기 위한 DTO이다. ArticleDto의 필드 외에도, 댓글 목록을 포함하여 게시글과 그 댓글들을 함께 전송하는 데 사용된다.

public record ArticleWithCommentsDto(
        Long id,
        UserAccountDto userAccountDto,
        Set<ArticleCommentDto> articleCommentDtos,
        String title,
        String content,
        String hashtag,
        LocalDateTime createdAt,
        String createdBy,
        LocalDateTime modifiedAt,
        String modifiedBy
) {
    public static ArticleWithCommentsDto of(Long id, UserAccountDto userAccountDto, Set<ArticleCommentDto> articleCommentDtos, String title, String content, String hashtag, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
        return new ArticleWithCommentsDto(id, userAccountDto, articleCommentDtos, title, content, hashtag, createdAt, createdBy, modifiedAt, modifiedBy);
    }

    public static ArticleWithCommentsDto from(Article entity) {
        return new ArticleWithCommentsDto(
                entity.getId(),
                UserAccountDto.from(entity.getUserAccount()),
                entity.getArticleComments().stream()
                        .map(ArticleCommentDto::from)
                        .collect(Collectors.toCollection(LinkedHashSet::new)),
                entity.getTitle(),
                entity.getContent(),
                entity.getHashtag(),
                entity.getCreatedAt(),
                entity.getCreatedBy(),
                entity.getModifiedAt(),
                entity.getModifiedBy()
        );
    }
}
  • from(Article entity): 도메인 객체인 Article로부터 ArticleWithCommentsDto를 생성한다.

4. UserAccountDto

UserAccountDto는 사용자 계정 정보를 전송하기 위한 DTO이다. 사용자 ID, 비밀번호, 이메일, 닉네임, 메모 등의 정보를 포함하며, 사용자 계정의 생성 및 수정 정보도 함께 담고 있다.

public record UserAccountDto(
        Long id,
        String userId,
        String userPassword,
        String email,
        String nickname,
        String memo,
        LocalDateTime createdAt,
        String createdBy,
        LocalDateTime modifiedAt,
        String modifiedBy
) {
    public static UserAccountDto of(Long id, String userId, String userPassword, String email, String nickname, String memo, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
        return new UserAccountDto(id, userId, userPassword, email, nickname, memo, createdAt, createdBy, modifiedAt, modifiedBy);
    }

    public static UserAccountDto from(UserAccount entity) {
        return new UserAccountDto(
                entity.getId(),
                entity.getUserId(),
                entity.getUserPassword(),
                entity.getEmail(),
                entity.getNickname(),
                entity.getMemo(),
                entity.getCreatedAt(),
                entity.getCreatedBy(),
                entity.getModifiedAt(),
                entity.getModifiedBy()
        );
    }

    public UserAccount toEntity() {
        return UserAccount.of(
                userId,
                userPassword,
                email,
                nickname,
                memo
        );
    }
}
  • from(UserAccount entity): 도메인 객체인 UserAccount로부터 UserAccountDto를 생성한다.
  • toEntity(): UserAccountDto를 UserAccount 엔티티로 변환한다.

Response: 클라이언트 응답 객체의 정의 및 활용

Response 패키지는 클라이언트에게 데이터를 전송하기 위한 응답 객체를 정의한다. 이 객체들은 주로 DTO를 기반으로 하며, 클라이언트에게 적합한 형식으로 데이터를 가공하여 전송하는 역할을 한다.

 

1. ArticleCommentResponse

ArticleCommentResponse 클래스는 클라이언트에게 전달할 댓글 데이터를 정의하는 역할을 한다.

package org.example.projectboard.dto.response;

import org.example.projectboard.dto.ArticleCommentDto;

import java.io.Serializable;
import java.time.LocalDateTime;

public record ArticleCommentResponse(
        Long id,
        String content,
        LocalDateTime createdAt,
        String email,
        String nickname
) implements Serializable {

    public static ArticleCommentResponse of(Long id, String content, LocalDateTime createdAt, String email, String nickname) {
        return new ArticleCommentResponse(id, content, createdAt, email, nickname);
    }

    public static ArticleCommentResponse from(ArticleCommentDto dto) {
        String nickname = dto.userAccountDto().nickname();
        if (nickname == null || nickname.isBlank()) {
            nickname = dto.userAccountDto().userId();
        }

        return new ArticleCommentResponse(
                dto.id(),
                dto.content(),
                dto.createdAt(),
                dto.userAccountDto().email(),
                nickname
        );
    }
}
  • 주요 필드:
    • Long id: 댓글의 ID를 저장한다.
    • String content: 댓글의 내용을 저장한다.
    • LocalDateTime createdAt: 댓글이 생성된 시간을 저장한다.
    • String email: 댓글 작성자의 이메일을 저장한다.
    • String nickname: 댓글 작성자의 닉네임을 저장한다. 닉네임이 비어있을 경우 사용자 ID를 대신 사용한다.
  • 주요 메서드:
    • of(): 인스턴스를 직접 생성할 때 사용한다.
    • from(): ArticleCommentDto 객체로부터 ArticleCommentResponse 객체를 생성한다. 닉네임이 비어있거나 없을 경우 사용자 ID를 대신 사용한다.

2. ArticleResponse

ArticleResponse 클래스는 클라이언트에게 전달할 게시글 데이터를 정의하는 역할을 한다.

package org.example.projectboard.dto.response;

import org.example.projectboard.dto.ArticleDto;

import java.io.Serializable;
import java.time.LocalDateTime;

public record ArticleResponse(
        Long id,
        String title,
        String content,
        String hashtag,
        LocalDateTime createdAt,
        String email,
        String nickname
) implements Serializable {

    public static ArticleResponse of(Long id, String title, String content, String hashtag, LocalDateTime createdAt, String email, String nickname) {
        return new ArticleResponse(id, title, content, hashtag, createdAt, email, nickname);
    }

    public static ArticleResponse from(ArticleDto dto) {
        String nickname = dto.userAccountDto().nickname();
        if (nickname == null || nickname.isBlank()) {
            nickname = dto.userAccountDto().userId();
        }

        return new ArticleResponse(
                dto.id(),
                dto.title(),
                dto.content(),
                dto.hashtag(),
                dto.createdAt(),
                dto.userAccountDto().email(),
                nickname
        );
    }
}
  • 주요 필드:
    • Long id: 게시글의 ID를 저장한다.
    • String title: 게시글의 제목을 저장한다.
    • String content: 게시글의 내용을 저장한다.
    • String hashtag: 게시글에 포함된 해시태그를 저장한다.
    • LocalDateTime createdAt: 게시글이 생성된 시간을 저장한다.
    • String email: 게시글 작성자의 이메일을 저장한다.
    • String nickname: 게시글 작성자의 닉네임을 저장한다. 닉네임이 비어있을 경우 사용자 ID를 대신 사용한다.
  • 주요 메서드:
    • of(): 인스턴스를 직접 생성할 때 사용한다.
    • from(): ArticleDto 객체로부터 ArticleResponse 객체를 생성한다. 닉네임이 비어있거나 없을 경우 사용자 ID를 대신 사용한다.

3. ArticleWithCommentResponse

ArticleWithCommentResponse 클래스는 게시글과 그에 대한 댓글들을 함께 클라이언트에게 전달할 데이터를 정의하는 역할을 한다.

package org.example.projectboard.dto.response;

import org.example.projectboard.dto.ArticleWithCommentsDto;

import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors;

public record ArticleWithCommentResponse(
        Long id,
        String title,
        String content,
        String hashtag,
        LocalDateTime createdAt,
        String email,
        String nickname,
        Set<ArticleCommentResponse> articleCommentResponses
) implements Serializable {

    public static ArticleWithCommentResponse of(Long id, String title, String content, String hashtag, LocalDateTime createdAt, String email, String nickname, Set<ArticleCommentResponse> articleCommentResponses) {
        return new ArticleWithCommentResponse(id, title, content, hashtag, createdAt, email, nickname, articleCommentResponses);
    }

    public static ArticleWithCommentResponse from(ArticleWithCommentsDto dto) {
        String nickname = dto.userAccountDto().nickname();
        if (nickname == null || nickname.isBlank()) {
            nickname = dto.userAccountDto().userId();
        }

        return new ArticleWithCommentResponse(
                dto.id(),
                dto.title(),
                dto.content(),
                dto.hashtag(),
                dto.createdAt(),
                dto.userAccountDto().email(),
                nickname,
                dto.articleCommentDtos().stream()
                        .map(ArticleCommentResponse::from)
                        .collect(Collectors.toCollection(LinkedHashSet::new))
        );
    }
}
  • 주요 필드:
    • Long id: 게시글의 ID를 저장한다.
    • String title: 게시글의 제목을 저장한다.
    • String content: 게시글의 내용을 저장한다.
    • String hashtag: 게시글에 포함된 해시태그를 저장한다.
    • LocalDateTime createdAt: 게시글이 생성된 시간을 저장한다.
    • String email: 게시글 작성자의 이메일을 저장한다.
    • String nickname: 게시글 작성자의 닉네임을 저장한다. 닉네임이 비어있을 경우 사용자 ID를 대신 사용한다.
    • Set<ArticleCommentResponse> articleCommentResponses: 해당 게시글에 달린 댓글들의 정보를 저장한다.
  • 주요 메서드:
    • of(): 인스턴스를 직접 생성할 때 사용한다.
    • from(): ArticleWithCommentsDto 객체로부터 ArticleWithCommentResponse 객체를 생성한다. 댓글 정보는 ArticleCommentResponse 객체들로 변환하여 저장한다.

Repository: 데이터베이스 접근 계층의 인터페이스 정의

Repository 패키지는 데이터베이스와의 상호작용을 담당하는 리포지토리 인터페이스들을 정의한다. 각 리포지토리는 특정 엔티티에 대해 CRUD 작업을 수행하고, QueryDSL을 활용한 복잡한 쿼리도 가능하게 한다.

 

1. ArticleRepository

ArticleRepository는 게시글(Article) 엔티티에 대한 데이터베이스 접근을 관리하는 리포지토리 인터페이스이다.

이 인터페이스는 JpaRepository를 상속받아 기본적인 CRUD 작업을 제공하며, QuerydslPredicateExecutor와 QuerydslBinderCustomizer를 통해 복잡한 조건을 기반으로 한 검색 기능을 지원한다.

Repository 패키지는 데이터베이스와의 상호작용을 담당하는 리포지토리 인터페이스들을 정의한다. 각 리포지토리는 특정 엔티티에 대해 CRUD 작업을 수행하고, QueryDSL을 활용한 복잡한 쿼리도 가능하게 한다.

package org.example.projectboard.repository;

import com.querydsl.core.types.dsl.DateTimeExpression;
import com.querydsl.core.types.dsl.StringExpression;
import org.example.projectboard.domain.Article;
import org.example.projectboard.domain.QArticle;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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>{
    Page<Article> findByTitle(String title, Pageable pageable);

    @Override
    default void customize(QuerydslBindings bindings, QArticle root){
        bindings.excludeUnlistedProperties(true);
        bindings.including(root.title, root.content, root.hashtag, root.createdAt, root.createdBy);
        bindings.bind(root.title).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.content).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.hashtag).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.createdAt).first(DateTimeExpression::eq);
        bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
    }
}
  • findByTitle(String title, Pageable pageable): 이 메서드는 주어진 제목을 가진 게시글을 페이지네이션하여 조회하는 기능을 제공한다. 주로 특정 제목을 가진 게시글 목록을 페이징 처리하여 가져올 때 사용된다.
  • QueryDSL 커스터마이징: QuerydslBinderCustomizer<QArticle>를 구현함으로써, 제목, 본문, 해시태그, 생성일, 생성자와 같은 필드를 기준으로 검색할 수 있도록 커스터마이징 할 수 있다. 이를 통해 유연한 검색 조건을 지원한다.

2. ArticleCommentRepository

ArticleCommentRepository는 게시글 댓글(ArticleComment) 엔티티에 대한 데이터베이스 접근을 관리하는 리포지토리 인터페이스이다. 이 인터페이스는 JpaRepository를 상속받아 기본적인 CRUD 작업을 제공하며, QuerydslPredicateExecutor와 QuerydslBinderCustomizer를 통해 복잡한 조건을 기반으로 한 댓글 검색 기능을 지원한다.

package org.example.projectboard.repository;

import com.querydsl.core.types.dsl.DateTimeExpression;
import com.querydsl.core.types.dsl.StringExpression;
import org.example.projectboard.domain.ArticleComment;
import org.example.projectboard.domain.QArticleComment;
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;

import java.util.List;

@RepositoryRestResource
public interface ArticleCommentRepository extends
        JpaRepository<ArticleComment, Long>,
        QuerydslPredicateExecutor<ArticleComment>,
        QuerydslBinderCustomizer<QArticleComment>{

    List<ArticleComment> findByArticleId(Long articleId);

    @Override
    default void customize(QuerydslBindings bindings, QArticleComment root){
        bindings.excludeUnlistedProperties(true);
        bindings.including(root.content, root.createdAt, root.createdBy);
        bindings.bind(root.content).first(StringExpression::containsIgnoreCase);
        bindings.bind(root.createdAt).first(DateTimeExpression::eq);
        bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
    }
}
  • findByArticleId(Long articleId): 이 메서드는 특정 게시글에 속한 댓글들을 조회하는 기능을 제공한다. 주어진 articleId를 기준으로 해당 게시글에 속하는 모든 댓글을 반환한다.
  • QueryDSL 커스터마이징: QuerydslBinderCustomizer<QArticleComment>를 구현하여, 댓글의 내용, 생성일, 생성자와 같은 필드를 기준으로 검색할 수 있도록 커스터마이징 할 수 있다. 이를 통해 유연한 댓글 검색 조건을 제공한다.

Service: 비즈니스 로직 구현을 위한 서비스 계층

Service 패키지는 애플리케이션의 비즈니스 로직을 구현하는 서비스 클래스를 포함한다. 서비스 클래스는 리포지토리를 이용해 데이터를 처리하고, 이를 기반으로 도메인 로직을 실행하여 필요한 작업을 수행한다.

 

1. ArticleCommentService

ArticleCommentService는 게시글 댓글에 대한 비즈니스 로직을 처리하는 서비스 클래스이다. 이 클래스는 댓글(ArticleComment)을 생성, 수정, 삭제 및 조회하는 기능을 제공한다. ArticleRepository와 ArticleCommentRepository를 주입받아, 데이터베이스와 상호작용하며 필요한 로직을 구현한다.

package org.example.projectboard.service;

import lombok.RequiredArgsConstructor;
import org.example.projectboard.dto.ArticleCommentDto;
import org.example.projectboard.repository.ArticleCommentRepository;
import org.example.projectboard.repository.ArticleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@RequiredArgsConstructor
@Transactional
@Service
public class ArticleCommentService {
    private final ArticleRepository articleRepository;
    private final ArticleCommentRepository articleCommentRepository;

    @Transactional(readOnly = true)
    public List<ArticleCommentDto> searchArticleComments(Long articleId) {
        return List.of();
    }

    public void saveArticleComment(ArticleCommentDto of) {
    }

    public void updateArticleComment(ArticleCommentDto dto) {
    }

    public void deleteArticleComment(Long articleCommentId) {
    }
}
  • searchArticleComments(Long articleId): 특정 게시글에 대한 모든 댓글을 조회하는 메서드이다. 이 메서드는 주어진 articleId를 기준으로 댓글 목록을 반환한다. 현재는 빈 리스트를 반환하고 있지만, 실제 구현에서는 ArticleCommentRepository를 이용해 데이터를 조회할 것이다.
  • saveArticleComment(ArticleCommentDto dto): 댓글을 저장하는 메서드이다. ArticleCommentDto 객체를 받아, 이를 엔티티로 변환한 후 ArticleCommentRepository를 통해 데이터베이스에 저장할 것이다.
  • updateArticleComment(ArticleCommentDto dto): 댓글을 수정하는 메서드이다. ArticleCommentDto 객체를 받아, 이를 기반으로 기존 댓글을 업데이트할 것이다.
  • deleteArticleComment(Long articleCommentId): 특정 댓글을 삭제하는 메서드이다. 주어진 articleCommentId를 이용해 해당 댓글을 데이터베이스에서 삭제할 것이다.

Service 계층의 비즈니스 로직 및 테스트 구현

Test 패키지는 서비스 로직을 검증하기 위한 테스트 클래스를 포함한다. 각 테스트 클래스는 서비스 계층의 로직이 올바르게 동작하는지 확인하기 위해 작성되며, Mockito를 이용해 모의 객체로 의존성을 주입하여 테스트를 수행한다.

 

1. ArticleCommentServiceTest

ArticleCommentServiceTest는 댓글에 대한 비즈니스 로직을 검증하는 테스트 클래스이다.

@ExtendWith(MockitoExtension.class) 어노테이션을 사용해 Mockito를 기반으로 한 테스트 환경을 설정하고 있다.

@DisplayName("비즈니스 로직 - 댓글")
@ExtendWith(MockitoExtension.class)
class ArticleCommentServiceTest {

    @InjectMocks 
    private ArticleCommentService sut;  // 테스트 대상인 서비스 클래스

    @Mock 
    private ArticleRepository articleRepository;  // 댓글과 관련된 게시글을 조회하기 위한 Mock 객체

    @Mock 
    private ArticleCommentRepository articleCommentRepository;  // 댓글 데이터를 관리하기 위한 Mock 객체

    @DisplayName("게시글 ID로 조회하면, 해당하는 댓글 리스트를 반환한다.")
    @Test
    void givenArticleId_whenSearchingArticleComments_thenReturnsArticleComments() {
        // given
        Long articleId = 1L;  // 테스트에 사용될 게시글 ID
        ArticleComment expected = createArticleComment("content");  // 기대되는 댓글 객체 생성

        given(articleCommentRepository.findByArticleId(articleId)).willReturn(List.of(expected));  // 댓글 리포지토리의 findByArticleId 메서드가 호출될 때, 예상되는 댓글 리스트를 반환하도록 설정

        // when
        List<ArticleCommentDto> actual = sut.searchArticleComments(articleId);  // 댓글 검색 서비스 호출

        // then
        assertThat(actual)
                .hasSize(1)  // 반환된 댓글 리스트의 크기가 1인지 확인
                .first().hasFieldOrPropertyWithValue("content", expected.getContent());  // 첫 번째 댓글의 내용이 기대값과 일치하는지 확인
        then(articleCommentRepository).should().findByArticleId(articleId);  // 댓글 리포지토리의 findByArticleId 메서드가 호출되었는지 확인
    }

    @DisplayName("댓글 정보를 입력하면, 댓글을 저장한다.")
    @Test
    void givenArticleCommentInfo_whenSavingArticleComment_thenSavesArticleComment() {
        // given
        ArticleCommentDto dto = createArticleCommentDto("댓글");  // 테스트에 사용할 댓글 DTO 생성
        given(articleRepository.getReferenceById(dto.articleId())).willReturn(createArticle());  // 게시글을 참조하는 메서드가 호출되면 게시글 객체 반환
        given(articleCommentRepository.save(any(ArticleComment.class))).willReturn(null);  // 댓글 저장 메서드가 호출되면 아무 값도 반환하지 않도록 설정

        // when
        sut.saveArticleComment(dto);  // 댓글 저장 서비스 호출

        // then
        then(articleRepository).should().getReferenceById(dto.articleId());  // 게시글 참조 메서드 호출 여부 확인
        then(articleCommentRepository).should().save(any(ArticleComment.class));  // 댓글 저장 메서드 호출 여부 확인
    }

    @DisplayName("댓글 저장을 시도했는데 맞는 게시글이 없으면, 경고 로그를 찍고 아무것도 안 한다.")
    @Test
    void givenNonexistentArticle_whenSavingArticleComment_thenLogsSituationAndDoesNothing() {
        // Given
        ArticleCommentDto dto = createArticleCommentDto("댓글");  // 테스트에 사용할 댓글 DTO 생성
        given(articleRepository.getReferenceById(dto.articleId())).willThrow(EntityNotFoundException.class);  // 게시글 참조 메서드가 호출되면 예외를 던지도록 설정

        // When
        sut.saveArticleComment(dto);  // 댓글 저장 서비스 호출

        // Then
        then(articleRepository).should().getReferenceById(dto.articleId());  // 게시글 참조 메서드 호출 여부 확인
        then(articleCommentRepository).shouldHaveNoInteractions();  // 댓글 리포지토리가 상호작용하지 않았는지 확인
    }

    @DisplayName("댓글 정보를 입력하면, 댓글을 수정한다.")
    @Test
    void givenArticleCommentInfo_whenUpdatingArticleComment_thenUpdatesArticleComment() {
        // Given
        String oldContent = "content";  // 기존 댓글 내용
        String updatedContent = "댓글";  // 수정된 댓글 내용
        ArticleComment articleComment = createArticleComment(oldContent);  // 기존 댓글 객체 생성
        ArticleCommentDto dto = createArticleCommentDto(updatedContent);  // 수정된 댓글 DTO 생성
        given(articleCommentRepository.getReferenceById(dto.id())).willReturn(articleComment);  // 댓글 참조 메서드가 호출되면 기존 댓글 객체 반환

        // When
        sut.updateArticleComment(dto);  // 댓글 수정 서비스 호출

        // Then
        assertThat(articleComment.getContent())
                .isNotEqualTo(oldContent)  // 기존 댓글 내용과 수정된 댓글 내용이 다른지 확인
                .isEqualTo(updatedContent);  // 수정된 댓글 내용이 기대값과 일치하는지 확인
        then(articleCommentRepository).should().getReferenceById(dto.id());  // 댓글 참조 메서드 호출 여부 확인
    }

    @DisplayName("없는 댓글 정보를 수정하려고 하면, 경고 로그를 찍고 아무 것도 안 한다.")
    @Test
    void givenNonexistentArticleComment_whenUpdatingArticleComment_thenLogsWarningAndDoesNothing() {
        // Given
        ArticleCommentDto dto = createArticleCommentDto("댓글");  // 수정할 댓글 DTO 생성
        given(articleCommentRepository.getReferenceById(dto.id())).willThrow(EntityNotFoundException.class);  // 댓글 참조 메서드가 호출되면 예외를 던지도록 설정

        // When
        sut.updateArticleComment(dto);  // 댓글 수정 서비스 호출

        // Then
        then(articleCommentRepository).should().getReferenceById(dto.id());  // 댓글 참조 메서드 호출 여부 확인
    }

    @DisplayName("댓글 ID를 입력하면, 댓글을 삭제한다.")
    @Test
    void givenArticleCommentId_whenDeletingArticleComment_thenDeletesArticleComment() {
        // Given
        Long articleCommentId = 1L;  // 삭제할 댓글 ID
        willDoNothing().given(articleCommentRepository).deleteById(articleCommentId);  // 댓글 삭제 메서드가 호출되면 아무 동작도 하지 않도록 설정

        // When
        sut.deleteArticleComment(articleCommentId);  // 댓글 삭제 서비스 호출

        // Then
        then(articleCommentRepository).should().deleteById(articleCommentId);  // 댓글 삭제 메서드 호출 여부 확인
    }

    // 헬퍼 메서드들
    private ArticleCommentDto createArticleCommentDto(String content) {
        return ArticleCommentDto.of(
                1L,
                1L,
                createUserAccountDto(),
                content,
                LocalDateTime.now(),
                "eunchan",
                LocalDateTime.now(),
                "eunchan"
        );
    }

    private UserAccountDto createUserAccountDto() {
        return UserAccountDto.of(
                1L,
                "eunchan",
                "password",
                "eunchan@mail.com",
                "Eunchan",
                "This is memo",
                LocalDateTime.now(),
                "eunchan",
                LocalDateTime.now(),
                "eunchan"
        );
    }

    private ArticleComment createArticleComment(String content) {
        return ArticleComment.of(
                Article.of(createUserAccount(), "title", "content", "hashtag"),
                createUserAccount(),
                content
        );
    }

    private UserAccount createUserAccount() {
        return UserAccount.of(
                "eunchan",
                "password",
                "eunchan@email.com",
                "Eunchan",
                null
        );
    }

    private Article createArticle() {
        return Article.of(
                createUserAccount(),
                "title",
                "content",
                "#java"
        );
    }
}
  • givenArticleId_whenSearchingArticleComments_thenReturnsArticleComments:
    • 댓글이 특정 게시글 ID에 대해 제대로 조회되는지를 검증한다.
    • articleCommentRepository.findByArticleId 메서드가 호출되었을 때 예상된 댓글 리스트가 반환되는지 확인한다.
  • givenArticleCommentInfo_whenSavingArticleComment_thenSavesArticleComment:
    • 댓글 정보를 통해 댓글이 제대로 저장되는지를 확인한다.
    • 댓글 저장 시 게시글이 참조되고, 댓글 저장이 정상적으로 이루어지는지를 검증한다.
  • givenNonexistentArticle_whenSavingArticleComment_thenLogsSituationAndDoesNothing:
    • 댓글을 저장하려는 시도에서 해당 게시글이 존재하지 않을 때, 시스템이 아무 동작도 하지 않고 경고 로그를 남기는지 테스트한다.
  • givenArticleCommentInfo_whenUpdatingArticleComment_thenUpdatesArticleComment:
    • 댓글 정보가 수정되었을 때, 해당 댓글이 올바르게 업데이트되는지 확인한다.
  • givenNonexistentArticleComment_whenUpdatingArticleComment_thenLogsWarningAndDoesNothing:
    • 존재하지 않는 댓글을 수정하려고 할 때, 시스템이 아무 동작도 하지 않고 경고 로그를 남기는지 검증한다.
  • givenArticleCommentId_whenDeletingArticleComment_thenDeletesArticleComment:
    • 댓글 ID를 통해 댓글을 삭제하는 기능이 올바르게 작동하는지를 확인한다.

2. ArticleServiceTest

ArticleServiceTest는 게시글에 대한 비즈니스 로직을 검증하는 테스트 클래스이다.

역시 @ExtendWith(MockitoExtension.class) 어노테이션을 사용해 Mockito를 기반으로 한 테스트 환경을 설정하고 있다.

@DisplayName("비즈니스 로직 - 게시글")
@ExtendWith(MockitoExtension.class)
class ArticleServiceTest {

    @InjectMocks 
    private ArticleService sut;  // 테스트 대상인 서비스 클래스

    @Mock 
    private ArticleRepository articleRepository;  // 게시글 데이터를 관리하기 위한 Mock 객체

    @DisplayName("검색어 없이 게시글을 검색하면, 게시글 페이지를 반환한다.")
    @Test
    void givenNoSearchParameters_whenSearchingArticles_thenReturnsArticlePage() {
        // given
        Pageable pageable = Pageable.ofSize(20);  // 페이지 크기 설정
        given(articleRepository.findAll(pageable)).willReturn(Page.empty());  // 게시글 리포지토리의 findAll 메서드가 호출되면 빈 페이지를 반환하도록 설정

        // when
        Page<ArticleDto> articles = sut.searchArticles(null, null, pageable);  // 게시글 검색 서비스 호출

        // then
        assertThat(articles).isEmpty();  // 반환된 게시글이 비어 있는지 확인
        then(articleRepository).should().findAll(pageable);  // 게시글 리포지토리의 findAll 메서드 호출 여부 확인
    }

    @DisplayName("검색어와 함께 게시글을 검색하면, 게시글 페이지를 반환한다.")
    @Test
    void givenSearchParameters_whenSearchingArticles_thenReturnsArticlePage() {
        // Given
        SearchType searchType = SearchType.TITLE;  // 검색 유형 설정
        String searchKeyword = "title";  // 검색 키워드 설정
        Pageable pageable = Pageable.ofSize(20);  // 페이지 크기 설정
        given(articleRepository.findByTitle(searchKeyword, pageable)).willReturn(Page.empty());  // 게시글 리포지토리의 findByTitle 메서드가 호출되면 빈 페이지를 반환하도록 설정

        // When
        Page<ArticleDto> articles = sut.searchArticles(searchType, searchKeyword, pageable);  // 게시글 검색 서비스 호출

        // Then
        assertThat(articles).isEmpty();  // 반환된 게시글이 비어 있는지 확인
        then(articleRepository).should().findByTitle(searchKeyword, pageable);  // 게시글 리포지토리의 findByTitle 메서드 호출 여부 확인
    }

    @DisplayName("게시글을 조회하면, 게시글을 반환한다.")
    @Test
    void givenArticleId_whenSearchingArticle_thenReturnsArticle() {
        // given
        Long articleId = 1L;  // 조회할 게시글 ID 설정
        Article article = createArticle();  // 테스트에 사용할 게시글 객체 생성
        given(articleRepository.findById(articleId)).willReturn(Optional.of(article));  // 게시글 리포지토리의 findById 메서드가 호출되면 게시글 객체를 Optional로 반환하도록 설정

        // when
        ArticleWithCommentsDto dto = sut.getArticle(articleId);  // 게시글 조회 서비스 호출

        // then
        assertThat(dto)
                .hasFieldOrPropertyWithValue("title", article.getTitle())  // 조회된 게시글의 제목이 기대값과 일치하는지 확인
                .hasFieldOrPropertyWithValue("content", article.getContent())  // 조회된 게시글의 내용이 기대값과 일치하는지 확인
                .hasFieldOrPropertyWithValue("hashtag", article.getHashtag());  // 조회된 게시글의 해시태그가 기대값과 일치하는지 확인
        then(articleRepository).should().findById(articleId);  // 게시글 리포지토리의 findById 메서드 호출 여부 확인
    }

    @DisplayName("없는 게시글을 조회하면, 예외를 던진다.")
    @Test
    void givenNonexistentArticleId_whenSearchingArticle_thenThrowsException() {
        // Given
        Long articleId = 0L;  // 존재하지 않는 게시글 ID 설정
        given(articleRepository.findById(articleId)).willReturn(Optional.empty());  // 게시글 리포지토리의 findById 메서드가 호출되면 빈 Optional을 반환하도록 설정

        // When
        Throwable t = catchThrowable(() -> sut.getArticle(articleId));  // 게시글 조회 서비스 호출 시 발생하는 예외를 잡아옴

        // Then
        assertThat(t)
                .isInstanceOf(EntityNotFoundException.class)  // 발생한 예외가 EntityNotFoundException인지 확인
                .hasMessage("게시글이 없습니다 - articleId: " + articleId);  // 예외 메시지가 기대값과 일치하는지 확인
        then(articleRepository).should().findById(articleId);  // 게시글 리포지토리의 findById 메서드 호출 여부 확인
    }

    @DisplayName("게시글 정보를 입력하면, 게시글을 생성한다.")
    @Test
    void givenArticleInfo_whenSavingArticle_thenSavesArticle() {
        // given
        ArticleDto dto = createArticleDto();  // 테스트에 사용할 게시글 DTO 생성
        given(articleRepository.save(any(Article.class))).willReturn(createArticle());  // 게시글 저장 메서드가 호출되면 게시글 객체 반환하도록 설정

        // when
        sut.saveArticle(dto);  // 게시글 저장 서비스 호출

        // then
        then(articleRepository).should().save(any(Article.class));  // 게시글 저장 메서드 호출 여부 확인
    }

    @DisplayName("게시글의 수정 정보를 입력하면, 게시글을 수정한다.")
    @Test
    void givenModifiedArticleInfo_whenUpdatingArticle_thenUpdatesArticle() {
        // given
        Article article = createArticle();  // 기존 게시글 객체 생성
        ArticleDto dto = createArticleDto("새 타이틀", "새 내용", "#springboot");  // 수정된 게시글 DTO 생성
        given(articleRepository.getReferenceById(dto.id())).willReturn(article);  // 게시글 참조 메서드가 호출되면 기존 게시글 객체 반환

        // when
        sut.updateArticle(dto);  // 게시글 수정 서비스 호출

        // then
        assertThat(article)
                .hasFieldOrPropertyWithValue("title", dto.title())  // 수정된 게시글의 제목이 기대값과 일치하는지 확인
                .hasFieldOrPropertyWithValue("content", dto.content())  // 수정된 게시글의 내용이 기대값과 일치하는지 확인
                .hasFieldOrPropertyWithValue("hashtag", dto.hashtag());  // 수정된 게시글의 해시태그가 기대값과 일치하는지 확인
        then(articleRepository).should().getReferenceById(dto.id());  // 게시글 참조 메서드 호출 여부 확인
    }

    @DisplayName("없는 게시글의 수정 정보를 입력하면, 경고 로그를 찍고 아무 것도 하지 않는다.")
    @Test
    void givenNonexistentArticleInfo_whenUpdatingArticle_thenLogsWarningAndDoesNothing() {
        // Given
        ArticleDto dto = createArticleDto("새 타이틀", "새 내용", "#springboot");  // 수정할 게시글 DTO 생성
        given(articleRepository.getReferenceById(dto.id())).willThrow(EntityNotFoundException.class);  // 게시글 참조 메서드가 호출되면 예외를 던지도록 설정

        // When
        sut.updateArticle(dto);  // 게시글 수정 서비스 호출

        // Then
        then(articleRepository).should().getReferenceById(dto.id());  // 게시글 참조 메서드 호출 여부 확인
    }

    @DisplayName("게시글의 ID를 입력하면, 게시글을 삭제한다.")
    @Test
    void givenArticleId_whenDeletingArticle_thenDeletesArticle() {
        // given
        Long articleId = 1L;  // 삭제할 게시글 ID 설정
        willDoNothing().given(articleRepository).deleteById(articleId);  // 게시글 삭제 메서드가 호출되면 아무 동작도 하지 않도록 설정

        // when
        sut.deleteArticle(1L);  // 게시글 삭제 서비스 호출

        // then
        then(articleRepository).should().deleteById(articleId);  // 게시글 삭제 메서드 호출 여부 확인
    }

    // 헬퍼 메서드들
    private UserAccount createUserAccount() {
        return UserAccount.of(
                "eunchan",
                "password",
                "eunchan@email.com",
                "Eunchan",
                null
        );
    }

    private Article createArticle() {
        return Article.of(
                createUserAccount(),
                "title",
                "content",
                "#java"
        );
    }

    private ArticleDto createArticleDto() {
        return createArticleDto("title", "content", "#java");
    }

    private ArticleDto createArticleDto(String title, String content, String hashtag) {
        return ArticleDto.of(1L,
                createUserAccountDto(),
                title,
                content,
                hashtag,
                LocalDateTime.now(),
                "Eunchan",
                LocalDateTime.now(),
                "Eunchan");
    }

    private UserAccountDto createUserAccountDto() {
        return UserAccountDto.of(
                1L,
                "eunchan",
                "password",
                "eunchan@mail.com",
                "Eunchan",
                "This is memo",
                LocalDateTime.now(),
                "eunchan",
                LocalDateTime.now(),
                "eunchan"
        );
    }
}
  • givenNoSearchParameters_whenSearchingArticles_thenReturnsArticlePage:
    • 검색 조건 없이 게시글을 검색할 때, 비어 있는 페이지가 반환되는지 확인한다.
  • givenSearchParameters_whenSearchingArticles_thenReturnsArticlePage:
    • 검색 조건(제목, 키워드)을 사용해 게시글을 검색할 때, 올바른 게시글 페이지가 반환되는지 검증한다.
  • givenArticleId_whenSearchingArticle_thenReturnsArticle:
    • 특정 ID로 게시글을 조회할 때, 해당 게시글이 제대로 반환되는지 테스트한다.
  • givenNonexistentArticleId_whenSearchingArticle_thenThrowsException:
    • 존재하지 않는 게시글을 조회할 때, 예외가 발생하는지 검증한다.
  • givenArticleInfo_whenSavingArticle_thenSavesArticle:
    • 게시글 정보를 입력받아 새 게시글을 생성하는 기능이 정상 작동하는지 테스트한다.
  • givenModifiedArticleInfo_whenUpdatingArticle_thenUpdatesArticle:
    • 기존 게시글을 수정하는 기능이 제대로 동작하는지 확인한다.
  • givenNonexistentArticleInfo_whenUpdatingArticle_thenLogsWarningAndDoesNothing:
    • 존재하지 않는 게시글을 수정하려 할 때, 시스템이 경고 로그를 남기고 아무 동작도 하지 않는지 테스트한다.
  • givenArticleId_whenDeletingArticle_thenDeletesArticle:
    • 게시글 ID를 통해 게시글을 삭제하는 기능이 정상 작동하는지 검증한다.

중간 정리

  • 루트 경로 설정: 루트 경로를 게시판 페이지로 설정하기 위해 MainController를 구현하고, 루트 경로에 대한 요청을 게시판 페이지로 forward하도록 하였다. 이를 통해 사용자가 웹 애플리케이션의 루트 경로에 접근했을 때 자동으로 게시판 페이지로 이동하게 만들었다.
  • DTO, Repository, Service 계층 구현:
    • 데이터 전송 객체(DTO)를 통해 데이터를 캡슐화하고, 이를 활용해 서비스 계층에서 비즈니스 로직을 처리하였다.
    • ArticleRepository와 ArticleCommentRepository를 통해 게시글과 댓글 데이터를 관리하고, Querydsl을 사용하여 동적 쿼리 기능을 구현하였다.
    • ArticleService와 ArticleCommentService를 통해 게시글과 댓글의 생성, 조회, 수정, 삭제 기능을 구현하였다.
  • 테스트 코드 작성:
    • 각 서비스 기능이 의도한 대로 동작하는지 확인하기 위해 단위 테스트를 작성하였다.
    • 게시글과 댓글의 조회, 생성, 수정, 삭제에 대한 다양한 시나리오를 테스트하여, 코드의 안정성을 확보하였다.
    • Mocking 기법을 사용하여 데이터베이스에 실제로 접근하지 않고도 테스트를 수행할 수 있도록 하였다.

도메인 수정 - 회원 계정과 게시글, 댓글 연관관계 연결

이제 게시글과 댓글이 각각 UserAccount 엔티티와 연관관계를 맺도록 수정했다.ArticleArticleCommentUserAccountManyToOne 관계를 통해 연결되며, 이를 통해 사용자 정보와 콘텐츠 간의 관계를 명확하게 정의했다. 엔티티와 SQL 데이터, 그리고 JPA 테스트 코드를 수정하여 일관성 있는 데이터 구조를 유지할 수 있게 되었다. 추가된 관계는 이후 기능 확장에도 활용될 예정이다.

 

이로 인해 작성된 코드는 다음과 같다.

Article

package org.example.projectboard.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;

@Getter
@ToString(callSuper = true)
@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; // 해시태그

    @ToString.Exclude
    @OrderBy("createdAt DESC")
    @OneToMany(mappedBy = "article", cascade = CascadeType.ALL)
    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 instanceof Article article)) return false;
        return id != null && id.equals(article.id);
    }

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

@Setter @ManyToOne(optional = false) private UserAccount userAccount;

  • Article 엔티티가 UserAccount와 ManyToOne 관계로 설정되었다. 이는 하나의 사용자 계정이 여러 개의 게시글을 작성할 수 있음을 의미한다. optional = false로 설정하여 이 관계가 필수임을 명시했다.

private Article(UserAccount userAccount, String title, String content, String hashtag) {
    this.userAccount = userAccount;
    this.title = title;
    this.content = content;
    this.hashtag = hashtag;
}

  • 생성자에 UserAccount를 추가하여 게시글이 생성될 때 작성자 정보를 함께 받아오도록 수정했다.

public static Article of(UserAccount userAccount, String title, String content, String hashtag) {
    return new Article(userAccount, title, content, hashtag);
}

  • Article 객체를 생성할 때 UserAccout 정보를 함께 받는 정적 팩토리 메서드를 추가했다.

ArticleComment

package org.example.projectboard.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.util.Objects;

@Getter
@ToString(callSuper = true)
@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; // 본문

    public ArticleComment() {}

    public 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 instanceof ArticleComment that)) return false;
        return id != null && id.equals(that.id);
    }

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

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

  • ArticleComment 엔티티가 UserAccount와 ManyToOne 관계로 설정되었다. 이는 하나의 사용자 계정이 여러 개의 댓글을 작성할 수 있음을 의미한다. optional = false로 설정하여 이 관계가 필수임을 명시했다.

private ArticleComment(Article article, UserAccount userAccount, String content) {
    this.article = article;
    this.userAccount = userAccount;
    this.content = content;
}

  • 생성자에 UserAccount를 추가하여 댓글이 생성될 때 작성자 정보를 함께 받아오도록 수정했다.

data.sql

-- 테스트 계정
-- TODO: 테스트용이지만 비밀번호가 노출된 데이터 세팅. 개선하는 것이 좋을 지 고민해 보자.
insert into user_account (user_id, user_password, nickname, email, memo, created_at, created_by, modified_at, modified_by) values
    ('eunchan', 'asdf1234', 'Eunchan', 'eunchan@mail.com', 'I am Eunchan.', now(), 'eunchan', now(), 'eunchan');


-- 123개 게시글
insert into article (user_account_id, title, content, hashtag, created_by, modified_by, created_at, modified_at) values (1, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit.', 'Aenean fermentum. Donec ut mauris eget massa tempor convallis. Nulla neque libero, convallis eget, eleifend luctus, ultricies eu, nibh.', 'Aquamarine', 'Odette', 'Angel', '2024-06-29 02:15:00', '2024-06-28 11:51:06');
.
.
.

-- 1000개 댓글
insert into article_comment (article_id, user_account_id, content, created_by, modified_by, created_at, modified_at) values (1,  1, 'Proin interdum mauris non ligula pellentesque ultrices. Phasellus id sapien in sapien iaculis congue. Vivamus metus arcu, adipiscing molestie, hendrerit at, vulputate vitae, nisl.', 'Wolfgang', 'Teressa', '2023-08-28 01:46:41', '2024-06-03 19:21:42');
.
.
.
  • Article 테이블에 데이터를 삽입할 때 user_account_id를 포함시켰다. 이는 각 게시글이 특정 사용자 계정과 연관됨을 나타낸다.

JpaRepositoryTest

package org.example.projectboard.repository;

import org.example.projectboard.config.JpaConfig;
import org.example.projectboard.domain.Article;
import org.example.projectboard.domain.UserAccount;
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.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;

import java.util.List;

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(123);
    }

    @DisplayName("insert 테스트")
    @Test
    void givenTestData_whenInserting_thenWorksFine() {
        // Given
        long previousCount = articleRepository.count();
        UserAccount userAccount = userAccountRepository.save(UserAccount.of("eunchan", "pw", null, null, null));
        Article article = Article.of(userAccount, "new article", "new content", "#spring");

        // When
        articleRepository.save(article);

        // Then
        assertThat(articleRepository.count()).isEqualTo(previousCount + 1);
    }

    @DisplayName("update 테스트")
    @Test
    void givenTestData_whenUpdating_thenWorksFine() {
        // Given
        Article article = articleRepository.findById(1L).orElseThrow();
        String updatedHashtag = "#springboot";
        article.setHashtag(updatedHashtag);

        // When
        Article savedArticle = articleRepository.saveAndFlush(article);

        // Then
        assertThat(savedArticle).hasFieldOrPropertyWithValue("hashtag", updatedHashtag);
    }

    @DisplayName("delete 테스트")
    @Test
    void givenTestData_whenDeleting_thenWorksFine() {
        // Given
        Article article = articleRepository.findById(1L).orElseThrow();
        long previousArticleCount = articleRepository.count();
        long previousArticleCommentCount = articleCommentRepository.count();
        int deletedCommentsSize = article.getArticleComments().size();

        // When
        articleRepository.delete(article);

        // Then
        assertThat(articleRepository.count()).isEqualTo(previousArticleCount - 1);
        assertThat(articleCommentRepository.count()).isEqualTo(previousArticleCommentCount - deletedCommentsSize);
    }
}

private final UserAccountRepository userAccountRepository;

  • UserAccount와 관련된 데이터 처리를 위해 UserAccountRepository를 추가했다.

UserAccount userAccount = userAccountRepository.save(UserAccount.of("eunchan", "pw", null, null, null));
Article article = Article.of(userAccount, "new article", "new content", "#spring");

  • 테스트 시, UserAccount를 먼저 저장한 후 이 계정을 기반으로 Article을 생성하여 저장하도록 수정했다.

위와 같은 변경사항들은 사용자 계정과 게시글, 댓글 간의 연관관계를 명확히 하고, 데이터의 무결성을 유지하며, 각각의 엔티티가 올바르게 매핑되도록 도와준다.

 

뷰 디자인 업데이트 및 템플릿 추가

[수정]

search-bar.css

/* 검색창 */
.search-form {
    width: 80%;
    margin: 0 auto;
    margin-top: 1rem;
}

.search-form input {
    height: 100%;
    background: transparent;
    border: 0;
    display: block;
    width: 100%;
    padding: 1rem;
    height: 100%;
    font-size: 1rem;
}

.search-form select {
    background: transparent;
    border: 0;
    padding: 1rem;
    height: 100%;
    font-size: 1rem;
}

.search-form select:focus {
    border: 0;
}

.search-form button {
    height: 100%;
    width: 100%;
    font-size: 1rem;
}

.search-form button svg {
    width: 24px;
    height: 24px;
}

.card-margin {
    margin-bottom: 1.875rem;
}

@media (min-width: 992px) {
    .col-lg-2 {
        flex: 0 0 16.66667%;
        max-width: 16.66667%;
    }
}

.card {
    border: 0;
    box-shadow: 0px 0px 10px 0px rgba(82, 63, 105, 0.1);
    -webkit-box-shadow: 0px 0px 10px 0px rgba(82, 63, 105, 0.1);
    -moz-box-shadow: 0px 0px 10px 0px rgba(82, 63, 105, 0.1);
    -ms-box-shadow: 0px 0px 10px 0px rgba(82, 63, 105, 0.1);
}
.card {
    position: relative;
    display: flex;
    flex-direction: column;
    min-width: 0;
    word-wrap: break-word;
    background-color: #ffffff;
    background-clip: border-box;
    border: 1px solid #e6e4e9;
    border-radius: 8px;
}
  • 전체 정렬을 했다.

detail.html

...
<link href="/css/articles/article-content.css" rel="stylesheet">
</head>

<body>
    ...
    <div class="row g-5">
        <section class="col-md-5 col-lg-4 order-md-last">
            <aside>
                <p><span>Eunchan</span></p>
                <p><a href="mailto:djkehh@gmail.com">uno@mail.com</a></p>
                <p><time datetime="2024-01-01T00:00:00">2024-01-01</time></p>
                <p><span>#java</span></p>
            </aside>
        </section>

        <article class="col-md-7 col-lg-8">
            <pre>본문<br><br></pre>
        </article>
    </div>
    ...

    <ul class="row col-7">
        <li>
            <div>
                <strong>Eunchan</strong>
                <small><time>2024-01-01</time></small>
                <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>Lorem ipsum dolor sit amet</p>
            </div>
        </li>
        <li>
            <div>
                <strong>Eunchan</strong>
                <small><time>2024-01-01</time></small>
                <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>Lorem ipsum dolor sit amet</p>
            </div>
        </li>
    </ul>
    ...
</body>
  • article-content.css를 포함하는 로직을 추가했다.
  • 게시글 상세 페이지에 article-content.css를 추가하고, 본문을 <pre> 태그로 감싸 포맷팅을 유지하도록 변경하였다.
  • 작성자 정보에 <time> 태그를 사용하여 시간 정보의 의미를 명확하게 하였다.

index.html

    ...
    
    <tr>
        <td>두 번째 글</td>
        <td>#spring</td>
        <td>Eunchan</td>
        <td><time>2024-08-07</time></td>
    </tr>
    <tr>
        <td>세 번째 글</td>
        <td>#java</td>
        <td>Eunchan</td>
        <td><time>2024-08-08</time></td>
    </tr>
    </tbody>
</table>

	...
  • 게시글 목록 페이지에 <time> 태그를 사용하여 시간 정보를 나타냈다.

[추가]

article-content.css

/* 게시글 본문 */
#article-content > pre {
    white-space: pre-wrap; /* Since CSS 2.1 */
    white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
    white-space: pre-wrap; /* Opera 4-6 */
    white-space: -o-pre-wrap; /* Opera 7 */
    word-wrap: break-word; /* Internet Explorer 5.5+ */
}
  • #article-content > pre CSS 선택자는 게시글 본문에서 가로 스크롤바가 생기지 않도록 pre-wrap 스타일을 적용하였다.

search-hashtag.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Articles</title>
</head>
<body>
게시글 해시태그 검색
</body>
</html>

 

search.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Articles</title>
</head>
<body>
게시글 검색
</body>
</html>
  • search.html 및 search-hashtag.html 게시글과 해시태그 검색을 위한 페이지 템플릿을 추가하였다.

sign-up.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>회원 가입</title>
</head>
<body>
<header>
    header 템플릿 삽입부
    <hr>
</header>

<form>
    <label for="userId">ID</label>
    <input id="userId" type="text" required>
    <label for="password">Password</label>
    <input id="password" type="password" required>
    <label for="email">Email</label>
    <input id="email" type="email" placeholder="you@example.com">
    <label for="nickname">Nickname</label>
    <input id="nickname" type="text">
    <label for="memo">메모</label>
    <textarea id="memo" rows="3"></textarea>
    <button type="submit">가입</button>
</form>

<footer>
    <hr>
    footer 템플릿 삽입부
</footer>

</body>
</html>
  • 회원 가입을 위한 폼을 포함한 템플릿을 추가하였다.

마무리

이 글에서는 프로젝트 초기 설정부터 게시글 서비스 기능 구현까지의 전체 개발 과정을 다루었다.

주요 내용은 다음과 같다.

  • 1. 루트 경로 설정: 프로젝트의 기본 설정과 엔트리 포인트를 정의했다.
  • 2. 도메인 설계: 엔티티 간의 관계를 설정하고, 데이터베이스에 매핑되는 클래스를 정의했다.
  • 3. 서비스 구현: 비즈니스 로직을 담은 서비스 계층을 작성했다.
  • 4. 테스트 코드 작성: 구현된 기능을 검증하기 위해 JPA 기반의 테스트 코드를 작성했다.
  • 5. 뷰 디자인 업데이트 및 템플릿 추가: UI 개선을 위해 CSS 파일을 수정하고, 새로운 HTML 템플릿을 추가했다.
  • 6. 도메인 수정: UserAccount와 Article, ArticleComment 엔티티를 연관시켜 게시글과 댓글이 특정 사용자 계정과 연결되도록 했다.

이번 작업에서는 루트 경로 설정부터 도메인 수정까지의 모든 과정을 완료했다. 특히, 도메인 수정 작업을 통해 사용자 계정과 게시글, 댓글 간의 관계를 명확하게 연결함으로써 데이터 무결성을 강화했다. 이로써 프로젝트의 기본 구조를 더욱 견고히 다졌으며, 앞으로의 기능 확장을 위한 중요한 기반을 마련했다.