메타코딩 SNS프로젝트

인스타그램 클론코딩 Chapter 3. 포토그램 인증 - 회원정보 수정

정현3 2022. 6. 16. 18:17

< 16. 회원정보 수정 - 시큐리티 태그 라이브러리 >

'세션 확인'까지 했으니 해당 데이터를 '수정'할 수도 있다.

맨 처음 해야 할 것은 '회원정보 수정'이다.

-> '회원정보 변경 페이지'로 왔을때, 고정된 값이 아닌 '세션에 담겨있는 정보'가 들어가야 한다.

update.jsp에 '세션 정보'를 넣으면 끝이다.

.jps는 View파일이기 때문에 세션정보인 PrincipalDetails를 model에 담아서 넘겨야 한다.

'매개변수'로 Model을 받아주고, model에 principalDetails를 담아주자.

package com.cos.photogramstart.web;

import com.cos.photogramstart.config.PrincipalDetails;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@Controller
public class UserController {

    @GetMapping("/user/{id}")
    public String profile(@PathVariable int id) {  //번호를 바꿔도 들어갈 수 있게
        return "user/profile";
    }

    @GetMapping("/user/{id}/update")
    public String update(@PathVariable int id,
                         @AuthenticationPrincipal PrincipalDetails principalDetails,
                         Model model) {

        //1. 추천
        System.out.println("세션 정보 :" + principalDetails.getUser());
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        //2. 극혐
        PrincipalDetails mPrincipalDetails = (PrincipalDetails)auth.getPrincipal();
        System.out.println("직접 찾은 세션 정보 : " + mPrincipalDetails.getUser());

        model.addAttribute("principal", principalDetails.getUser());

        return "user/update";
    }
}

//Principal은 접근주체(인증주체)로써 인증된 유저의 '오브젝트'로 흔히 사용된다

-> 첫번째로 들어갈 attributeName은 View파일에서 찾을 수 있는 이름이고, 두번째로 들어갈 곳은 모델에 담을 데이터이다.

이렇게 Model에 담으면 이제 .jsp 파일에서 해당 데이터를 받아서 사용할 수 있게 된다.

update.jsp 파일로 돌아가보자.

우선 User 오브젝트의 username을 view로 뿌려보자

<%@ page language="java" contentType="text/html; charset=UTF-8"
   pageEncoding="UTF-8"%>

<%@ include file="../layout/header.jsp"%>

<!--프로필셋팅 메인-->
<main class="main">
   <!--프로필셋팅 섹션-->
   <section class="setting-container">
      <!--프로필셋팅 아티클-->
      <article class="setting__content">

         <!--프로필셋팅 아이디영역-->
         <div class="content-item__01">
            <div class="item__img">
               <img src="#" onerror="this.src='/images/person.jpeg'" />
            </div>
            <div class="item__username">
               <h2>${Principal.user.username}</h2>
            </div>
         </div>
         <!--프로필셋팅 아이디영역end-->

         <!--프로필 수정-->
         <form id="profileUpdate">
            <div class="content-item__02">
               <div class="item__title">이름</div>
               <div class="item__input">
                  <input type="text" name="name" placeholder="이름"
                     value="${principal.user.name}" />
               </div>
            </div>
            <div class="content-item__03">
               <div class="item__title">유저네임</div>
               <div class="item__input">
                  <input type="text" name="username" placeholder="유저네임"
                     value="${principal.user.username}" readonly="readonly" />
               </div>
            </div>
            <div class="content-item__04">
               <div class="item__title">패스워드</div>
               <div class="item__input">
                  <input type="password" name="password" placeholder="패스워드"  />
               </div>
            </div>
            <div class="content-item__05">
               <div class="item__title">웹사이트</div>
               <div class="item__input">
                  <input type="text" name="website" placeholder="웹 사이트"
                     value="${principal.user.website}" />
               </div>
            </div>
            <div class="content-item__06">
               <div class="item__title">소개</div>
               <div class="item__input">
                  <textarea name="bio" id="" rows="3">${principal.user.bio}</textarea>
               </div>
            </div>
            <div class="content-item__07">
               <div class="item__title"></div>
               <div class="item__input">
                  <span><b>개인정보</b></span> <span>비즈니스나 반려동물 등에 사용된 계정인 경우에도
                     회원님의 개인 정보를 입력하세요. 공개 프로필에는 포함되지 않습니다.</span>
               </div>
            </div>
            <div class="content-item__08">
               <div class="item__title">이메일</div>
               <div class="item__input">
                  <input type="text" name="email" placeholder="이메일"
                     value="${principal.user.email}" readonly="readonly" />
               </div>
            </div>
            <div class="content-item__09">
               <div class="item__title">전회번호</div>
               <div class="item__input">
                  <input type="text" name="tel" placeholder="전화번호"
                     value="${principal.user.phone}" />
               </div>
            </div>
            <div class="content-item__10">
               <div class="item__title">성별</div>
               <div class="item__input">
                  <input type="text" name="gender" value="${principal.user.gender}" />
               </div>
            </div>

            <!--제출버튼-->
            <div class="content-item__11">
               <div class="item__title"></div>
               <div class="item__input">
                  <button>제출</button>
               </div>
            </div>
            <!--제출버튼end-->

         </form>
         <!--프로필수정 form end-->
      </article>
   </section>
</main>

<script src="/js/update.js"></script>

<%@ include file="../layout/footer.jsp"%>

