[아이티센 부트캠프] Spring Boot 3 (Lombok)

이언덕·2026년 5월 9일

아이티센 부트캠프

목록 보기
69/115
post-thumbnail

Lombok

Lombok은 Java 클래스에서 반복해서 작성해야 하는 코드를 어노테이션으로 자동 생성해 주는 라이브러리다.
여기서 라이브러리는 개발할 때 필요한 기능을 미리 만들어 둔 도구 모음이라고 이해하면 된다.
어노테이션은 클래스나 필드, 메서드 위에 붙여서 특정 기능을 적용하라고 알려 주는 표시다.


Java 클래스를 만들다 보면 필드 값을 읽는 getter, 필드 값을 바꾸는 setter, 생성자, toString(), equals(), hashCode() 같은 메서드를 자주 작성해야 한다.
이 코드들은 필요하지만 모양이 반복되는 경우가 많다.
Lombok은 이런 반복 코드를 직접 작성하지 않아도, 어노테이션을 붙이는 것만으로 자동 생성되게 해 준다.


핵심은 Lombok이 코드를 없애는 도구가 아니라, 반복되는 코드를 어노테이션을 기준으로 자동 생성해 주는 도구라는 점이다.
이 기준을 잡아 두면 @Getter, @Setter, @Builder, @Data 같은 어노테이션도 훨씬 쉽게 이해할 수 있다.


Lombok을 사용하는 이유

Lombok이 필요한 이유는 반복 코드를 줄여 클래스의 핵심 내용이 더 잘 보이게 하기 위해서다.
클래스에서 정말 중요한 것은 어떤 필드를 가지고 있고, 어떤 역할을 하는지다.
그런데 getter, setter, 생성자 같은 반복 코드가 많아지면 클래스의 핵심 구조가 한눈에 잘 보이지 않는다.


예를 들어 필드가 10개인 클래스가 있다고 생각해 보자.
각 필드마다 getter와 setter를 직접 만들면 메서드 수가 금방 늘어난다.
이 코드들은 필요하지만 대부분 일정한 규칙으로 반복된다.
그래서 사람이 직접 작성하면 시간이 오래 걸리고, 실수도 생길 수 있다.


Lombok을 사용하면 이런 반복 코드를 어노테이션 하나로 줄일 수 있다.
그러면 클래스 안에는 필드와 핵심 메서드가 더 잘 보인다.
코드 길이도 줄어들기 때문에 읽고 수정하기도 편해진다.


다만 Lombok을 사용한다고 해서 실제 기능이 사라지는 것은 아니다.
@Getter를 붙이면 getter 메서드가 있는 것처럼 동작하고, @AllArgsConstructor를 붙이면 전체 필드를 받는 생성자가 있는 것처럼 동작한다.
즉, 직접 보이지 않을 뿐 실제로는 필요한 코드가 생성되어 사용된다.


따라서 Lombok을 사용할 때는 코드가 짧아지는 장점만 보는 것이 아니라, 어떤 코드가 자동으로 만들어지는지도 함께 이해해야 한다.
이 점을 놓치면 어노테이션은 붙였지만 실제로 어떤 메서드나 생성자가 생기는지 모르는 상태가 될 수 있다.


Lombok 없이 작성하면 코드가 길어지는 이유

Lombok을 사용하지 않으면 필드 하나를 다루기 위해 여러 메서드를 직접 작성해야 한다.
필드 값을 읽으려면 getter가 필요하고, 필드 값을 바꾸려면 setter가 필요하다.
객체를 만들 때 값을 넣고 싶으면 생성자도 직접 작성해야 한다.


예를 들어 이름과 나이를 가진 클래스가 있다고 보자.
Lombok 없이 작성하면 아래처럼 반복 코드가 많아진다.

// UserWithoutLombok.java
public class UserWithoutLombok {
    private String name; // 이름 저장
    private int age; // 나이 저장

    public UserWithoutLombok() {
        // 기본 생성자
    }

    public UserWithoutLombok(String name, int age) {
        this.name = name; // 전달받은 name 저장
        this.age = age; // 전달받은 age 저장
    }

    public String getName() {
        return name; // 저장된 name 값 반환
    }

    public void setName(String name) {
        this.name = name; // 전달받은 name 값 저장
    }

    public int getAge() {
        return age; // 저장된 age 값 반환
    }

    public void setAge(int age) {
        this.age = age; // 전달받은 age 값 저장
    }

    @Override
    public String toString() {
        return "UserWithoutLombok{name='" + name + "', age=" + age + "}"; // 객체 정보 문자열 반환
    }
}

이 코드는 틀린 코드가 아니다.
오히려 Java에서 객체의 값을 안전하게 다루기 위해 자주 작성하는 기본 형태다.
하지만 필드가 많아질수록 같은 모양의 코드가 계속 늘어난다.


그래서 클래스의 핵심인 name, age보다 반복 메서드가 더 많이 보이게 된다.
이런 상황에서 Lombok을 사용하면 반복되는 부분을 줄이고 핵심 필드와 역할을 더 잘 드러낼 수 있다.


Lombok을 사용하면 코드가 줄어드는 이유

Lombok은 직접 작성해야 했던 반복 메서드를 어노테이션으로 대신 만든다.
아래 코드는 위에서 직접 작성한 기본 생성자, 전체 필드 생성자, getter, setter, toString()을 어노테이션으로 줄인 예시다.

// UserWithLombok.java
import lombok.AllArgsConstructor; // 전체 필드 생성자 자동 생성
import lombok.Getter; // getter 자동 생성
import lombok.NoArgsConstructor; // 기본 생성자 자동 생성
import lombok.Setter; // setter 자동 생성
import lombok.ToString; // toString 자동 생성

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class UserWithLombok {
    private String name; // 이름 저장
    private int age; // 나이 저장
}

코드가 짧아졌지만 기능이 사라진 것은 아니다.
@Getter 때문에 getName(), getAge()가 만들어진 것처럼 사용할 수 있다.
@Setter 때문에 setName(), setAge()도 사용할 수 있다.
@NoArgsConstructor와 @AllArgsConstructor 때문에 생성자도 사용할 수 있다.
@ToString 때문에 객체를 출력할 때 필드 값이 보이는 문자열도 사용할 수 있다.


즉, Lombok을 사용하면 코드를 직접 쓰지 않을 뿐, 필요한 메서드와 생성자는 자동으로 만들어진다.
그래서 Lombok을 볼 때는 “코드가 없다”가 아니라 “어노테이션이 코드를 대신 만들고 있다”라고 이해해야 한다.




Getter와 Setter

Lombok에서 가장 먼저 이해하기 쉬운 어노테이션은 @Getter와 @Setter다.
둘은 필드 값을 다룬다는 점에서는 비슷하지만 역할은 다르다.
@Getter는 값을 읽는 메서드를 만들고, @Setter는 값을 변경하는 메서드를 만든다.


객체의 필드는 보통 private으로 감춘다.
private은 클래스 밖에서 필드에 직접 접근하지 못하게 막는 접근 제한자다.
그 이유는 객체 안의 값을 아무 곳에서나 직접 바꾸지 못하게 하고, 정해진 메서드를 통해 다루기 위해서다.


이때 필드 값을 읽고 변경하려면 메서드가 필요하다.
Lombok은 그 메서드를 어노테이션으로 자동 생성해 준다.


Getter와 Setter가 필요한 이유

getter는 객체 안에 저장된 값을 밖에서 읽을 수 있게 해 주는 메서드다.
setter는 객체 안의 값을 새 값으로 저장하거나 변경할 수 있게 해 주는 메서드다.


초보자가 자주 헷갈리는 부분은 getter만 있으면 값이 생기는 것처럼 생각하는 것이다.
하지만 getter는 값을 만들어서 저장하는 기능이 아니다.
이미 객체 안에 저장된 값을 읽어서 반환하는 기능이다.


반대로 setter는 값을 읽는 기능이 아니다.
외부에서 전달된 값을 객체 안의 필드에 저장하는 기능이다.


