검색/이동은 동기, 데이터 로딩은 비동기 > 역할 분리 설계
[ 구현 배경 ]내가 작업하는 애니 리스트 페이지는 카드형 UI라서한 화면에 많은 데이터를 한 번에 보여주지 않는다.또한 검색(제목/줄거리), 정렬(최신/오래된/가나다),페이지 이동이 함께 붙어
lshfood2.tistory.com
지난 포스팅에 이어 그 설계를 실제 코드로
어떻게 구현했는지를 그대로 남기는 “적용기”다.
[ 적용 배경 ]
기존 AnimeListAction은 애니 목록을
selectAll로 전부 조회해서 anime.jsp로 넘기는 방식이었다.
- 데이터가 늘어날수록 서버/DB 부담 증가
- 페이지 이동마다 전체 목록을 다시 내려받는 구조
- 검색/정렬/페이지네이션이 섞이면 컨트롤러가 점점 비대해짐
그래서 다음처럼 책임을 분리했다.
- 동기(서버 렌더링): 검색 요청 검증 + 페이지 이동(anime.jsp)
- 비동기(JSON): 실제 목록 데이터 조회(페이지네이션/정렬/검색 결과)
1. 최종 구조(역할 분리)
구성 요소는 아래와 같이
역할에 따라 분리하여 구성했다.
1) AnimeListAction (동기)
- 검색 파라미터 검증(검색인데 키워드 없으면 message.jsp)
- condition/keyword를 request에 담아 anime.jsp로 forward
- DB 조회는 하지 않음
2) AnimeListDataAction (비동기 JSON)
- page/condition/keyword/sort 수신
- COUNT 조회 → totalPage 계산
- startRow/endRow 계산 → 해당 범위만 selectAll
- JSON으로 animeList + paging 반환
3) anime.jsp (View)
- 최초 진입 시(또는 검색 후 진입 시)
서버가 넘겨준 condition/keyword 기반 - JS가 AnimeListDataAction을 호출하여
1페이지 데이터부터 렌더링
2. 실행 흐름(순서)
A) 메뉴 클릭(전체보기)
- animeList.do 요청 (condition 없음)
- AnimeListAction이 condition을
ANIME_LIST_RECENT로 보정 후 anime.jsp 이동 - anime.jsp JS가 AnimeListDataAction.do?page=1
&condition=ANIME_LIST_RECENT&sort=RECENT 호출 - JSON 수신 → 카드 렌더링 + 페이지바 렌더링
B) 검색(제목/줄거리) - 동기 유지
- 검색 폼 submit → animeList.do?condition
=ANIME_SEARCH_TITLE&keyword=... - AnimeListAction에서 keyword 검증(빈값이면 message.jsp)
- anime.jsp로 이동(서버 렌더링) + condition/keyword 상태 유지
- anime.jsp JS가 1페이지부터 JSON 호출(검색조건 포함)
- 이후 페이지 이동은 비동기 페이지네이션으로만 처리
3. 동기 검색/이동을 유지한 이유
검색을 비동기만으로 처리할 수도 있지만
이번 구조는 검색을 동기로 유지했다.
- 검색어 검증/차단(message.jsp) 같은
정책을 컨트롤러에서 통일 - URL에 condition/keyword가 남아
뒤로가기/새로고침/공유가 자연스러움 - 실제 목록 데이터는 JSON으로만 내려서
페이지네이션 UX는 빠르게 유지
즉, “검색은 라우팅/정책(동기),
데이터는 페이지 단위로(비동기)” 구조로 정리했다.
4. [동기] AnimeListAction (검색 검증 + 페이지 이동만)
- 핵심 포인트
DB 조회 제거, 검색어 없으면 message.jsp로 차단
public class AnimeListAction implements Action {
@Override
public ActionForward execute(HttpServletRequest request, HttpServletResponse response) {
ActionForward forward = new ActionForward();
/*
[역할]
- 비동기 페이지네이션 적용 후, 이 Action은 DB 조회를 하지 않는다.
- 대신 "검색 조건(condition) + 키워드(keyword)"만 정리해서 anime.jsp로 넘긴다.
- 실제 목록 데이터는 anime.jsp의 JS가 AnimeListDataAction(JSON)을 호출해서 받아온다.
*/
// 1) 검색 분기값/키워드 수신
String condition = request.getParameter("condition");
String keyword = request.getParameter("keyword");
System.out.println("[애니리스트 이동 로그] condition : [" + condition + "]");
System.out.println("[애니리스트 이동 로그] keyword : [" + keyword + "]");
// 2) 기본값 보정 (허용 검색 컨디션이 아니면 기본 목록으로)
if (!"ANIME_SEARCH_TITLE".equals(condition) && !"ANIME_SEARCH_STORY".equals(condition)) {
condition = "ANIME_LIST_RECENT";
}
// 3) 검색 여부 판단(보정된 condition 기준)
boolean isSearch = "ANIME_SEARCH_TITLE".equals(condition) || "ANIME_SEARCH_STORY".equals(condition);
// 4) keyword 정리
if (keyword != null) keyword = keyword.trim();
// 5) 검색인데 keyword가 없으면 message.jsp로 차단
if (isSearch && (keyword == null || keyword.isEmpty())) {
request.setAttribute("msg", "검색어가 없습니다.");
request.setAttribute("location", "animeList.do"); // 돌아갈 곳
forward.setPath("message.jsp");
forward.setRedirect(false);
return forward;
}
// 6) 검색이 아닌 경우 빈 문자열이면 null로 정리(선택)
if (!isSearch && keyword != null && keyword.isEmpty()) {
keyword = null;
}
// 7) View 상태 유지값 전달 (JS가 그대로 재사용)
request.setAttribute("condition", condition);
request.setAttribute("keyword", keyword);
// 8) 페이지 이동만 수행
forward.setPath("anime.jsp");
forward.setRedirect(false);
return forward;
}
}
5. [비동기] AnimeListDataAction (페이지네이션 + 정렬 + 검색 JSON)
- 핵심 포인트
결과는 result = { animeList, paging } 두 덩어리로 반환
paging 안에 다음 상태유지 값 포함: condition/keyword/sort
public class AnimeListDataAction implements Action {
/*
[애니 리스트 비동기 페이지네이션 + 정렬(JSON) 액션 흐름 요약]
1) 파라미터 수신
- page : 현재 페이지 번호
- condition : 목록/검색 분기 (ANIME_LIST_RECENT / ANIME_SEARCH_TITLE / ANIME_SEARCH_STORY)
- keyword : 검색어(검색일 때만 의미)
- sort : 정렬 기준
· RECENT : 최신등록순(=PK DESC)
· OLDEST : 오래된순(=PK ASC)
· TITLE : 제목 가나다순(=TITLE ASC)
2) COUNT 조회(animeCount)
- totalPage 계산을 위해 전체 개수 필요
- (정렬은 COUNT에 영향 없음, 검색조건만 반영)
3) startRow/endRow 계산 후 LIST_PAGE 조회
- LIST_PAGE 쿼리에서 sort(정렬)을 반영한 ORDER BY 적용
4) JSON 응답 구성
- animeList + paging(page/totalPage/hasPrev/hasNext/condition/keyword/sort)
5) JSON 응답 후 return null
*/
@Override
public ActionForward execute(HttpServletRequest request, HttpServletResponse response) {
// 0) JSON 응답 세팅
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=UTF-8");
Gson gson = new Gson();
// 0-1) 정책: 한 페이지에 보여줄 애니 개수 (3열 UI 기준 15개)
final int listSize = 15;
// =========================================================
// 1) 파라미터 수신 + 검증
// 1-1) page
int page = 1;
try {
String pageParam = request.getParameter("page");
if (pageParam != null && !pageParam.trim().isEmpty()) {
page = Integer.parseInt(pageParam.trim());
}
} catch (NumberFormatException e) {
page = 1;
}
if (page < 1) page = 1;
// 1-2) condition
String condition = request.getParameter("condition");
// 1-3) keyword
String keyword = request.getParameter("keyword");
if (keyword != null) {
keyword = keyword.trim();
if (keyword.isEmpty()) keyword = null;
}
// 1-4) sort
String sort = request.getParameter("sort");
if (sort != null) sort = sort.trim();
// sort 화이트리스트(안전하게 보정)
if (!"RECENT".equals(sort) && !"OLDEST".equals(sort) && !"TITLE".equals(sort)) {
sort = "RECENT";
}
// =========================================================
// 2) condition → DAO 컨디션 매핑(COUNT/LIST_PAGE)
// 왜 COUNT와 LIST_PAGE를 분리하나?
// - 페이지네이션 계산에는 "개수(COUNT)"가 필요하고
// - 실제 목록은 "범위조회(LIST_PAGE)"가 필요하기 때문
// - 검색 조건에 따라 둘 다 "검색용"으로 바뀌어야 한다.
String countCondition;
String listCondition;
if ("ANIME_SEARCH_TITLE".equals(condition)) {
countCondition = "ANIME_COUNT_TITLE";
listCondition = "ANIME_LIST_PAGE_TITLE";
} else if ("ANIME_SEARCH_STORY".equals(condition)) {
countCondition = "ANIME_COUNT_STORY";
listCondition = "ANIME_LIST_PAGE_STORY";
} else {
condition = "ANIME_LIST_RECENT";
countCondition = "ANIME_COUNT_RECENT";
listCondition = "ANIME_LIST_PAGE_RECENT";
}
AnimeDAO animeDAO = new AnimeDAO();
// =========================================================
// 3) COUNT 조회 → animeCount (검색 조건 반영)
AnimeDTO animeDTO = new AnimeDTO();
animeDTO.setCondition(countCondition);
animeDTO.setKeyword(keyword);
AnimeDTO countData = animeDAO.selectOne(animeDTO);
int animeCount = 0;
if (countData != null) {
animeCount = countData.getAnimeCount();
}
// =========================================================
// 4) totalPage 계산 + page 상한 보정
int totalPage = (int) Math.ceil((double) animeCount / listSize);
if (totalPage < 1) totalPage = 1;
if (page > totalPage) page = totalPage;
// =========================================================
// 5) startRow/endRow (Oracle rn BETWEEN 용도)
int startRow = (page - 1) * listSize + 1;
int endRow = page * listSize;
// =========================================================
// 6) LIST_PAGE 조회 (정렬/검색 반영)
animeDTO = new AnimeDTO();
animeDTO.setCondition(listCondition);
animeDTO.setStartRow(startRow);
animeDTO.setEndRow(endRow);
animeDTO.setKeyword(keyword);
animeDTO.setSort(sort); // DAO에서 ORDER BY 분기용
List<AnimeDTO> animeList = animeDAO.selectAll(animeDTO);
if (animeList == null) animeList = Collections.emptyList(); // NPE 방지
// =========================================================
// 7) 페이지바 블록 계산
int blockSize = 10;
int startPage = ((page - 1) / blockSize) * blockSize + 1;
int endPage = Math.min(startPage + blockSize - 1, totalPage);
boolean hasPrev = startPage > 1; // 이전 "블록" 존재 여부
boolean hasNext = endPage < totalPage; // 다음 "블록" 존재 여부
// =========================================================
// 8) JSON 응답 구성
// result.animeList : 현재 페이지 카드 데이터
// result.paging : 페이지바 + 상태유지 데이터
Map<String, Object> paging = new HashMap<>();
paging.put("page", page);
paging.put("listSize", listSize);
paging.put("animeCount", animeCount);
paging.put("totalPage", totalPage);
paging.put("startPage", startPage);
paging.put("endPage", endPage);
paging.put("hasPrev", hasPrev);
paging.put("hasNext", hasNext);
// 상태 유지용: 다음 비동기 요청 만들 때 그대로 재사용
paging.put("condition", condition);
paging.put("keyword", keyword);
paging.put("sort", sort);
Map<String, Object> result = new HashMap<>();
result.put("animeList", animeList);
result.put("paging", paging);
// =========================================================
// 9) JSON 출력 후 종료
try {
response.getWriter().write(gson.toJson(result));
} catch (IOException e) {
e.printStackTrace();
}
return null; // FrontController가 null이면 추가 이동 없이 종료
}
}
6. JSON 응답 형태(프론트가 받는 구조)
{
"animeList": [ ...현재 페이지 15개... ],
"paging": {
"page": 1,
"totalPage": 12,
"startPage": 1,
"endPage": 10,
"hasPrev": false,
"hasNext": true,
"condition": "ANIME_SEARCH_TITLE",
"keyword": "나루토",
"sort": "RECENT"
}
}
- animeList : 카드 렌더링용 데이터
- paging : 페이지바 + “검색/정렬 상태 유지”용 메타데이터
7. 컨디션 정리(문서화용)
모델과의 원활한 협업을 위해
추가가 필요한 컨디션은 문서화해둔다.
| 페이지 | 기능 | 컨디션명 | 테이블 | CRUD |
| anime.jsp | 전체목록 COUNT | ANIME _COUNT _RECENT |
ANIME | SELECT _ONE |
| anime.jsp | 전체목록 페이지 조회 | ANIME _LIST_PAGE _RECENT |
ANIME | SELECT _ALL |
| anime.jsp | 제목검색 COUNT | ANIME _COUNT _TITLE |
ANIME | SELECT _ONE |
| anime.jsp | 제목검색 페이지 조회 | ANIME _LIST _PAGE _TITLE |
ANIME | SELECT _ALL |
| anime.jsp | 줄거리검색 COUNT | ANIME _COUNT _STORY |
ANIME | SELECT _ONE |
| anime.jsp | 줄거리검색 페이지 조회 | ANIME _LIST _PAGE _STORY |
ANIME | SELECT _ALL |
8. 마무리
이 구조의 핵심은 딱 2가지다.
- 검색/이동은 동기로 두고(정책/검증/URL 유지)
- 데이터 조회는 비동기로 분리해서(페이지 단위/정렬/검색 결과)
성능과 UX를 동시에 챙긴다.
'개주 훈련일지 > 🏋️ 전집중 호흡 훈련' 카테고리의 다른 글
| 이메일 인증 완료 후 비밀번호 변경까지 - Ajax 3단계 + 최종 ResetAction 적용기 (0) | 2025.12.31 |
|---|---|
| 비밀번호 찾기 UX 개선: 아이디 확인 → 이메일 자동 세팅 → 인증코드 발송(AJAX/Servlet) (0) | 2025.12.31 |
| 검색/이동은 동기, 데이터 로딩은 비동기 > 역할 분리 설계 (0) | 2025.12.28 |
| 프로필 이미지 변경 최종 확정 구현: temp→final 이동 + 캐시 차감 + 롤백 설계 (0) | 2025.12.25 |
| 프로필 이미지 업로드 UX 개선: 비동기 리사이징 미리보기 + 최종 확정 시 캐시 차감 연계 (0) | 2025.12.24 |