본문 바로가기
공부 및 정리/점프 투 스프링부트

점프 투 스프링부트 추가 기능 구현 - 프로필 구현

by 스파이펭귄 2024. 1. 19.
728x90

https://wikidocs.net/162814의 추가 기능 구현 - 프로필 구현

 

3-14 SBB 추가 기능 구현하기

이 책에서 구현할 SBB의 기능은 아쉽지만 여기까지이다. 함께 더 많은 기능을 추가하고 싶지만 이 책은 SBB의 완성이 아니라 SBB를 성장시키는 경험을 전달하는 데 목표를 두고…

wikidocs.net

이 글은 점프 투 스프링 부트의 3-13까지 구현과 이 글 이전의 구현을 마친 것을 전제로 한다.


이제 로그인 후 계정 정보를 보여주는 프로필 화면을 구현해보자.

이 화면에서는 사용자의 기본 정보와 작성한 질문, 답변, 추천한 글들을 확인할 수 있게 구현하면 좋을 것 같다.

 

navbar에 프로필 링크 추가하기

먼저 navbar.html에 내 정보 링크를 추가해 주자.

                (...생략...)        
                <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item">
                    <a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
                    <a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/user/signup}">회원가입</a>
                    <a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/profile}">내 정보</a>
                </li>
            </ul>
        </div>
    </div>
</nav>

이때 기존 코드에서는 로그인해도 회원가입이 가능했는데 로그인한 사람은 굳이 새로 회원가입을 할 필요가 없으므로 sec:authorize="isAnonymous()" 라는 코드를 추가해 로그인시 안보이게 해주자.

회원가입 제거

그 후 로그인 하면 보이는 내 정보라는 /user/profile로 이동하는 링크를 달아주면 아래와 같게 보인다.

당연히 지금 클릭하면 404 페이지를 찾지 못하는 오류가 발생한다.

해당 페이지에서는 로그인한 계정 정보를 활용해 사용자의 기본 정보와 작성한 질문, 답변, 추천한 글들을 보여주어야 한다. 이제 백엔드 작업을 시작해보자.

 

작성 글 가져오기

먼저 작성한 Question, Answer 글을 가져오는 작업부터 시작하자.

QuestionRepository, AnswerRepository에 findByAuthor를 추가해 작성자의 아이디를 사용해 작성한 글들을 조회할 수 있는 함수를 추가하자.

public interface QuestionRepository extends JpaRepository<Question, Integer> {
        (...생략...)

        Page<Question> findByAuthor(SiteUser siteUser, Pageable pageable);
}
public interface AnswerRepository extends JpaRepository<Answer, Integer> {

        (...생략...)

        Page<Answer> findByAuthor(SiteUser siteUser, Pageable pageable);
}

 

 

너무 많은 글이 있는 경우를 대비해 페이징한 결과를 리턴해주도록 했다.

이제 QuestionService, AnswerService를 통해 아이디를 넘겨주면 작성한 글을 최신 글순으로 페이징해 리턴해주는 함수를 구현하자.

@Service
public class QuestionService {
        (...생략...)

    public Page<Question> getListByAuthor(int page, SiteUser siteUser) {
        List<Sort.Order> sorts = new ArrayList();
        sorts.add(Sort.Order.desc("createDate"));
        Pageable pageable = PageRequest.of(page, 5, Sort.by(sorts));
        return this.questionRepository.findByAuthor(siteUser, pageable);
    }
}
@Service
public class AnswerService {
        (...생략...)

    public Page<Answer> getListByAuthor(int page, SiteUser siteUser) {
        List<Sort.Order> sorts = new ArrayList<>();
        sorts.add(Sort.Order.desc("createDate"));
        Pageable pageable = PageRequest.of(page, 3, Sort.by(sorts));
        return this.answerRepository.findByAuthor(siteUser, pageable);
    }
}

 

 

특정 유저가 작성한 질문, 답변 글을 가져오는 함수 구현을 완료했다. 이제 추천한 글을 가져오는 함수를 구현해보자.

 

추천한 글 가져오기

