어드민 컴포넌트 개편
안녕하세요 셀렉트팀 이진혁입니다.
셀렉트어드민은 YAML 입력으로 다양한 화면을 만들어주는 제품으로 다양한 팀, 회사의 어드민과 내부 운영툴, 파트너센터, 대시보드 역할을 하고 있습니다.
초기에는 SQL 쿼리 실행 결과, API 실행 결과를 시각화하는 방향으로 고안되어 모든 스펙에 type: http type: query 가 필요한 방식을 이어왔습니다.
그러나 다양한 화면구성, 정교한 어드민 구현을 하다보면 많은 중첩(nested), 상위(parent), 중간 데이터가 생겨나며 위의 개념이 불편하거나 충돌을 일으키는 경우가 있습니다.
따라서 UI 컴포넌트 추가 개편에 앞서 방향성을 고민했습니다.
- Data-UI를 표시하는 안정적인 방식
- 대부분 사용자(server, backend engineer)의 사용성을 고려
- 프론트(react, state, vuejs) 개념을 최소화
- 화면, 모달, 탭등 초기화 과정을 투명
새로운 스펙
UI Blocks
그 결과 존재하는 블록 타입은 이와 같이 시각적인 단위로 정리하였습니다.
- type: table
- type: info
- type: tabs
- type: form
- type: search
Functional UI
또한 공통적인 기능-action button, updateOptions등을 정리했습니다.
- buttons, leftButtons, rightButtons
- clickFn
- rowClickFn
Modal API
또한 어드민에 자주쓰이는 모달(popup modal)을 정리했습니다.
- type: modal
- $modal.show
- $modal.hide
Data API
그리고 데이터 가져오기(fetch)와 중간 저장소(store)를 추가했습니다.
기존의 requestFn, responseFn, responseErrorFn의 어려움을 보완합니다.
기존의 blocks.{name}을 통한 접근시 오류의 가능성을 보완합니다.
- fetchFn
- state.{ANY_DATA}
- state.{method}
Route API
기존에는 params에 대해 자동으로 query string(parameter)를 만들어주었고 편리함도 있지만 과도한 URL 생성, 새로고침의 오류가 있었습니다. 이 부분을 명시적으로 직접 처리하도록 개선했습니다.
- $router.push (원하는 URL로 저장)
- $route.query (원하는 URL 부분에서 초기 상태 불러오기)
Input API
기존에는 param.key에 따라 자동으로 block 조회, 수정시 값을 넘겨주었다면 지금은 모든 param은 state에 들어갑니다. 여러 테이블에서도 하나의 값을 사용가능하고, 상세모달에서도 복잡함 없이 활용 가능합니다
기존의 defaultValue, defaultValueFromQuery, valueFromRow, datalistFromQuery등을 보완합니다.
- params.formName = state.formName = $route.query.formName
Options
기존에는 fn 코드 부분에 어떤 파라메터가 함께 넘어오는지 파악하기 어려웠습니다. 매번 문서를 확인하거나 값 마다 console.log를 해야하는 불편함이 있었습니다.
paramsfetchFn은 해당 파라메터에 모든걸 전달opt모든 코드 함수(clickFn...)는 해당 파라메터에 모든걸 전달
예제
CRUD(Create-Read-Update-Delete)에 대하여 스펙을 본문에 하나씩 나열합니다.
실제 셀렉트어드민 사용자들의 요구사항, 사용 패턴을 고려하여 재설계 했습니다.
조회
- 모든 데이터를 표시하면서 불필요한 필드를 hidden하거나
- 지정한 컬럼만 원하는 순서로 표시합니다.

- type: table
fetchFn: |
return [
{ name: "Name" }
]
headers:
name:테이블 조회하기 - 헤더 지정 (columnOptions처럼 replace)
- type: table
fetchFn: |
return [
{ name: "Name" }
]
autoHeaders: true테이블 조회하기 - 헤더 자동으로 만들기 (columns 처럼 merge)
조회 조건
- 검색조건과 데이터결과 부분을 분리했습니다.
- 1개의 검색조건으로 여러개 데이터를 새로고침하는 경우
- AND, OR등 조건에 따른 submit 버튼이 여러개이거나 분기 처리하는 경우

- type: search
params:
- key: id
- key: name
buttons:
- label: Search
type: submit
clickFn: |
const {state} = opt
await state.reload(opt)- type: search
params:
- key: id
- key: name
buttons:
- label: Search
type: submit
clickFn: |
const {state, $router} = opt
await state.reload(opt)
$router.push({
query: {
id: state.id,
name: state.name,
}
})새로고침시 검색조건을 유지해야할 필요있는 경우
폼 입력
- 검증 로직을 개별말고 submit 이전에 추가
- 기존의 input filter 형태가 아닌 폼 형태를 기본값으로

- type: form
params:
- key: comment
format: textarea
rows: 5
vertical: true
buttons:
- type: submit
label: Submit
clickFn: |
alert('Saved')업데이트
- updateOptions등 편리한 기능이 있지만 1개 값 수정만 가능했던 한계

