Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ dependencies {

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

implementation 'com.auth0:java-jwt:4.4.0'

implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
}
32 changes: 32 additions & 0 deletions src/main/java/org/sopt/config/SwaggerConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.sopt.config;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;

@Configuration
public class SwaggerConfig {

@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("SOPT API")
.description("SOPT 과제 API 문서")
.version("v1.0.0")
.contact(new Contact()
.name("byunheemin")
.email("[email protected]")))
.servers(List.of(
new Server()
.url("http://localhost:8080")
.description("로컬 서버")
));
}
}
80 changes: 80 additions & 0 deletions src/main/java/org/sopt/controller/CommentController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package org.sopt.controller;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.sopt.dto.CommentResponseDto;
import org.sopt.dto.CreateCommentRequestDto;
import org.sopt.dto.UpdateCommentRequestDto;
import org.sopt.global.ApiResponseDto;
import org.sopt.service.CommentService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

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

private final CommentService commentService;

// 댓글 작성
@PostMapping
public ResponseEntity<ApiResponseDto<Long>> createComment(
@Valid @RequestBody CreateCommentRequestDto request) {

Long commentId = commentService.createComment(request);
ApiResponseDto<Long> response = ApiResponseDto.success(commentId);

return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

// 댓글 단건 조회
@GetMapping("/{id}")
public ResponseEntity<ApiResponseDto<CommentResponseDto>> getComment(
@PathVariable Long id) {

CommentResponseDto comment = commentService.getComment(id);
ApiResponseDto<CommentResponseDto> response = ApiResponseDto.success(comment);

return ResponseEntity.ok(response);
}

// 특정 게시글의 모든 댓글 조회
@GetMapping("/article/{articleId}")
public ResponseEntity<ApiResponseDto<List<CommentResponseDto>>> getCommentsByArticle(
@PathVariable Long articleId) {

List<CommentResponseDto> comments = commentService.getCommentsByArticle(articleId);
ApiResponseDto<List<CommentResponseDto>> response = ApiResponseDto.success(comments);

return ResponseEntity.ok(response);
}

// 댓글 수정
@PatchMapping("/{id}")
public ResponseEntity<ApiResponseDto<Void>> updateComment(
@PathVariable Long id,
@RequestParam Long memberId,
@Valid @RequestBody UpdateCommentRequestDto request) {

commentService.updateComment(id, memberId, request);
ApiResponseDto<Void> response = ApiResponseDto.success(null);

return ResponseEntity.ok(response);
}

// 댓글 삭제
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponseDto<Void>> deleteComment(
@PathVariable Long id,
@RequestParam Long memberId) {

commentService.deleteComment(id, memberId);
ApiResponseDto<Void> response = ApiResponseDto.success(null);

return ResponseEntity.ok(response);
}
}
13 changes: 12 additions & 1 deletion src/main/java/org/sopt/domain/Article.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@
import lombok.NoArgsConstructor;

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