즉, setter는 값을 저장하는 흐름에 사용되고, getter는 저장된 값을 읽는 흐름에 사용된다.
이 차이를 알아야 DTO나 요청 객체를 사용할 때 헷갈리지 않는다.


Lombok 없이 작성한 전체 코드

Lombok 없이 getter와 setter를 사용하려면 필드마다 메서드를 직접 작성해야 한다.
아래 코드는 name과 age 값을 저장하고 읽을 수 있는 DTO 예시다.

// MemberDTOWithoutLombok.java
public class MemberDTOWithoutLombok {
    private String name; // 이름 저장
    private int age; // 나이 저장

    public MemberDTOWithoutLombok() {
        // 기본 생성자
    }

    public String getName() {
        return name; // 저장된 name 값 반환
    }

    public void setName(String name) {
        this.name = name; // 전달받은 name 값 저장
    }

    public int getAge() {
        return age; // 저장된 age 값 반환
    }

    public void setAge(int age) {
        this.age = age; // 전달받은 age 값 저장
    }
}

이 코드에서 setName()과 setAge()는 값을 객체 안에 저장한다.
getName()과 getAge()는 객체 안에 저장된 값을 읽어서 반환한다.


따라서 getter와 setter는 역할이 다르다.
setter로 값이 저장되어야 getter로 읽을 값도 생긴다.


Lombok으로 줄인 코드

Lombok을 사용하면 위에서 직접 작성한 getter, setter, 기본 생성자를 어노테이션으로 줄일 수 있다.

// MemberDTOWithLombok.java
import lombok.Getter; // getter 자동 생성
import lombok.NoArgsConstructor; // 기본 생성자 자동 생성
import lombok.Setter; // setter 자동 생성

@Getter
@Setter
@NoArgsConstructor
public class MemberDTOWithLombok {
    private String name; // 이름 저장
    private int age; // 나이 저장
}

이 코드에는 getName(), setName(), getAge(), setAge()가 직접 보이지 않는다.
하지만 @Getter와 @Setter가 붙어 있기 때문에 해당 메서드가 자동 생성된 것처럼 사용할 수 있다.


또 @NoArgsConstructor가 있으므로 매개변수가 없는 기본 생성자도 사용할 수 있다.
Spring이 요청값을 객체로 묶어 받을 때 기본 생성자와 setter가 필요할 수 있기 때문에 함께 사용되는 경우가 많다.


Setter로 DTO 값을 저장하고 Getter로 읽는 흐름

Controller에서 DTO를 매개변수로 받으면 요청값이 객체에 들어오는 흐름을 이해해야 한다.
예를 들어 사용자가 name=둘리, age=20을 전송했다고 보자.


Spring은 먼저 MemberDTOWithLombok 객체를 만든다.
그 다음 요청으로 들어온 값을 객체의 필드에 넣는다.
이때 값을 넣는 데 필요한 메서드가 setter다.
그리고 Controller 안에서 값을 사용할 때는 getter를 호출한다.


실제 Controller 코드 안에는 setName(), setAge()가 직접 보이지 않을 수 있다.
하지만 요청값을 DTO 객체에 묶는 과정에서 Spring이 내부적으로 setter를 사용해 값을 넣는다고 이해하면 된다.
흐름을 코드처럼 풀어 보면 아래와 같다.

// SpringInternalBindingFlow.java
public class SpringInternalBindingFlow {
    public static void main(String[] args) {
        MemberDTOWithLombok memberDTO = new MemberDTOWithLombok(); // DTO 객체 생성
        memberDTO.setName("둘리"); // 요청값 name 저장
        memberDTO.setAge(20); // 요청값 age 저장
    }
}

위 코드는 Controller에 직접 작성하는 코드가 아니라, Spring이 요청값을 객체에 넣는 흐름을 이해하기 위한 예시다.
즉, 요청값이 들어오면 setter를 통해 객체 안에 값이 저장된다.


그 다음 Controller에서는 이미 값이 저장된 DTO에서 getter를 사용해 값을 읽는다.

// MemberController.java
import org.springframework.stereotype.Controller; // Controller 등록
import org.springframework.web.bind.annotation.PostMapping; // POST 요청 매핑
import org.springframework.web.bind.annotation.ResponseBody; // 응답 본문 반환

@Controller
public class MemberController {

    @PostMapping("/member")
    @ResponseBody
    public String member(MemberDTOWithLombok memberDTO) {
        String name = memberDTO.getName(); // 저장된 name 값 읽기
        int age = memberDTO.getAge(); // 저장된 age 값 읽기

        return name + " / " + age; // 응답 문자열 반환
    }
}

이 흐름을 순서대로 보면 아래와 같다.

  • 사용자가 name, age 요청값을 보낸다.
  • Spring이 MemberDTOWithLombok 객체를 만든다.
  • Spring이 setter를 사용해 요청값을 객체 필드에 저장한다.
  • Controller에서 getter를 사용해 저장된 값을 읽는다.
  • 읽은 값을 사용해서 응답을 만든다.

요청값이 객체에 저장될 때는 setter가 필요하고, 저장된 값을 꺼내서 사용할 때는 getter가 필요하다.
따라서 @Getter와 @Setter는 단순히 메서드를 줄이는 기능이 아니라, Spring에서 요청 데이터를 객체로 받을 때도 자주 연결되는 기능이다.


boolean 타입 Getter 이름 규칙

boolean 타입은 참과 거짓을 저장하는 타입이다.
이 타입의 필드에서는 getter 이름이 get으로 시작하지 않고 is로 시작할 수 있다.
예를 들어 필드가 active라면 getActive()가 아니라 isActive()처럼 만들어질 수 있다.

// MemberStatus.java
import lombok.Getter; // getter 자동 생성

@Getter
public class MemberStatus {
    private boolean active; // 활성 상태 저장
}

이 경우 active 값을 읽을 때 아래처럼 사용할 수 있다.

// MemberStatusTest.java
public class MemberStatusTest {
    public static void main(String[] args) {
        MemberStatus memberStatus = new MemberStatus(); // 객체 생성
        boolean result = memberStatus.isActive(); // active 값 읽기

        System.out.println(result); // 결과 출력
    }
}
// 출력결과
// false

boolean 필드의 기본값은 false다.
그래서 따로 값을 저장하지 않으면 isActive()를 호출했을 때 false가 나온다.


즉, boolean 타입에서는 getter 이름이 is필드명 형태로 만들어질 수 있다는 점을 기억해야 한다.
이 차이를 알아 두면 getter 메서드가 왜 get으로 시작하지 않는지 헷갈리지 않는다.




Constructor

생성자는 객체를 만들 때 호출되는 특별한 메서드다.
객체를 만들면서 필요한 값을 필드에 넣거나, 객체가 처음부터 올바른 상태로 만들어지도록 도와준다.
Lombok은 생성자도 어노테이션으로 자동 생성해 줄 수 있다.


생성자 관련 어노테이션은 종류가 여러 개라서 처음에는 헷갈릴 수 있다.
하지만 기준은 단순하다.
어떤 필드를 생성자 매개변수로 받을 것인가를 기준으로 나누면 된다.


생성자가 필요한 이유

객체는 그냥 모양만 있다고 바로 사용할 수 있는 것이 아니다.
객체 안에 필요한 값이 들어가야 제대로 동작한다.
생성자는 객체가 만들어지는 순간 필요한 값을 넣어 주는 역할을 한다.


예를 들어 User 객체에 name과 age가 필요하다면, 객체를 만들 때 이 값을 함께 전달할 수 있다.
생성자를 사용하면 객체가 처음 만들어질 때부터 필요한 값을 가진 상태가 된다.


또 Spring에서는 Controller가 Service를 사용하고, Service가 Repository를 사용하는 구조가 자주 나온다.
이때 필요한 객체를 생성자로 전달받는 방식을 생성자 주입이라고 한다.
생성자 주입을 사용하면 객체가 만들어질 때 필요한 의존 객체를 반드시 받게 만들 수 있다.


