Skip to content
Open
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
56 changes: 56 additions & 0 deletions .claude/commands/create-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
너는 이 프로젝트의 테크니컬 라이터이자 시니어 엔지니어야.
아래 지침을 반드시 지켜서 Markdown 문서를 생성해줘.

# 목적
- 내가 고민했어야 할 지점
- 실제로 선택한 설계와 그 이유
- 아직 열려 있는 의사결정과 리스크를 정리한 문서를 만든다.

# 입력 정보
- 새로 추가한 파일들의 구조를 살펴보고
- "의도된 설계"와 "암묵적 결정"을 추론한다

# 출력 형식
- 파일명: .docs/log/week-log.md
- Markdown 형식
- 실무 문서처럼 간결하지만 맥락은 충분히 포함

# 문서 구조 (고정)

## 1. 핵심 설계 결정 (Decision Records)
각 항목은 아래 형식을 따른다:

### [결정 주제]
- **선택한 방식**
- **대안**
- **왜 이 선택을 했는지**
- **트레이드오프**
- **이 결정이 미치는 영향**

## 2. 아키텍처 관점의 고민 포인트
- 의존성 방향
- 확장/변경 시 취약한 지점

## 3. 테스트와 품질 관련 판단
- 테스트 전략 (있다면)
- 테스트가 부족하거나 생략된 이유
- 이후 보완이 필요한 부분

## 4. 아직 남아 있는 질문들
- 명확히 결론 내리지 못한 부분
- 향후 다시 검토해야 할 결정들

## 5. 다음 단계 제안
- 기술 부채
- 리팩터링 후보
- 구조 개선 아이디어

# 작성 규칙
- 추측은 “추론됨”이라고 명시
- 코드에 근거한 내용만 작성
- 불필요한 미사여구 금지
- 체크리스트가 필요한 부분은 체크박스로 작성

# 실행
- 실제 파일을 생성한다
- 기존 파일이 있다면 덮어쓰지 말고 개선 제안만 한다
2 changes: 2 additions & 0 deletions .claude/commands/create-pr.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
현재 브랜치의 변경사항을 분석하여 `.github/pull_request_template.md` 양식에 맞는 PR을 자동 생성한다.

## 수행 절차
### 0단계: 작업 내역 커밋
- 작업 내역을 내용별로 여러 단계에 나눠서 커밋

### 1단계: 변경사항 분석
아래 명령어를 **병렬로** 실행하여 정보를 수집한다:
Expand Down
96 changes: 96 additions & 0 deletions .claude/skills/analyze-query/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
name: analyze-query
description:
대상이 되는 코드 범위를 탐색하고, Spring @Transactional, JPA, QueryDSL 기반의 코드에 대해 트랜잭션 범위, 영속성 컨텍스트, 쿼리 실행 시점 관점에서 분석한다.

특히 다음을 중점적으로 점검한다.
- 트랜잭션이 불필요하게 크게 잡혀 있지는 않은지
- 조회/쓰기 로직이 하나의 트랜잭션에 혼합되어 있지는 않은지
- JPA의 지연 로딩, flush 타이밍, 변경 감지로 인해
의도치 않은 쿼리 또는 락이 발생할 가능성은 없는지

단순한 정답 제시가 아니라, 현재 구조의 의도와 trade-off를 드러내고 개선 가능 지점을 선택적으로 판단할 수 있도록 돕는다.
---

### 📌 Analysis Scope
이 스킬은 아래 대상에 대해 분석한다.
- @Transactional 이 선언된 클래스 / 메서드
- Service / Facade / Application Layer 코드
- JPA Entity, Repository, QueryDSL 사용 코드
- 하나의 유즈케이스(요청 흐름) 단위
> 컨트롤러 → 서비스 → 레포지토리 전체 흐름을 기준으로 분석하며 특정 메서드만 떼어내어 판단하지 않는다.

### 🔍 Analysis Checklist
#### 1. Transaction Boundary 분석
다음을 순서대로 확인한다.
- 트랜잭션 시작 지점은 어디인가?
- Service / Facade / 그 외 계층?
- 트랜잭션이 실제로 필요한 작업은 무엇인가?
- 상태 변경 (쓰기)
- 단순 조회
- 트랜잭션 내부에서 수행되는 작업 나열
- 외부 API 호출
- 복잡한 조회(QueryDSL)
- 반복문 기반 처리

**출력 예시**
```markdown
- 현재 트랜잭션 범위:
OrderFacade.placeOrder()
├─ 유저 검증
├─ 상품 조회
├─ 주문 생성
├─ 결제 요청
└─ 재고 차감

- 트랜잭션이 필요한 핵심 작업:
- 주문 생성
- 재고 차감
```

#### 2. 불필요하게 큰 트랜잭션 식별
아래 패턴이 존재하는지 점검한다.
- Controller 에서 Transactional 이 사용되고 있음
- 읽기 전용 로직이 쓰기 트랜잭션에 포함됨
- 외부 시스템 호출이 트랜잭션 내부에 포함됨
- 트랜잭션 내부에서 대량 조회 / 복잡한 QueryDSL 실행
- 상태 변경 이후에도 트랜잭션이 길게 유지됨

