본문 바로가기

카테고리 없음

23.12.22 스프링 5

https://docs.spring.io/spring-data/jpa/docs/current/api/org/springframework/data/jpa/repository/JpaRepository.html

에 들어가서 CrudRepository를 클릭하면, 쓸수 있는 함수들을 볼수있다.

 

자세히 보면, `findAll()` 을 통해 모든 데이터를 가져올 수 있고, `findAllById` 를 통해 특정 ID 목록에 해당하는 Entity 목록을 가져올 수 있는걸 알 수 있다. `SELECT * FROM ~` , `SELECT * FROM ~ WHERE id IN ()` 과 동일하다.예시는 
    

    val allCourses = courseRepository.findAll()
    val specificCourses = courseRepository.findAllById(listOf(1L, 2L, 3L))
 
    
그 다음, `findById()` 가 보인다. id에 기반해 한개의 데이터를 리턴한다. 그리고 이건 굉장히 많이 쓰이는 함수이다.

그런데, Optional<T> findById(ID id) 즉, Optional<T>` 로 되어있는데, Kotlin에서는 굳이 Optional을 쓸 이유가 없다.


이 때문에, Spring Boot 2.1.2 버전부터 `findByIdOrNull` 함수를 추가로 지원한다. 이것은 null 일시 exception을 던질 수 있고, 이후의 코드에서는 course가 null이 아니라는 걸 보장할 수 있다.
    
    val course = courseRepository.findByIdOrNull(1L) ?: throw ModelNotFoundException("Course", 1L)

    
-그리고, `save()` 와 `saveAll()` 은 Entity를 추가, 혹은 수정을 한다. 영속성 컨텍스트에 등록하여 변경을 감지하고, 트랜잭션이 종료될 때 변경사항을 데이터베이스에 반영한다.

실제 구현부를 보면, 새로운 entity 면 `em.persist()` , 그렇지 않은 상태(detached)면 `em.merge()` 를 호출하는 걸 볼 수 있다. 우리는 이렇게 EntityManager를 직접 쓰지 않고도, EntityManager의 기능을 추상화된 Repository 라는 인터페이스를 통해 쉽게 쓸 수 있다
    

    public <S extends T> S save(S entity) {
        if (this.entityInformation.isNew(entity)) {
            this.em.persist(entity);
            return entity;
        } else {
            return this.em.merge(entity);
        }
    }

    
마지막으로, 삭제를 담당하는 `delete()`, `deleteAll()` 도  데이터의 존재 여부를 확인 하는`existsById()` Method도 볼 수 있다.

 

Repository 구현하기

course 패키지 하위에 repository 를 추가한후, CourseRepository Interface를 작성, JpaRepository<Course, Long> 을 상속받으면 됨. 그걸위해 3개를 작성해야함.

JpaRepository의 명세를 보면, JpaRepository<T, ID> 형태로 되어있으므로, 앞에는 Entity의 type, 뒤에는 id의 Type을 설정하면 된다.

 

import com.example.courseregistration.domain.course.model.Course
import org.springframework.data.jpa.repository.JpaRepository

interface CourseRepository: JpaRepository<Course, Long> {

}

이제 이걸 interface CourseRepository: JpaRepository<Course, Long> 색칠 부분만 바꿔서 유저, 코스어플,렉쳐에 추가하면 된다.

 

sql함수 있는 사이트

https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html

 

JPA Query Methods :: Spring Data JPA

As of Spring Data JPA release 1.4, we support the usage of restricted SpEL template expressions in manually defined queries that are defined with @Query. Upon the query being run, these expressions are evaluated against a predefined set of variables. Sprin

docs.spring.io

 

. find 는 SELECT 에 해당하고, By 는 WHERE. 에를 들자면,

findDistinctByLastnameAndFirstname select distinct …​ where x.lastname = ?1 and x.firstname = ?2

 

query를 직접 작성하여 Method에 맵핑

 

항상 별칭을 사용해야하고, 대소문자를 구분하기에 Table name대신에 대문자로 시작하는 Entity name을 사용해야합니다. 예를들어 select * from post 형태가 아닌, select p from Post p 를 사용합니다. 
query에 변수를 넣어줄 때에는, : 을 앞에 붙여 사용하고, @Param Annotation을 사용하여 해당 변수와 맵핑되는 Argument를 지정해줍니다. 위의 Method 이름을 통해 작성한 쿼리는 아래 처럼 표현될 수 있습니다.
interface PostRepository: JpaRepository<Post, Long> {

@Query("select p from Post p where p.title like %:title%)
fun searchByTitle(@Param("title") keyword: String): List<Post>
}

 

val course = courseRepository.findByIdOrNull(1L) ?: throw ModelNotFoundException("Course", 1L)
val courseResponse = CourseResponse(
	  id = course.id!!,
	  title = course.title,
	  description = course.description,
	  status = course.status.name,
	  maxApplicants = course.maxApplicants,
	  numApplicants = course.numApplicants
)
return courseResponse

  • 하지만, 매번 이렇게 변환을 하기에는 코드의 중복이 너무 많아집니다. 우리는 이를 위해, 변환하는 함수를 작성해 줄 수 있습니다. Course Class 하위에 아래처럼 Extension Function을 추가해줄 수 있습니다! DTO로 변환하는 것은 Model이 가지는 필수적인 도메인 로직이 아니기 때문에, Extension Function을 활용하면 좋습니다. 

 

  • fun Course.toResponse(): CourseResponse { return CourseResponse( id = id!!, title = title, description = description, status = status.name, maxApplicants = maxApplicants, numApplicants = numApplicants ) }

최종적으로, 처음에 작성한 코드는 아래처럼 단순화됩니다!

  • val course = courseRepository.findByIdOrNull(1L) ?: throw ModelNotFoundException("Course", 1L) return course.toResponse()

작성한 코드의 최종형태

 

코스

@Entity
@Table(name = "course")
class Course(
    @Column(name = "title", nullable = false)
    var title: String,

    @Column(name = "description")
    var description: String? = null,

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    var status: CourseStatus = CourseStatus.OPEN,

    @Column(name = "max_applicants", nullable = false)
    val maxApplicants: Int = 30,

    @Column(name = "num_applicants", nullable = false)
    var numApplicants: Int = 0,

    @OneToMany(mappedBy = "course", cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.LAZY)
    var lectures: MutableList<Lecture> = mutableListOf(),

    @OneToMany(mappedBy = "course", cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.LAZY)
    var courseApplications: MutableList<CourseApplication> = mutableListOf()
) {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    fun isFull(): Boolean {
        return numApplicants >= maxApplicants
    }

    fun isClosed(): Boolean {
        return status == CourseStatus.CLOSED
    }

    fun close() {
        status = CourseStatus.CLOSED
    }

    fun addApplicant() {
        numApplicants += 1
    }

    fun addLecture(lecture: Lecture) {
        lectures.add(lecture)
    }

    fun removeLecture(lecture: Lecture) {
        lectures.remove(lecture)
    }

    fun addCourseApplication(courseApplication: CourseApplication) {
        courseApplications.add(courseApplication)
    }

}

fun Course.toResponse(): CourseResponse {
    return CourseResponse(
        id = id!!,
        title = title,
        description = description,
        status = status.name,
        maxApplicants = maxApplicants,
        numApplicants = numApplicants
    )
}

 

코스리포짓

interface CourseRepository: JpaRepository<Course, Long> {

}

 

유저

@Entity
@Table(name = "app_user")
class User(
    @Column(name = "email", nullable = false)
    val email: String,

    @Column(name = "password", nullable = false)
    val password: String,

    @Embedded
    var profile: Profile,

    @Enumerated(EnumType.STRING)
    @Column(name = "role", nullable = false)
    val role: UserRole,

    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.LAZY)
    val courseApplications: MutableList<CourseApplication> = mutableListOf()
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
}

fun User.toResponse(): UserResponse {
    return UserResponse(
        id = id!!,
        nickname = profile.nickname,
        email = email,
        role = role.name
    )
}

 

코스어플

@Entity
@Table(name = "course_application")
class CourseApplication(
    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    var status: CourseApplicationStatus = CourseApplicationStatus.PENDING,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "course_id")
    val course: Course,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    val user: User
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    fun isProceeded(): Boolean {
        return status != CourseApplicationStatus.PENDING
    }

    fun accept() {
        status = CourseApplicationStatus.ACCEPTED
    }

    fun reject() {
        status = CourseApplicationStatus.REJECTED
    }
}

fun CourseApplication.toResponse(): CourseApplicationResponse {
    return CourseApplicationResponse(
        id = id!!,
        course = course.toResponse(),
        user = user.toResponse(),
        status = status.name
    )
}

코스어플리포짓

interface CourseApplicationRepository: JpaRepository<CourseApplication, Long> {

    fun existsByCourseIdAndUserId(courseId: Long, userId: Long): Boolean

    fun findByCourseIdAndId(courseId: Long, id: Long): CourseApplication?
}

 

유저

@Entity
@Table(name = "app_user")
class User(
    @Column(name = "email", nullable = false)
    val email: String,

    @Column(name = "password", nullable = false)
    val password: String,

    @Embedded
    var profile: Profile,

    @Enumerated(EnumType.STRING)
    @Column(name = "role", nullable = false)
    val role: UserRole,

    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.LAZY)
    val courseApplications: MutableList<CourseApplication> = mutableListOf()
) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
}

fun User.toResponse(): UserResponse {
    return UserResponse(
        id = id!!,
        nickname = profile.nickname,
        email = email,
        role = role.name
    )
}

 

 

유저서비스임플

@Service
class UserServiceImpl(
    private val userRepository: UserRepository
): UserService {

    @Transactional
    override fun signUp(request: SignUpRequest): UserResponse {
        if (userRepository.existsByEmail(request.email)) {
            throw IllegalStateException("Email is already in use")
        }

        return userRepository.save(
            User(
                email = request.email,
                // TODO: 비밀번호 암호화
                password = request.password,
                profile = Profile(
                    nickname = request.nickname
                ),
                role = when (request.role) {
                    UserRole.STUDENT.name -> UserRole.STUDENT
                    UserRole.TUTOR.name -> UserRole.TUTOR
                    else -> throw IllegalArgumentException("Invalid role")
                }
            )
        ).toResponse()
    }

    @Transactional
    override fun updateUserProfile(userId: Long, request: UpdateUserProfileRequest): UserResponse {
        val user = userRepository.findByIdOrNull(userId) ?: throw ModelNotFoundException("User", userId)
        user.profile = Profile(
            nickname = request.nickname
        )

        return userRepository.save(user).toResponse()
    }

}

 

유저리포짓

interface UserRepository: JpaRepository<User, Long> {

    fun existsByEmail(email: String): Boolean
}

 

 

렉쳐

@Entity
@Table(name = "lecture")
class Lecture(
    @Column(name = "title", nullable = false)
    var title: String,

    @Column(name = "video_url", nullable = false)
    var videoUrl: String,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "course_id")
    var course: Course
) {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

}

fun Lecture.toResponse(): LectureResponse {
    return LectureResponse(
        id = id!!,
        title = title,
        videoUrl = videoUrl,
    )
}

 

렉쳐리포짓

interface LectureRepository: JpaRepository<Lecture, Long> {

    fun findByCourseIdAndId(courseId: Long, lectureId: Long): Lecture?
}

코스서비스임플

@Service
class CourseServiceImpl(
    private val courseRepository: CourseRepository,
    private val lectureRepository: LectureRepository,
    private val courseApplicationRepository: CourseApplicationRepository,
    private val userRepository: UserRepository,
) : CourseService {
    override fun getAllCourseList(): List<CourseResponse> {
        return courseRepository.findAll().map { it.toResponse() }
    }

    override fun getCourseById(courseId: Long): CourseResponse {
        val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)
        return course.toResponse()
    }

    @Transactional
    override fun createCourse(request: CreateCourseRequest): CourseResponse {
        return courseRepository.save(
            Course(
                title = request.title,
                description = request.description,
                status = CourseStatus.OPEN,
            )
        ).toResponse()
    }

    @Transactional
    override fun updateCourse(courseId: Long, request: UpdateCourseRequest): CourseResponse {
        val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)
        val (title, description) = request

        course.title = title
        course.description = description

        return courseRepository.save(course).toResponse()
    }

    @Transactional
    override fun deleteCourse(courseId: Long) {
        val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)
        courseRepository.delete(course)
    }

    @Transactional
    override fun addLecture(courseId: Long, request: AddLectureRequest): LectureResponse {
        val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)

        val lecture = Lecture(
            title = request.title,
            videoUrl = request.videoUrl,
            course = course
        )
        // Course에 Lecture 추가
        course.addLecture(lecture)
        // Lecture에 영속성을 전파
        courseRepository.save(course)
        return lecture.toResponse()
    }

    override fun getLecture(courseId: Long, lectureId: Long): LectureResponse {
        val lecture = lectureRepository.findByCourseIdAndId(courseId, lectureId)
            ?: throw ModelNotFoundException("Lecture", lectureId)

        return lecture.toResponse()
    }

    override fun getLectureList(courseId: Long): List<LectureResponse> {
        val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)
        return course.lectures.map { it.toResponse() }
    }

    @Transactional
    override fun updateLecture(
        courseId: Long,
        lectureId: Long,
        request: UpdateLectureRequest
    ): LectureResponse {
        val lecture = lectureRepository.findByCourseIdAndId(courseId, lectureId)
            ?: throw ModelNotFoundException("Lecture", lectureId)

        val (title, videoUrl) = request
        lecture.title = title
        lecture.videoUrl = videoUrl

        return lectureRepository.save(lecture).toResponse()
    }

    @Transactional
    override fun removeLecture(courseId: Long, lectureId: Long) {
        val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)
        val lecture = lectureRepository.findByIdOrNull(lectureId)
            ?: throw ModelNotFoundException("Lecture", lectureId)

        course.removeLecture(lecture)

        // Lecture에 영속성을 전파
        courseRepository.save(course)
    }

    @Transactional
    override fun applyCourse(courseId: Long, request: ApplyCourseRequest): CourseApplicationResponse {
        val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)
        val user = userRepository.findByIdOrNull(request.userId)
            ?: throw ModelNotFoundException("User", request.userId)

        // Course 마감여부 체크
        if (course.isClosed()) {
            throw IllegalStateException("Course is already closed. courseId: $courseId")
        }

        // CourseApplication 중복 체크
        if (courseApplicationRepository.existsByCourseIdAndUserId(courseId, request.userId)) {
            throw IllegalStateException("Already applied. courseId: $courseId, userId: ${request.userId}")
        }

        val courseApplication = CourseApplication(
            course = course,
            user = user,
        )
        course.addCourseApplication(courseApplication)
        // CourseApplication에 영속성을 전파
        courseRepository.save(course)

        return courseApplication.toResponse()
    }

    override fun getCourseApplication(courseId: Long, applicationId: Long): CourseApplicationResponse {
        val application = courseApplicationRepository.findByCourseIdAndId(courseId, applicationId)
            ?: throw ModelNotFoundException("CourseApplication", applicationId)

        return application.toResponse()
    }

    override fun getCourseApplicationList(courseId: Long): List<CourseApplicationResponse> {
        val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)

        return course.courseApplications.map { it.toResponse() }
    }

    @Transactional
    override fun updateCourseApplicationStatus(
        courseId: Long,
        applicationId: Long,
        request: UpdateApplicationStatusRequest
    ): CourseApplicationResponse {
        val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)
        val application = courseApplicationRepository.findByCourseIdAndId(courseId, applicationId)
            ?: throw ModelNotFoundException("CourseApplication", applicationId)

        // 이미 승인 혹은 거절된 신청건인지 체크
        if (application.isProceeded()) {
            throw IllegalStateException("Application is already proceeded. applicationId: $applicationId")
        }

        // Course 마감여부 체크
        if (course.isClosed()) {
            throw IllegalStateException("Course is already closed. courseId: $courseId")
        }

        // 승인 / 거절 따른 처리
        when (request.status) {
            // 승인 일때
            CourseApplicationStatus.ACCEPTED.name -> {
                // 승인 처리
                application.accept()
                // Course의 신청 인원 늘려줌
                course.addApplicant()
                // 만약 신청 인원이 꽉 찬다면 마감 처리
                if (course.isFull()) {
                    course.close()
                }
                courseRepository.save(course)
            }

            // 거절 일때
            CourseApplicationStatus.REJECTED.name -> {
                // 거절 처리
                application.reject()
            }
            // 승인 거절이 아닌 다른 인자가 들어올 경우 에러 처리
            else -> {
                throw IllegalArgumentException("Invalid status: ${request.status}")
            }
        }

        return courseApplicationRepository.save(application).toResponse()
    }
}

 

테스트하는법

스웨거가서 포스트에 트라이 누르고 아무거나 입력

 

쿼리 확인하기

application.yml 의 spring 밑에 

jpa:
  properties:
    hibernate:
      show_sql: true

이런거 넣고 다시 스웹거에서 입력하면

Hibernate: insert into course (description,max_applicants,num_applicants,status,title,id) values (?,?,?,?,?,default)

이런게 나오면 된것

 

  • 일대다 단방향
    • 이제, 단방향은 @OneToMany 단방향을 할 것인가, @ManyToOne 단방향을 할 것 인가를 선택해야합니다. 먼저, @OneToMany 단방향 형태로 수정
    • Course를 아래처럼 수정합니다. mappedBy 를 삭제하고, JPA에게 어떤 Column을 통해 관계가 성립되는지 알려주기 위해 @JoinColumn 을 붙여줍니다!
@Entity
@Table(name = "course")
class Course(
    @Column(name = "title", nullable = false)
    var title: String,

    @Column(name = "description")
    var description: String? = null,

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    var status: CourseStatus = CourseStatus.OPEN,

    @Column(name = "max_applicants", nullable = false)
    val maxApplicants: Int = 30,

    @Column(name = "num_applicants", nullable = false)
    var numApplicants: Int = 0,

    @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.LAZY)
    @JoinColumn(name = "course_id")
    var lectures: MutableList<Lecture> = mutableListOf(),

    @OneToMany(mappedBy = "course", cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.LAZY)
    var courseApplications: MutableList<CourseApplication> = mutableListOf()
)
  • Lecture에서는 관계를 제거해 줍니다.

 

@Entity
@Table(name = "lecture")
class Lecture(
    @Column(name = "title", nullable = false)
    var title: String,

    @Column(name = "video_url", nullable = false)
    var videoUrl: String,

//    @ManyToOne(fetch = FetchType.LAZY)
//    @JoinColumn(name = "course_id")
//    var course: Course
)
  • 관련 코드도 수정해줘야겠죠? CourseServiceImpl 의 addLecture 메소드를 먼저 수정해주세요. Lecture를 생성할때 course를 명시할 필요가 없겠죠?

 

@Transactional
    override fun addLecture(courseId: Long, addLectureRequest: AddLectureRequest): LectureResponse {
        val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)

        val lecture = Lecture(
            title = addLectureRequest.title,
            videoUrl = addLectureRequest.videoUrl,
            //course = course 
        )
        // Course에 Lecture 추가
        course.addLecture(lecture)
        // Lecture에 영속성을 전파
        courseRepository.save(course)
        return lecture.toResponse()
    }
  • 그리고 돌리면!! 정상실행이 되지 않습니다. 왜 그럴까요? getLecture와 updateLecture에서 courseId와 lectureId 기반으로 lecture를 가져오는데, JPA는 이제 lecture가 courseId를 들고 있다는 걸 모르기 때문이죠! LectureRepository의 findByCourseIdAndId 를 주석처리 해주시고, findByCourseIdAndId 를 findByIdOrNull(lectureId) 로 변경해주세요. 
package com.teamsparta.courseregistration.domain.lecture.repository

import com.teamsparta.courseregistration.domain.lecture.model.Lecture
import org.springframework.data.jpa.repository.JpaRepository

interface LectureRepository: JpaRepository<Lecture, Long> {

    //fun findByCourseIdAndId(courseId: Long, lectureId: Long): Lecture?
}
  • override fun getLecture(courseId: Long, lectureId: Long): LectureResponse {
        val lecture = lectureRepository.findByIdOrNull(lectureId)
            ?: throw ModelNotFoundException("Lecture", lectureId)
    
        return lecture.toResponse()
    }
    
    @Transactional
    override fun updateLecture(
        courseId: Long,
        lectureId: Long,
        updateLectureRequest: UpdateLectureRequest
    ): LectureResponse {
        val lecture = lectureRepository.findByIdOrNull(lectureId)
            ?: throw ModelNotFoundException("Lecture", lectureId)
    
        val (title, videoUrl) = updateLectureRequest
        lecture.title = title
        lecture.videoUrl = videoUrl
    
        return lectureRepository.save(lecture).toResponse()
    }
    
  •  
  • 자, 이제 다시 lecture를 추가하면? 차이가 보이시나요? lecture에 title, video_url을 추가한 후, 이어서 update를 통해 course_id를 추가하는 걸 볼 수 있어요. 처음 insert될 때에는 course_id가 NULL로 설정이 됐다가 이후에 관계가 설정되는거죠!
select
    l1_0.course_id,
    l1_0.id,
    l1_0.title,
    l1_0.video_url 
from
    lecture l1_0 
where
    l1_0.course_id=?


insert 
into
    lecture
    (title,video_url) 
values
    (?,?)


update
    lecture 
set
    course_id=? 
where
    id=?

 

 

 

  • 다대일 단방향
    • 이제 반대로, 다대일 단방향, 즉 Lecture에서 Course의 연관관계를 갖게끔 설계를 해봅시다!
    • 먼저, Lecture측에 Course를 다시 설정해줍니다.
package com.teamsparta.courseregistration.domain.lecture.model

import com.teamsparta.courseregistration.domain.course.model.Course
import com.teamsparta.courseregistration.domain.lecture.dto.LectureResponse
import jakarta.persistence.*

@Entity
@Table(name = "lecture")
class Lecture(
    @Column(name = "title", nullable = false)
    var title: String,

    @Column(name = "video_url", nullable = false)
    var videoUrl: String,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "course_id")
    var course: Course
)

 

Course에서는 Lecture와의 관계를 제거해주시고, 관련 함수도 제거해주세요

 

package com.teamsparta.courseregistration.domain.course.model

import com.teamsparta.courseregistration.domain.course.dto.CourseResponse
import com.teamsparta.courseregistration.domain.courseapplication.model.CourseApplication
import jakarta.persistence.*

@Entity
@Table(name = "course")
class Course(
    @Column(name = "title", nullable = false)
    var title: String,

    @Column(name = "description")
    var description: String? = null,

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    var status: CourseStatus = CourseStatus.OPEN,

    @Column(name = "max_applicants", nullable = false)
    val maxApplicants: Int = 30,

    @Column(name = "num_applicants", nullable = false)
    var numApplicants: Int = 0,

//    @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.LAZY)
//    @JoinColumn(name = "course_id")
//    var lectures: MutableList<Lecture> = mutableListOf(),

    @OneToMany(mappedBy = "course", cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.LAZY)
    var courseApplications: MutableList<CourseApplication> = mutableListOf()
) {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    fun isFull(): Boolean {
        return numApplicants >= maxApplicants
    }

    fun isClosed(): Boolean {
        return status == CourseStatus.CLOSED
    }

    fun close() {
        status = CourseStatus.CLOSED
    }

    fun addApplicant() {
        numApplicants += 1
    }

//    fun addLecture(lecture: Lecture) {
//        lectures.add(lecture)
//    }
//
//    fun removeLecture(lecture: Lecture) {
//        lectures.remove(lecture)
//    }

    fun addCourseApplication(courseApplication: CourseApplication) {
        courseApplications.add(courseApplication)
    }

}

fun Course.toResponse(): CourseResponse {
    return CourseResponse(
        id = id!!,
        title = title,
        description = description,
        status = status.name,
        maxApplicants = maxApplicants,
        numApplicants = numApplicants
    )
}

 

  • CourseServiceImpl 쪽에 에러가 많이 보일텐데요, 이를 수정해줍시다! 먼저, addLecture method에서는 이제 Course를 통한 영속성 전파가 아니라, lectureRepository를 이용한 저장을 해줘야합니다.
@Transactional
override fun addLecture(courseId: Long, addLectureRequest: AddLectureRequest): LectureResponse {
    val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)

    val lecture = Lecture(
        title = addLectureRequest.title,
        videoUrl = addLectureRequest.videoUrl,
        course = course
    )
    return lectureRepository.save(lecture).toResponse()
}
  • getLectureList 에서는, 더 이상 course를 통한 lecture의 접근을 할 수 없어요. 그러므로, LectureRepository 에 coureId로 lecture 목록을 가져오는 인터페이스를 작성해서 사용해줘야합니다.
    package com.teamsparta.courseregistration.domain.lecture.repository
    
    import com.teamsparta.courseregistration.domain.lecture.model.Lecture
    import org.springframework.data.jpa.repository.JpaRepository
    
    interface LectureRepository: JpaRepository<Lecture, Long> {
    
        //fun findByCourseIdAndId(courseId: Long, lectureId: Long): Lecture?
    
        fun findAllByCourseId(courseId: Long): List<Lecture>
    }
    
override fun getLectureList(courseId: Long): List<LectureResponse> {
    return lectureRepository.findAllByCourseId(courseId).map { it.toResponse() }
}

 

package com.teamsparta.courseregistration.domain.lecture.repository

import com.teamsparta.courseregistration.domain.lecture.model.Lecture
import org.springframework.data.jpa.repository.JpaRepository

interface LectureRepository: JpaRepository<Lecture, Long> {

    //fun findByCourseIdAndId(courseId: Long, lectureId: Long): Lecture?

    fun findAllByCourseId(courseId: Long): List<Lecture>
}
  • 이렇게 설정 후 재실행을 한 다음, Lecture 추가 API를 요청하면? 아래와 같이 select와 insert가 실행되는걸 볼 수 있어요!
 select
    c1_0.id,
    c1_0.description,
    c1_0.max_applicants,
    c1_0.num_applicants,
    c1_0.status,
    c1_0.title 
from
    course c1_0 
where
    c1_0.id=?


insert 
into
    lecture
    (course_id,title,video_url) 
values
    (?,?,?)

 

  • 대다(OneToMany) 단방향 관계
    • 장점
      • 자식이 부모의 정보를 몰라도 된다면, 객체 지향적 관점에서 명확한 구조가 됩니다.
    • 단점
      • update쿼리가 추가적으로 발생됩니다.
      • 외래키(Foreign Key)에 NOT NULL 과 같은 제약조건이 걸려있다면 실패합니다. (update 이전에 NULL 값으로 외래키를 insert 하므로)
    • 개발시 주의점
      • @JoinColumn 을 필수로 입력해줘야합니다.
  • 다대일(ManyToOne) 단방향 관계
    • 장점
      • 외래키의 관리 주체가 데이터베이스와 일치하여, 데이터 일관성이 높아집니다.
    • 단점
      • 역방향으로 연관된 Entity에 대한 참조가 없어, 역방향으로 접근하려면 별도의 쿼리가 필요합니다.
    • 개발시 주의점
      • @JoinColumn 을 필수로 입력해줘야합니다.
  • 양방향 관계
  •  장점
    • 두 Entity 간의 관계를 양측에서 접근할 수 있어, 쿼리 작성이 유연해집니다.
    • 단점
      • 양방향을 모두 신경써야 하므로 관리해야할 부분이 더 많습니다.
    • 개발시 주의점
      • 연관관계의 주인을 필수로 표시해줘야합니다. (mappedBy)
      • 자식 Entity 저장시 부모 Entity까지 모두 입력해줘야합니다. (Kotlin을 사용하면서, nullable하지 않게 부모(e.g. Course)를 표기한다면, 컴파일러가 이런 부분을 알려줍니다. null-safe하지 않은 Java는 매우 주의해야하는 점이에요)

 

  • 쿠키(Cookie)
    • 이름, 값, 유효 시간, 도메인, 경로 정보가 포함됩니다.
    • 서버에서 전송이 되어 클라이언트에 저장되고, 이후 같은 서버에서 요청이 있을 때마다 자동적으로 서버에 전송됩니다.
  • 세션 (Session)
    • 클라이언트에게 고유한 세션 ID를 부여하여 클라이언트 정보를 관리합니다. 이 때, 세션 ID를 부여하기 위해 일반적으로 쿠키를 사용합니다.
    • 서버는 세션 ID 외의 클라이언트 정보를 서버 측 메모리나 별도 저장소를 이용하여 저장합니다.

이둘의 공통 단점은, 확장성이 좋지않습니다. 사용자가 많아지면, 보통 서버를 한 대가 아니라 여러 대를 두게 됩니다. 세션 정보는 보통 서버 애플리케이션 측 메모리에 저장하는데요, 이때 클라이언트가 기존에 요청하던 서버가 아닌 다른 서버에 요청하면 해당 서버는 클라이언트의 세션 ID가 없다고 판단하게 됩니다.

 

토큰(Token) 기반 인증

  • 인증정보를 서버측에서 저장하고 있지 않고, 토큰이라는 데이터에 저장하여, 클라이언트가 토큰을 갖고 요청을 하게끔 하는 방식입니다.

 

 

 

 

 

단점은  보안상 취약

 

  • egistered Claims
    • 토큰 자체의 정보를 담은 Claim으로, 필수는 아니지만 Payload에 포함하기 권장되는 claim 들입니다. 아래 Claim들이 존재합니다.
      • iss : Issuer, JWT의 발급 주체를 표기합니다. 예를들어, [sparta.com](<http://sparta.com>) 같은 형태가 될 수 있습니다!
      • sub : Subject, JWT의 대상 주체, 즉 유저를 표기합니다. 유저의 ID등이 될 수 있겠죠!
      • aud : Audience, 토큰을 받을 대상 그룹입니다. sub 와 헷갈릴수 있는데요,. 발급주체가 동일하더라도 해당 토큰이 어디서 쓰일지는 다를 수 있겠죠! 예를 들어, [auth.sparta.com](<http://auth.sparta.com>) 이라는 issuer에서 토큰을 발급하더라도, [course.sparta.com](<http://course.sparta.com>) 대상으로 발급을 할 수 도 있고, [admin.sparta.com](<http://admin.sparta.com>) 으로 발급을 할 수 도 있습니다. 보통 여러 시스템에 걸쳐 토큰을 사용할 수 있거나 한 시스템 내에서 토큰 별로 API 혹은 Resource에 대해 권한이 나뉘는, 큰 시스템에서 사용합니다.
      • exp : Expiration time, JWT가 만료되는 시간입니다. timestamp 형태로 표현합니다.
      • nbf : Not before time, 해당 시간 전에는 JWT가 사용되어서는 안됩니다. 잘 사용하지 않습니다. 예약작업 등에 사용할 수 있겠죠!
      • iat : Issued at time, JWT가 발급된 시간입니다.
      • jti : JWT ID, Uniquie Identifier 즉 식별자로, 매번 랜덤으로 해당 jti를 생성하여 동일한 정보 이더라도 생성할때마다 다른 JWT가 나오도록 하는 역할입니다. Replay Attack(보내는 메시지를 가로채 동일한 메시지를 다시 보내는 방식)을 방지하기 위해 쓰입니다.
      • 일반적으로, iss, sub, exp 와 iat 둘중 하나를 사용하고, jti를 추가하기도 합니다. 나머지는 시스템 요구사항에 따라 선택적으로 사용합니다.
  • Custom Claims
    • 필요에 의해 JWT에 추가적으로 담는 claim 입니다. email 정보, roles (권한) 정보등을 필요에 따라 추가할 수 있습니다. 비밀번호 같은 민감정보는 담기면 안돕니다.
  • 서명(Signature) = 암호화
  • 대칭키(Sercret Key, Symmetric Key) : 한 개의 키로 암호화 및 복호화가 가능할때, 이를 대칭키라 합니다.
  • 비대칭키 (Asymmetric Key) : 공개키의 경우 개인키(Private Key)와 공개키(Public Key)로 구성됩니다. 대칭키와 다르게, 한 키로 서명을 하면, 다른 한키로 복호화를 할 수 있습니다. 이 특성을 이용해 두 가지를 할 수 있습니다.
    • 공개키 암호화: 철수가 공개키를 다른 영희에게 주고, “나한테 메시지를 보낼때는 이 공개키로 암호화를 해서 보내!” 라고 한 후 개인키를 철수만이 알도록 보관을 한다면, 영희가 보내는 메시지(암호문)는 철수가 가진 키로만 복호화를 할 수 있으므로 철수만이 영희가 쓴 원본 메시지를 볼 수 있습니다.
    • 서명: 철수가 메시지와 개인키로 해당 메시지를 암호화한 암호문을 영희에게 보내면서, “앞으로 메시지를 받을 때에는 공개키를 이용해서 내가 보낸 암호문을 복호화 하고, 내가 보낸 메시지와 정말로 일치하는지 확인해봐!” 라고 할 수 있습니다. 메시지가 서로 일치하면, 영희는 이 메시지가 철수가 보낸게 맞구나!를 확인할 수 있습니다.