노트

[SolveGO #10] 심장을 꽂다 - 문제 풀이 API 구현 본문

Project/SolveGO

[SolveGO #10] 심장을 꽂다 - 문제 풀이 API 구현

forwarder 2026. 6. 29. 13:51

 

 

 

 

 

 

 

 

 

 

지금껏 백엔드용 포폴에 초점을 맞추어 프로젝트를 진행했다.

하지만 그 규모와 무관하게 포폴의 본질은 "서비스"이다.

그리고 그 서비스의 본질은 "어떤 가치를 주느냐" 에서 나온다.

 

SolveGO는 바둑 사활 문제 플랫폼이다.

그렇다면 이 서비스가 사용자에게 제공해야 하는 가장 핵심적인 가치는 무엇일까?

문제를 풀 수 있어야 한다.

 

사용자가 문제를 보고, 자신이 생각한 답을 제출하고, 그 답이 정답인지 확인할 수 있어야 한다.
이 흐름이 없다면 SolveGO는 단순한 문제 저장소에 가깝다.

 

따라서 이번 글에서는 SolveGO의 핵심 가치인 문제 풀이 기능을 구현한다.

서비스에 "심장을 꽂아보자"

 

 

 


 

 

설계 : 탑다운

 

문제를 푼다는 것은 무엇일까?

먼저 문제는 정답이 있는 문제와, 정답이 없는 문제로 나눌 수 있다.

그러나 우리가 고려하는 "바둑 문제"는 사용자가 문제를 등록할 때, 사용자가 정답을 함께 등록해야하므로.

이 서비스에서 "문제를 푼다"라는 것은 "정답이 있는 문제"를 푼다고 해석할 수 있다.

 

그렇다면 정답이 있는 문제를 푼다는 것은 무엇일까?

간단하다.

사용자가 자신이 생각하는 자신의 답을 제출하면, 그에 따른 결과를 받아볼 수 있는 행위를 의미한다.

 

SolveGO에서 바둑 문제는 문제 등록 시 정답 좌표를 함께 저장한다.
따라서 이 서비스에서 “문제를 푼다”는 것은 사용자가 자신이 생각한 좌표를 제출하고,

서버가 그 좌표를 저장된 정답 좌표와 비교하는 행위라고 볼 수 있다.

 

조금 더 구체적으로 보면 흐름은 다음과 같다.

 

클라이언트는 문제 상세 화면에서 바둑판 상태를 확인한다.
이후 사용자는 자신이 정답이라고 생각하는 좌표를 선택하여 서버에 제출한다.

 

서버는 먼저 요청을 보낸 사용자가 로그인한 사용자인지 확인한다.
로그인한 사용자라면, 클라이언트가 제출한 좌표와 DB에 저장된 해당 문제의 정답 좌표를 비교한다.

그리고 정답 여부를 계산한 뒤, 이 풀이 행위를 Attempt 엔티티로 저장한다.

마지막으로 서버는 클라이언트에게 정답 여부를 반환한다.

 

즉, 문제 풀이 API는 단순히 정답 여부만 반환하는 API가 아니다.
사용자가 어떤 문제에 어떤 답을 제출했는지 기록하는 API이기도 하다.

이 기록이 있어야 이후 틀린 문제 조회, 풀이 기록 조회, 사용자별 정답률 계산 같은 기능으로 확장할 수 있다.

 

 

사실 이 흐름은 아래 API 설계 글에 이미 기재해 두었지만, 독자의 편의성을 위해 다시 한번 나타내었다.

 

 

 

[SolveGO #2] 유죄추정의 원칙 - API 설계

서론 우리나라 헌법에는 무죄추정의 원칙이 있다.이는 유죄가 확정되기 전까지 모든 사람을 무죄로 간주한다는 원칙이다.왜 이런 원칙이 존재할까? 그 이유는 아래 두 종류의 오류 중 하나를 더

forwarder1121.tistory.com

 

UC-06 문제 풀이

더보기

문제 풀이는 사용자가 문제에 대한 답을 제출하고, 시스템이 정답 여부를 판별하는 기능이다.

정답 판별 결과는 사용자에게 즉시 반환되며, 동시에 풀이 기록(Attempt)을 저장한다.

이를 통해 사용자의 풀이 이력을 관리하고, 이후 오답 노트 기능을 제공할 수 있게 하였다.

Scenario

  1. 사용자는 문제 상세 화면에 접근한다.
  2. 사용자는 바둑판의 특정 좌표에 착수한다.
  3. 시스템은 사용자의 착수 좌표를 전달받는다.
  4. 시스템은 정답 판별을 수행한다.
  5. 시스템은 풀이 결과를 사용자에게 보여준다.
  6. 시스템은 사용자, 문제, 착수 좌표, 정답 여부, 풀이 시간을 저장한다.

API 명세

  • 관여 도메인 : Problem, User, Attempt
  • Action : Create
  • Method : POST
  • API URL : /api/problems/{problemId}/attempts
  • 인증 필요 여부 : O

URL 왼쪽에는 더 큰 맥락이 되는 리소스를, 오른쪽에는 그 리소스에 종속되는 하위 리소스를 배치했다.

이 경우는 "풀이 시도"는 특정 "문제"에 종속되므로, problems/{problemId}/attempts 형태로 설계했다.

 

Request Header

Authorization: Bearer {accessToken}

문제 풀이 기록은 사용자별로 관리되어야 하므로 인증된 사용자만 요청할 수 있다.

Request Body

{
  "selectedPosition": {
    "x": 5,
    "y": 4
  }
}

Response Body

{
  "isCorrect": true
}

정답 여부만 반환하고 정답 좌표는 반환하지 않는다.

오답 시에도 정답 좌표를 함께 제공하면 문제 풀이의 의미가 사라질 수 있기 때문이다.

 

Status Code

  • 201 Created :: 풀이 시도 저장 및 정답 여부 판별 완료
  • 400 Bad Request : 요청 형식 오류
  • 401 Unauthorized : 인증되지 않은 사용자
  • 404 Not Found : 존재하지 않는 문제

 

 

 

이제 이 흐름을 시퀀스 다이어그램으로 정리해보자.

 

 

 

핵심 비즈니스 로직인 만큼, Service가 담당하는 로직은 이전 API들에 비해 다소 복잡해진다.

 

요청한 사용자를 식별하고, 문제를 조회하고, 제출 좌표와 정답 좌표를 비교하고, 그 결과를 Attempt로 저장해야 하기 때문이다.

 

설계는 탑다운으로 정리했으니, 이제 바텀업으로 하나씩 구현해보자.

 


 

구현 : 바텀업

 

구현 순서는 다음과 같다.

 

1. DTO

2. Repository -> Service -> Controller

3. Exception 처리

4. Security 설정

 

 

 


 

 

DTO

API 명세에 따르면, 요청 body는 아래와 같았기에

{
  "selectedPosition": {
    "x": 5,
    "y": 4
  }
}

 

API 요청 body를 담기 위한 DTO는 다음과 같다.

package com.kdh.solvego.domain.attempt.dto;

//...

public record AttemptCreateRequest(
        @Valid
        @NotNull
        Position selectedPosition
) {
}

 

 

 

마찬가지로, 응답 body를 담기 위한 DTO는 아래와 같다.

package com.kdh.solvego.domain.attempt.dto;
//...

public record AttemptCreateResponse(

        @JsonProperty("isCorrect")
        boolean isCorrect
) {
}

 

 

 

Repository

 

package com.kdh.solvego.domain.attempt.repository;

//...

public interface AttemptRepository extends JpaRepository<Attempt, Long> {
}

 

 

AttemptRepository는 풀이 시도를 나타내는 Attempt 엔티티를 저장하기 위해 정의했다.

사용자가 문제를 풀면 새로운 Attempt를 저장하면 되므로, 기본적인 save() 메서드만 있으면 충분하다.

 

save() 메서드는 JpaRepository에서 기본으로 제공되기 때문에, 별도의 메서드를 직접 정의할 필요가 없다.

따라서 AttemptRepositoryJpaRepository<Attempt, Long>를 상속받기만 하면 된다.

 

이렇게 인터페이스만 정의해두면, Spring Data JPA가 런타임에 해당 인터페이스의 프록시 구현체를 생성해준다.

 

Service

 

 

package com.kdh.solvego.domain.attempt.service;

//...

@Service
public class AttemptService {

    private final AttemptRepository attemptRepository;
    private final ProblemRepository problemRepository;
    private final UserRepository userRepository;

    public AttemptService(
            AttemptRepository attemptRepository,
            ProblemRepository problemRepository,
            UserRepository userRepository){
        this.attemptRepository = attemptRepository;
        this.problemRepository = problemRepository;
        this.userRepository = userRepository;
    }

    @Transactional
    public AttemptCreateResponse createAttempt(
            Long problemId,
            Long userId,
            AttemptCreateRequest request
    ){
        Problem problem = problemRepository.findById(problemId)
                .orElseThrow(ProblemNotFoundException::new);

        User user = userRepository.findById(userId)
                .orElseThrow(UserNotFoundException::new);

        Integer selectedX = request.selectedPosition().x();
        Integer selectedY = request.selectedPosition().y();

        boolean isCorrect = problem.isCorrectPosition(selectedX, selectedY);

        Attempt attempt = new Attempt(
                user,
                problem,
                selectedX,
                selectedY,
                isCorrect
        );

        attemptRepository.save(attempt);

        return new AttemptCreateResponse(isCorrect);
    }
}

 

AttemptService는 Attempt 엔티티를 저장하는 Repo, 문제와 사용자 정보를 조회하기 위한 Repo. 총 3가지 Repo를 주입받는다.

또한 문제 조회, 정답 판별, Attempt 저장은 하나의 트랜잭션으로 처리하기 위해 @Transactional 애너테이션을 붙였다.

 

또, 문제가 맞았는지 정답을 판별하는 책임을 Service에 둘지, Problem Entity에 둘지 고민하였는데,

최종적으로는 Problem 엔티티가 정답 판별을 책임지도록 했다.
정답 좌표는 Problem이 가지고 있는 데이터이고, 제출된 좌표가 해당 문제의 정답인지 판단하는 규칙 역시 Problem에 가까운 책임이라고 판단했기 때문이다.

따라서 Service는 문제 풀이 흐름을 책임지고, 정답 판별이라는 도메인 규칙은 Problem에게 위임하였다.

 

Problem 엔티티에 추가한 정답 판별 메서드는 아래와 같다.

 public boolean isCorrectPosition(Integer selectedX,Integer selectedY){
        return this.answerX.equals(selectedX) && this.answerY.equals(selectedY);
}

 

 

Controller

 

package com.kdh.solvego.domain.attempt.controller;

//...

@RestController
@RequestMapping("/api/problems/{problemId}/attempts")
public class AttemptController {

    private final AttemptService attemptService;

    public AttemptController(AttemptService attemptService) {
        this.attemptService = attemptService;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public AttemptCreateResponse createAttempt(
            @PathVariable Long problemId,
            @Valid @RequestBody AttemptCreateRequest request,
            Authentication authentication
    ) {
        Long userId = (Long) authentication.getPrincipal();

        AttemptCreateResponse response = attemptService.createAttempt(
                problemId,
                userId,
                request
        );

        return response;
    }
}

 

Controller는 요청 URL에 있는 problemId, 요청 DTO, SecurityContext에 있는 principal 정보를 Service에 넘기는 책임을 진다.

 

 

Exception

 

package com.kdh.solvego.global.exception;
//...

@RestControllerAdvice
public class GlobalExceptionHandler {

   	//...

    @ExceptionHandler(ProblemNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleProblemNotFoundException(
            ProblemNotFoundException e
    ){
        return new ErrorResponse(e.getMessage());
    }


    @ExceptionHandler(UserNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleUserNotFoundException(
            UserNotFoundException e
    ) {
        return new ErrorResponse(e.getMessage());
    }
}

 

해당 API 요청 중 발생할 수 있는 예외에 대한 것은 예외 클래스를 정의해주고, 알맞게 처리해주었다.

 

 

 

package com.kdh.solvego.global.security;

//...

@Configuration
public class SecurityConfig {

    //...
    
    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http,
            JwtAuthenticationFilter jwtAuthenticationFilter
    ) throws Exception {
        http
                //...
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.POST, "/api/users").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
                        .requestMatchers(HttpMethod.GET,"/api/problems").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/problems/*").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/problems/*/attempts").authenticated() // added
                        .dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
                        .anyRequest().authenticated()
                )
                //...

        return http.build();
    }

}

 

또한, 문제 풀이 기능은 로그인한 유저만 이용가능하게 설계하였으므로,

Security 설정에서 문제 풀이 API는 인증이 필요하도록 아래와 같이 설정했다.

requestMatchers(HttpMethod.POST, "/api/problems/*/attempts").authenticated() // added

 

 

 

 

Testing

 

API 명세에 의해 확인해야할 예외는 다음과 같았다.

 

201 Created : 풀이 시도 저장 및 정답 여부 판별 완료

400 Bad Request : 요청 형식 오류

401 Unauthorized: 인증되지 않은 사용자

404 Not Found : 존재하지 않는 문제

 

알맞게 정답을 입력하면 201 Created와 함께, 정답 표시를 반환받는 것을 확인할 수 있다.

오답일 경우, 오답 처리와 함께, 201 응답 코드를 올바르게 반환 받는다.

 

 

잘못된 정답 좌표 (요청 형식 오류)시에는 400 Bad Request를,

 

로그인 하지 않았을 경우는 401 Unauthorized를 반환받는다.

 

 

마지막으로 존재하지 않는 문제를 풀이하려고 할 경우, 404 Not Found 에러가 적절하게 반환받는 것을 확인할 수 있었으며,

 

또한 모든 정상 풀이 시도는 아래와 같이 attempts 테이블에 실제로 저장되는 것을 확인할 수 있었다.

 

 

 

 

 


 

 

이제 7개의 MVP Use Case 중 6개의 Use Case API 구현을 마쳤다.

특히 이번 글에서는 SolveGO의 핵심 기능이라고 할 수 있는 문제 풀이 API를 구현하였다.

사용자가 문제를 보고, 좌표를 선택하고, 서버가 정답 여부를 판별한 뒤, 그 풀이 기록을 저장하는 흐름까지 완성했다.

 

이제 SolveGO는 단순히 문제를 등록하고 조회하는 서비스를 넘어, 실제로 사용자가 문제를 풀 수 있는 서비스에 가까워졌다.

 

끝이 보인다.

 

다음 글에서는 마지막 MVP 기능인 틀린 문제 조회 API를 구현해보자!