생성자의 핵심은 객체를 만들 때 필요한 값을 처음부터 넣어 주는 것이다.
생성자 어노테이션은 이 생성자 코드를 직접 작성하지 않도록 도와준다.


Lombok 없이 작성한 생성자 코드

Lombok 없이 생성자를 사용하려면 필요한 생성자를 직접 작성해야 한다.
아래 코드는 기본 생성자와 전체 필드 생성자를 직접 작성한 예시다.

// UserConstructorWithoutLombok.java
public class UserConstructorWithoutLombok {
    private String name; // 이름 저장
    private int age; // 나이 저장

    public UserConstructorWithoutLombok() {
        // 매개변수가 없는 기본 생성자
    }

    public UserConstructorWithoutLombok(String name, int age) {
        this.name = name; // 전달받은 name 저장
        this.age = age; // 전달받은 age 저장
    }
}

기본 생성자는 아무 값도 받지 않고 객체를 만든다.
전체 필드 생성자는 모든 필드 값을 전달받아 객체를 만든다.


생성자 코드 자체는 어렵지 않지만, 클래스가 많아지고 필드가 많아지면 반복해서 작성해야 하는 양이 늘어난다.
이 반복을 줄이기 위해 Lombok의 생성자 어노테이션을 사용할 수 있다.


NoArgsConstructor로 줄인 코드

@NoArgsConstructor는 매개변수가 없는 생성자를 자동으로 만들어 준다.
여기서 매개변수는 생성자를 호출할 때 전달하는 값이다.
즉, 기본 생성자는 값을 전달하지 않고 객체를 만들 수 있게 해 주는 생성자다.

// UserNoArgsConstructorExample.java
import lombok.NoArgsConstructor; // 기본 생성자 자동 생성

@NoArgsConstructor
public class UserNoArgsConstructorExample {
    private String name; // 이름 저장
    private int age; // 나이 저장
}

위 코드는 아래 생성자를 직접 작성한 것처럼 동작한다.

// UserNoArgsConstructorGeneratedExample.java
public class UserNoArgsConstructorGeneratedExample {
    private String name; // 이름 저장
    private int age; // 나이 저장

    public UserNoArgsConstructorGeneratedExample() {
        // 매개변수 없이 객체 생성
    }
}

그래서 아래처럼 객체를 만들 수 있다.

// UserNoArgsConstructorTest.java
public class UserNoArgsConstructorTest {
    public static void main(String[] args) {
        UserNoArgsConstructorExample user = new UserNoArgsConstructorExample(); // 기본 생성자로 객체 생성
    }
}

@NoArgsConstructor는 객체를 먼저 만들고 나중에 값을 채우는 방식에서 필요할 수 있다.
특히 요청값을 객체로 묶는 과정이나 특정 프레임워크가 객체를 자동으로 만들 때 기본 생성자가 필요한 경우가 있다.


핵심은 @NoArgsConstructor가 아무 값도 받지 않는 기본 생성자를 자동으로 만들어 준다는 점이다.


AllArgsConstructor로 줄인 코드

@AllArgsConstructor는 클래스의 모든 필드를 매개변수로 받는 생성자를 자동으로 만들어 준다.
즉, 객체를 만들 때 모든 필드 값을 한 번에 넣을 수 있게 해 준다.

// UserAllArgsConstructorExample.java
import lombok.AllArgsConstructor; // 전체 필드 생성자 자동 생성

@AllArgsConstructor
public class UserAllArgsConstructorExample {
    private String name; // 이름 저장
    private int age; // 나이 저장
}

위 코드는 아래 생성자를 직접 작성한 것처럼 동작한다.

// UserAllArgsConstructorGeneratedExample.java
public class UserAllArgsConstructorGeneratedExample {
    private String name; // 이름 저장
    private int age; // 나이 저장

    public UserAllArgsConstructorGeneratedExample(String name, int age) {
        this.name = name; // 전달받은 name 저장
        this.age = age; // 전달받은 age 저장
    }
}

그래서 아래처럼 객체를 만들 수 있다.

// UserAllArgsConstructorTest.java
public class UserAllArgsConstructorTest {
    public static void main(String[] args) {
        UserAllArgsConstructorExample user = new UserAllArgsConstructorExample("둘리", 20); // 모든 필드 값을 전달해 객체 생성
    }
}

@AllArgsConstructor는 객체를 만들 때 필요한 값을 한 번에 넣을 수 있어서 편리하다.
하지만 필드가 많아지면 생성자에 들어가는 값의 순서를 헷갈릴 수 있다.
예를 들어 문자열 필드가 여러 개라면 어떤 값이 어떤 필드에 들어가는지 실수하기 쉽다.


핵심은 @AllArgsConstructor가 모든 필드를 매개변수로 받는 생성자를 자동으로 만들어 준다는 점이다.


RequiredArgsConstructor로 줄인 코드

@RequiredArgsConstructor는 꼭 필요한 필드만 매개변수로 받는 생성자를 자동으로 만들어 준다.
여기서 꼭 필요한 필드는 보통 final이 붙은 필드나 @NonNull이 붙은 필드다.


final은 한 번 값이 정해지면 다시 바꿀 수 없다는 뜻이다.
따라서 final 필드는 객체를 만들 때 반드시 값이 들어가야 한다.
그래서 @RequiredArgsConstructor는 이런 필드를 생성자 매개변수로 포함한다.

// UserServiceRequiredArgsConstructorExample.java
import lombok.RequiredArgsConstructor; // 필수 필드 생성자 자동 생성

@RequiredArgsConstructor
public class UserServiceRequiredArgsConstructorExample {
    private final UserRepository userRepository; // 반드시 필요한 의존 객체
}

위 코드는 아래 생성자를 직접 작성한 것처럼 동작한다.

// UserServiceConstructorGeneratedExample.java
public class UserServiceConstructorGeneratedExample {
    private final UserRepository userRepository; // 반드시 필요한 의존 객체

    public UserServiceConstructorGeneratedExample(UserRepository userRepository) {
        this.userRepository = userRepository; // 전달받은 객체 저장
    }
}

이 방식은 특히 의존 객체를 주입받을 때 자주 사용된다.
의존 객체는 어떤 클래스가 동작하기 위해 필요한 다른 객체를 의미한다.
UserService가 UserRepository를 사용해야 한다면, 객체가 만들어질 때부터 UserRepository가 반드시 필요하다.


핵심은 @RequiredArgsConstructor가 모든 필드가 아니라, 반드시 초기화가 필요한 필드를 받는 생성자를 자동으로 만들어 준다는 점이다.


Controller와 Service에서 생성자 주입이 사용되는 흐름

Spring에서는 Controller가 요청을 받고, 실제 비즈니스 처리는 Service에 맡기는 구조를 자주 사용한다.
이때 Controller는 Service 객체가 필요하다.
필요한 객체를 필드에 직접 새로 만들기보다, 생성자를 통해 전달받는 방식을 많이 사용한다.


Lombok을 사용하지 않으면 아래처럼 생성자를 직접 작성해야 한다.

// MemberControllerWithoutLombok.java
import org.springframework.stereotype.Controller; // Controller 등록

@Controller
public class MemberControllerWithoutLombok {
    private final MemberService memberService; // 의존 객체 저장

    public MemberControllerWithoutLombok(MemberService memberService) {
        this.memberService = memberService; // 생성자로 의존 객체 주입
    }
}

@RequiredArgsConstructor를 사용하면 생성자 코드를 줄일 수 있다.

// MemberControllerWithLombok.java
import lombok.RequiredArgsConstructor; // final 필드 생성자 자동 생성
import org.springframework.stereotype.Controller; // Controller 등록

@Controller
@RequiredArgsConstructor
public class MemberControllerWithLombok {
    private final MemberService memberService; // 반드시 필요한 의존 객체
}

