Java Record 클래스: 올바른 예외처리와 역할 분담
들어가며
Java 14에서 도입된 Record 클래스는 데이터 전송 객체(DTO)를 간결하게 작성할 수 있는 강력한 기능입니다. 하지만 Record에서 예외처리를 어떻게 해야 할지, 그리고 Service Layer와의 역할을 어떻게 분담해야 할지에 대해서는 많은 개발자들이 고민하고 있습니다. 이번 글에서는 Record 클래스의 기본 개념부터 올바른 예외처리 방법까지 자세히 알아보겠습니다.
Record 클래스란?
Record는 "데이터만 담는 불변 클래스"를 간단하게 만들어주는 문법입니다. 기존에 DTO를 만들 때 필요했던 반복적인 코드들을 컴파일러가 자동으로 생성해줍니다.
기존 방식과의 비교
기존 방식
public class UserInfoResponseDto {
private final Long id;
private final String nickname;
private final String email;
public UserInfoResponseDto(Long id, String nickname, String email) {
this.id = id;
this.nickname = nickname;
this.email = email;
}
public Long getId() { return id; }
public String getNickname() { return nickname; }
public String getEmail() { return email; }
@Override
public boolean equals(Object obj) {
// 긴 equals 구현...
}
@Override
public int hashCode() {
// 긴 hashCode 구현...
}
@Override
public String toString() {
// 긴 toString 구현...
}
}
Record 방식

Record가 자동으로 제공하는 기능들
Record를 선언하면 컴파일러가 자동으로 다음 기능들을 생성해줍니다:
- 생성자: 모든 필드를 매개변수로 받는 생성자
- 접근자 메서드: getId() 대신 id()와 같은 형태
- equals(), hashCode(), toString(): 자동 구현
- 불변성: 모든 필드가 final로 선언됨
// 사용 예시
UserInfoResponseDto dto = new UserInfoResponseDto(1L, "김개발", "kim@example.com");
// 데이터 접근
Long id = dto.id();
String nickname = dto.nickname();
String email = dto.email();
// toString() 결과
System.out.println(dto);
// 출력: UserInfoResponseDto[id=1, nickname=김개발, email=kim@example.com]
Record에서의 예외처리
Record에서는 두 가지 방법으로 예외처리를 할 수 있습니다.
1. Compact Constructor
가장 일반적이고 권장되는 방법입니다:
public record UserNicknameUpdateRequestDto(
String nickname
) {
public UserNicknameUpdateRequestDto {
if (nickname == null || nickname.trim().isEmpty()) {
throw new IllegalArgumentException("닉네임은 필수입니다.");
}
if (nickname.length() > 20) {
throw new IllegalArgumentException("닉네임은 20자 이내여야 합니다.");
}
// 필드 할당은 자동으로 수행됩니다
}
}
2. 일반 Constructor
더 복잡한 검증이 필요한 경우:
public record UserPasswordUpdateRequestDto(
String currentPassword,
String newPassword,
String confirmPassword
) {
public UserPasswordUpdateRequestDto(String currentPassword, String newPassword, String confirmPassword) {
if (currentPassword == null || currentPassword.trim().isEmpty()) {
throw new IllegalArgumentException("현재 비밀번호는 필수입니다.");
}
if (newPassword == null || newPassword.length() < 8) {
throw new IllegalArgumentException("새 비밀번호는 8자 이상이어야 합니다.");
}
// 필드 할당을 반드시 해야 합니다
this.currentPassword = currentPassword;
this.newPassword = newPassword;
this.confirmPassword = confirmPassword;
}
}
Record 예외처리의 적절한 범위
Record에서 예외처리를 할 때는 그 범위를 명확히 구분해야 합니다.
Record에서 해야 할 것: 형식 검증
public record UserCreateRequestDto(
String email,
String nickname
) {
public UserCreateRequestDto {
// 기본적인 형식 검증
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("유효한 이메일 형식이 아닙니다.");
}
if (nickname == null || nickname.trim().isEmpty()) {
throw new IllegalArgumentException("닉네임은 필수입니다.");
}
if (nickname.length() > 20) {
throw new IllegalArgumentException("닉네임은 20자 이내여야 합니다.");
}
}
}
Record에서 하지 말아야 할 것: 비즈니스 검증
public record UserCreateRequestDto(String email, String nickname) {
public UserCreateRequestDto {
// 이런 것들은 Record에서 하면 안 됩니다:
// 1. 외부 의존성이 필요한 검증
// if (userRepository.existsByEmail(email)) { ... }
// 2. 복잡한 비즈니스 로직
// if (blacklistService.isBlacklisted(email)) { ... }
// 3. 네트워크 호출이 필요한 검증
// if (!emailValidationService.validate(email)) { ... }
}
}
올바른 역할 분담: Record vs Service Layer
Record와 Service Layer 간의 올바른 역할 분담은 다음과 같습니다:
Record: 기본적인 형식 검증
public record UserCreateRequestDto(
@Email(message = "유효한 이메일 형식이어야 합니다.")
@NotBlank(message = "이메일은 필수입니다.")
String email,
@NotBlank(message = "닉네임은 필수입니다.")
@Size(min = 2, max = 20, message = "닉네임은 2-20자 이내여야 합니다.")
String nickname
) {}
Service Layer: 비즈니스 검증
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional
public void createUser(UserCreateRequestDto request) {
// Record에서 기본 형식 검증은 이미 완료됨
// Service에서 비즈니스 검증 수행
validateBusinessRules(request);
// 실제 비즈니스 로직 수행
User user = User.builder()
.email(request.email())
.nickname(request.nickname())
.build();
userRepository.save(user);
}
private void validateBusinessRules(UserCreateRequestDto request) {
if (userRepository.existsByEmail(request.email())) {
throw new BusinessException("이미 존재하는 이메일입니다.");
}
if (userRepository.existsByNickname(request.nickname())) {
throw new BusinessException("이미 존재하는 닉네임입니다.");
}
}
}
실제 프로젝트에서의 적용 사례
실제 모임 플랫폼 프로젝트에서 사용한 예시를 살펴보겠습니다:

Bean Validation과의 조합
Record는 Bean Validation 애노테이션과 완벽하게 호환됩니다:
이렇게 하면 Spring의 @Valid 애노테이션과 함께 사용하여 컨트롤러 레벨에서 자동으로 검증을 수행할 수 있습니다.
결론
Record 클래스는 DTO 작성을 획기적으로 간소화해주는 강력한 기능입니다. 하지만 Record에서의 예외처리는 다음 원칙을 지켜야 합니다:
- Record는 데이터 컨테이너에 집중: 기본적인 형식 검증만 수행
- Service Layer에서 비즈니스 로직 처리: 외부 의존성이 필요한 복잡한 검증
- Bean Validation 적극 활용: 선언적이고 재사용 가능한 검증
- 명확한 역할 분담: 각 계층의 책임을 명확히 구분
이러한 원칙을 지키면 유지보수하기 쉽고 테스트하기 용이한 깔끔한 코드를 작성할 수 있습니다. Record는 단순히 코드를 줄여주는 것을 넘어서, 더 나은 설계를 유도하는 도구가 될 수 있습니다.