# Admin UI 전체 페이지 검증 결과 및 수정 지침서

> 검증일: 2026-04-03
> 검증 대상: http://localhost:5173 (admin-ui dev server)
> 백엔드: http://localhost:8080 (admin-api)
> 로그인: admin@cryptoments.io / admin1234

---

## 1. 검증 결과 요약

### 전체 현황 (32개 페이지)

| 상태 | 페이지 수 | 비율 |
|------|----------|------|
| 정상 | 22 | 69% |
| 경미한 문제 (데이터 매핑) | 7 | 22% |
| 심각한 문제 (기능 불가) | 3 | 9% |

### 심각도별 이슈 분류

| 심각도 | 이슈 | 페이지 |
|--------|------|--------|
| CRITICAL | 컨트랙트 관리 — ID/상태만 표시, 핵심 컬럼 누락 | /infra/contracts |
| CRITICAL | 인프라 지갑 — 데이터 빈 테이블 (DTO 불일치) | /infra/wallets |
| CRITICAL | 관리자 계정 — 빈 목록 (로그인된 관리자도 안 보임) | /admins |
| HIGH | 전체 지갑 — 네트워크 컬럼 빈 칸 | /infra/global-wallets |
| HIGH | 통화 관리 — 코드 컬럼 빈 칸 | /infra/currencies |
| HIGH | 감사 로그 — 관리자명/IP 주소 빈 칸 | /system/audit-logs |
| MEDIUM | 입금 조회 — 요약 카드 "-" 표시 | /deposits |
| MEDIUM | 파트너 상세 > 체인/통화 설정 — 헤더 가독성 | /partners/:id |
| LOW | 환율 — 토큰/통화 라벨 없음 | /gas/exchange-rates |

---

## 2. 정상 페이지 목록 (22개)

- /dashboard — 대시보드 (요약 카드, 이상 알림)
- /partners — 파트너 목록 (30건, 페이지네이션 정상)
- /partners/:id — 파트너 상세 > 기본 정보 탭
- /partners/:id — 파트너 상세 > 지갑 탭
- /partners/:id — 파트너 상세 > 출금 정책 탭
- /partners/:id — 파트너 상세 > 연동 설정 탭
- /partners/:id — 파트너 상세 > 하위 파트너 탭
- /withdrawals — 출금 조회 (요약 카드 + 데이터 정상)
- /withdrawals/approval — 출금 승인 (빈 데이터 정상)
- /collections — 집금 현황 (7건 정상)
- /cs/unidentified — 미식별 입금 (빈 데이터 정상)
- /cs/tx-search — TX 검색
- /infra/networks — 네트워크 관리 (3개 네트워크)
- /infra/hd-wallets — HD Wallet (3개)
- /infra/approvals — Approve 현황 (20건, FAILED 재시도 버튼)
- /infra/nonce — 논스 관리 (3개, 동기화 버튼)
- /infra/relayers — Relayer 관리 (빈 데이터 정상)
- /settlements/daily-fees — 일별 수수료 (데이터 풍부)
- /settlements/realizations — 실현 현황 (빈 데이터 정상)
- /settlements/balances — 참여자 잔액 (12건)
- /gas/records — 가스비 기록 (데이터 풍부)
- /gas/invoices — 가스비 인보이스 (3건, OVERDUE/PAID/ISSUED 배지)
- /gas/ledger — 원장 (FEE/CREDIT/DEBIT 배지, 컬러 금액)
- /system/settings — 런타임 설정 (그룹별 정상)
- /system/maintenance — 유지보수 (정상 운영 상태)

---

## 3. CRITICAL 이슈 수정 지침

### 3.1 컨트랙트 관리 — 핵심 컬럼 누락

**현상**: /infra/contracts 페이지에서 ID와 상태만 표시. 네트워크, 컨트랙트 주소, 소유자, ABI 버전 등 누락.

**원인 분석**:
- 백엔드 `ContractManagementController.getContracts()` → `BlockchainApiClient.getContractList()` 호출
- blockchain-api가 반환하는 `ContractListResponse.ContractInfo` 필드: `id, networkId, contractAddress, ownerAddressId, deployTxHash, abiVersion, status, pausedAt, pausedReason, createdAt, updatedAt`
- 프론트엔드 `ContractItem` 기대 필드: `id, networkId, networkCode, contractAddress, ownerAddress, abiVersion, status, pausedReason, createdAt`

