본문 바로가기
Team project/[파이널] 개발자 매칭 서비스 - Perfectfolio

[4~7일차] 카카오 로그인 연동 구현하기

by 개발자공부 2024. 8. 22.
사전 정보 - OAuth & JWT 개념을 재점검해야 합니다.
https://devnote0203.tistory.com/129 (네이버 로그인 연동때 설명 기술)

 

토스페이먼츠 개발자센터 JWT(JSON 웹 토큰) 설명

https://docs.tosspayments.com/resources/glossary/jwt

 

JWT(JSON 웹 토큰) | 토스페이먼츠 개발자센터

JSON 웹 토큰(JWT)은 온라인 네트워크에서 정보를 안전하게 통신할 때 사용하는 인터넷 표준 토큰입니다.

docs.tosspayments.com

 


 

0. Kakao Developers

https://developers.kakao.com/

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

1. 내 애플리케이션 > 애플리케이션 추가하기

앱 이름, 회사명, 카테고리를 설정해줍니다.

 

2. 내 애플리케이션 > 앱 설정 > 플랫폼 등록하기

자신이 사용하고자 하는 플랫폼을 등록합니다.
Web 프로젝트를 만들 것이기 때문에 Web 플랫폼을 선택했습니다. 사이트 도메인을 입력합니다. http://localhost:8080 으로 등록했습니다.

 

 

3. Redirect URI 등록하기

도메인 등록 후 URI 설정 문구가 나옵니다.
활성화 상태를 ON으로 바꾸면 Redirect URI를 등록할 수 있는 버튼이 아래에 나옵니다.

 

클릭해서 Redirect URI를 등록합니다.

 

4. 내 애플리케이션 > 제품 설정 > 카카오 로그인 > 동의항목 설정하기

  카카오 로그인은 닉네임과 프로필 사진을 기본으로 제공합니다. 이외의 유저 정보는 사업자 등록증을 제출해야 하기 때문에 두 항목만 필수 동의로 설정하였습니다.

동의 단계 설정은 4가지입니다. 동의 목적은 필수로 기입해야 합니다.


 

문서 > 카카오 로그인 > REST API 문서를 참고하여 카카오 로그인 구현하기
5. 인가 코드받기

https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

인가 코드를 받는 URL입니다.
위 URL에 필수 쿼리 파라미터를 추가합니다.

요청문
https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=
${REST_API_KEY}
&redirect_uri=
${REDIRECT_URI}

받을 수 있는 응답입니다. 토큰을 받기 위해 code 값이 필요합니다.

 

6. 토큰 받기

토큰 발급을 위한 URL입니다.
헤더와 바디를 구성하여 전송합니다.

 

받을 수 있는 응답입니다. 유저 정보를 가져오기 위해 토큰 정보가 필요합니다.

 

7. 액세스 토큰 방식으로 유저 정보 받아오기

헤더와 바디를 구성하여 위 URL로 요청을 보냅니다. Bearer 다음에 공백도 값이라서 반드시 추가해야 합니다!!!
카카오는 응답으로 회원번호를 제공합니다. 데이터베이스를 설계할 때 카카오 유저는 id 값에 해당 회원번호를 저장하였습니다. 이외에 제공 받기로 설정한 값을 전송받을 수 있습니다.

 


 

코드
더보기

동의 화면을 출력하기 위한 링크입니다. ClientId등의 값은 yml에 선언했습니다.

<a href="https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${kakaoRestApiKey}&redirect_uri=${kakaoRedirectUri}">
<img alt="카카오 로그인 버튼" class="social--btn" src="/images/kakao_login_medium_narrow.png">
</a>
더보기

yml 파일

kakao:
  client-id: 49a90f391b06f25728ec0d6f9f30ce86 # 발급 받은 REST API 키
  redirect-uri: http://localhost:8080/user/kakao #
  client-authentication-method: client_secret_post
#            scope:
#              - name
#              - email
#              - profile_image
  client-name: Kakao

더보기
@Controller
@RequestMapping("/user")
@RequiredArgsConstructor
public class UserController {

