TIL/TIL

Lombok @Builder vs @Accessor vs Setter

JoJobum 2023. 4. 21.

서론

최근 스프링 개발할 때 Lombok의 @Builder 어노테이션을 많이 쓰고 있는데, 
아무리 생각해도 기존의 방식인 get/set 방식이랑 비교해서 너무 편해지고 가독성도 좋기에 분명 성능의 일부를 포기하고 이러한 장점을 얻었을 것이라는 생각이 자연스럽게 들었다.

그렇지만 Setter를 활용하는 것은 코드의 가독성도 해치고 무엇보다 너무 귀찮다. 검색하던 와중 이러한 불편함을 해결하는 @Accessors에 대한 존재도 알 수 있었다. 

 

그래서 @Builder 나 @Accessors 같이 편하게 만들어주는 요소들은 성능의 일부를 포기하는 것이 정말 맞나? 라는 답을 찾기 위해 테스트를 수행하고 결과에 대한 이야기를 하기 전에 @Builder와 @Accessors에 대한 이야기를 먼저 해보고자 한다.

 

@Builder란? + 내부 동작

Lombok에서 제공하는 이 어노테이션은 생성자 인자를 메서드 체인을 통해 명시적으로 대입하여 생성자를 호출할 수 있게 빌더 클래스를 생성 해준다. 빌더 클래스와 IDE의 자동 완성 기능을 같이 활용하면 생성자 작성 시 오기입 확률과 인자를 누락할 확률을 획기적으로 낮출 수 있다.

@Data
@Builder
public class Student {
  private String firstName;
  private String lastName;
  private String studentId;
  private Integer year;
  private List<Integer> marks;
}

 

Student student = Student.builder()
	.firstName("John")
	.studentId("John312")
	.year(2)
	.build();

빌더 패턴을 통해 많은 필드를 가진 간단한 데이터 클래스의 인스턴스를 쉽게 만들 수 있음

하지만 좀 더 복잡해진다면 이야기는 달라진다.

 

이를 알기 위해 @Builder의 동작 원리를 파고 들어가보자면

기본적으로 메소드, 생성자에만 붙일 수 있는데

클래스에 붙일 경우 모든 요소를 받는 package-private 생성자가 자동으로 생성되며,

이 생성자에 @Builder를 붙인 것과 동일하게 동작 ⇒ 클래스에 붙은 @Builder도 생성자에 붙인 것과 사실상 동일하다.

 

위의 package-private 생성자를 만드는 경우는 (NoArgs, RequiredArgs) 또는 어떤 생성자도 클래스 내부에 선언하지 않았을 경우에만 생성된다.

 

두 경우 중 하나를 했을 경우 모든 필드를 매개값으로 하는 생성자를 자동 선언하여 사용하기에 All Args Constructor가 없으면 컴파일 에러가 발생한다.

 

@Builder
public class BuildMe {

    private String username;
    private int age;
    private final String keyword = "VERSION 1.0";

}

@Builder 를 붙이면 내부적으로는 아래와 같이 변환된다

public class BuildMe {
    private String username;
    private int age;
	private final String constant = "VERSION 1.0";
	private final String keyword;

	// package-private 생성자 생성
	BuildMe(String username, int age, String keyword) {
        this.username = username;
        this.age = age;
        this.keyword = keyword;
    }

    public static BuildMe.BuildMeBuilder builder() {
        return new BuildMe.BuildMeBuilder();
    }

		// 클래스 필드와 동일한 필드를 가지고 
		// 필드 이름의 setter 메소드를 제공하는 빌더 클래스 생성
    public static class BuildMeBuilder {
        private String username;
        private int age;
        private String keyword;

        BuildMeBuilder() {
        }

        public BuildMe.BuildMeBuilder username(String username) {
            this.username = username;
            return this;
        }

        public BuildMe.BuildMeBuilder age(int age) {
            this.age = age;
            return this;
        }

        public BuildMe.BuildMeBuilder keyword(String keyword) {
            this.keyword = keyword;
            return this;
        }

        public BuildMe build() {
            return new BuildMe(this.username, this.age, this.keyword);
        }

        public String toString() {
            return "BuildMe.BuildMeBuilder(username=" + this.username + ", age=" + this.age + ", keyword=" + this.keyword + ")";
        }
    }
}

final로 초기화되어 있는 필드 같은 경우에는 변경할 수 없기 때문에 아예 생성자도 지원하지 않음

 

💡 final인 keyword 필드에 값을 설정하지 않아도 컴파일 에러가 발생하지 않은 이유?
빌더 클래스는 클래스와 동일한 필드를 내부적으로 유지하지만 private, non-static, non-final 속성을 가지기 때문에 final인 keyword 필드도 빌더 클래스에서는 일반 전역 변수로 존재하게 된다. 생성자로 실제 클래스의 객체를 생성할 때도 기본값인 null이 전달되기에 final 필드를 초기화하지 않아도 문제가 없는 것이다.

