[LG CNS AM 6기] 26일차 TIL : Swagger API, Spring 요청 파라미터, Access·Refresh Token 발급, React 연동

윤현일·2026년 9월 2일

LGCNS

목록 보기
31/41

1. 오늘의 한 줄 요약

Swagger로 REST API를 직접 테스트하고, React에서 Spring Boot로 회원가입과 로그인 요청을 보내 Access Token과 Refresh Token을 저장하는 과정까지 연결했다.

어제는 Controller, Service, MyBatis Mapper를 각각 연결했다면, 오늘은 여기에 Swagger API 문서화와 토큰 발급, CORS 설정, React의 Axios 요청을 추가했다.

처음으로 다음 흐름을 하나의 기능으로 이어서 확인한 날이었다.

React
→ Spring Controller
→ Service
→ MyBatis Mapper
→ Database
→ Token 발급
→ React에 응답

2. 배운 내용

핵심 개념

전체 회원가입 흐름

회원가입은 React에서 입력받은 데이터를 JSON으로 전송하고, Spring이 이를 DTO로 변환하는 방식으로 구현했다.

SignUpPage
→ POST /users/signUp
→ JSON Request Body
→ UserRequestDTO
→ UserController
→ UserService
→ UserMapper.save()
→ INSERT SQL
→ 201 Created
→ 로그인 페이지 이동

React에서는 useState로 이름, 이메일, 비밀번호를 관리했다.

const [form, setForm] = useState({
  name: '',
  email: '',
  password: ''
});

const keyHandler = (e) => {
  const { name, value } = e.target;
  setForm({
    ...form,
    [name]: value
  });
};

폼이 제출되면 Axios를 통해 Spring 백엔드로 JSON을 전송한다.

const signUpHandler = async (e) => {
  e.preventDefault();

  const data = { ...form };

  await api
    .post('/users/signUp', data)
    .then((response) => {
      if (response.status === 201) {
        moveUrl('/users/signIn');
      }
    })
    .catch((error) => {
      console.log(error);
    });
};

Spring에서는 @RequestBody가 JSON 요청 본문을 읽고 UserRequestDTO로 변환한다.

@PostMapping("/signUp")
public ResponseEntity<?> signUp(
        @RequestBody UserRequestDTO request) {

    int signUpFlag = userService.signUp(request);

    if (signUpFlag != 0) {
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(null);
    }

    return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(null);
}

@RequestBody는 단순히 DTO 앞에 붙이는 표시가 아니다. HTTP 요청 본문의 JSON을 HttpMessageConverter가 Java 객체로 역직렬화하도록 지시한다고 한다.

Spring 공식 문서 - RequestBody

요청 파라미터를 받는 방법

오늘은 클라이언트가 전달한 값을 Controller에서 받는 방법을 비교했다.

방식요청 예시주로 사용하는 상황
@PathVariable/users/10URL 경로의 식별자
@RequestParam/users?email=yhiQuery String이나 Form Data
DTO 자동 바인딩/users?email=yhi&password=1234여러 Query Parameter를 객체로 관리
@RequestBody DTOJSON BodyPOST, PUT, PATCH 요청 데이터

PathVariable

@GetMapping("/{userId}")
public ResponseEntity<?> findUser(
        @PathVariable Long userId) {

    return null;
}
GET /users/10

RequestParam

현재 로그인 API는 Query Parameter로 이메일과 비밀번호를 받는다.

@GetMapping("/signIn")
public ResponseEntity<?> signIn(
        @RequestParam("email") String email,
        @RequestParam("password") String password) {

    return null;
}
GET /users/signIn?email=yhi&password=1234

DTO 자동 바인딩

어제 언급한 내용! Query Parameter가 많아지면 DTO 하나로 받을 수도 있다.

@GetMapping("/signIn")
public ResponseEntity<?> signIn(
        UserRequestDTO request) {

    return null;
}

복합 객체에 별도의 애너테이션이 없다면 Spring MVC에서는 암묵적으로 @ModelAttribute처럼 처리될 수 있다.

RequestBody

React가 다음 JSON을 전송한다면,

{
  "email": "yhi",
  "password": "1234",
  "name": "현일"
}

Spring에서는 다음과 같이 받을 수 있다.

@PostMapping("/signUp")
public ResponseEntity<?> signUp(
        @RequestBody UserRequestDTO request) {

    return null;
}

다 아는걸 왜 굳이 한번 더 언급하냐면, 수업 중 Get 요청은 RequestBody 처리를 못한다고 설명하셨다(솔직히 이해 못해서 다시 찾아봄)

껄껄..그랬구나..
전에 프로젝트에서 꼭 GET은 조회!! POST는 생성!! 이 아니라 service측면에서 유연하게 대처가 가능하다고 들어서 사실 될줄 알았다. 머쓱하다.

