[TIL] 250102 DDD, 팀 조회 기능 구현

MONA·2025년 1월 2일

나혼공

목록 보기
51/92

DDD(Domain-Driven Design, 도메인 주도 설계)

소프트웨어 설계 접근 방식 중 하나
복잡한 소프트웨어 시스템 개발 시 비즈니스 도메인을 중심으로 설계하고 개발하는 것을 목표로 함

개발팀과 도메인 전문가가 협력해 소프트웨어 설계의 기반이 되는 도메인 모델을 정의하고, 이를 코드에 반영함

주요 개념

  1. 도메인 (Domain)

    • 소프트웨어가 해결하려는 특정 문제 영역
  2. 도메인 모델 (Domain Model)

    • 도메인을 이해하기 쉽게 표현한 추상화된 모델
    • 도메인 전문가와 개발자가 공통적으로 이해할 수 있는 언어(유비쿼터스 언어)를 사용함
  3. 유비쿼터스 언어 (Ubiquitous Language)

    • 도메인 전문가와 개발자가 사용하는 통일된 언어
    • 소통 오류를 줄이고, 코드와 요구사항 간의 불일치를 최소화하기 위함
  4. 컨텍스트 경계 (Bounded Context)

    • 하나의 도메인을 여러 개의 하위 도메인(서브 도메인)으로 나누고, 각 하위 도메인의 경계를 정의
    • 각 경계 내에서 독립적으로 모델을 설계
  5. 애그리거트 (Aggregate)

    • 도메인 모델의 일관성을 유지하기 위해 묶인 객체들의 집합
    • 루트 엔티티(Root Entity)를 통해 접근
  6. 리포지토리 (Repository)

    • 애그리거트나 엔티티를 저장하고 조회하는 계층
  7. 서비스 (Service)

    • 도메인 객체에서 표현하기 어려운 비즈니스 로직을 담당

DDD의 필요성

  1. 복잡한 비즈니스 로직 관리

    • 비즈니스 로직을 도메인 중심으로 구조화하여 이해하고 유지보수하기 쉽게 만들어줌
  2. 비즈니스와 기술 간의 간극 해소

    • 도메인 전문가와 개발자가 같은 언어를 사용하여 소통하므로 요구사항 전달 오류를 줄일 수 있음
  3. 확장성과 유지보수성

    • 컨텍스트 경계를 명확히 하고 각 경게 안에서 독립적으로 모델링하므로 시스템을 확장하거나 변경하기 용이함
  4. 일관성 보장

    • 애그리거트 같은 개념을 통해 데이터 일관성을 유지할 수 있음
  5. 비즈니스 요구사항 반영 용이

    • 비즈니스의 변화가 코드에 자연스럽게 반영되도록 설계함
  6. 팀 간 협업 강화

    • 모든 팀원이 동일한 모델과 언어를 사용하므로 커뮤니케이션이 원활해짐

DDD가 적합한 상황

적합한 상황

  • 도메인이 복잡하고 비즈니스 로직이 많을 때
  • 요구사항 변경이 잦은 프로젝트
  • 여러 팀이 협력해야 하는 대규모 프로젝트

적용하기 어려운 경우

  • 단순한 CRUD 중심 애플리케이션
  • 초기 프로젝트에서 복잡한 도메인 로직이 필요하지 않을 때
  • 짧은 시간 내 결과물이 필요한 경우(DDD는 초기 학습과 설계 시간이 많이 들기 때문에)

적용해보기

뭐가 정답인지 모르겠다.
일단 내가 맡은 서비스에 대해 적용해보았다.
현재 스포츠 티켓 예매 서비스에서 팀, 경기장, 좌석 관련 기능을 맡고 있다.

team-stadium/
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   ├── com.spoticket.teamstadium/
│   │   │   │   ├── team/                   # 팀 관련 기능
│   │   │   │   ├── stadium/                # 경기장 관련 기능
│   │   │   │   ├── global/                 # 공통 로직
│   │   │   │   ├── infrastructure/         # 외부 시스템 통신
│   │   │   │   ├── TeamStadiumApplication.java
│   │   ├── resources/
│   │   │   ├── application.yml             # 환경 설정
│   │   │   ├── application-dev.yml         
│   ├── test/
│       ├── java/com/spoticket/teamstadium/
│
├── build.gradle

이런 식으로 먼저 팀과 경기장으로 분류했다.

  • team: 팀 정보와 관련된 모든 로직을 처리
  • stadium: 경기장 정보와 관련된 모든 로직을 처리
  • global: 공통적으로 사용되는 설정, 유틸리티, 에러 핸들링
  • infrastructure: 데이터베이스 및 외부 시스템과의 통신
