Skip to content

Commit 3e2e971

Browse files
authored
Merge pull request #300 from DevKor-github/revert-298-revert-297-develop
Revert "Revert "#286~#296 반영해 배포""
2 parents fd0c9d7 + 7662f5e commit 3e2e971

89 files changed

Lines changed: 3374 additions & 310 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/deploy-to-dev-ec2.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ jobs:
2222
- name: Grant execute permission for Gradle
2323
run: chmod +x gradlew
2424

25+
- name: Fix Docker API compatibility for Testcontainers
26+
run: echo 'api.version=1.44' > ~/.docker-java.properties
27+
2528
- name: Build with Gradle
2629
run: ./gradlew test bootJar
2730

.github/workflows/deploy-to-prod-ec2.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ jobs:
2222
- name: Grant execute permission for Gradle
2323
run: chmod +x gradlew
2424

25+
- name: Fix Docker API compatibility for Testcontainers
26+
run: echo 'api.version=1.44' > ~/.docker-java.properties
27+
2528
- name: Build with Gradle
2629
run: ./gradlew test bootJar
2730

.github/workflows/run-tests-on-pull-request-to-develop.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,8 @@ jobs:
2222
- name: Grant execute permission for Gradle
2323
run: chmod +x gradlew
2424

25+
- name: Fix Docker API compatibility for Testcontainers
26+
run: echo 'api.version=1.44' > ~/.docker-java.properties
27+
2528
- name: Run tests
2629
run: ./gradlew test

