[Spring Boot/JPA] #3 View와 Controller 제작 : 마이페이지 만들기 (1)

뀨뀨찬찬·2021년 3월 4일
1

spring

목록 보기
3/4
post-custom-banner

개인적으로 시작한 개발이며, 틀린 점이나 부족한 부분이 많을 수 있으니 보완할 사항이나 질문은 댓글로 남겨주세요!

개발 환경

Database : mysql community Server 8.0.23
language : Java 11
Framework : Spring
IDE : IntelliJ ultimate ver.
OS : MS Win10 64bit

컨트롤러와 뷰 개발

지난 번 기본적인 서비스에 대한 개발을 했으니 필요한 뷰와 컨트롤러를 만들었고,
나는 로그인 이후 마이페이지를 맡기로 했다.

기본적인 URI를

~/mypage/me : 닉네임 또는 이메일 변경 / 정보 표시
~/mypage/contents : 내가 쓴 게시글
~/mypage/comments : 내가 쓴 댓글
~/mypage/scrap : 내가 스크랩한 글
~/mypage/password : 비밀번호 변경

로 정했다.

mypage/me

우선 제일 기본이 되는 페이지인 /mypage/me의 뷰를 만들었다.
JSP에 대해 잘 알지 못하는 관계로 진입장벽이 그나마 낮은 Thymeleaf(View Template)을 사용했다.

  • mypage/me.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div th:replace="fragments/nav :: fragment-nav"></div>

    <th:block th:insert="fragments/mypage-body :: mypage-body"/>
    <div class="col-lg-4">
        <div class="useredit">
            <div class="fa-user">개인정보 수정</div>
            <form role="form" th:action="@{/mypage/me}" th:object="${memberForm}" method="post">
                <div class="form-group">
                    <label th:for="name">닉네임</label>
                    <input type="text" th:field="*{name}" class="form-control"
                        placeholder="닉네임을 입력하세요"
                        th:class="${#fields.hasErrors('name')}? 'form-control fieldError' : 'form-control'">
                    <p th:if="${#fields.hasErrors('name')}"
                        th:errors="*{name}">Incorrect input</p>
                </div>
                <div class="form-group">
                    <label th:for="email">이메일</label>
                    <input type="email" th:field="*{email}" class="form-control"
                           	placeholder="None" th:class="${#fields.hasErrors('email')}? 'form-control fieldError' : 'form-control'">
                    <p th:if="${#fields.hasErrors('email')}"
                        th:errors="*{email}">Incorrect input</p>
                </div>
                <button type="submit" class="btn btn-primary">수정</button>
            </form>
            <br/>
        </div>
    </div>
</body>
  • controller/MemberForm
@Getter @Setter
public class MemberForm {

    @NotEmpty(message = "닉네임은 필수입니다.")
    private String name;
    @NotEmpty(message = "이메일은 필수입니다.")
    private String email;
}

회원 정보 수정 DTO인 memberForm을 넘겨 받아, 그 안의 변수인 name과 string을 각 입력 field로 사용한다. 수정 button을 눌렀을 때 form과 함께 HTTP post 메서드로 요청할 주소를
뷰를 열심히 뚝딱뚝딱 만들었으니 이를 보여주고 post요청을 처리할 컨트롤러가 필요하다.

  • controller/MyPageController
    @GetMapping("/mypage/me")
    public String myPageHome(Model model, @AuthenticationPrincipal Member currentMember) {
        List<Category> categoryList = categoryService.findAll();

        MemberForm memberForm = new MemberForm();
        memberForm.setName(currentMember.getUsername());
        memberForm.setEmail(currentMember.getEmail());
        
        model.addAttribute("categoryList", categoryList);
        model.addAttribute("memberForm", memberForm);
        return "mypage/me";
    }

category에 대한 정보는 모든 뷰에 공통으로 들어가는 side-bar fragments에서 사용되는 것이므로 추후에 설명하도록 한다.

