[React+Spring] 카카오 소셜 로그인

HJ·2024년 2월 2일
1

React+Spring

목록 보기
8/11

아래 내용은 코드로 배우는 React with 스프링부트 API서버 강의와 함께 설명이 부족한 부분에 대해서 조사하여 추가한 내용입니다.

강의 코드를 그대로 따라 친 것이 아닌 제 나름대로 작성한 코드들이 있기 때문에 강의 코드와 동일하진 않습니다.


카카오 로그인


카카오는 권한부여 승인코드 방식( AuthorizationCode Grant )을 사용합니다. 이와 관련된 설명은 이전 게시글에서 확인하실 수 있고 카카오 로그인 정보는 해당 공식문서에서 확인할 수 있습니다.

React 에서 Kakao Auth Server 에서 인가 코드와 Access Token 을 받고, Access Token 을 Spring 으로 넘겨주어서, Spring 에서 사용자 정보를 받도록 하였습니다.


Step 1. 인가 코드 받기

// 1. 인가 코드 받기
const rest_api_key = '';
const redirect_uri = 'http://localhost:3000/member/kakao';
const kakao_auth_path = 'https://kauth.kakao.com/oauth/authorize';

export const getKaKaoLoginLink = () => {
    const kakaoURL = `${kakao_auth_path}?client_id=${rest_api_key}&redirect_uri=${redirect_uri}&response_type=code`;
    return kakaoURL;
}
  1. 인가 코드를 받기 위해서는 https://kauth.kakao.com/oauth/authorizeGET 방식으로 요청합니다.

  2. 해당 요청을 할 때 필수 쿼리 파라미터로 client_id, redirect_uri, response_type 를 함께 보내주어야 합니다.

  3. client_id 는 REST API KEY 를, redirect_url 는 인가 코드를 전달받을 URI 를 의미하고, response_typecode 로 고정됩니다.

  4. 로그인을 시도하면 Kakao Auth Server 에서 Redirect URI 로 인가 코드를 전달하는데 redirect_uri?code=XXXXXX 형식으로 전달됩니다.

  5. 리다이렉트 되는 페이지에서 searchParams() 를 통해 code 값을 추출할 수 있습니다.


Step 2. Access Token 받기

const access_token_uri = 'https://kauth.kakao.com/oauth/token';

// 2. 인가 코드를 통해 Access Token 발급
export const getAccessToken = async(authCode) => {
    const header = { headers: {
            "Content-Type" : "application/x-www-form-urlencoded;charset=utf-8"
    }}
    const body = {
        grant_type: 'authorization_code',
        client_id: rest_api_key,
        redirect_uri: redirect_uri,
        code: authCode
    }
    const res = await axios.post(access_token_uri, body, header);
    const accessToken = res.data.access_token;
    return accessToken;
}
  1. 토큰 발급을 요청할 때는 https://kauth.kakao.com/oauth/tokenPOST 방식으로 요청합니다.

  2. 이때 Content-Type 에는 application/x-www-form-urlencoded;charset=utf-8 을 지정합니다.

  3. body 에는 필수값들인 grant_type, client_id, redirect_uri, code 를 전달합니다.

  4. grant_type 은 authorization_code 로 고정되고, code 는 전달받은 인가 코드를 의미합니다. 나머지는 이전과 동일합니다.

  5. 요청의 응답으로는 access_token, expires_in, refresh_token, scope 등이 오게 됩니다.

  6. 리다이렉트 페이지에서 useEffect() 를 사용하여 인가 코드를 전달 받은 다음 Access Token 을 받을 수 있도록 합니다.


Step 3. 사용자 정보 받기

