Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
3b0def9
✨ 새 기능 : 커스텀 및 기본 데이터 삽입/삭제를 위한 SQL 스크립트 추가
rowing0328 Mar 26, 2025
98ade06
✨ 새 기능 : 커스텀 및 기본 데이터 삽입/삭제를 위한 SQL 스크립트 추가
rowing0328 Mar 26, 2025
99ad292
✨ 새 기능 : 도메인 엔티티 테스트용 Fixture 클래스 추가
rowing0328 Mar 26, 2025
0d17927
✨ 새 기능 : Article 엔티티 추가 및 기본 기능 구현
rowing0328 Mar 27, 2025
04623d8
✨ 새 기능 : ArticleType enum 구현 및 유효성 검사 로직 추가
rowing0328 Mar 27, 2025
087091e
✨ 새 기능 : Blog 엔티티 생성 및 기본 기능 구현
rowing0328 Mar 27, 2025
2a77424
✨ 새 기능 : Comment 엔티티 구현 및 기본 기능 추가
rowing0328 Mar 27, 2025
4d985c3
✨ 새 기능 : Scrap 엔티티 구현 및 스크랩 기능 기본 로직 추가
rowing0328 Mar 27, 2025
56831e0
✨ 새 기능 : Like 엔티티 구현 및 기본 좋아요 기능 추가
rowing0328 Mar 27, 2025
3594155
✨ 새 기능 : SortType enum 구현 및 정렬 타입 검증 로직 추가
rowing0328 Mar 27, 2025
ab15d80
✅ 테스트 추가 : ArticleType 예외 처리 케이스 추가
rowing0328 Mar 27, 2025
825532d
✅ 테스트 추가 : Blog 엔티티 테스트 케이스 추가 및 예외 처리 검증
rowing0328 Mar 27, 2025
1b32f48
✅ 테스트 추가 : Like 엔티티 테스트 케이스 추가
rowing0328 Mar 27, 2025
1fcb7d9
✅ 테스트 추가 : Like 엔티티 테스트 케이스 추가
rowing0328 Mar 27, 2025
3cd5267
✅ 테스트 추가 : Scrap 엔티티 테스트 케이스 추가
rowing0328 Mar 27, 2025
7e8cf35
✨ 새 기능 : Article 리포지토리 인터페이스 및 커스텀 리포지토리 구현
rowing0328 Mar 27, 2025
a85a777
✨ 새 기능 : Blog 리포지토리 인터페이스 및 JPA 구현 추가
rowing0328 Mar 27, 2025
2545cf5
✨ 새 기능 : Comment 리포지토리 인터페이스 및 JPA 구현 추가
rowing0328 Mar 27, 2025
922acc8
✨ 새 기능 : Scrap 리포지토리 인터페이스 및 JPA 구현 추가
rowing0328 Mar 27, 2025
6e9b7b4
✨ 새 기능 : Like 리포지토리 인터페이스 및 JPA 구현 추가
rowing0328 Mar 27, 2025
68f573e
♻️ 코드 리팩토링 : SecurityConfig에서 알고리즘 임시허용 엔드포인트 제거 및 allowed 페이지 정리
rowing0328 Mar 27, 2025
3b760a1
✨ 새 기능 : BlogQueryService 구현 및 테스트 케이스 추가
rowing0328 Mar 27, 2025
16d389e
✨ 새 기능 : BlogCommandService 구현 및 테스트 케이스 추가
rowing0328 Mar 27, 2025
5a46ee4
✨ 새 기능 : ArticleQueryService 구현 및 통합 테스트 케이스 추가
rowing0328 Mar 27, 2025
0849caa
✨ 새 기능 : ArticleCommandService 구현 및 테스트 케이스 추가
rowing0328 Mar 27, 2025
010a114
✨ 새 기능 : ArticlePreview 프로젝션 및 연간 아티클 통계 레코드 추가
rowing0328 Mar 27, 2025
bb55ac4
✨ 새 기능 : CommentQueryService 구현 및 통합 테스트 케이스 추가
rowing0328 Mar 27, 2025
4129b9f
✨ 새 기능 : CommentCommandService 구현 및 기능 추가
rowing0328 Mar 27, 2025
4e3f5bb
✨ 새 기능 : LikeQueryService 구현 및 테스트 케이스 추가
rowing0328 Mar 27, 2025
00c205a
✨ 새 기능 : ScrapQueryService 구현 및 테스트 케이스 추가
rowing0328 Mar 27, 2025
28cb697
✨ 새 기능 : ScrapCommandService 구현 및 기능 추가
rowing0328 Mar 27, 2025
68193e8
✨ 새 기능 : 뷰 캐시 서비스 구현 및 스케줄 기반 플러시 기능 추가
rowing0328 Mar 27, 2025
849528b
✨ 새 기능 : 캐시 서비스 구현 및 스케줄링 플러시 기능 추가
rowing0328 Mar 27, 2025
35fbf90
✨ 새 기능 : InMemory 캐시 저장소 구현
rowing0328 Mar 27, 2025
39740ab
✨ 새 기능 : ViewEventHandler 구현 및 비동기 이벤트 처리
rowing0328 Mar 28, 2025
ff9303f
✨ 새 기능 : LikeEventHandler 구현 및 비동기 트랜잭션 이벤트 처리
rowing0328 Mar 28, 2025
82f0f39
✨ 새 기능 : CommentEventHandler 구현 및 아티클 숨김 이벤트 처리
rowing0328 Mar 28, 2025
d5f2016
✨ 새 기능 : ArticleController 및 관련 페이로드 구현 및 Swagger 문서화
rowing0328 Mar 28, 2025
181304c
✨ 새 기능 : LikeController 구현 및 좋아요 등록/취소 API 추가
rowing0328 Mar 28, 2025
b61d6e7
✨ 새 기능 : CommentController 구현 및 댓글 API 추가
rowing0328 Mar 28, 2025
083d3ae
✨ 새 기능 : ScrapController 구현 및 스크랩 등록/취소 API 추가
rowing0328 Mar 28, 2025
c30b9f8
✨ 새 기능 : BlogController 구현 및 블로그 조회/변경 API 추가
rowing0328 Mar 28, 2025
32006ae
✨ 새 기능 : BlogEventHandler 구현 및 사용자 생성 이벤트 처리 추가
rowing0328 Mar 28, 2025
5471cc2
✨ 새 기능 : BlogEventHandler 구현 및 사용자 생성 이벤트 처리 추가
rowing0328 Mar 28, 2025
315dd90
Merge remote-tracking branch 'origin/feature/techinfo-v2' into featur…
rowing0328 Mar 28, 2025
c394ffc
✨ 새 기능 : Caffeine 캐시 및 Async/Retry 설정 추가
rowing0328 Mar 28, 2025
931d635
✨ 새 기능 : Paging 및 문자열 유틸리티 클래스 추가
rowing0328 Mar 28, 2025
e8a4be0
✨ 새 기능 : Tech Info 에러 코드 메시지 및 Redis 캐시 매니저 구성 추가
rowing0328 Mar 28, 2025
06aee1e
✨ 새 기능 : 비동기 작업에 재시도 및 복구 로직 추가, JPA 예외 대상 설정
rowing0328 Apr 2, 2025
47400a7
✨ 새 기능 : 이벤트 및 테스트 관련 JavaDocs 추가
rowing0328 Apr 3, 2025
ea11f43
✅ 테스트 수정 : Awaitility를 사용하여 비동기 댓글 삭제(숨김) 처리 테스트 안정성 향상
rowing0328 Apr 3, 2025
9562391
♻️코드 리팩토링 : 도메인 엔티티 주 생성자 / 부 생성자 패턴을 적용하여 리팩토링
rowing0328 Apr 3, 2025
da6ca3d
✅ 테스트 수정 : jdbc_리스트_배치_저장_테스트 임시 비활성화
rowing0328 Apr 3, 2025
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
9 changes: 9 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,18 @@ dependencies {
// Spring Actuator
implementation 'org.springframework.boot:spring-boot-starter-actuator'

// Spring Cache
implementation 'org.springframework.boot:spring-boot-starter-cache'

// Spring Retry
implementation 'org.springframework.retry:spring-retry'

// H2
runtimeOnly 'com.h2database:h2'

// Caffeine
implementation 'com.github.ben-manes.caffeine:caffeine'

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

Expand Down
2 changes: 2 additions & 0 deletions src/main/java/darkoverload/itzip/ItzipApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableRetry
@EnableScheduling
@EnableJpaAuditing
@SpringBootApplication
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ public String getUsername() {
return email;
}

public String getUserNickname() {
return nickname;
}

@Override
public boolean isAccountNonExpired() {
return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package darkoverload.itzip.feature.techinfo.application.event.handler;

import darkoverload.itzip.feature.techinfo.application.event.payload.UserCreatedEvent;
import darkoverload.itzip.feature.techinfo.application.service.command.BlogCommandService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

/**
* 회원 생성 이벤트를 처리하여 블로그 생성을 수행하는 이벤트 핸들러입니다.
*
* <p>
* 트랜잭션이 커밋된 후에 실행되며,
* {@link UserCreatedEvent} 발생 시 {@link BlogCommandService#create(Long)} 를 호출합니다.
* </p>
*/
@Component
public class BlogEventHandler {

private final BlogCommandService blogCommandService;

public BlogEventHandler(final BlogCommandService blogCommandService) {
this.blogCommandService = blogCommandService;
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleUserCreated(final UserCreatedEvent event) {
blogCommandService.create(event.userId());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package darkoverload.itzip.feature.techinfo.application.event.handler;

import darkoverload.itzip.feature.techinfo.application.event.payload.ArticleHiddenEvent;
import darkoverload.itzip.feature.techinfo.application.service.command.CommentCommandService;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

/**
* 아티클 삭제(숨김) 이벤트를 처리하여 해당 아티클의 댓글을 삭제(숨김) 수행하는 핸들러입니다.
*/
@Component
public class CommentEventHandler {

private final CommentCommandService commandService;

public CommentEventHandler(final CommentCommandService commandService) {
this.commandService = commandService;
}

@EventListener
public void handleArticleHidden(final ArticleHiddenEvent event) {
final String articleIdHex = event.articleId().toHexString();
commandService.deleteByArticleId(articleIdHex);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package darkoverload.itzip.feature.techinfo.application.event.handler;

import darkoverload.itzip.feature.techinfo.application.event.payload.LikeCancelledEvent;
import darkoverload.itzip.feature.techinfo.application.event.payload.LikedEvent;
import darkoverload.itzip.feature.techinfo.application.service.cache.LikeCacheService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

/**
* 좋아요 및 취소 이벤트를 수행하는 핸들러입니다.
* 트랜잭션 커밋 이후에 실행됩니다.
*/
@Component
public class LikeEventHandler {

private final LikeCacheService cacheService;

public LikeEventHandler(final LikeCacheService likeCacheService) {
this.cacheService = likeCacheService;
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleArticleLikedEvent(final LikedEvent event) {
cacheService.merge(event.articleId());
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleArticleLikeCancelledEvent(final LikeCancelledEvent event) {
cacheService.subtract(event.articleId());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package darkoverload.itzip.feature.techinfo.application.event.handler;

import darkoverload.itzip.feature.techinfo.application.event.payload.ViewedEvent;
import darkoverload.itzip.feature.techinfo.application.service.cache.ViewCacheService;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

/**
* 조회 이벤트를 수행하는 핸들러입니다.
*/
@Component
public class ViewEventHandler {

private final ViewCacheService cacheService;

public ViewEventHandler(final ViewCacheService cacheService) {
this.cacheService = cacheService;
}

@EventListener
public void handleArticleViewedEvent(final ViewedEvent event) {
cacheService.merge(event.articleId());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package darkoverload.itzip.feature.techinfo.application.event.payload;

import org.bson.types.ObjectId;

/**
* 아티클 숨김 이벤트를 나타내는 레코드입니다.
*
* <p>아티클이 숨김 처리될 때 발생하며, 숨김 대상 아티클의 식별자를 포함합니다.</p>
*/
public record ArticleHiddenEvent(ObjectId articleId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package darkoverload.itzip.feature.techinfo.application.event.payload;

/**
* 좋아요 취소 이벤트를 나타내는 레코드입니다.
*
* <p>아티클의 좋아요가 취소될 때 발생하며, 해당 아티클의 식별자를 포함합니다.</p>
*/
public record LikeCancelledEvent(String articleId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package darkoverload.itzip.feature.techinfo.application.event.payload;

/**
* 좋아요 이벤트를 나타내는 레코드입니다.
*
* <p>아티클에 좋아요가 발생했을 때 발생하며, 해당 게시글의 식별자를 포함합니다.</p>
*/
public record LikedEvent(String articleId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package darkoverload.itzip.feature.techinfo.application.event.payload;

/**
* 사용자 생성 이벤트를 나타내는 레코드입니다.
*
* <p>사용자가 생성되었을 때 발생하며, 생성된 사용자의 식별자를 포함합니다.</p>
*/
public record UserCreatedEvent(Long userId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package darkoverload.itzip.feature.techinfo.application.event.payload;

import org.bson.types.ObjectId;

/**
* 조회 이벤트를 나타내는 레코드입니다.
*
* <p>아티클이 조회될 때 발생하며, 조회된 게시글의 식별자를 포함합니다.</p>
*/
public record ViewedEvent(ObjectId articleId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package darkoverload.itzip.feature.techinfo.application.generator;

import darkoverload.itzip.feature.techinfo.application.type.SortType;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;

/**
* 페이지와 정렬 옵션에 따른 Pageable 객체를 생성하는 유틸리티 클래스입니다.
*/
public class PageableGenerator {

private static final String FIELD_VIEW_COUNT = "viewCount";
private static final String FIELD_LIKE_COUNT = "likesCount";
private static final String FIELD_CREATED_AT = "createdAt";

private PageableGenerator() {
}

public static Pageable generate(final int page, final int size, final SortType type) {
return switch (type) {
case VIEW_COUNT-> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, FIELD_VIEW_COUNT));
case LIKE_COUNT -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, FIELD_LIKE_COUNT));
case OLDEST -> PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, FIELD_CREATED_AT));
default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, FIELD_CREATED_AT));
};
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package darkoverload.itzip.feature.techinfo.application.generator;

import org.springframework.data.domain.Page;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.PagedModel;
import org.springframework.hateoas.PagedModel.PageMetadata;

/**
* Spring Data의 Page 객체를 HATEOAS의 PagedModel로 변환하는 유틸리티 클래스입니다.
*/
public class PagedModelGenerator {

private PagedModelGenerator() {
}

public static <T> PagedModel<EntityModel<T>> generate(final Page<T> page) {
final PageMetadata metadata = new PageMetadata(
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages());

return PagedModel.of(
page.stream()
.map(EntityModel::of)
.toList(),
metadata
);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package darkoverload.itzip.feature.techinfo.application.generator;

import java.util.Objects;

/**
* 문자열을 대문자로 변환하는 유틸리티 클래스입니다.
*/
public class UpperCaseGenerator {

private UpperCaseGenerator() {
}

public static String generate(final String value) {
if (Objects.isNull(value) || value.isBlank()) {
throw new IllegalArgumentException("NULL 혹은 공백인 값을 변환할 수 없습니다.");
}
return value.toUpperCase();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package darkoverload.itzip.feature.techinfo.application.service.cache;

public interface LikeCacheService {

void merge(String articleId);

void subtract(String articleId);

void flush();

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package darkoverload.itzip.feature.techinfo.application.service.cache;

import org.bson.types.ObjectId;

public interface ViewCacheService {

void merge(ObjectId articleId);

void flush();

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package darkoverload.itzip.feature.techinfo.application.service.cache.impl;

import darkoverload.itzip.feature.techinfo.application.service.cache.LikeCacheService;
import darkoverload.itzip.feature.techinfo.application.service.command.ArticleCommandService;
import darkoverload.itzip.feature.techinfo.infrastructure.persistence.cache.LikeCacheRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.util.Map;

/**
* 좋아요 캐시 처리를 위한 서비스 구현체입니다.
*
* <p>
* 좋아요 병합, 감소, 그리고 캐시 플러시 작업을 수행합니다.
* 재시도 로직과 비동기 처리(@Async)를 적용하여 일시적인 오류에 대비합니다.
* </p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LikeCacheServiceImpl implements LikeCacheService {

private static final String FLUSH_DELAY = "30000";

private final LikeCacheRepository cacheRepository;

private final ArticleCommandService articleCommandService;

@Async
@Retryable(
value = Exception.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
@Override
public void merge(final String articleId) {
cacheRepository.merge(articleId, 1L);
}

@Recover
public void recoverMerge(final Exception e, final String articleId) {
log.error("좋아요 병합 재시도 실패: {}", articleId);
}

@Async
@Retryable(
value = Exception.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
@Override
public void subtract(final String articleId) {
cacheRepository.merge(articleId, -1L);
}

@Recover
public void recoverSubtract(final Exception e, final String articleId) {
log.error("좋아요 감소 재시도 실패: {}", articleId);
}

@Scheduled(fixedDelayString = FLUSH_DELAY)
public void flush() {
final Map<String, Long> batch = cacheRepository.retrieveAll();
if (batch.isEmpty()) {
return;
}
cacheRepository.clear();
batch.forEach(articleCommandService::updateLikesCount);
}

}
Loading
Loading