"/mypage/me"의 URL로 Get요청으로 들어올 경우 위에서 만든 뷰를 보여줘야 한다.
해당 뷰에는 개인정보를 수정할 Form이 필요하므로 MemberForm을 새로 생성해주고 현재 로그인한 정보를 @AuthenticationPrincipal을 통해 접근하여 set한 후 model에 Attribute로 추가시켜준다.
Form의 field를 set하는 이유는 빈칸의 form이 아닌 현재 사용자의 정보를 담게 하고 싶어서이다.

@AuthenticationPrincipal

Spring Security에서 현재 로그인한 사용자 정보를 Session에서 조회할 수 있도록 제공한다. @AuthenticationPrincipal을 통해 UserDetails를 구현한 구현체인 Member 클래스를 반환한다. UserDetails를 구현해야하는 이유는, Spring Security가 UserDetailsService를 구현한 구현체의 오버라이딩된 메서드인 loadUserByUsername()을 통해 현재 사용자를 리턴하기 때문이다.

  • service/MemberServiceImpl
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member memberEntity = memberRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));

        List<GrantedAuthority> authorities = new ArrayList<>();

        if(("admin").equals(username)) {
            authorities.add(new SimpleGrantedAuthority(MemberRole.ADMIN.getValue()));
        }
        else{
            authorities.add(new SimpleGrantedAuthority(MemberRole.USER.getValue()));
        }
        return new Member(username, memberEntity.getPassword(), memberEntity.getEmail(), authorities);
    }

이 메서드는 반드시 오버라이딩해야하는 메서드인데 리턴 타입이 UserDetails이다.
회원의 Role에 맞는 권한을 부여하고 새 Member 객체를 리턴한다. 여기서 Member가 UserDetails를 구현한 구현체이므로 Member를 리턴할 수 있다. UserDetails를 구현한 구현체가 여러 개라면 new User 또는 new UserDetails라고 하는 게 낫겠으나, 우리는 구현체가 Member 클래스 하나 뿐이므로 Member를 리턴하도록 하였다.

돌아와서

Get 요청을 받아 해당 뷰를 보여주는 컨트롤러를 만들었으니 Form을 받아 Post 요청을 처리하는 컨트롤러도 만들어야 한다.

  • controller/MyPageController
    @PostMapping("/mypage/me")
    public String userEdit(MemberForm form, BindingResult result, @AuthenticationPrincipal Member currentMember) {
        if(result.hasErrors()) {
            return "redirect:/mypage/me";
        }
        
        memberService.updateInfo(currentMember.getUsername(), form.getName(), form.getEmail());
        currentMember.setUsername(form.getName());
        currentMember.setEmail(form.getEmail());

        return "redirect:/mypage/me";
    }
  • service/MemberSerivceImple
    @Transactional
    @Override
    public Long updateInfo(String username, String newName, String email) {
        Member member  = memberRepository.findByUsername(username)
                .orElseThrow(()-> new UsernameNotFoundException(username));
        
        member.setUsername(newName);
        member.setEmail(email);
        return member.getId();
    }

memberService의 updateInfo를 호출해 DB의 username과 email을 변경한다. JPA 변경 감지를 통해 수정한다. JPA 변경 감지는 자세한 공부를 위해 다른 포스트에서 설명해야할 것 같다.

그리고 컨트롤러에서 현재 로그인한 사용자의 username과 email도 바꿔줘야 한다.
@AuthenticationPrincipal을 통해 사용자를 리턴받을 때 Session에 있는 username을 통해 검색을 하는데, 그게 바뀌지 않을 경우 아무리 getUsername을 해봐야 update 이전의 username을 뱉어낸다.(DB에는 update되어 있지만 Session에는 update되지 않은 정보가 남아 있다.)

이게 맞는 방법인지는 모르겠다. Session update에 대해 더 찾아봐야겠다.

currentMember의 userName을 set한 경우/하지 않은 경우를 테스트 해보면

원래 닉네임이 ddd11이고 ddd22로 변경

  • set한 경우

GetMapping된 컨트롤러에서 memberForm의 기본 정보를 현재 사용자 정보로 설정해주므로 이렇게 변경된 닉네임이 나오는 게 맞다.

  • set을 안 한 경우

