꿈틀꿈틀 개발일기

Todolist - 개인과제

by jeongminy

 

 

 

언제부터 였는지 모르겠지만 2024년 새 해가 밝았음에도,,
오늘은 그저 나의 휴일 일 뿐..ㅎㅎ 감흥이 없다 ㅎ

그래도 남들 하듯이 올해는 좋은 일만 생기길 기원 해보면서..

프로젝트를 만들어 왔던 과정을
순서대로 정리해 봤다.

 

 

 

 

 

 

 Github - todolist

 

https://github.com/jeongminy/todolist

 

GitHub - jeongminy/todolist

Contribute to jeongminy/todolist development by creating an account on GitHub.

github.com

 

 

 

 프로젝트 생성


1.  java17, gradle-kotlin, Spring Boot 3.1.5버전, Spring Configuration Processor, Spring Web

2. swagger설치(API문서화도구)

implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") //swager


swagger는 아래 주소에서 확인 할 수 있다.
http://localhost:8080/swagger-ui/index.html


3. swagger 이름 변경

package com.teamsparta.courseregistration.infra.swagger

import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class SwaggerConfig {

    @Bean
    fun openAPI(): OpenAPI = OpenAPI()
        .components(Components())
        .info(
            Info()
                .title("Todolist API")
                .description("Todolist API")
                .version("1.0.0")
        )
}



 DTO


4. 크게 todocard, comment, user가 있고 하위 패키지로 각각 controller, dto, model, repository, service를 가짐

5. comment dto 안에, commentResponse, CreateCommentRequest, UpdateCommentRequest를 넣고,
controller 안에, CommentController를 넣는다.
(비슷한 개념을 todocard에도 적용)

 

 Controller


6. user dto에는 SignUpRequest, UpdateUserProfileRequest, UserResponse를 넣고,
controller에는 UserController를 작성한다.

 

 Service Layer


7. todocardService를 interface로 작성

8. todocardServiceImpl 로 실제 구현부를 작성한다

9. Service와 Controller를 연결해야함.
controller에 생성자주입을 한다.

class TodocardController(
    private val todocardService: TodocardService
)



10. Controller아래의 메소드들에 대해 return을 추가해준다.

@GetMapping
fun getTodocardList(): ResponseEntity<List<TodocardResponse>>{
    return ResponseEntity
        .status(HttpStatus.OK)
        .body(todocardService.getAllTodocardList())
    TODO()
}



11.
생성은 HttpStatus.CREATED

삭제는 ResponseEntity
    .status(HttpStatus.NO_CONTENT)
    .build()

반환타입은 : ResponseEntity<Unit>

 

 

 예외처리


12. exception패키지 생성> ModelNotFoundException클래스 생성

data class ModelNotFoundException(val modelName: String, val id: Long?) :
    RuntimeException("Model $modelName not found with given id: $id")




13. Transaction 걸어주기 (트랜젝션 사용하기 위해 dependencies 추가)

implementation("org.springframework.boot:spring-boot-starter-data-jpa") 
//@Transactional 어노테이션을 사용하기 위해서
//JPA로 지금은 단순히 Database와 통신하기 위한 library라고 이해

implementation("com.h2database:h2") //DB 임시 저장소


TodocardServiceImpl의 CUD에 @Transactional  을 걸어준다.

 

 

14. 처리된 예외에 대한 적절한 응답

@RestControllerAdvice
class GlobalExceptionHandler {

    //todocardId가 없을 경우에 대한 예외처리
    @ExceptionHandler(ModelNotFoundException::class)
    fun handleModelNotFoundException(e: ModelNotFoundException): ResponseEntity<ErrorResponse> {
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse("해당 todocard가 없습니다."))
    }


에러메세지 생성

data class ErrorResponse(
    val message: String?
)



 DB 연결


15. supabase 설정하기 / 비밀번호 기억해놓기
https://supabase.com/dashboard/projects

16. DB 테이블 생성

CREATE TABLE app_user (
  id BIGSERIAL PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  password TEXT NOT NULL,
  nickname TEXT NOT NULL,
  role TEXT NOT NULL
);

