# Guide #34: 출금 취소(Cancel) API 설계 및 구현 지침서

**작성일**: 2026-03-22
**상태**: 설계 완료 — IntelliJ 구현 대기
**버전**: v1.0

---

## 📋 개요

현재 `WithdrawalService.cancel()` 메서드는 **REQUESTED, PENDING_APPROVAL** 상태에서만 취소를 허용한다.
이 가이드는 다음을 구현한다:

1. **핵심 로직 확장**: `WithdrawalService.cancel()` → **FAILED 상태 추가 지원**
2. **파트너 전용 메서드**: `WithdrawalService.cancelByPartner()` → REQUESTED/PENDING_APPROVAL만 허용
3. **Partner API 엔드포인트**: `POST /api/partner/withdrawals/{withdrawalId}/cancel`
4. **Admin API 엔드포인트**: `POST /api/admin/withdrawals/{withdrawalId}/cancel`
5. **알림 확장**: `TelegramMessageFormatter.withdrawalCancelled()` 메서드 추가

---

## 🎯 핵심 설계 원칙

### 1. 동결(Freeze) 처리의 이해

**현재 구조** (SettlementService 분석):
- `freeze()`: 출금 요청 시 호출 — **현재는 검증만 수행, frozen_amount 컬럼 없음**
- `unfreeze()`: 취소/거부 시 호출 — **현재는 no-op (아무것도 하지 않음)**

**영향**: DDL v1.6에서 `frozen_amount` 컬럼이 없으므로, 취소 시에도 unfreeze 호출은 no-op이다.
하지만 **향후 DDL 개선**을 대비하여, **모든 상태 전환에서 unfreeze를 정확히 호출**한다.

### 2. 상태별 취소 규칙

| 상태 | Admin 가능 | Partner 가능 | 동작 | 주의 |
|------|-----------|-----------|------|------|
| REQUESTED | ✅ | ✅ | unfreeze 호출 | 금전 영향 없음 |
| PENDING_APPROVAL | ✅ | ✅ | unfreeze 호출 | 금전 영향 없음 |
| FAILED | ✅ | ❌ | unfreeze 호출 | **동결됨 → 해제 필수** |
| APPROVED+ | ❌ | ❌ | 불가 | TX 브로드캐스트 이후 |

### 3. 알림 전략

**취소 시 알림 발송 여부**:
- ✅ `notificationService.send()` 호출 → `WITHDRAWAL_CANCELLED` 이벤트
- ✅ Telegram 알림 → `withdrawalCancelled()` 메서드 추가
- 대상: Partner만 (Admin 취소는 감사 로그만)

---

## 🛠️ 구현 단계

### Step 1: core/withdrawal/WithdrawalService 확장

#### 1.1 기존 cancel() 메서드 수정

**파일**: `core/src/main/java/com/cryptoments/core/withdrawal/WithdrawalService.java`

**현재 코드** (169-190줄):
```java
public Withdrawal cancel(Long withdrawalId, String changedBy, ChangeSource changeSource) {
    Withdrawal withdrawal = findById(withdrawalId);

    if (withdrawal.getStatus() != WithdrawalStatus.REQUESTED
            && withdrawal.getStatus() != WithdrawalStatus.PENDING_APPROVAL) {
        throw new ConflictException(ErrorCodes.WITHDRAWAL_CANCELLATION_NOT_ALLOWED,
                "취소할 수 없는 상태입니다: " + withdrawal.getStatus());
    }
    // ...
}
```

**변경 사항**: FAILED 상태 추가