**불일치 항목**:

| 백엔드 반환 | 프론트엔드 기대 | 문제 |
|------------|---------------|------|
| `networkId` (Long) | `networkCode` (String) | 프론트가 "BSC", "TRON" 같은 문자열 기대 |
| `ownerAddressId` (Long) | `ownerAddress` (String) | 프론트가 실제 지갑 주소 문자열 기대 |
| `contractAddress` | `contractAddress` | 일치 |
| `abiVersion` | `abiVersion` | 일치 |

**수정 방법 (Backend)**:

파일: `admin-api/src/main/java/com/cryptoments/adminapi/dto/response/ContractListResponse.java` (신규 또는 변환)

```java
// 방법 1: Service에서 변환 로직 추가
// ContractManagementService.java
public List<ContractDisplayResponse> getContractsForDisplay() {
    ContractListResponse apiResponse = blockchainApiClient.getContractList();
    List<ContractInfo> contracts = apiResponse.getContracts();

    return contracts.stream().map(c -> {
        // networkId → networkCode 변환
        BlockchainNetwork network = networkRepository.findById(c.getNetworkId());
        String networkCode = network != null ? network.getChainSymbol() : String.valueOf(c.getNetworkId());

        // ownerAddressId → ownerAddress 변환
        WalletAddress owner = walletAddressRepository.findById(c.getOwnerAddressId());
        String ownerAddress = owner != null ? owner.getAddress() : "-";

        return ContractDisplayResponse.builder()
                .id(c.getId())
                .networkId(c.getNetworkId())
                .networkCode(networkCode)
                .contractAddress(c.getContractAddress())
                .ownerAddress(ownerAddress)
                .abiVersion(c.getAbiVersion())
                .status(c.getStatus())
                .pausedReason(c.getPausedReason())
                .createdAt(c.getCreatedAt())
                .build();
    }).collect(Collectors.toList());
}
```

파일: `admin-api/src/main/java/com/cryptoments/adminapi/dto/response/ContractDisplayResponse.java` (신규)

```java
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class ContractDisplayResponse {
    /** ID */
    private Long id;
    /** 네트워크 ID */
    private Long networkId;
    /** 네트워크 코드 (BSC, POLYGON, TRON) */
    private String networkCode;
    /** 컨트랙트 주소 */
    private String contractAddress;
    /** 소유자 지갑 주소 */
    private String ownerAddress;
    /** ABI 버전 */
    private String abiVersion;
    /** 상태 */
    private String status;
    /** 정지 사유 */
    private String pausedReason;
    /** 생성일시 */
    private LocalDateTime createdAt;
}
```

**수정 방법 (Frontend 대안)**: 만약 백엔드를 바로 수정할 수 없다면, 프론트엔드에서 `networkId`를 받아 미리 로드한 네트워크 목록과 매핑하는 방식도 가능.

파일: `admin-ui/src/views/infra/ContractListView.vue`
- `onMounted`에서 네트워크 목록(`/api/admin/networks`)을 함께 로드
- `networkId`를 `networkCode`로 변환하는 computed 속성 추가
- `ownerAddressId`는 백엔드에서만 해결 가능 (주소 ID → 주소 문자열)

---

### 3.2 인프라 지갑 — DTO 불일치로 빈 테이블

**현상**: /infra/wallets 페이지에서 API 200 응답이지만 테이블에 데이터가 표시되지 않음.

**원인 분석**:

백엔드 `InfraWalletResponse` 필드:
```
id, address, networkId, networkCode, networkName, walletType, balance (단일), status, createdAt
```

프론트엔드 `InfraWalletItem` 기대 필드:
```
id, networkId, networkCode, walletType, address, nativeBalance, usdtBalance, usdcBalance, monitorRegistered, status, createdAt
```

| 백엔드 | 프론트엔드 | 문제 |
|--------|----------|------|
| `balance` (BigDecimal, 단일) | `nativeBalance`, `usdtBalance`, `usdcBalance` (3개 분리) | 구조 불일치 |
| 없음 | `monitorRegistered` (Boolean) | 필드 누락 |

**수정 방법 (Backend)**:

파일: `admin-api/src/main/java/com/cryptoments/adminapi/dto/response/InfraWalletResponse.java`

