-
Notifications
You must be signed in to change notification settings - Fork 1
[LNK-48] 공감하기 동시성 문제 해결 #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "LNK-48-Leenk-\uACF5\uAC10\uD558\uAE30-\uB3D9\uC2DC\uC131-\uBB38\uC81C-\uD574\uACB0"
Changes from all commits
b6802fa
375efba
6f6fffc
c298d57
b3e7c98
dba41c7
1f154dd
c1fadc8
ee2715f
cac3f5d
c562002
b6ad07a
d376877
a271a5e
e1cf92f
474589a
55cebd6
1d171f1
64cbd29
28690a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,15 @@ | ||
| package leets.leenk.domain.feed.domain.repository; | ||
|
|
||
| import jakarta.persistence.LockModeType; | ||
| import jakarta.persistence.QueryHint; | ||
| import leets.leenk.domain.feed.domain.entity.Feed; | ||
| import leets.leenk.domain.user.domain.entity.User; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.domain.Slice; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.data.jpa.repository.Lock; | ||
| import org.springframework.data.jpa.repository.Query; | ||
| import org.springframework.data.jpa.repository.QueryHints; | ||
| import org.springframework.data.repository.query.Param; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
@@ -23,6 +27,16 @@ public interface FeedRepository extends JpaRepository<Feed, Long> { | |
| @Query("SELECT f FROM Feed f JOIN FETCH f.user WHERE f.deletedAt IS NULL AND f.id = :id") | ||
| Optional<Feed> findByDeletedAtIsNullAndId(Long id); | ||
|
|
||
| /** | ||
| * 비관적 락을 사용하여 피드 조회 (동시성 제어용) | ||
| * 공감하기 등 동시 수정이 발생할 수 있는 경우 사용 | ||
| * feed.user를 함께 가져오기 때문에 해당 유저에도 락이 함께 걸리니 주의 | ||
| */ | ||
| @Lock(LockModeType.PESSIMISTIC_WRITE) | ||
| @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) | ||
| @Query("SELECT f FROM Feed f JOIN FETCH f.user WHERE f.deletedAt IS NULL AND f.id = :id") | ||
| Optional<Feed> findByIdWithPessimisticLock(@Param("id") Long id); | ||
|
Comment on lines
+35
to
+38
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 지금 JOIN FETCH를 통해 USER를 묶고 있는데 이렇게 할 경우 USER까지 락에 걸릴 수 있지 않을까요?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3번 문제: 다른 피드더라도 작성자의 경우 “totalReactionCount” 필드를 동시에 업데이트 하기 때문에 데드락 발생 테스트 해보니 페치 조인을 쓰는 경우 User 엔티티에도 락이 걸리는 것 맞네요! 다른 케이스라면 문제가 될 수 있지만 현재 요구사항으로는 딱 맞는 상황입니당. 주석에 해당 내용을 작성해둘테니 필요에 따라 구분해서 사용할 필요가 있어 보이네요!! 덕분에 해당 락이 중복이 되서 코드를 간략하게 만들 수 있겠네요 승현님 덕분에 하나 더 배웟습니당
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 그리고 커스텀 예외 하나 만들어서 잡아주겟습니다! |
||
|
|
||
| /** | ||
| * 현재 피드보다 최신인 피드 조회 (이전 피드) | ||
| * createDate > currentCreateDate 조건으로 더 최근 피드를 ASC 정렬로 조회 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package leets.leenk.global.common.exception; | ||
|
|
||
| public class ResourceLockedException extends BaseException{ | ||
| public ResourceLockedException() { | ||
| super(ErrorCode.RESOURCE_LOCKED); | ||
| } | ||
| } |
This file was deleted.
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package leets.leenk.config | ||
|
|
||
| import org.springframework.boot.test.context.TestConfiguration | ||
| import org.springframework.boot.testcontainers.service.connection.ServiceConnection | ||
| import org.springframework.context.annotation.Bean | ||
| import org.testcontainers.containers.MongoDBContainer | ||
|
|
||
| private const val MONGO_IMAGE: String = "mongo:7.0" | ||
|
|
||
| @TestConfiguration | ||
| class MongoTestConfig { | ||
| @Bean | ||
| @ServiceConnection | ||
| fun mongoContainer(): MongoDBContainer { | ||
| return MongoDBContainer(MONGO_IMAGE) | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leenk에서는 실제로 mongoDB를 사용하나요 ?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 알림 기능을 위해서 몽고디비 사용하고 있습니당 |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package leets.leenk.config | ||
|
|
||
| import org.springframework.boot.test.context.TestConfiguration | ||
| import org.springframework.boot.testcontainers.service.connection.ServiceConnection | ||
| import org.springframework.context.annotation.Bean | ||
| import org.testcontainers.containers.MySQLContainer | ||
|
|
||
| private const val MYSQL_IMAGE: String = "mysql:8.0.41" | ||
|
|
||
| @TestConfiguration | ||
| class MysqlTestConfig { | ||
| @Bean | ||
| @ServiceConnection | ||
| fun mysqlContainer(): MySQLContainer<*> { | ||
| return MySQLContainer(MYSQL_IMAGE) | ||
| .withDatabaseName("testdb") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package leets.leenk.config | ||
|
|
||
| import io.kotest.core.spec.style.StringSpec | ||
| import io.kotest.matchers.shouldBe | ||
| import io.kotest.matchers.shouldNotBe | ||
| import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase | ||
| import org.springframework.boot.test.context.SpringBootTest | ||
| import org.springframework.context.annotation.Import | ||
| import org.testcontainers.containers.MongoDBContainer | ||
| import org.testcontainers.containers.MySQLContainer | ||
|
|
||
| @SpringBootTest | ||
| @Import(MysqlTestConfig::class, MongoTestConfig::class) | ||
| @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) | ||
| class TestContainersTest( | ||
| private val mysqlContainer: MySQLContainer<*>, | ||
| private val mongoContainer: MongoDBContainer | ||
| ) : StringSpec({ | ||
|
|
||
| "MySQL 컨테이너가 정상적으로 실행되어야 한다" { | ||
| mysqlContainer shouldNotBe null | ||
| mysqlContainer.isRunning shouldBe true | ||
| mysqlContainer.databaseName shouldBe "testdb" | ||
|
|
||
| println("MySQL Container JDBC URL: ${mysqlContainer.jdbcUrl}") | ||
| } | ||
|
|
||
| "MongoDB 컨테이너가 정상적으로 실행되어야 한다" { | ||
| mongoContainer shouldNotBe null | ||
| mongoContainer.isRunning shouldBe true | ||
|
|
||
| println("MongoDB Container Connection String: ${mongoContainer.connectionString}") | ||
| println("MongoDB Container Replica Set URL: ${mongoContainer.replicaSetUrl}") | ||
| } | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package leets.leenk.domain.feed.application.domain.service | ||
|
|
||
| import io.kotest.assertions.throwables.shouldThrow | ||
| import io.kotest.core.spec.style.DescribeSpec | ||
| import io.mockk.every | ||
| import io.mockk.mockk | ||
| import jakarta.persistence.PessimisticLockException | ||
| import leets.leenk.domain.feed.domain.repository.FeedRepository | ||
| import leets.leenk.domain.feed.domain.service.FeedGetService | ||
| import leets.leenk.global.common.exception.ResourceLockedException | ||
|
|
||
| class FeedGetServiceTest : DescribeSpec({ | ||
| val feedRepository = mockk<FeedRepository>() | ||
| val feedGetService = FeedGetService(feedRepository) | ||
|
|
||
| describe("피드 리액션 기능") { | ||
| context("동시 요청 시 락 타임아웃 발생 시") { | ||
| it("DB에서 락 예외가 발생하면 커스텀 예외로 변환해야 한다") { | ||
| every { feedRepository.findByIdWithPessimisticLock(any()) } throws PessimisticLockException() | ||
|
|
||
| shouldThrow<ResourceLockedException> { | ||
| feedGetService.findByIdWithLock(1L) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
조회 쿼리에서 lock.timeout = 2000ms로 설정하신 기준이나 고려하신 부분이 있을지 궁금합니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
보통의 경우 3초 정도로 설정하는 듯 한데, 공감하기의 경우는 너무 느린 응답속도로 반응하면 사용성이 떨어질 것 같아 약간 짧게 2초로 설정했습니다!!