본문 바로가기

Java/Spring

[Spring] 게시판 만들기 #7

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는 다음과 같은 방식으로 작성하였다

  1. implements Serializable
  2. Factory Pattern
    • 생성자는 private, protected 키워드 사용 → 외부에서 생성 불가
    • of 매써드로 생성
  3. 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에서 제공하는 기본 로그인 페이지 사용
@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 : 한 페이지에 게시글 개수 조절

 

  • 게시글 페이지
    • params
      • 게시글 아이디
    • TODO : 게시글 수정/삭제
@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>

좌:html / 우:th.xml

 

게시글 페이지

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