Skip to content
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

feat: 동시성 제어 어노테이션 기능 구현 #876

Open
wants to merge 61 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
efb5253
feat: redisson으로 따닥 제어 어노테이션 구현
seongjae6751 Sep 7, 2024
46066c4
test: 테스트 구현
seongjae6751 Sep 8, 2024
d042f04
chore: 필요 없는 로그 제거
seongjae6751 Sep 8, 2024
c730a9f
chore: 간단한 수정
seongjae6751 Sep 8, 2024
e4c286c
chore: 필요 없는 줄 삭제
seongjae6751 Sep 8, 2024
ee4d930
chore: 접근 제어자 수정
seongjae6751 Sep 8, 2024
acb5401
chore: 주석 제거
seongjae6751 Sep 8, 2024
9c3b8a9
chore: 주석 제거
seongjae6751 Sep 8, 2024
92da359
chore: config 비밀번호 설정 추가
seongjae6751 Sep 8, 2024
2b45e71
chore: ssl 통신 고려
seongjae6751 Sep 8, 2024
0b23205
chore: redisson 라이브러리 버전 증가
seongjae6751 Sep 9, 2024
88b57cb
chore: test redis 관련 설정 추가
seongjae6751 Sep 9, 2024
ca3495e
chore: 충돌 해결
seongjae6751 Sep 14, 2024
febfb9b
chore: 비밀번호 설정
seongjae6751 Sep 14, 2024
783423b
chore: redisson 빈에 비밀번호 설정 추가
seongjae6751 Sep 14, 2024
e319625
chore: test yml 설정에 redis 관련 추가
seongjae6751 Sep 14, 2024
88c738a
chore: 테스트 redis에서 비밀번호 관련 설정 제거
seongjae6751 Sep 15, 2024
4ed9a21
chore: 테스트 yml에 설정 추가
seongjae6751 Sep 15, 2024
d1da667
chore: 테스트 yml에 host port 설정 제거
seongjae6751 Sep 15, 2024
ff4ac9d
chore: acceptanceTest에 비밀번호 설정 추가
seongjae6751 Sep 15, 2024
8ac8017
chore: TestRedissonConfig 추가
seongjae6751 Sep 16, 2024
75790d6
chore: acceptance test에 test redisson config 추가
seongjae6751 Sep 16, 2024
7340a63
chore: test redis에 비밀번호 설정 제거
seongjae6751 Sep 16, 2024
1b506a1
chore: TestRedissonConfig 설정 변경
seongjae6751 Sep 17, 2024
2bec31e
chore: TestRedissonConfig import 제거
seongjae6751 Sep 17, 2024
edc8606
Merge branch 'develop' of https://github.com/BCSDLab/KOIN_API_V2 into…
seongjae6751 Sep 17, 2024
b635a9b
chore: flyway 파일 버전 수정
seongjae6751 Sep 17, 2024
cf493f6
chore: 필드에 직접 주입으로 변경
seongjae6751 Sep 17, 2024
6de9b2c
chore: acceptancetest에서 config import 추가
seongjae6751 Sep 17, 2024
cca6571
chore: test redisson config 구성 변화
seongjae6751 Sep 17, 2024
689743b
chore: test redisson config import 추가
seongjae6751 Sep 17, 2024
e544c53
chore: 보안 설정 고려
seongjae6751 Sep 17, 2024
7f66618
chore: 포트 고정
seongjae6751 Sep 17, 2024
8de60b8
chore: 동적 할당 beforeall적용
seongjae6751 Sep 17, 2024
a15553b
chore: host 고정
seongjae6751 Sep 17, 2024
f46e065
chore: 네트워크 공유 설정 명시적으로 지정
seongjae6751 Sep 17, 2024
54a141a
chore: test redisson config 지연 초기화
seongjae6751 Sep 17, 2024
9717a0c
chore: redisson config import 제거
seongjae6751 Sep 17, 2024
9979b6c
chore: redisson config import 제거
seongjae6751 Sep 17, 2024
5386b9b
chore: redisson config설정 변경
seongjae6751 Sep 17, 2024
46585a9
chore: redisson config 기본 값 설정
seongjae6751 Sep 17, 2024
88294fb
chore: 로깅
seongjae6751 Sep 17, 2024
e7903e0
chore: import에 test config 다시 추가
seongjae6751 Sep 17, 2024
398b1c3
chore: lazy 설정 제거
seongjae6751 Sep 18, 2024
e487a29
chore: yml에 기본 정보 추가
seongjae6751 Sep 18, 2024
3ebc1cf
chore: config 구성 변경
seongjae6751 Sep 18, 2024
3bffd87
Merge branch 'develop' of https://github.com/BCSDLab/KOIN_API_V2 into…
seongjae6751 Sep 18, 2024
8300506
chore: 어노테이션 mock 환경에서는 안돌아가게 제한
seongjae6751 Sep 18, 2024
3206f1e
chore: 테스트 환경에서 redisson 연결 차단
seongjae6751 Sep 18, 2024
2b2c90a
chore: 테스트 환경에서 redisson 연결 차단
seongjae6751 Sep 18, 2024
98701b8
chore: 안 쓰는 import 제거
seongjae6751 Sep 18, 2024
f0a8a03
chore: 다시 제외 시도
seongjae6751 Sep 18, 2024
005aac4
Merge branch 'develop' of https://github.com/BCSDLab/KOIN_API_V2 into…
seongjae6751 Sep 18, 2024
97eb831
chore: flyway 파일 버전 변경
seongjae6751 Sep 18, 2024
ef24f8a
chore: 안 쓰는 import 제거
seongjae6751 Sep 18, 2024
8fd190e
chore: profile 어노테이션 구성 요소 다 적용
seongjae6751 Sep 18, 2024
9d9117a
chore: mock으로 redisson추가
seongjae6751 Sep 18, 2024
da1606b
chore: test config 제거
seongjae6751 Sep 18, 2024
c58d3ae
chore: 테스트
seongjae6751 Sep 18, 2024
9afe353
chore: 테스트에서 명시적으로 redisson 제외
seongjae6751 Sep 18, 2024
ca24540
chore: 정리
seongjae6751 Sep 18, 2024
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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ dependencies {
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

// Redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.35.0'
}

