
이제 관리자페이지를 생성해보자. 관리자의 권한으로 할 수 있는 것은 상품을 추가, 수정, 삭제 정도일 것 같다.
관리자페이지에 대해서 인증권한을 부여하여, 로그인을 하게끔 구현하면 좋을 것 같지만, 우선 기능 구현부터 해보자.
✅ Mutation in Resolver
id는 uuid()로 독립적인 id값을 갖게 하고, createdAt은 현재날짜로 설정하여, DB의 상품데이터에 추가되도록 한다.// src/resolvers/product.ts
const setJSON = (data: Products) => writeDB(DBField.PRODUCTS, data);
const productResolver: Resolver = {
...
Mutation: {
addProduct: async (parent,{ imageUrl, price, title, description },{ db }) => {
const newProduct = {
id: uuid(),
imageUrl,
price,
title,
description,
createdAt: Date.now(),
};
db.products.push(newProduct);
setJSON(db.products);
return newProduct;
},
...
},
};
✅ Schema
imageUrl, Price, Title, Description, 모두 필수값으로 설정,// src/schema/product.ts
extend type Mutation {
addProduct(
imageUrl: String!
price: Int!
title: String!
description: String!
): Product!
....
}
`;
✅ Apollo Server 확인

✅ Mutation in Resolver
findIndex를 통해 수정하고자 하는 상품의 id와 일치하는 상품을 찾고, splice를 통해 새로운값으로 수정// src/resolvers/product.ts
const setJSON = (data: Products) => writeDB(DBField.PRODUCTS, data);
const productResolver: Resolver = {
...
Mutation: {
updateProduct: (parent, { id, ...data }, { db }) => {
const existProductIndex = db.products.findIndex((item) => item.id === id);
if (existProductIndex < 0) {
throw new Error("없는 상품입니다.");
}
const updateItem = {
...db.products[existProductIndex],
...data,
};
db.products.splice(existProductIndex, 1, updateItem);
setJSON(db.products);
return updateItem;
},
...
},
};
✅ Schema
// src/schema/product.ts
extend type Mutation {
...
updateProduct(
id: ID!
imageUrl: String
price: Int
title: String
description: String
): Product!
...
}
✅ Apollo Server 확인

한가지 고려할 것은 상품을 삭제하면, 어떻게 처리할 것인지이다.
1️⃣ 상품 삭제시 실제로 DB에서도 해당 상품의 데이터가 삭제. (영구삭제)
2️⃣ Customer 페이지에서는 삭제된 상품이 보이지 않고, Admin 페이지에서는 삭제된 상품이라는 표시.
나는 2️⃣ 번의 방법을 선택하였고, 삭제된 것을 확인하기 위해 상품의 createdAt을 삭제하도록 하자. 그렇다면, createdAt이 존재하지 않는 상품은 삭제한 상품이라고 인식할 것이다.
✅ Mutation in Resolver
새롭게 추가되는 상품의 id는 uuid()로 독립적인 id값을 갖게 하고, createdAt은 현재날짜로 설정하여, DB의 상품데이터에 추가되도록 한다.
// src/resolvers/product.ts
const setJSON = (data: Products) => writeDB(DBField.PRODUCTS, data);
const productResolver: Resolver = {
...
Mutation: {
deleteProduct: (parent, { id }, { db }) => {
const existProductIndex = db.products.findIndex((item) => item.id === id);
if (existProductIndex < 0) {
throw new Error("없는 상품입니다.");
}
const deleteItem = {
...db.products[existProductIndex],
};
delete deleteItem.createdAt; // createdAt만 삭제
db.products.splice(existProductIndex, 1, deleteItem);
setJSON(db.products);
return id;
},
...
},
};
✅ Schema
// src/schema/product.ts
extend type Mutation {
...
deleteProduct(id: ID!): ID!
}
✅ Apollo Server 확인

createdAt이 삭제되는 기능은 구현하였다.
그럼 이제 앞서 말했듯이 createdAt이 삭제된 상품은 상품목록에 보이지 않도록 해주어야 한다.
✅ Query in Resolver
기본값인 showDeleted을 받는다.
showDeleted가 true : 모든 상품을 보여준다.( = db.products)
showDeleted가 false : 삭제된 상품 빼고 보여준다.
db.products.filter((product) => product.createdAt)// src/resolvers/product.ts
const setJSON = (data: Products) => writeDB(DBField.PRODUCTS, data);
const productResolver: Resolver = {
Query: {
products: async (parent, { cursor = "", showDeleted = false }, { db }) => {
const filteredDB = showDeleted
? db.products
: db.products.filter((product) => product.createdAt);
const fromIndex =
filteredDB.findIndex((product) => product.id === cursor) + 1;
return filteredDB.slice(fromIndex, fromIndex + 15) || [];
},
...
},
};
✅ Testing
만약 상품 1의 createdAt을 삭제하고, 상품리스트를 출력하면 다음과 같이 상품1을 제외하고 나머지 상품들이 출력되는 것을 확인할 수 있다.

앞서 말했듯이 createdAt의 삭제에 따라, Customer 페이지에는 상품이 삭제된 것처럼하고, Admin 페이지에는 상품을 보여주되 삭제된 상품이라는 표시를 줄 것이다.
우선, Admin 페이지로 이동했을 때, Product 페이지의 상품리스트가 보이는 것처럼 상품들을 가져와야 한다.
✅ 단, 하나 다른 점은 showDeleted값을 전달하는 것이다.
showDeleted는 true가 되므로 admin 페이지에서는 모든 상품을 보이게 한다.// server/src/resolves/product.ts
const filteredDB = showDeleted
? db.products // true
: db.products.filter((product) => product.createdAt); // false
// client/src/pages/admin/index.tsx
const AdminPage = () => {
...
const { data, isSuccess, isFetchingNextPage, fetchNextPage, hasNextPage } =
useInfiniteQuery<Products>(
QueryKeys.PRODUCTS,
({ pageParam = "" }) =>
// showDeleted값 전달
graphqlFetcher(GET_PRODUCTS, { cursor: pageParam, showDeleted: true }),
{
getNextPageParam: (lastPage) => {
return lastPage.products[lastPage.products.length - 1]?.id;
},
}
);
...
export default AdminPage;
👎 하지만, 현재 Product페이지에 가져오는 상품리스트와 Admin페이지에서 가져오는 상품리스트가 같게 출력된다.
아래와 같이 모두 상품1이 제외된 모든 상품이 출력된다.
왜냐하면 둘 다 공통된 graphql을 사용하고, 같은 QueryKey값을 주고 있기 때문이다.

👍 해결방법은 Query Key값을 고유한 값으로 설정하여 서로 다른 데이터라는 것을 인지시키기
// Product Page
useInfinityQuery<Products>(
[QueryKeys.PRODUCTS, "products"],
...
)
// Admin Page
useInfinityQuery<Products>(
[QueryKeys.PRODUCTS, "admin"],
...
)
✅ 변수가 있는 배열 키
쿼리가 해당 데이터를 고유하게 설명하기 위해 추가 정보가 필요한 경우 문자열과 일련화 가능한 개체 수를 설명하는 배열을 사용할 수 있다. (참고)
// An individual todo
useQuery({ queryKey: ['todo', 5], ... })
// An individual todo in a "preview" format
useQuery({ queryKey: ['todo', 5, { preview: true }], ...})
// A list of todos that are "done"
useQuery({ queryKey: ['todos', { type: 'done' }], ... })
✅ 쿼리 키는 결정적으로 해시가 된다
useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ...})
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })
useQuery({ queryKey: ['todos', status, page], ... })
useQuery({ queryKey: ['todos', page, status], ...})
useQuery({ queryKey: ['todos', undefined, page, status], ...})
✅ 서로 다른 요청이라는 것을 인지하고 잘 작동하는 것을 확인

이제 실제 Admin 페이지에서 상품을 추가, 수정, 삭제 해보자.
일반적으로 customer에게 보이는 상품리스트에 추가버튼, 수정버튼, 삭제 버튼 같은 UI를 추가하면 Admin 페이지를 완성할 수 있을 것 같다.
✅ UI 생성
// src/components/admin/addForm.tsx
const AddForm = () => {
...
return (
<form onSubmit={handleSubmit}>
<label>
상품명: <input name="title" type="text" required />
</label>
<label>
이미지URL: <input name="imageUrl" type="text" required />
</label>
<label>
상품가격: <input name="price" type="number" required min="1000" />
</label>
<label>
상세: <textarea name="description" />
</label>
<button type="submit">등록</button>
</form>
);
}
✅ handleSubmit 구현
// src/components/admin/addForm.tsx
...
const AddForm = () => {
const handleSubmit = (e: SyntheticEvent) => {
e.preventDefault();
const formData = arrToObj([...new FormData(e.target as HTMLFormElement)]);
formData.price = Number(formData.price);
addProduct(formData as MutableProduct);
};
...
}


💡 배열에서 객체로 변경시키는 유틸함수 생성
// src/util/arrToobj.ts const arrToObj = (arr: [string, any][]) => arr.reduce<{ [key: string]: any }>((res, [key, val]) => { res[key] = val; return res; }, {}); export default arrToObj;
✅ addProduct 구현
상품을 추가하였으므로 새롭게 데이터를 가져온다. 이때 invalidate(무효화)되는 것을 막기 위해 exact, refetchInactive를 설정한다.
refetchInactive : 기본값은 false로, true로 설정하면 refetch 대상 또는 랜더링되지 않은 쿼리가 모두 유효하지 않다고 판단하여 새롭게 랜더링한다.exact: 기본값은 true로, false로 설정하여 변경에 대하여 더 탐색이 필요하다고 설정.// src/components/admin/addForm.tsx
...
const AddForm = () => {
const queryClient = getClient();
const { mutate: addProduct } = useMutation(
({ title, imageUrl, price, description }: MutableProduct) =>
graphqlFetcher(ADD_PRODUCT, { title, imageUrl, price, description }),
{
onSuccess: () => {
queryClient.invalidateQueries(QueryKeys.PRODUCTS, {
exact: false,
refetchInactive: true,
});
},
}
);
...
}
// src/graphql/product.ts export const ADD_PRODUCT = gql` mutation ADD_PRODUCT( $imageUrl: String! $price: Int! $title: String! $description: String! ) { addProduct( imageUrl: $imageUrl price: $price title: $title description: $description ) { id imageUrl price title description createdAt } } `;
✅ Admin Page
수정할 상품의 Index를 담을 State 생성
수정 시작과 완료 함수 생성
startEdit : 인자로 상품의 index값이 담기는 메서드doneEdit : 완료 클릭 시 State의 index값 null로 초기화관리자페이지 상품리스트로 props 전달
// src/pages/admin.index.tsx
const AdminPage = () => {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
...
const startEdit = (index: number) => () => setEditingIndex(index);
const doneEdit = () => setEditingIndex(null);
return (
<div>
...
<AdminList
list={data?.pages || []}
editingIndex={editingIndex}
startEdit={startEdit}
doneEdit={doneEdit}
/>
...
</div>
);
};
✅ AdminList
startEdit={startEdit(i)} : 수정할 상품의 인덱스값 전달
isEditing={editingIndex === i}: 전달된 인덱스값과 동일한 상품을 찾기
// src/components/admin/list.tsx
const AdminList = ({list,editingIndex,startEdit, doneEdit}: {
list: { products: Product[]}[];
editingIndex: number | null;
startEdit: (index: number) => () => void;
doneEdit: () => void;
}) => (
<ul className="products">
{list.map((page) =>
page.products.map((product, i) => (
<AdminItem
{...product}
key={product.id}
isEditing={editingIndex === i}
startEdit={startEdit(i)}
doneEdit={doneEdit}
/>
))
)}
</ul>
);
✅ AdminItem
// src/components/admin/item.tsx
const AdminItem = ({ ... props 타입 정의 ...}) => {
const queryClient = getClient();
const { mutate: updateProduct } = useMutation(
({ title, imageUrl, price, description }: MutableProduct) =>
graphqlFetcher(UPDATE_PRODUCT, {id, title, imageUrl, price, description}),
{
onSuccess: () => {
queryClient.invalidateQueries(QueryKeys.PRODUCTS, {
exact: false,
refetchInactive: true,
});
doneEdit();
},
}
);
const handleSubmit = (e: SyntheticEvent) => {
e.preventDefault();
const formData = arrToObj([...new FormData(e.target as HTMLFormElement)]);
formData.price = Number(formData.price);
updateProduct(formData as MutableProduct);
};
if (isEditing)
return (
<li className="product-item">
... 수정 Form 태그 ...
</li>
);
return (
<li className="product-item">
...
<button className="product-item__add-cart" onClick={startEdit}>
수정
</button>
...
</li>
);
};
// src/graphql/product.ts export const UPDATE_PRODUCT = gql` mutation UPDATE_PRODUCT( $id: ID! $imageUrl: String $price: Int $title: String $description: String ) { updateProduct( id: $id imageUrl: $imageUrl price: $price title: $title description: $description ) { id imageUrl price title description createdAt } } `;
✅ 삭제할 상품의 id만 잘 전달하면 끝. 나머지는 Server에서 삭제해서 응답할 것이다.
// src/components/admin/list.tsx
const AdminItem = ({ ... props 타입 정의 ...}) => {
const queryClient = getClient();
...
const { mutate: deleteProduct } = useMutation(
(id: string) => graphqlFetcher(DELETE_PRODUCT, { id }),
{
onSuccess: () => {
queryClient.invalidateQueries(QueryKeys.PRODUCTS, {
exact: false,
refetchInactive: true,
});
},
}
);
...
const deleteItem = () => {
deleteProduct(id);
};
...
return (
<li className="product-item">
...
<button className="product-item__delete-cart" onClick={deleteItem}>
삭제
</button>
</li>
);
};
export default AdminItem;
// src/graphql/product.ts export const DELETE_PRODUCT = gql` mutation DELETE_PRODUCT($id: ID!) { deleteProduct(id: $id) } `;
생각해보니 이런 경우의 수가 발생할 수 있을 것 같다.
✅ 장바구니에 A라는 상품을 잘 담아놨는데, 관리자가 A 상품을 삭제시켰다면?
: 장바구니에 존재하는 A 상품을 수정하거나, 결제되지 않게 설정해줘야할 것이다.
// src/components/item.tsx
// 삭제 유무에 따라 체크 유무 설정
<input ... disabled={!createdAt} />
...
// 삭제됬다면, '삭제된 상품입니다' 그렇지 안다면 수량 조절 가능
{!createdAt ? (
<div>삭제된 상품입니다.</div>
) : (
<input
type="number"
className="cart-item__amount"
value={amount}
onChange={handlerUpdateAmount}
min={1}
/>
)}
