diff --git a/build.gradle b/build.gradle index 8e377f6..794395d 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-validation' } tasks.named('test') { diff --git a/src/main/java/com/example/springbootassignment/controller/PostController.java b/src/main/java/com/example/springbootassignment/controller/PostController.java new file mode 100644 index 0000000..7820d67 --- /dev/null +++ b/src/main/java/com/example/springbootassignment/controller/PostController.java @@ -0,0 +1,77 @@ +package com.example.springbootassignment.controller; + +import com.example.springbootassignment.dto.PostCreateRequest; +import com.example.springbootassignment.dto.PostListResponse; +import com.example.springbootassignment.dto.PostResponse; +import com.example.springbootassignment.dto.PostUpdateRequest; +import com.example.springbootassignment.service.PostService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/posts") +public class PostController { + + private final PostService postService; + + public PostController(PostService postService) { + this.postService = postService; + } + + /** + * 게시글 목록 조회 + * GET /posts?page=1&size=10&sort=createdAt&order=desc + */ + @GetMapping + public ResponseEntity getPostList( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "createdAt") String sort, + @RequestParam(defaultValue = "desc") String order) { + PostListResponse response = postService.getPostList(page, size, sort, order); + return ResponseEntity.ok(response); + } + + /** + * 게시글 상세 조회 + * GET /posts/{postId} + */ + @GetMapping("/{postId}") + public ResponseEntity getPostDetail(@PathVariable Long postId) { + PostResponse response = postService.getPostDetail(postId); + return ResponseEntity.ok(response); + } + + /** + * 게시글 작성 + * POST /posts + */ + @PostMapping + public ResponseEntity createPost(@RequestBody PostCreateRequest request) { + PostResponse response = postService.createPost(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * 게시글 수정 + * PUT /posts/{postId} + */ + @PutMapping("/{postId}") + public ResponseEntity updatePost( + @PathVariable Long postId, + @RequestBody PostUpdateRequest request) { + PostResponse response = postService.updatePost(postId, request); + return ResponseEntity.ok(response); + } + + /** + * 게시글 삭제 + * DELETE /posts/{postId} + */ + @DeleteMapping("/{postId}") + public ResponseEntity deletePost(@PathVariable Long postId) { + postService.deletePost(postId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/example/springbootassignment/domain/post/entity/Post.java b/src/main/java/com/example/springbootassignment/domain/post/entity/Post.java index 943c679..daca53e 100644 --- a/src/main/java/com/example/springbootassignment/domain/post/entity/Post.java +++ b/src/main/java/com/example/springbootassignment/domain/post/entity/Post.java @@ -13,7 +13,8 @@ @Entity @Table(name = "posts") @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Setter +@NoArgsConstructor public class Post { @Id @@ -21,7 +22,7 @@ public class Post { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) + @JoinColumn(name = "user_id", nullable = true) // ← nullable = true로 변경 private User user; @Column(nullable = false, length = 100) @@ -52,4 +53,11 @@ public class Post { @OneToMany(mappedBy = "post", cascade = CascadeType.ALL) private List comments = new ArrayList<>(); + + /** + * 조회수 증가 + */ + public void increaseViewCount() { + this.viewCount++; + } } diff --git a/src/main/java/com/example/springbootassignment/domain/post/entity/PostRepository.java b/src/main/java/com/example/springbootassignment/domain/post/entity/PostRepository.java new file mode 100644 index 0000000..badcfc8 --- /dev/null +++ b/src/main/java/com/example/springbootassignment/domain/post/entity/PostRepository.java @@ -0,0 +1,7 @@ +package com.example.springbootassignment.domain.post.entity; + +import com.example.springbootassignment.domain.post.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/springbootassignment/dto/ErrorResponse.java b/src/main/java/com/example/springbootassignment/dto/ErrorResponse.java new file mode 100644 index 0000000..2e1053d --- /dev/null +++ b/src/main/java/com/example/springbootassignment/dto/ErrorResponse.java @@ -0,0 +1,16 @@ +package com.example.springbootassignment.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ErrorResponse { + private String code; // 에러 코드 (COMMON400, POST4041 등) + private String message; // 사용자 친화적인 메시지 + private String details; // 상세 정보 +} diff --git a/src/main/java/com/example/springbootassignment/dto/PostCreateRequest.java b/src/main/java/com/example/springbootassignment/dto/PostCreateRequest.java new file mode 100644 index 0000000..fae0aaa --- /dev/null +++ b/src/main/java/com/example/springbootassignment/dto/PostCreateRequest.java @@ -0,0 +1,18 @@ +package com.example.springbootassignment.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PostCreateRequest { + + @NotBlank(message = "제목은 필수 입력 사항입니다.") + private String title; + + @NotBlank(message = "내용은 필수 입력 사항입니다.") + private String content; + + private Long userId; +} diff --git a/src/main/java/com/example/springbootassignment/dto/PostListResponse.java b/src/main/java/com/example/springbootassignment/dto/PostListResponse.java new file mode 100644 index 0000000..9553c86 --- /dev/null +++ b/src/main/java/com/example/springbootassignment/dto/PostListResponse.java @@ -0,0 +1,42 @@ +package com.example.springbootassignment.dto; + +import com.example.springbootassignment.domain.post.entity.Post; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.domain.Page; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PostListResponse { + + private List postId; + private List title; + private List nickname; + private List createdAt; + + /** + * Page를 PostListResponse로 변환 + */ + public static PostListResponse from(Page posts) { + PostListResponse response = new PostListResponse(); + response.postId = posts.getContent().stream() + .map(Post::getId) + .toList(); + response.title = posts.getContent().stream() + .map(Post::getTitle) + .toList(); + response.nickname = posts.getContent().stream() + .map(post -> post.getUser() != null ? post.getUser().getNickname() : "unknown") + .toList(); + response.createdAt = posts.getContent().stream() + .map(Post::getCreatedAt) + .toList(); + return response; + } +} diff --git a/src/main/java/com/example/springbootassignment/dto/PostResponse.java b/src/main/java/com/example/springbootassignment/dto/PostResponse.java new file mode 100644 index 0000000..b6cc429 --- /dev/null +++ b/src/main/java/com/example/springbootassignment/dto/PostResponse.java @@ -0,0 +1,34 @@ +package com.example.springbootassignment.dto; + +import com.example.springbootassignment.domain.post.entity.Post; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PostResponse { + + private Long postId; + private String title; + private String content; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** + * Post Entity를 PostResponse DTO로 변환 + */ + public static PostResponse from(Post post) { + PostResponse response = new PostResponse(); + response.postId = post.getId(); + response.title = post.getTitle(); + response.content = post.getContent(); + response.createdAt = post.getCreatedAt(); + response.updatedAt = post.getUpdatedAt(); + return response; + } +} diff --git a/src/main/java/com/example/springbootassignment/dto/PostUpdateRequest.java b/src/main/java/com/example/springbootassignment/dto/PostUpdateRequest.java new file mode 100644 index 0000000..e548bee --- /dev/null +++ b/src/main/java/com/example/springbootassignment/dto/PostUpdateRequest.java @@ -0,0 +1,16 @@ +package com.example.springbootassignment.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PostUpdateRequest { + + @NotBlank(message = "제목은 필수 입력 사항입니다.") + private String title; + + @NotBlank(message = "내용은 필수 입력 사항입니다.") + private String content; +} diff --git a/src/main/java/com/example/springbootassignment/exception/CommentNotFoundException.java b/src/main/java/com/example/springbootassignment/exception/CommentNotFoundException.java new file mode 100644 index 0000000..c072836 --- /dev/null +++ b/src/main/java/com/example/springbootassignment/exception/CommentNotFoundException.java @@ -0,0 +1,7 @@ +package com.example.springbootassignment.exception; + +public class CommentNotFoundException extends RuntimeException { + public CommentNotFoundException(Long commentId) { + super("존재하지 않는 댓글입니다. id: " + commentId); + } +} diff --git a/src/main/java/com/example/springbootassignment/exception/ForbiddenException.java b/src/main/java/com/example/springbootassignment/exception/ForbiddenException.java new file mode 100644 index 0000000..09e8332 --- /dev/null +++ b/src/main/java/com/example/springbootassignment/exception/ForbiddenException.java @@ -0,0 +1,7 @@ +package com.example.springbootassignment.exception; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException(Long postId, Long userId) { + super("게시글 id: " + postId + "를(을) 수정/삭제할 권한이 없습니다. (사용자 id: " + userId + ")"); + } +} diff --git a/src/main/java/com/example/springbootassignment/exception/GlobalExceptionHandler.java b/src/main/java/com/example/springbootassignment/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..808aee6 --- /dev/null +++ b/src/main/java/com/example/springbootassignment/exception/GlobalExceptionHandler.java @@ -0,0 +1,97 @@ +package com.example.springbootassignment.exception; + +import com.example.springbootassignment.dto.ErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 잘못된 요청 (400 Bad Request) + * - 제목이나 내용이 비어있는 경우 + * - 유효하지 않은 입력값 + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + ErrorResponse errorResponse = new ErrorResponse( + "COMMON400", + "잘못된 요청입니다. 입력값을 확인해주세요.", + e.getMessage() + ); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /** + * 인증 오류 (401 Unauthorized) + * - 게시글 작성 시 필수 항목 누락 + * - JWT 토큰 없음 + */ + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handleUnauthorizedException(UnauthorizedException e) { + ErrorResponse errorResponse = new ErrorResponse( + "COMMON401", + "인증이 필요합니다.", + e.getMessage() + ); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); + } + + /** + * 권한 오류 (403 Forbidden) + * - 자신이 쓴 글이 아닌데 수정/삭제 시도 + */ + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handleForbiddenException(ForbiddenException e) { + ErrorResponse errorResponse = new ErrorResponse( + "COMMON403", + "접근 권한이 없습니다.", + e.getMessage() + ); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse); + } + + /** + * 게시글 조회 실패 (404 Not Found) + * - 존재하지 않는 게시글 ID로 조회/수정/삭제 시도 + */ + @ExceptionHandler(PostNotFoundException.class) + public ResponseEntity handlePostNotFoundException(PostNotFoundException e) { + ErrorResponse errorResponse = new ErrorResponse( + "POST4041", + "게시글을 찾을 수 없습니다.", + e.getMessage() + ); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); + } + + /** + * 댓글 조회 실패 (404 Not Found) + * - 존재하지 않는 댓글 ID + */ + @ExceptionHandler(CommentNotFoundException.class) + public ResponseEntity handleCommentNotFoundException(CommentNotFoundException e) { + ErrorResponse errorResponse = new ErrorResponse( + "COMMON404", + "댓글을 찾을 수 없습니다.", + e.getMessage() + ); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); + } + + /** + * 내부 서버 오류 (500 Internal Server Error) + * - 예상치 못한 오류 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException(Exception e) { + ErrorResponse errorResponse = new ErrorResponse( + "COMMON500", + "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", + e.getMessage() + ); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } +} diff --git a/src/main/java/com/example/springbootassignment/exception/PostNotFoundException.java b/src/main/java/com/example/springbootassignment/exception/PostNotFoundException.java new file mode 100644 index 0000000..f4a2932 --- /dev/null +++ b/src/main/java/com/example/springbootassignment/exception/PostNotFoundException.java @@ -0,0 +1,8 @@ +package com.example.springbootassignment.exception; + +public class PostNotFoundException extends RuntimeException { + + public PostNotFoundException(Long postId) { + super("존재하지 않는 게시글입니다. id: " + postId); + } +} diff --git a/src/main/java/com/example/springbootassignment/exception/UnauthorizedException.java b/src/main/java/com/example/springbootassignment/exception/UnauthorizedException.java new file mode 100644 index 0000000..d00afcf --- /dev/null +++ b/src/main/java/com/example/springbootassignment/exception/UnauthorizedException.java @@ -0,0 +1,7 @@ +package com.example.springbootassignment.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/springbootassignment/service/PostService.java b/src/main/java/com/example/springbootassignment/service/PostService.java new file mode 100644 index 0000000..12d6974 --- /dev/null +++ b/src/main/java/com/example/springbootassignment/service/PostService.java @@ -0,0 +1,80 @@ +package com.example.springbootassignment.service; + +import com.example.springbootassignment.domain.post.entity.Post; +import com.example.springbootassignment.domain.post.entity.PostRepository; +import com.example.springbootassignment.dto.PostCreateRequest; +import com.example.springbootassignment.dto.PostListResponse; +import com.example.springbootassignment.dto.PostResponse; +import com.example.springbootassignment.dto.PostUpdateRequest; +import com.example.springbootassignment.exception.PostNotFoundException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +@Service +public class PostService { + + private final PostRepository postRepository; + + public PostService(PostRepository postRepository) { + this.postRepository = postRepository; + } + + public PostListResponse getPostList(int page, int size, String sort, String order) { + int pageNumber = Math.max(0, page - 1); + Sort.Direction direction = "desc".equalsIgnoreCase(order) ? Sort.Direction.DESC : Sort.Direction.ASC; + Pageable pageable = PageRequest.of(pageNumber, size, Sort.by(direction, sort)); + Page posts = postRepository.findAll(pageable); + return PostListResponse.from(posts); + } + + public PostResponse getPostDetail(Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException(postId)); + post.increaseViewCount(); + postRepository.save(post); + return PostResponse.from(post); + } + + public PostResponse createPost(PostCreateRequest request) { + if (request.getTitle() == null || request.getTitle().trim().isEmpty()) { + throw new IllegalArgumentException("제목은 필수입니다."); + } + if (request.getContent() == null || request.getContent().trim().isEmpty()) { + throw new IllegalArgumentException("내용은 필수입니다."); + } + + Post post = new Post(); + post.setTitle(request.getTitle()); + post.setContent(request.getContent()); + + Post savedPost = postRepository.save(post); + return PostResponse.from(savedPost); + } + + public PostResponse updatePost(Long postId, PostUpdateRequest request) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException(postId)); + + if (request.getTitle() == null || request.getTitle().trim().isEmpty()) { + throw new IllegalArgumentException("제목은 필수입니다."); + } + if (request.getContent() == null || request.getContent().trim().isEmpty()) { + throw new IllegalArgumentException("내용은 필수입니다."); + } + + post.setTitle(request.getTitle()); + post.setContent(request.getContent()); + + Post updatedPost = postRepository.save(post); + return PostResponse.from(updatedPost); + } + + public void deletePost(Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException(postId)); + postRepository.delete(post); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e808fdd..6f8e689 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,6 +4,6 @@ spring.datasource.username=root spring.datasource.password= spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -spring.jpa.hibernate.ddl-auto=update +spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=true -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect \ No newline at end of file +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect diff --git a/test.http b/test.http index 4e99134..5f0729a 100644 --- a/test.http +++ b/test.http @@ -4,4 +4,22 @@ Content-Type: application/json { "text": "hello" -} \ No newline at end of file +} +### POST - 게시글 작성 +POST http://localhost:8080/posts +Content-Type: application/json + +{ + "title": "첫 번째 게시글", + "content": "테스트 내용입니다" +} +### PUT - 게시글 수정 +PUT http://localhost:8080/posts/1 +Content-Type: application/json + +{ + "title": "수정된 제목", + "content": "수정된 내용입니다" +} +### DELETE - 게시글 삭제 +DELETE http://localhost:8080/posts/1