반응형

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 다 수정하러 가야겠다...

반응형

Junit에서 redis를 @MockBean으로 만들어서 사용하던 중 에러가 발생했다.

사용한 코드는 다음과 같다.

 

        @Test
        @DisplayName("유효한 코드를 입력시 true를 반환")
        fun checkValidCodeReturnTrue(){
            //given
            val email = "${UUID.randomUUID()}@test.com"
            val code = UUID.randomUUID().toString()

            //when
            `when`(reactiveRedisTemplate.opsForValue().get("$redisKeyPrefix:$email"))
                .thenReturn(Mono.just(code))

            //then
            StepVerifier.create(emailService.checkValidEmail(email, code))
                .expectNext(true)
                .verifyComplete()
        }

 

Redis를 확인해보고 해당 인증번호가 있는지 확인하는 테스트코드이다.

 

여기서 다음과 같은 에러가 발생했다.

Cannot invoke "org.springframework.data.redis.core.ReactiveValueOperations.get(Object)" because the return value of "org.springframework.data.redis.core.ReactiveRedisTemplate.opsForValue()" is null

 

 

reactiveRedisTemplate은 opsForValue 메서드를 호출하면 ReactiveValueOperations를 반환하는데, 이 객체가 null이라는 것이다.

redis를 mocking하고 메서드를 호출 할 때는 바로 get을 호출하는 게 아니라, 그 중간에 ReactiveValueOperations도 Mocking하고 여기서 get을 호출해야 하는 것이다.

 

        `when`(reactiveRedisTemplate.opsForValue())
            .thenReturn(reactiveValueOperations)

        `when`(reactiveValueOperations.get("$redisKeyPrefix:$email"))
            .thenReturn(Mono.just(code))

 

 

이렇게 2단계로 when을 나누어주는 방법으로 테스트에 성공했다.

반응형

Webflux 토이프로젝트로 500개의 쿠폰을 발급하는 서비스를 만들고, 부하테스트를 하고 있었다.

 

분명 발급 제한 수와 실제 발급 수가 200으로 같지만

 

해당 쿠폰으로 무려 40000개가 발급이 되어있었다.

 

운영체제와 데이터베이스를 공부할 때 나오는 critical section 문제이다.

 

아래와 같이 한 유저가 조회하고 수량을 증가하여 저장하기 전에, 다른 유저가 중간에 끼어들어 조회하면 비록 2명 이상이 조회하더라도 수량은 1만 증가하게 되는 것이다.

 

그렇기 때문에 조회하고 저장하는 그 부분을 critical section으로 보호하여, 다른 쓰레드의 개입이 없도록 해야한다.

 

이 방법을 해결하기 위해 외부 동시성 제어 메커니즘을 사용해보려 한다.

WebFlux를 사용하기에 RedissonReactiveClient를 사용해보겠다.

 

우선 Bean을 만들어주자.

gradle에 다음 의존성을 추가해준다.

implementation("org.redisson:redisson-spring-boot-starter:3.16.6")

 

@Configuration
class RedissonConfig(
    @Value("\${spring.data.redis.host}") private val host: String,
    @Value("\${spring.data.redis.port}") private val port: Int,
) {

    @Bean
    fun redissonClient(): RedissonClient {
        val config = Config()
        val serverConfig: SingleServerConfig = config.useSingleServer()
        serverConfig.address = "redis://${host}:${port}"
        return Redisson.create(config)
    }

    @Bean
    fun redissonReactiveClient(): RedissonReactiveClient = redissonClient().reactive()
}

 

host, port를 넣어서 RedissonClient를 만들어주고, 그걸 바탕으로 redissonReactiveClient도 만들어준다.

redissonClient().reactive()로 reactive하게 만들어 줄 수 있다.

Redis 설정을 해주었더라도, 여기에도 redis 정보를 넣어주어야 한다.

 

이제 사용하는 방법이다.