이렇게 반드시 초기화되어야 하는 필드의 경우 @Builder.Default 속성을 사용하거나 선언 시점 혹은 생성자에서 초기화하는 것이 좋다.

 

@Builder.Default 속성을 필드 위에 작성함으로서 특정 값으로 초기화할 수 있다.

@Builder.Default
private String name = "짱구엄마";

 

 

@Builder의 단점

  1. 클래스 위에 @Builder를 선언하면 모든 멤버 필드에 대해서 매개변수를 받는 생성자를 만듭니다. 
    1. 이 경우 Id, createAt, updateAt 같이 객체 생성 시 받지 않아야 할 데이터들도 빌더에 노출이 됩니다.
    2. 그러므로 객체 생성 시 받아야할 데이터들만 Parameter로 받는 생성자를 만들고 그 위에 @Builder를 붙이는 게 바람직합니다.
  2. 클래스 위에 @Builder를 선언하면 생성자의 접근 레벨이 default가 되므로 동일 패키지 내에서 해당 생성자를 호출할 수 있는 문제가 있습니다.

You Might Stop Using Lombok’s @Builder After Reading This | by Emanuel Trandafir | Javarevisited | Medium

 

 

@Accessors가 대안이 될 수도?

@Builder가 많은 사람들에게 사랑받는 이유는 setter를 사용하는 것이 귀찮고 가독성이 떨어지기 때문이라 생각

이와 비슷한 기능을 수행하며, @Builder의 단점을 가지지 않은 @Accessors는 대안이 될 수 있다 생각함

 

Student2 studentSetter = new Student2();
studentSetter.setFirstName("cho");
studentSetter.setLastName("bumsoo");
studentSetter.setYear(100);
studentSetter.setMarks(arr);

기존에 setter를 사용하기 위해서는 위처럼 작성해야 했음

 

@Builder 를 통해 아래와 같이 작성하기 시작함

Student2 studentBuilder = Student2.builder()
                .firstName("cho")
                .lastName("bumsoo")
                .year(100)
                .marks(arr)
                .build();

 

@Accessor를 사용하면 chain 옵션값을 줌으로써 setter를 Builder 패턴처럼 사용할 수 있게 해줌

Student studentAccessor2 = new Student()
                .setFirstName("cho")
                .setLastName("bumsoo")
                .setYear(100)
                .setMarks(arr);

 

fluent 옵션값으로 get/set 도 제외하고 사용할 수 있음

Student studentAccessor = new Student()
                .firstName("cho")
                .lastName("bumsoo")
                .year(100)
                .marks(arr);

 

@Builder vs Setter, 성능 테스트

앞서 가졌던 의문이 정말 맞나? 라는 답을 찾기 위해 테스트를 시도하였다.

(제대로 된 테스트인지는 잘 모르겠지만...)

public class TestService{

    public List<AccessorTest> accessorTests(){
        List<AccessorTest> list = new ArrayList<>();
        ArrayList<Integer> marks = new ArrayList<>();
        for (int i = 0; i < 1000000; i++) {
            marks.add(i);
            list.add(new AccessorTest().age(27).firstName("cho").lastName("bumsoo").id("970923").marks(marks));
        }

        return list;
    }

    public List<BuilderTest> builderTests(){
        List<BuilderTest> list = new ArrayList<>();
        ArrayList<Integer> marks = new ArrayList<>();
        for (int i = 0; i < 1000000; i++) {
            marks.add(i);
            list.add(BuilderTest.builder().age(27).firstName("cho").lastName("bumsoo").id("970923").marks(marks).build());
        }

        return list;
    }

    public List<SetterTest> setterTests(){
        List<SetterTest> list = new ArrayList<>();
        ArrayList<Integer> marks = new ArrayList<>();
        for (int i = 0; i < 1000000; i++) {
            marks.add(i);
            SetterTest setterTest = new SetterTest();
            setterTest.setAge(27);
            setterTest.setFirstName("cho");
            setterTest.setLastName("bumsoo");
            setterTest.setId("970923");
            setterTest.setMarks(marks);
            list.add(setterTest);
        }

        return list;
    }
}
class TestServiceTest {

    private TestService testService;
    private long sTime;
    private final int repeat = 100;

    @BeforeEach
    void setTestService() {
        testService = new TestService();
        sTime = System.currentTimeMillis(); // 시작시간
    }

//    @DisplayName("Accessor Test")
//    @RepeatedTest(value = repeat)
//    void accessorTests() {
//        testService.accessorTests();
//    }

//    @DisplayName("Builder Test")
//    @RepeatedTest(value = repeat)
//    void builderTests() {
//        testService.builderTests();
//    }
//
    @DisplayName("Setter Test")
    @RepeatedTest(value = repeat)
    void setterTests() {
        testService.setterTests();
    }

