first Project 회고

JDBC 기반 DDD 구현 회고- 이상과 현실 사이에서 #

원티드 포텐업 첫 프로젝트는 LXP(Learning Experi기반 프로젝트였습니다. 단, 기술 스택은 제한적이었습니다. 순수 자바(POJO)와 JDBC만을 이용한 CLI 프로그램을 만들어야 했고, 요구사항은 강좌 생성과 조회 기능이었습니다.

사실 전통적인 MVC 패턴으로 구현했다면 빠른 시간 내에 끝낼 수 있었을 것입니다. 하지만 욕심이 생겼습니다. “이번 기회에 DDD로 설계해볼까?” 라는 생각을 했고, 결국 팀장(나)의 (반쯤 강권에 의한) 결정으로 DDD 설계 방식이 채택되었습니다. (사실 팀장의 직권으로 인한 설계 방식 채택 ㅋ)

이 회고는 제가 JDBC 환경에서 DDD를 설계하고 구현하면서 겪었던 어려움을 정리한 글입니다. 이 과정을 통해 설계가 얼마나 중요한지, 그리고 기술 스택에 따라 설계 전략이 어떻게 달라져야 하는지 깨달았습니다. 또한 다음 프로젝트(JPA 기반)에서 무엇을 보완하고 개선해야 할지 명확히 알 수 있었던 소중한 경험이었습니다.


이 회고에서 다루는 내용 #

이 글은 “잘했던 점"보다는 “고민했던 점"에 초점을 맞춥니다. 물론 잘한 부분도 있었습니다

잘했던 점:

  • 외부 의존성 주입 컨테이너 직접 구현 (ApplicationContext)
  • 명확한 패키지 구조 설계 (각 Aggregate별로 독립적인 Repository 구현)
  • 레이어 분리의 명확성 (다른 Aggregate 참조 시 Service에서만 조율)
  • Row와 Entity의 명확한 분리 (DB 의존성 최소화)

하지만 이 회고에서는 “왜 어려웠는가?” “어떤 고민이 있었는가?” “어떤 타협을 했는가?“에 집중하려고 합니다. 실패와 고민에서 얻는 배움이 더 크다고 믿기 때문입니다.

들어가며 #

처음 DDD(Domain-Driven Design)를 도입하며 기대했던 것은 명확했습니다. 비즈니스 로직을 도메인 객체에 응집시키고, 불변식을 철저히 지키며, 상태 변화를 객체가 스스로 관리하는 깔끔한 설계였습니다. 하지만 JDBC 환경에서 실제로 구현하면서 이상과 현실 사이의 괴리를 마주했고, 많은 타협과 고민이 필요했습니다.

1. 깨진 불변식: 외부 조립의 딜레마 #

문제 상황 #

*// CourseService.save()에서*
List<Section> sections = IntStream.range(0, sectionRequests.size())
    .mapToObj(i -> Section.createFromRequest(sectionRequests.get(i), i + 1))
    .collect(Collectors.toList());

Course newCourse = Course.create(
    courseRequest.title(),
    user.getId(),
    courseRequest.content(),
    courseRequest.contentDetail(),
    category.getId(),
    sections,  *// 외부에서 생성된 섹션들*
    tags
);

DDD의 핵심 원칙 중 하나는 Aggregate Root가 내부 엔티티의 생성과 생명주기를 관리하는 것입니다. 하지만 현재 코드에서는 Section을 외부에서 생성한 후 Course에 주입하고 있습니다. 이는 불변식이 깨지는 전형적인 패턴입니다.

이상적인 방식은 무엇인가? #

DDD 이론에 따르면 다음과 같이 구현해야 합니다

*// 모든 생성을 Aggregate Root를 통해*
Course newCourse = Course.create(...);
for (SectionRequest req : sectionRequests) {
    newCourse.addSection(req.title());
    for (LectureRequest lectureReq : req.lectures()) {
        newCourse.addLecture(req.title(), ...);
    }
}

이렇게 하면 Course가 모든 생성을 제어하므로 불변식이 완벽하게 지켜집니다. 하지만 현실에서는 여러 문제에 부딪혔습니다.

왜 이상적인 방식을 택하지 못했는가? #

1) 매핑 복잡성과 팀의 기술 숙련도 #

첫 번째 문제는 조회 시 매핑의 복잡성이었습니다

