Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b6b8121
refactor : 접근제어자 변경, 컨벤션 준수
Dec 7, 2025
2ba990b
doc : Question의 필드의 객체 분리 필요성 관련 의견 메모
Dec 7, 2025
1b35cca
fix : 컴파일 에러 해결
Dec 7, 2025
74bdefd
feat : 강의 시작일 종료일 관련 객체 구현 및 유효성 검증
Dec 7, 2025
1bce708
feat : 강의 커버 이미지 객체 구현 및 정규식 검증
Dec 7, 2025
d54677c
feat : 강의 상태 기능 개발 및 테스트
Dec 7, 2025
5bbcbfb
feat : 무료, 유료 강의에 따라 강의 신청 및 정원 지불 관리 개발
Dec 7, 2025
72f9e84
refactor : enum 클래스 공통된 enumerate 패키지로 이동
Dec 7, 2025
1c06b04
refactor : 정책관련 객체 ProvidePolicy 를 추가하고, Provide 에 일부 필드 변경
Dec 8, 2025
0bbac87
feat : session 및 관련 객체 개발 완료, 수강신청 기능 개발 완료
Dec 8, 2025
ec29495
refactor : apply 관련 메서드 반환 값 변경 및 관련 테스트 코드 변경
Dec 8, 2025
5cb9398
fix : 강의 모입 중 상태에 신청 가능하게 수정
Dec 8, 2025
ff4179f
test : 깨진 테스트 고치기
Dec 8, 2025
ea85e9c
refactor
Dec 8, 2025
7771c41
feat : Course 에 예외전파 및 수강신청 추가
Dec 8, 2025
4d2a1b9
refactor : 수강신청 수강생 수가 아니라 신청 자 리스트를 받게끔 변경
Dec 9, 2025
8ed1865
feat : 결제모듈 붙이고, 항상 결제된것으로 구현
Dec 9, 2025
8f33b9a
refactor : EnrolledUsers, Sessions 일급컬렉션으로 변환
Dec 9, 2025
00ab466
refactor : 정원초과 로직 EnrolledUsers 로 이관
Dec 9, 2025
1980d14
docs : 개발 과정 메모
Dec 9, 2025
faf17e1
refactor :
Dec 10, 2025
d535c7a
refactor : CoverImage 의 많은 필드의 원시값을 포장하여 관련된 것 끼리 분리
Dec 10, 2025
7ff5afb
refactor
Dec 10, 2025
26efd98
refactor : apply 에 ProvideType 에 따라 분기되게끔 변경
Dec 10, 2025
370be1a
refactor
Dec 10, 2025
7c46655
refactor : 각각 변수를 SessionApply로 객체 분리
Dec 10, 2025
2dc593e
refactor : 불필요한 static 등 제거
Dec 11, 2025
89be588
refactor : null object + 전략 패턴 도입으로 무료 유료강의 정책에 대한 DI 방식 구현
Dec 12, 2025
93d0c8a
refactor : 디렉토리 구조 변경
Dec 12, 2025
7ca2fd5
refactor : 테스트 픽스쳐를 '오브젝트 마더 패턴' + 빌더패턴(기본 값 숨기기, 빌더 덜쓰기, 팩토리 메서드를 이용…
Dec 12, 2025
d16f988
docs : 리팩터링 기준 메모
Dec 12, 2025
44f0d33
refactor : Free, Paid로 나누어진 Builder를 하나로 합쳐서 간소화
Dec 12, 2025
4dd0d87
refactor : 놓친 필드 기본 설정 제거
Dec 12, 2025
f3fe388
chore : 패키지 리스트럭쳐링
Dec 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 171 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,174 @@
- [x] : `Answer` 에 대한 삭제 테스트 필요하자않은가? 이유작성
- 이유 : 필요하다 Answer 을 수동적으로 바라보았기에 비즈니스 로직에 대한 테스트 코드가 생략되었다.
- [ ] : `Answers` 에서 한땀한땀 삭제하는건 `Answer` 에 대한 수동적으로 바라보는 느낌아닌가? 이유 작성
- 이유 : 맞다 또한 검증에 대해서도 모두 수행하는것은 바람직하지 않음, 검증은 `Answer` 내부에서 해야함
- 이유 : 맞다 또한 검증에 대해서도 모두 수행하는것은 바람직하지 않음, 검증은 `Answer` 내부에서 해야함

## 3차 피드백
- [x] : `Question` 의 `title`, `contents` 필드를 별도로 `QuestionBody` 로 분리하는 것이 의미 있을까?
- 생각컨데 분리는 의마가 없다고 본다.
- 그 이유는 : `title`, `contents` 로 분리를 하면은 값을 보관해주는 역할에 불과한 점
- 분리한 객체만의 특별한 도메인 규칙이나 행동이 없는 점
- 때문이다.

# 2단계 : 수강신청(도메인 모델)

- DB 테이블 설계 없이 도메인 모델부터 구현하기
- 도메인 모델은 TDD로 구현 (Service 클래스는 단위 테스트 없이)

## 기능목록

1. 과정(Course)과 강의(Session)
- 과정(Course)은 '기수' 단위 운영
- 과정에 여러개의 강의(Session) 를 가질 수있음
2. 강의 기간
- 시작일과 종료일 가짐
3. 강의에 커버이미지 가짐
- 이미지 크기는 1MB 이하
- 이미지 타입은 gif, jpg(jpeg), png, svg
- 이미지 크기 : width - 300px / height - 200px 이상
- width / height 비율 3:2
4. 강의 타입 : '무료 강의' '유료 강의'로 나누어진다
- 무료 강의 : 최대 수강신청 인원 제한이 없다
- 유료 강의 : 강의 최대 수강 인원 초과할 수 없음
- 수강생이 결제한 금액과 수강료가 일치 할 시 수강 신청 가능
5. 강의 상태 : '준비중', '모집중', '종료'
- 모집 중 일때 강의 수강신청 가능
6. 결제
- 유료 강의 결제는 이미 완료된 것으로 가정 하고 이후 과정 구현함
- 결제 완료 한 결제정보는 payments 모듈로 관리, 결제정보는 Payment 객체에 담겨 반환

## 객체 설계

### 객체 역할

- Course : 기수별 session 들의 관리관련 정보 전문가 및 행위 책임자
- Session : 강의 자체의 전반적인 정보 전문가 및 행위 책임자
- Duration : 강의의 시작, 종료 관련 정보 전문가 및 행위 책임자
- CoverImage : 강의의 커버 이미지 관련 정보 전문가 및 행위 책임자
- CoverImageType
- ProvideType : 강의의 무료, 유료 강의 관련 정보 전문가 및 행위 책임자
- SessionStatus : 강의의 준비중, 모집 중 같은 정보 전문가 및 행위 책임자
- SessionStatusType

### 구현 순서 (작은 도메인 부터)

Duration → CoverImage → SessionStatus → ProvideType → Session → Course

### 중요 : 만들어진 강의에 수강신청 하는 것임

### 객체 별 기능 사항

#### Duration

- [x] : 종료일이 시작일 보다 뒤에 있을 수없다
- [x] : 시작일과 종료일이 오늘보다 뒤에 있다
- [ ] : startDate, endDate 원시 값 포장

#### CoverImage

- [x] : 커버 이미지의 크기는 1MB 이하이다
- [x] : 이미지 타입은 gif, jpg(jpeg), png, svg 이다 (CoverImageType)
- [x] : 이미지 크기는 width - 300px / height - 200px 이상 이다
- [x] : width / height 비율 3:2 이다

#### SessionStatus

- [x] : 모집 중 일때 만 강의 수강신청 가능

#### Provide

- [x] : 강의 타입은 무료 강의, 유료 강의 2가지 이다.
- [x] : 수강신청 제한인원은 타입에 존재한다 (규칙)
- [x] : 강의를 수강신청 하면 수강생 수가 올라간다
- [x] : 클라이언트 코드에서 수강신청 제한인원 받아서 그 수가 넘는지 확인해준다
- [x] : 지불금액을 보고 금액이 같은지 검증한다
- [x] : 무료인데 지불하면 안된다.

##### 객체 분리 필요성 고민

1. Provide 를 Free, Paid 로 객체를 나눌 것인가?
- 기대효과 : 각각 다른 규칙에 대해서 정리 가능
- 예) 무료 : 인원수 제한 X/ 금액 무료
- 한계점 : 비슷한 형태의 객체가 2개인데, 어떤 강의를 신청하느냐에 따라 요청이 오거나, 불러오는 객체가 달라짐