아무리 변경해봐야 얘는 안 바뀐다.

mypage/contents

  • mypage/contents.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div th:replace="fragments/nav :: fragment-nav"></div>
<th:block th:insert="fragments/mypage-body :: mypage-body"/>

<div class="container d-flex mt-5">
    <table class="box shadow table">
        <thead>
        <tr>
            <td class="h4" colspan="6">내가 쓴 글</td>
        </tr>

        <tr>
            <th style="width: 10%">번호</th>
            <th style="width: 50%">제목</th>
            <th style="width: 10%">이름</th>
            <th style="width: 10%">추천</th>
            <th style="width: 10%">날짜</th>
            <th style="width: 10%">조회</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="post: ${posts}">
            <td style="width: 10%" th:text="${post.id}"></td>
            <td style="width: 10%"><a th:href="@{/content/{postId}(postId=${post.id},prev=1,prev_content='/board/'+${post.category.id})}" th:text="${post.title}"></a></td>
            <td style="width: 10%" th:text="${post.member.username}">익명</td>
            <td style="width: 10%" th:text="${post.likes}">추천</td>
            <td style="width: 10%" th:text="${#temporals.format(post.createDate, 'HH:mm')}">날짜</td>
            <td style="width: 10%" th:text="${post.visit}">조회</td>
        </tr>
        </tbody>
    </table>
</div>
<div class = "container">
    <nav aria-label="Page navigation example">
        <ul class="pagination justify-content-center"
            th:with="start=${T(Math).floor(posts.number/10)*10 + 1},
                            last=(${start + 9 < posts.totalPages ? start + 9 : posts.totalPages})">
            <li class="page-item">
                <a th:href="@{/contents/(page=1)}" aria-label="First">
                    <span aria-hidden="true">First</span>
                </a>
            </li>

            <li class="page-item" th:class="${posts.first} ? 'disabled'">
                <a th:href="${posts.first} ? '#' : @{/contents/(page=${posts.number})}" aria-label="Previous">
                    <span aria-hidden="true">&lt;</span>
                </a>
            </li>

            <li class="page-item" th:each="page: ${#numbers.sequence(start, last)}" th:class="${page == posts.number + 1} ? 'active'">
                <a th:text="${page}" th:href="@{/contents/(page=${page})}"></a>
            </li>

            <li class="page-item" th:class="${posts.last} ? 'disabled'">
                <a th:href="${posts.last} ? '#' : @{/contents/(page=${posts.number+2})}" aria-label="Next">
                    <span aria-hidden="true">&gt;</span>
                </a>
            </li>

            <li class="page-item">
                <a th:href="@{/contents/(page=${posts.totalPages})}" aria-label="Last">
                    <span aria-hidden="true">Last</span>
                </a>
            </li>
        </ul>
    </nav>
</div>
</body>
</html>

아래와 같이 나온다. 임의로 게시글을 여러개 만들었다. 테이블을 만들어 Thymeleaf 문법을 사용해 반복문으로 td를 만들게 했다.

html의 class="container"인 div는 page에 관한 태그들인데, First, Last 1, 2 ...를 나타내기 위함이다.

  • controller/MyPageController
    @GetMapping("/mypage/contents")
    public String myContents(Model model, @AuthenticationPrincipal Member currentMember,
                             @PageableDefault Pageable pageable) {
        Member member = memberService.findByUsername(currentMember.getUsername())
                .orElseThrow(()-> new UsernameNotFoundException(currentMember.getUsername()));

        Page<Post> posts = postService.getPostListByMember(member, pageable);
  
        List<Category> categoryList = categoryService.findAll();
  
        model.addAttribute("categoryList", categoryList);
        model.addAttribute("posts", posts);
        return "mypage/contents";
    }

새로운 어노테이션인 @PageableDefault ... JPA는 어렵다. Paging에 관련된 어노테이션이다. 컨트롤러는 간단하게 페이징한 목록을 model에 추가해 뷰를 리턴하는 구조이다.
여기서 눈여겨볼 것은 @PageableDefault, 페이징에 대해 열심히 구글링 해보면서 여러 페이징 방법이 있지만 가장 편하게? 할 수 있는 방법 같다.