CREATE TABLE course (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  description TEXT,
  status TEXT NOT NULL,
  max_applicants INTEGER NOT NULL,
  num_applicants INTEGER NOT NULL
);

CREATE TABLE lecture (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  video_url TEXT NOT NULL,
  course_id BIGINT,
  CONSTRAINT fk_lecture_course FOREIGN KEY (course_id) REFERENCES course
);

CREATE TABLE course_application (
  id BIGSERIAL PRIMARY KEY,
  status TEXT NOT NULL,
  user_id BIGINT,
  course_id BIGINT,
  CONSTRAINT fk_application_course FOREIGN KEY (course_id) REFERENCES course,
  CONSTRAINT fk_application_user FOREIGN KEY (user_id) REFERENCES app_user

);

 


17. DB와 연결하기
intelliJ 우측 햄버거 > +버튼 > 데이터소스 > postgreSQL > Host정보,  User, Password 입력 > testConnection 후> OK

18. PostgreSQL Driver를 dependencies에 추가

runtimeOnly("org.postgresql:postgresql") //postgresql DB driver 설치



19. application.properties 를 application.yml로 바꿔준다.

spring:
  datasource:
    url: {여러분의 URL}
    username: postgres
    password: {비밀번호}



20. .yml형태의 파일을 이용할수 도 있지만 중요정보 노출될 수 있으므로 환경변수로 설정한다.

상단 재생버튼 왼쪽에 프로젝트이름을 클릭후 > 구성편집 > 환경변수 입력

SPRING_DATASOURCE_URL={URL}?user={USERNAME}&password={PASSWORD}

와 같은 형태로, 유저이름, 비밀번호를 포함한 연결정보를 환경변수로 주입



21. Entity는 Entity Manager를 통해 상태가 변경되기도 하고, 자체적으로 도메인의 요구사항을 갖기도 함
(단순한 데이터 전달의 용도로 사용되기 보다 더 넓은 의미로 사용됨)
-> Kotlin에서 기본적으로 class는 java로 변환될 시 final, 즉 immutable 형태로 컴파일 됨
(그래서 매번 open class 형태로 선언을 해줘야 함)
-> 단 이 과정이 귀찮을 수 있으므로 plugins을 추가한다.

plugins {
kotlin("plugin.noarg") version "1.8.22"
}


-> 아래 두가지도 추가!

noArg {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}

allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}

 

 

 Entity 작성


22. Entity 작성하기 (todocard, comment, user 비슷)

model 패키지 하위 > course 생성 후 > Entity 작성

@Entity
@Table(name="todocard")
class Todocard(

    @Column(name="title")
    var title: String,

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

    @Enumerated(EnumType.STRING) //데이터베이스에 0, 1로 담기지 말고 "문자열" 형태로 저장 되도록!
    @Column(name="status")
    var status: TodocardStatus ,

    @Column(name="created_time")
    val createdTime: LocalDateTime = LocalDateTime.now(),

    @Column(name="author")
    val author: String,

){

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) 
    //Id를 자동생성 해주도록 JPA에 위임함. 자동적으로 id가 1씩증가하며 생성됨.
    var id: Long? = null


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



    fun removeComment(comment: Comment) {
        comments.remove(comment)
    }

}

fun Todocard.toResponse(): TodocardResponse {
    return TodocardResponse(
        id = id!!,
        title = title,
        description = description,
        status = status == TodocardStatus.COMPLETE,
        author = author
    )
}



23. enum Class 작성

enum class TodocardStatus {
    COMPLETE,
    UNCOMPLETE
}



24. Entity 간의 관계 연결 
-> Todocard에는 @OneToMany, Comment에는 @ManyToOne

@OneToMany 
@ManyToOne



25. Entity 간의 로딩 방식 설정 

fetch = FetchType.LAZY 
fetch = FetchType.EAGER


.LAZY 지연로딩 예시: 화면을 구성할 때, Course의 이름 정도만 조회하고 이후에 해당 Course를 클릭했을시 Lecture목록을 보여준다
.EAGER 즉시로딩 예시: 화면 구성할 때, Course를 조회시에 항상 Lecture목록을 같이 보여준다


