> ## Content Index
> Fetch the complete content index at: https://mlog.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# PostgreSQL 동적 컬럼 조회·추가·삭제: Spring Boot·Vue·IBSheet 실전 구조
- URL: https://mlog.me/postgresql-dynamic-columns-spring-boot-vue-ibsheet/
- Published: 2026-08-19T13:30:12.000Z
- Updated: 2026-09-09T13:07:11.000Z
- Description: PostgreSQL에서 0~50개의 가변 컬럼을 행·JSON으로 조회하고, Spring Boot에서 조합해 Vue와 IBSheet에서 추가·조회·삭제하는 방법을 정리합니다.
- Author: mLog
- Tags: 개발, Spring Boot, PostgreSQL, #Vue.js, #IBSheet

`code`, `test`, `gbn`은 항상 존재하지만, `1`, `2`, `3`처럼 다른 테이블의 값에 따라 열이 0개에서 최대 50개까지 달라지는 화면이 필요했다. 처음에는 PostgreSQL이 조회할 때마다 실제 결과 컬럼을 동적으로 만들어 주는 방식을 떠올리기 쉽다.

하지만 화면용 API라면 구조를 조금 다르게 잡는 편이 단순하다.

> PostgreSQL에는 고정 데이터와 가변 값을 세로형 행으로 저장하고, Spring Boot가 이를 화면용 행으로 조합한 뒤, Vue나 IBSheet가 응답의 컬럼 목록을 기준으로 열을 만든다.

이 구조를 사용하면 동적 컬럼이 0개, 1개, 50개인 경우를 같은 API 계약으로 처리할 수 있다.

## 어떤 문제였나

고정 컬럼은 다음 세 개다.

- `code`
- `test`
- `gbn`

동적 컬럼 개수는 다른 테이블 조회 결과에 따라 0개부터 최대 50개까지 달라지며, 화면 제목은 `1`, `2`, `3`처럼 구성된다. 예를 들어 동적 컬럼이 세 개라면 화면은 다음처럼 보여야 한다.

```text
code | test | gbn | 1    | 2    | 3
a    | 1    | a   | 0000 | 0010 | 0020
b    | 1    | a   | 0010 | 0020 | 0030
c    | 1    | a   | 0020 | 0030 | 0040

```

여기서 중요한 점은 `1`, `2`, `3`이 PostgreSQL 테이블에 계속 추가되는 실제 열이 아니라, 행으로 저장된 가변 항목을 화면에서 열처럼 펼친 결과라는 것이다.

## 권장 데이터 구조

예제에서는 기본 행과 동적 값을 분리한다. 실제 프로젝트에서는 테이블명과 조인 조건을 업무 키에 맞게 바꾸면 된다.

```sql
CREATE TABLE base_item (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code text NOT NULL,
    test text NOT NULL,
    gbn  text NOT NULL
);

CREATE TABLE base_item_value (
    base_id bigint NOT NULL
        REFERENCES base_item(id) ON DELETE CASCADE,
    col_no smallint NOT NULL
        CHECK (col_no BETWEEN 1 AND 50),
    val text,
    PRIMARY KEY (base_id, col_no)
);

```

`0000`, `0010`처럼 앞자리 0을 보존해야 하므로 예제의 `val`은 숫자형이 아니라 `text`로 두었다. `(base_id, col_no)`를 기본 키로 설정한 이유도 중요하다. 같은 행의 같은 동적 컬럼에 값이 두 개 생기면 집계 과정에서 데이터 오류가 가려질 수 있기 때문이다.

## PostgreSQL에서는 세로형으로 조회한다

가장 단순하고 범용적인 조회 결과는 다음과 같다.

```sql
SELECT
    b.id AS base_id,
    b.code,
    b.test,
    b.gbn,
    v.col_no,
    v.val
FROM base_item b
LEFT JOIN base_item_value v
       ON v.base_id = b.id
ORDER BY b.id, v.col_no;

```

동적 값이 없는 기본 행도 반환해야 하므로 `LEFT JOIN`을 사용했다. Spring Boot는 이 결과를 한 번 순회하면서 같은 `base_id`끼리 묶고, `col_no`를 JSON 필드명으로 바꾼다.

### JSONB 한 열로 묶는 방법

DB에서 가변 값을 JSON 객체 하나로 묶어서 받고 싶다면 `jsonb_object_agg`를 사용할 수 있다.