이 코드에는 생성자가 직접 보이지 않는다.
하지만 @RequiredArgsConstructor가 final 필드인 memberService를 받는 생성자를 자동으로 만든다.
그래서 Spring은 생성자를 통해 MemberService 객체를 넣어 줄 수 있다.


흐름은 아래와 같다.

  • MemberControllerWithLombok에는 MemberService가 필요하다.
  • memberService 필드에 final이 붙어 있다.
  • @RequiredArgsConstructor가 MemberService를 받는 생성자를 만든다.
  • Spring이 그 생성자를 통해 MemberService 객체를 주입한다.
  • Controller는 주입받은 memberService를 사용한다.

즉, @RequiredArgsConstructor는 Controller나 Service에서 필요한 의존 객체를 생성자로 주입받을 때 자주 사용된다.
생성자 코드가 보이지 않아도 실제로는 생성자가 자동 생성되어 동작한다.


생성자 어노테이션 차이 정리

생성자 어노테이션은 이름이 비슷해 보여도 기준이 다르다.
어떤 값을 생성자에서 받을 것인지에 따라 선택해야 한다.


정리하면 아래와 같다.

  • @NoArgsConstructor : 아무 값도 받지 않는 기본 생성자를 만든다.
  • @AllArgsConstructor : 모든 필드를 받는 생성자를 만든다.
  • @RequiredArgsConstructor : final 또는 @NonNull처럼 필수인 필드를 받는 생성자를 만든다.

즉, 생성자 관련 어노테이션의 핵심은 객체를 만들 때 어떤 필드를 반드시 받아야 하는지 구분하는 것이다.
이 기준을 잡으면 세 어노테이션을 헷갈리지 않고 사용할 수 있다.




ToString과 EqualsAndHashCode

객체를 만들고 나면 객체 안의 값을 확인하거나, 두 객체가 같은지 비교해야 할 때가 있다.
이때 Lombok은 @ToString과 @EqualsAndHashCode를 제공한다.
둘은 모두 객체와 관련된 메서드를 자동 생성하지만 역할은 다르다.


@ToString은 객체 내용을 문자열로 확인하기 위한 메서드를 만든다.
@EqualsAndHashCode는 객체 비교에 필요한 메서드를 만든다.
즉, 하나는 출력용이고, 다른 하나는 비교용이다.


객체 출력과 객체 비교가 필요한 이유

객체를 사용하다 보면 객체 안에 어떤 값이 들어 있는지 확인해야 할 때가 있다.
예를 들어 요청으로 받은 DTO에 값이 제대로 들어왔는지 확인하거나, Service에서 만든 객체의 상태를 확인해야 할 수 있다.
이때 객체를 출력하면 내부 값을 빠르게 확인할 수 있다.


또 객체 두 개가 같은지 비교해야 할 때도 있다.
예를 들어 같은 회원 정보인지, 같은 상품인지, 같은 값이 이미 컬렉션 안에 들어 있는지 확인해야 할 수 있다.
이때 단순히 ==로 비교하면 객체 안의 값이 아니라 메모리상 같은 객체인지 비교하게 된다.


그래서 객체 내용을 보기 좋게 출력하려면 toString()이 필요하고, 객체를 값 기준으로 비교하려면 equals()와 hashCode()가 필요하다.


@ToString은 객체 내용을 확인하기 위한 기능이고, @EqualsAndHashCode는 객체를 값 기준으로 비교하기 위한 기능이다.
둘은 함께 보이지만 목적이 다르다.


Lombok 없이 작성한 출력과 비교 코드

Lombok 없이 객체 출력과 비교 기능을 만들려면 toString(), equals(), hashCode()를 직접 작성해야 한다.
아래 예시는 name과 age 값이 같으면 같은 객체로 보도록 만든 코드다.

// UserCompareWithoutLombok.java
import java.util.Objects; // 객체 비교와 hashCode 생성에 사용

public class UserCompareWithoutLombok {
    private String name; // 이름 저장
    private int age; // 나이 저장

    public UserCompareWithoutLombok(String name, int age) {
        this.name = name; // 전달받은 name 저장
        this.age = age; // 전달받은 age 저장
    }

    @Override
    public String toString() {
        return "UserCompareWithoutLombok{name='" + name + "', age=" + age + "}"; // 객체 정보 문자열 반환
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true; // 같은 객체면 true
        }

        if (o == null || getClass() != o.getClass()) {
            return false; // 비교 대상이 없거나 클래스가 다르면 false
        }

        UserCompareWithoutLombok user = (UserCompareWithoutLombok) o; // 비교 대상 형변환

        return age == user.age && Objects.equals(name, user.name); // 필드 값 비교
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age); // name과 age 기준 hashCode 생성
    }
}

이 코드는 객체 출력과 비교를 정확히 할 수 있게 해 준다.
하지만 코드가 길고 반복된다.
필드가 늘어나면 toString(), equals(), hashCode() 안에서 비교해야 하는 값도 함께 늘어난다.


Lombok은 이 반복 코드를 어노테이션으로 줄여 준다.


ToString으로 줄인 코드

@ToString은 toString() 메서드를 자동으로 만들어 준다.
toString()은 객체를 문자열로 표현할 때 사용되는 메서드다.

// UserToStringExample.java
import lombok.AllArgsConstructor; // 전체 필드 생성자 자동 생성
import lombok.ToString; // toString 자동 생성

@ToString
@AllArgsConstructor
public class UserToStringExample {
    private String name; // 이름 저장
    private int age; // 나이 저장
}

이렇게 작성하면 객체를 출력할 때 필드 값이 포함된 문자열을 볼 수 있다.

// UserToStringTest.java
public class UserToStringTest {
    public static void main(String[] args) {
        UserToStringExample user = new UserToStringExample("둘리", 20); // 사용자 객체 생성

        System.out.println(user); // 객체 정보 출력
    }
}
// 출력결과
// UserToStringExample(name=둘리, age=20)

@ToString은 특히 디버깅할 때 유용하다.
디버깅은 코드가 어떻게 동작하는지 확인하면서 문제를 찾는 과정이다.
객체 안에 어떤 값이 들어 있는지 빠르게 확인할 수 있기 때문이다.


핵심은 @ToString이 객체를 사람이 읽기 쉬운 문자열로 확인할 수 있게 toString() 메서드를 자동 생성한다는 점이다.


Controller와 Service에서 객체 내용을 확인하는 흐름

@ToString은 Controller나 Service에서 객체 안의 값을 확인할 때 자주 도움이 된다.
예를 들어 요청값을 DTO로 받은 뒤, 값이 제대로 들어왔는지 확인하고 싶을 수 있다.

// MemberRequestDTO.java
import lombok.Getter; // getter 자동 생성
import lombok.Setter; // setter 자동 생성
import lombok.ToString; // toString 자동 생성

@Getter
@Setter
@ToString
public class MemberRequestDTO {
    private String name; // 이름 저장
    private int age; // 나이 저장
}

Controller에서는 아래처럼 객체 자체를 출력할 수 있다.

// MemberLogController.java
import org.springframework.stereotype.Controller; // Controller 등록
import org.springframework.web.bind.annotation.PostMapping; // POST 요청 매핑
import org.springframework.web.bind.annotation.ResponseBody; // 응답 본문 반환

@Controller
public class MemberLogController {

    @PostMapping("/member-log")
    @ResponseBody
    public String memberLog(MemberRequestDTO memberRequestDTO) {
        System.out.println(memberRequestDTO); // DTO 값 확인

        return "ok"; // 응답 문자열 반환
    }
}
// 출력결과
// MemberRequestDTO(name=둘리, age=20)

요청값으로 name=둘리, age=20이 들어오면 위처럼 객체 안의 값을 확인할 수 있다.
이 흐름에서 @ToString이 없으면 객체를 출력했을 때 사람이 보기 어려운 문자열이 나올 수 있다.
반대로 @ToString이 있으면 객체 안의 값이 바로 보여서 요청 데이터가 제대로 들어왔는지 확인하기 쉽다.


