프로젝트 롬복(Project Lombok) 살펴보기
프로젝트 롬복(Lombok)
프로젝트 롬복(Project Lombok, 이하 롬복)은 게터(getter), 세터(setter), equals()/hashCode() 등과 같이 코드를 작성하면서 계속 비슷한 내용이 지루하게 반복되었던 코드들을 애노테이션 선언 하나로 간단하게 대체할 수 있도록 도와주는 자바 라이브러리입니다.
설치 방법
여기서는 IntelliJ 위주로 살펴보므로, 최신 정보나 IntelliJ가 아닌 다른 IDE에서는 어떻게 설치하는지 알고 싶다면 공식 홈페이지에서 확인해보세요. IntelliJ 같은 경우는 2020.3 버전부터 롬복 플러그인이 내장되어 있어서 별도로 설정해줄 것이 없습니다. 의존성 설정은 직접 해도 되지만 아래와 같이 Alt+Enter를 눌러서 Context Actions에서 롬복(lombok)을 간단하게 클래스패스에 추가할 수도 있습니다.
Gradle
repositories {
mavenCentral()
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.24'
annotationProcessor 'org.projectlombok:lombok:1.18.24'
testCompileOnly 'org.projectlombok:lombok:1.18.24'
testAnnotationProcessor 'org.projectlombok:lombok:1.18.24'
}
Maven
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
<scope>provided</scope>
</dependency>
</dependencies>
지원하는 애노테이션
롬복에서 지원하는 모든 애노테이션을 보려면 이곳에서 살펴볼 수 있습니다.
IntelliJ IDEA에 내장된 롬복 플러그인
롬복 플러그인을 사용하면 간단하게 롬복 애노테이션을 풀어서 원본 코드를 볼 수 있는데, IntelliJ 같은 경우는 2020.3 버전부터 롬복 플러그인이 기본적으로 내장되어 있으므로 소스 코드에서 우클릭 후 "Refactor > Delombok"을 누르면 바로 볼 수 있습니다. 또한 롬복을 사용하려면 Settings -> Build, Execution, Deployment -> Compiler -> Annotation Processors에서 Enable annotation processing을 눌러서 활성화해야 했지만, 플러그인이 내장되면서 자동으로 활성화되므로 그럴 필요가 없어졌습니다.
@NonNull
프로그래머가 명시적으로 작성해야 했던 널 검사를 이 애노테이션으로 대체할 수 있습니다. 메서드나 생성자의 매개변수 뿐만 아니라 자바 14 이후로 추가된 레코드의 컴포넌트(component)에도 사용할 수 있습니다.
// 롬복 사용 전
class Foo {
private String name;
public Foo(Bar bar) {
if (bar == null) {
throw new NullPointerException("bar is marked non-null but is null");
}
this.name = bar.getName();
}
}
// 롬복 사용 후
class Foo {
private String name;
public Foo(@NonNull Bar bar) {
this.name = bar.getName();
}
}
발생하는 예외 메시지를 살펴보면 다음과 같습니다. 자바 13 이전에는 예외의 기본 메시지가 비어있어서 원인을 좀처럼 파악하기가 어려웠지만, 자바 14 이후에는 더 자세하고 유용한 메시지를 제공합니다. 그래서 @NonNull이 다소 효용성은 떨어질 수는 있으나 여전히 문서화의 이점을 제공합니다. 이를 사용하면 굳이 코드 내부를 살펴보지 않고도 사용자에게 널을 전달하지 않아야 한다는 사실을 전달할 수 있습니다.
// 롬복 사용 전 (자바 13 이전)
Exception in thread "main" java.lang.NullPointerException
// 롬복 사용 전 (자바 14 이후)
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "com.example.Bar.getName()" because "bar" is null
// 롬복 사용 후
Exception in thread "main" java.lang.NullPointerException: bar is marked non-null but is null
여기서 매개변수가 널(null)이라는 부분에 초점을 맞추기보다는 인수가 잘못되었음을 강조하고 싶을 때는 아래의 설정을 사용할 수 있습니다.
; 기본값은 NullPointerException이다. 예외 메시지는 동일하다.
; IllegalArgumentException 이외에도 JDK, Guava를 설정할 수 있다.
lombok.nonNull.exceptionType = IllegalArgumentException
@Getter/@Setter
말 그대로 애노테이션을 통해 게터(getter)와 세터(setter)를 추가할 수 있게 해줍니다. 관례적으로 게터의 접두사는 get이 붙고(boolean의 경우에는 is가 붙음), 세터의 접두사에는 set이 붙습니다.
// 롬복 사용 전
class Person {
private String name;
private int age;
// getter
public String getName() {
return name;
}
public int getAge() {
return age;
}
// setter
public void setName(String name) {
this.name = name;
}
public void setAge(int age) {
this.age = age;
}
}
// 롬복 사용 후
@Getter
@Setter
class Person {
private String name;
private int age;
// ...
}
물론 필드 선언에 달 수 있지만 위처럼 클래스 선언에도 달 수 있습니다. 클래스 선언에 달면 해당 클래스 내의 모든 인스턴스 필드에 애노테이션이 달린다고 생각하면 됩니다. 게터와 세터는 접근 수준(AccessLevel)을 따로 지정해주지 않으면 위와 같이 public이 지정됩니다.
@Getter
@Setter
class Person {
private String name;
// AccessLevel에는 PUBLIC, MODULE, PROTECTED, PACKAGE, PRIVATE, NONE이 있다.
// AccessLevel.NONE을 붙이면 아무것도 생성하지 않는다.
// 필드의 애노테이션이 클래스의 애노테이션보다 우선되므로,
// 이 예시에서는 name의 세터는 생성되지만 age는 만들어지지 않는다.
@Setter(AccessLevel.NONE) private int age;
...
}
게터, 세터와 관련된 롬복 설정 중 일부를 살펴보면 아래와 같습니다.
; boolean 필드의 접두사를 is 대신에 get을 사용한다. 기본값은 false다.
lombok.getter.noIsPrefix = true
; 자바 빈 표준 접두사인 get, is, set을 앞에 붙이는 대신에 필드명과 동일한 이름을
; 게터와 세터의 이름으로 사용한다. 기본값은 false다.
; 예를 들어서 필드 name은 name()으로 접근할 수 있게 된다.
lombok.accessors.fluent = true
@ToString
이 애노테이션을 클래스 선언에 달면 롬복이 toString() 메서드의 구현을 자동으로 추가해줍니다.
// 롬복 사용 전
class Foo {
private String[] items;
private int maxItems;
// ...
@Override
public String toString() {
return "Foo(items=" + Arrays.deepToString(items) + ", maxItems=" + maxItems + ")";
}
}
// 롬복 사용 후
@ToString
class Foo {
private String[] items;
private int maxItems;
// ...
}
롬복 사용 전을 보면 알겠지만 롬복으로 구현되는 toString()이 호출되면 클래스명 뒤에 소괄호가 등장하고 그 안에 인스턴스 필드(정적 필드 제외)가 나열되는 식으로 출력합니다. 언뜻 보면 편해 보이기는 하지만 각별한 주의가 필요합니다. 아래와 같이 양방향 연관관계가 있으면 Foo가 bar.toString()을 호출하고 이어서 Bar가 foo.toString()을 호출하는 식으로 무한 재귀가 일어나서 java.lang.StackOverflowError이 발생할 수 있기 때문입니다.
@ToString
class Foo {
private String name;
private Bar bar;
// ...
}
@ToString
class Bar {
private Foo foo;
// ...
}
이를 방지하려면 아래와 같이 문제가 되는 필드를 출력에서 제외하여 재귀가 일어나는 것을 중간에서 끊어야 합니다. 덧붙여서, 아래의 @ToString.Include는 메서드에도 사용할 수 있습니다. 즉, 인스턴스 필드 외에도 메서드의 반환 값도 같이 출력할 수 있습니다. 또한 코드에서 나타나는 순서가 아니라 별도의 순서가 필요하다면 @ToString.Include(rank = 1)과 같이 직접 순서를 지정해줄 수도 있습니다. 이때는 상위 랭크(더 큰 숫자)가 먼저 출력됩니다. 참고로 랭크(rank)의 기본값은 0입니다.
@ToString
class Foo {
private String name;
// 이 필드는 출력에서 제외된다(exclude).
@ToString.Exclude
private Bar bar;
// ...
}
// 또는 ...
// 아래와 같이 지정하면 @ToString.Include가 달린 필드나 메서드만 출력한다.
@ToString(onlyExplicitlyIncluded = true)
class Foo {
// 이 필드를 출력에 포함한다(include).
@ToString.Include
private String name;
private Bar bar;
// ...
}
// 또는 ...
// 아래와 같은 방법도 있지만 이 방법은 앞으로 사라질(deprecated) 예정이다.
@ToString(of = {"name"}) // 혹은 @ToString(exclude = {"bar"})
class Foo {
private String name;
private Bar bar;
// ...
}
이외에도 @ToString에 사용할 수 있는 요소에는 아래와 같은 것들이 있습니다.
// 보통은 게터가 있으면 필드 접근 대신에 게터에서 값을 얻어오는데,
// 게터를 사용하지 않고 필드에서 직접 값을 가져오고 싶으면 아래와 같이 지정할 수 있다.
@ToString(doNotUseGetters = true) // 기본값 false
// 부모의 toString() 호출 결과도 포함하고 싶으면 아래와 같이 지정할 수 있다.
// 예) Child(super=Parent(name="Sam"), name="John")
@ToString(callSuper = true)
class Child extends Parent { ... }
@NoArgsConstructor, @RequiredArgsConstructor, @AllArgsConstructor
이름 그대로 생성자에 관한 애노테이션들입니다. 인수가 없는 기본 생성자는 @NoArgsConstructor, 초기화가 필수적인 필드만 초기화하는 생성자는 @RequiredArgsConstructor, 모든 필드를 초기화하는 생성자는 @AllArgsConstructor를 사용할 수 있습니다.
@NoArgsConstructor
말 그대로 인수가 없는 기본 생성자를 만듭니다.
// 전
class Foo {
private String name;
private int age;
public Foo() {
}
// ...
}
// 후
@NoArgsConstructor
class Foo {
private String name;
private int age;
// ...
}
만약 클래스 내에 final 필드가 있으면 당연히 에러가 발생하는데, 이를 무시하고 강제로 기본 생성자를 만들고 싶다면 아래와 같이 force를 사용하면 됩니다. 이때 모든 final 필드는 기본값(0, null, false)으로 초기화됩니다.
@NoArgsConstructor(force = true) // 기본값 false
class Foo {
private final String name;
// ...
}
@RequiredArgsConstructor
아직 초기화되지 않은 final 필드나 @NonNull 애노테이션이 붙은 필드의 초기화를 진행하는 생성자를 만듭니다. 즉, 이미 필드 선언에서 명시적으로 초기화된 final 필드나 @NonNull 필드는 생성자에 들어가지 않습니다.
// 전
class Foo {
private final String name;
private final int age;
private String address;
public Foo(String name, int age) {
this.name = name;
this.age = age;
}
// ...
}
// 후
@RequiredArgsConstructor
class Foo {
private final String name;
private final int age;
private String address;
// ...
}
@AllArgsConstructor
말 그대로 모든 필드를 초기화하는 생성자를 만듭니다.
// 전
class Foo {
private final String name;
private final int age;
private String address;
public Foo(String name, int age, String address) {
this.name = name;
this.age = age;
this.address = address;
}
// ...
}
// 후
@AllArgsConstructor
class Foo {
private final String name;
private final int age;
private String address;
// ...
}
공통 요소
위에서 살펴본 모든 생성자 관련 애노테이션에서 사용할 수 있는 요소들을 살펴봅시다. 생성자의 접근 제어를 별도로 지정하고 싶으면 아래와 같이 access를 사용할 수 있습니다.
// 게터, 세터 예시에서 봤던 것처럼 아래와 같은 접근 수준을 지원한다.
// PUBLIC, MODULE, PROTECTED, PACKAGE, PRIVATE, NONE
@NoArgsConstructor(access = AccessLevel.PROTECTED)
이번에는 staticName입니다. 이는 정적 팩토리 메서드를 생성할 때 사용되며, 그와 동시에 사용된 애노테이션에 따라 별도의 private 생성자가 만들어집니다.
// 전
class Foo {
private String name;
private int length;
private Foo(String name, int length) {
this.name = name;
this.length = length;
}
public static Foo of(String name, int length) {
return new Foo(name, length);
}
// ...
}
// 후
@AllArgsConstructor(staticName = "of")
class Foo {
private String name;
private int length;
// ...
}
@EqualsAndHashCode
이름에서도 짐작 가듯이 동등성(equality) 비교에 사용되는 equals()과 해시를 사용하는 자료구조에서 사용되는 hashCode()를 만들어줍니다. 기본적으로 transient이나 static이 붙지 않은 모든 필드를 구현에 사용합니다.
// 전
class Foo {
private String name;
private int age;
// ...
public boolean equals(final Object o) {
if (o == this) return true;
if (!(o instanceof Foo)) return false;
final Foo other = (Foo) o;
if (!other.canEqual((Object) this)) return false;
final Object this$name = this.name;
final Object other$name = other.name;
if (this$name == null ? other$name != null : !this$name.equals(other$name)) return false;
if (this.age != other.age) return false;
return true;
}
protected boolean canEqual(final Object other) {
return other instanceof Foo;
}
public int hashCode() {
final int PRIME = 59;
int result = 1;
final Object $name = this.name;
result = result * PRIME + ($name == null ? 43 : $name.hashCode());
result = result * PRIME + this.age;
return result;
}
}
// 후
@EqualsAndHashCode
class Foo {
private String name;
private int age;
// ...
}
만약에 equals()나 hashCode()의 구현에 포함하거나 제외할 필드가 있으면 아래와 같이 @EqualsAndHashCode.Include 혹은 @EqualsAndHashCode.Exclude를 사용하면 됩니다. 덧붙여서, @EqualsAndHashCode.Include는 필드 뿐만 아니라 메서드에서도 사용할 수 있으며, rank를 통해서 equals() 내에서의 비교 순서와 hashCode() 내에서의 계산 순서를 변경할 수 있습니다. rank가 높은 것부터 먼저 처리하는데, 기본 타입의 기본 rank는 1000이고 기본 래퍼 타입(Int, Double, Float)의 경우에는 800으로 설정되어 있습니다.
// name 필드를 포함하고 age 필드를 제외하는 세 가지 방법
class Foo {
private String name;
@EqualsAndHashCode.Exclude
private int age;
// ...
}
// 또는 ...
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
static class Foo {
@EqualsAndHashCode.Include
private String name;
private int age;
// ...
}
// 또는 ...
// 아래와 같은 방법도 있지만 이 방법은 앞으로 사라질(deprecated) 예정이다.
@EqualsAndHashCode(of = "name") // 혹은 @...(exclude = "age")
static class Foo {
private String name;
private int age;
// ...
}
이번에는 아래의 예시를 봅시다. childA와 childB 두 객체는 동등하지 않으므로(두 객체의 각 필드의 값이 일치하지 않으므로) 아래의 테스트를 통과해야 정상입니다. 하지만 테스트를 실행해보면 실패하고 두 객체가 동등하다는 메시지를 볼 수 있습니다. 이는 Child 클래스에 추가된 equals() 메서드가 Parent 클래스까지 고려하지 않고 Child 클래스 내의 필드만 고려하기 때문입니다.
public class LombokTest {
@Test
void testEquality() {
Child childA = new Child("A", 20, 100);
Child childB = new Child("B", 23, 100);
assertNotEquals(childA, childB);
}
@EqualsAndHashCode
static class Parent {
protected String name;
protected int age;
protected Parent(String name, int age) {
this.name = name;
this.age = age;
}
}
@EqualsAndHashCode
static class Child extends Parent {
private double height;
public Child(String name, int age, double height) {
super(name, age);
this.height = height;
}
}
}
따라서 이 경우에는 Child 클래스의 equals() 메서드 내부에서 Parent 클래스의 equals()도 호출하도록 아래와 같이 callSuper를 true로 지정해주어야 합니다.
@EqualsAndHashCode(callSuper = true) // 기본값은 false
static class Child extends Parent { ... }
그 외에도 아래와 같은 것들이 있습니다.
// 보통은 게터가 있으면 필드 접근 대신에 게터에서 값을 얻어오는데,
// 게터를 사용하지 않고 필드에서 직접 값을 가져오고 싶으면 아래와 같이 지정할 수 있다.
@EqualsAndHashCode.Include(doNotUseGetters = true) // 기본값 false
@Data
이 애노테이션은 @Getter @Setter @RequiredArgsConstructor @ToString @EqualsAndHashCode를 단 것과 동일합니다.
// 두 방법은 동일하다.
@Getter
@Setter
@RequiredArgsConstructor
@ToString
@EqualsAndHashCode
class Foo {
// ...
}
@Data
class Foo {
// ...
}
참고로 @Data 하나로는 각 애노테이션을 세부적으로 지정하는 것은 할 수 없습니다. 예를 들어서, 여기서 일부 애노테이션만 빼고 싶다던가, 접근 수준 같은 자잘한 설정 등을 조금 변경하고 싶은 경우에는 어떻게 해야 할까요? 바로 아래와 같이 명시적으로 추가하면 기존 애노테이션에 덧씌울 수 있습니다.
@Data
@Setter(AccessLevel.NONE)
@ToString(callSuper = true)
static class Foo { ... }
@Value
@Value는 @Data의 불변 버전에 가깝습니다. @Getter @FieldDefaults(makeFinal=true, level=AccessLevel.PRIVATE) @AllArgsConstructor @ToString @EqualsAndHashCode를 단 것과 동일합니다. @FieldDefaults(makeFinal=true, level=AccessLevel.PRIVATE)은 아래를 보면 알겠지만 @NonFinal이 붙지 않은 각 인스턴스 필드에 final 한정자를 추가하고, @PackagePrivate이나 접근 제어자가 붙지 않은(즉, default인) 필드의 접근 수준을 private로 설정합니다. 불변 클래스를 상속받으면 불변이 깨질 수 있으므로 클래스 자체도 final로 선언됩니다.
// 전
final class Foo {
private final String name;
private final int height;
public Foo(String name, int height) { /* ... */ }
public String getName() { /* ... */ }
public int getHeight() { /* ... */ }
public boolean equals(final Object o) { /* ... */ }
public int hashCode() { /* ... */ }
public String toString() { /* ... */ }
// ...
}
// 후
@Value
class Foo {
String name;
int height;
// ...
}
@Log
이 애노테이션을 클래스에 달면 롬복이 로거(logger) 필드를 따로 만들어줍니다. 로거의 이름은 log이고 static final로 선언되며 타입은 선택한 로거에 따라서 달라집니다. 지원하는 애노테이션에는 @Slf4j, @XSlf4j, @Log4j, @Log4j2, @Log, @CommonsLog, @Flogger, @JBossLog, @CustomLog가 있습니다. 구체적으로 어떻게 선언되는지는 공식 문서에서 살펴봐 주세요.
// 전
class Foo {
private static final org.slf4j.Logger log = new org.slf4j.LoggerFactory.getLogger(Foo.class);
public void doSomething() {
log.info("Something happened");
}
}
// 후
@Slf4j
class Foo {
public void doSomething() {
log.info("Something happened");
}
}
@Synchronized
이 애노테이션을 메서드에 붙이면 자동으로 private 락과 동기화 블록을 만들어줍니다. 정적 메서드나 인스턴스 메서드 두 메서드에서 모두 사용할 수 있으며, 인스턴스 메서드에 달면 인스턴스 필드인 $lock으로 잠그고 정적 메서드에 달면 정적 필드인 $LOCK으로 잠급니다. 프로그래머가 만든 별도의 락 객체 lock이 있으면 @Synchronized("lock")와 같이 작성하면 됩니다.
// 전
class Foo {
private static final Object $LOCK = new Object[0];
private final Object $lock = new Object[0];
public static void doSomething() {
synchronized ($LOCK) {
// ...
}
}
public void doSomethingElse() {
synchronized ($lock) {
// ...
}
}
}
// 후
class Foo {
@Synchronized
public static void doSomething() {
// ...
}
@Synchronized
public void doSomethingElse() {
// ...
}
}
'프로그래밍 관련 > 자바' 카테고리의 다른 글
invokedynamic의 내부 동작 (0) | 2022.05.22 |
---|---|
38편. 레코드(Record) (0) | 2022.05.20 |
JVM. 클래스로더 서브시스템(Class Loader Subsystem) (0) | 2022.05.16 |
번외편. 인터페이스라는 이름의 계약 (0) | 2022.05.14 |
37편. 열거형(Enum Types) (0) | 2022.05.05 |