*// Repository에서 조회할 때*
public Optional<Course> findAggregateById(Long courseId) {
    var courseRowOpt = courseDao.findById(courseId);
    var sectionRows = sectionDao.findAllByCourseId(courseId);
    var lectureRows = lectureDao.findAllBySectionIds(sectionIds);
    var tagRows = tagDao.findAllByCourseId(courseId);
    
    *// 이 Row 데이터들을 어떻게 도메인 객체로 조립할 것인가?*
    var course = CourseMapper.toDomain(courseRowOpt.get(), sectionRows, lectureRows, tagRows);
}

Row 데이터를 도메인 객체로 변환할 때, 이상적으로는 다음과 같이 해야 합니다

*// 이상적이지만 복잡한 방식*
Course course = Course.create(...);
for (SectionRow sectionRow : sectionRows) {
    Section section = course.addSection(sectionRow.getTitle());
    for (LectureRow lectureRow : getLecturesForSection(sectionRow.getId())) {
        section.addLecture(...);  *// 또는 course.addLecture()*
    }
}

하지만 팀원들은 Stream API로 바로 매핑하는 방식에 익숙했고

*// 팀원들이 익숙한 방식*
return courseRows.stream()
    .map(row -> CourseMapper.toDomain(row, sections, lectures, tags))
    .collect(Collectors.toList());

복잡한 객체 조립 로직을 작성할 기술적 시간과 숙련도가 부족했습니다.

2) 팩토리 메서드의 매개변수 폭발 문제 #

두 번째 문제는 생성 시 매개변수가 폭발적으로 증가한다는 점이었습니다.

만약 팩토리 메서드로 모든 정보를 한 번에 받으려면?

*// Course가 Request DTO를 알게 됨*
Course course = Course.create(title, instructor, sectionRequests);

*// Course.create() 내부*
public static Course create(..., List<SectionRequest> sectionRequests) {
    List<Section> sections = new ArrayList<>();
    for (int i = 0; i < sectionRequests.size(); i++) {
        SectionRequest req = sectionRequests.get(i);
        Section section = new Section(req.title(), i + 1);
        
        for (LectureRequest lectureReq : req.lectures()) {
            section.addLecture(lectureReq.title(), ...);
        }
        sections.add(section);
    }
    return new Course(title, instructor, sections, tags);
}

이 방식의 문제점:

  • 도메인이 DTO에 의존: Course가 SectionRequest라는 애플리케이션 레이어의 객체를 알게 됨
  • 매핑 로직이 도메인으로 침투: 변환 로직이 Course 내부로 들어가 복잡해짐
  • 결국 외부 조립과 큰 차이 없음: Section을 내부에서 만들든 외부에서 만들든 비슷한 로직

3) 트랜잭션 일관성 관점 #

세 번째로 고민한 부분은 트랜잭션 일관성이었습니다.

Course를 생성할 때

  • Section과 Lecture는 Course와 동시에 생성되어야 함
  • 이들은 하나의 트랜잭션 안에서 원자적으로 처리되어야 함
  • Course만 생성되고 Section이 실패하면 안 됨

두 가지 접근을 비교해보면

*// 방법 A: 외부에서 조립 후 한 번에 생성*
List<Section> sections = buildSections(requests);
Course course = Course.create(title, ..., sections);
courseRepository.save(course);  *// 한 번에 전부 저장// 방법 B: 단계적 생성*
Course course = Course.create(title, ...);
for (Request req : requests) {
    course.addSection(req);  *// 하나씩 추가*
}
courseRepository.save(course);  *// 결국 한 번에 저장*

어차피 최종적으로는 courseRepository.save()를 호출할 때 Course-Section-Lecture가 모두 한 번에 저장됩니다. 그렇다면 중간 과정에서 addSection()을 반복 호출하는 것이 실질적으로 의미가 있을까요?

특히 초기 생성(Create) 시나리오에서는

  • 모든 데이터가 이미 확정되어 있음
  • 단계적으로 추가하는 것이 아니라 일괄 생성하는 것
  • 생성자로 받는 것이 의도를 더 명확하게 표현
*// 생성 의도가 명확함*
Course course = new Course(title, instructor, sections, tags);

*// vs 생성인지 수정인지 애매함*
Course course = new Course(title, instructor);
course.addSection(section1);
course.addSection(section2);

내린 결론: 외부 조립 방식 선택 #

