Skip to content
Merged
Changes from 1 commit
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
b416f37
test: 동시성 테스트 및 비교 테스트 구현
hyxklee Feb 18, 2026
938020a
refactor: 삭제 전 검증하도록 개선
hyxklee Feb 18, 2026
6ee95a0
refactor: 스웨거 어노테이션 추가
hyxklee Feb 18, 2026
76ade6a
chore: 통합 테스트를 위한 더미 환경변수 추가
hyxklee Feb 18, 2026
bfafdcb
refactor: Notice 제거
hyxklee Feb 18, 2026
5d9361b
refactor: Board, Post 엔티티 마이그레이션
hyxklee Feb 18, 2026
a231443
refactor: 예외 마이그레이션
hyxklee Feb 18, 2026
4609059
refactor: 예외 마이그레이션
hyxklee Feb 18, 2026
3a76cd3
refactor: Dto 마이그레이션
hyxklee Feb 18, 2026
b67693d
refactor: Repository 마이그레이션
hyxklee Feb 18, 2026
2967e4e
refactor: 컨트롤러 마이그레이션
hyxklee Feb 18, 2026
cfadd16
refactor: Comment 변경 사항 반영
hyxklee Feb 18, 2026
4b18760
refactor: Comment 변경 사항 반영
hyxklee Feb 18, 2026
36ba318
refactor: File 변경 사항 반영
hyxklee Feb 18, 2026
c69b6fb
feat: 글로벌 Json Converter 구현
hyxklee Feb 18, 2026
071a714
refactor: Post Usecase 분리 및 마이그레이션
hyxklee Feb 18, 2026
b077df5
refactor: 기존 Post 파일 제거
hyxklee Feb 18, 2026
31223e4
refactor: Post Mapper 마이그레이션
hyxklee Feb 18, 2026
bf777d3
refactor: TestFixture
hyxklee Feb 18, 2026
452dc36
refactor: 예외 전용 컨트롤러 Notice 제거
hyxklee Feb 18, 2026
68f580c
refactor: Comment 변경 사항 대응
hyxklee Feb 18, 2026
24cdacf
refactor: 쓰기 권한은 User.Role을 첨부하도록 수정
hyxklee Feb 19, 2026
395688c
refactor: admin api 경로 추가
hyxklee Feb 19, 2026
e3f3a85
refactor: soft delete로 변경
hyxklee Feb 19, 2026
4e4f638
refactor: role 변경
hyxklee Feb 19, 2026
591ca78
feat: 게시판/게시글 권한에 따른 조회 분리
hyxklee Feb 19, 2026
1429624
refactor: lint 설정
hyxklee Feb 19, 2026
1bcc59e
refactor: 권한 추출 로직 추가
hyxklee Feb 19, 2026
d4349a4
docs: todo 주석
hyxklee Feb 19, 2026
c29bb04
test: 게시판 테스트 추가
hyxklee Feb 19, 2026
bda56c9
test: 댓글 동시성 테스트 수정
hyxklee Feb 19, 2026
5f021b1
docs: todo 주석 추가
hyxklee Feb 19, 2026
1ffb796
refactor: 리뷰 내용 반영
hyxklee Feb 19, 2026
9e6cf8f
refactor: ktlint 및 스웨거 수정
hyxklee Feb 19, 2026
55417ab
test: CI에서 돌지 않도록 수정
hyxklee Feb 19, 2026
1e39d9c
test: redis test container 추가
hyxklee Feb 19, 2026
67540cf
test: CI에 Redis 추가
hyxklee Feb 19, 2026
52bfa7d
refactor: 삭제 여부 검증 추가
hyxklee Feb 19, 2026
4a57b52
refactor: 업데이트시 검증 추가
hyxklee Feb 19, 2026
b83b505
refactor: 업데이트시 검증 추가
hyxklee Feb 19, 2026
84f345e
test: 헬퍼 메서드 추가
hyxklee Feb 19, 2026
d6eafd9
docs: todo 주석 추가
hyxklee Feb 19, 2026
4737456
test: gradle 설정 제거
hyxklee Feb 19, 2026
b692fda
refactor: 글쓰기 권한 검증 확대
hyxklee Feb 19, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
package com.weeth.domain.comment.application.usecase.command

