PostgreSQL 데이터를 Spring Boot를 거쳐 Vue와 IBSheet 동적 컬럼으로 구성하는 흐름

PostgreSQL 동적 컬럼 조회·추가·삭제: Spring Boot·Vue·IBSheet 실전 구조

PostgreSQL 2026년 8월 19일

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처럼 구성된다. 예를 들어 동적 컬럼이 세 개라면 화면은 다음처럼 보여야 한다.

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 테이블에 계속 추가되는 실제 열이 아니라, 행으로 저장된 가변 항목을 화면에서 열처럼 펼친 결과라는 것이다.

권장 데이터 구조

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

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에서는 세로형으로 조회한다

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

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를 사용할 수 있다.

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는 다음 형태가 된다.

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

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

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

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

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

다른 선택지도 있다.

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

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

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

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

{
  "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"
    }
  ]
}

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

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

Spring Boot에서 행을 피벗한다

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

DynamicColumnMeta.java

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

DynamicTableResponse.java

public record DynamicTableResponse(
        List<DynamicColumnMeta> columns,
        List<Map<String, Object>> rows
) {}
@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>를 사용하면 문자열 정렬에서 102보다 먼저 오는 문제를 피할 수 있다. keylabel을 분리하면 화면 제목은 자유롭게 바꾸면서 내부 필드명은 안정적으로 유지할 수 있다. 또한 각 행에 없는 동적 키를 null로 채우므로 프론트엔드가 행마다 키 존재 여부를 따로 확인할 필요가 없다.

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

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

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 targets = new Set(selectedKeys.value.map(String))

  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에 전달하는 방식이 가장 단순하다.

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을 사용할 수 있다. 여러 열을 한꺼번에 바꿀 때는 각 호출의 렌더링을 끄고 마지막에 한 번만 다시 그리는 편이 효율적이다.

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은 시트 안의 해당 열과 값을 제거하므로, 단순히 잠시 감추는 기능에는 hideColshowCol이 더 알맞다.

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 내부 데이터도 서로 다른 상태라는 점에 주의한다. 단순 표시 변경이라면 원본 행의 속성을 지우지 않는다.

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

{
  "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의 역할이 분명해진다.

참고 문서

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

태그