2. enrolledCount, maxEnrollment / tuitionFee, pay 각각 묶을 것인가?
- 기대효과 : 인원수 체크가 손쉽게 됨, 금액을 내야하는지 체크가 가능 / 서로 비슷한 규칙끼리 묶여서 검증 쉬움
- 한계점 : 최초 고민인 무료인 경우 인원수 제한, 수강료에 대한 통제는 여전히 어려움
- 규칙 : 그러나 만들어진 각각 객체안에서 무료인 경우 제한 검증 X, 수강료 검증 X 되지 않을까?
- 생성 : 무료인경우 생성자 인자를 생략하기? -> but 결국에 인원수를 0으로 세팅해야함

3. enrolledCount, tuitionFee / pay, maxEnrollment 각각 묶을 것인가?
- 기대 효과 : 무료, 유료인 경우에 대한 변칙 적용이 수월함
- 한계점 : 묶이는 필드끼리 생명주기가 같을지 의문
- 차라리 현상이 아닌 정해진 생명주기끼리 묶는게 낫지않을까?
- 예) tuitionFee, maxEnrollment (세션자체의 규칙) / enrolledCount, pay (요청 때 마다 변칙)
- 전자는 Session 에서, 후자는 Provide 에서

4. tuitionFee, maxEnrollment / pay, enrolledCount 각각 묶을 것인가?
- 기대 효과 : tuitionFee, maxEnrollment 는 상위 클래스 Session 에서 통제함
- 제약과 현상을 분리함
- 무료인 경우 0 fee 와 무제한 수강인원에 대한 검증 가능
- 그리고 억지로 0으로 만들필요 없음
- 한계점 : 하지만 전자를 결국에 객체로 만들면 0으로 세팅이 필요함
- 결국에 상단 2번처럼 문제가

