반응형

https://github.com/Seungkyu-Han/Toge-do-backend

 

GitHub - Seungkyu-Han/Toge-do-backend: Toge-do 앱의 백엔드 리포지토리입니다

Toge-do 앱의 백엔드 리포지토리입니다. Contribute to Seungkyu-Han/Toge-do-backend development by creating an account on GitHub.

github.com

 

해당 프로젝트 중에 아래와 같은 data class를 object mapper를 사용하여 읽은 후 redis에 String으로 저장하는 코드가 있었다.

package vp.togedo.model.documents.personalSchedule

import org.bson.types.ObjectId
import vp.togedo.model.exception.personalSchedule.PersonalScheduleEndTimeBeforeStartTimeException
import vp.togedo.model.exception.personalSchedule.PersonalScheduleTimeIsNotRangeException

data class PersonalScheduleElement(
    val id: ObjectId = ObjectId.get(),
    val startTime: String,
    val endTime: String,
    val name: String,
    val color: String
){
    /**
     * 해당 개인 스케줄 요소의 시작 시간이 종료 시간보다 앞인지 확인
     * @return true
     * @throws PersonalScheduleEndTimeBeforeStartTimeException 종료 시간이 시작 시간보다 앞에 있음
     */
    private fun isStartTimeBefore(): Boolean{
        if(startTime.length != endTime.length ||
            startTime > endTime)
            throw PersonalScheduleEndTimeBeforeStartTimeException()
        return true
    }

    /**
     * 해당 스케줄의 시간이 범위 내에 있는지 확인
     * @param startTimeRange 시작 범위
     * @param endTimeRange 종료 범위
     * @return true
     * @throws PersonalScheduleTimeIsNotRangeException 유효한 시간 범위가 아님
     */
    private fun isTimeRange(
        startTimeRange: String,
        endTimeRange: String): Boolean{
        if(startTime.length != startTimeRange.length ||
            endTime.length != endTimeRange.length ||
            startTime !in startTimeRange..endTimeRange ||
            endTime !in startTimeRange..endTimeRange){
            throw PersonalScheduleTimeIsNotRangeException()
        }
        return true
    }

    /**
     * 유동 스케줄의 시간이 유효한지 확인
     * @return true
     * @throws PersonalScheduleTimeIsNotRangeException 유효한 시간 범위가 아님
     * @throws PersonalScheduleEndTimeBeforeStartTimeException 종료 시간이 시작 시간보다 앞에 있음
     */
    fun isValidTimeForFlexibleSchedule(): Boolean{
        return isStartTimeBefore() &&
                //00(년)_01(월)_01(일)_00(시)_00(분) ~ 99(년)_12(월)_31(일)_23(시)_59(분)
                isTimeRange(
                    startTimeRange = "0001010000",
                    endTimeRange = "9912312359",)
    }

    /**
     * 고정 스케줄의 시간이 유효한지 확인
     * @return true
     * @throws PersonalScheduleTimeIsNotRangeException 유효한 시간 범위가 아님
     * @throws PersonalScheduleEndTimeBeforeStartTimeException 종료 시간이 시작 시간보다 앞에 있음
     */
    fun isValidTimeForFixedSchedule(): Boolean{
        return isStartTimeBefore() &&
                //1(요일)_00(시)_00(분) ~ 7(요일)_23(시)_59(분)
                isTimeRange(
                    startTimeRange = "10000",
                    endTimeRange = "72359")
    }

}

 

그리고 여기에서 고정 일정을 추가하기 위해 isValidFimeForFixedSchedule만 호출을 하고 저장을 하는데, 계속 isValidTimeForFixedSchedule이 호출되어 Exception이 발생했다.

 

디버깅을 아무리 해보아도, 해당 API에서는 Fix 검증 코드만 사용하고 있었기에 버그를 잡기가 굉장히 어려웠다.

 

그러던 중 해당 에러가, redis 저장과정에서 생긴 다는 것을 알게 되었고 object mapper의 writeValueAsString에서 에러가 발생했다.

 