추천한 글은 기존에 Set로 정의한 voter에서 특정 SIteUser가 그 Set에 존재하는지 확인하는 과정이 필요하기에 기본 JPA에서 제공하는 findBy로는 구하기 어렵다.

그러므로 이전에 검색에서 사용한 Specification을 사용해서 가져오도록 하자. 먼저 Specification<> 객체를 리턴하는 hasVoter라는 객체를 만들어주자.

@Service
public class QuestionService {
        (...생략...)

    public Specification<Question> hasVoter(SiteUser siteUser) {
        return new Specification<Question>() {
            private static final long serialVersionUID = 1L;

            @Override
            public Predicate toPredicate(Root<Question> q, CriteriaQuery<?> query, CriteriaBuilder cb) {
                query.distinct(true);
                return cb.isMember(siteUser, q.get("voter"));
            }
        };
    }
        (...생략...)

위 코드에서 기존에 검색에서 사용해본것에서 못 본 것은 cb.isMember(siteUser, q.get("voter"));이다

먼저 CriteriaBuilder는 쿼리 조건을 만드는 객체라 보면 되며, isMember 함수는 주어진 요소가 컬렉션(List, Set, Map 등의 클래스)에 속하는지 여부를 확인하는 함수다.

위 함수를 사용한 추천글을 가져오는 함수를 만들자. 기존의 검색 기능 구현과 차이가 없다.

@Service
public class QuestionService {
        (...생략...)

    public Page<Question> getListByVoter(int page, SiteUser siteUser) {
        List<Sort.Order> sorts = new ArrayList();
        sorts.add(Sort.Order.desc("createDate"));
        Pageable pageable = PageRequest.of(page, 5, Sort.by(sorts));
        Specification<Question> spec = this.hasVoter(siteUser);
        return this.questionRepository.findAll(spec, pageable);
    }
}

똑같이 Answer에 대해서도 구현해주자.

public interface AnswerRepository extends JpaRepository<Answer, Integer> {
    (...생략...)

    Page<Answer> findAll(Specification<Answer> spec, Pageable pageable);
}
@Service
public class AnswerService {
        (...생략...)

    public Specification<Answer> hasVoter(SiteUser siteUser) {
        return new Specification<Answer>() {
            private static final long serialVersionUID = 1L;

            @Override
            public Predicate toPredicate(Root<Answer> a, CriteriaQuery<?> query, CriteriaBuilder cb) {
                query.distinct(true);
                return cb.isMember(siteUser, a.get("voter"));
            }
        };
    }

    public Page<Answer> getListByVoter(int page, SiteUser siteUser) {
        List<Sort.Order> sorts = new ArrayList<>();
        sorts.add(Sort.Order.desc("createDate"));
        Pageable pageable = PageRequest.of(page, 3, Sort.by(sorts));
        Specification<Answer> spec = this.hasVoter(siteUser);
        return this.answerRepository.findAll(spec, pageable);
    }
}

필요한 서비스 함수는 모두 만들었다. 만든 함수를 조합해 프로필 화면을 만드는 함수를 만들자.

 

유저 컨트롤러 수정하기

이제 UserController에 위에서 정의한 링크를 처리하는 매핑 함수 profile를 만들어주자.

@Controller
@RequestMapping("/user")
public class UserController{
        (...생략...)

