프로젝트를 진행 중, API의 기능에 대한 질문을 했습니다. API의 역할 중 '상태 동기화'라는 새로운 패턴을 배우기까지의 과정을 기록해 보려고 합니다.


문제 : 몇 개의API를 만들어야 하는가?

관리자 페이지에서 '방' 목록을 자유롭게 편집(추가, 수정, 삭제)하고 '저장' 버튼을 누르면 그 결과를 서버에 반영하는 기능을 개발해야 합니다.


'새로 추가된 방은 POST로 보내고, 수정된 건 PATCH로, 삭제된 건 DELETE로 각각 API를 호출해야 한다' 라고 생각을 하였고, '클라이언트에서 방에 대하여 추가, 수정, 삭제하는로직이 필요하다' 라고 생각을 하였습니다.

 

그러나,  "여러 개의 로직을 만들 필요 없이, PUT API 하나로 클라이언트에서 최종 방 목록 전체를 PUT으로 보내면, 서버에서 기존 데이터와 비교해서 한 번에 처리하면 된다"라는 동료의 의견을 얻었습니다.

원인 분석

처음에는 동료의 의견에 대하여 이해하지 못했습니다.
'PUT은 상태 변경에 대하여 업데이트를 하는 api이고 어떤 방이 삭제되었는지에 대한 정보가 없으므로, 서버가 어떻게 삭제를 하는 것인가? DELETE API는 꼭 필요하지 않은가?' 라고 생각이 되었습니다.

 

이후, 동료에게 PUT 메소드에 대해 설명을 들었으며 그 내용은 "PUT은 단순히 데이터를 '수정'하는 것이 아니라, 리소스의 상태를 요청받은 상태로 '교체'하는 역할이라는 것" 입니다.

 

즉, 서버의 이전 상태가 어땠는지는 중요하지 않고 리소스의 최종 상태는 내가 보낸 모습과 똑같이 만든다는 것 입니다.

DELETE API가 필요 없는 이유는 '삭제'라는 행위 자체가 PUT의 '교체'라는 개념 안에 이미 포함되어 있기 때문입니다. 최종 목록에 없는 데이터는 자연스럽게 '제거되어야 할 대상'입니다.

 

이 설명을 듣고 다음과 같은 서버의 로직을 생각했습니다.

'상태 동기화' 패턴의 서버 로직

  1. 현실 파악 (SELECT): 먼저, DB에서 '기존 상태'의 방 목록을 전부 가져온다.
  2. 차이점 분석 (Compare): 클라이언트가 보낸 '최종 상태'와 '기존 상태'를 비교한다.
  3. 작업 수행 (Execute): 비교 결과를 바탕으로 필요한 C/U/D 작업을 한 번에 처리한다.
    Create: '최종 상태'에만 있는 방은 새로 추가한다.
    Update: 양쪽 모두에 있는 방은 정보를 갱신한다. (UPSERT)
    Delete: '기존 상태'에만 있던 방은 삭제한다.

해결

1. 클라이언트 : 최종 상태만 전송

클라이언트는 현재 화면에 보이는 rooms 배열을 PUT을 요청합니다. 복잡한 상태를 추적할 필요가 없습니다.

const spaceSave = async () => {
  try {
    await fetch(`/api/admin/spaces/${spaceId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        spaceName: adminName,
        // 현재 클라이언트의 rooms 상태 전체를 그대로 전달
        rooms: rooms.map(room => ({
          roomId: typeof room.id === 'number' ? room.id : undefined,
          name: room.name ?? '',
          // ... 나머지 데이터
        })),
      }),
    });
    alert('저장되었습니다.');
  } catch (error) {
    console.error('저장 실패:', error);
  }
};

2. 서버: 상태 동기화 로직 수행

서버의 PUT 핸들러가 '상태 동기화' 역할을 수행합니다.

export async function PUT(request: NextRequest, { params }) {
  try {
    const spaceId = Number(params.spaceId);
    const { spaceName, rooms: incomingRooms } = await request.json();

    // 1. DB에서 기존 방 목록 조회
    const existingRooms = await roomRepository.findBySpaceId(spaceId) ?? [];
    const existingRoomIds = new Set(existingRooms.map(r => r.id as number));

    // 2. 요청과 비교하여 C/U/D 목록 분류
    const incomingRoomIds = new Set(incomingRooms.filter(r => r.roomId).map(r => r.roomId));
    
    const roomsToCreate = incomingRooms.filter(r => !r.roomId);
    const roomsToUpdate = incomingRooms.filter(r => r.roomId);
    const roomIdsToDelete = [...existingRoomIds].filter(id => !incomingRoomIds.has(id));

    // 3. DB 작업 실행
    if (roomsToCreate.length > 0) { /* ... saveAll 로직 ... */ }
    if (roomsToUpdate.length > 0) { /* ... upsert 로직 ... */ }
    if (roomIdsToDelete.length > 0) { /* ... deleteByIds 로직 ... */ }

    return NextResponse.json({ message: '성공적으로 업데이트되었습니다.' });
  } catch (error) {
    return NextResponse.json({ error: '업데이트 실패' }, { status: 500 });
  }
}

정리

'상태 동기화' 패턴은 상황에 잘 맞추어서 사용해야 합니다.

장점 : 게시물의 태그 목록, 장바구니, 설문조사 항목처럼 하나의 리소스에 속한 여러 자식 요소들을 한 화면에서 자유롭게 편집할 때  권장됩니다. 클라이언트 로직이 단순해지며 단일 API 호출로 데이터 정합성을 지키기 쉬워집니다.

단점 : 데이터 양이 아주 많을 때는 전체 목록을 보내는 것이 비효율적입니다. 하나의 데이터만 바뀌어도 전체를 바꿔야 하는 문제가 생길 수 있으며, 이러한 경우에는 PATCH 같은 다른 방법을 고민해야 합니다.


간단한 CRUD 기능만 구현하는 작은 프로젝트이거나 개발 일정이 매우 촉박한 경우에는 사용을 추천합니다.

그러나,  '기존 데이터 조회 -> 비교 -> C/U/D 실행' 이라는 비교적 복잡한 로직 구현을 거쳐야 합니다. 이 구현 비용이 프로젝트의 규모나 일정에 비해 과도할 수 있습니다.

728x90