clean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
Expand All @@ -20,7 +21,10 @@
@Entity
@Table(name = "article_search_keyword_ip_map", indexes = {
@Index(name = "idx_ip_address", columnList = "ipAddress")
})
},
uniqueConstraints = {
@UniqueConstraint(name = "ux_keyword_ip", columnNames = {"keyword_id", "ipAddress"})
})
@NoArgsConstructor(access = PROTECTED)
public class ArticleSearchKeywordIpMap extends BaseEntity {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import in.koreatech.koin.domain.community.article.repository.BoardRepository;
import in.koreatech.koin.domain.community.article.repository.redis.ArticleHitRepository;
import in.koreatech.koin.domain.community.article.repository.redis.HotArticleRepository;
import in.koreatech.koin.global.concurrent.ConcurrencyGuard;
import in.koreatech.koin.global.exception.KoinIllegalArgumentException;
import in.koreatech.koin.global.model.Criteria;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -114,7 +115,7 @@ public List<HotArticleItemResponse> getHotArticles() {
return cacheList.stream().map(HotArticleItemResponse::from).toList();
}

@Transactional
@ConcurrencyGuard(lockName = "searchLog")
public ArticlesResponse searchArticles(String query, Integer boardId, Integer page, Integer limit,
String ipAddress) {
if (query.length() >= MAXIMUM_SEARCH_LENGTH) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordSuggestRepository;
import in.koreatech.koin.domain.user.repository.UserRepository;
import in.koreatech.koin.global.auth.exception.AuthorizationException;
import in.koreatech.koin.global.concurrent.ConcurrencyGuard;
import in.koreatech.koin.global.exception.KoinIllegalArgumentException;
import lombok.RequiredArgsConstructor;

Expand All @@ -50,7 +51,7 @@ public class KeywordService {
private final ArticleRepository articleRepository;
private final UserRepository userRepository;

@Transactional
@ConcurrencyGuard(lockName = "createKeyword")
public ArticleKeywordResponse createKeyword(Integer userId, ArticleKeywordCreateRequest request) {
String keyword = validateAndGetKeyword(request.keyword());
if (articleKeywordUserMapRepository.countByUserId(userId) >= ARTICLE_KEYWORD_LIMIT) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import in.koreatech.koin.domain.user.model.User;
import in.koreatech.koin.domain.user.repository.UserRepository;
import in.koreatech.koin.global.auth.exception.AuthorizationException;
import in.koreatech.koin.global.concurrent.ConcurrencyGuard;
import lombok.RequiredArgsConstructor;

@Service
Expand Down Expand Up @@ -77,7 +78,7 @@ public List<TimetableFrameResponse> getTimetablesFrame(Integer userId, String se
.toList();
}

@Transactional
@ConcurrencyGuard(lockName = "deleteFrame")
public void deleteTimetablesFrame(Integer userId, Integer frameId) {
TimetableFrame frame = timetableFrameRepositoryV2.getByIdWithLock(frameId);
if (!Objects.equals(frame.getUser().getId(), userId)) {
Comment on lines +81 to 84
Copy link
Contributor

Choose a reason for hiding this comment

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

A

그럼 이건 userId를 키값으로 동일한 userId에서 Lock이 걸리는건가요?

그리고 현재 메서드에서 4개의 요청이 동시에 들어온 상황에 대해 궁금한 점이 있습니다. 예를들어 4개의 API 요청이 동시에 들어온다면 1개의 요청은 바로 DB에 쿼리문으로 보내지고, 1개의 요청은 3초뒤에 DB에 쿼리문으로 보내지며, 2개의 요청은 3초동안 대기하다가 에러가 발생되는건가요??

만약 위와같은 방식이라면 사용자가 frame을 동시에 여러개 지우려할때 3초동안 아무런 에러메세지 반응도 없어서 불편함을 겪지 않을까요? 제가 해당 기술에 대해 지식이 있지않다보니 약간 헷갈리네요...

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package in.koreatech.koin.global.concurrent;

import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

import org.springframework.context.annotation.Profile;

@Documented
@Target(METHOD)
@Retention(RUNTIME)
@Profile("!test")
public @interface ConcurrencyGuard {

String lockName();

long waitTime() default 5L;
Copy link
Contributor

Choose a reason for hiding this comment

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

A

궁금한 점인데, 기본적으로 5초, 3초로 하면 조금 시간이 길지 않나요?
처리 시간이 ms 단위이지 않나 싶어서요!


long leaseTime() default 3L;

TimeUnit timeUnit() default TimeUnit.SECONDS;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package in.koreatech.koin.global.concurrent;

import java.lang.reflect.Method;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

import in.koreatech.koin.global.concurrent.exception.ConcurrencyLockException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Aspect
@Component
@Profile("!test")
@RequiredArgsConstructor
public class ConcurrencyGuardAspect {

private final RedissonClient redissonClient;
private final TransactionAspect transactionAspect;

@Around("@annotation(ConcurrencyGuard) && (args(..))")
public Object handleConcurrency(ProceedingJoinPoint joinPoint) throws Throwable {
ConcurrencyGuard annotation = getAnnotation(joinPoint);

Object[] args = joinPoint.getArgs();

String lockName = getLockName(args, annotation);
RLock lock = redissonClient.getLock(lockName);

try {
boolean available = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), annotation.timeUnit());

if (!available) {
throw ConcurrencyLockException.withDetail("Redisson GetLock 타임 아웃 lockName: " + lockName);
}

return transactionAspect.proceed(joinPoint);
} finally {
try {
lock.unlock();
} catch (IllegalMonitorStateException e) {
log.warn("Redisson 락이 이미 해제되었습니다 lockName: " + lockName);
}
}
}

private ConcurrencyGuard getAnnotation(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
return method.getAnnotation(ConcurrencyGuard.class);
}

private String getLockName(Object[] args, ConcurrencyGuard annotation) {
String lockNameFormat = "lock:%s:%s";

String relevantParameter;
if (args.length > 0) {
relevantParameter = args[0].toString();
} else {
relevantParameter = "default";
}

return String.format(lockNameFormat, annotation.lockName(), relevantParameter);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package in.koreatech.koin.global.concurrent;

import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Profile("!test")
@Component
public class TransactionAspect {
// leaseTime보다 트랜잭션 타임아웃은 작아야 한다.
// leastTimeOut 발생 전에 rollback 시키기 위함
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 2)
Copy link
Contributor

Choose a reason for hiding this comment

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

A

트랜잭션에 시간도 걸 수 있었군요 👀

public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package in.koreatech.koin.global.concurrent.exception;

import in.koreatech.koin.global.auth.exception.AuthorizationException;
import in.koreatech.koin.global.exception.KoinException;

public class ConcurrencyLockException extends KoinException {

private static final String DEFAULT_MESSAGE = "현재 요청을 처리할 수 없습니다. 잠시 후 다시 시도해 주세요.";

public ConcurrencyLockException(String message) {
super(message);
}

public ConcurrencyLockException(String message, String detail) {
super(message, detail);
}

public static ConcurrencyLockException withDetail(String detail) {
return new ConcurrencyLockException(DEFAULT_MESSAGE, detail);
}
}
34 changes: 34 additions & 0 deletions src/main/java/in/koreatech/koin/global/config/RedissonConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package in.koreatech.koin.global.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
@Profile("!test")
public class RedissonConfig {

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

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

@Value("${spring.data.redis.password:}")
private String redisPassword;

private static final String REDISSION_HOST_PREFIX = "redis://";

@Bean
public RedissonClient redissionClient() {
Config config = new Config();
config.useSingleServer()
.setAddress(REDISSION_HOST_PREFIX + redisHost + ":" + redisPort)
.setPassword(redisPassword.isEmpty() ? null : redisPassword);
return Redisson.create(config);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
ALTER TABLE `article_search_keywords`
MODIFY COLUMN `keyword` VARCHAR(255) NOT NULL UNIQUE;

ALTER TABLE article_search_keyword_ip_map
ADD CONSTRAINT unique_keyword_ip UNIQUE (keyword_id, ip_address),
ADD CONSTRAINT fk_keyword_id
FOREIGN KEY (keyword_id)
REFERENCES article_search_keywords (id)
ON DELETE CASCADE;

Copy link
Contributor

Choose a reason for hiding this comment

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

C

마지막 공백 라인 지워주세요~!! + 리뷰 후 머지 전 flyway 번호 수정!!!

5 changes: 4 additions & 1 deletion src/test/java/in/koreatech/koin/AcceptanceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@

@SpringBootTest
@AutoConfigureMockMvc
@Import({DBInitializer.class, TestJpaConfiguration.class, TestTimeConfig.class, TestRedisConfiguration.class})
@Import({DBInitializer.class,
TestJpaConfiguration.class,
TestTimeConfig.class,
TestRedisConfiguration.class})
@ActiveProfiles("test")
public abstract class AcceptanceTest {

Expand Down
87 changes: 81 additions & 6 deletions src/test/java/in/koreatech/koin/acceptance/ArticleApiTest.java
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
package in.koreatech.koin.acceptance;

import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import java.time.LocalDate;
import java.util.List;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;

import in.koreatech.koin.AcceptanceTest;
Expand Down Expand Up @@ -101,4 +95,85 @@ void givenBeforeEach() {
}
"""));
}

// 클래스 단에 transactional이 붙으면 테스트 실패 함
/* @Test
void 같은_ip_동일한_query로_4개의_스레드가_동시에_검색시_동시성_제어() throws InterruptedException {
String query = "sameQuery";
String ipAddress = "127.0.0.1";

ExecutorService executor = Executors.newFixedThreadPool(4);
CountDownLatch latch = new CountDownLatch(4);

List<Response> responseList = new ArrayList<>();

Runnable searchTask = () -> {
Response response = RestAssured
.given()
.queryParam("query", query)
.queryParam("boardId", 1)
.queryParam("page", 0)
.queryParam("limit", 10)
.header("X-Forwarded-For", ipAddress)
.when()
.get("articles/search");
responseList.add(response);
latch.countDown();
};

for (int i = 0; i < 4; i++) {
executor.submit(searchTask);
}

latch.await();

long successCount = responseList.stream()
.filter(response -> response.getStatusCode() == 200)
.count();

assertThat(successCount).isEqualTo(4);

executor.shutdown();
}

@Test
void 다른_IP에서_동일한_쿼리로_동시에_검색시_동시성_처리() throws InterruptedException {
String query = "sameQuery";

List<String> ipAddresses = List.of("127.0.0.1", "192.168.0.1", "10.0.0.1", "172.16.0.1");

ExecutorService executor = Executors.newFixedThreadPool(4);
CountDownLatch latch = new CountDownLatch(4);

List<Response> responseList = new ArrayList<>();

for (int i = 0; i < 4; i++) {
String ipAddress = ipAddresses.get(i);
Runnable searchTask = () -> {
Response response = RestAssured
.given()
.queryParam("query", query)
.queryParam("boardId", 1)
.queryParam("page", 0)
.queryParam("limit", 10)
.header("X-Forwarded-For", ipAddress)
.when()
.get("articles/search");
responseList.add(response);
latch.countDown();
};

executor.submit(searchTask);
}

latch.await();

long successCount = responseList.stream()
.filter(response -> response.getStatusCode() == 200)
.count();

assertThat(successCount).isEqualTo(4);

executor.shutdown();
} */
}
Loading
Loading