Skip to content

Commit 5001f0e

Browse files
robinjoonclaude
andauthored
feat: GraphQL 예외 처리 표준 적용 (#8)
* feat: GraphQL 예외 처리 표준 적용 (sealed LoopException + @ControllerAdvice 핸들러) VO의 require() → InvalidInputException, Service의 범용 예외를 도메인 예외로 교체하고 @ControllerAdvice 핸들러로 예외 → GraphQL ErrorType 매핑을 구현하여 클라이언트가 BAD_REQUEST, NOT_FOUND 등을 구분할 수 있도록 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: SpectaQL 문서 대상에 auth API 스키마 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1e42790 commit 5001f0e

14 files changed

Lines changed: 185 additions & 23 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# GraphQL 예외 처리 표준 적용 검증 체크리스트
2+
3+
## 필수 항목
4+
- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준)
5+
- [x] 레이어 의존성 규칙 위반 없음
6+
- [x] 테스트 코드 작성 완료 (Domain, Application 필수)
7+
- [x] 모든 테스트 통과
8+
- [x] 기존 테스트 깨지지 않음
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# GraphQL 예외 처리 표준 적용 맥락
2+
3+
## 배경
4+
현재 auth, common BC의 코드가 `docs/graphql-error-handling.md`에 정의된 예외 처리 표준을 따르지 않음.
5+
- VO에서 `require()``IllegalArgumentException` (클라이언트에 `INTERNAL`로 전달)
6+
- Service에서 `NoSuchElementException`, `require()` 사용 (역시 `INTERNAL`로 전달)
7+
- 예외 → GraphQL ErrorType 매핑 핸들러 부재
8+
9+
## 목표
10+
- sealed `LoopException` 예외 계층 도입
11+
- 기존 코드의 범용 예외를 도메인 예외로 교체
12+
- `@ControllerAdvice` 핸들러로 예외 → GraphQL ErrorType 자동 매핑
13+
14+
## 제약조건
15+
- Domain 순수성 유지 (프레임워크 의존 없음)
16+
- 기존 테스트 모두 통과
17+
- Architecture 규칙 준수
18+
19+
## 관련 문서
20+
- `docs/graphql-error-handling.md`
21+
- `docs/architecture.md`
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# GraphQL 예외 처리 표준 적용 계획
2+
3+
## 단계
4+
5+
- [x] 1단계: `Exceptions.kt` 생성 (sealed LoopException + 6개 하위 클래스)
6+
- [x] 2단계: Domain VO 마이그레이션 (MemberId, LoginId, Nickname) + 테스트 업데이트
7+
- [x] 3단계: AuthService 마이그레이션 + 테스트 업데이트
8+
- [x] 4단계: AuthorizeArgumentResolver 마이그레이션
9+
- [x] 5단계: `GraphQlExceptionHandler.kt` 생성
10+
- [x] 6단계: 전체 테스트 실행 검증

spectaql/config.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@ spectaql:
33

44
introspection:
55
schemaFile:
6-
- src/main/resources/schema/learning.graphqls
7-
# BC별 스키마 추가 시 여기에 경로 추가하고 learning.graphqls는 제거
6+
- src/main/resources/schema/auth.graphqls
87
# - src/main/resources/schema/task.graphqls
9-
# - src/main/resources/schema/auth.graphqls
108

119
info:
1210
title: Loop GraphQL API