26. Entity 간의 영속성 전달
- Cascade는 데이터베이스에서 또는 객체-관계-매핑(ORM) 프레임워크에서 일종의 '연쇄 작용'을 의미함.
- Cascade 옵션을 통해, 특정 엔티티에 변화가 발생할 때 그와 연관된 다른 엔티티들에게도 같은 연산을 적용.
- Cascade의 경우, 부모 자식 관계에서 부모 Entity의 영속성을 전파할 때 쓰임.
- 실무에서는 주로, CascadeType.ALL, CasecadeType.Persist 를 많이 사용
- @OneToMany(cascade = [CascadeType.ALL]) 

cascade = [CascadeType.ALL]
// OneToMany 관계에 있는 엔티티에 대해 
//모든 종류의 연산(CREATE, UPDATE, DELETE 등)이 Cascade 될 것임을 의미



27. 고아 자식 객체 자동 삭제하기
(기본적으로 부모 객체가 삭제되도 자식 객체는 삭제되지 않는데 -> JPA가 자동으로 이런 고아 객체를 삭제하도록 설정)

orphanRemoval = true



28. 위 내용 총정리 결과

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

 

 

 JPA


29. JPA 라이브러리 추가
implementation("org.springframework.boot:spring-boot-starter-data-jpa") // 이전 과정에서 추가했으니 또 추가 안해도됨

30. method를 통해 데이터를 가져온다.

findAll() 을 통해 모든 데이터를 가져올 수 있고 (SELECT * FROM ~) val allCourses = courseRepository.findAll()

findAllById 를 통해 특정 ID 목록에 해당하는 Entity 목록을 가져올 수 있음(SELECT * FROM ~ WHERE id IN ()) val specificCourses = courseRepository.findAllById(listOf(1L, 2L, 3L))

findById() 는 id에 기반해 한개의 데이터를 리턴
( Java는 기본적으로 null-safety하지 않아서, null을 안전하게 사용하기 위해, Optional 형태를 많이 씀 -> 하지만 Kotlin에서는 이러한게 필요하지 않음 ->  Spring Boot 2.1.2 버전부터 findByIdOrNull 함수를 추가로 지원 -> Elvis Operator를 통해 null 일시 exception을 던질 수 있고, 이후의 코드에서는 course가 null이 아니라는 걸 보장할 수 있음)

val course = courseRepository.findByIdOrNull(1L) ?: throw ModelNotFoundException("Course", 1L)
//주어진 ID에 해당하는 Course 객체를 반환하거나, 
//해당 ID에 해당하는 객체가 없을 경우 null을 반환한다. 
//nul을 반환할 경우, ModelNotFoundException이라는 예외를 발생시킨다.

 

 

 Repository작성


31. repository 패키지 하위 > TodocardRepository 인터페이스 작성 (comment, user도 마찬가지)

32. Method 이름을 통해 Query를 생성

interface TodocardRepository: JpaRepository<Todocard, Long> {
    fun findAllByOrderByCreatedTimeAsc(): List<Todocard>
    fun findAllByOrderByCreatedTimeDesc(): List<Todocard>
    fun findByAuthor(author: String): List<Todocard>
}



※ Query를 직접 작성하여 Method에 맵핑 해주는 방법도 있음.

interface TodocardRepository : JpaRepository<Todocard, Long> {
    @Query("SELECT t FROM Todocard t ORDER BY t.createdTime ASC")
    fun findAllByOrderByCreatedTimeAsc(): List<Todocard>

    @Query("SELECT t FROM Todocard t ORDER BY t.createdTime DESC")
    fun findAllByOrderByCreatedTimeDesc(): List<Todocard>

    @Query("SELECT t FROM Todocard t WHERE t.author = :author")
    fun findByAuthor(@Param("author") author: String): List<Todocard>
}



 Repository와 Service 연결


33. Repository를 주입받아 Service와 연결하기
//Service 구현부인 TodocardServiceImpl을 작성한다.

package com.example.todolist.domain.todocard.service

