어느 분야든 마찬가지이듯이 개념을 확실히 알고 넘어가는 습관이 개발자에게 중요한 요건이라는 것을 잘 알고 있기에 "이 코드를 왜 쓰는가?"라는 질문을 lombok annotation의 NoArgsConstructor accessLevel에 던져보았다. 하지만 그 무엇보다도 진짜 학기 중에는 괜찮다가 방학 막바지에 겔겔거리고 있는데 건강도 잘 챙겨야 개발도 계속 할 수 있다는 점을 잊지 말자...
프로젝트를 진행할 때 팀원들끼리 코드 리뷰를 하면서 엔티티 클래스에 아래의 어노테이션을 붙이는 것보다
@Entity
@EntityListeners(AuditingEntityListener.class)
@Getter
아래 어노테이션을 추가하는 것이 더 좋지 않을까에 대한 피드백이 달린 적이 있었다.
@Entity
@NoArgsConstructor (access = AccessLevel.PROTECTED)
@Getter
매개변수가 없는 생성자를 자동으로 생성해준다라는 것이 포인트가 아니라 왜 PROTECTED로 해야하는가에 대해서, 왜 PRIVATE, PUBLIC은 쓰이지 않는지, 왜 PROTECTED가 보안상 더 좋다는 말이 나오는 것인지 살펴보자!
PROTECTED의 의미
이 게시글에서 집중적으로 다룰 @NoArgsConstructor && PROTECTED는 아무런 매개변수가 없는 생성자를 생성하되 다른 패키지에 소속된 클래스는 접근을 불허한다 라는 의미이다. 아래 실제 내가 진행했던 프로젝트의 코드를 예제 코드로 삼아 살펴보려고 한다.
@Entity
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public class Member extends BaseAuditEntity {
@Id
@UuidGenerator
@Column(name = "id", columnDefinition = "char(36)")
private String id;
// 일부 필드 생략
@Enumerated(EnumType.STRING)
@Column(columnDefinition = "enum('MALE', 'FEMALE')", nullable = false)
private Gender gender;
@ManyToOne(fetch = FetchType.LAZY)
private CollegeMajor collegeMajor;
@ManyToOne(fetch = FetchType.LAZY)
private CollegeMajor collegeMinor; // 부전공 혹은 복수전공
@Column(columnDefinition = "int", nullable = false)
private Integer grade;
@Column(columnDefinition = "varchar(10)", nullable = false)
private String studentId;
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Study> studies = new ArrayList<>();
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Buddy> buddies = new ArrayList<>();
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Honbab> honbabs = new ArrayList<>();
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<StudyRelation> studyRelations = new ArrayList<>();
@Builder
private Member(
String account,
String password,
String name,
String nickname,
String phoneNumber,
Gender gender,
CollegeMajor collegeMajor,
CollegeMajor collegeMinor,
Integer grade,
String studentId,
String kakaoAccount) {
this.account = account;
this.password = password;
this.name = name;
this.nickname = nickname;
this.phoneNumber = phoneNumber;
this.kakaoAccount = kakaoAccount;
this.collegeMinor = collegeMinor;
this.collegeMajor = collegeMajor;
this.gender = gender;
this.grade = grade;
this.studentId = studentId;
this.status = Status.ACTIVE;
}
public static Member create(
SignUpRequest request,
CollegeMajor collegeMajor,
CollegeMajor collegeMinor,
String encodedPassword) {
return Member.builder()
.name(request.name())
.collegeMajor(collegeMajor)
.collegeMinor(collegeMinor)
.grade(request.grade())
.phoneNumber(request.phoneNumber())
.account(request.account())
.password(encodedPassword)
.gender(request.gender())
.studentId(request.studentId())
.kakaoAccount(request.kakaoAccount())
.nickname(request.nickname())
.build();
}
// 일부 public 함수 생략
}
@NoArgsConstructor 어노테이션을 선언했으므로 아래와 같은 기본 생성자 코드를 생성해준다.
protected Member() {}
그렇다면 왜 접근 권한을 PROTECTED로 지정하는가
엔티티 연관 관계에서 fetchType LAZY의 경우 실제 엔티티가 아닌 Proxy 객체를 통해서 조회를 한다. JPA 구현체는 실제 엔티티의 기본 생성자를 통해 프록시 객체를 생성하여 지연 로딩에 대한 조회를 한다. 하지만 이 때 접근 권한을 PRIVATE로 한다면 프록시 객체를 생성할 수 없다. 하지만 EAGER로 바꾼다면 접근 권한과 관계 없이 Proxy 객체가 아니라 실제 엔티티를 생성하기 때문에 아무런 문제가 없게 되기는 하지만 불필요한 데이터까지 한번에 조회될 수 있어서 지양하는 것이 좋다..
다시 왜 PRIVATE라면 프록시 객체를 생성할 수 없는지 그 이유를 포함해서 정리해보자.
지연 로딩을 할 때 실제 객체를 상속한 프록시 객체를 생성하게 된다. 프록시 객체는 실제 객체의 참조 변수를 가지고 있어야 하기 때문에 super를 호출한다. 하지만 실제 객체의 기본 생성자가 private라면 super를 호출할 수 없다. 따라서 프록시 객체를 생성하는데 있어 기본 생성자의 접근 제한은 최소한 protected일 수 밖에 없다.
프로젝트에서 Member와 Buddy는 일대다, @ManyToOne 관계를 가지고 있으며 Buddy 엔티티는 아래와 같다.
@Entity
@Getter
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public class Buddy extends BaseAuditEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
// 일부 버디 필드 생략
@Comment("매칭 상태")
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private BuddyStatus status;
@OneToOne(mappedBy = "owner", cascade = CascadeType.ALL, orphanRemoval = true)
private BuddyMatched matchedAsOwner;
@OneToOne(mappedBy = "partner", cascade = CascadeType.ALL, orphanRemoval = true)
private BuddyMatched matchedAsPartner;
@Builder(access = AccessLevel.PRIVATE) // 테스트 코드 작성을 위해 accessLevel 선언은 지웠다
private Buddy(
Member member,
GenderOption genderOption,
ClassTypeOption classTypeOption,
CollegeMajorOption collegeMajorOption,
GradeOption gradeOption,
BuddyStatus status,
boolean isSubMajor) {
this.member = member;
this.genderOption = genderOption;
this.classTypeOption = classTypeOption;
this.collegeMajorOption = collegeMajorOption;
this.gradeOption = gradeOption;
this.status = status;
this.isSubMajor = isSubMajor;
}
public static Buddy create(
BuddyRegistrationRequest request,
Member member) {
return Buddy.builder()
.member(member)
.genderOption(request.genderOption())
.classTypeOption(request.classTypeOption())
.collegeMajorOption(request.collegeMajorOption())
.gradeOption(request.gradeOption())
.status(BuddyStatus.IN_PROGRESS)
.isSubMajor(request.isSubMajor())
.build();
}
public void changeStatus(BuddyStatus status) {
this.status = status;
}
}
테스트 코드는 아래와 같다.
@SpringBootTest
class DemoApplicationTests {
@Autowired
private EntityManager em;
@BeforeEach
void makeEntity() {
Member member = Member.builder()
// 기타 필드 생략
.password("q1w2e3r4")
.build();
em.persist(member);
Buddy buddy = Buddy.builder()
.status("PENDING")
// 기타 필드 생략
.member(member)
.build();
em.persist(buddy);
}
@Test
@Transactional
void proxyTest() {
Buddy buddy = em.find(Buddy.class, 1L);
System.out.println("buddy의 ID 값은 : " + buddy.getId());
System.out.println("member의 ID 값은 : " + buddy.getMember().getId());
}
}
최소 접근 제한을 PROTECTED로 해야하는 이유에 대한 근거를 테스트 코드 결과로 설명해보자.
@NoArgsConstructor의 접근 권한 경우의 수에 대한 테스트 상황 설정
1. Buddy, Member 모두 PROTECTED일 때
위의 System.out.println 두 줄에 해당하는 값 모두 정상 출력이 된다. 접근 권한이 PROTECTED이므로 JPA 구현체가 Member 프록시 객체를 정상적으로 생성하고 실제 지연 로딩을 통해 Member Entity 값이 필요해질 때 Member 프록시 객체가 초기화를 통해서 실제 엔티티를 참조해 값을 가져오기 때문이다.
2. Buddy는 PROTECTED, Member는 PRIVATE일 때
테스트 코드 결과 상 에러가 터진다. Member 접근 권한이 private이므로 지연로딩 시 buddy에서 member로 JPA 구현체가 Member 프록시 객체를 생성할 때 접근할 수 없기 때문이다.
3. Buddy는 PRIVATE, Member는 PROTECTED일 때
1번과 같이 정상적으로 테스트 코드가 다 통과한다. Buddy 엔티티는 지연 로딩과 접근 권한과 관계 없이 테스트 코드 상 em.find를 통해 실제 entity 객체로 조회가 되기 때문이었다. 그리고 Member는 PROTECTED이므로 정상적으로 proxy 객체가 생성된다.
즉, 접근 권한을 PRIVATE가 아니라 PROTECTED로 해야 프록시 객체를 생성하게 해줄 수 있기 때문에 PROTECTED를 써야 했다.
그렇다면 접근 제한을 PUBLIC으로 두는 것은 왜 안돼...?
public으로 한다면 아무런 조치 없이 바로 접근(setter 등)할 수 있기 때문에 에러 발생 시 원인을 찾기 위한 역추적이 어렵다. 따라서 캡슐화를 위해 public은 지양하는 것이 좋다. 또, 기본 생성자를 이용해 값을 주입하는 방식을 최대한 방지하기 위함이다.
다시 첫 질문에 대한 대답을 해보자
접근 권한을 private로 한다면 프록시 객체 생성이 안되고 public으로 하면 객체가 무분별하게 생성되며 setter를 통해 값 주입을 할 우려가 생겨버리기 때문에 protected로 이에 대한 문제를 둘다 해결하기 위해 엔티티 클래스에서 기본 생성자에 대한 lombok 어노테이션을 쓸 때 접근 권한을 PROTECTED로 둔다!!
'SpringBoot > 오개념 때려잡기' 카테고리의 다른 글
[Spring] JPA @NotNull @NonNull nullable=false 차이와 대안 (1) | 2025.04.28 |
---|---|
[Spring] @EntityScan은 언제 필요할까 (0) | 2025.04.27 |
[Spring] SecurityFilterChain 구성 요소 (0) | 2024.09.01 |
[Spring] JPA 일대다 양방향 관계 주의할 점: MappedBy와 연관 관계 주인 (0) | 2024.05.13 |