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
22 changes: 16 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ jobs:
java-version: '17'
distribution: 'corretto'

- name: Start Redis container
run: docker run -d --name redis-test -p 6379:6379 redis:7-alpine

- name: Cache Gradle
uses: actions/cache@v3
with:
Expand All @@ -32,8 +35,8 @@ jobs:

- name: Generate application-test.yml
run: |
mkdir -p src/main/resources
cat <<EOF > src/main/resources/application-test.yml
mkdir -p src/test/resources
cat <<EOF > src/test/resources/application-test.yml
spring:
datasource:
driver-class-name: org.h2.Driver
Expand All @@ -43,15 +46,22 @@ jobs:

jpa:
hibernate:
ddl-auto: create
ddl-auto: create-drop
show-sql: true
database-platform: org.hibernate.dialect.H2Dialect
properties:
hibernate:
# H2에서 MySQL 문법을 사용하기 위한 설정
dialect: org.hibernate.dialect.H2Dialect
# 이 설정을 추가하여 모든 DB 식별자(테이블, 컬럼명 등)에 따옴표를 붙입니다.
# "user"와 같은 예약어와의 충돌을 근본적으로 방지합니다.
globally_quoted_identifiers: true

data:
redis:
host: ${{ secrets.REDIS_HOST }}
port: ${{ secrets.REDIS_PORT }}
password: ${{ secrets.REDIS_PASSWORD }}
host: localhost
port: 6379
#password: ""

jwt:
secret-key: ${{ secrets.JWT_SECRET_KEY }}
Expand Down
36 changes: 32 additions & 4 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,19 @@ java {
}

configurations {
compileOnly {
extendsFrom annotationProcessor
}
compileOnly {
extendsFrom annotationProcessor
}
// 테스트 시 slf4j-simple 구현체가 포함되지 않도록 전역적으로 제외하여 로깅 충돌 방지
testImplementation {
exclude group: 'org.slf4j', module: 'slf4j-simple'
}
}

repositories {
mavenCentral()
mavenCentral()
// embedded-redis 라이브러리가 호스팅되는 JitPack 저장소 추가
maven { url 'https://jitpack.io' }
}