@Entity
@Table(name = "articles")
@Table(name = "articles", indexes = {
@Index(name = "idx_article_member_id", columnList = "memberId"),
@Index(name = "idx_article_created_date", columnList = "createdDate"),
@Index(name = "idx_article_category", columnList = "category"),
@Index(name = "idx_article_category_created", columnList = "category, createdDate")
})
@Getter
@NoArgsConstructor
public class Article {
Expand All @@ -30,6 +37,10 @@ public class Article {

private String content;

@OneToMany(mappedBy = "article", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<Comment> comments = new ArrayList<>();


public Article(Member member, Category category, String title, String content) {
this.member = member;
this.category = category;
Expand Down
46 changes: 46 additions & 0 deletions src/main/java/org/sopt/domain/Comment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package org.sopt.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDate;

@Entity
@Table(name = "comments", indexes = {
@Index(name = "idx_comment_article_id", columnList = "articleId"),
@Index(name = "idx_comment_member_id", columnList = "memberId"),
@Index(name = "idx_comment_article_created", columnList = "articleId, createdDate")
})
@Getter
@NoArgsConstructor
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch=FetchType.LAZY)
@JoinColumn(name = "memberId",nullable = false)
private Member member;

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

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="articleId",nullable = false)
private Article article;

private LocalDate createdDate;

public Comment(Member member, Article article, String content) {
this.member = member;
this.article = article;
this.content = content;
this.createdDate = LocalDate.now();
}

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

}
10 changes: 8 additions & 2 deletions src/main/java/org/sopt/dto/ArticleResponseDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import org.sopt.domain.Category;

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

public record ArticleResponseDto(
Long id,
Expand All @@ -12,7 +14,8 @@ public record ArticleResponseDto(
Category category,
LocalDate createdDate,
String title,
String content
String content,
List<CommentResponseDto> comments
) {
public static ArticleResponseDto from(Article article) {
return new ArticleResponseDto(
Expand All @@ -22,7 +25,10 @@ public static ArticleResponseDto from(Article article) {
article.getCategory(),
article.getCreatedDate(),
article.getTitle(),
article.getContent()
article.getContent(),
article.getComments().stream()
.map(CommentResponseDto::from)
.collect(Collectors.toList())
);
}
}
25 changes: 25 additions & 0 deletions src/main/java/org/sopt/dto/CommentResponseDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.sopt.dto;

import org.sopt.domain.Comment;

import java.time.LocalDate;

public record CommentResponseDto(
Long id,
Long memberId,
String memberName,
Long articleId,
String content,
LocalDate createdDate
) {
public static CommentResponseDto from(Comment comment) {
return new CommentResponseDto(
comment.getId(),
comment.getMember().getId(),
comment.getMember().getName(),
comment.getArticle().getId(),
comment.getContent(),
comment.getCreatedDate()
);
}
}
17 changes: 17 additions & 0 deletions src/main/java/org/sopt/dto/CreateCommentRequestDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.sopt.dto;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.hibernate.validator.constraints.NotBlank;

public record CreateCommentRequestDto(
@NotNull(message = "회원 ID는 필수입니다.")
Long memberId,

@NotNull(message = "게시글 ID는 필수입니다.")
Long articleId,

@NotBlank(message = "댓글 내용은 필수입니다.")
@Size(max = 300, message = "댓글은 300자 이내로 작성해주세요.")
String content
) {}
10 changes: 10 additions & 0 deletions src/main/java/org/sopt/dto/UpdateCommentRequestDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.sopt.dto;

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

public record UpdateCommentRequestDto(
@NotBlank(message = "댓글 내용은 필수입니다.")
@Size(max = 300, message = "댓글은 300자 이내로 작성해주세요.")
String content
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.sopt.exception;

public class CommentNotFoundException extends RuntimeException {
public CommentNotFoundException(Long commentId) {
super("댓글을 찾을 수 없습니다. ID: " + commentId);
}
}
7 changes: 7 additions & 0 deletions src/main/java/org/sopt/exception/UnauthorizedException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.sopt.exception;

public class UnauthorizedException extends RuntimeException {
public UnauthorizedException(String message) {
super(message);
}
}
18 changes: 18 additions & 0 deletions src/main/java/org/sopt/global/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ public ResponseEntity<ApiResponseDto<Void>> handleMemberNotFoundException(
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}

// 404: 댓글을 찾을 수 없음
@ExceptionHandler(CommentNotFoundException.class)
public ResponseEntity<ApiResponseDto<Void>> handleCommentNotFoundException(
CommentNotFoundException e) {

ApiResponseDto<Void> response = ApiResponseDto.error(404, e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}

// 403: 댓글 수정 권한 없음
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<ApiResponseDto<Void>> handleUnauthorizedException(
UnauthorizedException e) {

ApiResponseDto<Void> response = ApiResponseDto.error(403, e.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response);
}

// 409: 이메일 중복
@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ApiResponseDto<Void>> handleDuplicateEmailException(
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/org/sopt/repository/ArticleRepository.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
package org.sopt.repository;

import org.sopt.domain.Article;
import org.sopt.domain.Category;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

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

@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {
boolean existsByTitle(String title);
// N+1 문제 해결: Member를 fetch join으로 한번에 조회
@Query("SELECT a FROM Article a JOIN FETCH a.member ORDER BY a.createdDate DESC")
List<Article> findAllWithMember();

// 카테고리별 조회 (인덱스 활용)
@Query("SELECT a FROM Article a JOIN FETCH a.member WHERE a.category = :category ORDER BY a.createdDate DESC")
List<Article> findByCategoryWithMember(@Param("category") Category category);

// 게시글 상세 조회 시 Member와 Comments를 한번에 조회 (N+1 해결)
@Query("SELECT DISTINCT a FROM Article a " +
"JOIN FETCH a.member " +
"LEFT JOIN FETCH a.comments c " +
"LEFT JOIN FETCH c.member " +
"WHERE a.id = :id")
Optional<Article> findByIdWithMemberAndComments(@Param("id") Long id);
}
21 changes: 21 additions & 0 deletions src/main/java/org/sopt/repository/CommentRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.sopt.repository;

import org.sopt.domain.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

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

public interface CommentRepository extends JpaRepository<Comment, Long> {
List<Comment> findByArticleId(Long articleId);

// 인덱스 사용, articleId로 조회
@Query("SELECT c FROM Comment c JOIN FETCH c.member WHERE c.article.id = :articleId ORDER BY c.createdDate DESC")
List<Comment> findByArticleIdWithMember(@Param("articleId") Long articleId);

// 댓글 단건 조회 시 Member도 함께 조회
@Query("SELECT c FROM Comment c JOIN FETCH c.member WHERE c.id = :id")
Optional<Comment> findByIdWithMember(@Param("id") Long id);
}
Loading