Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions docs/plans/category-query-api-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# 카테고리 조회/검색 API 구현 계획

- 작성일: 2026-07-14
- 관련 문서: 없음

## 1. 개요

`category` 도메인은 현재 `domain` / `port/out` / `adapter/out`(영속성) 계층까지만 구현되어 있고,
`port/in`(UseCase) / `service` / `adapter/web`(GraphQL 리졸버)은 없다. 즉 카테고리를 직접 조회하는
GraphQL Query가 아직 하나도 없으며, `score` 도메인이 `CategoryPersistencePort.findByCategoryType`을
내부적으로 소비하는 형태로만 쓰이고 있다.

`src/main/resources/graphql/category.graphqls`에는 `type Category`가 이미 정의되어 있고
(`categoryEnglishName`, `categoryKoreanName`, `categoryMaximumValue` 등) `score.graphqls`의 `Score` 타입이
이를 중첩 필드로 이미 참조 중이다. 이번에 추가할 티켓 스펙의 응답 필드명(`englishName`, `koreanName`,
`maxRecordCount`)은 이 기존 필드명과 다르다 — 기존 `Category` 타입을 건드리면 `Score` 조회 쪽에 영향이
가므로, 인터뷰 결과 **신규 `CategoryPayload` GraphQL 타입을 별도로 만들어** 티켓 스펙 필드명을 그대로
쓰기로 했다.

카테고리 데이터(`category_tb`)는 `CategoryType` enum 기준 13개 행뿐인 사실상 정적인 참조 테이블이다.

## 2. 인터뷰로 확정한 의사결정 사항

| 항목 | 결정 |
| --- | --- |
| 응답 GraphQL 타입 | 기존 `Category` 타입을 건드리지 않고 신규 `CategoryPayload` 타입 생성. 필드명은 티켓 스펙 그대로(`englishName`, `koreanName`, `weight`, `maxRecordCount`, `evidenceType`, `calculationType`) |
| 식별자 포함 여부 | 티켓 스펙 응답에는 없지만 `categoryType`을 추가로 포함한다 — 프론트엔드가 이 조회 결과로 카테고리를 식별해 이후 `addScoreWithFile` 등 Mutation을 호출해야 하기 때문 |
| `searchCategories` 매칭 규칙 | `englishName` 또는 `koreanName` 중 하나라도 keyword를 포함하면 매칭(대소문자 무시, 부분일치) |
| 검색 구현 방식 | QueryDSL(`JPAQueryFactory`)로 DB 레벨에서 필터링. `score` 도메인의 `ScorePersistenceAdapter`가 이미 같은 방식(Q타입 + `queryFactory.selectFrom(...).where(...)`)을 쓰고 있어 일관성을 맞춘다. Spring Data 파생 쿼리 메서드(`findByCategoryEnglishNameContainingIgnoreCaseOrCategoryKoreanNameContainingIgnoreCaseOrderByCategoryIdAsc` 등)는 이름이 지나치게 길어지고 프로젝트 전반의 QueryDSL 사용 관례와도 어긋나 채택하지 않음. 메모리 필터링도 채택하지 않음 |
| `categories` 정렬 기준 | `categoryId` 오름차순 |
| `searchCategories`에 keyword 미전달/빈 문자열 | 필터링 없이 전체 카테고리 반환 (`categories`와 동일한 결과) |
| 인증/권한 수준 | 로그인한 사용자라면 역할(학생/교사/관리자) 무관 허용. 별도 권한 분기 로직 불필요, `/graphql` 엔드포인트의 기본 JWT 인증만 통과하면 됨 |
| 서비스 구조 | Fetch/Search를 완전히 별도의 `UseCase`/`Service`로 분리 (`FetchCategoriesUseCase`/`Service`, `SearchCategoriesUseCase`/`Service`) — convention.md의 Fetch/Search 키워드 규칙을 따름 |

## 3. 데이터 모델