team/
├── application/
│   ├── TeamService.java                  # 팀 관련 비즈니스 로직 처리
│   ├── TeamQueryService.java             # 조회 로직 처리
├── domain/
│   ├── model/                            # 팀 관련 엔티티
│   ├── repository/            		      # 팀 저장소 인터페이스
├── dto/
│   ├── request/
│   ├── response/
├── exception/
├── presentation/
    ├── TeamController.java               # 팀 관련 API 엔드포인트

팀 내에는 application, domain, dto, exception, presentation 패키지가 있다.

  • application: 비즈니스 로직 처리. service가 위치함
  • domain: 엔티티와 repository 인터페이스가 위치함
  • dto: 관련 dto
  • exception: 관련 예외
  • presentation: 관련 api 엔드포인트 작성한 controller 위치

Q. team/domain/repository 에 속한 repository와 infrastructure 내의 respository의 차이?

team/domain/repository의 repository를 TeamRepository,
infrastructure 내의 repository를 JpaTeamRepository라고 할 때,

  1. TeamRepository: 인터페이스

    • 저장소에 대한 추상화된 인터페이스
    • 저장소에 대한 비즈니스 로직이나 데이터 접근 방법을 캡슐화하여 도메인 계층이 특정 구현 방식(JPA, 특정 DB)에 의존하지 않도록 함
    • JPA 구현 여부와 상관없이 도메인 계층이 사용할 수 있도록 설계된 인터페이스
public interface TeamRepository {
    Team save(Team team);
    Optional<Team> findById(Long id);
    List<Team> findAll();
}

구현체가 JPA를 사용하든, NoSQL을 사용하든 상관없이 동일한 메서드 호출 방식으로 데이터를 다룰 수 있음

  1. JpaTeamRepository: JPA를 사용한 구현체

    • TeamRepository 인터페이스를 JPA로 구현한 클래스
    • 실제로 JPA를 사용하여 데이터베이스와 상호작용하는 구체적인 로직이 포함됨
    • JPA와 같은 특정 기술에 의존적으로, 인프라 계층에 위치함
@Repository
public class JpaTeamRepository implements TeamRepository {
    private final EntityManager entityManager;

    public JpaTeamRepository(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Override
    public Team save(Team team) {
        entityManager.persist(team);
        return team;
    }

    @Override
    public Optional<Team> findById(Long id) {
        return Optional.ofNullable(entityManager.find(Team.class, id));
    }

    @Override
    public List<Team> findAll() {
        return entityManager.createQuery("SELECT t FROM Team t", Team.class).getResultList();
    }
}

왜 분리할까?

  1. 구현 기술에 대한 의존성을 최소화하기 위해서

    • 도메인 계층(비즈니스 로직)은 데이터 저장 방식(JPA, JDBC, MongoDB 등)에 의존하지 않고, TeamRepository 인터페이스만 사용하도록 설계함
    • 다른 구현체로 쉽게 교체할 수 있음
    • JPA에서 MongoDB로 바꾸는 경우 JpaTeamRepository를 MongoTeamRepository로 교체하면 된다
  2. 테스트 용이성

    • TeamRepository 인터페이스를 통해 Mock Repository를 만들어 테스트할 수 있음
    • 실제 DB 없이 비즈니스 로직을 테스트할 수 있다
  3. 관심사의 분리

    • JpaTeamRepository는 데이터베이스와의 상호작용을 담당하고, TeamService나 도메인 계층은 비즈니스 로직에만 집중할 수 있음
  4. 유연성

    • 추후에 JPA 외 다른 기술(예: REST API, 파일 시스템)을 통해 데이터를 관리해야 한다면, 인터페이스를 구현하는 다른 클래스(ex: RestApiTeamRepository)를 추가하기만 하면 됨

JPA Repository를 Spring Data JPA로 대체하는 경우

Spring Data JPA를 사용하면, JpaTeamRepository를 직접 구현하지 않아도 됨
TeamRepository가 그냥 JPA 인터페이스를 상속받아 선언만 해도 동작함

어쨌든 최종 폴더구조

튜터님께 피드백 받아서 수정함...

├── main
│   ├── java
│   │   └── com
│   │       └── spoticket
│   │           └── teamstadium
│   │               ├── application
│   │               │   ├── dto
│   │               │   │   ├── request
│   │               │   │   └── response
│   │               │   └── service
│   │               ├── domain
│   │               │   ├── model
│   │               │   └── repository
│   │               ├── exception
│   │               │   └── global
│   │               ├── global
│   │               │   ├── common
│   │               │   └── dto
│   │               ├── infrastructure
│   │               │   ├── feign
│   │               │   └── repository
│   │               │       └── jpa
│   │               └── presentation
│   └── resources
└── test
    └── java
        └── com
            └── spoticket
                └── teamstadium
                    ├── factory
                    └── service

피드백 전 분류 방법을 적용했을 때는 team과 stadium 모두에서 사용될 reponse record를 어디에 위치시켜야 할 지 애매했었다.
두 군데 모두에서 쓰이니까 global에 포함시켰었는데, 비즈니스 로직과 관련된 부분이라 옮기면서도 좀 애매한 느낌이 들었었다.
도메인 사이즈가 크지 않아서 team과 stadium을 합치고, 재분류했더니 그런 고민이 사라졌다.
그래서 현재 폴더 구조의 분류 타입은 다음과 같다.

