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에 응답

회원가입은 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 객체로 역직렬화하도록 지시한다고 한다.
오늘은 클라이언트가 전달한 값을 Controller에서 받는 방법을 비교했다.
| 방식 | 요청 예시 | 주로 사용하는 상황 |
|---|---|---|
@PathVariable | /users/10 | URL 경로의 식별자 |
@RequestParam | /users?email=yhi | Query String이나 Form Data |
| DTO 자동 바인딩 | /users?email=yhi&password=1234 | 여러 Query Parameter를 객체로 관리 |
@RequestBody DTO | JSON Body | POST, PUT, PATCH 요청 데이터 |
@GetMapping("/{userId}")
public ResponseEntity<?> findUser(
@PathVariable Long userId) {
return null;
}
GET /users/10
현재 로그인 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
어제 언급한 내용! Query Parameter가 많아지면 DTO 하나로 받을 수도 있다.
@GetMapping("/signIn")
public ResponseEntity<?> signIn(
UserRequestDTO request) {
return null;
}
복합 객체에 별도의 애너테이션이 없다면 Spring MVC에서는 암묵적으로 @ModelAttribute처럼 처리될 수 있다.
React가 다음 JSON을 전송한다면,
{
"email": "yhi",
"password": "1234",
"name": "현일"
}
Spring에서는 다음과 같이 받을 수 있다.
@PostMapping("/signUp")
public ResponseEntity<?> signUp(
@RequestBody UserRequestDTO request) {
return null;
}
다 아는걸 왜 굳이 한번 더 언급하냐면, 수업 중 Get 요청은 RequestBody 처리를 못한다고 설명하셨다(솔직히 이해 못해서 다시 찾아봄)

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

Swagger UI를 사용하면 Controller의 API를 브라우저에서 확인하고 직접 요청도 보낼 수 있다.
오늘 사용한 애너테이션은 다음과 같다.
| 애너테이션 | 역할 |
|---|---|
@Tag | Controller 단위의 API 그룹 설명 |
@Operation | API의 기능과 목적 설명 |
@ApiResponse | 예상되는 응답 코드 설명 |
@Schema | 요청 또는 응답 DTO 구조 설명 |
Swagger @RequestBody | API 문서에 요청 본문 명세 |
현재 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로 표시하는 것이 정확하다.
인프런 강의에서 못박도록 들었던 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는 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 응답으로 변환할 수 있다.
로그인에 성공하면 JwtProvider를 통해 두 종류의 토큰을 만든다.
String accessToken =
jwtProvider.createAccessToken(request.getEmail());
String refreshToken =
jwtProvider.createRefreshToken(request.getEmail());
각 토큰의 일반적인 역할은 다음과 같다.
| 토큰 | 역할 |
|---|---|
| Access Token | API에 접근할 수 있는 권한을 증명 |
| Refresh Token | Access 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에서는 로그인 요청이 성공하면 사용자 정보와 응답 헤더의 토큰을 저장한다.
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: 응답 헤더현재는 학습을 위해 두 토큰을 localStorage에 저장했다. 다만 localStorage의 값은 JavaScript에서 접근할 수 있으므로 XSS 공격에 노출될 수 있다.
실제 인증 설계에서는 Access Token 저장 위치와 Refresh Token을 HttpOnly, Secure 쿠키로 관리하는 방법도 함께 검토해야 한다.
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?...');
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 Boot 프로젝트 안에서 기능별로 나누는 구조를 사용했다.
features
├── users
├── blogs
├── comments
└── commons
하나의 애플리케이션으로 빌드하고 배포하므로 현재 구조는 모놀리식 애플리케이션에 가깝다.
기능을 별도의 애플리케이션으로 분리하면 서비스 간 통신, 설정, 인증, 장애 처리 등이 필요해진다. Spring Cloud는 이런 분산 시스템을 구성할 때 사용할 수 있는 여러 도구를 제공한다고 하셨다.
inspire_mybatis 프로젝트를 새로 구성했다..env로 분리하고 YAML에서 참조했다.@Tag, @Operation, @ApiResponse, @Schema로 API를 설명했다.@RequiredArgsConstructor와 final을 이용해 생성자 주입을 적용했다.INSERT SQL과 연결했다.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까지 돌아오는 흐름을 확인할 수 있었다.
처음에는 로그인 정보를 다음과 같이 @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로 변경할 필요가 있다.
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 허용 여부는 별개의 문제라는 것을 알게 되었다.
form.passwrd 오타 수정하기(오타있더라ㅜ)오늘은 단순히 하나의 API를 만드는 것에서 끝나지 않고, API 명세부터 요청 데이터 처리, DB 조회, 토큰 발급, CORS 설정, React 저장까지 인증 기능의 전체 흐름을 연결했다.
아직 실제 JWT 검증과 보안 설정은 남아 있지만, 프론트엔드와 백엔드가 어떻게 요청과 응답을 주고받는지 훨씬 구체적으로 이해할 수 있었다.