본문으로 건너뛰기
  1. Posts/

[Spring] DTO와 Entity에 @Getter, @Setter, @RequiredArgsConstructor, @NoArgsConstructor 뭘 써야 할까?

·6 분
NineKoo9
작성자
NineKoo9
목차

Spring 프로젝트에서 DTO와 Entity 클래스를 만들 때, Lombok 어노테이션을 습관적으로 붙이고 있진 않으신가요?

@Getter, @Setter, @NoArgsConstructor, @RequiredArgsConstructor 등 편리한 어노테이션이 많지만, DTO와 Entity는 역할이 다르기 때문에 적용해야 할 어노테이션도 다릅니다.

이 글에서는 각 어노테이션의 동작을 정리하고, DTO와 Entity에 어떻게 적용하는 것이 적절한지 코드 예시와 함께 살펴보겠습니다.


먼저, 각 어노테이션이 하는 일을 정리하자
#

@Getter
#

모든 필드에 대해 getter 메서드를 생성합니다.

@Getter
public class Product {
    private String name;
    private double price;
}

// 아래 코드가 자동 생성됨
// public String getName() { return this.name; }
// public double getPrice() { return this.price; }

@Setter
#

모든 필드에 대해 setter 메서드를 생성합니다.

@Setter
public class Product {
    private String name;
    private double price;
}

// 아래 코드가 자동 생성됨
// public void setName(String name) { this.name = name; }
// public void setPrice(double price) { this.price = price; }

@NoArgsConstructor
#

파라미터가 없는 기본 생성자를 생성합니다.

@NoArgsConstructor
public class Product {
    private String name;
    private int price;
}

// 아래 코드가 자동 생성됨
// public Product() {}

access 옵션으로 접근 제한자를 설정할 수 있습니다.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
    private String name;
    private int price;
}

// 아래 코드가 자동 생성됨
// protected Product() {}

@RequiredArgsConstructor
#

final 필드만을 파라미터로 받는 생성자를 생성합니다.

@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private String tempValue; // final이 아니므로 생성자 파라미터에 포함되지 않음
}

// 아래 코드가 자동 생성됨
// public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
//     this.orderRepository = orderRepository;
//     this.paymentService = paymentService;
// }

@AllArgsConstructor
#

모든 필드를 파라미터로 받는 생성자를 생성합니다.

@AllArgsConstructor
public class User {
    private String username;
    private int age;
    private String email;
}

// 아래 코드가 자동 생성됨
// public User(String username, int age, String email) {
//     this.username = username;
//     this.age = age;
//     this.email = email;
// }

Entity에는 어떤 어노테이션을 써야 할까?
#

권장 조합
#

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member {

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

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String name;

    @Builder
    public Member(String email, String name) {
        this.email = email;
        this.name = name;
    }
}

왜 이 조합인가?
#

1. @Getter - 사용

Service, Controller 등에서 Entity의 데이터를 조회할 때 필요합니다.

2. @Setter - 사용하지 않음

Entity에 @Setter를 열어두면 어디서든 Entity의 상태를 변경할 수 있게 됩니다.

// @Setter가 있으면 이런 코드가 가능해짐 - 위험!
member.setName("newName");
member.setEmail("new@email.com");

이렇게 되면 Entity의 상태가 언제, 어디서, 왜 변경되었는지 추적하기 어렵습니다.

대신, 의미 있는 비즈니스 메서드를 통해 상태를 변경해야 합니다.

@Entity
public class Member {
    // ... 필드 생략

    // setter 대신 의미 있는 메서드를 정의
    public void updateProfile(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public void deactivate() {
        this.active = false;
        this.deactivatedAt = LocalDateTime.now();
    }
}

setName()이라는 이름만으로는 이 변경이 어떤 비즈니스 맥락에서 일어나는지 알 수 없지만, updateProfile()이나 deactivate()의도가 명확합니다.

3. @NoArgsConstructor(access = AccessLevel.PROTECTED) - 사용

JPA 스펙상 Entity 클래스는 기본 생성자(no-args constructor)가 반드시 필요합니다. JPA가 리플렉션을 통해 객체를 생성하기 때문입니다.

하지만 public으로 열어두면 아무 곳에서나 빈 Entity 객체를 만들 수 있으므로, AccessLevel.PROTECTED로 제한합니다.

// public이면 이런 코드가 가능 - 불완전한 객체 생성 위험
Member member = new Member(); // 모든 필드가 null

// protected로 제한하면 외부에서 직접 호출 불가
// 같은 패키지 또는 상속 관계에서만 사용 가능 (JPA가 사용하는 범위)

4. @RequiredArgsConstructor - 사용하지 않음

Entity는 Spring Bean이 아니기 때문에 의존성 주입이 필요 없습니다. final 필드를 쓸 일도 거의 없으므로 @RequiredArgsConstructor는 Entity에 적합하지 않습니다.

또한 JPA Entity의 필드를 final로 선언하면, JPA가 리플렉션으로 객체를 생성한 뒤 필드 값을 세팅할 수 없게 됩니다.

5. @AllArgsConstructor - 사용하지 않음

모든 필드에 대한 생성자를 열어두면 id처럼 자동 생성되어야 하는 필드까지 외부에서 주입할 수 있게 됩니다. 필드 순서가 바뀌면 컴파일 에러 없이 값이 뒤바뀌는 버그도 발생할 수 있습니다.

대신 @Builder를 사용하여 필요한 필드만 명시적으로 설정하는 것이 안전합니다.


DTO에는 어떤 어노테이션을 써야 할까?
#

DTO는 데이터를 전달하는 것이 목적인 객체입니다. Entity와는 성격이 다르므로 다른 애너테이션이 필요합니다.

요청(Request) DTO
#

@Getter
@NoArgsConstructor
public class MemberCreateRequest {