- 고민
- 정원을 숫자가 아닌 정책으로 보아야함
- 무제한 정책은 숫자가 필요없음
- 제한 정책은 숫자가 필요
- 정원이 없는것은 null 로 지정 가능
- DDD 관점에서 '개념 부재' 표현임 (null 보다 의미없는 값(0) 으로 도메인 개념 왜곡이 더큰 문제)
- null 은 인간언어로 표현 가능함
- 두갈래 고민
- 2번 : 수강생 수 / 비용 으로 분리
- 생성 방식에 대한 문제 해결함 -> 고려 가능
- 도달 여부 쉽게 판단
- 4번 : 기준 / 준수여부 로 분리
- 생명주기 비슷
- 최종 결정
- 4번: ProvidePolicy / ProvideStatus
- 걱정 : null 로 넣는게 인위적인데 전략패턴을 이용해야할까 싶으면서 오버엔지이너링일까 우려됨

#### Session

- [x] : `Provide` 에게 해당 세션의 제한인원 수 전달해서 남은 자리 조율
- [x] : 무료, 유료에 따라서 수강신청 메서드 분리

#### Course

- [x] : 신청하는 강의의 id 와 가격을 넣어서 수강신청한다
- [x] : 수강신청 하려는 session의 id가 없으면 예외전파

#### CourseService

- [x] : 해당 코스의 특정 강의를 조회한다
- [x] : 조회한 강의를 수강신청한다
- 무료강의 이면 바로 신청
- 유료 강의 이면 결제 모듈 조회 후 신청
- 당연 성공이므로 곧바로 amount 넣기
- [x] : 조회 성공 후 결과를 저장하기

#### 놓치거나 마무리 부분

- [x] : 수강신청한 수강생의 정보 넣기
- [x] : 결제 완료를 기준으로 던지기
- [x] : 컬렉션을 일급 컬렉션으로 바꾸기 (EnrolledUsers, Sessions)

## 1차 피드백

- [x] : 무료 유료인 상태의 객체 구분을 null로 구현이 아닌, null을 추상화한 Null Object 로 추가
- 인터페이스로 null Object 로 구분하기 / 그에 따라서 구현하는 방향 바꾸기
- 무료는 싱글턴 null 객체로, 유료는 생성자로 초기화
- DI 방식으로 하니 무분별하게 분기 칠 필요가 없어짐
- [x] : 무료 유료 구분은 생성자 유무가 아닌 ProvideType 값에 따라 결정
- apply() 분기도 마찬가지
- [x] : Base 추상클래스로 구현하여 직접 생상안되게 하기
- [x] : CoverImage 필드 수 줄이기
- [x] : boolean 타입이 아닌데 is 로 시작하는 메서드명 validate 시작으로 변경
- [x] : `ProvidePolicy` 에 수강신청 판단 하는 모든 값 가져도 될지 고민하기
- SessionStatus, 현재 수강신청한 수강생 목록도 신청여부 판단에 필요한 값
- -> 이름을 Enrollment의 EnrollmentPolicy로 몀명하니 필요성을 가지게 됨
- [x] : 복잡한 객체의 테스트 데이터 생성 시 중복코드 많고, 생성자 인자가 변경되는 경우 변경할 부분이 많음
- 테스트 데이터 생성 시 방법 탐색해서 적용 후 앞으로 활용하기
- 테스트 픽스쳐를 '오브젝트 마더 패턴' + 빌더패턴'(기본 값 숨기기, 빌더 덜쓰기, 팩토리 메서드를 이용해 도메인 강조, 비슷한 객체를 이용 시 코드 줄이기위한 but 사용)
- 이용해서 축소
8 changes: 8 additions & 0 deletions src/main/java/nextstep/courses/CanNotCreateException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package nextstep.courses;

public class CanNotCreateException extends Exception {

public CanNotCreateException(String message) {
super(message);
}
}
8 changes: 8 additions & 0 deletions src/main/java/nextstep/courses/CanNotJoinException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package nextstep.courses;