```java
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class InfraWalletResponse {
    private Long id;
    private String address;
    private Long networkId;
    private String networkCode;
    private String networkName;
    private WalletType walletType;
    // 변경: 단일 balance → 3개 분리
    private BigDecimal nativeBalance;
    private BigDecimal usdtBalance;
    private BigDecimal usdcBalance;
    private Boolean monitorRegistered;  // 추가
    private WalletAddressStatus status;
    private LocalDateTime createdAt;
}
```

파일: `admin-api/src/main/java/com/cryptoments/adminapi/mapper/InfraWalletSearchMapper.java`

기존 단일 LEFT JOIN으로는 3개 통화별 잔액을 가져올 수 없음. 서비스 레이어에서 처리하는 것을 권장:

```java
// InfraWalletService.java - getInfraWallets() 수정
public XPage<InfraWalletResponse> getInfraWallets(XPagination pagination, WalletType walletType, Long networkId) {
    // 1. 기본 지갑 목록 조회 (기존 매퍼 사용, balance 컬럼 제거)
    XPage<InfraWalletResponse> page = infraWalletSearchMapper.searchInfraWallets(pagination, walletType, networkId, InfraWalletResponse.class);

    // 2. 각 지갑의 잔액 정보 보강
    for (InfraWalletResponse wallet : page.getRecords()) {
        List<WalletBalance> balances = walletBalanceRepository.findByWalletAddressId(wallet.getId());
        for (WalletBalance wb : balances) {
            Currency currency = currencyRepository.findById(wb.getCurrencyId());
            if (currency != null) {
                if (currency.getCurrencyType() == CurrencyType.NATIVE) {
                    wallet.setNativeBalance(wb.getBalance());
                } else if ("USDT".equals(currency.getSymbol())) {
                    wallet.setUsdtBalance(wb.getBalance());
                } else if ("USDC".equals(currency.getSymbol())) {
                    wallet.setUsdcBalance(wb.getBalance());
                }
            }
        }
        // monitorRegistered 설정
        WalletAddress wa = walletAddressRepository.findById(wallet.getId());
        if (wa != null) {
            wallet.setMonitorRegistered(wa.getMonitorRegistered());
        }
    }
    return page;
}
```

매퍼 SQL에서 `balance` 관련 JOIN 제거 (서비스 레이어에서 처리):

```java
@Select("<script>" +
    "SELECT wa.id, wa.address, wa.network_id, " +
    "  bn.chain_symbol AS network_code, bn.name AS network_name, " +
    "  wa.wallet_type, wa.status, wa.created_at, wa.monitor_registered" +
    "  FROM wallet_addresses wa" +
    "  INNER JOIN blockchain_networks bn ON wa.network_id = bn.id" +
    " <where>" +
    "  wa.wallet_type IN ('ADMIN', 'GAS', 'SETTLEMENT', 'RELAYER')" +
    "  <if test='walletType != null'>AND wa.wallet_type = #{walletType}</if>" +
    "  <if test='networkId != null'>AND wa.network_id = #{networkId}</if>" +
    " </where>" +
    "</script>")
XPage<InfraWalletResponse> searchInfraWallets(...);
```

---

### 3.3 관리자 계정 — 빈 목록

**현상**: /admins 페이지에서 "등록된 관리자가 없습니다" 표시. 현재 admin@cryptoments.io로 로그인 중임에도 불구하고.

**원인 분석**:
- `AdminManagementController.getAdmins()` → `adminManagementService.getAdmins()` → `adminRepository.findAll()`
- admins 테이블에 데이터가 없을 가능성 (v1→v2 마이그레이션에서 admin 계정이 누락되었거나, DDL의 seed INSERT가 다른 테이블에만 적용됨)
- 로그인은 별도 메커니즘으로 동작할 수 있음 (하드코딩 또는 application.yml의 기본 관리자)

**확인 방법**:
```sql
SELECT * FROM cryptoments_db.admins;
```

**수정 방법**:

1. DDL에 admin seed 데이터가 있는지 확인:
```sql
-- 없으면 수동 INSERT
INSERT INTO admins (email, password_hash, name, role, status, created_at, updated_at)
VALUES ('admin@cryptoments.io', '$2a$10$...', 'admin', 'SUPER_ADMIN', 'ACTIVE', NOW(), NOW());
```