    @PreAuthorize("isAuthenticated()")
    @GetMapping("/profile")
    public String profile(@RequestParam(value="question-page", defaultValue="0") int questionPage,
                          @RequestParam(value="ans-page", defaultValue="0") int ansPage,
                          @RequestParam(value="voter-page", defaultValue="0") int voterPage,
                          Principal principal) {
        SiteUser siteUser = this.userService.getUser(principal.getName());
        Page<Question> wroteQuestions = this.questionService.getListByAuthor(questionPage, siteUser);
        Page<Answer> wroteAnswers = this.answerService.getListByAuthor(ansPage, siteUser);
        Page<Question> votedQuestions = this.questionService.getListByVoter(voterPage, siteUser);
        Page<Answer> votedAnswers = this.answerService.getListByVoter(voterPage, siteUser);

        model.addAttribute("wrote_question_paging", wroteQuestions);
        model.addAttribute("wrote_answer_paging", wroteAnswers);
        model.addAttribute("voted_question_paging", votedQuestions);
        model.addAttribute("voted_answer_paging", votedAnswers);
        model.addAttribute("username", siteUser.getUsername());
        model.addAttribute("userEmail", siteUser.getEmail());
        return "profile";
    }
}

먼저 로그인한 상태로만 접근할 수 있도록 @PreAuthorize("isAuthenticated()") 어노테이션을 추가했고, Get메서드로 접근하게 할 것이므로 @GetMapping을 어노테이션으로 추가 해주었다.

이제 resources/templates/profile.html 를 생성해 아래와 같이 코드를 추가해주자.

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <h2 class="border-bottom py-2" >내 정보</h2>
    <div class="card my-3">
        <div class="card-body">
            <div class="mb-3">
                <label class="form-label">사용자 ID</label>
                <input type="text" th:value="${username}" class="form-control" readonly>
            </div>
            <div class="mb-3">
                <label class="form-label">이메일</label>
                <input type="email" th:value="${userEmail}" class="form-control" readonly>
            </div>
        </div>
    </div>

    <h2 class="border-bottom py-2" >내 질문</h2>
    <div class="card my-3">
        <table class="table">
            <thead class="table-dark">
            <tr class="text-center">
                <th>번호</th>
                <th style="width:50%">제목</th>
                <th>글쓴이</th>
                <th>작성일시</th>
                <th>조회수</th>
            </tr>
            </thead>
            <tbody>
            <tr class="text-center" th:each="question, loop : ${wrote_question_paging}">
                <td th:text="${question.id}"></td>
                <td class="text-start">
                    <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
                    <span class="text-danger small ms-2"
                          th:if="${#lists.size(question.answerList) > 0}"
                          th:text="|[${#lists.size(question.answerList)}]|">
                        </span>
                </td>
                <td><span th:if="${question.author != null}" th:text="${question.author.username}"></span></td>
                <td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
                <td th:text="${question.views}"></td>
            </tr>
            </tbody>
        </table>
        <!-- 페이징 처리 시작 -->
        <div th:if="${!wrote_question_paging.isEmpty()}">
            <ul class="pagination justify-content-center">
                <li class="page-item" th:classappend="${!wrote_question_paging.hasPrevious} ? 'disabled'">
                    <a class="page-link" th:href="@{|?question-page=${wrote_question_paging.number-1}|}" th:data-page="${wrote_question_paging.number-1}">
                        <span>이전</span>
                    </a>
                </li>
                <li th:each="page: ${#numbers.sequence(0, wrote_question_paging.totalPages-1)}"
                    th:if="${page >= wrote_question_paging.number-5 and page <= wrote_question_paging.number+5}"
                    th:classappend="${page == wrote_question_paging.number} ? 'active'"
                    class="page-item">
                    <a th:text="${page}" class="page-link" href="javascript:void(0)" th:data-page="${page}"></a>
                </li>
                <li class="page-item" th:classappend="${!wrote_question_paging.hasNext} ? 'disabled'">
                    <a class="page-link" th:href="@{|?question-page=${wrote_question_paging.number+1}|}" th:data-page="${wrote_question_paging.number+1}">
                        <span>다음</span>
                    </a>
                </li>
            </ul>
        </div>
    </div>