TESTING.md

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# 테스트 가이드
2+
3+
## 테스트 성능 최적화
4+
5+
이 프로젝트는 **Singleton Container 패턴**을 사용하여 테스트 실행 속도를 크게 향상시켰습니다.
6+
7+
### 주요 최적화 사항
8+
9+
1. **Singleton PostgreSQL Container**
10+
- 모든 테스트가 하나의 Testcontainers 인스턴스를 공유
11+
- 컨테이너는 첫 테스트 실행 시 한 번만 시작되고 JVM 종료 시까지 유지
12+
- 테스트 클래스마다 컨테이너를 시작/중지하는 오버헤드 제거
13+
14+
2. **Flyway 마이그레이션 최적화**
15+
- 컨테이너당 한 번만 마이그레이션 실행
16+
- `baseline-on-migrate: true` 설정으로 이미 마이그레이션된 DB 처리
17+
- `clean-disabled: true`로 불필요한 스키마 삭제 방지
18+
19+
3. **트랜잭션 기반 테스트 격리**
20+
- `@DataJpaTest`가 자동으로 `@Transactional` 제공
21+
- 각 테스트 메서드는 자동으로 롤백되어 독립성 보장
22+
- 별도 데이터 정리 로직 불필요
23+
24+
### 성능 개선 효과
25+
26+
- **이전**: 리포지토리 테스트 1개당 ~8초 (컨테이너 시작 + Flyway 마이그레이션)
27+
- **현재**: 첫 테스트 ~8초, 이후 테스트 ~1-2초
28+
- **전체 테스트 스위트**: 약 60-70% 시간 단축
29+
30+
## 리포지토리 테스트 작성 가이드
31+
32+
### 기본 구조
33+
34+
```java
35+
@DataJpaTest // 자동으로 @Transactional 포함
36+
@Import(YourRepository.class)
37+
@ActiveProfiles("test")
38+
class YourRepositoryTest extends AbstractPostgresContainerTest {
39+
40+
@Autowired
41+
TestEntityManager em;
42+
43+
@Autowired
44+
YourRepository repository;
45+
46+
@Test
47+
void testSomething() {
48+
// Given
49+
YourEntity entity = new YourEntity();
50+
em.persist(entity);
51+
em.flush();
52+
53+
// When
54+
YourEntity found = repository.findById(entity.getId()).orElseThrow();
55+
56+
// Then
57+
assertThat(found).isNotNull();
58+
59+
// 테스트 종료 시 자동 롤백 - 데이터 정리 불필요
60+
}
61+
}
62+
```
63+
64+
### 주의사항
65+
66+
#### ✅ DO
67+
68+
```java
69+
@DataJpaTest
70+
class GoodTest extends AbstractPostgresContainerTest {
71+
@Test
72+
void testWithAutoRollback() {
73+
// 테스트 로직
74+
// 자동 롤백됨 - 다음 테스트에 영향 없음
75+
}
76+
}
77+
```
78+
79+
#### ❌ DON'T
80+
81+
```java
82+
@DataJpaTest
83+
@Commit // ❌ 롤백 비활성화하지 마세요!
84+
class BadTest extends AbstractPostgresContainerTest {
85+
@Test
86+
void testWithCommit() {
87+
// 데이터가 커밋되어 다른 테스트에 영향
88+
}
89+
}
90+
```
91+
92+
```java
93+
@DataJpaTest
94+
@Transactional(propagation = Propagation.NOT_SUPPORTED) // ❌ 트랜잭션 비활성화하지 마세요!
95+
class BadTest extends AbstractPostgresContainerTest {
96+
@Test
97+
void testWithoutTransaction() {
98+
// 테스트 격리 깨짐
99+
}
100+
}
101+
```
102+
103+
### 통합 테스트 작성 시 주의사항
104+
105+
`@SpringBootTest`를 사용하는 통합 테스트에서도 Singleton Container의 혜택을 받을 수 있습니다:
106+
107+
```java
108+
@SpringBootTest
109+
@ActiveProfiles("test")
110+
@Transactional // 명시적으로 추가 필요
111+
class IntegrationTest extends AbstractPostgresContainerTest {
112+
113+
@Test
114+
void integrationTest() {
115+
// 통합 테스트 로직
116+
}
117+
}
118+
```
119+
120+
## 병렬 테스트 실행
121+
122+
Singleton Container 패턴은 병렬 테스트 실행과 호환됩니다:
123+
124+
```bash
125+
# Gradle에서 병렬 테스트 실행
126+
./gradlew test --parallel --max-workers=4
127+
```
128+
129+
트랜잭션 격리 덕분에 각 테스트가 독립적으로 실행되므로 안전합니다.
130+
131+
## 트러블슈팅
132+
133+
### Flyway 마이그레이션 충돌
134+
135+
테스트 실행 중 Flyway 오류가 발생하면:
136+
137+
```yaml
138+
# application-test.yml
139+
spring:
140+
flyway:
141+
baseline-on-migrate: true
142+
clean-disabled: true
143+
```
144+
145+
설정이 있는지 확인하세요.
146+
147+
### 테스트 간 데이터 오염
148+
149+
- `@DataJpaTest`가 적용되어 있는지 확인
150+
- `@Commit`이나 `@Transactional(propagation = NOT_SUPPORTED)` 사용 여부 확인
151+
- 필요시 `@Sql`로 특정 데이터 초기화:
152+
153+
```java
154+
@Test
155+
@Sql("/test-data/cleanup.sql")
156+
void testWithCleanup() {
157+
// 테스트 로직
158+
}
159+
```
160+
161+
## 참고 자료
162+
163+
- [Testcontainers 공식 문서](https://www.testcontainers.org/)
164+
- [Spring Boot Testing Best Practices](https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.testing)
Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
package org.devkor.apu.saerok_server.domain.admin.audit.application.dto;
22

3-
public record AdminAuditQueryCommand(Integer page, Integer size) {
3+
import org.devkor.apu.saerok_server.global.shared.util.Pageable;
44

5-
public boolean hasValidPagination() {
6-
if (page == null && size == null) return true;
7-
if (page == null || size == null) return false;
8-
return page >= 1 && size > 0;
9-
}
10-
}
5+
public record AdminAuditQueryCommand(
6+
Integer page,
7+
Integer size
8+
) implements Pageable {}

src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,15 @@ public void deleteCommentByReport(Long adminUserId, Long reportId, String reason
153153
// 1) 관련 신고 정리
154154
commentReportRepository.deleteByCommentId(commentId);
155155

156-
// 2) 댓글 삭제
156+
// 2) 댓글 삭제 (대댓글이 있으면 soft delete, 없으면 hard delete)
157157
UserBirdCollectionComment comment = commentRepository.findById(commentId)
158158
.orElseThrow(() -> new NotFoundException("해당 댓글이 존재하지 않아요"));
159-
commentRepository.remove(comment);
159+
160+
if (commentRepository.hasReplies(commentId)) {
161+
comment.ban();
162+
} else {
163+
commentRepository.remove(comment);
164+
}
160165

161166
// 3) 감사 기록
162167
User admin = userRepository.findById(adminUserId)

src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportQueryService.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionCommentsResponse;
99
import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionDetailResponse;
1010
import org.devkor.apu.saerok_server.domain.collection.application.CollectionCommentQueryService;
11+
import org.devkor.apu.saerok_server.domain.collection.application.dto.CommentQueryCommand;
1112
import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService;
1213
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection;
1314
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionComment;
@@ -99,7 +100,7 @@ public ReportedCollectionDetailResponse getReportedCollectionDetail(Long reportI
99100
);
100101

101102
// 댓글 목록 (관리자 기준 isLiked/isMine 계산 불필요)
102-
GetCollectionCommentsResponse comments = commentQueryService.getComments(collection.getId(), null);
103+
GetCollectionCommentsResponse comments = commentQueryService.getComments(collection.getId(), null, new CommentQueryCommand(null, null));
103104

104105
return new ReportedCollectionDetailResponse(report.getId(), collectionDetail, comments);
105106
}
@@ -126,7 +127,7 @@ public ReportedCommentDetailResponse getReportedCommentDetail(Long reportId) {
126127
);
127128

128129
// 댓글 목록
129-
GetCollectionCommentsResponse comments = commentQueryService.getComments(parentCollection.getId(), null);
130+
GetCollectionCommentsResponse comments = commentQueryService.getComments(parentCollection.getId(), null, new CommentQueryCommand(null, null));
130131

131132
// 신고된 댓글 정보
132133
ReportedCommentDetailResponse.ReportedComment commentDto =

src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatAggregationService.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import java.time.*;
1414
import java.util.EnumSet;
1515
import java.util.HashMap;
16+
import java.util.List;
1617
import java.util.Map;
1718
import java.util.Set;
1819

@@ -45,6 +46,9 @@ public void aggregateFor(LocalDate date, Set<StatMetric> metrics) {
4546
case USER_DAU -> aggregateUserDau(date);
4647
case USER_WAU -> aggregateUserWau(date);
4748
case USER_MAU -> aggregateUserMau(date);
49+
50+
case USER_SIGNUP_SOURCE_TOTAL -> aggregateUserSignupSourceTotal(date);
51+
case USER_DEVICE_PLATFORM_TOTAL -> aggregateUserDevicePlatformTotal(date);
4852
}
4953
}
5054
}
@@ -220,6 +224,47 @@ SELECT COUNT(DISTINCT user_id) FROM user_activity_ping
220224
dailyRepo.upsertValue(StatMetric.USER_MAU, date, n.longValue());
221225
}
222226

