F12 서비스 의존성 개선 리팩토링

F12 서비스 링크(https://f12.app/)

기존 상황

F12 서비스 백엔드 아키텍처는 도메인 개념 별로 의존성을 그려보면 다음과 같다.

총 세가지 양방향 의존이 생긴다. 이런 상황이면 Review에 변경이 생기면 Product -> InventoryProduct -> Member -> Following 까지 변경에 따른 영향이 생길 수 있다.

Member <–> InventoryProduct

Member의 상황

회원을 의미한다.

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
31
32
33
34
35
36
37
38
39
40
41
42
@Entity
@Builder
public class Member {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "github_id", nullable = false)
private String gitHubId;

@Column(name = "name")
private String name;

@Column(name = "image_url", length = 65535, nullable = false)
private String imageUrl;

@Column(name = "registered", nullable = false)
private boolean registered;

@Column(name = "career_level")
@Enumerated(EnumType.STRING)
private CareerLevel careerLevel;

@Column(name = "job_type")
@Enumerated(EnumType.STRING)
private JobType jobType;

@Column(name = "follower_count", nullable = false)
private int followerCount;

@Builder.Default
@Embedded
private InventoryProducts inventoryProducts = new InventoryProducts();

@Builder.Default
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
private Role role = Role.USER;

// 생략
}

Member의 엔티티는 InventoryProduct 도메인에 해당하는 InventoryProducts를 멤버로 가지고 있다. InventoryProducts를 통해 사용자의 프로필 장비를 조회할 수 있다.

F12 서비스에서 다음과 같은 프로필을 구현할 때 사용자의 회원정보와 그 사용자의 프로필 장비를 같이 보여주기 때문에 멤버 변수로 의존하게 됐다.

즉 Member를 조회할 때 InventoryProduct를 함께 조회해서 위와 같은 프로필을 만들기 용이하기 위해 Member가 InventoryProduct에 의존한다.

그리고 Member의 서비스 로직에서 사용자들의 프로필을 만들 때 해당 회원들의 인벤토리 장비도 조회해와야 한다.아래가 사용자들의 프로필을 만드는 서비스 로직이다. Member를 조회해오고 그에 해당하는 InventoryProduct를 조회해서 조립하고 있다. 이렇게 MemberInventoryProduct를 따로 조회해서 조립하면 InventoryProductRepository에 의존성이 생긴다.

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@Service
@Transactional(readOnly = true)
public class MemberService {

private final MemberRepository memberRepository;
private final FollowingRepository followingRepository;
private final InventoryProductRepository inventoryProductRepository;

public MemberService(final MemberRepository memberRepository, final FollowingRepository followingRepository,
final InventoryProductRepository inventoryProductRepository) {
this.memberRepository = memberRepository;
this.followingRepository = followingRepository;
this.inventoryProductRepository = inventoryProductRepository;
}

// 생략...
public MemberPageResponse findBySearchConditions(@Nullable final Long loggedInId,
final MemberSearchRequest memberSearchRequest,
final Pageable pageable) {
final Slice<Member> slice = findBySearchConditions(memberSearchRequest, pageable);
if (slice.isEmpty()) {
return MemberPageResponse.ofByFollowingCondition(slice, false);
}
setInventoryProductsToMembers(slice);
if (isNotLoggedIn(loggedInId)) {
return MemberPageResponse.ofByFollowingCondition(slice, false);
}
final List<Following> followings = followingRepository.findByFollowerIdAndFollowingIdIn(loggedInId,
extractMemberIds(slice.getContent()));
return MemberPageResponse.of(slice, followings);
}

private Slice<Member> findBySearchConditions(final MemberSearchRequest memberSearchRequest,
final Pageable pageable) {
final CareerLevel careerLevel = parseCareerLevel(memberSearchRequest);
final JobType jobType = parseJobType(memberSearchRequest);
if (memberSearchRequest.getQuery() == null && careerLevel == null && jobType == null) {
return memberRepository.findWithOutSearchConditions(pageable);
}
return memberRepository.findWithSearchConditions(memberSearchRequest.getQuery(), careerLevel,
jobType, pageable);
}

private void setInventoryProductsToMembers(final Slice<Member> slice) {
final List<InventoryProduct> mixedInventoryProducts = inventoryProductRepository.findWithProductByMembers(
slice.getContent());
for (Member member : slice.getContent()) {
final List<InventoryProduct> memberInventoryProducts = mixedInventoryProducts.stream()
.filter(it -> it.getMember().isSameId(member.getId()))
.collect(Collectors.toList());
member.updateInventoryProducts(memberInventoryProducts);
}
}

private JobType parseJobType(final MemberSearchRequest memberSearchRequest) {
final JobTypeConstant jobTypeConstant = memberSearchRequest.getJobType();
if (jobTypeConstant == null) {
return null;
}
return jobTypeConstant.toJobType();
}

private CareerLevel parseCareerLevel(final MemberSearchRequest memberSearchRequest) {
final CareerLevelConstant careerLevelConstant = memberSearchRequest.getCareerLevel();
if (careerLevelConstant == null) {
return null;
}
return careerLevelConstant.toCareerLevel();
}

private List<Long> extractMemberIds(final List<Member> members) {
return members.stream()
.map(Member::getId)
.collect(Collectors.toList());
}
}

