연동 · 4장 응답 계약 · CRUD

연동 4장 · 응답 계약 & CRUD 한 바퀴

프론트와 백엔드는 "응답을 이런 형식으로 주고받자"는 약속(계약)을 미리 정해둡니다. 이 약속이 무엇인지 보고, 그 위에서 목록 조회 → 생성 → 목록 자동 갱신이라는 CRUD 한 바퀴가 어떻게 도는지 따라갑니다.

시나리오

"사용자 목록 화면에서 새 사용자를 추가하면, 표가 알아서 새로고침된다" — 이 작은 흐름 안에 응답 계약과 CRUD의 핵심이 다 들어 있어요.

"응답 계약"이란

백엔드가 아무 형식으로나 JSON을 던지면 프론트는 매번 다르게 받아야 해서 코드가 지저분해집니다. 그래서 양쪽이 공통 형식을 약속해 둡니다. 백엔드는 ActionResult 라는 공통 래퍼로 감싸 보내고, 프론트는 그걸 SuccessResponse / ErrorResponse 타입으로 받아요.

비유 — 택배 상자. 안에 든 물건(데이터)이 무엇이든, 상자 겉면에는 늘 같은 송장이 붙어 있어요. "성공인가요?(ok)", "메시지", "내용물(data)". 받는 쪽은 송장 보는 법만 알면 어떤 상자든 처리할 수 있습니다.

백엔드의 약속 — ActionResult

백엔드 공통 래퍼 ActionResult 는 성공/실패를 ok 한 필드로 구분합니다.

// 백엔드: web/dto/ActionResult.java
public class ActionResult {
    private boolean ok = false;      // 성공 여부
    private String message = "";     // 메시지(실패 사유 또는 성공 문구)
    private String type;             // success / info / warn / error
    private Object data;             // 성공 시 전달할 데이터
    private List<ObjectError> errors;
}

// 성공: ok=true, data 포함
return ActionResult.ok(userDto);
// 실패: ok=false, message 포함
return ActionResult.fail("저장에 실패했습니다");

검증 오류는 형식이 다르다

입력 검증?(@Valid) 에 걸린 오류는 ActionResult 가 아니라 별도의 Map 형식으로 옵니다. 어떤 필드가 왜 틀렸는지를 담아요.

// 검증 오류 응답(400) — ActionResult가 아님
{
  "status": 400,
  "error": "Bad Request",
  "message": "입력이 올바르지 않습니다",
  "validationErrors": {
    "email": "올바른 이메일 형식이 아닙니다",
    "userid": "필수 입력입니다"
  }
}

프론트의 약속 — SuccessResponse / ErrorResponse

프론트(src/lib/api.ts)는 응답을 두 가지 타입 중 하나로 받습니다.

// 프론트: src/lib/api.ts
export interface SuccessResponse<T> {
  ok: true
  type: string
  message: string
  data: T | null
}
export interface ErrorResponse {
  ok: false
  message: string
  status: number
  errors?: Record<string, string>   // 검증 오류 시 채워짐
}

400 검증 오류가 오면, 위의 validationErrors 를 꺼내 errors 필드로 옮겨 담습니다(api.ts 의 검증 분기). 그래서 폼은 errors 만 보면 어떤 입력칸에 메시지를 띄울지 알 수 있어요. 한편 403(권한 없음) 은 화면 곳곳에서 공통으로 토스트를 띄웁니다.

주의 — get과 post의 반환 형태가 다르다

같은 api 객체라도 메서드마다 반환이 달라요(src/lib/api.ts).

  • api.get 은 성공 시 응답 JSON을 그대로 돌려줍니다(→ PageDTO 같은 원본). 실패 시에는 예외를 throw해 React Query가 error로 처리합니다.
  • api.post / put / delete 는 성공이면 SuccessResponse, 실패면 ErrorResponse정규화해서 돌려줍니다 → result.ok 로 분기해 받습니다.

또한 일부 백엔드 엔드포인트(/api/auth/current, /api/menus/accessible 등)는 ActionResult 래핑 없이 raw 객체를 그대로 돌려줍니다. 즉 "모든 응답이 ActionResult"라고 가정하면 안 돼요.

CRUD 한 바퀴 — 목록 조회 → 생성 → 자동 갱신

아래 스테퍼를 넘기며 "조회"와 "생성"이 어떻게 한 바퀴로 이어지는지 보세요.

1. 목록 조회 — 화면이 요청 — 컴포넌트가 React Query?useQuery 로 목록을 조회?합니다. queryKey 에 필터를 담아 "이 조건의 목록"을 식별해요.

useQuery
queryKey + filter
GET /api/users
컨트롤러
QueryDSL
DB

2. 조회 — 백엔드가 처리 — 컨트롤러 listByPage(filter, pageable)QueryDSL? listByPage 로 동적 조회한 뒤 PageDTO<UserDTO>(목록+페이지정보)로 응답합니다.

useQuery
GET /api/users
컨트롤러
listByPage
QueryDSL
DB

3. 조회 — 캐시에 저장 — 응답 PageDTO<UserDTO> 가 돌아오면 React Query가 캐시?queryKey 별로 저장하고 표를 그립니다. (목록 조회는 api.get 이라 응답을 그대로 받음)

표 렌더 ✓
React Query 캐시
queryKey 별 저장
PageDTO<UserDTO>

4. 생성 — 화면이 보냄 — "추가" 버튼을 누르면 뮤테이션? (useMutation)이 POST /api/users 로 입력값(UserCreateReq)을 보냅니다.

useMutation
UserCreateReq
POST /api/users
컨트롤러
save
DB

