Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.snackgame.server.common.domain.BaseEntity
import com.snackgame.server.game.metadata.Metadata
import com.snackgame.server.game.session.exception.ScoreCannotBeDecreased
import java.time.Duration
import java.time.LocalDateTime
import javax.persistence.Embedded
import javax.persistence.GeneratedValue
import javax.persistence.GenerationType
Expand Down Expand Up @@ -35,8 +36,8 @@ abstract class Session(

abstract val metadata: Metadata

fun pause() = sessionState.pause()
fun resume() = sessionState.resume()
fun pause(): LocalDateTime = sessionState.pause()
fun resume(): LocalDateTime = sessionState.resume()
fun end() = sessionState.end()
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package com.snackgame.server.game.session.domain
import com.snackgame.server.game.session.domain.SessionStateType.EXPIRED
import com.snackgame.server.game.session.domain.SessionStateType.IN_PROGRESS
import com.snackgame.server.game.session.domain.SessionStateType.PAUSED
import com.snackgame.server.game.session.exception.SessionNotPausedException
import com.snackgame.server.game.session.exception.SessionExpiredException
import com.snackgame.server.game.session.exception.SessionNotInProgressException
import com.snackgame.server.game.session.exception.SessionNotPausedException
import java.time.Duration
import java.time.LocalDateTime
import java.time.LocalDateTime.now
Expand All @@ -31,20 +31,22 @@ class SessionState(private val timeLimit: Duration) {
return IN_PROGRESS
}

fun pause() {
fun pause(): LocalDateTime {
validateInProgress()
with(now()) {
pausedAt = this
expiresAt = this + TTL
return now().also {
pausedAt = it
expiresAt = it + TTL
}
}

fun resume() {
fun resume(): LocalDateTime {
validatePaused()
val pausedDuration = Duration.between(pausedAt, now())
startedAt += pausedDuration
expiresAt = startedAt + timeLimit
pausedAt = null
return now().also { resumedAt ->
val pausedDuration = Duration.between(pausedAt, resumedAt)
startedAt += pausedDuration
expiresAt = startedAt + timeLimit
pausedAt = null
}
}

fun end() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ data class SessionPauseEvent(
) : SessionStateEvent {

companion object {
fun of(session: Session): SessionPauseEvent {
fun of(session: Session, occurredAt: LocalDateTime): SessionPauseEvent {
return SessionPauseEvent(
session.sessionId,
session.ownerId,
LocalDateTime.now()
occurredAt
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ data class SessionResumeEvent(
) : SessionStateEvent {

companion object {
fun of(session: Session): SessionResumeEvent {
fun of(session: Session, occurredAt: LocalDateTime): SessionResumeEvent {
return SessionResumeEvent(
session.sessionId,
session.ownerId,
LocalDateTime.now()
occurredAt
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ open class SnackgameBiz(
private fun calculateMultiplier(streakWithFever: StreakWithFever): Int {
val serverFever = feverTime ?: return NORMAL_MULTIPLIER

if (streakWithFever.clientIsFever && serverFever.isFeverTime(streakWithFever.occurredAt)) {
if (streakWithFever.clientIsFever &&
serverFever.isFeverTime(streakWithFever.occurredAt) &&
serverFever.canApplyFeverMultiplier()
) {
serverFever.incrementFeverStreak()
return FEVER_MULTIPLIER
}
return NORMAL_MULTIPLIER
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ open class SnackgameBizV2(
private fun calculateMultiplier(streakWithFever: StreakWithFever): Int {
val serverFever = feverTime ?: return NORMAL_MULTIPLIER

if (streakWithFever.clientIsFever && serverFever.isFeverTime(streakWithFever.occurredAt)) {
if (streakWithFever.clientIsFever &&
serverFever.isFeverTime(streakWithFever.occurredAt) &&
serverFever.canApplyFeverMultiplier()
) {
serverFever.incrementFeverStreak()
return FEVER_MULTIPLIER
}
return NORMAL_MULTIPLIER
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class SnackgameBizService(
fun pause(memberId: Long, sessionId: Long): SnackgameResponse {
val game = snackGameBizRepository.getBy(memberId, sessionId)

game.pause()
game.pause() // pause 시간 반환하지만 이 Service는 Event 발행 안함

return SnackgameResponse.of(game)
}
Expand All @@ -60,7 +60,7 @@ class SnackgameBizService(
fun resume(memberId: Long, sessionId: Long): SnackgameResponse {
val game = snackGameBizRepository.getBy(memberId, sessionId)

game.resume()
game.resume() // resume 시간 반환하지만 이 Service는 Event 발행 안함

return SnackgameResponse.of(game)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.snackgame.server.game.snackgame.biz.service


import com.snackgame.server.game.session.event.SessionEndEvent
import com.snackgame.server.game.snackgame.biz.domain.SnackgameBizV2
import com.snackgame.server.game.snackgame.biz.domain.SnackgameBizV2Repository
import com.snackgame.server.game.snackgame.biz.domain.getBy
Expand Down Expand Up @@ -40,7 +39,7 @@ class SnackgameBizV2Service(
fun pause(memberId: Long, sessionId: Long): SnackgameResponse {
val game = snackgameBizRepository.getBy(memberId, sessionId)

game.pause()
game.pause() // pause 시간 반환하지만 이 Service는 Event 발행 안함

return SnackgameResponse.of(game)
}
Expand All @@ -49,7 +48,7 @@ class SnackgameBizV2Service(
fun resume(memberId: Long, sessionId: Long): SnackgameResponse {
val game = snackgameBizRepository.getBy(memberId, sessionId)

game.resume()
game.resume() // resume 시간 반환하지만 이 Service는 Event 발행 안함

return SnackgameResponse.of(game)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ open class Snackgame(
private fun calculateMultiplier(streakWithFever: StreakWithFever): Int {
val serverFever = feverTime ?: return NORMAL_MULTIPLIER

if (streakWithFever.clientIsFever && serverFever.isFeverTime(streakWithFever.occurredAt)) {
if (streakWithFever.clientIsFever &&
serverFever.isFeverTime(streakWithFever.occurredAt) &&
serverFever.canApplyFeverMultiplier()
) {
serverFever.incrementFeverStreak()
return FEVER_MULTIPLIER
}
return NORMAL_MULTIPLIER
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,34 @@ class FeverTime(
val feverStartedAt: LocalDateTime,
var feverEndAt: LocalDateTime,
var lastPausedAt: LocalDateTime? = null,
var paused: Boolean? = false
var paused: Boolean? = false,
var feverStreakCount: Int? = 0
) {

fun isFeverTime(occurredAt: LocalDateTime): Boolean {
fun isFeverTime(occurredAt: LocalDateTime, serverNow: LocalDateTime = LocalDateTime.now()): Boolean {
// 1. 서버 시간 기준 합리성 검증 (치팅 방지)
if (occurredAt.isBefore(serverNow.minus(MAX_PAST_ALLOWED)) ||
occurredAt.isAfter(serverNow.plus(MAX_FUTURE_ALLOWED))
) {
return false
}

// 2. 피버타임 범위 검증 (서버 연산 및 네트워크 지연 고려)
val validEndAt = feverEndAt.plus(BUFFER_DURATION)
val validStartedAt = feverStartedAt.minus(BUFFER_DURATION)

if (occurredAt.isBefore(validStartedAt) || occurredAt.isAfter(feverEndAt)) {
if (occurredAt.isBefore(validStartedAt) || occurredAt.isAfter(validEndAt)) {
return false
}
return true
}

fun canApplyFeverMultiplier(): Boolean = (feverStreakCount ?: 0) < MAX_FEVER_STREAKS

fun incrementFeverStreak() {
feverStreakCount = (feverStreakCount ?: 0) + 1
}

fun pause(at: LocalDateTime) {
if (paused != true) {
this.paused = true
Expand All @@ -31,7 +47,7 @@ class FeverTime(
fun resume(at: LocalDateTime) {
if (paused == true && lastPausedAt != null) {
val pausedDuration = Duration.between(lastPausedAt, at)
// 쉬었던 만큼 종료 시간을 뒤로 미룸

this.feverEndAt = this.feverEndAt.plus(pausedDuration)

this.paused = false
Expand All @@ -42,6 +58,9 @@ class FeverTime(
companion object {
private val FEVER_DURATION: Duration = Duration.ofSeconds(30)
private val BUFFER_DURATION: Duration = Duration.ofSeconds(1)
private val MAX_PAST_ALLOWED: Duration = Duration.ofMinutes(3)
private val MAX_FUTURE_ALLOWED: Duration = Duration.ofSeconds(5)
const val MAX_FEVER_STREAKS = 60

fun start(now: LocalDateTime = LocalDateTime.now()): FeverTime {
return FeverTime(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.snackgame.server.game.snackgame.core.service.dto.SnackgameEndResponse
import com.snackgame.server.game.snackgame.core.service.dto.SnackgameResponse
import com.snackgame.server.game.snackgame.core.service.dto.SnackgameUpdateRequest
import com.snackgame.server.game.snackgame.core.service.dto.StreaksRequest
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
Expand All @@ -26,6 +27,7 @@ class SnackgameService(
private val itemService: ItemService,
private val eventPublisher: ApplicationEventPublisher
) {
private val log = LoggerFactory.getLogger(javaClass)

@Transactional
fun startSessionFor(memberId: Long): SnackgameResponse {
Expand Down Expand Up @@ -79,8 +81,8 @@ class SnackgameService(
fun pause(memberId: Long, sessionId: Long): SnackgameResponse {
val game = snackGameRepository.getBy(memberId, sessionId)

game.pause()
eventPublisher.publishEvent(SessionPauseEvent.of(game))
val pausedAt = game.pause()
eventPublisher.publishEvent(SessionPauseEvent.of(game, pausedAt))

return SnackgameResponse.of(game)
}
Expand All @@ -89,15 +91,27 @@ class SnackgameService(
fun resume(memberId: Long, sessionId: Long): SnackgameResponse {
val game = snackGameRepository.getBy(memberId, sessionId)

game.resume()
eventPublisher.publishEvent(SessionResumeEvent.of(game))
val resumedAt = game.resume()
eventPublisher.publishEvent(SessionResumeEvent.of(game, resumedAt))

return SnackgameResponse.of(game)
}

@Transactional
fun end(memberId: Long, sessionId: Long): SnackgameEndResponse {
log.info("[게임 종료 시도] memberId: $memberId, sessionId: $sessionId")

// DB에서 세션 ID로만 조회하여 실제 ownerId 확인
val sessionById = snackGameRepository.findById(sessionId)
if (sessionById.isPresent) {
val actualOwnerId = sessionById.get().ownerId
log.info("[세션 존재 확인] sessionId: $sessionId, actualOwnerId: $actualOwnerId, requestMemberId: $memberId, 일치여부: ${actualOwnerId == memberId}")
} else {
log.warn("[세션 없음] sessionId: $sessionId 가 DB에 존재하지 않음")
}

val game = snackGameRepository.getBy(memberId, sessionId)
log.info("[세션 조회 성공] sessionId: $sessionId, ownerId: ${game.ownerId}, score: ${game.score}")
Comment on lines +104 to +114

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 end 메소드에서 게임 세션을 조회하기 위해 데이터베이스에 두 번 접근하고 있습니다. 첫 번째는 로깅을 위해 findById(sessionId)를 호출하고, 두 번째는 실제 게임 객체를 가져오기 위해 getBy(memberId, sessionId)를 호출합니다. 이는 비효율적입니다.

findById(sessionId)로 한 번만 조회한 후, 세션 존재 여부와 소유자 일치 여부를 확인하고 로깅하는 방식으로 리팩토링하여 데이터베이스 접근을 한 번으로 줄일 수 있습니다. 이렇게 하면 성능을 개선하면서도 동일한 디버깅 정보를 얻을 수 있습니다.

Suggested change
// DB에서 세션 ID로만 조회하여 실제 ownerId 확인
val sessionById = snackGameRepository.findById(sessionId)
if (sessionById.isPresent) {
val actualOwnerId = sessionById.get().ownerId
log.info("[세션 존재 확인] sessionId: $sessionId, actualOwnerId: $actualOwnerId, requestMemberId: $memberId, 일치여부: ${actualOwnerId == memberId}")
} else {
log.warn("[세션 없음] sessionId: $sessionId 가 DB에 존재하지 않음")
}
val game = snackGameRepository.getBy(memberId, sessionId)
log.info("[세션 조회 성공] sessionId: $sessionId, ownerId: ${game.ownerId}, score: ${game.score}")
val game = snackGameRepository.findById(sessionId)
.orElseThrow {
log.warn("[세션 없음] sessionId: {} 가 DB에 존재하지 않음", sessionId)
SessionNotFoundException()
}
log.info("[세션 소유자 확인] sessionId: {}, actualOwnerId: {}, requestMemberId: {}, 일치여부: {}", sessionId, game.ownerId, memberId, game.ownerId == memberId)
if (game.ownerId != memberId) {
throw SessionNotFoundException()
}
log.info("[세션 조회 성공] sessionId: {}, ownerId: {}, score: {}", sessionId, game.ownerId, game.score)


game.end()
eventPublisher.publishEvent(SessionEndEvent.of(game))
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class SnackgameInfiniteService(
fun pause(memberId: Long, sessionId: Long): SnackgameResponse {
val game = snackGameRepository.getBy(memberId, sessionId)

game.pause()
game.pause() // pause 시간 반환하지만 이 Service는 Event 발행 안함

return SnackgameResponse.of(game)
}
Expand All @@ -48,7 +48,7 @@ class SnackgameInfiniteService(
fun resume(memberId: Long, sessionId: Long): SnackgameResponse {
val game = snackGameRepository.getBy(memberId, sessionId)

game.resume()
game.resume() // resume 시간 반환하지만 이 Service는 Event 발행 안함

return SnackgameResponse.of(game)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,22 @@ class SnackgameTest {
@Test
fun `일시정지_후_재개하면_피버타임도_연장되어_점수_2배가_적용된다`() {
val game = Snackgame(땡칠().id, BoardFixture.TWO_BY_FOUR())
val feverStartTime = LocalDateTime.now()
game.startFeverTime()

game.feverTime!!.pause(LocalDateTime.now().plusSeconds(10))
// 피버타임 10초 후 일시정지
val pauseAt = feverStartTime.plusSeconds(10)
game.feverTime!!.pause(pauseAt)

game.feverTime!!.resume(LocalDateTime.now().plusHours(1))
// 20초 일시정지 후 재개 (총 30초 경과, 하지만 피버는 10초만 흘렀음)
val resumeAt = pauseAt.plusSeconds(20)
game.feverTime!!.resume(resumeAt)

val streak = Streak.of(arrayListOf(Coordinate(0, 0), Coordinate(1, 0)))


val occurredAt = LocalDateTime.now().plusHours(1).plusSeconds(5)
// 재개 후 5초 뒤 (피버타임 15초 시점, 아직 30초 내)
// 서버 시간 기준으로는 현재 시간 + 약간의 오차로 설정
val occurredAt = LocalDateTime.now().plusSeconds(2)
val request = StreakWithFever(streak, clientIsFever = true, occurredAt = occurredAt)

game.remove(request)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ class FeverTimeTest {
val end = start.plusSeconds(30)
val feverTime = FeverTime(start, end)


assertThat(feverTime.isFeverTime(start)).isTrue
assertThat(feverTime.isFeverTime(end)).isTrue
assertThat(feverTime.isFeverTime(start.plusSeconds(15))).isTrue


assertThat(feverTime.isFeverTime(start.minusSeconds(2))).isFalse
assertThat(feverTime.isFeverTime(end.plusSeconds(2))).isFalse
// 서버 시간 검증은 ±3분/5초 허용하므로, 각 occurredAt 근처로 serverNow 설정
assertThat(feverTime.isFeverTime(start, start)).isTrue
assertThat(feverTime.isFeverTime(end, end)).isTrue
assertThat(feverTime.isFeverTime(start.plusSeconds(15), start.plusSeconds(15))).isTrue

// 피버타임 범위 밖이면서, 서버 시간은 실제 요청 시점으로 설정
assertThat(feverTime.isFeverTime(start.minusSeconds(2), start.minusSeconds(2))).isFalse
assertThat(feverTime.isFeverTime(end.plusSeconds(2), end.plusSeconds(2))).isFalse
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,5 @@ class SnackgameServiceTest {
val totalDuration = Duration.between(feverTime.feverStartedAt, feverTime.feverEndAt)

assertThat(totalDuration).isGreaterThan(Duration.ofSeconds(30))

val slightlyLateTime = feverTime.feverStartedAt.plusSeconds(30).plusNanos(500)
assertThat(feverTime.isFeverTime(slightlyLateTime)).isTrue()
}
}
Loading