Spring

[Spring] 엔티티와 DTO는 왜 타입이 달라도 되는가

디버거러너 2026. 7. 28. 23:58

배경

"엔티티 필드와 DTO 필드는 이름과 타입이 같아야 한다"고 배웠던 것 같은데, 실제 실습 코드를 보면 그렇지 않았다.

// 엔티티
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
private User user;
// 응답 DTO
public class PostDto {
    private long Id;
    private String username; // user 객체가 아니라 username 문자열
}

이름도 다르고(user → username), 타입도 다르다(Long → long, 객체 참조 → 문자열). 그런데도 이 코드는 실제로 잘 동작한다. 왜 그런지, 그리고 이게 아무렇게나 해도 괜찮다는 뜻인지 두 가지 사례로 파봤다.

기준은  "반대로 왜 다른 방식(Enum 타입으로 바로 받는 것 등)을 원했냐고 되물었을 때 답할 수 있는가"로 잡았다.

그래서 정답이 정해진 사례(id 타입)뿐 아니라 정답이 없는 설계 선택(role 타입)까지, 실제로 반대 케이스를 코드로 만들어보고 비교했다.

타입은 "그 시점에 보장할 수 있는 것"을 표현한다

엔티티, 요청 DTO, 응답 DTO는 같은 도메인을 다루더라도 서로 다른 시점, 다른 신뢰 수준의 데이터를 대상으로 한다.

  • 엔티티는 DB에 저장된(또는 저장될) 상태를 대표한다. @GeneratedValue로 자동 생성되는 PK는 저장 전엔 값이 없으므로, 그 "없음"을 표현하려면 Long(래퍼)이 필요하다.
  • 요청 DTO는 클라이언트가 보낸, 아직 아무것도 검증되지 않은 원시 데이터다. 이 시점엔 값이 뭐가 올지 서버가 통제할 수 없다.
  • 응답 DTO는 검증과 저장이 다 끝난, 확정된 사실만 담는다. 이 시점엔 더 이상 불확실성이 없다.

이 세 시점이 서로 다르기 때문에, 같은 개념(id, role)이라도 계층마다 다른 타입을 쓰는 데는 이유가 있다. 다만 그 이유가 항상 유일한 정답을 뜻하지는 않는다. 아래 두 사례로 구체적으로 확인했다.

사례 1: id (엔티티 Long / 응답 long / 요청 Long+@NotNull)

엔티티가 Long을 쓰는 이유는 앞서 봤듯 저장 전 null 상태를 표현하기 위해서다. 나머지 두 계층은 이렇다.

PostDto.from(post)는 이미 저장이 끝난 Post를 변환하는 메서드다. 이 시점엔 post.getId()가 항상 실제 값을 반환한다는 전제가 있어서 long(primitive)을 써도 안전하다.

 

여기서 primitive를 쓴 건 단순히 "null일 리 없어서 편하게 썼다"가 아니다. Long(null 가능) 값을 primitive 매개변수에 넘기면 컴파일러가 Long.longValue() 호출로 변환하는데, 만약 전제가 깨져서 null이 들어오면 그 자리에서 바로 NullPointerException이 터진다. 즉 long은 전제가 깨졌을 때 그 즉시 요란하게 실패하게 만드는 fail-fast 장치이기도 하다.

요청 쪽은 반대다. 코드를 보면:

public class ManagerSaveRequest {
    @NotNull
    private Long managerUserId;
}

만약 managerUserId가 long(primitive)이었다면 어떻게 됐을까. long은 애초에 null이라는 상태를 가질 수 없다. 그래서 @NotNull을 붙여도 검증할 대상 자체가 없어 의미가 없고, 클라이언트가 필드를 아예 안 보내면 자바 기본값인 0이 조용히 채워져서 정상 요청처럼 통과해버린다. 실제 코드처럼 Long + @NotNull로 받으면 필드가 없을 때 null로 남고, Bean Validation이 컨트롤러 로직 실행 전에 명확하게 걸러낸다.

 

이걸 직접 테스트해보다가 예상 못 한 걸 발견했다. "managerUserId": null로 명시하면 400과 함께 "null이어서는 안 된다"는 명확한 메시지가 오는데, 바디가 비어있거나 형식이 깨진 상태로 보내면 500 Internal Server Error가 떴다. 원인은 GlobalExceptionHandler에 있었다.