결국 다음과 같은 방식을 선택했습니다

// Service Layer에서 조립
List<Section> sections = IntStream.range(0, sectionRequests.size())
    .mapToObj(i -> Section.createFromRequest(sectionRequests.get(i), i + 1))
    .collect(Collectors.toList());

Course course = Course.create(title, instructor, sections, tags);`

이 방식을 선택한 이유:

  1. 매개변수 폭발 방지: Section과 Lecture의 모든 정보를 개별 파라미터로 넘기면 코드가 지저분해짐
  2. 도메인의 순수성 유지: Course가 Request DTO를 알 필요가 없음
  3. 매핑 로직의 명확한 분리: 복잡한 변환 로직이 Service나 Mapper에 위치
  4. 팀의 기술 수준 고려: 팀원들이 이해하고 수정할 수 있는 코드
  5. 불변식 유지 가능: Course.create()에서 검증하면 불변식은 여전히 지켜짐
*// Course.create() 내부에서 불변식 검증*
public static Course create(..., List<Section> sections, ...) {
    validateSections(sections);  *// Section 검증*
    
    Course course = new Course(title, instructor, sections, tags);
    course.updateTotalDuration();  *// 불변식 계산*
    return course;
}

생성 vs 수정: 다른 전략 적용 #

중요한 깨달음은 생성과 수정은 다르게 접근할 수 있다는 것입니다

*// 생성: 초기 데이터를 생성자로 한 번에*
Course course = Course.create(title, instructor, sections, tags);

*// 수정: Aggregate Root를 통한 변경*
course.addSection(newSection);
course.removeSection(sectionId);

생성(Create) 시:

  • 모든 데이터가 확정되어 있음
  • 외부 조립 후 검증하는 방식도 허용 가능
  • 트랜잭션 일관성이 명확함

수정(Update) 시:

  • 상태가 런타임에 변경됨
  • 불변식 관리가 더 중요함
  • 반드시 Root를 통해 변경해야 함

타협과 반성 #

실용성과 원칙 사이에서 실용성을 선택했습니다. 하지만 여전히 의문이 남습니다

  • “외부 조립이 정말 불변식을 깨뜨리는가?”
  • “생성자에서 검증한다면 실질적으로 안전하지 않은가?”
  • “DDD 원칙을 지키는 것과 현실적인 코드 사이의 균형점은 어디인가?”

결국 깨달은 것은 어떤 방법으로 생성했는가가 아니라 불변식이 지켜지는가가 핵심이라는 점입니다. 외부에서 Section을 조립해서 넘기더라도, 생성자나 팩토리 메서드에서 제대로 검증하고 불변식을 계산한다면 실질적으로 안전합니다.

이것이 완벽한 DDD 구현은 아니지만, 현재 팀 상황에서 최선의 타협이었다고 생각합니다.


2. Aggregate 경계의 혼란: Lecture는 누가 관리하는가? #

더 깊은 딜레마 발견 #

위 코드를 작성하면서 더 근본적인 문제를 발견했습니다

for (LectureRequest lectureReq : req.lectures()) {
    section.addLecture(lectureReq.title(), ...);  *// Section이 Lecture를 관리?*
}

처음에는 “Section이 Lecture를 관리하니까 section.addLecture()가 맞다"고 생각했습니다. 하지만 곧 의문이 들었습니다.

“잠깐, Course가 Aggregate Root라면, Course를 통해서만 내부를 변경해야 하는 거 아닌가?”

Aggregate Root의 정의에 대한 혼란 #

DDD 교과서에서는 이렇게 말합니다

“Aggregate Root를 통해서만 Aggregate 내부에 접근해야 한다.”

그렇다면 정답은 이것일까요?

*// 방법 1: Course가 모든 것을 관리*
Course newCourse = Course.create(...);
for (SectionRequest req : sectionRequests) {
    Section section = newCourse.addSection(req.title());  *// Section 반환*
    for (LectureRequest lectureReq : req.lectures()) {
        newCourse.addLecture(section.getId(), lectureReq.title(), ...);  *// Course를 통해!*
    }
}

아니면 이것일까요?

*// 방법 2: Section이 Lecture를 관리 (현재 방식)*
Course newCourse = Course.create(...);
for (SectionRequest req : sectionRequests) {
    newCourse.addSection(req.title());
    Section section = newCourse.getLastSection();
    for (LectureRequest lectureReq : req.lectures()) {
        section.addLecture(lectureReq.title(), ...);  *// Section을 통해*
    }
}

각 방식의 트레이드오프 #

방법 1: Course가 모든 것을 관리

public class Course {
    public void addLecture(Long sectionId, String title, String videoUrl, int duration) {
        Section section = findSectionById(sectionId);
        Lecture lecture = section.createLecture(title, videoUrl, duration);
        updateTotalDuration();  *// Course가 불변식 유지*
    }
}

장점:

  • Aggregate Root의 책임이 명확함
  • 모든 변경이 Course를 통과하므로 불변식 관리 용이
  • updateTotalDuration() 같은 계산을 Course에서 일관되게 처리

단점:

  • Course 클래스가 비대해짐 (God Object 위험)
  • Section의 세부 구현까지 Course가 알아야 함 (높은 결합도)
  • API가 복잡해짐: course.addLecture(sectionId, ...) vs section.addLecture(...)

방법 2: Section이 Lecture를 관리 (현재 방식)

public class Section {
    public Lecture createLecture(String title, String videoUrl, int duration) {
        int nextOrder = lectures.stream()
            .mapToInt(Lecture::getLectureOrder)
            .max().orElse(0) + 1;
        Lecture lec = new Lecture(title, nextOrder, videoUrl, duration);
        lectures.add(lec);
        return lec;
    }
}

장점:

  • 책임이 적절히 분산됨 (Section은 Lecture 관리, Course는 Section 관리)
  • 각 클래스의 크기가 적당함
  • 직관적인 API: “섹션에 강의를 추가한다”

단점:

  • Aggregate Root를 우회하는 것처럼 보임
  • Course가 변경을 감지하지 못할 수 있음
  • 불변식 유지가 어려움 (예: totalDuration 계산)

실제 겪은 문제 #

바로 이 부분에서 문제가 발생했습니다

*// Service 코드에서*
Course course = courseRepository.findById(courseId).orElseThrow();
Section section = course.getSections().get(0);  *// Section을 직접 가져옴*
section.addLecture("새 강의", "url", 100);      *// Section을 통해 추가// 문제: Course의 totalDuration이 업데이트되지 않음!*
courseRepository.update(course);  *// 잘못된 totalDuration이 저장됨*

Aggregate Root를 우회했기 때문에 Course가 변경을 감지하지 못하고, 불변식이 깨졌습니다.

해결 방법 1: 명시적 재계산 #

Section section = course.getSections().get(0);
section.addLecture("새 강의", "url", 100);
course.recalculateTotalDuration();  *// 수동으로 재계산*

문제점: 개발자가 잊어먹으면 끝 그래서 course를 통한 간접 접근 방식을 채택했습니다.

해결 방법 2: Course를 통한 간접 접근 #

public class Course {
    public void addLectureToSection(Long sectionId, String title, String url, int duration) {
        Section section = findSectionById(sectionId);
        section.createLecture(title, url, duration);
        updateTotalDuration();  *// 자동으로 재계산*
    }
    
    *// 또는 모든 Section 메서드를 Course에 위임*
    public void removeLectureFromSection(Long sectionId, Long lectureId) {
        Section section = findSectionById(sectionId);
        section.removeLecture(lectureId);
        updateTotalDuration();
    }
}

문제점: Course가 비대해지고, Section의 모든 메서드를 래핑해야 함

하지만 이것은 문제를 해결한 것이 아니라 숨긴 것에 가깝습니다. 여전히 다음과 같은 의문이 남습니다:

  • Section은 독립적인 Entity인가, 아니면 Course의 일부인가?
  • Aggregate는 몇 단계 깊이까지 관리해야 하는가?
  • Course-Section-Lecture는 하나의 Aggregate가 맞는가?

배운 교훈 #

  1. Aggregate 경계 설정이 가장 어렵다
    • 이론에서는 명확해 보이지만, 실전에서는 애매한 경우가 많음
    • “어디까지가 한 덩어리인가?“에 대한 정답은 없음
  2. 일관성 경계 = Aggregate 경계
    • 트랜잭션 일관성을 보장해야 하는 범위가 Aggregate
    • Course-Section-Lecture를 한 번에 변경해야 한다면 하나의 Aggregate
  3. 편의성 vs 원칙의 충돌
    • 모든 것을 Root를 통하면 코드가 불편해짐
    • 직접 접근을 허용하면 불변식이 깨질 위험
  4. JPA의 영속성 컨텍스트가 그리운 이유
    • Dirty Checking이 있다면 section.addLecture() 해도
    • updateTotalDuration()이 자동으로 호출될 수 있음 (생명주기 콜백)

3. 생성 컬럼 vs 조회 컬럼: CQRS의 유혹 #

발견한 문제 #

private Course(String title, Long instructorId, ...) {
    *// ...*
    updateTotalDuration();      *// 생성 시점에 계산*
    updateTotalLectureCount();
}

public Course(Long id, String title, ..., String totalSeconds, int totalLectureCount) {
    *// ...*
    this.totalSeconds = totalSeconds;  *// DB에서 읽을 때는 그대로 사용*
    this.totalLectureCount = totalLectureCount;
}

생성자가 두 개인 이유는 명확합니다

  • 생성 시: totalSecondstotalLectureCount를 계산해서 넣어야 함
  • 조회 시: DB에서 가져온 값을 그대로 사용해야 함

하지만 이것은 근본적인 질문을 던집니다. 생성과 조회에 필요한 데이터가 다르다면, 애초에 모델을 분리해야 하는 것 아닌가?

CQRS를 고려하게 된 배경 #

*// Command 모델 (생성/수정)*
public class CourseCommand {
    private String title;
    private List<Section> sections;
    *// totalSeconds는 없음 - 계산으로 도출*
}

*// Query 모델 (조회)*
public class CourseQuery {
    private String title;
    private String totalSeconds;  *// 미리 계산된 값*
    private int totalLectureCount;
    *// sections는 없거나 단순화됨*
}

CQRS를 도입하면 이런 혼란을 해결할 수 있을 것 같았습니다. 하지만 JDBC 환경에서 CQRS를 도입하는 것은 또 다른 복잡성을 의미했고, 지금 당장 필요한가에 대한 확신이 서지 않았습니다.

4. 상태 추적의 악몽 #

DDD를 도입한 이유 vs 현실 #

DDD를 선택한 가장 큰 이유는 “객체가 스스로 상태를 관리하고 추적한다"는 점이었습니다. 하지만 현실은 달랐습니다.

*// CourseService.updateCourse()*
public void updateCourse(CourseUpdateRequest request) throws SQLException {
    try {
        Course course = courseRepository.findById(request.courseId())
            .orElseThrow(...);
        
        course.rename(request.title());
        course.setDetail(new CourseDetail(request.content(), request.contentDetail()));
        
        courseRepository.update(course);  *// 여기서 문제 발생*
        TransactionManager.commit();
    } catch (Exception e) {
        TransactionManager.rollback();
        throw new RuntimeException(e);
    }
}

핵심 문제점 #

  1. 어떤 필드가 변경되었는지 추적이 안 됨
    • JPA라면 변경 감지(Dirty Checking)가 자동으로 됨
    • JDBC에서는 수동으로 모든 필드를 UPDATE 하거나, 변경 추적 로직을 직접 구현해야 함
  2. Aggregate 전체를 다시 계산해야 하는 부담
   *// Section이 추가/삭제되면*
   course.addSection(newSection);
   *// totalSeconds를 다시 계산하려면?// 모든 Section과 Lecture를 다시 조회해야 함!*
  1. 부분 업데이트의 어려움
    • 제목만 바꾸고 싶은데, Course 전체를 조회해야 함
    • Aggregate가 커질수록 성능 부담이 증가

아이러니 #

DDD는 상태 관리를 쉽게 하려고 도입했는데, JDBC 환경에서는 오히려 상태 추적이 더 어려워졌습니다. “과연 내가 DDD를 사용하는 게 맞았나?“라는 회의감이 들었던 순간입니다.

5. 트랜잭션 관리의 혼란 #

전역 트랜잭션 관리의 어려움 #

*// Service Layer*
TransactionManager.beginTransaction();
*// ... 비즈니스 로직*
TransactionManager.commit();

*// DAO Layer*
public Long save(CourseRow row) throws SQLException {
    *// DataSource에서 생성된 Connection은 어떻게?// TransactionManager의 Connection과 같은가, 다른가?*
}

문제의 핵심 #

  1. Connection 소유권의 불명확성
    • DataSource에서 생성된 Connection → DAO에서 리소스 해제
    • Transaction으로 시작된 Connection → Service에서 리소스 해제
    • 이 둘을 어떻게 구분하고 관리할 것인가?
  2. 일관성 있는 처리의 어려움
   *// 이게 Transaction 안에서 실행되는지, 밖에서 실행되는지// 코드만 봐서는 알 수 없음*
   courseRepository.save(course);
  1. 중첩 트랜잭션 문제
    • Repository 메서드가 다른 Repository를 호출하면?
    • 각각 트랜잭션을 시작하면 어떻게 되는가?

깨달은 점 #

Spring의 @Transactional이 얼마나 강력한 추상화인지 뼈저리게 느꼈습니다. 선언적 트랜잭션 관리 없이 일관성을 유지하는 것은 생각보다 훨씬 어려운 일이었습니다.

6. JPA로 전환 시 고려사항 #

해결하고 싶은 것들 #

1. 불변식 관리와 Aggregate 경계

@Entity
public class Course {
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Section> sections = new ArrayList<>();
    
    public void addSection(String title) {
        Section section = new Section(title, sections.size() + 1);
        sections.add(section);
        updateTotalDuration();  *// 자동으로 Dirty Checking*
    }
    
    *// @PreUpdate, @PrePersist 콜백으로 불변식 강제*
    @PreUpdate
    @PrePersist
    private void maintainInvariants() {
        updateTotalDuration();
    }
}
  • JPA의 영속성 컨텍스트를 활용하면 Aggregate 내부에서 엔티티 생성 가능
  • 변경 감지를 통해 상태 추적 자동화
  • 생명주기 콜백으로 불변식 자동 유지

2. 생성/조회 모델 분리 (CQRS)

*// Command*
@Entity
public class Course { ... }

*// Query*
@Immutable
@Entity
@Table(name = "course")
public class CourseQueryModel {
    @Formula("(SELECT SUM(l.duration) FROM lecture l ...)")
    private String totalSeconds;
}
  • Query용 엔티티를 별도로 만들어 조회 최적화
  • @Formula나 Native Query로 계산 필드 처리

3. 트랜잭션 일관성

@Service
@Transactional
public class CourseService {
    *// 메서드 전체가 하나의 트랜잭션// Connection 관리 고민 불필요*
}

여전히 고민되는 것들 #

  1. N+1 문제: Aggregate를 조회할 때 성능 이슈
  2. Aggregate 크기: Course-Section-Lecture가 너무 크면?
  3. 영속성 컨텍스트 관리: 언제 flush하고 clear할 것인가?
  4. Section 접근 방식: 여전히 section.addLecture()를 허용할 것인가?

결론: 배움과 다짐 #

배운 것들 #

  1. DDD는 은탄환이 아니다
    • 기술 스택과 팀 상황에 따라 적합성이 다름
    • JDBC 환경에서는 구현 비용이 매우 높음
  2. Aggregate 경계 설정이 가장 어렵다
    • “몇 단계까지 Root가 관리해야 하는가?“에는 정답이 없음
    • 일관성 경계와 편의성 사이의 균형 필요
  3. 원칙과 실용성의 균형
    • 이상적인 설계를 고집하다 개발 속도가 느려질 수 있음
    • 현재 상황에서 가장 합리적인 타협점 찾기
  4. 프레임워크의 가치
    • Spring과 JPA가 해결해주는 문제들의 크기를 체감
    • 추상화 레이어의 중요성 인식

앞으로의 다짐 #

JPA를 도입하면 많은 문제가 해결될 것이라 기대합니다. 하지만 그것이 곧 DDD가 성공적으로 적용될 것이라는 의미는 아닙니다. 중요한 것은:

  • 도메인 로직의 응집: 여전히 핵심 목표
  • 적절한 Aggregate 설계: 너무 크지도, 작지도 않게
  • 성능과 설계의 균형: 이론과 실무의 중간 지점 찾기
  • Aggregate Root 책임의 명확화: Course가 어디까지 관여할지 재설계

이번 경험은 실패가 아닌 값진 학습 과정이었습니다. JDBC로 고생하며 얻은 통찰이 JPA 환경에서 더 나은 설계를 만드는 밑거름이 될 것이라 믿습니다. 특히 “Section을 통한 Lecture 추가"라는 작은 고민이 Aggregate 설계의 본질을 깨닫게 해준 소중한 계기였습니다.