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/.idea/dataSources.xml b/.idea/dataSources.xml index 45ece12..48290b7 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -1,16 +1,11 @@ - + mysql.8 true com.mysql.cj.jdbc.Driver jdbc:mysql://localhost:3306 - - - - - $ProjectFileDir$ 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/.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/build.gradle b/build.gradle index 97804b1..43a0431 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' @@ -17,6 +17,10 @@ repositories { mavenCentral() } +test { + useJUnitPlatform() +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' @@ -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' } \ No newline at end of file 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/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); 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/article/entity/Article.java b/src/main/java/org/sopt/article/entity/Article.java index 4e6ad32..3f5629a 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,10 @@ public class Article { @JoinColumn(name = "member_id") 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) { Article article = Article.builder() .title(title) 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/article/service/ArticleService.java b/src/main/java/org/sopt/article/service/ArticleService.java index 4db24c5..6578785 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 조회 아티클 ID: {}", articleId); + Article article = articleRepository.findById(articleId) .orElseThrow(() -> new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND)); @@ -50,8 +59,12 @@ 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/comment/controller/CommentController.java b/src/main/java/org/sopt/comment/controller/CommentController.java new file mode 100644 index 0000000..180b6e0 --- /dev/null +++ b/src/main/java/org/sopt/comment/controller/CommentController.java @@ -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> 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> 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/CommentCreateRequest.java b/src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java new file mode 100644 index 0000000..cdc3910 --- /dev/null +++ b/src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java @@ -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 + +) { +} 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/dto/response/CommentListResponse.java b/src/main/java/org/sopt/comment/dto/response/CommentListResponse.java new file mode 100644 index 0000000..c8e164f --- /dev/null +++ b/src/main/java/org/sopt/comment/dto/response/CommentListResponse.java @@ -0,0 +1,18 @@ +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..41f2b47 --- /dev/null +++ b/src/main/java/org/sopt/comment/dto/response/CommentResponse.java @@ -0,0 +1,28 @@ +package org.sopt.comment.dto.response; + +import org.sopt.comment.entity.Comment; + +public record CommentResponse( + + // 댓글 아이디 + Long id, + + // 댓글 내용 + String content, + + // 댓글 작성자 멤버 id + Long memberId, + + // 댓글 작성자 멤버 이름 + String memberName +) { + + public static CommentResponse from(Comment comment) { + return new CommentResponse( + comment.getId(), + comment.getContent(), + comment.getMember().getId(), + comment.getMember().getName() + ); + } +} 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..c4cbdae --- /dev/null +++ b/src/main/java/org/sopt/comment/entity/Comment.java @@ -0,0 +1,48 @@ +package org.sopt.comment.entity; + + +import jakarta.persistence.*; +import lombok.*; +import org.sopt.article.entity.Article; +import org.sopt.member.entity.Member; + +@Entity +@Builder(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@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; + + 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; + } + + 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 new file mode 100644 index 0000000..6c6a160 --- /dev/null +++ b/src/main/java/org/sopt/comment/exception/CommentErrorCode.java @@ -0,0 +1,32 @@ +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","해당 댓글을 찾을 수 없습니다."), + COMMENT_NOT_MATCH_ARTICLE(HttpStatus.NOT_FOUND,"C002","해당 아티클의 댓글이 아닙니다."), + NOT_COMMENT_OWNER(HttpStatus.NOT_FOUND,"C003","해당 아티클의 작성자가 아닙니다."); + + 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/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/repository/CommentRepository.java b/src/main/java/org/sopt/comment/repository/CommentRepository.java new file mode 100644 index 0000000..25efc68 --- /dev/null +++ b/src/main/java/org/sopt/comment/repository/CommentRepository.java @@ -0,0 +1,10 @@ +package org.sopt.comment.repository; + +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 new file mode 100644 index 0000000..d8ad4ec --- /dev/null +++ b/src/main/java/org/sopt/comment/service/CommentService.java @@ -0,0 +1,117 @@ +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.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; +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(readOnly = true) +@RequiredArgsConstructor +@Service +public class CommentService { + + private final CommentRepository commentRepository; + 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) + .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); + } + + 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); + } + + // 댓글 수정 시 해당 아티클 캐시 삭제 + @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) + .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); + + } + + // 댓글 삭제 시 해당 아티클 캐시 삭제 + @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) + .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); + } +} 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..55d3d01 --- /dev/null +++ b/src/main/java/org/sopt/global/config/redis/RedisConfig.java @@ -0,0 +1,123 @@ +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 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; +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; + +@Slf4j +@Configuration +@EnableCaching +@RequiredArgsConstructor +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(); + mapper.findAndRegisterModules(); + + BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder() + .allowIfBaseType(Object.class) + .build(); + mapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.EVERYTHING); + + 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/java/org/sopt/global/config/security/SecurityConfig.java b/src/main/java/org/sopt/global/config/security/SecurityConfig.java index 6fb2aa0..415ccb9 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; @@ -22,9 +23,18 @@ public class SecurityConfig { private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; - private static final String[] ALLOWED_PATH={ - "/auth/**","/members/**","/articles/all","/articles/{id}","/articles/search", - "/swagger-ui/**","/v3/api-docs/**","/*.html", "/static/**", "/css/**", "/js/**" + private static final String[] ALLOWED_PATH = { + "/auth/**", + "/members/**", + + "/articles/all", + "/articles/**", + "/articles/search", + + "/v3/api-docs/**", + "/swagger-ui/**", + + "/*.html", "/static/**", "/css/**", "/js/**" }; @Bean @@ -34,6 +44,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers(ALLOWED_PATH).permitAll() + .requestMatchers(HttpMethod.GET,"/articles/{articleId}/comments").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) 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/java/org/sopt/global/config/swagger/SwaggerResponseDescription.java b/src/main/java/org/sopt/global/config/swagger/SwaggerResponseDescription.java index 24606e6..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,11 +3,14 @@ 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; import org.sopt.member.exception.MemberErrorCode; import java.util.LinkedHashSet; +import java.util.LinkedList; import java.util.Set; @Getter @@ -38,6 +41,28 @@ 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 + ))), + + 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; 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","접근 권한이 없습니다."); ; 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); } diff --git a/src/main/java/org/sopt/member/entity/Member.java b/src/main/java/org/sopt/member/entity/Member.java index fac7b55..04d54a9 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; @@ -38,6 +39,7 @@ public class Member { private Gender gender; @OneToMany(mappedBy = "member") + @Builder.Default private List
articles = new ArrayList<>(); @Column(nullable = true) @@ -49,6 +51,10 @@ public class Member { @Column(name = "provider_id") private String providerId; // 카카오 회원번호를 위한 필드 + @OneToMany(mappedBy = "member") + @Builder.Default + private List comments = new ArrayList<>(); + public static Member create(String name, String birth, String email, Gender gender, String password) { validateAge(birth); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1177036..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,10 +13,32 @@ spring: ddl-auto: update properties: hibernate: + default_batch_fetch_size: 100 dialect: org.hibernate.dialect.MySQL8Dialect 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: @@ -25,4 +47,11 @@ 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: + swagger-ui: + url: /v3/api-docs + path: /swagger-ui.html + api-docs: + path: /v3/api-docs \ No newline at end of file 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 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..7d1d512 --- /dev/null +++ b/src/test/java/org/sopt/member/service/MemberServiceTest.java @@ -0,0 +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