```java
/**
 * 출금 취소 (REQUESTED/PENDING_APPROVAL/FAILED → CANCELLED).
 * Admin은 모든 상태 취소 가능. Partner는 cancelByPartner() 사용.
 */
public Withdrawal cancel(Long withdrawalId, String changedBy, ChangeSource changeSource) {
    Withdrawal withdrawal = findById(withdrawalId);

    // Admin 전용: REQUESTED, PENDING_APPROVAL, FAILED 모두 가능
    if (withdrawal.getStatus() != WithdrawalStatus.REQUESTED
            && withdrawal.getStatus() != WithdrawalStatus.PENDING_APPROVAL
            && withdrawal.getStatus() != WithdrawalStatus.FAILED) {
        throw new ConflictException(ErrorCodes.WITHDRAWAL_CANCELLATION_NOT_ALLOWED,
                "취소할 수 없는 상태입니다: " + withdrawal.getStatus());
    }

    String previousStatus = withdrawal.getStatus().name();
    withdrawal.setStatus(WithdrawalStatus.CANCELLED);
    withdrawalRepository.modify(withdrawal);

    // 모든 상태에서 동결 해제 (향후 frozen_amount 도입 시 필수)
    settlementService.unfreeze(withdrawal.getPartnerId(), withdrawal.getCurrencyId(),
            withdrawal.getNetworkId(), withdrawal.getAmount(),
            LedgerReferenceType.WITHDRAWAL, withdrawalId);

    saveStatusHistory(withdrawalId, previousStatus, WithdrawalStatus.CANCELLED.name(),
            changedBy, changeSource, null);

    return withdrawal;
}
```

#### 1.2 새로운 cancelByPartner() 메서드 추가

**추가 위치**: `cancel()` 메서드 바로 다음 (194줄)

```java
/**
 * 출금 취소 (파트너 전용).
 * 파트너는 REQUESTED/PENDING_APPROVAL 상태만 취소 가능.
 * FAILED 상태는 Admin만 처리 가능.
 *
 * @param withdrawalId 출금 ID
 * @param partnerId 파트너 ID (소유권 검증용)
 * @throws NotFoundException 출금 미존재
 * @throws ConflictException 파트너 미매칭 또는 상태 불일치
 * @return 취소된 출금
 */
public Withdrawal cancelByPartner(Long withdrawalId, Long partnerId) {
    Withdrawal withdrawal = findById(withdrawalId);

    // 파트너 소유권 검증
    if (!partnerId.equals(withdrawal.getPartnerId())) {
        throw new ConflictException(ErrorCodes.UNAUTHORIZED,
                "파트너 ID 미매칭");
    }

    // 파트너: REQUESTED, PENDING_APPROVAL만 가능
    if (withdrawal.getStatus() != WithdrawalStatus.REQUESTED
            && withdrawal.getStatus() != WithdrawalStatus.PENDING_APPROVAL) {
        throw new ConflictException(ErrorCodes.WITHDRAWAL_CANCELLATION_NOT_ALLOWED,
                "취소 불가 상태입니다: " + withdrawal.getStatus() + " (REQUESTED, PENDING_APPROVAL만 가능)");
    }

    String previousStatus = withdrawal.getStatus().name();
    withdrawal.setStatus(WithdrawalStatus.CANCELLED);
    withdrawalRepository.modify(withdrawal);

    // 동결 해제
    settlementService.unfreeze(withdrawal.getPartnerId(), withdrawal.getCurrencyId(),
            withdrawal.getNetworkId(), withdrawal.getAmount(),
            LedgerReferenceType.WITHDRAWAL, withdrawalId);

    saveStatusHistory(withdrawalId, previousStatus, WithdrawalStatus.CANCELLED.name(),
            withdrawal.getPartnerId().toString(), ChangeSource.API, null);

    // 파트너 알림 발송
    if (withdrawal.getPartnerId() != null) {
        String webhookData = "{\"event\":\"WITHDRAWAL_CANCELLED\",\"withdrawalId\":" + withdrawalId + "}";
        String telegramMsg = TelegramMessageFormatter.withdrawalCancelled(
                withdrawal.getWithdrawalCode(), withdrawal.getAmount(), "USDT");
        notificationService.send(TransactionType.WITHDRAWAL, withdrawal.getPartnerId(),
                withdrawalId, webhookData, telegramMsg);
    }

    return withdrawal;
}
```

---

### Step 2: core/notification/TelegramMessageFormatter 확장

**파일**: `core/src/main/java/com/cryptoments/core/notification/TelegramMessageFormatter.java`

**추가 위치**: `withdrawalRejected()` 메서드 다음 (107줄)

```java
/** 출금 취소 알림 */
public static String withdrawalCancelled(String withdrawalCode, BigDecimal amount,
                                          String currency) {
    return """
            ⏹️ <b>출금 취소 완료</b>

            출금코드: <code>%s</code>
            금액: <b>%s %s</b>
            시각: %s

            ℹ️ 출금 요청이 취소되었습니다."""
            .formatted(withdrawalCode, amount.toPlainString(), currency,
                    LocalDateTime.now().format(DT_FORMAT));
}
```

