본문 바로가기

내일배움캠프

Spring 심화주차 Part1

1. OAuth(소셜 로그인)

OAuth("Open Authorization")는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다.
(출처: https://ko.wikipedia.org/wiki/OAuth)

요즘 웹 서비스에서 지원하는 카카오, 구글, 네이버 로그인 같은 것들을 쉽게 얘기해서 OAuth라고 한다.

 

2. 카카오로그인

카카오 로그인의 라이프사이클

 

카카오로그인 api를 사용하기 위해서는 카카오디벨로퍼스(https://developers.kakao.com/)에서 회원가입을 하고 api 사용신청을 해야한다.

회원가입
애플리케이션 추가
원래는 동그라미친 곳에 Web플랫폼 등록이라는 버튼이 나온다.

Web 플랫폼 등록을 하고 아래에 Redirect URI를 등록하라는 글이 보이는데 들어가서 Redirect URI도 등록해야 한다.

카카오 로그인 탭에서 활성화 설정을 해줘야 로그인 기능을 쓸 수 있다.
로그인 할 때 어떤 정보를 받을지 설정할 수 있다.

만약 처음 애플리케이션을 생성했다면 사진과는 달리 이메일이 부분이 비활성화 되어 있을 것이다.

비즈니스 탭에 가보면 '개인 개발자 비즈 앱'이라고 적힌 곳에 비즈 카카오비즈니스 통합 서비스 약관 동의를 하고 개인 개발자 비즈앱으로 전환(?)을 하면 이메일이 활성화 된다.

 

3. 카카오 로그인 예시

로그인 뷰 파일 일부:

<body>
<div id="login-form">
    <div id="login-title">Log into Select Shop</div>
    <button id="login-kakao-btn" onclick="location.href='https://kauth.kakao.com/oauth/authorize?client_id={REST API키}&redirect_uri=http://localhost:8080/api/user/kakao/callback&response_type=code'">
        카카오로 로그인하기
    </button>
    <button id="login-id-btn" onclick="location.href='/api/user/signup'">
        회원 가입하기
    </button>
    <div>
        <div class="login-id-label">아이디</div>
        <input type="text" name="username" id="username" class="login-input-box">

        <div class="login-id-label">비밀번호</div>
        <input type="password" name="password" id="password" class="login-input-box">

        <button id="login-id-submit" onclick="onLogin()">로그인</button>
    </div>
    <div id="login-failed" style="display:none" class="alert alert-danger" role="alert">로그인에 실패하였습니다.</div>
</div>

REST API키라고 적힌 부분에는 앱 키 탭에서 REST API키 값을 복사해서 붙여넣으면 된다.

 

카카오 로그인 컨트롤러:

@GetMapping("/user/kakao/callback")
public String kakaoLogin(@RequestParam String code, HttpServletResponse response) throws JsonProcessingException {
    // code: 카카오 서버로부터 받은 인가 코드 Service 전달 후 인증 처리 및 JWT 반환
    String token = kakaoService.kakaoLogin(code);

    // Cookie 생성 및 직접 브라우저에 Set
    Cookie cookie = new Cookie(JwtUtil.AUTHORIZATION_HEADER, token.substring(7));
    cookie.setPath("/");
    response.addCookie(cookie);

    return "redirect:/";
}

카카오 서버에서 받은 인가 코드를 가지고 서비스로 전달한 다음 인증이 완료되면 인증 처리된 사용자 정보를 jwt 토큰으로 반환하는 구조이다. 반환받은 토큰은 쿠키에 저장해서 응답한다.

 

로그인 서비스:

@Slf4j(topic = "KAKAO Login")
@Service
@RequiredArgsConstructor
public class KakaoService {

    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;
    private final RestTemplate restTemplate;
    private final JwtUtil jwtUtil;

    public String kakaoLogin(String code) throws JsonProcessingException {
        // 1. "인가 코드"로 "액세스 토큰" 요청
        String accessToken = getToken(code);

        // 2. 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
        KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);

        return null;
    }



}

이대로 실행하면 restTemplate의 생성자가 실행되지 않기 때문에 에러가 난다. restTemplate는 빈으로 등록되어 있지 않아서 수동으로 생성자(builder)를 호출해야 하는데 사실 그 방법 말고도 수동으로 빈을 등록하는 방법도 있다.

@Configuration
public class RestTemplateConfig  {
    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder
                // RestTemplate 으로 외부 API 호출 시 일정 시간이 지나도 응답이 없을 때
                // 무한 대기 상태 방지를 위해 강제 종료 설정
                .setConnectTimeout(Duration.ofSeconds(5)) // 5초
                .setReadTimeout(Duration.ofSeconds(5)) // 5초
                .build();
    }
}

 

