disabled 속성 추가true 로 설정<button disabled={!valid.password || !valid.newPassword} className="btn btn-sm btn-success" type="submit">수정하기</button>
setState 함수 추가 const [dirty, setdDirty] = setState({
password:false,
newPassword:false
})
classnames 추가classnames : 클래스를 편하게 제어하기 위한 플러그인import cn from 'classnames';
<input onChange={handleChange} value={state.password} className={cn("form-control", {"is-valid":valid.password, "is-invalid":!valid.password && dirty.password})} type="password" name="password" id="password"/>
<input onChange={handleChange} value={state.newPassword} className={cn("form-control", {"is-valid":valid.newPassword, "is-invalid":!valid.newPassword && dirty.newPassword})} type="password" name="newPassword" id="newPassword" />
➜ 비밀번호 수정 양식은 초기 상태에는 깨끗하지만 한번이라도 입력하면 수정하기 버튼은 비활성화, 모든 조건이 true 일 경우에만 수정 버튼 활성화
handleChange 함수 추가const handleChange = (e) => {
setDirty({
...dirty,
[e.target.name]:true
})
}
useEffect 함수 안에 특수문자가 있는지 여부를 검증하는 조건 추가setValid({
password:state.password.length >= 1,
newPassword:state.newPassword === state.newPassword2 && reg.test(state.newPassword)
});
form-text 입력 조건 표시 추가<div className="form-text">특수문자를 반드시 포함하고 아래의 확인란과 동일하게 입력하세요</div>
reg 상수 추가const reg = /[\w]/;
passwordUpdate 메소드 생성ResponseEntity<Void> : HTTP 응답을 표현하는 객체Void : 본문(body)이 없는 응답 ➜ 204 응답@PatchMapping("/user/password")
public ResponseEntity<Void> passwordUpdate(@RequestBody PwdChangeRequest request) {
//로그인 된 userName
String userName = SecurityContextHolder.getContext().getAuthentication().getName();
request.setUserName(userName);
userService.updatePassword(request);
// 빈 content 와 함께 204 응답이 된다
return ResponseEntity.noContent().build();
}
updatePassword 메소드 수정ExceptionController 사용하지 않고 간단하게 에러 상태 코드를 지정하여 처리 가능@Override
public void updatePassword(PwdChangeRequest pcr) {
UserDto dto=dao.getByUserName(pcr.getUserName());
String encodedPwd=dto.getPassword();
boolean isVaild=BCrypt.checkpw(pcr.getPassword(), encodedPwd);
if(!isVaild) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "기존 비밀번호가 일치하지 않아요!");
}
dto.setPassword(encoder.encode(pcr.getNewPassword()));
dao.updatePassword(dto);
}
handleSubmit 함수 안에 try-catch 문 & await, api 코드 추가try{
const res = await api.patch("/v1/user/password", state);
//정상 응답(200) 이면 여기가 실행되고
console.log(res);
alert("비밀번호 수정 완료");
}catch(err){
//정상 응답이 아니면 catch 블럭이 실행된다.
console.log(err);
};
spring10.exception 패키지 생성 후 spring09 에서 ExceptionController 복사해 붙여넣기
@Hidden // swagger ui 에서 무시하도록 @Hidden
@RestControllerAdvice
public class ExceptionController {
// @Valid 어노테이션을 이용해서 검증을 하다가 검증을 통과하지 못하면 여기가 실행된다
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidation(MethodArgumentNotValidException ex) {
// dto 에 필드 검증에 작성한 메세지 정보가 에러 정보에 담겨서 응답되도록 한다
var errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
DefaultMessageSourceResolvable::getDefaultMessage,
(a, b) -> a,
LinkedHashMap::new
));
Map<String, Object> body = Map.of(
"code", "VALIDATION_ERROR",
"errors", errors
);
return ResponseEntity.badRequest().body(body);
}
}
public class PasswordException extends RuntimeException{
public PasswordException(String msg) {
super(msg);
}
}
updatePassword 메소드 수정@Override
public void updatePassword(PwdChangeRequest pcr) {
UserDto dto = dao.getByUserName(pcr.getUserName());
String encodedPwd = dto.getPassword();
boolean isValid=BCrypt.checkpw(pcr.getPassword(), encodedPwd);
if(!isValid) {
throw new PasswordException("기존 비밀번호가 일치하지 않습니다!");
}
dto.setPassword(encoder.encode(pcr.getNewPassword()));
dao.updatePassword(dto);
}
passwordException 메소드 생성@ExceptionHandler(PasswordException.class)
public ResponseEntity<String> passwordException(PasswordException pe){
// 예외 객체로부터 메세지 얻어내기
String msg = pe.getMessage();
// 400번 에러를 발생시키면서 예외 메세지를 응답
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(msg);
}
@ExceptionHandler(PasswordException.class)
public ResponseEntity<String> asswordException(PasswordException pe){
// 예외 객체로부터 메세지 얻어내기
String msg = pe.getMessage();
// 400번 에러를 발생시키면서 예외 메세지를 응답
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(msg);
}
useState 함수 추가const [errorMsg, setErrorMsg] = useState(null);
handleSubmit 코드 수정 및 try-catch 문 & async, await 코드 추가const handleSubmit = async (e)=>{
e.preventDefault();
// 폼 제출시 에러메세지 초기화
setErrorMsg(null);
try{
const res = await api.patch("/v1/user/password", state);
// 정상 응답(200) 이면 여기가 실행되고
console.log(res);
alert("비밀번호를 수정했습니다");
} catch(err){
// 정상 응답이 아니면 catch 블럭이 실행된다
console.log(err);
// 에러 메세지를 상태값에 넣어준다
setErrorMsg(err.response.data);
};
};
alert 코드 추가{errorMsg && <p className="alert alert-danger">{errorMsg}</p>}
useDispatch 함수 추가const dispatch = useDispatch();
handleSubmit 함수 try 문 안에 dispatch 코드 추가delete localStorage.token;
dispatch({
type:"LOGIN_MODAL",
payload:{title:"수정된 비밀번호로 다시 로그인 하세요", show:true, url:"/user"}
});
⚠️ Eclipes에 코드 수정 후 저장할 때마다 에러 발생
예상 원인 : MyBatis 를 프로젝트 중간에 추가해 발생할 에러 가능성
application.properties 에 mybatis 관련 코드 2줄 주석 처리
# mybatis.mapper-locations=classpath:mapper/*.xml
# mybatis.type-aliases-package=com.example.spring10.**
@Configuration
public class MyBatisConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
factory.setDataSource(dataSource);
// XML 매퍼들 등록 (classpath* = 의존 JAR까지 포함한 전체 클래스패스 탐색)
PathMatchingResourcePatternResolver resolver =
new PathMatchingResourcePatternResolver();
factory.setMapperLocations(resolver.getResources("classpath:mapper/**/*.xml"));
// 선택 : 타입 별칭
factory.setTypeAliasesPackage("com.example.spring10.dto");
return factory.getObject();
}
}
<Button onClick = {() => {
navigate("/user/new")
}} size="sm" variant="primary" className="ms-1">회원가입</Button>
function UserForm() {
return <>
</>
}
UserForm 라우트 경로 추가<ProtectedRoute> 적용X{path:"/user/new", element:<UserForm/>}
function UserForm() {
return <>
<h1>회원 가입 양식</h1>
<form action="" method="post" novalidate>
<div className="mb-2">
<label className="form-label" htmlFor="userName">아이디</label>
<input className="form-control" type="text" name="userName" id="userName"/>
<small className="form-text">영문자 소문자로 시작하고 5글자~10글자 이내로 입력하세요</small>
<div className="valid-feedback">사용 가능한 아이디 입니다.</div>
<div className="invalid-feedback">사용할 수 없는 아이디 입니다.</div>
</div>
<div className="mb-2">
<label className="form-label" htmlFor="pwd">비밀번호</label>
<input className="form-control" type="password" name="pwd" id="pwd"/>
<small className="form-text">특수 문자를 하나 이상 조합하세요.</small>
<div className="invalid-feedback">비밀 번호를 확인 하세요</div>
</div>
<div className="mb-2">
<label className="form-label" htmlFor="pwd2">비밀번호 확인</label>
<input className="form-control" type="password" name="pwd2" id="pwd2"/>
</div>
<div className="mb-2">
<label className="form-label" htmlFor="email">이메일</label>
<input className="form-control" type="email" name="email" id="email"/>
<div className="invalid-feedback">이메일 형식에 맞게 입력하세요.</div>
</div>
<button className="btn btn-primary btn-sm" type="submit">가입</button>
</form>
</>
}
state 에 대한useState 함수 추가const [state, setState] = useState({
userName:"",
pwd:"",
pwd2:"",
email:""
});
valid 에 대한 useState 함수 추가const [valid, setValid] = useState({
userName:false,
pwd:false,
email:false
});
dirty 에 대한 useState 함수 추가const [dirty, setDirty]=useState({
userName:false,
pwd:false,
email:false
});
useEffect 함수 추가useEffect(()=>{
}, [state]);
handleChange 함수 추가const handleChange = (e)=>{
setState({
...state,
[e.target.name]:e.target.value
});
};
// userName 을 검증할 정규표현식
const regUserName = /^[a-z].{4,9}$/;
// 비밀번호 정규 표현식
const regPwd = /[\W]/;
// 이메일 정규 표현식
const regEmail = /@/;
useEffect 함수 안에 setValid 코드 추가useEffect(()=>{
setValid({
userName:regUserName.test(state.userName),
pwd: regPwd.test(state.pwd) && state.pwd === state.pwd2,
email: regEmail.test(state.email)
});
}, [state]);
handleChange 함수 안에 setDirty 코드 추가true 로 변경setDirty({
...state,
[e.target.name]:true
});
onChange 이벤트 추가 및 classnames 로 유효성 여부 클래스명 설정<input onChange={handleChange} className={cn("form-control", {"is-valid":valid.userName, "is-invalid":!valid.userName && dirty.userName})} />
<input onChange={handleChange} className={cn("form-control", {"is-valid":valid.pwd, "is-invalid":!valid.pwd && dirty.pwd})} />
<input onChange={handleChange} className={cn("form-control", {"is-valid":valid.email, "is-invalid":!valid.email && dirty.email})} type="email" name="email" id="email"/>
disabled 속성 추가<button disabled={!valid.userName || !valid.password || !valid.email}
className="btn btn-primary btn-sm" type="submit">가입</button>
action 코드 추가<form action="/v1/user" method="post" noValidate>
pwd 필드명 password로 수정private String password
signup 메소드 생성@PostMapping("/user")
public ResponseEntity<Void> signup(@RequestBody UserDto dto){
userService.createUser(dto);
return ResponseEntity.noContent().build();
}
createUser 메소드에 코드 추가UserDto result = dao.getByUserName(dto.getUserName());
if(result != null) {
}
public class UserNameException extends RuntimeException{
public UserNameException(String msg) {
super(msg);
}
}
createUser 메소드에 if문 안에 throw 추가throw new UserNameException("이미 사용중인 아이디 입니다");
userNameException 메소드 생성@ExceptionHandler(UserNameException.class)
public ResponseEntity<String> userNameException(UserNameException une){
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(une.getMessage());
}
useState 함수 추가const [errorMsg, setErrorMsg] = useState(null);
const handleSubmit = async (e)=>{
e.preventDefault();
try{
}catch(err){
console.log(err);
setErrorMsg(err.response.data);
}
}
{errorMsg && <Alert variant="danger">{errorMsg}</Alert> }
handleSubmit 함수 안에 setErrorMsg 추가setErrorMsg(null);
useDispatch 함수 추가const dispatch = useDispatch();
handleSubmit 함수에 try 문 안에 await & dispatch 코드 추가await api.post("/v1/user", state);
// 로그인 창을 띄워준다
dispatch({
type:"LOGIN_MODAL",
payload:{title:"가입한 아이디로 로그인을 하세요", show:true, url:"/user"}
})
onSubmit 이벤트 추가 <button onSubmit={handleSubmit} disabled={!valid.userName || !valid.password || !valid.email} />
<NavLink to="/user/edit">가입정보 수정</NavLink>
function UserUpdateForm() {
return <>
<h1>회원정보 수정 양식</h1>
</>
}
UserUpdateForm 라우트 경로 추가{path:"/user/edit", element:<ProtectedRoute><UserUpdateForm/></ProtectedRoute>}
useState 함수 추가 const [dto, setDto] =useState({
userName:"",
email:"",
profileImage:""
});
useEffect 함수 추가useEffect(()=>{
api.get("/v1/user")
.then(res=>setDto(res.data))
.catch(err=>console.log(err));
},[]);
<h1>회원정보 수정 양식</h1>
<form action="" method="post"
enctype="multipart/form-data" className="p-4 border rounded shadow-sm bg-light">
{/* 아이디 */}
<div className="mb-3">
<label for="userName" className="form-label">아이디</label>
<input type="text" className="form-control" id="userName" name="userName" readonly />
</div>
{/* 이메일 */}
<div className="mb-3">
<label for="email" className="form-label">이메일</label>
<input type="email" className="form-control" id="email" name="email" placeholder="이메일을 입력하세요" />
</div>
{/* 프로필 이미지 */}
<div className="mb-3">
<label className="form-label d-block">프로필 이미지</label>
<div className="text-center mb-2">
<a href="javascript:" id="profileLink">
{/* 기본 아이콘 */}
<i className="bi bi-person-circle text-secondary"
style="font-size:100px;"></i>
{/* 업로드 된 이미지 */}
<img className="rounded-circle border"
style="width:100px; height:100px; object-fit:cover;" />
</a>
</div>
<input type="file" className="form-control d-none"
name="profileFile" accept="image/*" />
<div className="form-text">클릭해서 프로필 이미지를 변경하세요</div>
</div>
{/* 버튼 영역 */}
<div className="d-flex justify-content-between">
<button type="submit" className="btn btn-primary">수정</button>
<button type="reset" className="btn btn-outline-secondary">취소</button>
</div>
</form>
value 추가<input type="text" className="form-control" id="userName"
value={dto.userName} readonly />
<input type="email" className="form-control" id="email" name="email"
value={dto.email} placeholder="이메일을 입력하세요" />
if 조건 추가&& 연산자로 처리{ !dto.profileImage && <i className="bi bi-person-circle text-secondary"
style={{fontSize:"100px"}}></i>}
{ dto.profileImage && <img src={`/upload/${dto.profileImage}`}
className="rounded-circle border"
style={{width:"100px",height:"100px",objectFit:"cover"}} /> }
⚠️ STYLE 문법 주의
// HTML: style="font-size: 100px"
// React: style={{fontSize: "100px"}}
// - 객체 형태로 전달
// - CSS 속성명은 camelCase
useRef 함수 추가const inputRef = useRef();
handleClick 함수 추가const handleClick = () =>{
// input type="file" 을 강제 클릭한다
inputRef.current.click();
};
➜ 프로필 요소의 강제 클릭을 위해 input type="file" 요소의 참조값 필요
a 요소에 onClick 이벤트 추가<a href="javascript:" id="profileLink" onClick={handleClick}>
{ !dto.profileImage && <i className="bi bi-person-circle text-secondary"
style={{fontSize:"100px"}}></i>}
{ dto.profileImage && <img src={`/upload/${dto.profileImage}`}
className="rounded-circle border"
style={{width:"100px",height:"100px",objectFit:"cover"}} /> }
</a>
handleClick 함수 안에 inputRef 코드 추가inputRef.current.click();
ref 추가<input ref={inputRef} type="file" className="form-control d-none" name="profileFile" accept="image/*"/>
handleImageChange 함수 추가const handleImageChange = (e) => {
// 선택한 파일 객체
const file = e.target.files[0]
};
➜ 파일 업로드 때문에 json 으로 보낼 수 없어 new FormData(e.target) 로 전송예정이므로 상태값으로 관리할 필요 없다
⭐ src 의 경로 표현에 2가지 방식
<img src="/upload/xxx.jpg" />
<img src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEA..." />
➜ person 이미지 폰트 인코딩 해서 ‘dataurl:xxdfdd’ 형태로 변환 가능
<i className="bi bi-person-circle text-secondary"></i><svg ref={personRef} style={personStyle} xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path fillRule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
</svg>
useRef 함수 추가const personRef = useRef();
personStyle 상수 추가const personStyle = {
width:"100px",
height:"100px",
display:"none"
}
useState 함수 추가const [imageUrl, setImageUrl] = useState({
original:"",
current:""
});
useEffect 함수 추가urloriginal, current 에 담기Base64 로 인코딩하여 이미지 src 로 사용 가능useEffect(() => {
api.get("/v1/user")
.then(res => {
setDto(res.data);
// 만일 등록된 프로필 이미지가 있다면
if (res.data.profileImage) {
const url = `/upload/${res.data.profileImage}`;
setImageUrl({ original: url, current: url });
} else { // 없으면 svg 를 디코딩한 data url 을 넣어준다.
// svg 이미지를 2 진 데이터 문자열로 읽어들여서
const svgString = new XMLSerializer().serializeToString(personRef.current);
// 2진데이터 문자열을 btoa (binary to ascii) 함수를 이용해서 ascii 코드로 변경
const encodedData = btoa(svgString);
// 변경된 ascii 코드를 이용해서 dataUrl 을 구성한다
const url = "data:image/svg+xml;base64," + encodedData;
setImageUrl({ original: url, current: url });
}
})
.catch(err => console.log(err));
}, []);
imageUrl 코드로 수정imageUrl.current : 등록(수정)된 사진 없으면 인물 아이콘<a href="javascript:" onClick={handleClick}>
{imageUrl.current && <img src={imageUrl.current}
className="rounded-circle border"
style={{ width: "100px", height: "100px", objectFit: "cover" }} />}
</a>
handleImageChange 함수 안에 파일 관련 코드 추가e.target.files[0] 로 선택된 파일 객체 얻고 FileReader 로 파일을 Data URL 로 변환current 에 담아 이미지 미리보기 업데이트//파일을 변경했을때
const handleImageChange = (e)=>{
// 선택한 파일 객체
const file=e.target.files[0];
// FileReader 객체로 읽어들이기
const reader=new FileReader();
reader.readAsDataURL(file);
reader.onload=(event)=>{
// 읽은 데이터
const data=event.target.result;
setImageUrl({
...imageUrl,
current:data
});
}
};
handleReset 함수 추가const handleReset = ()=>{
setImageUrl({
...imageUrl,
current:imageUrl.original //최초의 이미지를 출력할 수 있도록
})
}
onClick 이벤트 추가imageUrl.original 출력<button onClick={handleReset} type="reset" className="btn btn-outline-secondary">취소</button>
handleSubmit 함수 추가const handleSubmit = async (e)=>{
e.preventDefault();
// 폼에 입력한 내용을 이용해서 FormData 객체를 생성한다
const formData=new FormData(e.target);
try{
const res = await api.patch("/v1/user", formData, {
headers:{"Content-Type":"multipart/form-data"}
});
// 나머지 처리는 다음시간에
} catch(err){
console.log(err);
}
};
onSubmit 이벤트 추가 및 action 속성 경로 수정<form onSubmit={handleSubmit} action="/v1/user">