다만 비밀번호처럼 노출되면 안 되는 민감한 값이 있는 경우에는 toString() 출력에 포함되지 않도록 조심해야 한다.
객체 출력은 편리하지만, 모든 값을 무조건 출력해도 된다는 뜻은 아니다.


EqualsAndHashCode로 줄인 코드

@EqualsAndHashCode는 equals()와 hashCode() 메서드를 자동으로 만들어 준다.
equals()는 두 객체가 같은지 비교할 때 사용하는 메서드다.
hashCode()는 객체를 특정 숫자값으로 표현하는 메서드이며, 객체를 컬렉션에서 비교하거나 찾을 때 함께 사용된다.

// UserEqualsAndHashCodeExample.java
import lombok.AllArgsConstructor; // 전체 필드 생성자 자동 생성
import lombok.EqualsAndHashCode; // equals와 hashCode 자동 생성

@EqualsAndHashCode
@AllArgsConstructor
public class UserEqualsAndHashCodeExample {
    private String name; // 이름 저장
    private int age; // 나이 저장
}

이 어노테이션을 사용하면 필드 값을 기준으로 객체 비교 메서드가 자동 생성된다.
따라서 같은 값을 가진 객체를 비교할 때 더 의미 있는 비교가 가능해진다.

// UserEqualsAndHashCodeTest.java
public class UserEqualsAndHashCodeTest {
    public static void main(String[] args) {
        UserEqualsAndHashCodeExample user1 = new UserEqualsAndHashCodeExample("둘리", 20); // 첫 번째 객체 생성
        UserEqualsAndHashCodeExample user2 = new UserEqualsAndHashCodeExample("둘리", 20); // 두 번째 객체 생성

        System.out.println(user1.equals(user2)); // 값 기준 비교
    }
}
// 출력결과
// true

두 객체는 서로 다른 시점에 new로 만들어진 객체다.
그래도 name과 age 값이 같기 때문에 equals() 비교 결과가 true가 된다.


핵심은 @EqualsAndHashCode가 객체의 값 비교에 필요한 equals()와 hashCode() 메서드를 자동 생성한다는 점이다.


컬렉션에서 객체를 비교하는 흐름

@EqualsAndHashCode는 Set이나 Map처럼 객체를 비교해서 저장하거나 찾는 컬렉션에서 중요하다.
Set은 중복 값을 허용하지 않는 자료구조다.
그런데 객체를 넣을 때 어떤 기준으로 같은 객체인지 알아야 중복을 판단할 수 있다.


아래 코드는 같은 값을 가진 객체를 Set에 넣는 예시다.

// UserSetCompareTest.java
import java.util.HashSet; // 중복을 허용하지 않는 Set 구현체
import java.util.Set; // Set 타입 사용

public class UserSetCompareTest {
    public static void main(String[] args) {
        Set<UserEqualsAndHashCodeExample> users = new HashSet<>(); // User 객체를 저장할 Set 생성

        users.add(new UserEqualsAndHashCodeExample("둘리", 20)); // 첫 번째 객체 저장
        users.add(new UserEqualsAndHashCodeExample("둘리", 20)); // 같은 값을 가진 두 번째 객체 저장 시도

        System.out.println(users.size()); // 저장된 객체 개수 출력
    }
}
// 출력결과
// 1

@EqualsAndHashCode가 있으면 Set은 두 객체의 값을 비교해서 같은 객체로 판단할 수 있다.
그래서 같은 값을 가진 객체가 중복으로 저장되지 않는다.


여기서는 깊게 외우기보다, 객체 비교를 정확히 하려면 equals()와 hashCode()가 함께 필요하다고 이해하면 된다.
특히 HashSet, HashMap처럼 해시 기반 컬렉션에서는 두 메서드가 함께 중요해진다.


출력용 메서드와 비교용 메서드의 차이

@ToString과 @EqualsAndHashCode는 모두 객체를 다룰 때 사용하지만 목적이 다르다.
@ToString은 객체 안의 값을 문자열로 확인하기 위한 출력용 기능이다.
반대로 @EqualsAndHashCode는 객체가 같은지 비교하기 위한 비교용 기능이다.


정리하면 아래와 같다.

  • @ToString : 객체 내용을 문자열로 확인할 때 사용한다.
  • @EqualsAndHashCode : 객체를 값 기준으로 비교할 때 사용한다.

즉, 객체를 보기 좋게 출력하고 싶은지, 객체가 같은지 비교하고 싶은지에 따라 사용하는 어노테이션이 달라진다.
이 차이를 잡아 두면 두 어노테이션을 같은 기능으로 착각하지 않게 된다.




Builder

@Builder는 객체를 만들 때 빌더 패턴을 적용할 수 있게 해 주는 어노테이션이다.
빌더 패턴은 객체를 만들 때 필요한 값을 메서드 이름으로 하나씩 지정하고, 마지막에 객체를 완성하는 방식이다.


생성자로 객체를 만들 때는 값의 순서를 정확히 맞춰야 한다.
필드가 적을 때는 괜찮지만, 필드가 많아지면 어떤 값이 어떤 필드에 들어가는지 헷갈릴 수 있다.
Builder 방식은 이런 문제를 줄이기 위해 사용한다.


생성자 방식이 헷갈리는 이유

생성자 방식은 객체를 만들 때 필요한 값을 괄호 안에 순서대로 넣는 방식이다.
예를 들어 이름, 나이, 주소를 받는 생성자가 있다고 보자.


필드가 적을 때는 생성자 방식도 충분히 읽기 쉽다.
하지만 필드가 많아지거나 같은 타입의 값이 여러 개 나오면 순서를 헷갈릴 수 있다.
특히 String 값이 여러 개라면 순서를 바꿔도 문법 오류가 나지 않을 수 있다.


예를 들어 이름과 주소가 모두 String이라면, "서울"이 이름 자리에 들어가도 문법적으로는 문제가 없을 수 있다.
하지만 실제 객체에는 잘못된 값이 들어가게 된다.


생성자 방식의 핵심 문제는 값의 의미가 이름으로 보이지 않고 순서에 의존한다는 점이다.
이 문제를 줄이기 위해 Builder 방식을 사용할 수 있다.


Lombok 없이 Builder를 직접 작성한 코드

Builder 방식은 원래 직접 작성할 수도 있다.
하지만 직접 작성하면 객체 필드뿐 아니라, 값을 하나씩 저장하는 Builder 클래스와 build() 메서드까지 만들어야 한다.


아래 코드는 Lombok 없이 Builder 방식을 직접 작성한 예시다.

// UserManualBuilderExample.java
public class UserManualBuilderExample {
    private String name; // 이름 저장
    private int age; // 나이 저장
    private String address; // 주소 저장

    private UserManualBuilderExample(String name, int age, String address) {
        this.name = name; // name 값 저장
        this.age = age; // age 값 저장
        this.address = address; // address 값 저장
    }

    public static UserManualBuilderExampleBuilder builder() {
        return new UserManualBuilderExampleBuilder(); // Builder 객체 생성
    }

    public static class UserManualBuilderExampleBuilder {
        private String name; // Builder에서 임시로 name 저장
        private int age; // Builder에서 임시로 age 저장
        private String address; // Builder에서 임시로 address 저장

        public UserManualBuilderExampleBuilder name(String name) {
            this.name = name; // name 값 지정
            return this; // 다음 메서드를 이어서 호출할 수 있게 반환
        }

        public UserManualBuilderExampleBuilder age(int age) {
            this.age = age; // age 값 지정
            return this; // 다음 메서드를 이어서 호출할 수 있게 반환
        }

        public UserManualBuilderExampleBuilder address(String address) {
            this.address = address; // address 값 지정
            return this; // 다음 메서드를 이어서 호출할 수 있게 반환
        }

        public UserManualBuilderExample build() {
            return new UserManualBuilderExample(name, age, address); // 최종 객체 생성
        }
    }
}

