작업기록1. 카카오 로그인 API 구현

박서영·2026년 7월 31일

(1) 액세스 토큰 & DB 저장: OauthID(KakaoID)

  • 유튜브에서 공부했던 건 외부 API로 로그인하는게 아니라 직접 서버에서 아이디, 비밀번호를 관리하는 것이었는데 외부 API를 호출해서 처리하는거랑은 또 달라서? 어떻게 해야할지?
    • 기존은 ‘아이디, 비밀번호’를 DB에 암호화해서 저장하는 방식
    • 외부 API는 카카오에서 ‘액세스 토큰’을 받아서 그걸로 인증하는 방식 → DB에 액세스 토큰을 저장하면 될 듯?
      • 액세스 토큰은 암호화 안해도 되는지? (비밀번호같은거는 노출되면 안되니까 암호화해야하는데) ⇒ 액세스 토큰을 DB에 저장하는게 아님
    • 카카오에서 발급하는 액세스 토큰(accessToken)은 DB에 저장하지 않음. 요청-응답에서 카카오 쪽에 해당 회원이 누군지? 인증용으로 일회성으로 묻는 용도.
      • 거기에 더해 전송 구간은 HTTPS(TLS)로 보호됨. 포스트맨/프론트 → 우리 서버, 우리 서버 → 카카오는 둘 다 HTTPS로 통신하니까 암호화되어서 전송됨. Payload 안의 문자열을 추가 암호화할 필요는 없음.
    • 저장하는 건 ‘OauthID(kakaoId)’: 카카오가 각 사용자에게 부여하는 고유하고, 변하지 않는 회원 식별번호. ⇒ 이게 영구적인 회원 식별자로 저장. 암호화가 불필요함
      • 암호화 불필요한 이유: 회원 식별 용도이지, 이게 탈취당했다고해서 바로 계정이 탈취되지는 않음.
  • 전체적으로 액세스 토큰 받아오는 부분은 프론트 쪽에서 카카오 개발자 콘솔 작업을 진행하고 백엔드에서는 프론트에서 액세스 토큰을 전달한다는 가정하에 진행하였다. 백엔드에서 처리하는 방법으로 진행하였다면 아마 Rediret URL 같은 작업도 했어야했을 것 같다.

(2) 폴더 구조

  • 폴더 구조는 전체적으로 팀에서 정한 것 내에서 조금씩 수정. 동아리 활동하면서 도메인(domain) 내에 controller, service, dto, repository 구조에는 되게 익숙해서 기본으로 추가함

    • 그런데 repository 같은 경우에는 auth 도메인 내에는 불필요했음! 비밀번호를 서버에서 직접 저장해서 관리하지 않으니까, 카카오의 응답 확인하고(auth) 그 회원의 oauthId(kakaoId)하고 정보를 DB에 저장하는데 이 부분은 member 도메인쪽이라 그쪽 repository 폴더에 넣어둠.

    서버 쪽에서 jwt(토큰)을 발급해줘야하니까 해당 내용 관련 폴더 하나 파고, 카카오 외부 API 로그인을 구현할 거라 해당 부분 관련 내용을 넣을 client 폴더를 추가함.

(3) Client 폴더

실질적으로? 확장성 생각하면 카카오 로그인 외에도 대부분의 서비스에 구글, 다른 SNS 로그인이 있을 수 있긴하니까, OauthApiClient, OauthUserInfo 같이 공통 인터페이스로 빼두었다. 공통 부분을 인터페이스로 빼는게 추후에 확장성에는 전체적으로 좋을 것 같긴하니까?

OauthApiClient

  • 제일 착잡했던 부분? 외부 API 가져다가 쓰는게 처음이라 어떤식으로 써야할지? 코드를 어떻게 작성해야할지도 모르겠었다.
//OauthApiClient.java
public interface OauthApiClient {
    OauthUserInfo getUserInfo(String accessToken);
    OauthProvider supportedProvider();
}
  • 일단은 인터페이스니까 여러 api에서 공통적으로 사용할 부분들 정의
  • OauthProvider의 경우에는 enum으로 만들었고, 카카오는 KAKAO로 두었다. 구글을 하게되면 GOOGLE 이렇게 될테니까?

OauthUserInfo

public interface OauthUserInfo {
    String getProviderId(); //각 소셜의 고유 ID(문자열로 통일)
    String getNickname();
    String getProfileImageUrl();
    OauthProvider getProvider();
}
  • 사실 현재 구현하려는 서비스 정책 기준으로 필요한건 providerID 즉 kakaoID 하나가 전부였다. 닉네임이나 프로필 이미지는 필요한 부분은 아니었고, OauthProvider도 확장성 때문에 두긴했지만 실질적으로는 KAKAO 하나였으니까?