private String[] getMemberInfoFromKakao(String accessToken) {
        String email = "";
        String userInfoUri = "https://kapi.kakao.com/v2/user/me";

        RestTemplate restTemplate = new RestTemplate();

        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", JwtConstant.JWT_TYPE + accessToken);
        headers.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");

        HttpEntity<String> entity = new HttpEntity<>(headers);

        UriComponents uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(userInfoUri).build();

        // 요청한 결과로 LinkedHashMap 으로 나온다
        ResponseEntity<LinkedHashMap> responseEntity = restTemplate.exchange(
                uriComponentsBuilder.toUri(),
                HttpMethod.GET,
                entity,
                LinkedHashMap.class);

        LinkedHashMap<String, LinkedHashMap> responseEntityBody = responseEntity.getBody();
        LinkedHashMap<String, String> kakaoAccount = responseEntityBody.get("kakao_account");
        LinkedHashMap<String, String> properties = responseEntityBody.get("properties");
        String nickname = properties.get("nickname");
        String email = kakaoAccount.get("email");

        log.info("responseEntityBody = {}", responseEntityBody);
        
        return new String[]{nickname, email};
    }
  1. 리다이렉트 페이지에서 서버가 사용자 정보를 받을 수 있도록 API 를 요청합니다.

  2. 사용자 정보 요청은 https://kapi.kakao.com/v2/user/meGET/POST 방식으로 요청합니다.

  3. Authorization 헤더에는 Bearer ${ACCESS_TOKEN} 형식으로 전달 받은 Access Token 을 추가합니다.

  4. Content-Type 헤더에는 application/x-www-form-urlencoded;charset=utf-8 를 지정합니다.


아래는 사용자 정보를 추출하는 URL 을 호출했을 때 나오는 응답의 body 에 있는 데이터입니다.

현재 카카오는 비즈앱을 등록하지 않으면 이메일을 제공하지 않는데 예전에 만들어둔 프로젝트를 사용해 이메일을 받을 수 있도록 하였습니다.

responseEntityBody = {
	id=30...
	connected_at=2024-02-01T11:23:54Z
	properties={nickname=XXXX}
	kakao_account={
		profile_nickname_needs_agreement=false
		profile={nickname=XXXX}
		has_email=true
		email_needs_agreement=false
		is_email_valid=true
		is_email_verified=true
		email=XXXX
	}
}

[ 사용자 정보 수신 후 ]

public class OAuth2ServiceImpl implements OAuth2Service {
    ...
    @Override
    public MemberDTO getMember(String accessToken) {
        // 1. accessToken 을 이용해서 사용자 정보를 추출
        String[] memberInfoFromKakao = getMemberInfoFromKakao(accessToken);
        String nickname = memberInfoFromKakao[0];
        String email = memberInfoFromKakao[1];

        Member memberWithRoles = memberRepository.findMemberWithRoles(email);
        // 2-1. DB 에 회원 정보가 있는 경우
        if (memberWithRoles != null) {
            return entityToDTO(memberWithRoles);
        }
        
        // 2-2. DB 에 회원 정보가 없는 경우 신규 생성
        Member newSocialMember = createNewSocialMember(nickname, email);
        memberRepository.save(newSocialMember);
        return entityToDTO(newSocialMember);
    }
}

이메일을 통해 DB 에서 조회한 다음, 사용자가 있다면 찾아서 반환하고 없다면 createNewSocialMember 에 닉네임과 이메일을 전달해 새로운 사용자를 만들도록 하였습니다.

생성한 다음 Controller 에서 전달 받은 MemberDTO 의 정보와 함께 Access Token, Refresh Token 을 생성하여 React 로 넘겨주게 됩니다.


[ 리다이렉트 페이지 ]

