반응형

과거부터 개발하면서 꿈이 있었다면, 에러가 발생하더라도 서버에서 로그를 확인하지 않고도 바로 알도록 만드는 것이었다.

이런 시스템을 만들어두면 에러가 발생하자마자 바로바로 조치가 가능하기 때문이었다.

 

웹훅으로 만드려고 했으며, 가장 쉬운 슬랙을 사용했다.

 

우선 슬랙에서 다음과 같은 앱을 추가하자

 

이 Incoming Webhooks를 사용해서 채널에 알림을 보낼것이다.

 

여기서 webhook url을 생성하고, 이 과정은 생략하도록 하겠다.

 

에러를 보고하는 서비스의 추상화를 만든다.

interface ErrorReporter {

    fun reportError(content: String, localDateTime: LocalDateTime)
}

 

현재는 슬랙으로 하고 있지만, 구현체가 변경될 수도 있으니 추상화로 만들어둔다.

 

@Service
class SlackErrorReporter(
    @Value("\${slack.webhook_url.error}")
    private val slackWebHookUrl: String
): ErrorReporter {

    override fun reportError(content: String, localDateTime: LocalDateTime) {

        val payload = mapOf("text" to "${localDateTime}에 $content")

        WebClient.create()
            .post().uri(slackWebHookUrl)
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(payload)
            .retrieve()
            .bodyToMono(Void::class.java)
            .block()

    }
}

 

당연히 webhook의 주소는 깃에 노출되면 안되고 변경이 될 수 있기에 환경변수로 주입받는다.

 

http로 요청을 해야하기에 WebClient를 사용했다.

 

안에 들어가는 json 형식은 다음과 같다.

{
	"text": "에러 내용"
}

그렇기에 나는 여기에 시간과 에러의 내용을 보고하도록 만들었다.

 

그리고 이제 이 reporter를 사용해보자.

당연히 전역의 ExceptionHandler를 사용했다.

 

현재 멀티모듈을 사용하기에 container 역할을 하는 container 모듈에 다음과 같은 ExceptionHandler를 추가했다.

 

@ControllerAdvice
class GlobalExceptionHandler(
    private val errorReporter: ErrorReporter
) {

    @ExceptionHandler(Exception::class)
    fun handleException(e: Exception): ResponseEntity<ErrorResponse> {

        val sw = StringWriter()
        e.printStackTrace(PrintWriter(sw))
        errorReporter.reportError(sw.toString(), LocalDateTime.now())

        return ResponseEntity.internalServerError().build()
    }
}

 

errorReporter를 주입받고, 해당 에러에 대한 stackTrace를 errorReporter로 넘기게 된다.

클라이언트에게는 500에러를 그대로 반환한다.

 

이러면 서버에서 해결하지 못했던 모든 500에러가 슬랙을 통해 개발자에게 보고된다.

 

테스트를 해보도록 하자.

그냥 다짜고짜 컨트롤러에서 에러를 던져보았다.

throw IllegalStateException()

 

컨트롤러는 해당 에러를 처리할 수 없기에 클라이언트로 넘기려고 할거고, 그러면 전역 핸들러에 걸리게 될 것이다.

 

일단 Swagger에는 500에러가 나오게 된다.

 

그리고 슬랙에서도 컨트롤러에서 IllegalStateException이 발생했다고 알림이 오는 것을 볼 수 있다.

 

반응형

데이터의 Total 개수와 해당 페이지에 해당하는 요소를 데이터베이스로부터 추출해 응답해야 하는 API를 개발해야 했다.

보통은 이런 것을 JPA의 페이징을 이용해서 만든다.

근데, 이것을 JDBC로 만들어보고 JPA보다 빠른지 궁금해져서 만들며 비교를 해보려고 한다.

각각 파이썬으로 데이터를 1000개 정도 넣고 테스트 해보았다.

 

  • JDBC

우선 JDBC이다.

사용한 코드는 아래와 같다.

총 개수와 해당 요소들을 가져오는 쿼리문을 2번 날리게 된다.

 

Service

