API 목록 페이징 기능 추가 - 검색 결과 페이지 처리 로직 구현

API 아이콘 스트리밍 엔드포인트 추가 - base64 데이터 URL 캐싱 처리
response 압축 설정 추가 - application.yml에 설정 값 정의
This commit is contained in:
Rinjae
2026-08-21 10:48:47 +09:00
parent 889d77b990
commit 603cba65a7
9 changed files with 103 additions and 10 deletions
@@ -15,6 +15,10 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.ui.ModelMap; import org.springframework.ui.ModelMap;
@@ -79,12 +83,17 @@ public class ApiController {
} }
@GetMapping @GetMapping
public String apiList(@ModelAttribute ApiGroupSearch search, Model model) { public String apiList(@ModelAttribute ApiGroupSearch search, @PageableDefault Pageable pageable, Model model) {
Map<String, Object> searchResult = apiSearchFacade.searchApis(search); Map<String, Object> searchResult = apiSearchFacade.searchApis(search);
@SuppressWarnings("unchecked")
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
model.addAttribute("search", search); model.addAttribute("search", search);
model.addAttribute("services", searchResult.get("services")); model.addAttribute("services", searchResult.get("services"));
model.addAttribute("apis", searchResult.get("apis")); model.addAttribute("apis", apiPage.getContent());
model.addAttribute("page", apiPage);
model.addAttribute("totalApiCount", searchResult.get("totalApiCount")); model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount")); model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1"); model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
@@ -94,6 +103,19 @@ public class ApiController {
return "apps/apis/mainApiList"; return "apps/apis/mainApiList";
} }
/**
* apiSearchFacade.searchApis() 는 다른 소비처(API Status 필터 목록 등)와 계약을 공유하므로
* 항상 전체 List 를 돌려준다. 목록 화면 렌더링에서만 결과를 잘라 Page 로 감싼다.
*/
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
int total = apis == null ? 0 : apis.size();
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
return new PageImpl<>(content, pageable, total);
}
@GetMapping("/testbed/api") @GetMapping("/testbed/api")
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) { public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도. // 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
@@ -46,4 +46,6 @@ public class ApiSpecInfoDto {
private String displayRoleCode; private String displayRoleCode;
private String apiGroupName; private String apiGroupName;
private String apiGroupId;
} }
@@ -115,7 +115,9 @@ public class ApiSearchFacadeImpl implements ApiSearchFacade {
filteredApis.forEach(api -> { filteredApis.forEach(api -> {
ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId()); ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
if (service != null) { if (service != null) {
api.setMainIcon(service.getMainIcon()); // mainIcon(CLOB, base64)은 카드 수만큼 복제하지 않는다. 렌더링은
// apiGroupId 기준 /api-services/{id}/icon 스트리밍 엔드포인트를 사용한다.
api.setApiGroupId(service.getId());
api.setApiGroupName(service.getGroupName()); api.setApiGroupName(service.getGroupName());
} }
}); });
@@ -5,8 +5,17 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceTabInfo; import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceTabInfo;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService; import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import java.util.Arrays; import java.util.Arrays;
import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -20,6 +29,10 @@ import org.springframework.web.servlet.ModelAndView;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ApiServiceController { public class ApiServiceController {
// ApiGroup.mainIcon 은 "data:image/png;base64,...." 형태의 Data URL 문자열 그대로 저장돼 있다.
private static final Pattern DATA_URL_PATTERN = Pattern.compile("^data:(image/[a-zA-Z0-9+.-]+);base64,(.+)$", Pattern.DOTALL);
private static final String DEFAULT_ICON_PATH = "/img/api_icon_default.png";
private final ApiServiceService apiServiceService; private final ApiServiceService apiServiceService;
@@ -52,4 +65,28 @@ public class ApiServiceController {
return "apps/apiservice/apiServiceDetail"; return "apps/apiservice/apiServiceDetail";
} }
/**
* API 그룹 아이콘 스트리밍. mainIcon(CLOB, Data URL 문자열)을 그대로 HTML에 인라인하면
* 카드 개수만큼 반복 전송돼 응답 용량이 폭증하므로, 그룹 id 당 1회만 내려주고
* 브라우저 캐시로 재사용시킨다.
*/
@GetMapping("/{id}/icon")
public ResponseEntity<byte[]> icon(@PathVariable String id) {
String mainIcon = apiServiceService.getMainIcon(id);
Matcher matcher = mainIcon == null ? null : DATA_URL_PATTERN.matcher(mainIcon);
if (matcher == null || !matcher.matches()) {
return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, DEFAULT_ICON_PATH)
.build();
}
byte[] imageBytes = Base64.getDecoder().decode(matcher.group(2));
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(matcher.group(1)))
.cacheControl(CacheControl.maxAge(1, TimeUnit.DAYS).cachePublic())
.body(imageBytes);
}
} }
@@ -120,5 +120,14 @@ public class ApiServiceService {
.orElse(null); .orElse(null);
} }
/**
* 그룹 아이콘(mainIcon)만 가볍게 조회. {@link #getApiGroupById} 는 apiGroupApiList 까지
* 함께 로드해 무거우므로, 아이콘 스트리밍 엔드포인트 전용으로 CLOB 값만 꺼낸다.
*/
public String getMainIcon(String id) {
return apiServiceRepository.findById(id)
.map(ApiGroup::getMainIcon)
.orElse(null);
}
} }
+5
View File
@@ -30,6 +30,11 @@ server:
# RequestHeader set X-Forwarded-Proto "https" / X-Forwarded-Port "1443" (mod_wl_ohs 는 미전송) # RequestHeader set X-Forwarded-Proto "https" / X-Forwarded-Port "1443" (mod_wl_ohs 는 미전송)
forward-headers-strategy: native forward-headers-strategy: native
compression:
enabled: true
mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
min-response-size: 1024
spring: spring:
config: config:
@@ -61,6 +61,7 @@
<form id="searchForm" th:action="@{/apis}" method="get"> <form id="searchForm" th:action="@{/apis}" method="get">
<input type="hidden" name="groupIds" <input type="hidden" name="groupIds"
th:value="${search.groupIds != null and !search.groupIds.isEmpty() ? search.groupIds[0] : ''}" /> th:value="${search.groupIds != null and !search.groupIds.isEmpty() ? search.groupIds[0] : ''}" />
<input type="hidden" name="page" value="1" />
<input type="text" class="search-input" name="keyword" th:value="${search.keyword}" <input type="text" class="search-input" name="keyword" th:value="${search.keyword}"
placeholder="검색어를 입력하세요."> placeholder="검색어를 입력하세요.">
<button type="submit" class="search-submit-btn" aria-label="검색"> <button type="submit" class="search-submit-btn" aria-label="검색">
@@ -99,13 +100,17 @@
<!-- API Image (Bottom Right) --> <!-- API Image (Bottom Right) -->
<div class="api-card-image"> <div class="api-card-image">
<img th:if="${api.mainIcon}" th:src="${api.mainIcon}" th:alt="${api.apiGroupName}" <img th:if="${api.apiGroupId}" th:src="@{/api-services/{id}/icon(id=${api.apiGroupId})}"
onerror="this.style.display='none'"> th:alt="${api.apiGroupName}" onerror="this.style.display='none'">
</div> </div>
</div> </div>
</div> </div>
<!-- Pagination -->
<div class="pagination" th:if="${apis != null and !apis.isEmpty()}"
th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
<!-- Empty State --> <!-- Empty State -->
<div class="api-empty-state" th:if="${apis == null or apis.isEmpty()}"> <div class="api-empty-state" th:if="${apis == null or apis.isEmpty()}">
<div class="empty-icon">🔍</div> <div class="empty-icon">🔍</div>
@@ -121,6 +126,14 @@
<th:block layout:fragment="contentScript"> <th:block layout:fragment="contentScript">
<script th:inline="javascript"> <script th:inline="javascript">
// fragment/pagination.html 의 인라인 onclick 에서 호출 (전역 스코프 필요)
window.fn_select_page = function (pageNo) {
const form = document.getElementById('searchForm');
const pageInput = form.querySelector('input[name="page"]');
if (pageInput) pageInput.value = pageNo;
form.submit();
};
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
// DOM Elements // DOM Elements
const sidebar = document.getElementById('apiSidebar'); const sidebar = document.getElementById('apiSidebar');
@@ -128,6 +141,7 @@
const mobileOverlay = document.getElementById('mobileOverlay'); const mobileOverlay = document.getElementById('mobileOverlay');
const searchForm = document.getElementById('searchForm'); const searchForm = document.getElementById('searchForm');
const groupIdsInput = searchForm.querySelector('input[name="groupIds"]'); const groupIdsInput = searchForm.querySelector('input[name="groupIds"]');
const pageInput = searchForm.querySelector('input[name="page"]');
const searchInput = searchForm.querySelector('input[name="keyword"]'); const searchInput = searchForm.querySelector('input[name="keyword"]');
// API Card Click Handlers // API Card Click Handlers
@@ -230,6 +244,9 @@
groupIdsInput.value = ''; groupIdsInput.value = '';
} }
// 카테고리 변경 시 1페이지로 리셋
if (pageInput) pageInput.value = '1';
// Submit form // Submit form
searchForm.submit(); searchForm.submit();
}); });
@@ -239,6 +256,7 @@
searchInput.addEventListener('keypress', function (e) { searchInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (pageInput) pageInput.value = '1';
searchForm.submit(); searchForm.submit();
} }
}); });
@@ -238,10 +238,8 @@
</span> </span>
</p> </p>
<div class="card-illustration"> <div class="card-illustration">
<!-- 서비스별 아이콘 매핑 - base64 인코딩된 이미지 사용 --> <!-- 서비스별 아이콘: base64 인라인 대신 그룹 id 기준 스트리밍 엔드포인트(캐시 가능) 사용 -->
<img <img th:src="@{/api-services/{id}/icon(id=${service.id})}" th:alt="${service.groupName}">
th:src="${service.mainIcon != null and !#strings.isEmpty(service.mainIcon)} ? ${service.mainIcon} : @{/img/api_icon_default.png}"
th:alt="${service.groupName}">
</div> </div>
</div> </div>
</div> </div>
@@ -19,7 +19,7 @@
th:classappend="${isInvited ? 'input-readonly' : ''}" maxlength="50" th:value="${domain}" th:classappend="${isInvited ? 'input-readonly' : ''}" maxlength="50" th:value="${domain}"
th:readonly="${isInvited}" th:placeholder="#{portalUser.Register.domain}"> th:readonly="${isInvited}" th:placeholder="#{portalUser.Register.domain}">
</div> </div>
<button type="button" class="btn-action-primary md" th:classappend="${isInvited ? 'hidden' : ''}"> <button type="button" class="btn-action-primary md btn_check_email" th:classappend="${isInvited ? 'hidden' : ''}">
중복체크 중복체크
</button> </button>
</div> </div>