    <h2 class="border-bottom py-2" >내 답변</h2>
    <div class="card my-3">
        <div class="card my-3" th:each="answer, loop : ${wrote_answer_paging}">
            <a th:id="|answer_${answer.id}|"></a>
            <div class="card-body">
                <div class="card-text" th:utext="${@commonUtil.markdown(answer.content)}"></div>
                <div class="d-flex justify-content-end">
                    <div th:if="${answer.modifyDate != null}" class="badge bg-light text-dark p-2 text-start mx-3">
                        <div class="mb-2">modified at</div>
                        <div th:text="${#temporals.format(answer.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
                    </div>
                    <div class="badge bg-light text-dark p-2 text-start">
                        <div class="mb-2">
                            <span th:if="${answer.author != null}" th:text="${answer.author.username}"></span>
                        </div>
                        <div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
                    </div>
                </div>
                <div class="my-3">
                    <a th:href="@{|/question/detail/${answer.question.id}|}" class="btn btn-sm btn-outline-secondary"
                       th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                       th:text="원본질문"></a>
                    <a href="javascript:void(0);" class="recommend btn btn-sm btn-outline-secondary"
                       th:data-uri="@{|/answer/vote/${answer.id}|}">
                        추천
                        <span class="badge rounded-pill bg-success" th:text="${#lists.size(answer.voter)}"></span>
                    </a>
                    <a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
                       sec:authorize="isAuthenticated()"
                       th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                       th:text="수정"></a>
                    <a href="javascript:void(0);" th:data-uri="@{|/answer/delete/${answer.id}|}"
                       class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
                       th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                       th:text="삭제"></a>

                </div>
            </div>
        </div>

        <!-- 답변 페이징 처리 시작 -->
        <div th:if="${!wrote_answer_paging.isEmpty()}">
            <ul class="pagination justify-content-center">
                <li class="page-item" th:classappend="${!wrote_answer_paging.hasPrevious} ? 'disabled'">
                    <a class="page-link" th:href="@{|?ans-page=${wrote_answer_paging.number-1}|}" th:data-page="${wrote_answer_paging.number-1}">
                        <span>이전</span>
                    </a>
                </li>
                <li th:each="page: ${#numbers.sequence(0, wrote_answer_paging.totalPages-1)}"
                    th:if="${page >= wrote_answer_paging.number - 5 and page <= wrote_answer_paging.number+5}"
                    th:classappend="${page == wrote_answer_paging.number} ? 'active'"
                    class="page-item">
                    <a th:text="${page}" class="page-link" th:href="@{|?ans-voter-page=${page}|}" th:data-page="${page}"></a>
                </li>
                <li class="page-item" th:classappend="${!wrote_answer_paging.hasNext()} ? 'disabled'">
                    <a class="page-link" th:href="@{|?ans-page=${wrote_answer_paging.number+1}|}" th:data-page="${wrote_answer_paging.number+1}">
                        <span>다음</span>
                    </a>
                </li>
            </ul>
        </div>
    </div>

    <h2 class="border-bottom py-2" >추천한 질문</h2>
    <div class="card my-3">
        <table class="table">
            <thead class="table-dark">
            <tr class="text-center">
                <th>번호</th>
                <th style="width:50%">제목</th>
                <th>글쓴이</th>
                <th>작성일시</th>
                <th>조회수</th>
            </tr>
            </thead>
            <tbody>
            <tr class="text-center" th:each="question, loop : ${voted_question_paging}">
                <td th:text="${question.id}"></td>
                <td class="text-start">
                    <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
                    <span class="text-danger small ms-2"
                          th:if="${#lists.size(question.answerList) > 0}"
                          th:text="|[${#lists.size(question.answerList)}]|">
                        </span>
                </td>
                <td><span th:if="${question.author != null}" th:text="${question.author.username}"></span></td>
                <td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
                <td th:text="${question.views}"></td>
            </tr>
            </tbody>
        </table>

        <!-- 페이징 처리 시작 -->
        <div th:if="${!voted_question_paging.isEmpty()}">
            <ul class="pagination justify-content-center">
                <li class="page-item" th:classappend="${!voted_question_paging.hasPrevious} ? 'disabled'">
                    <a class="page-link" th:href="@{|?question-vote-page=${voted_question_paging.number-1}|}" th:data-page="${voted_question_paging.number-1}">
                        <span>이전</span>
                    </a>
                </li>
                <li th:each="page: ${#numbers.sequence(0, voted_question_paging.totalPages-1)}"
                    th:if="${page >= voted_question_paging.number-5 and page <= voted_question_paging.number+5}"
                    th:classappend="${page == voted_question_paging.number} ? 'active'"
                    class="page-item">
                    <a th:text="${page}" class="page-link" href="javascript:void(0)" th:data-page="${page}"></a>
                </li>
                <li class="page-item" th:classappend="${!voted_question_paging.hasNext} ? 'disabled'">
                    <a class="page-link" th:href="@{|?question-vote-page=${voted_question_paging.number+1}|}" th:data-page="${voted_question_paging.number+1}">
                        <span>다음</span>
                    </a>
                </li>
            </ul>
        </div>
    </div>