import com.example.todolist.domain.comment.dto.CommentResponse
import com.example.todolist.domain.comment.dto.CreatCommentRequest
import com.example.todolist.domain.comment.dto.DeleteCommentRequest
import com.example.todolist.domain.comment.dto.UpdateCommentRequest
import com.example.todolist.domain.comment.model.Comment
import com.example.todolist.domain.comment.model.toResponse
import com.example.todolist.domain.comment.repository.CommentRepository
import com.example.todolist.domain.exception.ModelNotFoundException
import com.example.todolist.domain.todocard.dto.CreateTodocardRequest
import com.example.todolist.domain.todocard.dto.TodocardResponse
import com.example.todolist.domain.todocard.dto.UpdateTodocardRequest
import com.example.todolist.domain.todocard.model.Todocard
import com.example.todolist.domain.todocard.model.TodocardStatus
import com.example.todolist.domain.todocard.model.toResponse
import com.example.todolist.domain.todocard.repository.TodocardRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime

@Service
class TodocardServiceImpl(
    private val todocardRepository: TodocardRepository,
    private val commentRepository: CommentRepository,
    private val encoder: PasswordEncoder
): TodocardService{


    // 할 일 목록 api에 작성일을 기준으로 오름차순, 내림차순 정렬하는 기능을 추가하기 (step3-1미션)
    override fun getAllTodocardList(order: String): List<TodocardResponse> {
        return if (order.toUpperCase() == "ASC") {
            todocardRepository.findAllByOrderByCreatedTimeAsc().map { it.toResponse() }
        } else {
            todocardRepository.findAllByOrderByCreatedTimeDesc().map { it.toResponse() }
        }
    }

    //할 일 목록 api에 작성자(이름을 포함하는)를 기준으로 필터하는 기능을 추가하기 (step3-2미션)
    override fun getAllTodocardListByAuthor(author: String): List<TodocardResponse> {
        return todocardRepository.findByAuthor("%author%").map { it.toResponse() }
    }





    override fun getTodocardById(todocardId: Long): TodocardResponse {
        val todocard = todocardRepository.findByIdOrNull(todocardId) ?: throw ModelNotFoundException("todocard",todocardId)
        return todocard.toResponse()
        //throw ModelNotFoundException(modelName="Todocard", id = 1L)
    }

    @Transactional
    override fun createTodocard(request: CreateTodocardRequest): TodocardResponse {
        return todocardRepository.save(
            Todocard(
                title = request.title,
                description = request.description,
                status = TodocardStatus.UNCOMPLETE, //댓글의 완료여부를 만들고, 기본값을 FALSE로 설정함 (step2-1미션)
                createdTime = LocalDateTime.now(),
                author = request.author
            )
        ).toResponse()
    }

    @Transactional
    override fun updateTodocard(todocardId: Long, request: UpdateTodocardRequest): TodocardResponse {
        val todocard = todocardRepository.findByIdOrNull(todocardId) ?: throw ModelNotFoundException("todocard", todocardId)

        val (title, description, status) = request

        todocard.title = title
        todocard.description = description
        todocard.status = status

        return todocardRepository.save(todocard).toResponse()
    }

    @Transactional
    override fun deleteTodocard(todocardId: Long) {
        val todocard = todocardRepository.findByIdOrNull(todocardId) ?: throw ModelNotFoundException("todocard", todocardId)
        todocardRepository.delete(todocard)
    }






    override fun getCommentList(todocardId: Long): List<CommentResponse> {
        val todocard = todocardRepository.findByIdOrNull(todocardId) ?: throw ModelNotFoundException("todocard", todocardId)
        return todocard.comments.map {it.toResponse()}
    }

    override fun getComment(todocardId: Long, commentId: Long): CommentResponse {
        val todocard = todocardRepository.findByIdOrNull(todocardId) ?: throw ModelNotFoundException("todocard", todocardId)
        val comment = commentRepository.findByTodocardIdAndId(todocardId, commentId) ?: throw ModelNotFoundException("Comment", commentId)

        return comment.toResponse()
    }

    @Transactional
    override fun addComment(todocardId: Long, request: CreatCommentRequest): CommentResponse {
        val todocard = todocardRepository.findByIdOrNull(todocardId) ?: throw ModelNotFoundException("todocard", todocardId)

        val encodedPassword = encoder.encode(request.commentPassword)

        val comment = Comment(
            comment = request.comment.toString(),
            author = request.author,
            commentPassword = encodedPassword //댓글을 작성할 때 '작성자 이름'과 '비밀번호'를 함께 받기 (step2-2미션)
        )
        return commentRepository.save(comment).toResponse()
    }

    @Transactional
    override fun updateComment(todocardId: Long, commentId: Long, request: UpdateCommentRequest): CommentResponse {
        val todocard = todocardRepository.findByIdOrNull(todocardId) ?: throw ModelNotFoundException("todocard", todocardId)
        val comment = commentRepository.findByIdOrNull(commentId) ?: throw ModelNotFoundException("Comment", commentId)

        //작성자 이름과 비밀번호를 함께 받아 저장한 값과 일치하면 수정 가능 (step2-3미션)
        if (!encoder.matches(request.commentPassword, comment.commentPassword)) {
            throw IllegalArgumentException("댓글의 비밀번호가 일치하지 않습니다.")
        } else {comment.comment = request.comment}


        return commentRepository.save(comment).toResponse()
    }

    @Transactional
    override fun removeComment(todocardId: Long, commentId: Long, request: DeleteCommentRequest) {
        val todocard = todocardRepository.findByIdOrNull(todocardId) ?: throw ModelNotFoundException("todocard", todocardId)
        val comment = commentRepository.findByIdOrNull(commentId) ?: throw ModelNotFoundException("Comment", commentId)

        //작성자 이름과 비밀번호를 함께 받아 저장한 값과 일치하면 삭제 가능 (step2-4미션)
        if (!encoder.matches(request.commentPassword, comment.commentPassword)) {
            throw IllegalArgumentException("댓글의 비밀번호가 일치하지 않습니다.")
        } else {commentRepository.delete(comment)}



    }
}

 

 

 테스트


