◼︎ 환경
- Model : MacBook Pro (14-inch, 2021)
- CPU : Apple M1 Pro
- MENORY : 16GB
- DISK : 512 GB SSD
- OS : macOS 13.2.4 (22F66)
- TOOLS : Visual Studio Code, Java 11, Gradle, Docker
- Version Control : GitHub
- Programming Language : Java, Vue3
- Front-End Framework : Vue 3.4.30, Vuetify 3.6.10, ag-grid-vue3 31.3.2
- Back-End Framework : Spring Boot 2.7.12, Spring Security 5.7.7
- DBMS : MySql 8.0.33
- Cloud : OCI (free tier account)
AG-Grid 을 이용한 그리드 구현
Vue3 에서 AG-Grid 는 다음과 같은 3단계 과정을 통하여 만들 수 있다.
① Vue3 용 ag-grid-vue3 패키지 설치
② AG-Grid CSS 스타일 추가
import "ag-grid-community/styles/ag-grid.css"; // Mandatory CSS required by the grid
import "ag-grid-community/styles/ag-theme-quartz.css"; // Optional Theme applied to the grid
③ AG-Grid 설정 ( 그리드를 구현한 vue 파일에서 )
gridOptions
를 사용하여 그리드의 전반적인 공통 설정을 관리하고, columnDefs
를 사용하여 각 컬럼의 설정을 관리한다. 이를 통해 그리드의 설정을 더 쉽게 체계적으로 관리할 수 있다. <template>
<AgGridVue class="ag-theme-quartz"
:gridOptions="gridOptions"
:columnDefs="columnDefs"
:rowData="gridData"
@grid-ready="onGridReady"
style="height:600px;"></AgGridVue>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { AgGridVue } from "ag-grid-vue3"; // Vue Data Grid Component
import type { GridApi, IGetRowsParams } from 'ag-grid-community';
import type { GridOptions, ColDef } from 'ag-grid-community';
// ag-grid
const loader = ref(false);
const gridData = ref([]);
const gridApi = ref<GridApi | null>();
// ag-grid options for common.
const gridOptions: GridOptions = {
defaultColDef: {
flex: 1,
minWidth: 100,
resizable: true,
sortable: true,
filter: true,
cellStyle: {
display: 'flex',
alignItems: 'center'
}
},
columnTypes: {
string: {
filter: 'agTextColumnFilter',
filterParams: { suppressAndOrCondition: true },
cellStyle: { textAlign: 'center' },
},
number: {
filter: 'agNumberColumnFilter',
filterParams: { suppressAndOrCondition: true },
cellStyle: { textAlign: 'right' },
},
},
};
// define grid coloums
const columnDefs = [
{ field: 'groupId', headerName: 'ID', type: 'number',
width: 100, sortable: true },
{ field: 'name', headerName: 'Name', sortable: true , type:"text",
flex:3 , cellRenderer : GroupCellRenderer}, // 사용자 정의 셀 렌더러.
{ field: 'description', headerName: 'Description', type: 'string',
width: 50, sortable: false , filter:false},
];
const onGridReady = (params: any) => {
gridApi.value = params.api;
};
- gridOptions : 그리드의 전반적인 설정을 정의. (defaultColDef, rowData, paginationPageSize, cacheBlockSize, rowSelection, getRowHeight 등)
- columnDefs: 각 컬럼의 설정을 정의. 각 컬럼에 대해 headerName, field, sortable, filter, cellStyle, cellRenderer 등을 설정.
- rowData: 그리드에 표시할 데이터를 설정.
- onGridReady: 그리드가 준비되면 gridApi 인스턴스을 저장하여 그리드 제어에 사용
서버 사이드 페이징
서버 사이드 페이징 기능을 사용하지 않는 경우라면 간단하게 아래와 같이 rowData 에 해당하는 gridData 객체에 데이터를 강제로 주입해주기만 하면 쉽게 구현이 가능하지만 페이지, 필터, 소팅 등의 기능들을 서버에서 처리하는 것은 조금 더 많은 수고가 필요하다.
onMounted(async () => {
fetch(`https://api.example.com/data`)
.then(response => response.json())
.then(data => {
gridData.value = data.rows;
})
.catch(error => {
console.error(error);
});});
유료 버전을 사용하는 경우라면 gridOptions 의 rowModelType 값을 'serverSide' 로 변경하고 추가로 datasource 를 설정하면 쉽게 구현이 되는것으로 보이지만 무료 커뮤니티 버전은 좀더 복잡한 과정을 진행해야 한다.
rowModelType 은 그리드가 데이터를 로드하고 관리하는 방식을 결정한다. AG-Grid는 다양한 데이터 로딩 및 관리 요구를 충족시키기 위해 여러 가지 rowModelType을 제공하며, 각 타입은 특정 사용 사례에 최적화되어 있다. 무료 버전에서 서버 사이드 페이징을 구현하려면 rowModelType 값을 infinite 을 사용하여 구현해야 한다.
<AgGridVue class="ag-theme-quartz"
:rowModelType="'infinite'"
:gridOptions="gridOptions"
:columnDefs="columnDefs"
:pagination="true"
:paginationPageSize="pageSize"
:cacheBlockSize="cacheBlockSize"
@grid-ready="onGridReady"
style="height:600px;"></AgGridVue>
페이징 기능을 사용하기 위하여 아래와 같은 설정을 추가한다. 가독성을 고려하여 gridOptions 에 추가하지 않고 AgGridVue 의 개별 속성 값을 직접 설정하는 방식을 사용했다.
- pagination : 그리드에서 페이징 기능을 사용할지를 결정하는 옵션. 기본적으로 pagination 속성을 true로 설정하면 페이징이 활성화된다. 페이징이 활성화되면 그리드는 한 번에 일정한 수의 행만 표시하고, 사용자가 페이지를 전환할 수 있도록 페이지 내비게이션 컨트롤을 제공한다.
- paginationPageSize : 한 페이지에 표시할 행(row)의 수를 설정하는 옵션. 이 속성을 설정하면 사용자가 그리드 페이지를 전환할 때마다 이 수만큼의 행이 표시되며 기본값은 100이다. (참고로 서버에서 데이터를 페이징하여 가져오는 값과는 구분된다.)
- cacheBlockSize : infinite 또는 serverSide row model을 사용할 때 한 번에 서버에서 가져올 데이터의 블록 크기를 설정하는 옵션. 이 속성은 서버에서 데이터를 페이징 방식으로 가져올 때 유용하며 기본값은 100이다. (-> 서버에 전달하는 페이징 크기 값)
이제 데이터를 가져오는 datasource 에 대한 코딩이 필요하다. datasource 설정은 유연성을 고려하여 onGridReady 이벤트에서 설정한다. 주의할 것은 이벤트에서 인자로 전달되는 params 값을 사용하여 페이지 정보를 서버에 전달해야 하는 점이다.
function sortString (sort: SortModelItem[] ) {
if( sort.length > 0 ) {
return sort[0].colId + ',' + sort[0].sort ||''.toUpperCase();
}
return null;
}
const onGridReady = (params: any) => {
gridApi.value = params.api;
const dataSource = {
getRows: async (params: IGetRowsParams) => {
const page = Math.floor(params.startRow / pageSize.value);
const pageSizeValue = params.endRow - params.startRow;
const filterModel = params.filterModel;
const sortModel = params.sortModel;
try {
dataStore.setSort(sortModel);
dataStore.setFilter(filterModel);
dataStore.setPage(page);
filtersActive.value = Object.keys(filterModel).length > 0;
await axios.post(`https://api.example.com/data`,
JSON.stringify(filterModel),
{
params: {
...{
page: page,
size: cacheBlockSize.value,
sort: sortString(sortModel),
},
},
})
.then((response) => {
const data = response.data;
gridData.value = data
total.value = data.totalElements;
params.successCallback(gridData.value, data.totalElements );
).catch(error => {
console.error(error);
});
params.successCallback(gridData.value, total.value);
} catch (error) {
params.failCallback();
console.error(error);
}
}
};
params.api.setGridOption('datasource', dataSource);
};
위의 예는 서버에 post 방식으로 sorting, paging, filter 정보를 전달하고 있는데 Spring JPA 를 사용하고 있다면 아주 쉽게 서버 프로그램을 만들어 볼 수 있다.
추가로 서버에 데이터를 전송할 때에 Map 형식의 AG-Grid 필터값을 객체 배열로 변환하고 필터 타입을 약어로 변경하여 서버로 전송하도록 아래와 같은 변환 함수를 사용 변환하여 전송했다.
export const convertAgGridFilterToServerFormat = (agFilterModel) => {
const kendoFilters = Object.keys(agFilterModel).map((colId) => {
const filter = agFilterModel[colId];
let operator;
switch (filter.filterType) {
case 'text':
operator = convertNumberFilterType(filter.type);
break;
case 'number':
operator = convertNumberFilterType(filter.type);
break;
case 'boolean':
operator = 'eq';
break;
case 'date':
operator = convertNumberFilterType(filter.type);;
break;
default:
operator = 'eq';
}
return {
field: colId,
operator: operator,
value: filter.filter
};
});
return {
logic: 'and',
filters: kendoFilters
};
};
// 숫자 필터 타입 변환 함수
const convertNumberFilterType = (type) => {
switch (type) {
case 'equals':
return 'eq';
case 'notEqual':
return 'ne';
case 'lessThan':
return 'lt';
case 'lessThanOrEqual':
return 'lte';
case 'greaterThan':
return 'gt';
case 'greaterThanOrEqual':
return 'gte';
case 'contains':
return 'contains'
default:
return 'eq';
}
};
아래는 별도의 store 객체로 분리하여 서버 통신 모듈을 구성한 예이다.
import { API_HEADERS, authHeader, convertAgGridFilterToKendo } from "@/util/helpers";
import axios from "axios";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { useAlertStore } from "../../alert.store";
import { GroupModel } from "@/types/models/GroupModel";
import type { SortModelItem } from 'ag-grid-community';
export const usePageableGroupsStore = defineStore("pageable-groups-store", () => {
// error state store
const alertStore = useAlertStore();
// state
const isLoaded = ref<boolean>(false);
const dataItems = ref<GroupModel[]>([]);
// pageable
const total = ref<number>(0);
const page = ref<number>(1);
const pageSize = ref<number>(20);
const sort = ref<SortModelItem[]>([]);
const filter = ref({ logic: "and", filters:[] });
// getters
const getById = computed(() => {
return (groupId: number) =>
dataItems.value.find((item) => item.groupId === groupId);
});
const getByName = computed(() => {
return (name: string) => dataItems.value.find((item) => item.name === name);
});
// setters
function setSort(newValue: SortModelItem[]) {
sort.value = newValue;
}
function setFilter(newValue:any) {
filter.value = convertAgGridFilterToKendo(newValue) ;
}
function setPage(newVal: number): void {
if (page.value === newVal) return;
page.value = newVal;
isLoaded.value = false;
}
// actions
async function loadById(groupId: number) {
const headers = { ...API_HEADERS, ...authHeader() };
const category = await axios.get(
`${ import.meta.env.VITE_API_URL }/data/secure/mgmt/security/groups/${groupId}`,
{ headers: headers }
)
.then((response) => {
const data = response.data;
return data;
})
.catch((err) => {
alertStore.error(err);
});
return category;
}
async function saveOrUpdate(item: GroupModel) {
const isNew = item.groupId === 0;
const headers = { ...API_HEADERS, ...authHeader() };
await axios
.post(
`${import.meta.env.VITE_API_URL}/data/secure/mgmt/security/groups/${item.groupId}`,
JSON.stringify(item),
{ headers: headers }
)
.then((response) => {
const dataItems = response.data;
isLoaded.value = false;
})
.catch((err) => {
alertStore.error(err);
});
}
function sortString () {
if( sort.value.length > 0 ) {
return sortField() + ',' + sortDir()||''.toUpperCase();
}
return null;
}
function sortField() {
if (sort.value.length > 0) {
return sort.value[0].colId;
}
return null;
}
function sortDir() {
if (sort.value.length > 0) {
return sort.value[0].sort ;
}
return null;
}
function pagableParams(){
return {
page: page.value,
size: pageSize.value,
sort: sortString(),
}
}
function filterParams() {
var params = {};
if (filter.value.filters.length > 0) {
filter.value.filters.forEach( item =>{
params[item.field] = item.value;
})
}
return params;
}
async function fetch() {
const headers = { ...API_HEADERS, ...authHeader() };
if( filter.value.filters.length > 0){
return await axios.post(
`${import.meta.env.VITE_API_URL}/data/secure/mgmt/security/groups:find`,
JSON.stringify(filter.value),
{
params: {
...pagableParams(),
},
headers: headers })
.then((response) => {
const data = response.data;
dataItems.value = [];
total.value = data.totalElements;
data.content.forEach((item) => {
dataItems.value.push(item);
});
isLoaded.value = true;
}).catch( err => {
alertStore.error(err);
});
}else{
await axios
.get( `${import.meta.env.VITE_API_URL}/data/secure/mgmt/security/groups`, {
params: {
...pagableParams()
},
headers: headers })
.then((response) => {
const data = response.data;
dataItems.value = [];
total.value = data.totalCount;
data.items.forEach((item) => {
dataItems.value.push(item);
});
isLoaded.value = true;
})
.catch((err) => {
alertStore.error(err);
});
}
}
return {
isLoaded,
dataItems,
total,
page,
pageSize,
getById,
getByName,
setFilter,
loadById,
fetch,
saveOrUpdate,
setPage,
setSort,
};
});
서버 프로그램
Vue 프로그램에서 요청을 처리하기 위한 서버 프로그램은 스프링 컨트롤러를 이용하여 코딩하면 된다. 아래 컨트롤러 예제는 페이징과 소팅 값은 파라메터 형태로 필터 값은 바디 형태로 바인딩 하고 있다. (-> 페이징 및 소팅은 Spring 에서 제공하는 Pageable 을 활용하여 바인딩)
@PostMapping(value = "/data", produces = MediaType.APPLICATION_JSON_VALUE)
public Page<Group> findGroups(@RequestBody FilterModel filter, @PageableDefault(size = 100, sort = "userId", direction = Sort.Direction.DESC) Pageable pageable) {
return groupService.findGroups(filter.getFilters(), pageable);
}
필터는 별도의 필터 클래스를 정의하여 사용한다. 위 코드에서 groupService 는 에 해당하는 소스는 데이터베이스에서 데이터를 조회하기 위하여 JPA Repositry 을 기반으로 하는 GroupRepository 을 호출하게 된다. GroupRepository 클래스는 필터에 따른 동적 쿼리를 지원하기 위하여 JpaRepository 와 JpaSpecificationExecutor 을 상속 받도록 한다.
groupService 서비스 클래스는 ⑴ 필터 객체(FilterModel) 배열과 페이징(Pageable) 객체를 인자로 받아 ⑵ Specification 객체를 생성하고 ⑶ Repository 객체의 findAll 함수를 호출하여 Page 형식으로 데이터를 응답받아 리턴하는 형태로 구현한다.
댓글 없음:
댓글 쓰기