    <h2 class="border-bottom py-2" >추천한 답변</h2>
    <div class="card my-3">
        <div class="card my-3" th:each="answer, loop : ${voted_answer_paging}">
            <a th:id="|answer_${answer.id}|"></a>
            <div class="card-body">
                <div class="card-text" th:utext="${@commonUtil.markdown(answer.content)}"></div>
                <div class="d-flex justify-content-end">
                    <div th:if="${answer.modifyDate != null}" class="badge bg-light text-dark p-2 text-start mx-3">
                        <div class="mb-2">modified at</div>
                        <div th:text="${#temporals.format(answer.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
                    </div>
                    <div class="badge bg-light text-dark p-2 text-start">
                        <div class="mb-2">
                            <span th:if="${answer.author != null}" th:text="${answer.author.username}"></span>
                        </div>
                        <div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
                    </div>
                </div>
                <div class="my-3">
                    <a th:href="@{|/question/detail/${answer.question.id}|}" class="btn btn-sm btn-outline-secondary"
                       th:text="원본질문"></a>
                    <a href="javascript:void(0);" class="recommend btn btn-sm btn-outline-secondary"
                       th:data-uri="@{|/answer/vote/${answer.id}|}">
                        추천
                        <span class="badge rounded-pill bg-success" th:text="${#lists.size(answer.voter)}"></span>
                    </a>
                    <a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
                       sec:authorize="isAuthenticated()"
                       th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                       th:text="수정"></a>
                    <a href="javascript:void(0);" th:data-uri="@{|/answer/delete/${answer.id}|}"
                       class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
                       th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                       th:text="삭제"></a>
                </div>
            </div>
        </div>

        <!-- 답변 페이징 처리 시작 -->
        <div th:if="${!voted_answer_paging.isEmpty()}">
            <ul class="pagination justify-content-center">
                <li class="page-item" th:classappend="${!voted_answer_paging.hasPrevious} ? 'disabled'">
                    <a class="page-link" th:href="@{|?ans-vote-page=${voted_answer_paging.number-1}|}" th:data-page="${voted_answer_paging.number-1}">
                        <span>이전</span>
                    </a>
                </li>
                <li th:each="page: ${#numbers.sequence(0, voted_answer_paging.totalPages-1)}"
                    th:if="${page >= voted_answer_paging.number - 5 and page <= voted_answer_paging.number+5}"
                    th:classappend="${page == voted_answer_paging.number} ? 'active'"
                    class="page-item">
                    <a th:text="${page}" class="page-link" th:href="@{|?ans-voter-page=${page}|}" th:data-page="${page}"></a>
                </li>
                <li class="page-item" th:classappend="${!voted_answer_paging.hasNext()} ? 'disabled'">
                    <a class="page-link" th:href="@{|?ans-vote-page=${voted_answer_paging.number+1}|}" th:data-page="${voted_answer_paging.number+1}">
                        <span>다음</span>
                    </a>
                </li>
            </ul>
        </div>
    </div>
</div>
</html>

상당히 긴 HTML이지만 천천히 읽어보면 기존에 작성한 페이징글 목록 보여주기의 반복일 뿐이다.

<a th:href="@{|/question/detail/${answer.question.id}|}" 
    class="btn btn-sm btn-outline-secondary"
    th:text="원본질문"></a>

위와 같이 답변에 대해서는 원본 질문으로 이동할 수 있는 링크를 추가로 구현하였다.

내 정보에 가면 위 그림처럼 잘 나오는 것을 확인할 수 있다.

 

전체 구현은 깃허브 참조.

728x90