# Axim REST Framework 개선 지침서

> **목적**: E2E 테스트 중 반복 발견된 프레임워크 레벨 이슈 2건 수정
> **날짜**: 2026-03-22
> **대상**: `rest-framework` (GitHub: `Axim-one/rest-framework`, 현재 v1.3.1 — 본 지침서는 v1.2.1 기준 개선안으로 v1.2.2에 반영 완료)
> **소스 위치**: `/Users/dudgh/git/project/framework/rest-framework`
> **발견 계기**: cryptoments v2 Phase 3 E2E 테스트 — save() 에러 디버깅에 매번 과도한 시간 소요

---

## 1. 문제 요약

### 1-1. save() 에러 메시지가 실제 원인을 숨김 (Critical)

**현상**: `IXRepository.save(entity)` 호출 시 DB 에러가 발생하면 항상 `"Could not determine save action"`으로 래핑됨.

**실제 발생한 사례들**:

| 실제 원인 | 로그에 표시된 메시지 | 디버깅 소요 |
|-----------|---------------------|-------------|
| `Data truncation: Out of range value for column 'amount'` | `Could not determine save action` | ~30분 |
| `Column 'retry_count' cannot be null` (추정) | `Could not determine save action` | ~20분 |
| SQL syntax error (컬럼 매핑 오류) | `Could not determine save action` | ~15분 |

**근본 원인**: `XRepositoryProxy.handleSave()` (line ~131)

```java
} catch (Exception e) {
    throw new RuntimeException("Could not determine save action", e);
}
```

모든 예외를 `RuntimeException`으로 래핑. `e.getMessage()`로 잡으면 항상 `"Could not determine save action"`만 보임.
`e.getCause()`를 파고들어야 실제 DB 에러를 알 수 있지만, 호출부에서 `e.getMessage()`만 로깅하는 경우가 대부분.

---

### 1-2. null 필드가 INSERT SQL에 포함됨 (Medium)

**현상**: Entity의 필드가 `null`이면 INSERT SQL에서 해당 컬럼을 **생략**하지 않고 `NULL` 값으로 **포함**함.
DDL에 `NOT NULL DEFAULT 0` 같은 기본값이 있어도, 명시적 `NULL` INSERT가 NOT NULL 제약을 위반.

**실제 발생 사례**:

```java
// CollectionQueue Entity
private Integer retryCount;  // Builder에서 설정 안 함 → null

// 생성된 SQL
INSERT INTO collection_queue (..., retry_count, ...) VALUES (..., NULL, ...)
// → MySQL: Column 'retry_count' cannot be null
```

DDL: `retry_count int NOT NULL DEFAULT 0`
- 컬럼 **생략** 시: MySQL이 DEFAULT 0 적용 → 정상
- 컬럼에 **NULL 명시** 시: NOT NULL 위반 → 에러

**근본 원인**: `CrudSqlProvider.buildInsert()` / `buildUpsert()`

```java
for (ColumnMetadata column : metadata.getInsertableColumns()) {
    if (column.isAutoIncrement()) continue;
    if (column.isDBDefaultUsed() && column.resolveInsertValue() == null) continue;
    // ↑ @XDefaultValue(isDBDefaultUsed=true) 인 경우만 스킵
    // ↑ 일반 null 필드는 스킵하지 않음 → NULL로 INSERT
    VALUES(column.getColumnName(), "#{model." + column.getFieldName() + "}");
}
```

---

## 2. 수정 사항

### 2-1. save() 에러 메시지 개선

**파일**: `mybatis/src/main/java/one/axim/framework/mybatis/proxy/XRepositoryProxy.java`

**변경 위치**: `handleSave()` 메서드의 catch 블록

**Before**:
```java
private Object handleSave(Object model) {
    try {
        // ... PK 판단 + INSERT/UPSERT 실행 ...
    } catch (Exception e) {
        throw new RuntimeException("Could not determine save action", e);
    }
}
```