변경 없음. 기존 `category_tb` / `CategoryJpaEntity` / `Category` 도메인 모델을 그대로 사용하고 신규 컬럼은
추가하지 않는다.

`maxRecordCount`는 기존 `categoryMaximumValue`를 그대로 매핑한다. 이 필드는 `COUNT_BASED`(예:
자격증 개수)뿐 아니라 `SCORE_BASED`(예: TOPCIT 최대 점수) 카테고리에도 공통으로 쓰이는 값이라 "record
count"라는 이름이 후자의 의미와는 다소 어긋나지만, 실제 값은 하나이고 이번 요청은 티켓 스펙의 필드명을
그대로 쓰기로 했으므로 이름만 그대로 노출한다(§8 참고).

## 4. 신규/변경 파일

```
domain/category/port/out/CategoryPersistencePort.kt — 변경: findAll(), searchByKeyword(keyword) 추가
domain/category/adapter/out/persistence/CategoryPersistenceAdapter.kt
— 변경: JPAQueryFactory 주입 + QueryDSL로 신규 포트 메서드 구현
(CategoryJpaRepository는 기존 findByCategoryType만 그대로 유지, 변경 없음)

domain/category/port/in/FetchCategoriesUseCase.kt — 신규
domain/category/port/in/SearchCategoriesUseCase.kt — 신규
domain/category/service/FetchCategoriesService.kt — 신규
domain/category/service/SearchCategoriesService.kt — 신규

domain/category/adapter/web/CategoryPayload.kt — 신규: 응답 DTO + Category.toPayload() 확장함수
domain/category/adapter/web/CategoryWebAdapter.kt — 신규: @QueryMapping categories/searchCategories

src/main/resources/graphql/category.graphqls — 변경: CategoryPayload 타입 + extend type Query 추가
```

`ProjectWebAdapter`/`ScoreWebAdapter`와 동일하게 GraphQL 인바운드 어댑터 패키지는 `adapter/web`을 쓴다
(아키텍처 문서의 `adapter/in`은 이 코드베이스에서 실제로는 `adapter/web`으로 명명되어 있음). Mutation
Input DTO가 `domain/score/adapter/web/ScoreWithValueInput.kt`처럼 어댑터 패키지에 flat하게 위치하는
선례를 따라 `CategoryPayload`도 같은 패키지에 둔다.

## 5. GraphQL 스키마

**`category.graphqls` 추가분** (기존 `type Category`/enum 선언은 그대로 유지)

```graphql
type CategoryPayload {
categoryType: CategoryType!
englishName: String!
koreanName: String!
weight: Int!
maxRecordCount: Int!
evidenceType: EvidenceType!
calculationType: ScoreCalculationType!
}

extend type Query {
categories: [CategoryPayload!]!
searchCategories(keyword: String): [CategoryPayload!]!
}
```

## 6. 신규 `ErrorCode`

없음. 두 Query 모두 결과가 없으면 예외 없이 빈 리스트를 반환한다(카테고리 부재는 예외적 상황이 아님).

## 7. 처리 흐름

1. **`categories`**: (인증은 Spring Security 필터에서 전역 처리) →
`CategoryPersistencePort.findAll()` (categoryId 오름차순 정렬) → 각 `Category`를
`CategoryPayload`로 매핑 → 반환
2. **`searchCategories(keyword)`**:
- `keyword`가 `null`이거나 blank → `FetchCategoriesUseCase`와 동일하게 `findAll()` 결과 전체 반환
- 그 외 → `CategoryPersistencePort.searchByKeyword(keyword)` (영문명/한글명 대소문자 무시 부분일치,
categoryId 오름차순) → `CategoryPayload` 매핑 → 반환

### `CategoryPersistenceAdapter` QueryDSL 구현(예시)

`ScorePersistenceAdapter`와 동일하게 `JPAQueryFactory`를 생성자 주입받아 Q타입(`QCategoryJpaEntity.categoryJpaEntity`)으로 조회한다.

