
강의를 통해 개발 공부를 하면서 개인 프로젝트 하나 해봐야지, 해봐야지 하면서 계속 "이 강의만 보고 해야지!!" 하며 미뤄왔는데요, 더 이상 미룰 수 없다! 진짜 간단한 토이 프로젝트라도 만들어 보기로 했습니다.
그래서! 다들 한번씩 만들어본다는 게시판을 만들어보기로 했습니다.
게시판을 만들면 게시글, 게시글을 작성하는 회원, 그리고 댓글 등을 생각할 수 있는데요, 맨 처음에는 아주아주 정말정말 간단한 것만 구현하고 그 다음에 하나씩 기능을 추가해보기로 했습니다.
그래서 엔티티는 오직 "게시글(Post)"만 있습니다!
기능은 매우 간단하게! 아래 세가지만 구현하겠습니다.
1. 게시글 목록 보기
2. 게시글 작성
3. 특정 게시글 보기
각 기능별로 url은 아래 같이 했습니다.
1. 게시글 목록 보기 -> /posts
2. 게시글 작성 -> /posts/write
3. 특정 게시글 보기 -> /posts/{postId}
유일한 엔티티인 Post는 제목, 내용, 생성날짜만 필드로 가지도록 하겠습니다.
Spring, JPA, H2 database를 사용했습니다. 이유는... 제가 들은 강의에서 이걸 사용했기 때문..!! ㅎㅎ
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Post {
@Id
@GeneratedValue
@Column(name = "post_id")
private Long id;
@NotBlank
private String title;
@Lob
@Basic(fetch = FetchType.LAZY)
@NotBlank
private String contents;
@NotNull
private LocalDateTime createdDateTime;
public static Post of(String title, String contents) {
Post post = new Post();
post.title = title;
post.contents = contents;
post.createdDateTime = LocalDateTime.now();
return post;
}
}
작성할 때 고려했던 점
일반적으로 contents가 매우 많은 내용일 때 EAGER 타입으로 엔티티를 생성한다면 문제가 생길 수 있다고 생각했습니다. 예를 들어 게시글 목록을 불러올 때 게시글의 제목, 생성날짜 정도는 필요하지만 내용은 불필요합니다. 이 때문에 LAZY로 FetchType을 정했습니다.
(@Basic이라는 애노테이션은 처음 봤는데 인텔리제이 ai 어시스턴트가 알려줘서 보고 판단했던 거 같습니다. 갓텔리제이)
스태틱 메서드 of()를 사용해서 Post 객체를 생성할 수 있도록 했는데요, 이건 PostWriteDto에서 Post로 변환할 때 사용하도록 만들었습니다.
전에 봤던 강의나 책에서 스태틱 메서드나 빌더 패턴을 활용한 장점을 봤는데 자세한 이유가 기억은 안나네요.. 스태틱 메서드를 통한 생성은 생성 메서드에 이름을 부여할 수 있다는 장점 정도가 생각납니다.
제가 유일하게 아는 Controller - Service - Repository의 계층구조로 만들었습니다. 여기서 Service는 현재 프로젝트의 구현 복잡도가 매우 낮아서 Service가 하는 역할이 Repository의 기능을 단순 위임하는 정도라 만들지 않았습니다. 이로 인해 유의할 점은 보통 트랜잭션을 서비스의 메서드를 기준으로 하는데 Service가 없기 때문에 Repository의 메서드에 트랜잭션을 걸어줘야 했습니다.
@Repository
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostRepository {
private final EntityManager em;
@Transactional
public void save(Post post) {
em.persist(post);
}
public Post findById(Long id) {
return em.find(Post.class, id);
}
// todo 임시
public List<Post> findByTitle(String title) {
return em.createQuery("select p from Post p where p.title like :title", Post.class)
.setParameter("title", title)
.getResultList();
}
public List<Post> findAll() {
return em.createQuery("select p from Post p", Post.class)
.getResultList();
}
}
위에서 말씀드렸듯이 @Transaction을 Repository에 걸었습니다.
findByTitle(String title)은 나중에 쓰일 것 같아서 일단은 임시로 작성했습니다 ㅎㅎ
@Controller
@RequiredArgsConstructor
public class PostController {
private final PostRepository postRepository;
@GetMapping("/posts")
public String showAllPosts(Model model) {
List<Post> posts = postRepository.findAll();
model.addAttribute("posts", posts);
return "posts/postList";
}
@GetMapping("/posts/{postId}")
public String post(@PathVariable Long postId, Model model) {
Post post = postRepository.findById(postId);
model.addAttribute("post", post);
return "posts/post";
}
@GetMapping("/posts/write")
public String writeForm(Model model) {
model.addAttribute("form", PostWriteDto.emptyOf());
return "posts/writeForm";
}
@PostMapping("/posts/write")
public String write(@ModelAttribute PostWriteDto postWriteDto) {
postRepository.save(postWriteDto.convertToPostEntity());
return "redirect:/posts";
}
}
웹과 주고받는 Post에 대한 내용을 PostWriteDto에 담아서 Repository에 넘겨줄 때는 Post로 변환해서 넘겨주었습니다.
@Getter
@AllArgsConstructor
public class PostWriteDto {
private String title;
private String contents;
public static PostWriteDto emptyOf() {
return new PostWriteDto(null, null);
}
public Post convertToPostEntity() {
return Post.of(title, contents);
}
}
저는 템플릿 엔진으로 타임리프(Thymeleaf)를 사용했는데요, 요놈이 model에 담긴 변수를 사용하고, 컨트롤러에서 @ModelAttribute로 변수를 받으려면 Getter, Setter를 사용해야 하더라고요. 데이터 전송만을 위한 객체라서 Setter가 있어도 괜찮다고 하지만 뭔가.. 거슬려서 대신에 모든 필드에 대한 생성자를 만들었습니다. 잘 되더라구요 ㅎㅎ
여기서도 컨트롤러에서 빈 PostWriteDto객체가 필요해서 emptyOf() 스태틱 생성 메서드를 만들어 사용했습니다.
또 Controller에서 Respository에 Post를 만들어 넘기기 위한 convertToPostEntity()메서드를 작성했습니다.
타임리프는 잘 기억 안 나서 전에 들었던 강의 자료도 보고 특히 HTML은 제가 잘 몰라서 GPT한테 도움을 좀 받아봤습니다.
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>글 목록</title>
</head>
<body>
<div>
<div>
<a href="posts/write">
<button>글 작성하기</button>
</a>
</div>
<div>
<table>
<thead>
<tr>
<th>#</th>
<th>제목</th>
<th>작성일</th>
</tr>
</thead>
<tbody>
<tr th:each="post : ${posts}">
<td th:text="${post.id}"></td>
<td>
<a th:href="@{'/posts/' + ${post.id}}" th:text="${post.title}">제목</a>
</td>
<td th:text="${#temporals.format(post.createdDateTime, 'yyyy/MM/dd HH:mm')}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
@GetMapping("/posts/{postId}")
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${post.title}">게시글</title>
</head>
<body>
<h1 th:text="${post.title}">제목</h1>
<p>
<strong>작성일: </strong>
<span th:text="${#temporals.format(post.createdDateTime, 'yyyy/MM/dd HH:mm')}">2024-04-01</span>
</p>
<hr/>
<div>
<pre th:text="${post.contents}">내용</pre>
</div>
</body>
</html>
@GetMapping("/posts/write"), @PostMapping("/posts/write")
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>글 작성</title>
</head>
<body>
<h1>글 작성</h1>
<form th:action="@{/posts/write}" th:object="${form}" method="post">
<div>
<label th:for="title">제목</label><br>
<input type="text" th:field="*{title}"/>
</div>
<div>
<label th:for="contents">내용</label><br>
<textarea type="text" th:field="*{contents}"></textarea>
</div>
<button type="submit" class="btn btn-primary">작성</button>
</form>
</body>
</html>