-
Notifications
You must be signed in to change notification settings - Fork 1
architecture Architecture
Kimgyuilli edited this page Jan 15, 2026
·
4 revisions
이 문서는 Cherrish 프로젝트의 전체 아키텍처와 레이어별 책임을 설명합니다.
프로젝트는 DDD (Domain-Driven Design) 기반의 레이어드 아키텍처를 따릅니다.
src/
├── main/
│ ├── java/
│ │ └── com.sopt.cherrish/
│ │ ├── global/ # 공통 설정
│ │ │ ├── config/ # 전역 설정 (DB, Security, Swagger 등)
│ │ │ ├── exception/ # 전역 예외 처리 (GlobalExceptionHandler)
│ │ │ ├── response/ # 공통 응답 형식
│ │ │ │ ├── error/
│ │ │ │ └── success/
│ │ │ ├── entity/ # 공통 엔티티 (BaseEntity 등)
│ │ │ ├── annotation/ # 커스텀 어노테이션
│ │ │ └── swagger/ # Swagger 설정
│ │ │
│ │ ├── domain/ # 도메인 영역
│ │ │ ├── user/ # User 도메인
│ │ │ │ ├── presentation/ # Presentation Layer (표현 계층)
│ │ │ │ │ ├── UserController.java
│ │ │ │ │ └── dto/
│ │ │ │ │ ├── request/
│ │ │ │ │ └── response/
│ │ │ │ │
│ │ │ │ ├── application/ # Application Layer (응용 계층)
│ │ │ │ │ ├── service/ # 비즈니스 로직
│ │ │ │ │ └── facade/ # 여러 도메인 서비스 조합 (필요시)
│ │ │ │ │
│ │ │ │ ├── domain/ # Domain Layer (도메인 계층)
│ │ │ │ │ ├── model/ # 도메인 모델/엔티티
│ │ │ │ │ └── repository/ # 리포지토리
│ │ │ │ │
│ │ │ │ ├── exception/ # 도메인별 예외
│ │ │ │ │ └── UserErrorCode.java
│ │ │ │ │
│ │ │ │ └── infrastructure/ # Infrastructure Layer (외부 연동)
│ │ │ │ └── client/ # 외부 API 클라이언트 (필요시)
│ │ │ │
│ │ │ ├── userprocedure/ # UserProcedure 도메인 (QueryDSL 사용 예시)
│ │ │ │ ├── presentation/
│ │ │ │ │ └── dto/
│ │ │ │ ├── application/
│ │ │ │ │ └── service/
│ │ │ │ ├── domain/
│ │ │ │ │ ├── model/
│ │ │ │ │ ├── repository/ # Repository + QueryDSL
│ │ │ │ │ │ ├── UserProcedureRepository.java
│ │ │ │ │ │ ├── UserProcedureRepositoryCustom.java # QueryDSL 커스텀 인터페이스
│ │ │ │ │ │ └── UserProcedureRepositoryImpl.java # QueryDSL 구현체
│ │ │ │ │ └── vo/ # Value Object
│ │ │ │ └── exception/
│ │ │ │
│ │ │ ├── challenge/ # Challenge 도메인 (복잡한 하위 구조 예시)
│ │ │ │ ├── core/ # 핵심 챌린지 기능
│ │ │ │ │ ├── presentation/
│ │ │ │ │ ├── application/
│ │ │ │ │ │ ├── service/
│ │ │ │ │ │ ├── facade/
│ │ │ │ │ │ └── scheduler/
│ │ │ │ │ ├── domain/
│ │ │ │ │ └── exception/
│ │ │ │ ├── recommendation/ # 챌린지 추천 기능
│ │ │ │ │ └── infrastructure/ # 외부 API 연동
│ │ │ │ │ └── openai/
│ │ │ │ └── demo/ # 기타 하위 도메인
│ │ │ │
│ │ │ └── ... # 기타 도메인 (procedure, calendar, maindashboard 등)
│ │ │
│ │ └── CherrishApplication.java
│ │
│ └── resources/
│ ├── application.yml
│ └── application-{profile}.yml
└── test/
책임:
- HTTP 요청/응답 처리
- 입력 검증 (
@Valid) - 비즈니스 로직 포함 금지
✅ 해야 할 일:
- HTTP 상태 코드 및 응답 형식 결정
- 요청 데이터 검증 (
@Valid,@PathVariable,@RequestParam) - Service 계층 호출 및 결과 반환
- API 문서화 (
@Operation,@Tag)
❌ 하지 말아야 할 일:
- 비즈니스 로직 작성 (Service로)
- 데이터베이스 직접 접근 (Repository는 Service에서만)
- 트랜잭션 관리 (Service에서 관리)
- 예외 직접 처리 (GlobalExceptionHandler가 처리)
예제:
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Tag(name = "User", description = "회원 관련 API")
public class UserController {
private final UserService userService;
// 데이터가 있는 성공 응답
@Operation(summary = "회원 조회")
@GetMapping("/{id}")
public CommonApiResponse<UserResponseDto> getUser(@PathVariable Long id) {
UserResponseDto user = userService.getUser(id);
return CommonApiResponse.success(SuccessCode.SUCCESS, user);
}
// 데이터가 없는 성공 응답 (생성, 삭제 등)
@Operation(summary = "회원 생성")
@PostMapping
public CommonApiResponse<Void> createUser(@Valid @RequestBody UserRequestDto request) {
userService.createUser(request);
return CommonApiResponse.success(SuccessCode.SUCCESS);
}
}책임:
- 비즈니스 로직 처리
- 트랜잭션 관리 (
@Transactional) - 여러 Repository 조합
✅ 해야 할 일:
- 핵심 비즈니스 로직 구현
- 트랜잭션 경계 설정 (
@Transactional) - 도메인 객체 간 협업 조율
- 유효성 검증 (비즈니스 규칙)
- 여러 Repository 조합하여 복잡한 작업 수행
❌ 하지 말아야 할 일:
- HTTP 관련 코드 작성 (Controller에서만)
- SQL 쿼리 직접 작성 (Repository에서)
- Entity를 직접 반환 (DTO 변환 필수)
주의사항:
- 클래스 레벨에
@Transactional(readOnly = true)선언 (기본값) - 쓰기 작업 메서드에만
@Transactional재선언 - 트랜잭션 안에서 외부 API 호출 지양
예제:
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
// 조회는 readOnly
public UserResponseDto getUser(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new BaseException(UserErrorCode.USER_NOT_FOUND));
return UserResponseDto.from(user);
}
// 쓰기 작업은 @Transactional 재선언
@Transactional
public UserResponseDto createUser(UserRequestDto request) {
// 1. 비즈니스 검증
validateDuplicateEmail(request.getEmail());
// 2. Request DTO → Entity 변환
User user = request.toEntity();
// 3. Entity 저장
User savedUser = userRepository.save(user);
// 4. Entity → Response DTO 변환
return UserResponseDto.from(savedUser);
}
private void validateDuplicateEmail(String email) {
if (userRepository.existsByEmail(email)) {
throw new BaseException(UserErrorCode.DUPLICATE_EMAIL);
}
}
}책임:
- 데이터 접근만 담당
- JPA 메서드 네이밍 규칙 준수
- 복잡한 쿼리는
@Query또는 QueryDSL 사용
✅ 해야 할 일:
- 데이터베이스 CRUD 작업
- JPA 네이밍 규칙을 따르는 메서드 정의 (
findBy,existsBy,deleteBy등) - 복잡한 쿼리는
@Query어노테이션 사용 - 성능이 중요한 경우 QueryDSL 사용
❌ 하지 말아야 할 일:
- 비즈니스 로직 작성 (Service에서)
- DTO 변환 작업 (Service에서)
- 트랜잭션 관리 (Service에서)
예제:
public interface UserRepository extends JpaRepository<User, Long> {
// JPA 메서드 네이밍 규칙
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
List<User> findByAgeGreaterThan(int age);
// 복잡한 쿼리는 @Query 사용
@Query("SELECT u FROM User u WHERE u.name LIKE %:keyword% AND u.isActive = true")
List<User> searchActiveUsers(@Param("keyword") String keyword);
// Native Query (필요한 경우만)
@Query(value = "SELECT * FROM users WHERE created_at > :date", nativeQuery = true)
List<User> findRecentUsers(@Param("date") LocalDateTime date);
}JPA 메서드 네이밍 규칙:
-
findBy...: 조회 -
existsBy...: 존재 여부 확인 -
countBy...: 개수 세기 -
deleteBy...: 삭제
커스텀 구현 (QueryDSL 사용):
// 1. 커스텀 인터페이스
public interface UserProcedureRepositoryCustom {
Map<Integer, Long> findMonthlyProcedureCounts(Long userId, int year, int month);
List<UserProcedure> findDailyProcedures(Long userId, LocalDate date);
}
// 2. 구현체 ({Repository이름}Impl 형식)
@RequiredArgsConstructor
public class UserProcedureRepositoryImpl implements UserProcedureRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public List<UserProcedure> findDailyProcedures(Long userId, LocalDate date) {
return queryFactory
.selectFrom(userProcedure)
.join(userProcedure.procedure).fetchJoin() // N+1 방지
.where(
userProcedure.user.id.eq(userId),
userProcedure.scheduledAt.between(date.atStartOfDay(), date.plusDays(1).atStartOfDay())
)
.fetch();
}
}
// 3. 기존 Repository에 상속
public interface UserProcedureRepository
extends JpaRepository<UserProcedure, Long>, UserProcedureRepositoryCustom {
Optional<UserProcedure> findByIdAndUserId(Long id, Long userId);
}책임:
- 외부 시스템 연동
- 외부 API 클라이언트
- 메시징/이벤트 처리
위치:
domain/{domain}/infrastructure/
사용 예시:
-
domain/ai/infrastructure/client/: AI API 클라이언트 -
domain/challenge/recommendation/infrastructure/openai/: OpenAI 연동 -
domain/challenge/recommendation/infrastructure/prompt/: 프롬프트 관리
주의사항:
- Infrastructure는 외부 시스템 연동에만 사용
- Repository는 domain/repository에 위치 (Infrastructure가 아님)
- QueryDSL 구현체(RepositoryImpl)도 domain/repository에 위치
Presentation Layer (Controller)
↓
Application Layer (Service / Facade)
↓
Domain Layer (Model / Repository / VO)
↓
Infrastructure Layer (External API / Messaging)
원칙:
- 상위 레이어는 하위 레이어에 의존할 수 있음
- 하위 레이어는 상위 레이어에 의존하면 안 됨
- 각 레이어는 자신의 책임만 수행
사용 시기:
- 여러 도메인의 Service를 조합해야 할 때
- 복잡한 비즈니스 워크플로우를 단순화할 때
위치:
domain/{domain}/application/facade/
예제:
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MainDashboardFacade {
private final UserService userService;
private final ChallengeService challengeService;
private final UserProcedureService userProcedureService;
public MainDashboardResponseDto getMainDashboard(Long userId) {
UserSummaryDto user = userService.getUserSummary(userId);
ChallengeSummaryDto challenge = challengeService.getCurrentChallenge(userId);
ProcedureSummaryDto procedure = userProcedureService.getRecentProcedures(userId);
return MainDashboardResponseDto.of(user, challenge, procedure);
}
}각 도메인의 exception/ 패키지에 {Domain}ErrorCode.java 작성
@Getter
@RequiredArgsConstructor
public enum UserErrorCode implements ErrorCode {
USER_NOT_FOUND(NOT_FOUND, "사용자를 찾을 수 없습니다."),
DUPLICATE_EMAIL(CONFLICT, "이미 사용 중인 이메일입니다.");
private final HttpStatus httpStatus;
private final String message;
}
// 사용
throw new BaseException(UserErrorCode.USER_NOT_FOUND);DTO는 record로 작성
// Request DTO
public record UserCreateRequestDto(
@NotBlank String name,
@Min(1) Integer age
) {
public User toEntity() {
return User.builder().name(name).age(age).build();
}
}
// Response DTO
public record UserResponseDto(Long id, String name, Integer age) {
public static UserResponseDto from(User user) {
return new UserResponseDto(user.getId(), user.getName(), user.getAge());
}
}위치: domain/{domain}/domain/vo/
사용 시기: 도메인 개념을 표현하는 불변 객체, 비즈니스 규칙이 포함된 값 객체
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ScheduledDate {
private LocalDateTime scheduledAt;
public ScheduledDate(LocalDateTime scheduledAt) {
validate(scheduledAt);
this.scheduledAt = scheduledAt;
}
private void validate(LocalDateTime scheduledAt) {
if (scheduledAt == null || scheduledAt.isBefore(LocalDateTime.now())) {
throw new BaseException(ErrorCode.INVALID_SCHEDULED_DATE);
}
}
}복잡한 도메인의 경우 하위 도메인으로 분리:
domain/challenge/
├── core/ # 핵심 챌린지 기능
│ ├── presentation/
│ ├── application/
│ │ ├── service/
│ │ ├── facade/
│ │ └── scheduler/ # 스케줄러
│ ├── domain/
│ └── exception/
│
├── recommendation/ # 챌린지 추천 기능
│ ├── presentation/
│ ├── application/
│ └── infrastructure/
│ └── openai/ # OpenAI 연동
│
└── ... # demo, homecare 등 기타 하위 도메인
원칙:
- 각 하위 도메인은 독립적인 레이어 구조를 가짐
- 하위 도메인 간 직접 참조는 지양
- 필요시 상위 레벨의 Facade에서 조율