src/main/kotlin/kr/io/team/loop/auth/application/service/AuthService.kt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import kr.io.team.loop.auth.domain.model.LoginId
55
import kr.io.team.loop.auth.domain.model.MemberCommand
66
import kr.io.team.loop.auth.domain.repository.MemberRepository
77
import kr.io.team.loop.common.config.JwtTokenProvider
8+
import kr.io.team.loop.common.domain.exception.AuthenticationException
9+
import kr.io.team.loop.common.domain.exception.DuplicateEntityException
10+
import kr.io.team.loop.common.domain.exception.EntityNotFoundException
811
import org.springframework.security.crypto.password.PasswordEncoder
912
import org.springframework.stereotype.Service
1013
import org.springframework.transaction.annotation.Transactional
@@ -17,8 +20,8 @@ class AuthService(
1720
) {
1821
@Transactional
1922
fun register(command: MemberCommand.Register): AuthTokenDto {
20-
require(!memberRepository.existsByLoginId(command.loginId)) {
21-
"LoginId already exists: ${command.loginId.value}"
23+
if (memberRepository.existsByLoginId(command.loginId)) {
24+
throw DuplicateEntityException("LoginId already exists: ${command.loginId.value}")
2225
}
2326
val encodedPassword = checkNotNull(passwordEncoder.encode(command.rawPassword))
2427
val member = memberRepository.save(command, encodedPassword)
@@ -33,9 +36,9 @@ class AuthService(
3336
): AuthTokenDto {
3437
val member =
3538
memberRepository.findByLoginId(loginId)
36-
?: throw NoSuchElementException("Member not found: ${loginId.value}")
37-
require(passwordEncoder.matches(rawPassword, member.password)) {
38-
"Password does not match"
39+
?: throw EntityNotFoundException("Member not found: ${loginId.value}")
40+
if (!passwordEncoder.matches(rawPassword, member.password)) {
41+
throw AuthenticationException("Password does not match")
3942
}
4043
val token = jwtTokenProvider.generateToken(member.id.value)
4144
return AuthTokenDto(accessToken = token)
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package kr.io.team.loop.auth.domain.model
22

3+
import kr.io.team.loop.common.domain.exception.InvalidInputException
4+
35
@JvmInline
46
value class LoginId(
57
val value: String,
68
) {
79
init {
8-
require(value.isNotBlank()) { "LoginId must not be blank" }
9-
require(value.length <= 50) { "LoginId must not exceed 50 characters" }
10+
if (value.isBlank()) throw InvalidInputException("LoginId must not be blank")
11+
if (value.length > 50) throw InvalidInputException("LoginId must not exceed 50 characters")
1012
}
1113
}
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package kr.io.team.loop.auth.domain.model
22

3+
import kr.io.team.loop.common.domain.exception.InvalidInputException
4+
35
@JvmInline
46
value class Nickname(
57
val value: String,
68
) {
79
init {
8-
require(value.isNotBlank()) { "Nickname must not be blank" }
9-
require(value.length <= 30) { "Nickname must not exceed 30 characters" }
10+
if (value.isBlank()) throw InvalidInputException("Nickname must not be blank")
11+
if (value.length > 30) throw InvalidInputException("Nickname must not exceed 30 characters")
1012
}
1113
}

src/main/kotlin/kr/io/team/loop/common/config/AuthorizeArgumentResolver.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package kr.io.team.loop.common.config
33
import com.netflix.graphql.dgs.context.DgsContext
44
import com.netflix.graphql.dgs.internal.method.ArgumentResolver
55
import graphql.schema.DataFetchingEnvironment
6+
import kr.io.team.loop.common.domain.exception.AuthenticationException
67
import org.springframework.core.MethodParameter
78
import org.springframework.core.Ordered
89
import org.springframework.core.annotation.Order
@@ -28,7 +29,7 @@ class AuthorizeArgumentResolver(
2829
}
2930

3031
if (authorize.require) {
31-
throw IllegalArgumentException("Authentication required")
32+
throw AuthenticationException("Authentication required")
3233
}
3334
return null
3435
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package kr.io.team.loop.common.config
2+
3+
import com.netflix.graphql.types.errors.ErrorType
4+
import graphql.GraphQLError
5+
import kr.io.team.loop.common.domain.exception.AccessDeniedException
6+
import kr.io.team.loop.common.domain.exception.AuthenticationException
7+
import kr.io.team.loop.common.domain.exception.BusinessRuleException
8+
import kr.io.team.loop.common.domain.exception.DuplicateEntityException
9+
import kr.io.team.loop.common.domain.exception.EntityNotFoundException
10+
import kr.io.team.loop.common.domain.exception.InvalidInputException
11+
import org.slf4j.LoggerFactory
12+
import org.springframework.graphql.data.method.annotation.GraphQlExceptionHandler
13+
import org.springframework.web.bind.annotation.ControllerAdvice
14+
15+
@ControllerAdvice
16+
class GraphQlExceptionHandler {
17+
private val log = LoggerFactory.getLogger(javaClass)
18+
19+
@GraphQlExceptionHandler
20+
fun handleInvalidInput(ex: InvalidInputException): GraphQLError {
21+
log.warn("[BAD_REQUEST] {}", ex.message, ex)
22+
return GraphQLError
23+
.newError()
24+
.errorType(ErrorType.BAD_REQUEST)
25+
.message(ex.message)
26+
.build()
27+
}
28+
29+
@GraphQlExceptionHandler
30+
fun handleEntityNotFound(ex: EntityNotFoundException): GraphQLError {
31+
log.warn("[NOT_FOUND] {}", ex.message, ex)
32+
return GraphQLError
33+
.newError()
34+
.errorType(ErrorType.NOT_FOUND)
35+
.message(ex.message)
36+
.build()
37+
}
38+
39+
@GraphQlExceptionHandler
40+
fun handleDuplicateEntity(ex: DuplicateEntityException): GraphQLError {
41+
log.warn("[FAILED_PRECONDITION] {}", ex.message, ex)
42+
return GraphQLError
43+
.newError()
44+
.errorType(ErrorType.FAILED_PRECONDITION)
45+
.message(ex.message)
46+
.build()
47+
}
48+
49+
@GraphQlExceptionHandler
50+
fun handleBusinessRule(ex: BusinessRuleException): GraphQLError {
51+
log.warn("[FAILED_PRECONDITION] {}", ex.message, ex)
52+
return GraphQLError
53+
.newError()
54+
.errorType(ErrorType.FAILED_PRECONDITION)
55+
.message(ex.message)
56+
.build()
57+
}
58+
59+
@GraphQlExceptionHandler
60+
fun handleAuthentication(ex: AuthenticationException): GraphQLError {
61+
log.warn("[UNAUTHENTICATED] {}", ex.message, ex)
62+
return GraphQLError
63+
.newError()
64+
.errorType(ErrorType.UNAUTHENTICATED)
65+
.message(ex.message)
66+
.build()
67+
}
68+
69+
@GraphQlExceptionHandler
70+
fun handleAccessDenied(ex: AccessDeniedException): GraphQLError {
71+
log.warn("[PERMISSION_DENIED] {}", ex.message, ex)
72+
return GraphQLError
73+
.newError()
74+
.errorType(ErrorType.PERMISSION_DENIED)
75+
.message(ex.message)
76+
.build()
77+
}
78+
}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package kr.io.team.loop.common.domain
22

3+
import kr.io.team.loop.common.domain.exception.InvalidInputException
4+
35
@JvmInline
46
value class MemberId(
57
val value: Long,
68
) {
79
init {
8-
require(value > 0) { "MemberId must be positive" }
10+
if (value <= 0) throw InvalidInputException("MemberId must be positive")
911
}
1012
}

0 commit comments

Comments
 (0)