-
[Java/Spring] 롬복(Lombok)이란? 활용법 정리프로그래밍/JAVA 2022. 10. 12. 22:30
개요
lombok의 다양한 활용법을 정리해보는 시간을 가져볼까 한다. 자바를 이용하여 개발을 진행하다보면 반복적인 코드의 작성이 발생하는 경우가 있다. 예를들어, 데이터 클래스(Data Class)를 만들 때, 각각의 필드에 대한 getter & setter 메서드를 만들어 캡슐화 작업을 해줘야하고, 생성자 작성 등 번거롭다. 이때 lombok을 활용하면 아주 유용한데, 빌드 과정에서 특정 어노테이션에 따라 .class 파일에 자바 바이트코드를 자동으로 생성해줌으로써 위에서 언급한 번거로운 작업을 해결할 수 있다.
lombok을 사용하기 위한 사전 준비는 간단하다. maven 혹은 gradle 환경에 맞게끔 의존성을 다음과 같이 추가해주면 된다.
<!-- maven --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.20</version> <scope>provided</scope> </dependency>
// gradle configurations { compileOnly { extendsFrom annotationProcessor } } compileOnly 'org.projectlombok:lombok:1.18.20' annotationProcessor 'org.projectlombok:lombok:1.18.20'
※ IntelliJ를 사용하는 경우 Plugins에서 lombok 설치를 사전에 완료한 후 의존성을 추가해야 한다.
@Getter, @Setter, @NoArgsConstructor, @AllArgsConstructor
자바에서 필드를 private으로 설정한 후, public 접근자로 getter & setter 메서드를 만들어 데이터를 캡슐화하는 것이 일반적인 관행으로 모두 잘 알고 있을 것이다. (Java Beans Pattern)
만약 필드가 계속해서 추가된다면, 이에 따라 getter & setter 메서드도 추가해줘야 하는 번거로움이 있다. 물론 IDE에서 getter & setter 메서드에 대한 자동생성을 지원해주긴 한다.
public class Book { private Long id; private String title; private String author; private String isbn; private int price; public Book() { } public Book(Long id, String title, String author, String isbn, int price) { this.id = id; this.title = title; this.author = author; this.isbn = isbn; this.price = price; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getAuthor() { return author; } public void setAuthor(String author) { this.author = author; } public String getIsbn() { return isbn; } public void setIsbn(String isbn) { this.isbn = isbn; } public int getPrice() { return price; } public void setPrice(int price) { this.price = price; } }
그러나 예제에서 확인할 수 있는 것 처럼 코드가 너무 장황해진다. 심지어 해당 메서드들은 단순히 데이터에 접근하기 위한 용도이고 비즈니스 로직을 가지고 있지도 않다. 이러한 단점을 극복하기 위해 lombok을 적용해보도록 하자.
@AllArgsConstructor @NoArgsConstructor @Getter @Setter public class Book { private Long id; private String title; private String author; private String isbn; private int price; }
@Getter, @Setter 어노테이션을 추가하여 getter & setter 메서드를 자동 생성했고, @NoArgsConstructor, @AllArgsConstructor 어노테이션을 추가하여 빈 생성자와 모든 인자를 가진 생성자를 자동 생성하였다.
또한, 일부 속성에 접근하기 위한 getter & setter의 접근 지정자 단계를 설정할 수 있다.
@AllArgsConstructor @NoArgsConstructor @Getter @Setter public class Book { @Setter(AccessLevel.PROTECTED) // == private Long getId() { return this.id; } @Getter(AccessLevel.PRIVATE) // == protected void setId(Long id) { this.id = id; } private Long id; private String title; private String author; private String isbn; private int price; }
위와같이 설정하게 되면, getId 메서드는 private, setId 메서드는 protected로 설정된다. 추가로 생성자의 접근 지정자도 설정해줄 수 있다. 방법은 비슷하다.
@AllArgsConstructor(access = AccessLevel.PACKAGE) @NoArgsConstructor(access = AccessLevel.PUBLIC) @Getter @Setter public class Book { private Long id; private String title; private String author; private String isbn; private int price; }
위와같이 설정하게 되면 빈 생성자는 public(미설정시 default), 모든 인자를 가진 생성자는 package-private로 설정된다.
Lazy Getter
특정 값을 계산하거나 객체를 생성하는데 많은 자원과 비용이 드는 경우, Lazy Getter 라는 기능을 사용할 수 있다. Lazy Getter를 사용하면 최초에 getter를 호출할 때 expensive 자원에 대한 캐시 처리가 진행되고, 이후에 getter를 호출하면 캐싱된 값을 불러옴으로써 성능 향상을 가져올 수 있다.
@Slf4j public class GetterLazy { @Getter(lazy = true) private final int[] cached = expensive(); private int[] expensive() { log.debug("expensive start"); // 계산이 몇 번 이루어지는지 확인을 위한 로그 Random random = new Random(); random.setSeed(System.currentTimeMillis()); int[] result = new int[1000000]; for (int i = 0; i < result.length; i++) { result[i] = random.nextInt(1000); // 1000 미만의 랜덤 정수 리턴 } return result; } }
해당 예제는 1000 미만의 랜덤 정수 값을 1000000번 생성하여, 1000000 크기의 배열에 넣는 계산을 하는 메서드를 가지고 있는 클래스이다. 계산이 완료된 값을 cached라는 필드 값에 캐싱한다. 이제 테스트 코드를 통해 Lazy Getter가 제대로 동작되는지 확인해보자.
class GetterLazyTest { @DisplayName("실행 비용이 많이 드는 경우 Lazy Getter를 통해 캐시처리 할 수 있다.") @Test void lazyGetterTest() { // given GetterLazy getterLazy = new GetterLazy(); // when int[] cached1 = getterLazy.getCached(); int[] cached2 = getterLazy.getCached(); int sum1 = calculateSum(cached1); int sum2 = calculateSum(cached2); // then assertEquals(sum1, sum2); } private int calculateSum(int[] cached) { int sum = 0; for (int i = 0; i < cached.length; i++) { sum += cached[i]; } return sum; } }
GetterLazy 객체를 생성한 후, 해당 객체에서 getter를 두번 호출한다. 그리고 각각의 getter를 통해 생성된 배열의 값을 토대로 합계를 구하여 두 합계가 같은지 비교하는 코드이다. Lazy Getter가 제대로 동작하지 않는다면 getter 호출 시 난수 값이 배열에 저장되므로 두 합계가 다를 것이다. 결과를 확인해보자.
테스트 코드 실행결과 getter를 두번 실행하여 얻은 두 배열의 원소 합계가 같은 것을 확인할 수 있고, expensive value를 계산하는 메서드의 실행 로그가 한 번 찍힌 것을 통해 getter를 최초로 호출했을 때만 해당 메서드가 실행된 것을 확인할 수 있다. 그렇다면 Lazy Getter를 적용할 때 주의점은 무엇이 있을까? lombok의 공식 가이드를 살펴보면 다음과 같은 문구를 확인할 수 있다.
Lazy Getter 기능을 사용하여 필드 값을 캐싱할 경우, 필드 값에 직접 접근해서는 안되며 반드시 lombok에 의해 생성된 getter를 통해 접근해야 한다고 안내하고 있다. getter를 통해 접근해야 하는 이유는 해당 필드가 AtomicReference 타입으로 래핑 되기 때문이고 만약 해당 필드가 자기 자신을 가리키게 되면, 해당 값이 null로 계산되게 될 것이고, 이 레퍼런스는 null을 참조하게 된다고 한다.
실습을 진행하며 새롭게 알게된 가이드에 주의사항만 기재한 것이 아니라, Lazy Getter 적용시 필드에 직접 접근을 못하도록 컴파일러가 실제로 막아 주는 것을 확인하였다. public access level을 가진 특정 필드에 @Getter(lazy = true)로 설정하면 자바 컴파일러가 해당 필드는 private final로 둬야 한다며 위와같이 컴파일에러 메시지를 보여준다.
마지막으로 Lazy Getter를 적용하면 개발자가 멀티 스레드 환경에서의 동기화 이슈에 대하여 따로 코드를 추가해줄 필요가 없다. 이와 관련된 처리를 컴파일러가 자동으로 생성해준다. .class 파일을 살펴보도록 하자.
public class GetterLazy { private static final Logger log = LoggerFactory.getLogger(GetterLazy.class); private final AtomicReference<Object> cached = new AtomicReference(); public GetterLazy() { } private int[] expensive() { log.debug("expensive start"); Random random = new Random(); random.setSeed(System.currentTimeMillis()); int[] result = new int[1000000]; for(int i = 0; i < result.length; ++i) { result[i] = random.nextInt(1000); } return result; } public int[] getCached() { Object value = this.cached.get(); if (value == null) { synchronized(this.cached) { value = this.cached.get(); if (value == null) { int[] actualValue = this.expensive(); value = actualValue == null ? this.cached : actualValue; this.cached.set(value); } } } return (int[])(value == this.cached ? null : value); } }
위에서 언급했던 것처럼 데이터를 AtomicReference 타입으로 래핑하여 멀티스레드 환경에서 발생할 수 있는 동기화 이슈가 발생하지 않는다. 또한, 최초로 getter가 호출되어 expensive value에 대한 계산이 이루어질 때, lock을 걸어주는 것을 확인할 수 있다.
불변 객체(Immutable Object) 만들기
lombok을 이용하면 불변 객체를 비교적 쉽게 생성할 수 있다. 불변 객체란 객체의 생성 후 객체의 상태 값이 변경되지 않는 객체를 의미한다. 보통 setter를 제거하고 @Builder 어노테이션을 통한 빌더 패턴을 이용하는 것이 일반적이다.
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(length = 50, nullable = false) private String title; @Column(length = 20, nullable = false) private String author; @Column(length = 30, nullable = false) private String isbn; @Builder public Book(Long id, String title, String author, String isbn) { this.id = id; this.title = title; this.author = author; this.isbn = isbn; } }
참고로 JPA에서는 프록시 생성을 위해서 기본 생성자를 반드시 하나를 생성 해야한다. 기본 생성자를 아무 이유 없이 열어두는 것(public)은 객체 생성 시 안전성을 심각하게 떨어트릴 수 있으므로, 접근 권한을 protected로 설정하여 외부에서의 생성을 막아주는 것이 좋다.
다시 본론으로 들어가 빌더 패턴을 사용하는 이유는 쉽게 말해 생성자의 장점과 setter의 장점을 모두 가지고 있기 때문이라고 설명할 수 있을 것 같다. 다음 예제를 통해 좀 더 알아보도록 하자.
// 생성자 Book book = new Book(1L, "lombok", "주인장숭황", "1234-5678"); // 빌더 패턴 Book book = Book.builder() .id(1L) .title("lombok") .author("주인장숭황") .isbn("1234-5678") .build();
생성자를 통해 객체를 생성하는 경우 생성자에 들어갈 인자의 순서를 모두 맞춰줘야 한다. 현재는 인자가 4개밖에 되지 않아 어렵지 않지만, 인자가 점점 많아진다면 이에 대한 순서가 직관적으로 보이지 않는다는 단점이 있다. 또한, 객체를 생성할 때 필요한 인자의 조합 케이스가 모두 다를 것이다. 이에 대한 모든 경우의 수에 맞게 생성자를 생성해줘야 한다는 단점도 치명적이다.
그러나 빌더 패턴을 사용하면, 특히 lombok의 @Builder를 이용하면, 어노테이션을 추가하는 것 만으로 빌더 패턴을 자동으로 구현하여 어떤 인자에 무슨 값을 초기화할 것인지 직관적으로 확인할 수 있고, 심지어 순서를 지키지 않아도 된다. 게다가 인자의 경우의 수를 모두 고려하여 생성자를 작성할 필요도 없다.
추가로 @Value 어노테이션을 이용하여 불변 객체를 만드는 방법도 존재한다.
@Accessors(fluent = true) @RequiredArgsConstructor @Value public class ImmutableObject { Long id; String name; String age; }
위와같이 @Value 어노테이션을 설정하게 되면 해당 클래스의 필드를 모두 private final로 설정한다. 따라서 현재 setter가 없지만 setter를 생성한다해도 필드의 데이터 값을 변경할 수 없다.
@RequiredArgsConstructor는 final 키워드나 @NotNull 어노테이션이 붙은 필드의 생성자를 자동으로 생성해주는 어노테이션이다. 스프링에서 의존성 주입을 할 때 많이 사용하는 어노테이션인데, 좀 더 뒤에 예제를 통해 따로 살펴 볼 예정이다.
@Accesors 어노테이션 getter 혹은 setter를 Java Beans 방식과 다르게 생성해주는 어노테이션이다. 현재 예제에서는 fluent = true 옵션을 설정하였는데, 이렇게 하면 자바 14에서 새롭게 추가된 레코드의 getter 생성 형태를 갖게된다. (아래의 테스트 코드를 보면 이해가 갈 것이다.)
class ImmutableObjectTest { @DisplayName("lombok의 @Value 어노테이션을 활용해 불변 객체를 만들 수 있다.") @Test void makeImmutableObjectWithLombok() { // given ImmutableObject immutableObject = new ImmutableObject(1L, "주인장숭황", "secret...🤫"); // when //immutableObject. // setter가 없다. (있어도 필드가 final이라 변경 불가) // then assertEquals(1L, immutableObject.id()); assertEquals("주인장숭황", immutableObject.name()); assertEquals("secret...🤫", immutableObject.age()); } }
겉보기에는 꽤 유용한 어노테이션인 것 같지만 실무에서 활용도가 높을지는 의문이다. 대부분의 실무 프로젝트에서 Web Layer - Service Layer - Data Layer 사이에서 데이터가 이동할 때 Entity → DTO 혹은 DTO → Entity의 변환 과정을 거치는데, 이 @Value 어노테이션을 이용하면 해당 작업이 불가능할 것으로 판단되기 때문이다. (이에 대한 실제 사용 사례 및 정보는 확인이 필요할 것 같다 ㅠ)
@ToString, @EqualsAndHashCode
lombok의 @ToString과 @EqualsAndHashCode 어노테이션을 추가하면 Object 클래스의 toString(), equals(), hashCode() 메서드를 자동으로 재정의해준다.
@ToString @EqualsAndHashCode public class Author { private Long id; private String name; private String surname; }
사용법은 위와같이 간단하다. 특히 equals()와 hashCode() 메서드의 경우 재정의 하는 방법이 매우 어렵고 복잡한데, 이러한 과정을 단순하게 해준다. 해당 클래스의 .class 파일을 살펴보자.
public class Author { private Long id; private String name; private String surname; public Author() { } public String toString() { return "Author(id=" + this.id + ", name=" + this.name + ", surname=" + this.surname + ")"; } public boolean equals(Object o) { if (o == this) { return true; } else if (!(o instanceof Author)) { return false; } else { Author other = (Author)o; if (!other.canEqual(this)) { return false; } else { label47: { Object this$id = this.id; Object other$id = other.id; if (this$id == null) { if (other$id == null) { break label47; } } else if (this$id.equals(other$id)) { break label47; } return false; } Object this$name = this.name; Object other$name = other.name; if (this$name == null) { if (other$name != null) { return false; } } else if (!this$name.equals(other$name)) { return false; } Object this$surname = this.surname; Object other$surname = other.surname; if (this$surname == null) { if (other$surname != null) { return false; } } else if (!this$surname.equals(other$surname)) { return false; } return true; } } } protected boolean canEqual(Object other) { return other instanceof Author; } public int hashCode() { int PRIME = true; int result = 1; Object $id = this.id; int result = result * 59 + ($id == null ? 43 : $id.hashCode()); Object $name = this.name; result = result * 59 + ($name == null ? 43 : $name.hashCode()); Object $surname = this.surname; result = result * 59 + ($surname == null ? 43 : $surname.hashCode()); return result; } }
toString()의 경우 기본적으로 클래스 이름이 포함된 문자열과 쉼표로 구분된 각 필드 값이 반환되도록 재정의되어 있다. 참고로 JPA를 사용하는 환경에서 두 엔티티가 양방향 매핑으로 설계되어 있다면 @ToString을 사용함으로써 발생할 수 있는 무한 순환 참조를 조심해야 한다. 해당 문제가 발생한다면 @ToString(exclude = {”클래스명”})와 같이 설정하여 회피할 수 있다.
equals()와 hashCode()의 경우 구현시 반드시 지켜야 할 규칙들을 잘 준수하여 자동으로 생성되는 것을 확인할 수 있다. 또한, callSuper 옵션을 통해 equals()와 hashCode() 메소드 자동 생성 시 부모 클래스의 필드를 감안할지 안 할지에 대해서 설정할 수 있다. 즉, true로 설정하면 부모 클래스 필드 값들도 동일한지 체크, false로 설정(default)하면 자신 클래스의 필드 값들만 고려한다.
의존성 자동 주입(DI : Dependency Injection)
@RequiredArgsConstructor @Service public class BookService { private final BookRepository bookRepository; private final MailSender mailSender; /* @Autowired 및 생성자 생략 가능 @Autowired public BookService(BookRepository bookRepository, MailSender mailSender) { this.bookRepository = bookRepository; this.mailSender = mailSender; } */ ... }
lombok의 @RequiredArgsConstructor 어노테이션을 활용하면 @Autowired를 사용하지 않고도 DI를 구현할 수 있다.
@SneakyThrows
메서드 선언부에서 사용하는 throws 키워드를 명시하지 않아도 해당 어노테이션을 사용하면 checked exception을 던질 수 있다. 어노테이션으로 예외 클래스를 파라미터로 입력받는다. 어찌보면 유용해보이긴 하지만 공식 문서에 따르면 신중히 사용해야 하며 다음과 같이 특정 상황에 유용하다고 언급이 되어있는데, 예제를 통해 살펴보자.
public class SneakyThrowsObject { @SneakyThrows(UnsupportedEncodingException.class) public String utf8ToString(byte[] someByteArray) { return new String(someByteArray, "UTF-8"); } @SneakyThrows public void run() { throw new Throwable(); } }
new String(someByteArray, "UTF-8")은 지원되지 않는 인코딩 타입에 대한 예외로 UnsupportedEncodingException이 던져질 수 있다고 선언되어 있으나 JVM 스펙에서는 UTF-8이 항상 사용 가능해야 하기 때문에 이 예외는 '발생할 수 없는' 예외다. 이런 경우 @SneakyThrows를 사용하면 유용하다고 한다.
또한, Runnable의 run() 메소드 안에서 발생한 예외는 모든 예외가 RuntimeException으로 묶여 던져지기 때문에(심지어 예외 메시지가 비어있는 경우 존재) 정상적인 예외 처리를 할 수 없다. 이럴 때도 @SneakyThrows 사용을 추천한다고 한다.
public class SneakyThrowsObject { public SneakyThrowsObject() { } public String utf8ToString(byte[] someByteArray) { try { return new String(someByteArray, "UTF-8"); } catch (UnsupportedEncodingException var3) { throw var3; } } public void run() { try { throw new Throwable(); } catch (Throwable var2) { throw var2; } } }
.class 파일을 살펴보면 @SneakyThrows를 사용한 메서드에서 자동으로 try-catch문이 생성되는 것을 확인할 수 있다. 따라서 해당 메서드에서 예외가 발생하면 catch문에서 printStackTrace() 메서드를 호출한 것과 같이 예외를 출력한다.
@Cleanup
public class CleanupObject { public static void main(String[] args) throws IOException { @Cleanup InputStream input = new FileInputStream(args[0]); @Cleanup OutputStream output = new FileOutputStream(args[1]); byte[] b = new byte[10000]; while (true) { int r = input.read(b); if (r == -1) break; output.write(b, 0, r); } } }
FileInputStream, FileOutputStream 등과 같이 시스템 자원을 사용하는 클래스는 객체를 자원의 사용이 끝난 후 close() 메서드를 반드시 호출해야 한다. 보통 이 경우 try-catch-finally문의 finally 구문에 작성하는데, 이러한 번거로운 작업을 @Cleanup 메서드를 사용하면 자동으로 close() 메서드를 호출해준다.
@Synchronized
자바에서는 synchronized 키워드를 사용하여 임계 영역을 구현할 수 있다. 그러나 이 방법은 100% 안전하지 않으며 데드락을 발생시킬 수 있다. 이러한 단점을 극복하기 위해 @Synchronized 어노테이션을 활용할 수 있다.
public class SynchronizedObject { private final Object readLock = new Object(); @Synchronized public static void hello() { System.out.println("world"); } @Synchronized public int answerToLife() { return 42; } @Synchronized("readLock") public void foo() { System.out.println("bar"); } }
synchronized 키워드처럼 해당 어노테이션은 static 메서드, 인스턴스 메서드에서만 사용할 수 있다. 그러나 lock을 거는 단위가 다른데, synchronized는 this에 lock을 걸지만, @Synchronized 어노테이션은 $lock이라는 이름을 가진 특별한 private 필드에 lock을 건다.
해당 필드는 개발자가 작성하지 않았을 경우 lombok에 의해 자동으로 생성되고, 만약 static 메서드에 어노테이션을 사용하면 $LOCK이라는 이름을 가진 private 필드가 생성되어 lock을 건다. 원하는 경우 lock을 직접 만들 수 있는데, 이 경우 $lock, $LOCK은 자동으로 생성되지 않으며 직접 만든 lock을 @Synchronized의 파라미터로 설정 해줘야 한다.
public class SynchronizedObject { private static final Object $LOCK = new Object[0]; private final Object $lock = new Object[0]; private final Object readLock = new Object(); public SynchronizedObject() { } public static void hello() { synchronized($LOCK) { System.out.println("world"); } } public int answerToLife() { synchronized(this.$lock) { return 42; } } public void foo() { synchronized(this.readLock) { System.out.println("bar"); } } }
.class 파일을 살펴보면 hello() 메서드의 경우 static 메서드이므로, @Synchronized 어노테이션을 사용했을 때 $LOCK이라는 static 필드가 자동생성되고 lock이 걸린 것을 확인할 수 있다. awnswerToLife() 메서드의 경우 인스턴스 메서드이므로 $lock이라는 필드가 자동생성되고 lock이 걸렸다. 마지막으로 foo() 메서드의 경우 직접 만든 readLock이라는 lock이 걸린 것을 확인할 수 있다.
마무리
이렇게 lombok의 stable features 정리를 마무리 하였다. 많은 자바 개발자들이 lombok을 사용하고 있다. 불행히 나의 팀에서는 lombok을 사용하고 있진 않지만, lombok이 제공하는 편리하고 유용한 기능들을 정리해보는 시간을 가져봄으로써 lombok에 대한 이해도를 높일 수 있었다. 학습한 내용을 사이드 프로젝트에 잘 적용해볼 수 있도록 노력해야겠다 👍
Reference
https://projectlombok.org/features/
https://www.baeldung.com/intro-to-project-lombok
https://auth0.com/blog/a-complete-guide-to-lombok/
'프로그래밍 > JAVA' 카테고리의 다른 글
[Java] 자바 스트림(Stream) API 내부 동작 알아보기 (0) 2022.10.14 [Java] 자바로 인접행렬, 인접리스트 구현하기 (0) 2022.10.13 [Java] 다형성을 이용하여 null 처리하기 (0) 2022.08.30 [JAVA] 문자열 클래스 (0) 2020.09.07 [JAVA] 추상 클래스와 인터페이스 (0) 2020.09.07