	@Value("${kakao.client-id}")
	private String kakaoClientId;
	@Value("${kakao.redirect-uri}")
	private String kakaoRedirectUri;
	private KakaoUserInfoDTO kakaoUserInfoDTO;

/**
	 * 카카오 로그인 API
	 * 
	 * @param code
	 * @return
	 */
	@GetMapping("/kakao")
	public String getMethodName(@RequestParam(name = "code", required = false) String code, Model model) {
		// Access Token 발급 요청
		RestTemplate rt1 = new RestTemplate();
		HttpHeaders header1 = new HttpHeaders();
		header1.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
		MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
		params.add("grant_type", "authorization_code");
		params.add("client_id", kakaoClientId);
		params.add("redirect_uri", kakaoRedirectUri);
		params.add("code", code);
		HttpEntity<MultiValueMap<String, String>> reqkakaoToken = new HttpEntity<>(params, header1);
		ResponseEntity<KakaoToken> response1 = rt1.exchange(Define.KAKAO_REQUEST_ACCESSTOKEN_URL, HttpMethod.POST,
				reqkakaoToken, KakaoToken.class);
		accessToken = response1.getBody().getAccess_token();

		// Access Token으로 유저 정보 받아오기
		RestTemplate rt2 = new RestTemplate();
		HttpHeaders headers2 = new HttpHeaders();
		headers2.add("Authorization", "Bearer " + response1.getBody().getAccess_token());
		headers2.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
		HttpEntity<MultiValueMap<String, String>> reqKakaoMessage = new HttpEntity<>(headers2);
		ResponseEntity<KakaoUserInfoDTO> response2 = rt2.exchange(Define.KAKAO_REQUEST_USERINFO_URL, HttpMethod.POST,
				reqKakaoMessage, KakaoUserInfoDTO.class);
		kakaoUserInfoDTO = response2.getBody();

		// 최초 소셜 사용자인지 판별
		PrincipalDTO principalDTO = userService.searchUserId(kakaoUserInfoDTO.getId());
		if (principalDTO == null) {
			return "user/addKakaoUserInfo";
		}

		session.setAttribute("principal", principalDTO);

		return "redirect:/user/main";
	}

	/**
	 * 카카오 로그인시 추가 정보 로직 처리
	 * 
	 * @param
	 * @param kakaoAddUserInfoDTO - 추가 정보: 이름, 이메일, 성별, 생일, 전화번호
	 * @param session
	 * @return
	 */
	@PostMapping("/addKakaoUserInfo")
	public String addKakaoUserInfo(KakaoAddUserInfoDTO kakaoAddUserInfoDTO, HttpSession session) {

		SignUpDTO signUpDTO = SignUpDTO.builder().username(kakaoAddUserInfoDTO.getUsername())
				.userId(kakaoUserInfoDTO.getId()).userPassword(Define.SOCIAL_PASSWORD_KAKAO)
				.userNickname(kakaoUserInfoDTO.getProperties().getNickname())
				.userEmail(kakaoAddUserInfoDTO.getUserEmail()).userBirth(kakaoAddUserInfoDTO.getUserBirth())
				.userGender(kakaoAddUserInfoDTO.getUserGender()).userTel(kakaoAddUserInfoDTO.getUserTel())
				.userSocialType(Define.SOCIAL_TYPE_IS_KAKAO)
//				.userOriginProfileImage(naverUserInfoDTO.getResponse().getProfile_image())
				.build();

		// 유효성 검사
		if (!ValidationUtil.isValidateName(kakaoAddUserInfoDTO.getUsername())) {
			throw new DataDeliveryException(Define.SIGNUP_NAME, HttpStatus.BAD_REQUEST);
		}
		if (!ValidationUtil.isValidateEmail(kakaoAddUserInfoDTO.getUserEmail())) {
			throw new DataDeliveryException(Define.SIGNUP_EMAIL, HttpStatus.BAD_REQUEST);
		}
		if (kakaoAddUserInfoDTO.getUserBirth() == null || kakaoAddUserInfoDTO.getUserBirth().trim().isEmpty()) {
			throw new DataDeliveryException(Define.SIGNUP_BIRTH, HttpStatus.BAD_REQUEST);
		}
		if (!ValidationUtil.isValidateTel(kakaoAddUserInfoDTO.getUserTel())) {
			throw new DataDeliveryException(Define.SIGNUP_TEL, HttpStatus.BAD_REQUEST);
		}

		userService.createUser(signUpDTO);
		PrincipalDTO principalDTO = userService.searchUserId(kakaoUserInfoDTO.getId());

		session.setAttribute("principal", principalDTO);

		return "redirect:/user/main";
	}

}
더보기
public class Define {

