for(int i=0; i<100; i++) {
Member tmp = Member.builder().name("이름"+i).addr("주소"+i).build();
em.persist(tmp);
}
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class MemberPageResponse {
// 회원 목록
private List<MemberDto> list;
private int startPageNum;
private int endPageNum;
private int totalPageCount;
private int pageNum;
}
{
"list":[{], {}, {}, ...]
"startPageNum":1,
"endPageNum":10,
"totalPageNum" : 11,
"pageNum" : 3
}
getPage 메소드 생성public MemberPageResponse getPage(int pageNum);
// 한 페이지에 몇 개의 row 를 출력할 것인지에 대한 값
final int PAGE_ROW_COUNT = 10;
// 페이징 처리 UI 에 페이지 번호를 몇 개씩 출력할 지에 대한 값
final int PAGE_DISPLAY_COUNT = 5;
@Override
public MemberPageResponse getPage(int pageNum) {
// num 에 대해서 내림차순 정렬하겠다는 Sort 객체
Sort sort = Sort.by(Sort.Direction.DESC, "num");
// pageNum 과 page row count 와 정렬 객체를 전달해서 원하는 PageRequest 를 만들어내고
// 몇 페이지 내용이 필요한지, 페이지 사이즈, 정렬 방법
PageRequest pageRequest = PageRequest.of(pageNum-1, PAGE_ROW_COUNT, sort);
// drg.springframework.data.domain 패키지의 Page type 을 import 해야 한다
Page<Member> page = memberRepo.findAll(pageRequest);
// Page 객체를 stream 으로 만들어서 dto 의 list 를 얻어낸다
List<MemberDto> list = page.stream().map(MemberDto :: toDto).toList();
return MemberPageResponse.builder()
.list(list)
.pageNum(pageNum)
.totalPageNum(page.getTotalPages())
.build();
}
@RequiredArgsConstructor
@RequestMapping("/v2")
@RestController
public class RestMemberController2 {
private final MemberService memberService;
@GetMapping("/members")
public MemberPageResponse list(@RequestParam(defaultValue = "1") int pageNum) {
// @RequestParam(defaultValue="1") int pageNum
// 만일 pageNum 이 넘어오지 않는다면 기본값 1 을 설정한다
return memberService.getPage(pageNum);
}
}
useState 기본값 설정 const [state, setState] = useState({
list:[],
pageNum:0,
startPageNum:0,
endPageNum:0,
totalPageCount:0
});
fetch 경로 수정useEffect(()=>{
fetch("/api/v2/members")
.then(res=>res.json())
.then(data=>{
// data 는 {list:[], pageNum:1, totalPageCount:x} 형식의 object 이다
setState(data);
});
}, []);
tbody 의 map 함수 수정<tbody>
{state.list.map(item=>
<tr>
<td>{item.num}</td>
<td>{item.name}</td>
<td>
<NavLink to={`/members/${item.num}`}>보기</NavLink>
</td>
</tr>
)}
</tbody>
getPage 코드 추가public MemberPageResponse getPage(int pageNum) {
// num 에 대해서 내림차순 정렬하겠다는 Sort 객체
Sort sort=Sort.by(Sort.Direction.DESC, "num");
// pageNum 과 page row count 와 정렬 객체를 전달해서 원하는 PageRequest 를 만들어내고
PageRequest pageRequest = PageRequest.of(pageNum-1, PAGE_ROW_COUNT, sort);
// org.springframework.data.domain 페키지의 Page type 을 import 해야 한다.
Page<Member> page=memberRepo.findAll(pageRequest);
// Page 객체를 stream 으로 만들어서 dto 의 List 를 얻어낸다.
List<MemberDto> list=page.stream().map(MemberDto::toDto).toList();
// 하단 시작 페이지 번호
int startPageNum = 1 + ((pageNum-1)/PAGE_DISPLAY_COUNT)*PAGE_DISPLAY_COUNT;
// 하단 끝 페이지 번호
int endPageNum=startPageNum+PAGE_DISPLAY_COUNT-1;
// 전체 페이지의 갯수 구하기 (Page 객체에 이미 계산되어서 들어 있다)
int totalPageCount = page.getTotalPages();
// 끝 페이지 번호가 이미 전체 페이지 갯수보다 크게 계산되었다면 잘못된 값이다.
if(endPageNum > totalPageCount){
endPageNum = totalPageCount; //보정해 준다.
}
return MemberPageResponse.builder()
.list(list)
.pageNum(pageNum)
.totalPageCount(totalPageCount)
.startPageNum(startPageNum)
.endPageNum(endPageNum)
.build();
}
Pagination 페이징 처리 코드 추가<Pagination>
<Pagination.Item>Prev</Pagination.Item>
{
state.list.map(item => <Pagination.Item></Pagination.Item>)
}
<Pagination.Item>Next</Pagination.Item>
</Pagination>
range 함수 추가function range(start, end) {
const result = [];
for (let i = start; i <= end; i++) {
result.push(i);
}
return result;
}
const pageArray = range(state.startPageNum, state.endPageNum);
pageArray 로 map 코드 수정<Pagination>
<Pagination.Item>Prev</Pagination.Item>
{
pageArray.map(num => <Pagination.Item>{num}</Pagination.Item>)
}
<Pagination.Item>Next</Pagination.Item>
</Pagination>
pageMove 함수 추가const pageMove = (num) => {
};
onClick 이벤트 추가<Pagination>
<Pagination.Item>Prev</Pagination.Item>
{
pageArray.map(num =>
<Pagination.Item onClick={()=>pageMove(num)}>
{num}
</Pagination.Item>
)
}
<Pagination.Item>Next</Pagination.Item>
</Pagination>
➜ navigate 함수를 이용해 파라미터 값을 읽어와서 해당 컴포넌트에서 페이지 번호로 읽어오게 코드를 수정
/members?pageNum=1
/members?pageNum=2
/members?pageNum=3
navigate 추가const navigate = useNavigate();
pageMove 함수에 navigate 사용const pageMove = (num) => {
navigate(`/members?pageNum=${num}`);
};
useSearchParams 함수 추가"/members?pageNum=x" 에서 pageNum 을 추출하기 위해 사용const [params] = useSearchParams();
useEffect 함수에 query 파라미터 & url 구성 & params 전달useEffect(()=>{
// query 파라미터값을 읽어와 본다
const pageNum = params.get("pageNum");
// pageNum 이 있으면 해당 페이지 정보를 요청하는 url 을 구성하고 없으면 페이지 번호 없이 구성
const url = pageNum ? `/api/v2/members?pageNum=${pageNum}` : "/api/v2/members"
fetch(url)
.then(res=>res.json())
.then(data=>{
setState(data);
});
}, [params]);
active 처리<Pagination>
<Pagination.Item>Prev</Pagination.Item>
{
pageArray.map(num =>
<Pagination.Item
onClick={()=>pageMove(num)}
active={state.pageNum === num}
>
{num}
</Pagination.Item>
)
}
<Pagination.Item>Next</Pagination.Item>
</Pagination>
onClick 이벤트 추가<Pagination.Item
onClick={()=>pageMove(state.startPageNum-1)}
disabled={state.startPageNum === 1}
>
Prev
</Pagination.Item>
<Pagination.Item
onClick={()=>pageMove(state.endPageNum+1)}
disabled={state.endPageNum === state.totalPageCount}
>
Next
</Pagination.Item>
select 옵션 & 검색 박스 & 버튼 추가<label htmlFor="condition">검색 조건</label>
<select name="condition" id="condition">
<option value="name_addr">이름 + 주소</option>
<option value="name">이름</option>
<option value="addr">주소</option>
</select>
<input type="text" name="keyword" placeholder="검색어 입력" />
<button>검색</button>
const [search, setSearch] = useState({
condition:"name_addr",
keyword:""
});
handleSearchChange 함수 추가e.target.name : condition or keyword const handleSearchChange = (e) =>{
setSearch({
...search,
[e.target.name]:e.target.value
});
};
onChange 이벤트 & value 속성 추가<select onChange={handleSearchChange} value={search.condition} name="condition" id="condition">
<input onChange={handleSearchChange} value={search.keyword} type="text" name="keyword" placeholder="검색어 입력" />
handleSearchClick 함수 추가const handleSearchClick = () => {
// search 상태값 object 에 저장된 내용을 query 문자열로 변경한다
// {condition:"name_addr" , keyword:"kim"} => condition=name_addr&keyword=kim
const query = new URLSearchParams(search).toString();
// "/members?condition=name_addr&keyword=kim" 이런 형식으로 주소창이 변경된다
navigate(`/members?${query}`);
};
onClick 이벤트 추가<button onClick={handleSearchClick}>검색</button>
http://localhost:5173/#/members?condition=name_addr&keyword=%EA%B0%95%EB%82%A8
useEffect 함수 전체 수정// 컴포넌트가 활성화 될 때 회원 목록 받아오기 + params 가 변경되었을 때도 다시 받아오기
useEffect(() => {
// params 정보를 읽어온다
const pageNum = params.get("pageNum"); // null 일 수 있다
const condition = params.get("condition"); // null 일 수 있다
const keyword = params.get("keyword"); // null 일 수 있다
// api 서버에 요청할 query 문자열 구성
const qs = new URLSearchParams(); // 객체 생성 후 하나씩 setting
if (pageNum) {
qs.set("pageNum", pageNum);
}
if (keyword) {
qs.set("condition", condition);
qs.set("keyword", keyword);
}
console.log(qs.toString());
fetch(`/api/v2/members?${qs.toString()}`)
.then(res => res.json())
.then(data => {
// data 는 {list:[], pageNum:1, totalPageCount:x} 형식의 object 이다
setState(data);
});
}, [params]);
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
public class MemberListRequest {
// 페이지 번호 (default 값 1)
private int pageNum = 1;
// 검색 조건
private String condition;
// 검색 키워드
private String keyword;
}
list 메소드 수정@GetMapping("/members")
public MemberPageResponse list(MemberListRequest request) {
return memberService.getPage(request);
}
getPage 메소드 수정public MemberPageResponse getPage(MemberListRequest request);
getPage 메소드 오버라이드 수정public MemberPageResponse getPage(MemberListRequest request) {
// 페이지 번호
int pageNum = request.getPageNum();
// num 에 대해서 내림차순 정렬하겠다는 Sort 객체
Sort sort=Sort.by(Sort.Direction.DESC, "num");
// pageNum 과 page row count 와 정렬 객체를 전달해서 원하는 PageRequest 를 만들어내고
PageRequest pageRequest = PageRequest.of(pageNum-1, PAGE_ROW_COUNT, sort);
// org.springframework.data.domain 페키지의 Page type 을 import 해야 한다.
Page<Member> page=memberRepo.findAll(pageRequest);
// Page 객체를 stream 으로 만들어서 dto 의 List 를 얻어낸다.
List<MemberDto> list=page.stream().map(MemberDto::toDto).toList();
// 하단 시작 페이지 번호
int startPageNum = 1 + ((pageNum-1)/PAGE_DISPLAY_COUNT)*PAGE_DISPLAY_COUNT;
// 하단 끝 페이지 번호
int endPageNum=startPageNum+PAGE_DISPLAY_COUNT-1;
// 전체 페이지의 갯수 구하기 (Page 객체에 이미 계산되어서 들어 있다)
int totalPageCount = page.getTotalPages();
// 끝 페이지 번호가 이미 전체 페이지 갯수보다 크게 계산되었다면 잘못된 값이다.
if(endPageNum > totalPageCount){
endPageNum = totalPageCount; //보정해 준다.
}
return MemberPageResponse.builder()
.list(list)
.pageNum(pageNum)
.totalPageCount(totalPageCount)
.startPageNum(startPageNum)
.endPageNum(endPageNum)
.build();
}
findByNameContainingpublic Page<Member> findByNameContaining(String keyword, Pageable pageable);
findByAddrContainingpublic Page<Member> findByAddrContaining(String keyword, Pageable pageable);
findByNameContainingAddrContainingpublic Page<Member> findByNameContainingOrAddrContaining(String keyword, String keyword2, Pageable pageable);
getPage 에 코드 추가// 검색 키워드
String keyword = request.getKeyword();
Page<Member> page = null;
// 만일 키워드가 비었으면 모든 회원 정보 중에서 원하는 페이지의 결과 얻어내기
if(keyword == null || keyword.isEmpty()) {
page = memberRepo.findAll(pageRequest);
} else { // 키워드가 있으면 키워드에 해당하는 결과 얻어내기
// 검색 조건으로 분기한다
switch(request.getCondition()) {
case "name":
page = memberRepo.findByNameContaining(keyword, pageRequest);
break;
case "addr":
page = memberRepo.findByAddrContaining(keyword, pageRequest);
break;
case "name_addr":
page = memberRepo.findByNameContainingOrAddrContaining(keyword, keyword, pageRequest);
break;
}
}
pageMove 함수 코드 수정const pageMove = (num) => {
// 현재 URLSearchParams 를 복사하고
const qs = new URLSearchParams(params);
// pageNum 만 교체 한다
qs.set("pageNum", num);
navigate(`/members?${qs.toString()}`);
};
src 폴더명을 Step02SPA 로 변경
src_spa 폴더를 복사해 복붙한 폴더명을 src 로 변경
<NavLink to="/members">회원 목록</NavLink>
function Member() {
return <>
<h1>회원 목록</h1>
<table className="table table-striped">
<thead>
<tr>
<th>번호</th>
<th>이름</th>
<th>주소</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</>
}
Member 라우트 경로 추가 {paht:"members", element:<Member/>}
useState & useEffect 추가// 회원 목록을 상태값으로 관리
const [members, setMembers] = useState([]);
useEffect(()=>{
// npm install axios 로 설치한 axios 를 이용해서 api 요청해보기
// get방식 : axios.get post방식 :axios.post
axios.get("/api/v1/members")
.then(res => {
console.log(res);
// res.data 에 서버가 응답한 data 가 들어 있다
setMembers(res.data);
})
.catch(err => console.log(err));
},[]);
tbody 에 목록 띄우기<tbody>
{members.map(item=>
<tr key={item.num}>
<td>{item.num}</td>
<td>{item.name}</td>
<td>{item.addr}</td>
</tr>
)}
</tbody>
<NavLink to="/members/new">회원 추가</NavLink>
function MemberForm() {
// javascript 로 라우팅할 수 있는 함수 얻어내기
const navigate = useNavigate(); // 여기서 navigate 함수를 이용해서 이동할 수 있다
//form 에 submit 이벤트가 발생했을때 실행할 함수
const handleSubmit = (e)=>{
// 기본동작 막기 (form 제출 막기)
e.preventDefault();
// 이벤트가 발생한 요소의 참조값 (form 요소의 참조값)
const form=e.target;
// 폼에 입력한 내용을 담고 있는 FormData 객체를 얻어낸다
const formData=new FormData(form);
// 폼에 입력한 내용을 object 로 변환
const obj=Object.fromEntries(formData);
};
return<>
<h1>회원 추가 양식</h1>
<form action="/api/v1/members" method="post" onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label" htmlFor="name">이름</label>
<input className="form-control" type="text" name="name" id="name"/>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="addr">주소</label>
<input className="form-control" type="text" name="addr" id="addr"/>
</div>
<button className="btn btn-success" type="submit">저장</button>
</form>
</>
}
MemberForm 라우트 경로 추가{path:"/members/new", element:<MemberForm/>}
axios 추가axios.post("/api/v2/members", obj)
.then(res=>{
console.log(res.data);
})
.catch(err=>{
// err 는 에러 정보를 담고 있는 object 이다
console.log(err);
})
@size 어노테이션 추가@Size(max=20, message="이름은 최대 20글자 까지 가능합니다")
create 메소드 생성@Valid 어노테이션을 추가해서 검증도 수행한다@PostMapping("/members")
public MemberDto create(@Valid @RequestBody MemberDto dto) {
return memberService.addMember(dto);
}
step09.exception 패키지 생성
handleValidation 메소드 생성@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);
}
}
@NotBlank(message = "이름은 반드시 입력해야 합니다")
@Size(max=20, message="이름은 최대 20글자 까지 가능합니다")
private String name;
@NotBlank(message = "주소는 반드시 입력해야 합니다")
private String addr;


useState 추가const [errors, setError] = useState({});
catch 문 안에 추가setError(err.response.data.errors);
{name:"최대 20자 가능"}{addr:"반드시 입력해야 한다", name:"최대 20자 가능"}<div className="mb-3">
<label className="form-label" htmlFor="name">이름</label>
<input className="form-control" type="text" name="name" id="name"/>
{errors.name && <p className="form-text text-danger">{errors.name}</p>}
</div>
<div className="mb-3">
<label className="form-label" htmlFor="addr">주소</label>
<input className="form-control" type="text" name="addr" id="addr"/>
{errors.addr && <p className="form-text text-danger">{errors.addr}</p>}
</div>