**문제 후보 예시**
- 결제 API 호출이 트랜잭션 내부에 포함되어 있음
- 주문 생성 이후 추천 상품 조회 로직까지 동일 트랜잭션에 포함됨

#### 3. JPA / 영속성 컨텍스트 관점 분석
다음을 중심으로 분석한다.
- Entity 변경이 언제 flush 되는지
- 조회용 Entity가 변경 감지 대상이 되는지
- 지연 로딩으로 인해 트랜잭션 후반에 쿼리가 발생할 가능성
- @Transactional(readOnly = true) 미적용 여부

**체크리스트 예시**
```markdown
- 단순 조회인데 Entity 반환 후 변경 가능성 존재?
- DTO Projection 대신 Entity 조회 사용 여부
- QueryDSL 조회 결과가 영속성 컨텍스트에 포함되는지
```

#### 4. Improvement Proposal (선택적 제안)
개선안은 강제하지 않고 선택지로 제시한다.
- 트랜잭션 분리
- 조회 → 쓰기 분리
- Facade에서 orchestration, Service는 최소 트랜잭션
- `@Transactional(readOnly = true)` 적용
- DTO Projection (읽기 전용 모델) 도입
- 외부 호출 / 이벤트 발행을 트랜잭션 외부로 이동
- Application Service / Domain Service 책임 재조정

**개선안 예시**
```markdown
[개선안 1]
- 주문 생성과 결제 요청을 분리
- 주문 생성까지만 트랜잭션 유지
- 결제 요청은 트랜잭션 종료 후 수행

[고려 사항]
- 결제 실패 시 주문 상태 관리 필요
- 보상 트랜잭션 또는 상태 전이 설계 필요
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableAsync;

import java.util.TimeZone;

@EnableAsync
@ConfigurationPropertiesScan
@SpringBootApplication
public class CommerceApiApplication {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,36 @@

import com.loopers.domain.like.Like;
import com.loopers.domain.like.LikeDomainService;
import com.loopers.domain.like.LikeRepository;
import com.loopers.domain.product.ProductRepository;
import com.loopers.infrastructure.cache.LikeCountCacheService;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

/**
* 좋아요 Application Service.
* 여러 BC 조합 및 트랜잭션 경계 담당.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LikeApplicationService {

private final LikeDomainService likeDomainService;
private final ProductRepository productRepository;
private final LikeRepository likeRepository;
private final LikeCountCacheService likeCountCacheService;

/**
* 좋아요 등록.
* 상품 존재 여부 검증 후 도메인 서비스 호출.
* 트랜잭션 커밋 후 Redis 카운터 증가.
*
* @param userId 사용자 ID
* @param productId 상품 ID
Expand All @@ -32,23 +41,64 @@ public class LikeApplicationService {
public LikeResult like(Long userId, Long productId) {
validateProductExists(productId);
Like like = likeDomainService.like(userId, productId);

registerAfterCommit(() -> incrementLikeCount(productId));

return LikeResult.from(like);
}

/**
* 좋아요 취소.
* 멱등하게 동작 - 존재하지 않아도 예외 없이 처리.
* 트랜잭션 커밋 후 Redis 카운터 감소.
*
* @param userId 사용자 ID
* @param productId 상품 ID
*/
@Transactional
public void unlike(Long userId, Long productId) {
likeDomainService.unlike(userId, productId);
boolean existed = likeDomainService.unlike(userId, productId);

if (existed) {
registerAfterCommit(() -> decrementLikeCount(productId));
}
}

private void validateProductExists(Long productId) {
productRepository.findByIdActive(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
}

private void incrementLikeCount(Long productId) {
try {
Long count = likeCountCacheService.increment(productId);
log.debug("좋아요 카운터 증가: productId={}, count={}", productId, count);
} catch (Exception e) {
log.warn("좋아요 카운터 증가 실패 (무시): productId={}", productId, e);
}
}

private void decrementLikeCount(Long productId) {
try {
Long count = likeCountCacheService.decrement(productId);
log.debug("좋아요 카운터 감소: productId={}, count={}", productId, count);
} catch (Exception e) {
log.warn("좋아요 카운터 감소 실패 (무시): productId={}", productId, e);
}
}

private void registerAfterCommit(Runnable action) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
action.run();
}
}
);
} else {
action.run();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ public record ProductResult(
Long price,
Integer stock,
String imageUrl,
Long likeCount,
ZonedDateTime createdAt,
ZonedDateTime updatedAt
) {
public static ProductResult from(Product product) {
public static ProductResult from(Product product, Long likeCount) {
return new ProductResult(
product.getId(),
product.getBrandId(),
Expand All @@ -24,8 +25,13 @@ public static ProductResult from(Product product) {
product.getPrice().amount(),
product.getStock().quantity(),
product.getImageUrl(),
likeCount != null ? likeCount : 0L,
product.getCreatedAt(),
product.getUpdatedAt()
);
}

public static ProductResult from(Product product) {
return from(product, 0L);
}
}
Loading