공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
협업 개발자 코드 검토
도메인 설계 작업에 앞서, 협업 개발자가 개발을 하고 깃허브에 푸시한 상황을 가정해 보고 pull request를 분석해 보자.
기존 이슈를 확인한다.
협업 개발자가 작업한 내용을 확인한다.
연관관계 필드만 정리했다고 한다. 이슈도 클릭해서 어떤 업무를 정리했는지 확인한다.
코드도 확인해 본다. 연관관계가 추가될 가능성을 고려해 싱글라인에서 멀티라인으로 변경한 것으로 보인다.
소스 코드를 모두 확인 후 리뷰를 남겨준다. 다음 변경사항도 확인해 보자.
기존 이슈를 확인한다.
협업 개발자가 작업한 내용을 확인한다.
코드를 확인한 결과, ID에 직접 접근하던 부분을 getter 메서드를 통해 접근하도록 코드를 수정하였다. 이는 Hibernate의 지연 로딩에서 프록시 객체를 사용할 때 발생할 수 있는 값 비교 문제를 방지하기 위한 조치로 보인다.
따라서 원활한 코드 리뷰를 위해서는 Pull Request와 코드 리뷰 자체를 잘 작성하는 것이 중요하다.
추가 도메인 설계
기존 도메인 코드 분석
기존 ERD
변경된 ERD
1. 해시태그 시스템 개선
기존 설계에서 게시글(article) 테이블에 직접 포함되어 있던 해시태그 필드가 제거되었다. 대신, 새로운 해시태그(hashtag) 테이블이 추가되었다. 이 테이블은 해시태그 이름만을 저장하는 간단한 구조를 가진다.
게시글과 해시태그 간의 다대다 관계를 관리하기 위해 매핑 테이블(article_hashtag)이 도입되었다. 이 테이블은 게시글 ID와 해시태그 ID를 외래 키로 가진다. 이를 통해 각 게시글이 어떤 해시태그들과 연관되어 있는지 효율적으로 관리할 수 있다.
매핑 테이블을 직접 정의함으로써 얻을 수 있는 이점은 다음과 같다:
- 테이블에 대한 직접적인 제어가 가능하다. 필요시 추가 컬럼을 정의하거나 인덱스를 설정할 수 있다.
- 데이터베이스 설계가 명시적으로 드러나 DBA와의 협업이 용이해진다.
- JPA의 자동 생성 기능에 의존하지 않아 데이터베이스 스키마에 대한 완전한 통제권을 갖는다.
다만, 이번 프로젝트에서는 학습 목적으로 JPA의 다대다 매핑을 사용할 예정이다. 이를 통해 자동 생성된 매핑 테이블의 장단점을 직접 경험하고, 수동으로 정의한 매핑 테이블과 비교 분석할 수 있는 기회를 가질 것이다.
2. 계층형 댓글 구현
댓글(article_comment) 테이블에 'parent_comment_id' 필드가 추가되었다. 이는 자기 참조 외래 키로, 댓글의 계층 구조를 표현한다. 이를 통해 대댓글 기능을 구현할 수 있으며, 댓글 간의 관계를 명확히 표현할 수 있다.
이러한 설계 변경을 통해 더욱 유연하고 확장 가능한 게시글 시스템을 구축할 수 있게 되었다. 해시태그 시스템의 개선으로 검색 및 분류 기능이 강화되었고, 계층형 댓글 구조를 통해 사용자 간의 더욱 풍부한 상호작용이 가능해졌다.
대규모 파일 수정 작업 - ERD 설계 반영
ERD 설계에 맞춰 코드를 수정해 보았다. 해시태그가 여러 개로 변경되면서 연관된 클래스들, 즉 response, request, DTO, data.sql, 그리고 테스트 코드 모두 수정이 필요했다. 특히 도메인 코드와 직접적으로 연결된 리포지토리와 DTO 코드에서 가장 큰 변화가 있었다.
현재 테스트는 실패하는 상태이지만, 컴파일 에러는 발생하지 않는다. 비록 Article과 Hashtag 두 개의 엔티티만 변경되었을 뿐이지만, 이와 관련된 거의 모든 클래스들을 함께 수정해 주어야 했다. 이는 시스템 내 객체들 간의 높은 결합도를 보여주는 단적인 예시라고 할 수 있다.
1. 도메인 엔티티 클래스 (Domain Entities)
Article.java(수정)
먼저 Article.java 클래스를 수정했다.
기존에는 해시태그가 단순한 문자열 필드로 존재했지만, 수정 후에는 Hashtag 엔티티와의 다대다 관계로 변경되었다.
package org.example.projectboard.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
@Getter
@ToString(callSuper = true)
@Table(indexes = {
@Index(columnList = "title"),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy"),
})
@Entity
public class Article extends AuditingFields {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter
@ManyToOne(optional = false)
@JoinColumn(name = "userId")
private UserAccount userAccount; // 유저 정보(ID)
@Setter @Column(nullable = false) private String title; // 제목
@Setter @Column(nullable = false, length = 10000) private String content; // 본문
@ToString.Exclude
@JoinTable(
name = "article_hashtag",
joinColumns = @JoinColumn(name = "articleId"),
inverseJoinColumns = @JoinColumn(name = "hashtagId")
)
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private Set<Hashtag> hashtags = new LinkedHashSet<>();
...
protected Article(){}
private Article(UserAccount userAccount, String title, String content) {
this.userAccount = userAccount;
this.title = title;
this.content = content;
}
public static Article of(UserAccount userAccount, String title, String content) {
return new Article(userAccount, title, content);
}
public void addHashtag(Hashtag hashtag) {
this.getHashtags().add(hashtag);
}
public void addHashtags(Collection<Hashtag> hashtags) {
this.getHashtags().addAll(hashtags);
}
public void clearHashtags() {
this.getHashtags().clear();
}
...
}
- 해시태그 필드 제거
- 기존의 hashtag 문자열 필드가 제거되었다.
- 다대다 관계 설정
- 새로운 hashtags 필드가 추가되었으며, 이 필드는 Set<Hashtag> 타입으로 여러 해시태그를 관리한다.
- @ManyToMany 어노테이션과 함께 @JoinTable을 사용하여 article_hashtag 매핑 테이블을 정의했다.
- 이를 통해 게시글과 해시태그 간의 관계를 데이터베이스에서 명확하게 관리할 수 있게 되었다.
- 해시태그 관리 메서드 추가
- 해시태그를 추가하거나 제거하는 addHashtag, addHashtags, clearHashtags 메서드가 추가되어, 동적으로 해시태그를 관리할 수 있게 되었다.
이 변경을 통해 게시글과 해시태그 간의 관계를 더 유연하게 관리할 수 있게 되었으며, 시스템의 확장성과 유지보수성도 크게 향상되었다.
Hashtag.java(추가)
Hashtag.java는 새롭게 추가된 클래스이며, 해시태그를 별도로 관리하기 위해 설계된 엔티티다.
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 = "hashtagName", unique = true),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy")
})
@Entity
public class Hashtag extends AuditingFields {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ToString.Exclude
@ManyToMany(mappedBy = "hashtags")
private Set<Article> articles = new LinkedHashSet<>();
@Setter @Column(nullable = false) private String hashtagName; // 해시태그 이름
protected Hashtag() {}
private Hashtag(String hashtagName) {
this.hashtagName = hashtagName;
}
public static Hashtag of(String hashtagName) {
return new Hashtag(hashtagName);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Hashtag that)) return false;
return this.getId() != null && this.getId().equals(that.getId());
}
@Override
public int hashCode() {
return Objects.hash(this.getId());
}
}
- 해시태그 엔티티 정의
- Hashtag 클래스는 hashtagName이라는 필드를 가지고 있으며, 이는 해시태그의 이름을 저장하는 역할을 한다.
- 이 필드는 @Column(nullable = false)로 설정되어 있어, 반드시 값이 존재해야 한다.
- 다대다 관계 설정
- Article 엔티티와의 다대다 관계가 @ManyToMany 어노테이션을 통해 정의되었다.
- 이 관계는 Article 클래스에서 hashtags 필드로 연결되며, Hashtag 클래스에서는 articles 필드로 역참조 된다.
- 인덱스 설정
- hashtagName 필드에 인덱스를 설정하여, 데이터베이스에서 해시태그 검색 성능을 최적화했다.
- 특히, hashtagName 필드는 유일성 제약 조건이 설정되어, 동일한 이름의 해시태그가 중복되지 않도록 한다.
- 객체 메서드
- equals와 hashCode 메서드가 오버라이드되어, Hashtag 객체의 동일성을 id 값으로 판단하도록 설정되었다.
- 이는 엔티티의 식별자를 기준으로 객체를 비교할 수 있게 해 준다.
이 클래스를 추가함으로써, 해시태그와 관련된 데이터를 독립적으로 관리할 수 있게 되었고, 게시글과 해시태그 간의 관계를 더 체계적으로 관리할 수 있게 되었다. 이로써 시스템의 확장성과 유지보수성이 크게 향상되었다.
2. 데이터 전송 객체(DTO)
ArticleDto.java(수정)
ArticleDto에서는 해시태그를 관리하는 방식이 문자열에서 Set<HashtagDto>로 변경되었다.
이를 통해 해시태그를 객체로 다루며, 데이터의 일관성과 관리 효율성을 높였다.
package org.example.projectboard.dto;
import org.example.projectboard.domain.Article;
import org.example.projectboard.domain.UserAccount;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.stream.Collectors;
/**
* DTO for {@link org.example.projectboard.domain.Article}
*/
public record ArticleDto(
Long id,
UserAccountDto userAccountDto,
String title,
String content,
Set<HashtagDto> hashtagDtos,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
public static ArticleDto of(UserAccountDto userAccountDto, String title, String content, Set<HashtagDto> hashtagDtos) {
return new ArticleDto(null, userAccountDto, title, content, hashtagDtos, null, null, null, null);
}
public static ArticleDto of(Long id, UserAccountDto userAccountDto, String title, String content, Set<HashtagDto> hashtagDtos, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
return new ArticleDto(id, userAccountDto, title, content, hashtagDtos, createdAt, createdBy, modifiedAt, modifiedBy);
}
public static ArticleDto from(Article entity) {
return new ArticleDto(
entity.getId(),
UserAccountDto.from(entity.getUserAccount()),
entity.getTitle(),
entity.getContent(),
entity.getHashtags().stream()
.map(HashtagDto::from)
.collect(Collectors.toUnmodifiableSet())
,
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
public Article toEntity(UserAccount userAccount) {
return Article.of(
userAccount,
title,
content
);
}
}
- 해시태그 필드 변경
- 기존의 문자열 필드 대신 Set<HashtagDto> 타입의 hashtagDtos 필드가 추가되었다.
- 이로써 여러 해시태그를 객체로 관리할 수 있게 되었다.
- from 메서드 수정
- Article 엔티티에서 해시태그를 가져올 때, 문자열 대신 HashtagDto 객체로 변환하여 ArticleDto에 포함시키도록 from 메서드가 수정되었다.
- 이를 위해 stream()과 map()을 사용해 HashtagDto로 변환한 후, collect(Collectors.toUnmodifiableSet())로 변환된 데이터를 Set으로 모아준다.
- 생성자 메서드 수정
- of 메서드에서 해시태그를 Set<HashtagDto>로 전달받아 ArticleDto 객체를 생성할 수 있도록 변경되었다.
이러한 변경으로 해시태그를 객체로 관리하게 되어, 코드의 일관성과 유지보수성이 높아졌다. 해시태그와 관련된 데이터의 처리가 더 명확하고 구조화되었다.
ArticleWithCommentsDto.java(수정)
ArticleWithCommentsDto에서도 해시태그 관리 방식이 문자열에서 Set<HashtagDto>로 변경되었다.
이를 통해 댓글과 해시태그를 함께 관리하면서도 데이터의 일관성과 관리 효율성을 유지할 수 있게 되었다.
package org.example.projectboard.dto;
import org.example.projectboard.domain.Article;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors;
public record ArticleWithCommentsDto(
Long id,
UserAccountDto userAccountDto,
Set<ArticleCommentDto> articleCommentDtos,
String title,
String content,
Set<HashtagDto> hashtagDtos,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
public static ArticleWithCommentsDto of(Long id, UserAccountDto userAccountDto, Set<ArticleCommentDto> articleCommentDtos, String title, String content, Set<HashtagDto> hashtagDtos, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
return new ArticleWithCommentsDto(id, userAccountDto, articleCommentDtos, title, content, hashtagDtos, 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.getHashtags().stream()
.map(HashtagDto::from)
.collect(Collectors.toUnmodifiableSet()),
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
}
- 해시태그 필드 추가
- 기존의 hashtag 문자열 필드가 Set<HashtagDto>로 변경되었다. 이를 통해 여러 해시태그를 객체로 관리할 수 있게 되었다.
- from 메서드 수정
- Article 엔티티에서 해시태그를 가져올 때, HashtagDto 객체로 변환하여 ArticleWithCommentsDto에 포함시키도록 from 메서드가 수정되었다.
- 이 과정에서는 stream()과 map()을 사용하여 HashtagDto로 변환한 후, collect(Collectors.toUnmodifiableSet())로 Set으로 변환하여 데이터를 모은다.
- 생성자 메서드 수정
- of 메서드에서 해시태그를 Set<HashtagDto>로 전달받아 ArticleWithCommentsDto 객체를 생성할 수 있도록 변경되었다.
이러한 변경으로 댓글이 포함된 게시글에서도 해시태그를 객체로 관리할 수 있게 되었으며, 데이터의 일관성과 코드의 유지보수성이 개선되었다. 해시태그와 관련된 데이터를 명확하고 구조적으로 처리할 수 있게 되어 코드의 가독성도 향상되었다.
HashtagDto.java와 HashtagWithArticlesDto.java 추가
새롭게 추가된 HashtagDto와 HashtagWithArticlesDto 클래스는 해시태그 관련 데이터를 효과적으로 관리하고, 해시태그와 연관된 게시글 정보를 처리하기 위해 설계되었다. 이 DTO 클래스들은 해시태그와 관련된 데이터를 캡슐화하여, 비즈니스 로직에서 해시태그를 명확하고 일관되게 다룰 수 있도록 한다.
package org.example.projectboard.dto;
import org.example.projectboard.domain.Hashtag;
import java.time.LocalDateTime;
public record HashtagDto(
Long id,
String hashtagName,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
public static HashtagDto of(String hashtagName) {
return new HashtagDto(null, hashtagName, null, null, null, null);
}
public static HashtagDto of(Long id, String hashtagName, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
return new HashtagDto(id, hashtagName, createdAt, createdBy, modifiedAt, modifiedBy);
}
public static HashtagDto from(Hashtag entity) {
return new HashtagDto(
entity.getId(),
entity.getHashtagName(),
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
public Hashtag toEntity() {
return Hashtag.of(hashtagName);
}
}
- HashtagDto는 해시태그의 이름, 생성 및 수정 정보를 관리하는 데이터 전송 객체이다.
- from 메서드를 통해 Hashtag 엔티티를 HashtagDto로 변환할 수 있으며, toEntity 메서드를 통해 HashtagDto를 Hashtag 엔티티로 변환할 수 있다.
- 이를 통해 해시태그 데이터를 쉽게 변환하고 다룰 수 있어, 시스템 전반에서 해시태그와 관련된 데이터를 일관성 있게 관리할 수 있게 된다.
package org.example.projectboard.dto;
import org.example.projectboard.domain.Hashtag;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.stream.Collectors;
public record HashtagWithArticlesDto(
Long id,
Set<ArticleDto> articles,
String hashtagName,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
public static HashtagWithArticlesDto of(Set<ArticleDto> articles, String hashtagName) {
return new HashtagWithArticlesDto(null, articles, hashtagName, null, null, null, null);
}
public static HashtagWithArticlesDto of(Long id, Set<ArticleDto> articles, String hashtagName, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
return new HashtagWithArticlesDto(id, articles, hashtagName, createdAt, createdBy, modifiedAt, modifiedBy);
}
public static HashtagWithArticlesDto from(Hashtag entity) {
return new HashtagWithArticlesDto(
entity.getId(),
entity.getArticles().stream()
.map(ArticleDto::from)
.collect(Collectors.toUnmodifiableSet())
,
entity.getHashtagName(),
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
public Hashtag toEntity() {
return Hashtag.of(hashtagName);
}
}
- HashtagWithArticlesDto는 해시태그와 해당 해시태그와 연관된 게시글 정보를 함께 관리하는 데이터 전송 객체이다.
- from 메서드를 통해 Hashtag 엔티티에서 관련 게시글을 Set<ArticleDto>로 변환하여 DTO 객체로 관리할 수 있다.
- 이 클래스는 해시태그와 연관된 게시글 정보를 캡슐화하여, 특정 해시태그와 그와 연관된 게시글을 명확하게 관리하고 조회할 수 있게 해 준다.
이 두 클래스는 해시태그와 관련된 데이터를 보다 구조화된 방식으로 관리할 수 있도록 도와주며, 시스템의 일관성을 유지하고 코드의 가독성을 향상한다.
3. 리포지토리 (Repositories)
ArticleRepository.java(수정)
ArticleRepository에서는 해시태그와 관련된 필드를 처리하는 방식이 수정되었다.
기존에는 해시태그가 문자열로 처리되었으나, 수정 후에는 Set<Hashtag> 타입의 필드를 지원하도록 변경되었다.
이로 인해 Querydsl 바인딩 설정도 함께 수정되었다.
package org.example.projectboard.repository;
...
@RepositoryRestResource
public interface ArticleRepository extends
JpaRepository<Article, Long>,
ArticleRepositoryCustom,
QuerydslPredicateExecutor<Article>,
QuerydslBinderCustomizer<QArticle>{
...
@Override
default void customize(QuerydslBindings bindings, QArticle root){
bindings.excludeUnlistedProperties(true);
bindings.including(root.title, root.content, root.hashtags, root.createdAt, root.createdBy);
bindings.bind(root.hashtags.any().hashtagName).first(StringExpression::containsIgnoreCase);
bindings.bind(root.title).first(StringExpression::containsIgnoreCase);
bindings.bind(root.content).first(StringExpression::containsIgnoreCase);
bindings.bind(root.createdAt).first(DateTimeExpression::eq);
bindings.bind(root.createdBy).first(StringExpression::containsIgnoreCase);
}
}
- 필드 포함 설정:
- 기존에는 bindings.including(root.title, root.content, root.hashtag, root.createdAt, root.createdBy); 형태로 설정되어 있었다. 여기서 root.hashtag는 단일 문자열 필드를 의미했다.
- 수정 후에는 bindings.including(root.title, root.content, root.hashtags, root.createdAt, root.createdBy);로 변경되어, 해시태그가 Set<Hashtag>로 관리되는 구조에 맞게 root.hashtags를 포함하도록 수정되었다.
- 해시태그 바인딩 설정:
- 기존의 root.hashtag 바인딩 대신, bindings.bind(root.hashtags.any().hashtagName)으로 변경되었다. 이는 Set<Hashtag> 내의 각 해시태그 이름을 기준으로 검색할 수 있도록 지원하며, 해시태그 필드가 다수의 해시태그 객체로 구성된 경우에도 효과적으로 동작하도록 설정되었다.
이러한 변경을 통해, 해시태그가 문자열이 아닌 객체로 관리되는 상황에서도 Querydsl을 사용하여 효율적으로 검색하고 필터링할 수 있게 되었다. 데이터베이스에서의 검색 성능도 최적화되며, 코드의 일관성과 유지보수성이 크게 향상되었다.
HashtagRepository.java(추가)
HashtagRepository는 Hashtag 엔티티와 관련된 데이터베이스 작업을 처리하기 위해 설계된 리포지토리 인터페이스이다.
이 리포지토리는 기본적인 CRUD 기능 외에도 해시태그를 효율적으로 검색하고 관리하기 위한 추가 기능들을 제공한다.
package org.example.projectboard.repository;
import org.example.projectboard.domain.Hashtag;
import org.example.projectboard.repository.querydsl.HashtagRepositoryCustom;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@RepositoryRestResource
public interface HashtagRepository extends
JpaRepository<Hashtag, Long>,
HashtagRepositoryCustom,
QuerydslPredicateExecutor<Hashtag> {
Optional<Hashtag> findByHashtagName(String hashtagName);
List<Hashtag> findByHashtagNameIn(Set<String> hashtagNames);
}
기본 CRUD 기능
- JpaRepository<Hashtag, Long>를 상속받아 Hashtag 엔티티에 대한 기본적인 CRUD(Create, Read, Update, Delete) 기능을 자동으로 제공한다.
- 예를 들어, save(), findById(), deleteById() 등의 메서드를 통해 해시태그 데이터를 손쉽게 처리할 수 있다.
해시태그 이름으로 검색
- Optional<Hashtag> findByHashtagName(String hashtagName);
- 이 메서드는 특정 해시태그 이름으로 Hashtag 엔티티를 검색한다. Optional로 결과를 반환하기 때문에, 해시태그가 존재하지 않을 경우 안전하게 null을 처리할 수 있다.
- 예를 들어, 사용자가 입력한 해시태그 이름에 해당하는 해시태그가 데이터베이스에 존재하는지 확인하고자 할 때 이 메서드를 사용할 수 있다.
다중 해시태그 이름으로 검색
- List<Hashtag> findByHashtagNameIn(Set<String> hashtagNames);
- 이 메서드는 여러 해시태그 이름을 한 번에 검색할 수 있게 한다. Set<String> 타입으로 해시태그 이름들을 전달하면, 해당 이름들에 해당하는 모든 Hashtag 엔티티들을 List<Hashtag>로 반환한다.
- 예를 들어, 사용자가 여러 해시태그를 검색할 때, 해당 해시태그들이 데이터베이스에 존재하는지 확인하고, 그 해시태그들을 가져올 때 유용하게 사용할 수 있다.
Querydsl 지원
- QuerydslPredicateExecutor<Hashtag>를 구현하여, Querydsl을 통해 동적 쿼리를 작성하고 실행할 수 있다.
- Querydsl을 사용하면 복잡한 검색 조건을 타입 안전하게 작성할 수 있으며, 런타임 대신 컴파일 타임에 쿼리의 오류를 감지할 수 있어 안전한 코드 작성이 가능하다.
커스텀 리포지토리 지원
- HashtagRepositoryCustom을 상속받아 추가적인 커스텀 메서드를 구현할 수 있다. 이 부분은 복잡한 쿼리나 비즈니스 로직을 추가적으로 처리하고자 할 때 유용하다.
결과적으로, HashtagRepository는 해시태그와 관련된 데이터를 효율적으로 관리하고, 다양한 검색 기능을 제공하여 해시태그 기반의 검색과 관리 작업을 최적화할 수 있다. 이를 통해 해시태그를 중심으로 한 기능 구현이 더욱 간편해지며, 애플리케이션의 확장성과 유지보수성이 크게 향상된다.
ArticleRepositoryCustom.java와 ArticleRepositoryCustomImpl.java 수정
ArticleRepositoryCustom과 ArticleRepositoryCustomImpl에서는 해시태그 관리 방식의 변화에 따라 메서드들이 수정되었다.
기존에 문자열로 해시태그를 관리하던 방식에서, 해시태그 도메인이 새로 추가됨에 따라 더 이상 사용되지 않는 메서드는 비활성화되었으며, 새로운 기능이 추가되었다.
package org.example.projectboard.repository.querydsl;
import org.example.projectboard.domain.Article;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Collection;
import java.util.List;
public interface ArticleRepositoryCustom {
/**
* @deprecated 해시태그 도메인을 새로 만들었으므로 이 코드는 더 이상 사용할 필요 없다.
* @see HashtagRepositoryCustom#findAllHashtagNames()
*/
@Deprecated
List<String> findAllDistinctHashtags();
Page<Article> findByHashtagNames(Collection<String> hashtagNames, Pageable pageable);
}
findAllDistinctHashtags() 메서드 비활성화
- 기존의 findAllDistinctHashtags() 메서드는 해시태그를 문자열로 관리하던 구조에서 사용되었으나, 해시태그 도메인이 새로 생성되면서 이 메서드는 더 이상 필요하지 않게 되었다.
- @Deprecated 어노테이션이 추가되어 이 메서드가 더 이상 사용되지 않음을 표시했으며, 대체 메서드로 HashtagRepositoryCustom에 findAllHashtagNames()를 참조하도록 했다.
새로운 메서드 추가
- findByHashtagNames(Collection<String> hashtagNames, Pageable pageable) 메서드가 추가되었다. 이 메서드는 주어진 해시태그 이름 컬렉션에 속하는 게시글들을 페이지네이션하여 반환한다. 이로 인해 특정 해시태그들에 해당하는 게시글을 효율적으로 검색하고, 페이지네이션을 통해 결과를 관리할 수 있게 되었다.
HashtagRepositoryCustom(추가)
HashtagRepositoryCustom은 Hashtag 엔티티와 관련된 커스텀 쿼리 기능을 제공하기 위해 설계된 인터페이스다.
이 인터페이스는 표준 JPA 리포지토리 메서드 외에 추가적인 쿼리 로직을 구현할 수 있도록 도와준다.
package org.example.projectboard.repository.querydsl;
import java.util.List;
public interface HashtagRepositoryCustom {
List<String> findAllHashtagNames();
}
커스텀 메서드 정의
- List<String> findAllHashtagNames() 메서드는 Hashtag 엔티티에서 모든 해시태그 이름을 검색하는 기능을 제공한다.
- 이 메서드는 표준 JPA 리포지토리 메서드로는 지원되지 않는 맞춤형 쿼리 로직을 구현하기 위해 정의되었다.
확장성 제공
- 이 인터페이스를 통해 HashtagRepository에 더 복잡한 쿼리나 특정 비즈니스 로직을 추가할 수 있다. 이를 통해 데이터베이스 쿼리 성능을 최적화하거나, 복잡한 검색 조건을 처리할 수 있다.
HashtagRepositoryCustomImpl(추가)
HashtagRepositoryCustomImpl은 HashtagRepositoryCustom 인터페이스를 구현한 클래스다.
이 클래스는 QueryDSL을 사용해 커스텀 쿼리를 실제로 구현하고, 이를 HashtagRepository에서 사용할 수 있도록 한다.
package org.example.projectboard.repository.querydsl;
import org.example.projectboard.domain.Hashtag;
import org.example.projectboard.domain.QHashtag;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import java.util.List;
public class HashtagRepositoryCustomImpl extends QuerydslRepositorySupport implements HashtagRepositoryCustom {
public HashtagRepositoryCustomImpl() {
super(Hashtag.class);
}
@Override
public List<String> findAllHashtagNames() {
QHashtag hashtag = QHashtag.hashtag;
return from(hashtag)
.select(hashtag.hashtagName)
.fetch();
}
}
QuerydslRepositorySupport 상속
- HashtagRepositoryCustomImpl 클래스는 QuerydslRepositorySupport 클래스를 상속받아, QueryDSL을 활용한 동적 쿼리 작성 및 실행을 지원한다.
- 이 상속 덕분에 QueryDSL을 사용해 쉽게 쿼리를 빌드하고 실행할 수 있으며, Hashtag 엔티티와 관련된 모든 데이터베이스 작업을 처리할 수 있다.
findAllHashtagNames() 메서드 구현
- 이 메서드는 HashtagRepositoryCustom 인터페이스에 정의된 findAllHashtagNames() 메서드를 실제로 구현한 것이다.
- QHashtag 클래스를 사용해 Hashtag 엔티티에 접근하고, select(hashtag.hashtagName) 구문을 통해 데이터베이스에서 모든 해시태그 이름을 검색한 뒤 리스트로 반환한다.
- fetch() 메서드를 사용해 결과를 가져오며, 이는 모든 해시태그 이름을 포함한 List<String>으로 반환된다.
QueryDSL을 사용한 커스텀 쿼리
- QuerydslRepositorySupport와 QHashtag를 사용해 동적 쿼리를 작성할 수 있다. 이를 통해 복잡한 쿼리 조건을 효과적으로 처리할 수 있으며, 데이터베이스 쿼리 성능을 최적화할 수 있다.
결과적으로, 이 두 클래스는 Hashtag와 관련된 커스텀 쿼리 로직을 구현하고 관리할 수 있게 해 준다. HashtagRepositoryCustom 인터페이스를 통해 메서드를 정의하고, HashtagRepositoryCustomImpl 클래스에서 QueryDSL을 활용해 실제 쿼리를 구현함으로써, 해시태그 데이터를 더욱 효율적으로 검색하고 관리할 수 있다.
4. 서비스 계층 (Service Layer)
ArticleService(수정)
ArticleService 클래스에는 해시태그의 관리 방식과 검색 기능이 변경됨에 따라 일부 메서드가 업데이트되었으며, 새로운 해시태그 검색 로직이 추가되었다.
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleService {
private final ArticleRepository articleRepository;
private final UserAccountRepository userAccountRepository;
@Transactional(readOnly = true)
public Page<ArticleDto> searchArticles(SearchType searchType, String searchKeyword, Pageable pageable) {
if (searchKeyword ==null || searchKeyword.isBlank()){
return articleRepository.findAll(pageable).map(ArticleDto::from);
}
return switch (searchType) {
case TITLE -> articleRepository.findByTitleContaining(searchKeyword, pageable).map(ArticleDto::from);
case CONTENT -> articleRepository.findByContentContaining(searchKeyword, pageable).map(ArticleDto::from);
case ID -> articleRepository.findByUserAccount_UserIdContaining(searchKeyword, pageable).map(ArticleDto::from);
case NICKNAME -> articleRepository.findByUserAccount_NicknameContaining(searchKeyword, pageable).map(ArticleDto::from);
case HASHTAG -> articleRepository.findByHashtagNames(
Arrays.stream(searchKeyword.split(" ")).toList(),
pageable
)
.map(ArticleDto::from);
};
}
...
@Transactional(readOnly = true)
public Page<ArticleDto> searchArticlesViaHashtag(String hashtag, Pageable pageable) {
if (hashtag == null || hashtag.isBlank()){
return Page.empty(pageable);
}
return articleRepository.findByHashtagNames(null, pageable).map(ArticleDto::from);
}
...
}
searchArticles 메서드 수정
- 기존에는 해시태그를 문자열로 처리했지만, 수정 후에는 여러 개의 해시태그를 처리할 수 있도록 findByHashtagNames 메서드를 호출하게 변경되었다.
- searchKeyword가 공백으로 구분된 여러 해시태그일 경우, 이를 split(" ")으로 나누고, 리스트로 변환하여 검색할 수 있도록 구현되었다.
updateArticle 메서드 수정
- 기존에는 해시태그를 직접적으로 설정하는 코드가 있었으나, 수정 후에는 해시태그와 관련된 로직이 삭제되었다. 이는 해시태그가 별도의 엔티티로 관리되기 시작했기 때문이다.
searchArticlesViaHashtag 메서드 수정
- 이 메서드는 주어진 해시태그를 기반으로 게시글을 검색하는 기능을 제공한다. 기존의 문자열 기반 검색을 대체하여, findByHashtagNames 메서드를 사용해 다중 해시태그 검색을 지원한다.
HashtagService(추가)
HashtagService는 해시태그와 관련된 비즈니스 로직을 처리하는 서비스 계층이다.
해시태그의 파싱, 검색, 삭제 등의 기능을 제공하기 위해 추가되었다.
package org.example.projectboard.service;
import org.springframework.stereotype.Service;
import java.util.Set;
@Service
public class HashtagService {
public Object parseHashtagNames(String content) {
return null;
}
public Object findHashtagsByNames(Set<String> expectedHashtagNames) {
return null;
}
public void deleteHashtagWithoutArticles(Object any) {
}
}
해시태그 파싱
- parseHashtagNames 메서드는 게시글의 내용에서 해시태그를 추출하는 로직을 구현하기 위해 준비되었다.
- 이 메서드는 주어진 콘텐츠에서 해시태그를 추출하여 반환하는 기능을 수행할 수 있다.
해시태그 검색
- findHashtagsByNames 메서드는 주어진 해시태그 이름들에 해당하는 Hashtag 엔티티를 검색하는 기능을 구현하기 위해 추가되었다. 이 메서드를 통해 여러 해시태그를 한 번에 검색할 수 있다.
해시태그 삭제
- deleteHashtagWithoutArticles 메서드는 게시글과 연관되지 않은 해시태그를 삭제하는 기능을 제공하기 위해 추가되었다.
- 이 메서드는 시스템에서 불필요한 해시태그를 정리하는 데 사용할 수 있다.
HashtagService 클래스는 해시태그와 관련된 로직을 캡슐화하여, 시스템의 다른 부분에서 해시태그를 관리하고 조작하는 기능을 제공할 수 있다. 이는 해시태그 관련 작업을 모듈화 하여 유지보수성을 높이는 데 기여한다.
5. 요청/응답 객체 (Request/Response)
ArticleRequest(수정)
ArticleRequest 클래스는 클라이언트로부터 전달받은 게시글 생성 또는 수정 요청을 처리하는 데이터 전송 객체(DTO)이다.
수정 후, 해시태그 관리 방식이 변경됨에 따라 ArticleRequest와 관련된 DTO의 구조도 변경되었다.
package org.example.projectboard.dto.request;
import org.example.projectboard.dto.ArticleDto;
import org.example.projectboard.dto.HashtagDto;
import org.example.projectboard.dto.UserAccountDto;
import java.util.Set;
public record ArticleRequest(
String title,
String content
) {
public static ArticleRequest of(String title, String content) {
return new ArticleRequest(title, content);
}
public ArticleDto toDto(UserAccountDto userAccountDto) {
return toDto(userAccountDto, null);
}
public ArticleDto toDto(UserAccountDto userAccountDto, Set<HashtagDto> hashtagDtos) {
return ArticleDto.of(
userAccountDto,
title,
content,
hashtagDtos
);
}
}
해시태그 필드 제거
- 기존에는 hashtag 필드를 사용해 문자열로 해시태그를 관리했으나,
- 수정 후 이 필드를 제거하고 대신 Set<HashtagDto> 타입의 필드를 사용해 해시태그를 객체로 관리할 수 있게 변경되었다.
toDto 메서드 오버로드
- toDto 메서드는 두 가지 형태로 제공된다.
- 첫 번째는 해시태그 없이 게시글을 ArticleDto로 변환하는 기본 메서드이며,
- 두 번째는 Set<HashtagDto>를 함께 전달받아 변환하는 메서드이다.
- 이를 통해 클라이언트 요청을 객체로 변환할 때 더 유연하게 처리할 수 있다.
ArticleResponse(수정)
ArticleResponse 클래스는 클라이언트에게 게시글 응답 데이터를 전송하기 위한 DTO이다.
해시태그 관리 방식의 변경에 따라 응답 객체도 수정되었다.
package org.example.projectboard.dto.response;
import org.example.projectboard.dto.ArticleDto;
import org.example.projectboard.dto.HashtagDto;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.stream.Collectors;
public record ArticleResponse(
Long id,
String title,
String content,
Set<String> hashtags,
LocalDateTime createdAt,
String email,
String nickname
) {
public static ArticleResponse of(Long id, String title, String content, Set<String> hashtags, LocalDateTime createdAt, String email, String nickname) {
return new ArticleResponse(id, title, content, hashtags, 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.hashtagDtos().stream()
.map(HashtagDto::hashtagName)
.collect(Collectors.toUnmodifiableSet())
,
dto.createdAt(),
dto.userAccountDto().email(),
nickname
);
}
}
해시태그 필드 변경
- 기존에는 hashtag 필드가 문자열로 존재했으나, 수정 후 Set<String> 타입으로 변경되어 여러 해시태그를 관리할 수 있게 되었다.
from 메서드 수정
- from 메서드는 ArticleDto 객체를 ArticleResponse 객체로 변환할 때, Set<HashtagDto>에서 해시태그 이름만 추출해 Set<String>으로 변환하도록 수정되었다.
- 이를 통해 응답 객체에서 여러 해시태그를 명확하게 표현할 수 있다.
ArticleWithCommentsResponse(수정)
ArticleWithCommentsResponse 클래스는 댓글이 포함된 게시글 응답 데이터를 전송하기 위한 DTO이다.
해시태그와 관련된 필드와 메서드가 변경되었다.
package org.example.projectboard.dto.response;
import org.example.projectboard.dto.ArticleWithCommentsDto;
import org.example.projectboard.dto.HashtagDto;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors;
public record ArticleWithCommentsResponse(
Long id,
String title,
String content,
Set<String> hashtags,
LocalDateTime createdAt,
String email,
String nickname,
String userId,
Set<ArticleCommentResponse> articleCommentsResponse
) {
public static ArticleWithCommentsResponse of(Long id, String title, String content, Set<String> hashtags, LocalDateTime createdAt, String email, String nickname, String userId, Set<ArticleCommentResponse> articleCommentResponses) {
return new ArticleWithCommentsResponse(id, title, content, hashtags, createdAt, email, nickname, userId, articleCommentResponses);
}
public static ArticleWithCommentsResponse from(ArticleWithCommentsDto dto) {
String nickname = dto.userAccountDto().nickname();
if (nickname == null || nickname.isBlank()) {
nickname = dto.userAccountDto().userId();
}
return new ArticleWithCommentsResponse(
dto.id(),
dto.title(),
dto.content(),
dto.hashtagDtos().stream()
.map(HashtagDto::hashtagName)
.collect(Collectors.toUnmodifiableSet())
,
dto.createdAt(),
dto.userAccountDto().email(),
nickname,
dto.userAccountDto().userId(),
dto.articleCommentDtos().stream()
.map(ArticleCommentResponse::from)
.collect(Collectors.toCollection(LinkedHashSet::new))
);
}
}
해시태그 필드 변경
- 기존에는 hashtag 필드가 문자열로 존재했으나, 수정 후 Set<String>으로 변경되어 여러 해시태그를 관리할 수 있게 되었다.
from 메서드 수정:
- from 메서드는 ArticleWithCommentsDto 객체를 ArticleWithCommentsResponse 객체로 변환할 때, Set<HashtagDto>에서 해시태그 이름만 추출해 Set<String>으로 변환하도록 수정되었다.
- 이를 통해 댓글이 포함된 게시글에서도 여러 해시태그를 명확하게 표현할 수 있다.
이 변경들을 통해 클라이언트와 서버 간의 데이터 전송 객체들이 해시태그를 객체로 관리하면서도 유연하고 확장 가능한 구조를 유지할 수 있게 되었다. 이는 응답 데이터의 일관성을 유지하고, 해시태그와 관련된 데이터를 보다 명확하게 표현하는 데 기여한다.
6. 테스트 코드 (Test Code)
JpaRepositoryTest(수정)
JpaRepositoryTest 클래스는 JPA 리포지토리의 기능을 테스트하기 위한 코드로, 해시태그 관련 기능이 추가되면서 테스트 케이스가 변경되고 추가되었다.
package org.example.projectboard.repository;
...
class JpaRepositoryTest {
private final ArticleRepository articleRepository;
private final ArticleCommentRepository articleCommentRepository;
private final UserAccountRepository userAccountRepository;
private final HashtagRepository hashtagRepository;
JpaRepositoryTest(
@Autowired ArticleRepository articleRepository,
@Autowired ArticleCommentRepository articleCommentRepository,
@Autowired UserAccountRepository userAccountRepository,
@Autowired HashtagRepository hashtagRepository
) {
this.articleRepository = articleRepository;
this.articleCommentRepository = articleCommentRepository;
this.userAccountRepository = userAccountRepository;
this.hashtagRepository = hashtagRepository;
}
...
@DisplayName("insert 테스트")
@Test
void givenTestData_whenInserting_thenWorksFine() {
// Given
long previousCount = articleRepository.count();
UserAccount userAccount = userAccountRepository.save(UserAccount.of("newUno", "pw", null, null, null));
Article article = Article.of(userAccount, "new article", "new content");
article.addHashtags(Set.of(Hashtag.of("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();
Hashtag updatedHashtag = Hashtag.of("springboot");
article.clearHashtags();
article.addHashtags(Set.of(updatedHashtag));
// When
Article savedArticle = articleRepository.saveAndFlush(article);
// Then
assertThat(savedArticle.getHashtags())
.hasSize(1)
.extracting("hashtagName", String.class)
.containsExactly(updatedHashtag.getHashtagName());
}
@DisplayName("[Querydsl] 전체 hashtag 리스트에서 이름만 조회하기")
@Test
void givenNothing_whenQueryingHashtags_thenReturnsHashtagNames() {
// Given
// When
List<String> hashtagNames = hashtagRepository.findAllHashtagNames();
// Then
assertThat(hashtagNames).hasSize(19);
}
@DisplayName("[Querydsl] hashtag로 페이징된 게시글 검색하기")
@Test
void givenHashtagNamesAndPageable_whenQueryingArticles_thenReturnsArticlePage() {
// Given
List<String> hashtagNames = List.of("blue", "crimson", "fuscia");
Pageable pageable = PageRequest.of(0, 5, Sort.by(
Sort.Order.desc("hashtags.hashtagName"),
Sort.Order.asc("title")
));
// When
Page<Article> articlePage = articleRepository.findByHashtagNames(hashtagNames, pageable);
// Then
assertThat(articlePage.getContent()).hasSize(pageable.getPageSize());
assertThat(articlePage.getContent().get(0).getTitle()).isEqualTo("Fusce posuere felis sed lacus.");
assertThat(articlePage.getContent().get(0).getHashtags())
.extracting("hashtagName", String.class)
.containsExactly("fuscia");
assertThat(articlePage.getTotalElements()).isEqualTo(17);
assertThat(articlePage.getTotalPages()).isEqualTo(4);
}
...
}
insert 테스트 수정
- 게시글을 저장할 때, 해시태그를 함께 추가하는 로직이 포함되었다. article.addHashtags(Set.of(Hashtag.of("spring")));를 통해 해시태그가 제대로 저장되는지 확인한다.
update 테스트 수정
- 게시글의 해시태그를 업데이트하는 테스트로, 기존 해시태그를 제거하고 새로운 해시태그를 추가하는 로직을 확인한다.
- article.clearHashtags();로 기존 해시태그를 삭제한 후, article.addHashtags(Set.of(updatedHashtag));로 새로운 해시태그를 추가하여 변경사항이 제대로 저장되는지 테스트한다.
QueryDSL 테스트 추가
- 전체 해시태그 조회
- hashtagRepository.findAllHashtagNames(); 메서드를 사용해 데이터베이스에서 모든 해시태그 이름을 조회하는 테스트다. 이를 통해 해시태그 리스트가 올바르게 반환되는지 확인한다.
- 해시태그 기반 게시글 검색
- 특정 해시태그 이름들에 해당하는 게시글을 검색하고, 결과를 페이지네이션하여 반환하는 기능을 테스트한다. 페이징과 정렬이 제대로 작동하는지, 특정 해시태그에 맞는 게시글이 올바르게 반환되는지를 확인한다.
이 테스트 코드를 통해, 해시태그와 관련된 기능들이 데이터베이스에서 올바르게 작동하는지 확인할 수 있다. QueryDSL을 활용해 복잡한 검색 로직을 테스트할 수 있으며, 데이터베이스의 일관성과 정확성을 유지할 수 있다.
ArticleControllerTest(수정)
ArticleControllerTest 클래스는 게시글과 관련된 컨트롤러의 동작을 테스트하기 위한 코드로, 해시태그 관련 기능이 추가되면서 일부 테스트 케이스가 수정되고 업데이트되었다.
package org.example.projectboard.controller;
...
class ArticleControllerTest {
private final MockMvc mvc;
private final FormDataEncoder formDataEncoder;
@MockBean private ArticleService articleService;
@MockBean private PaginationService paginationService;
ArticleControllerTest(
@Autowired MockMvc mvc,
@Autowired FormDataEncoder formDataEncoder
) {
this.mvc = mvc;
this.formDataEncoder = formDataEncoder;
}
...
@DisplayName("[view][GET] 게시글 리스트 (게시판) 페이지 - 정상 호출")
@Test
void givenNothing_whenRequestingArticlesView_thenReturnsArticlesView() throws Exception {
// Given
given(articleService.searchArticles(eq(null), eq(null), any(Pageable.class))).willReturn(Page.empty());
given(paginationService.getPaginationBarNumbers(anyInt(), anyInt())).willReturn(List.of(0, 1, 2, 3, 4));
// When & Then
mvc.perform(get("/articles"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("articles/index"))
.andExpect(model().attributeExists("articles"))
.andExpect(model().attributeExists("paginationBarNumbers"))
.andExpect(model().attributeExists("searchTypes"))
.andExpect(model().attribute("searchTypeHashtag", SearchType.HASHTAG));
then(articleService).should().searchArticles(eq(null), eq(null), any(Pageable.class));
then(paginationService).should().getPaginationBarNumbers(anyInt(), anyInt());
}
...
@WithMockUser
@DisplayName("[view][GET] 게시글 페이지 - 정상 호출, 인증된 사용자")
@Test
void givenAuthorizedUser_whenRequestingArticleView_thenReturnsArticleView() throws Exception {
// Given
Long articleId = 1L;
long totalCount = 1L;
given(articleService.getArticleWithComments(articleId)).willReturn(createArticleWithCommentsDto());
given(articleService.getArticleCount()).willReturn(totalCount);
// When & Then
mvc.perform(get("/articles/" + articleId))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("articles/detail"))
.andExpect(model().attributeExists("article"))
.andExpect(model().attributeExists("articleComments"))
.andExpect(model().attribute("totalCount", totalCount))
.andExpect(model().attribute("searchTypeHashtag", SearchType.HASHTAG));
then(articleService).should().getArticleWithComments(articleId);
then(articleService).should().getArticleCount();
}
...
@WithUserDetails(value = "unoTest", setupBefore = TestExecutionEvent.TEST_EXECUTION)
@DisplayName("[view][POST] 새 게시글 등록 - 정상 호출")
@Test
void givenNewArticleInfo_whenRequesting_thenSavesNewArticle() throws Exception {
// Given
ArticleRequest articleRequest = ArticleRequest.of("new title", "new content");
willDoNothing().given(articleService).saveArticle(any(ArticleDto.class));
// When & Then
mvc.perform(
post("/articles/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.content(formDataEncoder.encode(articleRequest))
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles"))
.andExpect(redirectedUrl("/articles"));
then(articleService).should().saveArticle(any(ArticleDto.class));
}
...
@WithUserDetails(value = "unoTest", setupBefore = TestExecutionEvent.TEST_EXECUTION)
@DisplayName("[view][POST] 게시글 수정 - 정상 호출")
@Test
void givenUpdatedArticleInfo_whenRequesting_thenUpdatesNewArticle() throws Exception {
// Given
long articleId = 1L;
ArticleRequest articleRequest = ArticleRequest.of("new title", "new content");
willDoNothing().given(articleService).updateArticle(eq(articleId), any(ArticleDto.class));
// When & Then
mvc.perform(
post("/articles/" + articleId + "/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.content(formDataEncoder.encode(articleRequest))
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles/" + articleId))
.andExpect(redirectedUrl("/articles/" + articleId));
then(articleService).should().updateArticle(eq(articleId), any(ArticleDto.class));
}
...
private ArticleDto createArticleDto() {
return ArticleDto.of(
createUserAccountDto(),
"title",
"content",
Set.of(HashtagDto.of("java"))
);
}
private ArticleWithCommentsDto createArticleWithCommentsDto() {
return ArticleWithCommentsDto.of(
1L,
createUserAccountDto(),
Set.of(),
"title",
"content",
Set.of(HashtagDto.of("java")),
LocalDateTime.now(),
"eunchan",
LocalDateTime.now(),
"eunchan"
);
}
private UserAccountDto createUserAccountDto() {
return UserAccountDto.of(
"eunchan",
"pw",
"eunchan@mail.com",
"eunchan",
"memo",
LocalDateTime.now(),
"eunchan",
LocalDateTime.now(),
"eunchan"
);
}
}
createArticleDto 및 createArticleWithCommentsDto 메서드 수정
- DTO 객체를 생성할 때, 해시태그를 포함하도록 변경되었다. Set.of(HashtagDto.of("java"))를 사용하여 해시태그를 추가해 DTO 객체를 생성한다.
뷰 검증에 해시태그 검색 타입 추가
- 테스트 케이스에서 뷰를 검증할 때, SearchType.HASHTAG를 모델 속성에 포함하여 해시태그 검색 기능이 제대로 동작하는지 확인한다.
게시글 등록 및 수정 테스트
- 새 게시글 등록 및 수정 시, 해시태그를 포함한 ArticleDto 객체를 사용하도록 변경되었다. 해시태그와 관련된 데이터를 포함하여 DTO 객체를 테스트하는 로직이 추가되었다.
이 변경으로 인해, 해시태그와 관련된 기능이 추가된 컨트롤러 테스트가 보완되었으며, 해시태그를 포함한 게시글 생성, 수정, 조회 등의 기능이 제대로 동작하는지 검증할 수 있게 되었다.
ArticleServiceTest(수정)
ArticleServiceTest 클래스는 서비스 계층의 게시글 관련 로직을 테스트하는 코드로, 해시태그 관련 기능이 추가됨에 따라 테스트 코드가 변경되었다.
package org.example.projectboard.service;
...
@DisplayName("비즈니스 로직 - 게시글")
@ExtendWith(MockitoExtension.class)
class ArticleServiceTest {
@InjectMocks private ArticleService sut;
@Mock private HashtagService hashtagService;
@Mock private ArticleRepository articleRepository;
@Mock private UserAccountRepository userAccountRepository;
@Mock private HashtagRepository hashtagRepository;
@DisplayName("검색어 없이 게시글을 검색하면, 게시글 페이지를 반환한다.")
@Test
void givenNoSearchParameters_whenSearchingArticles_thenReturnsArticlePage() {
// Given
Pageable pageable = Pageable.ofSize(20);
given(articleRepository.findAll(pageable)).willReturn(Page.empty());
// When
Page<ArticleDto> articles = sut.searchArticles(null, null, pageable);
// Then
assertThat(articles).isEmpty();
then(articleRepository).should().findAll(pageable);
}
...
@DisplayName("검색어 없이 게시글을 해시태그 검색하면, 빈 페이지를 반환한다.")
@Test
void givenNoSearchParameters_whenSearchingArticlesViaHashtag_thenReturnsEmptyPage() {
// Given
Pageable pageable = Pageable.ofSize(20);
// When
Page<ArticleDto> articles = sut.searchArticlesViaHashtag(null, pageable);
// Then
assertThat(articles).isEqualTo(Page.empty(pageable));
then(hashtagRepository).shouldHaveNoInteractions();
then(articleRepository).shouldHaveNoInteractions();
}
@DisplayName("없는 해시태그를 검색하면, 빈 페이지를 반환한다.")
@Test
void givenNonexistentHashtag_whenSearchingArticlesViaHashtag_thenReturnsEmptyPage() {
// Given
String hashtagName = "난 없지롱";
Pageable pageable = Pageable.ofSize(20);
given(articleRepository.findByHashtagNames(List.of(hashtagName), pageable)).willReturn(new PageImpl<>(List.of(), pageable, 0));
// When
Page<ArticleDto> articles = sut.searchArticlesViaHashtag(hashtagName, pageable);
// Then
assertThat(articles).isEqualTo(Page.empty(pageable));
then(articleRepository).should().findByHashtagNames(List.of(hashtagName), pageable);
}
@DisplayName("게시글을 해시태그 검색하면, 게시글 페이지를 반환한다.")
@Test
void givenHashtag_whenSearchingArticlesViaHashtag_thenReturnsArticlesPage() {
// Given
String hashtagName = "java";
Pageable pageable = Pageable.ofSize(20);
Article expectedArticle = createArticle();
given(articleRepository.findByHashtagNames(List.of(hashtagName), pageable)).willReturn(new PageImpl<>(List.of(expectedArticle), pageable, 1));
// When
Page<ArticleDto> articles = sut.searchArticlesViaHashtag(hashtagName, pageable);
// Then
assertThat(articles).isEqualTo(new PageImpl<>(List.of(ArticleDto.from(expectedArticle)), pageable, 1));
then(articleRepository).should().findByHashtagNames(List.of(hashtagName), pageable);
}
@DisplayName("게시글 ID로 조회하면, 댓글 달긴 게시글을 반환한다.")
@Test
void givenArticleId_whenSearchingArticleWithComments_thenReturnsArticleWithComments() {
// Given
Long articleId = 1L;
Article article = createArticle();
given(articleRepository.findById(articleId)).willReturn(Optional.of(article));
// When
ArticleWithCommentsDto dto = sut.getArticleWithComments(articleId);
// Then
assertThat(dto)
.hasFieldOrPropertyWithValue("title", article.getTitle())
.hasFieldOrPropertyWithValue("content", article.getContent())
.hasFieldOrPropertyWithValue("hashtagDtos", article.getHashtags().stream()
.map(HashtagDto::from)
.collect(Collectors.toUnmodifiableSet())
);
then(articleRepository).should().findById(articleId);
}
...
@DisplayName("게시글을 조회하면, 게시글을 반환한다.")
@Test
void givenArticleId_whenSearchingArticle_thenReturnsArticle() {
// Given
Long articleId = 1L;
Article article = createArticle();
given(articleRepository.findById(articleId)).willReturn(Optional.of(article));
// When
ArticleDto dto = sut.getArticle(articleId);
// Then
assertThat(dto)
.hasFieldOrPropertyWithValue("title", article.getTitle())
.hasFieldOrPropertyWithValue("content", article.getContent())
.hasFieldOrPropertyWithValue("hashtagDtos", article.getHashtags().stream()
.map(HashtagDto::from)
.collect(Collectors.toUnmodifiableSet())
);
then(articleRepository).should().findById(articleId);
}
...
@DisplayName("게시글 정보를 입력하면, 본문에서 해시태그 정보를 추출하여 해시태그 정보가 포함된 게시글을 생성한다.")
@Test
void givenArticleInfo_whenSavingArticle_thenExtractsHashtagsFromContentAndSavesArticleWithExtractedHashtags() {
// Given
ArticleDto dto = createArticleDto();
Set<String> expectedHashtagNames = Set.of("java", "spring");
Set<Hashtag> expectedHashtags = new HashSet<>();
expectedHashtags.add(createHashtag("java"));
given(userAccountRepository.getReferenceById(dto.userAccountDto().userId())).willReturn(createUserAccount());
given(hashtagService.parseHashtagNames(dto.content())).willReturn(expectedHashtagNames);
given(hashtagService.findHashtagsByNames(expectedHashtagNames)).willReturn(expectedHashtags);
given(articleRepository.save(any(Article.class))).willReturn(createArticle());
// When
sut.saveArticle(dto);
// Then
then(userAccountRepository).should().getReferenceById(dto.userAccountDto().userId());
then(hashtagService).should().parseHashtagNames(dto.content());
then(hashtagService).should().findHashtagsByNames(expectedHashtagNames);
then(articleRepository).should().save(any(Article.class));
}
@DisplayName("게시글의 수정 정보를 입력하면, 게시글을 수정한다.")
@Test
void givenModifiedArticleInfo_whenUpdatingArticle_thenUpdatesArticle() {
// Given
Article article = createArticle();
ArticleDto dto = createArticleDto("새 타이틀", "새 내용 #springboot");
Set<String> expectedHashtagNames = Set.of("springboot");
Set<Hashtag> expectedHashtags = new HashSet<>();
given(articleRepository.getReferenceById(dto.id())).willReturn(article);
given(userAccountRepository.getReferenceById(dto.userAccountDto().userId())).willReturn(dto.userAccountDto().toEntity());
willDoNothing().given(articleRepository).flush();
willDoNothing().given(hashtagService).deleteHashtagWithoutArticles(any());
given(hashtagService.parseHashtagNames(dto.content())).willReturn(expectedHashtagNames);
given(hashtagService.findHashtagsByNames(expectedHashtagNames)).willReturn(expectedHashtags);
// When
sut.updateArticle(dto.id(), dto);
// Then
assertThat(article)
.hasFieldOrPropertyWithValue("title", dto.title())
.hasFieldOrPropertyWithValue("content", dto.content())
.extracting("hashtags", as(InstanceOfAssertFactories.COLLECTION))
.hasSize(1)
.extracting("hashtagName")
.containsExactly("springboot");
then(articleRepository).should().getReferenceById(dto.id());
then(userAccountRepository).should().getReferenceById(dto.userAccountDto().userId());
then(articleRepository).should().flush();
then(hashtagService).should(times(2)).deleteHashtagWithoutArticles(any());
then(hashtagService).should().parseHashtagNames(dto.content());
then(hashtagService).should().findHashtagsByNames(expectedHashtagNames);
}
@DisplayName("없는 게시글의 수정 정보를 입력하면, 경고 로그를 찍고 아무 것도 하지 않는다.")
@Test
void givenNonexistentArticleInfo_whenUpdatingArticle_thenLogsWarningAndDoesNothing() {
// Given
ArticleDto dto = createArticleDto("새 타이틀", "새 내용");
given(articleRepository.getReferenceById(dto.id())).willThrow(EntityNotFoundException.class);
// When
sut.updateArticle(dto.id(), dto);
// Then
then(articleRepository).should().getReferenceById(dto.id());
then(userAccountRepository).shouldHaveNoInteractions();
then(hashtagService).shouldHaveNoInteractions();
}
@DisplayName("게시글 작성자가 아닌 사람이 수정 정보를 입력하면, 아무 것도 하지 않는다.")
@Test
void givenModifiedArticleInfoWithDifferentUser_whenUpdatingArticle_thenDoesNothing() {
// Given
Long differentArticleId = 22L;
Article differentArticle = createArticle(differentArticleId);
differentArticle.setUserAccount(createUserAccount("John"));
ArticleDto dto = createArticleDto("새 타이틀", "새 내용");
given(articleRepository.getReferenceById(differentArticleId)).willReturn(differentArticle);
given(userAccountRepository.getReferenceById(dto.userAccountDto().userId())).willReturn(dto.userAccountDto().toEntity());
// When
sut.updateArticle(differentArticleId, dto);
// Then
then(articleRepository).should().getReferenceById(differentArticleId);
then(userAccountRepository).should().getReferenceById(dto.userAccountDto().userId());
then(hashtagService).shouldHaveNoInteractions();
}
@DisplayName("게시글의 ID를 입력하면, 게시글을 삭제한다")
@Test
void givenArticleId_whenDeletingArticle_thenDeletesArticle() {
// Given
Long articleId = 1L;
String userId = "eunchan";
given(articleRepository.getReferenceById(articleId)).willReturn(createArticle());
willDoNothing().given(articleRepository).deleteByIdAndUserAccount_UserId(articleId, userId);
willDoNothing().given(articleRepository).flush();
willDoNothing().given(hashtagService).deleteHashtagWithoutArticles(any());
// When
sut.deleteArticle(1L, userId);
// Then
then(articleRepository).should().getReferenceById(articleId);
then(articleRepository).should().deleteByIdAndUserAccount_UserId(articleId, userId);
then(articleRepository).should().flush();
then(hashtagService).should(times(2)).deleteHashtagWithoutArticles(any());
}
@DisplayName("게시글 수를 조회하면, 게시글 수를 반환한다")
@Test
void givenNothing_whenCountingArticles_thenReturnsArticleCount() {
// Given
long expected = 0L;
given(articleRepository.count()).willReturn(expected);
// When
long actual = sut.getArticleCount();
// Then
assertThat(actual).isEqualTo(expected);
then(articleRepository).should().count();
}
@DisplayName("해시태그를 조회하면, 유니크 해시태그 리스트를 반환한다")
@Test
void givenNothing_whenCalling_thenReturnsHashtags() {
// Given
Article article = createArticle();
List<String> expectedHashtags = List.of("java", "spring", "boot");
given(hashtagRepository.findAllHashtagNames()).willReturn(expectedHashtags);
// When
List<String> actualHashtags = sut.getHashtags();
// Then
assertThat(actualHashtags).isEqualTo(expectedHashtags);
then(hashtagRepository).should().findAllHashtagNames();
}
private UserAccount createUserAccount() {
return createUserAccount("eunchan");
}
private UserAccount createUserAccount(String userId) {
return UserAccount.of(
userId,
"password",
"eunchan@email.com",
"Eunchan",
null
);
}
private Article createArticle() {
return createArticle(1L);
}
private Article createArticle(Long id) {
Article article = Article.of(
createUserAccount(),
"title",
"content"
);
article.addHashtags(Set.of(
createHashtag(1L, "java"),
createHashtag(2L, "spring")
));
ReflectionTestUtils.setField(article, "id", id);
return article;
}
private Hashtag createHashtag(String hashtagName) {
return createHashtag(1L, hashtagName);
}
private Hashtag createHashtag(Long id, String hashtagName) {
Hashtag hashtag = Hashtag.of(hashtagName);
ReflectionTestUtils.setField(hashtag, "id", id);
return hashtag;
}
private HashtagDto createHashtagDto() {
return HashtagDto.of("java");
}
private ArticleDto createArticleDto() {
return createArticleDto("title", "content");
}
private ArticleDto createArticleDto(String title, String content) {
return ArticleDto.of(
1L,
createUserAccountDto(),
title,
content,
null,
LocalDateTime.now(),
"Eunchan",
LocalDateTime.now(),
"Eunchan");
}
private UserAccountDto createUserAccountDto() {
return UserAccountDto.of(
"eunchan",
"password",
"eunchan@mail.com",
"eunchan",
"This is memo",
LocalDateTime.now(),
"eunchan",
LocalDateTime.now(),
"eunchan"
);
}
}
해시태그 처리 방식의 변화
- 이전 코드에서는 해시태그를 단순한 문자열로 처리했다. Article 엔티티의 hashtag 필드는 단일 문자열로 존재하며, 이를 통해 해시태그를 검색하거나 조작했다.
- 수정된 코드에서는 해시태그를 Hashtag 엔티티로 분리하여 관리한다. 여러 개의 해시태그가 Set 컬렉션으로 Article 엔티티에 연결된다. 이는 다대다 관계를 구현하며, 해시태그를 더 유연하게 관리하고 검색할 수 있게 한다.
해시태그 관련 테스트 추가
- 수정된 코드에서는 해시태그를 다루기 위해 추가적인 테스트가 도입되었다. 특히, 해시태그를 추출하고 이를 통해 게시글을 생성 및 수정하는 로직을 검증하는 테스트가 추가되었다.
- 예를 들어, 게시글을 저장하거나 수정할 때 본문에서 해시태그를 추출하고, 해당 해시태그에 대한 정보가 제대로 저장되는지를 확인하는 테스트가 포함되었다.
해시태그 검색 로직의 변경
- 기존에는 단일 해시태그 문자열로 검색했지만, 이제는 Set<String>으로 해시태그 목록을 받아 해당 해시태그들로 게시글을 검색한다. 이로 인해 검색 로직과 이에 대한 테스트가 수정되었다.
이러한 변경 사항들은 해시태그를 보다 구조화된 방식으로 관리하고 검색할 수 있게 하며, 관련 테스트를 통해 새로운 로직이 제대로 동작하는지를 검증하도록 업데이트되었다.
ArticleCommentServiceTest 수정
...
private Article createArticle() {
Article article = Article.of(
createUserAccount(),
"title",
"content"
);
article.addHashtags(Set.of(createHashtag(article)));
return article;
}
private Hashtag createHashtag(Article article) {
return Hashtag.of("java");
}
createArticle 메서드
- 기존: 단순히 UserAccount, title, content로 Article 객체를 생성했다.
- 수정 후: Article 객체를 생성한 후 addHashtags 메서드를 호출해, 해시태그를 Article에 추가하는 로직이 추가되었다.
createHashtag 메서드
- 신규: 해시태그를 생성하는 로직이 추가되었으며, 여기서는 "java"라는 해시태그를 Hashtag 객체로 생성하고 있다.
이 변경 사항은 Article이 해시태그와 연관될 수 있도록 하는 기능을 추가한 것이다. 이제 Article 객체가 생성될 때, 기본적으로 "java" 해시태그가 포함되며, 이를 통해 해시태그 기반의 검색 및 필터링 기능을 구현할 수 있다.
7. 기타 파일
data.sql(수정)
//게시글
insert into article (user_id, title, content, created_by, modified_by, created_at, modified_at) values ('eunchan', 'Proin interdum mauris non ligula pellentesque ultrices.', 'Integer ac leo. Pellentesque ultrices mattis odio. Donec vitae nisi.', 'Aindrea', 'Tudor', '2024-05-27 15:02:07', '2024-03-10 23:47:22');
...
insert into hashtag (hashtag_name, created_at, modified_at, created_by, modified_by) values
('blue', now(), now(), 'eunchan', 'eunchan'),
('crimson', now(), now(), 'eunchan', 'eunchan'),
('fuscia', now(), now(), 'eunchan', 'eunchan'),
('goldenrod', now(), now(), 'eunchan', 'eunchan'),
('green', now(), now(), 'eunchan', 'eunchan'),
('indigo', now(), now(), 'eunchan', 'eunchan'),
('khaki', now(), now(), 'eunchan', 'eunchan'),
('maroon', now(), now(), 'eunchan', 'eunchan'),
('mauv', now(), now(), 'eunchan', 'eunchan'),
('orange', now(), now(), 'eunchan', 'eunchan'),
('pink', now(), now(), 'eunchan', 'eunchan'),
('puce', now(), now(), 'eunchan', 'eunchan'),
('purple', now(), now(), 'eunchan', 'eunchan'),
('red', now(), now(), 'eunchan', 'eunchan'),
('teal', now(), now(), 'eunchan', 'eunchan'),
('turquoise', now(), now(), 'eunchan', 'eunchan'),
('violet', now(), now(), 'eunchan', 'eunchan'),
('yellow', now(), now(), 'eunchan', 'eunchan'),
('white', now(), now(), 'eunchan', 'eunchan')
;
insert into article_hashtag (article_id, hashtag_id) values
(1, 11),
(2, 13),
(3, 13),
(4, 9),
(5, 5),
(6, 8),
(7, 10),
(8, 15),
(9, 7),
(10, 12),
(11, 10),
(12, 13),
(13, 8),
(15, 7),
(18, 4),
(19, 18),
(20, 10),
(21, 3),
(22, 12),
(24, 15),
(25, 3),
(26, 8),
(27, 15),
(28, 16),
(29, 3),
(31, 1),
(32, 18),
(33, 11),
(34, 4),
(35, 1),
(37, 13),
(38, 5),
(40, 16),
(42, 3),
(43, 17),
(45, 14),
(45, 19),
(47, 13),
(48, 2),
(49, 6),
(50, 7),
(52, 16),
(54, 11),
(55, 10),
(57, 10),
(58, 11),
(59, 2),
(60, 2),
(61, 15),
(63, 17),
(64, 17),
(65, 17),
(66, 16),
(67, 12),
(68, 3),
(70, 12),
(71, 11),
(72, 3),
(73, 14),
(75, 16),
(76, 1),
(77, 11),
(80, 13),
(81, 17),
(82, 16),
(83, 13),
(84, 2),
(85, 15),
(86, 14),
(88, 17),
(90, 7),
(91, 10),
(92, 13),
(93, 16),
(94, 16),
(95, 3),
(96, 8),
(97, 18),
(98, 10),
(99, 17),
(100, 2),
(102, 12),
(103, 14),
(104, 7),
(105, 16),
(106, 14),
(107, 1),
(111, 18),
(112, 6),
(113, 9),
(114, 2),
(116, 16),
(117, 14),
(119, 12),
(120, 18),
(122, 18)
;
기존 article 테이블 데이터 수정
- article 테이블에서 기존에 존재하던 hashtag 컬럼이 제거되었다. 이는 이제 해시태그가 별도의 hashtag 테이블에서 관리되기 때문이다.
- 예를 들어, insert into article 문에서 hashtag 필드가 제거되어, 게시글 데이터가 hashtag 없이 삽입되었다.
해시태그 추가
- 다양한 색상을 나타내는 해시태그(blue, crimson, fuscia, 등)들이 hashtag 테이블에 추가되었다.
- 각 해시태그는 created_at, modified_at, created_by, modified_by 필드를 갖고 있으며, 이 필드는 각각 현재 시간을 나타내는 now() 함수와 'eunchan'이라는 사용자로 설정되었다.
게시글과 해시태그 관계 설정
- article_hashtag 테이블에 article_id와 hashtag_id를 매핑하는 관계가 추가되었다. 이를 통해 게시글과 해시태그 간의 다대다 관계가 설정되며, 특정 게시글이 여러 해시태그를 가질 수 있다.
해시태그가 독립적인 테이블에서 관리되며, 게시글과의 관계가 다대다로 설정되었다. 이를 통해 더 유연하고 강력한 검색 및 필터링 기능을 구현할 수 있게 되었다.
마무리
이번 대규모 수정 작업을 통해 프로젝트의 코드베이스가 더욱 깔끔해지고, 해시태그를 중심으로 한 기능들이 명확하게 정리되었다. 특히, 해시태그와 게시글 간의 관계를 개선함으로써 데이터 관리가 용이해졌으며, 확장성 있는 구조를 구축하는 데 한 걸음 더 나아갔다.
이러한 개선은 단순히 코드를 변경하는 것에 그치지 않고, 전체 시스템의 안정성과 유연성을 크게 향상시키는 데 있어 핵심적인 역할을 했다고 볼 수 있다. 이번 작업을 진행하면서 도메인 모델의 변경이 시스템 전반에 얼마나 큰 영향을 미치는지 다시 한번 실감할 수 있었다.
앞으로는 객체 간 결합도를 낮추고 응집도를 높이는 방향으로 설계와 구현을 해 나가는 것이 유지보수성과 확장성 측면에서 매우 중요함을 인지해야 할 것이다. 이를 통해 프로젝트의 발전과 성장에 한 걸음 더 다가설 수 있을 것으로 기대된다.
'BackEnd > Project' 카테고리의 다른 글
[Adv. Board] Ch02. 해시태그 검색 기능 고도화 (0) | 2024.08.15 |
---|---|
[Adv. Board] Ch02. DB 마이그레이션 (0) | 2024.08.14 |
[Adv. Board] Ch01. 프로젝트 기획 (0) | 2024.08.13 |
[Board] Ch03. 인증 기능 구현(2) (0) | 2024.08.13 |
[Board] Ch03. 인증 기능 구현(1) (0) | 2024.08.13 |