개주 훈련일지/🏋️ 전집중 호흡 훈련

동기 검색/이동+비동기 페이지네이션 적용기

lshfood2 2025. 12. 30. 21:17

 

 

검색/이동은 동기, 데이터 로딩은 비동기 > 역할 분리 설계

[ 구현 배경 ]내가 작업하는 애니 리스트 페이지는 카드형 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) 메뉴 클릭(전체보기)

  1. animeList.do 요청 (condition 없음)
  2. AnimeListAction이 condition을
    ANIME_LIST_RECENT로 보정 후 anime.jsp 이동
  3. anime.jsp JS가 AnimeListDataAction.do?page=1
    &condition=ANIME_LIST_RECENT&sort=RECENT 호출
  4. JSON 수신 → 카드 렌더링 + 페이지바 렌더링

B) 검색(제목/줄거리) - 동기 유지

  1. 검색 폼 submit → animeList.do?condition
    =ANIME_SEARCH_TITLE&keyword=...
  2. AnimeListAction에서 keyword 검증(빈값이면 message.jsp)
  3. anime.jsp로 이동(서버 렌더링) + condition/keyword 상태 유지
  4. anime.jsp JS가 1페이지부터 JSON 호출(검색조건 포함)
  5. 이후 페이지 이동은 비동기 페이지네이션으로만 처리

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가지다.

  1. 검색/이동은 동기로 두고(정책/검증/URL 유지)
  2. 데이터 조회는 비동기로 분리해서(페이지 단위/정렬/검색 결과)
    성능과 UX를 동시에 챙긴다.