직접 작성한 Builder 코드는 값의 이름이 잘 보인다는 장점이 있다.
하지만 Builder 클래스, 필드 저장 메서드, build() 메서드를 모두 직접 작성해야 해서 코드가 길어진다.


이 반복 코드를 줄이기 위해 Lombok의 @Builder를 사용할 수 있다.


Builder로 줄인 객체 생성 코드

@Builder를 사용하면 직접 작성해야 했던 Builder 클래스와 메서드를 자동으로 만들어 준다.
그래서 객체를 만들 때는 builder(), 필드 이름 메서드, build()만 사용하면 된다.

// UserBuilderExample.java
import lombok.Builder; // builder 자동 생성
import lombok.ToString; // 출력 확인용 toString 자동 생성

@Builder
@ToString
public class UserBuilderExample {
    private String name; // 이름 저장
    private int age; // 나이 저장
    private String address; // 주소 저장
}

이제 아래처럼 객체를 만들 수 있다.

// UserBuilderTest.java
public class UserBuilderTest {
    public static void main(String[] args) {
        UserBuilderExample user = UserBuilderExample.builder() // builder 시작
                .name("둘리") // name 값 지정
                .age(20) // age 값 지정
                .address("서울") // address 값 지정
                .build(); // 객체 생성 완료

        System.out.println(user); // 객체 정보 출력
    }
}
// 출력결과
// UserBuilderExample(name=둘리, age=20, address=서울)

@Builder를 사용하면 직접 작성해야 했던 Builder 관련 코드가 줄어든다.
그리고 객체를 만들 때 어떤 필드에 어떤 값이 들어가는지도 메서드 이름으로 확인할 수 있다.


핵심은 @Builder가 생성자 자체를 단순히 줄이는 것이 아니라, Builder 방식으로 객체를 만들기 위한 코드를 자동 생성해 준다는 점이다.


Controller와 Service에서 Builder를 사용하는 흐름

Builder는 요청을 처리한 뒤 응답용 DTO를 만들 때 자주 사용할 수 있다.
예를 들어 Service에서 회원 정보를 조회한 뒤, 화면이나 API 응답으로 보낼 객체를 만든다고 보자.


아래는 응답용 DTO를 Builder 방식으로 만들 수 있게 준비한 코드다.

// MemberResponseDTO.java
import lombok.Builder; // builder 자동 생성
import lombok.Getter; // getter 자동 생성
import lombok.ToString; // 출력 확인용 toString 자동 생성

@Getter
@Builder
@ToString
public class MemberResponseDTO {
    private String name; // 응답할 이름
    private int age; // 응답할 나이
    private String message; // 응답 메시지
}

Service에서는 아래처럼 필드 이름을 보면서 응답 객체를 만들 수 있다.

// MemberService.java
import org.springframework.stereotype.Service; // Service 등록

@Service
public class MemberService {

    public MemberResponseDTO findMember() {
        return MemberResponseDTO.builder() // builder 시작
                .name("둘리") // name 값 지정
                .age(20) // age 값 지정
                .message("회원 조회 성공") // message 값 지정
                .build(); // 응답 DTO 생성
    }
}

Controller에서는 Service가 만든 응답 객체를 반환할 수 있다.

// MemberResponseController.java
import lombok.RequiredArgsConstructor; // 생성자 주입용 생성자 자동 생성
import org.springframework.stereotype.Controller; // Controller 등록
import org.springframework.web.bind.annotation.GetMapping; // GET 요청 매핑
import org.springframework.web.bind.annotation.ResponseBody; // 응답 본문 반환

@Controller
@RequiredArgsConstructor
public class MemberResponseController {
    private final MemberService memberService; // 의존 객체 주입

    @GetMapping("/member-response")
    @ResponseBody
    public MemberResponseDTO memberResponse() {
        return memberService.findMember(); // Service가 만든 DTO 반환
    }
}

이 흐름은 아래처럼 정리할 수 있다.

  • Controller가 요청을 받는다.
  • Controller가 Service를 호출한다.
  • Service가 응답에 필요한 값을 준비한다.
  • Service가 Builder로 MemberResponseDTO 객체를 만든다.
  • Controller가 만들어진 DTO를 응답으로 반환한다.

이때 Builder를 사용하면 어떤 필드에 어떤 값이 들어가는지 코드에서 바로 보인다.
응답 객체의 필드가 많아질수록 이 장점이 더 커진다.


생성자 방식과 Builder 방식의 차이

생성자 방식은 짧고 단순하다.
하지만 값의 의미를 순서로 기억해야 한다.
반대로 Builder 방식은 코드가 조금 길어질 수 있지만, 값이 들어가는 필드 이름이 드러나서 읽기 쉽다.


정리하면 아래와 같다.

  • 생성자 방식 : 값을 정해진 순서대로 전달한다.
  • Builder 방식 : 필드 이름을 보면서 값을 지정한다.

객체가 단순하고 필드가 적다면 생성자 방식도 충분하다.
하지만 필드가 많거나 같은 타입의 값이 많다면 Builder 방식이 더 안전하고 읽기 쉽다.


즉, 생성자 방식은 순서가 중요하고, Builder 방식은 어떤 필드에 어떤 값을 넣는지가 더 잘 드러난다.




Data

@Data는 여러 Lombok 기능을 한 번에 적용하는 어노테이션이다.
편리하지만, 어떤 기능들이 함께 적용되는지 모르고 사용하면 오히려 위험할 수 있다.
그래서 @Data는 “편한 어노테이션”으로만 외우지 말고, 어떤 기능을 묶어서 제공하는지부터 이해해야 한다.


@Data는 대표적으로 @Getter, @Setter, @ToString, @EqualsAndHashCode 기능을 함께 제공한다.
그리고 final 필드나 @NonNull 필드처럼 반드시 초기화해야 하는 필드가 있으면 @RequiredArgsConstructor 역할도 함께 적용된다.


즉, 필드 값을 읽고, final이 아닌 필드 값을 바꾸고, 객체를 출력하고, 객체를 비교하는 기능이 한 번에 들어갈 수 있다.
필수 필드가 있다면 그 필드를 받는 생성자까지 자동으로 만들어질 수 있다.


Data가 한 번에 만들어 주는 기능

@Data를 클래스에 붙이면 여러 메서드가 자동 생성된다.
가장 먼저 모든 필드에 대한 getter가 만들어진다.
또 final이 아닌 필드에 대해서는 setter가 만들어진다.


그리고 toString(), equals(), hashCode()도 함께 만들어진다.
필수 필드를 받는 생성자도 자동 생성될 수 있다.
다만 이 생성자 기능은 final 필드나 @NonNull 필드처럼 반드시 초기화해야 하는 필드가 있을 때 의미가 분명해진다.


정리하면 @Data는 아래 기능을 한 번에 적용한다.

  • @Getter : 필드 값을 읽는 메서드를 만든다.
  • @Setter : final이 아닌 필드 값을 바꾸는 메서드를 만든다.
  • @ToString : 객체 내용을 문자열로 확인하게 만든다.
  • @EqualsAndHashCode : 객체 비교 메서드를 만든다.
  • @RequiredArgsConstructor : final 또는 @NonNull 필드가 있을 때 필수 필드 생성자를 만든다.

핵심은 @Data가 여러 어노테이션 기능을 한 번에 적용하는 강한 어노테이션이라는 점이다.
그래서 편리하지만, 어떤 기능이 함께 생기는지 알고 사용해야 한다.


Lombok 없이 작성한 전체 코드

@Data가 줄여 주는 코드를 직접 작성하면 생각보다 길다.
아래 코드는 name과 age 필드를 가진 클래스에 getter, setter, toString(), equals(), hashCode()를 직접 작성한 예시다.

// UserDataWithoutLombok.java
import java.util.Objects; // equals와 hashCode 작성에 사용

public class UserDataWithoutLombok {
    private String name; // 이름 저장
    private int age; // 나이 저장

    public String getName() {
        return name; // name 값 반환
    }

    public void setName(String name) {
        this.name = name; // name 값 저장
    }