5. 생성 — 백엔드가 검증·저장 — 컨트롤러가 @Valid 로 입력을 검증?합니다. 통과하면 userRepo.save 로 저장하고 성공 응답을, 실패하면 400 검증 오류를 보냅니다.

useMutation
POST /api/users
컨트롤러
@Valid
save
DB

6. 자동 갱신 — 무효화 → 재조회 — 성공하면 onSuccess 에서 queryClient.invalidateQueries({ queryKey: ["users"] }) 로 목록 캐시를 무효화?합니다. 무효화된 useQuery 가 자동으로 다시 조회(refetch)하면서 표에 새 사용자가 나타나요. 한 바퀴 완성!

성공
invalidateQueries
["users"]
useQuery 재조회
표 갱신 ✓
현재 단계 데이터/캐시 완료
무효화 매칭은 "배열 요소 접두사"로 일어난다

invalidateQueries({ queryKey: ["users"] }) 는 정확히 어디까지 무효화할까요? 규칙은 배열 요소 단위 접두사 일치입니다.

  • ["users"]["users", filter]무효화함 — 앞 요소가 같고 접두사가 일치
  • ["users"]["users-list"]무효화하지 않음"users""users-list"다른 문자열

그래서 무효화가 "왜 안 먹지?" 싶을 땐 queryKey첫 요소 문자열이 정확히 같은지 부터 확인하세요.

양쪽 코드 나란히 보기

프론트의 useQuery / useMutation 과 백엔드 컨트롤러가 같은 약속 위에서 어떻게 맞물리는지 비교해 보세요.

프론트 — 조회와 생성

// 프론트: 목록 조회 (useQuery)
function useUserListQuery(pageable, filter) {
  return useQuery({
    queryKey: ["users", filter, pageable],   // 조건별 식별 키
    queryFn: () => api.get("/api/users", { ...filter, ...pageable }),
  })
}

// 프론트: 생성 (useMutation) + 성공 시 무효화
function useCreateUser() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (req) => api.post("/api/users", req),   // 성공 SuccessResponse / 실패 ErrorResponse
    onSuccess: () => {
      // ["users"]로 무효화 → ["users", filter, pageable] 목록이 다시 조회됨
      qc.invalidateQueries({ queryKey: ["users"] })
    },
  })
}

백엔드 — 같은 URL을 받는 컨트롤러

// 백엔드: web/endpoint/UserAccountEndpoint.java
@RestController
@RequestMapping("/api/users")
public class UserAccountEndpoint {

  // 목록 조회 → PageDTO로 응답
  @GetMapping("")
  public PageDTO<UserDTO> listByPage(UserFilter filter, Pageable pageable) {
    var page = userRepo.listByPage(filter, pageable)
        .map(UserDTO::fromEntity);
    return PageDTO.fromPage(page);
  }

  // 생성 → @Valid 검증 후 save (반환 타입 void → 성공 시 본문 없는 200)
  @PostMapping("")
  @Transactional
  public void createUserAccount(@Valid @RequestBody UserCreateReq req) {
    var newUser = new User();
    newUser.setUserid(req.userid());
    newUser.setName(req.name());
    newUser.setEmail(req.email());
    newUser.setEncryptedPassword(passwordEncoder.encode(req.password())); // 비밀번호는 BCrypt로
    userRepo.save(newUser);   // 검증 통과 시 저장
  }
}
이 프로젝트의 실제 코드

학습 편의를 위해 위 예시는 queryKey: ["users"] 로 적었습니다. 실제 코드의 queryKey 첫 요소는 "listUser" 이고(features/user-list/user.query.ts), 무효화는 createReload("listUser") 헬퍼(lib/query-support.ts)가 invalidateQueries({ queryKey: ["listUser"] }) 를 호출해 처리합니다. 키 이름만 다를 뿐 원리는 똑같아요.

직접 보내보기 (시뮬레이션)

생성 성공과 검증 실패, 두 경우의 요청·응답 형태를 흉내 내 봅니다.

성공POST /api/users 가 검증을 통과해 저장됩니다. 이 컨트롤러는 반환 타입이 void성공 시 본문이 없습니다(200).

검증 실패 — 이메일 형식이 틀려 @Valid 에 걸립니다. ActionResult 가 아니라 validationErrors 형식으로 옵니다.

이 흐름이 도움이 되는 순간
  • "저장은 됐는데 목록이 안 바뀐다" — 6단계 무효화의 queryKey 첫 요소가 조회 키와 같은 문자열인지 확인
  • "입력 오류 메시지가 화면에 안 뜬다" — 응답이 validationErrors 형식인지, 프론트가 errors 로 옮겨 받는지 확인
  • "result.ok가 undefined다"api.get(원본 그대로)과 api.post(SuccessResponse)의 반환 형태 차이를 떠올리기
  • "분명 성공인데 data가 비었다" — 일부 엔드포인트(생성 등)는 본문 없이 성공만 알리거나 raw 객체를 줄 수 있음

정리

  • 응답 계약: 백엔드 ActionResult(ok/message/data) ↔ 프론트 SuccessResponse/ErrorResponse. 검증 오류만 별도 validationErrors 형식.
  • 반환 형태 주의: api.get 은 원본 그대로, api.post/put/deleteSuccessResponse 로 정규화. 일부 엔드포인트는 래핑 없는 raw 응답.
  • CRUD 한 바퀴: useQuery 로 조회 → useMutation 으로 생성 → invalidateQueries 로 무효화 → 자동 재조회.
  • 무효화 매칭: 배열 요소 접두사 일치. 첫 요소 문자열이 정확히 같아야 무효화됨.

백엔드 쪽 응답·예외 처리를 더 보려면 백엔드 7장 · 권한과 예외, 프론트의 React Query·상태 라이브러리는 프론트 4장 · 상태관리 라이브러리 를 보세요.