🏅오늘의 목표
✅ 진행한 작업
- 코스 엔티티 생성
- 코스 리포지토리 생성
- 코스 서비스 생성
- 코스 등록 API 생성
📃 개발내용
코스(Course) 엔티티 생성
@Entity
@Table(name="courses")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Course extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id",nullable = false)
private User user;
@Column(name = "course_name",nullable = false, length = 50)
private String courseName;
@Column(name = "course_description", nullable = false, columnDefinition = "TEXT")
private String courseDescription;
@Column(name = "course_distance",nullable = false)
private BigDecimal courseDistance;
@Column(name = "course_time",nullable = false)
private Integer courseTime;
@Enumerated(EnumType.STRING)
@Column(name = "course_level",nullable = false)
private CourseLevel courseLevel;
@Column(name = "location_start",nullable = false)
private String locationStart;
@Column(name = "location_end",nullable = false)
private String locationEnd;
@Column(name = "thumbnail_url")
private String thumbnailUrl;
@Enumerated(EnumType.STRING)
@Column(name = "course_visibility",nullable = false)
private CourseVisibility courseVisibility;
@Column(name = "like_cnt",nullable = false)
private Integer likeCnt;
@Column(name = "view_cnt",nullable = false)
private Integer viewCnt;
@Column(name = "bookmark_cnt",nullable = false)
private Integer bookmarkCnt;
@Column(name = "comment_cnt",nullable = false)
private Integer commentCnt;
@OneToMany(mappedBy = "course",cascade = CascadeType.ALL,orphanRemoval = true)
private List<CourseLike> likes = new ArrayList<>();
@OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true)
private List<CourseBookmark> bookmarks = new ArrayList<>();
@OneToMany(mappedBy = "course", cascade = CascadeType.ALL, orphanRemoval = true)
private List<CourseComment> comments = new ArrayList<>();
// 코스 생성
public static Course create(
User user,String courseName, String courseDescription,BigDecimal courseDistance,
Integer courseTime, CourseLevel courseLevel, String locationStart, String locationEnd,
String thumbnailUrl, CourseVisibility courseVisibility) {
Course course = new Course();
course.user = user;
course.courseName = courseName;
course.courseDescription = courseDescription;
course.courseDistance = courseDistance;
course.courseTime = courseTime;
course.courseLevel = courseLevel;
course.locationStart = locationStart;
course.locationEnd = locationEnd;
course.thumbnailUrl = thumbnailUrl;
course.courseVisibility = courseVisibility;
course.likeCnt = 0;
course.viewCnt = 0;
course.bookmarkCnt = 0;
course.commentCnt = 0;
return course;
}
// 수정
public void update(String courseName, BigDecimal courseDistance, Integer courseTime,
CourseLevel courseLevel, String locationStart, String locationEnd,
String courseDescription, String thumbnailUrl, CourseVisibility visibility) {
this.courseName = courseName;
this.courseDistance = courseDistance;
this.courseTime = courseTime;
this.courseLevel = courseLevel;
this.locationStart = locationStart;
this.locationEnd = locationEnd;
this.courseDescription = courseDescription;
this.thumbnailUrl = thumbnailUrl;
this.courseVisibility = visibility;
}
// 증가
public void incrementLikeCnt(){
this.likeCnt++;
}
public void incrementViewCnt(){
this.viewCnt++;
}
public void incrementBookmarkCnt(){
this.bookmarkCnt++;
}
public void incrementCommentCnt(){
this.commentCnt++;
}
// 감소
public void decrementLikeCnt(){
if(this.likeCnt > 0){
this.likeCnt--;
}
}
public void decrementBookmarkCnt(){
if(this.bookmarkCnt > 0){
this.bookmarkCnt--;
}
}
public void decrementCommentCnt(){
if(this.commentCnt > 0){
this.commentCnt--;
}
}
// 공개 범위 변경
public void changeVisibility(CourseVisibility visibility){
this.courseVisibility = visibility;
}
// 공개 여부 확인
public boolean isPublic(){
return this.courseVisibility == CourseVisibility.PUBLIC;
}
public boolean isPrivate(){
return this.courseVisibility == CourseVisibility.PRIVATE;
}
public boolean isCrew(){
return this.courseVisibility == CourseVisibility.CREW;
}
}
엔티티 구조
- BaseEntity 를 상속하여 공통 필드 관리
- 기본 생성자는 PROTECTED 로 막아 의도하지 않은 생성 방지
User 연관관계
- 한 유저가 여러 개의 코스를 만들 수 있음
- 지연 로딩
- 코스 목록 조회 시 유저 정보가 불필요한 경우가 많음
필드
- 이름 : 길이 제한
- 설명 : TEXT 타입으로 길이 제한 없음
- 거리 : BigDecimal → 정확한 수치 계산을 위해 실수 대신 사용
- 시간 : 분 단위 정수
- 카운트 컬럼 : 생성 시 0 으로 초기화
- 연관 컬렉션 : 단방향 관리(역방향 탐색이 필요한 경우는 거의 없음), 코스 삭제 시 연관 데이터 자동 정리
메서드
- create()
- 기본 생성자를 외부에서 호출하지 못하도록 막아 정적 팩토리 메서드를 통해서만 생성되도록 설계
- 장점 : 필수 값 누락 방지 , 카운트 필드 초기화 강제
- update()
- setter 사용 x
- 변경 가능한 필드만 정의
- 카운트 증가/감소
- 엔티티에서 관리하는 이유 : 음수 방지, 서비스 계층의 책임 감
- 공개 범위
CourseLike,CourseBookmark,CourseComment 엔티티 생성
@Entity
@Table(
name = "course_like",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_course_like_user",
columnNames = {"course_id","user_id"}
)
},
indexes = {
@Index(name = "idx_course_like_course",columnList = "course_id"),
@Index(name = "idx_course_like_user", columnList = "user_id")
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CourseLike extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id",nullable = false)
private Course course;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id",nullable = false)
private User user;
// 좋아요 생성
public static CourseLike create(Course course, User user) {
CourseLike courseLike = new CourseLike();
courseLike.course = course;
courseLike.user = user;
course.incrementLikeCnt();
return courseLike;
}
// 좋아요 취소
public void cancelLike(){
this.course.decrementLikeCnt();
}
}
@Entity
@Table(
name = "course_bookmark",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_course_bookmark_user",
columnNames = {"course_id", "user_id"}
)
},
indexes = {
@Index(name = "idx_course_bookmark_course", columnList = "course_id"),
@Index(name = "idx_course_bookmark_user", columnList = "user_id")
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CourseBookmark extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id", nullable = false)
private Course course;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
// 북마크 생성
public static CourseBookmark create(Course course, User user) {
CourseBookmark courseBookmark = new CourseBookmark();
courseBookmark.course = course;
courseBookmark.user = user;
course.incrementBookmarkCnt();
return courseBookmark;
}
// 북마크 취소
public void cancelBookmark() {
this.course.decrementBookmarkCnt();
}
}
@Entity
@Table(name = "course_comment")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CourseComment extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id", nullable = false)
private Course course;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_comment_id")
private CourseComment parentComment;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
// 댓글 생성
public static CourseComment create(Course course, User user, CourseComment parentComment, String content) {
CourseComment comment = new CourseComment();
comment.course = course;
comment.user = user;
comment.content = content;
comment.parentComment = null;
course.incrementCommentCnt();
return comment;
}
// 대댓글 생성
public static CourseComment createNested(Course course, User user, String content,CourseComment parentComment) {
CourseComment comment = new CourseComment();
comment.course = course;
comment.user = user;
comment.content = content;
comment.parentComment = parentComment;
course.incrementCommentCnt();
return comment;
}
// 댓글 수정
public void update(String content) {
this.content = content;
}
// 댓글 논리 삭제
public void delete() {
this.deletedAt = LocalDateTime.now();
this.content = "삭제된 댓글입니다.";
this.course.decrementCommentCnt();
}
// 삭제 확인
public boolean isDeleted(){
return this.deletedAt != null;
}
}
CourseLike
- course_id,user_id 유니크 제약
- 한 사용자가 같은 코스에 중복 좋아요를 누를 수 없도록 DB 레벨에서 차단
- 조회 성능을 위한 인덱스 추가
- 코스별 좋아요 수 조회
- 사용자별 좋아요 여부 확인
CourseBookmark
- course_id,user_id 유니크 제약
- 한 사용자가 같은 코스에 중복 북마크를 누를 수 없도록 DB 레벨에서 차단
CourseComment
코스 리포지토리 생성
public interface CourseRepository extends JpaRepository<Course, UUID> {
Optional<Course> findByUuid(UUID uuid);
boolean existsByUuid(UUID uuid);
}
코스 서비스 생성
@Service
@Slf4j
@Transactional
@RequiredArgsConstructor
public class CourseService {
private final UserRepository userRepository;
private final CourseRepository courseRepository;
private final CourseMapper courseMapper;
// 코스 생성
public CourseDto create(UUID userUuid,CourseCreateRequest request) {
log.debug("코스 생성 시작 : userUuid={}, courseName={}", userUuid,request.courseName());
// 유저 찾기
User user = userRepository.findByUuid(userUuid)
.orElseThrow(()-> new BaseException(UserErrorCode.USER_NOT_FOUND));
// 코스 생성
Course course = Course.create(
user,
request.courseName(),
request.courseDescription(),
request.courseDistance(),
request.courseTime(),
request.courseLevel(),
request.locationStart(),
request.locationEnd(),
request.thumbnailUrl(),
request.courseVisibility()
);
// 코스 저장
Course savedCourse = courseRepository.save(course);
log.info("코스 생성 완료 : courseId={}", savedCourse.getId());
log.info("코스 상세 : name={}, description={}", request.courseName(), request.courseDescription());
// 반환
return courseMapper.toDto(savedCourse);
}
}
- 비즈니스 규칙은 엔티티
- 흐름 제어는 서비스
- 인증된 사용자 기반 User 조회
- Course 생성
- 저장 및 DTO 변환
코스 컨트롤러 생성
@RestController
@Slf4j
@RequestMapping("/course")
@RequiredArgsConstructor
@Validated
public class CourseController {
private final CourseService courseService;
@PostMapping
public ResponseEntity<ApiResponse<CourseDto>> createCourse(
@AuthenticationPrincipal CustomUserDetails customUserDetails,
@Valid @RequestBody CourseCreateRequest courseCreateRequest
){
CourseDto courseDto = courseService.create(customUserDetails.getUserUuid(), courseCreateRequest);
return ResponseEntity.status(HttpStatus.CREATED)
.body(new ApiResponse<>("코스가 생성되었습니다.", courseDto));
}
}
CourseDto,CourseCreateRequest 생성
public record CourseDto(
UUID courseUuid,
String courseName,
String courseDescription,
BigDecimal courseDistance,
Integer courseTime,
CourseLevel courseLevel,
String locationStart,
String locationEnd,
String thumbnailUrl,
CourseVisibility courseVisibility
) {
}
public record CourseCreateRequest(
@NotBlank(message = "제목은 필수입니다.")
@Size(max = 50, message = "제목은 50자 이하여야 합니다.")
String courseName,
@NotBlank(message = "설명은 필수입니다.")
String courseDescription,
@NotNull(message = "거리는 필수입니다.")
@DecimalMin(value = "0.01", message = "거리는 0보다 커야 합니다.")
BigDecimal courseDistance,
@NotNull(message = "시간은 필수입니다.")
@Min(value = 1, message = "시간은 1초 이상이어야 합니다.")
Integer courseTime,
@NotNull(message = "코스 레벨은 필수입니다.")
CourseLevel courseLevel,
@NotBlank(message = "시작위치는 필수입니다.")
String locationStart,
@NotBlank(message = "종료위치는 필수입니다.")
String locationEnd,
String thumbnailUrl,
@NotNull(message = "공개범위는 필수입니다.")
CourseVisibility courseVisibility
) {
}
CourseMapper 생성
@Mapper(componentModel = "spring")
public interface CourseMapper {
@Mapping(target = "courseUuid",source = "uuid")
CourseDto toDto(Course course);
}
📝 테스트