이렇게 생긴 의존성은 MemberRepository에서 Member를 조회할 때 Left join 기반으로 한 fetch join으로 연관된 엔티티를 한번에 읽어오게 하면 서비스 로직에서 InventoryProductRepository에 의존하지 않고 회원들의 인벤토리 장비를 가져올 수 있다.

여기서 한가지 포인트를 알고가자.
fetch join으로 한번에 같이 조회해서 서비스 로직에서 InventoryProduct 의존성을 제거할 수 있다. 하지만 Member 엔티티에서 InventoryProduct를 멤버 변수로 의존하게 된다. 또 MemberRepository에서 InventoryProduct를 알게 된다.
Member 엔티티에서 InventoryProduct 의존성을 제거하면 MemberService에서 Member를 조회해올 때 InventoryProduct를 한번에 조회할 수 없고 InventoryProductRepository에 의존하게 된다.

Member 입장에서 보면 프로필을 만드는 로직 때문에 서비스 레이어 혹은 엔티티에서 InventoryProduct에 의존하게 된다.

InventoryProduct의 상황

회원이 리뷰를 남긴 제품을 의미한다.인벤토리 장비가 프로필 장비인 지 구분할 수 있다. 리뷰를 남긴 제품은 자동으로 회원의 인벤토리 장비가 된다.
InventoryProduct는 DB 테이블로 생각하면 Member와 Product의 다대다 매핑 테이블 역할도 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Builder
@Getter
public class InventoryProduct {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "selected")
private boolean selected;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
// 생략
}

엔티티 상으로는 MemberProduct에 의존한다. Product 의존성은 InventoryProduct가 대부분 장비의 정보를 함께 조회해서 멤버 변수로 가져도 괜찮다.
하지만 Member가 문제다. InventoryProductMember를 사용하지 않는데 멤버 변수로 갖고 있다.
이런 불필요한 직접 참조는 id로 간접 참조하면 쉽게 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Entity
@Builder
@Getter
public class InventoryProduct {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "selected")
private boolean selected;

@Column(name = "member_id")
private Long memberId;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
// 생략
}

문제는 서비스 레이어에서 Member에 의존하고 있다는 점이다. 특정 사용자id로 해당 사용자의 인벤토리 장비를 조회해야 하는 경우 사용자 Id가 유효한 지 확인해야 한다.
그래서 다음과 같은 의존이 생긴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
public class InventoryProductService {
// 생략...
public InventoryProductsResponse findByMemberId(final Long memberId) {
validateMember(memberId);
final List<InventoryProduct> inventoryProducts = inventoryProductRepository.findWithProductByMemberId(memberId);
return InventoryProductsResponse.from(inventoryProducts);
}

private void validateMember(final Long memberId) {
if (!memberRepository.existsById(memberId)) {
throw new MemberNotFoundException();
}
}
}

이런 의존을 AOP로 분리할 수는 있을 것 같다. 하지만 그렇게 하면 서비스 로직이 여러군데 퍼지는 것 같고 실수할 여지가 많을 것 같다.
혹은 DIP를 적용해서 MemberValidator라는 인터페이스를 InventoryProduct 패키지에 두고, MemberValidator의 구현체는 Member에 두고 빈 등록하여 InventoryServiceMemberValidator 인터페이스를 주입받아서 사용하면 Member에게 의존하지 않고 MemberInventoryProduct에 의존하게 할 수 있다.

하지만 InventoryProductMember에 의존하는게 잘못된 걸까?
의미 상 InventoryProduct는 특정 회원의 인벤토리 장비이다. InventoryProduct의 로직 상 Member에게 의존해서 협력하는 게 자연스럽고, 이 의존을 없애거나 역전하기 위해 복잡도를 높이는 게 오히려 코드를 이해하는 데 어려울 수 있다.