	// 이미지 관련
    public static final String UPLOAD_FILE_DERECTORY = "C:\\Users\\user\\git\\perfect_folio\\src\\main\\resources\\static\\images/";
    public static final int MAX_FILE_SIZE = 1024 * 1024 * 20; // 20MB

	// REST api 요청 URL
    public static final String NAVER_REQUEST_ACCESSTOKEN_URL = "https://nid.naver.com/oauth2.0/token";
    public static final String NAVER_REQUEST_USERINFO_URL = "https://openapi.naver.com/v1/nid/me";
    public static final String KAKAO_REQUEST_ACCESSTOKEN_URL = "https://kauth.kakao.com/oauth/token";
    public static final String KAKAO_REQUEST_USERINFO_URL = "https://kapi.kakao.com/v2/user/me";

    public static final String GOOGLE_REQUEST_ACCESSTOKEN_URL = "https://oauth2.googleapis.com/token";
    public static final String GOOGLE_REQUEST_USERINFO_URL = "https://www.googleapis.com/userinfo/v2/me?access_token=";

    // 소셜 로그인 타입
    public static final String SOCIAL_TYPE_IS_LOCAL = "local";
    public static final String SOCIAL_TYPE_IS_NAVER = "naver";
    public static final String SOCIAL_TYPE_IS_GOOGLE = "google";
    public static final String SOCIAL_TYPE_IS_KAKAO = "kakao";
    public static final String SOCIAL_TYPE_IS_ENTERPRISE = "com";

    // 소셜 로그인 비밀번호 할당
    public static final String SOCIAL_PASSWORD_GOOGLE = "OAuth_Google";
    public static final String SOCIAL_PASSWORD_NAVER = "OAuth_Naver";
    public static final String SOCIAL_PASSWORD_KAKAO = "OAuth_Kakao";


}

 

더보기

토큰 정보를 받는 DTO입니다.

@Data
@ToString
public class KakaoToken {
	public String token_type;
	public String access_token;
	public Integer expires_in;
	public String refresh_token;
	public Integer refresh_token_expires_in;
}
더보기

카카오 유저 정보를 받는 DTO입니다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
public class KakaoUserInfoDTO {
	public String id;
	public String connectedApp;
	public Properties properties;
	
	@Data
	public class Properties {
		private String nickname;
		private String profileImage;
		private String thumbnailImage;
	}
}
더보기

카카오에서 제공받지 못하는 정보를 추가로 받기 위한 DTO입니다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
public class KakaoAddUserInfoDTO {

	private String username;
	private String userEmail;
	private String userBirth;
	private String userGender;
	private String userTel;
}

 

더보기

유저 서비스 클래스

- 클라이언트는 서비스 클래스에 진입할 수 없습니다. 컨트롤러에서 작업 처리가 끝나야 합니다.

- User 모델을 컨트롤러에 반환하면 데이터를 그대로 노출하는 것이기 때문에 세션에 내려줄 최소한의 값만 DTO에 담았습니다.

 

만났던 사소한 오류 : 카카오, 구글은 사업자 등록증이 없는 경우 프로젝트에서 필요한 유저의 필수 정보를 모두 제공해주지 않았습니다. 추가로 받는 정보들 중 이메일이 있었습니다. 예를 들어 카카오, 구글 모두 연동한 유저의 경우, 카카오로 연동 했을 때 기입한 이메일이 구글이라면 다음 로그인시 구글로 자동 로그인되었습니다. 그래서 이메일 중복 체크를 진행하여 이미 가입 내역이 존재하는 이메일일 경우 가입을 제한하였습니다.

@Service
@RequiredArgsConstructor
public class UserService {

    @Autowired
	private final UserRepository userRepository;

/**
	 * 
	 * userEmail로 사용자 존재 여부 조회
	 * 
	 * @param = String userEmail
	 * @return User
	 */
	public PrincipalDTO searchUserEmail(String userEmail) {
		User user = userRepository.findByUserEmail(userEmail);
		System.out.println("유저 서비스::: 사용자???:" + user);

		if (user != null) {
			PrincipalDTO principalDTO = PrincipalDTO.builder().id(user.getId()).userId(user.getUserId())
					.username(user.getUsername()).userSocialType(user.getSocialType()).build();
			return principalDTO;
		}

		return null;
	}
}

 

더보기
@Mapper
public interface UserRepository {

	public int insert(User user); 
	public User findByUserEmail(String userEmail);
	public User findByUserId(String userId);
	
