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: 3 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
runtimeOnly 'com.h2database:h2'
implementation 'org.springframework.boot:spring-boot-starter-validation'
}

tasks.named('test') {
useJUnitPlatform()
}

springBoot {
mainClass = 'com.example.demo.DemoApplication'
mainClass = 'com.example.demo1.Demo1Application'
}

8 changes: 6 additions & 2 deletions src/main/java/com/example/demo1/Demo1Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@EnableJpaAuditing // 생성일/수정일 자동 생성을 위해 꼭 필요해요!
@SpringBootApplication // (exclude = ...) 부분을 지워서 DB 자동 설정을 활성화합니다.
@EnableJpaAuditing // 생성일/수정일 자동 기록을 위해 꼭 필요합니다!
@SpringBootApplication
@EnableJpaRepositories(basePackages = "com.example.demo1")
@EntityScan(basePackages = "com.example.demo1")
public class Demo1Application {
public static void main(String[] args) {
SpringApplication.run(Demo1Application.class, args);
Expand Down
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> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

공통 응답을 제네릭을 활용해 데이터 타입을 유연하게 관리할 수 있도록 설계하고 onSuccess와 onFailure와 같은 정적 팩토리 메서드를 작성해 가독성도 높인 점 잘하셨고 고생하셨습니다! 👍

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());
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

추후에는 코드 확장성과 유지보수성 측면에서 좀 더 유리할 수 있도록 ErrorCode나 SuccessCode를 만들어 에러코드와 메시지를 한 번에 관리한다면 더 좋을 것 같습니다! 💊 아니면 혹시 저렇게("2000","게시글 목록 조회 성공") 으로 하드 코딩하신 이유가 따로 있으신 걸까요❓

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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);
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/example/demo1/domain/post/dto/PostBlockDto.java
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;
}
12 changes: 10 additions & 2 deletions src/main/java/com/example/demo1/domain/post/entity/Post.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ public class Post extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private Long id; // [★체크] Service와 타입을 맞추기 위해 Integer 대신 Long 사용을 권장합니다.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@JoinColumn(name = "user_id", nullable = true) // [★체크] 테스트 편의를 위해 일단 nullable = true로 수정했습니다.
private User user;

@Column(length = 255)
Expand All @@ -37,4 +37,12 @@ public class Post extends BaseEntity {
@Builder.Default
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<Comment> comments = new ArrayList<>();

// [★추가] 게시글 수정을 위한 핵심 메서드입니다.
// 이 메서드가 있어야 PostService의 50라인 에러가 사라집니다.
public void update(String title, String content, String description) {
this.title = title;
this.content = content;
this.description = description;
}
}
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 기능이 작동합니다.
}
109 changes: 109 additions & 0 deletions src/main/java/com/example/demo1/domain/post/service/PostService.java
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("해당 게시글을 찾을 수 없습니다."));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

IllegalException 또는 GeneralException 대신 NoSuchElementException을 사용하신 의도가 따로 있으신건가요❓ 따로 사용해 본 적이 없어서 궁금해서 질문 드립니다!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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);
}
}