-> principal은 아까 Model에 담았던 user 오브젝트의 '세션값'이고, 우리가 찾을 변수인 username을 바로 호출해도 자동으로 Getter을 생성해줘서 데이터를 받아올 수 있다.

이 방법이 JSP의 'EL 표현식' 이다

다른 value 부분도 principal을 받아서 '변수'들을 호출해보자

-> 이렇게 수정한 후 '회원정보변경 페이지'로 이동하면 세션에 저장된 데이터들이 View에 뿌려진것을 알 수 있다.

 

'시큐리티 태그 라이브러리'를 이용하는 방법

-> 그리고 '시큐리티 태그 라이브러리'로 지정된 principal은 결국 PrincipalDetails를 명칭하는것이므로, update.jsp파일에서도 경로를 principal.user. 으로 받아주어야 한다.

-> 다시 '회원정보 변경 페이지'로 가보면 '세션 정보'를 잘 받아와져 있다.

-> Model에 일일히 '세션정보'를 담을 필요가 없으므로 JSP파일을 사용할땐 이 '시큐리티 태그 라이브러리'를 사용하는것을 추천한다.

 

 

< 17. 회원정보 수정 - JQuery, Ajax 사용하기 >

'회원정보'를 수정하기 위해선 PUT method를 사용해야 한다

update.jsp로 돌아가보자

form 태그안에 method를 넣을것인데 GET,POST만 가능하다.

따라서 PUT을 사용하기 위해선 '자바스크립트'를 사용해야 한다.

 

'회원정보 수정 제출버튼'을 클릭했을때의 이벤트를 update() 함수호출로 만들어주자.

-> 당연히 update.jsp의 '제출버튼'의 button onclick에도 '해당 변수'를 받아주어야 한다.

<button onclick = "update(${principal.user.id}, event)"> 제출 </button>

이 update 함수 내에서 JQuery를 사용할것이다.

JQuery는 header.jsp에 정의되어있으므로 바로 사용할 수 있다.

JQuery는 $("#") 이라는 문법을 사용한다.

# 뒤에 jsp에서 지정해준 id값을 입력하면, 그 태그를 찾아낼 수 있다.

그리고 뒤에 .serialize()를 걸어주면 해당 태그가 가지고 있는 모든 input값을 찾아낼 수 있다. console의 로그를 활용하여 확인해보자.

ajax 내부에는 '자바스크립트 오브젝트'가 들어가는 영역이고, done은 성공했을 시 응답하는 영역, fail은 실패했을 시 에러를 응답하는 영역이다.

update.js

// (1) 회원정보 수정
function update(userId) {

    let data = $("#profileUpdate").serialize();

    console.log(data);

    $.ajax({
        type: "put",
        url: '/api/user/${userId}',
        data: data,
        contextType: "application/x-www-form-urlencoded;charset=utf-8",
        dataType: "json"
    }).done(res=> {
        console.log("update 성공");
    }).fail(error=>{
        console.log("update 실패");
    });

 }

-> type : 통신할 method 입력

-> url : 통신할 주소, 변수가 있을경우 ""가 아닌, '(backtick)을 사용한다

-> date : 전송할 데이터, 우리는 이 데이터를 let data를 통해 data에 담았으므로 data를 입력

-> contentType : 이 data가 무엇인지 서버에 설명해주는 공간. key=value의 형태이므로 applicaion/x-www-form-urlencoded라고 알려주고, 해당 데이터의 문자열셋은 utf-8이라고 알려주었다.

-> dataType : 서버로부터 응답받고 싶은 '데이터 타입'을 입력하는곳. json 타입으로 받겠다고 알려주면, 서버가 자동으로 '자바스크립트 오브젝트'로 파싱하여 res에 응답해준다.

 

 

-> 지금은 '제출버튼'을 클릭해도 무조건 에러가 뜰수밖에 없다.

Http 상태코드 404를 보면 알다시피 우리가 해당 경로로 이동할 수 있게 해주는 Controller를 만들지 않았기 때문이다.

주소경로가 api/user/userid 이기 때문에 별도로 ApiController로 만들어 주어야 한다.

왜나하면 Ajax통신을 하면 '파일(페이지)'를 응답하는것이 아닌 data를 응답하기 때문이다

data를 응답하는 것을  API 라고 부른다

따라서 Controller도 당연히 @RestController가 된다. -> '데이터'를 응답할 것이기 때문에

web/api 경로로 UserApiController 클래스 파일을 만들어주자

package com.cos.photogramstart.web.api;

import com.cos.photogramstart.web.user.UserUpdateDto;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController //데이터를 응답할것이기 때문에
public class UserApiController {

    @PutMapping("/api/user/{id}")
    public String update(UserUpdateDto userUpdateDto) {
        System.out.println(userUpdateDto);
        return "update 메서드 실행 ok";
    }
}

-> 이 Controller에서 우리가 받아올 데이터는

여기에 있던 데이터이니, 이 데이터를 받을 수 있는 DTO(Data Transfer Object)를 만들어주어야 한다.

web/dto/user 경로안에 UserUpdateDto 클래스 파일을 만들어주자.

이 UserUpdateDto안에 담길 데이터를 분석해보자

package com.cos.photogramstart.web.user;

import com.cos.photogramstart.domain.user.User;
import lombok.Data;

@Data
public class UserUpdateDto {

    private String name; //필수
    private String password; //필수
    private String website;
    private String bio;
    private String phone;
    private String gender;