state:
formName: wow
blocks:
- type: table
fetchFn: |
return [
{ id: 1000, name: 'wow' }
]
autoHeader: true
headers:
name:
iconEnd: pencil
rowClickFn: |
const {key, row, $modal, state} = opt
if (key == 'name') {
state.formName = row[key]
$modal.show('editName')
}
- type: modal
name: editName
blocks:
- type: form
params:
- key: formName
vertical: true
buttons:
- label: Update
상세 조회 (중첩 모달)
- 기존 viewModal, modals는 부모-자식 값 교환 문제가 있음
- modal name 단위로 수동으로 표시, 여러개 모달 띄울때 문제 제거
- valueFromRow가 아닌 명시적인 브라우저 주소로 해결
- type: table
fetchFn: |
return [
{ org_id: 1000 }
]
autoHeader: true
width: 100%
rowClickFn: |
const {block, row, key, $modal, $router} = opt
$router.push({
query: {
oid: row.org_id,
}
}).catch()
$modal.show('org_id')
- type: modal
name: org_id
header: Teams of organization
routeQuery: oid
width: 800
defaultHeight: 50vh
blocks:
- type: table
fetchFn: |
const r = await query('DEV_RO', `
SELECT *
FROM Team
WHERE org_id = '${ params.$route?.query?.oid || 0 }'
LIMIT 1000
`)
return r
autoHeader: trueURL 화면 상태 처리
- 셀렉트 기본제공이 아닌 커스텀하게 처리하는 경우
- 조회를 분기하는 경우
- type: table
fetchFn: |
const {block, $route} = params
state.id = $route.query.id
state.name = $route.query.name
state.reload = async () => {
if (state.id) {
const r = await query('DEV_RO', `
SELECT id, name, domain, plan
FROM Team
WHERE id = ${ +state.id || 0}
LIMIT 100
`)
return params.$result(r)
}
else if (state.name) {
const r = await query('DEV_RO', `
SELECT id, name, domain, plan
FROM Team
WHERE name LIKE '%${ state.name }%'
LIMIT 100
`)
return params.$result(r)
}
else {
const r = await query('DEV_RO', `
SELECT id, name, domain, plan
FROM Team
LIMIT 100
`)
return params.$result(r)
}
}
return state.reload()
autoHeader: true체크하여 일괄 처리
- 정렬, 선택을 직접 처리 가능
- 기존 actions(forEach, valueFromSelectedRows) 부분을 state로 해결
- 기타 테이블 로직을 그대로 공개

- type: table
headers:
checked:
label: 선택
width: 50px
format: checkbox
id:
label: ID
oid:
label: OID
name:
# width: 1000px
width: 200px
priceText:
label: Price
clickFn: |
const {header, block, _} = opt
if (!header.iconEnd) {
// asc
block._rows = block.rows
block.rows = _.sortBy(block.rows, 'price')
header.iconEnd = 'arrow-down'
}
else if (header.iconEnd == 'arrow-down') {
// desc
block.rows = _.sortBy(block.rows, 'price').reverse()
header.iconEnd = 'arrow-up'
}
else {
block.rows = block._rows
header.iconEnd = ''
}
classFn: |
const {row} = opt
if (+row.price > 500000) return 'bg-amber-200/20'
if (+row.price < 200000) return 'bg-sky-200/20'
return ''
rowClickFn: |
const {row, key, block} = opt
row.checked = !row.checked
leftButtons:
- label: 전체선택
clickFn: |
const {block} = opt
const next = !block.rows[0].checked
block.rows.forEach(e => {
e.checked = next
})
- label: 삭제
clickFn: |
const { $modal, $toast, block, state } = opt
const checked = block.rows.filter(e => e.checked)
if (checked.length == 0) {
return $toast('항목을 선택해주세요.')
}
state.selectedRows = checked
$modal.show('confirm-delete')
- label: 비어있는 버튼
rightButtons:
- label: 추가
clickFn: |
const { $modal, $toast, block, state } = opt
$modal.show('add')
# 세로폭 줄이기만 가능
# height: 200px
# 가로폭 줄이기만 가능
# width: 500px
# 항상 크게 채우기
full: true
fetchFn: |
const rows = await params.loadOrders()
return rows.map(e => {
e.checked = false
e.priceText = filters.number(e.price)
return e
})모달, 탭의 새로고침
- 조회조건등 변경으로 하위 내용이 새로고침되어야함
- routeQuery 직접 지정하여 다시 렌더링
삭제시 confirm
- 기존은 confirm: true, confirmText등 단순한 형태의 컨펌만 가능함
- 별도 모달띄워서 안전한 확인 내용 표시, 입력받은 후(사유) 삭제 실행 가능함
- 삭제완료후 결과표시, 되돌리기(undo) 액션도 가능
- 삭제완료후 조회페이지의 경우 목록페이지로 이동하기
개선 방향
기존에 지원하던 옵션들을 하나씩 적용 예정입니다.
- columns: format 추가지원 및 사용법
- 기존 formatFn 사용법
- html, template 직접 사용법
- param: type 추가지원 및 사용법
장단점
- 기존 사용법 대비 높은 커스텀이 가능하지만 간단한 작업에도 js 코드를 입력이 필요해서 번거로움이 높아질 수 있습니다. → 그러나 셀렉트 어드민 당장 못만들어서 결국 별도 페이지를 개발하는하는 상황을 벗어날 수 있습니다.
- 변수명을 직접 짓고 id, name, state(key)를 별도로 고려해야하는게 번거롭다. → 그러나 화면 복잡도가 커질수록 정확한 변수(key) 이름으로 YAML 스펙을 이해하기 쉽고, 안정적으로 고치기 쉬워집니다.
- 본인은 다른 언어를 주로 사용하고 있고 자바스크립트 코딩은 원하지 않는다. → 대부분 코드는 복사-붙여넣기 가능하도록 레시피를 제공하고 있습니다. 또한 GPT등을 통해 자동완성 여부를 꾸준히 검토하고 있습니다. LLM 생성에 친화적으로 스펙을 관리하고 있습니다.
그동안 많은 의견을 주셔서 셀렉트 팀에 큰 힘이 되었습니다.
앞으로도 많은 의견 부탁드립니다.
감사합니다.