연동 4장 · 응답 계약 & CRUD 한 바퀴
프론트와 백엔드는 "응답을 이런 형식으로 주고받자"는 약속(계약)을 미리 정해둡니다. 이 약속이 무엇인지 보고, 그 위에서 목록 조회 → 생성 → 목록 자동 갱신이라는 CRUD 한 바퀴가 어떻게 도는지 따라갑니다.
"사용자 목록 화면에서 새 사용자를 추가하면, 표가 알아서 새로고침된다" — 이 작은 흐름 안에 응답 계약과 CRUD의 핵심이 다 들어 있어요.
"응답 계약"이란
백엔드가 아무 형식으로나 JSON을 던지면 프론트는 매번 다르게 받아야 해서 코드가 지저분해집니다.
그래서 양쪽이 공통 형식을 약속해 둡니다. 백엔드는 ActionResult 라는 공통 래퍼로 감싸 보내고,
프론트는 그걸 SuccessResponse / ErrorResponse 타입으로 받아요.
백엔드의 약속 — 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(권한 없음) 은 화면 곳곳에서 공통으로 토스트를 띄웁니다.
같은 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 에 필터를 담아 "이 조건의 목록"을 식별해요.
queryKey + filter
2. 조회 — 백엔드가 처리 — 컨트롤러
listByPage(filter, pageable) 가
QueryDSL?
listByPage 로 동적 조회한 뒤 PageDTO<UserDTO>(목록+페이지정보)로 응답합니다.
listByPage
3. 조회 — 캐시에 저장 — 응답
PageDTO<UserDTO> 가 돌아오면 React Query가
캐시? 에 queryKey 별로 저장하고 표를 그립니다.
(목록 조회는 api.get 이라 응답을 그대로 받음)
queryKey 별 저장
4. 생성 — 화면이 보냄 — "추가" 버튼을 누르면
뮤테이션?
(useMutation)이 POST /api/users 로 입력값(UserCreateReq)을 보냅니다.
UserCreateReq
5. 생성 — 백엔드가 검증·저장 — 컨트롤러가
@Valid 로 입력을 검증?합니다.
통과하면 userRepo.save 로 저장하고 성공 응답을, 실패하면 400 검증 오류를 보냅니다.
@Valid
6. 자동 갱신 — 무효화 → 재조회 — 성공하면
onSuccess 에서 queryClient.invalidateQueries({ queryKey: ["users"] }) 로
목록 캐시를 무효화?합니다.
무효화된 useQuery 가 자동으로 다시 조회(refetch)하면서 표에 새 사용자가 나타나요. 한 바퀴 완성!
["users"]
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/delete는SuccessResponse로 정규화. 일부 엔드포인트는 래핑 없는 raw 응답. - CRUD 한 바퀴:
useQuery로 조회 →useMutation으로 생성 →invalidateQueries로 무효화 → 자동 재조회. - 무효화 매칭: 배열 요소 접두사 일치. 첫 요소 문자열이 정확히 같아야 무효화됨.
백엔드 쪽 응답·예외 처리를 더 보려면 백엔드 7장 · 권한과 예외, 프론트의 React Query·상태 라이브러리는 프론트 4장 · 상태관리 라이브러리 를 보세요.