방학 동안에 프로젝트를 진행하기로 하였다.
chat ai를 사용하여 대학생들에게 여러 정보를 전달해 주는 등 여러 기능을 추가할 생각이다.
이 프로젝트는 실제 서비스를 할 생각이기에 꼼꼼하게 코드를 작성하고 서로 리뷰를 할 생각이다.
또한 서비스를 통해 트래픽을 받아보는 경험을 할 수 있을 것이라 생각된다.
각자 맡은 파트가 있기에 초반 Auth 구조의 경우 내가 개발을 하지는 않았지만
이 프로젝트를 통해 서로 모두 공부의 목적도 있기에 모든 코드를 이해하고, 분석하며 코드리뷰를 진행했다.
또한 컨벤션을 정하여 이를 기준으로 작성하기로 하였다. 코드의 통일성 및 가독성을 위한 선택이다.
Merge도 매우 신중하게 진행되며, 코드리뷰를 2인 이상 받아야지만 Merge가 가능해진다.
또한 백엔드의 경우 Lombok을 사용하지 않기로 하였다. 기존에는 Lombok을 주로 사용하였는데, 이번에는 직접 작성하며 사용할 예정이다. 또한 기존에는 문서화를 위해 Swagger를 사용하였는데, 이번에는 Rest Docs를 사용하기로 하였다.
그리고 가장 중요한 부분은 테스트라고 생각한다.
기존에는 단위테스트만 진행하였는데, 이 프로젝트는 인수테스트, E2E, 통합, 단위 모든 테스트를 진행할 예정이다.
Config
기존에는 Spring Security를 적용하여 사용하였지만, 제대로 알고 사용한 것이 아니기에 이번에는 직접 만들어서 사용하였다.
아직 완벽하지는 않고 대략적인 형태만 잡아두었다.
public class AuthorizationFilter implements Filter {
private static final String AUTHORIZATION = "Authorization";
private static final String BEARER_ = "Bearer ";
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String requestURI = request.getRequestURI();
if (isLoginURI(requestURI)) {
filterChain.doFilter(request, response);
return;
}
String token = request.getHeader(AUTHORIZATION);
if (token == null || !token.startsWith(BEARER_)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "토큰이 존재하지 않습니다.");
return;
}
// 토큰이 유효하지 않은 경우 접근 차단
if (!isValidToken(token)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "토큰이 유효하지 않습니다.");
return;
}
filterChain.doFilter(request, response);
}
private boolean isLoginURI(final String requestURI) {
return requestURI.startsWith("/api/auth");
}
// 토큰 유효성을 확인하기 위한 로직을 구현
private boolean isValidToken(String token) {
return true;
}
}
우선 JWT토큰을 사용하였고, 로그인 및 회원가입을 제외한 URI에 접근하려면 토큰 인증을 받아야 한다.
JwtLoginResolver
ArgumentResolver를 통해 토큰을 Member객체로 바인딩해 주었다.
public class JwtLoginResolver implements HandlerMethodArgumentResolver {
private static final String TOKEN_SEPARATOR = " ";
private static final int BEARER_INDEX = 0;
private static final int PAYLOAD_INDEX = 1;
private static final String BEARER = "Bearer";
private final JwtAuthService jwtAuthService;
public JwtLoginResolver(final JwtAuthService jwtAuthService) {
this.jwtAuthService = jwtAuthService;
}
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(JwtLogin.class);
}
@Override
public Member resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) throws Exception {
String authorizationHeader = webRequest.getHeader(HttpHeaders.AUTHORIZATION);
validateAuthorization(authorizationHeader);
return jwtAuthService.findMemberByJwtPayload(getJwtPayload(Objects.requireNonNull(authorizationHeader)));
}
private void validateAuthorization(final String authorizationHeader) {
if (!hasAuthorizationHeader(authorizationHeader) || !isBearerToken(authorizationHeader)) {
throw new BearerTokenNotFoundException();
}
}
private boolean hasAuthorizationHeader(final String authorizationHeader) {
return authorizationHeader != null;
}
private boolean isBearerToken(final String authorizationHeader) {
return authorizationHeader.split(TOKEN_SEPARATOR)[BEARER_INDEX].equals(BEARER);
}
private String getJwtPayload(final String authorizationHeader) {
return authorizationHeader.split(TOKEN_SEPARATOR)[PAYLOAD_INDEX];
}
}
Controller
현재는 회원가입, 로그인의 기능이 구현되어 있다.
@RequestMapping("/api/auth")
@RestController
public class AuthController {
private final AuthService authService;
public AuthController(final AuthService authService) {
this.authService = authService;
}
@PostMapping("/sign-up")
public ResponseEntity<MemberResponse> register(@RequestBody @Valid final MemberCreateRequest memberCreateRequest) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(authService.register(memberCreateRequest));
}
@PostMapping("/sign-in")
public ResponseEntity<TokenResponse> login(@RequestBody @Valid final MemberLoginRequest memberLoginRequest) {
return ResponseEntity.ok(authService.login(memberLoginRequest));
}
}
Service
AuthService를 구현하고 있는데, AuthService의 경우에는 나중에 OAuth로그인이 추가될 수 도 있기에 Interface를 이용하였다.
@Service
public class JwtAuthService implements AuthService {
private final MemberRepository memberRepository;
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthService(final MemberRepository memberRepository, final JwtTokenProvider jwtTokenProvider) {
this.memberRepository = memberRepository;
this.jwtTokenProvider = jwtTokenProvider;
}
@Transactional
public MemberResponse register(final MemberCreateRequest memberCreateRequest) {
Member member = memberRepository.save(Member.from(memberCreateRequest.getEmail(),
memberCreateRequest.getPassword()));
return MemberResponse.from(member);
}
@Transactional(readOnly = true)
public TokenResponse login(final MemberLoginRequest memberLoginRequest) {
Member member = memberRepository.findByEmail(memberLoginRequest.getEmail())
.orElseThrow(MemberNotFoundException::new);
validateLogin(member, memberLoginRequest);
String accessToken = jwtTokenProvider.createAccessToken(memberLoginRequest.getEmail());
return new TokenResponse(accessToken);
}
private void validateLogin(final Member member, final MemberLoginRequest memberLoginRequest) {
if (!member.isEmailSameWith(memberLoginRequest.getEmail())) {
throw new AuthorizationInvalidEmailException(memberLoginRequest.getEmail());
}
if (!member.isPasswordSameWith(memberLoginRequest.getPassword())) {
throw new AuthorizationInvalidPasswordException(memberLoginRequest.getPassword());
}
}
@Transactional(readOnly = true)
public Member findMemberByJwtPayload(final String jwtPayload) {
String jwtPayloadOfEmail = jwtTokenProvider.getPayload(jwtPayload);
return memberRepository.findByEmail(jwtPayloadOfEmail)
.orElseThrow(MemberNotFoundException::new);
}
}
Test
앞서 말한 대로 여러 테스트를 작성하였다.
- 컨트롤러 E2E 테스트
- 컨트롤러 단위 테스트
- 서비스 통합 테스트
- 서비스 단위 테스트
- 도메인 테스트
이번엔 Rest Docs를 사용하였는데, 아래와 같이 사용되었다.
우선 Rest Docs를 쉽게 사용하기 위해 helper를 만들었다.
public class RestDocsHelper {
public static RestDocumentationResultHandler customDocument(final String identifier, final Snippet... snippets) {
return document("{class-name}/" + identifier,
preprocessRequest(
prettyPrint()),
preprocessResponse(
prettyPrint()),
snippets
);
}
}
실제 테스트 코드는 아래와 같이 작성되었다.
@WebMvcTest(AuthController.class)
@AutoConfigureRestDocs
public class AuthControllerUnitTest {
@MockBean
private JwtAuthService jwtAuthService;
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@DisplayName("회원가입을 진행한다.")
@Test
void sign_up() throws Exception {
// given
MemberCreateRequest memberCreateRequest = new MemberCreateRequest("a@a.com", "1234");
MemberResponse memberResponse = MemberResponse.from(MemberFixture.createMember());
given(jwtAuthService.register(Mockito.any(MemberCreateRequest.class))).willReturn(memberResponse);
// when & then
mockMvc.perform(post("/api/auth/sign-up")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(memberCreateRequest))
).andExpect(status().isCreated())
.andExpect(jsonPath("$.memberId").value(1))
.andExpect(jsonPath("$.email").value(memberCreateRequest.getEmail()))
.andDo(customDocument("register_member",
requestFields(
fieldWithPath(".email").description("회원가입할 이메일 주소"),
fieldWithPath(".password").description("사용할 패스워드")
),
responseFields(
fieldWithPath("memberId").description("회원가입 후 반환된 Member의 ID"),
fieldWithPath("email").description("회원가입 후 반환된 Member의 Email")
)
));
}
@DisplayName("로그인을 진행한다.")
@Test
void login() throws Exception {
// given
MemberLoginRequest memberLoginRequest = new MemberLoginRequest("a@a.com", "1234");
TokenResponse tokenResponse = new TokenResponse("accessToken");
given(jwtAuthService.login(any(MemberLoginRequest.class))).willReturn(tokenResponse);
// when & then
mockMvc.perform(post("/api/auth/sign-in")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(memberLoginRequest))
).andExpect(status().isOk())
.andExpect(jsonPath("$.accessToken").value(tokenResponse.getAccessToken()))
.andDo(customDocument("login",
requestFields(
fieldWithPath(".email").description("로그인 이메일 주소"),
fieldWithPath(".password").description("로그인 패스워드")
),
responseFields(
fieldWithPath(".accessToken").description("Jwt AccessToken 값")
)
));
}
}
이렇게 작성한 코드를 index.adoc 파일에서 문서화할 수 있다.
:toc: left
:source-highlighter: highlightjs
:sectlinks:
:toclevels: 2
:sectlinks:
:sectnums:
== Auth
=== 회원 가입
==== 요청
include::{snippets}/auth-controller-unit-test/register_member/http-request.adoc[]
include::{snippets}/auth-controller-unit-test/register_member/request-fields.adoc[]
==== 응답
include::{snippets}/auth-controller-unit-test/register_member/http-response.adoc[]
include::{snippets}/auth-controller-unit-test/register_member/response-fields.adoc[]
=== 로그인
==== 요청
include::{snippets}/auth-controller-unit-test/login/http-request.adoc[]
include::{snippets}/auth-controller-unit-test/login/request-fields.adoc[]
==== 응답
include::{snippets}/auth-controller-unit-test/login/http-response.adoc[]
include::{snippets}/auth-controller-unit-test/login/response-fields.adoc[]
그럼 아래와 같이 문서화가 되어서 나타난다.
나머지 테스트도 무사히 통과하는 것을 볼 수 있다.
자세한 코드는 아래 깃허브 주소를 통해 확인할 수 있습니다.
https://github.com/kimtaesoo99/ChatUniv
'프로젝트 > ChatUniv' 카테고리의 다른 글
[프로젝트] ChatUniv 페이징 최적화 (2) | 2023.09.02 |
---|---|
[프로젝트] ChatUniv GPT 적용 (0) | 2023.08.26 |
[프로젝트] ChatUniv Comment API, 동적 테스트 #4 (0) | 2023.07.30 |
[프로젝트] ChatUniv Board API #3 (0) | 2023.07.16 |
[프로젝트] ChatUniv Member API #2 (0) | 2023.07.16 |