2024년 8월 21일부터 세차새차에 백엔드 개발팀에 합류하였다! 이번 포스팅은 온보딩 이후 나의 첫 이슈 해결 일지이다.
이슈 자체는 간단했다. (나의 기능 개발이 간단하지 않아서 문제였지만 ㅎㅎ..)
회원 엔티티인 Member의 필드값 수정 API를 구현하는 것이었다. 추가적으로 세차장의 새로운 Owner에 대한 Owner 변경 API 또한 구현하였다.
1. 회원 정보 변경 API 구현
🤔 유효성 검사는 어느 계층에서?!
변경될 내용이 담긴 DTO가 컨트롤러에 전달된다. 이때, validation은 어느 단에서 처리해주어야할지 고민이 되었는데 CTO님과 상의 후 DTO 단에서 처리하도록 결정하였다! 여름방학에 한상곤 교수님께서는 듣기론 controller, repository, service, dto 모든 계층에서 validation 처리를 해야된다고 하셨던 말씀을 되짚어보면 안정성을 위해서는 모든 계층에서 validation을 처리하고 테스트해봐야할 필요가 있을 거라고 생각된다.
🤔 양방향 매핑을 지양해야하는 이유
'태윤님 양방향 매핑은 지양하셔야해요...!!' CTO님의 한 마디에 양방향 매핑 성애자였던 나는 이번 기회에 양방향 매핑을 지양해야하는 이유에 대해 리서치해봤다!
양방향 매핑은 엔티티 간의 관계를 서로 참조하도록 설계하는 방식이다. 이는 때로 복잡한 상황에서 엔티티 간의 데이터 일관성이 유지되지 않아 무분별하게 양방향으로 매핑된 엔티티는 의도하지 않은 버그가 일어날 가능성이 농후하다.
예를 들어 어떤 경우가 있을까??
(1) 삭제를 했음에도 삭제가 되지 않은 상황
Parent 엔티티와 Child 엔티티가 양방향으로 매핑되어있을 때 Parent 엔티티에서 Child 엔티티를 제거하더라도, Child 엔티티는 여전히 Parent를 참조하고 있을 수 있다.
@Entity
public class Parent {
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> children = new ArrayList<>();
public void removeChild(Child child) {
children.remove(child);
child.setParent(null); // Child와의 연결을 끊어야 함
}
}
@Entity
public class Child {
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
public void setParent(Parent parent) {
this.parent = parent;
}
}
child.setParent를 통해 Child와의 연결을 명시적으로 끊어주지 않으면 Child 엔티티는 여전히 Parent 엔티티를 참조하게 되어 해당 객체가 삭제되지 않거나, DB에 여전히 참조가 남아있게 될 수 있다.
(2) 삭제하지 않음에도 삭제가 된 상황
반대로, Child 엔티티에서 Parent를 참조하는 속성을 null로 설정하거나 제거할 때, Parent 엔티티에서 Child와의 관계를 제대로 관리하지 않으면 Child 엔티티가 예상치 않게 삭제될 수 있다.
@Entity
public class Parent {
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Child> children = new ArrayList<>();
}
@Entity
public class Child {
@ManyToOne
@JoinColumn(name = "parent_id")
private Parent parent;
public void setParent(Parent parent) {
this.parent = parent;
if (parent != null && !parent.getChildren().contains(this)) {
parent.getChildren().add(this); // Parent에 이 Child를 추가
}
}
}
예를 들어 Parent와의 관계를 끊기 위해서 parent.setParent(null);을 호출하게 된다면, orphanRemoval = true 설정으로 인해 해당 Child 엔티티가 삭제될 수 있다. 이 경우 원래 의도와는 다르게 Child 엔티티 자체가 삭제되는 결과를 초래할 수 있다!
추가적인 자료 - 해당 질문과 답변에서도 알 수 있듯 다음과 같은 이유들을 추가적으로 뽑을 수 있다.
- 순환 참조로 인한 복잡성의 증가
- 데이터 일관성의 문제를 명시적으로 해결해야 함
- 단방향 매핑보다 복잡한 설계
- 도메인과 DB 간의 강결합 등의 이유
🤔 순환 참조의 위험성
순환 참조는 두 개 이상의 클래스가 서로를 직접 또는 간접적으로 참조하는 구조에서 발생한다.
순환 참조가 발생하는 예시는 무엇이 있고 어떻게 해결해야 할까?
(1) 불필요한 의존성 추가
@Transactional
public void changeStoreOwnerBySlug(String slug, String ownerUuid) {
Store store = storeService.getBySlug(slug);
// 기존 Owner
Member originalOwner = store.getOwner();
originalOwner.removeStore(store);
memberAdminService.changeMemberByUuid(originalOwner.getUuid(), MemberChangeAdminRequest.builder().role(MemberRole.ROLE_USER).build());
// 새로운 Owner
Member newOwner = memberService.getByUuid(ownerUuid);
newOwner.removeStore(store);
memberAdminService.changeMemberByUuid(originalOwner.getUuid(), MemberChangeAdminRequest.builder().role(MemberRole.ROLE_OWNER).build());
store.updateOwner(newOwner);
}
위와 같이 기존 Owner와 새로운 Owner의 Role을 바꾸는 경우 엔티티 내의 메서드를 사용하여 해결할 수 있지만 굳이 memberAdminService의 메서드를 사용하여도 해결할 수 있다. 이는 불필요한 의존성을 추가한 사례로 memberAdminService가 StoreService를 참조하고, StoreService가 memberAdminService를 참조하는 경우, 순환 참조가 발생할 수 있기에 지양해야 한다.
따라서 도메인 로직을 엔티티 내부에 캡슐화하여 사용하는 것이 좋은 설계 방식이자 해결책이다.
@Transactional
public void changeStoreOwnerBySlug(String slug, String ownerUuid) {
Store store = storeService.getBySlug(slug);
// 기존 Owner
Member originalOwner = store.getOwner();
originalOwner.changeRole(MemberRole.ROLE_USER);
// 새로운 Owner
Member newOwner = memberService.getByUuid(ownerUuid);
newOwner.changeRole(MemberRole.ROLE_OWNER);
store.updateOwner(newOwner);
}
(2) 서비스 간의 순환 참조
@Service
public class OrderService {
@Autowired
private CustomerService customerService;
public void createOrder(Order order, Customer customer) {
// 주문 생성 로직
customerService.notifyCustomer(customer);
}
public void notifyOrderCreation(Customer customer) {
// 주문 생성 알림 로직
}
}
@Service
public class CustomerService {
@Autowired
private OrderService orderService;
public Customer getCustomerById(Long customerId) {
// 고객 조회 로직
return new Customer();
}
public void saveCustomer(Customer customer) {
// 고객 저장 로직
orderService.notifyOrderCreation(customer);
}
public void notifyCustomer(Customer customer) {
// 고객에게 알림 로직
}
}
위와 같이 서비스끼리 서로를 참조하면 다음과 같이 순환 참조가 발생할 수 있다.
Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'customerService':
Requested bean is currently in creation: Is there an unresolvable circular reference?
이를 해결하기 위해서는, 중간 서비스를 도입하여 의존성 구조를 변경하여 해결할 수 있다!
@Service
public class OrderCustomerService {
@Autowired
private CustomerService customerService;
@Autowired
private OrderService orderService;
public void createOrderForCustomer(Long customerId, Order order) {
Customer customer = customerService.getCustomerById(customerId);
orderService.createOrder(order, customer);
}
public void notifyAndSaveCustomer(Customer customer) {
orderService.notifyOrderCreation(customer);
customerService.saveCustomer(customer);
}
}
@RestController
public class OrderController {
@Autowired
private OrderCustomerService orderCustomerService;
// OrderController는 OrderCustomerService만 사용
public void createOrder(Long customerId, Order order) {
orderCustomerService.createOrderForCustomer(customerId, order);
}
}
@RestController
public class CustomerController {
@Autowired
private OrderCustomerService orderCustomerService;
// CustomerController도 OrderCustomerService만 사용
public void createCustomer(Customer customer) {
orderCustomerService.notifyAndSaveCustomer(customer);
}
}
2. 어색함과 성장
주어진 첫 이슈다보니 책임감을 가지고 완벽하게 해내고자 하는 의지가 강했다. 따라서 주어진 이슈, 태스크가 학교 과제나 부트캠프 과제처럼 완벽하게 구현된 상태로 PR을 날려야 하는 것이라고 생각하였다. 하지만 실제 현업에서는 PR을 제출하는 순간이 테스트킈 끝이 아니라, 오히려 테스크의 시작이라는 느낌을 받았다. 이 부분이 처음에는 매우 어색하였다.
어색함 속에서 리뷰어와 함께 해당 기능이 실제로 어떻게 적용되는 것이 올바른지, 유효성 검사나 테스트 코드에는 무엇을 추가해야 할지에 대해 끊임없이 논의하는 것이 비로서 테스크 수행과정임을 깨달았다. 이를 바탕으로 나의 성장에 있어 코드 리뷰가 도화선이 되었음을 느끼게 된 순간이었다-!
'Backend > Spring' 카테고리의 다른 글
[Kakao Tech Campus Step2] 2주차 회고 (0) | 2024.07.07 |
---|---|
[Kakao Tech Campus Step2] 1주차 회고 (1) | 2024.06.30 |
[Spring Core] 초록 스터디 Step3 회고 (0) | 2024.05.03 |
[Spring MVC] 초록 스터디 Step2 회고 (1) | 2024.04.22 |
[Spring MVC] 초록 스터디 Step1 회고 (1) | 2024.04.14 |