override fun professorGetWritingList(
        assignmentId: Int,
        page: Int,
        pageSize: Int
    ): ResponseEntity<CoBoResponseDto<ProfessorGetWritingListRes>> {
        val startTime = System.currentTimeMillis()
        val totalElements = writingRepository.countByAssignmentIdWithJDBC(assignmentId)
        val writings =
            writingRepository.findByAssignmentIdOrderByStatePagingWithJDBC(
                assignmentId = assignmentId,
                page = page,
                pageSize = pageSize
            ).map{
                ProfessorGetWritingLisElementRes(
                    studentId = it.studentId,
                    createdAt = it.createdAt!!,
                    updatedAt = it.updatedAt!!,
                    writingState = it.state.value
                )
            }

        println("TIME: ${System.currentTimeMillis() - startTime}ms")

        return CoBoResponse(
            data = ProfessorGetWritingListRes(
                totalElements = totalElements,
                writings = writings
            ),
            coBoResponseStatus = CoBoResponseStatus.SUCCESS
        ).getResponseEntity()
    }

 

Repository

    override fun findByAssignmentIdOrderByStatePagingWithJDBC(assignmentId: Int, page: Int, pageSize: Int): List<Writing> {
        val sql = "SELECT writing.id, writing.student_id, writing.assignment_id, writing.content, writing.state, writing.created_at, writing.updated_at, writing.submitted_at " +
                "FROM writing WHERE writing.assignment_id = ? ORDER BY writing.state LIMIT ?, ?"
        return jdbcTemplate.query(
            sql, {rs, _ -> writingRowMapper(rs)}, assignmentId, page, pageSize
        )
    }

    override fun countByAssignmentIdWithJDBC(assignmentId: Int): Long {
        return jdbcTemplate.queryForObject("SELECT count(*) FROM writing WHERE assignment_id = ?", Long::class.java, assignmentId)!!

    }

    private fun writingRowMapper(resultSet: ResultSet): Writing {
        return Writing(
            id = resultSet.getInt("id"),
            studentId = resultSet.getString("student_id"),
            assignment = Assignment(resultSet.getInt("assignment_id")),
            content = resultSet.getString("content"),
            state = WritingStateEnum.from(resultSet.getShort("state"))!!,
            createdAt = resultSet.getTimestamp("created_at").toLocalDateTime(),
            updatedAt = resultSet.getTimestamp("updated_at").toLocalDateTime(),
            submittedAt = resultSet.getTimestamp("submitted_at").toLocalDateTime()
        )
    }

 

0페이지에 요소를 20개로 속도를 측정해보면

요소 20개 요소 100개
39MS 47MS
41MS 46MS
44MS 48MS
38MS 40MS

 

이 정도의 속도가 나왔다, 역시 요소가 많아질수록 시간이 오래 걸리기는 한다.

 

  • JPA

이번에는 간편한 JPA이다.

간단하게 PageRequest로 페이징해서 거기에서 totalElements와 요소들을 가져왔다.

override fun professorGetWritingList(
        assignmentId: Int,
        page: Int,
        pageSize: Int
    ): ResponseEntity<CoBoResponseDto<ProfessorGetWritingListRes>> {
        val startTime = System.currentTimeMillis()
        val jpaList = writingRepository.findByAssignment(Assignment(assignmentId), PageRequest.of(page, pageSize))

        val totalElements = jpaList.totalElements
        val writings = jpaList.content.map {
            ProfessorGetWritingListElementRes(
                it.studentId, it.createdAt!!, it.updatedAt!!, it.state.value
            )
        }

        println("TIME: ${System.currentTimeMillis() - startTime}ms")

        return CoBoResponse(
            data = ProfessorGetWritingListRes(
                totalElements = totalElements,
                writings = writings
            ),
            coBoResponseStatus = CoBoResponseStatus.SUCCESS
        ).getResponseEntity()
    }

 

전과 동일하게 측정해보자

요소 20개 요소 100개
128MS 81MS
63MS 76MS
61MS 68MS
61MS 69MS

 

확실히 JDBC로 호출하는 것이 빠른 것을 볼 수 있다.

 

사용자가 얼마 없으면 JPA로 개발하고, 사용자가 많아지면 JDBC로 개발할 것 같다.

이번은 이미 JDBC 코드로 만들어놔서 JPA를 사용하지는 않을 것 같다.

 

  • JDBC 비동기

마지막으로 JDBC에서 호출하던 2개의 쿼리문을 비동기로 동시에 호출해보자.

 

JDBC 서비스 코드에서 아래같이 수정했다.