2. 로그인 시 admins 테이블을 조회하는지 확인. 만약 application.yml의 정적 설정으로 로그인하고 있다면, admins 테이블과 동기화 필요.

3. `AdminController`의 `getAdmins()` 메서드가 올바른 repository를 사용하는지 확인.

---

## 4. HIGH 이슈 수정 지침

### 4.1 전체 지갑 — 네트워크 컬럼 빈 칸

**현상**: /infra/global-wallets에서 네트워크 컬럼이 모든 행에서 빈 칸.

**원인**: `WalletSearchMapper`가 `wallet_addresses` 테이블만 SELECT하고 `blockchain_networks` JOIN이 없음.

파일: `admin-api/src/main/java/com/cryptoments/adminapi/mapper/WalletSearchMapper.java`

**수정 방법**: 전용 응답 DTO 생성 + 매퍼에 JOIN 추가

```java
// 신규 DTO: WalletListResponse.java
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class WalletListResponse {
    /** ID */
    private Long id;
    /** 네트워크 코드 */
    private String networkCode;
    /** 지갑 유형 */
    private WalletType walletType;
    /** 주소 */
    private String address;
    /** 파트너명 */
    private String partnerName;
    /** 파트너 사용자 ID */
    private String partnerUserId;
    /** USDT 잔액 */
    private BigDecimal usdtBalance;
    /** USDC 잔액 */
    private BigDecimal usdcBalance;
    /** HD Wallet ID */
    private Long hdWalletId;
    /** 파생 인덱스 */
    private Integer derivationIndex;
    /** 모니터 등록 여부 */
    private Boolean monitorRegistered;
    /** 상태 */
    private WalletAddressStatus status;
    /** 생성일시 */
    private LocalDateTime createdAt;
}
```

매퍼 SQL 수정:
```java
@Select("<script>" +
    "SELECT wa.id, bn.chain_symbol AS network_code, wa.wallet_type, wa.address, " +
    "  p.name AS partner_name, wa.partner_user_id, " +
    "  wa.hd_wallet_id, wa.derivation_index, wa.monitor_registered, " +
    "  wa.status, wa.created_at" +
    "  FROM wallet_addresses wa" +
    "  INNER JOIN blockchain_networks bn ON wa.network_id = bn.id" +
    "  LEFT JOIN partners p ON wa.partner_id = p.id" +
    " <where>" +
    "  <if test='walletType != null'>AND wa.wallet_type = #{walletType}</if>" +
    "  <if test='networkId != null'>AND wa.network_id = #{networkId}</if>" +
    "  <if test='address != null'>AND wa.address LIKE CONCAT('%', #{address}, '%')</if>" +
    "  <if test='status != null'>AND wa.status = #{status}</if>" +
    " </where>" +
    "</script>")
XPage<WalletListResponse> searchWallets(XPagination pagination, ...);
```

---

### 4.2 통화 관리 — 코드 컬럼 빈 칸

**현상**: /infra/currencies에서 "코드" 컬럼이 모든 행에서 빈 칸.

**원인**: 백엔드 `Currency` 엔티티의 필드명이 `symbol`인데, 프론트엔드는 `code` 필드를 기대.

**수정 방법 (2가지 중 택 1)**:

**방법 A (프론트엔드 수정 — 추천):**

파일: `admin-ui/src/views/infra/CurrencyListView.vue`
- 테이블 컬럼 바인딩에서 `code` → `symbol`로 변경

```vue
<!-- 변경 전 -->
<td>{{ item.code }}</td>
<!-- 변경 후 -->
<td>{{ item.symbol }}</td>
```

**방법 B (백엔드 수정):**

전용 응답 DTO 생성:
```java
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class CurrencyResponse {
    private Long id;
    private String code;        // entity의 symbol을 code로 매핑
    private String name;
    private CurrencyType currencyType;
    private Integer decimals;
    private Boolean isStablecoin;
    private Integer networkCount;  // 추가 필드
    private Integer displayOrder;
}
```

---

### 4.3 감사 로그 — 관리자명/IP 주소 빈 칸

**현상**: /system/audit-logs에서 "관리자" 컬럼과 "IP 주소" 컬럼이 빈 칸.