```sql
SELECT
    b.id,
    b.code,
    b.test,
    b.gbn,
    COALESCE(
        jsonb_object_agg(
            v.col_no::text,
            v.val
            ORDER BY v.col_no
        ) FILTER (WHERE v.col_no IS NOT NULL),
        '{}'::jsonb
    ) AS dynamic_values
FROM base_item b
LEFT JOIN base_item_value v
       ON v.base_id = b.id
GROUP BY b.id, b.code, b.test, b.gbn
ORDER BY b.id;

```

결과의 `dynamic_values`는 다음 형태가 된다.

```json
{
  "1": "0000",
  "2": "0010",
  "3": "0020"
}

```

동적 값이 없으면 일반 집계 함수가 `NULL`을 반환할 수 있으므로 빈 객체가 필요한 API에서는 `COALESCE(..., '{}'::jsonb)`를 사용한다. 키가 없는 `LEFT JOIN` 행은 `FILTER`로 제외한다.

JSON 객체의 키 순서를 화면 컬럼 순서로 믿어서는 안 된다. 화면 순서는 별도의 `columns` 배열을 숫자 기준으로 정렬해 관리하는 것이 안전하다.

## 추가 실행 검증: 0개·50개 컬럼과 빈 값의 차이

2026년 9월 9일, 본문의 두 테이블 DDL과 세로형 조회·JSONB 집계 SQL을 별도 환경에서 그대로 실행했다. 환경은 Node.js 24.19.0과 PGlite 0.5.8에 포함된 PostgreSQL 18.3(WebAssembly·메모리 DB)이다. **DB의 저장·조회 결과를 확인한 실험이며, Spring Boot·Vue·IBSheet를 기동한 통합 테스트는 아니다.**

기본 행 3개를 만들고 동적 값을 전혀 넣지 않은 상태를 먼저 조회했다. 이후 한 행에는 4개, 다른 행에는 50개의 값을 넣어 다시 확인했다.

| 입력·검증 조건                | 실제 결과                                    |
| ----------------------- | ---------------------------------------- |
| 모든 기본 행의 동적 값이 0개       | 기본 행 3개 유지, JSONB 값은 각각 {}               |
| 컬럼을 50, 10, 2, 1 순서로 삽입 | 세로형 SQL의 조회 순서는 1, 2, 10, 50             |
| 한 행에 1\~50번 값을 모두 저장    | JSONB에 키 50개, 첫 값 "0001"·마지막 값 "0050" 보존 |
| 같은 행의 1번 컬럼 재삽입         | 기본 키 위반 23505, 기존 값 유지                   |
| 컬럼 번호 0 또는 51 삽입        | CHECK 위반 23514, 두 건 모두 거부                |
| 존재하지 않는 기본 행에 값 삽입      | 외래 키 위반 23503                            |

특히 4개 값을 넣은 행의 JSONB 결과는 다음과 같았다.

```json
{"1":"0000","2":null,"10":"NULL","50":""}

```

1번은 앞자리 0이 있는 문자열, 2번은 SQL `NULL`, 10번은 글자 그대로의 문자열 `"NULL"`, 50번은 빈 문자열이다. **2번 키는 값이 null이어도 존재하지만, 저장하지 않은 3번 키는 없었다.** 값의 모양만 보지 않고 키 존재 여부도 대조해 이 차이를 확인했다. 본문의 Java 예제는 없는 키를 `null`로 채우므로, 그 뒤의 화면용 응답에서는 이 구분이 합쳐진다. 따라서 저장할 때는 누락된 키를 삭제 지시로 추정하지 않고 `removedColumns`처럼 삭제 의도를 따로 전달해야 한다.

