- 개발팀 전용 폴더 dev/ 생성 (standards, guides, quickstart, changes, deploys, data, history, dev_plans 이동) - 프론트엔드 전용 폴더 frontend/ 생성 (api/ → frontend/api-specs/) - 기획팀 폴더 requests/ 생성 - plans/ → dev/dev_plans/ 이름 변경 - README.md 신규 (사람용 안내), INDEX.md 재작성 (Claude Code용) - resources.md 신규 (노션 링크용, assets/brochure 이관 예정) - CURRENT_WORKS.md 삭제, TODO.md → dev/ 이동 - 전체 참조 경로 업데이트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
405 lines
11 KiB
Markdown
405 lines
11 KiB
Markdown
# JSON options 컬럼 표준 정책
|
|
|
|
> **작성일**: 2026-02-27
|
|
> **상태**: 설계 확정
|
|
> **적용 범위**: 모든 비즈니스 테이블 신규 생성 및 확장
|
|
|
|
---
|
|
|
|
## 1. 개요
|
|
|
|
### 1.1 목적
|
|
|
|
멀티테넌시 환경에서 테넌트별 스키마 변경(ALTER TABLE) 없이 유연하게 속성을 확장하기 위한 `options` JSON 컬럼의 표준 사용 정책을 정의한다.
|
|
|
|
### 1.2 핵심 원칙
|
|
|
|
> **FK/조인키만** 테이블 컬럼으로 추가한다.
|
|
> **나머지 속성은** `options` JSON에 저장한다.
|
|
|
|
---
|
|
|
|
## 2. 컬럼 분류 기준
|
|
|
|
### 2.1 전용 컬럼으로 만들어야 하는 경우
|
|
|
|
```
|
|
✅ FK/조인키 (다른 테이블 참조)
|
|
✅ WHERE 조건에 자주 사용되는 필터 (status, is_active)
|
|
✅ ORDER BY 정렬 대상 (sort_order, created_at)
|
|
✅ UNIQUE 제약이 필요한 필드 (code, number)
|
|
✅ INDEX가 필요한 고빈도 조회 필드
|
|
✅ 집계/통계 대상 (SUM, AVG 등)
|
|
```
|
|
|
|
### 2.2 options JSON에 넣어야 하는 경우
|
|
|
|
```
|
|
✅ 테넌트별로 다를 수 있는 확장 속성
|
|
✅ 선택적(nullable) 부가 정보
|
|
✅ 구조가 유동적인 데이터 (키-값 쌍이 변할 수 있음)
|
|
✅ 드롭다운 선택지 목록
|
|
✅ 중첩 구조 (배열, 객체)
|
|
✅ 이력/스냅샷성 데이터 (취소 사유, 변환 전 원본 등)
|
|
```
|
|
|
|
### 2.3 판단 흐름도
|
|
|
|
```
|
|
새 필드 추가 필요?
|
|
├── FK/조인? ──────────────────→ 전용 컬럼
|
|
├── WHERE/ORDER BY 필수? ──────→ 전용 컬럼
|
|
├── UNIQUE 제약 필요? ─────────→ 전용 컬럼
|
|
├── 집계(SUM/AVG) 대상? ──────→ 전용 컬럼
|
|
└── 그 외 ─────────────────────→ options JSON
|
|
```
|
|
|
|
> **참고**: MySQL 8.0은 `options->key` 경로로 JSON 내부 필드를 쿼리할 수 있다. 저빈도 필터링은 JSON 경로 쿼리로 충분하다.
|
|
|
|
---
|
|
|
|
## 3. 마이그레이션 표준
|
|
|
|
### 3.1 신규 테이블 생성 시
|
|
|
|
모든 비즈니스 테이블에 `options` 컬럼을 기본 포함한다.
|
|
|
|
```php
|
|
Schema::create('example_table', function (Blueprint $table) {
|
|
$table->id();
|
|
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
|
|
|
// --- 전용 컬럼 (FK, 필터, 정렬, 유니크) ---
|
|
$table->string('code', 50)->comment('코드');
|
|
$table->string('status', 20)->default('draft')->comment('상태');
|
|
$table->boolean('is_active')->default(true)->comment('활성 여부');
|
|
$table->integer('sort_order')->default(0)->comment('정렬 순서');
|
|
|
|
// --- options JSON (확장 속성) ---
|
|
$table->json('options')->nullable()->comment('추가 옵션');
|
|
|
|
// --- 감사 컬럼 ---
|
|
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
|
|
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
|
|
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자');
|
|
$table->timestamps();
|
|
$table->softDeletes();
|
|
});
|
|
```
|
|
|
|
### 3.2 기존 테이블에 options 추가 시
|
|
|
|
```php
|
|
Schema::table('existing_table', function (Blueprint $table) {
|
|
$table->json('options')->nullable()->after('remark')->comment('추가 옵션');
|
|
});
|
|
```
|
|
|
|
### 3.3 선언 규칙
|
|
|
|
| 항목 | 표준 |
|
|
|------|------|
|
|
| 컬럼명 | `options` (고정) |
|
|
| 타입 | `json` |
|
|
| nullable | `nullable()` (필수) |
|
|
| comment | 용도 설명 포함 |
|
|
| 위치 | 비즈니스 컬럼 이후, 감사 컬럼 이전 |
|
|
|
|
---
|
|
|
|
## 4. 모델 표준
|
|
|
|
### 4.1 기본 선언 (필수)
|
|
|
|
모든 모델에 공통 적용한다.
|
|
|
|
```php
|
|
class ExampleModel extends Model
|
|
{
|
|
protected $fillable = [
|
|
// ... 전용 컬럼들
|
|
'options',
|
|
'created_by',
|
|
'updated_by',
|
|
'deleted_by',
|
|
];
|
|
|
|
protected $casts = [
|
|
'options' => 'array', // ✅ 'array' 통일 (❌ 'json' 사용 금지)
|
|
];
|
|
}
|
|
```
|
|
|
|
> **주의**: `'array'`와 `'json'` cast의 차이
|
|
> - `'array'` → PHP 배열로 변환 (표준)
|
|
> - `'json'` → JSON 문자열로 유지
|
|
> - 프로젝트 전체에서 `'array'`로 통일한다.
|
|
|
|
### 4.2 헬퍼 메서드 (필수)
|
|
|
|
`options`를 사용하는 모든 모델에 아래 2개 메서드를 추가한다.
|
|
|
|
```php
|
|
/**
|
|
* options에서 값 조회 (점표기 지원)
|
|
*/
|
|
public function getOption(string $key, mixed $default = null): mixed
|
|
{
|
|
return data_get($this->options, $key, $default);
|
|
}
|
|
|
|
/**
|
|
* options에 값 설정 (점표기 지원)
|
|
*/
|
|
public function setOption(string $key, mixed $value): static
|
|
{
|
|
$options = $this->options ?? [];
|
|
data_set($options, $key, $value);
|
|
$this->options = $options;
|
|
|
|
return $this;
|
|
}
|
|
```
|
|
|
|
> **`data_get`/`data_set`을 사용하는 이유**: 중첩 키를 점표기(`meta.color`)로 접근할 수 있다.
|
|
|
|
### 4.3 키 상수 정의 (권장)
|
|
|
|
options에 3개 이상의 키를 사용하면 상수로 정의한다.
|
|
|
|
```php
|
|
// Options 키 상수
|
|
public const OPTION_MANUFACTURER = 'manufacturer';
|
|
public const OPTION_INSPECTION_STATUS = 'inspection_status';
|
|
public const OPTION_INSPECTION_DATE = 'inspection_date';
|
|
```
|
|
|
|
### 4.4 Accessor + $appends (API 노출 시)
|
|
|
|
options 내부 값을 API 응답에 **일급 필드**로 노출해야 할 때 적용한다.
|
|
|
|
```php
|
|
protected $appends = [
|
|
'manufacturer',
|
|
'inspection_status',
|
|
];
|
|
|
|
public function getManufacturerAttribute(): ?string
|
|
{
|
|
return $this->getOption(self::OPTION_MANUFACTURER);
|
|
}
|
|
|
|
public function getInspectionStatusAttribute(): ?string
|
|
{
|
|
return $this->getOption(self::OPTION_INSPECTION_STATUS);
|
|
}
|
|
```
|
|
|
|
### 4.5 구현 수준 가이드
|
|
|
|
상황에 따라 적절한 수준을 선택한다.
|
|
|
|
| 수준 | 적용 조건 | 구현 항목 |
|
|
|------|----------|----------|
|
|
| **L1 기본** | options 키 1~2개, 내부 사용 | cast + `getOption`/`setOption` |
|
|
| **L2 상수** | options 키 3개 이상 | L1 + `OPTION_*` 상수 |
|
|
| **L3 노출** | API 응답에 옵션값 포함 | L2 + accessor + `$appends` |
|
|
| **L4 쿼리** | options 키로 필터/정렬 필요 | L3 + JSON 스코프 |
|
|
|
|
---
|
|
|
|
## 5. 서비스/컨트롤러 사용 패턴
|
|
|
|
### 5.1 저장 시 (Create/Update)
|
|
|
|
```php
|
|
// 서비스에서 options 구성
|
|
$options = [];
|
|
if ($data['manufacturer'] ?? null) {
|
|
$options[Receiving::OPTION_MANUFACTURER] = $data['manufacturer'];
|
|
}
|
|
if ($data['inspection_status'] ?? null) {
|
|
$options[Receiving::OPTION_INSPECTION_STATUS] = $data['inspection_status'];
|
|
}
|
|
|
|
Receiving::create([
|
|
'tenant_id' => $tenantId,
|
|
// ... 전용 컬럼
|
|
'options' => $options ?: null, // 빈 배열이면 null 저장
|
|
]);
|
|
```
|
|
|
|
### 5.2 수정 시 (기존 options 유지)
|
|
|
|
```php
|
|
// ✅ 올바른 방법: 기존 options를 유지하면서 특정 키만 변경
|
|
$model->setOption('cancelled_at', now()->toIso8601String());
|
|
$model->setOption('cancelled_by', $userId);
|
|
$model->save();
|
|
|
|
// ❌ 잘못된 방법: options 전체를 덮어씀 (기존 키 소실)
|
|
$model->options = ['cancelled_at' => now()];
|
|
$model->save();
|
|
```
|
|
|
|
### 5.3 조회 시 (읽기)
|
|
|
|
```php
|
|
// 헬퍼 사용 (권장)
|
|
$manufacturer = $receiving->getOption('manufacturer');
|
|
|
|
// 중첩 키 (점표기)
|
|
$lotNo = $workOrderItem->getOption('result.lot_no');
|
|
|
|
// 기본값 지정
|
|
$status = $receiving->getOption('inspection_status', '-');
|
|
```
|
|
|
|
### 5.4 중첩 키 구조 (도메인 분리)
|
|
|
|
하나의 options에 여러 도메인 데이터를 저장할 때 최상위 키로 그룹핑한다.
|
|
|
|
```php
|
|
// 작업 결과 + 검사 데이터 + 동적 BOM을 하나의 options에
|
|
$item->setOption('result', [
|
|
'good_qty' => 100,
|
|
'defect_qty' => 2,
|
|
'lot_no' => 'LOT-2026-001',
|
|
'completed_at' => now()->toIso8601String(),
|
|
]);
|
|
|
|
$item->setOption('inspection_data', [
|
|
'process_type' => 'bending',
|
|
'inspector_id' => 5,
|
|
]);
|
|
|
|
$item->save();
|
|
```
|
|
|
|
---
|
|
|
|
## 6. MySQL JSON 경로 쿼리
|
|
|
|
### 6.1 기본 필터링
|
|
|
|
```php
|
|
// 단순 키 비교
|
|
->where('options->manufacturer', '삼성전자')
|
|
|
|
// 중첩 키 비교
|
|
->where('options->result->worker_id', $workerId)
|
|
|
|
// NULL 체크
|
|
->whereNotNull('options->result')
|
|
->whereNull('options->cancelled_at')
|
|
```
|
|
|
|
### 6.2 LIKE 검색
|
|
|
|
```php
|
|
->where('options->result->lot_no', 'like', "LOT-2026-%")
|
|
```
|
|
|
|
### 6.3 정렬
|
|
|
|
```php
|
|
->orderByDesc('options->result->completed_at')
|
|
```
|
|
|
|
### 6.4 JSON 스코프 (L4)
|
|
|
|
자주 사용하는 JSON 필터는 스코프로 정의한다.
|
|
|
|
```php
|
|
// 모델에 스코프 정의
|
|
public function scopeHasResult($query)
|
|
{
|
|
return $query->whereNotNull('options->result');
|
|
}
|
|
|
|
public function scopeByProcessType($query, string $type)
|
|
{
|
|
return $query->where('options->inspection_data->process_type', $type);
|
|
}
|
|
|
|
// 사용
|
|
WorkOrderItem::hasResult()->byProcessType('bending')->get();
|
|
```
|
|
|
|
### 6.5 성능 주의사항
|
|
|
|
```
|
|
⚠️ JSON 경로 쿼리는 인덱스를 사용하지 않는다.
|
|
⚠️ 대용량 테이블에서 고빈도 필터링이 필요하면 전용 컬럼으로 승격한다.
|
|
⚠️ Generated Column + INDEX로 JSON 필드에 인덱스를 걸 수 있지만, 필요시에만 적용한다.
|
|
```
|
|
|
|
---
|
|
|
|
## 7. 유사 JSON 컬럼과의 구분
|
|
|
|
`options` 외에 용도별로 분화된 JSON 컬럼을 사용한다.
|
|
|
|
| 컬럼명 | 용도 | 사용 조건 |
|
|
|--------|------|----------|
|
|
| `options` | 범용 확장 속성 | 기본 (모든 테이블) |
|
|
| `attributes` | EAV 대체 동적 필드값 | Item, Product 등 카탈로그성 엔티티 |
|
|
| `metadata` | 감사/관계 메타 정보 | 감사 로그, 이벤트 로그 |
|
|
| `settings` | 설정값 | 알림, 환경 설정 |
|
|
| `bom` | BOM 구성품 배열 | 제품/품목 |
|
|
|
|
> **원칙**: 하나의 테이블에 JSON 컬럼이 2개 이상이면, 각 컬럼의 역할을 comment에 명확히 구분한다.
|
|
|
|
---
|
|
|
|
## 8. 드롭다운 선택지 패턴
|
|
|
|
필드 정의 테이블에서 선택지 목록을 options에 저장하는 패턴이다.
|
|
|
|
```php
|
|
// 마이그레이션
|
|
$table->json('options')->nullable()->comment('드롭다운 옵션 [{label, value}]');
|
|
|
|
// 저장 구조
|
|
[
|
|
{"label": "블라인드", "value": "blind"},
|
|
{"label": "스크린", "value": "screen"},
|
|
{"label": "셔터", "value": "shutter"}
|
|
]
|
|
```
|
|
|
|
적용 테이블: `item_master_fields`, `item_fields`, `category_fields`, `document_template_section_fields`
|
|
|
|
---
|
|
|
|
## 9. 체크리스트
|
|
|
|
### 신규 테이블 생성 시
|
|
|
|
- [ ] `$table->json('options')->nullable()->comment('...')` 포함
|
|
- [ ] 비즈니스 컬럼 이후, 감사 컬럼 이전 위치
|
|
- [ ] 모델에 `'options' => 'array'` cast 선언
|
|
- [ ] 모델에 `getOption()` / `setOption()` 헬퍼 추가
|
|
- [ ] options 키 3개 이상 시 `OPTION_*` 상수 정의
|
|
|
|
### options 키 추가 시
|
|
|
|
- [ ] 기존 options 구조와 키 충돌 없는지 확인
|
|
- [ ] 서비스에서 `setOption()` 사용 (직접 배열 덮어쓰기 금지)
|
|
- [ ] API 응답 노출 필요 시 accessor + `$appends` 추가
|
|
- [ ] 고빈도 필터링 필요 시 전용 컬럼 승격 검토
|
|
|
|
---
|
|
|
|
## 관련 문서
|
|
|
|
| 문서 | 설명 |
|
|
|------|------|
|
|
| [system/database/README.md](/home/aweso/sam/docs/system/database/README.md) | DB 스키마 현황 및 공통 패턴 |
|
|
| [guides/PROJECT_DEVELOPMENT_POLICY.md](/home/aweso/sam/docs/guides/PROJECT_DEVELOPMENT_POLICY.md) | 개발 공통 정책 |
|
|
| [standards/api-rules.md](/home/aweso/sam/docs/standards/api-rules.md) | API 개발 규칙 |
|
|
|
|
---
|
|
|
|
**최종 업데이트**: 2026-02-27
|