    //조금 위험함. 코드 수정이 필요할 예정
    public User toEntity() {

        return User.builder()
                .name(name)
                .password(password)
                .website(website)
                .bio(bio)
                .phone(phone)
                .gender(gender)
                .build();
    }
}

-> 이 데이터들 중 필수로 받아야 하는 데이터는 name과 password이다

따라서 '필수데이터가 아닌' 데이터를 분기시켜야 하는데, 일단 그게 필요하다는 것만 알아두고 넘어가자.

DTO를 만들었으니 UserApiController로 돌아가서 이 DTO를 받아주어야 한다.

그리고 DTO의 정보를 잘 받아왔는지부터 확인해보자

package com.cos.photogramstart.web.api;

import com.cos.photogramstart.web.user.UserUpdateDto;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController //데이터를 응답할것이기 때문에
public class UserApiController {

    @PutMapping("/api/user/{id}")
    public String update(UserUpdateDto userUpdateDto) {
        System.out.println(userUpdateDto);
        return "update 메서드 실행 ok";
    }
}

json타입으로 응답받기로 했는데 Controller를 String타입으로 했기 때문에 실패한 것임.

 

< 18. 회원정보 수정 - 회원정보 수정 로직 완료 >

저번 시간에는 데이터를 응답받는것까지 성공했다.

이번 시간에는 DB에 UPDATE만 진행하면 된다.

package com.cos.photogramstart.web.user;

import com.cos.photogramstart.domain.user.User;
import lombok.Data;

@Data
public class UserUpdateDto {

    private String name; //필수
    private String password; //필수
    private String website;
    private String bio;
    private String phone;
    private String gender;

    //조금 위험함. 코드 수정이 필요할 예정
    public User toEntity() {

        return User.builder()
                .name(name) // name을 기재 안했으면 문제 Validation 체크
                .password(password) //password를 사용자가 기재하지 않았으면 문제!! Validation 체크
                .website(website)
                .bio(bio)
                .phone(phone)
                .gender(gender)
                .build();
    }
}

UserUpdateDto에서 만든 Entity에 필수로 입력받아야 하는 데이터는 name과 password이다.

그렇지만 이 상태로는 위험하다. 왜냐하면 클라이언트가 만약 password를 기재하지 않고 '수정'을 진행한다면 DB에 '공백'인 상태 그대로 UPDATE가 진행될 것이기 때문이다. 

따라서 UserUpdateDto에서도 Validation Check을 해주어야 한다.

이 '유효성 검사'는 DB에 UPDATE가 잘 되는지 부터 확인하고 나서 진행해 보겠다.

package com.cos.photogramstart.web.api;

import com.cos.photogramstart.config.PrincipalDetails;
import com.cos.photogramstart.domain.user.User;
import com.cos.photogramstart.service.UserService;
import com.cos.photogramstart.web.dto.CMRespDTO;
import com.cos.photogramstart.web.user.UserUpdateDto;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController //데이터를 응답할것이기 때문
public class UserApiController {

    private final UserService userService; //DI 주입

    @PutMapping("/api/user/{id}")
    public CMRespDTO<?> update(@PathVariable int id,
                               UserUpdateDto userUpdateDto,
                               @AuthenticationPrincipal PrincipalDetails principalDetails) { //세션정보 변경

        User userEntity = userService.회원수정(id, userUpdateDto.toEntity()); //userObject를 날린다
        principalDetails.setUser(userEntity); //세션정보 변경

        return new CMRespDTO<>(1,"회원수정 완료", userEntity); //응답의 DTO. 1은 성공
    }

}

UserApiController에서 데이터를 잘 받았으니, Update를 진행하기 위해 id값도 받아주자.

그리고 이 '회원수정 로직'을 실행시키기 위한 Service도 만들어주자.

package com.cos.photogramstart.service;

import com.cos.photogramstart.domain.user.User;
import com.cos.photogramstart.domain.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor 
@Service 
public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;


    @Transactional
    public User 회원수정(int id, User user) {

        //1.영속화
        User userEntity = userRepository.findById(id).get(); //1.무조건 찾았다 걱정마 get() 2. 못찾았어 Excetption 발동시킬게
                                                                                       // orElseThrow()

        //2.영속화된 오브젝트를 수정 - DirtyChecking (업데이트 완료)

        String rawPassword = user.getPassword();
        String encPassword = bCryptPasswordEncoder.encode(rawPassword); //password 암호화를 해주어야 한다

        userEntity.setName(user.getName());
        userEntity.setPassword(user.getPassword());
        userEntity.setBio(user.getBio());
        userEntity.setWebsite(user.getWebsite());
        userEntity.setPhone(user.getPhone());
        userEntity.setGender(user.getGender());

        return userEntity; //DirtyChecking이 일어나서 업데이트가 완료경
    }
}
//영속화된 Object를 수정하면 자동으로 DB에 반영이 된다

-> @Service어노테이션을 걸고 id와 user오브젝트를 '매개변수'로 받아주었다.

-> write가 되는 상황이므로 @Transactional 어노테이션도 걸어주었다.

@RequiredArgsConstructor
@RestController //데이터를 응답할것이기 때문
public class UserApiController {

    private final UserService userService; //DI 주입