  • application: 비즈니스 로직 처리. service와 비즈니스 로직 관련 dto가 존재함
  • domain: 엔티티와 repository 인터페이스가 위치함
  • exception: global 예외와 비즈니스 예외를 처리함
  • global: 공통적으로 사용되는 responseDto, baseEntity를 포함함
  • infrastructure: 외부와 소통하는 로직 처리. feign, JpaRepository etc
  • presentation: api 엔드포인트를 작성한 controller 위치

팀 목록 조회 기능 구현

팀 등록 및 단일, 목록 조회 기능을 구현했다.
그중 팀 목록 조회에 대한 내용이다.

팀 목록 조회 시 아래와 같은 응답을 반환해야 한다.

{
	"code": 200,
	"msg": "조회 완료",
	"data": {
		"totalElements": 50,
		"totalPages": 5,
		"currentPage": 1,
		"pageSize": 10,
		"content": [               
			{
				"teamId": "18732da8-6b04-4912-e6ae-5981206dba2d",
				"name": "삼성라이온즈",
				"category": "BASEBALL"
			},
			{
				"teamId": "18732da8-6b04-4912-e6ae-5981206dba2d",
				"name": "기아타이거즈",
				"category": "BASEBALL"
			},
			{
				"teamId": "18732da8-6b04-4912-e6ae-5981206dba2d",
				"name": "롯데자이언츠",
				"category": "BASEBALL"
			}
		]
	}
}

야구밖에 없는 건 내가 야구팀 밖에 몰라서이다. 여튼

param으로 category, fav, page, size를 받아 처리할 수 있다.

요청은 다음과 같은 controller method로 받는다.

  @GetMapping
  public ApiResponse<PaginatedResponse<TeamListReadResponse>> getTeams(
      @RequestParam(required = false) TeamCategoryEnum category,
      @RequestParam(required = false) Boolean fav,
      @RequestParam(defaultValue = "0") int page,
      @RequestParam(defaultValue = "10") int size
//      @RequestHeader(name = "userId", required = false) UUID userId
  ) {
    UUID userId = UUID.randomUUID(); // 임시값
    PaginatedResponse<TeamListReadResponse> response = teamService
        .getTeams(category, fav, userId, page, size);
    return new ApiResponse<>(200, "조회 완료", response);
  }

service에서는 이렇게 처리한다.

public PaginatedResponse<TeamListReadResponse> getTeams(
      TeamCategoryEnum category,
      Boolean fav,
      UUID userId,
      int page,
      int size) {
    Pageable pageable = PageRequest.of(page, size);
    Page<Team> teams;
    if (Boolean.TRUE.equals(fav) && userId != null) {
      teams = getFavTeams(userId, pageable);
    } else if (category != null) {
      teams = teamRepository.findAllByCategoryAndIsDeletedFalse(category, pageable);
    } else {
      // 전체 조회
      teams = teamRepository.findAllByIsDeletedFalse(pageable);
    }

    Page<TeamListReadResponse> response = teams.map(TeamListReadResponse::from);
    return PaginatedResponse.of(response);
  }

아직 구현이 안 된 부분은 주석처리 해두었다.

  1. fav-> 관심 팀 조회

    요청에 fav가 존재하고 True이면서 userId가 있으면 관심 팀 조회를 반환한다.

  2. category-> 카테고리 별 조회

    요청 예시: /api/v1/teams?category=BASEBALL&page=0&size=10

    category에 올 수 있는 값은 ENUM으로 정해져 있다.

    public enum TeamCategoryEnum {
      HANDBALL,
      ICE_HOCKEY,
      SOCCER,
      BASEBALL,
      BASKETBALL,
      VOLLEYBALL,
      OTHER,
      E_SPORTS
    }

    TeamCategoryEnum에 해당하지 않는 값이 들어오면 500 서버 에러가 발생한다.
    하지만 이건 서버 잘못이 아니지않은가? 요청 값이 잘못된거지.
    그래서 GlobalExceptionHandler에 해당 상황에 대한 응답을 정의했다.

