스프링 부트로 지원 현황 관리 서비스(CareerLog)의 CRUD API를 구현하던 중, GPT가 작성해 준 수정 DTO(ApplicationUpdateRequest)와 엔티티(Application) 예시 코드를 보다가 두 가지 의문이 들었습니다.
// 1. DTO에는 왜 id 필드가 없을까?
public record ApplicationUpdateRequest(
@NotBlank String companyName,
@NotBlank String positionTitle,
String jobUrl,
@NotNull ApplicationStatus status,
@NotNull Priority priority,
LocalDate appliedAt,
LocalDate resultExpectedAt,
String nextAction,
LocalDateTime nextActionAt,
String memo
) {}
// 2. 엔티티 수정 메서드는 왜 DTO를 안 받고 파라미터 10개를 낱개로 다 풀어서 받을까?
public void update(String companyName, String positionTitle, String jobUrl, ...) {
this.companyName = companyName;
this.positionTitle = positionTitle;
...
}
"DTO에
id를 넣으면 서비스 메서드에서 DTO 하나만 넘겨받아 더 깔끔해질 텐데?"
"엔티티 수정 메서드도 파라미터 10개를 일일이 받는 것보다 DTO를 덩어리째 넘겨받는 게 훨씬 간결하지 않나?"
그냥 제시해 준 대로 복사해서 쓸 수도 있었지만, 왜 이런 구조가 정석 패턴으로 자리 잡았는지 백엔드 설계 관점에서 파헤쳐 보았습니다.
만약 DTO 안에도 id를 넣는다면 API 요청은 다음과 같은 모양이 됩니다.
/api/applications/5 (5번 지원 건 수정 요청){ "id": 10, "companyName": "카카오", ... } (10번 지원 건 데이터)이처럼 URL 경로의 ID(5)와 Request Body DTO 내부의 ID(10)가 서로 달라지는 데이터 불일치 문제가 발생할 수 있습니다.
이렇게 되면 백엔드 서버 입장에서는 어느 데이터를 기준점으로 삼아야 할지 모호해지고, 컨트롤러나 서비스마다 if (urlId != dto.id()) 같은 중복 검증 로직을 추가해야 하는 번거로움이 생깁니다. DTO에서 ID를 아예 빼버리면 이러한 모호성과 오류 가능성을 근본적으로 차단할 수 있습니다.
예전 글에서 언급했듯 REST API 설계 원칙에서 URL 주소는 제어하려는 자원 그 자체를 의미합니다. PUT /api/applications/5 표현만으로도 5번 자원을 수정하겠다는 목적이 전달됩니다.
또한 서버에서 보안이나 권한 검증(예: "현재 로그인한 유저가 이 5번 지원 건의 작성자가 맞는가?")을 수행할 때, 값비싼 작업인 Request Body(JSON) 파싱을 거치는 것보다 URL 경로의 ID만 빠르게 추출하여 검증하는 것이 성능 및 보안상 훨씬 효율적이라고 합니다.
엔티티 내부의 update() 메서드가 DTO를 직접 넘겨받아 파라미터를 줄이면 안 될까 싶은 생각도 들었습니다. 하지만 아키텍처 관점에서 엔티티(Entity)는 DTO의 존재를 몰라야(Import 하지 않아야) 한다고 합니다.
[Controller] ──> [Service] ──> [Entity (핵심)]
│ │
└──> [DTO] <───┘
만약 엔티티가 DTO를 직접 의존하게 만들면, 화면이나 DTO 구조가 바뀔 때마다 핵심 도메인인 엔티티 코드까지 함께 영향을 받게 됩니다. 엔티티가 DTO를 모르도록 파라미터를 풀어서 넘겨주는 방식은 외곽의 변화(DTO)가 내부의 핵심(엔티티)을 흔들지 못하도록 보호하는 장치입니다.
결과적으로 서비스 단의 update 메서드는 (Long applicationId, ApplicationUpdateRequest request) 2개의 파라미터를 받고, 엔티티 호출 시 DTO의 값을 풀어 전달하게 됩니다.
@Transactional
public ApplicationResponse update(Long applicationId, ApplicationUpdateRequest request) {
// 1. 식별 키(applicationId)로 수정할 대상을 안전하게 찾음
Application application = applicationRepository.findById(applicationId)
.orElseThrow(() -> new IllegalArgumentException("지원 건을 찾을 수 없습니다."));
// 2. DTO의 데이터들을 풀어 엔티티의 상태를 안전하게 변경
application.update(
request.companyName(),
request.positionTitle(),
request.jobUrl(),
...
);
return ApplicationResponse.from(application);
}
언뜻 보면 파라미터가 분리되어 지저분해 보일 수 있지만, 백엔드 관점에서는 "식별 정보"와 "바꿀 내용물"의 역할이 명확히 분리된 깔끔한 코드 흐름입니다.
AI가 짜주는 예시 코드는 개발 생산성을 압도적으로 올려주지만, 주는 대로 복사해서 쓰기만 해서는 실력이 늘지 않는다고 생각합니다.
코드 한 줄, 파라미터 하나에도 "왜?"라는 의문을 갖고 이유를 파헤쳐 보는 과정 속에서 백엔드 아키텍처의 깊은 의도를 배울 수 있었습니다.