import com.weeth.config.QueryCountUtil
import com.weeth.config.TestContainersConfig
import com.weeth.domain.board.domain.entity.Post
import com.weeth.domain.board.domain.entity.enums.Category
import com.weeth.domain.board.domain.entity.enums.Part
import com.weeth.domain.board.domain.repository.PostRepository
import com.weeth.domain.comment.application.dto.request.CommentSaveRequest
import com.weeth.domain.comment.domain.entity.Comment
import com.weeth.domain.comment.domain.repository.CommentRepository
import com.weeth.domain.user.domain.entity.User
import com.weeth.domain.user.domain.entity.enums.Status
import com.weeth.domain.user.domain.repository.UserRepository
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import jakarta.persistence.EntityManager
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.support.TransactionTemplate
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference

@SpringBootTest
@ActiveProfiles("test")
@Import(TestContainersConfig::class, CommentConcurrencyBenchmarkConfig::class)
class CommentConcurrencyTest(
private val postCommentUsecase: PostCommentUsecase,
private val postRepository: PostRepository,
private val userRepository: UserRepository,
private val commentRepository: CommentRepository,
private val entityManager: EntityManager,
private val atomicCommentCountCommand: AtomicCommentCountCommand,
) : DescribeSpec({
val runPerformanceTests = System.getProperty("runPerformanceTests")?.toBoolean() ?: false
Copy link
Collaborator

Choose a reason for hiding this comment

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

성능 테스트라면 @Tag("performance")로 분리해도 될 것 같은데 Gradle 실행 옵션을 사용하신 특별한 이유가 있을까욤??

Copy link
Contributor Author

Choose a reason for hiding this comment

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

특정 케이스만 분리하기 위함이라고 하네욤.. 제거하겠습니당ㅋㅋ


data class ConcurrencyResult(
val successCount: Int,
val failCount: Int,
val postCommentCount: Int,
val actualCommentCount: Int,
val queryCount: Long,
val elapsedTimeMs: Double,
val firstError: String?,
)

fun createUsers(size: Int): List<User> =
(1..size).map { i ->
userRepository.save(
User
.builder()
.name("user$i")
.email("[email protected]")
.status(Status.ACTIVE)
.build(),
)
}

fun createPost(title: String): Post =
postRepository.save(
Post
.builder()
.title(title)
.content("내용")
.comments(ArrayList())
.commentCount(0)
.category(Category.StudyLog)
.cardinalNumber(1)
.week(1)
.part(Part.ALL)
.parts(listOf(Part.ALL))
.build(),
)

fun runConcurrentSave(
threadCount: Int,
saveAction: (postId: Long, userId: Long, index: Int) -> Unit,
): ConcurrencyResult {
val users = createUsers(threadCount)
val post = createPost("동시성 테스트 게시글")
val executor = Executors.newFixedThreadPool(threadCount)
val latch = CountDownLatch(threadCount)
val successCount = AtomicInteger(0)
val failCount = AtomicInteger(0)
val firstError = AtomicReference<String?>(null)

entityManager.clear()

val measured =
QueryCountUtil.count(entityManager) {
repeat(threadCount) { i ->
executor.submit {
try {
saveAction(post.id, users[i].id, i)
successCount.incrementAndGet()
} catch (e: Exception) {
failCount.incrementAndGet()
firstError.compareAndSet(null, "${e::class.simpleName}: ${e.message}")
} finally {
latch.countDown()
}
}
}

latch.await()
executor.shutdown()
}

entityManager.clear()
val updatedPost = postRepository.findById(post.id).orElseThrow()
val actualCommentCount =
entityManager
.createQuery("select count(c) from Comment c where c.post.id = :postId", java.lang.Long::class.java)
.setParameter("postId", post.id)
.singleResult
.toInt()

return ConcurrencyResult(
successCount = successCount.get(),
failCount = failCount.get(),
postCommentCount = updatedPost.commentCount,
actualCommentCount = actualCommentCount,
queryCount = measured.queryCount,
elapsedTimeMs = measured.elapsedTimeMs,
firstError = firstError.get(),
)
}

afterEach {
commentRepository.deleteAllInBatch()
postRepository.deleteAllInBatch()
userRepository.deleteAllInBatch()
}

describe("동시 댓글 생성") {
it("10개의 동시 요청 후 commentCount가 정확히 10이어야 한다") {
val threadCount = 10
val result =
runConcurrentSave(threadCount) { postId, userId, index ->
postCommentUsecase.savePostComment(
dto = CommentSaveRequest(parentCommentId = null, content = "댓글 $index", files = null),
postId = postId,
userId = userId,
)
}
result.successCount shouldBe threadCount
result.failCount shouldBe 0
result.postCommentCount shouldBe result.actualCommentCount
result.postCommentCount shouldBe threadCount
result.firstError shouldBe null
}
}

describe("동시성 해소 방식별 성능 비교") {
// TODO(board-refactor): Board 도메인 구조 개편(댓글 카운트 책임/저장 구조 변경) 이후
// 이 비교 시나리오는 동일 조건으로 다시 측정해 기준선을 재작성한다.
it("PESSIMISTIC_WRITE와 Atomic Increment를 측정한다").config(enabled = runPerformanceTests) {
val threadCount = 30

val pessimisticResult =
runConcurrentSave(threadCount) { postId, userId, index ->
postCommentUsecase.savePostComment(
dto =
CommentSaveRequest(
parentCommentId = null,
content = "pessimistic-$index",
files = null,
),
postId = postId,
userId = userId,
)
}

val atomicResult =
runConcurrentSave(threadCount) { postId, userId, index ->
atomicCommentCountCommand.savePostCommentWithAtomicIncrement(
dto =
CommentSaveRequest(
parentCommentId = null,
content = "atomic-$index",
files = null,
),
postId = postId,
userId = userId,
)
}

println("[pessimistic] $pessimisticResult")
println("[atomic] $atomicResult")

pessimisticResult.failCount shouldBe 0
atomicResult.failCount shouldBe 0
pessimisticResult.postCommentCount shouldBe threadCount
pessimisticResult.actualCommentCount shouldBe threadCount
atomicResult.postCommentCount shouldBe threadCount
atomicResult.actualCommentCount shouldBe threadCount
}
}
})