override fun professorGetWritingList(
        assignmentId: Int,
        page: Int,
        pageSize: Int
    ): ResponseEntity<CoBoResponseDto<ProfessorGetWritingListRes>> {

        val startTime = System.currentTimeMillis()

        val totalElementsCompletableFuture = CompletableFuture.supplyAsync {
            writingRepository.countByAssignmentIdWithJDBC(assignmentId)
        }

        val writingsCompletableFuture = CompletableFuture.supplyAsync {
            writingRepository.findByAssignmentIdOrderByStatePagingWithJDBC(
                assignmentId = assignmentId,
                page = page,
                pageSize = pageSize
            ).map{
                ProfessorGetWritingListElementRes(
                    studentId = it.studentId,
                    createdAt = it.createdAt!!,
                    updatedAt = it.updatedAt!!,
                    writingState = it.state.value
                )
            }
        }

        val coboResponse = CoBoResponse(
            data = ProfessorGetWritingListRes(
                totalElements = totalElementsCompletableFuture.get(),
                writings = writingsCompletableFuture.get()
            ),
            coBoResponseStatus = CoBoResponseStatus.SUCCESS
        )

        println("TIME: ${System.currentTimeMillis() - startTime}ms")

        return coboResponse.getResponseEntity()
    }

 

실행해보면

요소 20개 요소 100개
35MS 46MS
37MS 31MS
29MS 21MS
30MS 26MS

 

이렇게 말도 안되는 속도가 나온다.

물론 가장 빠르기는 하지만, 그냥 JDBC를 사용했을 때와 비슷한 거 같기도....?

 

확실히 JPA가 편하고 비동기가 강력한 것 같다.

반응형

이번에 진행하는 프로젝트에서는 학생들의 과제 제출시간을 한국시간으로 저장하고 체크하기 위해, 아래와 같은 테스트코드를 사용했었다.

 

    @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로 설정이 되어 있었다.

 

처음에는 당연히 스프링부트가 우분투의 시간을 따른다고 생각하고 우분투의 시간을 한국시간으로 맞춰주었다.

 

sudo timedatectl set-timezone Asia/Seoul

timedatectl

 

이렇게 timedatectl로 한국시간이 나오는 것 까지 확인을 하고

 

테스트를 다시 해봤지만, 다시 에러가 발생했다.

 

찾아보니 스프링부트는 로컬이 아니면, UTC의 시간을 가져온다고 한다.

 

따라서 스프링부트 자체에 시간을 설정하는 코드를 추가해주거나, java를 실행할 때 시간을 환경변수로 설정해야 한다고 한다.

 

나는 이 중에서 시간을 설정해주는 코드를 추가했다.

 

@Configuration
class TimeConfig {

    @PostConstruct
    fun setTimeZone(){
        TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
    }
}

 

이렇게 TimeConfig를 추가해줬고, 시간은 Bean을 초기화하기 전에 설정해줘야 하기에 PostConstruct annotation을 사용했다.

 

이렇게 설정하고 테스트를 다시 해보니, 모두 성공했다.

반응형

저번에는 Docker를 사용하지 않고, 그냥 ubuntu 서버에서 빌드하고 실행해서 Swagger를 proxy 했었다.

 

이번에는 Docker를 사용해보려고 한다.

우선 Docker가 설치되어 있어야 하고, Docker-compose도 설치가 되어 있어야 한다.

 

  • Nginx

우선 nginx의 config 파일이다.

server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    #access_log  /var/log/nginx/host.access.log  main;

    location /auth/ {
    		proxy_pass http://auth-server:8080/;
    		proxy_set_header X-Real-IP $remote_addr;
    		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    		proxy_set_header Host $http_host;

    		proxy_set_header X-Forwarded-Prefix /auth;
    	}

    location /chat/ {
    		proxy_pass http://chat-server:12041/;
    		proxy_set_header X-Real-IP $remote_addr;
    		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    		proxy_set_header Host $http_host;

    		proxy_set_header X-Forwarded-Prefix /chat;
    }

    location /writing/ {
        		proxy_pass http://writing-server:12042/;
        		proxy_set_header X-Real-IP $remote_addr;
        		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        		proxy_set_header Host $http_host;

        		proxy_set_header X-Forwarded-Prefix /writing;
        }


    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }

    # proxy the PHP scripts to Apache listening on 127.0.0.1:80
    #
    #location ~ \.php$ {
    #    proxy_pass   http://127.0.0.1;
    #}

    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    #
    #location ~ \.php$ {
    #    root           html;
    #    fastcgi_pass   127.0.0.1:9000;
    #    fastcgi_index  index.php;
    #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
    #    include        fastcgi_params;
    #}

    # deny access to .htaccess files, if Apache's document root
    # concurs with nginx's one
    #
    #location ~ /\.ht {
    #    deny  all;
    #}
}

 

당연히 저번과 다른 것은 없다.

 

