Repository: Ahn-seokjoo/todo-spring
Pull request #11: [#6] 기본 에러 타입 정의
Author: login: Ahn-seokjoo
Created: 2025-03-08T13:51:51Z | Merged: 2025-03-16T15:38:36Z

기본 에러 타입 정의했습니당

추후에 추가해나가겠습니다~

as-is
![image](https://github.com/user-attachments/assets/520a8495-68de-475c-8397-ff0cf84fe853)

to-be
![image](https://github.com/user-attachments/assets/a7928565-9706-488f-a99f-0bdb6185ef5a)

close #6

Linked issue #6: 에러 코드 정의
현재는 코틀린, 스프링 기본 에러로 처리중인데, 
Todo list 전용 에러 코드 정의 할 것!

Diff:
diff --git a/README.md b/README.md
index 7ba1a62..1071aba 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,33 @@
 # todo
 
+### 📌 Advanced To-Do List 요구사항 정리
+
+1️⃣ 기본 기능
+사용자는 할 일을 추가, 수정, 삭제할 수 있다.\
+완료된 할 일은 체크할 수 있으며, 완료/미완료 상태를 관리할 수 있다.
+
+2️⃣ 카테고리 기능
+할 일은 카테고리에 속할 수도 있고, 속하지 않을 수도 있다.\
+하나의 할 일은 여러 개의 카테고리에 포함될 수 있다.\
+사용자는 카테고리만 따로 추가할 수도 있다.
+
+3️⃣ 로그인 및 공유 기능
+로그인한 사용자는 본인의 To-Do List를 관리할 수 있다.\
+다른 사용자의 To-Do List를 조회할 수 있다.
+
+4️⃣ 성능 최적화 (캐싱)
+사용자의 To-Do List를 빠르게 불러오기 위해 캐싱을 적용한다.\
+자주 조회되는 데이터는 Redis 등의 캐싱 기술을 활용하여 성능을 최적화한다.
+
+5️⃣ 확장성 및 메시징 시스템
+Kafka를 활용한 이벤트 기반 처리를 적용할 예정이다.\
+예: To-Do 생성, 수정, 삭제 시 이벤트를 발행하여 확장성 확보\
+향후 알림 기능이나 분석 시스템과 연계 가능
+
+6️⃣ 배포 및 운영 환경
+To-Do List 서비스는 Kubernetes(K8s) 기반으로 배포할 예정이다.\
+자동 확장(Auto Scaling) 및 로드 밸런싱을 지원\
+다중 인스턴스를 활용한 고가용성(HA) 아키텍처 구축 가능
+
+
 ![image](https://github.com/user-attachments/assets/0d34999b-d65f-4482-ae3f-f05c03251bb9)
diff --git a/build.gradle.kts b/build.gradle.kts
index 276333c..32bf54b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -31,7 +31,14 @@ dependencies {
 	runtimeOnly("org.postgresql:postgresql")
 	testImplementation("org.springframework.boot:spring-boot-starter-test")
 	testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
+	testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
+	testImplementation("org.mockito:mockito-core:4.8.0")
 	testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+	testImplementation("io.mockk:mockk:1.13.17")
+	// kotest
+	testImplementation("io.kotest:kotest-runner-junit5:5.4.2")
+	testImplementation("io.kotest:kotest-assertions-core:5.4.2")
+	testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.2")
 }
 
 kotlin {
diff --git a/docker-compose.yml b/docker-compose.yml
index 1a40c6d..81b4207 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,6 +12,8 @@ x-environment: &common_environment
 services:
   postgres-primary-0:
     image: bitnami/postgresql-repmgr
+    networks:
+      - default
     volumes:
       - postgres_primary_data:/bitnami/postgresql
     restart: always
@@ -27,15 +29,18 @@ services:
     image: bitnami/postgresql-repmgr
     volumes:
       - postgres_standby_data:/bitnami/postgresql
+    networks:
+      - default
     restart: always
     environment:
       <<: *common_environment
       REPMGR_NODE_NAME: postgres-standby-1
       REPMGR_NODE_NETWORK_NAME: postgres-standby-1
-    ## 추후에 백엔드 서버도 도커로 구성하면, expose 로 수정하여 외부접근 차단 해야함 - standby 는 5433으로 세팅
-    ports:
-      - "5433:5432"
 
 volumes:
   postgres_primary_data:
   postgres_standby_data:
+
+networks:
+  default:
+    driver: bridge
diff --git a/src/main/kotlin/com/seokjoo/todo/common/exception/TodoException.kt b/src/main/kotlin/com/seokjoo/todo/common/exception/TodoException.kt
new file mode 100644
index 0000000..0a57ce6
--- /dev/null
+++ b/src/main/kotlin/com/seokjoo/todo/common/exception/TodoException.kt
@@ -0,0 +1,21 @@
+package com.seokjoo.todo.common.exception
+
+class TodoException(
+    val errorCode: String,
+    val httpStatusCode: Int,
+    override val message: String,
+) : RuntimeException() {
+    companion object {
+        fun of(type: TodoExceptionType) = TodoException(
+            errorCode = type.errorCode,
+            httpStatusCode = type.httpStatusCode,
+            message = type.message,
+        )
+
+        fun of(type: TodoExceptionType, message: String) = TodoException(
+            errorCode = type.errorCode,
+            httpStatusCode = type.httpStatusCode,
+            message = message,
+        )
+    }
+}
diff --git a/src/main/kotlin/com/seokjoo/todo/common/exception/TodoExceptionType.kt b/src/main/kotlin/com/seokjoo/todo/common/exception/TodoExceptionType.kt
new file mode 100644
index 0000000..9a00371
--- /dev/null
+++ b/src/main/kotlin/com/seokjoo/todo/common/exception/TodoExceptionType.kt
@@ -0,0 +1,15 @@
+package com.seokjoo.todo.common.exception
+
+enum class TodoExceptionType(
+    val message: String,
+    val errorCode: String,
+    val httpStatusCode: Int,
+) {
+    // TODO
+    NOT_EXISTED_TODO(message = "존재하지 않는 Todo 입니다", errorCode = "T000_TODO_ERROR", httpStatusCode = 404),
+    ID_BAD_REQUEST(message = "ID 형식을 잘못 입력했습니다.", errorCode = "TO01_BAD_REQUEST", httpStatusCode = 400),
+    ID_VALIDATION_BAD_REQUEST(message = "Todo는 빈 값일 수 없습니다", errorCode = "TO02_BAD_REQUEST", httpStatusCode = 400),
+
+    // COMMON
+    COMMON_BAD_REQUEST(message = "잘못된 요청입니다", errorCode = "C000_BAD_REQUEST", httpStatusCode = 400);
+}
diff --git a/src/main/kotlin/com/seokjoo/todo/domain/entity/todo/Todo.kt b/src/main/kotlin/com/seokjoo/todo/domain/entity/todo/Todo.kt
index 8a4a78c..d9c8aa2 100644
--- a/src/main/kotlin/com/seokjoo/todo/domain/entity/todo/Todo.kt
+++ b/src/main/kotlin/com/seokjoo/todo/domain/entity/todo/Todo.kt
@@ -31,4 +31,16 @@ class Todo(
         todoCategories.add(todoCategory)
         category.todoCategories.add(todoCategory)
     }
+
+    fun copy(
+        todo: String? = null,
+        isDone: Boolean? = null,
+        todoCategories: MutableList<TodoCategory>? = null,
+        id: Long? = null,
+    ) = Todo(
+        todo = todo ?: this.todo,
+        isDone = isDone ?: this.isDone,
+        todoCategories = todoCategories ?: this.todoCategories,
+        id = id ?: this.id,
+    )
 }
diff --git a/src/main/kotlin/com/seokjoo/todo/domain/repository/categorytodo/TodoCategoryRepository.kt b/src/main/kotlin/com/seokjoo/todo/domain/repository/categorytodo/TodoCategoryRepository.kt
new file mode 100644
index 0000000..90c4876
--- /dev/null
+++ b/src/main/kotlin/com/seokjoo/todo/domain/repository/categorytodo/TodoCategoryRepository.kt
@@ -0,0 +1,11 @@
+package com.seokjoo.todo.domain.repository.categorytodo
+
+import com.seokjoo.todo.domain.entity.todocategory.TodoCategory
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.data.jpa.repository.Query
+import org.springframework.stereotype.Repository
+
+@Repository
+interface TodoCategoryRepository : JpaRepository<TodoCategory, Long> {
+    fun countByCategoryId(id: Long?): Int
+}
diff --git a/src/main/kotlin/com/seokjoo/todo/domain/service/todo/TodoService.kt b/src/main/kotlin/com/seokjoo/todo/domain/service/todo/TodoService.kt
index bdc1e31..577ee81 100644
--- a/src/main/kotlin/com/seokjoo/todo/domain/service/todo/TodoService.kt
+++ b/src/main/kotlin/com/seokjoo/todo/domain/service/todo/TodoService.kt
@@ -1,17 +1,22 @@
 package com.seokjoo.todo.domain.service.todo
 
+import com.seokjoo.todo.common.exception.TodoException
+import com.seokjoo.todo.common.exception.TodoExceptionType
 import com.seokjoo.todo.domain.entity.todo.Todo
+import com.seokjoo.todo.domain.entity.todocategory.TodoCategory
 import com.seokjoo.todo.domain.repository.category.CategoryRepository
+import com.seokjoo.todo.domain.repository.categorytodo.TodoCategoryRepository
 import com.seokjoo.todo.domain.repository.todo.TodoRepository
+import org.springframework.data.repository.findByIdOrNull
 import org.springframework.stereotype.Service
 import org.springframework.transaction.annotation.Transactional
-import kotlin.jvm.optionals.getOrElse
 
 @Service
 @Transactional(readOnly = true)
 class TodoService(
     private val todoRepository: TodoRepository,
     private val categoryRepository: CategoryRepository,
+    private val todoCategoryRepository: TodoCategoryRepository,
 ) {
     fun getAllTodos(): List<TodoServiceResponseDTO> {
         val todoList = todoRepository.findAll()
@@ -19,22 +24,23 @@ class TodoService(
     }
 
     fun getTodoById(id: Long): TodoServiceResponseDTO {
-        val result = todoRepository.findById(id).getOrElse { throw IllegalAccessError("bad") }
+        val result = todoRepository.findByIdOrNull(id) ?: throw TodoException.of(TodoExceptionType.NOT_EXISTED_TODO)
         return TodoServiceResponseDTO.from(result)
     }
 
     @Transactional
-    fun createTodo(request: TodoServiceRequestDTO) {
+    fun createTodo(request: TodoServiceRequestDTO): TodoServiceResponseDTO {
         // 1. 저장하여 영속화 먼저
         val todo = Todo(todo = request.todo, isDone = request.isDone)
         todoRepository.save(todo)
 
         checkExistAndAddCategory(request, todo)
+        return TodoServiceResponseDTO.from(todo)
     }
 
     @Transactional
     fun updateTodo(id: Long, request: TodoServiceRequestDTO): TodoServiceResponseDTO {
-        val todo = todoRepository.findById(id).getOrElse { throw IllegalArgumentException("id가 없음") }
+        val todo = todoRepository.findByIdOrNull(id) ?: throw TodoException.of(TodoExceptionType.NOT_EXISTED_TODO)
 
         todo.apply {
             this.todo = request.todo
@@ -49,9 +55,19 @@ class TodoService(
 
     @Transactional
     fun deleteTodo(id: Long) {
+        val todo = todoRepository.findByIdOrNull(id) ?: throw TodoException.of(TodoExceptionType.NOT_EXISTED_TODO)
+        val todoCategory = todo.todoCategories
         todoRepository.deleteById(id)
+
+        todoCategory
+            .filter { it.category != null && isCategoryLinked(it) }
+            .mapNotNull { it.category }
+            .forEach { categoryRepository.delete(it) }
     }
 
+    private fun isCategoryLinked(it: TodoCategory) =
+        todoCategoryRepository.countByCategoryId(it.id) > 0
+
     private fun checkExistAndAddCategory(
         request: TodoServiceRequestDTO,
         todo: Todo,
diff --git a/src/main/kotlin/com/seokjoo/todo/presentation/category/controller/CategoryApiController.kt b/src/main/kotlin/com/seokjoo/todo/presentation/category/controller/CategoryApiController.kt
index 186abc3..c7c30b9 100644
--- a/src/main/kotlin/com/seokjoo/todo/presentation/category/controller/CategoryApiController.kt
+++ b/src/main/kotlin/com/seokjoo/todo/presentation/category/controller/CategoryApiController.kt
@@ -3,7 +3,7 @@ package com.seokjoo.todo.presentation.category.controller
 import com.seokjoo.todo.domain.service.category.CategoryService
 import org.springframework.web.bind.annotation.RestController
 
-@RestController
+@RestController("/api/v1")
 class CategoryApiController(
     private val categoryService: CategoryService,
 )
diff --git a/src/main/kotlin/com/seokjoo/todo/presentation/error/ApiErrorResponse.kt b/src/main/kotlin/com/seokjoo/todo/presentation/error/ApiErrorResponse.kt
new file mode 100644
index 0000000..8840c50
--- /dev/null
+++ b/src/main/kotlin/com/seokjoo/todo/presentation/error/ApiErrorResponse.kt
@@ -0,0 +1,6 @@
+package com.seokjoo.todo.presentation.error
+
+data class ApiErrorResponse(
+    val errorCode: String,
+    val message: String,
+)
diff --git a/src/main/kotlin/com/seokjoo/todo/presentation/error/ApiExceptionHandler.kt b/src/main/kotlin/com/seokjoo/todo/presentation/error/ApiExceptionHandler.kt
new file mode 100644
index 0000000..6524ae6
--- /dev/null
+++ b/src/main/kotlin/com/seokjoo/todo/presentation/error/ApiExceptionHandler.kt
@@ -0,0 +1,67 @@
+package com.seokjoo.todo.presentation.error
+
+import com.seokjoo.todo.common.exception.TodoException
+import com.seokjoo.todo.common.exception.TodoExceptionType
+import jakarta.servlet.http.HttpServletRequest
+import org.slf4j.LoggerFactory
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.MethodArgumentNotValidException
+import org.springframework.web.bind.annotation.ExceptionHandler
+import org.springframework.web.bind.annotation.RestControllerAdvice
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
+
+@RestControllerAdvice
+class ApiExceptionHandler {
+    private val logger = LoggerFactory.getLogger(ApiExceptionHandler::class.java)
+
+    @ExceptionHandler
+    fun handlerTodoException(exception: TodoException): ResponseEntity<ApiErrorResponse> {
+        logger.info(exception.message, exception)
+
+        return ResponseEntity
+            .status(exception.httpStatusCode)
+            .body(
+                ApiErrorResponse(
+                    errorCode = exception.errorCode,
+                    message = exception.message,
+                )
+            )
+    }
+
+    @ExceptionHandler
+    fun handleException(exception: MethodArgumentNotValidException): ResponseEntity<ApiErrorResponse> {
+        logger.info(exception.message, exception)
+
+        val message = exception.bindingResult.fieldErrors.getOrNull(0)?.defaultMessage
+
+        return ResponseEntity
+            .status(TodoExceptionType.ID_VALIDATION_BAD_REQUEST.httpStatusCode)
+            .body(
+                ApiErrorResponse(
+                    errorCode = TodoExceptionType.ID_VALIDATION_BAD_REQUEST.errorCode,
+                    message = TodoExceptionType.ID_VALIDATION_BAD_REQUEST.message,
+                )
+            )
+    }
+
+    @ExceptionHandler
+    fun handlerTypeMismatchException(
+        request: HttpServletRequest,
+        exception: MethodArgumentTypeMismatchException,
+    ): ResponseEntity<ApiErrorResponse> {
+        logger.info(exception.message, exception)
+        val error = when (exception.parameter.parameterName) {
+            "id" -> TodoExceptionType.ID_BAD_REQUEST
+            else -> TodoExceptionType.COMMON_BAD_REQUEST
+        }
+
+        return ResponseEntity
+            .status(error.httpStatusCode)
+            .body(
+                ApiErrorResponse(
+                    errorCode = error.errorCode,
+                    message = error.message,
+                )
+            )
+    }
+}
diff --git a/src/main/kotlin/com/seokjoo/todo/presentation/todo/controller/TodoApiController.kt b/src/main/kotlin/com/seokjoo/todo/presentation/todo/controller/TodoApiController.kt
index ee30603..559b33a 100644
--- a/src/main/kotlin/com/seokjoo/todo/presentation/todo/controller/TodoApiController.kt
+++ b/src/main/kotlin/com/seokjoo/todo/presentation/todo/controller/TodoApiController.kt
@@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.PostMapping
 import org.springframework.web.bind.annotation.RequestBody
 import org.springframework.web.bind.annotation.RequestMapping
 import org.springframework.web.bind.annotation.RestController
+import java.net.URI
 
 /**
  * 요구사항 다시 정해보기
@@ -58,9 +59,9 @@ class TodoApiController(
         description = "todo 추가 성공",
         content = [Content(mediaType = "text/plain", schema = Schema(example = "ok"))]
     )
-    fun createTodo(@RequestBody @Validated request: TodoRequest): ResponseEntity<String> {
-        todoService.createTodo(request = request.toTodoServiceRequest())
-        return ResponseEntity.ok("ok")
+    fun createTodo(@RequestBody @Validated request: TodoRequest): ResponseEntity<TodoResponse> {
+        val todo = todoService.createTodo(request = request.toTodoServiceRequest()).toResponse()
+        return ResponseEntity.created(URI.create("/todos/${todo.id}")).body(todo)
     }
 
     @PatchMapping("/todos/{id}")
diff --git a/src/main/kotlin/com/seokjoo/todo/presentation/todo/dto/request/TodoRequest.kt b/src/main/kotlin/com/seokjoo/todo/presentation/todo/dto/request/TodoRequest.kt
index a5f3b6d..71bb846 100644
--- a/src/main/kotlin/com/seokjoo/todo/presentation/todo/dto/request/TodoRequest.kt
+++ b/src/main/kotlin/com/seokjoo/todo/presentation/todo/dto/request/TodoRequest.kt
@@ -7,7 +7,7 @@ import io.swagger.v3.oas.annotations.media.Schema
 import jakarta.validation.constraints.NotBlank
 
 data class TodoRequest(
-    @field:NotBlank(message = "todo는 필수입니다")
+    @field:NotBlank
     @Schema(description = "todo를 입력합니다.", example = "위플래시 재개봉 보러가기", required = true)
     val todo: String,
     val isDone: Boolean = false,
diff --git a/src/test/kotlin/com/seokjoo/todo/domain/service/todo/TodoServiceTest.kt b/src/test/kotlin/com/seokjoo/todo/domain/service/todo/TodoServiceTest.kt
new file mode 100644
index 0000000..bb6dfeb
--- /dev/null
+++ b/src/test/kotlin/com/seokjoo/todo/domain/service/todo/TodoServiceTest.kt
@@ -0,0 +1,184 @@
+package com.seokjoo.todo.domain.service.todo
+
+import com.seokjoo.todo.common.exception.TodoException
+import com.seokjoo.todo.common.exception.TodoExceptionType
+import com.seokjoo.todo.domain.entity.category.Category
+import com.seokjoo.todo.domain.entity.todo.Todo
+import com.seokjoo.todo.domain.entity.todocategory.TodoCategory
+import com.seokjoo.todo.domain.repository.category.CategoryRepository
+import com.seokjoo.todo.domain.repository.todo.TodoRepository
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.BehaviorSpec
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.shouldNotBe
+import io.mockk.every
+import io.mockk.mockk
+import org.springframework.data.repository.findByIdOrNull
+
+class TodoServiceTest : BehaviorSpec({
+    val todoRepository: TodoRepository = mockk()
+    val categoryRepository: CategoryRepository = mockk()
+
+    val todoService = TodoService(
+        todoRepository = todoRepository,
+        categoryRepository = categoryRepository,
+        todoCategoryRepository = mockk(),
+    )
+
+    Given("create todo") {
+        val request = TodoServiceRequestDTO(
+            todo = "abcde",
+            isDone = false,
+        )
+        val requestTodo = Todo(id = 1L, todo = request.todo, isDone = request.isDone)
+        When("정상 케이스에서") {
+            every { todoRepository.save(any()) } returns requestTodo
+            val todo = todoService.createTodo(request)
+            Then("create Todo 시에, todo 한개가 잘 생성된다") {
+                todo.isDone shouldBe false
+                todo.todo shouldBe "abcde"
+            }
+        }
+    }
+
+    Given("getTodos") {
+        val id = 1L
+        every { todoRepository.findByIdOrNull(id) } returns Todo(id = 1L, todo = "abcde")
+        When("정상 케이스에서") {
+            val todo = todoService.getTodoById(id)
+            Then("getTodos 를 수행했을 때, todo 한개가 잘 나온다") {
+                todo.isDone shouldBe false
+                todo.todo shouldBe "abcde"
+                todo.id shouldBe 1L
+            }
+        }
+        When("exception") {
+            every { todoRepository.findByIdOrNull(id) } throws TodoException.of(TodoExceptionType.NOT_EXISTED_TODO)
+            Then("에러 케이스의 경우 404, T000_TODO_ERROR를 준다") {
+                val exception = shouldThrow<TodoException> {
+                    todoService.getTodoById(id = id)
+                }
+                exception.message shouldBe "존재하지 않는 Todo 입니다"
+                exception.httpStatusCode shouldBe 404
+                exception.errorCode shouldBe "T000_TODO_ERROR"
+            }
+        }
+    }
+
+    Given("getAll todo") {
+        every { todoRepository.findAll() } returns listOf(
+            Todo(todo = "abcde", isDone = true),
+            Todo(todo = "abcdef", isDone = false),
+        )
+        When("정상 케이스에서") {
+            val allTodos = todoService.getAllTodos()
+            Then("getAll 시에 추가한 만큼 잘 들어가있다.") {
+                allTodos.count() shouldBe 2
+            }
+        }
+    }
+
+    Given("update test") {
+        val id = 1L
+        val request = TodoServiceRequestDTO(
+            todo = "abcdef",
+            isDone = false,
+            categories = listOf()
+        )
+        val previousTodo = Todo(
+            id = 1L,
+            todo = "abcde",
+            isDone = false,
+        )
+        val nextTodo = Todo(
+            id = 1L,
+            todo = "abcdef",
+            isDone = false,
+        )
+        When("업데이트 시에 없는 id를 주면") {
+            every { todoRepository.findByIdOrNull(id) } throws TodoException.of(TodoExceptionType.NOT_EXISTED_TODO)
+            Then("에러가 난다") {
+                val exception = shouldThrow<TodoException> {
+                    todoService.updateTodo(id = id, request = TodoServiceRequestDTO(todo = "abas"))
+                }
+                exception.message shouldBe "존재하지 않는 Todo 입니다"
+                exception.httpStatusCode shouldBe 404
+                exception.errorCode shouldBe "T000_TODO_ERROR"
+            }
+        }
+
+        When("todo를 바꿔서 주는 경우") {
+            every { todoRepository.findByIdOrNull(id) } returns previousTodo
+            every { todoRepository.save(any()) } returns nextTodo
+
+            val updatedTodo = todoService.updateTodo(id = id, request = request)
+            Then("업데이트가 잘 된다") {
+                updatedTodo.todo shouldBe "abcdef"
+                updatedTodo.isDone shouldBe false
+                updatedTodo.id shouldNotBe null
+            }
+        }
+
+        When("isDone을 바꿔서 주는 경우") {
+            val newRequest = request.copy(isDone = true)
+            val newPreviousTodo = previousTodo.copy()
+            val newNextTodo = nextTodo.copy(isDone = true)
+            every { todoRepository.findByIdOrNull(id) } returns newPreviousTodo
+            every { todoRepository.save(any()) } returns newNextTodo
+            val updatedTodo = todoService.updateTodo(id = id, request = newRequest)
+
+            Then("업데이트가 잘 된다.") {
+                updatedTodo.todo shouldBe "abcdef"
+                updatedTodo.isDone shouldBe true
+                updatedTodo.id shouldNotBe null
+            }
+        }
+
+        When("category를 바꿔서 주는 경우") {
+            val newRequest = request.copy(
+                categories = listOf(
+                    Category(name = "horror"),
+                    Category(name = "comedy"),
+                )
+            )
+            val newPreviousTodo = previousTodo.copy(todo = "abcde")
+            val newNextTodo = nextTodo.copy(
+                todo = "abcde", todoCategories = mutableListOf(
+                    TodoCategory(todo = Todo("abcde", isDone = false), Category(name = "horror")),
+                    TodoCategory(todo = Todo("abcde", isDone = false), Category(name = "comedy")),
+                )
+            )
+            every { todoRepository.findByIdOrNull(1L) } returns newPreviousTodo
+            every { todoRepository.save(any()) } returns newNextTodo
+            every { categoryRepository.findByName(any()) } returnsMany listOf(
+                Category(name = "horror"),
+                Category(name = "comedy")
+            )
+            val updatedTodo = todoService.updateTodo(id = id, request = newRequest)
+            Then("업데이트가 잘 된다") {
+                updatedTodo.todo shouldBe "abcdef"
+                updatedTodo.isDone shouldBe false
+                updatedTodo.id shouldNotBe null
+                updatedTodo.categories.size shouldBe 2
+                updatedTodo.categories[0] shouldBe "horror"
+                updatedTodo.categories[1] shouldBe "comedy"
+            }
+        }
+    }
+
+    Given("delete test") {
+        val id = 1L
+        val expectedStatusCode = 404
+        When("delete when") {
+            every { todoRepository.findByIdOrNull(id) } throws TodoException.of(TodoExceptionType.NOT_EXISTED_TODO)
+            Then("없는 ID를 주는 경우 ") {
+                val exception = shouldThrow<TodoException> {
+                    todoService.deleteTodo(id)
+                }
+                exception.message shouldBe "존재하지 않는 Todo 입니다"
+                exception.httpStatusCode shouldBe expectedStatusCode
+                exception.errorCode shouldBe "T000_TODO_ERROR"
+            }
+        }
+    }
+})
diff --git a/src/test/kotlin/com/seokjoo/todo/presentation/todo/controller/TodoApiControllerTest.kt b/src/test/kotlin/com/seokjoo/todo/presentation/todo/controller/TodoApiControllerTest.kt
new file mode 100644
index 0000000..8e2743b
--- /dev/null
+++ b/src/test/kotlin/com/seokjoo/todo/presentation/todo/controller/TodoApiControllerTest.kt
@@ -0,0 +1,101 @@
+package com.seokjoo.todo.presentation.todo.controller
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.seokjoo.todo.common.exception.TodoExceptionType
+import com.seokjoo.todo.domain.service.todo.TodoService
+import com.seokjoo.todo.domain.service.todo.TodoServiceResponseDTO
+import com.seokjoo.todo.presentation.todo.dto.request.TodoRequest
+import org.junit.jupiter.api.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.given
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
+import org.springframework.http.MediaType
+import org.springframework.test.context.bean.override.mockito.MockitoBean
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.get
+import org.springframework.test.web.servlet.post
+
+@WebMvcTest(controllers = [TodoApiController::class])
+class TodoApiControllerTest {
+
+    @Autowired
+    private lateinit var mockMvc: MockMvc
+
+    @Autowired
+    private lateinit var objectMapper: ObjectMapper
+
+    @MockitoBean
+    internal lateinit var service: TodoService
+
+    @Test
+    fun `getAllTodo 함수를 호출하면 200 이 나온다`() {
+        mockMvc.get("/api/v1/todos")
+            .andDo { print() }
+            .andExpect {
+                status { isOk() }
+            }
+    }
+
+    @Test
+    fun `getTodoById로 id가 숫자가 아닌 값이 들어올 때 bad reqeust`() {
+        mockMvc.get("/api/v1/todos/hi")
+            .andDo { print() }
+            .andExpect {
+                status { isBadRequest() }
+                jsonPath("$.errorCode") { value(TodoExceptionType.ID_BAD_REQUEST.errorCode) }
+                jsonPath("$.message") { value(TodoExceptionType.ID_BAD_REQUEST.message) }
+            }
+    }
+
+    @Test
+    fun `create todo 시에 validation이 제대로 되고 있는지`() {
+        // 유효하지 않은 요청 (todo 비어 있음)
+        val request = TodoRequest(
+            todo = "",
+        )
+        mockMvc.post("/api/v1/todos") {
+            content = objectMapper.writeValueAsString(request)
+            contentType = MediaType.APPLICATION_JSON
+        }
+            .andDo { print() }
+            .andExpect {
+                status { isBadRequest() }
+                jsonPath("$.message") { value("Todo는 빈 값일 수 없습니다") } // 메시지 검증
+            }
+    }
+
+    @Test
+    fun `create todo 시에 예상한 것 처럼 응답을 잘 만들어 낸다`() {
+        val request = TodoRequest(
+            todo = "create todo",
+        )
+        val mockResponse = TodoServiceResponseDTO(
+            id = 1L,
+            todo = "create todo",
+            isDone = false,
+            categories = listOf()
+        )
+        given(service.createTodo(any())).willReturn(mockResponse)
+
+        mockMvc.post("/api/v1/todos") {
+            content = objectMapper.writeValueAsString(request)
+            contentType = MediaType.APPLICATION_JSON
+        }
+            .andDo { print() }
+            .andExpect {
+                status { isCreated() }
+                content {
+                    json(
+                        """
+                        {
+                            "id": 1,
+                            "todo": "create todo",
+                            "isDone": false
+                        }
+                        """.trimIndent()
+                    )
+                }
+            }
+    }
+}


Reviews:
user: login: Ahn-seokjoo
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-08T14:44:17Z
user: login: da-nyee
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-15T14:40:04Z
user: login: da-nyee
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-15T14:46:31Z
user: login: da-nyee
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-15T14:49:46Z
user: login: Ahn-seokjoo
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-15T18:32:02Z
user: login: da-nyee
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-16T15:08:09Z

Review comments:
diff_hunk: @@ -0,0 +1,21 @@
+package com.seokjoo.todo.common.exception
+
+class TodoException(
path: src/main/kotlin/com/seokjoo/todo/common/exception/TodoException.kt
user: login: Ahn-seokjoo
body: 이걸 상속받는 특정 에러 타입을 (예를들면 admin, todo, category) 만들 수도 있겠지만,
그걸 이용해서 ApiExceptionHandler에서 각각 다른 처리를 해줄만한게 있을까,,? 싶은 생각이 들어요 
이건 제가 잘 몰라서 그런 걸 수도 있는데, 이런 공통 처리 말고는 특정 에러를 잡아서 처리해줄만한게 있을까요? 
딱히 활용방안이 생각 나는게 없네요
다른 프로젝트를 보면 logging 구분 정도밖에 안해주는 것 같아서요 !
created_at: 2025-03-08T14:44:16Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,21 @@
+package com.seokjoo.todo.common.exception
+
+class TodoException(
path: src/main/kotlin/com/seokjoo/todo/common/exception/TodoException.kt
user: login: da-nyee
body: > 다른 프로젝트를 보면 logging 구분 정도밖에 안해주는 것 같아서요 !

요 문장 뜻이 TodoException + 로깅 일까요, 아니면 TodoException을 상속받는 특정 예외로 로깅 일까요?!
created_at: 2025-03-15T14:40:03Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,21 @@
+package com.seokjoo.todo.common.exception
+
+class TodoException(
path: src/main/kotlin/com/seokjoo/todo/common/exception/TodoException.kt
user: login: da-nyee
body: 예외 세분화는 보통 로그를 좀 더 편하게 확인하게 한다고 생각해요!
만약 TodoException으로만 예외를 던진다면, 예외 수집 플랫폼에서 예외가 전부 TodoException으로 분류되어서 에러 메시지를 읽어야만 어떤 예외가 던져진 건지 알 수 있어 약간의 불편함이 있을 것 같아요
요거는 제가 평일에 슬랙으로 조금 더 설명드릴게요! 저희 회사 플랫폼으로 예시 들면 바로 와닿을 것 같아서 ...!!
created_at: 2025-03-15T14:46:31Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,26 @@
+package com.seokjoo.todo.presentation.error
+
+import com.seokjoo.todo.common.exception.TodoException
+import org.slf4j.LoggerFactory
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.ExceptionHandler
+import org.springframework.web.bind.annotation.RestControllerAdvice
+
+@RestControllerAdvice
+class ApiExceptionHandler {
+    private val logger = LoggerFactory.getLogger(ApiExceptionHandler::class.java)
+
+    @ExceptionHandler
+    fun handlerTodoException(exception: TodoException): ResponseEntity<ApiErrorResponse> {
path: src/main/kotlin/com/seokjoo/todo/presentation/error/ApiExceptionHandler.kt
user: login: da-nyee
body: NPE 같은 TodoException으로 정의되지 않은 예외도 처리해주면 통일성 있게 로그를 찍을 수 있을 것 같아요 ~~
created_at: 2025-03-15T14:49:44Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,21 @@
+package com.seokjoo.todo.common.exception
+
+class TodoException(
path: src/main/kotlin/com/seokjoo/todo/common/exception/TodoException.kt
user: login: Ahn-seokjoo
body: 조아요 ! 평일에 잠깐,, 회의라도 ,,,
created_at: 2025-03-15T18:32:02Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,21 @@
+package com.seokjoo.todo.common.exception
+
+class TodoException(
path: src/main/kotlin/com/seokjoo/todo/common/exception/TodoException.kt
user: login: da-nyee
body: 네네네 !
created_at: 2025-03-16T15:08:09Z
author_association: COLLABORATOR

PR comments:
user: login: Ahn-seokjoo
created_at: 2025-03-14T16:08:24Z
body: 순서를 바꿔서 머지해버려서 전에 피알이 딸려왔네용 ㅠ 죄송해용
author_association: OWNER
user: login: da-nyee
created_at: 2025-03-15T14:46:55Z
body: > 순서를 바꿔서 머지해버려서 전에 피알이 딸려왔네용 ㅠ 죄송해용

갠차나요 ~~
author_association: COLLABORATOR