상황: 빈번한 상태 변경이 발생하는 도메인 구조
MyItem은 아이템 보관 정보 담당하고, 그중 현재 사용 중인 아이템은 UsingItem 테이블
(MyItem <-> UsingItem은 1:1 관계)
*숲 꾸미기 서비스 개요 참고
[PlantiFy/Item Service] 숲 꾸미기 서비스 - JPA N+1 문제 및 해결
숲 꾸미기 서비스 개요ERD 설계아이템을 캐시로 구매 -> 보유 -> 실제 공간에 배치ex. 싸이월드 미니룸Item (상점 아이템) └─ MyItem (사용자가 보유한 아이템) └─ UsingItem (공간에 배치된 아이템)Ite
debug.tistory.com
기존 REST 구조
사용 중인 아이템 하나당 서버를 호출하는 REST API 구조
POST /v1/items/my-items/using-items
PUT /v1/items/my-items/using-items/{usingItemId}
DELETE /v1/items/my-items/using-items/{usingItemId}
첫 번째 고민: Action 기반 REST API
서버 부하를 줄이기 위해 하나의 요청에 여러 작업을 담는 API
요청 바디에 `CREATE`, `UPDATE`, `DELETE` action을 함께 전달하는 방식
POST /v1/items/my-items/using-items/action
문제
- 서버가 action 조합별 분기 로직 모두 처리해야 함
- 요청 구조 복잡해질수록 검증 로직과 예외 케이스 ↑
- API 의도 점점 불명확해짐
두 번째 고민: 편집 모드
편집 버튼 -> 수정 -> 완료 시 한 번에 서버 전송하는 방식
문제
- 서버 부하는 줄어들지만 사용자가 아이템 즉각적으로 수정할 수 없는 UX
- Drag & Drop의 직관성 X
해결: GraphQL 도입
UI 행위(action) 단위로 요청을 표현할 수 있는 GraphQL 도입
단일 Mutation으로 작업 통합
- Create / Update / Delete를 하나의 `mutation`으로 처리
- Drag & Drop 한 번 = 네트워크 요청 한 번
- 단일 엔드포인트로 복잡한 작업을 명확하게 표현
추가: Subscription으로 상태 동기화
아이템 변경 결과 -> GraphQL `subscription` 통해 클라이언트로 실시간 전파
- 서버가 변경된 정답 상태를 push
- 클라이언트는 polling이나 재조회 없이 UI 반영
- 상태 불일치 문제 ↓
*GraphQL 참고
GraphQL | A query language for your API
Client-specified response A GraphQL service publishes the capabilities that its clients are allowed to consume. It is the client who control the data they receive, requesting only what they need at a field level, unlike traditional fixed endpoints.
graphql.org
코드 확인
GraphQL Schema
type Query {
getAllUsingItemsByUser: [UsingItemOutput!]!
}
type Mutation {
manageUsingItems(actions: [UsingItemActionInput!]!): [UsingItemOutput!]!
}
input UsingItemActionInput {
action: String!
usingItemId: ID
myItemId: ID
posX: Float
posY: Float
}
type UsingItemOutput {
id: ID!
myItemId: ID!
userId: ID!
imageUri: String!
posX: Float!
posY: Float
category: String!
createdAt: String!
updatedAt: String!
}
type Subscription {
usingItemUpdates(userId: ID!): UsingItemOutput!
}
Mutation
`UsingItemUserController` 중 `manageUsingItems` 메서드
@MutationMapping
public List<UsingItemOutput> manageUsingItems(@Argument List<UsingItemActionInput> actions) {
List<UsingItemOutput> results = usingItemUserService.manageUsingItems(actions);
results.forEach(usingItemSink::tryEmitNext);
return results;
}
`UsingItemUserServiceImpl` 중 `manageUsingItems` 메서드
- 단일 mutation 요청을 여러 action으로 분해하되, 각 action은 독립적으로 처리되도록 설계
- 서비스 레벨에서 트랜잭션을 적용해 도중 예외 발생 시 모든 변경 사항이 함께 롤백
- GraphQL 단일 요청이 DB 관점에서도 하나의 원자적 작업으로 처리되도록 보장
- 한 mutation = 하나의 원자적 작업
<트랜잭션 없을 때 문제>
1) CREATE 성공 (MyItem 수량 -1, UsingItem 생성됨)
2) UPDATE 성공
3) DELETE 중 예외 발생 (ITEM_NOT_FOUND)
=> 1, 2번은 이미 DB에 반영/ 3번에서 예외 터져도 롤백 X
클라이언트는 요청 실패를 받았지만, DB 상태는 중간 상태(partial commit)
@Override
@Transactional
public List<UsingItemOutput> manageUsingItems(List<UsingItemActionInput> actions) {
Long userId = userInfoProvider.getUserInfo().userId();
return actions.stream()
.map(action -> processAction(action, userId))
.filter(Objects::nonNull)
.toList();
}
private UsingItemOutput processAction(UsingItemActionInput action, Long userId) {
switch (action.action().toUpperCase()) {
case "CREATE":
return createUsingItem(action, userId);
case "UPDATE":
return updateUsingItem(action, userId);
case "DELETE":
deleteUsingItem(action, userId);
return null;
default:
throw new IllegalArgumentException("Invalid action type: " + action.action());
}
}
private UsingItemOutput createUsingItem(UsingItemActionInput action, Long userId) {
MyItem myItem = myItemRepository.findMyItemByMyItemIdAndUserId(action.myItemId(), userId)
.orElseThrow(() -> new ApplicationException(ItemErrorCode.ITEM_NOT_FOUND));
long availableQuantity = myItem.getQuantity() - usingItemRepository.countByMyItem_MyItemId(myItem.getMyItemId());
if (availableQuantity <= 0) {
throw new ApplicationException(ItemErrorCode.INVALID_ITEM_DATA);
}
UsingItem newUsingItem = action.CreateUsingItem(myItem);
myItem.updateQuantity(myItem.getQuantity() - 1);
myItemRepository.save(myItem);
return UsingItemOutput.from(usingItemRepository.save(newUsingItem));
}
private UsingItemOutput updateUsingItem(UsingItemActionInput action, Long userId) {
UsingItem usingItem = usingItemRepository.findByUsingItemIdAndUserId(action.usingItemId(), userId)
.orElseThrow(() -> new ApplicationException(ItemErrorCode.ITEM_NOT_FOUND));
UsingItem updatedUsingItem = action.UpdateUsingItem(usingItem);
return UsingItemOutput.from(usingItemRepository.save(updatedUsingItem));
}
private void deleteUsingItem(UsingItemActionInput action, Long userId) {
UsingItem usingItem = usingItemRepository.findByUsingItemIdAndUserId(action.usingItemId(), userId)
.orElseThrow(() -> new ApplicationException(ItemErrorCode.ITEM_NOT_FOUND));
MyItem myItem = usingItem.getMyItem();
myItem.updateQuantity(myItem.getQuantity() + 1);
myItemRepository.save(myItem);
usingItemRepository.deleteById(action.usingItemId());
}
Subscription
`UsingItemUserController` 중 `usingItemUpdates` 메서드
- 서버에서 확정된 상태 변경 결과 기준으로 클라이언트 UI 동기화
- `userId` 기준 필터링으로 불필요한 이벤트 전파와 타 사용자 데이터 노출 가능성 차단
- 향후 팔로우 기능을 통해 다른 사용자의 숲을 구경하는 경우에도, 구독 대상 유저의 상태 변경 이벤트만 전달
<현재 구조>
1) A가 구독
2) B가 숲을 수정 ->
3) A는 아무 이벤트도 받지 않음 = 자기 숲 이벤트만 받음
<팔로우 기능 추가할 경우>
1) A가 B의 숲을 구경
2) B가 숲을 수정
3) A는 B의 숲 변경을 실시간으로 봄 = A는 B의 숲을 구경 중일 때만 B의 이벤트를 받음
=> 불필요한 네트워크 전송 X, 다른 유저 데이터 노출 X
@SubscriptionMapping
public Flux<UsingItemOutput> usingItemUpdates(@Argument Long userId) {
return usingItemSink.asFlux()
.filter(update -> update.userId().equals(userId));
}
참고: GraphQL Schema의 Action
`String` 문제점
- 오타 가능
- 런타임에만 오류 발견
- action 타입이 늘어날수록 분기 취약
=> `Enum`으로 변경하면 클라이언트는 정해진 값만 보낼 수 있고, 스키마 레벨에서 검증됨
GraphQL Schema를 Enum으로 변경
GraphQL enum 추가
enum UsingItemActionType {
CREATE
UPDATE
DELETE
}
Input 수정
input UsingItemActionInput {
action: UsingItemActionType!
usingItemId: ID
myItemId: ID
posX: Float
posY: Float
}
Java 쪽 Enum 추가
`UsingItemActionType`
public enum UsingItemActionType {
CREATE,
UPDATE,
DELETE
}
After: `UsingItemActionInput`
public record UsingItemActionInput(
UsingItemActionType action,
Long usingItemId,
Long myItemId,
Float posX,
Float posY
) {}
깃허브
플랜티파이
플랜티파이 has 21 repositories available. Follow their code on GitHub.
github.com
GitHub - hk-plantify/item-service: [Java] 숲꾸미기 아이템 서비스(+ cash repo)
[Java] 숲꾸미기 아이템 서비스(+ cash repo). Contribute to hk-plantify/item-service development by creating an account on GitHub.
github.com