이거를 Docker 컨테이너에 있는 Nginx에도 넣어줘야 하기 때문에, 일단 파일로 작성하여 nginx/config 폴더에 default.con라는 이름으로 저장해둔다.

 

  • SpringBoot

이제 스프링부트의 Dockerfile을 만들어보자.

 

build 한 빌드 파일을 도커의 볼륨에 넣고, 실행하여 배포하면 된다.

 

FROM openjdk:17-jdk

CMD ["./gradlew", "clean", "build", "-x", "test"]

VOLUME /auth

ARG JAR_FILE=build/libs/auth-0.0.1-SNAPSHOT.war

COPY ${JAR_FILE} auth.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "/auth.jar"]

 

openjdk:17을 가져와서

기본 실행 폴더를 /auth로 지정하고 빌드 폴더에 있는 war 파일을 가져가서 실행하면 된다.

 

이 부분은 어렵지 않기 때문에 바로 넘어가겠다.

 

  • docker-compose

가장 루트 폴더에 docker-compose.yml이다.

이곳에 의존성과 각 이미지를 빌드하는 설정들을 명시하면 된다.

 

version: '3'

services:
  auth-server:
    container_name: auth-server
    build: ./auth
    ports:
      - "8080:8080"
    networks:
      - cobo
  chat-server:
    container_name: chat-server
    build: ./chat
    ports:
      - "12041:12041"
    networks:
      - cobo
  writing-server:
    container_name: writing-server
    build: ./writing
    ports:
      - "12042:12042"
    networks:
      - cobo

  nginx:
    build: ./nginx
    image: nginx:stable-perl
    ports:
      - "80:80"
    volumes:
      - ./nginx/config/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - auth-server
      - chat-server
      - writing-server
    networks:
      - cobo

networks:
  cobo:

 

services에서 각 폴더에 있는 Dockerfile을 이용해 이미지를 생성하며 네트워크는 cobo라는 동일한 네트워크를 사용해, 각 컨테이너끼리 통신할 수 있도록 한다.

 

그리고 Nginx의 구성파일을 Nginx 컨테이너의 /etc/nginx/conf.d에 넣어줘야 하는데

가지고 있는 default.conf 파일을 volumes로 해서 마운트해줘, 해당 파일을 우리가 작성한 파일로 변경해주면 된다.

 

'크무톡톡 프로젝트' 카테고리의 다른 글

SpringBoot 시간 설정  (0) 2024.08.09
JPA Soft Delete  (0) 2024.08.06
JDBC ON DUPLICATE KEY UPDATE  (0) 2024.07.29
JUnit을 Kotlin으로 테스트 할 때 beforeAll, AfterAll  (0) 2024.07.26
카카오 Oauth 로그인, SpringBoot  (0) 2024.07.25
반응형

DB를 사용하다보면 이런 경우를 많이 보게 될 것이다.

 

만약 해당 PK를 가지는 데이터가 있다면 해당 데이터를 update, 해당 PK를 가지는 데이터가 없다면 해당 데이터를 update

 

이 로직을 JPA로 처리하려면 findById로 해당 데이터를 찾고, if-else를 사용해서 데이터를 저장하는 알고리즘을 만들게 된다.

 

 이 알고리즘은 굉장히 간단하다.

 

하지만 데이터베이스를 2번 접근해야 한다는 문제가 있다.

비록 아직 주니어이지만, 데이터베이스를 많이 접근하게 된다면 응답속도에 굉장한 문제가 있다는 것을 알고 있다.

그리고 데이터베이스의 접근을 최소한으로 줄이고 싶었다.

 

그래서 JDBC로 쿼리문을 한줄로 작성하여 요청하는 방법을 사용하였다.

 

    override fun ifExistUpdateElseInsert(chatRoom: ChatRoom) {
        jdbcTemplate.update(
            "INSERT INTO chat_room (id, chat_state_enum) " +
                    "VALUES (?, ?) " +
                    "ON DUPLICATE KEY UPDATE chat_state_enum = VALUES(chat_state_enum)",
            chatRoom.id, chatRoom.chatStateEnum.value)
    }

 

위와 같은 코드를 작성했다.

DUPLICATE KEY UPDATE를 사용하는 방법이다.

 

이러면 해당 PK가 있을 경우, PK를 제외한 나머지 데이터를 업데이트하게 된다.

 

이 방법을 사용하면 데이터베이스에 한번만 접근하기 때문에 JPA로 두번 접근하는 방법보다는 훨씬 빠를 것이다.

 

