From a24be2a7f2af21d9a4390c3abc627b7f799e4365 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Sat, 6 Dec 2025 01:45:09 +0900 Subject: [PATCH 01/25] =?UTF-8?q?feat:=20comment=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/dataSources.xml | 7 +++++++ .idea/data_source_mapping.xml | 6 ------ .../org/sopt/comment/controller/CommentController.java | 9 +++++++++ .../org/sopt/comment/repository/CommentRepository.java | 7 +++++++ .../java/org/sopt/comment/service/CommentService.java | 4 ++++ 5 files changed, 27 insertions(+), 6 deletions(-) delete mode 100644 .idea/data_source_mapping.xml create mode 100644 src/main/java/org/sopt/comment/controller/CommentController.java create mode 100644 src/main/java/org/sopt/comment/repository/CommentRepository.java create mode 100644 src/main/java/org/sopt/comment/service/CommentService.java diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 45ece12..a89bfa3 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -13,5 +13,12 @@ $ProjectFileDir$ + + mysql.8 + true + com.mysql.cj.jdbc.Driver + jdbc:mysql://localhost:3306 + $ProjectFileDir$ + \ No newline at end of file diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml deleted file mode 100644 index 2ad1ee4..0000000 --- a/.idea/data_source_mapping.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/java/org/sopt/comment/controller/CommentController.java b/src/main/java/org/sopt/comment/controller/CommentController.java new file mode 100644 index 0000000..ffb2c3f --- /dev/null +++ b/src/main/java/org/sopt/comment/controller/CommentController.java @@ -0,0 +1,9 @@ +package org.sopt.comment.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/comment") +public class CommentController { +} diff --git a/src/main/java/org/sopt/comment/repository/CommentRepository.java b/src/main/java/org/sopt/comment/repository/CommentRepository.java new file mode 100644 index 0000000..e923fea --- /dev/null +++ b/src/main/java/org/sopt/comment/repository/CommentRepository.java @@ -0,0 +1,7 @@ +package org.sopt.comment.repository; + +import org.sopt.comment.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { +} diff --git a/src/main/java/org/sopt/comment/service/CommentService.java b/src/main/java/org/sopt/comment/service/CommentService.java new file mode 100644 index 0000000..bc609f1 --- /dev/null +++ b/src/main/java/org/sopt/comment/service/CommentService.java @@ -0,0 +1,4 @@ +package org.sopt.comment.service; + +public class CommentService { +} From 66ae7673ad86f2c6f742a544579deb9bcd9b01d9 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Sat, 6 Dec 2025 01:45:32 +0900 Subject: [PATCH 02/25] =?UTF-8?q?feat:=20Comment=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EA=B7=B8=EC=97=90?= =?UTF-8?q?=20=EB=94=B0=EB=A5=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/sopt/article/entity/Article.java | 6 ++++ .../java/org/sopt/comment/entity/Comment.java | 34 +++++++++++++++++++ .../java/org/sopt/member/entity/Member.java | 4 +++ 3 files changed, 44 insertions(+) create mode 100644 src/main/java/org/sopt/comment/entity/Comment.java diff --git a/src/main/java/org/sopt/article/entity/Article.java b/src/main/java/org/sopt/article/entity/Article.java index 4e6ad32..ac3a601 100644 --- a/src/main/java/org/sopt/article/entity/Article.java +++ b/src/main/java/org/sopt/article/entity/Article.java @@ -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 @@ -35,6 +38,9 @@ public class Article { @JoinColumn(name = "member_id") private Member member; + @OneToMany(mappedBy = "article") + private List comments = new ArrayList<>(); + public static Article create(String title,String content,LocalDate date,Tag tag,Member member) { Article article = Article.builder() .title(title) diff --git a/src/main/java/org/sopt/comment/entity/Comment.java b/src/main/java/org/sopt/comment/entity/Comment.java new file mode 100644 index 0000000..32c840e --- /dev/null +++ b/src/main/java/org/sopt/comment/entity/Comment.java @@ -0,0 +1,34 @@ +package org.sopt.comment.entity; + + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sopt.article.entity.Article; +import org.sopt.member.entity.Member; + +@Entity +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + private Long id; + + @Column(length = 300) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id") + private Article article; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; +} diff --git a/src/main/java/org/sopt/member/entity/Member.java b/src/main/java/org/sopt/member/entity/Member.java index fac7b55..e40fccd 100644 --- a/src/main/java/org/sopt/member/entity/Member.java +++ b/src/main/java/org/sopt/member/entity/Member.java @@ -6,6 +6,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.sopt.article.entity.Article; +import org.sopt.comment.entity.Comment; import org.sopt.member.exception.MemberException; import org.sopt.member.exception.MemberErrorCode; @@ -49,6 +50,9 @@ public class Member { @Column(name = "provider_id") private String providerId; // 카카오 회원번호를 위한 필드 + @OneToMany(mappedBy = "member") + private List comments = new ArrayList<>(); + public static Member create(String name, String birth, String email, Gender gender, String password) { validateAge(birth); From 676ec56281ce319726ab21a1ed179e5b4b2e47ec Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Mon, 15 Dec 2025 03:14:21 +0900 Subject: [PATCH 03/25] =?UTF-8?q?refactor:=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/sopt/article/entity/Article.java | 1 + src/main/java/org/sopt/member/entity/Member.java | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/main/java/org/sopt/article/entity/Article.java b/src/main/java/org/sopt/article/entity/Article.java index ac3a601..3f5629a 100644 --- a/src/main/java/org/sopt/article/entity/Article.java +++ b/src/main/java/org/sopt/article/entity/Article.java @@ -39,6 +39,7 @@ public class Article { private Member member; @OneToMany(mappedBy = "article") + @Builder.Default private List comments = new ArrayList<>(); public static Article create(String title,String content,LocalDate date,Tag tag,Member member) { diff --git a/src/main/java/org/sopt/member/entity/Member.java b/src/main/java/org/sopt/member/entity/Member.java index e40fccd..04d54a9 100644 --- a/src/main/java/org/sopt/member/entity/Member.java +++ b/src/main/java/org/sopt/member/entity/Member.java @@ -39,6 +39,7 @@ public class Member { private Gender gender; @OneToMany(mappedBy = "member") + @Builder.Default private List
articles = new ArrayList<>(); @Column(nullable = true) @@ -51,6 +52,7 @@ public class Member { private String providerId; // 카카오 회원번호를 위한 필드 @OneToMany(mappedBy = "member") + @Builder.Default private List comments = new ArrayList<>(); From ad82146de03171c36b3e8a4ff75e572638f18cfa Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Mon, 15 Dec 2025 03:15:13 +0900 Subject: [PATCH 04/25] =?UTF-8?q?feat:=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/CommentCreateRequest.java | 11 +++++++ .../dto/response/CommentListResponse.java | 16 ++++++++++ .../comment/dto/response/CommentResponse.java | 32 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java create mode 100644 src/main/java/org/sopt/comment/dto/response/CommentListResponse.java create mode 100644 src/main/java/org/sopt/comment/dto/response/CommentResponse.java diff --git a/src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java b/src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java new file mode 100644 index 0000000..1fb6889 --- /dev/null +++ b/src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java @@ -0,0 +1,11 @@ +package org.sopt.comment.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record CommentCreateRequest( + + @NotBlank(message = "내용을 빈칸으로 둘 수 없습니다.") + String content + +) { +} diff --git a/src/main/java/org/sopt/comment/dto/response/CommentListResponse.java b/src/main/java/org/sopt/comment/dto/response/CommentListResponse.java new file mode 100644 index 0000000..a71a17f --- /dev/null +++ b/src/main/java/org/sopt/comment/dto/response/CommentListResponse.java @@ -0,0 +1,16 @@ +package org.sopt.comment.dto.response; + +import org.sopt.comment.entity.Comment; + +import java.util.List; + +public record CommentListResponse(List comments) { + + public static CommentListResponse from(List comments){ + List commentResponses = comments.stream(). + map(CommentResponse::from) + .toList(); + + return new CommentListResponse(commentResponses); + } +} diff --git a/src/main/java/org/sopt/comment/dto/response/CommentResponse.java b/src/main/java/org/sopt/comment/dto/response/CommentResponse.java new file mode 100644 index 0000000..449614e --- /dev/null +++ b/src/main/java/org/sopt/comment/dto/response/CommentResponse.java @@ -0,0 +1,32 @@ +package org.sopt.comment.dto.response; + +import org.sopt.comment.entity.Comment; + +public record CommentResponse( + + // 댓글 아이디 + Long id, + + // 댓글 내용 + String content, + + // 댓글 단 아티클 제목 + String articleTitle, + + // 댓글 작성자 멤버 id + Long memberId, + + // 댓글 작성자 멤버 이름 + String memberName +) { + + public static CommentResponse from(Comment comment) { + return new CommentResponse( + comment.getId(), + comment.getContent(), + comment.getArticle().getTitle(), + comment.getMember().getId(), + comment.getMember().getName() + ); + } +} From 7c235ab31e44520f50f7f1605b474a5b6f98e76c Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Mon, 15 Dec 2025 03:16:03 +0900 Subject: [PATCH 05/25] =?UTF-8?q?feat:=20comment=20=EC=9E=91=EC=84=B1,?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentController.java | 40 ++++++++++++++- .../java/org/sopt/comment/entity/Comment.java | 24 ++++++--- .../comment/exception/CommentErrorCode.java | 30 +++++++++++ .../comment/repository/CommentRepository.java | 3 ++ .../sopt/comment/service/CommentService.java | 51 +++++++++++++++++++ 5 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/sopt/comment/exception/CommentErrorCode.java diff --git a/src/main/java/org/sopt/comment/controller/CommentController.java b/src/main/java/org/sopt/comment/controller/CommentController.java index ffb2c3f..545e3ad 100644 --- a/src/main/java/org/sopt/comment/controller/CommentController.java +++ b/src/main/java/org/sopt/comment/controller/CommentController.java @@ -1,9 +1,45 @@ package org.sopt.comment.controller; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.sopt.comment.dto.request.CommentCreateRequest; +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("/comment") +@RequiredArgsConstructor +@Tag(name = "댓글", description = "댓글 작성 / 조회 등 관리 API") public class CommentController { + + private final CommentService commentService; + + @Operation(summary = "댓글 작성", description = "로그인한 회원이 아티클에 댓글을 작성합니다") + @PostMapping("/{articleId}") + @BusinessExceptionDescription(SwaggerResponseDescription.CREATE_COMMENT) + @SecurityRequirement(name = "JWT") + public ResponseEntity> createComment(@LoginMemberId Long memberId, + @PathVariable Long articleId, + CommentCreateRequest request) { + CommentResponse response = commentService.createComment(memberId,articleId,request); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response)); + } + + @Operation(summary = "댓글 조회", description = "특정 아티클의 댓글을 조회합니다.") + @GetMapping("{articleId}") + @BusinessExceptionDescription(SwaggerResponseDescription.GET_COMMENT) + public ResponseEntity> findComment(@PathVariable Long articleId) { + CommentListResponse responses = commentService.findComment(articleId); + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(responses)); + } } diff --git a/src/main/java/org/sopt/comment/entity/Comment.java b/src/main/java/org/sopt/comment/entity/Comment.java index 32c840e..3a59bcc 100644 --- a/src/main/java/org/sopt/comment/entity/Comment.java +++ b/src/main/java/org/sopt/comment/entity/Comment.java @@ -2,17 +2,14 @@ import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import org.sopt.article.entity.Article; import org.sopt.member.entity.Member; @Entity -@Builder -@NoArgsConstructor -@AllArgsConstructor +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter public class Comment { @@ -31,4 +28,17 @@ public class Comment { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") private Member member; + + public static Comment create(String content, Article article, Member member) { + Comment comment = Comment.builder() + .content(content) + .article(article) + .member(member) + .build(); + + article.getComments().add(comment); + member.getComments().add(comment); + + return comment; + } } diff --git a/src/main/java/org/sopt/comment/exception/CommentErrorCode.java b/src/main/java/org/sopt/comment/exception/CommentErrorCode.java new file mode 100644 index 0000000..c801529 --- /dev/null +++ b/src/main/java/org/sopt/comment/exception/CommentErrorCode.java @@ -0,0 +1,30 @@ +package org.sopt.comment.exception; + +import org.sopt.global.exception.errorcode.ErrorCode; +import org.springframework.http.HttpStatus; + +public enum CommentErrorCode implements ErrorCode { + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND,"C001","해당 댓글을 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; + + CommentErrorCode(HttpStatus status, String code, String message) { + this.status = status; + this.code = code; + this.message = message; + } + + public HttpStatus getStatus() { + return status; + } + + public String getCode() { + return code; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/org/sopt/comment/repository/CommentRepository.java b/src/main/java/org/sopt/comment/repository/CommentRepository.java index e923fea..25efc68 100644 --- a/src/main/java/org/sopt/comment/repository/CommentRepository.java +++ b/src/main/java/org/sopt/comment/repository/CommentRepository.java @@ -3,5 +3,8 @@ import org.sopt.comment.entity.Comment; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface CommentRepository extends JpaRepository { + List findByArticleId(Long articleId); } diff --git a/src/main/java/org/sopt/comment/service/CommentService.java b/src/main/java/org/sopt/comment/service/CommentService.java index bc609f1..abfe039 100644 --- a/src/main/java/org/sopt/comment/service/CommentService.java +++ b/src/main/java/org/sopt/comment/service/CommentService.java @@ -1,4 +1,55 @@ package org.sopt.comment.service; +import lombok.RequiredArgsConstructor; +import org.sopt.article.entity.Article; +import org.sopt.article.exception.ArticleErrorCode; +import org.sopt.article.exception.ArticleException; +import org.sopt.article.repository.ArticleRepository; +import org.sopt.comment.dto.request.CommentCreateRequest; +import org.sopt.comment.dto.response.CommentListResponse; +import org.sopt.comment.dto.response.CommentResponse; +import org.sopt.comment.entity.Comment; +import org.sopt.comment.repository.CommentRepository; +import org.sopt.member.entity.Member; +import org.sopt.member.exception.MemberErrorCode; +import org.sopt.member.exception.MemberException; +import org.sopt.member.repository.MemberRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +@Transactional +@RequiredArgsConstructor +@Service public class CommentService { + + private final CommentRepository commentRepository; + private final MemberRepository memberRepository; + private final ArticleRepository articleRepository; + + public CommentResponse createComment(Long memberId, Long articleId, CommentCreateRequest request) { + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND)); + + Article article = articleRepository.findById(articleId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); + + Comment comment = Comment.create(request.content(),article,member); + + Comment savedComment = commentRepository.save(comment); + + return CommentResponse.from(savedComment); + } + + @Transactional(readOnly = true) + public CommentListResponse findComment(Long articleId){ + + Article article = articleRepository.findById(articleId) + .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); + + List comments = commentRepository.findByArticleId(articleId); + + return CommentListResponse.from(comments); + } } From 7859abe9ddcf569aaf5532e36d2637bfaea16073 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Mon, 15 Dec 2025 03:16:28 +0900 Subject: [PATCH 06/25] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sopt/global/exception/errorcode/GlobalErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/sopt/global/exception/errorcode/GlobalErrorCode.java b/src/main/java/org/sopt/global/exception/errorcode/GlobalErrorCode.java index bcd20ac..0f9c692 100644 --- a/src/main/java/org/sopt/global/exception/errorcode/GlobalErrorCode.java +++ b/src/main/java/org/sopt/global/exception/errorcode/GlobalErrorCode.java @@ -11,7 +11,7 @@ public enum GlobalErrorCode implements ErrorCode { INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "J002", "유효하지 않은 액세스 토큰입니다."), INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "J003", "유효하지 않은 리프레쉬 토큰입니다."), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "J004", "만료된 토큰입니다."), - EMPTY_TOKEN(HttpStatus.UNAUTHORIZED, "J005", "토큰이 비어있습니다."), + EMPTY_TOKEN(HttpStatus.UNAUTHORIZED, "J005", "토큰이 비어있습니다. 로그인이 필요합니다."), FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN,"J006","접근 권한이 없습니다."); ; From 6c298bbfe5a68f5c7b71591565e80356a8587c12 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Mon, 15 Dec 2025 03:17:34 +0900 Subject: [PATCH 07/25] =?UTF-8?q?feat:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/swagger/SwaggerResponseDescription.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/org/sopt/global/config/swagger/SwaggerResponseDescription.java b/src/main/java/org/sopt/global/config/swagger/SwaggerResponseDescription.java index 24606e6..7e6f83b 100644 --- a/src/main/java/org/sopt/global/config/swagger/SwaggerResponseDescription.java +++ b/src/main/java/org/sopt/global/config/swagger/SwaggerResponseDescription.java @@ -3,11 +3,13 @@ import lombok.Getter; import org.sopt.article.exception.ArticleErrorCode; import org.sopt.auth.exception.AuthErrorCode; +import org.sopt.comment.exception.CommentErrorCode; import org.sopt.global.exception.errorcode.ErrorCode; import org.sopt.global.exception.errorcode.GlobalErrorCode; import org.sopt.member.exception.MemberErrorCode; import java.util.LinkedHashSet; +import java.util.LinkedList; import java.util.Set; @Getter @@ -38,6 +40,16 @@ public enum SwaggerResponseDescription { REQUEST_LOGIN(new LinkedHashSet<>(Set.of( AuthErrorCode.INVALID_PASSWORD + ))), + + CREATE_COMMENT(new LinkedHashSet<>(Set.of( + MemberErrorCode.MEMBER_NOT_FOUND, + ArticleErrorCode.ARTICLE_NOT_FOUND, + CommentErrorCode.COMMENT_NOT_FOUND + ))), + + GET_COMMENT(new LinkedHashSet<>(Set.of( + ArticleErrorCode.ARTICLE_NOT_FOUND ))); private final Set errorCodeList; From 3e656a5f46b22711468326ed78e03a5fc3a2945a Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Mon, 15 Dec 2025 03:17:46 +0900 Subject: [PATCH 08/25] =?UTF-8?q?refactor:=20security=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/dataSources.xml | 12 ---------- .idea/vcs.xml | 6 ----- .../config/security/SecurityConfig.java | 2 ++ .../global/jwt/JwtAuthenticationFilter.java | 24 +++++++++++-------- 4 files changed, 16 insertions(+), 28 deletions(-) delete mode 100644 .idea/vcs.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index a89bfa3..48290b7 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -1,18 +1,6 @@ - - mysql.8 - true - com.mysql.cj.jdbc.Driver - jdbc:mysql://localhost:3306 - - - - - - $ProjectFileDir$ - mysql.8 true diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/java/org/sopt/global/config/security/SecurityConfig.java b/src/main/java/org/sopt/global/config/security/SecurityConfig.java index 6fb2aa0..d24cf08 100644 --- a/src/main/java/org/sopt/global/config/security/SecurityConfig.java +++ b/src/main/java/org/sopt/global/config/security/SecurityConfig.java @@ -4,6 +4,7 @@ import org.sopt.global.jwt.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -34,6 +35,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers(ALLOWED_PATH).permitAll() + .requestMatchers(HttpMethod.GET,"/comment/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/src/main/java/org/sopt/global/jwt/JwtAuthenticationFilter.java b/src/main/java/org/sopt/global/jwt/JwtAuthenticationFilter.java index 0a8da8b..8b6e097 100644 --- a/src/main/java/org/sopt/global/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/sopt/global/jwt/JwtAuthenticationFilter.java @@ -33,24 +33,28 @@ protected void doFilterInternal( try { String token = jwtTokenValidator.extractTokenFromHeader(request); - if (token != null) { - Long memberId = jwtTokenValidator.getMemberIdFromAccessToken(token); + if (token == null || token.isEmpty()) { + request.setAttribute("exception", GlobalErrorCode.EMPTY_TOKEN); + filterChain.doFilter(request, response); + return; + } + + Long memberId = jwtTokenValidator.getMemberIdFromAccessToken(token); - Authentication authentication = new UsernamePasswordAuthenticationToken( - memberId, - null, - List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); + Authentication authentication = new UsernamePasswordAuthenticationToken( + memberId, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); SecurityContextHolder.getContext().setAuthentication(authentication); - } } catch (GlobalException e) { // JWT 관련 예외 request.setAttribute("exception", e.getErrorCode()); - throw e; + } catch (Exception e) { request.setAttribute("exception", GlobalErrorCode.INVALID_TOKEN); - throw new GlobalException(GlobalErrorCode.INVALID_TOKEN); + } filterChain.doFilter(request, response); } From 413608f2aabb73bd7d0635b027b5c9c277d4e36d Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Mon, 15 Dec 2025 19:14:39 +0900 Subject: [PATCH 09/25] =?UTF-8?q?refactor:=20dto=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/dto/response/ArticleResponse.java | 15 +++++++++++++-- .../comment/dto/request/CommentCreateRequest.java | 2 ++ .../comment/dto/response/CommentListResponse.java | 2 ++ .../comment/dto/response/CommentResponse.java | 4 ---- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/sopt/article/dto/response/ArticleResponse.java b/src/main/java/org/sopt/article/dto/response/ArticleResponse.java index 8bd23be..21c7582 100644 --- a/src/main/java/org/sopt/article/dto/response/ArticleResponse.java +++ b/src/main/java/org/sopt/article/dto/response/ArticleResponse.java @@ -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( @@ -27,9 +29,17 @@ public record ArticleResponse( Long memberId, @Schema(description = "작성자 이름", example = "조효동") - String memberName + String memberName, + + @Schema(description = "댓글 목록", example = "1등") + List comments ) { public static ArticleResponse from(Article article) { + + List comments = article.getComments().stream() + .map(CommentResponse::from) + .toList(); + return new ArticleResponse( article.getId(), article.getTitle(), @@ -37,7 +47,8 @@ public static ArticleResponse from(Article article) { article.getTag(), article.getDate(), article.getMember().getId(), - article.getMember().getName() + article.getMember().getName(), + comments ); } } \ No newline at end of file diff --git a/src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java b/src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java index 1fb6889..cdc3910 100644 --- a/src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java +++ b/src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java @@ -1,10 +1,12 @@ 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 ) { diff --git a/src/main/java/org/sopt/comment/dto/response/CommentListResponse.java b/src/main/java/org/sopt/comment/dto/response/CommentListResponse.java index a71a17f..c8e164f 100644 --- a/src/main/java/org/sopt/comment/dto/response/CommentListResponse.java +++ b/src/main/java/org/sopt/comment/dto/response/CommentListResponse.java @@ -13,4 +13,6 @@ public static CommentListResponse from(List comments){ return new CommentListResponse(commentResponses); } + + } diff --git a/src/main/java/org/sopt/comment/dto/response/CommentResponse.java b/src/main/java/org/sopt/comment/dto/response/CommentResponse.java index 449614e..41f2b47 100644 --- a/src/main/java/org/sopt/comment/dto/response/CommentResponse.java +++ b/src/main/java/org/sopt/comment/dto/response/CommentResponse.java @@ -10,9 +10,6 @@ public record CommentResponse( // 댓글 내용 String content, - // 댓글 단 아티클 제목 - String articleTitle, - // 댓글 작성자 멤버 id Long memberId, @@ -24,7 +21,6 @@ public static CommentResponse from(Comment comment) { return new CommentResponse( comment.getId(), comment.getContent(), - comment.getArticle().getTitle(), comment.getMember().getId(), comment.getMember().getName() ); From d5a09c0cb14affb0883e4ded63496e22dcbaa24a Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:35:15 +0900 Subject: [PATCH 10/25] =?UTF-8?q?feat:=20comment=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentController.java | 35 ++++++++++++-- .../dto/request/CommentUpdateRequest.java | 13 ++++++ .../java/org/sopt/comment/entity/Comment.java | 4 ++ .../comment/exception/CommentErrorCode.java | 4 +- .../comment/exception/CommentException.java | 10 ++++ .../sopt/comment/service/CommentService.java | 46 ++++++++++++++++++- 6 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/sopt/comment/dto/request/CommentUpdateRequest.java create mode 100644 src/main/java/org/sopt/comment/exception/CommentException.java diff --git a/src/main/java/org/sopt/comment/controller/CommentController.java b/src/main/java/org/sopt/comment/controller/CommentController.java index 545e3ad..180b6e0 100644 --- a/src/main/java/org/sopt/comment/controller/CommentController.java +++ b/src/main/java/org/sopt/comment/controller/CommentController.java @@ -3,8 +3,10 @@ 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; @@ -17,7 +19,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/comment") +@RequestMapping("/articles") @RequiredArgsConstructor @Tag(name = "댓글", description = "댓글 작성 / 조회 등 관리 API") public class CommentController { @@ -25,21 +27,46 @@ public class CommentController { private final CommentService commentService; @Operation(summary = "댓글 작성", description = "로그인한 회원이 아티클에 댓글을 작성합니다") - @PostMapping("/{articleId}") + @PostMapping("/{articleId}/comments") @BusinessExceptionDescription(SwaggerResponseDescription.CREATE_COMMENT) @SecurityRequirement(name = "JWT") public ResponseEntity> createComment(@LoginMemberId Long memberId, @PathVariable Long articleId, - CommentCreateRequest request) { + @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}") + @GetMapping("/{articleId}/comments") @BusinessExceptionDescription(SwaggerResponseDescription.GET_COMMENT) public ResponseEntity> 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> 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> deleteComment(@LoginMemberId Long memberId, + @PathVariable Long articleId, + @PathVariable Long commentId) { + commentService.deleteComment(memberId,articleId,commentId); + + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success(null)); + } } diff --git a/src/main/java/org/sopt/comment/dto/request/CommentUpdateRequest.java b/src/main/java/org/sopt/comment/dto/request/CommentUpdateRequest.java new file mode 100644 index 0000000..6d47c21 --- /dev/null +++ b/src/main/java/org/sopt/comment/dto/request/CommentUpdateRequest.java @@ -0,0 +1,13 @@ +package org.sopt.comment.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentUpdateRequest( + + @NotBlank(message = "댓글 내용은 필수입니다.") + @Size(max = 300, message = "댓글은 300자를 초과할 수 없습니다.") + String content + +) { +} diff --git a/src/main/java/org/sopt/comment/entity/Comment.java b/src/main/java/org/sopt/comment/entity/Comment.java index 3a59bcc..c4cbdae 100644 --- a/src/main/java/org/sopt/comment/entity/Comment.java +++ b/src/main/java/org/sopt/comment/entity/Comment.java @@ -41,4 +41,8 @@ public static Comment create(String content, Article article, Member member) { return comment; } + + public void updateContent(String content) { + this.content = content; + } } diff --git a/src/main/java/org/sopt/comment/exception/CommentErrorCode.java b/src/main/java/org/sopt/comment/exception/CommentErrorCode.java index c801529..6c6a160 100644 --- a/src/main/java/org/sopt/comment/exception/CommentErrorCode.java +++ b/src/main/java/org/sopt/comment/exception/CommentErrorCode.java @@ -4,7 +4,9 @@ import org.springframework.http.HttpStatus; public enum CommentErrorCode implements ErrorCode { - COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND,"C001","해당 댓글을 찾을 수 없습니다."); + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND,"C001","해당 댓글을 찾을 수 없습니다."), + COMMENT_NOT_MATCH_ARTICLE(HttpStatus.NOT_FOUND,"C002","해당 아티클의 댓글이 아닙니다."), + NOT_COMMENT_OWNER(HttpStatus.NOT_FOUND,"C003","해당 아티클의 작성자가 아닙니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/org/sopt/comment/exception/CommentException.java b/src/main/java/org/sopt/comment/exception/CommentException.java new file mode 100644 index 0000000..988d1f4 --- /dev/null +++ b/src/main/java/org/sopt/comment/exception/CommentException.java @@ -0,0 +1,10 @@ +package org.sopt.comment.exception; + +import org.sopt.global.exception.BusinessException; +import org.sopt.global.exception.errorcode.ErrorCode; + +public class CommentException extends BusinessException { + public CommentException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/org/sopt/comment/service/CommentService.java b/src/main/java/org/sopt/comment/service/CommentService.java index abfe039..5b148e3 100644 --- a/src/main/java/org/sopt/comment/service/CommentService.java +++ b/src/main/java/org/sopt/comment/service/CommentService.java @@ -6,9 +6,12 @@ import org.sopt.article.exception.ArticleException; import org.sopt.article.repository.ArticleRepository; 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.entity.Comment; +import org.sopt.comment.exception.CommentErrorCode; +import org.sopt.comment.exception.CommentException; import org.sopt.comment.repository.CommentRepository; import org.sopt.member.entity.Member; import org.sopt.member.exception.MemberErrorCode; @@ -35,7 +38,7 @@ public CommentResponse createComment(Long memberId, Long articleId, CommentCreat Article article = articleRepository.findById(articleId) .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); - Comment comment = Comment.create(request.content(),article,member); + Comment comment = Comment.create(request.content(), article, member); Comment savedComment = commentRepository.save(comment); @@ -43,7 +46,7 @@ public CommentResponse createComment(Long memberId, Long articleId, CommentCreat } @Transactional(readOnly = true) - public CommentListResponse findComment(Long articleId){ + public CommentListResponse findComment(Long articleId) { Article article = articleRepository.findById(articleId) .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); @@ -52,4 +55,43 @@ public CommentListResponse findComment(Long articleId){ return CommentListResponse.from(comments); } + + public CommentResponse updateComment(Long memberId, Long articleId, Long commentId, CommentUpdateRequest request) { + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CommentException(CommentErrorCode.COMMENT_NOT_FOUND)); + + // 아티클 일치 확인 + if (!comment.getArticle().getId().equals(articleId)) { + throw new CommentException(CommentErrorCode.COMMENT_NOT_MATCH_ARTICLE); + } + + // 작성자 확인 + if (!comment.getMember().getId().equals(memberId)) { + throw new CommentException(CommentErrorCode.NOT_COMMENT_OWNER); + } + + comment.updateContent(request.content()); + + return CommentResponse.from(comment); + + } + + public void deleteComment(Long memberId, Long articleId, Long commentId) { + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CommentException(CommentErrorCode.COMMENT_NOT_FOUND)); + + // 게시글 일치 확인 + if (!comment.getArticle().getId().equals(articleId)) { + throw new CommentException(CommentErrorCode.COMMENT_NOT_MATCH_ARTICLE); + } + + // 작성자 확인 + if (!comment.getMember().getId().equals(memberId)) { + throw new CommentException(CommentErrorCode.NOT_COMMENT_OWNER); + } + + commentRepository.delete(comment); + } } From 17b9f84b309cb296068d2e4d0ea8a5ae65deb26e Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:35:23 +0900 Subject: [PATCH 11/25] =?UTF-8?q?feat:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/swagger/SwaggerResponseDescription.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/org/sopt/global/config/swagger/SwaggerResponseDescription.java b/src/main/java/org/sopt/global/config/swagger/SwaggerResponseDescription.java index 7e6f83b..b951ab6 100644 --- a/src/main/java/org/sopt/global/config/swagger/SwaggerResponseDescription.java +++ b/src/main/java/org/sopt/global/config/swagger/SwaggerResponseDescription.java @@ -3,6 +3,7 @@ import lombok.Getter; import org.sopt.article.exception.ArticleErrorCode; import org.sopt.auth.exception.AuthErrorCode; +import org.sopt.comment.entity.Comment; import org.sopt.comment.exception.CommentErrorCode; import org.sopt.global.exception.errorcode.ErrorCode; import org.sopt.global.exception.errorcode.GlobalErrorCode; @@ -50,6 +51,18 @@ public enum SwaggerResponseDescription { GET_COMMENT(new LinkedHashSet<>(Set.of( ArticleErrorCode.ARTICLE_NOT_FOUND + ))), + + UPDATE_COMMENT(new LinkedHashSet<>(Set.of( + CommentErrorCode.COMMENT_NOT_FOUND, + CommentErrorCode.COMMENT_NOT_MATCH_ARTICLE, + CommentErrorCode.NOT_COMMENT_OWNER + ))), + + DELETE_COMMENT(new LinkedHashSet<>(Set.of( + CommentErrorCode.COMMENT_NOT_FOUND, + CommentErrorCode.COMMENT_NOT_MATCH_ARTICLE, + CommentErrorCode.NOT_COMMENT_OWNER ))); private final Set errorCodeList; From da5c76f252dee9f17dada33feaf3c5034fd7faa5 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:35:32 +0900 Subject: [PATCH 12/25] =?UTF-8?q?refactor:=20security=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/sopt/global/config/security/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/sopt/global/config/security/SecurityConfig.java b/src/main/java/org/sopt/global/config/security/SecurityConfig.java index d24cf08..a13b57f 100644 --- a/src/main/java/org/sopt/global/config/security/SecurityConfig.java +++ b/src/main/java/org/sopt/global/config/security/SecurityConfig.java @@ -35,7 +35,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers(ALLOWED_PATH).permitAll() - .requestMatchers(HttpMethod.GET,"/comment/**").permitAll() + .requestMatchers(HttpMethod.GET,"/articles/{articleId}/comments").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) From 0c8fc93b6b4868f22a234f137128c245533f8776 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:50:31 +0900 Subject: [PATCH 13/25] =?UTF-8?q?feat:=20validation=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index 97804b1..170704e 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,9 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // validation 의존성 + implementation 'org.springframework.boot:spring-boot-starter-validation' + // mysql runtimeOnly 'com.mysql:mysql-connector-j' From fa33fc128caffdfad816cad1f6431aa7d4213aba Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Tue, 16 Dec 2025 04:14:25 +0900 Subject: [PATCH 14/25] =?UTF-8?q?feat:=20Redis=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.gradle b/build.gradle index 170704e..91f619d 100644 --- a/build.gradle +++ b/build.gradle @@ -40,4 +40,8 @@ dependencies { // 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' } \ No newline at end of file From aa79eed1eaaa37c2f85ac13a62d2a3d7de0d12a8 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Tue, 16 Dec 2025 04:15:10 +0900 Subject: [PATCH 15/25] =?UTF-8?q?feat:=20RedisConfig=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/redis/CacheTtlProperties.java | 14 +++ .../sopt/global/config/redis/RedisConfig.java | 93 +++++++++++++++++++ src/main/resources/application.yml | 23 +++++ 3 files changed, 130 insertions(+) create mode 100644 src/main/java/org/sopt/global/config/redis/CacheTtlProperties.java create mode 100644 src/main/java/org/sopt/global/config/redis/RedisConfig.java diff --git a/src/main/java/org/sopt/global/config/redis/CacheTtlProperties.java b/src/main/java/org/sopt/global/config/redis/CacheTtlProperties.java new file mode 100644 index 0000000..3f859a8 --- /dev/null +++ b/src/main/java/org/sopt/global/config/redis/CacheTtlProperties.java @@ -0,0 +1,14 @@ +package org.sopt.global.config.redis; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "cache.ttl") +public record CacheTtlProperties( + + long defaultTtl, + + long articleDetail, + + long articleList +) { +} \ No newline at end of file diff --git a/src/main/java/org/sopt/global/config/redis/RedisConfig.java b/src/main/java/org/sopt/global/config/redis/RedisConfig.java new file mode 100644 index 0000000..a954e1c --- /dev/null +++ b/src/main/java/org/sopt/global/config/redis/RedisConfig.java @@ -0,0 +1,93 @@ +package org.sopt.global.config.redis; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +@Configuration +@EnableCaching +@RequiredArgsConstructor +public class RedisConfig { + + private final CacheTtlProperties cacheTtlProperties; + + @Bean + public ObjectMapper redisObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.findAndRegisterModules(); + + BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder() + .allowIfBaseType(Object.class) + .build(); + mapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL); + + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + return mapper; + } + + @Bean + public RedisSerializer redisSerializer(ObjectMapper redisObjectMapper) { + // JSON 형식으로 직렬화 + return new GenericJackson2JsonRedisSerializer(redisObjectMapper); + } + + @Bean + public RedisCacheConfiguration redisCacheConfiguration(RedisSerializer redisSerializer) { + + return RedisCacheConfiguration.defaultCacheConfig() + // key -> String으로 저장 + .serializeKeysWith( + RedisSerializationContext.SerializationPair + .fromSerializer(new StringRedisSerializer()) + ) + // value -> JSON으로 저장 + .serializeValuesWith( + RedisSerializationContext.SerializationPair + .fromSerializer(redisSerializer) + ) + // null값 -> 캐싱x + .disableCachingNullValues() + .entryTtl(Duration.ofMillis(cacheTtlProperties.defaultTtl())); + } + + @Bean + public CacheManager cacheManager( + RedisConnectionFactory redisConnectionFactory, + RedisCacheConfiguration redisCacheConfiguration){ + + // 캐시별 설정을 담는 Map + Map cacheConfigurations = new HashMap<>(); + + // articleDetail 캐시 + cacheConfigurations.put("articleDetail", redisCacheConfiguration.entryTtl(Duration.ofMillis(cacheTtlProperties.articleDetail()))); + + cacheConfigurations.put("articleList",redisCacheConfiguration.entryTtl(Duration.ofMillis(cacheTtlProperties.articleList()))); + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(redisCacheConfiguration) + .withInitialCacheConfigurations(cacheConfigurations) + .build(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1177036..acb65c7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,6 +17,29 @@ spring: format_sql: true show-sql: true + # Redis 설정 + data: + redis: + host: ${REDIS_HOST} # Redis 서버 주소 + port: ${REDIS_PORT} # Redis 포트 + password: ${REDIS_PASSWORD} + + # 캐시 설정 + cache: + type: redis # 캐시 구현체로 Redis 사용 + +cache: + ttl: + default-ttl: ${CACHE_TTL_DEFAULT:600000} + article-detail: ${CACHE_TTL_ARTICLE_DETAIL} + article-list: ${CACHE_TTL_ARTICLE_LIST} + +logging: + level: + org.springframework.cache: TRACE + org.springframework.cache.interceptor.CacheInterceptor: TRACE + + security: jwt: From 73a43ed15a3caf2b04c025981499203d6a567c52 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Tue, 16 Dec 2025 04:15:25 +0900 Subject: [PATCH 16/25] =?UTF-8?q?refactor:=20dto=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArticleListCommentCountResponse.java | 48 +++++++++++++++++++ .../dto/response/ArticleListResponse.java | 6 +-- 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/sopt/article/dto/response/ArticleListCommentCountResponse.java diff --git a/src/main/java/org/sopt/article/dto/response/ArticleListCommentCountResponse.java b/src/main/java/org/sopt/article/dto/response/ArticleListCommentCountResponse.java new file mode 100644 index 0000000..ccfc261 --- /dev/null +++ b/src/main/java/org/sopt/article/dto/response/ArticleListCommentCountResponse.java @@ -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() + ); + } +} \ No newline at end of file diff --git a/src/main/java/org/sopt/article/dto/response/ArticleListResponse.java b/src/main/java/org/sopt/article/dto/response/ArticleListResponse.java index 192cbec..b7cd318 100644 --- a/src/main/java/org/sopt/article/dto/response/ArticleListResponse.java +++ b/src/main/java/org/sopt/article/dto/response/ArticleListResponse.java @@ -4,11 +4,11 @@ import java.util.List; -public record ArticleListResponse(List articles) { +public record ArticleListResponse(List articles) { public static ArticleListResponse from(List
articles) { - List articleResponses = articles.stream() - .map(ArticleResponse::from) + List articleResponses = articles.stream() + .map(ArticleListCommentCountResponse::from) .toList(); return new ArticleListResponse(articleResponses); From e982cff4d2913ba26b44388107fa79bb16f364e7 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Tue, 16 Dec 2025 04:15:46 +0900 Subject: [PATCH 17/25] =?UTF-8?q?feat:=20Redis=20=EC=BA=90=EC=8B=B1=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/article/service/ArticleService.java | 11 +++++++++ .../sopt/comment/service/CommentService.java | 24 +++++++++++++++++-- .../config/security/SecurityConfig.java | 2 +- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/sopt/article/service/ArticleService.java b/src/main/java/org/sopt/article/service/ArticleService.java index 4db24c5..8f00c48 100644 --- a/src/main/java/org/sopt/article/service/ArticleService.java +++ b/src/main/java/org/sopt/article/service/ArticleService.java @@ -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; @@ -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) @@ -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()); @@ -41,8 +46,12 @@ public ArticleResponse createArticle(Long memberId, ArticleCreateRequest request } + // 아티클 상세조회 (댓글 포함) 캐싱 + @Cacheable(value = "articleDetail", key = "#articleId") public ArticleResponse findArticle(Long articleId) { + log.info(">>>> [Cache Miss - DB Access] findArticle method executed for articleId: {}", articleId); + Article article = articleRepository.findById(articleId) .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); @@ -50,6 +59,8 @@ public ArticleResponse findArticle(Long articleId) { } + // 아티클 전체 조회 (댓글 개수만) 캐싱 + @Cacheable(value = "articleList", key = "'all'") public ArticleListResponse findAllArticles() { List
articles = articleRepository.findAll(); diff --git a/src/main/java/org/sopt/comment/service/CommentService.java b/src/main/java/org/sopt/comment/service/CommentService.java index 5b148e3..d8ad4ec 100644 --- a/src/main/java/org/sopt/comment/service/CommentService.java +++ b/src/main/java/org/sopt/comment/service/CommentService.java @@ -17,11 +17,14 @@ 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.Caching; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; + import java.util.List; -@Transactional +@Transactional(readOnly = true) @RequiredArgsConstructor @Service public class CommentService { @@ -30,6 +33,12 @@ public class CommentService { private final MemberRepository memberRepository; private final ArticleRepository articleRepository; + // 댓글 작성 시 아티클 상세, 아티클 목록 캐시 모두 무효화 + @Caching(evict = { + @CacheEvict(value = "articleDetail", key = "#articleId"), + @CacheEvict(value = "articleList", key = "'all'") + }) + @Transactional public CommentResponse createComment(Long memberId, Long articleId, CommentCreateRequest request) { Member member = memberRepository.findById(memberId) @@ -45,7 +54,6 @@ public CommentResponse createComment(Long memberId, Long articleId, CommentCreat return CommentResponse.from(savedComment); } - @Transactional(readOnly = true) public CommentListResponse findComment(Long articleId) { Article article = articleRepository.findById(articleId) @@ -56,6 +64,12 @@ public CommentListResponse findComment(Long articleId) { return CommentListResponse.from(comments); } + // 댓글 수정 시 해당 아티클 캐시 삭제 + @Caching(evict = { + @CacheEvict(value = "articleDetail", key = "#articleId"), + @CacheEvict(value = "articleList", key = "'all'") + }) + @Transactional public CommentResponse updateComment(Long memberId, Long articleId, Long commentId, CommentUpdateRequest request) { Comment comment = commentRepository.findById(commentId) @@ -77,6 +91,12 @@ public CommentResponse updateComment(Long memberId, Long articleId, Long comment } + // 댓글 삭제 시 해당 아티클 캐시 삭제 + @Caching(evict = { + @CacheEvict(value = "articleDetail", key = "#articleId"), + @CacheEvict(value = "articleList", key = "'all'") + }) + @Transactional public void deleteComment(Long memberId, Long articleId, Long commentId) { Comment comment = commentRepository.findById(commentId) diff --git a/src/main/java/org/sopt/global/config/security/SecurityConfig.java b/src/main/java/org/sopt/global/config/security/SecurityConfig.java index a13b57f..4860c63 100644 --- a/src/main/java/org/sopt/global/config/security/SecurityConfig.java +++ b/src/main/java/org/sopt/global/config/security/SecurityConfig.java @@ -24,7 +24,7 @@ public class SecurityConfig { private final JwtAccessDeniedHandler jwtAccessDeniedHandler; private static final String[] ALLOWED_PATH={ - "/auth/**","/members/**","/articles/all","/articles/{id}","/articles/search", + "/auth/**","/members/**","/articles/all","/articles/**","/articles/search", "/swagger-ui/**","/v3/api-docs/**","/*.html", "/static/**", "/css/**", "/js/**" }; From f5b79df25158f4053d421ad680fdfd4a33fdd770 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:26:30 +0900 Subject: [PATCH 18/25] =?UTF-8?q?fix:=20Redis=20=EC=97=AD=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/sopt/global/config/redis/RedisConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/sopt/global/config/redis/RedisConfig.java b/src/main/java/org/sopt/global/config/redis/RedisConfig.java index a954e1c..7a99913 100644 --- a/src/main/java/org/sopt/global/config/redis/RedisConfig.java +++ b/src/main/java/org/sopt/global/config/redis/RedisConfig.java @@ -39,7 +39,7 @@ public ObjectMapper redisObjectMapper() { BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder() .allowIfBaseType(Object.class) .build(); - mapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL); + mapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.EVERYTHING); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); From 0018e4e650365de786ab52d8e049021a3a3f825d Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:57:31 +0900 Subject: [PATCH 19/25] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20Redis=20=EC=BC=9C=EC=A0=B8=EC=9E=88?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=84=20=EC=8B=9C=20DB=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/article/service/ArticleService.java | 4 ++- .../sopt/global/config/redis/RedisConfig.java | 32 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/sopt/article/service/ArticleService.java b/src/main/java/org/sopt/article/service/ArticleService.java index 8f00c48..6578785 100644 --- a/src/main/java/org/sopt/article/service/ArticleService.java +++ b/src/main/java/org/sopt/article/service/ArticleService.java @@ -50,7 +50,7 @@ public ArticleResponse createArticle(Long memberId, ArticleCreateRequest request @Cacheable(value = "articleDetail", key = "#articleId") public ArticleResponse findArticle(Long articleId) { - log.info(">>>> [Cache Miss - DB Access] findArticle method executed for articleId: {}", articleId); + log.info("[CACHE MISS] DB 조회 아티클 ID: {}", articleId); Article article = articleRepository.findById(articleId) .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); @@ -63,6 +63,8 @@ public ArticleResponse findArticle(Long articleId) { @Cacheable(value = "articleList", key = "'all'") public ArticleListResponse findAllArticles() { + log.info("[CACHE MISS] DB 조회 - 전체 아티클 목록"); + List
articles = articleRepository.findAll(); return ArticleListResponse.from(articles); diff --git a/src/main/java/org/sopt/global/config/redis/RedisConfig.java b/src/main/java/org/sopt/global/config/redis/RedisConfig.java index 7a99913..55d3d01 100644 --- a/src/main/java/org/sopt/global/config/redis/RedisConfig.java +++ b/src/main/java/org/sopt/global/config/redis/RedisConfig.java @@ -7,9 +7,14 @@ import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CachingConfigurer; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.cache.interceptor.SimpleCacheErrorHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.cache.RedisCacheConfiguration; @@ -24,13 +29,38 @@ import java.util.HashMap; import java.util.Map; +@Slf4j @Configuration @EnableCaching @RequiredArgsConstructor -public class RedisConfig { +public class RedisConfig implements CachingConfigurer { private final CacheTtlProperties cacheTtlProperties; + @Override + public CacheErrorHandler errorHandler() { + + return new SimpleCacheErrorHandler() { + @Override + public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) { + // Get 하다가 에러 나면 로그만 찍고 넘어감 -> DB 조회로 감 + log.warn("Redis Connection Error (GET): {}", exception.getMessage()); + } + + @Override + public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) { + // Put 하다가 에러 나면 로그만 찍고 넘어감 + log.warn("Redis Connection Error (PUT): {}", exception.getMessage()); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) { + // Evict(삭제) 하다가 에러 나면 로그만 찍고 넘어감 + log.warn("Redis Connection Error (EVICT): {}", exception.getMessage()); + } + }; + } + @Bean public ObjectMapper redisObjectMapper() { ObjectMapper mapper = new ObjectMapper(); From e4d8ac506cabd897b65d5113061cb4b83c7d3421 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:53:43 +0900 Subject: [PATCH 20/25] =?UTF-8?q?refactor:=20import=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/article/controller/ArticleController.java | 6 ------ .../global/config/security/SecurityConfig.java | 13 +++++++++++-- .../sopt/global/config/swagger/ExampleHolder.java | 2 +- .../sopt/global/config/swagger/SwaggerConfig.java | 9 ++++----- src/main/resources/application.yml | 14 +++++++++++++- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/sopt/article/controller/ArticleController.java b/src/main/java/org/sopt/article/controller/ArticleController.java index 92c9df2..99ce213 100644 --- a/src/main/java/org/sopt/article/controller/ArticleController.java +++ b/src/main/java/org/sopt/article/controller/ArticleController.java @@ -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; @@ -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") diff --git a/src/main/java/org/sopt/global/config/security/SecurityConfig.java b/src/main/java/org/sopt/global/config/security/SecurityConfig.java index 4860c63..16b0599 100644 --- a/src/main/java/org/sopt/global/config/security/SecurityConfig.java +++ b/src/main/java/org/sopt/global/config/security/SecurityConfig.java @@ -24,8 +24,17 @@ public class SecurityConfig { private final JwtAccessDeniedHandler jwtAccessDeniedHandler; private static final String[] ALLOWED_PATH={ - "/auth/**","/members/**","/articles/all","/articles/**","/articles/search", - "/swagger-ui/**","/v3/api-docs/**","/*.html", "/static/**", "/css/**", "/js/**" + "/auth/**", + "/members/**", + + "/articles/all", + "/articles/**", + "/articles/search", + + "/v3/api-docs/**", + "/swagger-ui/**", + + "/*.html", "/static/**", "/css/**", "/js/**" }; @Bean diff --git a/src/main/java/org/sopt/global/config/swagger/ExampleHolder.java b/src/main/java/org/sopt/global/config/swagger/ExampleHolder.java index 60e05ef..7c8d6d2 100644 --- a/src/main/java/org/sopt/global/config/swagger/ExampleHolder.java +++ b/src/main/java/org/sopt/global/config/swagger/ExampleHolder.java @@ -1,4 +1,4 @@ -package org.sopt.__sopkathon.global.config.swagger; +package org.sopt.global.config.swagger; import io.swagger.v3.oas.models.examples.Example; import lombok.Builder; diff --git a/src/main/java/org/sopt/global/config/swagger/SwaggerConfig.java b/src/main/java/org/sopt/global/config/swagger/SwaggerConfig.java index d0b5bd0..368763f 100644 --- a/src/main/java/org/sopt/global/config/swagger/SwaggerConfig.java +++ b/src/main/java/org/sopt/global/config/swagger/SwaggerConfig.java @@ -9,7 +9,6 @@ import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.responses.ApiResponse; import io.swagger.v3.oas.models.responses.ApiResponses; -import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import org.sopt.global.annotation.BusinessExceptionDescription; import org.sopt.global.exception.errorcode.ErrorCode; @@ -81,18 +80,18 @@ private void generateErrorCodeResponseExample( Set errorCodeList = type.getErrorCodeList(); // 3. 각 에러 코드 ExampleHolder로 변환 및 HTTP 상태별 그룹핑 - Map> statusWithExampleHolders = + Map> statusWithExampleHolders = errorCodeList.stream() .map( errorCode -> { - return org.sopt.__sopkathon.global.config.swagger.ExampleHolder.builder() + return org.sopt.global.config.swagger.ExampleHolder.builder() .holder( getSwaggerExample(errorCode)) // ErrorCode -> Swagger Example .code(errorCode.getStatus().value()) // 404 .name(errorCode.toString()) // M001 .build(); } - ).collect(groupingBy(org.sopt.__sopkathon.global.config.swagger.ExampleHolder::getCode)); + ).collect(groupingBy(org.sopt.global.config.swagger.ExampleHolder::getCode)); // 4. 스웨거에 추가 addExamplesToResponses(responses, statusWithExampleHolders); @@ -125,7 +124,7 @@ private Example getSwaggerExample(ErrorCode errorCode) { // Swagger에 Example 추가 private void addExamplesToResponses( ApiResponses responses, - Map> statusWithExampleHolders) { + Map> statusWithExampleHolders) { // HTTP 상태 코드별 처리 statusWithExampleHolders.forEach( diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index acb65c7..0c8d99a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -48,4 +48,16 @@ security: refreshTokenExpired: ${REFRESH_TOKEN_EXPIRED} kakao: restApiKey: ${KAKAO_REST_API_KEY} - redirectUri: ${KAKAO_REDIRECT_URI} \ No newline at end of file + redirectUri: ${KAKAO_REDIRECT_URI} + +springdoc: + default-consumes-media-type: application/json + default-produces-media-type: application/json + api-docs: + path: /v3/api-docs # API 명세서 JSON 경로 설정 + swagger-ui: + path: /swagger-ui # Swagger UI 접속 경로 (localhost:8080/swagger-ui) + url: /v3/api-docs # ⭐️ 가장 중요! Swagger에게 읽어올 문서 위치를 강제로 지정 + disable-swagger-default-url: true # 기본 Petstore URL 비활성화 + operations-sorter: method # API 목록을 HTTP Method 순서로 정렬 + display-request-duration: true # 요청 보냈을 때 걸린 시간 표시 (디버깅용) \ No newline at end of file From 679c90ce509ef8bf2863704b5d500f00e9e8e620 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Thu, 25 Dec 2025 14:33:47 +0900 Subject: [PATCH 21/25] =?UTF-8?q?refactor:=20yml=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + build.gradle | 7 ++++--- .../article/repository/ArticleRepository.java | 1 + .../config/security/SecurityConfig.java | 2 +- src/main/resources/application.yml | 20 +++++++------------ 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 53ec8db..ec095f1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ build/ .idea/jarRepositories.xml .idea/compiler.xml .idea/libraries/ +.idea/ *.iws *.iml *.ipr diff --git a/build.gradle b/build.gradle index 91f619d..4ab8f44 100644 --- a/build.gradle +++ b/build.gradle @@ -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' @@ -25,7 +25,8 @@ 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 의존성 추가 diff --git a/src/main/java/org/sopt/article/repository/ArticleRepository.java b/src/main/java/org/sopt/article/repository/ArticleRepository.java index 68aedaa..745a1c6 100644 --- a/src/main/java/org/sopt/article/repository/ArticleRepository.java +++ b/src/main/java/org/sopt/article/repository/ArticleRepository.java @@ -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; diff --git a/src/main/java/org/sopt/global/config/security/SecurityConfig.java b/src/main/java/org/sopt/global/config/security/SecurityConfig.java index 16b0599..415ccb9 100644 --- a/src/main/java/org/sopt/global/config/security/SecurityConfig.java +++ b/src/main/java/org/sopt/global/config/security/SecurityConfig.java @@ -23,7 +23,7 @@ public class SecurityConfig { private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; - private static final String[] ALLOWED_PATH={ + private static final String[] ALLOWED_PATH = { "/auth/**", "/members/**", diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0c8d99a..1f65cdd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,8 +4,8 @@ spring: datasource: url: jdbc:mysql://localhost:3306/assignment - username: hyodongg - password: ejvhqkel12!@ + username: ${LOCAL_DB_USER} + password: ${LOCAL_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver jpa: @@ -13,6 +13,7 @@ spring: ddl-auto: update properties: hibernate: + default_batch_fetch_size: 100 dialect: org.hibernate.dialect.MySQL8Dialect format_sql: true show-sql: true @@ -39,8 +40,6 @@ logging: org.springframework.cache: TRACE org.springframework.cache.interceptor.CacheInterceptor: TRACE - - security: jwt: secret: ${JWT_SECRET} @@ -51,13 +50,8 @@ security: redirectUri: ${KAKAO_REDIRECT_URI} springdoc: - default-consumes-media-type: application/json - default-produces-media-type: application/json - api-docs: - path: /v3/api-docs # API 명세서 JSON 경로 설정 swagger-ui: - path: /swagger-ui # Swagger UI 접속 경로 (localhost:8080/swagger-ui) - url: /v3/api-docs # ⭐️ 가장 중요! Swagger에게 읽어올 문서 위치를 강제로 지정 - disable-swagger-default-url: true # 기본 Petstore URL 비활성화 - operations-sorter: method # API 목록을 HTTP Method 순서로 정렬 - display-request-duration: true # 요청 보냈을 때 걸린 시간 표시 (디버깅용) \ No newline at end of file + url: /v3/api-docs + path: /swagger-ui.html + api-docs: + path: /v3/api-docs \ No newline at end of file From d909cea3e2b518903d7f93d40af85b6f8c3f8341 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Fri, 26 Dec 2025 14:28:01 +0900 Subject: [PATCH 22/25] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.gradle b/build.gradle index 4ab8f44..43a0431 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,10 @@ repositories { mavenCentral() } +test { + useJUnitPlatform() +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' From b0d73debc9b6f7d0f43334850095e8361b1be193 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:10:41 +0900 Subject: [PATCH 23/25] =?UTF-8?q?Feat:=20MemberService=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/org/sopt/member/service/MemberServiceTest.java | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/test/java/org/sopt/member/service/MemberServiceTest.java diff --git a/src/test/java/org/sopt/member/service/MemberServiceTest.java b/src/test/java/org/sopt/member/service/MemberServiceTest.java new file mode 100644 index 0000000..1fed4ff --- /dev/null +++ b/src/test/java/org/sopt/member/service/MemberServiceTest.java @@ -0,0 +1 @@ +import org.sopt.member.repository.MemberRepository; From 7a53256901f3496205926f6734b680e7b3a41625 Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:46:04 +0900 Subject: [PATCH 24/25] =?UTF-8?q?refactor:=20=EB=82=B4=EB=B6=80=20?= =?UTF-8?q?=ED=97=AC=ED=8D=BC=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/service/MemberServiceTest.java | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/src/test/java/org/sopt/member/service/MemberServiceTest.java b/src/test/java/org/sopt/member/service/MemberServiceTest.java index 1fed4ff..7d1d512 100644 --- a/src/test/java/org/sopt/member/service/MemberServiceTest.java +++ b/src/test/java/org/sopt/member/service/MemberServiceTest.java @@ -1 +1,181 @@ +package org.sopt.member.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.member.dto.request.MemberCreateRequest; +import org.sopt.member.dto.response.MemberResponse; +import org.sopt.member.entity.Gender; +import org.sopt.member.entity.Member; +import org.sopt.member.exception.MemberException; import org.sopt.member.repository.MemberRepository; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +// Mockito 사용 준비 +@ExtendWith(MockitoExtension.class) +@DisplayName("MemberService 단위 테스트") +class MemberServiceTest { + + // 가짜 객체 생성 + @Mock + private MemberRepository memberRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + // MemberService에 위 Mock들 자동 주입 + @InjectMocks + private MemberService memberService; + + @DisplayName("이메일 중복이 없으면 회원가입에 성공한다.") + @Test + void joinSuccess() { + // given 준비 + MemberCreateRequest request = new MemberCreateRequest( + "test", + "2001-12-01", + "test@example.com", + Gender.MALE, + "1234" + ); + // 이메일 중복 없다고 가정 + when(memberRepository.existsByEmail("test@example.com")) + .thenReturn(false); + // 비밀번호 암호화 시 + when(passwordEncoder.encode("1234")) + .thenReturn("encoded_password_1234"); + when(memberRepository.save(any(Member.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when 실행 + MemberResponse response = memberService.join(request); + + // then 검증 + assertThat(response).isNotNull(); + assertThat(response) + .extracting("name","email") + .containsExactly("test","test@example.com"); + } + + @DisplayName("미성년자가 회원가입을 시도하면 예외가 발생한다.") + @Test + void joinFailWithUnderAge() { + // given 준비 + MemberCreateRequest request = new MemberCreateRequest( + "test", + "2010-10-10", + "test@example.com", + Gender.FEMALE, + "1234" + ); + + when(memberRepository.existsByEmail("test@example.com")) + .thenReturn(false); + + when(passwordEncoder.encode("1234")) + .thenReturn("encoded_password_1234"); + + // when & then 검증 + assertThatThrownBy(() -> memberService.join(request)) + .isInstanceOf(MemberException.class); + } + + @DisplayName("중복된 이메일로 회원가입을 시도하면 예외가 발생한다") + @Test + void joinFailWithDuplicateEmail() { + // given 준비 + MemberCreateRequest request = new MemberCreateRequest( + "test", + "2001-12-01", + "test@example.com", + Gender.MALE, + "1234" + ); + when(memberRepository.existsByEmail("test@example.com")) + .thenReturn(true); + + // when & then 검증 + assertThatThrownBy(() -> memberService.join(request)) + .isInstanceOf(MemberException.class); + } + + @DisplayName("회원 ID로 회원을 조회할 수 있다") + @Test + void findMemberSuccess() { + // given 준비 + Member member = createTestMember(); + + when(memberRepository.findById(1L)) + .thenReturn(Optional.of(member)); + + // when 실행 + MemberResponse response = memberService.findOne(member.getId()); + + // then 검증 + assertThat(response).isNotNull(); + assertThat(response.name()).isEqualTo("test"); + } + + @DisplayName("존재하지 않는 회원 ID로 조회하면 예외가 발생한다") + @Test + void findMemberFailWithNotFound() { + // given 준비 + when(memberRepository.findById(999L)) + .thenReturn(Optional.empty()); + + // when & then 검증 + assertThatThrownBy(() -> memberService.findOne(999L)) + .isInstanceOf(MemberException.class); + } + + @DisplayName("회원 ID로 회원을 삭제할 수 있다") + @Test + void deleteMemberSuccess() { + // given 준비 + Member member = createTestMember(); + + when(memberRepository.findById(1L)) + .thenReturn(Optional.of(member)); + + // when 실행 + memberService.deleteMember(1L); + + // then 검증 + // 메서드가 제대로 호출되었는지 검증 + verify(memberRepository).deleteById(1L); + } + + @DisplayName("존재하지 않는 회원 ID로 삭제하면 예외가 발생한다") + @Test + void deleteMemberFailWithNotFound() { + // given 준비 + when(memberRepository.findById(999L)) + .thenReturn(Optional.empty()); + + // when & then 검증 + assertThatThrownBy(() -> memberService.deleteMember(999L)) + .isInstanceOf(MemberException.class); + } + + private Member createTestMember() { + return Member.builder() + .id(1L) + .name("test") + .birth("2001-12-01") + .email("test@example.com") + .gender(Gender.MALE) + .password("encoded_password") + .build(); + } +} \ No newline at end of file From 93c0367f1ef306ef74e1f56b0aef461fa0b4e6ff Mon Sep 17 00:00:00 2001 From: JO HYODONG <160843817+hyodongg@users.noreply.github.com> Date: Fri, 26 Dec 2025 18:07:38 +0900 Subject: [PATCH 25/25] =?UTF-8?q?feat:=20Controller=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/MemberControllerTest.java | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/test/java/org/sopt/member/controller/MemberControllerTest.java diff --git a/src/test/java/org/sopt/member/controller/MemberControllerTest.java b/src/test/java/org/sopt/member/controller/MemberControllerTest.java new file mode 100644 index 0000000..89c85f0 --- /dev/null +++ b/src/test/java/org/sopt/member/controller/MemberControllerTest.java @@ -0,0 +1,87 @@ +package org.sopt.member.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sopt.global.config.security.SecurityConfig; +import org.sopt.global.jwt.JwtAuthenticationFilter; +import org.sopt.member.dto.request.MemberCreateRequest; +import org.sopt.member.dto.response.MemberResponse; +import org.sopt.member.entity.Gender; +import org.sopt.member.service.MemberService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest( + controllers = MemberController.class, + excludeFilters = { + @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = { + SecurityConfig.class, + JwtAuthenticationFilter.class + } + ) + } +) +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("MemberController 테스트") +class MemberControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private MemberService memberService; + + + @DisplayName("회원 가입 요청 시 201 Created를 반환한다") + @Test + void createMember() throws Exception { + // given + MemberCreateRequest request = new MemberCreateRequest( + "test", + "2001-12-01", + "test@example.com", + Gender.MALE, + "1234" + ); + + MemberResponse response = new MemberResponse( + 1L, + "test", + "2001-12-01", + "test@example.com", + Gender.MALE + ); + + when(memberService.join(any(MemberCreateRequest.class))) + .thenReturn(response); + + // when & then + mockMvc.perform(post("/members") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.message").value("요청이 성공적으로 처리되었습니다")) + .andExpect(jsonPath("$.data.name").value("test")) + .andExpect(jsonPath("$.data.email").value("test@example.com")); + } + +} \ No newline at end of file