    @NotBlank(message = "이메일은 필수입니다")
    private String email;

    @NotBlank(message = "이름은 필수입니다")
    private String name;
}

왜 이 조합인가?

  • @Getter - O: Controller에서 Service로 데이터를 전달할 때 필드 값을 읽어야 합니다.
  • @Setter - X: 요청 데이터는 한번 바인딩되면 변경될 이유가 없습니다.
  • @NoArgsConstructor - O: Jackson이 기본 생성자로 객체를 생성한 뒤, 리플렉션으로 필드 값을 바인딩합니다. public 기본 생성자가 필요합니다.

Jackson의 역직렬화 과정:

  1. 기본 생성자로 빈 객체 생성
  2. JSON 필드와 매칭되는 Java 필드에 리플렉션으로 값 주입

그런데 @Setter가 필요한 경우도 있다 - @ModelAttribute
#

위에서 @RequestBody로 JSON을 받을 때는 @Setter가 필요 없다고 했습니다. 하지만 @ModelAttribute로 폼 데이터나 쿼리 파라미터를 바인딩할 때는 이야기가 다릅니다.

Spring의 DataBinder는 기본 생성자로 객체를 만든 뒤, setter 메서드를 호출하여 값을 주입합니다. 리플렉션으로 필드에 직접 접근하는 Jackson과는 동작 방식이 다릅니다.

// GET /members?name=홍길동&email=hong@test.com
@GetMapping("/members")
public ResponseEntity search(@ModelAttribute MemberSearchRequest request) {
    // ...
}
@Getter
@Setter            // @ModelAttribute 바인딩에 필요!
@NoArgsConstructor
public class MemberSearchRequest {
    private String name;
    private String email;
}

@Setter가 없으면 DataBinder가 값을 주입할 수 없어 모든 필드가 null로 남게 됩니다.

요청 DTO에서 @Setter를 쓸지 말지는 어떤 방식으로 데이터를 받느냐에 따라 달라집니다. 무조건 @Setter를 빼는 것이 아니라, 바인딩 메커니즘을 이해하고 필요한 경우에 적용하는 것이 중요합니다.

응답(Response) DTO
#

@Getter
@RequiredArgsConstructor
public class MemberResponse {
    private final Long id;
    private final String email;
    private final String name;

    public static MemberResponse from(Member member) {
        return new MemberResponse(
                member.getId(),
                member.getEmail(),
                member.getName()
        );
    }
}

또는 @Builder 패턴을 활용할 수도 있습니다.

@Getter
@Builder
public class MemberResponse {
    private final Long id;
    private final String email;
    private final String name;

    public static MemberResponse from(Member member) {
        return MemberResponse.builder()
                .id(member.getId())
                .email(member.getEmail())
                .name(member.getName())
                .build();
    }
}

왜 이 조합인가?

  • @Getter - O: 응답 JSON으로 직렬화할 때 필요합니다.
  • @RequiredArgsConstructor - O: 응답 DTO는 생성 시점에 모든 데이터가 확정되므로, final 필드 + @RequiredArgsConstructor불변 객체를 만드는 것이 좋습니다.
  • @Setter - X: 응답 데이터는 한번 생성되면 변경되면 안 됩니다.
  • @NoArgsConstructor - X: 응답 DTO는 Jackson 역직렬화 대상이 아니므로 기본 생성자가 필요 없습니다.

Java 16+ 라면 Record를 고려하자
#

Java 16부터 도입된 record를 사용하면 Lombok 없이도 불변 DTO를 간결하게 만들 수 있습니다.

public record MemberResponse(
        Long id,
        String email,
        String name
) {
    public static MemberResponse from(Member member) {
        return new MemberResponse(
                member.getId(),
                member.getEmail(),
                member.getName()
        );
    }
}

record는 자동으로 getter, equals(), hashCode(), toString()을 생성하며, 모든 필드가 final입니다. Spring Data JPA에서도 DTO Projection으로 record를 지원합니다.

public record UserDto(String firstname, String lastname) {}

interface UserRepository extends Repository<User, Long> {
    @Query("SELECT u FROM User u WHERE u.lastname = :lastname")
    List<UserDto> findByLastname(String lastname);
}