data class ChatBotReq(
val content: String,
@JsonProperty("assistant_id")
val assistantId: String
)
data class ChatGPTReq(
@JsonProperty("assistant_id")
val assistantId: String,
val thread: Thread,
val stream: Boolean
)
data class Message(
val role: String,
val content: String
)
data class Thread(
val messages: List<Message>
)
여기서 ChatBotReq는 외부서버에서 해당 서버로 요청 할 때 사용하는 DTO이고, 그 아래의 3개는 openAi로 요청 할 때 사용하는 DTO이다.
Response DTO
data class ChatGPTRes(
val id: String,
@JsonProperty("object")
val objectType: String,
@JsonProperty("created_at")
val createdAt: Long,
@JsonProperty("assistant_id")
val assistantId: String,
@JsonProperty("thread_id")
val threadId: String,
@JsonProperty("run_id")
val runId: String,
val status: String,
@JsonProperty("incomplete_details")
val incompleteDetails: String?,
@JsonProperty("incomplete_at")
val incompleteAt: Long?,
@JsonProperty("completed_at")
val completedAt: Long,
val role: String,
val content: List<Content>,
val attachments: List<Any>,
val metadata: Map<String, Any>?
)
data class Content(
val type: String,
val text: Text
)
data class Text(
val value: String,
val annotations: List<Annotation>?
)
data class Annotation(
val type: String,
val text: String,
@JsonProperty("start_index")
val startIndex: Int,
@JsonProperty("end_index")
val endIndex: Int,
@JsonProperty("file_citation")
val fileCitation: FileCitation?
)
data class FileCitation(
@JsonProperty("file_id")
val fileId: String?
)
OpenAI로부터 오는 응답 DTO이다.
근데 사실 여기서는 문자열로 받아서, 그 문자열을 ObjectMapper를 사용하여 해당 클래스로 매핑할 예정이다.
그 다음은 Router이다.
@Configuration
class ChatBotRouter {
@Bean
fun chatBotRouterMapping(
chatBotHandler: ChatBotHandler
) = coRouter {
POST("/", chatBotHandler::post)
}
}
이번에 진행하는 프로젝트에서는 학생들의 과제 제출시간을 한국시간으로 저장하고 체크하기 위해, 아래와 같은 테스트코드를 사용했었다.
@Test
fun testValidTime(){
val koreaTime = LocalDateTime.now(ZoneId.of("Asia/Seoul"))
val curTime = LocalDateTime.now()
assertTrue(koreaTime.minusMinutes(5).isBefore(curTime))
assertTrue(koreaTime.plusMinutes(5).isAfter(curTime))
}
이렇게 Asia/Seoul의 시간과 현재 스프링부트의 시간을 가져온 후 앞뒤로 5분 차이가 나지 않는지 확인하는 것이다.
(만약 시간이 다르다면 무조건 1시간 이상 차이가 날 테니까)
당연히 로컬에서는 에러가 없었지만, DEV 서버에 올려 테스트를 해보니 통과하지 못하는 문제가 발생했다.
당연히 시간이 안 맞는 것일거고
로그를 찍어서 확인해보니
이렇게 스프링부트의 시간이 UTC로 설정이 되어 있었다.
처음에는 당연히 스프링부트가 우분투의 시간을 따른다고 생각하고 우분투의 시간을 한국시간으로 맞춰주었다.
개발을 하다보면 기록을 남겨야 하기 때문에, 로그 말고도 Soft delete를 사용할 때가 있다.
Soft delete는 데이터베이스에서 데이터를 delete 할 때 실제로 delete 쿼리를 날려 삭제하는 것이 아니라,
deleted와 같은 column의 값을 false에서 true로 바꿔주는 update 쿼리를 날려 실제 데이터는 남겨놓으면서 삭제되었다고 알려주는 방식을 말한다.
실제 비즈니스 개발을 할 때는, 사용자가 삭제된 데이터를 다시 요청하는 경우도 있기 때문에 대부분의 경우 Soft delete를 사용한다고 배웠다.
JPA에서도 이런 Soft delete 방식을 편리하게 지원한다.
물론 update를 하는 방식을 계속 사용할 수는 있지만, 그래도 편리하게 다음과 같은 기능을 제공하니 알아보도록 하자.
@SQLDelete
우선 삭제를 위한 @이다.
@SQLDelete를 사용하면 JPA에서 Delete를 사용할 때 실제로 삭제하는 것이 아니라, 해당 update 쿼리를 날려서 update하게 된다.
@SQLDelete(sql = "UPDATE assignment SET deleted = true where id = ?")
이런식으로 사용하면 된다.
이러면 deleted의 값을 true로 변경해주게 된다.
@SQLRestriction
조회를 위한 @이다.
@SQLRestriction을 사용하면 JPA에서 findBy를 사용할 때, 해당 column까지 확인을 해서 데이터를 가져오게 된다.
만약 해당 데이터가 이곳의 명시된 값과 다르면 조회하지 않는 것이다.
@SQLRestriction("deleted = false")
이런식으로 deleted는 false라는 값의 제약을 주었다.
보통은 이 2개를 같이 사용하며, 그 예시는 아래와 같다.
@Entity
@SQLRestriction("deleted = false")
@SQLDelete(sql = "UPDATE assignment SET deleted = true where id = ?")
data class Assignment(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Int?,
@Column(length = 200)
var title: String?,
@Column(length = 500)
var description: String?,
var score: Int,
var startDate: LocalDate,
var endDate: LocalDate,
var deleted: Boolean = false,
)
interface OauthService {
fun getOauth(code: String, isRemote: Boolean): Oauth
fun getAccessToken(code: String, isRemote: Boolean): String
}
Oauth dataclass
@Entity
@Table(
indexes = [
Index(name = "idx_type_id", columnList = "oauthType, oauthId")
]
)
data class Oauth(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Int?,
@ManyToOne
var user: User?,
@Column(length = 100)
var oauthId: String,
@Enumerated(EnumType.ORDINAL)
var oauthType: OauthTypeEnum
)
그대로 사용한다.
우선 마찬가지로 카카오 애플리케이션 등록을 해야한다.
그 후에는 또 마찬가지로 redirect_uri를 지정해야 한다.
카카오 로그인을 ON으로 해주고
아래에 있는
본인이 사용할 redirect_uri를 지정해준다.
또한 앱 키 페이지에 있는 Rest API키를 가져와서 스프링부트의 환경변수에 넣어둔다.
AccessToken 받아오기
바로 AccessToken을 받아오도록 해보자.
마찬가지로 사용자가 넘겨주는 code를 post로 요청해 토큰을 가져오는 것이다.
private final val kakaoAccessTokenServer = "https://kauth.kakao.com/oauth/token"
override fun getAccessToken(code: String, isRemote: Boolean): String {
val restTemplate = RestTemplate()
val httpHeaders = HttpHeaders()
httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
val httpBody = LinkedMultiValueMap<String, String>()
httpBody.add("redirect_uri", if (isRemote) redirectUri else localRedirectUri)
httpBody.add("client_id", clientId)
httpBody.add("code",code)
return restTemplate.postForObject(kakaoAccessTokenServer, this.getHttpEntity(httpBody), KakaoAccessToken::class.java)?.accessToken ?: ""
}
private data class KakaoAccessToken(
@JsonProperty("access_token")
val accessToken: String
)
fun getHttpEntity(httpBody: LinkedMultiValueMap<String, String>): HttpEntity<LinkedMultiValueMap<String, String>> {
val httpHeaders = HttpHeaders()
httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
httpBody.add("grant_type", "authorization_code")
return HttpEntity(httpBody, httpHeaders)
}
이렇게 해당 주소로 redirect_uri, client_id(Rest API키), code, grant_type을 넣어 post 요청하면 Body로 access_token이 하나 나오게 된다.
사용자 정보 가져오기
이제 사용자의 정보를 가져오자.
마찬가지로 응답받은 AccessToken을 그대로 넣어 get 요청하면 된다.
private final val kakaoUserInfoServer = "https://kapi.kakao.com/v2/user/me"
override fun getOauth(code: String, isRemote: Boolean): Oauth {
val accessToken = getAccessToken(code, isRemote)
val restTemplate = RestTemplate()
val kakaoUserInfo = restTemplate.exchange(
RequestEntity<Any>(this.getHttpHeadersWithAuthorization(accessToken), HttpMethod.POST, URI.create(kakaoUserInfoServer)),
KakaoUserInfo::class.java
).body
val kakaoUserId = kakaoUserInfo?.id ?: ""
return this.getOauthFromOauthIdAndOauthType(
oauthId = kakaoUserId,
oauthTypeEnum = OauthTypeEnum.KAKAO)
}
private data class KakaoUserInfo(
@JsonProperty("id") val id: String,
@JsonProperty("connected_at") val connectedAt: String,
@JsonProperty("properties") val properties: Properties,
@JsonProperty("kakao_account") val kakaoAccount: KakaoAccount
)
private data class Properties(
@JsonProperty("nickname") val nickname: String
)
private data class KakaoAccount(
@JsonProperty("profile_nickname_needs_agreement") val profileNicknameNeedsAgreement: Boolean,
@JsonProperty("profile") val profile: Profile
)
private data class Profile(
@JsonProperty("nickname") val nickname: String,
@JsonProperty("is_default_nickname") val isDefaultNickname: Boolean,
@JsonProperty("profile_image_url") val profileImageUrl:String?
)
fun getHttpHeadersWithAuthorization(accessToken: String): HttpHeaders{
val httpHeaders = HttpHeaders()
httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
httpHeaders.add("Authorization", "Bearer $accessToken")
return httpHeaders
}
저번처럼 요청을 하며, 응답받은 json 형식도 위와 같다.
이 중에서는 카카오의 동의항목에 따라 오지 않는 데이터가 있을 수도 있다.
그리고 여기서도 id를 통해 로그인한 사용자를 고유번호를 통하여 식별할 수 있다.
아래는 사용한 전체의 코드이다.
@Service
class KakaoOauthServiceImpl(
@Value("\${kakao.auth.client_id}")
private val clientId: String,
@Value("\${kakao.auth.redirect_uri}")
private val redirectUri: String,
@Value("\${kakao.auth.local_redirect_uri}")
private val localRedirectUri: String,
private val oauthRepository: OauthRepository
) : OauthService, OauthServiceImpl(oauthRepository) {
private final val kakaoAccessTokenServer = "https://kauth.kakao.com/oauth/token"
private final val kakaoUserInfoServer = "https://kapi.kakao.com/v2/user/me"
override fun getOauth(code: String, isRemote: Boolean): Oauth {
val accessToken = getAccessToken(code, isRemote)
val restTemplate = RestTemplate()
val kakaoUserInfo = restTemplate.exchange(
RequestEntity<Any>(this.getHttpHeadersWithAuthorization(accessToken), HttpMethod.POST, URI.create(kakaoUserInfoServer)),
KakaoUserInfo::class.java
).body
val kakaoUserId = kakaoUserInfo?.id ?: ""
return this.getOauthFromOauthIdAndOauthType(
oauthId = kakaoUserId,
oauthTypeEnum = OauthTypeEnum.KAKAO)
}
override fun getAccessToken(code: String, isRemote: Boolean): String {
val restTemplate = RestTemplate()
val httpHeaders = HttpHeaders()
httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
val httpBody = LinkedMultiValueMap<String, String>()
httpBody.add("redirect_uri", if (isRemote) redirectUri else localRedirectUri)
httpBody.add("client_id", clientId)
httpBody.add("code",code)
return restTemplate.postForObject(kakaoAccessTokenServer, this.getHttpEntity(httpBody), KakaoAccessToken::class.java)?.accessToken ?: ""
}
private data class KakaoAccessToken(
@JsonProperty("access_token")
val accessToken: String
)
private data class KakaoUserInfo(
@JsonProperty("id") val id: String,
@JsonProperty("connected_at") val connectedAt: String,
@JsonProperty("properties") val properties: Properties,
@JsonProperty("kakao_account") val kakaoAccount: KakaoAccount
)
private data class Properties(
@JsonProperty("nickname") val nickname: String
)
private data class KakaoAccount(
@JsonProperty("profile_nickname_needs_agreement") val profileNicknameNeedsAgreement: Boolean,
@JsonProperty("profile") val profile: Profile
)
private data class Profile(
@JsonProperty("nickname") val nickname: String,
@JsonProperty("is_default_nickname") val isDefaultNickname: Boolean,
@JsonProperty("profile_image_url") val profileImageUrl:String?
)
}
open class OauthServiceImpl(
private val oauthRepository: OauthRepository
) {
fun getHttpEntity(httpBody: LinkedMultiValueMap<String, String>): HttpEntity<LinkedMultiValueMap<String, String>> {
val httpHeaders = HttpHeaders()
httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
httpBody.add("grant_type", "authorization_code")
return HttpEntity(httpBody, httpHeaders)
}
fun getHttpHeadersWithAuthorization(accessToken: String): HttpHeaders{
val httpHeaders = HttpHeaders()
httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
httpHeaders.add("Authorization", "Bearer $accessToken")
return httpHeaders
}
fun getOauthFromOauthIdAndOauthType(oauthId: String, oauthTypeEnum: OauthTypeEnum): Oauth {
val optionalOauth = oauthRepository.findByOauthIdAndOauthType(oauthId, oauthTypeEnum)
if (optionalOauth.isPresent) {
CompletableFuture.supplyAsync{
optionalOauth.get()
}.thenApply {
oauthRepository.save(it)
}
return optionalOauth.get()
}
else{
return oauthRepository.save(Oauth(
id = null,
user = null,
oauthId = oauthId,
oauthType = oauthTypeEnum
))
}
}
}
이번에도 저번과 마찬가지로 토큰 받아오기, 사용자 정보 받아오기를 구현하며 인터페이스도 저번에 사용했던
OauthService interface
interface OauthService {
fun getOauth(code: String, isRemote: Boolean): Oauth
fun getAccessToken(code: String, isRemote: Boolean): String
}
Oauth data class
@Entity
@Table(
indexes = [
Index(name = "idx_type_id", columnList = "oauthType, oauthId")
]
)
data class Oauth(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Int?,
@ManyToOne
var user: User?,
@Column(length = 100)
var oauthId: String,
@Enumerated(EnumType.ORDINAL)
var oauthType: OauthTypeEnum
)
해당 코드들을 그대로 사용한다.
그러면 이번에는 구글 Oauth를 통해서 oauthId를 받아오도록 해보자.
우선 구글 애플리케이션을 등록해야 한다.
GCP에서 애플리케이션을 등록하고, API 인증 및 서비스 항목의 사용자 인증정보 페이지에서 리디렉션 URI를 다음과 같이 등록해주자.
http://localhost:8080/api/auth/google-login
그 다음에는 구글에 가져올 정보와 테스트 계정을 등록해줘야 한다.
앱 등록 수정에서
가져오는 정보를 다음과 같이 email, profile, openid를 등록해준다.
그리고 다음페이지에서 테스트 계정도 등록해둔다.
이러면 애플리케이션 등록에서는 할 일이 끝났다.
AccessToken 받아오기
저번과 마찬가지로 우선 애플리케이션에서 해당 사용자에 대한 AccessToken을 받아오는 것부터 시작이다.
저번과 비슷하다.
POST 방식으로 구글 인증 주소에 요청해서 토큰을 받아오는 것이다.
private final val googleAccessTokenServer = "https://oauth2.googleapis.com/token"
override fun getAccessToken(code: String, isRemote: Boolean): String {
val restTemplate = RestTemplate()
val httpBody = LinkedMultiValueMap<String, String>()
httpBody.add("client_id", clientId)
httpBody.add("code",code)
httpBody.add("client_secret", clientSecret)
httpBody.add("redirect_uri", if (isRemote) redirectUri else localRedirectUri)
val result = restTemplate.postForObject(googleAccessTokenServer, this.getHttpEntity(httpBody), GoogleAccessToken::class.java)?.accessToken ?: ""
return result
}
fun getHttpEntity(httpBody: LinkedMultiValueMap<String, String>): HttpEntity<LinkedMultiValueMap<String, String>> {
val httpHeaders = HttpHeaders()
httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
httpBody.add("grant_type", "authorization_code")
return HttpEntity(httpBody, httpHeaders)
}
private data class GoogleAccessToken(
@JsonProperty("access_token") val accessToken: String,
@JsonProperty("expires_in") val expiresIn: Int,
@JsonProperty("token_type") val tokenType: String,
@JsonProperty("scope") val scope: String,
@JsonProperty("id_token") val idToken: String,
)
넣는 정보는 다음과 같다.
구글 애플리케이션에서 가져오는 client_id, client_secret을 가져오고, 본인이 등록한 redirect_uri를 넣는다.
그리고 해당 로그인으로부터 가져오는 code와 가져오는 type을 authorization으로 넣어 post요청하면 해당 유저의 AccessToken을 발급해준다.
client_id와 client_secret, redirect_uri는 환경변수에 넣어두고 가져오도록 만들면 된다.
사용자 정보 가져오기
이제 사용자의 고유 id를 가져오자.
응답받은 AccessToken을 그대로 넣어 요청하면 끝난다.
private final val googleUserInfoServer = "https://www.googleapis.com/oauth2/v2/userinfo"
override fun getOauth(code: String, isRemote: Boolean): Oauth {
val accessToken = getAccessToken(code, isRemote)
val restTemplate = RestTemplate()
val googleUserInfo = restTemplate.exchange(
RequestEntity<Any>(this.getHttpHeadersWithAuthorization(accessToken), HttpMethod.GET, URI.create(googleUserInfoServer)),
GoogleUserInfo::class.java
).body
val googleUserId = googleUserInfo?.id ?: ""
return this.getOauthFromOauthIdAndOauthType(
oauthId = googleUserId,
oauthTypeEnum = OauthTypeEnum.GOOGLE,
accessToken = accessToken)
}
fun getHttpHeadersWithAuthorization(accessToken: String): HttpHeaders{
val httpHeaders = HttpHeaders()
httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
httpHeaders.add("Authorization", "Bearer $accessToken")
return httpHeaders
}
private data class GoogleUserInfo(
@JsonProperty("id") val id: String?,
@JsonProperty("email") val email: String?,
@JsonProperty("verified_email") val verifiedEmail: Boolean?,
@JsonProperty("name") val name: String?,
@JsonProperty("given_name") val givenName: String?,
@JsonProperty("family_name") val familyName: String?,
@JsonProperty("picture") val picture: String?,
@JsonProperty("locale") val locale: String?
)
해당 token을 헤더에 넣고 주소로 get 요청을 보낸다.
응답받는 Response 형식은 다음과 같다.
여기서 Id를 통해 구글로 로그인한 사용자를 고유번호를 통해 식별할 수 있게 되었다.
아래는 사용한 전체의 코드이다.
@Service
class GoogleOauthServiceImpl(
@Value("\${google.auth.client_id}")
private val clientId: String,
@Value("\${google.auth.redirect_uri}")
private val redirectUri: String,
@Value("\${google.auth.local_redirect_uri}")
private val localRedirectUri: String,
@Value("\${google.auth.client_secret}")
private val clientSecret: String,
private val oauthRepository: OauthRepository
): OauthService, OauthServiceImpl(oauthRepository) {
private final val googleAccessTokenServer = "https://oauth2.googleapis.com/token"
private final val googleUserInfoServer = "https://www.googleapis.com/oauth2/v2/userinfo"
override fun getOauth(code: String, isRemote: Boolean): Oauth {
val accessToken = getAccessToken(code, isRemote)
val restTemplate = RestTemplate()
val googleUserInfo = restTemplate.exchange(
RequestEntity<Any>(this.getHttpHeadersWithAuthorization(accessToken), HttpMethod.GET, URI.create(googleUserInfoServer)),
GoogleUserInfo::class.java
).body
val googleUserId = googleUserInfo?.id ?: ""
return this.getOauthFromOauthIdAndOauthType(
oauthId = googleUserId,
oauthTypeEnum = OauthTypeEnum.GOOGLE,
accessToken = accessToken)
}
override fun getAccessToken(code: String, isRemote: Boolean): String {
val restTemplate = RestTemplate()
val httpBody = LinkedMultiValueMap<String, String>()
httpBody.add("client_id", clientId)
httpBody.add("code",code)
httpBody.add("client_secret", clientSecret)
httpBody.add("redirect_uri", if (isRemote) redirectUri else localRedirectUri)
val result = restTemplate.postForObject(googleAccessTokenServer, this.getHttpEntity(httpBody), GoogleAccessToken::class.java)?.accessToken ?: ""
return result
}
private data class GoogleAccessToken(
@JsonProperty("access_token") val accessToken: String,
@JsonProperty("expires_in") val expiresIn: Int,
@JsonProperty("token_type") val tokenType: String,
@JsonProperty("scope") val scope: String,
@JsonProperty("id_token") val idToken: String,
)
private data class GoogleUserInfo(
@JsonProperty("id") val id: String?,
@JsonProperty("email") val email: String?,
@JsonProperty("verified_email") val verifiedEmail: Boolean?,
@JsonProperty("name") val name: String?,
@JsonProperty("given_name") val givenName: String?,
@JsonProperty("family_name") val familyName: String?,
@JsonProperty("picture") val picture: String?,
@JsonProperty("locale") val locale: String?
)
}
open class OauthServiceImpl(
private val oauthRepository: OauthRepository
) {
fun getHttpEntity(httpBody: LinkedMultiValueMap<String, String>): HttpEntity<LinkedMultiValueMap<String, String>> {
val httpHeaders = HttpHeaders()
httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
httpBody.add("grant_type", "authorization_code")
return HttpEntity(httpBody, httpHeaders)
}
fun getHttpHeadersWithAuthorization(accessToken: String): HttpHeaders{
val httpHeaders = HttpHeaders()
httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
httpHeaders.add("Authorization", "Bearer $accessToken")
return httpHeaders
}
fun getOauthFromOauthIdAndOauthType(oauthId: String, oauthTypeEnum: OauthTypeEnum, accessToken: String): Oauth {
val optionalOauth = oauthRepository.findByOauthIdAndOauthType(oauthId, oauthTypeEnum)
if (optionalOauth.isPresent) {
CompletableFuture.supplyAsync{
optionalOauth.get()
}.thenApply {
oauthRepository.save(it)
}
return optionalOauth.get()
}
else{
return oauthRepository.save(Oauth(
id = null,
user = null,
oauthId = oauthId,
oauthType = oauthTypeEnum
))
}
}
}