포스트

프로젝트 코드 리뷰: BoundedContext, Repository, 동시성

프로젝트 팀원에게 했던 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) 프로젝트 원칙 재확인

  1. 각 Context는 자신만의 도메인 모델을 가진다
    • 같은 개념(예: MemberId)이라도 각 Context에서 독립적으로 정의
  2. 의존성 방향은 명확해야 한다
    • 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) 도메인 모델 재설계

CourseLikeCourse와 독립적인 생애주기를 가지기 때문에, 하나의 모델로 처리할 수 없다. 때문에 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을 활용해 분산락을 도입할 예정이다. 대강 위와 같은 형태로 어노테이션을 통해 활용할 수 있게 구현할 예정이다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.