하지만 DUPLICATE KEY UPDATE 방법이 데이터베이스에 부하를 주기 때문에, 때로는 JPA로 2번 접근하는 것이 더 좋은 방법일 수도 있다고 한다.

운영중인 서버 환경을 잘 파악해 이 방법 중 적절한 방법을 사용해야 할 것 같다.

반응형

TDD까지는 아니더라도 이번 프로젝트에서는 JUnit으로 테스트를 하려고 한다.

 

저번 학기에 발생한 에러를 프론트의 문제인지, 백의 문제인지 바로 파악하지 못했기 때문이다.

 

 

JUnit의 AfterEach, BeforeEach, AfterAll, BeforeAll 등의 방법들은 모두 알고는 있지만, 이전에 코틀린으로는 해본 적이 없었다.

 

그렇게 @BeforeAll, @AfterAll을 추가하려고 했는데 빨간줄로 에러가 발생했다.

 

이 Annotation들은 static 함수에 붙여줬었는데, 생각해보니 코틀린에서는 static 함수가 없었다.

 

그렇기 때문에 이 @BeforeAll과 @AfterAll은 companion object에 만들어줘야 한다.

그리고 또한, static하게 만들기 위해 @JvmStatic Annotation을 붙여줘야 한다.

 

아래와 같다.

companion object {
        @JvmStatic
        @BeforeAll
        internal fun beforeAll(
            @Autowired userRepository: UserRepository
        ) {
        }

        @JvmStatic
        @AfterAll
        internal fun afterAll(
            @Autowired userRepository: UserRepository
        ) {
        }
    }

 

이렇게 companion object에 @JvmStatic을 달아주면 된다.

 

또한 여기서는 밖에서 @Autowired한 Bean들을 사용할 수 없기 때문에 각각의 함수에서 따로 주입받아주어야 한다.

반응형

마지막으로 카카오 로그인이다.

 

저번에도 사용한 

 

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
)

 

그대로 사용한다.

 

우선 마찬가지로 카카오 애플리케이션 등록을 해야한다.

 

그 후에는 또 마찬가지로 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
            ))
        }
    }
}

 

이렇게 네이버, 구글, 카카오의 3가지 소셜로그인에 대하여 알아보았다.

 

다음에는 어떤 내용으로 글을 작성할지 고민해보도록 하겠다.

반응형

저번 네이버 로그인에 이어 이번에는 구글 로그인이다.

 

이번에도 저번과 마찬가지로 토큰 받아오기, 사용자 정보 받아오기를 구현하며 인터페이스도 저번에 사용했던

 

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
            ))
        }
    }
}

 

이렇게 작성하여 요청하면 되는데, 구글은 로그인의 주소가 좀 어렵다.

https://accounts.google.com/o/oauth2/v2/auth?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code&scope=https://www.googleapis.com/auth/userinfo.email

 

위의 주소에 값을 넣어서 요청하면 된다.

반응형

이번 2학기 개발에서는 기존 카카오 로그인에 추가로 네이버 로그인과 구글 로그인을 추가하게 되었다.

 

카카오 로그인과 구글 로그인도 글을 작성하겠지만, 우선 네이버 로그인부터 작성을 해보려고 한다.

 

각각의 Oauth에서는 사용할 기능이 토큰 받아오기, 사용자 정보 받아오기 이렇게 2가지이기 때문에 인터페이스를 만들고 해당 인터페이스를 구현하는 방법으로 각각의 Oauth 로그인을 만들었다.

 

우선 작성한 인터페이스이다.

interface OauthService {

    fun getOauth(code: String, isRemote: Boolean): Oauth
    fun getAccessToken(code: String, isRemote: Boolean): String
}

 

redirect_uri를 원격서버, 로컬서버 이렇게 2개 연결하고 싶었기 때문에 그것을 결정하기 위한 isRemote 파라미터를 추가하였다.

그리고 Oauth에서 정보를 받아와 사용자의 정보를 구별하기 위한 Oauth data class를 사용하였다.

 

사용한 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를 받아오도록 해보자.

 

우선 여기에서 애플리케이션을 등록해야 한다.

https://developers.naver.com/main/

 

NAVER Developers

네이버 오픈 API들을 활용해 개발자들이 다양한 애플리케이션을 개발할 수 있도록 API 가이드와 SDK를 제공합니다. 제공중인 오픈 API에는 네이버 로그인, 검색, 단축URL, 캡차를 비롯 기계번역, 음

developers.naver.com

 

그리고 토큰을 받아올 수 있도록 redirect_uri를 지정해준다.

