What to do?
게시판 화면 만들기
- Dto 작성
- Controller 코드 작성
- Service 코드 작성
- View 작성
MySQL 데이터 베이스에 있는 게시글을 렌더링
제목을 클릭하면 해당 게시글로 이동
Entity 수정
UserAccount
userAccount 필드를 추가하였고, ManyToOne annotation을 붙여줌
@ToString.Exclude @ManyToOne
private UserAccount userAccount;
resources/data.sql
Entity를 수정해주었기 때문에 기존에 작성한 data.sql 파일 때문에 에러가 난다.
Mockaroo에서 가짜 데이터를 생성하는 insert 쿼리를 작성해서 넣어주었다.
insert into user_account (email, username, password, description, created_at) values ('smeco1@i2i.jp', 'zillion', '9hy8KudbaAw', '1D4PT4GK4BW098425', '2022-08-08 12:27:28');
insert into user_account (email, username, password, description, created_at) values ('sreape@slideshare.net', 'diger', 'i82cqV', 'JM3TB2BA7B0951184', '2022-11-07 07:58:53');
insert into user_account (email, username, password, description, created_at) values ('vhadeke3@time.com', 'veiga', 'zVGf3urMlB', '1B3CC4FB5AN294974', '2022-10-08 04:00:14');
insert into user_account (email, username, password, description, created_at) values ('nsm4421@naver.com', 'karma', 'zVGf3urMlB', '1B3CC4FB5AN294974', '2022-10-08 04:00:14');
insert into article (title, content, created_at, created_by, hashtags) values ('Flashdog', 'Estimator', '2022-09-27 14:15:49', 'diger', 'Crimson');
insert into article (title, content, created_at, created_by, hashtags) values ('JumpXS', 'Project Manager', '2022-06-02 18:26:30', 'veiga', 'Green');
insert into article (title, content, created_at, created_by, hashtags) values ('Ozu', 'Electrician', '2022-10-19 06:56:32', 'karma', 'Red');
insert into article (title, content, created_at, created_by, hashtags) values ('Babbleopia', 'Surveyor', '2022-09-06 21:29:51', 'zillion', 'Red');
insert into article (title, content, created_at, created_by, hashtags) values ('Brightbean', 'Construction Expeditor', '2022-09-01 06:34:27', 'zillion', 'Turquoise');
insert into article (title, content, created_at, created_by, hashtags) values ('Layo', 'Project Manager', '2022-09-19 16:45:07', 'zillion', 'Khaki');
insert into article (title, content, created_at, created_by, hashtags) values ('Zoovu', 'Subcontractor', '2022-02-25 16:15:42', 'karma', 'Khaki');
insert into article (title, content, created_at, created_by, hashtags) values ('Flashset', 'Supervisor', '2022-09-08 21:47:20', 'diger', 'Turquoise');
insert into article (title, content, created_at, created_by, hashtags) values ('Podcat', 'Construction Manager', '2022-06-26 16:00:17', 'zillion', 'Khaki');
Dto 생성
모든 Dto는 다음과 같은 방식으로 작성하였다
- implements Serializable
- Factory Pattern
- 생성자는 private, protected 키워드 사용 → 외부에서 생성 불가
- of 매써드로 생성
- from
- Entity → Dto로 변환하는 from 매써드 생성
- UserAccountDto
@Getter
public class UserAccountDto implements Serializable {
private String email;
private String username;
private String password;
private String description;
private RoleType roleType;
protected UserAccountDto(){}
private UserAccountDto(String email, String username, String password, String description, RoleType roleType) {
this.email = email;
this.username = username;
this.password = password;
this.description = description;
this.roleType = roleType;
}
public static UserAccountDto of(String email, String username, String password, String description, RoleType roleType){
return new UserAccountDto(email, username, password, description, roleType);
}
public static UserAccountDto from(UserAccount userAccount){
return new UserAccountDto(
userAccount.getEmail(), userAccount.getUsername(), userAccount.getPassword(),
userAccount.getDescription(), userAccount.getRoleType()
);
}
}
- ArticleWithCommentDto
- 게시글(Article)과 댓글(Comment)를 동시에 가진 Dto가 필요해서 만들어주었다.
- from 메써드는 ArticleDto, UserAccountDto, Set<CommentDto>를 인자로 받는다.
@Getter
public class ArticleWithCommentDto implements Serializable {
private Long articleId;
private String title;
private String content;
private String hashtags;
private UserAccountDto userAccountDto;
private LocalDateTime createdAt;
private String createdBy;
private Set<CommentDto> comments;
protected ArticleWithCommentDto(){}
private ArticleWithCommentDto(Long articleId, String title, String content, String hashtags, UserAccountDto userAccountDto,
LocalDateTime createdAt, String createdBy, Set<CommentDto> comments) {
this.articleId = articleId;
this.title = title;
this.content = content;
this.hashtags = hashtags;
this.userAccountDto = userAccountDto;
this.createdAt = createdAt;
this.createdBy = createdBy;
this.comments = comments;
}
public static ArticleWithCommentDto of(Long articleId, String title, String content, String hashtags, UserAccountDto userAccountDto,
LocalDateTime createdAt, String createdBy, Set<CommentDto> comments){
return new ArticleWithCommentDto(articleId, title, content, hashtags, userAccountDto, createdAt, createdBy, comments);
}
public static ArticleWithCommentDto from(ArticleDto articleDto, UserAccountDto userAccountDto, Set<CommentDto> comments){
return new ArticleWithCommentDto(
articleDto.getId(), articleDto.getTitle(), articleDto.getContent(), articleDto.getHashtags(),
userAccountDto, articleDto.getCreatedAt(), articleDto.getCreatedBy(), comments
);
}
}
- ArticleDto
@Getter
public class ArticleDto implements Serializable {
private Long id;
private String title;
private String content;
private String hashtags;
private LocalDateTime createdAt;
private String createdBy;
protected ArticleDto(){}
private ArticleDto(Long id, String title, String content, String hashtags, LocalDateTime createdAt, String createdBy) {
this.id = id;
this.title = title;
this.content = content;
this.hashtags = hashtags;
this.createdAt = createdAt;
this.createdBy = createdBy;
}
private ArticleDto(Long id, String title, String content, String hashtags) {
this.id = id;
this.title = title;
this.content = content;
this.hashtags = hashtags;
this.createdAt = null;
this.createdBy = null;
}
public static ArticleDto of(Long id, String title, String content, String hashtags, LocalDateTime createdAt, String createdBy){
return new ArticleDto(id, title, content, hashtags, createdAt, createdBy);
}
public static ArticleDto of(Long id, String title, String content, String hashtags){
return new ArticleDto(id, title, content, hashtags);
}
// Entity → Dto
public static ArticleDto from(Article article){
return ArticleDto.of(
article.getId(), article.getTitle(), article.getContent(), article.getHashtags(),
article.getCreatedAt(), article.getCreatedBy()
);
}
}
- CommentDto
@Getter
public class CommentDto {
private String content;
private LocalDateTime createdAt;
private String createdBy;
private CommentDto(String content, LocalDateTime createdAt, String createdBy) {
this.content = content;
this.createdAt = createdAt;
this.createdBy = createdBy;
}
private CommentDto(String content) {
this.content = content;
this.createdAt = null;
this.createdBy = null;
}
protected CommentDto(){}
public static CommentDto of(String content, LocalDateTime createdAt, String createdBy){
return new CommentDto(content, createdAt, createdBy);
}
public static CommentDto from(Comment comment){
return CommentDto.of(
comment.getContent(),
comment.getCreatedAt(),
comment.getCreatedBy()
);
}
}
Spring Security
- Principal
@Getter
public class MyPrincipal implements UserDetails {
private String email;
private String username;
private String password;
private String description;
private Collection<? extends GrantedAuthority> authorities;
private MyPrincipal(String email, String username, String password, String description, Collection<? extends GrantedAuthority> authorities) {
this.email = email;
this.username = username;
this.password = password;
this.description = description;
this.authorities = authorities;
}
public static MyPrincipal of(String email, String username, String password, String description) {
Set<RoleType> roleTypes = Set.of(RoleType.USER); // TODO
Collection<? extends GrantedAuthority> authorities = roleTypes
.stream()
.map(RoleType::getName)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toUnmodifiableSet());
return new MyPrincipal(email, username, password, description, authorities);
}
protected MyPrincipal(){}
// Principal → Entity
public UserAccount to(MyPrincipal myPrincipal){
return UserAccount.of(
myPrincipal.getEmail(),
myPrincipal.getUsername(),
myPrincipal.getPassword(),
myPrincipal.getDescription(),
RoleType.USER // TODO
);
}
// Entity → Principal
public static MyPrincipal from(UserAccount userAccount){
return MyPrincipal.of(
userAccount.getEmail(),
userAccount.getUsername(),
userAccount.getPassword(),
userAccount.getDescription()
);
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
- SecurityConfig
- 인증기능을 열어둘 url 지정
- PathRequest.toStaticResources().atCommonLocations() → static 파일 (html, css, js, favicon...) 경로
- /, "/articles", "/articles/**" → 해당 경로로 GET요청을 날리는 경우 인증이 필요하지 않음
- Spring Security에서 제공하는 기본 로그인 페이지 사용
- 인증기능을 열어둘 url 지정
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http
) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toStaticResources().atCommonLocations())
.permitAll()
.mvcMatchers(
HttpMethod.GET,
"/",
"/articles",
"/articles/**", // TODO : 제거 (테스트를 위해 열어놓음))
.permitAll()
.anyRequest()
.authenticated()
)
.formLogin(withDefaults())
.logout(logout -> logout.logoutSuccessUrl("/"))
.build();
}
@Bean
public UserDetailsService userDetailsService(UserAccountService userAccountService) {
return username -> MyPrincipal.from(userAccountService.findByUsername(username));
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Controller
MainController
- / 경로로 접속하면 /articles 경로로 redirection
@Controller
@RequestMapping("/")
public class MainController {
@GetMapping("/")
public String index(){
return "redirect:/articles";
}
}
ArticleController
- 게시판 페이지
- Parmas
- SearchType : 검색방법
- Keyword : 검색어
- Pageable
- TODO : 한 페이지에 게시글 개수 조절
- Parmas
- 게시글 페이지
- params
- 게시글 아이디
- TODO : 게시글 수정/삭제
- params
@Controller
@RequestMapping("/articles")
@RequiredArgsConstructor
public class ArticleController {
private final ArticleService articleService;
@GetMapping
public String articles(
@RequestParam(required = false) SearchType searchType,
@RequestParam(required = false) String keyword,
@PageableDefault(size=20, sort = "createdAt", direction = Sort.Direction.DESC)Pageable pageable,
ModelMap map){
Page<ArticleDto> articleDtoPage = articleService.searchArticleDtoPage(searchType, keyword, pageable);
map.addAttribute("articles", articleDtoPage.map(ArticlesResponse::from));
return "article/index";
}
@GetMapping("/{articleId}")
public String article(@PathVariable Long articleId, ModelMap map){
ArticleWithCommentDto dto = articleService.getArticleWithCommentDto(articleId);
map.addAttribute("article", ArticleResponse.from(dto));
map.addAttribute("comments", CommentsResponse.from(dto));
return "article/detail/index";
}
}
ArticleService
게시글 조회/생성/수정/삭제
@Service
@Transactional
@RequiredArgsConstructor
public class ArticleService {
private final UserAccountRepository userAccountRepository;
private final ArticleRepository articleRepository;
@Transactional(readOnly = true)
public Page<ArticleDto> searchArticleDtoPage(SearchType searchType, String keyword, Pageable pageable){
// 키워드가 안들어오면 전체
if (keyword == null || keyword.isBlank()){
return articleRepository.findAll(pageable).map(ArticleDto::from);
}
// 검색 유형
return switch (searchType){
case TITLE->articleRepository.findByTitleContaining(keyword, pageable).map(ArticleDto::from);
case USERNAME->articleRepository.findByUserAccount_UsernameContaining(keyword, pageable).map(ArticleDto::from);
case HASHTAG->articleRepository.findByHashtags(keyword, pageable).map(ArticleDto::from);
case CONTENT->articleRepository.findByContentContaining(keyword, pageable).map(ArticleDto::from);
default -> articleRepository.findAll(pageable).map(ArticleDto::from);
};
}
@Transactional(readOnly = true)
private Article findById(Long articleId){
return articleRepository
.findById(articleId)
.orElseThrow(()->{throw new MyException(
ErrorCode.ENTITY_NOT_FOUND,
String.format("Article with id [%s] not founded", articleId));});
}
@Transactional(readOnly = true)
public ArticleWithCommentDto getArticleWithCommentDto(Long articleId){
Article article = findById(articleId);
ArticleDto articleDto = ArticleDto.from(article);
Set<CommentDto> commentDtoSet = article.getComments().stream().map(CommentDto::from).collect(Collectors.toSet());
UserAccountDto userAccountDto;
// Case ⅰ) Article.userAccount == null
if (article.getUserAccount() == null){
String username = article.getCreatedBy();
userAccountDto = UserAccountDto.from(
userAccountRepository
.findByUsername(username)
.orElseThrow(()->{throw new MyException(
ErrorCode.USER_NOT_FOUND,
String.format("Username [%s] is not founded", username));}));
// Case ⅱ) Article.userAccount != null
} else {
userAccountDto = UserAccountDto.from(article.getUserAccount());
}
return ArticleWithCommentDto.from(articleDto, userAccountDto, commentDtoSet);
}
public ArticleDto saveArticle(Article article) {
Article savedArticle = articleRepository.save(article);
return ArticleDto.from(savedArticle);
}
public ArticleDto updateArticle(Long articleId, ArticleDto articleDto){
if (articleDto.getTitle() == null){throw new MyException(ErrorCode.INVALID_PARAMETER, "Title is null");}
if (articleDto.getContent() == null){throw new MyException(ErrorCode.INVALID_PARAMETER, "Content is null");}
Article article = findById(articleId);
article.setTitle(articleDto.getTitle());
article.setContent(articleDto.getContent());
article.setHashtags(articleDto.getHashtags());
return ArticleDto.from(articleRepository.save(article));
}
public void deleteArticle(Long articleId){
articleRepository.delete(findById(articleId));
}
}
View
- Thymeleaf Template의 Decoupled Logic을 사용
- th.xml파일에서 controller에 받은 modelmap을 HTML에 뿌림
- Thymeleaf 문법
- sel : 일반적인 selector와 동일하게 #은 id를, .(dot)은 class명
- th:text="${#temporals.format(article.createdAt, 포맷)}" 와 같이 date formating 가능
게시판페이지
articles/index.th.xml
<?xml version="1.0"?>
<thlogic xmlns:th="http://www.thymeleaf.org">
<attr sel="#header" th:replace="header :: #header"/>
<attr sel="#footer" th:replace="footer :: #footer"/>
<attr sel="#article-table">
<attr sel="tbody" th:remove="all-but-first">
<attr sel="tr[0]" th:each="article : ${articles}">
<attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/'+${article.id}}"/>
<attr sel="td.hashtags" th:text="${article.hashtags}"/>
<attr sel="td.author" th:text="${article.author}"/>
<attr sel="td.created-at" th:datetime="${article.createdAt}" th:text="${#temporals.format(article.createdAt, 'yy년 MM월 dd일 hh시 mm분')}"/>
</attr>
</attr>
</attr>
</thlogic>
게시글 페이지
articles/detail/index.th.xml
<?xml version="1.0"?>
<thlogic xmlns:th="http://www.thymeleaf.org">
<attr sel="#header" th:replace="header :: #header"/>
<attr sel="#footer" th:replace="footer :: #footer"/>
<!--Title-->
<attr sel="#title-area" th:object="${article}">
<!--TODO:Pagination-->
<attr sel="#btn-next">
<attr sel="a" th:href="@{'/articles/'+${article.id-1}}"/>
</attr>
<attr sel="#btn-previous">
<attr sel="a" th:href="@{'/articles/'+${article.id+1}}"/>
</attr>
<attr sel="#title" th:text="*{title}"/>
</attr>
<!--Profile-->
<attr sel="#profile-area" th:object="${article}">
<attr sel="#author" th:text="*{author}"/>
<attr sel="#created-at" th:datetime="*{createdAt}" th:text="${#temporals.format(article.createdAt, 'yy년 MM월 dd일 hh시 mm분')}"/>
<attr sel="#hashtags" th:text="*{hashtags}"/>
</attr>
<!--Body-->
<attr sel="#content" th:text="${article.content}"/>
<!--Write Comment-->
<attr sel="#write-comment-area">
<!--TODO:Submit Comment-->
</attr>
<!--See Comments-->
<attr sel="#article-comments">
<attr sel="li" th:remove="all-but-first">
<attr sel="div" th:each="comment : ${comments.comments}">
<attr sel="strong" th:text="*{createdBy}"/>
<attr sel="small/time" th:datetime="*{createdAt}" th:text="${#temporals.format(comment.createdAt, 'yy년 MM월 dd일 hh시 mm분')}"/>
<attr sel="p" th:text="*{content}"/>
</attr>
</attr>
</attr>
</thlogic>
'Java > Spring' 카테고리의 다른 글
[Spring] 게시판 만들기 #9 (0) | 2022.11.23 |
---|---|
[Spring] 게시판 만들기 #8 (0) | 2022.11.23 |
[Spring] 게시판 만들기 #6 (0) | 2022.11.21 |
[Spring] 게시판 만들기 #5 (0) | 2022.11.14 |
[Spring] 게시판 만들기 #4 (0) | 2022.11.13 |