    public int getAge() {
        return age; // age 값 반환
    }

    public void setAge(int age) {
        this.age = age; // age 값 저장
    }

    @Override
    public String toString() {
        return "UserDataWithoutLombok{name='" + name + "', age=" + age + "}"; // 객체 정보 문자열 반환
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true; // 같은 객체면 true
        }

        if (o == null || getClass() != o.getClass()) {
            return false; // 비교할 수 없으면 false
        }

        UserDataWithoutLombok user = (UserDataWithoutLombok) o; // 비교 대상 형변환

        return age == user.age && Objects.equals(name, user.name); // 필드 값 비교
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age); // name과 age 기준 hashCode 생성
    }
}

@Data는 이렇게 길게 작성해야 하는 기능들을 한 번에 묶어 준다.
그래서 단순히 값을 담는 클래스에서는 코드가 매우 짧아질 수 있다.


하지만 여기서 중요한 점이 있다.
코드가 짧아졌다는 것은 기능이 적다는 뜻이 아니다.
오히려 여러 기능이 한 번에 들어간다는 뜻이다.


Data로 줄인 코드

@Data를 사용하면 위 코드를 아래처럼 줄일 수 있다.

// UserDataWithLombok.java
import lombok.Data; // getter, setter, toString, equals, hashCode 등을 자동 생성

@Data
public class UserDataWithLombok {
    private String name; // 이름 저장
    private int age; // 나이 저장
}

이렇게 작성하면 코드가 매우 짧아진다.
하지만 짧아졌다고 해서 기능이 없는 것은 아니다.
오히려 getter, setter, toString(), equals(), hashCode()가 한 번에 들어간다.


그래서 @Data는 편리하지만 조심해서 써야 한다.
특히 값이 함부로 바뀌면 안 되는 객체라면 setter가 자동 생성되는 것이 문제가 될 수 있다.


즉, @Data는 여러 기능을 한 번에 제공하기 때문에 편리하지만, 클래스 목적에 맞는지 반드시 확인해야 한다.


Data를 무조건 쓰면 위험한 이유

@Data는 편리하지만 모든 클래스에 무조건 붙이는 것은 좋은 습관이 아니다.
왜냐하면 @Setter까지 함께 생성되기 때문이다.
모든 필드에 setter가 생기면 객체 값이 여러 곳에서 쉽게 바뀔 수 있다.


또 toString(), equals(), hashCode()까지 자동 생성되기 때문에, 객체 구조에 따라 예상하지 못한 문제가 생길 수 있다.
예를 들어 서로 연결된 객체가 복잡하게 얽혀 있다면 toString() 호출 시 너무 많은 정보가 출력될 수 있다.


따라서 @Data를 사용할 때는 이 클래스에 정말 모든 기능이 필요한지 먼저 생각해야 한다.
단순히 값을 담는 용도의 클래스라면 사용할 수 있지만, 값 변경을 제한해야 하는 클래스라면 필요한 어노테이션만 따로 붙이는 편이 더 안전할 수 있다.


@Data는 편리하지만 무조건 좋은 선택은 아니며, 클래스 목적에 맞는 기능만 선택해야 한다.


필요한 어노테이션만 선택하는 기준

@Data 대신 필요한 어노테이션만 골라 쓰면 클래스의 의도가 더 분명해진다.
값을 읽기만 하면 되는 클래스라면 @Getter만 붙이면 된다.
값을 바꿔야 하는 경우에만 @Setter를 붙이면 된다.
객체 출력이 필요하면 @ToString을 붙이면 된다.


정리하면 아래처럼 선택할 수 있다.

  • 값을 읽기만 하면 @Getter를 사용한다.
  • 값을 변경해야 하면 @Setter를 추가한다.
  • 객체 내용을 확인해야 하면 @ToString을 추가한다.
  • 객체를 값 기준으로 비교해야 하면 @EqualsAndHashCode를 추가한다.
  • 필수 필드 생성자가 필요하면 @RequiredArgsConstructor를 사용한다.
  • 모든 기능이 정말 필요할 때만 @Data를 신중하게 사용한다.

즉, Lombok은 많이 붙이는 것이 중요한 게 아니라, 필요한 기능만 정확히 선택하는 것이 중요하다.




NonNull

@NonNull은 값이 null인지 자동으로 검사하는 데 사용되는 어노테이션이다.
null은 값이 없다는 뜻이다.
어떤 값이 반드시 있어야 하는데 null이 들어오면 프로그램 실행 중 문제가 생길 수 있다.


@NonNull은 이런 상황에서 null 값을 더 빨리 발견하도록 도와준다.
값이 null이면 NullPointerException이 발생할 수 있다.
여기서 NullPointerException은 값이 없는 대상을 사용하려고 할 때 발생하는 예외다.


NonNull이 필요한 이유

객체를 만들 때 반드시 필요한 값이 있을 수 있다.
예를 들어 회원 이름이 반드시 있어야 하는 객체라면, name에 null이 들어오면 정상적인 객체라고 보기 어렵다.


이런 값은 객체를 만들 때부터 검사하는 것이 좋다.
값이 없는 상태로 객체가 만들어지고 나중에 문제가 터지면 원인을 찾기 어려워질 수 있다.
그래서 반드시 필요한 값에는 null이 들어오지 못하도록 막아야 한다.


@NonNull은 반드시 값이 있어야 하는 위치를 코드에 표시하고, null이 들어왔을 때 빠르게 문제를 발견하도록 도와준다.


Lombok 없이 작성한 null 검사 코드

Lombok 없이 null 검사를 하려면 생성자나 메서드 안에서 직접 조건문을 작성해야 한다.
아래 코드는 name이 null이면 예외를 발생시키는 예시다.

// UserNullCheckWithoutLombok.java
public class UserNullCheckWithoutLombok {
    private String name; // 이름 저장

    public UserNullCheckWithoutLombok(String name) {
        if (name == null) {
            throw new NullPointerException("name is marked non-null but is null"); // null이면 예외 발생
        }

        this.name = name; // null이 아니면 name 저장
    }
}

이 코드는 name이 반드시 있어야 한다는 규칙을 직접 검사한다.
하지만 이런 검사를 여러 생성자나 메서드에서 반복해야 한다면 코드가 길어진다.


@NonNull은 이런 null 검사 코드를 줄이는 데 도움을 준다.


NonNull로 줄인 코드

@NonNull은 해당 값이 null이면 안 된다는 표시다.
필드나 매개변수에 붙일 수 있고, Lombok은 이 표시를 보고 자동으로 null 검사를 넣어 줄 수 있다.

// UserNonNullExample.java
import lombok.NonNull; // null 검사 자동 생성
import lombok.RequiredArgsConstructor; // 필수 필드 생성자 자동 생성

@RequiredArgsConstructor
public class UserNonNullExample {
    @NonNull
    private String name; // null 값을 허용하지 않는 이름
}

이 경우 @RequiredArgsConstructor는 @NonNull이 붙은 name을 생성자 매개변수에 포함할 수 있다.
그리고 생성자에 null이 전달되면 NullPointerException이 발생할 수 있다.


즉, @NonNull은 단순한 표시가 아니라 값이 없으면 안 되는 위치를 코드에 드러내는 역할을 한다.


핵심은 @NonNull이 null이 들어오면 안 되는 값임을 표시하고, 필요한 경우 자동으로 null 검사를 넣어 준다는 점이다.


RequiredArgsConstructor와 함께 쓰이는 흐름

@RequiredArgsConstructor는 필수 필드를 받는 생성자를 만들어 준다.
여기서 필수 필드에는 final 필드뿐 아니라 @NonNull이 붙은 필드도 포함될 수 있다.

// UserRequiredNonNullExample.java
import lombok.NonNull; // null 허용하지 않음
import lombok.RequiredArgsConstructor; // 필수 필드 생성자 자동 생성

@RequiredArgsConstructor
public class UserRequiredNonNullExample {
    @NonNull
    private String name; // 생성자에서 반드시 받을 값