227+
/** 누적 가입 경로별 가입자 수 (스냅샷): signupCompletedAt < end, signupSource IS NOT NULL */
228+
private void aggregateUserSignupSourceTotal(LocalDate date) {
229+
var end = endExclusive(date);
230+
231+
@SuppressWarnings("unchecked")
232+
List<Object[]> rows = em.createQuery("""
233+
SELECT u.signupSource, COUNT(u) FROM User u
234+
WHERE u.signupCompletedAt < :end
235+
AND u.signupSource IS NOT NULL
236+
GROUP BY u.signupSource
237+
""")
238+
.setParameter("end", end)
239+
.getResultList();
240+
241+
Map<String, Object> payload = new HashMap<>();
242+
for (Object[] row : rows) {
243+
payload.put(row[0].toString(), ((Number) row[1]).longValue());
244+
}
245+
dailyRepo.upsertPayload(StatMetric.USER_SIGNUP_SOURCE_TOTAL, date, payload);
246+
}
247+
248+
/** 누적 플랫폼별 유니크 유저 수 (스냅샷): UserDevice.createdAt < end */
249+
private void aggregateUserDevicePlatformTotal(LocalDate date) {
250+
var end = endExclusive(date);
251+
252+
@SuppressWarnings("unchecked")
253+
List<Object[]> rows = em.createQuery("""
254+
SELECT ud.platform, COUNT(DISTINCT ud.user.id) FROM UserDevice ud
255+
WHERE ud.createdAt < :end
256+
GROUP BY ud.platform
257+
""")
258+
.setParameter("end", end)
259+
.getResultList();
260+
261+
Map<String, Object> payload = new HashMap<>();
262+
for (Object[] row : rows) {
263+
payload.put(row[0].toString(), ((Number) row[1]).longValue());
264+
}
265+
dailyRepo.upsertPayload(StatMetric.USER_DEVICE_PLATFORM_TOTAL, date, payload);
266+
}
267+
223268
/* Helpers */
224269