Spring 공식 문서 - Controller Method Arguments

Swagger로 API 명세 작성하기

Swagger UI를 사용하면 Controller의 API를 브라우저에서 확인하고 직접 요청도 보낼 수 있다.

오늘 사용한 애너테이션은 다음과 같다.

애너테이션역할
@TagController 단위의 API 그룹 설명
@OperationAPI의 기능과 목적 설명
@ApiResponse예상되는 응답 코드 설명
@Schema요청 또는 응답 DTO 구조 설명
Swagger @RequestBodyAPI 문서에 요청 본문 명세

현재 UserController에는 사용자 API 그룹을 명시했다.

@Tag(
    name = "User API",
    description = "사용자 생성과 로그인 관련 API 명세서"
)
@RestController
@RequestMapping("/users")
public class UserController {
}

회원가입 API에는 요청 DTO와 응답 코드를 추가했다.

@Operation(
    summary = "회원가입",
    description = "신규가입(email, password, name)"
)
@ApiResponses({
    @ApiResponse(
        responseCode = "201",
        description = "가입 성공"
    ),
    @ApiResponse(
        responseCode = "400",
        description = "유효성 검사 실패"
    ),
    @ApiResponse(
        responseCode = "500",
        description = "가입 실패"
    )
})
@PostMapping("/signUp")
public ResponseEntity<?> signUp(
        @RequestBody UserRequestDTO request) {

    // ...
}

Swagger 애너테이션은 API의 동작을 바꾸는 코드가 아니라, API를 설명하기 위한 명세다.

따라서 실제 Spring의 요청 처리 방식과 Swagger 설명이 일치해야 한다.

Spring @RequestBody
→ 실제 요청 본문을 DTO로 변환

Swagger @RequestBody
→ API 문서에 요청 본문 구조를 표현

현재 로그인 API는 실제로 @RequestParam을 사용하고 있으므로 Swagger에도 요청 본문이 아닌 Query Parameter로 표시하는 것이 정확하다.

springdoc-openapi 공식 문서

생성자 주입

인프런 강의에서 못박도록 들었던 RequiredArgsConstructor + final 조합. 이게 업계 표준이라고 하셨다.

UserService는 Mapper와 Token Provider를 생성자 주입으로 받는다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserMapper userMapper;
    private final JwtProvider jwtProvider;
}

Lombok의 @RequiredArgsConstructor가 final 필드를 매개변수로 받는 생성자를 만들어 준다.

Controller도 같은 방식으로 Service를 주입받는다.

@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;
}

이 방식은 의존 관계가 생성 시점에 결정되고, 필드가 final이어서 실행 중에 다른 객체로 변경되지 않는다는 장점이 있다.

구현체가 여러 개라면 @Autowired와 @Qualifier를 함께 사용해 원하는 Bean을 선택할 수도 있다.

Service에서 Mapper 호출과 예외 처리

회원가입 Service는 Controller에서 전달받은 DTO를 Mapper로 전달한다.

public int signUp(UserRequestDTO request) {
    return userMapper.save(request);
}

로그인에서는 조회 결과를 Optional로 받고, 일치하는 사용자가 없으면 예외를 발생시켰다.

UserResponseDTO response =
    userMapper
        .signIn(request)
        .orElseThrow(
            () -> new RuntimeException("로그인 실패")
        );

Controller에서 직접 DB 결과를 판단하는 대신 Service에서 로그인 성공 여부를 처리했다.

이후에는 단순한 RuntimeException 대신 로그인 실패를 표현하는 사용자 정의 예외를 만들고, @RestControllerAdvice에서 HTTP 응답으로 변환할 수 있다.

Access Token과 Refresh Token

로그인에 성공하면 JwtProvider를 통해 두 종류의 토큰을 만든다.

String accessToken =
    jwtProvider.createAccessToken(request.getEmail());

String refreshToken =
    jwtProvider.createRefreshToken(request.getEmail());

각 토큰의 일반적인 역할은 다음과 같다.

토큰역할
Access TokenAPI에 접근할 수 있는 권한을 증명
Refresh TokenAccess Token이 만료됐을 때 재발급에 사용

Service는 사용자 정보와 토큰을 Map으로 묶어 Controller에 반환한다.

Map<String, Object> map = new HashMap<>();

map.put("response", response);
map.put("access-token", accessToken);
map.put("refresh-token", refreshToken);

Controller는 토큰을 응답 헤더에 담는다.

HttpHeaders headers = new HttpHeaders();

headers.add(
    "Authorization",
    (String) map.get("access-token")
);

headers.add(
    "Refresh-Token",
    (String) map.get("refresh-token")
);

응답 본문에는 로그인한 사용자 정보를 담는다.

return ResponseEntity
        .status(HttpStatus.OK)
        .headers(headers)
        .body(map.get("response"));