    @PutMapping("/api/user/{id}")
    public CMRespDTO<?> update(@PathVariable int id, UserUpdateDto userUpdateDto,
                               @AuthenticationPrincipal PrincipalDetails principalDetails) { //세션정보 변경

        User userEntity = userService.회원수정(id, userUpdateDto.toEntity()); //userObject를 날린다
        principalDetails.setUser(userEntity); //세션정보 변경

        return new CMRespDTO<>(1,"회원수정 완료", userEntity); //응답의 DTO. 1은 성공
    }

}

-> 다시 UserApiController로 돌아가서 UserService를 DI해주고, '회원수정 메서드'를 호출해주자. 해당 '메서드'를 호출할 때 id와 user 오브젝트를 받아주어야 한다.

-> User오브젝트는 userUpdateDto안에 toEntity를 가지고 있으므로, 이걸 받아주자.

그리고 String 타입으로 응답해줄것이 아니기 때문에, 앞서 만들었던 '공통응답 Dto'를 이용해주자. -> CMRespDTO

// (1) 회원정보 수정
function update(userId) {

    let data = $("#profileUpdate").serialize();
// . #
    console.log(data);

    $.ajax({
        type: "put",
        url: `/api/user/${userId}` ,
        data: data,
        contextType: "application/x-www-form-urlencoded;charset=utf-8",
        dataType: "json"
    }).done(res=> {
        console.log("update 성공",res);
        alert("회원정보가 성공적으로 수정되었습니다.");
        location.href = `/user/${userId}`;
    }).fail(error=>{
        console.log("update 실패", error);
    });

 }
@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;


    @Transactional
    public User 회원수정(int id, User user) {

        //1.영속화
        User userEntity = userRepository.findById(id).get(); //1.무조건 찾았다 걱정마 get() 2. 못찾았어 Excetption 발동시킬게
                                                                                       // orElseThrow()

        //2.영속화된 오브젝트를 수정 - DirtyChecking (업데이트 완료)

        String rawPassword = user.getPassword();
        String encPassword = bCryptPasswordEncoder.encode(rawPassword); //password 암호화를 해주어야 한다

        userEntity.setName(user.getName());
        userEntity.setPassword(user.getPassword());
        userEntity.setBio(user.getBio());
        userEntity.setWebsite(user.getWebsite());
        userEntity.setPhone(user.getPhone());
        userEntity.setGender(user.getGender());

        return userEntity; //DirtyChecking이 일어나서 업데이트가 완료경
    }
}
//영속화된 Object를 수정하면 자동으로 DB에 반영이 된다

 

Controller의 설계는 끝났으니, UserService '회원수정' 메서드를 마무리 하러 가주자.

Service를 진행하기 위해서 맨 처음으로 해주어야 할 것이 '영속화'이다

두번째로는 '영속화된 오브젝트를 수정'하는 것이고, return이 완료되면 DirtyChecking이 되면서 업데이트가 완료되게 된다.

첫번째 목표인 '영속화'부터 진행하자

'영속화'란? - 서버가 findById()를 이용하여 DB에 저장되어 있는 user의 정보를 찾을것이다. DB가 해당 user의 정보를 가지고 있어서 해당 데이터를 응답해줄것이다. 이 데이터를 응답해주는 경로 사이에 '영속성 컨텍스트'라는 것이 존재한다.

이 DB로부터 찾은 데이터가 서버에 응답되기 전에 '영속성 컨텍스트'안에 담기는데, 이걸 '영속화'가 되었다고 한다.

이것을 Entity라고 부르는데, 이걸 '수정'하면 DB에 바로 반영이 되게 되어있다. UPDATE가 진행되는 것이다.

//1.영속화
User userEntity = userRepository.findById(id).get();

 

여기서 findById(id)에 .get()까지 추가한 이유는 JpaRepository가 가지고 있는 findById() 메서드에서 찾으려고 하는 User의 id가 없다면, null이 return이 되어버린다.

때문에 자바에서는 WrappingClass인 Optional<T> 라는걸 자동으로 만들어준다.

이 Optional<T> 덕분에 3가지 옵션을 선택할 수 있게 되었는데

1. get() : 데이터를 무조건 찾아줄테니 신경쓰지 마라

2. orElseThrow() : 데이터가 null일때 , Exception을 발동시켜 주겠다.

3. orElse() : 데이터가 null 일때, 괄호안에 객체를 만들어서 '리턴'시켜 주겠다.

우리는 1번과 2번만 사용할 것이다. get()을 사용한 이유는 Exception 발동을 나중으로 미루기 위해서이다.

'유효성 검사'를 하면서 할 것이기 때문이다.

 

오브젝트를 수정해보자

//2.영속화된 오브젝트를 수정 - DirtyChecking (업데이트 완료)

        String rawPassword = user.getPassword();
        String encPassword = bCryptPasswordEncoder.encode(rawPassword); //password 암호화를 해주어야 한다

        userEntity.setName(user.getName());
        userEntity.setPassword(user.getPassword());
        userEntity.setBio(user.getBio());
        userEntity.setWebsite(user.getWebsite());
        userEntity.setPhone(user.getPhone());
        userEntity.setGender(user.getGender());

        return userEntity; //DirtyChecking이 일어나서 업데이트가 완료경
    }
}
//영속화된 Object를 수정하면 자동으로 DB에 반영이 된다

이렇게 수정된 Entity를 return 해주면 된다.

현재까지의 설계는 클라이언트가 '회원수정 요청'을 하면 userUpdateDto에 input한 데이터가 담기게 되고, 

'회원정보 수정 Service'가 동작하면서 수정된 결과를 userEntity로 받아서,

