아래 내용은 코드로 배우는 React with 스프링부트 API서버 강의와 함께 설명이 부족한 부분에 대해서 조사하여 추가한 내용입니다.
강의 코드를 그대로 따라 친 것이 아닌 제 나름대로 작성한 코드들이 있기 때문에 강의 코드와 동일하진 않습니다.
카카오는 권한부여 승인코드 방식( AuthorizationCode Grant )을 사용합니다. 이와 관련된 설명은 이전 게시글에서 확인하실 수 있고 카카오 로그인 정보는 해당 공식문서에서 확인할 수 있습니다.
React 에서 Kakao Auth Server 에서 인가 코드와 Access Token 을 받고, Access Token 을 Spring 으로 넘겨주어서, Spring 에서 사용자 정보를 받도록 하였습니다.
// 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;
}
인가 코드를 받기 위해서는 https://kauth.kakao.com/oauth/authorize
에 GET
방식으로 요청합니다.
해당 요청을 할 때 필수 쿼리 파라미터로 client_id, redirect_uri, response_type 를 함께 보내주어야 합니다.
client_id 는 REST API KEY 를, redirect_url 는 인가 코드를 전달받을 URI 를 의미하고, response_type 은 code
로 고정됩니다.
로그인을 시도하면 Kakao Auth Server 에서 Redirect URI 로 인가 코드를 전달하는데 redirect_uri?code=XXXXXX
형식으로 전달됩니다.
리다이렉트 되는 페이지에서 searchParams()
를 통해 code
값을 추출할 수 있습니다.
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;
}
토큰 발급을 요청할 때는 https://kauth.kakao.com/oauth/token
에 POST
방식으로 요청합니다.
이때 Content-Type 에는 application/x-www-form-urlencoded;charset=utf-8
을 지정합니다.
body 에는 필수값들인 grant_type, client_id, redirect_uri, code 를 전달합니다.
grant_type 은 authorization_code 로 고정되고, code 는 전달받은 인가 코드를 의미합니다. 나머지는 이전과 동일합니다.
요청의 응답으로는 access_token, expires_in, refresh_token, scope 등이 오게 됩니다.
리다이렉트 페이지에서 useEffect()
를 사용하여 인가 코드를 전달 받은 다음 Access Token 을 받을 수 있도록 합니다.
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};
}
리다이렉트 페이지에서 서버가 사용자 정보를 받을 수 있도록 API 를 요청합니다.
사용자 정보 요청은 https://kapi.kakao.com/v2/user/me
에 GET/POST
방식으로 요청합니다.
Authorization 헤더에는 Bearer ${ACCESS_TOKEN}
형식으로 전달 받은 Access Token 을 추가합니다.
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])
...
}
Step 1 다음 리다이렉트 되는 페이지이며 쿼리 파라미터에 담긴 인가 코드를 추출합니다.
Step 2 이전에 호출되며 인가 코드가 도착하면 Access Token 을 받을 수 있도록 인가 코드를 전달하여 Kakao API 를 호출합니다.
Step 2 다음 호출되며 전달 받은 Access Token 을 Spring 에서 구현한 사용자 정보를 추출하는 API 를 호출할 때 함께 전달합니다.
Step 3 는 Spring 에서 수행되며, getMemberFromKakao
호출 결과로 사용자 정보와 함께 Access Token, Refresh Token 을 생성해서 전달해줍니다.
직접 로그인할 때는 로그인을 호출하는 API 를 loginSlice 에서 createAsyncThunk
로 Thunk 액션을 만들고 extraReducers 를 통해서 상태가 변경되도록 하였습니다.
소셜 로그인의 경우, 애플리케이션의 상태 변경을 위해 reducers 에 정의된 login
, logout
을 통해 상태를 변경해보도록 하겠습니다.
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
액션을 디스패치합니다.
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 가 끝나고 소셜 로그인 한 사용자라면 비밀번호 수정이 필요하므로 멤버를 수정하는 페이지로, 단순 로그인 한 사용자라면 메인으로 이동하도록 설정합니다.
// 수정 가능한 정보들
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 });
}
}
initState 에 수정 가능한 정보들을 지정합니다.
useSelector 를 통해 스토어에 저장된 사용자의 정보를 가져옵니다.
사용자의 정보는 컴포넌트에서 상태로 관리하고, 가져온 정보를 set
메서드를 통해 member 에 상태로 지정합니다. 이때 비밀번호를 임의로 지정해서 저장시켰기 때문에 비밀번호를 지정하도록 비워둡니다.
member 에 저장된 정보들을 보여주고, 값이 변경되면 handleChange
함수가 호출되도록 합니다.