    private int age; // 생성자 필수 값은 아님
}

위 코드에서는 name이 @NonNull이므로 생성자에서 받을 값이 될 수 있다.
반면 age는 final도 아니고 @NonNull도 아니므로 필수 생성자 대상이 아니다.


이 흐름은 아래처럼 이해하면 된다.

  • name은 @NonNull이 붙어 있다.
  • @RequiredArgsConstructor는 name을 필수 값으로 판단한다.
  • name을 받는 생성자가 자동 생성된다.
  • 생성자에 null이 들어오면 예외가 발생할 수 있다.

이렇게 @NonNull은 단순히 null 검사뿐 아니라, 어떤 필드가 객체 생성 시 꼭 필요한 값인지 드러내는 역할도 할 수 있다.


즉, @NonNull과 @RequiredArgsConstructor를 함께 보면, 값이 반드시 필요한 필드를 생성자에서 받도록 만들 수 있다.


NonNull을 사용할 때 주의할 점

@NonNull은 편리하지만, 아무 곳에나 붙인다고 모든 문제가 해결되는 것은 아니다.
먼저 어떤 값이 정말 null이면 안 되는지 판단해야 한다.
선택적으로 비어 있을 수 있는 값에 @NonNull을 붙이면 오히려 불필요한 예외가 발생할 수 있다.


따라서 @NonNull은 반드시 값이 있어야 하는 필드나 매개변수에 사용해야 한다.
즉, 값이 없으면 객체가 정상적으로 동작할 수 없는 경우에 붙이는 것이 자연스럽다.


핵심은 @NonNull을 null 검사용 편의 기능으로만 보지 말고, 반드시 필요한 값을 코드에 표시하는 도구로 이해하는 것이다.




Lombok 사용 시 주의할 점

Lombok은 코드를 짧게 만들어 주는 도구이지만, 코드를 모르게 만들어도 되는 도구는 아니다.
어노테이션을 붙이면 코드가 보이지 않게 줄어들지만, 실제로는 getter, setter, 생성자, toString() 같은 메서드가 자동으로 만들어진 것처럼 동작한다.


따라서 Lombok을 사용할 때는 어떤 어노테이션이 어떤 코드를 만들어 주는지 반드시 이해해야 한다.
코드가 짧아지는 장점만 보고 무작정 붙이면, 나중에 객체가 왜 변경되는지, 어떤 생성자가 있는지, 어떤 기준으로 비교되는지 헷갈릴 수 있다.


자동 생성되는 코드를 이해해야 하는 이유

Lombok을 쓰면 반복 코드를 줄일 수 있다.
하지만 반복 코드를 줄인다는 말은 그 코드의 의미를 몰라도 된다는 뜻이 아니다.
@Getter를 붙이면 getter가 생기고, @Setter를 붙이면 setter가 생기고, @Builder를 붙이면 Builder 방식으로 객체를 만들 수 있다.


즉, 어노테이션 하나를 붙이는 것은 단순한 장식이 아니다.
클래스에 실제 기능을 추가하는 일이다.
그래서 어노테이션을 붙이기 전에 이 클래스에 어떤 기능이 필요한지 먼저 생각해야 한다.


예를 들어 값을 바꾸면 안 되는 객체에 @Setter를 붙이면 외부에서 값을 쉽게 바꿀 수 있다.
또 모든 기능이 필요하지 않은 클래스에 @Data를 붙이면 너무 많은 메서드가 자동으로 만들어질 수 있다.


핵심은 Lombok을 사용할 때 코드가 짧아졌다는 결과보다, 어떤 코드가 자동으로 만들어졌는지를 이해하는 것이 더 중요하다는 점이다.


클래스 목적에 맞게 어노테이션을 고르는 기준

Lombok 어노테이션은 클래스 목적에 맞게 선택해야 한다.
단순히 모든 어노테이션을 많이 붙인다고 좋은 코드가 되는 것은 아니다.
필요한 기능만 붙이는 것이 더 명확한 코드가 될 수 있다.


예를 들어 값을 읽기만 하면 되는 클래스라면 @Getter만으로 충분할 수 있다.
값 변경이 필요하지 않다면 @Setter는 붙이지 않는 것이 더 안전할 수 있다.
객체를 생성할 때 필드가 많고 순서가 헷갈린다면 @Builder를 사용할 수 있다.


생성자가 필요하다면 어떤 생성자가 필요한지도 생각해야 한다.
기본 생성자가 필요한지, 모든 필드를 받는 생성자가 필요한지, 필수 필드만 받는 생성자가 필요한지에 따라 사용하는 어노테이션이 달라진다.


정리하면 아래처럼 볼 수 있다.

  • 값 읽기만 필요하면 @Getter를 고려한다.
  • 값 변경이 필요하면 @Setter를 고려한다.
  • 기본 생성자가 필요하면 @NoArgsConstructor를 고려한다.
  • 모든 필드 생성자가 필요하면 @AllArgsConstructor를 고려한다.
  • 필수 필드 생성자가 필요하면 @RequiredArgsConstructor를 고려한다.
  • 객체 생성 코드가 복잡하면 @Builder를 고려한다.
  • 객체 내용을 출력해야 하면 @ToString을 고려한다.
  • 객체를 값 기준으로 비교해야 하면 @EqualsAndHashCode를 고려한다.
  • 여러 기능을 한 번에 적용해야 한다면 @Data를 신중하게 고려한다.

즉, Lombok의 핵심은 어노테이션을 많이 쓰는 것이 아니라, 클래스 목적에 맞는 어노테이션을 정확히 선택하는 것이다.




마지막 정리

Lombok은 Java 코드에서 반복적으로 작성되는 메서드와 생성자를 어노테이션으로 자동 생성해 주는 라이브러리다.
코드를 짧게 만들어 주지만, 실제로 어떤 코드가 만들어지는지 이해하지 못하면 오히려 헷갈릴 수 있다.


@Getter는 필드 값을 읽는 메서드를 만들고, @Setter는 필드 값을 변경하는 메서드를 만든다.
요청값이 DTO에 저장될 때는 setter가 사용될 수 있고, 저장된 값을 Controller에서 읽을 때는 getter가 사용된다.


@NoArgsConstructor, @AllArgsConstructor, @RequiredArgsConstructor는 각각 기본 생성자, 전체 필드 생성자, 필수 필드 생성자를 만든다.
특히 @RequiredArgsConstructor는 Controller나 Service에서 final 의존 객체를 생성자로 주입받을 때 자주 사용된다.


@ToString은 객체를 문자열로 확인할 수 있게 한다.
Controller나 Service에서 요청 객체나 응답 객체의 값을 확인할 때 도움이 된다.
@EqualsAndHashCode는 객체 비교에 필요한 equals()와 hashCode()를 만든다.
Set, Map 같은 컬렉션에서 객체를 값 기준으로 비교할 때 중요하다.


@Builder는 Builder 방식으로 객체를 만들기 위한 코드를 자동 생성해 준다.
직접 Builder를 만들면 Builder 클래스와 필드 저장 메서드, build() 메서드를 작성해야 하지만, @Builder를 사용하면 이 반복 코드를 줄일 수 있다.
Builder 방식은 어떤 필드에 어떤 값을 넣는지가 잘 드러난다.


@Data는 여러 기능을 한 번에 제공하지만, 필요한 기능이 무엇인지 알고 신중하게 사용해야 한다.
@NonNull은 반드시 값이 있어야 하는 위치에 null 검사를 도와준다.


결국 Lombok을 사용할 때 가장 중요한 것은 편리함보다 이해다.
어노테이션을 붙이면 어떤 메서드가 생기는지, 객체 값이 어디서 저장되고 읽히는지, 생성자가 어떤 필드를 받는지 알아야 한다.


최종 핵심은 Lombok이 반복 코드를 줄여 주는 도구이지만, 자동 생성되는 코드의 역할을 이해하고 클래스 목적에 맞는 어노테이션을 선택해야 한다는 점이다.

0개의 댓글