카카오 사용자 정보를 전달하기 위한 KakaoUserInfoDto:

@Getter
@NoArgsConstructor
public class KakaoUserInfoDto {
    private Long id;
    private String nickname;
    private String email;

    public KakaoUserInfoDto(Long id, String nickname, String email) {
        this.id = id;
        this.nickname = nickname;
        this.email = email;
    }
}

 

액세스 토큰 요청 메소드:

private String getToken(String code) throws JsonProcessingException {
    log.info("인가코드 : " + code);
    // 요청 URL 만들기
    URI uri = UriComponentsBuilder
            .fromUriString("https://kauth.kakao.com")
            .path("/oauth/token")
            .encode()
            .build()
            .toUri();

    // HTTP Header 생성
    HttpHeaders headers = new HttpHeaders();
    headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

    // HTTP Body 생성
    MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
    body.add("grant_type", "authorization_code");
    body.add("client_id", "REST API키");
    body.add("redirect_uri", "http://localhost:8080/api/user/kakao/callback");
    body.add("code", code);

    RequestEntity<MultiValueMap<String, String>> requestEntity = RequestEntity
            .post(uri)
            .headers(headers)
            .body(body);

    // HTTP 요청 보내기
    ResponseEntity<String> response = restTemplate.exchange(
            requestEntity,
            String.class
    );

    // HTTP 응답 (JSON) -> 액세스 토큰 파싱
    JsonNode jsonNode = new ObjectMapper().readTree(response.getBody());
    return jsonNode.get("access_token").asText();

REST API키에는 이전과 마찬가지로 본인이 발급받은 REST API키를 넣어야 한다.

http request에는 다음과 같은 정보들이 들어가야 한다.

 

사용자 정보 요청 메소드:

private KakaoUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
        log.info("accessToken : " + accessToken);
        // 요청 URL 만들기
        URI uri = UriComponentsBuilder
                .fromUriString("https://kapi.kakao.com")
                .path("/v2/user/me")
                .encode()
                .build()
                .toUri();

        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        RequestEntity<MultiValueMap<String, String>> requestEntity = RequestEntity
                .post(uri)
                .headers(headers)
                .body(new LinkedMultiValueMap<>());

        // HTTP 요청 보내기
        ResponseEntity<String> response = restTemplate.exchange(
                requestEntity,
                String.class
        );

        JsonNode jsonNode = new ObjectMapper().readTree(response.getBody());
        Long id = jsonNode.get("id").asLong();
        String nickname = jsonNode.get("properties")
                .get("nickname").asText();
        String email = jsonNode.get("kakao_account")
                .get("email").asText();

        log.info("카카오 사용자 정보: " + id + ", " + nickname + ", " + email);
        return new KakaoUserInfoDto(id, nickname, email);
    }

사용자 정보를 요청하기 위해 필요한 파라미터 들이다.

여기서는 필수 값인 액세스 토큰과 content-type 정보만 넘겨줬다.

응답하는 데이터는 분량이 굉장히 방대하니 공식문서(https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info)에서 확인하자.

여기서는 이메일과 닉네임만 가져올 것이다.

 

인가코드를 통해 액세스 토큰 가져오고, 액세스 토큰으로 이름과 이메일이 정상적으로 반환된 모습이다.

 

4. 카카오 사용자 정보로 회원가입 예시

카카오 사용자 정보로 회원가입을 구현하려면 사용자 정보를 db에 저장할 필요가 있다. db에 저장하는 방법은 기존의 users 테이블에 카카오 회원 id만 저장하는 방법과 카카오 회원만 저장하는 테이블을 만들고 기존 users 테이블과 관계를 맺는 방법 두 가지가 있다.

