Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
2341fc3
docs: 수강신청 도메인 모델 문서 추가 및 링크 수정
ghtjr410 Dec 9, 2025
18aecc7
feat: Course에 generation 필드 추가 및 저장/조회 기능 확장
ghtjr410 Dec 9, 2025
ed41d5b
feat: 이미지 타입 enum 추가 및 변환 로직 구현
ghtjr410 Dec 9, 2025
f7e3a10
refactor: ImageType 대소문자 처리 개선 및 파라미터화 테스트 추가
ghtjr410 Dec 9, 2025
5ce49fc
feat: CoverImage 도메인 추가 및 파일 크기 검증 로직 구현
ghtjr410 Dec 9, 2025
ae72ccc
feat: CoverImage에 최소 해상도 검증 추가
ghtjr410 Dec 9, 2025
44cfb1c
feat: CoverImage에 이미지 비율(3:2) 검증 추가
ghtjr410 Dec 9, 2025
87608e0
feat: CoverImage에 확장자 자동 추출 및 검증 로직 추가
ghtjr410 Dec 9, 2025
95a274c
feat: 이미지 해상도 전용 Value Object ImageDimension 추가
ghtjr410 Dec 9, 2025
282297f
feat: 이미지 파일 전용 Value Object ImageFile 추가
ghtjr410 Dec 9, 2025
de23c2a
refactor: CoverImage를 ImageFile·ImageDimension VO 조합 구조로 단순화
ghtjr410 Dec 9, 2025
4c31460
feat: SessionStatus 추가 및 수강신청 가능 여부 판단 로직 구현
ghtjr410 Dec 9, 2025
6f59675
feat: SessionPeriod 값 객체 추가
ghtjr410 Dec 9, 2025
658f20b
feat: 금액 값 객체 Money 추가
ghtjr410 Dec 9, 2025
b05d3c7
feat: Money 비교 기능 추가
ghtjr410 Dec 9, 2025
f77c368
refactor: 테스트 메서드 명을 생성자/기능 중심 네이밍으로 정리
ghtjr410 Dec 9, 2025
45e6e28
feat: Session 도메인 추가
ghtjr410 Dec 9, 2025
35de848
feat: 무료/유료 세션 도메인 추가
ghtjr410 Dec 9, 2025
1c5bcc9
refactor: Session을 추상 클래스로 전환하고 테스트 정리
ghtjr410 Dec 9, 2025
781867a
feat: 수강신청 엔티티 및 컬렉션 도메인 추가
ghtjr410 Dec 10, 2025
c856a60
feat: Session에 수강신청 기능 추가 및 Enrollment에 sessionId 반영
ghtjr410 Dec 10, 2025
4a4c9a3
feat: 세션 수강신청 기능 확장 및 FreeSession 생성자 추가
ghtjr410 Dec 10, 2025
bd668f3
feat: 유료 세션 결제 검증 및 상태 검증 로직 강화
ghtjr410 Dec 10, 2025
50f0f02
feat: PaidSession 수강 인원 제한 검증 추가
ghtjr410 Dec 10, 2025
a7b32e6
refactor: Course에서 generation 제거 및 관련 코드 정리
ghtjr410 Dec 10, 2025
d3803a6
feat: Course에 Sessions 추가 및 생성자/매핑 구조 확장
ghtjr410 Dec 10, 2025
ea535b8
feat: Course에 Session 관리 기능 추가 및 세션 컬렉션 안정화
ghtjr410 Dec 10, 2025
79a4ccb
refactor: PaidSession 필드명 의미 명확화 및 용어 일관성 개선
ghtjr410 Dec 10, 2025
d8cc1b8
feat: 수강 인원 값 객체 Capacity 추가
ghtjr410 Dec 10, 2025
3c2f1a6
feat: Capacity에 정원 초과 여부 판단 기능 추가
ghtjr410 Dec 10, 2025
e7d1082
refactor: PaidSession에서 Capacity·Money 값 객체 적용 및 테스트 정리
ghtjr410 Dec 10, 2025
99f90a8
refactor: Course·Enrollment 불변성 강화
ghtjr410 Dec 10, 2025
c65b23a
refactor: Session 불변 필드 적용 및 테스트 주석 정리
ghtjr410 Dec 10, 2025
10d47c2
docs: 도메인 모델 문서에 규칙·체크리스트·구현 목록 추가
ghtjr410 Dec 10, 2025
c6e13ba
docs: PR 링크 추가
ghtjr410 Dec 10, 2025
e39012d
feat: 수강 신청 중복 검증 추가
ghtjr410 Dec 10, 2025
1fb78b7
refactor: Session 수강신청 템플릿 메서드 패턴 적용
ghtjr410 Dec 10, 2025
28c9199
test: FreeSession 전용 테스트 확장 및 SessionTest 삭제
ghtjr410 Dec 10, 2025
2fdb686
test: 생성자 및 수강신청 테스트 간결화
ghtjr410 Dec 10, 2025
d0f4f1a
docs: 세션 구조 및 템플릿 메서드 패턴 적용 내용 업데이트
ghtjr410 Dec 10, 2025
900c8ef
refactor: 중복 수강 신청 검증 로직 간소화
ghtjr410 Dec 10, 2025
8986d3b
feat: 수강 신청 정책 분리 및 무료 수강 정책 기본 구현 추가
ghtjr410 Dec 10, 2025
3597f2c
feat: 유료 수강 정책(PaidEnrollmentPolicy) 추가 및 검증 테스트 구현
ghtjr410 Dec 10, 2025
c2844b4
refactor: Session 구조 단순화 및 정책 기반 구성으로 통합
ghtjr410 Dec 10, 2025
a0cd14a
docs: 정책 기반 Session 구조로 문서 내용 업데이트
ghtjr410 Dec 10, 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
본 저장소는 TDD 학습을 위한 단계별 과제를 포함합니다.
## 단계별 문서
- **[1단계 - 레거시 코드 리팩터링](./docs/01-legacy-code-refactoring.md)**
- **[2단계 - 수강신청(도메인 모델)]()**
- **[2단계 - 수강신청(도메인 모델)](./docs/02-lms-domain-model.md)**
- **[3단계 - 수강신청(DB 신청)]()**
- **[4단계 - 수강신청(요구사항 변경)]()**
## 진행 방법
Expand Down
104 changes: 104 additions & 0 deletions docs/02-lms-domain-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# 2단계 - 수강신청(도메인 모델)
***
## 코드 리뷰
> PR 링크:
> **[https://github.com/next-step/java-lms/pull/811](https://github.com/next-step/java-lms/pull/811)**
## 나의 학습 목표
- TDD 사이클로 구현
- 객체지향 생활 체조 원칙 준수
- 테스트 작성하기 쉬운 구조로 설계
- 자기 점검 체크리스트 준수
- 특히 "규칙 7: 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다."를 지키기 위해 노력한다.
## 학습 관리 시스템(LMS)
- 넥스트스텝은 재직자를 대상으로 소프트웨어 교육을 진행하는 교육 기관이다.
- 2018년 교육 사업을 시작했다.
- 교육 사업을 시작하며 자체적으로 학습 관리 시스템을 개발해 수강생을 모집하고, 컨텐츠를 관리하고 있다.
## 수강 신청 기능 요구사항
- 과정(Course)은 기수 단위로 운영하며, 여러 개의 강의(Session)를 가질 수 있다.
- 강의는 시작일과 종료일을 가진다.
- 강의는 강의 커버 이미지 정보를 가진다.
- 이미지 크기는 1MB 이하여야 한다.
- 이미지 타입은 gif, jpg(jpeg 포함),, png, svg만 허용한다.
- 이미지의 width는 300픽셀, height는 200픽셀 이상이어야 하며, width와 height의 비율은 3:2여야 한다.
- 강의는 무료 강의와 유료 강의로 나뉜다.
- 무료 강의는 최대 수강 인원 제한이 없다.
- 유료 강의는 강의 최대 수강 인원을 초과할 수 없다.
- 유료 강의는 수강생이 결제한 금액과 수강료가 일치할 때 수강 신청이 가능하다.
- 강의 상태는 준비중, 모집중, 종료 3가지 상태를 가진다.
- 강의 수강신청은 강의 상태가 모집중일 때만 가능하다.
- 유료 강의의 경우 결제는 이미 완료한 것으로 가정하고 이후 과정을 구현한다.
- 결제를 완료한 결제 정보는 payments 모듈을 통해 관리되며, 결제 정보는 Payment 객체에 담겨 반한된다.
## 프로그래밍 요구사항
- DB 테이블 설꼐 없이 도메인 모델부터 구현한다.
- 도메인 모델은 TDD로 구현한다.
- 단, Service 클래스는 단위 테스트가 없어도 된다.
## PR 전 점검
**[체크리스트 확인하기](checklist.md)**
## 구현 기능 목록
#### Course
- [x] 강의 추가
- [x] 강의 개수 조회

#### Sessions (일급 컬렉션)
- [x] 강의 추가
- [x] 강의 개수 조회

#### Session
- [x] 수강 신청
- [x] 모집중 상태 검증
- [x] Policy에 검증 위임
- [x] 수강 인원 조회
- [x] 강의 타입 조회 (Policy에서 반환)

#### EnrollmentPolicy (추상 클래스, 템플릿 메서드 패턴)
- [x] final validate(payment, currentCount) - 알고리즘 순서 강제
- [x] 결제 검증 (validatePayment) - 하위에서 구현
- [x] 정원 검증 (validateCapacity) - 하위에서 구현
- [x] 강의 타입 반환 (getType) - 하위에서 구현

#### FreeEnrollmentPolicy
- [x] EnrollmentPolicy 상속
- [x] 결제 검증: 빈 구현 (무료)
- [x] 정원 검증: 빈 구현 (제한 없음)
- [x] 타입 반환: FREE

#### PaidEnrollmentPolicy
- [x] EnrollmentPolicy 상속
- [x] 결제 금액 검증 (수강료 일치)
- [x] 수강 인원 검증 (정원 초과)
- [x] 타입 반환: PAID

#### Enrollments (일급 컬렉션)
- [x] 수강 신청 추가
- [x] 수강 인원 조회
- [x] 중복 수강 신청 검증

#### CoverImage (VO)
- [x] 이미지 파일 정보와 크기 정보 조합 생성

#### ImageFile (VO)
- [x] 파일 크기 검증 (1MB 이하)
- [x] 확장자 추출
- [x] 이미지 타입 변환

#### ImageDimension (VO)
- [x] 너비/높이 최소값 검증 (300x200 이상)
- [x] 비율 검증 (3:2)

#### ImageType (Enum)
- [x] 확장자 → ImageType 변환 (from(String))
- [x] JPEG → JPG 변환 지원

#### SessionPeriod (VO)
- [x] 시작일/종료일 검증 (종료일 >= 시작일)

#### SessionStatus (Enum)
- [x] 수강 신청 가능 여부

#### Money (VO)
- [x] 금액 검증 (0 이상)
- [x] 금액 비교

#### Capacity (VO)
- [x] 최대 인원 검증 (1명 이상)
- [x] 초과 여부 확인
20 changes: 20 additions & 0 deletions src/main/java/nextstep/courses/domain/Capacity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package nextstep.courses.domain;

public class Capacity {
private final int value;

public Capacity(int value) {
if (value <= 0) {
throw new IllegalArgumentException(String.format("최대 수강 인원은 1명 이상이어야 합니다. (입력: %d)", value));
}
this.value = value;
}

public boolean isOver(int currentCount) {
return currentCount >= value;
}

public int getValue() {
return this.value;
}
}
27 changes: 21 additions & 6 deletions src/main/java/nextstep/courses/domain/Course.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,39 @@ public class Course {

private Long creatorId;

private LocalDateTime createdAt;
private final Sessions sessions;

private LocalDateTime updatedAt;
private final LocalDateTime createdAt;

public Course() {}
private final LocalDateTime updatedAt;

public Course(String title, Long creatorId) {
this(0L, title, creatorId, LocalDateTime.now(), null);
public Course(String title, Long creatorId, LocalDateTime now) {
this(0L, title, creatorId, new Sessions(), now, null);
}

public Course(Long id, String title, Long creatorId, LocalDateTime createdAt, LocalDateTime updatedAt) {
public Course(
Long id,
String title,
Long creatorId,
Sessions sessions,
LocalDateTime createdAt,
LocalDateTime updatedAt) {
this.id = id;
this.title = title;
this.creatorId = creatorId;
this.sessions = sessions;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}

public void addSession(Session session) {
sessions.add(session);
}

public int sessionCount() {
return sessions.count();
}

public String getTitle() {
return title;
}
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/nextstep/courses/domain/CoverImage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package nextstep.courses.domain;

public class CoverImage {
private final ImageFile imageFile;
private final ImageDimension dimension;

public CoverImage(String filename, long fileSize, int width, int height) {
this.imageFile = new ImageFile(filename, fileSize);
this.dimension = new ImageDimension(width, height);
}
}
25 changes: 25 additions & 0 deletions src/main/java/nextstep/courses/domain/Enrollment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package nextstep.courses.domain;

import java.time.LocalDateTime;

public class Enrollment {
private final Long id;
private final Long sessionId;
private final Long studentId;
private final LocalDateTime createdAt;

public Enrollment(Long sessionId, Long studentId, LocalDateTime now) {
this(0L, sessionId, studentId, now);
}

public Enrollment(Long id, Long sessionId, Long studentId, LocalDateTime createdAt) {
this.id = id;
this.sessionId = sessionId;
this.studentId = studentId;
this.createdAt = createdAt;
}

public Long getStudentId() {
return studentId;
}
}
14 changes: 14 additions & 0 deletions src/main/java/nextstep/courses/domain/EnrollmentPolicy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package nextstep.courses.domain;

public abstract class EnrollmentPolicy {
public final void validate(Money payment, int currentCount) {
validatePayment(payment);
validateCapacity(currentCount);
}

public abstract SessionType getType();

protected abstract void validatePayment(Money payment);

protected abstract void validateCapacity(int currentCount);
}
31 changes: 31 additions & 0 deletions src/main/java/nextstep/courses/domain/Enrollments.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package nextstep.courses.domain;

import java.util.ArrayList;
import java.util.List;

public class Enrollments {
private final List<Enrollment> values;

public Enrollments() {
this(new ArrayList<>());
}

public Enrollments(List<Enrollment> values) {
this.values = new ArrayList<>(values);
}

public void add(Enrollment enrollment) {
validateNotDuplicate(enrollment);
this.values.add(enrollment);
}

private void validateNotDuplicate(Enrollment enrollment) {
if (values.contains(enrollment)) {
throw new IllegalArgumentException("이미 수강 신청한 강의입니다.");
}
}

public int count() {
return this.values.size();
}
}
19 changes: 19 additions & 0 deletions src/main/java/nextstep/courses/domain/FreeEnrollmentPolicy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package nextstep.courses.domain;

public class FreeEnrollmentPolicy extends EnrollmentPolicy {

@Override
public SessionType getType() {
return SessionType.FREE;
}

@Override
protected void validatePayment(Money payment) {
// 무료는 검증 X
}

@Override
protected void validateCapacity(int currentCount) {
// 무료는 검증 X
}
}
33 changes: 33 additions & 0 deletions src/main/java/nextstep/courses/domain/ImageDimension.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package nextstep.courses.domain;

public class ImageDimension {
private static final int MIN_WIDTH = 300;
private static final int MIN_HEIGHT = 200;
private static final int WIDTH_RATIO = 3;
private static final int HEIGHT_RATIO = 2;

private final int width;
private final int height;

public ImageDimension(int width, int height) {
validateDimension(width, height);
validateAspectRatio(width, height);
this.width = width;
this.height = height;
}

private void validateDimension(int width, int height) {
if (width < MIN_WIDTH) {
throw new IllegalArgumentException(String.format("이미지 너비는 %d픽셀 이상이어야 합니다. (입력: %d)", MIN_WIDTH, width));
}
if (height < MIN_HEIGHT) {
throw new IllegalArgumentException(String.format("이미지 높이는 %d픽셀 이상이어야 합니다. (입력: %d)", MIN_HEIGHT, height));
}
}

private void validateAspectRatio(int width, int height) {
if (width * HEIGHT_RATIO != height * WIDTH_RATIO) {
throw new IllegalArgumentException(String.format("이미지 비율은 3:2여야 합니다. (입력: %d:%d)", width, height));
}
}
}
30 changes: 30 additions & 0 deletions src/main/java/nextstep/courses/domain/ImageFile.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package nextstep.courses.domain;

public class ImageFile {
private static final long MAX_FILE_SIZE = 1024 * 1024;

private final String filename;
private final long fileSize;
private final ImageType imageType;

public ImageFile(String filename, long fileSize) {
validateFileSize(fileSize);
this.filename = filename;
this.fileSize = fileSize;
this.imageType = ImageType.from(extractExtension(filename));
}

private void validateFileSize(long fileSize) {
if (fileSize > MAX_FILE_SIZE) {
throw new IllegalArgumentException(String.format("이미지 크기는 1MB 이하여야 합니다. (입력: %d bytes)", fileSize));
}
}

private String extractExtension(String filename) {
int dotIndex = filename.lastIndexOf('.');
if (dotIndex == -1 || dotIndex == filename.length() - 1) {
throw new IllegalArgumentException("파일 확장자가 없습니다. (입력: " + filename + ")");
}
return filename.substring(dotIndex + 1);
}
}
16 changes: 16 additions & 0 deletions src/main/java/nextstep/courses/domain/ImageType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package nextstep.courses.domain;

public enum ImageType {
GIF,
JPG,
PNG,
SVG;

public static ImageType from(String extension) {
String upper = extension.toUpperCase();
if (upper.equals("JPEG")) {
return JPG;
}
return valueOf(upper);
}
}
20 changes: 20 additions & 0 deletions src/main/java/nextstep/courses/domain/Money.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package nextstep.courses.domain;

public class Money {
public static final Money ZERO = new Money(0);

private final int amount;

public Money(int amount) {
validate(amount);
this.amount = amount;
}

private static void validate(int value) {
if (value < 0) throw new IllegalArgumentException(String.format("금액은 0 이상이어야 합니다. (입력: %d)", value));
}

public boolean isSameAs(Money other) {
return this.amount == other.amount;
}
}
Loading