우선 로컬에서 사용하기 때문에, http://localhost:8080/api/auth/naver-login으로 만들어줬다.

 

이 주소는 나중에 서비스가 배포된다면 변경해주거나 추가해주어야 한다.

 

우선 Controller이다.

    @GetMapping("/naver-login")
    @Operation(summary = "네이버 로그인 API")
    @Parameters(
        Parameter(name = "code", description = "네이버 로그인 code")
    )
    fun getNaverLogin(
        @RequestParam code: String
    ): ResponseEntity<CoBoResponseDto<GetAuthLoginRes>>{
        return authService.getNaverLogin(code)
    }

이렇게 그냥 Parameter로 코드만 가져오면 된다.

 

  • AccessToken 받아오기
        private final val naverAccessTokenServer = "https://nid.naver.com/oauth2.0/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("client_id", clientId)
        httpBody.add("client_secret", clientSecret)
        httpBody.add("code", code)

        return restTemplate.postForObject(naverAccessTokenServer, this.getHttpEntity(httpBody), NaverAccessToken::class.java)?.accessToken ?: ""
    }
    
    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 NaverAccessToken(
        @JsonProperty("access_token") val accessToken: String,
        @JsonProperty("state") val state: String?,
        @JsonProperty("error") val error: String?,
        @JsonProperty("error_description") val errorDescription: String?
    )

 

RestTemplate를 통해 작성했다.

 

https://nid.naver.com/oauth2.0/token

해당 주소로 POST를 사용해 

client_id, client_secret, code를 넣어서 요청하면 된다.

응답받는 json은 아래와 같다.

당연히 client_id, client_secret은 환경변수에 넣어두고 가져오면 된다.

 

이렇게 해당 사용자의 AccessToken을 받아오게 되었다.

 

  • 사용자 정보 가져오기

이제 사용자의 고유 Id를 가져오자.

 

응답받은 AccessToken을 그대로 넣어서 요청하면 끝난다.

https://developers.naver.com/docs/login/profile/profile.md

 

네이버 회원 프로필 조회 API 명세 - LOGIN

네이버 회원 프로필 조회 API 명세 NAVER Developers - 네이버 로그인 회원 프로필 조회 가이드 네이버 로그인을 통해 인증받은 받고 정보 제공에 동의한 회원에 대해 회원 메일 주소, 별명, 프로필 사