@Service
class CouponIssueService(
    private val couponRepository: CouponRepository,
    private val couponIssueRepository: CouponIssueRepository,
    private val redissonReactiveClient: RedissonReactiveClient
) {

    val lock: RLockReactive = redissonReactiveClient.getLock("coupon_lock")

    @Transactional
    suspend fun issueByLock(couponId: String, userId: String): CouponIssue? {
        val couponObjectId = ObjectId(couponId)
        val userObjectId = ObjectId(userId)


        return lock.lock(10, TimeUnit.SECONDS)
            .then(Mono.defer {
                findCoupon(couponObjectId)
                    .flatMap { coupon ->
                        coupon.issue()
                        couponRepository.save(coupon)
                            .then(saveCouponIssue(couponId = couponObjectId, userId = userObjectId))
                    }
                    .doFinally {
                        lock.unlock()
                    }
            }).awaitSingle()
    }
}

 

우선 해당 부분을 lock 할 수 있는 key를 만들어야 한다.

getLock()으로 해당 부분을 lock, unlock 할 수 있는 키를 만들 수 있다.

 

lock 하는 방법은 해당 key에서 lock 메서드를 호출하는 것이다.

lock().then()으로 lock 한 후 실행할 작업들을 명시 할 수 있다.

 

작업이 다 되었다면 .doFinally()를 사용하여 unlock해서 해당 부분을 풀어주고 나와야 한다.

 

여기서 중요한 점은 lock하는 쓰레드와 unlock하는 쓰레드가 같아야 한다는 것이다.

코루틴이나 리액티브 프로그래밍 등으로 쓰레드가 바뀐다면 에러가 발생 할 것이다.

 

정확한 개수로 발급하는 지 확인해보도록 하자.

 

 

목표한 수량은 200개였고

 

발급된 수량을 확인해보니

정확하게 200개가 발급된 것을 볼 수 있다.

 

물론 이렇게하면 critical section을 보호해서 정확한 발급을 보장 할 수 있지만, 해당 부분을 한 쓰레드씩 사용하게 만들기 때문에 멀티쓰레딩의 효율이 줄고, 처리량이 줄어든다.

필요한 부분에만 최소한으로 사용하도록 하자.

반응형

Redis Sentinel는 Redis에서 장애가 생길 경우를 대비하여, Redis를 복제(?)해두는 기법이라고 생각하면 된다.

이러한 구조로 이루어지며, replica들은 master redis를 복제하다가, master에 장애가 생기게 되면 본인이 master가 되어 장애에 대비할 수 있도록 한다. (그 중 하나가 master로 승격한다)

 

https://hub.docker.com/_/redis

 

redis - Official Image | Docker Hub

Quick reference Supported tags and respective Dockerfile links 8.0-M02-alpine, 8.0-M02-alpine3.20⁠8.0-M02, 8.0-M02-bookworm⁠7.4.1, 7.4, 7, latest, 7.4.1-bookworm, 7.4-bookworm, 7-bookworm, bookworm⁠7.4.1-alpine, 7.4-alpine, 7-alpine, alpine, 7.4.1-al

hub.docker.com

Redis의 공식 이미지 페이지에 redis.conf로 설정하는 방법이 나와있다.

한 번 따라해보도록 하겠다.

 

redis.conf라는 파일을 만들고 master redis에 대한 정보를 작성하였다.

replicaof redis 6379

 

docker-compose.yml로 도커를 실행할텐데, 같은 디렉토리에 만들어서 넣어주면 된다.

 

그렇게 docker-compose.yml은 다음과 같이 작성하였다.

version: '3.8'

services:
  redis:
    container_name: redis
    image: redis:7.4-alpine
    ports:
      - '12042:6379'
    networks:
      - monitoring
  
  replica1:
    container_name: replica1
    image: redis:7.4-alpine
    ports:
      - '12043:6379'
    networks:
      - monitoring
    volumes:
      - ./redis.conf:/usr/local/etc/redis/redis.conf
    command: redis-server /usr/local/etc/redis/redis.conf
  
  replica2:
    container_name: replica2
    image: redis:7.4-alpine
    ports:
      - '12044:6379'
    networks:
      - monitoring
    volumes:
      - ./redis.conf:/usr/local/etc/redis/redis.conf
    command: redis-server /usr/local/etc/redis/redis.conf

networks:
  monitoring:

 

