반응형

저번에 작성한 글과 이어진다.

 

일단 WebFlux로 만들어는 놨고, 어느 서버가 더 성능이 좋은지 테스트를 해본 후 해당 서버로 교체를 하려 한다.

솔직히 WebFlux가 더 좋을거라고는 이미 알고 있지만, 그래도 대략적인 Throughput을 구해놓기 위해 측정해봤다.

 

사용한 서버들은 아래와 같다.

https://github.com/Seungkyu-Han/KMU-TOKTOK2_FASTAPI

 

GitHub - Seungkyu-Han/KMU-TOKTOK2_FASTAPI

Contribute to Seungkyu-Han/KMU-TOKTOK2_FASTAPI development by creating an account on GitHub.

github.com

https://github.com/Seungkyu-Han/KMU-TOKTOK2_WEBFLUX

 

GitHub - Seungkyu-Han/KMU-TOKTOK2_WEBFLUX: 크무톡톡2 chatgpt webflux 서버

크무톡톡2 chatgpt webflux 서버. Contribute to Seungkyu-Han/KMU-TOKTOK2_WEBFLUX development by creating an account on GitHub.

github.com

 

이 중 FastAPI는 폴링이기 때문에 성능이 더 안 좋을 것이다.

그렇기에 FastAPI에는 기대를 하지 않고 시작했다.

 

혹시 성능 테스트에 다른 부분이 영향을 미칠수도 있으니 Docker로 CPU 1개, Ram 1GB로 설정해두고 테스트했다.

설정하는 방법은 docker-compose.yml을 사용하여 이런 옵션을 추가해주면 된다.

deploy:
  resources:
    limits:
      cpus: '1'
      memory: 1G

 

JMeter는 다음과 같이 작성했다.

헤더에 Content-Type을 명시하고

 

300명의 유저를 1분 동안 접속시켜 보았다.

 

이렇게 설정을 하고 테스트를 해보았다.

우선 WebFlux

평균 응답 시간은 11초, 최대 응답 시간은 64초

ThroughPut은 3.2/sec라고 한다.

사실 64초나 걸리면 안되지만, 그래도 300명이 1분 동안 접속할 일은 없기 때문에 이정도로 만족하려 한다.

 

이번엔 FastAPI다.

같은 조건으로 테스트 해보았지만 서버가 바로 죽어버렸다...

그렇기에 유저 수를 300명에서 100명으로 줄여보았지만, 그래도 좋은 결과는 나오지 않았다.

 

사실 이 서버는 외부 서버에 요청해서 가져오는 것이기 때문에 시간 보다는 ThroughPut을 중점으로 보려 했지만, 거기에서도 너무 많이 차이가 나버렸다...

 

이번 결과로 서버는 FastAPI에서 WebFlux로 교체하게 되었다.

반응형

기존에 Fastapi로 빠르게 ChatGPT 서버를 만들어서 사용하고 있었다.

GPT Assistant는 curl를 통한 요청을 지원하고 node.js와 python은 라이브러리를 지원했기 때문이다.

그 당시에는 일단 빠르게 배포를 해야 했기에 일단 파이썬으로 구현하고, 다음 주부터는 사용자가 몰릴 예정이라고 하여 여유있는 기간에 Webflux로 서버를 교청하게 되었다.

 

https://platform.openai.com/docs/api-reference/runs/createThreadAndRun

 

여기 openai의 assistant 공식 문서를 참조하여 만들었다.

 

일단 사용한 DTO들이다.

 

Request DTO

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

 

코루틴을 사용하기 때문에 coRouter를 사용하였다.

 

이제 Service 부분이다.