아니....왜 직렬화만 하라니까 함수를 호출하지?라고 생각을 하며 이유를 찾아보고 있었는데 함수의 이름 때문이었다.

 

직렬화 과정에서 함수 앞에 "is"가 붙어있으면 호출하여 해당 함수로 조건검사를 시도한다는 것이었다.

 

지금 함수명들이 IsValidTime이기 때문에 해당 함수들로 조건검사를 했던 것이다.

 

이 방법을 알고 있지 않았기 때문에 직접 조건검사를 하고 추가를 해주었고, 지금은 이 조건검사를 해제해야 했다.

 

@JsonIgnore

 

함수들에 이 annotation을 붙여주면 된다.

직렬화를 하지 않겠다는 것이다.

 

다음에는 굳이 ignore가 아닌, 직렬화를 조건검사를 한 후에 할 수도 있겠다라는 생각이 들었다.

반응형

https://github.com/Seungkyu-Han/Toge-do-backend

 

GitHub - Seungkyu-Han/Toge-do-backend: Toge-do 앱의 백엔드 리포지토리입니다

Toge-do 앱의 백엔드 리포지토리입니다. Contribute to Seungkyu-Han/Toge-do-backend development by creating an account on GitHub.

github.com

 

우선 에러가 발생한 코드는 다음과 같다.

    override fun findByUserId(userId: ObjectId): Mono<PersonalScheduleDocument> {
        return reactiveRedisTemplate.opsForValue()
            .get("$redisPrefix$userId")
            .map{
                objectMapper.readValue(it, PersonalScheduleDocument::class.java)
            }
            .switchIfEmpty(
                personalScheduleMongoRepository.findByUserId(userId)
            )
            .publishOn(Schedulers.boundedElastic())
            .doOnSuccess {
                if (it != null)
                    reactiveRedisTemplate.opsForValue()
                        .set(
                            "$redisPrefix$userId",
                            objectMapper.writeValueAsString(it),
                            personalScheduleRedisTime
                            ).block()
            }
    }

 

이렇게 Redis를 먼저 조회하고, 만약 Redis에 값이 없으면 MongoDB를 조회하는 코드이다.

해당 코드를 이용하여 테스트를 하고, Redis에 값이 있으면 MongoDB를 호출하지 않을 것이라고 생각했다.

 

@Test
        @DisplayName("Redis에 조회하려는 값이 있는 경우")
        fun findByUserIdAndRedisHaveResultReturnSuccess(){
            //given
            val userId = ObjectId.get()
            val personalSchedule = PersonalScheduleDocument(
                userId = userId,
            )
            val personalScheduleToString = objectMapper.writeValueAsString(personalSchedule)
            `when`(reactiveRedisTemplate.opsForValue())
                .thenReturn(reactiveValueOperations)

            `when`(reactiveValueOperations.get("$redisPrefix$userId"))
                .thenReturn(Mono.just(personalScheduleToString))

            `when`(personalScheduleMongoRepository.findByUserId(userId))
                .thenReturn(Mono.just(personalSchedule))

            `when`(reactiveValueOperations.set(
                "$redisPrefix$userId",
                personalScheduleToString,
                personalScheduleRedisTime
            )).thenReturn(Mono.just(true))

            //when
            StepVerifier.create(personalScheduleRepositoryImpl.findByUserId(userId))
                .expectNextMatches { it.userId == personalSchedule.userId }.verifyComplete()

            //then

            verify(personalScheduleMongoRepository, times(0)).findByUserId(userId)


        }

 

이렇게 하고 테스트를 해보았는데

이렇게 MongoDB의 findByUserId가 호출되었다고 나온다.

 

내가 코드를 작성한 것이라고 생각해서 계속 수정해 보았지만, 고칠 수 없었고 구글링을 통해 답을 얻을 수 있었다.

 

그냥 원래 그렇다고 한다...

