-
Notifications
You must be signed in to change notification settings - Fork 20
[3주차] 최유찬/[feat] 게시글 도메인 API 구현 #101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 최유찬/main
Are you sure you want to change the base?
The head ref may contain hidden characters: "\uCD5C\uC720\uCC2C/3\uC8FC\uCC28"
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| package com.example.demo1.domain.global.exception; | ||
|
|
||
| import com.example.demo1.domain.global.response.BaseResponse; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.MethodArgumentNotValidException; | ||
| import org.springframework.web.bind.annotation.ExceptionHandler; | ||
| import org.springframework.web.bind.annotation.RestControllerAdvice; | ||
|
|
||
| import java.util.HashMap; | ||
| import java.util.Map; | ||
| import java.util.NoSuchElementException; | ||
|
|
||
| @RestControllerAdvice | ||
| public class GlobalExceptionHandler { | ||
|
|
||
| // 1. 유효성 검사 실패 처리 (제목/내용 빈 값 등 - 코드 4002) [cite: 449, 479] | ||
| @ExceptionHandler(MethodArgumentNotValidException.class) | ||
| public ResponseEntity<BaseResponse<Map<String, String>>> handleValidationException(MethodArgumentNotValidException e) { | ||
| Map<String, String> errors = new HashMap<>(); | ||
| e.getBindingResult().getFieldErrors().forEach(error -> | ||
| errors.put(error.getField(), error.getDefaultMessage())); | ||
|
|
||
| return ResponseEntity.status(HttpStatus.BAD_REQUEST) | ||
| .body(BaseResponse.onFailure("4002", "잘못된 요청입니다.", errors)); | ||
| } | ||
|
|
||
| // 2. 리소스를 찾을 수 없을 때 처리 (게시글 없음 - 코드 4040) [cite: 436, 480] | ||
| @ExceptionHandler(NoSuchElementException.class) | ||
| public ResponseEntity<BaseResponse<Map<String, Long>>> handleNotFoundException(NoSuchElementException e) { | ||
| return ResponseEntity.status(HttpStatus.NOT_FOUND) | ||
| .body(BaseResponse.onFailure("4040", e.getMessage(), null)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package com.example.demo1.domain.global.response; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonInclude; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| @AllArgsConstructor | ||
| public class BaseResponse<T> { | ||
| private final Boolean isSuccess; | ||
| private final String code; | ||
| private final String message; | ||
|
|
||
| @JsonInclude(JsonInclude.Include.NON_NULL) // 결과값이 null이면 JSON에 포함하지 않음 | ||
| private T result; | ||
|
|
||
| // 성공 응답 정적 팩토리 메서드 | ||
| public static <T> BaseResponse<T> onSuccess(String code, String message, T result) { | ||
| return new BaseResponse<>(true, code, message, result); | ||
| } | ||
|
|
||
| // 실패 응답 정적 팩토리 메서드 | ||
| public static <T> BaseResponse<T> onFailure(String code, String message, T result) { | ||
| return new BaseResponse<>(false, code, message, result); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package com.example.demo1.domain.post.controller; | ||
|
|
||
| import com.example.demo1.domain.global.response.BaseResponse; | ||
| import com.example.demo1.domain.post.dto.PostRequestDto; | ||
| import com.example.demo1.domain.post.dto.PostResponseDto; | ||
| import com.example.demo1.domain.post.service.PostService; | ||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.web.bind.annotation.*; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/posts") | ||
| @RequiredArgsConstructor | ||
| public class PostController { | ||
|
|
||
| private final PostService postService; | ||
|
|
||
| // [API 1] 목록 조회 | ||
| @GetMapping | ||
| public BaseResponse<List<PostResponseDto>> getPosts() { | ||
| return BaseResponse.onSuccess("2000", "게시글 목록 조회 성공", postService.findAll()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 추후에는 코드 확장성과 유지보수성 측면에서 좀 더 유리할 수 있도록 ErrorCode나 SuccessCode를 만들어 에러코드와 메시지를 한 번에 관리한다면 더 좋을 것 같습니다! 💊 아니면 혹시 저렇게("2000","게시글 목록 조회 성공") 으로 하드 코딩하신 이유가 따로 있으신 걸까요❓
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 지적 감사합니다 현재 프로젝트 초기 단계라 빠르게 API 구조를 잡느라 미처 공통 코드로 분리하지 못했습니다. |
||
| } | ||
|
|
||
| // [API 2] 상세 조회 (findById 대신 getPostDetail로 수정) | ||
| @GetMapping("/{postId}") | ||
| public BaseResponse<PostResponseDto> getPost(@PathVariable Long postId) { | ||
| return BaseResponse.onSuccess("2001", "게시글 조회 성공", postService.getPostDetail(postId)); | ||
| } | ||
|
|
||
| // [API 3] 작성 | ||
| @PostMapping | ||
| public BaseResponse<PostResponseDto> createPost(@Valid @RequestBody PostRequestDto requestDto) { | ||
| return BaseResponse.onSuccess("2010", "게시글 생성 성공", postService.save(requestDto)); | ||
| } | ||
|
Comment on lines
+32
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 팀 과제 내용 반영해서 응답 메시지 잘 구성하신 것 같습니다. 고생 많으셨습니다! |
||
|
|
||
| // [API 4] 수정 | ||
| @PutMapping("/{postId}") | ||
| public BaseResponse<PostResponseDto> updatePost(@PathVariable Long postId, @Valid @RequestBody PostRequestDto requestDto) { | ||
| return BaseResponse.onSuccess("2002", "게시글 수정 성공", postService.update(postId, requestDto)); | ||
| } | ||
|
|
||
| // [API 5] 삭제 | ||
| @DeleteMapping("/{postId}") | ||
| public BaseResponse<String> deletePost(@PathVariable Long postId) { | ||
| postService.delete(postId); | ||
| return BaseResponse.onSuccess("2003", "게시글 삭제 성공", "삭제된 게시글 ID: " + postId); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package com.example.demo1.domain.post.dto; | ||
|
|
||
| import lombok.AllArgsConstructor; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @Builder | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| public class PostBlockDto { | ||
| private String blockType; | ||
| private String textContent; | ||
| private String imageUrl; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.example.demo1.domain.post.dto; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import lombok.Getter; | ||
| import lombok.Setter; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Getter | ||
| @Setter | ||
| public class PostRequestDto { | ||
|
|
||
| private Long userId; // 작성 시 필요 | ||
|
|
||
| @NotBlank(message = "제목은 비어 있을 수 없습니다.") | ||
| private String title; | ||
|
|
||
| @NotBlank(message = "내용은 비어 있을 수 없습니다.") | ||
| private String content; | ||
|
|
||
| @NotBlank(message = "설명은 비어 있을 수 없습니다.") | ||
| private String description; | ||
|
|
||
| private List<PostBlockDto> blocks; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package com.example.demo1.domain.post.dto; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonFormat; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
|
|
||
| @Getter | ||
| @Builder | ||
| public class PostResponseDto { | ||
| private Long postId; | ||
| private String title; | ||
| private String content; | ||
| private String description; | ||
| private String authorNickname; | ||
|
|
||
| @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") // 명세서 포맷 준수 | ||
| private LocalDateTime createdAt; | ||
|
|
||
| @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") | ||
| private LocalDateTime updatedAt; | ||
| private List<PostBlockDto> blocks; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.example.demo1.domain.post.repository; | ||
|
|
||
| import com.example.demo1.domain.post.entity.Post; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| @Repository // [★필수] 스프링이 이 인터페이스를 데이터 저장소로 인식하게 합니다. | ||
| public interface PostRepository extends JpaRepository<Post, Long> { | ||
| // 아무 내용도 적지 않아도 기본 CRUD 기능이 작동합니다. | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| package com.example.demo1.domain.post.service; | ||
|
|
||
| import com.example.demo1.domain.post.dto.PostBlockDto; | ||
| import com.example.demo1.domain.post.dto.PostRequestDto; | ||
| import com.example.demo1.domain.post.dto.PostResponseDto; | ||
| import com.example.demo1.domain.post.entity.Post; | ||
| import com.example.demo1.domain.post.repository.PostRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
| import java.util.NoSuchElementException; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| @Transactional(readOnly = true) | ||
| public class PostService { | ||
|
|
||
| private final PostRepository postRepository; | ||
|
|
||
| // 1. 목록 조회 로직 (엔티티 리스트를 DTO 리스트로 변환해서 반환하는 것이 정석입니다) | ||
| public List<PostResponseDto> findAll() { | ||
| return postRepository.findAll().stream() | ||
| .map(post -> PostResponseDto.builder() | ||
| .postId(post.getId()) | ||
| .title(post.getTitle()) | ||
| .content(post.getContent()) | ||
| .description(post.getDescription()) | ||
| .authorNickname("jae") | ||
| .createdAt(post.getCreatedAt()) | ||
| .build()) | ||
| .collect(Collectors.toList()); | ||
| } | ||
|
|
||
| // 2. 상세 조회 로직 | ||
| public PostResponseDto getPostDetail(Long postId) { | ||
| Post post = postRepository.findById(postId) | ||
| .orElseThrow(() -> new NoSuchElementException("해당 게시글을 찾을 수 없습니다.")); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IllegalException 또는 GeneralException 대신 NoSuchElementException을 사용하신 의도가 따로 있으신건가요❓ 따로 사용해 본 적이 없어서 궁금해서 질문 드립니다!
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IllegalArgumentException은 인자 값 자체가 잘못되었을 때(예: ID가 음수일 때) 주로 사용된다고 생각했습니다. 반면, 인자는 정상적이지만 DB에 해당 데이터가 존재하지 않는 상황을 좀 더 명확하게 표현하고 싶어서 '요소가 없음'을 뜻하는 NoSuchElementException을 선택해 보았습니다 |
||
|
|
||
| List<PostBlockDto> mockBlocks = List.of( | ||
| PostBlockDto.builder() | ||
| .blockType("TEXT") | ||
| .textContent("본문 내용입니다.") | ||
| .imageUrl(null) | ||
| .build() | ||
| ); | ||
|
|
||
| return PostResponseDto.builder() | ||
| .postId(post.getId()) | ||
| .title(post.getTitle()) | ||
| .content(post.getContent()) | ||
| .description(post.getDescription()) | ||
| .authorNickname("jae") | ||
| .createdAt(post.getCreatedAt()) | ||
| .build(); | ||
| } | ||
|
|
||
| // 3. 작성 로직 (하나로 통합함!) | ||
| @Transactional | ||
| public PostResponseDto save(PostRequestDto dto) { | ||
| Post post = Post.builder() | ||
| .title(dto.getTitle()) | ||
| .content(dto.getContent()) | ||
| .description(dto.getDescription()) | ||
| .build(); | ||
|
|
||
| Post savedPost = postRepository.save(post); | ||
|
|
||
| return PostResponseDto.builder() | ||
| .postId(savedPost.getId()) | ||
| .title(savedPost.getTitle()) | ||
| .content(savedPost.getContent()) | ||
| .description(savedPost.getDescription()) | ||
| .authorNickname("yuchan") | ||
| .createdAt(savedPost.getCreatedAt() != null ? savedPost.getCreatedAt() : LocalDateTime.now()) | ||
| .blocks(dto.getBlocks()) | ||
| .build(); | ||
| } | ||
|
|
||
| // 4. 수정 로직 | ||
| @Transactional | ||
| public PostResponseDto update(Long postId, PostRequestDto dto) { | ||
| Post post = postRepository.findById(postId) | ||
| .orElseThrow(() -> new NoSuchElementException("해당 게시글을 찾을 수 없습니다.")); | ||
|
|
||
| post.update(dto.getTitle(), dto.getContent(), dto.getDescription()); | ||
|
|
||
| return PostResponseDto.builder() | ||
| .postId(post.getId()) | ||
| .title(post.getTitle()) | ||
| .content(post.getContent()) | ||
| .description(post.getDescription()) | ||
| .authorNickname("jae") | ||
| .createdAt(post.getCreatedAt()) | ||
| .build(); | ||
| } | ||
|
|
||
| // 5. 삭제 로직 | ||
| @Transactional | ||
| public void delete(Long postId) { | ||
| if (!postRepository.existsById(postId)) { | ||
| throw new NoSuchElementException("해당 게시글을 찾을 수 없습니다."); | ||
| } | ||
| postRepository.deleteById(postId); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
공통 응답을 제네릭을 활용해 데이터 타입을 유연하게 관리할 수 있도록 설계하고 onSuccess와 onFailure와 같은 정적 팩토리 메서드를 작성해 가독성도 높인 점 잘하셨고 고생하셨습니다! 👍