이 방법을 사용하지 않고서 할 수 있는 방법은 VO를 만들고, 페이지에 해당하는 select SQL 쿼리를 날려 얻는 방법이 있다. 이는 데이터베이스마다 페이징 쿼리가 다를 수 있고 여러 요구 사항을 만족하기 어려울 수 있다. SQL이 아닌 java 코드 관점에서 DB를 접근하기 위해 Spring Data JPA를 사용하는 것이고 이를 이용하면 비즈니스 로직에 더욱 집중할 수 있기에 이 방법을 선택했다.

@PageableDefault

JPA에서 제공하는 Paging을 위한 어노테이션이다. Pageable 인터페이스를 구현한 구현체를 파라미터로 받는다.


/**
 * Annotation to set defaults when injecting a {@link org.springframework.data.domain.Pageable} into a controller
 * method. Instead of configuring {@link #sort()} and {@link #direction()} you can also use {@link SortDefault} or
 * {@link SortDefaults}.
 *
 * @since 1.6
 * @author Oliver Gierke
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface PageableDefault {

	/**
	 * Alias for {@link #size()}. Prefer to use the {@link #size()} method as it makes the annotation declaration more
	 * expressive and you'll probably want to configure the {@link #page()} anyway.
	 *
	 * @return
	 */
	int value() default 10;

	/**
	 * The default-size the injected {@link org.springframework.data.domain.Pageable} should get if no corresponding
	 * parameter defined in request (default is 10).
	 */
	int size() default 10;

	/**
	 * The default-pagenumber the injected {@link org.springframework.data.domain.Pageable} should get if no corresponding
	 * parameter defined in request (default is 0).
	 */
	int page() default 0;

	/**
	 * The properties to sort by by default. If unset, no sorting will be applied at all.
	 *
	 * @return
	 */
	String[] sort() default {};

	/**
	 * The direction to sort by. Defaults to {@link Direction#ASC}.
	 *
	 * @return
	 */
	Direction direction() default Direction.ASC;
}

Client로부터 쿼리스트링을 통해 페이징 정보가 주어지지 않는다면 PageDefault에 이 디폴트 값으로 설정된다.
디폴트 값이 아닌 원하는 값을 지정해주고 싶다면 @PageableDefault(page = 1, size = 20) 과 같이 파라미터로 넣어주면 된다.
나는 서비스의 메서드에서 값을 설정해주었는데, 이 방법보다는 파라미터로 넣어주는 것이 이후의 유지보수에도 효율적일 것 같다는 생각이 든다.

  • service/PostServiceImple
    @Override
    public Page<Post> getPostListByMember(Member member, Pageable pageable) {
        int page = (pageable.getPageNumber() == 0) ? 0 : (pageable.getPageNumber() -1);
        pageable = PageRequest.of(page, 10, Sort.by("id").descending());
        return postRepository.findByMember(member, pageable);
    }

Page 인터페이스를 AbstractPageRequest라는 추상클래스가 구현하고, 이 추상클래스를 PageRequest라는 클래스가 상속받는 구조로 되어있다.

PageRequest.of()를 통해 새로운 PageRequest를 생성하여 리턴받는다.

postRepository 수정

@Repository
public interface PostRepository extends JpaRepository<Post, Long>{

    public List<Post> findByMember(Member member);
    public List<Post> findByCategory(Category category);

    Page<Post> findByMember(Member member, Pageable pageable);
}

JpaRepository는 Page를 리턴 타입으로 갖고, Pageable을 파라미터로 받는 메서드를 만들어주면 알아서 해당 Pageable의 정보를 갖고 Paging해서 리턴한다. 이는 JpaRepository 인터페이스가 PagingAndSoringRepository 인터페이스를 상속받고 있기 때문이다.

--

쓰다보니 너무 길어져
다음에 myPage/comments와 password에 대한 글을 포스팅해야겠다...

profile
공부하고 있어요!
post-custom-banner

0개의 댓글