**원인**:
- `SystemSearchMapper`가 `admin_audit_logs` 테이블만 SELECT → `admins` 테이블 JOIN 없음 → adminName 없음
- `ipAddress` 필드는 엔티티에 존재하지만, audit log 기록 시 IP를 저장하지 않고 있을 수 있음

파일: `admin-api/src/main/java/com/cryptoments/adminapi/mapper/SystemSearchMapper.java`

**수정 방법**:

1. 전용 응답 DTO 생성:
```java
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class AuditLogResponse {
    /** ID */
    private Long id;
    /** 일시 */
    private LocalDateTime createdAt;
    /** 관리자명 */
    private String adminName;
    /** 액션 */
    private String action;
    /** 대상 유형 */
    private String targetType;
    /** 대상 ID */
    private Long targetId;
    /** 상세 */
    private String details;
    /** IP 주소 */
    private String ipAddress;
}
```

2. 매퍼 SQL에 JOIN 추가:
```java
@Select("<script>" +
    "SELECT al.id, al.created_at, a.name AS admin_name, " +
    "  al.action, al.target_type, al.target_id, al.details, al.ip_address" +
    "  FROM admin_audit_logs al" +
    "  LEFT JOIN admins a ON al.admin_id = a.id" +
    " <where>" +
    "  <if test='action != null'>AND al.action = #{action}</if>" +
    "  <if test='targetType != null'>AND al.target_type = #{targetType}</if>" +
    " </where>" +
    "</script>")
XPage<AuditLogResponse> searchAuditLogs(XPagination pagination, ...);
```

3. IP 주소 저장 — `AuditLogService.log()` 호출 시 `HttpServletRequest`에서 IP 추출:
```java
// AuditLogService.java
public void log(Long adminId, AuditAction action, Long targetId, Map<String, Object> details) {
    // ... 기존 로직 ...
    // IP 주소 추가
    HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
    String ipAddress = request.getHeader("X-Forwarded-For");
    if (ipAddress == null) ipAddress = request.getRemoteAddr();
    auditLog.setIpAddress(ipAddress);
}
```

---

## 5. MEDIUM 이슈 수정 지침

### 5.1 입금 조회 — 요약 카드 "-" 표시

**현상**: /deposits 페이지의 상단 4개 요약 카드 (오늘 입금, 대기 중, 확인 완료, 미식별)가 모두 "-" 표시.

**원인**: 입금 페이지 전용 summary API가 없거나, 프론트엔드가 대시보드 summary API를 재사용하되 필드 매핑이 다를 수 있음.

**확인 사항**:
- 프론트엔드에서 어떤 API를 호출하는지 확인 (deposit.service.ts)
- 입금 전용 summary 엔드포인트가 필요한지 확인

**수정 방법**:
- 프론트엔드에서 `/api/admin/deposits/summary` 같은 전용 API 호출 → 백엔드에 해당 엔드포인트 추가
- 또는 대시보드 summary 응답의 필드를 입금 카드에 올바르게 매핑

### 5.2 파트너 상세 > 체인/통화 설정 — 헤더 가독성

**현상**: 테이블 헤더에 "USDT"가 3번 반복되어 어떤 네트워크의 USDT인지 구분 불가.

**현재 헤더**: `Network | USDT | BNB | USDT | POL | USDT | TRX`

**개선안**: 2단 헤더 사용

```
         |    BSC      |   POLYGON   |    TRON     |
Network  | USDT | BNB  | USDT | POL  | USDT | TRX  |
```

**수정 파일**: `admin-ui/src/components/partner/ChainCurrencyConfig.vue` (또는 해당 컴포넌트)
- `<thead>`에 `colspan` 사용하여 네트워크별 그룹 헤더 추가

---

## 6. LOW 이슈 수정 지침

### 6.1 환율 — 토큰/통화 라벨 없음

**현상**: /gas/exchange-rates 카드에 어떤 토큰/통화의 환율인지 라벨이 없음.

**수정**: 각 카드 상단에 통화명 (BNB, POL, TRX, USDT(BSC), USDT(Polygon), USDT(TRON) 등) 표시.

---

## 7. 수정 우선순위 및 작업 계획

### Phase 1: CRITICAL (즉시 수정, 1일)