우선 모든 redis 컨테이너들을 같은 네트워크로 연결해주고, 포트는 각각 다르게 나올 수 있도록 설정해준다.

그리고 volumes를 사용해 redis.conf를 우리가 작성한 redis.conf로 바꿔준다.

마지막으로 해당 redis conf를 사용할 수 있도록 redis-server /usr/local/etc/redus/redis.conf를 명령어를 사용하여 설정을 적용해준다.

 

이렇게 하면 적용이 완료되었다.

 

우선 master redis를 연결하여 redis-cli에서 다음 명령어를 입력해보자.

info replication

 

그러면 아래와 같이 slave가 2개 연결되었다고 나온다.

 

값은 동기화가 되는지 확인해보자

SET key1 100
SET key2 200

master에 key1, key2를 설정해주고 slave에서 keys로 확인해보자.

 

이렇게 replica1의 커널에서도 값이 생성된 것을 볼 수 있다.

(여기서 replica된 redis에는 값을 입력할 수 없다.)

 

앞으로는 장애가 생겼을 때, 데이터의 손실을 막기 위해서 replica 하나 정도는 설정해두도록 해야겠다.

 

 

반응형

Redis의 pub-sub 구조를 공부하면서, 이런 비동기 pub-sub 구조로 별도의 서버에서 알림을 보내고, 받는 서버를 만들 수 있을 거 같다는 생각이 들었다.

 

Redis와 SSE를 더 연습하기 위해 바로 만들어보았다.

 

우선 Redis의 Pub-Sub은 비동기 message queue 방식으로

이렇게 Redis에 publish하면 subscribe하고 있는 서버들에게 데이터를 전송하는 구조이다.

하지만 redis에 있는 데이터는 남아있지 않고 바로 휘발되기에 때에 따라서는 Kafka와 같은 message queue 구조가 더 적합할 수도 있다.

이렇게 Redis를 사용하면 send server에서 직접적으로 receive server들에게 데이터를 전송할 필요가 없기 때문에, 느슨한 연결관계를 만들어 줄 수 있기에 만약 receive server를 추가하더라도 send server에서는 변경할 필요 없이 해당 receive server가 redis를 subscribe하기만 하면 된다.

우선 각각의 send server, receive server를 20000, 20001 포트로 구성했다.

 

사용한 dependencies들은 다음과 같다.

    implementation("org.springframework.boot:spring-boot-starter-webflux:3.3.5")

    //COROUTINE
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-rx3")

    implementation("io.lettuce:lettuce-core:6.5.0.RELEASE")

    compileOnly("org.projectlombok:lombok:1.18.34")
    annotationProcessor("org.projectlombok:lombok:1.18.34")
    implementation("org.springframework.boot:spring-boot-starter-data-redis")

 

  • Publish Server

우선 상대적으로 쉬운 publish server부터 만들어보겠다.

 

아래와 같이 redis 정보를 입력해주고

spring:
  data:
    redis:
      port: 12042

server:
  port: 20000

 

ReactiveRedisTemplate에 convertAndSend 함수를 사용하여 해당 채널로 메시지를 전송할 수 있다.

 


@RestController
class SendController(
    private val redisTemplate: ReactiveRedisTemplate<String, String>,
    private val objectMapper: ObjectMapper
) {

    data class MessageDto(
        val message: String,
        val user: String,
        val chatRoom: String
    )

    @PostMapping("/send")
    suspend fun sendMessage(@RequestBody messageDto: MessageDto) {
        redisTemplate.convertAndSend(
            "users:message",
                objectMapper.writeValueAsString(messageDto)
        ).subscribe()
    }
}

 

Post로 메시지를 받고, "users:message"라는 채널로 해당 메시지를 직렬화하여 전송한다.

Publish Server에는 이렇게 전송만하면 된다.

 

  • Subscribe Server

이제 어려운 Subsribe server를 만들어보자.

우선 Redis의 해당 채널을 구독해야 한다.

해당 채널의 정보는 Configuration에 작성한다.

 