    // TeamCategoryEnum에 해당하지 않는 값이 들어올 경우
      @ExceptionHandler(MethodArgumentTypeMismatchException.class)
      public ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(
          MethodArgumentTypeMismatchException ex) {
        if (ex.getRequiredType() == TeamCategoryEnum.class) {
          ErrorResponse errorResponse = new ErrorResponse(400, "유효하지 않은 카테고리입니다");
          return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
        }
        return new ResponseEntity<>(new ErrorResponse(500, "잘못된 요청입니다"),
            HttpStatus.INTERNAL_SERVER_ERROR);
      }
  3. 위의 두 경우가 모두 아니라면 전체 팀을 조회, 반환한다.

위의 메서드에 대한 테스트코드도 작성했다.

관심 팀 조회 테스트

@Test
  void getTeams_WhenFavIsTrue() {
    // Given
    UUID userId = UUID.randomUUID();
    Pageable pageable = PageRequest.of(0, 10);
    
	// 관심 팀 목록에 포함될 팀 엔티티
    Team team = Team.builder()
        .teamId(UUID.randomUUID())
        .name("Test Team")
        .category(TeamCategoryEnum.BASEBALL)
        .build();
	
    // 관심 팀 엔티티 생성
    FavTeam favTeam = FavTeam.builder()
        .favId(UUID.randomUUID())
        .team(team)
        .userId(userId)
        .build();

    Page<FavTeam> favTeams = new PageImpl<>(List.of(favTeam), pageable, 1);
    Page<Team> teams = new PageImpl<>(List.of(team), pageable, 1);

	// favTeamRepository.findAllByUserId 호출 시 favTeams를 반환하도록 설정
    when(favTeamRepository.findAllByUserId(userId, pageable)).thenReturn(favTeams);
    // teamRepository.findAllByTeamIdInAndIsDeletedFalse가 호출되면 teams를 반환하도록 설정
    when(teamRepository.findAllByTeamIdInAndIsDeletedFalse(anyList(), eq(pageable))).thenReturn(
        teams);

    // When
    // 호출
    PaginatedResponse<TeamListReadResponse> response = teamService.getTeams(null, true, userId, 0,
        10);

    // Then
    // 데이터 값 검증
    assertNotNull(response);
    assertEquals(1, response.totalElements());
    assertEquals("Test Team", response.content().get(0).name());

	// 호출 횟수 확인
    verify(favTeamRepository, times(1)).findAllByUserId(userId, pageable);
    verify(teamRepository, times(1)).findAllByTeamIdInAndIsDeletedFalse(anyList(), eq(pageable));
  }

카테고리별 조회 테스트

@Test
  void getTeams_WhenCategoryIsGiven() {
    // Given
    Pageable pageable = PageRequest.of(0, 10);

    Team team = Team.builder()
        .teamId(UUID.randomUUID())
        .name("Test Soccer Team")
        .category(TeamCategoryEnum.SOCCER)
        .build();

    Page<Team> teams = new PageImpl<>(List.of(team), pageable, 1);

    when(teamRepository.findAllByCategoryAndIsDeletedFalse(TeamCategoryEnum.SOCCER,
        pageable)).thenReturn(teams);

    // When
    PaginatedResponse<TeamListReadResponse> response = teamService.getTeams(TeamCategoryEnum.SOCCER,
        false, null, 0, 10);

    // Then
    assertNotNull(response);
    assertEquals(1, response.totalElements());
    assertEquals("Test Soccer Team", response.content().get(0).name());

    verify(teamRepository, times(1)).findAllByCategoryAndIsDeletedFalse(TeamCategoryEnum.SOCCER,
        pageable);
  }

전체 팀 조회 테스트

@Test
  void getTeams_WhenNoConditions() {
    // Given
    Pageable pageable = PageRequest.of(0, 10);

    Team team1 = Team.builder()
        .teamId(UUID.randomUUID())
        .name("Team A")
        .category(TeamCategoryEnum.BASEBALL)
        .build();

    Team team2 = Team.builder()
        .teamId(UUID.randomUUID())
        .name("Team B")
        .category(TeamCategoryEnum.SOCCER)
        .build();

    Page<Team> teams = new PageImpl<>(List.of(team1, team2), pageable, 2);

    when(teamRepository.findAllByIsDeletedFalse(pageable)).thenReturn(teams);

    // When
    PaginatedResponse<TeamListReadResponse> response = teamService.getTeams(null, false, null, 0,
        10);

    // Then
    assertNotNull(response);
    assertEquals(2, response.totalElements());
    assertEquals("Team A", response.content().get(0).name());
    assertEquals("Team B", response.content().get(1).name());

    verify(teamRepository, times(1)).findAllByIsDeletedFalse(pageable);
  }

비슷한 맥락으로 나머지도 테스트했다.

profile
고민고민고민

0개의 댓글