```kotlin
override fun findAll(): List<Category> =
queryFactory
.selectFrom(categoryJpaEntity)
.orderBy(categoryJpaEntity.categoryId.asc())
.fetch()
.map { it.toDomain() }

override fun searchByKeyword(keyword: String): List<Category> =
queryFactory
.selectFrom(categoryJpaEntity)
.where(
categoryJpaEntity.categoryEnglishName.containsIgnoreCase(keyword)
.or(categoryJpaEntity.categoryKoreanName.containsIgnoreCase(keyword)),
).orderBy(categoryJpaEntity.categoryId.asc())
.fetch()
.map { it.toDomain() }
```

## 8. 열린 이슈 (의도적으로 범위 밖으로 둔 것)

- `maxRecordCount`라는 필드명이 `SCORE_BASED` 카테고리(TOPCIT/TOEIC 등 "최대 점수")에는 의미상 어색할 수
있음 — 프론트엔드와 필드 의미를 다시 확인할 여지가 있으나, 이번 구현은 티켓 스펙의 필드명을 그대로 따름
- `CategoryPayload`는 이 코드베이스에서 "도메인과 필드명이 다른" Payload DTO의 첫 사례다. 지금까지는
GraphQL 타입 필드명과 도메인 프로퍼티명이 항상 일치해 별도 매핑 DTO 없이 도메인 객체를 그대로
반환해왔다(`ProjectWebAdapter`가 `DataGsmProject`를 그대로 반환하는 식) — 이후 유사 케이스가 생기면
이번 `Category.toPayload()` 확장함수 패턴을 참고할 수 있음
- `category_tb`는 사실상 불변 데이터라 캐싱(Redis 등) 여지가 있으나 이번 범위에서는 다루지 않음

## 9. 작업 순서 (체크리스트)

1. [x] `CategoryPersistencePort`에 `findAll()`, `searchByKeyword(keyword: String)` 추가
2. [x] `CategoryPersistenceAdapter`에 `JPAQueryFactory` 주입, QueryDSL로 신규 포트 메서드 2종 구현(`toDomain()` 매핑 재사용)
3. [x] `FetchCategoriesUseCase`/`FetchCategoriesService` 구현
4. [x] `SearchCategoriesUseCase`/`SearchCategoriesService` 구현 (keyword blank 분기 포함)
5. [x] `CategoryPayload` data class + `Category.toPayload()` 확장함수 작성
6. [x] `CategoryWebAdapter` 작성 (`@QueryMapping categories`, `@QueryMapping searchCategories`)
7. [x] `category.graphqls`에 `CategoryPayload` 타입 + `extend type Query` 추가
8. [x] KtLint 포맷 적용
9. [ ] GraphiQL로 `categories`/`searchCategories`(keyword 있음/없음/빈 문자열) 수동 검증
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package team.incube.gsmc.domain.category.adapter.out.persistence

import com.querydsl.jpa.impl.JPAQueryFactory
import team.incube.gsmc.domain.category.Category
import team.incube.gsmc.domain.category.CategoryType
import team.incube.gsmc.domain.category.adapter.out.persistence.entity.QCategoryJpaEntity.categoryJpaEntity
import team.incube.gsmc.domain.category.adapter.out.persistence.entity.toDomain
import team.incube.gsmc.domain.category.adapter.out.persistence.repository.CategoryJpaRepository
import team.incube.gsmc.domain.category.port.out.CategoryPersistencePort
Expand All @@ -10,12 +12,32 @@ import team.incube.gsmc.global.annotation.adapter.Adapter