users 테이블에 저장하면 구현이 단순하다는 장점이 있다. 대신 두 정보간 결합도가 높아져서 유지 보수나 기능 확장이 어려워진다.

테이블을 별도로 만들면 결합도가 낮아지기에 유지 보수나 기능 확장이 수월하지만 구현이 어렵다는 단점이 있다.

 

여기서는 회원가입을 어떤 느낌으로 하는지 맛보기만 할 것이기 때문에 기존 테이블에 카카오 사용자 정보를 저장하겠다.

@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private UserRoleEnum role;

    private Long kakaoId;

    public User(String username, String password, String email, UserRoleEnum role) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
    }

    public User(String username, String password, String email, UserRoleEnum role, Long kakaoId) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
        this.kakaoId =kakaoId;
    }

    public User kakaoIdUpdate(Long kakaoId) {
        this.kakaoId = kakaoId;
        return this;
    }
}

사용자 엔티티에 카카오 회원 id와 회원 id를 수정하는 메소드를 추가로 구현했다.

 

public String kakaoLogin(String code, HttpServletResponse response) throws JsonProcessingException {
    // 1. "인가 코드"로 "액세스 토큰" 요청
    String accessToken = getToken(code);

    // 2. 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
    KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);

    // 3. 필요시에 회원가입
    User kakaoUser = registerKakaoUserIfNeeded(kakaoUserInfo);

    // 4. JWT 토큰 반환
    String createToken =  jwtUtil.createToken(kakaoUser.getUsername(), kakaoUser.getRole());

    return createToken;
}

로그인 할 때 회원 정보가 없으면 자동으로 회원 가입이 되게끔 회원가입 메소드를 추가했다.

 

회원 가입 메소드:

private User registerKakaoUserIfNeeded(KakaoUserInfoDto kakaoUserInfo) {
    // DB 에 중복된 Kakao Id 가 있는지 확인
    Long kakaoId = kakaoUserInfo.getId();
    User kakaoUser = userRepository.findByKakaoId(kakaoId).orElse(null);

    if (kakaoUser == null) {
        // 카카오 사용자 email 동일한 email 가진 회원이 있는지 확인
        String kakaoEmail = kakaoUserInfo.getEmail();
        User sameEmailUser = userRepository.findByEmail(kakaoEmail).orElse(null);
        if (sameEmailUser != null) {
            kakaoUser = sameEmailUser;
            // 기존 회원정보에 카카오 Id 추가
            kakaoUser = kakaoUser.kakaoIdUpdate(kakaoId);
        } else {
            // 신규 회원가입
            // password: random UUID
            String password = UUID.randomUUID().toString();
            String encodedPassword = passwordEncoder.encode(password);

            // email: kakao email
            String email = kakaoUserInfo.getEmail();

            kakaoUser = new User(kakaoUserInfo.getNickname(), encodedPassword, email, UserRoleEnum.USER, kakaoId);
        }

        userRepository.save(kakaoUser);
    }
    return kakaoUser;
}

먼저 중복된 카카오 id가 있는지 확인을 한다. 중복된 id가 없다면 카카오 이메일과 동일한 이메일을 가진 회원을 확인하고 있다면 기존의 회원 정보에 카카오 id를 입력한다. 없으면 새로운 회원 엔티티를 생성한다.

비밀번호는 기존 로그인 뷰에서 로그인하지 않기 때문에 uuid로 랜덤한 난수를 생성 후 저장한다. 그리고 마지막으로 카카오 id, 이름(닉네임), 이메일, 미리 생성한 비밀번호를 엔티티에 저장하고 db에 새로운 로우를 생성한다.

 

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
    Optional<User> findByEmail(String email);
    Optional<User> findByKakaoId(Long kakaoId);
}

 

'내일배움캠프' 카테고리의 다른 글

Spring 심화주차 Part2  (0) 2024.06.18
gradle의 clean기능  (0) 2024.06.13
뉴스피드 프로젝트 5일차  (0) 2024.06.11
뉴스피드 프로젝트 4일차  (0) 2024.06.11
뉴스피드 프로젝트 2일차  (0) 2024.06.07