---

### Step 3: partner-api 모듈 확장

#### 3.1 PartnerWithdrawalController 수정

**파일**: `partner-api/src/main/java/com/cryptoments/partnerapi/controller/PartnerWithdrawalController.java`

**추가 위치**: `updateWhitelist()` 메서드 다음 (154줄)

```java
/**
 * 출금 취소 (파트너 전용 — REQUESTED/PENDING_APPROVAL만 가능).
 *
 * @param withdrawalId 출금 ID
 * @return 취소된 출금
 * @response 200 취소 성공
 * @response 404 출금을 찾을 수 없음
 * @response 409 파트너 미매칭 또는 취소 불가 상태
 * @group 출금 관리
 * @auth true
 * @throws NotFoundException
 * @throws ConflictException
 */
@PostMapping(name = "출금 취소", value = "/{withdrawalId}/cancel")
public WithdrawalResponse cancelWithdrawal(@PathVariable Long withdrawalId) {
    Long partnerId = getSession().getPartnerId();
    return partnerWithdrawalService.cancelWithdrawal(partnerId, withdrawalId);
}
```

#### 3.2 PartnerWithdrawalService 수정

**파일**: `partner-api/src/main/java/com/cryptoments/partnerapi/service/PartnerWithdrawalService.java`

**추가 위치**: `updateWhitelist()` 메서드 다음 (143줄)

```java
/**
 * 출금 취소 (파트너 전용).
 * withdrawalId 소유권 검증 후 cancelByPartner() 호출.
 */
public WithdrawalResponse cancelWithdrawal(Long partnerId, Long withdrawalId) {
    // 소유권 검증: withdrawalDetail 조회로 partnerId 확인
    WithdrawalResponse detail = partnerWithdrawalMapper.findWithdrawalDetail(withdrawalId, partnerId);
    if (detail == null) {
        throw new NotFoundException(ErrorCodes.WITHDRAWAL_NOT_FOUND);
    }

    // core 서비스 호출 (파트너 전용 로직)
    withdrawalService.cancelByPartner(withdrawalId, partnerId);

    // 취소된 출금 상세 반환
    WithdrawalResponse updated = partnerWithdrawalMapper.findWithdrawalDetail(withdrawalId, partnerId);
    if (updated == null) {
        throw new NotFoundException(ErrorCodes.WITHDRAWAL_NOT_FOUND);
    }
    return updated;
}
```

---

### Step 4: admin-api 모듈 확장

#### 4.1 WithdrawalManagementController 수정

**파일**: `admin-api/src/main/java/com/cryptoments/adminapi/controller/WithdrawalManagementController.java`

**추가 위치**: `batchReject()` 메서드 다음 (189줄)

```java
/**
 * 출금 취소 (Admin 전용 — REQUESTED/PENDING_APPROVAL/FAILED 모두 가능).
 *
 * @param id 출금 ID
 * @param request 취소 사유 (선택사항)
 * @return 취소된 출금
 * @response 200 취소 성공
 * @response 404 출금을 찾을 수 없음
 * @response 409 취소 불가 상태
 * @group 출금 관리
 * @auth true
 * @throws NotFoundException
 * @throws ConflictException
 */
@PostMapping(name = "출금 취소", value = "/{id}/cancel")
public Withdrawal cancelWithdrawal(@PathVariable Long id,
                                   @Valid @RequestBody(required = false) WithdrawalCancelRequest request) {
    return withdrawalManagementService.cancelWithdrawal(id, request, getSession().getAdminId());
}
```

#### 4.2 새로운 Request DTO 생성

**파일**: `admin-api/src/main/java/com/cryptoments/adminapi/dto/request/WithdrawalCancelRequest.java`

**생성 내용**:

```java
package com.cryptoments.adminapi.dto.request;

import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.Size;

/**
 * 출금 취소 요청.
 */
public class WithdrawalCancelRequest {

    /**
     * 취소 사유 (선택사항).
     */
    @JsonProperty("reason")
    @Size(max = 500, message = "사유는 500자 이내여야 합니다.")
    private String reason;

    // ── Getters / Setters ──

    public String getReason() {
        return reason;
    }

    public void setReason(String reason) {
        this.reason = reason;
    }
}
```