34. Swagger로 테스트 하기.

- 정상 상황일 때 Response Body가 적절한지
- 정상 상황일 때 Status Code가 적절한지
- 비정상 상황일 때 Response Body가 적절한지
- 비정상 상황일 때 Status Code가 적절한지

테스트가 모두 잘 통과하였다면 성공!

35. 실제 쿼리가 의도한 대로 나가는지도 확인 -> application.yml 의 spring 하위에 아래와 같이 설정

spring:
  jpa:
    open-in-view: false
    properties:
      hibernate:
        format_sql: true
        highlight_sql: true
        use_sql_comments: true

loggint:
  level:
    org:
      hibernate:
        SQL: debug
        orm:
          jdbc:
            bind: trace


logger를 통해 쿼리 및 값들이 잘 확인되는지 볼수 있음.


36. 테스트 까지 완료하면 끝.

 

 

 

 추가했던 Dependencies 정리

 

    implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") //swager
    
    implementation("org.springframework.boot:spring-boot-starter-data-jpa") //JPA
    
    // implementation("com.h2database:h2") //DB 임시 저장소
    
    implementation("org.springframework.boot:spring-boot-starter-security") //spring 시큐리티
    
    runtimeOnly("org.postgresql:postgresql") //postgresql DB driver 설치 
    
    noArg {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
	}
    
    allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
	}
    
    plugins {
    	kotlin("plugin.noarg") version "1.8.22"
	}

 

 

 강의에서 추천한 사이트


SQL 연습
- 문법 참고 사이트 - https://www.w3schools.com/sql/
- 연습 및 학습 사이트 - https://sqlbolt.com/lesson/


table 변경사항을 쉽게 도와주는 tool -> flyway
https://flywaydb.org/

Hibernate ORM 5.4.33.최종 사용자 가이드
https://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#entity

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

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

JSON Web Token (JWT)
https://www.iana.org/assignments/jwt/jwt.xhtml#claims

base64기반 인코딩 / 디코딩 사이트
https://www.base64decode.org/

jwt를 직접 만들어볼수 있는 사이트
https://jwt.io/

 

 

 

 

블로그의 정보

꿈틀꿈틀 개발일기

jeongminy

활동하기