아 물론 현재 JwtProvider가 반환하는 값은 실제 JWT가 아니라 테스트용~

public String createAccessToken(String email) {
    return "Bearer ddddd";
}

public String createRefreshToken(String email) {
    return "xxxx";
}

React에서 토큰 저장하기

React에서는 로그인 요청이 성공하면 사용자 정보와 응답 헤더의 토큰을 저장한다.

await api
  .get(
    `/users/signIn?email=${form.email}` +
    `&password=${form.password}`
  )
  .then((response) => {
    if (response.status === 200) {
      localStorage.setItem(
        'user',
        response.data.email
      );

      const accessToken =
        response.headers.get('Authorization');

      const refreshToken =
        response.headers.get('Refresh-Token');

      localStorage.setItem('at', accessToken);
      localStorage.setItem('rt', refreshToken);
    }
  });

Axios 응답은 다음과 같은 정보를 제공한다.

  • response.data: 응답 본문
  • response.status: HTTP 상태 코드
  • response.headers: 응답 헤더

Axios 공식 문서 - Response Schema

현재는 학습을 위해 두 토큰을 localStorage에 저장했다. 다만 localStorage의 값은 JavaScript에서 접근할 수 있으므로 XSS 공격에 노출될 수 있다.

실제 인증 설계에서는 Access Token 저장 위치와 Refresh Token을 HttpOnly, Secure 쿠키로 관리하는 방법도 함께 검토해야 한다.

Axios의 baseURL을 환경변수로 관리하기

React에서는 백엔드 주소를 코드에 직접 작성하지 않고 환경변수로 분리했다.

import axios from 'axios';

const endPoint =
    process.env.REACT_APP_BACKEND_ENDPOINT;

const api = axios.create({
    baseURL: endPoint
});

export default api;

이제 각 페이지에서는 전체 주소가 아닌 API 경로만 작성하면 된다.

api.post('/users/signUp', data);
api.get('/users/signIn?...');

CORS와 Preflight

React와 Spring Boot는 서로 다른 포트에서 실행된다.

React       → http://localhost:3000
Spring Boot → http://localhost:8000

브라우저에서 Origin은 프로토콜, 호스트, 포트의 조합으로 구분한다. 포트가 다르기 때문에 두 애플리케이션은 서로 다른 Origin이다.

브라우저는 다른 Origin으로 요청을 보낼 때 서버가 이를 허용했는지 확인한다. 특정 요청은 실제 요청 전에 OPTIONS 방식의 Preflight 요청을 먼저 보낸다.

Browser
  → OPTIONS Preflight
  → Spring CORS 확인
  → 허용
  → 실제 GET 또는 POST 요청

백엔드에서는 전역 CORS 설정을 추가했다.

@Configuration
public class CorsConfig
        implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(
            CorsRegistry registry) {

        registry
            .addMapping("/**")
            .allowedHeaders("*")
            .allowedOriginPatterns(
                "http://localhost:3000"
            )
            .allowedMethods(
                "GET",
                "POST",
                "DELETE",
                "PATCH",
                "PUT",
                "OPTIONS"
            );
    }
}

참고로 CORS는 초보 개발자들을 괴롭히는 아주 나쁜놈이다..

CORS 설정이 없거나 현재 Origin과 일치하지 않으면 백엔드가 정상적으로 응답하더라도 브라우저가 응답 사용을 차단한다.

또한 Authorization, Refresh-Token 같은 응답 헤더를 브라우저 JavaScript에서 읽으려면 외부에 노출할 헤더를 지정해야 한다.

현재는 Controller에서 직접 다음 헤더를 추가했다.

headers.add(
    "Access-Control-Expose-Headers",
    "Authorization, Refresh-Token"
);

이를 전역 CORS 설정으로 옮길 수도 있다.

.exposedHeaders(
    "Authorization",
    "Refresh-Token"
)

Spring 공식 문서 - CORS

모놀리식 구조와 기능별 패키지

오늘 프로젝트는 사용자, 블로그, 댓글, 공통 기능을 하나의 Spring Boot 프로젝트 안에서 기능별로 나누는 구조를 사용했다.

features
├── users
├── blogs
├── comments
└── commons

하나의 애플리케이션으로 빌드하고 배포하므로 현재 구조는 모놀리식 애플리케이션에 가깝다.

기능을 별도의 애플리케이션으로 분리하면 서비스 간 통신, 설정, 인증, 장애 처리 등이 필요해진다. Spring Cloud는 이런 분산 시스템을 구성할 때 사용할 수 있는 여러 도구를 제공한다고 하셨다.

3. 실습 / 적용

