일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 스프링
- computer science
- 스프링 시큐리티
- JPA
- virtualization
- Spring
- 백엔드
- 데이터베이스
- CI/CD
- spring cloud
- HTTP
- 웹 서버
- Java
- Spring Security
- spring boot
- 자바
- vm
- 영속성 컨텍스트
- 컨테이너
- 배포
- mysql
- 도커
- Container
- web server
- 가상화
- ORM
- 스프링 부트
- 스프링 배치
- CS
- spring batch
- Today
- Total
개발 일기
[Spring Boot] Entity에서의 올바른 롬복(Lombok) 사용에 있어서 나의 생각 정리 본문
평소 롬복을 쓰면 코드가 확연히 줄게 되어 즐겨 사용한다.
그러나 사실 그냥 엔티티에서는 @NoArgsConstructor, @Getter, @Builder 쓰고 Controller, service 등에서는@RequiredArgsConstructor를 많이 쓰니까 나도 따라서 그냥 아무생각없이 쓰고 있었다.
그러나 이제 스프링 프레임워크에 차츰 익숙해지고 있으니 이러한 것들도 하나씩 잡고 가야되겠다는 생각이 들어 정리해보게 됐다.
Entity에서의 Lombok
현재 나는 Entity에서 사용 중인 롬복 어노테이션은 아래와 같다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
Getter와 Setter를 사용하는 이유
객체 지향의 원칙 중 하나는 정보 은닉(Information Hiding)이다. 객체의 구체적인 정보를 외부에 노출하지 말라는 것이다. 이러한 이유 때문에 자바에서는 클래스를 작성할 때 모든 필드를 private으로 숨기고 public 메소드를 통해 간접적으로 필드를 다루게 된다. 코틀린의 경우에는 프로퍼티를 private으로 숨기지는 않지만, 이 역시 내부적으로 보면 필드는 private으로 숨겨져있고 public인 Getter와 Setter를 통해 간접적으로 접근하게 된다.
Entity를 구성할때 주의해야할 3가지 포인트가 있었다.
1️⃣ @Setter의 사용을 지양하자
Entity에서 @Setter 사용을 지양해야한다는 것은 익히 들었다. 사실 절대 쓰면 안된다는 아니지만 지양해야한다. 그 이유는 아래와 같다.
첫번째로 의도를 파악하기 어려워진다?
: 사실 많이 애매했다. 그냥 솔직히 말해서 .setAge()나 .updateAge()나 다른게 뭘까 싶다. 그냥 블로그를 봐도 그냥 계속 이러한 말만 반복하지만 나는 와닿지가 않았다.
그런데 나랑 똑같은 고민을 한 사람이 있었고 그걸 질문으로 담겼는데 어떤 강사님?께서 그것에 대한 답을 영상으로 남겨주셔서 해결이 됐다. "도메인 객체가 변경되는 의도를 파악하기 어렵다." 라는 문장을 보고 좀 생각을 달리하게됐다.
비록 그 영상은 코틀린이긴 했지만 자바로 변환해서 예시로 들어주신 것을 이해해보겠다.
장난감 자동차를 만들때 빨간 버튼을 누르면 속도가 25가 되고 불빛이 나야하는 것이 요구사항이라고 하자.
// 장난감 자동차 엔티티
@Entity
@Getter
@Setter // <- Setter가 선언되어있다고 가정
public class ToyCar {
// ... speed, status, isTwinkle 3가지 필드가 있음.
// Setter도 있는 상태
public void pressRedButton() {
this.speed = 25;
this.status = CarStatus.MOVE;
this.isTwinkle = true;
}
}
// Setter를 통해 실제 사용시
ToyCar toyCar = new ToyCar();
toyCar.setSpeed = 25;
toyCar.setStatus = CarStatus.MOVE;
toyCar.setIsTwinkle = true;
// Setter를 사용하지않으면?
toyCar.pressRedButton();
위의 코드를 보면 그냥 단순히 Setter들을 나열하는 것보다는 toyCar.pressRedButton()이 도메인 지식을 코드에 더 잘 드러내는 것을 확인할 수 있고 추가로 코드의 응집성이 좋아진다는 장점 또한 있다.
즉, 타 블로그에서 지겹도록 반복되는 "의도를 파악하기 어려워진다"는 도메인 지식을 코드에 드러내기가 힘들어진다는 의미였고 메서드를 직접 명명하여 사게되면 추가로 코드의 응집성도 가져올 수 있었다.
그런데 만약 저렇게 여러 Setter를 하나로 묶어줘서 코드의 응집성을 올려줄 수 있는 pressRedButton()가 아니라 단순히 updateSpeed()와 같이 하나의 필드만 수정해주는 메소드의 경우는 setSpeed()와 비교했을때 여전히 가독성에 있어서 별로 차이가 없다고 생각하여 의아함이 가시지 않았다. 이 부분은 코드를 추적하는 관점으로 보라고 한다.
updateSpeed()가 불리는 곳을 정확하게 찾아가는 것과 setSpeed()가 불리는 곳 모두로 찾아가는 것 이 두가지를 비교했을 때 프로젝트의 규모가 커질수록 생산성 차이가 날 수 있고 또한 만약 최대 속도가 있다고 할때 메소드로 처리하는 경우 직접 조건을 걸어줄 수 있지만 setter의 경우 Service와 같은 비즈니스 로직에다가 처리해줘야해서 로직이 복잡해져 가독성을 헤칠 수 있다. 즉, 유지 보수 시 코드 추적에 용이해질 수 있으며 생산성에서 차이를 보일 수 있다.
두번째로는 객체의 일관성을 유지하기 위함이다.
Setter는 객체 지향 프로그래밍의 캡슐화 원칙을 위반한다. 접근제어자가 Public으로 설정되어 있는 Setter이기 때문에 어디서든지 객체 내부의 상태에 직접 접근이 가능하기 때문이다.
추가적으로 객체를 Setter를 통해서 생성할때 만약 필드가 10개라면 Set함수를 id 제외하고 해도 9개로 꽤 많은 .setXxx() 메소드를 작성해야한다. 그렇게되면 객체가 완전히 초기화되기 전까지 객체의 상태의 일관성이 보장될 수 없다고 한다.
Solution of 1️⃣ - 그래서 어떻게 처리해야 하는가? -> @Builder / 수정 메소드 직접 선언
우선 값을 변경할때 사용하기 위한 Setter의 대체제로는 엔티티에다가 메소드를 직접 선언하여 사용하는 것이다. 이때 도메인과 관련하여 무엇을 하는 메소드인지 잘표현할 수 있는 이름으로 짓고 코드의 응집성을 끌어올릴 수 잇도록 생각하며 설계하는 것이 중요한 것 같다.
당연한 객체를 생성할때 사용하게 되는 Setter의 경우에는 당연한 소리이긴 하지만 생성자를 사용하면 된다.
그러면 객체를 생성할때는 ToyCar toycar = new ToyCar(0, CarType.STOP, false);를 통해 처리할 수 있다.
그러나 이 생성자로 처리하는 것 또한 완벽하진 않았다.
만약 생성자에 매개변수가 많다면 가독성이 매우 떨어진다. 단순 필드값의 나열이기 때문에
Builder 패턴을 통해 처리하게되면 아래와 같이 가독성을 끌어올릴 수 있다.
ToyCar toycar = new ToyCar(10000, 100, 0, CarType.STOP, false);
ToyCar toyCar = ToyCar.builder()
.weight(10000)
.height(100)
.speed(0)
.carType(CarType.STOP)
.isTwinkle(false)
.build();
또한 순서 또한 바뀌어도 Builder은 상관없지만 생성자를 사용하면 가독성도 떨어지는데 순서까지 바뀌어선 안되기 때문에 타입이 다른 두 값의 순서를 헷갈리게 되면 컴파일 오류가 나겠지만 실수로 타입이 같은 두 값을 바꿔서 넣은 경우 컴파일 오류도 안나기 때문에 오류를 찾기 힘든 상황이 올 수 있다. 즉, 유지보수와 가독성에서 큰 효과를가져올 수 있다.
생성자를 별도로 만들어줄 필요가 없어 코드가 줄어들고 필요한 데이터만 설정할 수 있다.
2️⃣ 기본 생성자(NoArgsConstructor) 그리고 접근 제어자 설정
왜 @NoArgsConstructor가 필요한데?
우선 @NOArgsConstructor가 필요한 이유부터 따져보자. 바로 JPA를 위해서이다.
JPA(Java Persistence API)에서는 엔티티 클래스를 프록시 객체로 생성하거나 리플렉션(Reflection)을 통해 객체를 생성할 때 기본 생성자를 필요로 한다.
오케이 그럼 @NoArgsConstructor 자체가 왜 필요한 것은 알겠다.
그럼 access = AccessLevel.PROTECTED은 왜 붙이는것일까?
JPA에서 프록시 객체 생성 등의 이유로 어쩔 수 없이 매개변수가 없는 기본 생성자를 만들었는데 Default 설정 자체는 Public이기 때문에 어디서든지 해당 엔티티를 불완전한 상태로 기본 생성자를 통해 생성될 수 있다는 뜻이다. 이런것처럼 아무 곳에서 기본 생성자를 막 사용하는 것을 막을 필요가 있다.
그러면 access = AccessLevel.PRIVATE은 어떤가?
위와 같은 컴파일 에러가 발생한다. 즉, 기본 생성자인 @NoArgsConstructor의 접근 제어자는 PUBLIC 또는 PROTECTED만 가능하다고 한다. 왜냐? PRIVATE으로 되어있으면 정작 해당 기본 생성자를 사용하는 JPA 조차도 프록시를 생성할때 접근하지 못하기 때문이다.
그래서 기본 생성자의 접근 제어자를 PROTECTED로 설정하는 것을 권장하는 것 같다.
3️⃣ @AllArgsConstructor 지양하기
1️⃣과 2️⃣의 결과에 따라 @Builder와 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 같이 사용하게 되는데 그렇게되면 왼쪽 사진과 같이 컴파일 에러가 발생한다.
왜 둘이 동시에 사용했을때 컴파일 에러가 발생하는 것일까?
우선, @Builder와 @NoArgsConstructor가 어떻게 동작하는지 이해할 필요가 있다.
- 클래스 레벨에서 @Builder를 사용한 경우
- 클래스에 생성자가 X : 클래스의 모든 멤버 변수를 파라미터로 받는 생성자를 생성
- 클래스에 생성자가 O : 기존 생성자를 활용하고 새로운 생성자를 생성하지 않는다.
- @NoArgsConstructor은 기본 생성자로 파라미터를 받지 않는 생성자가 생성된다.
이 두개가 동시에 동작하면? @NoArgsConstructor에 의해 기본 생성자가 먼저 생성되고 그런 다음 @Builder가 동작하는데 이때 이미 기본 생성자가 있기 때문에 빌더 입장에서는 이미 생성자가 존재한다고 판단하여 추가적인 생성자를 생성하지 않는다.
그렇게 되면 추후에 빌더 클래스가 인스턴스를 생성할 때, 모든 필드를 초기화하는 생성자를 호출하려고 시도하지만 해당 생성자가 없기 때문에 오류가 발생하기에 컴파일 오류가 발생한다.
그렇다면 @AllArgsConstructor로 메꿔주면되지 않나?
또 그렇지 않다. 위험한 방법이라고 한다. 이는 선언된 필드의 순서대로 생성자를 생성해주는데 그 경우 매개변수를 받는 생성자 사용시에 실수하기 쉽고 또한 요구사항 변경으로 필드에 변화가 있는 경우 유지 보수 시에도 힘들어 질 수 있다.
그래서 @AllArgsConstructor의 사용을 지양하는 것 같다.
Solution of 3️⃣ - 그래서 어떻게 처리해야 하는가?
최종적으로 정리해보면 @Builder를 사용하는데 클래스 레벨이 아니라 생성자를 직접 만들어 놓고 생성자에다가 해당 @Builder 어노테이션을 붙히게 되면 @AllArgsConstructor가 필요하지 않게 된다.
즉, 클래스 레벨에 @NoArgsConstructor(access = AccessLevel.PROTECTED)와 @Getter를 사용하고 사용하고자하는 생성자에다가 @Builder를 달아주는 것이 가장 좋은 방법같다.
최종 결과
피드백 환영입니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SQLDelete(sql = "UPDATE member SET is_deleted = true, deleted_at = NOW() WHERE id = ?")
@SQLRestriction("is_deleted = false")
public class Member extends BaseTimeEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// oauth2_id = provider + " " + provider's id
@Column(nullable = false, name = "oauth2_id")
private String oAuth2Id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private RoleType roleType;
@Column(name = "refresh_token", length = 500) // refresh_token 속성의 최대 길이를 300으로 지정
private String refreshToken;
@Column(nullable = true, unique = true)
private String nickname;
@Column(nullable = true, name = "member_intro")
private String memberIntro;
@OneToMany(mappedBy = "member")
private List<Content> contents = new ArrayList<>();
@OneToMany(mappedBy = "member")
private List<Comment> comments = new ArrayList<>();
/**
* Soft Delete
*/
@Column(name = "is_deleted", columnDefinition = "BOOLEAN DEFAULT false")
private Boolean isDeleted = false;
@Column(name = "deleted_at")
private LocalDateTime deleteAt;
@Builder
private Member(String oAuth2Id, String name, String email, RoleType roleType){
this.oAuth2Id = oAuth2Id;
this.name = name;
this.email = email;
this.roleType = roleType;
}
public void updateRefreshToken(String refreshToken) { this.refreshToken = refreshToken;}
/**
* 각각 두지 않고 묶어서 코드 응집성을 높히고 이름도 도메인을 잘 드러내도록
*/
public void updateMemberProfile(String nickname, String memberIntro) {
this.nickname = nickname;
this.memberIntro = memberIntro;
}
}
'Back-End > Spring' 카테고리의 다른 글
[Spring Boot] JPA의 @Query, @Modifying 그리고 @Transactional (0) | 2024.08.16 |
---|---|
[Spring Boot] 우아콘2020 - 수십억건에서 QUERYDSL 사용하기 감상문 (2) | 2024.06.04 |
[Spring Boot] orphanRemoval = true 고아 객체 관리 (1) | 2024.06.03 |
[Spring Boot] 연관관계 영속성 전이(CASCADE) - REMOVE, PERSIST, ALL을 중심으로 (1) | 2024.06.03 |
[Spring Boot] Soft Delete시 연관관계 엔티티 처리 (다중 논리 삭제 처리) (0) | 2024.06.02 |