Skip to content

Commit 64bbbab

Browse files
slimsha2dyLibienznak-honesteunjungL
authored
[Feature] - 프로덕션 API v1.4.0 배포 (#659)
* [Feature] - graceful shutdown 설정 추가 #615 * [Feature] - BE 테스트 개선 1단계 Test Fixture 운용 방식 통일 (Enum Fixture) (#614) * test: OauthUserFixture - Enum 기반의 Fixture로 전환 * test: MemberFixture - EnumFixture 컨벤션 통일 개선 * test: MemberFixture 속성을 가지는 Member를 만드는 Request 객체 생성하는 기능 추가 * test: MemberFixture 속성을 기반으로 Request 객체를 만들도록 테스트 코드 수정 * test: OauthUserFixture의 팩토리 메서드 이름 수정 (createXXX -> getXXX) * test: MemberFixture의 팩토리 메서드 이름 수정 (createXXX -> getXXX) * test: 사용되지 않는 Fixture 클래스 제거 개선 * test: Travelogue관련 Enum Fixture 컨벤션 통일 개 * test: TravelogueCountry, TravelogueDay 도메인 테스트 중복 데이터 셋팅 제거 개선 * test: Request 객체 생성 도메인 Fixture 내부로 응집 * test: 도메인 구성 시 연관관계를 직접 주입하도록 테스트 코드 수정 * test: CountryCode 패키지 위치 수정 * test: TraveloguePhotoTest 연관관계 도메인 주입을 셋업 코드로 이동 개선 * test: Fixture를 통한 도메인 생성 시 연관관계를 주입하도록 로직 수정 * test: 사용하지 않는 상수 제거 개선 * test: 반복되는 메서드 모킹 setUp으로 이동 * test: 반복되는 메서드 모킹 setUp으로 이동 * test: 연관 관계를 가지는 Fixture를 주입받아 Request객체를 생성하는 기능 추가 * test: TravelogueRequestBuilder 구현 * test: TravelogueRequestBuilder에서 DayBuilder 체크로직 제거 * test: 실패하는 테스트 수정 * test: RequestBuilder 객체 패키지 이동 fixture -> helper * test: TravelPlan관련 fixture에 연관 도메인 관련 내용 제거 * test: TravelPlaceTodoFixture 구현 * test: Fixture를 통해서 Request 객체 만드는 기능 구현 * test: TravelPLanRequestBuilder 구현 * test: 과거 날짜 여행기 픽스쳐 추가 * test: Builder 클래스 개행 수정 * test: TravelPlanControllerTest에서 Request를 생성 시 RequestBuilder를 이용하도록 개선 * test: 사용하지 않는 테스트 상수 제거 개선 * test: TravelogueCountryTest 줄바꿈 컨벤션 적용 * test: TraveloguePlaceFixture 줄바꿈 컨벤션 적용 및 사용하지 않는 메서드 제거 개선 * test: TravelogueRequestBuilder와 TravelogueDayRequestBuilder 별도 클래스로 분리 * test: TravelPlanRequestBuilder와 PlanDayRequestBuilder 별도 클래스로 분리 * test: MemberFixture 이름 변경 DEFAULT_MEMBER -> TOUROOT_LOCAL_USER * [Feature] - 멤버 닉네임 중복을 허용 (#618) * feat: 멤버 닉네임 중복 검증 로직 제거 * test: 중복 닉네임 멤버 생성 테스트 제거 * [Feature] - 프로덕션 모니터링 구축 (#620) * chore: 프로덕션 loki 와 연결 * chore: 로그백 xml 파일에서 jasypt로 암호화 된 loki url을 복호화 하도록 스프링 부트의 초기화 동작 정의 * refactor: 컨벤션을 지키도록 개행 추가 Co-authored-by: eunjungL <[email protected]> * chore: dev loki url 암호화 --------- Co-authored-by: eunjungL <[email protected]> * [Feature] - BE 테스트 개선 2단계 Testcontainers 도입 (#623) * feat: testcontainers 의존성 추가 * feat: default profile logback CONSOLE appender 추가 * feat: 내장 H2 자동 활성화 제거 * feat: test datasource mysql testcontainers 도입 * fix: MySQL 문법 오류 수정 * feat: 테스트 컨테이너에 초기화 시 flyway가 실행되도록 설정 * feat: 테스트컨테이너 localstack 의존성 추가 * refactor: 내장 S3 Mocking 설정이 local 환경에만 적용되도록 수정 * feat: LocalStackContainer를 사용하는 S3TestConfig 작성 * feat: 테스트 관련 s3 클라우드 속성 값 수정 * test: 서비스 테스트가 테스트 컨테이너로 구성된 S3 설정을 바라보도록 수정 * feat: 테스트용 프로파일 이름 test로 설정 * feat: 기존 테스트에서 사용되는 프로파일 이름 수정 default -> test * feat: 각 테스트에서 test 프로파일로 테스트를 실행시키도록 수정 * refactor: s3 업로드 실패 시 stackTrace를 로깅하도록 핸들러 수정 * fix: 테스트 S3 버킷 images-base-uri 수정 * fix: 테스트에서 사용되는 Temporary 이미지 경로 영구저장소로 변경 * feat: S3 컨테이너 모킹 로직 제거 및 테스트컨테이너를 사용하도록 수정 * refactor: Testcontainers 설정 IntegrationTest로 통합 * refactor: S3 Bucket localStackContainer 실행 시 한 번만 생성하도록 변경 * feat: Controller, Service 계층 테스트에 IntegrationTest 상속 추가 * refactor: IntergrationTest S3 Bucket 이름 상수화 --------- Co-authored-by: eunjungL <[email protected]> * [Feature] - BE 테스트 개선 스프링 컨텍스트 캐싱 최적화 (#625) * refactor: 모든 서비스 테스트 스프링 부트 테스트 기반으로 통일 개 * refactor: IntegerationTest -> AbstractIntegrationTest 이름 변경 * refactor: 컨트롤러 테스트 구조 계층화 * refactor: 테스트 공통 로직 메서드명 변경 * refactor: 컨트롤러 테스트가 공통 로직을 사용하도록 추상화된 클래스 상속 * feat: 서비스 통합 테스트 추상 클래스 정의 * feat: 서비스 통합 테스트들이 추상화된 공통로직을 사용하도록 상속 * refactor: 사용하지 않는 애너테이션 제거 개선 * refactor: 불필요한 MockBean 제거 (MemberRepository, JwtTokenProvider) * refactor: MockBean 속성 접근 제어 수준 강화 (protected -> private) * [Feature] - Redis cache 도입 (#629) * feat: spring-data-redis 의존성 추가 * feat: CacheManager Redis로 변경 * refactor: EnableCaching 추가 * refactor: CacheConfig package global.config로 이동 * feat: redis 관련 설정 yml 추가 * style: 줄바꿈 컨벤션 준수 * feat: Testcontainers 레디스 모듈 의존성 추가 * feat: Testcontainers 기반 통합테스트 클래스에 레디스 컨테이너 추가 --------- Co-authored-by: libienz <[email protected]> * [Feature] - 모니터링/로깅 시스템 통합 (#633) * chore: dev 환경 톰캣에서 SSL 인증서 제거 * chore: prod loki url 변경 * chore: 깃허브 액션에서 ssl keystore 관련 부분 제거 * [Feature] - 좋아요 순 여행기 목록 조회 페이징 캐싱 (#631) * feat: 좋아요 순 여행기 목록 조회에 Cache 적용 * feat: PageImpl Deserializer 추가 * fix: PageDeserializer 제대로 동작되게 수정 * refactor: PageDeserializer 클래스 코드 정리 * fix: Sort 객체가 무조건 unsorted로 처리되던 현상 수정 * style: 필요 없는 로그 제거 * feat: 여행기 컨텐츠 페이지 캐싱 범위 설정 (PageNumber가 4이하인 것들만 캐싱) * refactor: CacheConfig 불필요한 애너테이션 제거 개선 * feat: Cache Default TTL 1시간에서 30분으로 변경 --------- Co-authored-by: libienz <[email protected]> * [Feature] - 레디스 관련 테스트 코드 작성 (#637) * test: 여행기 페이징 조회 기능 캐시 등록 테스트 작성 * test: 여행기 페이징 조회 기능 캐시 조건 테스트 작성 * test: 여행기 페이징 캐시 TTL 테스트 작성 * test: 테스트간 캐시 격리를 위한 캐시 tearDown 로직 작성 * test: ttl이 30분인지 테스트할 때 Expiration이 29-30분 사이인지 테스트 하도록 수정 * [Fix] - dev 환경 Redis host 수정 (#642) * [Feature] - 인기 여행기 페이징 응답 캐싱 무효화 전략 개선 (#644) * feat: 여행기 페이지 캐싱 시 좋아요 순 페이지 요청일 경우에만 캐싱하도록 수정 * refactor: 캐싱하는 최대 페이지 번호 상수화 * feat: Repository 통합 테스트 추상 클래스 작성 * feat: 좋아요 개수가 특정 등수만큼 많은 여행기의 좋아요 개수 조회 기능 구현 * test: 특정 등수 여행기의 좋아요 개수 조회 기능 테스트 작성 * fix: 좋아요 개수 반환 long 타입으로 수정 * feat: 여행기 좋아요가 특정 수치 이상인지 확인하는 기능 추가 * feat: 여행기 좋아요 변경 시 특정 수치 이상이면 페이징 캐싱을 무효화하는 로직 작성 * refactor: 캐시 이름 상수화 개선 * fix: Repository 테스트 롤백 기능 구현 * style: 캐시 무효화 로직 메서드 이름 변경 * style: 스태틱 임포트 제거 개선 * style: 테스트 코드 개행 개선 * [Feature] - default_batch_fetch_size 적용 (#647) * feat: default_batch_fetch_size - 100 설정 * feat: test 프로파일 default_batch_fetch_size - 100 설정 * [Feature] - TravelogueLike 조회를 fetch join으로 개선 (#649) * feat: 사용자가 좋아요한 데이터를 여행기와 여행기 작성자 정보와 함께 가져오는 기능 구현 * refactor: 사용자가 좋아요한 정보를 가져올 때 fetch join 쿼리를 사용하도록 메서드 변경 * refactor: 사용하지 않는 메서드 삭제 개선 * [Fix] - 서로 다른 테이블에 중복 저장되는 여행기 좋아요 정보 궁극적 일관성 확보 (#651) * fix: Repository 테스트에서 DataJpaTest로 인해 실 서비스와 다른 트랜잭션이 생성되지 않도록 수정 * feat: 실제 좋아요 정보와 여행기에 저장된 좋아요 개수를 동기화 하는 기능 추가 * feat: 매주 월요일 오전 3시 좋아요 개수를 동기화하는 배치 작업이 실행되도록 스케줄러 등록 * feat: ScheduleConfig에서 스케줄링 Enable * feat: redis 엔드포인트 수정 (#655) * feat: prod db 엔드포인트 수정 (#658) --------- Co-authored-by: 리비 <[email protected]> Co-authored-by: 이낙헌 <[email protected]> Co-authored-by: eunjungL <[email protected]> Co-authored-by: eunjungL <[email protected]>
1 parent afcdd3e commit 64bbbab

File tree

83 files changed

+2024
-1067
lines changed

Some content is hidden

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

83 files changed

+2024
-1067
lines changed

.github/workflows/be-cd-dev.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,6 @@ jobs:
2121
distribution: 'temurin'
2222
java-version: '17'
2323

24-
- name: Make keystore file
25-
run: echo "${{secrets.SSL_KEYSTORE}}" | base64 --decode > ./src/main/resources/keystore.p12
26-
2724
- name: Gradle Caching
2825
uses: actions/cache@v3
2926
with:

backend/build.gradle

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,16 @@ dependencies {
5151
testAnnotationProcessor 'org.projectlombok:lombok'
5252
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
5353

54+
// testcontainers
55+
testImplementation "org.testcontainers:testcontainers:1.20.4"
56+
testImplementation "org.testcontainers:junit-jupiter:1.20.4"
57+
testImplementation "org.testcontainers:mysql"
58+
testImplementation 'org.testcontainers:localstack'
59+
testImplementation 'com.redis:testcontainers-redis:2.2.2'
60+
5461
// cache
5562
implementation 'org.springframework.boot:spring-boot-starter-cache'
63+
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
5664
implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
5765

5866
// QueryDSL
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package kr.touroot;
22

3-
import org.springframework.boot.SpringApplication;
3+
import com.ulisesbocchio.jasyptspringboot.environment.StandardEncryptableEnvironment;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.boot.builder.SpringApplicationBuilder;
56
import org.springframework.cache.annotation.EnableCaching;
67
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
78

@@ -11,6 +12,9 @@
1112
public class TourootApplication {
1213

1314
public static void main(String[] args) {
14-
SpringApplication.run(TourootApplication.class, args);
15+
new SpringApplicationBuilder()
16+
.environment(new StandardEncryptableEnvironment())
17+
.sources(TourootApplication.class)
18+
.run(args);
1519
}
1620
}

backend/src/main/java/kr/touroot/authentication/service/LoginService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public LoginResponse login(String code, String encodedRedirectUri) {
3434
return LoginResponse.of(member, tokenProvider.createToken(member.getId()));
3535
}
3636

37-
private Member signUp(OauthUserInformationResponse userInformation) {
37+
public Member signUp(OauthUserInformationResponse userInformation) {
3838
return memberRepository.save(userInformation.toMember());
3939
}
4040

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package kr.touroot.global.config;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping;
5+
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
6+
import com.fasterxml.jackson.databind.module.SimpleModule;
7+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
8+
import java.time.Duration;
9+
import kr.touroot.global.util.PageDeserializer;
10+
import kr.touroot.global.util.SortDeserializer;
11+
import org.springframework.cache.CacheManager;
12+
import org.springframework.context.annotation.Bean;
13+
import org.springframework.context.annotation.Configuration;
14+
import org.springframework.data.domain.PageImpl;
15+
import org.springframework.data.domain.Sort;
16+
import org.springframework.data.redis.cache.RedisCacheConfiguration;
17+
import org.springframework.data.redis.cache.RedisCacheManager;
18+
import org.springframework.data.redis.connection.RedisConnectionFactory;
19+
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
20+
import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
21+
import org.springframework.data.redis.serializer.StringRedisSerializer;
22+
23+
@Configuration
24+
public class CacheConfig {
25+
26+
private static final Duration CACHE_TTL = Duration.ofMinutes(30);
27+
28+
@Bean
29+
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
30+
return RedisCacheManager.builder(connectionFactory)
31+
.cacheDefaults(getRedisCacheConfiguration())
32+
.build();
33+
}
34+
35+
private RedisCacheConfiguration getRedisCacheConfiguration() {
36+
SerializationPair<String> keySerializationPair = SerializationPair.fromSerializer(new StringRedisSerializer());
37+
SerializationPair<Object> valueSerializationPair = SerializationPair.fromSerializer(
38+
new GenericJackson2JsonRedisSerializer(getObjectMapperForRedisCacheManager())
39+
);
40+
41+
return RedisCacheConfiguration.defaultCacheConfig()
42+
.serializeKeysWith(keySerializationPair)
43+
.serializeValuesWith(valueSerializationPair)
44+
.entryTtl(CACHE_TTL)
45+
.disableCachingNullValues();
46+
}
47+
48+
private ObjectMapper getObjectMapperForRedisCacheManager() {
49+
ObjectMapper objectMapper = new ObjectMapper();
50+
51+
SimpleModule pageModule = new SimpleModule();
52+
pageModule.addDeserializer(PageImpl.class, new PageDeserializer());
53+
pageModule.addDeserializer(Sort.class, new SortDeserializer());
54+
55+
objectMapper.registerModules(new JavaTimeModule(), pageModule);
56+
objectMapper.activateDefaultTyping(
57+
BasicPolymorphicTypeValidator.builder().allowIfBaseType(Object.class).build(),
58+
DefaultTyping.EVERYTHING
59+
);
60+
61+
return objectMapper;
62+
}
63+
}

backend/src/main/java/kr/touroot/global/config/EmbeddedS3Config.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
1515

1616
@Configuration
17-
@Profile({"default", "local"})
17+
@Profile("local")
1818
public class EmbeddedS3Config {
1919

2020
private static final int DYNAMIC_PORT_NUMBER_LOWER = 49152;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package kr.touroot.global.config;
2+
3+
import org.springframework.context.annotation.Configuration;
4+
import org.springframework.scheduling.annotation.EnableScheduling;
5+
6+
@Configuration
7+
@EnableScheduling
8+
public class SchedulingConfig {
9+
}

backend/src/main/java/kr/touroot/global/exception/GlobalExceptionHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public ResponseEntity<ExceptionResponse> handleException(Exception exception) {
7373

7474
@ExceptionHandler(S3UploadException.class)
7575
public ResponseEntity<ExceptionResponse> handleS3UploadException(S3UploadException exception) {
76-
log.warn("S3_UPLOAD_EXCEPTION :: message = {}", exception.getMessage());
76+
log.warn("S3_UPLOAD_EXCEPTION :: stackTrace = ", exception);
7777

7878
ExceptionResponse data = new ExceptionResponse("이미지 업로드에 실패했습니다.");
7979
return ResponseEntity.badRequest()
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package kr.touroot.global.util;
2+
3+
import com.fasterxml.jackson.core.JsonParseException;
4+
import com.fasterxml.jackson.core.JsonParser;
5+
import com.fasterxml.jackson.core.JsonToken;
6+
import com.fasterxml.jackson.databind.DeserializationContext;
7+
import com.fasterxml.jackson.databind.JsonDeserializer;
8+
import java.io.IOException;
9+
import java.util.ArrayList;
10+
import java.util.List;
11+
import org.springframework.data.domain.PageImpl;
12+
import org.springframework.data.domain.PageRequest;
13+
import org.springframework.data.domain.Sort;
14+
15+
public class PageDeserializer extends JsonDeserializer<PageImpl<?>> {
16+
17+
@Override
18+
public PageImpl<?> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
19+
List<Object> content = new ArrayList<>();
20+
int pageNumber = 0;
21+
int pageSize = 0;
22+
long totalElements = 0;
23+
Sort sort = Sort.unsorted();
24+
25+
if (p.getCurrentToken() != JsonToken.START_OBJECT) {
26+
throw new JsonParseException("Page 객체를 역직렬화하는 과정에서 예외가 발생했습니다.");
27+
}
28+
29+
while (p.nextToken() != JsonToken.END_OBJECT) {
30+
String fieldName = p.getCurrentName();
31+
p.nextToken();
32+
33+
if (fieldName == null) {
34+
continue;
35+
}
36+
37+
if (fieldName.equals("content") && p.getCurrentToken() == JsonToken.START_ARRAY) {
38+
content = ctxt.readValue(
39+
p,
40+
ctxt.getTypeFactory().constructCollectionType(List.class, Object.class)
41+
);
42+
continue;
43+
}
44+
45+
if (fieldName.equals("number")) {
46+
pageNumber = p.getIntValue();
47+
continue;
48+
}
49+
50+
if (fieldName.equals("size")) {
51+
pageSize = p.getIntValue();
52+
continue;
53+
}
54+
55+
if (fieldName.equals("totalElements")) {
56+
totalElements = p.getLongValue();
57+
continue;
58+
}
59+
60+
if (fieldName.equals("sort")) {
61+
while (p.getCurrentToken() == JsonToken.START_OBJECT) {
62+
p.nextToken();
63+
}
64+
sort = ctxt.readValue(p, Sort.class);
65+
continue;
66+
}
67+
68+
p.skipChildren();
69+
}
70+
71+
PageRequest pageable = PageRequest.of(pageNumber, pageSize, sort);
72+
return new PageImpl<>(content, pageable, totalElements);
73+
}
74+
}
75+
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package kr.touroot.global.util;
2+
3+
import com.fasterxml.jackson.core.JsonParseException;
4+
import com.fasterxml.jackson.core.JsonParser;
5+
import com.fasterxml.jackson.core.JsonToken;
6+
import com.fasterxml.jackson.databind.DeserializationContext;
7+
import com.fasterxml.jackson.databind.JsonDeserializer;
8+
import java.io.IOException;
9+
import java.util.ArrayList;
10+
import java.util.List;
11+
import org.springframework.data.domain.Sort;
12+
import org.springframework.data.domain.Sort.Direction;
13+
import org.springframework.data.domain.Sort.Order;
14+
15+
public class SortDeserializer extends JsonDeserializer<Sort> {
16+
17+
@Override
18+
public Sort deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
19+
List<Sort.Order> orders = new ArrayList<>();
20+
boolean sorted = false;
21+
22+
if (p.getCurrentToken() != JsonToken.START_OBJECT) {
23+
throw new JsonParseException("Sort 객체를 역직렬화하는 과정에서 예외가 발생했습니다.");
24+
}
25+
26+
while (p.nextToken() != JsonToken.END_OBJECT) {
27+
String fieldName = p.getCurrentName();
28+
p.nextToken();
29+
30+
if (fieldName.equals("sorted")) {
31+
sorted = p.getBooleanValue();
32+
continue;
33+
}
34+
35+
if (fieldName.equals("orders")) {
36+
deserializeOrders(p, orders);
37+
continue;
38+
}
39+
40+
p.skipChildren();
41+
}
42+
43+
if (sorted) {
44+
return Sort.by(Sort.Order.asc("dummy"));
45+
}
46+
47+
return Sort.unsorted();
48+
}
49+
50+
private void deserializeOrders(JsonParser p, List<Order> orders) throws IOException {
51+
if (p.getCurrentToken() != JsonToken.START_ARRAY) {
52+
throw new JsonParseException("Sort.orders 객체를 역직렬화하는 과정에서 예외가 발생했습니다.");
53+
}
54+
55+
while (p.nextToken() != JsonToken.END_ARRAY) {
56+
String direction = null;
57+
String property = null;
58+
59+
while (p.nextToken() != JsonToken.END_OBJECT) {
60+
String fieldName = p.getCurrentName();
61+
62+
if (fieldName.equals("property")) {
63+
property = p.getText();
64+
continue;
65+
}
66+
67+
if (fieldName.equals("direction")) {
68+
direction = p.getText();
69+
}
70+
}
71+
72+
if (property != null && direction != null) {
73+
orders.add(new Order(Direction.fromString(direction), property));
74+
}
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)