수정이 반영된 User 오브젝트의 정보를 Ajax 통신으로 호출한 쪽으로 응답해주었다.

 

실제로 정상적으로 DB에 적용되는지 확인해보자.

수정전 DB에 담긴 User 오브젝트 데이터이다.

 

여기서 문제가 있다면 '회원 수정'을 했을때, '회원수정 페이지'가 아닌 '프로필 페이지'로 가도록 해주어야 하고, DB에 password가 저장될때, 암호화해서 저장이 되어야 하며,

'새로고침'을 하고 다시 '회원수정 페이지'를 갔을때, DB에 반영된 데이터가 view에 뿌려지도록 해주어야 한다.

//2.영속화된 오브젝트를 수정 - DirtyChecking (업데이트 완료)

String rawPassword = user.getPassword();
String encPassword = bCryptPasswordEncoder.encode(rawPassword); //password 암호화를 해주어야 한다

다음에는 DB에 반영된 데이터가 뿌려지도록 UserApiController를 수정하자.

DB에 반영된 데이터가 뿌려지면 session 정보가 바뀌어야한다.

'세션정보'를 불러오는 것은 앞에서 했다시피 @AuthenticationPrincipal 어노테이션을 사용하면 된다.

그리고 PrincipalDetails가 가지고 있는 'User 세션정보'에 userEntity를 담아주면, '변경된 세션정보'가 반영이 된다.

@RequiredArgsConstructor
@RestController //데이터를 응답할것이기 때문
public class UserApiController {

    private final UserService userService; //DI 주입

    @PutMapping("/api/user/{id}")
    public CMRespDTO<?> update(@PathVariable int id, UserUpdateDto userUpdateDto,
                               @AuthenticationPrincipal PrincipalDetails principalDetails) { //세션정보 변경

        User userEntity = userService.회원수정(id, userUpdateDto.toEntity()); //userObject를 날린다
        principalDetails.setUser(userEntity); //세션정보 변경

        return new CMRespDTO<>(1,"회원수정 완료", userEntity); //응답의 DTO. 1은 성공
    }

}

-> 이렇게 '회원수정 페이지'를 다시 들어가도 DB에 UPDATE가 반영된 값이 그대로 view에 뿌려진다.

 

마지막으로 '업데이트에 성공' 했을때 '프로필 페이지'를 응답해주어야 한다

update.js로 가서 done 영역을 수정해주자

// (1) 회원정보 수정
function update(userId) {

    let data = $("#profileUpdate").serialize();
// . #
    console.log(data);

    $.ajax({
        type: "put",
        url: `/api/user/${userId}` ,
        data: data,
        contextType: "application/x-www-form-urlencoded;charset=utf-8",
        dataType: "json"
    }).done(res=> {
        console.log("update 성공",res);
        alert("회원정보가 성공적으로 수정되었습니다.");
        location.href = `/user/${userId}`;
    }).fail(error=>{
        console.log("update 실패", error);
    });

 }

-> '회원수정 데이터' 제출 버튼을 클릭하면 성공적으로 해당 유저의 '프로필 페이지'를 응답해주게 되었다.

유저의 편의성을 위해 알람을 하나 띄워주는것도 좋겠다

 

< 19. 회원정보 수정 - 유효성 검사하기 >

이제 아직 처리하지 못한 문제점들을 해결할 시간이다.

지금 만든것들은 실제 '서비스'를 할 수 가 없는 상황이다.

첫번째로, 필수로 받아야 하는 데이터가 '공백'으로도 들어갈 수 있는 상황이다. password를 치지 않았는데도 정보수정이 완료되고 그러면 DB에 password가 '공백'으로 남아있어 다음 로그인때 로그인이 되지 않는 상황이 발생한다.

두번째로, 현재 userId를 1번으로 고정시켜놓았기 때문에 (profile.jsp파일을 보면 알 수 있다) 이것도 '변수'로써 처리해주어야 한다.

첫번째 문제는 '서버단(프론트,백엔드)'에서 해결이 가능한 문제이고 -> '유효성 검사'

두번째 문제는 DB단에서 해결해야 하는 문제이다.

우선 가장 쉬운 프론트단에서부터 '공백'을 막아보자.

name과 password의 input 태그에 required="required"를 걸어주자.

update.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
   pageEncoding="UTF-8"%>

<%@ include file="../layout/header.jsp"%>