해결책

현재까지 문제점을 정리하자면 다음과 같다.
Member는 프로필 관련 기능 때문에 InventoryProduct를 의존할 수 밖에 없다. (서비스 레이어 혹은 엔티티)
InventoryProductMember 의존성을 다양한 방법으로 제거할 수 는 있지만 의미 상 의존하는게 자연스럽고 의존을 제거하면 복잡도가 높아질 것 같다.

그렇다면 Member에서 프로필 관련 기능을 아예 다른 패키지로 추출하면 어떨까?
profile 이라는 새로운 패키지를 도입해서 프로필 관련 기능을 모을 수 있지 않을까?

프로필 관련 로직을 profile에 모으면 다음과 같이 의존성이 그려진다.

한번 로직을 모아보자!

도메인

먼저 프로필 기능에 필요한 도메인 클래스를 만들어보자. 프로필은 특정 대상이 팔로잉하는지 알려줄 수 있어야 하고, 대표장비와 회원정보를 알려줄 수 있어야 한다.

여기서 중요한 점은 프로필의 의미이다. 프로필은 아래 그림을 보면 알 수 있듯이, 특정 회원의 회원정보와 대표장비 그리고 보는 사람이 팔로우 했는지를 알려주는 개념이다.
즉 프로필은 보는 사람에 따라 팔로우 여부를 상대적으로 가지게 된다는 사실을 명심하자.

1
2
3
4
5
6
7
8
@Getter
public class Profile {

private final Member member;
private final InventoryProducts inventoryProducts;
private final boolean isFollowing;
// 생략
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Getter
public class Profiles {

private final List<Profile> profiles;

private Profiles(final List<Profile> profiles) {
this.profiles = profiles;
}

public static Profiles of(final List<Member> members, final List<InventoryProduct> inventoryProductsOfMembers,
final List<Following> followingRelations) {
final Map<Long, List<InventoryProduct>> inventoryProductsGroups = groupByMemberId(inventoryProductsOfMembers);
final List<Profile> profiles = members.stream()
.map(member -> createProfile(member, inventoryProductsGroups, followingRelations))
.collect(Collectors.toList());
return new Profiles(profiles);
}
// 생략...
}

서비스 코드

이제 기존에는 MemberService에서 수행하던 프로필 로직(Member조회하고 InventoryProduct 조회해서 합치기)를 ProfileService에서 하면 된다.

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
@Service
@Transactional(readOnly = true)
public class ProfileService {

private final MemberRepository memberRepository;
private final InventoryProductRepository inventoryProductRepository;
private final FollowingRepository followingRepository;


public ProfileService(final MemberRepository memberRepository,
final InventoryProductRepository inventoryProductRepository,
final FollowingRepository followingRepository) {
this.memberRepository = memberRepository;
this.inventoryProductRepository = inventoryProductRepository;
this.followingRepository = followingRepository;
}

public PagedProfilesResponse findBySearchConditions(@Nullable final Long loggedInId,
final ProfileSearchRequest profileSearchRequest,
final Pageable pageable) {
final Slice<Member> slice = findBySearchConditions(profileSearchRequest, pageable);
if (slice.isEmpty()) {
return PagedProfilesResponse.empty();
}
final Profiles profiles = createProfiles(loggedInId, slice.getContent());
return PagedProfilesResponse.of(slice.hasNext(), profiles);
}
//생략...
}

의존성을 정리하고 나면,,

이제 Member는 회원 정보 관리와 팔로우 관련 기능만 수행한다.
InventoryProduct는 인벤토리 장비 추가, 삭제, 대표장비 수정 관련 기능만 수행하게 된다.
ProfileMemberInventoryProduct를 기반으로 프로필 생성하는 기능을 수행하게 된다.

이전보다 각 도메인들의 역할이 간단명료해졌다!

Product <–> InventoryProduct, Review

Product의 상황

리뷰 대상인 장비를 의미한다. 엔티티만 살펴보면 특별히 다른 도메인에 의존하지 않는다.

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
31
32
@Entity
@Builder
@Getter
public class Product {

private static final int MAXIMUM_IMAGE_URL_LENGTH = 15000;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "name", nullable = false)
private String name;

@Column(name = "image_url", nullable = false, length = MAXIMUM_IMAGE_URL_LENGTH)
private String imageUrl;

@Column(name = "review_count", nullable = false)
private int reviewCount;

@Column(name = "total_rating", nullable = false)
private int totalRating;

@Column(name = "avg_rating", nullable = false)
private double rating;

@Column(name = "category", nullable = false, length = 8)
@Enumerated(value = EnumType.STRING)
private Category category;

// 생략
}

