| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- SolveGO
- query optimization
- performance test
- Slice Test
- JPA
- branch protection
- SelfHostedRunner
- 테스트 코드
- docker
- ERD설게
- backend
- Service Layer
- 백엔드 회원가입 구현
- 제1종오류
- B+Tree
- Spring Boot
- JWT
- 틀린문제조회
- CI
- Spring Security
- Integration Test
- 책임분리
- rest api
- EC2
- 제2종오류
- springboot
- MySQL
- AWS
- 백엔드 로그인 구현
- 백엔드
- Today
- Total
노트
[SolveGO #7] 문제가 문제요 - 문제 등록 API 구현과 ERROR Dispatch 트러블슈팅 본문
[SolveGO #7] 문제가 문제요 - 문제 등록 API 구현과 ERROR Dispatch 트러블슈팅
forwarder 2026. 6. 25. 11:42

이제는 SolveGo의 문제 등록 API를 구현하자.
모든 설계는 기존에 완료한 바, 단순히 CRUD 중의 Create 만 작성하면 되므로 간단하다.
시퀀스 다이어그램으로 해당 API의 처리 흐름을 정리한 뒤, 바텀업 방식으로 구현할 것이다.
또한 예외 처리를 하는 과정에서 Spring Security의 특성으로 인해 적절하게 예외 처리가 되지 않는 문제가 발생했는데,
이 문제의 원인을 파악하는 과정도 자세히 기술할 예정이다.
무슨 문제를 풀어야 하는가
[SolveGO #2] 유죄추정의 원칙 - API 설계
서론 우리나라 헌법에는 무죄추정의 원칙이 있다.이는 유죄가 확정되기 전까지 모든 사람을 무죄로 간주한다는 원칙이다.왜 이런 원칙이 존재할까? 그 이유는 아래 두 종류의 오류 중 하나를 더
forwarder1121.tistory.com
이 글에서 정의한 API 설계 중 3. 문제 등록에 해당하는 API를 구현하면 된다.
해당 API의 세부 설계는 아래와 같았다.
UC-03 문제 등록
문제 등록은 사용자가 새로운 바둑 문제를 생성하는 기능이다.
SolveGo에서는 문제를 이미지로 저장하지 않고, 바둑판의 상태를 저장하도록 설계했다.
바둑판은 흑돌 좌표집합, 백돌 좌표 집합, 다음 차례만으로 완전하게 표현될 수 있기 때문이다.
렌더링에 대한 책임은 클라이언트가 지도록 하였다.
Scenario
- 사용자는 제목, 제시문, 흑돌 좌표, 백돌 좌표, 다음 차례, 정답 좌표를 입력한다.
- 시스템은 입력된 문제 정보를 검증한다.
- 시스템은 현재 로그인한 사용자를 작성자로 지정한다.
- 시스템은 문제 정보를 저장한다.
- 등록된 문제는 문제 목록에 노출된다.
API 명세
- 관여 도메인 : Problem, User
- Action : Create
- Method : POST
- API URL : /api/problems
- 인증 필요 여부 : O
Request Header
Authorization: Bearer {accessToken}
문제 등록은 인증된 사용자만 수행할 수 있어야 한다.
따라서 클라이언트는 로그인 과정에서 발급받은 JWT Access Token을 요청 Header에 포함하여 서버에 전달한다.
Request Body
{
"title": "화점 사활 문제",
"description": "흑이 살 수 있는 수를 찾으세요.",
"blackStones": [
{ "x": 3, "y": 4 },
{ "x": 4, "y": 4 }
],
"whiteStones": [
{ "x": 3, "y": 5 },
{ "x": 4, "y": 5 }
],
"nextPlayer": "BLACK",
"answerPosition": {
"x": 5,
"y": 4
}
}
Response Body
{
"problemId": 1
}
Status Code
- 201 Created : 문제 등록 성공
- 400 Bad Request : 잘못된 입력 정보
- 401 Unauthorized : 인증되지 않은 사용자
어떻게 문제를 풀어야 하는가
POST /api/problems는 로그인한 사용자만 접근할 수 있어야 한다.
따라서 요청 Header에 포함된 JWT Access Token을 검증해야 한다.
JWT가 유효하다면 토큰에서 사용자 ID를 추출한다.
이후 해당 사용자 ID로 작성자 User를 조회하고, 요청 본문의 문제 정보를 Problem 엔티티로 변환하여 DB에 저장하면 된다.
시퀀스 다이어그램은 아래와 같다.

이 API에서 중요한 점은 Controller가 직접 사용자를 판단하지 않는다는 것이다. (SoC - Controller는 처리흐름만 책임진다)
사용자가 누구인지는 JWT 인증 필터가 토큰을 검증한 뒤 SecurityContext에 저장한다.
Controller는 SecurityContext에서 현재 사용자의 ID를 꺼내 Service에 전달한다.(SecurityContext는 요청마다 고유한 ThreadLocal 기반 SecurityContextHolder에 저장되므로 사용가능)
Service는 전달받은 사용자 ID로 작성자를 조회하고, 요청 본문에 담긴 문제 정보를 Problem 엔티티로 변환한 뒤 저장한다.
그러면 바텀업 방식으로
DTO -> Repository -> Service -> Controller -> GlobalExceptionHandler 순으로 구현하자.
API의 요청 본문을 객체로 매핑하기 위해 ProblemCreateRequest를 만들자.
기존 회원가입 API에서는 DTO를 일반 클래스로 작성했지만, 문제 도메인부터는 DTO를 record로 정의했다.
DTO는 주로 데이터를 전달하는 역할을 하므로, 별도의 상태 변경 로직을 가질 필요가 없다.
따라서 불변 객체에 가까운 record를 사용하면 필드 선언만으로 생성자, 필드 접근메서드, equals, hashCode, toString 등을 컴파일러가 자동으로 생성해준다.
이를 통해 일반 클래스로 DTO를 작성할 때보다 코드가 훨씬 간결해지고, DTO의 의도도 명확해진다
package com.kdh.solvego.domain.problem.dto;
import com.kdh.solvego.domain.problem.PlayerColor;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record ProblemCreateRequest(
@NotBlank
String title,
@NotBlank
String description,
@NotEmpty
List<@Valid Position> blackStones,
@NotEmpty
List<@Valid Position> whiteStones,
@NotNull
PlayerColor nextPlayer,
@NotNull
@Valid
Position answerPosition
) {
}
title과 description은 빈 문자열을 허용하지 않기 위해 @NotBlank를 사용했다.
blackStones와 whiteStones는 각각 흑돌 좌표 목록과 백돌 좌표 목록이다.
바둑 문제는 최소한 흑돌과 백돌이 모두 존재해야 한다고 판단했기 때문에 @NotEmpty를 사용했다.
또한 각 좌표 객체 내부의 x, y 값도 검증되어야 하므로 List<@Valid Position> 형태로 선언했다.
이렇게 해야 리스트 내부에 들어 있는 Position 객체의 검증 조건까지 함께 적용된다. (최소 0 최대 18이 되도록 제약을 걸어두었다)
nextPlayer와 answerPosition은 문제 풀이에 반드시 필요한 값이므로 @NotNull을 사용했다.
answerPosition 역시 내부 좌표 검증이 필요하므로 @Valid를 함께 적용했다.
응답을 보내기 위한 DTO인 ProblemCreateResponse는 아래와 같이 정의했다.
package com.kdh.solvego.domain.problem.dto;
public record ProblemCreateResponse(
Long problemId
) {
}
문제 등록 API는 문제 생성에 성공하면 생성된 문제의 ID만 반환하면 된다.
따라서 응답 DTO는 problemId 하나만 가지도록 단순하게 구성했다.
이처럼 요청 DTO와 응답 DTO를 분리하면, 클라이언트가 서버에 전달하는 데이터와 서버가 클라이언트에게 반환하는 데이터를 명확하게 구분할 수 있다.
문제를 저장하는 ProblemRepository는 아래와 같다.
package com.kdh.solvego.domain.problem;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProblemRepository extends JpaRepository<Problem, Long> {
}
JpaRepository를 상속하면 save(), findById(), findAll(), delete()와 같은 기본적인 데이터 접근 메서드를 직접 구현하지 않아도 사용할 수 있다.
시퀀스 다이어그램을 보면, 이 문제 등록 API에서는 Problem 엔티티를 DB에 저장해야 하므로 이후 Service 계층에서 problemRepository.save(problem)을 호출하게 된다.
여기서 ProblemRepository는 인터페이스일 뿐이지만,
Spring Data JPA가 애플리케이션 실행 시점에 이 인터페이스를 기반으로 프록시 구현체를 생성하고 Spring Bean으로 등록한다.
따라서 별도의 구현 클래스를 작성하지 않아도 Repository를 주입받아 사용할 수 있다.
Problem 도메인의 비즈니스 로직을 책임지는 ProblemService는 아래와 같다.
package com.kdh.solvego.domain.problem;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kdh.solvego.domain.problem.dto.ProblemCreateRequest;
import com.kdh.solvego.domain.problem.dto.ProblemCreateResponse;
import com.kdh.solvego.domain.user.User;
import com.kdh.solvego.domain.user.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProblemService {
private final ProblemRepository problemRepository;
private final UserRepository userRepository;
private final ObjectMapper objectMapper;
public ProblemService(ProblemRepository problemRepository, UserRepository userRepository, ObjectMapper objectMapper) {
this.problemRepository = problemRepository;
this.objectMapper = objectMapper;
this.userRepository = userRepository;
}
@Transactional
public ProblemCreateResponse createProblem(Long userId, ProblemCreateRequest request) {
User creator=userRepository.findById(userId).orElseThrow();
String blackStonesJson = toJson(request.blackStones());
String whiteStonesJson = toJson(request.whiteStones());
Problem problem = new Problem(
request.title(),
request.description(),
blackStonesJson,
whiteStonesJson,
request.nextPlayer(),
request.answerPosition().x(),
request.answerPosition().y(),
creator
);
Problem savedProblem = problemRepository.save(problem);
return new ProblemCreateResponse(savedProblem.getId());
}
private String toJson(Object value) {
try{
return objectMapper.writeValueAsString(value);
} catch(JsonProcessingException e){
throw new IllegalArgumentException("Invalid stone position format");
}
}
}
ProblemService는 문제 등록 API의 핵심 처리 흐름을 담당한다.
먼저 현재 로그인한 사용자를 문제 작성자로 지정해야 하므로,
userId를 이용해 UserRepository에서 작성자 User를 조회한다.
User creator = userRepository.findById(userId).orElseThrow();
여기서 userId는 Controller가 직접 판단한 값이 아니라,
앞단의 JWT 인증 필터가 토큰을 검증한 뒤 SecurityContext에 저장한 인증 정보에서 꺼낸 값이다.
요청 본문에 포함된 흑돌 좌표 목록과 백돌 좌표 목록을 JSON 문자열로 변환한다.
String blackStonesJson = toJson(request.blackStones());
String whiteStonesJson = toJson(request.whiteStones());
SolveGO에서는 문제를 이미지로 저장하지 않고, 바둑판의 상태를 저장한다. 흑돌과 백돌의 좌표 목록은 하나의 상태 값으로 함께 저장되고 조회되므로, MVP 단계에서는 별도의 Stone 테이블로 분리하지 않고 JSON 컬럼에 저장하도록 설계했다.
JSON 변환에는 Spring Boot가 자동으로 Bean으로 등록해둔 ObjectMapper를 사용했다.
private final ObjectMapper objectMapper;
ObjectMapper는 Java 객체를 JSON 문자열로 변환하거나,
반대로 JSON 문자열을 Java 객체로 변환할 때 사용하는 Jackson의 객체다.
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Invalid stone position format");
}
}
이후 요청 데이터와 작성자 정보를 바탕으로 Problem 엔티티를 생성한다.
Problem problem = new Problem(
request.title(),
request.description(),
blackStonesJson,
whiteStonesJson,
request.nextPlayer(),
request.answerPosition().x(),
request.answerPosition().y(),
creator
);
마지막으로 ProblemRepository를 통해 문제를 저장한다.
Problem savedProblem = problemRepository.save(problem);
저장된 문제의 ID는 응답 DTO인 ProblemCreateResponse에 담아 반환한다.
return new ProblemCreateResponse(savedProblem.getId());
이 메서드에는 @Transactional을 적용했다.
문제 등록은 작성자 조회와 문제 저장이 하나의 유스케이스 안에서 수행되는 작업이므로, 하나의 트랜잭션으로 묶어 처리하는 것이 자연스럽다.
마지막으로 구조에서는 Service 계층에서 ObjectMapper를 사용해 좌표 목록을 JSON 문자열로 변환한다. 이는 MVP 단계에서는 단순하고 직관적인 방식이다. 다만 Service가 DB 저장 포맷을 직접 알게 된다는 한계도 있으므로, 추후에는 BoardState 값 객체와 JPA AttributeConverter를 도입하여 JSON 변환 책임을 분리할 수 있다.
이제 controller 코드를 보자.
package com.kdh.solvego.domain.problem;
import com.kdh.solvego.domain.problem.dto.ProblemCreateRequest;
import com.kdh.solvego.domain.problem.dto.ProblemCreateResponse;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/problems")
public class ProblemController {
private final ProblemService problemService;
public ProblemController(ProblemService problemService) {
this.problemService = problemService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProblemCreateResponse createProblem(
@Valid @RequestBody ProblemCreateRequest request
){
Long userId = (Long) SecurityContextHolder
.getContext()
.getAuthentication()
.getPrincipal();
return problemService.createProblem(userId, request);
}
}
@Valid를 붙여 요청 본문이 ProblemCreateRequest의 검증 조건을 만족하는지 검사하도록 했다.
만약 검증에 실패하면 Controller 메서드의 본문이 실행되기 전에 MethodArgumentNotValidException이 발생한다.
또한 /api/problems는 인증이 필요한 API이므로, Controller 메서드까지 요청이 도달했다는 것은
앞단의 Spring Security Filter Chain에서 JWT 인증을 통과했다는 의미다.
다시 그 말은 곧 JwtAuthenticationFilter가 사용자 ID를 Authentication 객체의 principal로 담고, 이를 SecurityContextHolder에 넣어 두었다는 의미이다.(JWT 처리는 Filter가 하고, Controller는 이미 인증된 사용자 정보만 꺼내서 쓰는 구조)
따라서 Controller는 SecurityContextHolder에서 현재 사용자의 ID를 꺼내고, 문제 생성 처리는 ProblemService에 위임한다.
이제 postman으로 테스트 해보자.

유효한 JWT 토큰을 가지고 로그인 한 뒤, 알맞게 문제를 등록하였을 경우 문제가 알맞게 등록된다.

실제 DB에서도 성공적으로 저장되었음을 확인할 수 있다.
이제 API 명세에 있던 에러에 대한 에러 코드도 명세대로 잘 작동하는지 확인해보자.
테스트해야 하는 에러는 다음과 같다.
- 400 Bad Request : 잘못된 입력 정보
- 401 Unauthorized : 인증되지 않은 사용자

JWT토큰을 임의로 수정할 경우, JWTFilter가 위조를 검증하여 서명이 손상되었음을 감지한다.
따라서 401이 올바르게 던져진다.

그런데, 문제를 공백으로 보낸다면 controller의 valid에서 예외가 잡혀서 400 에러가 나야하는데,
권한이 없음을 나타내는 401 에러가 잡히는 문제가 발생했다!!
즉, 요청 본문이 잘못되었으므로 입력값 오류로 처리되어야 하는 상황에서, 인증되지 않은 사용자라는 의미의 401 응답이 반환된 것이다.
이 시점에서 단순히 “Validation이 실패했다”가 아니라, Validation 실패 이후의 예외 처리 흐름과 Spring Security가 어떻게 연결되는지 확인할 필요가 있었다.
이를 검증하기 위해 먼저 JWT 인증 자체가 실패한 것인지 확인했다.
같은 JWT 토큰으로 정상적인 요청 본문을 보내면 201 Created가 반환되었다.
또한 로그를 통해 JwtAuthenticationFilter가 정상적으로 동작하고, SecurityContextHolder에 Authentication 객체가 저장되는 것도 확인했다.
Authentication after = UsernamePasswordAuthenticationToken [Principal=1, Authenticated=true]
따라서 문제의 원인은 JWT 토큰 만료, Authorization Header 누락, 서명 검증 실패가 아니었다.
다음으로 확인한 것은 Validation 실패 이후의 에러 처리 흐름이었다.
title을 빈 문자열로 보내면 @Valid 검증에 실패하여 MethodArgumentNotValidException이 발생한다.
이 예외를 직접 처리하는 Handler가 없으면 Spring의 기본 예외 처리 흐름이 동작하고, 이 과정에서 에러 처리를 위한 ERROR dispatch가 발생할 수 있다.
문제는 이 ERROR dispatch도 Spring Security의 인가 검사 대상이 될 수 있다는 점이었다.
기존 설정에서는 다음과 같이 대부분의 요청에 인증을 요구하고 있었다.
.anyRequest().authenticated()
따라서 Validation 실패로 인해 발생한 ERROR dispatch도 인증 대상에 포함되었다.
하지만 JwtAuthenticationFilter는 최초에만 REQUEST dispatch에서 JWT를 검증하고 SecurityContextHolder에 인증 정보를 저장한다.
이후 에러 처리를 위한 ERROR dispatch가 발생했을 때는, JWT Header 자체는 남아 있지만, JWT 필터가 다시 실행되지 않아서 Authentication 객체를 새로 세팅되지 않았다!!!
public class JwtAuthenticationFilter extends OncePerRequestFilter
OncePerRequestFilter가 기본 설정에서 ERROR dispatch에 대해 다시 필터링하지 않아, JWT를 가짐에도 인증을 받지 못했던 것!
이 동작은 OncePerRequestFilter 공식 문서를 통해서도 확인할 수 있었다.
OncePerRequestFilter (Spring Framework 7.0.8 API)
Filter base class that aims to guarantee a single execution per request dispatch, on any servlet container. It provides a doFilterInternal(HttpServletRequest, HttpServletResponse, FilterChain) method with HttpServletRequest and HttpServletResponse argument
docs.spring.io

shouldNotFilterErrorDispatch()의 기본값은 true이며, 이는 ERROR dispatch에서는 필터가 호출되지 않는다는 의미다.
결과적으로 원래는 400 Bad Request로 처리되어야 할 Validation 실패가, ERROR dispatch 단계의 Security 인가 검사에 걸려 401 Unauthorized로 응답되는 문제가 발생했다.
이를 검증하기 위해 다음 설정을 추가했다.
.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
그 결과 동일한 잘못된 요청 본문에 대해 응답이 401 Unauthorized에서 400 Bad Request로 변경되었다.
즉, 문제의 원인은 JWT 인증 실패가 아니라, Validation 예외 이후 발생한 ERROR dispatch가 Spring Security 인가 검사에 걸린 것이었다.

이후 동일하게 문제 제목을 공백으로 두면 400 에러가 올바르게 호출되는 것을 확인할 수 있었다.
이번 문제의 흐름을 정리하면 다음과 같다.
title = ""
↓
@Valid 검증 실패
↓
MethodArgumentNotValidException 발생
↓
처음에는 GlobalExceptionHandler에 해당 예외 Handler가 없음
↓
Spring 기본 예외 처리 흐름 동작
↓
ERROR dispatch 발생
↓
ERROR dispatch가 Spring Security 인가 검사 대상에 포함됨
↓
401 Unauthorized 반환
해결 후 흐름은 다음과 같이 정리됐다.
title = ""
↓
@Valid 검증 실패
↓
MethodArgumentNotValidException 발생
↓
GlobalExceptionHandler에서 예외 처리
↓
400 Bad Request 반환
또한 DispatcherType.ERROR를 permitAll로 허용하여,
향후 에러 처리용 내부 dispatch가 Spring Security에 의해 401로 덮이는 문제도 방지했다.
.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()

물론 공식문서에서 본대로 아래처럼 JwtAuthenticationFilter에서 shouldNotFilterErrorDispatch()를 오버라이드 해서 에러 디스패치도 시큐리티 필터를 타게 해서, 인증을 받도록 할 수도 있었다.
@Override protected boolean shouldNotFilterErrorDispatch() { return false; }
그러나 ERROR dispatch는 사용자가 직접 호출하는 비즈니스 API가 아니라, 이미 발생한 예외에 대해 서버가 에러 응답을 만들기 위한 내부 처리 흐름이다.
따라서 에러 처리 흐름에서까지 JWT 인증 필터를 다시 실행시키는 것보다, Security 설정에서 ERROR dispatch 자체를 인증 검사 대상에서 제외하는 것이 더 자연스럽다고 판단했다.
처음에는 단순히 CRUD 중 Create에 해당하는 기능이라고 생각했기 때문에 금방 끝날 것이라고 예상했다.
하지만 실제 구현 과정에서는 요청 검증 실패가 예상한 400 응답이 아니라 401 응답으로 반환되는 문제가 발생했다.
이 문제를 해결하기 위해 JWT 인증 흐름, SecurityContextHolder, @Valid 검증 예외, GlobalExceptionHandler, 그리고 Spring Security의 ERROR dispatch 처리 흐름까지 함께 확인해야 했다.
결과적으로 단순한 문제 등록 API 구현을 넘어, Spring MVC의 예외 처리 흐름과 Spring Security의 인가 처리 흐름이 어떻게 연결되는지 직접 로그를 찍어 가며 확인할 수 있었다.
이렇게 문제 등록 API까지 마쳤다.
다음 글에서는 문제 목록 조회 API를 구현해보자.
'Project > SolveGO' 카테고리의 다른 글
| [SolveGO #9] 책임 분리는 다이어트와 같다 - 문제 상세 조회 API 구현 (0) | 2026.06.27 |
|---|---|
| [SolveGO #8] 기하로 푸는 N+1 문제와 DB 인덱스 - 문제 목록 조회 API 구현 (0) | 2026.06.26 |
| [SolveGO #6] Crush 잊어버리지마 - JWT 토큰 발급, 검증 구현 (0) | 2026.06.23 |
| [SolveGO #5] 내 누군지 아니? - 로그인 인증 구현 (0) | 2026.06.22 |
| [SolveGO #4] 어디로 가야하오? 문 뚫기 - 회원가입 API 구현 (0) | 2026.06.20 |