폴더 구조는 전체적으로 팀에서 정한 것 내에서 조금씩 수정. 동아리 활동하면서 도메인(domain) 내에 controller, service, dto, repository 구조에는 되게 익숙해서 기본으로 추가함
서버 쪽에서 jwt(토큰)을 발급해줘야하니까 해당 내용 관련 폴더 하나 파고, 카카오 외부 API 로그인을 구현할 거라 해당 부분 관련 내용을 넣을 client 폴더를 추가함.

실질적으로? 확장성 생각하면 카카오 로그인 외에도 대부분의 서비스에 구글, 다른 SNS 로그인이 있을 수 있긴하니까, OauthApiClient, OauthUserInfo 같이 공통 인터페이스로 빼두었다. 공통 부분을 인터페이스로 빼는게 추후에 확장성에는 전체적으로 좋을 것 같긴하니까?
//OauthApiClient.java
public interface OauthApiClient {
OauthUserInfo getUserInfo(String accessToken);
OauthProvider supportedProvider();
}
public interface OauthUserInfo {
String getProviderId(); //각 소셜의 고유 ID(문자열로 통일)
String getNickname();
String getProfileImageUrl();
OauthProvider getProvider();
}
@RequiredArgsConstructor 어노테이션 써서 스프링 컨테이너에서 주입하는 형식으로@Component
@RequiredArgsConstructor
public class KakaoApiClient implements OauthApiClient {
private final WebClient webClient;
@Override
public OauthUserInfo getUserInfo(String accessToken) {
try {
return webClient.get()
.uri("https://kapi.kakao.com/v2/user/me")
.header("Authorization", "Bearer " + accessToken)
.retrieve()
.bodyToMono(KakaoUserInfoResponse.class)
.block();
} catch (WebClientResponseException e) {
throw new BusinessException(GlobalErrorCode.INVALID_KAKAO_TOKEN);
}
}
@Override
public OauthProvider supportedProvider() {
return OauthProvider.KAKAO;
}
}
webClient.get()
.uri("https://kapi.kakao.com/v2/user/me")
.header("Authorization", "Bearer " + accessToken)
.retrieve()
.bodyToMono(KakaoUserInfoResponse.class)
.block();
get(): HTTP 메소드 작성. GET/POST/PUT/DELETE 요청 가능uri(): 요청을 보낼 주소 URL. 위에서는 카카오 API 문서에 명시되어있는 엔드포인트를 작성header(): HTTP 헤더 추가 → 즉, HTTP 요청에 헤더를 실어서 보내는 것.retrieve(): 응답 받아오기 시작. 즉, 실제 요청을 보내고 응답을 받아오겠다는 선언부. WebClient 자체의 API 설계상 필요한 것.bodyToMono(): 요청의 응답으로 온 Body를 서버(우리 서버)에서 정의한 객체로 변환하는 부분.KakaoUserInfoResponse라는 자바 객체로 자동 변환해달라는 의미.Mono: 리액티브 스트림에서 0개 또는 1개 결과가 나중에 온다는 것을 나타내는 타입.block(): 비동기 결과를 동기적으로 기다려서 받기.Mono-비동기 결과를 결과 완료까지 기다렸다가 실제 값으로 반환해달라는 의미. → 프로젝트는 서블릿(동기) 기반이기에, 리액티브 결과를 동기 코드 흐름에 맞추기 위해서 이렇게 처리 ⇒ 아직 동기/비동기 개념이 막 잘 와닿지는 않는 느낌…? 이론적으로는 알지만 실제로..어디서 쓰이겠다 이런?@JsonProperty: JSON 필드명과 자바의 필드명을 연결(매핑)해주는 어노테이션KakaoProfile의 내용은 카카오 API 문서에 명시된 응답 스펙 ⇒ 외부 API 어떻게 쓰는지 궁금했는데, 뭔가 프론트-백엔드 소통처럼 명시된 API 명세서/JSON에 따라서 응답 받고, 요청을 보내는 식이라 신기했음! (클래스명은 당연하지만, 자유) + 구글의 경우를 찾아봤는데, 구글은 또 다른 형식의 응답인데다가 중첩 구조가 아니라 flat한 구조였음!{
"id": 123456789,
"kakao_account": {
"profile": {
"nickname": "홍길동",
"profile_image_url": "https://..."
}
}
}public record KakaoUserInfoResponse (
Long id,
@JsonProperty("kakao_account") KakaoAccount kakaoAccount
) implements OauthUserInfo {
@Override
public String getProviderId() {return String.valueOf(id);}
@Override
public String getNickname() {return kakaoAccount.profile().nickname();}
@Override
public String getProfileImageUrl() {return kakaoAccount.profile().profileImageUrl();}
@Override
public OauthProvider getProvider() {return OauthProvider.KAKAO;}
public record KakaoAccount(KakaoProfile profile) {
public record KakaoProfile(
String nickname,
@JsonProperty("profile_image_url") String profileImageUrl
) {}
}
}
@Component
public class JwtUtil {
private final SecretKey key;
private final Long accessExpirationMs;
private final Long refreshExpirationMs;
public JwtUtil (@Value("${jwt.secret}")String secret,
@Value("${jwt.access-expiration}")Long accessExpirationMs,
@Value("${jwt.refresh-expiration}")Long refreshExpirationMs) {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.accessExpirationMs = accessExpirationMs;
this.refreshExpirationMs = refreshExpirationMs;
}
public String createAccessToken(Long memberId) {
return createToken(memberId, accessExpirationMs);
}
public String createRefreshToken(Long memberId) {
return createToken(memberId, refreshExpirationMs);
}
private String createToken(Long memberId, Long expirationMs) {
Date now = new Date();
Date expiry = new Date(now.getTime()+expirationMs);
return Jwts.builder()
.claim("memberId", memberId)
.issuedAt(now)
.expiration(expiry)
.signWith(key)
.compact();
}
public Long getAccessExpirationMs() {return accessExpirationMs;}
}
SecretKey: jjwt 라이브러리에서 제공하는 타입으로 javax.crypto에 정의되어있는 인터페이스. 대칭키 암호화에 쓰이는 비밀키를 나타내는 타입.Keys.hmacShaKeyFor(): application.yml에 문자열로 쓰여있는 jwt.secret 값을 받아와서 SecretKey 객체로 변환해주는 역할.signWith(key))의 경우 HMAC이라는 암호화 알고리즘을 쓰는데 문자열이 아니라 정해진 형식의 키 객체를 요구. 따라서 문자열을 바이트로 바꾼 후에, 바이트를 알고리즘이 이해할 수 있는 키 형태(SecretKey)로 감싸는 것.issuedAt, expiration)signWith의 경우, 서명하는 것. → 변조되지 않았음을 증명.@Value 어노테이션: application.yml의 설정값을 자바 필드/파라미터에 자동으로 주입해주는 스프링 어노테이션@Configuration: 스프링에 해당 클래스가 Bean 설정을 담당하는 클래스임을 알림@Bean: 해당 메소드가 반환하는 객체를 스프링이 관리하는 빈으로 등록하라는 의미.@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
return WebClient.builder().build();
}
}
authorizationHttpRequest 부분을 통해 경로별 권한/인가를 관리할 수 있는데 우선은 모든 경로에서 인증 필요없이 통과되도록 설정. (임시로 현재 테스트 진행을 위해서.)@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
//CSRF disable
http.csrf((auth) -> auth.disable());
//FORM login disable
http.formLogin((auth) -> auth.disable());
//HTTP basic 인증방식 disable
http.httpBasic((auth) -> auth.disable());
//경로별 권한(인가)관리: 임시로 전체 permit
http.authorizeHttpRequests((auth) -> auth
.anyRequest().permitAll());
//세션 stateless로 유지
http.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
@Slf4j를 통해서 로깅하는 거는 팀 컨벤션? 규칙이었는데 확실히 좋은 것 같다. 에러 났을 때 원인 파악도 훨씬 수월했다.ApiResponse라는 공통 응답형식도 존재하여 사용해서 응답함.@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Slf4j
public class AuthController {
private final AuthService authService;
@PostMapping("/sign-in")
public ResponseEntity<ApiResponse<SignInResponse>> signIn(@Valid @RequestBody SignInRequest request) {
log.info("로그인 요청 - provider: {}", request.provider());
SignInResponse response = authService.signIn(request);
return ResponseEntity.ok(ApiResponse.success("로그인 성공", response));
}
}
signIn() 메소드 안에 작성하였었다.OauthApiClient와 OauthUserInfo 를 통해서 요청자 정보를 request로부터 가져옴.provider: 어떤 SNS인지. 카카오oauthAccessToken: SNS 액세스 토큰. 이 토큰을 가지고 카카오 서버에 사용자 정보를 재요청하는 것.expiresIn과 expiresAt을 계산해서 전달하는 방식을 취함.@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class AuthService {
private final List<OauthApiClient> oauthApiClientList;
private final MemberRepository memberRepository;
private final JwtUtil jwtUtil;
public SignInResponse signIn(SignInRequest request) {
OauthApiClient client = findClient(request.provider());
OauthUserInfo userInfo = client.getUserInfo(request.oauthAccessToken());
Optional<Member> existingMember = memberRepository.findByOauthIdAndOauthProvider(userInfo.getProviderId(), request.provider());
boolean isNewUser = existingMember.isEmpty();
Member member = existingMember.orElseGet(() -> memberRepository.save(
Member.builder()
.oauthProvider(request.provider())
.oauthId(userInfo.getProviderId())
.build()
));
String accessToken = jwtUtil.createAccessToken(member.getId());
String refreshToken = jwtUtil.createRefreshToken(member.getId());
long expiresIn = jwtUtil.getAccessExpirationMs()/1000;
LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(expiresIn);
log.info("로그인 성공 - memberId: {}, provider: {}, isNewUser: {}",
member.getId(), request.provider(), isNewUser);
return SignInResponse.builder()
.memberId(member.getId())
.accessToken(accessToken)
.refreshToken(refreshToken)
.expiresIn(expiresIn)
.expiresAt(expiresAt)
.isNewUser(isNewUser)
.build();
}
private OauthApiClient findClient(OauthProvider provider) {
return oauthApiClientList.stream()
.filter(client -> client.supportedProvider() == provider)
.findFirst()
.orElseThrow(() -> new BusinessException(GlobalErrorCode.UNSUPPORTED_PROVIDER));
}
}
public record SignInRequest(
@NotNull (message = "provider는 필수입니다.")
OauthProvider provider,
@NotBlank(message = "oauthAccessToken은 필수입니다.")
String oauthAccessToken) {}
@Builder
public record SignInResponse(
Long memberId,
String accessToken,
String refreshToken,
long expiresIn,
LocalDateTime expiresAt,
boolean isNewUser
) {}
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
✔️ 프론트에서의 작업 - 백엔드에서의 작업
- 프론트에서 어디까지 처리해서 전달이 이루어지는 것인지 궁금해서 찾아보았을 때
프론트
- 카카오 SDK 로그인 버튼 → 클릭 시 카카오 로그인 화면 팝업
- 카카오 SDK: 카카오가 배포하는 라이브러리? 카카오 로그인/공유하기/카카오맵을 앱/웹사이트에 붙일 수 있게 해주는 코드.
- 프론트에서 직접 카카오 인증 서버 URL로의 리다이렉트, 응답으로 온 인가코드 받고, 이걸 다시 보내서 accessToken으로 교환하고 에러 처리/팝업 관리하는 부분을 생략할 수 있음.
- 사용자가 카카오 로그인을 하면, 카카오에서 프론트에게 accessToken을 발급
- 이 accessToken을 프론트 → 백엔드로 전달
백엔드
- 전달 받은 accessToken을 사용해서 카카오 서버에 사용자 정보 요청
- 카카오 서버에서 해당 토큰을 검증한 후에 사용자 정보를 전달해줌
- 전달받은 사용자 정보를 변환해서 사용.