Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3562127
feat: 회원가입 기능 구현
SukheeChoi Feb 2, 2026
f47a7d9
feat: 내 정보 조회 기능 구현
SukheeChoi Feb 2, 2026
eeb137c
feat: 비밀번호 수정 기능 구현
SukheeChoi Feb 2, 2026
23fa221
Merge feature/change-password into week1
SukheeChoi Feb 2, 2026
533790c
feat: DDD 리팩토링 + Value Object 도입 + 포인트 조회 구현
SukheeChoi Feb 5, 2026
e6ba4dd
Merge feature/ddd-refactoring into week1
SukheeChoi Feb 5, 2026
24c7c17
refactor: 불필요한 Gender, 포인트 조회 기능 제거
SukheeChoi Feb 5, 2026
4e2522f
refactor: Example 패키지 및 관련 파일 전체 삭제
SukheeChoi Feb 5, 2026
a0bba79
fix: MemberService에 @Transactional 추가
SukheeChoi Feb 5, 2026
7959cdc
docs: 이커머스 도메인 설계 문서 작성
SukheeChoi Feb 13, 2026
0fd169a
docs: 스냅샷 범위 설명 개선
SukheeChoi Feb 13, 2026
59a2d19
fix: 설계 문서 간 정합성 수정
SukheeChoi Feb 13, 2026
26e5522
fix: 요구사항에 없는 기능 제거
SukheeChoi Feb 13, 2026
e6165e5
docs: CLAUDE.md에 도메인/아키텍처 규칙 추가, DIP 인사이트 기록
SukheeChoi Feb 24, 2026
2a7be8f
refactor: MemberService를 application 레이어로 이동
SukheeChoi Feb 24, 2026
391b65c
feat: Brand, Product, Like, Order 도메인 구현
SukheeChoi Feb 24, 2026
ca1b57b
test: 도메인 단위 테스트 및 Facade 테스트 추가
SukheeChoi Feb 24, 2026
abcfc87
chore: 이전 주차 가이드 파일 정리
SukheeChoi Feb 24, 2026
bc64b47
fix: 요구사항 명세 대비 누락/불일치 수정 및 설계 고도화
SukheeChoi Feb 24, 2026
d1eebba
refactor: Order Aggregate가 OrderItem 라이프사이클을 통제하도록 개선
SukheeChoi Feb 24, 2026
508e780
docs: 클래스 다이어그램을 현재 구현에 맞게 갱신
SukheeChoi Feb 25, 2026
b46e527
refactor: 상품 단건 조회를 Facade 조합 방식으로 변경
SukheeChoi Feb 26, 2026
e01d607
refactor: Aggregate Root 불변식 강화 및 VO 의미론 보완
SukheeChoi Feb 26, 2026
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
82 changes: 82 additions & 0 deletions .codeguide/dip-insights.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# DIP & 아키텍처 인사이트

> 3주차 학습 과정에서 수집한 실무적 인사이트 정리

---

## 1. DIP 정석 vs 실무 타협

### DIP 정석 구조 (이미지: [Kev] DIP정석)

```
[Domain Layer] [Infrastructure Layer]
Order (순수 도메인 객체) OrderEntity (@Entity, JPA)
OrderRepository (interface) ◀── OrderJpaRepositoryImpl (구현체)
```

- Domain Entity와 JPA Entity를 **완전 분리**
- Infrastructure에서 Entity 간 변환 처리

### 실무 타협 (이미지: [Kev] 장바구니 도메인 구현 사례)

**장바구니 사례**: Cart(Domain) / CartEntity(JPA) / CartRedisEntity(Redis)

- 다중 저장소(JPA + Redis) 지원 시 분리가 의미 있음
- CartRepository 인터페이스 하나로 JPA/Redis 모두 지원 가능

### 수강생 채팅에서 나온 분리의 비용

