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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PostListResponse> 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<PostResponse> getPostDetail(@PathVariable Long postId) {
PostResponse response = postService.getPostDetail(postId);
return ResponseEntity.ok(response);
}

/**
* 게시글 작성
* POST /posts
*/
@PostMapping
public ResponseEntity<PostResponse> createPost(@RequestBody PostCreateRequest request) {
PostResponse response = postService.createPost(request);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RequestBody 앞에 @Valid가 빠져 있어서 PostCreateRequest@NotBlank 검증이 실제로 동작하지 않는 상태예요. @Valid를 추가해주시면 GlobalExceptionHandlerMethodArgumentNotValidException 처리와 바로 연결됩니다!

public ResponseEntity<PostResponse> createPost(@RequestBody @Valid PostCreateRequest request)

updatePost@RequestBody에도 동일하게 적용해주시면 좋을 것 같아요.

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

/**
* 게시글 수정
* PUT /posts/{postId}
*/
@PutMapping("/{postId}")
public ResponseEntity<PostResponse> updatePost(
@PathVariable Long postId,
@RequestBody PostUpdateRequest request) {
PostResponse response = postService.updatePost(postId, request);
return ResponseEntity.ok(response);
}

/**
* 게시글 삭제
* DELETE /posts/{postId}
*/
@DeleteMapping("/{postId}")
public ResponseEntity<Void> deletePost(@PathVariable Long postId) {
postService.deletePost(postId);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,16 @@
@Entity
@Table(name = "posts")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Setter
@NoArgsConstructor
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엔티티에 @Setter가 추가됐는데요, 이렇게 하면 서비스나 컨트롤러 어느 곳에서든 post.setTitle(...) 형태로 필드를 직접 변경할 수 있어서, 데이터가 어디서 바뀌는지 추적하기 어려워집니다. JPA 엔티티는 변경이 필요한 경우 의미 있는 이름의 메서드(예: post.update(title, content))를 정의하고, @Setter는 제거하는 패턴을 권장해요!

@NoArgsConstructor(access = AccessLevel.PROTECTED)도 무분별한 new Post() 생성을 막아주는 좋은 패턴이라, 원래대로 되돌려주시면 좋을 것 같아요.

public class Post {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

user_idnullable = true로 변경하셨는데, 혹시 의도하신 건가요? 게시글에 작성자 정보가 없어도 되는 구조라면 맞지만, 대부분의 경우 게시글은 반드시 작성자가 있어야 하므로 nullable = false가 더 적절할 것 같아요.

@JoinColumn(name = "user_id", nullable = true) // ← nullable = true로 변경
private User user;

@Column(nullable = false, length = 100)
Expand Down Expand Up @@ -52,4 +53,11 @@ public class Post {

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

/**
* 조회수 증가
*/
public void increaseViewCount() {
this.viewCount++;
}
}
Original file line number Diff line number Diff line change
@@ -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<Post, Long> {
}
Original file line number Diff line number Diff line change
@@ -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; // 상세 정보
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<Long> postId;
private List<String> title;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

응답 DTO가 컬럼별로 List<Long> postId, List<String> title처럼 구성되어 있는데요, 이 구조는 게시글 하나를 표현하는 단위가 없어서 클라이언트가 인덱스로 각 필드를 조합해야 합니다. 일반적으로는 게시글 하나를 나타내는 inner DTO를 만들고 List<PostSummary> 형태로 감싸는 구조를 사용해요!

@Getter
@AllArgsConstructor
public class PostListResponse {
    private List<PostSummary> posts;

    @Getter
    @Builder
    public static class PostSummary {
        private Long postId;
        private String title;
        private String nickname;
        private LocalDateTime createdAt;
    }
}

private List<String> nickname;
private List<LocalDateTime> createdAt;

/**
* Page<Post>를 PostListResponse로 변환
*/
public static PostListResponse from(Page<Post> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.springbootassignment.exception;

public class CommentNotFoundException extends RuntimeException {
public CommentNotFoundException(Long commentId) {
super("존재하지 않는 댓글입니다. id: " + commentId);
}
}
Original file line number Diff line number Diff line change
@@ -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 + ")");
}
}
Original file line number Diff line number Diff line change
@@ -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<ErrorResponse> 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<ErrorResponse> handleUnauthorizedException(UnauthorizedException e) {
ErrorResponse errorResponse = new ErrorResponse(
"COMMON401",
"인증이 필요합니다.",
e.getMessage()
);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
}

/**
* 권한 오류 (403 Forbidden)
* - 자신이 쓴 글이 아닌데 수정/삭제 시도
*/
@ExceptionHandler(ForbiddenException.class)
public ResponseEntity<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> handleGeneralException(Exception e) {
ErrorResponse errorResponse = new ErrorResponse(
"COMMON500",
"서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
e.getMessage()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.springbootassignment.exception;

public class PostNotFoundException extends RuntimeException {

public PostNotFoundException(Long postId) {
super("존재하지 않는 게시글입니다. id: " + postId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.springbootassignment.exception;

public class UnauthorizedException extends RuntimeException {
public UnauthorizedException(String message) {
super(message);
}
}
Loading