@Configuration
class RedisConfig(
    private val redisConnectionFactory: RedisConnectionFactory,
    private val messageListenerService: MessageListenerService
) {

    @Bean
    fun redisMessageListenerContainer(): RedisMessageListenerContainer {
        val container = RedisMessageListenerContainer()
        container.connectionFactory = redisConnectionFactory
        container.addMessageListener(messageListenerService,
            ChannelTopic("users:message")
        )
        return container
    }
}

 

이런 식으로 채널을 구독한다.

addMessageListener를 더 작성하여, 2개 이상의 채널을 구독할 수도 있다.

 

이제 MessageListener를 상속받은 MessageListenerService를 구현해야 한다.

 

우선 이렇게만 작성해보고, 채널에서 메시지가 오는지 확인해본다.

@Service
@Slf4j
class MessageListenerService(
    private val objectMapper: ObjectMapper
): MessageListener {

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

    override fun onMessage(message: Message, pattern: ByteArray?) {
        log.info("Received a message: {}, from channel : {}", message.body.toString(Charsets.UTF_8), message.channel.toString(Charsets.UTF_8))
    }
}

 

이렇게 작성하고 PublishServer에서 POST 요청을 해보니

 

이런 식으로 메시지 큐에서 값을 잘 전달받는 것을 볼 수 있었다.

 

이제 SSE를 요청할 Controller를 만들어보자