	public User findByid(int id);
	public List<User> findAll();
	public int insertWithdraw(User user); 
	public int insertWithdrawReason(@Param("userId") String userId, @Param("reason") String reason, @Param("reasonDetail") String reasonDetail);
	public void delete(int id);
	public void deleteOldWithdraw();

	public int checkDuplicateID(String userId);
	public int checkDuplicateEmail(String email);
	public void changePassword(String newPassword, String userId);

	public void updateUserInfo(User user);
	public List<Map<String, Object>> getUserSkillList(int id);

	public List<CountResultDTO> getCountResults();
}
더보기
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper
	namespace="com.tenco.perfectfolio.repository.interfaces.UserRepository">

	<insert id="insert">
		INSERT INTO user_tb(user_name, user_id,
		user_password, user_nickname,
		user_email,user_birth,user_gender,user_tel,social_type)
		VALUES (
		#{username}, #{userId}, #{userPassword}, #{userNickname},
		#{userEmail},#{userBirth},#{userGender},#{userTel},#{socialType})
	</insert>

	<insert id="insertWithdraw">
		INSERT INTO user_withdraw_tb(user_name, user_id,
		user_password, user_nickname,
		user_email,user_birth,user_gender,user_tel,social_type,created_at)
		VALUES ( #{username}, #{userId}, #{userPassword}, #{userNickname},
		#{userEmail},#{userBirth},#{userGender},#{userTel},#{socialType},#{createdAt})
	</insert>

	<insert id="insertWithdrawReason">
		INSERT INTO withdraw_reason_tb(user_id, reason,
		reason_detail)
		VALUES (#{userId}, #{reason}, #{reasonDetail})
	</insert>


	<select id="checkDuplicateID">
		SELECT count(user_id) FROM user_tb WHERE user_id =
		#{userId}
	</select>

	<select id="checkDuplicateEmail">
		SELECT count(user_email) FROM user_tb WHERE
		user_email = #{userEmail}
	</select>

	<update id="changePassword">
		UPDATE user_tb SET user_password = #{newPassword}
		WHERE user_id = #{userId}
	</update>

	<select id="findByid">
		SELECT * FROM user_tb WHERE id = #{id}
	</select>

	<select id="findByUserEmail">
		SELECT * FROM user_tb WHERE user_email = #{userEmail}
	</select>

	<select id="findByUserId">
		SELECT
			u.*,
			s.subscribing,
			s.order_name
		FROM user_tb as u
				 LEFT JOIN subscribing_tb as s
						   ON u.id = s.user_id
		WHERE u.user_id = #{userId};
	</select>

	<delete id="delete">
		DELETE FROM user_tb WHERE ID = #{id}
	</delete>

	<select id="findAllNotices"
		resultType="com.tenco.perfectfolio.repository.model.Notice">
		SELECT * FROM notice_tb
	</select>

	<delete id="deleteOldWithdraw">
		DELETE FROM user_withdraw_tb
		WHERE withdraw_at &lt;= NOW() - INTERVAL 3 YEAR;
	</delete>

	<update id="updateUserInfo">
		UPDATE user_tb
		SET
		    user_name = #{username},
			user_password = #{userPassword},
			user_nickname = #{userNickname},
			user_email = #{userEmail},
			user_tel = #{userTel}
		WHERE id = #{id}
	</update>

	<select id="getCountResults" resultType="com.tenco.perfectfolio.dto.CountResultDTO">
		SELECT
			1 AS id, COUNT(*) AS count
		FROM
			recommended_companies
		UNION SELECT
				  2 AS id, COUNT(*) AS count
			  FROM
				  recommended_companies
			  WHERE
				  DATE(recommended_date) = CURDATE()
			  UNION SELECT
						3 AS id, COUNT(*) AS count
					FROM
						crawl_notice_json
					UNION SELECT
							  4 AS id, COUNT(*) AS count
						  FROM
							  crawl_notice_json
						  WHERE
							  DATE(created_at) = (CURDATE() - 1)
	</select>

	<select id="findAll">
		select * from user_tb
	</select>
</mapper>

 

더보기
CREATE TABLE user_tb(
id int auto_increment primary key,
user_name varchar(255) not null,
user_id varchar(255) not null unique,
user_password varchar(255) NOT NULL,
user_nickname    varchar(20),
user_email    varchar(255)    NOT NULL  unique   ,
user_birth    varchar(100)    NOT NULL,
user_gender enum('남성','여성'),
user_tel    varchar(13),
created_at timestamp default now() not null,
social_type varchar(30) not null
);