런닝 코스 공유 서비스

[런닝 코스 공유 서비스] - 21. 코스 도메인 및 등록 API 개발

sson-coding 2026. 1. 15. 20:46

🏅오늘의 목표

  • 코스 도메인 개발
  • 코스 등록 API 개발

진행한 작업

  • 코스 엔티티 생성
  • 코스 리포지토리 생성
  • 코스 서비스 생성
  • 코스 등록 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);
}
  • pk(id) 노출 대신 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);
}

📝 테스트