    @AfterEach
    void printResult(){
        Long sec = System.currentTimeMillis() - sTime;
        System.out.println(sec);
    }

}

위의 메소드를 Junit5로 각각 100번 씩 돌려 총 수행시간을 측정해 보았을 때,

아래와 같은 결과를 만났다...!

2: 평균 수행시간, 3.총 수행시간

@Builder 이 수행 시간이 제일 적게 걸리는...?

심지어 Setter 를 사용한 경우는 다른 2가지 방법에 비해 10% 정도의 성능 차이가 발생하는 것을 확인할 수 있었다

 

 

그래서 도당체 왜??...

@Builder vs Setter

@Builder와 Setter는 사용 목적이 달랐다.

@Builder는 불변성을 유지하면서 객체를 생성하는 데 사용됨

Setter는 가변성을 유지하면서 객체의 속성을 설정하는 데 사용되지만, 프로그래머 입장에서 객체를 생성할 때 사용하기에 다른 용도로 착각하게 된다.

 

그래서 @Builder가 더 빠른 이유는 생성자를 통해 필드를 설정할 수 있기 때문이다.

생성자는 객체를 만들 때 모든 필드를 한 번에 초기화하고, 그렇기 때문에 @Builder를 사용하면 객체의 모든 필드가 한 번에 설정되므로 객체를 생성하는 데 걸리는 시간이 더 빨라진다.

반면에 Setter는 객체가 이미 생성된 이후에 사용된다. 즉, 객체의 속성을 변경하는 데 필요한 추가적인 작업이 필요하고, Setter는 해당 필드를 설정하는 작업만 수행하므로 일반적으로 생성자보다 느릴 수 있다.

 

(번외) @Builder는 객체를 생성할 때 필드를 초기화하기 때문에 객체의 불변성을 보장할 수 있다.

그러나 setter는 객체의 가변성을 유지하므로, 객체의 불변성이 보장되지 않을 수 있다. 이는 보안상의 이슈나 오류를 유발할 수 있으므로, 객체의 가변성이 필요없고 객체의 불변성이 중요한 경우에는 @Builder를 사용하는 것이 더 안전하다.

 

 

@Builder vs @Accessors

@Builder는 객체를 생성할 때 객체의 모든 필드를 한 번에 초기화하는 생성자를 자동으로 생성해주는 반면, @Accessors는 객체의 필드에 접근하기 위해 getter/setter 메서드를 자동으로 생성해줍니다.

 

두 어노테이션의 성능이 비슷하게 나오는 이유는

  1.  두 어노테이션은 다른 목적을 가지고 있지만, 컴파일 시점에서 코드를 자동으로 생성하는 방식으로 동작합니다. 이러한 코드 생성 기능은 Lombok의 Annotation Processor를 통해 수행됩니다. 따라서 빌드 시점에 코드를 생성하므로, 런타임 성능에는 거의 영향을 주지 않는다.
  2. @Accessors는 객체의 필드에 접근하기 위해 getter/setter 메서드를 생성하는 반면, @Builder는 생성자를 생성합니다. 이러한 차이점으로 인해 두 어노테이션의 성능이 조금 다를 수는 있지만, 보통 사소한 수준이다.

@Builder가 @Accessors에 비해 미미하게 선능상 좋을 수 있는 이유로는

  1. Builder 인스턴스는 일반적으로 사용 후 전혀 참조되지 않는 객체이므로, GC가 더 빠르게 실행될 수 있다.
  2. 객체가 단일 스택 프레임에서만 사용되는 경우 (즉, 다른 메소드 또는 반환 값으로 전달되지 않는 경우) 할당 및 GC가 더 빠르게 실행될 수 있습니다. @Builder가 이러한 방식으로 사용된다.
  3. JIT 컴파일은 런타임당 한 번 실행되고, 빌더 코드는 간단한 setter와 인스턴스 변수로 구성되기 때문에 사소다.
상황에 따라 @Accessors가 더 좋을 수 있는 사소한 차이이기에 성능보다는 다른 요소를 판단 기준으로 삼는게 좋겠다라는 결론을 내린다.

 

(번외) @Accessors를 남용하면 객체 접근에 대한 부담이 커질 수 있으므로, 사용 시 주의해야 한다. 예를 들어, 불필요한 getter/setter 메서드를 생성하면 객체의 메모리 사용량이 증가할 수 있고, getter/setter 호출로 인한 오버헤드가 발생할 수 있음. 따라서 객체의 필드가 자주 사용되지 않거나, 접근 지정자가 private인 경우에는 @Accessors를 사용하지 않는 것이 좋다.

 

반응형

댓글