class AtomicCommentCountCommand(
private val commentRepository: CommentRepository,
private val postRepository: PostRepository,
private val userRepository: UserRepository,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate,
) {
// TODO(board-refactor): 현재는 동시성 비교 실험용 테스트 전용 커맨드.
// Board 리팩토링 후 실제 카운트 갱신 구조에 맞춰 제거 또는 대체한다.
fun savePostCommentWithAtomicIncrement(
dto: CommentSaveRequest,
postId: Long,
userId: Long,
) {
val maxRetries = 10
var lastError: Exception? = null

repeat(maxRetries) { attempt ->
try {
transactionTemplate.executeWithoutResult {
val user = userRepository.findById(userId).orElseThrow()
val post = postRepository.findById(postId).orElseThrow()
val parent =
dto.parentCommentId?.let { parentId ->
commentRepository.findByIdAndPostId(parentId, postId) ?: throw IllegalArgumentException("parent not found")
}

commentRepository.save(
Comment.createForPost(
content = dto.content,
post = post,
user = user,
parent = parent,
),
)

entityManager
.createQuery("update Post p set p.commentCount = p.commentCount + 1 where p.id = :postId")
.setParameter("postId", postId)
.executeUpdate()
}
return
} catch (e: Exception) {
lastError = e
val deadlock = e.message?.contains("Deadlock found", ignoreCase = true) == true
if (!deadlock || attempt == maxRetries - 1) {
throw e
}
Thread.sleep(10)
}
}

throw IllegalStateException("Atomic increment retries exhausted", lastError)
}
}

@TestConfiguration
class CommentConcurrencyBenchmarkConfig {
@Bean
fun atomicCommentCountCommand(
commentRepository: CommentRepository,
postRepository: PostRepository,
userRepository: UserRepository,
entityManager: EntityManager,
transactionManager: PlatformTransactionManager,
): AtomicCommentCountCommand =
AtomicCommentCountCommand(
commentRepository = commentRepository,
postRepository = postRepository,
userRepository = userRepository,
entityManager = entityManager,
transactionTemplate = TransactionTemplate(transactionManager),
)
}