#### 4.3 WithdrawalManagementService 수정

**파일**: `admin-api/src/main/java/com/cryptoments/adminapi/service/WithdrawalManagementService.java`

**import 추가** (줄 4):
```java
import com.cryptoments.adminapi.dto.request.WithdrawalCancelRequest;
```

**메서드 추가** (197줄 이후):

```java
/**
 * 출금 취소 (Admin 전용).
 * 모든 상태(REQUESTED/PENDING_APPROVAL/FAILED) 취소 가능.
 */
public Withdrawal cancelWithdrawal(Long withdrawalId, WithdrawalCancelRequest request, Long adminId) {
    // request 안전 처리
    String reason = (request != null && request.getReason() != null)
            ? request.getReason()
            : "Admin에 의한 취소";

    // core 서비스 호출 (Admin은 모든 상태 취소 가능)
    Withdrawal result = withdrawalService.cancel(withdrawalId, String.valueOf(adminId), ChangeSource.ADMIN);

    // 감사 로그
    auditLogService.log(adminId, AuditAction.CANCEL_WITHDRAWAL, withdrawalId,
            Map.of("previousStatus", result.getStatus().name(), "reason", reason));

    return result;
}
```

---

## 🔍 상태 전환 매트릭스

```
┌────────────────────────────────────────────────────────────────┐
│                  출금 상태 취소 정책                              │
├────────────────────────────────────────────────────────────────┤
│ 현재 상태         │  Admin  │ Partner │    동작                  │
├────────────────────────────────────────────────────────────────┤
│ REQUESTED        │   ✅    │   ✅    │ → CANCELLED + unfreeze   │
│ PENDING_APPROVAL │   ✅    │   ✅    │ → CANCELLED + unfreeze   │
│ FAILED           │   ✅    │   ❌    │ → CANCELLED + unfreeze   │
├────────────────────────────────────────────────────────────────┤
│ APPROVED+        │   ❌    │   ❌    │ 불가 (TX 브로드캐스트)   │
│ CANCELLED        │   ❌    │   ❌    │ 불가 (이미 취소)         │
│ REJECTED         │   ❌    │   ❌    │ 불가 (이미 거부)         │
└────────────────────────────────────────────────────────────────┘
```

---

## 📝 API 스펙

### 파트너 API

#### POST /api/partner/withdrawals/{withdrawalId}/cancel

**경로 파라미터**:
- `withdrawalId` (Long): 출금 ID

**요청 본문**: 없음

**응답** (200 OK):
```json
{
  "id": 123,
  "withdrawalCode": "wdr_2603_a1b2c3d4",
  "status": "CANCELLED",
  "amount": "1000.00",
  "currency": "USDT",
  "network": "BSC",
  "toAddress": "0x...",
  "createdAt": "2026-03-22T10:00:00Z",
  "cancelledAt": "2026-03-22T10:05:00Z"
}
```

**에러 응답**:
- `404 NOT_FOUND`: 출금 미존재 또는 파트너 미소유
- `409 CONFLICT`: 취소 불가 상태 (FAILED, APPROVED+, 이미 취소됨)

---

### Admin API

#### POST /api/admin/withdrawals/{withdrawalId}/cancel

**경로 파라미터**:
- `withdrawalId` (Long): 출금 ID

**요청 본문** (선택사항):
```json
{
  "reason": "관리자 취소 — 테스트 목적"
}
```

**응답** (200 OK):
```json
{
  "id": 123,
  "withdrawalCode": "wdr_2603_a1b2c3d4",
  "status": "CANCELLED",
  "amount": "1000.00",
  "currency": "USDT",
  "network": "BSC",
  "toAddress": "0x...",
  "createdAt": "2026-03-22T10:00:00Z",
  "cancelledAt": "2026-03-22T10:05:00Z"
}
```

**에러 응답**:
- `404 NOT_FOUND`: 출금 미존재
- `409 CONFLICT`: 취소 불가 상태 (APPROVED+, 이미 취소/거부됨)

---

## 🔐 검증 및 권한

### Partner 검증 규칙

| 항목 | 검증 | 설명 |
|------|------|------|
| 소유권 | ✅ 필수 | `withdrawal.partnerId == session.partnerId` |
| 상태 | ✅ 필수 | REQUESTED/PENDING_APPROVAL만 가능 |
| 이력 | ✅ 자동 | `transaction_status_history` 자동 기록 |