<!--프로필셋팅 메인-->
<main class="main">
   <!--프로필셋팅 섹션-->
   <section class="setting-container">
      <!--프로필셋팅 아티클-->
      <article class="setting__content">

         <!--프로필셋팅 아이디영역-->
         <div class="content-item__01">
            <div class="item__img">
               <img src="#" onerror="this.src='/images/person.jpeg'" />
            </div>
            <div class="item__username">
               <h2>${Principal.user.username}</h2>
            </div>
         </div>
         <!--프로필셋팅 아이디영역end-->

         <!--프로필 수정-->
         <form id="profileUpdate" onsubmit="update(${principal.user.id}, event)">
            <div class="content-item__02">
               <div class="item__title">이름</div>
               <div class="item__input">
                  <input type="text" name="name" placeholder="이름"
                     value="${principal.user.name}" required="required"/>
               </div>
            </div>
            <div class="content-item__03">
               <div class="item__title">유저네임</div>
               <div class="item__input">
                  <input type="text" name="username" placeholder="유저네임"
                     value="${principal.user.username}" readonly="readonly" />
               </div>
            </div>
            <div class="content-item__04">
               <div class="item__title">패스워드</div>
               <div class="item__input">
                  <input type="password" name="password" placeholder="패스워드" required="required" />
               </div>
            </div>
            <div class="content-item__05">
               <div class="item__title">웹사이트</div>
               <div class="item__input">
                  <input type="text" name="website" placeholder="웹 사이트"
                     value="${principal.user.website}" />
               </div>
            </div>
            <div class="content-item__06">
               <div class="item__title">소개</div>
               <div class="item__input">
                  <textarea name="bio" id="" rows="3">${principal.user.bio}</textarea>
               </div>
            </div>
            <div class="content-item__07">
               <div class="item__title"></div>
               <div class="item__input">
                  <span><b>개인정보</b></span> <span>비즈니스나 반려동물 등에 사용된 계정인 경우에도
                     회원님의 개인 정보를 입력하세요. 공개 프로필에는 포함되지 않습니다.</span>
               </div>
            </div>
            <div class="content-item__08">
               <div class="item__title">이메일</div>
               <div class="item__input">
                  <input type="text" name="email" placeholder="이메일"
                     value="${principal.user.email}" readonly="readonly" />
               </div>
            </div>
            <div class="content-item__09">
               <div class="item__title">전회번호</div>
               <div class="item__input">
                  <input type="text" name="phone" placeholder="전화번호"
                     value="${principal.user.phone}" />
               </div>
            </div>
            <div class="content-item__10">
               <div class="item__title">성별</div>
               <div class="item__input">
                  <input type="text" name="gender" value="${principal.user.gender}" />
               </div>
            </div>

            <!--제출버튼-->
            <div class="content-item__11">
               <div class="item__title"></div>
               <div class="item__input">
                  <button >제출</button>
               </div>
            </div>
            <!--제출버튼end-->

         </form>
         <!--프로필수정 form end-->
      </article>
   </section>
</main>

<script src="/js/update.js"></script>

<%@ include file="../layout/footer.jsp"%>

required만 걸고 '수정버튼'을 누르면 데이터가 넘어가버린다

왜냐하면 button타입이 데이터를 전송하는 submit이 아니라 단순히 button으로 되어있기 때문이다

때문에 update()함수를 호출하는 것을 button 태그가 아닌 form태그에 이전시켜주겠다.

<!--프로필 수정-->
<form id="profileUpdate" onsubmit="update(${principal.user.id}, event)">
   <div class="content-item__02">
      <div class="item__title">이름</div>
      <div class="item__input">
         <input type="text" name="name" placeholder="이름"
            value="${principal.user.name}" required="required"/>
      </div>
   </div>
   <div class="content-item__03">
      <div class="item__title">유저네임</div>
      <div class="item__input">
         <input type="text" name="username" placeholder="유저네임"
            value="${principal.user.username}" readonly="readonly" />
      </div>
   </div>
   <div class="content-item__04">
      <div class="item__title">패스워드</div>
      <div class="item__input">
         <input type="password" name="password" placeholder="패스워드" required="required" />
      </div>

button의 옵션들을 지워버리고, form태그에 onsubmit을 걸어주면 된다.

<!--프로필 수정-->
<form id="profileUpdate" onsubmit="update(${principal.user.id}, event)">
<!--제출버튼-->
<div class="content-item__11">
   <div class="item__title"></div>
   <div class="item__input">
      <button >제출</button>
   </div>
</div>
<!--제출버튼end-->

-> 이제 password를 적지않고 '수정요청'을 보내면 이렇게 required가 정상적으로 작동하는것을 볼 수 있다

-> 그런데 정상적으로 입력후 제출버튼을 누르면 '회원수정로직'이 작동하지 않고 화면이 깜빡이기만 한다.

왜냐하면 button타입을 따로 지정해주지 않으면 default값으로 submit타입이 되어, form태그를 실행시키는데

action경로를 지정해주지 않았기 때문에 'default 경로인 원래주소'로 돌아온 것이다.



이걸 해결해주기 위해 form태그가 호출할 update함수에 event를 담아두자

<!--프로필 수정-->
<form id="profileUpdate" onsubmit="update(${principal.user.id}, event)">

당연히 update.js에서도 event를 받아주어야 한다.

그리고 form태그가 dafault로 가지고 있던 action경로를 비활성화 시켜서,

done()에서 지정한 경로를 탈 수 있게 바꾸어주자

// (1) 회원정보 수정
function update(userId,event) {

    event.preventDefault() //폼 태그 액션을 막기 - form태그의 action경로를 비활성화 시킨다

    let data = $("#profileUpdate").serialize();
// . #
    console.log(data);

    $.ajax({
        type: "put",
        url: `/api/user/${userId}` ,
        data: data,
        contextType: "application/x-www-form-urlencoded;charset=utf-8",
        dataType: "json"
    }).done(res=> {         //HttpStatus 상태코드 200번대
        console.log("update 성공",res);
        alert("회원정보가 성공적으로 수정되었습니다.");
        location.href = `/user/${userId}`;
    }).fail(error=>{        //HttpStatus 상태코드 200번대가 아닐
        alert(JSON.stringify(error.responseJSON.data));
    });

 }

정상적으로 실행되었다

 

첫번째인 프론트단에서의 '유효성 검사'는 끝났다.

백엔드에서도 막아주어야 하는데, 앞서 설명했듯이 우리가 제공한 웹 페이지가 아닌 , POSTMAN같은 프로그램을 사용하여 요청하였을땐 프론트단에서의 '제약조건'이 의미가 없기 때문이다.

