Spring에서 엔드 포인트(end point)는 클라이언트의 요청을 처리하는 URL 경로와 그에 해당하는 메소드를 의미한다.
간단한 게시판의
ERD
를 작성해보면 게시판(board), 게시물(post), 댓글(reply)의 테이블이 필요함
각 테이블의 ERD에서 관계를 설정하면
board : post = 1:N 관계
➡️ 하나의 게시판에는 여러개의 게시물이 포함될 수 있지만 하나의 게시물은 하나의 게시판에 속해야 함
post : reply = 1:N 관계
➡️ 하나의 게시물에는 여러 개의 댓글이 달릴 수 있지만 하나의 댓글은 하나의 게시물에 속해야 함
클래스간의 관계
PostApiController
: 사용자의 요청을 받아 PostService에 전달PostService
: 요청을 처리하고 필요한 데이터를 PostRepository에서 가져오며 ReplyService를 호출하여 댓글을 가져올 수도 있음PostRepository
: 데이터베이스에서 게시글 데이터를 조회/저장/삭제ReplyService
: 특정 게시글에 달린 댓글 리스트를 조회
HTTP 요청을 받아서 적절한 service 메소드를 호출함
@RestController
@RequestMapping("/api/post")
@RequiredArgsConstructor // final 필드(postService)에 대한 생성자를 자동으로 생성
public class PostApiController {
private final PostService postService;
@PostMapping("")
public PostEntity create(
@Valid
@RequestBody PostRequest postRequest
) {
return postService.create(postRequest);
}
@PostMapping("/view")
public PostEntity view(
@Valid
@RequestBody PostViewRequest postViewRequest
) {
return postService.view(postViewRequest);
}
@GetMapping("/all")
public List<PostEntity> list(
) {
return postService.all();
}
@PostMapping("/delete")
public void delete(
@Valid
@RequestBody PostViewRequest postViewRequest
) {
postService.delete(postViewRequest);
}
}
요청을 받을 때 사용하는 객체
@NotBlank
: 필수 입력 값@Size(min = 4, max = 4)
: 4자리만 가능하도록 설정
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class PostRequest {
@NotBlank
private String userName;
@NotBlank
@Size(min = 4, max = 4)
private String password;
@NotBlank
@Email
private String email;
@NotBlank
private String title;
@NotBlank
private String content;
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class PostViewRequest {
@NotNull
private Long id;
@NotBlank
private String password;
}
post 테이블과 매핑되는 클래스
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
@Entity(name = "post") // post 테이블과 매핑
public class PostEntity {
@Id // 기본키
@GeneratedValue(strategy = GenerationType.IDENTITY) // 자동 증가
private Long id;
private Long boardId;
private String userName;
private String password;
private String email;
private String status;
private String title;
@Column(columnDefinition = "TEXT") // TEXT 타입
private String content;
private LocalDateTime postedAt; // 작성 시간
// DB에 저장되지 않는 필드로 게시물을 조회할 때만 사용함
@Transient
private List<ReplyEntity> replyList = List.of();
}
Spring Data JPA를 사용하여 데이터베이스와 직접 소통하는 계층
Spring Boot JPA에서는 메소드 이름을 기반으로 자동으로 SQL 쿼리를 생성함Optional<PostEntity> findFirstByIdAndStatusOrderByIdDesc(Long id, String status); // 위의 메소드는 내부적으로 아래와 같은 SQL을 실행함 SELECT * FROM post WHERE id = ? AND status = ? ORDER BY id DESC LIMIT 1; // @Query를 사용해서 같은 기능을 구현할 수 있음 @Query("SELECT p FROM post p WHERE p.id = :id AND p.status = :status ORDER BY p.id DESC") Optional<PostEntity> findLatestPost(@Param("id") Long id, @Param("status") String status);
public interface PostRepository extends JpaRepository<PostEntity, Long> { // 기본적인 CRUD를 제공함
//select * from post where id = ? and status = ? order by id desc limit 1
// id, status가 주어진 값과 일치하는 게시물을 최신순으로 조회
Optional<PostEntity> findFirstByIdAndStatusOrderByIdDesc(Long id, String status);
}
실제 비즈니스 로직을 수행하며 데이터베이스에서 데이터를 가져오거나 수정하는 로직을 포함
@Service // 서비스 역할을 하는 클래스
@RequiredArgsConstructor // postRepository, replyService의 생성자를 자동으로 생성
public class PostService {
private final PostRepository postRepository;
private final ReplyService replyService;
public PostEntity create(
PostRequest postRequest
) {
var entity = PostEntity.builder()
.boardId(1L) // 임시 데이터
.userName(postRequest.getUserName())
.password(postRequest.getPassword())
.email(postRequest.getEmail())
.status("REGISTERED")
.title(postRequest.getTitle())
.content(postRequest.getContent())
.postedAt(LocalDateTime.now())
.build();
return postRepository.save(entity);
}
public PostEntity view(@Valid PostViewRequest postViewRequest) {
return postRepository.findFirstByIdAndStatusOrderByIdDesc(postViewRequest.getId(), "REGISTERED")
.map(it -> {
if (!it.getPassword().equals(postViewRequest.getPassword())) { // Corrected check
throw new RuntimeException(
String.format("패스워드가 맞지 않습니다. 입력값: %s, 실제값: %s",
postViewRequest.getPassword(), it.getPassword())
);
}
// 답변도 같이
var replyList = replyService.findAllByPostId(it.getId());
it.setReplyList(replyList);
return it;
})
.orElseThrow(() ->
new RuntimeException("해당 게시글이 존재하지 않습니다. ID: " + postViewRequest.getId())
);
}
public List<PostEntity> all() {
return postRepository.findAll();
}
public void delete(@Valid PostViewRequest postViewRequest) {
postRepository.findById(postViewRequest.getId())
.map(it -> { // map은 값이 존재하는 경우에만 동작함
if (it.getPassword().equals(postViewRequest.getPassword())) { // 비밀번호가 같지 않으면 예외를 발생 시킴
throw new RuntimeException(
String.format("패스워드가 맞지 않습니다. 입력값: %s, 실제값: %s",
postViewRequest.getPassword(), it.getPassword())
);
}
it.setStatus("UNREGISTERED");
postRepository.save(it);
return it;
}).orElseThrow( // 값이 없을 경우 예외를 발생 시킴
() -> new RuntimeException("해당 게시글이 존재하지 않습니다. : " + postViewRequest.getId())
);
}
}