KakaoApiClient

  • 위에서 만든 인터페이스를 실질적으로 구현해야하는 부분 이 부분 하면서 그래도 어느정도 외부 API 가져와서 쓰는게 이런 느낌이구나 했던 것 같다.
  • 일단 찾아봤을 때, WebClient가 필요하다고 해서 설정하고 필드로 두었다. @RequiredArgsConstructor 어노테이션 써서 스프링 컨테이너에서 주입하는 형식으로
    • 일단 WebClient는 WebClientConfig를 통해 Bean으로 등록했다.
    • 위처럼 스프링 빈으로 등록하면, 다른 곳에서 주입받아서 쓸 수 있으니까. 이렇게 해두고 어노테이션 써서 주입하는 식?
  • ✔️ WebClient 의존성
    • build.gradle에 spring-boot-starter-webflux. WebFlux에 webclient가 포함되어있음
@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를 통해서 카카오 사용자 정보 API를 호출 → access token을 Authorization: Bearer … 헤더로 보냄 → 이 응답을 다시 KaKaoUserInfo(OauthUserInfo 인터페이스 구현)로 받는 것.

✅ WebClient의 표준 API

  • WebClient: 스프링이 제공하는 HTTP 클라이언트.
    • 표준 API → 어떠한 외부 API를 호출하더라도 항상 이 패턴을 사용함
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 요청에 헤더를 실어서 보내는 것.
    • “Authorization”, “Bearer ” 형식은 OAuth 2.0 표준 관례이며, WebClient 고유 문법이 아니라 HTTP 표준(RFC 6750)
  • retrieve(): 응답 받아오기 시작. 즉, 실제 요청을 보내고 응답을 받아오겠다는 선언부. WebClient 자체의 API 설계상 필요한 것.
  • bodyToMono(): 요청의 응답으로 온 Body를 서버(우리 서버)에서 정의한 객체로 변환하는 부분.
    • 즉, 응답으로 온 JSON을 직접 선언/만든 KakaoUserInfoResponse라는 자바 객체로 자동 변환해달라는 의미.
    • Mono: 리액티브 스트림에서 0개 또는 1개 결과가 나중에 온다는 것을 나타내는 타입.
  • block(): 비동기 결과를 동기적으로 기다려서 받기.
    • Mono-비동기 결과를 결과 완료까지 기다렸다가 실제 값으로 반환해달라는 의미. → 프로젝트는 서블릿(동기) 기반이기에, 리액티브 결과를 동기 코드 흐름에 맞추기 위해서 이렇게 처리 ⇒ 아직 동기/비동기 개념이 막 잘 와닿지는 않는 느낌…? 이론적으로는 알지만 실제로..어디서 쓰이겠다 이런?

KakaoUserInfoResponse

  • @JsonProperty: JSON 필드명과 자바의 필드명을 연결(매핑)해주는 어노테이션
    • Jackson: JSON ↔ 자바 객체 변환 라이브러리
    • Jackson에게 자바 필드명과 JSON의 필드명을 서로 매칭시키라는 의미.
  • KakaoProfile의 내용은 카카오 API 문서에 명시된 응답 스펙 ⇒ 외부 API 어떻게 쓰는지 궁금했는데, 뭔가 프론트-백엔드 소통처럼 명시된 API 명세서/JSON에 따라서 응답 받고, 요청을 보내는 식이라 신기했음! (클래스명은 당연하지만, 자유) + 구글의 경우를 찾아봤는데, 구글은 또 다른 형식의 응답인데다가 중첩 구조가 아니라 flat한 구조였음!
    • 카카오의 실제 JSON 응답 형식
      {
        "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
        ) {}
    }
}

(4) JWT 폴더

JwtUtil

  • JWT 토큰 관련해서 유튜브를 봤을 때는, 전체적인 흐름은 서버에서 자체적으로 토큰을 발급해서 클라이언트 쪽에서 요청을 보낼 때 해당 토큰을 헤더에 보내게되고, 이 토큰이 해당 서버에서 발급한 토큰이 맞는 확인하는게 JWT를 활용해서 인증을 진행하는 전체적인 흐름인 것 같았다.
  • JwtUtil은 그중에서 서버에서 자체적으로 토큰을 발급하는 부분을 담당.
  • 액세스 토큰, 리프레시 토큰을 생성하는 로직 존재