이제 백엔드단에서 막아보자

 

이전 시간에 했던 방식과 똑같다

우선 UserUpdateDto로 가서 name과 password에 @NotBlank 어노테이션을 걸어주자.

그리고 UserApiController로 가서 @NotBlank가 걸린 UserUpdateDto에 @Valid 어노테이션을 걸어주어 @NotBlank가 작동하도록 하자.

그리고 '유효성 검사'가 실패했을때의 에러를 담아줄 BindingResult로 걸어준다.

BindingResult를 걸어주는 위치는 꼭 @Valid 되는 파라미터의 다음에 적어야 작동한다.

@Data
public class UserUpdateDto {

    @NotBlank
    private String name; //필수
    @NotBlank
    private String password; //필수
    private String website;
    private String bio;
    private String phone;
    private String gender;

    //조금 위험함. 코드 수정이 필요할 예정
    public User toEntity() {

        return User.builder()
                .name(name) // name을 기재 안했으면 문제 Validation 체크
                .password(password) //password를 사용자가 기재하지 않았으면 문제!! Validation 체크
                .website(website)
                .bio(bio)
                .phone(phone)
                .gender(gender)
                .build();
    }
}
@RequiredArgsConstructor
@RestController //데이터를 응답할것이기 때문
public class UserApiController {

    private final UserService userService; //DI 주입