하지만 Product 도메인은 서비스 레이어에서 여러 도메인들에 의존한다.
특히 Product 엔티티가 제거되면 해당 장비에 대한 리뷰와 해당 장비를 인벤토리 장비 설정한 내역도 모두 제거해야 된다.
그래서 ProductService에서는 다음과 같이 다른 도메인의 레포지토리를 의존한 경우가 있었다.

1
2
3
4
5
6
7
8
9
10
11
public class ProductService {
//... 생략
@Transactional
public void delete(final Long productId) {
final Product target = productRepository.findById(productId)
.orElseThrow(ProductNotFoundException::new);
reviewRepository.deleteByProduct(target);
inventoryProductRepository.deleteByProduct(target);
productRepository.delete(target);
}
}

InventoryProduct와 Review의 상황

InventoryProduct의 경우, 회원이 리뷰를 남긴 장비라는 의미이기 때문에 항상 제품 정보를 함께 보여줘야 한다. 그래서 엔티티에서 멤버로 Product를 의존하고, 서비스 레이어에서도 Product가 유효한 지 체크하는 로직도 있다.

Review의 경우, 회원이 장비에 남긴 리뷰를 의미한다. 리뷰도 inventoryProduct와 비슷하게 제품 정보를 같이 보여줘야 하는 경우가 많아서 엔티티에서 멤버로 Product를 의존하고, 서비스 레이어에서도 Product 유효성 검증을 한다.

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
@Entity
@Builder
@Getter
public class Review {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "content", nullable = false, length = MAXIMUM_CONTENT_LENGTH)
private String content;

@Column(name = "rating", nullable = false)
private int rating;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;

@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
//생략

해결책

지금까지 파악한 문제는 InventoryProduct, ReviewProduct와 조회되는 일이 많고 Product 검증해야 하는 로직 때문에 Product에 의존하고 있었다. Product는 장비 삭제할 때 해당하는 InvnentoryProductReview를 제거해야 해서 의존성이 생겼었다.

이런 문제는 Product 삭제 시, 이벤트를 발행해서 해당 이벤트를 처리하는 이벤트 리스너를ReviewInvnetoryProduct에 추가하는 방식으로 해결했다.

이벤트 구현

product 패키지에 장비가 삭제됐음을 알리는 이벤트를 만들어본다. ApplicationEvent를 상속해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ProductDeletedEvent extends ApplicationEvent {

private final Long productId;

public ProductDeletedEvent(final Object source, final Long productId) {
super(source);
this.productId = productId;
}

public Long getProductId() {
return productId;
}
}

그리고 ProductDeletedEvent를 장비 삭제 당시에 발행하도록 해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
@Transactional(readOnly = true)
public class ProductService {

private final ProductRepository productRepository;
private final ApplicationEventPublisher eventPublisher;

// 생략

@Transactional
public void delete(final Long productId) {
final Product target = productRepository.findById(productId)
.orElseThrow(ProductNotFoundException::new);
productRepository.delete(target);
final ProductDeletedEvent event = new ProductDeletedEvent(this, productId);
eventPublisher.publishEvent(event);
}
}

ApplicationEventPublisher를 주입받고 생성한 이벤트를 발행하도록 한다.

이벤트 리스너 구현하기

이제 reviewinventoryProduct 패키지에 ProductDeletedEvent를 처리하는 이벤트 리스너를 구현해보자. @EventListener를 이벤트 처리하는 메서드에 붙여주고, 매개변수를 처리할 이벤트로 해놓는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class ReviewEventListener {

private final ReviewRepository reviewRepository;

public ReviewEventListener(final ReviewRepository reviewRepository) {
this.reviewRepository = reviewRepository;
}

@EventListener
public void handle(final ProductDeletedEvent event) {
reviewRepository.deleteByProductId(event.getProductId());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class InventoryProductEventListener {

private final InventoryProductRepository inventoryProductRepository;

public InventoryProductEventListener(final InventoryProductRepository inventoryProductRepository) {
this.inventoryProductRepository = inventoryProductRepository;
}

@EventListener
public void handle(final ProductDeletedEvent event) {
inventoryProductRepository.deleteByProductId(event.getProductId());
}
}

결론

양방향 의존성을 새로운 개념의 패키지 추가하기, 이벤트 퍼블리싱 방식으로 패키지 양방향 의존을 해결했다.
해결한 뒤 의존성을 그려보면 다음과 같다.

Share