public class CanNotJoinException extends Exception {

public CanNotJoinException(String message) {
super(message);
}
}
53 changes: 0 additions & 53 deletions src/main/java/nextstep/courses/domain/Course.java

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package nextstep.courses.domain.builder;

import static nextstep.courses.domain.builder.EnrollmentPolicyBuilder.aFreeEnrollmentPolicyBuilder;
import static nextstep.courses.domain.builder.EnrollmentPolicyBuilder.aPaidEnrollmentPolicyBuilder;

import nextstep.courses.CanNotCreateException;
import nextstep.courses.domain.enrollment.Enrollment;
import nextstep.courses.domain.enrollment.EnrollmentPolicy;
import nextstep.courses.domain.enumerate.EnrollmentType;

public class EnrollmentBuilder {

private EnrollmentType enrollmentType;
private EnrollmentPolicy enrollmentPolicy;

public static EnrollmentBuilder aFreeEnrollmentBuilder() {
return new EnrollmentBuilder()
.withEnrollmentType(EnrollmentType.FREE)
.withEnrollmentPolicy(aFreeEnrollmentPolicyBuilder().build());
}

public static EnrollmentBuilder aPaidEnrollmentBuilder() {
return new EnrollmentBuilder()
.withEnrollmentType(EnrollmentType.PAID)
.withEnrollmentPolicy(aPaidEnrollmentPolicyBuilder().build());
}

private EnrollmentBuilder() {}

private EnrollmentBuilder(EnrollmentBuilder copy) {
this.enrollmentType = copy.enrollmentType;
this.enrollmentPolicy = copy.enrollmentPolicy;
}

public EnrollmentBuilder withEnrollmentType(EnrollmentType enrollmentType) {
this.enrollmentType = enrollmentType;
return this;
}

public EnrollmentBuilder withEnrollmentPolicy(EnrollmentPolicy enrollmentPolicy) {
this.enrollmentPolicy = enrollmentPolicy;
return this;
}

public Enrollment build() throws CanNotCreateException {
return new Enrollment(enrollmentType, enrollmentPolicy);
}

public EnrollmentBuilder but() {
return new EnrollmentBuilder(this);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package nextstep.courses.domain.builder;

import java.util.List;
import nextstep.courses.domain.enrollment.EnrolledUsers;
import nextstep.courses.domain.enrollment.EnrollmentPolicy;
import nextstep.courses.domain.enrollment.SessionStatus;
import nextstep.courses.domain.enrollment.enrollmentcondition.EnrollmentCondition;
import nextstep.courses.domain.enrollment.enrollmentcondition.FreeEnrollmentCondition;
import nextstep.courses.domain.enrollment.enrollmentcondition.PaidEnrollmentCondition;
import nextstep.courses.domain.enumerate.SessionStatusType;

public class EnrollmentPolicyBuilder {

private EnrollmentCondition enrollmentCondition;
private EnrolledUsers enrolledUsers = new EnrolledUsers(List.of(1L, 2L, 3L, 4L, 5L));
private SessionStatus status = new SessionStatus(SessionStatusType.RECRUITING);

public static EnrollmentPolicyBuilder aFreeEnrollmentPolicyBuilder() {
return new EnrollmentPolicyBuilder()
.withEnrollmentCondition(FreeEnrollmentCondition.INSTANCE);
}

public static EnrollmentPolicyBuilder aPaidEnrollmentPolicyBuilder() {
return new EnrollmentPolicyBuilder()
.withEnrollmentCondition(new PaidEnrollmentCondition(10L, 10));
}

private EnrollmentPolicyBuilder() {}

private EnrollmentPolicyBuilder(EnrollmentCondition enrollmentCondition) {
this.enrollmentCondition = enrollmentCondition;
}

private EnrollmentPolicyBuilder(EnrollmentPolicyBuilder copy) {
this.enrollmentCondition = copy.enrollmentCondition;
this.enrolledUsers = copy.enrolledUsers;
this.status = copy.status;
}

public EnrollmentPolicyBuilder withEnrollmentCondition(EnrollmentCondition enrollmentCondition) {
this.enrollmentCondition = enrollmentCondition;
return this;
}

public EnrollmentPolicyBuilder withEnrolledUsers(EnrolledUsers enrolledUsers) {
this.enrolledUsers = enrolledUsers;
return this;
}

public EnrollmentPolicyBuilder withSessionStatus(SessionStatus sessionStatus) {
this.status = sessionStatus;
return this;
}

public EnrollmentPolicy build() {
return new EnrollmentPolicy(enrollmentCondition, enrolledUsers, status);
}

public EnrollmentPolicyBuilder but() {
return new EnrollmentPolicyBuilder(this);
}

}
Loading