Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6593b2c
remove: 사용하지 않는 validate 파일 삭제
chaeyuuu Dec 18, 2025
6b75941
feat: 에러 로그 추가
chaeyuuu Dec 18, 2025
ef6f0e0
refactor: 아티클 전체 목록 조회와 상세 내용 조회 dto 분리
chaeyuuu Dec 18, 2025
3ce59c0
feat: Comment 엔티티 작성
chaeyuuu Dec 18, 2025
5fa729c
feat: Comment DTO 작성
chaeyuuu Dec 18, 2025
0c6782d
refactor: 아티클 조회 시 댓글 함께 반환
chaeyuuu Dec 18, 2025
c9ec31a
refactor: SuccessCode, ErrorCode 패키지 분리
chaeyuuu Dec 18, 2025
e94b9a6
feat: 댓글 작성 API 구현
chaeyuuu Dec 18, 2025
861ba1c
refactor: 댓글 작성 dto, 수정 dto 분리
chaeyuuu Dec 18, 2025
96ef5df
feat: 댓글 수정 API
chaeyuuu Dec 18, 2025
b3a630a
feat: 댓글 삭제 API
chaeyuuu Dec 18, 2025
69ffe95
refactor: 댓글 삭제 시 soft deleted 방식 적용
chaeyuuu Dec 18, 2025
4964429
feat: 댓글 조회 API
chaeyuuu Dec 18, 2025
8c1457c
refactor: 아티클 생성 시간 반환 추가
chaeyuuu Dec 18, 2025
876676b
refactor: 댓글 수정 시간 반환 추가
chaeyuuu Dec 18, 2025
41e300c
refactor: 댓글 수정, 삭제, 조회 API CommentController로 분리
chaeyuuu Dec 18, 2025
2a76bca
refactor: N+1 문제 해결을 위해 JOIN FETCH 적용
chaeyuuu Dec 18, 2025
73f9432
feat: redis 관련 설정 파일 추가
chaeyuuu Dec 22, 2025
7911f9d
feat: redis 관련 config 설정
chaeyuuu Dec 22, 2025
9482430
feat: 게시글 상세 및 전체 목록 조회 API에 Redis 캐싱 적용
chaeyuuu Dec 22, 2025
787ad0b
feat: 게시물 생성 시 articleList 캐시 무효화 로직 추가
chaeyuuu Dec 22, 2025
0d1b44b
feat: 댓글 생성 시 게시글 상세 캐시 무효화 적용
chaeyuuu Dec 22, 2025
3ce8435
feat: CacheManager 직접 호출을 통한 댓글 삭제 및 수정 시 캐시 무효화
chaeyuuu Dec 22, 2025
5c5cfd3
fix: Java Record 타입의 Redis 역질렬화 에러 해결
chaeyuuu Dec 22, 2025
46fb58e
test: Redis 캐시 성능 및 데이터 정합성 검증 테스트 작성
chaeyuuu Dec 22, 2025
b260bca
feat: 검색 API 추가
chaeyuuu Dec 22, 2025
3d333b4
feat: Article에 created_at에 인덱스 생성
chaeyuuu Dec 22, 2025
8c2737f
feat: Comment에 article_id로 인덱스 설정
chaeyuuu Dec 22, 2025
ceed376
test: Member 엔티티 단위 테스트 작성
chaeyuuu Dec 23, 2025
82dc3a2
fix: 회원 가입 나이 검증 로직 버그 수정
chaeyuuu Dec 23, 2025
0f6436b
test: MemberService 테스트 코드 작성
chaeyuuu Dec 23, 2025
04b7558
test: Article 도메인 엔티티 테스트
chaeyuuu Dec 25, 2025
4c9e762
test: ArticleService 테스트 코드 작성
chaeyuuu Dec 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
implementation 'org.springframework.boot:spring-boot-starter-validation'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand All @@ -33,6 +34,9 @@ dependencies {

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

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'
}