| 비용 | 내용 |
|------|------|
| 보일러플레이트 | Entity ↔ Domain 변환 로직(매퍼) 필요 |
| 더티체킹 포기 | JPA의 강력한 기능 못 씀, 명시적 save() 필요 |
| 클래스 폭발 | Order, OrderEntity 둘 다 관리 |
| 기능 제약 | JPA가 지원하는 편의 기능 활용 불가 |

---

## 2. DDD 저자의 실무 타협 (최범균, 도메인 주도 개발 시작하기)

### 명언 1: 변경이 거의 없는 상황에서 미리 대비하는 것은 과하다

> "DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함이다.
> 하지만, 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다.
> JPA로 구현한 리포지터리를 마이바티스나 다른 기술로 변경한 적이 없고,
> RDBMS를 사용하다 몽고DB로 변경한 적도 없다.
> 변경이 거의 없는 상황에서 변경을 미리 대비하는 것은 과하다고 생각한다."

### 명언 2: 복잡도를 높이지 않으면서 구조적 유연함 유지

> "JPA 전용 애너테이션을 사용하긴 했지만 도메인 모델을 단위 테스트하는 데 문제는 없다.
> 리포지터리도 마찬가지다. 스프링 데이터 JPA가 제공하는 Repository 인터페이스를 상속하고 있지만
> 리포지터리 자체는 인터페이스이고 테스트 가능성을 해치지 않는다.
> DIP를 완벽하게 지키면 좋겠지만 개발 편의성과 실용성을 가져가면서 구조적인 유연함은 어느정도 유지했다.
> 복잡도를 높이지 않으면서 기술에 따른 구현 제약이 낮다면 합리적인 선택이라고 생각한다."

---

## 3. 우리 과제에 적용할 인사이트

### 타협하는 부분 (실용성 우선)

| 항목 | 정석 | 우리의 타협 | 이유 |
|------|------|-----------|------|
| Domain Entity | 순수 POJO | @Entity 사용 | 더티체킹 활용, 보일러플레이트 감소 |
| VO | JPA 무관 | @Embeddable 사용 | 테스트에 영향 없음 |

### 지키는 부분 (구조적 유연함)

| 항목 | 적용 방식 | 이유 |
|------|----------|------|
| Repository Interface | Domain Layer에 정의 | 테스트 가능성 확보 (Fake 구현체 교체) |
| Repository 구현체 | Infrastructure Layer에 위치 | 의존 방향: Domain ← Infrastructure |
| Application Layer | Facade로 도메인 조합 | 유스케이스 조율과 비즈니스 로직 분리 |

### 판단 기준 (한 줄 요약)

```
"테스트 가능성을 해치지 않는 범위에서 타협한다"
```

- @Entity 사용해도 단위 테스트 가능? → ✅ 타협 OK
- Repository를 Infrastructure에서 직접 사용하면 테스트 어려움? → ❌ Interface 분리 필요
45 changes: 0 additions & 45 deletions .codeguide/loopers-1-week.md

This file was deleted.

4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,7 @@ out/

### Kotlin ###
.kotlin

### Claude Code ###
*.md
!docs/**/*.md
88 changes: 88 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# CLAUDE.md

## 역할

- 20년 경력의 백엔드 개발자
- 현재 네이버 백엔드 개발팀 팀장이자 면접관
- 코드 리뷰, PR 작성, 설계 피드백 시 이 역할 기준으로 판단하고 조언한다

---

## 도메인 & 객체 설계 전략

### Entity / VO / Domain Service 구분

| 구분 | 기준 | 예시 |
|------|------|------|
| **Entity** | 식별자(ID) + 상태 변화 + 연속성 | Product, Brand, Order, Like |
| **Value Object** | 값 동등성 + 불변 + 자기 검증 | Price, Stock |
| **Domain Service** | 상태 없음 + 여러 객체 협력 로직 | 단일 Entity로 처리 어려운 도메인 규칙 |

### 설계 규칙

1. 도메인 객체는 비즈니스 규칙을 캡슐화한다 (예: `Stock.decrease()`에서 음수 방지)
2. Application Layer(Facade)는 도메인을 조립하여 유스케이스를 완성한다
3. 도메인 로직이 여러 서비스에 중복되면 도메인 객체로 이동시킨다
4. Aggregate 간 참조는 ID로만 한다 (느슨한 결합)
5. VO는 불변(immutable)이며, 생성자에서 자기 검증을 수행한다