switchIfEmpty가 지금은 데이터베이스에 접근하고 있기 때문에 접근을 최대한 하지 않아야 한다.

 

그렇기 때문에 Mono.defer를 이용하여 호출을 최대한 미루기로 했다.(defer를 왜 사용하는지 잘 몰랐는데... 여기에서 사용하는구나)

 

override fun findByUserId(userId: ObjectId): Mono<PersonalScheduleDocument> {
        return reactiveRedisTemplate.opsForValue()
            .get("$redisPrefix$userId")
            .map{
                objectMapper.readValue(it, PersonalScheduleDocument::class.java)
            }
            .switchIfEmpty(
                Mono.defer{personalScheduleMongoRepository.findByUserId(userId) }
            )
            .publishOn(Schedulers.boundedElastic())
            .doOnSuccess {
                if (it != null)
                    reactiveRedisTemplate.opsForValue()
                        .set(
                            "$redisPrefix$userId",
                            objectMapper.writeValueAsString(it),
                            personalScheduleRedisTime
                            ).block()
            }
    }

 

이렇게 해당 코드를 Mono.defer{personalScheduleMongoRepository.findByUserId(userId)}로 감싸고 테스트를 해보니

 

이렇게 테스트가 잘 통과하는 것을 볼 수 있었다.

 

switchIfEmpty 다 수정하러 가야겠다...

반응형

https://tech.kakaopay.com/post/overcome-spring-aop-with-kotlin/

 

Kotlin으로 Spring AOP 극복하기! | 카카오페이 기술 블로그

Kotlin의 문법적 기능을 사용해서 Spring AOP 아쉬운 점을 극복한 경험을 공유합니다.

tech.kakaopay.com

이번 글은 해당 기술 블로그를 참고하였습니다.

 

현재 프로젝트에 유저 서비스에는 다음과 같은 메서드들이 있다.

 

이런 메서드를 호출 할 때마다, session에서 userId를 가져와 로그로 남기고 싶다.

 

그러면 해당 메서드에 하나하나 userId를 가져와서 로그를 남기는 코드를 추가해야 할까?

이럴 경우에는 하나하나 작성하는 것에 시간도 오래 걸리고, 변경사항이 생기면 다 찾아서 바꿔야 할 것임으로 굉장히 비효율적이다.

 

이럴 때, AOP의 개념을 사용하면 된다.

 

AOP(Aspect Oriented Programming)란?

여러 모듈이나 메서드에서 공통적으로 나타나는 공통 관심사항을 추출하여 공통 로직으로 관리하는 것이다.

 

그럼 이렇게 유저 아이디를 로그로 남기자! 라는 관심사를 분리하여 처리해보자.

 

    //AOP
    implementation("org.springframework.boot:spring-boot-starter-aop:3.1.2")

 

우선 코틀린에 이런 의존성을 추가해준다.

 

그 다음엔 이런 annotation을 추가해준다.

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class LoginCheck

 

아마 위에 추가하는 annotation의 value들이 자바와는 좀 다를거다.

 

그 다음에는 해당 annotation으로 로그를 출력하는 component이다.

@Aspect
@Component
class LoginCheckAspect {

    companion object {
        private val log = LoggerFactory.getLogger(LoginCheckAspect::class.java)
    }

    @SneakyThrows
    @Around("@annotation(seungkyu.board.aop.LoginCheck)")
    fun adminLoginCheck(joinPoint: ProceedingJoinPoint):Any? {
        val startAt = LocalDateTime.now()
        log.info("Start At : $startAt")

        (joinPoint.args[0] as ServerRequest)
            .session()
            .doOnNext {
                log.info("id: {}", it.attributes["id"])
            }.subscribe()


        val proceed = joinPoint.proceed()

        val endAt = LocalDateTime.now()

        log.info("End At : $startAt")
        log.info("Logic Duration : ${Duration.between(startAt, endAt).toMillis()}ms")

        return proceed
    }
}

 