| # | 작업 | 파일 | 난이도 |
|---|------|------|--------|
| 1 | 컨트랙트 관리 — ContractDisplayResponse DTO + Service 변환 로직 | admin-api (Controller, Service, DTO) | 중 |
| 2 | 인프라 지갑 — InfraWalletResponse 3분할 잔액 + monitorRegistered | admin-api (DTO, Mapper, Service) | 중 |
| 3 | 관리자 계정 — admins 테이블 데이터 확인 + seed INSERT | DDL + DB | 하 |

### Phase 2: HIGH (1일)

| # | 작업 | 파일 | 난이도 |
|---|------|------|--------|
| 4 | 전체 지갑 — WalletListResponse DTO + Mapper JOIN 추가 | admin-api (DTO, Mapper, Service) | 중 |
| 5 | 통화 관리 — symbol → code 매핑 (프론트 수정) | admin-ui (CurrencyListView.vue) | 하 |
| 6 | 감사 로그 — AuditLogResponse DTO + admins JOIN + IP 저장 | admin-api (DTO, Mapper, Service) | 중 |

### Phase 3: MEDIUM/LOW (0.5일)

| # | 작업 | 파일 | 난이도 |
|---|------|------|--------|
| 7 | 입금 요약 카드 — summary API 확인/추가 | admin-api + admin-ui | 하 |
| 8 | 체인/통화 설정 — 2단 헤더 적용 | admin-ui (ChainCurrencyConfig) | 하 |
| 9 | 환율 카드 — 통화 라벨 추가 | admin-ui (ExchangeRateView) | 하 |

---

## 8. 백엔드 DTO 불일치 종합표

아래 표는 프론트엔드가 기대하는 필드와 백엔드가 실제 반환하는 필드의 차이를 정리한 것.

### 컨트랙트 (`/api/admin/contracts`)

| 프론트엔드 필드 | 백엔드 필드 | 상태 |
|---------------|-----------|------|
| id | id | 일치 |
| networkCode | networkId | 불일치 — 변환 필요 |
| contractAddress | contractAddress | 일치 |
| ownerAddress | ownerAddressId | 불일치 — ID→주소 변환 필요 |
| abiVersion | abiVersion | 일치 |
| status | status | 일치 |

### 인프라 지갑 (`/api/admin/infra-wallets`)

| 프론트엔드 필드 | 백엔드 필드 | 상태 |
|---------------|-----------|------|
| id | id | 일치 |
| networkCode | networkCode | 일치 |
| walletType | walletType | 일치 |
| address | address | 일치 |
| nativeBalance | balance (단일) | 불일치 — 3개 분리 필요 |
| usdtBalance | (없음) | 누락 |
| usdcBalance | (없음) | 누락 |
| monitorRegistered | (없음) | 누락 |
| status | status | 일치 |

### 전체 지갑 (`/api/admin/wallets`)

| 프론트엔드 필드 | 백엔드 필드 | 상태 |
|---------------|-----------|------|
| networkCode | networkId | 불일치 — JOIN 필요 |
| partnerName | partnerId | 불일치 — JOIN 필요 |
| usdtBalance | (없음) | 누락 |
| usdcBalance | (없음) | 누락 |

### 통화 (`/api/admin/currencies`)

| 프론트엔드 필드 | 백엔드 필드 | 상태 |
|---------------|-----------|------|
| code | symbol | 필드명 불일치 |
| networkCount | (없음) | 누락 |

### 감사 로그 (`/api/admin/audit-logs`)

| 프론트엔드 필드 | 백엔드 필드 | 상태 |
|---------------|-----------|------|
| adminName | adminId | 불일치 — JOIN 필요 |
| ipAddress | ipAddress | 필드 존재하나 값 미저장 |

---

## 9. 코딩 규칙 참고

- **MyBatis Mapper 규칙**: (1) 첫 번째 파라미터 `XPagination`, (2) 마지막 파라미터 `Class<?> cls`, (3) 반환 `XPage<T>`. ORDER BY/LIMIT/COUNT 직접 작성 금지.
- **DTO 멤버 변수**: 반드시 JavaDoc 주석 필수.
- **Entity 순수성**: Entity에 JOIN 데이터 추가 금지. 별도 Response DTO로 분리.
- **프론트엔드 → 백엔드 방향**: 프론트엔드의 기대 필드에 백엔드 DTO를 맞추는 것이 원칙 (프론트 구현 기준).