---

## 아키텍처 & 패키지 전략

### 레이어드 아키텍처 + DIP

```
interfaces/api/{domain}/ → Controller, Request/Response DTO
application/{domain}/ → Facade (유스케이스 조율, 트랜잭션)
domain/{domain}/ → Entity, VO, Repository Interface
infrastructure/{domain}/ → Repository 구현체 (JPA)
```

### 의존 방향

```
Interfaces → Application → Domain ← Infrastructure
```

- Domain은 다른 레이어에 의존하지 않는다
- Infrastructure가 Domain의 Repository 인터페이스를 구현한다 (DIP)

### DIP 실무 타협 기준

- **타협**: @Entity, @Embeddable을 Domain에서 사용 (테스트 가능성 해치지 않으므로)
- **준수**: Repository Interface는 Domain에, 구현체는 Infrastructure에 분리

> "테스트 가능성을 해치지 않는 범위에서 타협한다"

### 패키지 구조 (계층 + 도메인)

```
/interfaces/api/member/
/interfaces/api/brand/
/interfaces/api/product/
/interfaces/api/order/
/interfaces/api/like/
/application/member/
/application/brand/
/application/product/
/application/order/
/application/like/
/domain/member/
/domain/brand/
/domain/product/
/domain/order/
/domain/like/
/infrastructure/member/
/infrastructure/brand/
/infrastructure/product/
/infrastructure/order/
/infrastructure/like/
```

### Application Layer 규칙

- Facade는 유스케이스 조율과 트랜잭션 경계를 담당한다
- 비즈니스 규칙 판단, 값 검증, 상태 변경 로직은 Domain에 위임한다
- 여러 도메인의 정보 조합은 Application Layer에서 처리한다
- 예: `ProductFacade.getProductDetail()` → Product + Brand 조합
4 changes: 4 additions & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ dependencies {

// web
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")

// security (password encryption only)
implementation("org.springframework.security:spring-security-crypto")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.loopers.application.brand;

import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandRepository;
import com.loopers.domain.like.LikeRepository;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BrandFacade {

private final BrandRepository brandRepository;
private final ProductRepository productRepository;
private final LikeRepository likeRepository;

public Brand getBrand(Long brandId) {
return brandRepository.findById(brandId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
}

public List<Brand> getAllBrands() {
return brandRepository.findAll();
}

@Transactional
public Brand createBrand(String name, String description) {
return brandRepository.save(new Brand(name, description));
}

@Transactional
public Brand updateBrand(Long brandId, String name, String description) {
Brand brand = brandRepository.findById(brandId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
brand.changeName(name);
brand.changeDescription(description);
return brand;
}

@Transactional
public void deleteBrand(Long brandId) {
Brand brand = brandRepository.findById(brandId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));

List<Product> products = productRepository.findAllByBrandId(brandId);
for (Product product : products) {
likeRepository.deleteAllByProductId(product.getId());
product.delete();
}
brand.delete();
}
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.loopers.application.like;

import com.loopers.domain.like.Like;
import com.loopers.domain.like.LikeRepository;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductRepository;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LikeFacade {

private final LikeRepository likeRepository;
private final ProductRepository productRepository;

@Transactional
public void addLike(Long memberId, Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));

if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) {
return;
}

likeRepository.save(new Like(memberId, productId));
product.incrementLikeCount();
}

@Transactional
public void removeLike(Long memberId, Long productId) {
Optional<Like> likeOpt = likeRepository.findByMemberIdAndProductId(memberId, productId);
if (likeOpt.isEmpty()) {
return;
}

likeRepository.delete(likeOpt.get());

Product product = productRepository.findById(productId)
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
product.decrementLikeCount();
}

public List<Like> getLikesByMemberId(Long memberId) {
return likeRepository.findAllByMemberId(memberId);
}
}
Loading