@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 객체로 변환해주는 역할.
      • 필요한 이유: JWT 서명(signWith(key))의 경우 HMAC이라는 암호화 알고리즘을 쓰는데 문자열이 아니라 정해진 형식의 키 객체를 요구. 따라서 문자열을 바이트로 바꾼 후에, 바이트를 알고리즘이 이해할 수 있는 키 형태(SecretKey)로 감싸는 것.
  • 토큰 생성
    • 토큰을 생성할 때 현재 시각, 만료 시각을 포함. (issuedAt, expiration)
    • signWith의 경우, 서명하는 것. → 변조되지 않았음을 증명.
  • 생성자
    • @Value 어노테이션: application.yml의 설정값을 자바 필드/파라미터에 자동으로 주입해주는 스프링 어노테이션
      • 이걸로 서명할 때 쓸 문자열, 액세스 토큰의 유효시간, 리프레시 토큰의 유효시간을 가져와서 클래스의 필드 설정해둠.

(5) Config 폴더

WebClientConfig

  • WebClient 객체를 딱 한 번 만들어서 스프링이 관리하는 공용 빈으로 등록하는 설정.
    • 현재는 KakaoApiClient에서만 사용하지만 추후 다른 API를 호출할 것이기에 빈으로 등록함.
  • @Configuration: 스프링에 해당 클래스가 Bean 설정을 담당하는 클래스임을 알림
  • @Bean: 해당 메소드가 반환하는 객체를 스프링이 관리하는 빈으로 등록하라는 의미.
  • 추후에 AI 스트리밍을 팀원분이 구현할 예정이라 WebFlux까지 넣음.
@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        return WebClient.builder().build();
    }
}

SecurityConfig

  • 이 부분은 사실 유튜브 내용을 그대로 따라 쓴 느낌..? 필터체인 부분이고, CSRF, FORM 로그인 부분의 체인은 JWT 사용할 때 사용하지 않을 거기에 꺼주었고, 마찬가지로 HTTP의 기본 인증 방식 역시 여기서는 사용하지 않을거라 꺼주었다.
  • authorizationHttpRequest 부분을 통해 경로별 권한/인가를 관리할 수 있는데 우선은 모든 경로에서 인증 필요없이 통과되도록 설정. (임시로 현재 테스트 진행을 위해서.)
  • 세션을 stateless로 유지하도록 설정.
@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();
    }
}

(6) Controller

  • Jwt 설정이나 토큰 발급, 실제 카카오 API하고 연동하는 부분을 처리하고 나서 Controller나 Service 부분의 작업은 그렇게 애먹지는 않았던 것 같다.
  • @Slf4j를 통해서 로깅하는 거는 팀 컨벤션? 규칙이었는데 확실히 좋은 것 같다. 에러 났을 때 원인 파악도 훨씬 수월했다.
  • ApiResponse라는 공통 응답형식도 존재하여 사용해서 응답함.
  • Request만 실상 Service 쪽으로 전달하는 느낌.
@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));
    }
}

(7) Service

  • 초반에 작성하였기도 하고, 이때는 로그인 구현을 끝내는게 우선이라 이미 존재하는 회원인지의 검증을 따로 헬퍼 메소드로 분리하지 않고 signIn() 메소드 안에 작성하였었다.
  • OauthApiClientOauthUserInfo 를 통해서 요청자 정보를 request로부터 가져옴.
    • provider: 어떤 SNS인지. 카카오
    • oauthAccessToken: SNS 액세스 토큰. 이 토큰을 가지고 카카오 서버에 사용자 정보를 재요청하는 것.
  • 이미 존재하지 않는 사용자와 존재하는 사용자를 구분해서 처리.
    • 존재하지 않는 사용자 → 새로 회원가입(DB 저장) 후 로그인
  • 로그인 시점에 액세스 토큰과 리프레시 토큰을 발급.
  • 응답에 만료시점을 전달하는 쪽으로 API 명세서를 짰기에 expiresInexpiresAt을 계산해서 전달하는 방식을 취함.
@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));
    }
}

(8) DTO

Request

public record SignInRequest(
        @NotNull (message = "provider는 필수입니다.")
        OauthProvider provider,
        @NotBlank(message = "oauthAccessToken은 필수입니다.")
        String oauthAccessToken) {}

Response

@Builder
public record SignInResponse(
    Long memberId,
    String accessToken,
    String refreshToken,
    long expiresIn,
    LocalDateTime expiresAt,
    boolean isNewUser
) {}
  • API 명세서는 팀원 다같이 짜서 그걸 토대로 응답을 구성.
  • 서버에서 발급한 액세스 토큰, 리프레시 토큰, 그리도 회원ID를 전달.
  • 외에도 만료시간(액세스토큰의)과 시각, 새로운 회원인지의 여부를 판단하여 전달.

build.gradle 설정

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을 사용해서 카카오 서버에 사용자 정보 요청
  • 카카오 서버에서 해당 토큰을 검증한 후에 사용자 정보를 전달해줌
  • 전달받은 사용자 정보를 변환해서 사용.
profile
이불 밖은 위험해.

0개의 댓글