**After**:
```java
private Object handleSave(Object model) {
    try {
        // ... PK 판단 + INSERT/UPSERT 실행 ...
    } catch (Exception e) {
        // 실제 원인 메시지를 포함하여 디버깅 용이성 확보
        String rootCauseMsg = extractRootCauseMessage(e);
        throw new RuntimeException(
                "Could not determine save action for " + entityMetadata.getTableName()
                        + ": " + rootCauseMsg, e);
    }
}

/**
 * 예외 체인에서 가장 깊은 원인 메시지를 추출.
 */
private String extractRootCauseMessage(Throwable e) {
    Throwable cause = e;
    while (cause.getCause() != null && cause.getCause() != cause) {
        cause = cause.getCause();
    }
    return cause.getMessage() != null ? cause.getMessage() : cause.getClass().getSimpleName();
}
```

**효과**:

| 상황 | Before | After |
|------|--------|-------|
| amount 오버플로우 | `Could not determine save action` | `Could not determine save action for deposits: Data truncation: Out of range value for column 'amount' at row 1` |
| NOT NULL 위반 | `Could not determine save action` | `Could not determine save action for collection_queue: Column 'retry_count' cannot be null` |
| 기타 SQL 에러 | `Could not determine save action` | `Could not determine save action for {table}: {실제 에러}` |

---

### 2-2. null 필드 INSERT 스킵 옵션

**파일**: `mybatis/src/main/java/one/axim/framework/mybatis/provider/CrudSqlProvider.java`

**방법 A (권장): INSERT 시 null 값 필드를 동적으로 스킵**

INSERT SQL을 빌드할 때 모델 객체의 **실제 값**을 확인하여 null이면 해당 컬럼을 생략.
이렇게 하면 DB의 DEFAULT 값이 자동 적용됨.

**Before** (`buildInsert()` 메서드):
```java
private String buildInsert(XMapperParameter parameter) {
    EntityMetadata metadata = getMetadata(parameter);
    return new SQL() {{
        INSERT_INTO(metadata.getTableName());
        for (ColumnMetadata column : metadata.getInsertableColumns()) {
            if (column.isAutoIncrement()) continue;
            if (column.isDBDefaultUsed() && column.resolveInsertValue() == null) continue;

            String value = column.resolveInsertValue();
            VALUES(column.getColumnName(), value != null ? value : "#{model." + column.getFieldName() + "}");
        }
    }}.toString();
}
```

**After**:
```java
private String buildInsert(XMapperParameter parameter) {
    EntityMetadata metadata = getMetadata(parameter);
    Object model = parameter.getModel();

    return new SQL() {{
        INSERT_INTO(metadata.getTableName());
        for (ColumnMetadata column : metadata.getInsertableColumns()) {
            if (column.isAutoIncrement()) continue;
            if (column.isDBDefaultUsed() && column.resolveInsertValue() == null) continue;

            // null 필드 스킵 — DB DEFAULT 값 활용
            if (model != null && isFieldNull(model, column)) continue;

            String value = column.resolveInsertValue();
            VALUES(column.getColumnName(), value != null ? value : "#{model." + column.getFieldName() + "}");
        }
    }}.toString();
}

/**
 * 엔티티 모델의 특정 필드 값이 null인지 확인.
 */
private boolean isFieldNull(Object model, ColumnMetadata column) {
    try {
        Object value = column.getPropertyDescriptor().getReadMethod().invoke(model);
        return value == null;
    } catch (Exception e) {
        return false; // 리플렉션 실패 시 안전하게 포함
    }
}
```

> **주의**: `buildUpsert()`에도 동일한 null 스킵 로직을 적용해야 합니다.

**방법 B (보수적): @XColumn에 skipIfNull 속성 추가**

기존 동작을 유지하면서, 명시적으로 null 스킵을 원하는 컬럼만 옵트인.

