프로젝트 팀원에게 했던 PR 리뷰 중, 기록해두면 좋을 내용을 정리한 글이다. 실제 코드 리뷰를 통해 발견한 문제점과 제시했던 해결 방안을 정리했다.
I. BoundedContext 독립성 문제
1
2
3
4
5
6
7
8
9
10
| // content/content-api/build.gradle
dependencies {
implementation(project(":core"))
implementation(project(":support:security"))
implementation(project(":content:content-app"))
implementation(project(":account:account-domain")) // ⚠️ 문제 발생
runtimeOnly(project(":content:content-infra"))
// ... 생략
}
|
Content Context의 표현 계층이 Account Context의 도메인 계층을 직접 참조하고 있다. 때문에 서로 다른 Context의 독립성이 지켜지지 않고, 도메인 모델이 분리되어 관리될 수 없다.
BoundedContext의 독립성: 도메인 모델과 용어가 특정 경계 안에서만 유효하고 일관성을 유지하며, 다른 컨텍스트와는 분리되어 관리되어야 한다는 원칙
Controller 코드를 통해 이런 문제가 발생한 원인을 알 수 있었다.
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
| // content-api/controller/CourseController.java
import com.connectors.content.domain.course.model.id.CourseId;
import com.connectors.account.domain.member.model.id.MemberId; // ⚠️ Account Context 참조
@RestController
@RequiredArgsConstructor
public class CourseController implements CourseApi {
private final CourseUseCase courseUseCase;
@Override
public ResponseEntity<Page<CourseResponse>> searchCourse(
final long memberId,
final String keyword,
final CourseSort sort,
final int page,
final int size) {
return ResponseEntity.ok().body(
courseSearchUseCase.searchCourses(
new MemberId(memberId), // ⚠️ Account의 도메인 객체 사용
keyword,
sort,
page,
size
)
);
}
@Override
public ResponseEntity<Void> likeCourse(final long memberId, final long courseId) {
courseLikeUseCase.likeCourse(
new MemberId(memberId), // ⚠️ Account Context
new CourseId(courseId) // ✅ Content Context
);
return ResponseEntity.ok().build();
}
}
|
- Content Context에서 회원 정보를 다루기 위해
Account Context의 MemberId를 직접 사용 - 이는 Content가 Account의 내부 구조(도메인 모델)를 알아야만 동작 가능하다는 의미
- Context 간 강한 결합이 발생하여 Account Context 변경 시 Content Context도 영향을 받음
이 문제는 다른 계층에도 전파되어 있었으며, 결과적으로 Content Context의 모든 계층이 Account Context에 의존하게 되었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| // content/content-app/build.gradle
dependencies {
implementation(project(":core"))
api(project(":content:content-domain"))
implementation(project(":account:account-domain")) // ⚠️ Application Layer에도 침범
// ... 생략
}
// content/content-infra/build.gradle
dependencies {
implementation(project(":account:account-domain")) // ⚠️ Infrastructure Layer에도 침범
// ... 생략
}
|
해결 방안: Context 경계 재정립
1) 프로젝트 원칙 재확인
- 각 Context는 자신만의 도메인 모델을 가진다
- 같은 개념(예: MemberId)이라도 각 Context에서 독립적으로 정의
- 의존성 방향은 명확해야 한다
- API Module → App Module → Domain Module
- 다른 Context의 Domain Module 직접 참조 불가
2) 도메인 모델 재정의
Content Context에서 자체적인 MemberId를 정의한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // content/content-domain/member/model/id/MemberId.java
package com.connectors.content.domain.member.model.id;
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberId {
@Column(name = "member_id", nullable = false)
private Long value;
public MemberId(Long value) {
if (value == null || value <= 0) {
throw new IllegalArgumentException("Invalid member ID");
}
this.value = value;
}
public Long getValue() {
return value;
}
// equals, hashCode 구현
}
|
- Account Context의 MemberId와 같은 이름, 다른 패키지
- Content Context에서 “회원”이라는 개념을 어떻게 표현할지는 Content가 결정
- 실제 값(Long)은 동일하지만, 도메인 의미는 각 Context에서 독립적으로 정의
- 예) 상담 Context에서 상담사, 상담자의 ID는 Account Context의 MemberId의 실제 값과 동일하지만, CounselorId, ClientId 등으로 표현
3) Controller 수정
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
| // content-api/controller/CourseController.java
import com.connectors.content.domain.course.model.id.CourseId;
import com.connectors.content.domain.member.model.id.MemberId; // ✅ 자체 MemberId
@RestController
@RequiredArgsConstructor
public class CourseController implements CourseApi {
private final CourseUseCase courseUseCase;
@Override
public ResponseEntity<Page<CourseResponse>> searchCourse(
final long memberId,
final String keyword,
final CourseSort sort,
final int page,
final int size) {
return ResponseEntity.ok().body(
courseSearchUseCase.searchCourses(
new MemberId(memberId), // ✅ Content의 MemberId
keyword,
sort,
page,
size
)
);
}
}
|
4) 의존성 정리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // content/content-api/build.gradle
dependencies {
implementation(project(":core"))
implementation(project(":support:security"))
implementation(project(":content:content-app"))
// ❌ implementation(project(":account:account-domain")) // 제거!
runtimeOnly(project(":content:content-infra"))
}
// content/content-app/build.gradle
dependencies {
implementation(project(":core"))
api(project(":content:content-domain"))
// ❌ implementation(project(":account:account-domain")) // 제거!
}
// content/content-infra/build.gradle
dependencies {
// ... 생략
compileOnly(project(":content:content-app"))
compileOnly(project(":content:content-domain"))
// ❌ implementation(project(":account:account-domain")) // 제거!
}
|
II. 비즈니스 로직 구현 관련 문제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // content-app/usecase/CourseLikeUseCase.java
@Service
@RequiredArgsConstructor
public class CourseLikeUseCase {
private final CourseRepository courseRepository;
private final CourseLikeRepository courseLikeRepository;
@Transactional
public void likeCourse(final MemberId memberId, final CourseId courseId) {
ensureCourseExists(courseId);
courseLikeRepository.save(memberId, courseId);
courseRepository.incrementLikeCount(courseId); // ⚠️ Repository에서 비즈니스 로직
}
}
// content-app/repository/CourseRepository.java
public interface CourseRepository {
List<Course> saveAll(List<Course> courses);
Optional<Course> findById(CourseId courseId);
void incrementLikeCount(CourseId courseId); // ⚠️ 비즈니스 로직
void decrementLikeCount(CourseId courseId); // ⚠️ 비즈니스 로직
}
|
Repository는 데이터 접근 추상화를 담당하지만, incrementLikeCount(), decrementLikeCount()와 같은 비즈니스 로직을 구현하고 있다. 이는 비즈니스 로직이 Infrastructure 계층에 흩어지는 결과로 이어지며, 각 계층별 테스트가 복잡해진다.
Repository의 역할: 데이터 접근 추상화, 데이터 조작(CRUD), 스토리지 캡슐화
해결 방안: 풍부한 도메인 모델 만들기
1) 도메인 모델 재설계
CourseLike는 Course와 독립적인 생애주기를 가지기 때문에, 하나의 모델로 처리할 수 없다. 때문에 CourseLike를 독립적인 도메인 모델로 분리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // content-domain/course/model/CourseLike.java
public class CourseLike {
private CourseLikeId id;
private LocalDateTime createdAt;
(...)
}
// content-domain/course/model/id/CourseLikeId.java
public class CourseLikeId implements Serializable {
private MemberId memberId;
private CourseId courseId;
(...)
}
|
2) Course 도메인 모델에 비즈니스 로직 추가
좋아요 수 관련 비즈니스 로직을 Course 도메인 내부로 이동시킨다.
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
| @Builder
public record Course (
CourseId id,
URI url,
CoursePlatform platform,
String title,
String description,
CourseDifficulty difficulty,
List<Skill> skills,
long likeCount,
LocalDateTime createdAt,
LocalDateTime updatedAt
) implements Embeddable<String> {
(...)
public Course incrementLikeCount() { // ✅ 좋아요 수 관련 비즈니스 로직
return Course.builder()
.id(this.id)
.url(this.url)
.platform(this.platform)
.title(this.title)
.description(this.description)
.difficulty(this.difficulty)
.likeCount(this.likeCount + 1)
.skills(this.skills)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now(ZoneId.of("Asia/Seoul")))
.build();
}
(...)
}
|
3) Application Service 개선
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
| // content-app/usecase/CourseLikeUseCase.java
@Service
@RequiredArgsConstructor
public class CourseLikeUseCase {
private final CourseRepository courseRepository;
private final CourseLikeRepository courseLikeRepository;
@Transactional
public void likeCourse(final MemberId memberId, final CourseId courseId) {
Course course = courseRepository.findById(courseId)
.orElseThrow(() -> ExceptionCreator.create(CourseException.COURSE_NOT_FOUND));
// ✅ 강좌 좋아요 도메인 모델 사용
CourseLike courseLike = CourseLike.create(memberId, courseId);
courseLikeRepository.save(courseLike);
// ✅ 강좌 좋아요 수 증감 관련 메소드 활용
courseRepository.save(course.incrementLikeCount());
}
@Transactional
public void unlikeCourse(final MemberId memberId, final CourseId courseId) {
// ✅ 강좌 좋아요 도메인 모델 사용
CourseLike courseLike = courseLikeRepository.findById(new CourseLikeId(memberId, courseId))
.orElseThrow(() -> ExceptionCreator.create(CourseException.COURSE_LIKE_NOT_FOUND));
courseLikeRepository.delete(courseLike);
Course course = courseRepository.findById(courseId)
.orElseThrow(() -> ExceptionCreator.create(CourseException.COURSE_NOT_FOUND));
// ✅ 강좌 좋아요 수 증감 관련 메소드 활용
courseRepository.save(course.decrementLikeCount());
}
}
|
4) Repository Interface 정리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // content-app/repository/CourseRepository.java
public interface CourseRepository {
List<Course> saveAll(List<Course> courses);
Course save(Course course); // ✅ 단순 저장
Optional<Course> findById(CourseId courseId);
Page<CourseResponse> searchCoursesByQuery(
MemberId memberId,
String keyword,
CourseSort courseSort,
Pageable pageable
);
// ❌ incrementLikeCount 제거
// ❌ decrementLikeCount 제거
}
|
5) CourseLikeRepository 정의 및 구현
1
2
3
4
5
| // content-app/repository/CourseLikeRepository.java
public interface CourseLikeRepository {
CourseLike save(CourseLike courseLike);
void delete(CourseLikeId id);
}
|
III. 동시성 문제
1
2
3
4
5
6
7
8
| @Transactional
public void likeCourse(final MemberId memberId, final CourseId courseId) {
Course course = courseRepository.findById(courseId)
.orElseThrow(() -> ...);
courseLikeRepository.save(CourseLike.create(memberId, courseId));
courseRepository.save(course.incrementLikeCount()); // ⚠️ Lost Update 가능
}
|
여러 사용자가 동시에 좋아요를 클릭했을 때, 현재 구조에서 아래와 같은 시나리오로 데이터 정합성에 문제가 생길 수 있다.
시나리오:
1
2
3
4
5
6
7
| 시간 →
사용자 A: findById (likeCount=100) → incrementLikeCount (101) → save
사용자 B: findById (likeCount=100) → incrementLikeCount (101) → save
결과: likeCount=101 (실제로는 102여야 함)
|
해결 방안: 분산 락 도입
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // 향후 support 모듈에 구현 예정
@Service
@RequiredArgsConstructor
public class CourseLikeUseCase {
private final CourseRepository courseRepository;
private final CourseLikeRepository courseLikeRepository;
private final GlobalLockService globalLockService; // 추가 예정
@Transactional
@GlobalLock(key = "#courseId.value", timeout = 3000) // 추가 예정
public void likeCourse(final MemberId memberId, final CourseId courseId) {
Course course = courseRepository.findById(courseId)
.orElseThrow(() -> ...);
courseLikeRepository.save(CourseLike.create(memberId, courseId));
courseRepository.save(course.incrementLikeCount());
}
}
|
현재 프로젝트에서 Redis를 캐시로 사용중에 있으므로, 위와 Redisson을 활용해 분산락을 도입할 예정이다. 대강 위와 같은 형태로 어노테이션을 통해 활용할 수 있게 구현할 예정이다.