test {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,28 @@
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.sopt.domain.article.dto.request.ArticleCreateRequestDto;
import org.sopt.domain.article.dto.response.ArticleResponseDto;
import org.sopt.domain.article.dto.response.ArticleDetailResponseDto;
import org.sopt.domain.article.dto.response.ArticleListResponseDto;
import org.sopt.domain.article.service.ArticleService;
import org.sopt.global.exception.constant.ArticleSuccessCode;
import org.sopt.domain.comment.dto.request.CommentCreateRequestDto;
import org.sopt.domain.comment.dto.response.CommentResponseDto;
import org.sopt.domain.comment.service.CommentService;
import org.sopt.global.exception.SuccessCode.ArticleSuccessCode;
import org.sopt.global.exception.SuccessCode.CommentSuccessCode;
import org.sopt.global.response.BaseResponse;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/articles")
public class ArticleController {

private final ArticleService articleService;
private final CommentService commentService;

@PostMapping
public BaseResponse<Long> createArticle(
Expand All @@ -26,19 +35,27 @@ public BaseResponse<Long> createArticle(
}

@GetMapping("/{articleId}")
public BaseResponse getArticle(
public BaseResponse<ArticleDetailResponseDto> getArticle(
@PathVariable Long articleId
) {
ArticleResponseDto response = articleService.findOne(articleId);

ArticleDetailResponseDto response = articleService.findOne(articleId);
return BaseResponse.ok(ArticleSuccessCode.GET_ARTICLE_SUCCESS.getMsg(), response);
}

@GetMapping
public BaseResponse<List<ArticleResponseDto>> getAllArticles() {
List<ArticleResponseDto> response = articleService.findAllArticles();
public BaseResponse<Page<ArticleListResponseDto>> getAllArticles(
@RequestParam(required = false) String keyword,
@ParameterObject @PageableDefault(size = 20) Pageable pageable) {
Page<ArticleListResponseDto> response = articleService.findAllArticles(keyword, pageable);
return BaseResponse.ok(ArticleSuccessCode.GET_ALL_ARTICLES_SUCCESS.getMsg(), response);
}

@PostMapping("/{articleId}/comments")
public BaseResponse<CommentResponseDto> createComment(@PathVariable Long articleId,
@Valid @RequestBody CommentCreateRequestDto commentCreateRequestDto) {
CommentResponseDto response = commentService.createComment(articleId, commentCreateRequestDto);
return BaseResponse.ok(CommentSuccessCode.CREATE_COMMENT_SUCCESS.getMsg(), response);
}
}


3 changes: 3 additions & 0 deletions src/main/java/org/sopt/domain/article/domain/Article.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "article", indexes = {
@Index(name = "idx_article_created_at", columnList = "created_at")
})
public class Article extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,35 @@
import lombok.Builder;
import org.sopt.domain.article.domain.Article;
import org.sopt.domain.article.domain.enums.Tag;
import org.sopt.domain.comment.domain.Comment;
import org.sopt.domain.comment.dto.response.CommentResponseDto;

import java.time.LocalDateTime;
import java.util.List;

@Builder
public record ArticleResponseDto(
public record ArticleDetailResponseDto(
Long id,
Long memberId,
String memberName,
Tag tag,
String title,
String content,
List<CommentResponseDto> comments,
LocalDateTime publishedAt) {

public static ArticleResponseDto fromEntity(Article article) {
return ArticleResponseDto.builder()
public static ArticleDetailResponseDto fromEntity(Article article, List<Comment> comments) {
return ArticleDetailResponseDto.builder()
.id(article.getId())
.memberId(article.getMember().getId())
.memberName(article.getMember().getName())
.tag(article.getTag())
.title(article.getTitle())
.content(article.getContent())
.comments(comments.stream()
.map(CommentResponseDto::fromEntity)
.toList())
.publishedAt(article.getCreatedAt())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.sopt.domain.article.dto.response;

import lombok.*;
import org.sopt.domain.article.domain.Article;
import org.sopt.domain.article.domain.enums.Tag;

import java.time.LocalDateTime;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
@Builder
public class ArticleListResponseDto {
private Long id;
private String memberName;
private Tag tag;
private String title;
private LocalDateTime createdAt;

public static ArticleListResponseDto fromEntity(Article article) {
return ArticleListResponseDto.builder()
.id(article.getId())
.memberName(article.getMember().getName())
.tag(article.getTag())
.title(article.getTitle())
.createdAt(article.getCreatedAt())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
package org.sopt.domain.article.repository;

import org.sopt.domain.article.domain.Article;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface ArticleRepository extends JpaRepository<Article, Long> {
@Query("SELECT a FROM Article a JOIN FETCH a.member")
List<Article> findAllWithMember();
@Query(value = "SELECT a FROM Article a JOIN FETCH a.member",
countQuery = "SELECT COUNT(a) FROM Article a")
Page<Article> findAllWithMember(Pageable pageable);

@Query(value = "SELECT a FROM Article a JOIN FETCH a.member m " +
"WHERE a.title LIKE %:keyword% OR m.name LIKE %:keyword%",
countQuery = "SELECT COUNT(a) FROM Article a JOIN a.member m " +
"WHERE a.title LIKE %:keyword% OR m.name LIKE %:keyword%") Page<Article> searchByKeyword(@Param("keyword") String keyword, Pageable pageable);

@Query("SELECT a FROM Article a JOIN FETCH a.member WHERE a.id = :id")
Optional<Article> findWithMemberById(@Param("id") Long id);

Optional<Article> findByTitle(String title);

}
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package org.sopt.domain.article.service;

import org.sopt.domain.article.dto.request.ArticleCreateRequestDto;
import org.sopt.domain.article.dto.response.ArticleResponseDto;

import java.util.List;
import org.sopt.domain.article.dto.response.ArticleDetailResponseDto;
import org.sopt.domain.article.dto.response.ArticleListResponseDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface ArticleService {
Long createArticle(ArticleCreateRequestDto request);
ArticleResponseDto findOne(Long articleId);
List<ArticleResponseDto> findAllArticles();
ArticleDetailResponseDto findOne(Long articleId);
Page<ArticleListResponseDto> findAllArticles(String keyword, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@
import lombok.RequiredArgsConstructor;
import org.sopt.domain.article.domain.Article;
import org.sopt.domain.article.dto.request.ArticleCreateRequestDto;
import org.sopt.domain.article.dto.response.ArticleResponseDto;
import org.sopt.domain.article.dto.response.ArticleDetailResponseDto;
import org.sopt.domain.article.dto.response.ArticleListResponseDto;
import org.sopt.domain.article.repository.ArticleRepository;
import org.sopt.domain.comment.domain.Comment;
import org.sopt.domain.comment.repository.CommentRepository;
import org.sopt.domain.member.domain.Member;
import org.sopt.domain.member.repository.MemberRepository;
import org.sopt.global.exception.CustomException;
import org.sopt.global.exception.constant.GlobalErrorCode;
import org.sopt.global.exception.ErrorCode.GlobalErrorCode;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -21,9 +28,11 @@ public class ArticleServiceImpl implements ArticleService {

private final ArticleRepository articleRepository;
private final MemberRepository memberRepository;
private final CommentRepository commentRepository;

@Override
@Transactional
@CacheEvict(value = "articleList", allEntries = true, cacheManager = "cacheManager")
public Long createArticle(ArticleCreateRequestDto request) {

Member member = memberRepository.findById(request.memberId())
Expand All @@ -46,17 +55,26 @@ public Long createArticle(ArticleCreateRequestDto request) {
}

@Override
public ArticleResponseDto findOne(Long articleId) {
Article article = articleRepository.findById(articleId)
@Cacheable(value = "article", key = "#articleId", cacheManager = "cacheManager")
public ArticleDetailResponseDto findOne(Long articleId) {
Article article = articleRepository.findWithMemberById(articleId)
.orElseThrow(() -> new CustomException(GlobalErrorCode.ARTICLE_NOT_FOUND));

return ArticleResponseDto.fromEntity(article);
List<Comment> comments = commentRepository.findAllByArticleWithMember(article);
return ArticleDetailResponseDto.fromEntity(article, comments);
}

@Override
public List<ArticleResponseDto> findAllArticles() {
return articleRepository.findAllWithMember().stream()
.map(ArticleResponseDto::fromEntity)
.toList();
@Cacheable(value = "articleList", key = "'all'", cacheManager = "cacheManager")
public Page<ArticleListResponseDto> findAllArticles(String keyword, Pageable pageable) {
Page<Article> articles;

if (keyword == null || keyword.isEmpty()) {
articles = articleRepository.findAllWithMember(pageable);
} else {
articles = articleRepository.searchByKeyword(keyword, pageable);
}

return articles.map(ArticleListResponseDto::fromEntity);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.sopt.domain.comment.controller;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.sopt.domain.comment.dto.request.CommentUpdateRequestDto;
import org.sopt.domain.comment.dto.response.CommentResponseDto;
import org.sopt.domain.comment.service.CommentService;
import org.sopt.global.exception.SuccessCode.CommentSuccessCode;
import org.sopt.global.response.BaseResponse;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/comments/{commentId}")
public class CommentController {

private final CommentService commentService;
@PutMapping
public BaseResponse<CommentResponseDto> updateComment(@PathVariable Long commentId,
@Valid @RequestBody CommentUpdateRequestDto commentUpdateRequestDto) {
CommentResponseDto response = commentService.updateComment(commentId, commentUpdateRequestDto);
return BaseResponse.ok(CommentSuccessCode.UPDATE_COMMENT_SUCCESS.getMsg(), response);
}

@DeleteMapping
public BaseResponse<Void> deleteComment(@PathVariable Long commentId) {
commentService.deleteComment(commentId);
return BaseResponse.ok(CommentSuccessCode.DELETE_COMMENT_SUCCESS.getMsg(), null);
}

@GetMapping
public BaseResponse<CommentResponseDto> getComment(@PathVariable Long commentId){
CommentResponseDto response = commentService.getComment(commentId);
return BaseResponse.ok(CommentSuccessCode.GET_COMMENT_SUCCESS.getMsg(), response);
}
}
57 changes: 57 additions & 0 deletions src/main/java/org/sopt/domain/comment/domain/Comment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package org.sopt.domain.comment.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.SQLRestriction;
import org.sopt.domain.article.domain.Article;
import org.sopt.domain.member.domain.Member;
import org.sopt.global.entity.BaseTimeEntity;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SQLRestriction("is_deleted = false")
@Table(name = "comment", indexes = {
@Index(name = "idx_comment_article_id", columnList = "article_id")
})
public class Comment extends BaseTimeEntity {

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

@Column(nullable = false, length = 300)
private String content;

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

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

private boolean isDeleted = false;

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

public static Comment create(String content, Article article, Member member) {
return new Comment(content, article, member);
}

public void updateComment(String content) {
this.content = content;
}

public void softDelete() {
this.isDeleted = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.sopt.domain.comment.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record CommentCreateRequestDto(@NotNull Long memberId, @NotBlank @Size(max = 300) String content) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.sopt.domain.comment.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CommentUpdateRequestDto(@NotBlank @Size(max = 300) String content) {
}

Loading