```java
// XColumn 어노테이션에 속성 추가
public @interface XColumn {
    // ... 기존 속성 ...
    boolean skipIfNull() default false;  // true이면 INSERT 시 null이면 스킵
}
```

```java
// CrudSqlProvider에서 체크
if (column.isSkipIfNull() && isFieldNull(model, column)) continue;
```

```java
// Entity에서 사용
@XColumn(value = "retry_count", skipIfNull = true)
private Integer retryCount;
```

---

## 3. 적용 우선순위

| # | 수정 | 난이도 | 영향 범위 | 우선순위 |
|---|------|--------|-----------|----------|
| 1 | save() 에러 메시지 개선 (2-1) | 낮음 | 전체 save() 호출 | **P0** — 즉시 |
| 2-A | null 필드 INSERT 스킵 (방법 A) | 중간 | 전체 INSERT/UPSERT | P1 — 사이드이펙트 검증 필요 |
| 2-B | @XColumn skipIfNull 속성 (방법 B) | 중간 | 옵트인 컬럼만 | P1 — 안전하지만 적용 범위 제한 |

**권장**: 2-1은 즉시 적용. 2-2는 방법 A가 이상적이나, 기존 프로젝트 호환성 우려 시 방법 B로 시작.

---

## 4. 적용 체크리스트

| # | 작업 | 파일 |
|---|------|------|
| 1 | handleSave() catch 블록 수정 + extractRootCauseMessage() 추가 | `XRepositoryProxy.java` |
| 2 | buildInsert()에 null 스킵 로직 추가 | `CrudSqlProvider.java` |
| 3 | buildUpsert()에 동일 null 스킵 로직 추가 | `CrudSqlProvider.java` |
| 4 | 단위 테스트 — null 필드가 있는 Entity save() 검증 | 테스트 코드 |
| 5 | 단위 테스트 — DB 에러 시 메시지에 root cause 포함 확인 | 테스트 코드 |
| 6 | `./gradlew :mybatis:build` | - |
| 7 | 버전 올리기 (1.2.1 → 1.2.2) | `build.gradle` |
| 8 | JitPack 배포 또는 로컬 publishToMavenLocal | - |
| 9 | cryptoments `build.gradle`에서 버전 업데이트 | `build.gradle` |

---

## 5. 검증

### 5-1. 에러 메시지 개선 검증

```java
// NOT NULL 컬럼에 null 값으로 save() 호출
CollectionQueue queue = CollectionQueue.builder()
        .collectionCode("test")
        .walletAddressId(1L)
        .partnerId(1L)
        .networkId(1L)
        .currencyId(1L)
        .amount(BigDecimal.ONE)
        .collectionMode(CollectionMode.IMMEDIATE)
        .status(CollectionStatus.QUEUED)
        // retryCount 생략 — null
        .build();
collectionQueueRepository.save(queue);

// Before: "Could not determine save action"
// After:  "Could not determine save action for collection_queue: Column 'retry_count' cannot be null"
```

### 5-2. null 스킵 검증 (방법 A 적용 시)

```java
// 동일 테스트 — retryCount null이지만 INSERT SQL에서 제외됨
// DDL의 DEFAULT 0이 적용되어 정상 INSERT
// 결과: retry_count = 0 (DB DEFAULT)
```

---

## 6. 참고: 현재 cryptoments에서의 임시 대응

프레임워크 수정 전까지, Entity 레벨에서 `@Builder.Default`로 NOT NULL 컬럼에 기본값을 명시하여 우회:

```java
// CollectionQueue.java — 이미 적용됨
@Builder.Default
private Integer retryCount = 0;
```

이 방법은 개별 Entity마다 NOT NULL DEFAULT 컬럼을 찾아 `@Builder.Default`를 붙여야 하므로 누락 위험이 있음.
프레임워크 레벨 수정(2-2)이 근본 해결책.