225270
private OffsetDateTime endExclusive(LocalDate date) {

src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatBatchScheduler.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ public void runDailyAggregation() {
4141
StatMetric.USER_WITHDRAWAL_DAILY,
4242
StatMetric.USER_DAU,
4343
StatMetric.USER_WAU,
44-
StatMetric.USER_MAU
44+
StatMetric.USER_MAU,
45+
46+
StatMetric.USER_SIGNUP_SOURCE_TOTAL,
47+
StatMetric.USER_DEVICE_PLATFORM_TOTAL
4548
)) {
4649
var last = dailyRepo.findLastDateOf(metric).orElse(null);
4750
LocalDate from = (last == null) ? yesterday : last.plusDays(1);

src/main/java/org/devkor/apu/saerok_server/domain/admin/stat/application/StatQueryService.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
import org.springframework.stereotype.Service;
1010
import org.springframework.transaction.annotation.Transactional;
1111

12+
import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform;
13+
import org.devkor.apu.saerok_server.domain.user.core.entity.SignupSourceType;
14+
1215
import java.time.LocalDate;
1316
import java.time.format.DateTimeParseException;
1417
import java.util.*;
@@ -51,6 +54,25 @@ public StatSeriesResponse getSeries(List<StatMetric> metrics, String period) {
5154
);
5255

5356
out.add(StatSeriesResponse.multi(m.name(), List.of(minSeries, maxSeries, avgSeries, stdSeries)));
57+
58+
} else if (m == StatMetric.USER_SIGNUP_SOURCE_TOTAL) {
59+
List<StatSeriesResponse.ComponentSeries> components = Arrays.stream(SignupSourceType.values())
60+
.map(src -> new StatSeriesResponse.ComponentSeries(
61+
src.name(),
62+
rows.stream().map(s ->
63+
new StatSeriesResponse.Point(s.getDate(), numberOrNull(s.getPayload().get(src.name())))).toList()
64+
)).toList();
65+
out.add(StatSeriesResponse.multi(m.name(), components));
66+
67+
} else if (m == StatMetric.USER_DEVICE_PLATFORM_TOTAL) {
68+
List<StatSeriesResponse.ComponentSeries> components = Arrays.stream(DevicePlatform.values())
69+
.map(p -> new StatSeriesResponse.ComponentSeries(
70+
p.name(),
71+
rows.stream().map(s ->
72+
new StatSeriesResponse.Point(s.getDate(), numberOrNull(s.getPayload().get(p.name())))).toList()
73+
)).toList();
74+
out.add(StatSeriesResponse.multi(m.name(), components));
75+
5476
} else {
5577
List<StatSeriesResponse.Point> points = rows.stream()
5678
.map(s -> new StatSeriesResponse.Point(s.getDate(), numberOrNull(s.getPayload().get("value"))))

0 commit comments

Comments
 (0)