이번 19개 자동 검증은 모두 통과했다. 숫자 순서는 세로형 SQL의 `ORDER BY v.col_no` 결과이며 JSON 객체의 키 순서를 보장한다는 뜻은 아니다. 운영 DB 성능, 실제 화면 열 추가·삭제, 사용자 권한 및 동시 수정은 이번 검증 범위에 포함하지 않았다. [PostgreSQL 집계 함수](https://www.postgresql.org/docs/18/functions-aggregate.html?ref=mlog.me)와 [오류 코드](https://www.postgresql.org/docs/18/errcodes-appendix.html?ref=mlog.me)의 의미도 함께 확인했다.

## PostgreSQL에서 실제 가변 결과 열을 만들지 않은 이유

일반적인 `SELECT`의 출력 열은 쿼리를 실행하는 동안 발견된 데이터에 따라 저절로 늘어나지 않는다. PostgreSQL의 `crosstab()`도 반환할 열 이름과 타입을 호출 쿼리에서 미리 선언해야 한다. 따라서 “현재 존재하는 번호만 0\~50개의 실제 결과 열로 자동 반환”하는 범용 API에는 잘 맞지 않는다.

다른 선택지도 있다.

- `MAX(val) FILTER (WHERE col_no = 1) AS "1"`을 `50`까지 선언하면 고정 폭 보고서나 엑셀 출력에는 단순하다. 다만 실제로 세 개만 사용해도 항상 50개 열이 반환되고, 상한이 바뀌면 SQL과 DTO도 수정해야 한다.
- PL/pgSQL의 동적 `EXECUTE`로 `SELECT` 목록을 조립할 수는 있다. 하지만 호출자가 반환 타입을 알아야 하고, 식별자 인용과 SQL 삽입 방어, 매핑 코드가 복잡해진다. 화면용 API보다는 결과 열 자체가 꼭 필요한 특수 보고서에 제한적으로 쓰는 편이 낫다.

동적 SQL을 사용해야 한다면 클라이언트가 보낸 열 이름을 그대로 문자열에 붙이지 않는다. 데이터 값은 바인드 파라미터로 전달하고, 서버가 허용한 식별자만 `format('%I', identifier)` 같은 방식으로 인용해야 한다.

## API는 컬럼 목록과 행을 함께 반환한다

이 글의 예제에서는 `columns`에 동적 컬럼 정보만 넣고, `code`, `test`, `gbn`은 고정 계약으로 둔다. 화면 제목과 데이터 키도 분리한다. 사용자에게는 숫자 `1`을 보여주되, JSON과 그리드 내부에서는 `slot_1`처럼 문자로 시작하는 안정적인 키를 쓰는 방식이다.

```json
{
  "columns": [
    { "key": "slot_1", "label": "1" },
    { "key": "slot_2", "label": "2" },
    { "key": "slot_3", "label": "3" }
  ],
  "rows": [
    {
      "rowKey": "1",
      "code": "a",
      "test": "1",
      "gbn": "a",
      "slot_1": "0000",
      "slot_2": "0010",
      "slot_3": "0020"
    },
    {
      "rowKey": "2",
      "code": "b",
      "test": "1",
      "gbn": "a",
      "slot_1": "0010",
      "slot_2": "0020",
      "slot_3": "0030"
    }
  ]
}

```

동적 컬럼이 하나도 없을 때도 응답 형태는 바뀌지 않는다.

```json
{
  "columns": [],
  "rows": [
    {
      "rowKey": "1",
      "code": "a",
      "test": "1",
      "gbn": "a"
    }
  ]
}

```

## Spring Boot에서 행을 피벗한다

다음은 Java 17과 Spring JDBC를 기준으로 한 핵심 예제다. 실제 코드에서는 Repository와 Service를 분리해도 처리 원리는 같다.

`DynamicColumnMeta.java`

```java
public record DynamicColumnMeta(String key, String label) {}

```

`DynamicTableResponse.java`

```java
public record DynamicTableResponse(
        List<DynamicColumnMeta> columns,
        List<Map<String, Object>> rows
) {}

```

```java
@Service
public class DynamicColumnService {

    private static final int MAX_DYNAMIC_COLUMN = 50;

    private static final String SQL = """
            SELECT
                b.id AS base_id,
                b.code,
                b.test,
                b.gbn,
                v.col_no,
                v.val
            FROM base_item b
            LEFT JOIN base_item_value v
                   ON v.base_id = b.id
            ORDER BY b.id, v.col_no
            """;

    private final JdbcTemplate jdbcTemplate;

    public DynamicColumnService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional(readOnly = true)
    public DynamicTableResponse findAll() {
        return jdbcTemplate.query(SQL, rs -> {
            Map<Long, Map<String, Object>> rowsById = new LinkedHashMap<>();
            Set<Integer> columnNumbers = new TreeSet<>();

            while (rs.next()) {
                long baseId = rs.getLong("base_id");

                Map<String, Object> row = rowsById.get(baseId);
                if (row == null) {
                    row = new LinkedHashMap<>();
                    row.put("rowKey", Long.toString(baseId));
                    row.put("code", rs.getString("code"));
                    row.put("test", rs.getString("test"));
                    row.put("gbn", rs.getString("gbn"));
                    rowsById.put(baseId, row);
                }

                int colNo = rs.getInt("col_no");
                if (rs.wasNull()) {
                    continue;
                }

                if (colNo < 1 || colNo > MAX_DYNAMIC_COLUMN) {
                    throw new DataIntegrityViolationException(
                            "허용 범위를 벗어난 동적 컬럼: " + colNo
                    );
                }

                String key = "slot_" + colNo;
                if (row.containsKey(key)) {
                    throw new DataIntegrityViolationException(
                            "같은 행에 동적 컬럼이 중복되었습니다: "
                                    + baseId + "/" + colNo
                    );
                }
                row.put(key, rs.getObject("val"));
                columnNumbers.add(colNo);
            }

            List<DynamicColumnMeta> columns = columnNumbers.stream()
                    .map(no -> new DynamicColumnMeta(
                            "slot_" + no,
                            Integer.toString(no)
                    ))
                    .toList();

            for (Map<String, Object> row : rowsById.values()) {
                for (DynamicColumnMeta column : columns) {
                    row.putIfAbsent(column.key(), null);
                }
            }

            return new DynamicTableResponse(
                    columns,
                    new ArrayList<>(rowsById.values())
            );
        });
    }
}

```

`TreeSet<Integer>`를 사용하면 문자열 정렬에서 `10`이 `2`보다 먼저 오는 문제를 피할 수 있다. `key`와 `label`을 분리하면 화면 제목은 자유롭게 바꾸면서 내부 필드명은 안정적으로 유지할 수 있다. 또한 각 행에 없는 동적 키를 `null`로 채우므로 프론트엔드가 행마다 키 존재 여부를 따로 확인할 필요가 없다.

예제는 `base_id`로 행을 묶고, 이를 화면에 표시하지 않는 기술용 `rowKey`로만 반환한다. `code`, `test`, `gbn` 조합이 반드시 유일하다는 가정을 피하기 위해서다. 외부 노출용 식별자 정책이 따로 있다면 UUID 같은 공개 식별자로 대체한다. 검색 조건을 추가할 때는 SQL 문자열에 값을 이어 붙이지 말고 JDBC 바인드 파라미터를 사용한다.

## Vue 3에서 동적 컬럼을 추가하고 삭제한다

Vue에서는 고정 컬럼과 동적 컬럼 메타데이터를 분리해 관리하면 편하다.

```vue
<script setup>
import { computed, ref } from 'vue'

const BASE_COLUMNS = [
  { key: 'code', label: '코드' },
  { key: 'test', label: '테스트' },
  { key: 'gbn', label: '구분' }
]

const dynamicColumns = ref([])
const rows = ref([])
const selectedKeys = ref([])

const columns = computed(() => [
  ...BASE_COLUMNS,
  ...dynamicColumns.value
])

function applyResponse(payload) {
  dynamicColumns.value = payload.columns.map(column => ({ ...column }))
  rows.value = payload.rows.map(row => ({ ...row }))
}

function addColumn(number) {
  const value = Number(number)
  if (!Number.isInteger(value) || value < 1 || value > 50) return

  const key = `slot_${value}`
  if (dynamicColumns.value.some(column => column.key === key)) return

  dynamicColumns.value = [
    ...dynamicColumns.value,
    { key, label: String(value) }
  ].sort((a, b) => Number(a.label) - Number(b.label))
  rows.value = rows.value.map(row => ({
    ...row,
    [key]: row[key] ?? ''
  }))
}

function removeSelectedColumns({ discardValues = false } = {}) {
  const dynamicKeys = new Set(dynamicColumns.value.map(column => column.key))
  const targets = new Set(
    selectedKeys.value.map(String).filter(key => dynamicKeys.has(key))
  )

  dynamicColumns.value = dynamicColumns.value.filter(
    column => !targets.has(column.key)
  )

  if (discardValues) {
    rows.value = rows.value.map(row => {
      const next = { ...row }
      for (const key of targets) delete next[key]
      return next
    })
  }

  selectedKeys.value = []
}
</script>

<template>
  <table>
    <thead>
      <tr>
        <th v-for="column in columns" :key="column.key">
          {{ column.label }}
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="row in rows" :key="row.rowKey">
        <td v-for="column in columns" :key="column.key">
          {{ row[column.key] ?? '' }}
        </td>
      </tr>
    </tbody>
  </table>
</template>

```

`dynamicColumns`에서 정의만 제거하면 화면 열은 사라지지만 행 객체의 원래 값은 남아 있다. 다시 표시하거나 저장할 가능성이 있다면 이 방식이 유용하다. 값까지 버려야 할 때만 `discardValues: true`로 새 행 객체를 만들고 해당 키를 삭제한다.

재조회할 때는 기존 배열에 계속 `push`하지 말고 `applyResponse()`처럼 서버 응답으로 동적 키를 교체해야 중복 열이 누적되지 않는다. `code`, `test`, `gbn`은 삭제 대상에 포함하지 않는 것도 중요하다.

## IBSheet에서 동적 컬럼을 구성한다

IBSheet8을 처음 생성할 때는 고정 컬럼과 응답의 동적 컬럼을 합쳐 `Cols`에 전달하는 방식이 가장 단순하다.

```javascript
const baseCols = [
  { Name: 'code', Header: '코드', Type: 'Text', Width: 100 },
  { Name: 'test', Header: '테스트', Type: 'Text', Width: 100 },
  { Name: 'gbn',  Header: '구분', Type: 'Text', Width: 100 }
]

const dynamicCols = payload.columns.map(column => ({
  Name: column.key,
  Header: column.label,
  Type: 'Text',
  Width: 100
}))

IBSheet.create({
  id: 'sheet',
  el: 'sheetDiv',
  options: {
    Cfg: {},
    Cols: [...baseCols, ...dynamicCols]
  },
  data: payload.rows
})

```

이미 생성된 시트에 열을 한 개 추가할 때는 IBSheet8의 `addCol`을 사용할 수 있다. 여러 열을 한꺼번에 바꿀 때는 각 호출의 렌더링을 끄고 마지막에 한 번만 다시 그리는 편이 효율적이다.

```javascript
for (const column of payload.columns) {
  sheet.addCol(
    column.key,
    1,
    -1,
    { Type: 'Text', Header: column.label, Width: 100 },
    true,
    false
  )
}

sheet.rerender()
sheet.loadSearchData(payload.rows)

```

아래 코드는 고정 열 삭제를 막는 최소 예시다. 실제 적용 시에는 애플리케이션이 관리하는 현재 동적 키 목록에도 포함되는지 확인한 뒤 삭제한다. `removeCol`은 시트 안의 해당 열과 값을 제거하므로, 단순히 잠시 감추는 기능에는 `hideCol`과 `showCol`이 더 알맞다.

```javascript
const FIXED_KEYS = new Set(['code', 'test', 'gbn'])

function removeDynamicColumn(key) {
  if (FIXED_KEYS.has(key)) return
  sheet.removeCol(key)
}

function hideDynamicColumn(key) {
  if (FIXED_KEYS.has(key)) return
  sheet.hideCol(key, true)
}

function showDynamicColumn(key) {
  sheet.showCol(key)
}

```

Vue에서 IBSheet 인스턴스를 직접 보관한다면 깊은 반응성이 필요하지 않으므로 `shallowRef`가 적합하다. 공식 `@ibsheet/vue` 컴포넌트를 사용하면 컴포넌트가 제거될 때 정리도 처리한다. `IBSheet.create()`를 직접 호출한 경우에는 Vue의 `onBeforeUnmount`에서 `dispose()`를 호출한다.

IBSheet를 재조회할 때는 현재 동적 키 목록을 별도로 관리해 기존 열을 제거한 뒤 새 열을 추가하거나, 응답 스키마가 크게 바뀐다면 시트를 안전하게 재생성한다. 서버의 컬럼 객체를 `addCol`에 그대로 전달하지 말고 `Type`, `Width`, `CanEdit`, `Format`처럼 허용한 속성만 매핑해야 한다. 세부 인자와 렌더링 동작은 설치된 IBSheet8 버전 및 프로젝트 래퍼에서 다시 확인한다.

## “컬럼 삭제”의 범위를 먼저 정한다

컬럼 삭제라는 말은 네 가지 의미로 사용될 수 있다.

1. 화면에서만 잠시 숨긴다.
2. 프론트엔드의 컬럼 정의와 행 객체의 값을 제거한다.
3. 서버에 삭제 요청을 보내 `base_item_value`의 해당 값을 제거한다.
4. PostgreSQL 테이블에 있는 실제 물리 컬럼을 `ALTER TABLE ... DROP COLUMN`으로 삭제한다.

이 글의 Vue와 IBSheet 예제는 1번 또는 2번이다. Vue의 원본 행과 IBSheet 내부 데이터도 서로 다른 상태라는 점에 주의한다. 단순 표시 변경이라면 원본 행의 속성을 지우지 않는다.

업무적으로 값을 삭제할 때는 누락된 속성만 보내지 말고 삭제 의도를 명시하는 편이 좋다.

```json
{
  "removedColumns": ["slot_2"],
  "rows": []
}

```

서버는 이 요청에 대해 권한, 참조 관계, 동시 수정 여부를 확인하고 트랜잭션으로 처리해야 한다. 세로형 값 구조에서는 보통 실제 PostgreSQL 테이블 컬럼을 삭제할 이유가 없다.

## 0\~50개 가변 컬럼 체크리스트

- 동적 컬럼이 0개여도 고정 컬럼과 행은 정상 표시되는가?
- 컬럼 번호를 문자열이 아닌 숫자 기준으로 정렬했는가?
- `(행 ID, 컬럼 번호)`의 중복을 DB 제약조건으로 막았는가?
- 값이 없는 동적 필드를 `null` 또는 빈 문자열 중 무엇으로 표현할지 정했는가?
- 화면 제목과 내부 키를 분리하고, 내부 키가 `code`, `test`, `gbn`과 충돌하지 않도록 제한했는가?
- 재조회할 때 이전 컬럼이 누적되지 않는가?
- 화면에서 숨기는 것과 원본 값을 삭제하는 것을 구분했는가?
- 서버에서 받은 그리드 옵션을 허용 목록 없이 그대로 적용하지 않는가?
- 클라이언트가 보낸 열 이름을 SQL 식별자로 직접 연결하지 않는가?

## 어떤 방식을 선택하면 될까

- 일반적인 화면용 API라면 세로형 조회 후 Spring Boot에서 `columns + rows`로 조합한다.
- 여러 서비스가 JSONB를 그대로 활용한다면 PostgreSQL에서 `jsonb_object_agg`로 묶는 방법도 좋다.
- 항상 1\~50열이 모두 필요한 고정 보고서라면 `FILTER` 집계를 검토한다.
- SQL 결과 자체가 가변 열이어야 하는 특수 출력이라면 동적 SQL을 제한적으로 사용한다.
- 화면에서 열만 잠시 감추는 기능이라면 데이터 삭제 로직과 분리한다.

핵심은 DB 컬럼을 실행 때마다 실제로 추가하는 것이 아니다. 고정 데이터와 가변 값을 분리해 저장하고, API가 컬럼 목록과 행 데이터를 함께 내려주면 PostgreSQL, Spring Boot, Vue, IBSheet의 역할이 분명해진다.

## 참고 문서

- [PostgreSQL 집계 함수와 jsonb\_object\_agg](https://www.postgresql.org/docs/current/functions-aggregate.html?ref=mlog.me)
- [PostgreSQL JSON과 JSONB](https://www.postgresql.org/docs/current/datatype-json.html?ref=mlog.me)
- [PostgreSQL crosstab](https://www.postgresql.org/docs/current/tablefunc.html?ref=mlog.me)
- [PostgreSQL 동적 EXECUTE](https://www.postgresql.org/docs/current/plpgsql-statements.html?ref=mlog.me#PLPGSQL-STATEMENTS-EXECUTING-DYN)
- [Spring Framework JDBC](https://docs.spring.io/spring-framework/reference/data-access/jdbc/core.html?ref=mlog.me)
- [Vue 3 반응형 기초](https://vuejs.org/guide/essentials/reactivity-fundamentals.html?ref=mlog.me)
- [Vue 3 shallowRef](https://vuejs.org/api/reactivity-advanced.html?ref=mlog.me#shallowref)
- [IBSheet8 addCol 공식 매뉴얼](https://github.com/ibsheet/ibsheet8-manual/blob/main/docs/funcs/core/add-col.md?ref=mlog.me)
- [IBSheet8 removeCol 공식 매뉴얼](https://github.com/ibsheet/ibsheet8-manual/blob/main/docs/funcs/core/remove-Col.md?ref=mlog.me)
- [IBSheet8 hideCol 공식 매뉴얼](https://github.com/ibsheet/ibsheet8-manual/blob/main/docs/funcs/core/hide-col.md?ref=mlog.me)
- [IBSheet8 loadSearchData 공식 매뉴얼](https://github.com/ibsheet/ibsheet8-manual/blob/main/docs/funcs/core/load-search-data.md?ref=mlog.me)

*공식 문서 확인일: 2026-08-19*