### Admin 검증 규칙

| 항목 | 검증 | 설명 |
|------|------|------|
| 권한 | ✅ 필수 | `session.adminId` 필수 |
| 상태 | ✅ 필수 | REQUESTED/PENDING_APPROVAL/FAILED 가능 |
| 감사 | ✅ 자동 | `AuditAction.CANCEL_WITHDRAWAL` 자동 기록 |

---

## 🔔 알림 흐름

### Partner 취소 시

```
PartnerWithdrawalService.cancelWithdrawal()
  ↓
WithdrawalService.cancelByPartner()
  ├─ 상태: REQUESTED/PENDING_APPROVAL → CANCELLED
  ├─ 동결 해제: settlementService.unfreeze()
  ├─ 이력 기록: saveStatusHistory()
  │
  └─ 🔔 파트너 알림 발송
      ├─ Webhook: {"event":"WITHDRAWAL_CANCELLED","withdrawalId":123}
      └─ Telegram: ⏹️ <b>출금 취소 완료</b>
         출금코드: wdr_2603_a1b2c3d4
         금액: 1000.00 USDT
         ...
```

### Admin 취소 시

```
WithdrawalManagementService.cancelWithdrawal()
  ↓
WithdrawalService.cancel()
  ├─ 상태: REQUESTED/PENDING_APPROVAL/FAILED → CANCELLED
  ├─ 동결 해제: settlementService.unfreeze()
  ├─ 이력 기록: saveStatusHistory()
  │
  └─ 📊 감사 로그만 기록
      └─ AuditAction.CANCEL_WITHDRAWAL
         previousStatus=FAILED, reason="..."
```

**주요 차이점**:
- **Partner**: 알림 발송 (webhookData + telegramMsg)
- **Admin**: 감사 로그만 (알림 미발송)

---

## ⚠️ 주의사항

### 1. 동결 해제 일관성

현재 DDL v1.6에서는 `frozen_amount` 컬럼이 없어 `unfreeze()` 호출이 no-op이다.
하지만 **반드시 모든 취소 로직에서 unfreeze()를 호출**해야 한다.
향후 DDL 개선 시 기능이 자동 활성화된다.

### 2. Partner 전용 메서드 분리

`cancelByPartner()` 메서드는 **Partner API에서만 호출**해야 한다.

```java
// ✅ 올바른 사용 (Partner)
withdrawalService.cancelByPartner(withdrawalId, partnerId);

// ❌ Admin이 cancelByPartner() 호출 금지
// Partner 검증 로직 우회되기 때문
```

### 3. 상태 검증 순서

```
1️⃣  출금 존재 확인 (findById)
2️⃣  권한/소유권 확인 (partnerId 또는 adminId)
3️⃣  상태 검증 (REQUESTED/PENDING_APPROVAL/FAILED)
4️⃣  상태 전환 + 저장
5️⃣  동결 해제
6️⃣  이력 기록
7️⃣  알림 발송 (Partner만)
8️⃣  감사 로그 (Admin만)
```

---

## 🧪 테스트 체크리스트

### Partner API 테스트

```bash
# ✅ 케이스 1: REQUESTED 상태 취소
POST /api/partner/withdrawals/1/cancel
→ 200 OK, status="CANCELLED"

# ✅ 케이스 2: PENDING_APPROVAL 상태 취소
POST /api/partner/withdrawals/2/cancel
→ 200 OK, status="CANCELLED"

# ❌ 케이스 3: FAILED 상태 취소 시도
POST /api/partner/withdrawals/3/cancel
→ 409 CONFLICT "취소 불가 상태입니다: FAILED"

# ❌ 케이스 4: APPROVED 상태 취소 시도
POST /api/partner/withdrawals/4/cancel
→ 409 CONFLICT "취소 불가 상태입니다: APPROVED"

# ❌ 케이스 5: 다른 파트너의 출금 취소 시도
POST /api/partner/withdrawals/999/cancel (partnerId 미소유)
→ 404 NOT_FOUND "출금을 찾을 수 없습니다"
```

### Admin API 테스트

