Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
Expand Up @@ -210,10 +210,17 @@ private List<LinkedUser> getLinkedUsers(User author, List<Long> userIds, Feed fe
.toList();
}

/**
* 피드에 공감을 추가
* 비관적 락(PESSIMISTIC_WRITE)을 사용하여 동시성 문제를 해결
* @see leets.leenk.domain.feed.domain.repository.FeedRepository#findByIdWithPessimisticLock
* @see leets.leenk.domain.user.domain.repository.UserRepository#findByIdWithPessimisticLock
*/
@Transactional
public void reactToFeed(long userId, long feedId, ReactionRequest request) {
Feed feed = feedGetService.findByIdWithLock(feedId); // !락 순서 중요!
User user = userGetService.findById(userId);
Feed feed = feedGetService.findById(feedId);

validateReaction(feed, user);

Reaction reaction = reactionGetService.findByFeedAndUser(feed, user)
Expand All @@ -225,6 +232,7 @@ public void reactToFeed(long userId, long feedId, ReactionRequest request) {

long previousReactionCount = reaction.getReactionCount();

// Feed를 가져올 때 Fetch Join으로 작성자를 함께 가져와 락이 함께 걸리므로 별도의 락 필요 없음.
feedUpdateService.updateTotalReaction(feed, reaction, feed.getUser(), request.reactionCount());
feedNotificationUsecase.saveFirstReactionNotification(reaction);

Expand Down
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;
Expand All @@ -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"))
Copy link
Collaborator

Choose a reason for hiding this comment

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

조회 쿼리에서 lock.timeout = 2000ms로 설정하신 기준이나 고려하신 부분이 있을지 궁금합니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

보통의 경우 3초 정도로 설정하는 듯 한데, 공감하기의 경우는 너무 느린 응답속도로 반응하면 사용성이 떨어질 것 같아 약간 짧게 2초로 설정했습니다!!

@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
Copy link
Member

Choose a reason for hiding this comment

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

지금 JOIN FETCH를 통해 USER를 묶고 있는데 이렇게 할 경우 USER까지 락에 걸릴 수 있지 않을까요?
해당 메서드가 구현된 이유는 락 순서를 고정해 데드락을 피하기 위해서입니다. 그런데 만약에 첫번째 쿼리에서 이미 USER가 잠겨버린다면 Feed 락-> User 락이 아니라 User 먼저 잠그고 Feed가 잠기는 현상이 일어날 수도 있을 것 같습니다.
또한 락 범위가 늘어나 불필요한 대기가 늘어날 수도 있을 것 같아요!!
개인적인 의견입니다..

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

3번 문제: 다른 피드더라도 작성자의 경우 “totalReactionCount” 필드를 동시에 업데이트 하기 때문에 데드락 발생
해당 문제 때문에 작성자에도 락을 걸어야하는 상황입니다!

테스트 해보니 페치 조인을 쓰는 경우 User 엔티티에도 락이 걸리는 것 맞네요! 다른 케이스라면 문제가 될 수 있지만 현재 요구사항으로는 딱 맞는 상황입니당. 주석에 해당 내용을 작성해둘테니 필요에 따라 구분해서 사용할 필요가 있어 보이네요!!

덕분에 해당 락이 중복이 되서 코드를 간략하게 만들 수 있겠네요 승현님 덕분에 하나 더 배웟습니당

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

그리고 커스텀 예외 하나 만들어서 잡아주겟습니다!


/**
* 현재 피드보다 최신인 피드 조회 (이전 피드)
* createDate > currentCreateDate 조건으로 더 최근 피드를 ASC 정렬로 조회
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package leets.leenk.domain.feed.domain.service;

import jakarta.persistence.PessimisticLockException;
import leets.leenk.domain.feed.application.exception.FeedNotFoundException;
import leets.leenk.domain.feed.domain.entity.Feed;
import leets.leenk.domain.feed.domain.repository.FeedRepository;
import leets.leenk.domain.feed.domain.service.dto.FeedNavigationResult;
import leets.leenk.domain.user.domain.entity.User;
import leets.leenk.domain.user.domain.entity.UserBlock;
import leets.leenk.global.common.exception.ResourceLockedException;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
Expand All @@ -26,6 +28,19 @@ public Feed findById(long feedId) {
.orElseThrow(FeedNotFoundException::new);
}

/**
* 비관적 락을 사용하여 피드 조회
* 동시 수정이 발생할 수 있는 경우 (공감하기 등) 사용
*/
public Feed findByIdWithLock(long feedId) {
try {
return feedRepository.findByIdWithPessimisticLock(feedId)
.orElseThrow(FeedNotFoundException::new);
} catch (PessimisticLockException e) {
throw new ResourceLockedException();
}
}

public Slice<Feed> findAll(Pageable pageable, List<UserBlock> blockedUser) {
List<Long> blockedUserIds = blockedUser.stream()
.map(UserBlock::getBlocked)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package leets.leenk.domain.user.domain.repository;

import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
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.LocalDate;
Expand All @@ -14,6 +18,15 @@

public interface UserRepository extends JpaRepository<User, Long> {

/**
* 비관적 락을 사용하여 유저 조회
* 동시 수정이 발생할 수 있는 경우 (공감하기 등) 사용
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000"))
@Query("SELECT u FROM User u WHERE u.id = :userId AND u.leaveDate IS NULL AND u.deleteDate IS NULL")
Optional<User> findByIdWithPessimisticLock(@Param("userId") long userId);

Optional<User> findByIdAndLeaveDateIsNullAndDeleteDateIsNull(long userId);

List<User> findAllByIdInAndLeaveDateIsNullAndDeleteDateIsNull(List<Long> userIds);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ public User findById(long userId) {
.orElseThrow(UserNotFoundException::new);
}

/**
* 비관적 락을 사용하여 유저 조회
* 동시 수정이 발생할 수 있는 경우 (공감하기 등) 사용
*/
public User findByIdWithLock(long userId) {
return userRepository.findByIdWithPessimisticLock(userId)
.orElseThrow(UserNotFoundException::new);
}

public Optional<User> existById(long userId) {
return userRepository.findById(userId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public enum ErrorCode implements ErrorCodeInterface {
// 3000번대: 서버 에러
INTERNAL_SERVER_ERROR(3001, HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류입니다."),
JSON_PROCESSING(3002, HttpStatus.INTERNAL_SERVER_ERROR, "JSON 처리 중 문제가 발생했습니다."),
RESOURCE_LOCKED(3003, HttpStatus.CONFLICT, "다른 사용자가 처리 중입니다. 잠시 후 다시 시도해주세요."),

// 4000번대: 클라이언트 요청 에러
INVALID_ARGUMENT(4001, HttpStatus.BAD_REQUEST, "잘못된 인자입니다."),
Expand Down
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);
}
}
19 changes: 0 additions & 19 deletions src/test/java/leets/leenk/config/MysqlTestConfig.java

This file was deleted.

32 changes: 0 additions & 32 deletions src/test/java/leets/leenk/config/TestContainersTest.java

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,8 @@ void reactToFeed1() {
User me = UserTestFixture.createUser(userId, "me");
Feed myFeed = FeedTestFixture.createFeed(feedId, me);

given(feedGetService.findByIdWithLock(feedId)).willReturn(myFeed);
given(userGetService.findById(userId)).willReturn(me);
given(feedGetService.findById(feedId)).willReturn(myFeed);

ReactionRequest request = new ReactionRequest(1L);

Expand All @@ -380,8 +380,8 @@ void reactToFeed2() {
User author = UserTestFixture.createUser(2L, "author");
Feed feed = FeedTestFixture.createFeed(feedId, author);

given(feedGetService.findByIdWithLock(feedId)).willReturn(feed);
given(userGetService.findById(userId)).willReturn(me);
given(feedGetService.findById(feedId)).willReturn(feed);

Reaction reaction = ReactionTestFixture.createReaction(feed, me, 4);
given(reactionGetService.findByFeedAndUser(feed, me)).willReturn(Optional.of(reaction));
Expand All @@ -408,8 +408,8 @@ void reactToFeed3() {
User author = UserTestFixture.createUser(2L, "author");
Feed feed = FeedTestFixture.createFeed(feedId, author);

given(feedGetService.findByIdWithLock(feedId)).willReturn(feed);
given(userGetService.findById(userId)).willReturn(me);
given(feedGetService.findById(feedId)).willReturn(feed);

given(reactionGetService.findByFeedAndUser(feed, me)).willReturn(Optional.empty());

Expand Down
17 changes: 17 additions & 0 deletions src/test/kotlin/leets/leenk/config/MongoTestConfig.kt
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)
}
}

Choose a reason for hiding this comment

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

Leenk에서는 실제로 mongoDB를 사용하나요 ?
몽고디비 환경도 추가한 이유가 궁금합니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

알림 기능을 위해서 몽고디비 사용하고 있습니당

18 changes: 18 additions & 0 deletions src/test/kotlin/leets/leenk/config/MysqlTestConfig.kt
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")
}
}
35 changes: 35 additions & 0 deletions src/test/kotlin/leets/leenk/config/TestContainersTest.kt
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)
}
}
}
}
})
Loading