Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a24be2a
feat: comment 폴더 구조 생성
hyodongg Dec 5, 2025
66ae767
feat: Comment 엔티티 생성 및 그에 따른 엔티티 수정
hyodongg Dec 5, 2025
676ec56
refactor: 어노테이션 추가
hyodongg Dec 14, 2025
ad82146
feat: DTO 추가
hyodongg Dec 14, 2025
7c235ab
feat: comment 작성,조회 구현
hyodongg Dec 14, 2025
7859abe
refactor: 에러코드 수정
hyodongg Dec 14, 2025
6c298bb
feat: 스웨거 추가
hyodongg Dec 14, 2025
3e656a5
refactor: security 수정
hyodongg Dec 14, 2025
413608f
refactor: dto 수정
hyodongg Dec 15, 2025
d5a09c0
feat: comment 수정, 삭제 구현
hyodongg Dec 15, 2025
17b9f84
feat: 스웨거 추가
hyodongg Dec 15, 2025
da5c76f
refactor: security 수정
hyodongg Dec 15, 2025
0c8fc93
feat: validation 의존성 추가
hyodongg Dec 15, 2025
fa33fc1
feat: Redis 의존성 추가
hyodongg Dec 15, 2025
aa79eed
feat: RedisConfig 추가
hyodongg Dec 15, 2025
73a43ed
refactor: dto 추가 및 수정
hyodongg Dec 15, 2025
e982cff
feat: Redis 캐싱 적용
hyodongg Dec 15, 2025
f5b79df
fix: Redis 역직렬화 문제 해결
hyodongg Dec 16, 2025
0018e4e
feat: 로그 추가 및 Redis 켜져있지 않을 시 DB조회
hyodongg Dec 16, 2025
e4d8ac5
refactor: import 수정
hyodongg Dec 16, 2025
679c90c
refactor: yml파일 수정
hyodongg Dec 25, 2025
d909cea
feat: 테스트를 위한 설정 추가
hyodongg Dec 26, 2025
b0d73de
Feat: MemberService 단위테스트
hyodongg Dec 26, 2025
7a53256
refactor: 내부 헬퍼 메서드 분리
hyodongg Dec 26, 2025
93c0367
feat: Controller 회원가입 테스트
hyodongg Dec 26, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ build/
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
.idea/
*.iws
*.iml
*.ipr
Expand Down
7 changes: 1 addition & 6 deletions .idea/dataSources.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions .idea/data_source_mapping.xml

This file was deleted.

6 changes: 0 additions & 6 deletions .idea/vcs.xml

This file was deleted.

18 changes: 15 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.4'
id 'io.spring.dependency-management' version '1.1.4'
id 'org.springframework.boot' version '3.3.5'
id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
Expand All @@ -17,6 +17,10 @@ repositories {
mavenCentral()
}