@RestController
class ReceiveController(
    private val messageListenerService: MessageListenerService
) {

    @GetMapping("/receive", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
    fun receive(@RequestParam chatRoom: String): Flux<ServerSentEvent<String>>{
        return messageListenerService.registerSink(chatRoom)
            .map{
                ServerSentEvent.builder<String>()
                    .data(it)
                    .comment("This is notification")
                    .build()
        }
    }
}

 

이렇게 messageListenerService를 주입받는 컨트롤러를 만든다.

Header를 TEXT_EVENT_STREAM_VALUE로 지정해서 SSE를 명시해준다.

그리고는 sink를 등록하고, 거기서부터 받는 메시지를 계속 ServerSentEvent로 보내준다.

 

다시 MessageListenerService로 돌아가서 수정해보자.

@Service
@Slf4j
class MessageListenerService(
    private val objectMapper: ObjectMapper
): MessageListener {

    companion object {
        private val log = LoggerFactory.getLogger(MessageListenerService::class.java)
    }
    private val sinksMap = mutableMapOf<String, Sinks.Many<String>>()

    data class MessageDto(
        val message: String,
        val user: String,
        val chatRoom: String
    )

    override fun onMessage(message: Message, pattern: ByteArray?) {
        log.info("Received a message: {}, from channel : {}", message.body.toString(Charsets.UTF_8), message.channel.toString(Charsets.UTF_8))
        val messageDto = objectMapper.readValue(message.body, MessageDto::class.java)
        sinksMap[messageDto.chatRoom]?.tryEmitNext(messageDto.message)
    }

    fun registerSink(chatRoom: String): Flux<String> {
        val sink = sinksMap.computeIfAbsent(chatRoom){
            Sinks.many().unicast().onBackpressureBuffer()
        }

        return sink.asFlux()
            .doOnCancel{
                log.info("Connection is closed")
                sinksMap.remove(chatRoom)
            }
    }
}

 

registerSink에서는 map을 사용해 요청한 chatRoom으로 sink를 등록한다.

doOnCancel을 사용해 연결이 끊어지면 map에서 삭제되도록 해두었다.

 

onMessage에서는 수정하여, 메시지 큐에서 메시지가 도착하면 chatRoom을 찾아 메시지를 전송해준다.

 

이렇게 모든 코드를 완성했다.

intellij http client를 사용하여 테스트 해보도록 하자.

 

receive 요청

GET http://localhost:20001/receive?chatRoom=1

 

send 요청

POST http://localhost:20000/send
Content-Type: application/json

{
  "message": "hello",
  "user": "seungkyu",
  "chatRoom": "1"
}

 

이렇게 작성하고, receive부터 한 후 메시지를 보내보았다.

 

결과로는

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

 

send에서 chatRoom을 2로 바꾸면, chatRoom에 해당하는 sink가 없기 때문에 Event가 오지 않는 것도 볼 수 있었다.

반응형

최근에 Redis를 공부하면서, Redis가 가지는 장점인 "접근 속도가 빠르다"를 활용하여 데이터베이스의 캐시를 만들어보려 한다.

 

사실 많이 사용할 것 같지는 않지만, 그래도 WebFlux와 Redis에 대해 더 잘 이해할 수 있을 거 같아서 한 번 시도해보고 정리하려 한다.

 

    implementation("io.lettuce:lettuce-core:6.5.0.RELEASE")
    implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive")
    implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")
    implementation("org.springframework.boot:spring-boot-starter-webflux")

 

우선 위의 의존성들을 추가 해 준 후

 

사용한 Entity와 Repository는 다음과 같이 작성했다.

Entity

@Document("user")
data class User(
    @Id
    var id: ObjectId? = null,
    var email: String,
    var name: String,
    @CreatedDate
    var createdAt: LocalDateTime? = null,
    @LastModifiedDate
    var updatedAt: LocalDateTime? = null,
)

 

Repository

interface UserRepository: ReactiveMongoRepository<User, ObjectId> {
}

 

간단하게 userId를 검색하면 이메일이 나오는 서버를 만들어 보았다.

 

    @GetMapping("/users/{id}")
    fun getUserEmail(@PathVariable id: String): Mono<String> {

        return Mono.just("result")
    }

 

이렇게 /users/{id}의 주소로 요청하면 값이 반환되도록 작성했다.

 

우선 가장 처음으로 만든 코드는

    return redisTemplate.opsForValue().get("users:$id")
            .map{
                it
            }.switchIfEmpty {
                userRepository.findById(ObjectId(id)).map{
                    it.email
                }
            }

 

이렇게 작성했다.

redis를 먼저 살펴 본 후에 있으면 그 값을, 없으면 다시 데이터베이스에 접근하여 값을 가져온다.

 

일단 테스트를 해보면

Redis에 데이터가 있는 경우는

 

이렇게 Redis에서, Redis의 값을 지우면

이렇게 Mongodb에서 값을 가져오게 된다.

 

근데 여기서 든 생각은, 비동기를 사용하기 때문에 동시에 접근해서 먼저 온 값을 넣는 방법은 없을까? 였다.

이렇게 사용하는 방법은 만약 cache miss가 일어난다면, 바로 데이터베이스에 접근하는 것보다 못하기 때문이다.

 

그래서 Mono.firstWithSignal을 사용했다.

firstWithSignal은 먼저 값이 나오는 Mono를 사용하는 메서드이다.

 

최종으로는 이렇게 수정했다.

    @GetMapping("/users/{id}")
    fun getUserEmail(@PathVariable id: String): Mono<String> {

        val cacheMono = redisTemplate.opsForValue().get("users:$id")
            .mapNotNull {
                it
            }

        val dbMono = userRepository.findById(ObjectId(id)).map{
            it.email
        }

        return Mono.firstWithSignal(cacheMono,dbMono)
    }

 

이번에도 테스트를 해보면

Redis에 값이 있으면

이렇게 Redis에서 값을 가져오고

 

Redis에 값이 없다면

이렇게 Mongodb에서 값을 가져오는 것을 볼 수 있다.

반응형

저번 글에 Redis를 사용해 조회수를 구현했었다.

하지만 저번 로직은 가장 큰 문제가 있다.

해당 API를 계속 누르면 카운트가 계속 올라간다, 이렇게 만들면 한 유저가 그냥 페이지를 계속 새로고침 하면 조회수가 계속 올라가게 된다.

 

그렇기에 이것을 막기 위해 유저마다 조회수 올라가는 것에 대기 시간을 주기로 했다.

 

처음에는 HttpServletRequest에서 유저들의 IP를 받아오고 해당 IP들을 redis에 저장을 한 뒤 해당 IP가 존재하면 카운트를 하지 않는 방법을 생각했었다.

 

하지만 추천하지 않는 방법이라고 한다.

우선 사용자의 IP가 변할 수 있다. IP는 유동적으로 변할텐데, 만약 바뀐다면 같은 유저라도 카운트가 될 수 있기 때문이다.

그리고 Redis에 용량에 부담이 가게 되며, 유저들의 IP를 가지고 있는 것 또한 보안에 문제가 될 수 있다고 한다.

 

그래서 생각한 방법이 처음 접속을 하면 해당 유저에게 쿠키를 넣어주고 만약 쿠키를 체크했을 때 쿠키가 있다면 조회수를 증가시키지 않는 방법이다.

 

    public ResponseEntity<AllHitRes> hit(
            @ApiIgnore @CookieValue(value = "hitCookie", defaultValue = "0") Integer hitCookie,
            HttpServletResponse httpServletResponse){
        return allService.getHit(hitCookie, httpServletResponse);
    }

이렇게 Controller에서 쿠키를 가져온다.

 

    private final RedisTemplate<String, String> redisTemplate;

    @Transactional
    public ResponseEntity<AllHitRes> getHit(Integer hitCookie, HttpServletResponse httpServletResponse){


        if(hitCookie == 0) IncrementTodayAndSetCookie(httpServletResponse);

        Long today = Long.parseLong(Objects.requireNonNull(redisTemplate.opsForValue().get("today")));
        Long total = Long.parseLong(Objects.requireNonNull(redisTemplate.opsForValue().get("total")));

        return new ResponseEntity<>(new AllHitRes(today, today + total), HttpStatus.OK);
    }

    private void IncrementTodayAndSetCookie(HttpServletResponse httpServletResponse){
        redisTemplate.opsForValue().increment("today");

        Cookie cookie = new Cookie("hitCookie", "1");
        cookie.setMaxAge(900);
        httpServletResponse.addCookie(cookie);
    }

Service에서 체크를 하고 만약 hitCookie가 default 값인 0이라면 15분 유효의 쿠키를 넣어주고 today의 값을 1 증가시켜 준다.

 

이렇게 구현을 하니 쿠키를 직접 지우지 않는 이상 중복된 접속은 조회수가 증가를 하지 않게 되었다.

'블로그 개발 프로젝트' 카테고리의 다른 글

Redis를 사용한 조회수 구현  (0) 2023.08.10
Redis ERR value is not an integer or out of range  (0) 2023.08.09
Nginx에 페이지 연결하기  (0) 2023.08.07
EC2에 Nginx 초기 설정  (0) 2023.08.05
ExceptionHandler  (0) 2023.07.28
반응형

저번 글에서 작성한 것처럼 SpringBoot와 Redis를 연결하고 조회수 기능을 구현할 것이다.

 

저번에 RedisTemplate까지 작성을 했었다.

 

우선 계획은 오늘의 조회수와 총조회수를 반환하는 API를 만들고 해당 API를 호출할 때마다 오늘의 조회수가 +1 될 수 있도록 만들 것이다.

 

우선 조회수가 반환될 수 있는 Dto를 작성해준다.

@Data
@AllArgsConstructor
public class AllHitRes {

    @ApiModelProperty(
            value = "블로그 오늘 조회수",
            example = "1"
    )
    private Long today;

    @ApiModelProperty(
            value = "블로그 전체 조회수",
            example = "1020303"
    )
    private Long total;
}

 

그러고는 Redis에서 데이터를 가져오고 오늘의 조회수를 +1 해주는 Service를 구현해 보자.

    private final RedisTemplate<String, String> redisTemplate;

    @Transactional
    public ResponseEntity<AllHitRes> getHit(Integer hitCookie, HttpServletResponse httpServletResponse){
		
        redisTemplate.opsForValue().increment("today");

        Long today = Long.parseLong(Objects.requireNonNull(redisTemplate.opsForValue().get("today")));
        Long total = Long.parseLong(Objects.requireNonNull(redisTemplate.opsForValue().get("total")));
	
        return new ResponseEntity<>(new AllHitRes(today, today + total), HttpStatus.OK);
    }

 

increment를 이용하여 today를 +1 하고 그 today와 total을 가져온다.

값을 클라이언트로 보낼 때는 today와 today + total(금일의 조회수까지 합치기 위함)로 보낸다.

 

이제 하루에 한 번 redis에 모아둔 값을 DB로 보내야 한다.

이 방법은 자바의 스케줄러를 사용한다.

@Component
@AllArgsConstructor
@Slf4j
public class RedisToDbScheduler {

    private RedisTemplate<String, String> redisTemplate;
    private HitRepository hitRepository;

    @Scheduled(cron = "0 0 0 * * *")
    @Transactional
    public void saveHitToDB(){

        Long today = Long.parseLong(Objects.requireNonNull(redisTemplate.opsForValue().get("today")));
        Long total = Long.parseLong(Objects.requireNonNull(redisTemplate.opsForValue().get("total")));

        HitEntity hitEntity = new HitEntity(today, total);

        redisTemplate.opsForValue().set("total", String.valueOf(today + total));
        redisTemplate.opsForValue().set("today", String.valueOf(0));

        log.info("today: {}. total: {}", today, today + total);

        hitRepository.save(hitEntity);
    }
}

@Component를 달아주고 스케줄러로 사용할 메서드에 @Scheduled로 시간을 명시해 준다.

당연하게 @Component를 달아주어야 Spring에게 관리될 수 있다.

 

@Scheduled에는 cron을 사용하여 서버시간으로 0시 0분 0초에 해당 메서드가 실행될 수 있도록 만들었다.

저 메서드에서는 금일의 조회수와 총조회수를 가져와서 DB에 기록해 두고 기록해 두고

금일 조회수와 총조회수를 합쳐 총 조회수를 갱신을 한 후 금일 조회수를 다시 0으로 만들어준다.

 

이렇게 하면 하루에 한 번 메서드가 실행이 되어 redis와 db를 관리할 수 있다.

'블로그 개발 프로젝트' 카테고리의 다른 글

조회수 중복 유저 제거  (0) 2023.08.10
Redis ERR value is not an integer or out of range  (0) 2023.08.09
Nginx에 페이지 연결하기  (0) 2023.08.07
EC2에 Nginx 초기 설정  (0) 2023.08.05
ExceptionHandler  (0) 2023.07.28
반응형

블로그의 조회수를 저장하고 가져올 수 있도록 해야 할 거 같아서 해당 기능을 Redis로 사용해보려 했다.

 

당연히 redis를 build.gradle에 추가하고

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

RedisConfig를 작성해줬다.

package cobo.blog.global.Config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@EnableRedisRepositories
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory(){
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}

여기까지야 뭐... 그냥 다른 블로그들 찾아가며 작성했다.

 

당연히 괜찮을 줄 알았다....

 

이렇게 template을 작성하고

    public void RedisTest(){
        ValueOperations<String, Integer> stringIntegerValueOperations = redisTemplate.opsForValue();

        if(stringIntegerValueOperations.get("count") == null)
            stringIntegerValueOperations.set("count", 1);

        Long incrementedValue = stringIntegerValueOperations.increment("count");
        log.info("after increment: " + incrementedValue);
    }

이 코드를 실행해보았다.

 

하지만 이런 에러가 발생

난 분명히 1을 넣었는데, 증가할 수 없는 값이라고 한다.

 

redis로 바로 가서 확인해보았다.

내가 넣은 값이 이렇게 변환되어 저장이 되었고, 그렇기에 증가할 수 없었던 것이다.

 

이 부분을 바꾸고 싶다면 RedisTemplate을 다시 작성해야 한다.

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }

저장 하는 중에 Serialize하는 방법을 바꾸어 주면 해결이 된다.

 

    public void RedisTest(){
        ValueOperations<String, String> stringIntegerValueOperations = redisTemplate.opsForValue();

        if(stringIntegerValueOperations.get("count") == null)
            stringIntegerValueOperations.set("count", "1");

        Long incrementedValue = stringIntegerValueOperations.increment("count");
        log.info("after increment: " + incrementedValue);
    }

이렇게 코드르 수정하고 실행해보면 

잘 작동하는 것을 볼 수 있다.

'블로그 개발 프로젝트' 카테고리의 다른 글

조회수 중복 유저 제거  (0) 2023.08.10
Redis를 사용한 조회수 구현  (0) 2023.08.10
Nginx에 페이지 연결하기  (0) 2023.08.07
EC2에 Nginx 초기 설정  (0) 2023.08.05
ExceptionHandler  (0) 2023.07.28

+ Recent posts