⭐ OAuth 2.0 인증 방식
OAuth 2.0은 안전하게 사용자 정보를 다른 수 있도록 설계된 오픈 표준 인증 프로토콜입니다.
사용하는 주요 목적은 애플리케이션(클라이언트)이 사용자의 비밀번호를 직접 받지 않고, 소셜 플랫폼이 제공하는 권한을 받아 사용자의 데이터에 접근할 수 있도록 하는 것입니다.
더 자세한 정보는 아래 글에서 확인할 수 있습니다.
1️⃣ 소셜 로그인 동작 원리 이해하기
소셜 로그인의 기본 흐름은 OAuth 2.0 인증 방식을 따릅니다.
1. 사용자가 로그인 버튼 클릭
2. 권한 요청
클라이언트는 사용자를 소셜 플랫폼의 인증 서버로 리다이렉트하여 권한 승인을 요청합니다. 이 요청에는 애플리케이션의 Client ID, 리다이렉트될 URL(Redirect URI), 필요한 권한 범위 등이 포함됩니다.
3. Authorization Code 발급
사용자가 권한 승인을 완료하면, 소셜 플랫폼의 인증 서버는 애플리케이션(클라이언트)으로 Authorization Code를 보냅니다. 이때 Authorization Code는 임시로 발급되는 코드로, 클라이언트가 소셜 플랫폼에서 Access Token을 발급받을 수 있도록 도와줍니다.
4. Access Token 교환
클라이언트는 받은 Authorization Code와 함께 소셜 플랫폼의 토큰 서버에 요청을 보내고, Access Token을 발급받습니다.
Access Token은 사용자의 데이터를 안전하게 요청하는 데 사용됩니다.
5. 사용자 정보 조회
클라이언트는 Access Token을 이용해 소셜 플랫폼의 리소스 서버에서 사용자 정보를 가져옵니다.
예를 들어, Google의 경우 https://www.googleapis.com/userinfo에서 정보를 요청합니다.
6. 애플리케이션 로그인 처리
가져온 사용자 정보를 기반으로 애플리케이션 내에서 로그인 또는 회원가입을 처리합니다.
2️⃣ 각 소셜 플랫폼의 개발자 센터에서 ID, Secret 발급받는 방법
이 과정은 스크린 샷이 함께 있으면 편한데, 전 따로 캡쳐해두지 않아서 다른 글을 읽으면 더 쉽게 이해하실 수 있을 거 같습니다.
이 부분은 어렵지 않고, 엄청 많은 글에서 다루고 있기 때문에 쉽게 따라하시 수 있을 것입니다.
그래도 간단히 각 소셜 플랫폼의 특징을 정리해보겠습니다.
- 구글
- Google Cloud Console에서 프로젝트 생성 후, OAuth 2.0 Client ID와 Secret을 발급받습니다.
- Redirect URI를 설정합니다. 사용자가 권한 승인을 완료한 후 리다이렉션될 URL을 입력해야 합니다. 이는 프로젝트가 실행되는 URL에 따라 다를 수 있지만, 뒤에 /auth/google/callback을 붙이는 게 통상적입니다.
- Client ID, Client Secret를 발급받을 수 있습니다.
- 카카오
- Kakao Developers에서 애플리케이션 생성 후, REST API 키와 Redirect URI를 설정합니다.
- Redirect URI를 설정합니다.
- REST API 키를 발급받을 수 있습니다. 이는 구글, 네이버의 Client ID처럼 사용됩니다.
- 추가로 보안을 강화하기 위해 Client Secret을 발급받고 싶다면, [내 애플리케이션] > [카카오 로그인] > [보안]에서 [코드 생성]를 눌러 발급받을 수 있습니다.
- 네이버
- Naver Cloud Platform에서 애플리케이션 등록 후, Client ID와 Client Secret을 발급받습니다.
- Callback URL을 지정합니다.
- Client ID, Client Secret을 발급받을 수 있습니다.
✅ application.yaml에 인증 정보 설정하기
민감한 정보를 외부 설정 파일로 분리하고, 이 파일은 소스 코드와 별도로 관리해야 합니다.
application.yaml 파일은 .gitignore에 추가하여 외부에 노출되지 않도록 해야 합니다.
또한, 애플리케이션의 인증 관련 URL 및 키 값을 별도로 관리하면 코드가 간결해지고 유지보수성이 좋아집니다.
sns:
google:
url: https://accounts.google.com/o/oauth2/v2/auth # 고정값
client:
id: <발급받은 ID> # 구글 개발자 콘솔에서 발급받은 Client ID
secret: <발급받은 Secret> # 구글 개발자 콘솔에서 발급받은 Client Secret
callback:
url: http://localhost:8080/auth/google/callback # 유저 설정에 따라 변경 가능 (Redirect URI)
token:
url: https://oauth2.googleapis.com/token # 고정값
kakao:
url: https://kauth.kakao.com/oauth/authorize # 고정값
client:
id: <발급받은 ID> # 카카오 개발자 센터에서 발급받은 REST API Key
secret: <발급받은 Secret> # 카카오 개발자 센터에서 발급받은 Client Secret (선택 사항)
callback:
url: http://localhost:8080/auth/kakao/callback # 유저 설정에 따라 변경 가능 (Redirect URI)
token:
url: https://kauth.kakao.com/oauth/token # 고정값
naver:
url: https://nid.naver.com/oauth2.0/authorize # 고정값
client:
id: <발급받은 ID> # 네이버 개발자 센터에서 발급받은 Client ID
secret: <발급받은 Secret> # 네이버 개발자 센터에서 발급받은 Client Secret
callback:
url: http://localhost:8080/auth/naver/callback # 유저 설정에 따라 변경 가능 (Redirect URI)
token:
url: https://nid.naver.com/oauth2.0/token # 고정값
1) 고정값
- Authorization URL (url):
- 각 소셜 플랫폼에서 제공하는 인증 URL로, 해당 값은 플랫폼별로 고정되어 있습니다.
- 예:
- 구글: https://accounts.google.com/o/oauth2/v2/auth
- 카카오: https://kauth.kakao.com/oauth/authorize
- 네이버: https://nid.naver.com/oauth2.0/authorize
- Token URL (token.url):
- 인증 코드로 Access Token을 교환하기 위한 URL입니다.
- 예:
- 구글: https://oauth2.googleapis.com/token
- 카카오: https://kauth.kakao.com/oauth/token
- 네이버: https://nid.naver.com/oauth2.0/token
2) 유저별 값 (발급받은 값과 설정 값)
- Client ID (client.id):
- 소셜 로그인 API를 사용할 애플리케이션의 고유 식별자입니다.
- 각 소셜 플랫폼의 개발자 콘솔에서 애플리케이션 등록 후 발급받습니다.
- Client Secret (client.secret):
- API 요청 시 애플리케이션의 신뢰성을 보증하는 비밀 키입니다.
- Redirect URI (callback.url):
- 사용자가 소셜 로그인 승인을 완료한 뒤, 인증 결과를 전달받을 애플리케이션의 경로입니다.
- 개발자 콘솔에서 사전에 등록한 값과 요청 시 사용하는 값이 정확히 일치해야 합니다.
- 예:
- 로컬 개발 환경: http://localhost:8080/auth/{provider}/callback
- 프로덕션 환경: https://example.com/auth/{provider}/callback
✅ 엔티티 파일
@Entity
@Table(name = "users")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // AUTO_INCREMENT 적용
@Column(name = "social_id", nullable = false, unique = true)
private String socialId; // 소셜 로그인에서 받은 사용자 ID (예: Google ID, Kakao ID)
@Column(name = "provider", nullable = false)
private String provider; // 소셜 로그인 제공자 (Google, Kakao, Naver 등)
@Column(name = "access_token", nullable = true)
private String accessToken; // 액세스 토큰 (소셜 로그인 인증 후 받은 토큰)
@Column(name = "name", nullable = true)
private String name; // 사용자 이름 (옵션)
@Column(name = "created_at")
private Timestamp createdAt;
@Column(name = "updated_at")
private Timestamp updatedAt;
@OneToMany(mappedBy = "user")
private List<ChatRoomMember> chatRoomMembers; // User가 참여한 ChatRooms에 대한 관계
@OneToMany(mappedBy = "sender")
private List<Message> messages; // 사용자가 보낸 메시지들에 대한 관계
@PrePersist
public void prePersist() {
// 엔티티가 처음 저장될 때 createdAt을 현재 시간으로 설정
this.createdAt = new Timestamp(System.currentTimeMillis());
}
}
이 User 엔티티는 소셜 로그인에서 받은 사용자 정보를 데이터베이스에 저장하고 관리하기 위해 사용됩니다. 주요 필드와 어노테이션 설명은 다음과 같습니다:
- @Entity: 이 클래스는 데이터베이스 테이블과 매핑된 엔티티임을 나타냅니다.
- @Id와 @GeneratedValue: id 필드는 기본 키로, 자동으로 값이 생성됩니다.
- @Column: 각 필드와 데이터베이스 컬럼을 매핑합니다. 예를 들어, socialId는 소셜 로그인에서 받은 사용자 고유 ID입니다.
- @PrePersist: 엔티티가 DB에 저장되기 전 createdAt 필드를 자동으로 현재 시간으로 설정합니다.
- @OneToMany: 사용자와 채팅방, 메시지 간의 관계를 설정합니다.
이 엔티티는 소셜 로그인 사용자 정보를 저장하고, 관련된 데이터를 관리하는 데 필요합니다.
✅ 소셜 타입 구분 Enum 파일
public enum SocialLoginType { //소셜 로그인 타입을 구분할 enum 클래스
GOOGLE,
KAKAO,
NAVER
}
소셜 로그인 제공자(Google, Kakao, Naver 등)를 구분하기 위해 enum을 사용합니다. 이렇게 하면 소셜 로그인 타입을 코드 내에서 쉽게 관리하고 오류를 방지할 수 있습니다.
✅ 대소문자 변환 파일
@Configuration
public class SocialLoginTypeConverter implements Converter<String, SocialLoginType> { //대문자 값을 소문자로 mapping
@Override
public SocialLoginType convert(String s) {
return SocialLoginType.valueOf(s.toUpperCase());
}
}
사용자가 입력하는 값(소문자)과 SocialLoginType enum을 매핑하기 위해 Converter를 사용합니다. 대소문자 구분 없이 값을 처리할 수 있게 합니다. 예를 들어, "google"을 SocialLoginType.GOOGLE로 변환합니다.
✅ 소셜 공통 클래스
SocialOauth 인터페이스는 소셜 로그인에서 공통적으로 필요한 메서드를 정의하는 역할을 합니다.
각 소셜 로그인 타입별로 해당 인터페이스를 구현하여, 다양한 소셜 로그인 서비스에 대해 일관된 방법으로 접근할 수 있습니다. 이 인터페이스는 세 가지 주요 메서드를 제공합니다.
public interface SocialOauth { //소셜 로그인 타입별로 공통적으로 사용될 interface
/**
* 각 Social Login 페이지로 Redirect 처리할 URL Build
* 사용자로부터 로그인 요청을 받아 Social Login Server 인증용 code 요청
*/
String getOauthRedirectURL();
/**
* API Server로부터 받은 code를 활용하여 사용자 인증 정보 요청
* @param code API Server 에서 받아온 code
* @return API 서버로 부터 응답받은 Json 형태의 결과를 string으로 반환
*/
String requestAccessToken(String code);
default SocialLoginType type() {
if (this instanceof GoogleOauth) {
return SocialLoginType.GOOGLE;
} else if (this instanceof NaverOauth) {
return SocialLoginType.NAVER;
} else if (this instanceof KakaoOauth) {
return SocialLoginType.KAKAO;
} else {
return null;
}
}
}
- getOauthRedirectURL():
- 사용자가 소셜 로그인 페이지로 리디렉션될 URL을 반환합니다. 이 URL은 사용자로부터 인증을 받기 위해 소셜 로그인 서버로 요청을 보내는 데 사용됩니다.
- requestAccessToken(String code):
- 소셜 로그인 서버로부터 받은 인증 코드를 사용하여 액세스 토큰을 요청합니다. 이 메서드는 해당 토큰을 반환하여, 이후 사용자의 정보를 요청할 때 필요합니다.
- type() (default 메서드):
- 해당 SocialOauth 객체가 어떤 소셜 로그인 서비스(Google, Naver, Kakao)에 속하는지 구분하는 메서드입니다. GoogleOauth, NaverOauth, KakaoOauth 클래스가 각각 구현하여 사용됩니다.
✅ 소셜별 OAuth 인증 처리 클래스 구현
1️⃣ 구글 OAuth 인증 처리 클래스
@Component
@RequiredArgsConstructor
public class GoogleOauth implements SocialOauth {
@Value("${sns.google.url}")
private String GOOGLE_SNS_BASE_URL;
@Value("${sns.google.client.id}")
private String GOOGLE_SNS_CLIENT_ID;
@Value("${sns.google.callback.url}")
private String GOOGLE_SNS_CALLBACK_URL;
@Value("${sns.google.client.secret}")
private String GOOGLE_SNS_CLIENT_SECRET;
@Value("${sns.google.token.url}")
private String GOOGLE_SNS_TOKEN_BASE_URL;
@Override
public String getOauthRedirectURL() {
Map<String, Object> params = new HashMap<>();
params.put("scope", "profile");
params.put("response_type", "code");
params.put("client_id", GOOGLE_SNS_CLIENT_ID);
params.put("redirect_uri", GOOGLE_SNS_CALLBACK_URL);
String parameterString = params.entrySet().stream()
.map(x -> x.getKey() + "=" + x.getValue())
.collect(Collectors.joining("&"));
return GOOGLE_SNS_BASE_URL + "?" + parameterString;
}
@Override
public String requestAccessToken(String code) {
RestTemplate restTemplate = new RestTemplate();
Map<String, Object> params = new HashMap<>();
params.put("code", code);
params.put("client_id", GOOGLE_SNS_CLIENT_ID);
params.put("client_secret", GOOGLE_SNS_CLIENT_SECRET);
params.put("redirect_uri", GOOGLE_SNS_CALLBACK_URL);
params.put("grant_type", "authorization_code");
ResponseEntity<String> responseEntity =
restTemplate.postForEntity(GOOGLE_SNS_TOKEN_BASE_URL, params, String.class);
if (responseEntity.getStatusCode() == HttpStatus.OK) {
return responseEntity.getBody();
}
return "구글 로그인 요청 처리 실패";
}
}
2️⃣ 카카오 OAuth 인증 처리 클래스
@Slf4j
@Component
@RequiredArgsConstructor
public class KakaoOauth implements SocialOauth {
@Value("${sns.kakao.url}")
private String KAKAO_SNS_BASE_URL;
@Value("${sns.kakao.client.id}")
private String KAKAO_SNS_CLIENT_ID;
@Value("${sns.kakao.callback.url}")
private String KAKAO_SNS_CALLBACK_URL;
@Value("${sns.kakao.client.secret}")
private String KAKAO_SNS_CLIENT_SECRET;
@Value("${sns.kakao.token.url}")
private String KAKAO_SNS_TOKEN_BASE_URL;
@Override
public String getOauthRedirectURL() {
Map<String, Object> params = new HashMap<>();
params.put("response_type", "code");
params.put("client_id", KAKAO_SNS_CLIENT_ID);
params.put("redirect_uri", KAKAO_SNS_CALLBACK_URL);
String parameterString = params.entrySet().stream()
.map(x -> x.getKey() + "=" + x.getValue())
.collect(Collectors.joining("&"));
return KAKAO_SNS_BASE_URL + "?" + parameterString;
}
@Override
public String requestAccessToken(String code) {
RestTemplate restTemplate = new RestTemplate();
// 1. HTTP 헤더 설정 (Content-Type을 application/x-www-form-urlencoded로 설정)
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 2. 요청 파라미터를 문자열로 인코딩
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("code", code);
params.add("client_id", KAKAO_SNS_CLIENT_ID);
params.add("client_secret", KAKAO_SNS_CLIENT_SECRET);
params.add("redirect_uri", KAKAO_SNS_CALLBACK_URL);
params.add("grant_type", "authorization_code");
// 3. HttpEntity 생성 (헤더와 파라미터 포함)
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(params, headers);
// 4. POST 요청 보내기
ResponseEntity<String> responseEntity =
restTemplate.postForEntity(KAKAO_SNS_TOKEN_BASE_URL, requestEntity, String.class);
// 5. 응답 확인 및 반환
if (responseEntity.getStatusCode() == HttpStatus.OK) {
return responseEntity.getBody();
}
return "카카오 로그인 요청 처리 실패";
}
}
3️⃣ 네이버 OAuth 인증 처리 클래스
@Component
@RequiredArgsConstructor
public class NaverOauth implements SocialOauth {
@Value("${sns.naver.url}")
private String NAVER_SNS_BASE_URL;
@Value("${sns.naver.client.id}")
private String NAVER_SNS_CLIENT_ID;
@Value("${sns.naver.callback.url}")
private String NAVER_SNS_CALLBACK_URL;
@Value("${sns.naver.client.secret}")
private String NAVER_SNS_CLIENT_SECRET;
@Value("${sns.naver.token.url}")
private String NAVER_SNS_TOKEN_BASE_URL;
@Override
public String getOauthRedirectURL() {
Map<String, Object> params = new HashMap<>();
params.put("response_type", "code");
params.put("client_id", NAVER_SNS_CLIENT_ID);
params.put("redirect_uri", NAVER_SNS_CALLBACK_URL);
params.put("state", "random_state_value"); // CSRF 방지를 위한 state 파라미터 추가
String parameterString = params.entrySet().stream()
.map(x -> x.getKey() + "=" + x.getValue())
.collect(Collectors.joining("&"));
return NAVER_SNS_BASE_URL + "?" + parameterString;
}
@Override
public String requestAccessToken(String code) {
RestTemplate restTemplate = new RestTemplate();
// Header 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 파라미터 설정 (application/x-www-form-urlencoded)
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("code", code);
params.add("client_id", NAVER_SNS_CLIENT_ID);
params.add("client_secret", NAVER_SNS_CLIENT_SECRET);
params.add("redirect_uri", NAVER_SNS_CALLBACK_URL);
params.add("grant_type", "authorization_code");
params.add("state", "random_state_value");
// HTTP Entity 생성
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(params, headers);
// 요청 전송
ResponseEntity<String> responseEntity =
restTemplate.postForEntity(NAVER_SNS_TOKEN_BASE_URL, requestEntity, String.class);
if (responseEntity.getStatusCode() == HttpStatus.OK) {
return responseEntity.getBody();
}
return "네이버 로그인 요청 처리 실패";
}
}
- @Value 어노테이션을 사용하여 application.yaml에서 설정해둔 각 소셜 로그인에 필요한 URL, 클라이언트 ID, 클라이언트 비밀번호, 리디렉션 URL 등을 프로퍼티 파일에서 가져옵니다.
- getOauthRedirectURL():
- 사용자가 Google 소셜 로그인 페이지로 리디렉션되도록 하는 URL을 생성합니다. 파라미터로 scope, response_type, client_id, redirect_uri 등을 설정하여 OAuth 인증을 시작하는 URL을 반환합니다.
- 이때 생성되는 URL은 각 소셜 서비스마다 다른 형식으로 요구하기 때문에 각각 따로 클래스를 구현해야합니다.
- 각 소셜 서비스의 인증 URL 구조 차이:
- 구글 OAuth:
- 필수 파라미터: client_id, redirect_uri, scope, response_type
- Google은 인증을 요청할 때 scope (예: "profile")을 명시하고, response_type은 code로 설정합니다. 이 URL을 통해 Google 로그인 페이지로 리디렉션됩니다.
- GOOGLE_SNS_BASE_URL은 Google의 인증 URL이며, 위 코드에서는 이 URL에 파라미터를 추가해 인증 요청 URL을 만듭니다.
- 카카오 OAuth:
- 필수 파라미터: client_id, redirect_uri, response_type
- Kakao의 경우 인증 요청 URL은 Google과 유사하지만, Kakao는 기본적으로 scope 파라미터가 없을 수 있습니다. 대신 Kakao에 필요한 다른 파라미터가 있을 수 있습니다.
- KAKAO_SNS_BASE_URL을 사용하여 Kakao 인증 요청 URL을 만듭니다.
- 네이버 OAuth:
- 필수 파라미터: client_id, redirect_uri, response_type, state
- Naver는 CSRF 공격을 방지하기 위해 state 파라미터를 요구합니다. 다른 파라미터들과 함께 Naver의 인증 URL을 생성합니다.
- NAVER_SNS_BASE_URL을 사용하여 Naver 인증 요청 URL을 만듭니다.
- 구글 OAuth:
- requestAccessToken(String code):
- 사용자가 인증 후 받은 인증 코드를 이용하여 액세스 토큰을 요청합니다. RestTemplate을 사용해 Google의 토큰 발급 API에 POST 요청을 보내고, 성공적인 응답을 반환합니다. 실패 시 오류 메시지를 반환합니다.
- 각 소셜 서비스의 토큰 요청 방식 차이
- 구글 OAuth:
- 필수 파라미터 : code, client_id, client_secret, redirect_uri, grant_type
- RestTemplate의 postForEntity 메서드를 사용하여 파라미터를 Map으로 전송.
- Content-Type 설정이 자동으로 처리되며, 추가적인 헤더나 파라미터 인코딩 없이 요청이 보냄.
- state 파라미터가 없음.
- 카카오 OAuth:
- 필수 파라미터 : code, client_id, client_secret, redirect_uri, grant_type
- Content-Type을 application/x-www-form-urlencoded로 명시적으로 설정. (application/x-www-form-urlencoded는 웹 폼 데이터를 서버로 전송할 때 일반적으로 사용되는 인코딩 형식이기 때문에 오류가 나서 Content-Type을 설정했습니다.)
- HttpEntity를 사용하여 파라미터와 헤더를 함께 전송.
- state 파라미터가 없음.
- 네이버 OAuth:
- 필수 파라미터 : code, client_id, client_secret, redirect_uri, grant_type, state
- Content-Type을 application/x-www-form-urlencoded로 명시적으로 설정.
- HttpEntity를 사용하여 파라미터와 헤더를 함께 전송.
- state 파라미터가 추가되어 있음 (보안 및 CSRF 공격 방지를 위한 랜덤 값).
- 구글 OAuth:
소셜 서비스 | 인증 URL 필수 파라미터 | 토큰 요청 파라미터 |
구글 OAuth | - client_id - redirect_uri - scope - response_type |
- code - client_id - client_secret - redirect_uri - grant_type |
카카오 OAuth | - client_id - redirect_uri - response_type |
- code - client_id - client_secret - redirect_uri - grant_type |
네이버 OAuth | - client_id - redirect_uri - response_type - state |
- code - client_id - client_secret - redirect_uri - grant_type - state |
✅ 레파지토리 구현
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// 소셜 로그인 ID로 사용자 찾기
Optional<User> findBySocialId(String socialId);
}
✅ 서비스 클래스 구현
이 서비스 클래스는 소셜 로그인(OAuth)을 처리하는 핵심적인 역할을 합니다. 사용자가 소셜 로그인을 할 때, 소셜 로그인 타입에 맞는 인증 절차를 관리하고, 사용자 정보를 받아와 저장하는 기능을 수행합니다.
@Service
@RequiredArgsConstructor
public class OauthService {
private final List<SocialOauth> socialOauthList; // 다양한 소셜 로그인 OAuth 처리 객체
private final UserRepository userRepository; // 사용자 정보를 저장하는 레포지토리
private static final Logger logger = LoggerFactory.getLogger(OauthService.class);
// 소셜 로그인 요청 URL을 반환하는 메서드
public String request(SocialLoginType socialLoginType) {
SocialOauth socialOauth = this.findSocialOauthByType(socialLoginType); // 주어진 소셜 로그인 타입에 맞는 OAuth 객체 찾기
return socialOauth.getOauthRedirectURL(); // 해당 OAuth 객체의 리디렉션 URL 반환
}
// 인증 코드로 액세스 토큰을 요청하는 메서드
public String requestAccessToken(SocialLoginType socialLoginType, String code) {
SocialOauth socialOauth = this.findSocialOauthByType(socialLoginType); // 주어진 소셜 로그인 타입에 맞는 OAuth 객체 찾기
return socialOauth.requestAccessToken(code); // 액세스 토큰 요청
}
// JSON에서 액세스 토큰만 추출하는 메서드
private String extractAccessTokenFromJson(String accessTokenJson) {
// JSON을 파싱하여 액세스 토큰만 추출
try {
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(accessTokenJson);
// 여기서 필요한 토큰만 반환
return jsonNode.get("access_token") != null ? jsonNode.get("access_token").asText() : null;
} catch (JsonProcessingException e) {
e.printStackTrace();
return null; // 실패할 경우 null 반환
}
}
// 액세스 토큰을 사용하여 사용자 정보를 가져오고, 사용자 정보가 있으면 저장하는 메서드
public User requestAccessTokenAndSaveUser(SocialLoginType socialLoginType, String code) {
// 1. 액세스 토큰을 포함한 JSON 응답을 요청
String accessTokenJson = this.requestAccessToken(socialLoginType, code);
// 2. JSON에서 액세스 토큰만 추출
String accessToken = extractAccessTokenFromJson(accessTokenJson);
if (accessToken == null) {
// 액세스 토큰이 없으면 예외 처리
throw new RuntimeException("Failed to extract access token.");
}
// 3. 액세스 토큰을 사용해 사용자 정보 요청
String userInfo = getUserInfo(socialLoginType, accessToken);
// 4. 사용자 정보를 파싱하여 User 객체 생성
User user = parseUserInfo(userInfo, socialLoginType, accessToken);
// 5. 기존 사용자 확인 후 처리
Optional<User> existingUser = userRepository.findBySocialId(user.getSocialId());
if (existingUser.isPresent()) {
// 이미 존재하는 사용자라면 로그인 처리 (액세스 토큰 갱신 등)
User existing = existingUser.get();
existing.setAccessToken(user.getAccessToken()); // 토큰 갱신
existing.setUpdatedAt(new Timestamp(System.currentTimeMillis())); // 수정 시간 갱신
return userRepository.save(existing); // 수정된 사용자 정보 저장
} else {
// 새 사용자라면 저장
user.setCreatedAt(new Timestamp(System.currentTimeMillis())); // 생성 시간 설정
return userRepository.save(user); // 새 사용자 정보 저장
}
}
// 실제 소셜 로그인 API에서 사용자 정보를 받아오는 메서드 (Google, Kakao, Naver 등)
private String getUserInfo(SocialLoginType socialLoginType, String accessToken) {
switch (socialLoginType) {
case GOOGLE:
return googleApiCall(accessToken); // Google API 호출 메서드
case KAKAO:
return kakaoApiCall(accessToken); // Kakao API 호출 메서드
case NAVER:
return naverApiCall(accessToken); // Naver API 호출 메서드
default:
throw new IllegalArgumentException("지원되지 않는 소셜 로그인 타입입니다."); // 지원되지 않는 로그인 타입 오류
}
}
// 각 소셜 로그인 제공자별 API 호출 (실제 API 호출 방식은 각 로그인 서비스의 문서를 참조해야 함)
// 구글 API 호출 시 응답 상태 코드와 메시지 출력
public String googleApiCall(String accessToken) {
try {
// accessToken을 URL 인코딩
String encodedAccessToken = URLEncoder.encode(accessToken, "UTF-8");
logger.debug("Encoded access token: {}", encodedAccessToken);
String url = "https://www.googleapis.com/oauth2/v3/userinfo?access_token=" + encodedAccessToken;
logger.debug("Google API URL: {}", url);
URL obj = new URL(url);
HttpURLConnection con = (HttpURLConnection) obj.openConnection();
con.setRequestMethod("GET");
con.setRequestProperty("Content-Type", "application/json");
int responseCode = con.getResponseCode();
logger.info("Google API response code: {}", responseCode);
if (responseCode == 200) {
BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
String inputLine;
StringBuffer response = new StringBuffer();
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
in.close();
logger.info("Successfully received response from Google API.");
return response.toString();
} else {
// 실패 시 에러 메시지와 상태 코드 출력
BufferedReader in = new BufferedReader(new InputStreamReader(con.getErrorStream()));
String inputLine;
StringBuffer errorResponse = new StringBuffer();
while ((inputLine = in.readLine()) != null) {
errorResponse.append(inputLine);
}
in.close();
logger.error("Google API call failed with response code: {}, error: {}", responseCode, errorResponse.toString());
throw new RuntimeException("Google API에서 사용자 정보를 가져오는 데 실패했습니다. 응답 코드: " + responseCode + ", 에러 메시지: " + errorResponse.toString());
}
} catch (IOException e) {
logger.error("Google API 호출 중 오류 발생: {}", e.getMessage(), e);
throw new RuntimeException("Google API 호출 중 오류 발생", e);
}
}
private String kakaoApiCall(String accessToken) {
try {
String url = "https://kapi.kakao.com/v2/user/me";
URL obj = new URL(url);
HttpURLConnection con = (HttpURLConnection) obj.openConnection();
con.setRequestMethod("GET");
// Kakao의 경우 Authorization 헤더에 "Bearer" 토큰을 설정해야 합니다.
con.setRequestProperty("Authorization", "Bearer " + accessToken);
int responseCode = con.getResponseCode();
if (responseCode == 200) {
BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
String inputLine;
StringBuffer response = new StringBuffer();
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
in.close();
return response.toString();
} else {
throw new RuntimeException("Kakao API에서 사용자 정보를 가져오는 데 실패했습니다. 응답 코드: " + responseCode); // 오류 메시지 한국어로 수정
}
} catch (IOException e) {
throw new RuntimeException("Kakao API 호출 중 오류 발생", e); // 오류 메시지 한국어로 수정
}
}
private String naverApiCall(String accessToken) {
try {
String url = "https://openapi.naver.com/v1/nid/me";
URL obj = new URL(url);
HttpURLConnection con = (HttpURLConnection) obj.openConnection();
con.setRequestMethod("GET");
// Naver의 경우 Authorization 헤더에 "Bearer" 토큰을 설정해야 합니다.
con.setRequestProperty("Authorization", "Bearer " + accessToken);
int responseCode = con.getResponseCode();
if (responseCode == 200) {
BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
String inputLine;
StringBuffer response = new StringBuffer();
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
in.close();
return response.toString();
} else {
throw new RuntimeException("Naver API에서 사용자 정보를 가져오는 데 실패했습니다. 응답 코드: " + responseCode); // 오류 메시지 한국어로 수정
}
} catch (IOException e) {
throw new RuntimeException("Naver API 호출 중 오류 발생", e); // 오류 메시지 한국어로 수정
}
}
// 사용자 정보를 파싱하여 User 객체 생성
private User parseUserInfo(String userInfo, SocialLoginType socialLoginType, String accessToken) {
JsonObject jsonObject = JsonParser.parseString(userInfo).getAsJsonObject();
// socialId와 name을 소셜 로그인 타입별로 분리
String socialId = "";
String name = "";
if (socialLoginType == SocialLoginType.GOOGLE) {
socialId = jsonObject.get("sub").getAsString(); // Google은 "sub"를 ID로 사용
name = jsonObject.get("name").getAsString(); // Google에서 제공하는 이름
} else if (socialLoginType == SocialLoginType.KAKAO) {
socialId = jsonObject.get("id").getAsString(); // Kakao는 "id"를 사용자 ID로 사용
name = jsonObject.getAsJsonObject("properties").get("nickname").getAsString(); // Kakao에서 제공하는 nickname
} else if (socialLoginType == SocialLoginType.NAVER) {
JsonObject response = jsonObject.getAsJsonObject("response"); // Naver의 데이터는 response 필드 안에 존재
socialId = response.get("id").getAsString(); // Naver는 "id"를 사용자 ID로 사용
name = response.get("name").getAsString(); // Naver에서 제공하는 이름
}
// User 객체에 정보 세팅
User user = new User();
user.setSocialId(socialId); // 소셜 ID 설정
user.setName(name); // 사용자의 이름 설정
user.setProvider(socialLoginType.name()); // 로그인 제공자 설정
user.setAccessToken(accessToken); // 액세스 토큰 설정
return user;
}
// 주어진 소셜 로그인 타입에 맞는 OAuth 객체를 찾는 메서드
private SocialOauth findSocialOauthByType(SocialLoginType socialLoginType) {
return socialOauthList.stream()
.filter(x -> x.type() == socialLoginType)
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("알 수 없는 SocialLoginType 입니다.")); // 지원되지 않는 소셜 로그인 타입 오류
}
}
📒 핵심 흐름
- 사용자가 소셜 로그인 요청을 하면 request() 메서드에서 소셜 로그인 URL을 반환합니다.
- 사용자가 로그인 후 인증 코드를 받으면, requestAccessToken() 메서드를 통해 액세스 토큰을 요청합니다.
- 액세스 토큰을 이용해 getUserInfo() 메서드로 사용자 정보를 받아오고, parseUserInfo()에서 이를 처리하여 User 객체로 변환합니다.
- 기존 사용자가 있으면 정보가 갱신되고, 새 사용자는 데이터베이스에 저장됩니다.
📗 각 메서드의 역할과 흐름
- request(SocialLoginType socialLoginType)
- 소셜 로그인 요청 URL을 반환합니다.
- 주어진 소셜 로그인 타입에 맞는 OAuth 객체를 찾아 리디렉션 URL을 반환합니다.
- requestAccessToken(SocialLoginType socialLoginType, String code)
- 주어진 인증 코드로 액세스 토큰을 요청하는 메서드입니다.
- 소셜 로그인 타입에 맞는 OAuth 객체를 사용하여 액세스 토큰을 요청합니다.
- extractAccessTokenFromJson(String accessTokenJson)
- JSON 형식의 응답에서 액세스 토큰만 추출하는 메서드입니다.
- ObjectMapper를 사용하여 JSON을 파싱하고, 액세스 토큰을 추출하여 반환합니다.
- requestAccessTokenAndSaveUser(SocialLoginType socialLoginType, String code)
- 액세스 토큰을 요청하고, 이를 이용해 사용자 정보를 가져와 사용자 데이터를 저장하는 메서드입니다.
- 액세스 토큰을 요청하고,
- 액세스 토큰을 사용해 사용자 정보를 가져와 파싱한 후 User 객체를 생성하고,
- 기존 사용자가 있으면 토큰을 갱신하고, 없다면 새 사용자로 저장합니다.
- 액세스 토큰을 요청하고, 이를 이용해 사용자 정보를 가져와 사용자 데이터를 저장하는 메서드입니다.
- getUserInfo(SocialLoginType socialLoginType, String accessToken)
- 소셜 로그인 제공자(구글, 카카오, 네이버)에 따라 사용자 정보를 가져오는 메서드입니다.
- 각 로그인 서비스에 맞는 API 호출 메서드로 분기합니다.
- googleApiCall(String accessToken)
- 구글 API를 호출하여 사용자 정보를 가져오는 메서드입니다. 액세스 토큰을 이용해 구글의 사용자 정보 API에 요청을 보내고, 응답을 반환합니다.
- URL 인코딩: 먼저 전달된 accessToken을 URL 인코딩하여 Google API 호출에 사용할 수 있도록 합니다.
- API 호출: https://www.googleapis.com/oauth2/v3/userinfo?access_token=encodedAccessToken URL을 사용하여 GET 요청을 보냅니다.
- kakaoApiCall(String accessToken)
- 카카오 API를 호출하여 사용자 정보를 가져오는 메서드입니다. 카카오의 사용자 정보 API에 액세스 토큰을 사용해 요청을 보냅니다.
- Authorization 헤더 설정: 카카오는 Authorization 헤더에 Bearer 타입의 accessToken을 설정해야 합니다.
- API 호출: https://kapi.kakao.com/v2/user/me URL에 GET 요청을 보내 사용자 정보를 조회합니다.
- naverApiCall(String accessToken)
- 네이버 API를 호출하여 사용자 정보를 가져오는 메서드입니다. 네이버의 사용자 정보 API에 액세스 토큰을 사용해 요청을 보냅니다.
- Authorization 헤더 설정: 네이버 API 역시 카카오와 마찬가지로 Authorization 헤더에 Bearer 타입의 accessToken을 사용해야 합니다.
- API 호출: https://openapi.naver.com/v1/nid/me URL로 GET 요청을 보냅니다.
- parseUserInfo(String userInfo, SocialLoginType socialLoginType, String accessToken)
- 소셜 로그인 서비스의 응답 데이터를 파싱하여 User 객체를 생성하는 메서드입니다.
- 각 소셜 로그인 제공자의 응답 형식에 맞게 데이터를 추출하여 User 객체를 생성하고 반환합니다.
- findSocialOauthByType(SocialLoginType socialLoginType)
- 주어진 소셜 로그인 타입에 맞는 OAuth 객체를 찾아 반환하는 메서드입니다. 제공된 소셜 로그인 타입에 맞는 SocialOauth 객체를 리스트에서 찾아 반환합니다. 만약 해당 타입이 없으면 예외를 던집니다.
✅ Response DTO 구현
이 클래스는 소셜 로그인 후 서버에서 반환할 사용자 정보를 캡슐화하는 역할을 하며, API 응답으로 사용자 이름, 액세스 토큰, 로그인 제공자를 포함한 정보를 반환합니다.
@Getter
@Setter
public class UserResponse {
private String name;
private String accessToken;
private String provider;
public UserResponse(String name, String accessToken, String provider) {
this.name = name;
this.accessToken = accessToken;
this.provider = provider;
}
}
✅ 컨트롤러 구현
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/auth")
@Slf4j
@Tag(name = "OAuth", description = "소셜 로그인 인증을 시작하고 콜백을 처리하는 API를 제공합니다.")
public class OauthController {
private final OauthService oauthService;
@Operation(
summary = "소셜 로그인 프로세스 시작",
description = "이 API는 사용자를 소셜 로그인 페이지로 리다이렉트하여 인증 절차를 시작합니다.",
operationId = "startSocialLogin",
parameters = {
@Parameter(name = "socialLoginType", description = "소셜 로그인 유형 (예: Kakao, Google 등).", required = true)
})
@GetMapping(value = "/{socialLoginType}")
public ResponseEntity<String> socialLoginType(
@PathVariable(name = "socialLoginType") SocialLoginType socialLoginType) {
log.info(">> 사용자로부터 SNS 로그인 요청을 받음 :: {} Social Login", socialLoginType);
String redirectURL = oauthService.request(socialLoginType);
return ResponseEntity.ok(redirectURL); // 리다이렉션 URL을 응답으로 반환
}
@Operation(
summary = "소셜 로그인 콜백 처리 (백엔드에서 사용 X)",
description = "사용자가 소셜 로그인 후 콜백 URL로 받은 코드를 통해 액세스 토큰을 요청합니다.",
operationId = "handleSocialLoginCallback",
parameters = {
@Parameter(name = "socialLoginType", description = "소셜 로그인 유형 (예: Kakao, Google 등).", required = true),
@Parameter(name = "code", description = "소셜 로그인 API 서버로부터 받은 인증 코드.", required = true)
})
@GetMapping(value = "/{socialLoginType}/callback")
public ResponseEntity<?> callback(
@PathVariable(name = "socialLoginType") SocialLoginType socialLoginType,
@RequestParam(name = "code") String code,
HttpSession session) {
log.info(">> 소셜 로그인 API 서버로부터 받은 code :: {}", code);
// 액세스 토큰을 통해 사용자 정보를 받아온 후 저장
User user = oauthService.requestAccessTokenAndSaveUser(socialLoginType, code);
if (user != null) {
log.info(">> 사용자 정보 DB 저장 완료 :: {}", user.getName());
// 세션에 사용자 정보 저장
session.setAttribute("loginUser", user);
// 로그인한 유저 정보를 response body로 반환
return ResponseEntity.ok(new UserResponse(user.getName(), user.getAccessToken(), user.getProvider()));
} else {
log.error(">> 사용자 정보 저장 실패");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("사용자 정보 저장 실패");
}
}
}
- socialLoginType 메서드:
- 목적: 사용자가 소셜 로그인 유형을 선택하면, 해당 소셜 로그인 페이지로 리다이렉트하는 URL을 반환합니다.
- 설명: socialLoginType을 경로 변수로 받아 해당 소셜 로그인 페이지로 리다이렉트할 URL을 생성하고 반환합니다.
- HTTP 메서드: GET
- 리턴: 소셜 로그인 페이지로 리다이렉트할 URL을 응답 본문에 담아 반환합니다.
- callback 메서드:
- 목적: 사용자가 소셜 로그인 후 콜백 URL로 받은 인증 코드를 처리하고, 액세스 토큰을 요청하여 사용자 정보를 저장합니다.
- 설명:
- code 파라미터로 받은 인증 코드를 통해 액세스 토큰을 요청합니다.
- 액세스 토큰을 통해 사용자 정보를 받아오고, 이를 데이터베이스에 저장한 후 세션에 사용자 정보를 저장합니다.
- 이후, 로그인한 사용자 정보를 응답 본문으로 반환합니다.
- HTTP 메서드: GET
- 리턴: 사용자 정보가 정상적으로 저장되면 로그인한 사용자 정보를 담은 UserResponse 객체를 반환하고, 실패 시 500 에러와 함께 실패 메시지를 반환합니다.
⭐ 구현 결과
세 소셜 로그인 모두 제대로 응답되는 것을 확인할 수 있었습니다!
📌 참고