developers.naver.com

 

 

    private final val naverUserInfoServer = "https://openapi.naver.com/v1/nid/me"

    override fun getOauth(code: String, isRemote: Boolean): Oauth {

        val accessToken = getAccessToken(code, isRemote)

        val restTemplate = RestTemplate()

        val naverUserInfo = restTemplate.exchange(
            RequestEntity<Any>(this.getHttpHeadersWithAuthorization(accessToken), HttpMethod.GET, URI.create(naverUserInfoServer)),
            NaverUserInfo::class.java
        ).body

        val naverUserId = naverUserInfo?.response?.id ?: ""

        return this.getOauthFromOauthIdAndOauthType(
            oauthId = naverUserId,
            oauthTypeEnum = OauthTypeEnum.NAVER,
            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 NaverUserInfo(
        @JsonProperty("resultcode") val resultCode: String,
        @JsonProperty("message") val message: String,
        @JsonProperty("response") val response: Response,
    )

    private data class Response(
        @JsonProperty("id") val id: String,
        @JsonProperty("nickname") val nickname: String?,
        @JsonProperty("name") val name: String?,
        @JsonProperty("email") val email: String?,
        @JsonProperty("gender") val gender: String?,
        @JsonProperty("age") val age: String?,
        @JsonProperty("birthday") val birthday: String?,
        @JsonProperty("profile_image") val profileImage: String?,
        @JsonProperty("birthyear") val birthyear: String?,
        @JsonProperty("mobile") val mobile: String?
    )

 

 

하지만 이번은 Post가 아니라 Get이기 때문에 다른 방법을 사용한다.

저렇게 헤더에 Bearer로 토큰만 넣어주면 응답값이 나온다.

응답의 결과는 아래의 data class와 같지만, 어차피 id만 사용하기 때문에 id만 가져오도록 만들었다.

 

이렇게해서 네이버 로그인으로 로그인한 사용자를 고유번호를 통해 식별할 수 있게 되었다.

 

아래는 사용한 전체 코드이다.

package cobo.auth.service.oauth.impl

import cobo.auth.data.entity.Oauth
import cobo.auth.data.enums.OauthTypeEnum
import cobo.auth.repository.OauthRepository
import cobo.auth.service.oauth.OauthService
import com.fasterxml.jackson.annotation.JsonProperty
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.RequestEntity
import org.springframework.stereotype.Service
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.client.RestTemplate
import java.net.URI

@Service
class NaverOauthServiceImpl(
    @Value("\${naver.auth.client_id}")
    private val clientId: String,
    @Value("\${naver.auth.client_secret}")
    private val clientSecret: String,
    private val oauthRepository: OauthRepository
): OauthService, OauthServiceImpl(oauthRepository) {

    private final val naverAccessTokenServer = "https://nid.naver.com/oauth2.0/token"
    private final val naverUserInfoServer = "https://openapi.naver.com/v1/nid/me"

    override fun getOauth(code: String, isRemote: Boolean): Oauth {

        val accessToken = getAccessToken(code, isRemote)

        val restTemplate = RestTemplate()

        val naverUserInfo = restTemplate.exchange(
            RequestEntity<Any>(this.getHttpHeadersWithAuthorization(accessToken), HttpMethod.GET, URI.create(naverUserInfoServer)),
            NaverUserInfo::class.java
        ).body

        val naverUserId = naverUserInfo?.response?.id ?: ""

        return this.getOauthFromOauthIdAndOauthType(
            oauthId = naverUserId,
            oauthTypeEnum = OauthTypeEnum.NAVER,
            accessToken = accessToken)
    }

    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("client_id", clientId)
        httpBody.add("client_secret", clientSecret)
        httpBody.add("code", code)

        return restTemplate.postForObject(naverAccessTokenServer, this.getHttpEntity(httpBody), NaverAccessToken::class.java)?.accessToken ?: ""
    }

    private data class NaverAccessToken(
        @JsonProperty("access_token") val accessToken: String,
        @JsonProperty("state") val state: String?,
        @JsonProperty("error") val error: String?,
        @JsonProperty("error_description") val errorDescription: String?
    )

    private data class NaverUserInfo(
        @JsonProperty("resultcode") val resultCode: String,
        @JsonProperty("message") val message: String,
        @JsonProperty("response") val response: Response,
    )

    private data class Response(
        @JsonProperty("id") val id: String,
        @JsonProperty("nickname") val nickname: String?,
        @JsonProperty("name") val name: String?,
        @JsonProperty("email") val email: String?,
        @JsonProperty("gender") val gender: String?,
        @JsonProperty("age") val age: String?,
        @JsonProperty("birthday") val birthday: String?,
        @JsonProperty("profile_image") val profileImage: String?,
        @JsonProperty("birthyear") val birthyear: String?,
        @JsonProperty("mobile") val mobile: String?
    )
}

 

package cobo.auth.service.oauth.impl

import cobo.auth.data.entity.Oauth
import cobo.auth.data.enums.OauthTypeEnum
import cobo.auth.repository.OauthRepository
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.util.LinkedMultiValueMap
import java.util.concurrent.CompletableFuture

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
            ))
        }
    }
}
반응형

팀원이 작성한 코드에서 성능 튜닝이 필요한 코드를 찾았다.

override fun patchAdmin(routineId: Int, newAdminId: Int, authentication: Authentication): ResponseEntity<CoBoResponseDto<CoBoResponseStatus>> {
        val user = userRepository.findById(authentication.name.toInt())
            .orElseThrow{throw NoSuchElementException("일치하는 사용자가 없습니다.")}

        val newAdmin = userRepository.findById(newAdminId)
            .orElseThrow{throw NoSuchElementException("일치하는 사용자가 없습니다.")}

        val routine = routineRepository.findById(routineId)
            .orElseThrow{throw NoSuchElementException("일치하는 루틴이 없습니다.")}

        if (routine.admin != user)
            throw IllegalAccessException("수정 권한이 없습니다.")

        if (!participationRepository.existsByUserAndRoutine(newAdmin, routine))
            throw NoSuchElementException("참여 정보가 없는 사용자입니다.")

        routine.admin = newAdmin
        routineRepository.save(routine)

        return CoBoResponse<CoBoResponseStatus>(CoBoResponseStatus.SUCCESS).getResponseEntity()
    }

 

해당 코드이다.

 

무려 데이터베이스에 5번 접근하게 된다.

마지막에 save하는 부분은 데이터베이스에서 데이터를 가져온 후 호출해야 하기 때문에 마지막에 실행해야 하지만, 위의 4개의 접근은 의존성이 존재하지 않기 때문에 4개를 각각 다른 쓰레드에서 비동기적으로 실행이 가능하다.

 