우선 클래스에 @Aspect, @Component annotation을 달아준다.

 

그 다음에는 출력하는 메서드를 작성하는데, 메서드 위에도 @annotation({해당 annotation의 패키지 위치})로 @Around를 작성해준다.

 

보통은 여기에서 다들 시간을 출력하기에 시간도 출력해보았다.

startTime을 joinPoint.proceed() 전으로, endTime을 joinPoint 후로 측정하면 해당 logic의 소요 시간을 측정할 수 있다.

 

그 다음으로는 session으로부터 id를 가져올건데, 우리가 사용하는 서비스 메서드의 parameter를 잘 봐야 한다.

 

override suspend fun getUserInfo(request: ServerRequest): ServerResponse

 

보통 이렇게 parameter가 serverRequest 하나이다.

 

그렇기에 joinPoint.args에서 0번째 Index를 가져와서 ServerRequest로 casting 해주면 그 안에서 session을 가져올 수 있다.

그렇게 가져온 session은 Mono타입이기 때문에 doOnNext로 해당 userId를 출력해줬다.

 

 

이렇게 원하는 대로 userId가 로그로 출력된 것을 볼 수 있다.

반응형

기존에 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
반응형

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들을 사용할 수 없기 때문에 각각의 함수에서 따로 주입받아주어야 한다.

반응형

최근에 서비스를 만들다보면 메일 서비스를 만들어야 하는 경우가 있었다.

앞으로도 쭉 사용을 할 거 같고 구글 메일에서 설정하는 부분을 자주 까먹어서 이번에 기록해두려고 한다.

 

  • 구글 이메일 설정

일단 서버를 구축하기 전에 구글 계정을 설정해주어야 한다.

 

구글의 계정설정 -> 보안

 

여기서 2단계 인증을 한 후 앱 비밀번호를 만들어준다.

 

나중에 스프링부트에서 사용해야 하기 때문에 이 때 만들어진 앱 비밀번호는 어디에다가 기록해두도록 한다.

 

그리고 Gmail 설정 -> 전달 및 POP/IMAP에서

IMAP 사용으로 변경 후 저장해준다.

 

  • SpringBoot 설정

이제 스프링부트에서 메일을 보낼 수 있는 서비스를 만들어보자.

 

일단 application.yml에 계정에 대한 정보를 입력한다.

 

spring.mail.host -> 서버 호스트

spring.mail.port -> 서버 포트 번호

spring.mail.username -> 서버 아이디

spring.mail.password -> 발급받은 앱 비밀번호 12자리

 

아래는 starttls 설정으로 안하면 관련된 에러가 발생할 것이다.

 

이제 전송하는 코드를 작성해보자.

일단 테스트 용으로 간단하게 API로 이메일을 받고 테스트 내용을 보내는 코드를 작성해 볼 것이다.

 

보낼 때는 javaMailSender를 이용한다.

 

@RestController
@RequiredArgsConstructor
class EmailController(
    val javaMailSender: JavaMailSender) {


    @GetMapping("/api/mail")
    fun send(email: String){
        val mimeMessage = javaMailSender.createMimeMessage()
        val helper = MimeMessageHelper(mimeMessage, true, "UTF-8")
        helper.setTo(email)
        helper.setSubject("테스트 용입니다.")
        helper.setText("테스트 드래곤입니다.")
        javaMailSender.send(mimeMessage)
    }
}

 

이렇게 작성을 끝내고 이제 Swagger로 테스트를 해보자.

 

 

이렇게 전송을 하면

 

 

잘 오는 것을 확인할 수 있다.

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

Nginx로 Swagger Proxy_pass  (1) 2024.07.22
CompletableFuture 적용으로 성능 튜닝  (0) 2024.03.09
Springboot와 DialogFlow 연동 - API  (0) 2024.01.17
EC2에 Java 17 설치  (1) 2023.12.29
EC2 memory swap  (0) 2023.12.29

+ Recent posts