// 인가 코드 요청 시 호출되는 리다이렉트 페이지
function KakaoRedirectPage() {
    // step 1 다음 : 인가 코드를 추출
    const [searchParams] = useSearchParams();
    const authCode = searchParams.get('code');

    // step 2 이전 : getAccessToken 을 호출하여 인가 코드로 Access Token 받기
    useEffect(() => {
        getAccessToken(authCode).then(accessToken => {
            // step 2 다음, step 3 이전 : access token 을 전달하여 서버가 사용자 정보를 추출할 수 있도록 하기
            getMemberFromKakao(accessToken).then(result => {
                // getMemberFromKakao url : /api/member/kakao?accessToken=XXX
                console.log("소셜 로그인 결과 = ", result);    
            })
        })
    }, [authCode])
    ...
}
  1. Step 1 다음 리다이렉트 되는 페이지이며 쿼리 파라미터에 담긴 인가 코드를 추출합니다.

  2. Step 2 이전에 호출되며 인가 코드가 도착하면 Access Token 을 받을 수 있도록 인가 코드를 전달하여 Kakao API 를 호출합니다.

  3. Step 2 다음 호출되며 전달 받은 Access Token 을 Spring 에서 구현한 사용자 정보를 추출하는 API 를 호출할 때 함께 전달합니다.

  4. Step 3 는 Spring 에서 수행되며, getMemberFromKakao 호출 결과로 사용자 정보와 함께 Access Token, Refresh Token 을 생성해서 전달해줍니다.




로그인 상태 변경


직접 로그인할 때는 로그인을 호출하는 API 를 loginSlice 에서 createAsyncThunk 로 Thunk 액션을 만들고 extraReducers 를 통해서 상태가 변경되도록 하였습니다.

소셜 로그인의 경우, 애플리케이션의 상태 변경을 위해 reducers 에 정의된 login, logout 을 통해 상태를 변경해보도록 하겠습니다.

1. login 액션 디스패치

import { login } from '../../slices/loginSlice';

function KakaoRedirectPage() {
    const dispatch = useDispatch();

    useEffect(() => {
        getAccessToken(authCode).then(accessToken => {
            getMemberFromKakao(accessToken).then(result => {
                console.log("소셜 로그인 결과 = ", result);   
                dispatch(login(result));
            })
        })
    }, [authCode])
}

소셜 로그인 결과로 반환된 사용자 정보를 useDispatch 훅을 이용하여 Redux 스토어에 login 액션을 디스패치합니다.


2. login 상태 변경

const loginSlice = createSlice({
    ...
    reducers: {
        login: (state, action) => {
            setCookie("member", JSON.stringify(action.payload), 1);
            return action.payload;
        },
        ...
    }
    ...
})

위의 코드에서 로그인한 사용자의 정보를 나타내는 action.payload 를 반환하고 있습니다.

action.payload 는 해당 액션이 디스패치될 때 전달된 데이터를 의미합니다. 이 데이터를 기반으로 상태를 업데이트합니다.

즉, 반환된 값은 Redux 스토어의 상태로 설정되어 애플리케이션 전체에서 해당 상태를 참조할 수 있게 됩니다.




로그인 후 페이지 이동


이동 경로 지정

function KakaoRedirectPage() {
    ...
    getMemberFromKakao(accessToken).then(result => {
        dispatch(login(result));

        if(result && result.needModifyFlag) {
            moveToPath("/member/modify");
        } else {
            moveToPath("/");
        }
    })
    ...
}

dispatch 가 끝나고 소셜 로그인 한 사용자라면 비밀번호 수정이 필요하므로 멤버를 수정하는 페이지로, 단순 로그인 한 사용자라면 메인으로 이동하도록 설정합니다.


[ ModifyComponent ]

// 수정 가능한 정보들
const initState = {
    password: '',
    nickname: ''
}

function ModifyComponent() {
    const loginInfo = useSelector(state => state.loginSlice);
    const [member, setMember] = useState(initState);

    useEffect(() => {
        setMember({ ...loginInfo, password: ''});
    }, [loginInfo]);

    const handleChange = (event) => {
        member[event.target.name] = event.target.value;
        setMember({ ...member });
    }
}
  1. initState 에 수정 가능한 정보들을 지정합니다.

  2. useSelector 를 통해 스토어에 저장된 사용자의 정보를 가져옵니다.

  3. 사용자의 정보는 컴포넌트에서 상태로 관리하고, 가져온 정보를 set 메서드를 통해 member 에 상태로 지정합니다. 이때 비밀번호를 임의로 지정해서 저장시켰기 때문에 비밀번호를 지정하도록 비워둡니다.

  4. member 에 저장된 정보들을 보여주고, 값이 변경되면 handleChange 함수가 호출되도록 합니다.

0개의 댓글