test {
useJUnitPlatform()
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'

Expand All @@ -25,16 +29,24 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'

// Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'

// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// lombok 의존성 추가
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

// validation 의존성
implementation 'org.springframework.boot:spring-boot-starter-validation'

// mysql
runtimeOnly 'com.mysql:mysql-connector-j'

// jwt 의존성
implementation 'com.auth0:java-jwt:4.4.0'

// Redis 관련 의존성
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.sopt.article.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
Expand All @@ -16,13 +15,8 @@
import org.sopt.global.response.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/articles")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package org.sopt.article.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import org.sopt.article.entity.Article;
import org.sopt.article.entity.Tag;

import java.time.LocalDate;

public record ArticleListCommentCountResponse(

@Schema(description = "아티클 ID", example = "1")
Long id,

@Schema(description = "아티클 제목", example = "집에 빨리 가는 법")
String title,

@Schema(description = "아티클 내용",example = "날아간다")
String content,

@Schema(description = "태그", example = "CS")
Tag tag,

@Schema(description = "날짜", example = "2025-11-26")
LocalDate date,

@Schema(description = "작성자 ID", example = "1")
Long memberId,

@Schema(description = "작성자 이름", example = "조효동")
String memberName,

@Schema(description = "댓글 개수", example = "30")
int commentCount
) {
public static ArticleListCommentCountResponse from(Article article) {

return new ArticleListCommentCountResponse(
article.getId(),
article.getTitle(),
article.getContent(),
article.getTag(),
article.getDate(),
article.getMember().getId(),
article.getMember().getName(),
article.getComments().size()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

import java.util.List;

public record ArticleListResponse(List<ArticleResponse> articles) {
public record ArticleListResponse(List<ArticleListCommentCountResponse> articles) {

public static ArticleListResponse from(List<Article> articles) {
List<ArticleResponse> articleResponses = articles.stream()
.map(ArticleResponse::from)
List<ArticleListCommentCountResponse> articleResponses = articles.stream()
.map(ArticleListCommentCountResponse::from)
.toList();

return new ArticleListResponse(articleResponses);
Expand Down
15 changes: 13 additions & 2 deletions src/main/java/org/sopt/article/dto/response/ArticleResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import io.swagger.v3.oas.annotations.media.Schema;
import org.sopt.article.entity.Article;
import org.sopt.article.entity.Tag;
import org.sopt.comment.dto.response.CommentResponse;

import java.time.LocalDate;
import java.util.List;

public record ArticleResponse(

Expand All @@ -27,17 +29,26 @@ public record ArticleResponse(
Long memberId,

@Schema(description = "작성자 이름", example = "조효동")
String memberName
String memberName,

@Schema(description = "댓글 목록", example = "1등")
List<CommentResponse> comments
) {
public static ArticleResponse from(Article article) {

List<CommentResponse> comments = article.getComments().stream()
.map(CommentResponse::from)
.toList();

return new ArticleResponse(
article.getId(),
article.getTitle(),
article.getContent(),
article.getTag(),
article.getDate(),
article.getMember().getId(),
article.getMember().getName()
article.getMember().getName(),
comments
);
}
}
7 changes: 7 additions & 0 deletions src/main/java/org/sopt/article/entity/Article.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.sopt.comment.entity.Comment;
import org.sopt.member.entity.Member;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
Expand Down Expand Up @@ -35,6 +38,10 @@ public class Article {
@JoinColumn(name = "member_id")
private Member member;

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

public static Article create(String title,String content,LocalDate date,Tag tag,Member member) {
Article article = Article.builder()
.title(title)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.sopt.article.repository;

import org.sopt.article.entity.Article;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/org/sopt/article/service/ArticleService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.sopt.article.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.sopt.article.dto.request.ArticleCreateRequest;
import org.sopt.article.dto.response.ArticleListResponse;
import org.sopt.article.dto.response.ArticleResponse;
Expand All @@ -12,12 +13,15 @@
import org.sopt.member.exception.MemberErrorCode;
import org.sopt.member.exception.MemberException;
import org.sopt.member.repository.MemberRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
Expand All @@ -26,6 +30,7 @@ public class ArticleService {
private final MemberRepository memberRepository;

@Transactional
@CacheEvict(value = "articleList", key="'all'")
public ArticleResponse createArticle(Long memberId, ArticleCreateRequest request) {

validateTitleExists(request.title());
Expand All @@ -41,17 +46,25 @@ public ArticleResponse createArticle(Long memberId, ArticleCreateRequest request

}

// 아티클 상세조회 (댓글 포함) 캐싱
@Cacheable(value = "articleDetail", key = "#articleId")
public ArticleResponse findArticle(Long articleId) {

log.info("[CACHE MISS] DB 조회 아티클 ID: {}", articleId);

Article article = articleRepository.findById(articleId)
.orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND));

return ArticleResponse.from(article);

}

// 아티클 전체 조회 (댓글 개수만) 캐싱
@Cacheable(value = "articleList", key = "'all'")
public ArticleListResponse findAllArticles() {

log.info("[CACHE MISS] DB 조회 - 전체 아티클 목록");

List<Article> articles = articleRepository.findAll();

return ArticleListResponse.from(articles);
Expand Down
72 changes: 72 additions & 0 deletions src/main/java/org/sopt/comment/controller/CommentController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package org.sopt.comment.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.sopt.comment.dto.request.CommentCreateRequest;
import org.sopt.comment.dto.request.CommentUpdateRequest;
import org.sopt.comment.dto.response.CommentListResponse;
import org.sopt.comment.dto.response.CommentResponse;
import org.sopt.comment.service.CommentService;
import org.sopt.global.annotation.BusinessExceptionDescription;
import org.sopt.global.annotation.LoginMemberId;
import org.sopt.global.config.swagger.SwaggerResponseDescription;
import org.sopt.global.response.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/articles")
@RequiredArgsConstructor
@Tag(name = "댓글", description = "댓글 작성 / 조회 등 관리 API")
public class CommentController {

private final CommentService commentService;

@Operation(summary = "댓글 작성", description = "로그인한 회원이 아티클에 댓글을 작성합니다")
@PostMapping("/{articleId}/comments")
@BusinessExceptionDescription(SwaggerResponseDescription.CREATE_COMMENT)
@SecurityRequirement(name = "JWT")
public ResponseEntity<ApiResponse<CommentResponse>> createComment(@LoginMemberId Long memberId,
@PathVariable Long articleId,
@Valid @RequestBody CommentCreateRequest request) {
CommentResponse response = commentService.createComment(memberId,articleId,request);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
}

@Operation(summary = "댓글 조회", description = "특정 아티클의 댓글을 조회합니다.")
@GetMapping("/{articleId}/comments")
@BusinessExceptionDescription(SwaggerResponseDescription.GET_COMMENT)
public ResponseEntity<ApiResponse<CommentListResponse>> findComment(@PathVariable Long articleId) {
CommentListResponse responses = commentService.findComment(articleId);
return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(responses));
}

@Operation(summary = "댓글 수정", description = "특정 아티클의 댓글을 수정합니다.")
@PatchMapping("/{articleId}/comments/{commentId}")
@SecurityRequirement(name = "JWT")
@BusinessExceptionDescription(SwaggerResponseDescription.UPDATE_COMMENT)
public ResponseEntity<ApiResponse<CommentResponse>> updateComment(@LoginMemberId Long memberId,
@PathVariable Long articleId,
@PathVariable Long commentId,
@Valid @RequestBody CommentUpdateRequest request
){
CommentResponse response = commentService.updateComment(memberId,articleId,commentId,request);
return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(response));
}

@Operation(summary = "댓글 삭제", description = "특정 아티클의 댓글을 삭제합니다.")
@DeleteMapping("/{articleId}/comments/{commentId}")
@SecurityRequirement(name = "JWT")
@BusinessExceptionDescription(SwaggerResponseDescription.DELETE_COMMENT)
public ResponseEntity<ApiResponse<Void>> deleteComment(@LoginMemberId Long memberId,
@PathVariable Long articleId,
@PathVariable Long commentId) {
commentService.deleteComment(memberId,articleId,commentId);

return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(null));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.sopt.comment.dto.request;

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

public record CommentCreateRequest(

@NotBlank(message = "내용을 빈칸으로 둘 수 없습니다.")
@Size(max = 300, message = "댓글은 300자를 초과할 수 없습니다.")
String content

) {
}
Loading