if (!participationRepository.existsByUserAndRoutine(newAdmin, routine))
            throw NoSuchElementException("참여 정보가 없는 사용자입니다.")

 

현재 이 코드는 의존성이 존재하지만, JPA가 아닌 QueryDsl을 사용하여 리펙토링해서 의존성을 없애도록 하였다.

 

participationRepository.existsByUserIdAndRoutineIdByQueryDsl(newAdminId, routineId)

 

이렇게 Entity가 아닌 Int 타입의 Id로 검색하도록 코드를 변경했다.

override fun existsByUserIdAndRoutineIdByQueryDsl(userId: Int, routineId: Int): Boolean {
        return jpaQueryFactory
            .select(participation)
            .from(participation)
            .leftJoin(participation.user)
            .where(
                participation.routine.id.eq(routineId),
                participation.user.kakaoId.eq(userId)
            ).fetchFirst() != null
    }

 

이 부분은 QueryDsl의 코드이다.

 

이렇게 저 코드에서도 비동기적으로 실행 할 수 있도록 의존성을 제거하였다.

 

이제 application 단계에서 코드들을 비동기적으로 처리하여 속도를 높여보자.

override fun patchAdmin(routineId: Int, newAdminId: Int, authentication: Authentication): ResponseEntity<CoBoResponseDto<CoBoResponseStatus>> {

        val userCompletableFuture = CompletableFuture.supplyAsync{
            userRepository.findById(authentication.name.toInt())
                .orElseThrow{throw NoSuchElementException("일치하는 사용자가 없습니다.")}
        }

        val newAdminFuture = CompletableFuture.supplyAsync {
            userRepository.findById(newAdminId)
                .orElseThrow{throw NoSuchElementException("일치하는 사용자가 없습니다.")}
        }

        val routineFuture = CompletableFuture.supplyAsync{
            routineRepository.findById(routineId)
                .orElseThrow{throw NoSuchElementException("일치하는 루틴이 없습니다.")}
        }

        val isParticipationFuture = CompletableFuture.supplyAsync{
            participationRepository.existsByUserIdAndRoutineIdByQueryDsl(newAdminId, routineId)
        }

        return CompletableFuture.allOf(userCompletableFuture, newAdminFuture, routineFuture, isParticipationFuture)
            .thenApplyAsync {

                val user = userCompletableFuture.get()
                val newAdmin = newAdminFuture.get()
                val routine = routineFuture.get()
                val isParticipation = isParticipationFuture.get()

                if (routine.admin != user)
                    throw IllegalAccessException("수정 권한이 없습니다.")

                if (!isParticipation)
                    throw NoSuchElementException("참여 정보가 없는 사용자입니다.")

                routine.admin = newAdmin
                routineRepository.save(routine)

                CoBoResponse<CoBoResponseStatus>(CoBoResponseStatus.SUCCESS).getResponseEntity()
            }.get()
    }

 

이렇게 코드를 변경하였다.

 

위의 4개를 CompletableFuture로 담아 비동기적으로 실행하였으며, 4개의 쓰레드가 모두 완료되었을 때를 allOf로 가져와 한 곳에서 모아 결과를 확인 한 후 다시 routineRepository에 저장하도록 하였다.

 

위의 4개의 접근은 한번에 실행하기 때문에 사실상 저 코드는 2개의 데이터베이스 접근의 시간과 비슷하게 튜닝이 되었을 것이다.

 

얼마나 속도가 좋아졌는지 궁금하여 로그로 시간을 찍어보았다.

 

아래 사진은 기존 코드의 실행시간이다.

 

아래 사진은 튜닝한 코드의 실행 시간이다.

 

데이터베이스의 접근 4개를 동시에 처리해서 그런지, 2배 이상의 성능 향상이 일어난 것을 볼 수 있다.

 

최근에 리액티브 프로그래밍에 재미를 느껴 따라해보았는데, 비동기가 왜 필요한지를 다시 한 번 느낄 수 있었던 것 같다.

'크무톡톡 프로젝트' 카테고리의 다른 글

네이버 Oauth 로그인, SpringBoot  (2) 2024.07.23
Nginx로 Swagger Proxy_pass  (1) 2024.07.22
Springboot와 DialogFlow 연동 - API  (0) 2024.01.17
SMTP 서버 구축  (0) 2024.01.04
EC2에 Java 17 설치  (1) 2023.12.29

+ Recent posts