@Component
class ChatBotHandler(
    @Value("\${OPEN_API_KEY}")
    private val openApiKey: String,
    private val objectMapper: ObjectMapper
) {

    private val openAIUrl = "https://api.openai.com/v1"
    private val errorMessage = "챗봇 요청 중 에러가 발생했습니다."

    private val createAndRunWebClient =
        WebClient.builder()
            .baseUrl("$openAIUrl/threads/runs")
            .defaultHeaders {
                it.set(HttpHeaders.AUTHORIZATION, "Bearer $openApiKey")
                it.set(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_VALUE)
                it.set("OpenAI-Beta", "assistants=v2")
            }
            .build()

    suspend fun post(serverRequest: ServerRequest): ServerResponse {
        return withContext(Dispatchers.IO) {
            val chatBotReq = serverRequest.bodyToMono(ChatBotReq::class.java).awaitSingle()
            val response = createAndRunWebClient
                .post()
                .bodyValue(
                    ChatGPTReq(
                        assistantId = chatBotReq.assistantId,
                        thread = Thread(
                            messages = listOf(
                                Message(
                                    role = "user",
                                    content = chatBotReq.content
                                )
                            )
                        ),
                        stream = true
                    )
                )
                .retrieve()
                .bodyToMono(String::class.java)
                .awaitSingle()


            ServerResponse.ok().bodyValue(
                try{
                objectMapper.readValue(response.split("event: thread.message.completed")[1]
                    .split("event: thread.run.step.completed")[0]
                    .trimIndent()
                    .removePrefix("data: "), ChatGPTRes::class.java).content[0].text.value
                }catch (e: IndexOutOfBoundsException){
                    errorMessage
                }
            )
                .awaitSingle()
        }
    }
}

 

우선 openApiKey는 필요하다.

이거는 발급해오고, 깃허브에는 올리면 안되기 때문에 환경변수로 빼준다.

 

우선 공통된 부분에 대한 webClient를 작성해준다.

헤더에 이런 부분들이 들어가니 똑같이 넣어주면 된다.

 

이제 POST로 요청하고, body 부분을 작성해주는데 여기서 stream은 true로 해준다.

SSE로 구현을 하지는 않는다. 그렇다고 해서 stream을 false로 하게 된다면, 이 응답이 생길 때 까지 서버에 polling을 해야 하기에 그냥 openAI에서 받을 때는 SSE로 받고, 대신 그것을 Flux가 아닌 Mono로 모아주었다.

 

이렇게 온 응답을 문자열로 출력해보면

event: thread.message~~~~~

data: {}

 

이렇게 오게 되는데, 우리는 이 중에서 메시지가 모두 만들어진 event:thread.message.completed일 때를 가져오면 된다.

그렇기 때문에 이 문자열로 split을 하고, 그 다음에 있는 내용을 가져온다.

 

그러고는 event: thread.run.step.completed 뒤에 있는 내용도 필요가 없기 때문에 그 부분도 split으로 뒷부분을 잘라준다.

그러면 이제 우리가 원하는 data: {} 이런 형태가 남게 될 것이다.

 

혹시 모르니까 trim으로 공백을 잘라주고, 앞 부분의 'data: ' 부분도 removePrefix로 제거해준다.

{~~~} 이런 내용만 남게 될 테니, ObjectMapper를 사용해 위에 있는 Dto로 변환해주고, content의 value 안에 있는 문자열을 넘겨주면 된다.

 

인덱스를 가져오는 과정들에서 에러가 생길 수도 있으니 IndexOutOfBoundsException을 사용해 Error를 핸들링 해준다.

 

Intellij의 http를 사용하여 테스트를 해보니 잘 나오는 것을 볼 수 있다.

 

이렇게 잘 나오는 것을 볼 수 있다.

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

FastAPI VS WebFlux jemeter 성능 테스트  (0) 2024.10.23
JPA Paging vs JDBC 속도 비교  (0) 2024.08.10
SpringBoot 시간 설정  (0) 2024.08.09
JPA Soft Delete  (0) 2024.08.06
Docker를 활용한 Nginx로 Swagger proxy  (0) 2024.08.05
반응형

데이터의 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을 사용했다.

 

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

반응형

개발을 하다보면 기록을 남겨야 하기 때문에, 로그 말고도 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,
)

 

 

반응형

저번에는 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

 

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

+ Recent posts