/**
* 카테고리 영속성 처리를 담당하는 아웃바운드 어댑터 클래스입니다.
* [CategoryPersistencePort]를 구현하며, 카테고리 조회 기능을 [CategoryJpaRepository]에 위임합니다.
* [CategoryPersistencePort]를 구현하며, 단건 조회는 [CategoryJpaRepository]에, 목록 조회/검색은
* QueryDSL(`JPAQueryFactory`)에 위임합니다.
*/
@Adapter(direction = PortDirection.OUTBOUND)
class CategoryPersistenceAdapter(
private val queryFactory: JPAQueryFactory,
private val categoryJpaRepository: CategoryJpaRepository,
) : CategoryPersistencePort {
override fun findByCategoryType(categoryType: CategoryType): Category? =
categoryJpaRepository.findByCategoryType(categoryType)?.toDomain()

override fun findAll(): List<Category> =
queryFactory
.selectFrom(categoryJpaEntity)
.orderBy(categoryJpaEntity.categoryId.asc())
.fetch()
.map { it.toDomain() }

override fun searchByKeyword(keyword: String): List<Category> =
queryFactory
.selectFrom(categoryJpaEntity)
.where(
categoryJpaEntity.categoryEnglishName
.containsIgnoreCase(keyword)
.or(categoryJpaEntity.categoryKoreanName.containsIgnoreCase(keyword)),
).orderBy(categoryJpaEntity.categoryId.asc())
.fetch()
.map { it.toDomain() }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package team.incube.gsmc.domain.category.adapter.web

import team.incube.gsmc.domain.category.Category
import team.incube.gsmc.domain.category.CategoryType
import team.incube.gsmc.domain.category.EvidenceType
import team.incube.gsmc.domain.category.ScoreCalculationType

/**
* 카테고리 조회/검색 Query의 GraphQL 응답 DTO입니다.
* 도메인 [Category]와 필드명이 달라(`categoryEnglishName` → `englishName` 등) 별도로 매핑합니다.
*/
data class CategoryPayload(
val categoryType: CategoryType,
val englishName: String,
val koreanName: String,
val weight: Int,
val maxRecordCount: Int,
val evidenceType: EvidenceType,
val calculationType: ScoreCalculationType,
)

/**
* 도메인 모델 [Category]를 [CategoryPayload]로 변환한다.
*
* @receiver 변환할 도메인 객체
* @return 변환된 [CategoryPayload]
*/
fun Category.toPayload(): CategoryPayload =
CategoryPayload(
categoryType = categoryType,
englishName = categoryEnglishName,
koreanName = categoryKoreanName,
weight = weight,
maxRecordCount = categoryMaximumValue,
evidenceType = evidenceType,
calculationType = calculationType,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package team.incube.gsmc.domain.category.adapter.web

import org.springframework.graphql.data.method.annotation.Argument
import org.springframework.graphql.data.method.annotation.QueryMapping
import org.springframework.stereotype.Controller
import team.incube.gsmc.domain.category.port.`in`.FetchCategoriesUseCase
import team.incube.gsmc.domain.category.port.`in`.SearchCategoriesUseCase

/**
* 카테고리 조회/검색 GraphQL Query 리졸버입니다.
* Query를 대응하는 UseCase에 위임하는 것 외의 비즈니스 로직은 갖지 않습니다.
*/
@Controller
class CategoryWebAdapter(
private val fetchCategoriesUseCase: FetchCategoriesUseCase,
private val searchCategoriesUseCase: SearchCategoriesUseCase,
) {
@QueryMapping
fun categories(): List<CategoryPayload> = fetchCategoriesUseCase.execute().map { it.toPayload() }

@QueryMapping
fun searchCategories(
@Argument keyword: String?,
): List<CategoryPayload> = searchCategoriesUseCase.execute(keyword).map { it.toPayload() }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@file:Suppress("ktlint:standard:package-name")

package team.incube.gsmc.domain.category.port.`in`

import team.incube.gsmc.domain.category.Category

/**
* 전체 카테고리 목록 조회 유스케이스 인터페이스입니다.
*/
interface FetchCategoriesUseCase {
/**
* @return categoryId 오름차순으로 정렬된 전체 카테고리 목록
*/
fun execute(): List<Category>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@file:Suppress("ktlint:standard:package-name")

package team.incube.gsmc.domain.category.port.`in`

import team.incube.gsmc.domain.category.Category

/**
* 키워드 기반 카테고리 검색 유스케이스 인터페이스입니다.
*/
interface SearchCategoriesUseCase {
/**
* 영문명 또는 한글명에 keyword가 포함된(대소문자 무시) 카테고리를 조회한다.
* keyword가 null이거나 blank이면 전체 카테고리를 반환한다.
*
* @param keyword 검색 키워드
* @return categoryId 오름차순으로 정렬된 카테고리 목록
*/
fun execute(keyword: String?): List<Category>
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,19 @@ interface CategoryPersistencePort {
* @return 해당 카테고리, 없으면 null
*/
fun findByCategoryType(categoryType: CategoryType): Category?

/**
* 전체 카테고리를 categoryId 오름차순으로 조회한다.
*
* @return 전체 카테고리 목록
*/
fun findAll(): List<Category>

/**
* 영문명 또는 한글명에 keyword가 포함된(대소문자 무시) 카테고리를 categoryId 오름차순으로 조회한다.
*
* @param keyword 검색 키워드
* @return 매칭된 카테고리 목록
*/
fun searchByKeyword(keyword: String): List<Category>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package team.incube.gsmc.domain.category.service

import org.springframework.transaction.annotation.Transactional
import team.incube.gsmc.domain.category.Category
import team.incube.gsmc.domain.category.port.`in`.FetchCategoriesUseCase
import team.incube.gsmc.domain.category.port.out.CategoryPersistencePort
import team.incube.gsmc.global.annotation.PortDirection
import team.incube.gsmc.global.annotation.port.Port

/**
* 전체 카테고리 목록 조회 유스케이스 구현 클래스입니다.
* [FetchCategoriesUseCase]를 구현하며, 조회를 [CategoryPersistencePort]에 위임합니다.
*/
@Port(direction = PortDirection.INBOUND)
class FetchCategoriesService(
Comment thread
jyx-07 marked this conversation as resolved.
private val categoryPersistencePort: CategoryPersistencePort,
) : FetchCategoriesUseCase {
@Transactional(readOnly = true)
override fun execute(): List<Category> = categoryPersistencePort.findAll()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package team.incube.gsmc.domain.category.service

import org.springframework.transaction.annotation.Transactional
import team.incube.gsmc.domain.category.Category
import team.incube.gsmc.domain.category.port.`in`.SearchCategoriesUseCase
import team.incube.gsmc.domain.category.port.out.CategoryPersistencePort
import team.incube.gsmc.global.annotation.PortDirection
import team.incube.gsmc.global.annotation.port.Port

/**
* 키워드 기반 카테고리 검색 유스케이스 구현 클래스입니다.
* [SearchCategoriesUseCase]를 구현하며, keyword가 없으면 전체 목록을, 있으면 검색 결과를
* [CategoryPersistencePort]에서 조회합니다.
*/
@Port(direction = PortDirection.INBOUND)
class SearchCategoriesService(
Comment thread
jyx-07 marked this conversation as resolved.
private val categoryPersistencePort: CategoryPersistencePort,
) : SearchCategoriesUseCase {
@Transactional(readOnly = true)
override fun execute(keyword: String?): List<Category> =
if (keyword.isNullOrBlank()) {
categoryPersistencePort.findAll()
} else {
categoryPersistencePort.searchByKeyword(keyword.trim())
}
Comment thread
jyx-07 marked this conversation as resolved.
}
15 changes: 15 additions & 0 deletions src/main/resources/graphql/category.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,18 @@ enum ScoreCalculationType {
COUNT_BASED
SCORE_BASED
}

type CategoryPayload {
categoryType: CategoryType!
englishName: String!
koreanName: String!
weight: Int!
maxRecordCount: Int!
evidenceType: EvidenceType!
calculationType: ScoreCalculationType!
}

extend type Query {
categories: [CategoryPayload!]!
searchCategories(keyword: String): [CategoryPayload!]!
}
Loading
Loading