dependencies {
Expand Down Expand Up @@ -54,6 +60,28 @@ dependencies {

// firebase
implementation 'com.google.firebase:firebase-admin:9.2.0'

// =================== 테스트 관련 의존성 ===================
// Spring Boot 기본 테스트 스타터 (JUnit 5, Mockito, AssertJ 등 포함)
testImplementation 'org.springframework.boot:spring-boot-starter-test'

// Mockito와 JUnit 5 연동을 위한 의존성 명시적 추가
testImplementation 'org.mockito:mockito-junit-jupiter'

// Spring Security 테스트 지원
testImplementation 'org.springframework.security:spring-security-test'

// 테스트용 인메모리 DB
testImplementation 'com.h2database:h2'

// 테스트 실행 시 내장 Redis 서버를 사용하기 위한 의존성
testImplementation 'com.github.codemonstur:embedded-redis:0.7.3'

// 테스트에서 MapStruct 프로세서를 사용하기 위함
testAnnotationProcessor "org.mapstruct:mapstruct-processor:1.5.5.Final"

// JUnit Platform 실행을 위한 런처
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ public interface AccessLogRepository extends JpaRepository<AccessLog, Long>, Jpa
"AND e.accessTime > a.accessTime)")
int countCurrentUsers();

@Query("SELECT DATE(a.accessTime) as date, COUNT(DISTINCT a.user.id) as userCnt " +
"FROM AccessLog a " +
"WHERE a.accessType = 'ENTRY' " +
"AND a.accessTime >= :startDate " +
"AND a.accessTime < :endDate " +
"GROUP BY DATE(a.accessTime)")
List<Object[]> countDailyUsers(@Param("startDate") LocalDateTime startDate,
@Query("SELECT CAST(a.accessTime AS date) as date, COUNT(DISTINCT a.user.id) as userCnt " +
"FROM AccessLog a " +
"WHERE a.accessType = 'ENTRY' " +
"AND a.accessTime >= :startDate " +
"AND a.accessTime < :endDate " +
"GROUP BY CAST(a.accessTime AS date)")
List<Object[]> countDailyUsers(@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package com.deepnyangning.capstonebe.integration.domain.statistics.service;

import com.deepnyangning.capstonebe.domain.access.entity.AccessLog;
import com.deepnyangning.capstonebe.domain.access.entity.AccessType;
import com.deepnyangning.capstonebe.domain.access.event.AccessEventListener;
import com.deepnyangning.capstonebe.domain.access.repository.AccessLogRepository;
import com.deepnyangning.capstonebe.domain.statistics.service.CongestionService;
import com.deepnyangning.capstonebe.domain.user.entity.Role;
import com.deepnyangning.capstonebe.domain.user.entity.User;
import com.deepnyangning.capstonebe.domain.user.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StopWatch;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

@Transactional
@SpringBootTest
@ActiveProfiles("test")
class CongestionServicePerformanceTest {

// =================== 테스트 조건 변수 ===================
private static final int TOTAL_USERS = 200; // DB에 생성할 총 사용자 수
private static final int CURRENT_USERS = 150; // 현재 이용자로 설정할 사용자 수
private static final int CONCURRENT_THREADS = 1000; // 동시 요청 스레드 수
// ======================================================

@Autowired
private CongestionService congestionService;

@Autowired
private AccessLogRepository accessLogRepository;

@Autowired
private UserRepository userRepository;

@MockBean
private AccessEventListener accessEventListener;

@Autowired
private RedisTemplate<String, String> redisTemplate;

private static final String CACHE_KEY_CURRENT = "congestion:current_users";
private static final String CACHE_KEY_AVG = "congestion:avg_users";

@BeforeEach
void setUp() {
redisTemplate.getConnectionFactory().getConnection().flushAll();

List<User> users = IntStream.range(0, TOTAL_USERS)
.mapToObj(i -> User.builder()
.identifier("user" + i)
.password("password123!")
.name("테스트유저" + i)
.role(Role.USER)
.build())
.collect(Collectors.toList());
userRepository.saveAllAndFlush(users);

List<AccessLog> pastLogs = new ArrayList<>();
for (int i = 1; i <= 7; i++) {
int dailyUserCount = ThreadLocalRandom.current().nextInt(100, 150);
LocalDateTime date = LocalDateTime.now().minusDays(i);
for (int j = 0; j < dailyUserCount; j++) {
pastLogs.add(AccessLog.builder()
.user(users.get(ThreadLocalRandom.current().nextInt(users.size())))
.accessType(AccessType.ENTRY)
.accessTime(date)
.build());
}
}
accessLogRepository.saveAllAndFlush(pastLogs);

List<AccessLog> recentLogs = new ArrayList<>();
for (int i = 0; i < CURRENT_USERS; i++) {
recentLogs.add(AccessLog.builder()
.user(users.get(i))
.accessType(AccessType.ENTRY)
.accessTime(LocalDateTime.now().minusHours(1))
.build());
}
for (int i = CURRENT_USERS; i < TOTAL_USERS; i++) {
recentLogs.add(AccessLog.builder()
.user(users.get(i))
.accessType(AccessType.ENTRY)
.accessTime(LocalDateTime.now().minusHours(2))
.build());
recentLogs.add(AccessLog.builder()
.user(users.get(i))
.accessType(AccessType.EXIT)
.accessTime(LocalDateTime.now().minusHours(1))
.build());
}
accessLogRepository.saveAllAndFlush(recentLogs);
}

@Test
@DisplayName("캐시 미적용 시, 동시 요청 성능 테스트")
void performanceTest_Without_Cache() throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(CONCURRENT_THREADS);
CountDownLatch latch = new CountDownLatch(CONCURRENT_THREADS);
StopWatch stopWatch = new StopWatch();

stopWatch.start();
for (int i = 0; i < CONCURRENT_THREADS; i++) {
executorService.submit(() -> {
try {
redisTemplate.delete(List.of(CACHE_KEY_CURRENT, CACHE_KEY_AVG));
congestionService.getCongestionData();
} finally {
latch.countDown();
}
});
}
latch.await();
stopWatch.stop();

System.out.println("===== 캐시 미적용 테스트 결과 =====");
System.out.printf("총 요청 수: %d\n", CONCURRENT_THREADS);
System.out.printf("총 소요 시간: %d ms\n", stopWatch.getTotalTimeMillis());
}

@Test
@DisplayName("캐시 적용 시, 동시 요청 성능 테스트")
void performanceTest_With_Cache() throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(CONCURRENT_THREADS);
CountDownLatch latch = new CountDownLatch(CONCURRENT_THREADS);
StopWatch stopWatch = new StopWatch();

// 첫 요청으로 캐시를 미리 생성
congestionService.getCongestionData();

stopWatch.start();
for (int i = 0; i < CONCURRENT_THREADS; i++) {
executorService.submit(() -> {
try {
congestionService.getCongestionData();
} finally {
latch.countDown();
}
});
}
latch.await();
stopWatch.stop();

System.out.println("\n===== 캐시 적용 테스트 결과 =====");
System.out.printf("총 요청 수: %d\n", CONCURRENT_THREADS);
System.out.printf("총 소요 시간: %d ms\n", stopWatch.getTotalTimeMillis());
}
}