```bash
# ✅ 케이스 1: REQUESTED 상태 취소
POST /api/admin/withdrawals/1/cancel
→ 200 OK, status="CANCELLED"

# ✅ 케이스 2: PENDING_APPROVAL 상태 취소
POST /api/admin/withdrawals/2/cancel
→ 200 OK, status="CANCELLED"

# ✅ 케이스 3: FAILED 상태 취소 (Admin 전용)
POST /api/admin/withdrawals/3/cancel
→ 200 OK, status="CANCELLED"

# ✅ 케이스 4: 사유 포함 취소
POST /api/admin/withdrawals/3/cancel
{
  "reason": "테스트 목적으로 취소"
}
→ 200 OK

# ❌ 케이스 5: APPROVED 상태 취소 시도
POST /api/admin/withdrawals/4/cancel
→ 409 CONFLICT "취소할 수 없는 상태입니다: APPROVED"
```

---

## 📊 영향 분석

### 변경 범위

| 모듈 | 파일 | 변경 사항 | 영향도 |
|------|------|----------|--------|
| core | WithdrawalService.java | `cancel()` 확장 + `cancelByPartner()` 추가 | 중간 |
| core | TelegramMessageFormatter.java | `withdrawalCancelled()` 추가 | 낮음 |
| partner-api | PartnerWithdrawalController.java | 엔드포인트 1개 추가 | 낮음 |
| partner-api | PartnerWithdrawalService.java | 메서드 1개 추가 | 낮음 |
| admin-api | WithdrawalManagementController.java | 엔드포인트 1개 추가 | 낮음 |
| admin-api | WithdrawalManagementService.java | 메서드 1개 추가 | 낮음 |
| admin-api | dto/request/ | WithdrawalCancelRequest.java 신규 | 낮음 |

### 기존 기능 호환성

- ✅ 기존 `cancel()` 호출 호환성 유지 (시그니처 동일)
- ✅ 기존 거부/승인/재시도 로직 미영향
- ✅ 기존 상태 이력 기록 체계 유지

---

## 🚀 배포 절차

### Phase 1: 코드 구현

1. **core 모듈**
   - `WithdrawalService`: `cancel()` 확장 + `cancelByPartner()` 추가
   - `TelegramMessageFormatter`: `withdrawalCancelled()` 추가

2. **partner-api 모듈**
   - `PartnerWithdrawalController`: POST `/cancel` 엔드포인트
   - `PartnerWithdrawalService`: `cancelWithdrawal()` 메서드

3. **admin-api 모듈**
   - `WithdrawalManagementController`: POST `/cancel` 엔드포인트
   - `WithdrawalManagementService`: `cancelWithdrawal()` 메서드
   - `WithdrawalCancelRequest` DTO 신규

### Phase 2: 테스트

- 단위 테스트: 각 메서드 정상 동작 검증
- 통합 테스트: API 엔드포인트 검증
- 상태 이력 검증: 취소 시 `transaction_status_history` 정확성
- 알림 검증: Webhook + Telegram 발송 확인

### Phase 3: 배포

1. 코드 리뷰
2. 테스트 환경 배포
3. Admin/Partner API 재시작
4. 프로덕션 배포 (하위 호환성 유지)

---

## 🔗 관련 문서

- `CRYPTOMENTS_V2_WITHDRAWAL_PROCESS.md`: 출금 전체 프로세스
- `CRYPTOMENTS_PARTNER_NOTIFICATION.md`: 파트너 알림 시스템
- `SPRING_AUDIT_LOG_REFACTOR_GUIDE.md`: 감사 로그 구조
- `SESSION_CONTEXT.md`: 현재 진행 상황 및 다음 단계

---

## ✅ 완료 체크리스트

- [ ] Step 1: `WithdrawalService` 확장 완료
- [ ] Step 2: `TelegramMessageFormatter` 확장 완료
- [ ] Step 3: `partner-api` 확장 완료
- [ ] Step 4: `admin-api` 확장 완료
- [ ] 컴파일 성공 (`./gradlew :admin-api:compileJava`)
- [ ] 단위 테스트 작성 및 통과
- [ ] 통합 테스트 작성 및 통과
- [ ] 코드 리뷰 완료
- [ ] 문서 업데이트 완료

---

**작성**: Cowork Session
**검토**: Pending
**상태**: 🟡 설계 완료 → 구현 대기