@ExceptionHandler(MethodArgumentNotValidException.class) // 400
@ExceptionHandler(Exception.class) // 500, catch-all

요청 바디 자체를 객체로 만드는 데 실패하면(JSON 파싱 단계) HttpMessageNotReadableException이 발생하는데, 이 프로젝트엔 이 예외용 핸들러가 없다. 그래서 MethodArgumentNotValidException(검증 실패)은 400으로 깔끔하게 처리되지만, 파싱 자체가 실패하는 경우는 catch-all로 떨어져 500이 나가고 있었다.

사례 2: role (엔티티 UserRole / 요청 String )

UserRoleChangeRequest는 권한 값을 String으로 받는다.

public void changeUserRole(long userId, UserRoleChangeRequest userRoleChangeRequest) {
    User user = userRepository.findById(userId).orElseThrow(...);
    user.updateRole(UserRole.of(userRoleChangeRequest.getRole()));
}

public enum UserRole {
    ADMIN, USER;
    public static UserRole of(String role) {
        return Arrays.stream(UserRole.values())
                .filter(r -> r.name().equalsIgnoreCase(role))
                .findFirst()
                .orElseThrow(() -> new CustomException(ErrorCode.INVALID_USER_ROLE));
    }
}

만약 필드 자체를 UserRole role로 선언했다면 어떻게 될까. 실제로 바꿔서 테스트해보니, Jackson이 파싱 단계에서 자기 방식대로 변환을 시도하면서 UserRole.of()는 호출될 기회조차 없어졌다. 그리고 Jackson의 기본 Enum 매칭은 대소문자를 구분해서, "admin"(소문자)을 보내면 실패했다. 반면 지금 구조는 equalsIgnoreCase로 직접 검증하므로 소문자도 받아준다.

이 사례로 정리되는 차이는 두 가지다.

  1. 필드별로 다른 에러 메시지/코드가 필요한가: String + 직접 검증이면 ErrorCode.INVALID_USER_ROLE처럼 이 필드만의 응답을 만들 수 있다.
  2. 프레임워크 기본 동작과 다른 매칭 규칙이 필요한가: 대소문자 무시 같은 규칙은 String + 직접 검증 쪽이 자유롭다.

처음엔 "Enum으로 바로 받으면 파싱 실패로 500이 새니까 String이 안전하다"고 생각했는데, 이건 근거가 약했다. 그 500은 HttpMessageNotReadableException 핸들러가 빠진 이 프로젝트의 문제지, Enum 타입 자체의 문제가 아니다. 그 핸들러 하나만 추가하면 Enum으로 바로 받아도 똑같이 400이 나온다.

결론

엔티티와 DTO가 이름·타입이 달라도 되는 이유는 "아무렇게나 해도 상관없어서"가 아니라, 각 계층이 책임지는 범위가 다르기 때문이다. 그 책임을 코드가 정확히 지키고 있는 한(응답 DTO는 저장 후에만 변환, 요청 DTO는 검증을 거쳐야만 엔티티로 감) 문제가 안 생기고, 오히려 타입 자체가 그 책임을 강제하는 장치로 쓰일 수 있다(primitive의 fail-fast, @NotNull의 검증 강제).

 

다만 id 사례와 달리 role 사례(Enum vs String)는 정답이 있는 문제가 아니었다. 이 실습 자체가 "정답 없이 의도를 가지고 리팩토링해보라"는 조건이었던 만큼, 실제로 남는 차이(에러 메시지 제어, 매칭 규칙)가 지금 이 프로젝트에 필요한 정도인지는 트레이드오프로 판단할 문제로 남는다.

 

그렇다면 이 검증 로직 자체가 왜 필요한지도 다시 생각해볼 만하다. UserRole.of()가 없었다면 String으로 받든 Enum으로 바로 받든 결과는 같았을 것이다. 정의되지 않은 값이 오면 그냥 500이 났을 것이다. 500은 "서버가 알아서 처리해야 할 문제"라는 신호고, 400과 함께 명확한 메시지를 주는 건 "요청을 보낸 쪽이 무엇을 고치면 되는지" 알려주는 신호다. 이 API를 실제로 쓰는 클라이언트 입장에서 둘의 차이는 크다. 검증 로직은 타입 설계의 정교함을 보여주기 위한 것이 아니라, 잘못된 요청을 보냈을 때 그 사실을 정확히 알려주기 위해 존재한다.