좋은 설계란 무엇인가?
객체지향 프로그래밍이란 무엇인가?
어떻게 해야 유연하고 재사용성이 높은 코드를 짤 수 있을까?
라는 질문을 하고 스스로 해답을 찾기 위해 노력해봤다.
하지만 이러한 고민을 기존에 하던 스프링 부트&JPA 프로젝트에 적용하기는 뭔가 거리가 멀어 보였다.
Controller, Service, Repository 로 이루어진 계층형 구조
구글에 검색하면 끝도 없이 보이는 주문 시스템, 블로그 글 예제
내가 진행하던 프로젝트에서 겪었던 어려움과 고민과는 거리가 먼 예제들만 나왔으며,
어떻게 해야 해결할 수 있는지 알 수 없었다.
사실 지금도 잘 모르겠다.
다만 문제와 해결과정, 그 과정에서 한 생각을 글로 남겨보려고 한다.
1. 복잡한 비즈니스 로직과 추가되는 요구사항 속에서 단일 책임 원칙 지키기
단일 책임 원칙을 어기는 객체가 생긴다면 한가지 변경사항이 생긴다면
관련된 코드 전부를 찾아서 고쳐야한다.
이는 복잡하고, 귀찮고, 버그의 원인이 되며, 리팩토링과 기능추가를 하기 힘들게 하는 원인이 된다.
예제를 보기 전에 프로젝트의 ERD를 보고 도메인 구조를 간단히 살펴보고 가자.
예제에 나올 부분만 가져와 봤다.
- User는 Workspace와 다대다(N:M) 관계를 가진다.
- User는 Schedule과도 다대다(N:M) 관계를 가진다.
- Workspace는 Schedule과 일대다(1:N) 관계를 가진다.
- UserWorkspace와 UserSchedule은 다대다 관계를 연결하기 위한 중간 테이블의 역할을 한다.
Schedule 도메인과 Schedule의 비즈니스 로직을 담당하는 ScheduleService를 간략히 살펴보자.
//Schedule.java
@Entity
@Getter
public class Schedule{
@Id @GeneratedValue
@Column(name = "schedule_id")
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "workspace_id")
private Workspace workspace;
@OneToMany(mappedBy="schedule", cascade = CascadeType.ALL, orphanRemoval = true)
private List<UserSchedule> userSchedules;
}
//UserSchedule.java
@Entity
public class UserSchedule{
@Id @GeneratedValue
@Column(name = "user_schedule_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "schedule_id")
private Schedule schedule;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
public UserSchedule(User user, Schedule schedule){
this.user = user;
this.schedule = schedule;
}
}
///ScheduleService.java
@Service
@RequiredArgsConstructor
public class ScheduleService{
private final WorkspaceRepository workspaceRepository;
private final ScheduleRepository scheduleRepository;
private final UserRepository userRepository;
@Transactional
public void addUser(Long scheduleId, String userId){
Schedule schedule = scheduleRepository.findById(scheduleId);
User user = userRepository.findById(userId);
schedule.getUserSchedules().add(new UserSchedule(user, schedule));
}
...
}
아무런 문제없이 잘 돌아가고, Schedule, Repository의 역할도 분리되었으며 괜찮게 작성한 것 같다.
그런데 비즈니스 로직에 요구사항이 몇가지 추가된다면?
"Schedule에 참여하는 User는 Workspace에 속한 유저만 가능하도록 해주세요"
비즈니스 로직은 서비스 레이어에서 담당한다.
스케줄에 유저 추가, 삭제, 스케줄 생성은 당연히 스케줄에 관련된 중요한 비즈니스 로직이다.
일단 스케줄 서비스에 검증 로직을 추가해보자.
@Service
@RequiredArgsConstructor
public class ScheduleService{
private final WorkspaceRepository workspaceRepository;
private final ScheduleRepository scheduleRepository;
private final UserRepository userRepository;
@Transactional
public void addUser(Long scheduleId, String userId){
Schedule schedule = scheduleRepository.findById(scheduleId);
User user = userRepository.findById(userId);
// <== 검증 로직 추가 ==>
Workspace workspace = schedule.getWorksapce();
List<User> workspaceUserList = workspace.getUserWorkspaces().stream()
.map(uw -> uw.getUser())
.Collectors.toList();
if (!workspaceUserList.contains(user)){
throw new IllegalStateException("워크스페이스에 존재하지 않는 유저입니다");
}
schedule.getUserSchedules().add(new UserSchedule(user, schedule));
}
}
서비스 레이어의 메서드를 수정해 비즈니스 로직을 처리하도록 했다.
그런데 만약 또 다른 요구사항이 등장한다면?
"한번에 여러명의 유저를 추가할 수 있도록 해주세요."
이 요구사항을 처리할 수 있는 방법은 여러가지이다.
- 컨트롤러 단에서 유저 서비스를 이용해 유저 리스트를 생성한 후, 스케줄서비스의 addUser() 메서드 반복 실행시키기
- 스케줄 서비스에서 유저 리스트 생성, 유저 추가 로직 둘 다 수행하게 하기
사실 전자가 더 좋은 방법처럼 보인다.
스케줄 서비스에서 유저 리포지토리를 활용해 유저 리스트를 만드는 것이 그리 좋아보이진 않는다.
그러나 컨트롤러에서 반복문을 돌리는 로직을 넣는다면 컨트롤러에 비즈니스 로직이 들어간다고 볼 수 있다.
만약 특정 상황에서만 스케줄에만 유저를 추가할 수 있는 요구사항이 생긴다면?
(한번에 추가할 수 있는 유저 수의 제한 등이 생긴다면?)
예시가 조금 억지같긴 하지만, 컨트롤러는 url 매핑, 입력처리와 응답 값 생성에 집중해야하므로 후자를 선택했다.
@Service
@RequiredArgsConstructor
public class ScheduleService{
...
@Transactional
public void addUsers(Long scheduleId, List<Long> userIdList){
Schedule schedule = scheduleRepository.findById(scheduleId);
List<User> userList = userRepository.findByIdIn(userIdList);
// <== 검증 로직 추가 ==>
Workspace workspace = schedule.getWorksapce();
List<User> workspaceUserList = workspace.getUserWorkspaces().stream()
.map(uw -> uw.getUser())
.Collectors.toList();
for(User user : userList){
if (!workspaceUserList.contains(user)){
throw new IllegalStateException("워크스페이스에 존재하지 않는 유저입니다");
}
schedule.getUserSchedules().add(new UserSchedule(user, schedule));
}
}
}
서비스에서 다음과 같이 유저 아이디를 받아서 유저 리스트를 조회한 후
반복문을 통해 검증 로직을 수행시키고 유저를 추가하는 addUsers()메서드를 생성했다.
검증 로직이 addUser(), addUsers() 두 개의 메서드에서 반복된다.
DRY 원칙을 지켜야하니까 메서드로 분리해보자.
@Service
@RequiredArgsConstructor
public class ScheduleService{
...
@Transactional
public void addUser(Long scheduleId, String userId){
Schedule schedule = scheduleRepository.findById(scheduleId);
User user = userRepository.findById(userId);
Workspace workspace = schedule.getWorksapce();
checkUserInWorkspace(workspace, user);
addUserInSchedule(schedule,user);
}
@Transactional
public void addUsers(Long scheduleId, List<Long> userIdList){
Schedule schedule = scheduleRepository.findById(scheduleId);
Workspace workspace = schedule.getWorksapce();
List<User> userList = userRepository.findByIdIn(userIdList);
for(User user : userList){
checkUserInWorkspace(workspace, user);
addUserInSchedule(schedule, user);
}
}
private void checkUserInWorkspace(Workspace workspace, User user){
List<User> workspaceUserList = workspace.getUserWorkspaces().stream()
.map(uw -> uw.getUser())
.Collectors.toList();
if (!workspaceUserList.contains(user)){
throw new IllegalStateException("워크스페이스에 존재하지 않는 유저입니다");
}
}
private void addUserInSchedule(Schedule schedule, User user){
schedule.getUserSchedules().add(new UserSchedule(user, schedule));
}
}
워크스페이스에 유저가 존재하는지 검증하는 로직을 메서드로 만들고,
스케줄에 유저를 추가하는 로직도 메서드로 만들어 분리했다.
중복되는 코드를 줄였고, 재사용성도 높였으며, 유지보수하기도 쉬워보인다.
그러나 한가지 문제점이 생기게 된다.
워크스페이스에 관련된 로직이 스케줄 서비스에 들어가 있다.
이제 스케줄 서비스는 워크스페이스의 변화에 영향을 받는다.
ScheduleService는 이제 단일 책임 원칙을 위반하는 코드가 됐다.
스케줄 서비스가 워크스페이스 도메인에도 강하게 결합된 것이다.
만약 워크스페이스에 변경사항이 생긴다면? (그럴 일은 없지만, 다대다 관계를 그냥 ManyToMany 필드를 써버린다면?)
ScheduleService에서 워크스페이스를 사용하는 메서드도 찾아서 변경해야 한다.
그렇다면 워크스페이스 서비스에서 유저 확인 로직을 구현한 후 ScheduleService에서 사용하면?
스케줄 서비스가 워크스페이스 서비스를 알고 있어야한다.
서비스에서 다른 서비스를 참조한다?
God Class, 책임이 집중되는 서비스가 생겨버리는 위험이 생길 수 있다.
어떻게 해야할까?
"묻지말고 시켜라 (Tell, Don't Ask)"
사실 위의 구현은 전부 "묻지말고 시켜라 (Tell, Don't Ask)" 원칙에 정반대로 위배되는 행위들이다.
private void checkUserInWorkspace(Workspace workspace, User user){
List<User> workspaceUserList = workspace.getUserWorkspaces().stream()
.map(uw -> uw.getUser())
.Collectors.toList();
if (!workspaceUserList.contains(user)){
throw new IllegalStateException("워크스페이스에 존재하지 않는 유저입니다");
}
}
private void addUserInSchedule(Schedule schedule, User user){
schedule.getUserSchedules().add(new UserSchedule(user, schedule));
}
객체지향적인 설계를 하려면 어떻게 해야할까? 라는 질문에 책임 주도 설계 방법이 등장한다.
먼저 적절한 메세지를 정하라.
메세지를 처리할 정보를 가장 잘 아는 객체에게 위임해라.
워크스페이스에 유저가 존재하는지 확인하라.
스케줄에 유저를 추가하라.
라는 메세지를 가장 잘 처리할 수 있는 알맞은 객체는 무엇일까?
사실 위의 비즈니스 로직들을 처리할 가장 적절한 객체는 도메인 객체이다.
워크스페이스에 어떤 유저가 연결되어있는지 가장 잘 아는 객체는 Workspace 도메인 자체이다.
스케줄에 특정 유저를 추가하기 위한 가장 좋은 방법은 그냥 Schedule 객체에게 맡기면 된다.
이제 서비스 객체가 아닌 도메인 객체가 비즈니스 로직을 추가해보자.
2. Service 레이어가 비즈니스 로직을 처리하는 곳 아닌가요? - 올바른 비즈니스 로직의 위치는 어디인가
Service와 Entity 비즈니스 로직에 관해 - 인프런
안녕하세요.DDD Start라는 책을 함께 읽으며 영한님의 강좌를 다시 한번 보고 있습니다.그러던 도중 과연 어디까지 비즈니스 로직으로 보아야할까에 대한 의문점이 생겼습니다.책에서는 도메인(
www.inflearn.com
반드시 서비스 레이어에서 비즈니스 로직을 처리해야만 하는 이유는 없다.
- 서비스 레이어에서 비즈니스 로직을 처리하는 것이 결합도를 높이는 상황
- 하나의 서비스 객체로는 해결하기 힘든 복잡한 비즈니스 로직
위와 같은 상황에선 도메인에 비즈니스 로직을 넣는 방법을 생각해볼 수 있다.
이미 영한 선생님은 JPA 강의에서 도메인에 비즈니스 로직을 넣음으로서 힌트를 주셨다.
(연관관계 매핑 메서드를 도메인에 만들어 둔것)
이번엔 우리의 도메인 객체(엔티티)에 해당 비즈니스 로직을 넣어보자.
//Workspace.java
@Entity
@Getter
public class Workspace{
@Id @GeneratedValue
@Column(name = "schedule_id")
private Long id;
private String name;
@OneToMany(mappedBy="workspace", cascade = CascadeType.ALL, orphanRemoval = true)
private List<UserWorkspace> userWorkspaces;
//<== 비즈니스 로직 ==>
public boolean hasUser(User user){
List<User> userList = userWorkspaces.stream()
.map(uw -> uw.getUser())
.Collectors.toList();
return userList.contains(user);
}
}
//Schedule.java
@Entity
@Getter
public class Schedule{
@Id @GeneratedValue
@Column(name = "schedule_id")
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "workspace_id")
private Workspace workspace;
@OneToMany(mappedBy="schedule", cascade = CascadeType.ALL, orphanRemoval = true)
private List<UserSchedule> userSchedules;
//<== 비즈니스 로직 ==>
public void addUser(User user){
if(workspace.hasUser(user)){
throw new IllegalStateException("워크스페이스에 존재하지 않는 유저입니다.");
}
userSchedules.add(new UserWorkspace(this,user));
}
}
Workspace 도메인 객체에 특정 유저가 속하는지 판별하는 hasUser() 메서드를 만들었다.
그리고 Schedule 도메인 객체에 유저를 스케줄에 추가하는 addUser() 메서드를 만들었다.
이제 ScheduleService의 로직을 바꿔보자.
@Service
@RequiredArgsConstructor
public class ScheduleService{
...
@Transactional
public void addUser(Long scheduleId, String userId){
Schedule schedule = scheduleRepository.findById(scheduleId);
User user = userRepository.findById(userId);
schedule.addUser(user);
}
@Transactional
public void addUsers(Long scheduleId, List<Long> userIdList){
Schedule schedule = scheduleRepository.findById(scheduleId);
userRepository.findByIdIn(userIdList).stream()
.forEach(user -> schedule.addUser(user));
}
}
이렇게 도메인 객체에 비즈니스 로직을 위임하게 되면 서비스 레이어는 얇고 단순해진다.
또한 더 이상 상관없는 도메인의 변경이 서비스의 변경에 영향을 주지 않는다. (Workspace에 의존하지 않는다)
검증,제약사항 등 비즈니스 로직을 추가할때에도 도메인 객체만 수정하면 된다.
3. 서비스와 도메인에 애매하게 걸쳐있는 비즈니스 로직 - 도메인 객체는 어디까지 권한을 가져도 되는가, 객체와 자료구조
비지니스 로직구현 Entity VS Service - 인프런
안녕하세요 강사님!이번 강의에서는Item.class에 다음과 같이 해당 변수에 대한 접근은 해당 entity에서 작성하여 주셨는데요,/* * 재고 수량 증가 */public void addStock(int quantity){ this.stockQuantity += q...
www.inflearn.com
그렇다면 모든 비즈니스 로직을 도메인에 넣어도 되는가?
도메인 객체의 권한은 어디까지인가에 대해서 고민해 볼 수 있다.
영한 선생님이 클린코드 6장(객체와 자료구조)을 참고해보라고 하셔서 읽고 왔다.
클래스의 인스턴스를 객체로 보거나 자료구조로 보는지에 따라 무엇이 달라지는지 설명하고 있다.
도메인 객체(엔티티)는 객체로 봐야하는가 자료구조로 봐야하는가? 라는 질문에
나는 객체로 보기로 했다.
엔티티는 단순히 값을 가지기만 하는 자료구조로 본다면,
복잡한 비즈니스 로직이 추가되면 서비스 레이어가 과부화되고 단일 책임 원칙을 지키기 힘들어진다.
워크스페이스 검증은 워크스페이스가 담당하는게 적합하다.
다른 곳에서 처리하는 것은 결합도를 높인다.
가능한 대부분의 비즈니스 로직을 도메인으로 옮겨왔다.
@Entity
@Getter
public class Workspace{
@Id @GeneratedValue
@Column(name = "workspace_id")
private Long id;
@Column(unique = true, nullable = false)
private String name;
@Column(nullable = false)
private String profile;
// Workspace가 UserWorkspace 영속성 관리
@OneToMany(mappedBy="workspace", cascade = CascadeType.ALL, orphanRemoval = true)
private List<UserWorkspace> userWorkspaces = new ArrayList<>();
// 연관관계 삭제용
@OneToMany(mappedBy="workspace", orphanRemoval = true)
private List<Schedule> schedules = new ArrayList<>();
// <== 비즈니스 로직 == > //
//유저 추가
public void addUser(User user){
if (this.hasUser(user)){
throw new IllegalStateException("이미 존재하는 user 입니다");
}
UserWorkspace userWorkspace = UserWorkspace.builder()
.user(user)
.workspace(this)
.userRole(UserRole.USER)
.build();
this.userWorkspaces.add(userWorkspace);
}
//유저 삭제
public void removeUser(User user){
if (!this.hasUser(user)){
throw new IllegalStateException("존재하지 않는 user 입니다");
}
this.userWorkspaces.removeIf(uw -> uw.getUser().equals(user));
}
//유저 권한 변경
public void giveAuthority(User user, UserRole userRole){
if (!this.hasUser(user)){
throw new IllegalStateException("존재하지 않는 user 입니다");
}
this.userWorkspaces.stream()
.filter(uw -> uw.getUser().equals(user))
.forEach(uw -> uw.setUserRole(userRole));
}
// 수정 로직
public void updateWorkspace(String name, String profile){
this.name = name;
this.profile = profile;
}
// 유저 검증
public boolean hasUser(User user){
return (this.userWorkspaces.stream()
.filter(uw -> uw.getUser().equals(user))
.count() == 1);
}
}
그러나 엔티티 혼자서는 해결하지 못하는 로직도 존재한다.
DB에 저장된 값을 조회해야 하는 경우, 즉 리포지토리를 확인해야 하는 로직의 경우(아이디를 이용해 조회가 필요한 경우)
이 경우는 서비스에서 진행할 수 밖에 없다.
또 UserSchedule, UserWorkspace와 같은 다대다 결합에서의 중간 테이블에 해당하는 객체는
비즈니스 로직을 가지지 않는다.
심지어 생성주기와 수명도 다른 엔티티에서 관리하고 있다. (스케줄이 삭제되면 같이 삭제 등등..)
DTO와 이런 중간 테이블 객체는 자료구조로 보고 Setter를 마음껏 사용하기로 했다.
단점 (trade-off 의 결과)
- 엔티티 객체가 너무 무거워졌다.
- 지금까지 존재하는 서비스 레이어를 전부 변경해야했다..
글이 너무 길어졌으므로 다음 글에선 이 도메인 비즈니스 로직을 사용해서
서비스 로직을 바꾸고 리팩토링한 과정과 장단점을 더 자세하게 써보려고 한다.
- DTO의 변환은 어느 레이어에서? - 응집도와 결합도, 효율성의 트레이드 오프
- 도메인별 생성자 전략, 빌더 패턴을 직접 만들어보자.
'Spring' 카테고리의 다른 글
JPA 상속 관계 매핑을 지양해야 하는가 (0) | 2025.02.24 |
---|---|
사지방에서 Spring boot 프로젝트 #3 - 패키지 구조 리팩토링 (적절한 패키지 구조는 무엇일까?) (0) | 2024.01.04 |
사지방에서 Spring boot로 토이 프로젝트를 진행하며 #2 - 테스트 코드 작성하기, 그 외 설정들 (0) | 2023.08.05 |