    @PutMapping("/api/user/{id}")
    public CMRespDTO<?> update(@PathVariable int id,
                               @Valid UserUpdateDto userUpdateDto,
                               BindingResult bindingResult,     //꼭 Vaild가 적혀있는 다음 파라미터에 적어야함 !!!!
                               @AuthenticationPrincipal PrincipalDetails principalDetails) { //세션정보 변경

그리고 앞서 AuthController에 signup(회원가입) 로직을 만들 때, 함께 만들었던 유효성 검사 if-else문을 복사해오자.

@RequiredArgsConstructor
@RestController //데이터를 응답할것이기 때문
public class UserApiController {

    private final UserService userService; //DI 주입

    @PutMapping("/api/user/{id}")
    public CMRespDTO<?> update(@PathVariable int id,
                               @Valid UserUpdateDto userUpdateDto,
                               BindingResult bindingResult,     //꼭 Vaild가 적혀있는 다음 파라미터에 적어야함 !!!!
                               @AuthenticationPrincipal PrincipalDetails principalDetails) { //세션정보 변경

        if (bindingResult.hasErrors()) {     //오류가 발생하면 getFieldErrors() 컬렉션에 모아준다
            Map<String, String> errorMap = new HashMap<>();

            for (FieldError error : bindingResult.getFieldErrors()) {

                errorMap.put(error.getField(), error.getDefaultMessage()); //20이하여야 합니다
                System.out.println("===================");
                System.out.println(error.getDefaultMessage());
                System.out.println("===================");
            }
            throw new CustomValidationApiException("유효성 검사 실패", errorMap); //예외 발생시킴
        } else {

            User userEntity = userService.회원수정(id, userUpdateDto.toEntity()); //userObject를 날린다
            principalDetails.setUser(userEntity); //세션정보 변경

            return new CMRespDTO<>(1, "회원수정 완료", userEntity); //응답의 DTO. 1은 성공
        }
    }
}

그리고 ApiController에서 작동할 CustomValidationApiException을 만들어서 걸어주자.

handler-ex 경로에 CustomValidationException을 복사하여 붙여넣기 하자

package com.cos.photogramstart.handler.ex;

import java.util.Map;

public class CustomValidationApiException extends RuntimeException{

    //객체를 구분할 떄!!!!
    private Map<String, String> errorMap;

    public CustomValidationApiException(String message, Map<String, String> errorMap) {
        super(message);
        this.errorMap =errorMap;
    }

    public Map<String, String> getErrorMap() {
        return errorMap;
    }
}

-> 왜 굳이 똑같은 Exception을 하나 더 만드냐고 생각할 수 있는데,

ControllerExceptionHandler를 만들때 설명했듯이, 

ajax,android 통신에선 Script가 아닌 CMRespDto로 응답하는것이 좋기 때문이다.

 ControllerExceptionHandler에서 CMRespDto로 응답하는 Exception을 만들어주자.


@RestController
@ControllerAdvice //모든 Exception을 다 낚아챈다
public class ControllerExceptionHandler {

    //JavaScript로 응답하는 Handler
    @ExceptionHandler(CustomValidationException.class)
    public String validationException(CustomValidationException e) {

        //CMRespDto, Script 비교
        //1. 클라이언트에게 응답할때는 Script 좋음
        //2. Ajax통신을 하거나 Android 통신을 하게되면 CMRespDto가 좋다
        //즉, 개발자를 위한 응답에는 CMRespDto, 클라이언트를 위해서는 Script가 좋다

        return Script.back(e.getErrorMap().toString());

        //자바스크립트로 짜는 부분까지 2가지 방향으로 갔을때 사용자에게 어떤것이 좋을지 판단해보라고 나눈것
    }

    //CMRespDto 오브젝트를 응답하는 핸들러
    @ExceptionHandler(CustomValidationApiException.class)
    public ResponseEntity<CMRespDTO<?>> validationApiException(CustomValidationApiException e) {

        return new ResponseEntity<>(new CMRespDTO<>(-1, e.getMessage(), e.getErrorMap()), HttpStatus.BAD_REQUEST);

    }
    // <?>를 사용하면 제네릭 타입이 결정이 된다
    // BAD_REQUEST는 400번대 오류이다 -> 너가 요청을 잘못했다
}

UserApiController로 가서 방금만든 Api요청에 응답할 Exception과 Handler를 걸어주자

package com.cos.photogramstart.web.api;

import com.cos.photogramstart.config.PrincipalDetails;
import com.cos.photogramstart.domain.user.User;
import com.cos.photogramstart.handler.ex.CustomValidationApiException;
import com.cos.photogramstart.handler.ex.CustomValidationException;
import com.cos.photogramstart.service.UserService;
import com.cos.photogramstart.web.dto.CMRespDTO;
import com.cos.photogramstart.web.user.UserUpdateDto;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;
import java.util.HashMap;
import java.util.Map;

@RequiredArgsConstructor
@RestController //데이터를 응답할것이기 때문
public class UserApiController {

    private final UserService userService; //DI 주입

    @PutMapping("/api/user/{id}")
    public CMRespDTO<?> update(@PathVariable int id,
                               @Valid UserUpdateDto userUpdateDto,
                               BindingResult bindingResult,     //꼭 Vaild가 적혀있는 다음 파라미터에 적어야함 !!!!
                               @AuthenticationPrincipal PrincipalDetails principalDetails) { //세션정보 변경

        if (bindingResult.hasErrors()) {     //오류가 발생하면 getFieldErrors() 컬렉션에 모아준다
            Map<String, String> errorMap = new HashMap<>();

            for (FieldError error : bindingResult.getFieldErrors()) {

                errorMap.put(error.getField(), error.getDefaultMessage()); //20이하여야 합니다
                System.out.println("===================");
                System.out.println(error.getDefaultMessage());
                System.out.println("===================");
            }
            throw new CustomValidationApiException("유효성 검사 실패", errorMap); //예외 발생시킴
        } else {

            User userEntity = userService.회원수정(id, userUpdateDto.toEntity()); //userObject를 날린다
            principalDetails.setUser(userEntity); //세션정보 변경

            return new CMRespDTO<>(1, "회원수정 완료", userEntity); //응답의 DTO. 1은 성공
        }
    }
}

-> 이 '유효성 검사 로직'이 정상적으로 실행되는지 테스트해보자

update.jsp에서 user input상태에 걸려있는 required를 지우고 name없이 '수정'을 진행해보면

-> 즉, 백엔드에서 설정한 '유효성 검사 로직'도 정상적으로 동작하는것을 알 수 있다

-> 마무리하기 위해 성공했다고 뜨지않고 '실패'했다고 뜨게 해주어야 한다.

앞 시간에 작성했던 ajax를 보면 done이 동작하는걸로 보이는데, done은 HTTPStatus 상태코드가 200번대(정상)일때 실행되고,  fail은  HttpStatus상태코드가 200번대가 아닐때(에러발생)일때 실행된다.

따라서 우리가 응답을 해줄때, HttpStatus상태코드도 함께 응답해주어야 컨트롤이 가능하다.

HttpStatus 상태코드도 응답해줄수 있도록 코드를 수정해보자

ControllerExceptionHandler로 가서 API 응답을 CMRespDto<?> 타입이 아닌 ResponseEntity<CMRespDto<?>> 타입으로 변경해주자.

그리고 당연하게도 Return 타입도 동일하게 맞춰주고, body값에 이전 데이터를 넣어주고, 상태코드도 HttpStatus.BAD_REQUEST를 담아주면 된다.


@RestController
@ControllerAdvice //모든 Exception을 다 낚아챈다
public class ControllerExceptionHandler {

    //JavaScript로 응답하는 Handler
    @ExceptionHandler(CustomValidationException.class)
    public String validationException(CustomValidationException e) {

        //CMRespDto, Script 비교
        //1. 클라이언트에게 응답할때는 Script 좋음
        //2. Ajax통신을 하거나 Android 통신을 하게되면 CMRespDto가 좋다
        //즉, 개발자를 위한 응답에는 CMRespDto, 클라이언트를 위해서는 Script가 좋다

        return Script.back(e.getErrorMap().toString());

        //자바스크립트로 짜는 부분까지 2가지 방향으로 갔을때 사용자에게 어떤것이 좋을지 판단해보라고 나눈것
    }

    //CMRespDto 오브젝트를 응답하는 핸들러
    @ExceptionHandler(CustomValidationApiException.class)
    public ResponseEntity<CMRespDTO<?>> validationApiException(CustomValidationApiException e) {

        return new ResponseEntity<>(
                new CMRespDTO<>(-1, e.getMessage(), e.getErrorMap()),
                HttpStatus.BAD_REQUEST
        );

    }
    // <?>를 사용하면 제네릭 타입이 결정이 된다
    // BAD_REQUEST는 400번대 오류이다 -> 너가 요청을 잘못했다
}

다시 확인해보면 

내가 원하는 에러에 대한 응답이 성공적으로 실행되었다.

마무리로 테스트를 위해 삭제했던 required를 update.jsp에 user input태그에 다시 걸어주도록 하자

 

이번 시간에는 ajax요청일때, 왜 ResponseEntity를 사용해야 하는지에 대해 알게되었다.

응답을 제대로 컨트롤 하려면 'HttpStatus 상태코드'도 같이 전달되어야 하기 때문이다.

이로서 '유효성 검사'는 끝이났다.