직접 해본 것

  • inspire_mybatis 프로젝트를 새로 구성했다.
  • DB 연결 정보를 .env로 분리하고 YAML에서 참조했다.
  • Swagger UI에서 회원가입 요청을 직접 실행했다.
  • @Tag, @Operation, @ApiResponse, @Schema로 API를 설명했다.
  • Path, Query Parameter, DTO, JSON Body 방식의 차이를 확인했다.
  • @RequiredArgsConstructor와 final을 이용해 생성자 주입을 적용했다.
  • 회원가입 요청을 MyBatis의 INSERT SQL과 연결했다.
  • 로그인 성공 시 Access Token과 Refresh Token을 만들었다.
  • 두 토큰을 HTTP 응답 헤더에 담았다.
  • React 회원가입 페이지에서 Spring API로 JSON을 전송했다.
  • React 로그인 페이지에서 Query Parameter로 로그인 요청을 보냈다.
  • CORS 오류를 확인하고 전역 CORS 설정을 추가했다.
  • 응답 헤더의 토큰을 React에서 읽어 localStorage에 저장했다.

결과

회원가입은 다음 흐름으로 연결했다.

SignUpPage
→ POST /users/signUp
→ @RequestBody UserRequestDTO
→ UserService.signUp()
→ UserMapper.save()
→ DB INSERT
→ 201 Created

로그인은 다음 흐름으로 연결했다.

SignInPage
→ GET /users/signIn
→ @RequestParam
→ UserService.signIn()
→ UserMapper.signIn()
→ 사용자 조회
→ 테스트 토큰 생성
→ Response Header
→ React localStorage 저장

이전에는 프론트엔드와 백엔드를 각각 실행하는 수준이었다면, 오늘은 사용자의 입력이 실제 DB와 Service를 거쳐 다시 React까지 돌아오는 흐름을 확인할 수 있었다.

4. 문제와 해결

막힌 부분 1: GET 요청에서 RequestBody 오류 발생

처음에는 로그인 정보를 다음과 같이 @RequestBody DTO로 받았다.

@GetMapping("/signIn")
public ResponseEntity<?> signIn(
        @RequestBody UserRequestDTO request) {

    // ...
}

Swagger에서 GET 요청을 실행하자 다음과 같은 오류가 발생했다.

Required request body is missing

@RequestBody는 HTTP Body의 JSON을 읽는다. 하지만 현재 로그인 요청은 URL의 Query String으로 값을 전달하고 있었기 때문에 요청 본문이 존재하지 않았다.

이를 @RequestParam 방식으로 변경했다.

@GetMapping("/signIn")
public ResponseEntity<?> signIn(
        @RequestParam String email,
        @RequestParam String password) {

    UserRequestDTO request =
        UserRequestDTO.builder()
            .email(email)
            .password(password)
            .build();

    return null;
}

정리하면 로그인 API는 다음 두 방식 중 하나로 일관되게 설계해야 한다.

GET + RequestParam

또는

POST + RequestBody DTO

현재 Controller에는 실제 파라미터는 @RequestParam인데 Swagger의 @RequestBody 설명이 함께 붙어 있다. Swagger 문서도 실제 요청 방식과 맞도록 @Parameter로 변경할 필요가 있다.

막힌 부분 2: React 연결 후 CORS 오류 발생

Swagger에서는 정상 동작했지만 React에서 API를 호출하자 CORS 오류가 발생했다.

Swagger UI는 백엔드와 같은 Origin에서 실행되므로 문제가 없었지만, React는 3000, Spring Boot는 8000 포트를 사용해 서로 다른 Origin이었다.

이를 해결하기 위해 WebMvcConfigurer를 구현한 전역 CORS 설정을 추가했다.

.allowedOriginPatterns(
    "http://localhost:3000"
)
.allowedMethods(
    "GET",
    "POST",
    "DELETE",
    "PATCH",
    "PUT",
    "OPTIONS"
)

또한 브라우저가 토큰 헤더를 읽을 수 있도록 노출할 응답 헤더도 지정했다.

이 과정을 통해 API 자체의 성공 여부와 브라우저의 CORS 허용 여부는 별개의 문제라는 것을 알게 되었다.

5. 다음에 할 일

  • 회원가입 입력의 form.passwrd 오타 수정하기(오타있더라ㅜ)
  • 테스트 문자열이 아닌 실제 JWT 생성과 만료 시간 적용하기
  • Refresh Token의 저장 및 재발급 흐름 구현하기
  • MyBatis Type Alias 패키지 경로 수정하기

오늘은 단순히 하나의 API를 만드는 것에서 끝나지 않고, API 명세부터 요청 데이터 처리, DB 조회, 토큰 발급, CORS 설정, React 저장까지 인증 기능의 전체 흐름을 연결했다.

아직 실제 JWT 검증과 보안 설정은 남아 있지만, 프론트엔드와 백엔드가 어떻게 요청과 응답을 주고받는지 훨씬 구체적으로 이해할 수 있었다.

profile
개발자

0개의 댓글