◼︎ 환경
- 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 형식으로 데이터를 응답받아 리턴하는 형태로 구현한다.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package architecture.user; | |
import java.util.Date; | |
import java.util.Map; | |
import javax.persistence.CollectionTable; | |
import javax.persistence.Column; | |
import javax.persistence.ElementCollection; | |
import javax.persistence.Entity; | |
import javax.persistence.FetchType; | |
import javax.persistence.GeneratedValue; | |
import javax.persistence.GenerationType; | |
import javax.persistence.Id; | |
import javax.persistence.JoinColumn; | |
import javax.persistence.MapKeyColumn; | |
import javax.persistence.Table; | |
import javax.persistence.Transient; | |
import org.springframework.data.annotation.CreatedDate; | |
import org.springframework.data.annotation.LastModifiedDate; | |
import com.fasterxml.jackson.annotation.JsonFormat; | |
import com.fasterxml.jackson.annotation.JsonProperty; | |
import lombok.Data; | |
import lombok.NoArgsConstructor; | |
@Entity(name="Group") | |
@Data | |
@Table(name = "GROUP") | |
@NoArgsConstructor | |
public class DefaultGroup implements Group { | |
@Id // tell persistence provider 'id' is primary key | |
@Column(name = "GROUP_ID", nullable = false) | |
@GeneratedValue( // tell persistence provider that value of 'id' will be generated | |
strategy = GenerationType.IDENTITY // use RDBMS unique id generator | |
) | |
Long groupId; | |
@Column(name = "NAME", nullable = false) | |
String name; | |
@Column(name = "DESCRIPTION") | |
String description; | |
@Transient | |
@JsonProperty | |
Long memberCount ; | |
@CreatedDate | |
@Column(name = "CREATION_DATE", updatable = false) | |
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") | |
Date creationDate; | |
@LastModifiedDate | |
@Column(name = "MODIFIED_DATE") | |
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") | |
Date modifiedDate; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package architecture.ee.model; | |
import java.util.List; | |
import lombok.AllArgsConstructor; | |
import lombok.Data; | |
import lombok.NoArgsConstructor; | |
@Data | |
@NoArgsConstructor | |
@AllArgsConstructor | |
public class FilterModel { | |
private String field; | |
private String type; | |
private String filterType; | |
private String operator; | |
private Object value; | |
private List<FilterModel> filters; | |
private String logic; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package architecture.user; | |
import java.util.Date; | |
public interface Group { | |
public abstract String getName(); | |
public abstract void setName(String name); | |
public abstract Long getGroupId(); | |
public abstract void setGroupId(Long groupId); | |
public abstract void setDescription( String name); | |
public abstract String getDescription(); | |
public abstract Date getCreationDate(); | |
public abstract Date getModifiedDate(); | |
public abstract void setCreationDate(Date date); | |
public abstract void setModifiedDate(Date date); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package architecture.user.dao.jpa; | |
import org.springframework.data.domain.Page; | |
import org.springframework.data.domain.Pageable; | |
import org.springframework.data.jpa.repository.JpaRepository; | |
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; | |
import org.springframework.stereotype.Repository; | |
import architecture.user.DefaultGroup; | |
@Repository(GroupRepository.SERVICE_NAME) | |
public interface GroupRepository extends JpaRepository<DefaultGroup, Long> , JpaSpecificationExecutor<DefaultGroup> { | |
public static final String SERVICE_NAME = "components:group-repository"; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package architecture.web.spring.web.controller.data.secure.mgmt.security; | |
import java.util.ArrayList; | |
import java.util.List; | |
import java.util.Map; | |
import java.util.Optional; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.beans.factory.annotation.Qualifier; | |
import org.springframework.data.domain.Page; | |
import org.springframework.data.domain.Pageable; | |
import org.springframework.data.domain.Sort; | |
import org.springframework.data.web.PageableDefault; | |
import org.springframework.http.MediaType; | |
import org.springframework.security.access.annotation.Secured; | |
import org.springframework.web.bind.annotation.GetMapping; | |
import org.springframework.web.bind.annotation.PathVariable; | |
import org.springframework.web.bind.annotation.PostMapping; | |
import org.springframework.web.bind.annotation.RequestBody; | |
import org.springframework.web.bind.annotation.RequestMapping; | |
import org.springframework.web.bind.annotation.RequestParam; | |
import org.springframework.web.bind.annotation.ResponseBody; | |
import org.springframework.web.bind.annotation.RestController; | |
import org.springframework.web.context.request.NativeWebRequest; | |
import architecture.ee.model.FilterModel; | |
import architecture.user.Group; | |
import architecture.user.GroupService; | |
import lombok.extern.slf4j.Slf4j; | |
@Slf4j | |
@RestController("controllers:mgmt:security:groups-data-controller") | |
@RequestMapping("/data/secure/mgmt/security") | |
public class GroupsDataController { | |
@Autowired(required = false) | |
@Qualifier(GroupService.SERVICE_NAME) | |
private PageableGroupService groupService; | |
@PostMapping(value = "/groups:find", 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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package architecture.user; | |
import java.util.ArrayList; | |
import java.util.Calendar; | |
import java.util.Date; | |
import java.util.List; | |
import java.util.concurrent.ExecutionException; | |
import java.util.concurrent.TimeUnit; | |
import java.util.stream.Collectors; | |
import org.apache.commons.lang3.StringUtils; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.beans.factory.annotation.Qualifier; | |
import org.springframework.data.domain.Page; | |
import org.springframework.data.domain.Pageable; | |
import org.springframework.data.jpa.domain.Specification; | |
import org.springframework.stereotype.Service; | |
import architecture.ee.model.FilterModel; | |
import architecture.user.dao.jpa.GroupRepository; | |
import lombok.NoArgsConstructor; | |
import lombok.extern.slf4j.Slf4j; | |
@Slf4j | |
@NoArgsConstructor | |
@Service(GroupService.SERVICE_NAME) | |
public class PageableGroupService { | |
@Autowired | |
@Qualifier(GroupRepository.SERVICE_NAME) | |
private GroupRepository groupRepository; | |
public Page<Group> findGroups(List<FilterModel> filters, Pageable pageable) { | |
Specification<DefaultGroup> spec = SpecificationUtils.byFilterModel(filters); | |
return toGroup( groupRepository.findAll(spec, pageable) ); | |
} | |
private Page<Group> toGroup(Page<DefaultGroup> foundList) { | |
return new org.springframework.data.domain.PageImpl<Group>( | |
foundList.getContent().stream().map( u -> (Group) u ).collect(Collectors.toList()), | |
foundList.getPageable() , | |
foundList.getTotalElements()); | |
} | |
} |
댓글 없음:
댓글 쓰기