소프트웨어 설계 접근 방식 중 하나
복잡한 소프트웨어 시스템 개발 시 비즈니스 도메인을 중심으로 설계하고 개발하는 것을 목표로 함
개발팀과 도메인 전문가가 협력해 소프트웨어 설계의 기반이 되는 도메인 모델을 정의하고, 이를 코드에 반영함
도메인 (Domain)
도메인 모델 (Domain Model)
유비쿼터스 언어 (Ubiquitous Language)
컨텍스트 경계 (Bounded Context)
애그리거트 (Aggregate)
리포지토리 (Repository)
서비스 (Service)
복잡한 비즈니스 로직 관리
비즈니스와 기술 간의 간극 해소
확장성과 유지보수성
일관성 보장
비즈니스 요구사항 반영 용이
팀 간 협업 강화
적합한 상황
적용하기 어려운 경우
뭐가 정답인지 모르겠다.
일단 내가 맡은 서비스에 대해 적용해보았다.
현재 스포츠 티켓 예매 서비스에서 팀, 경기장, 좌석 관련 기능을 맡고 있다.
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: 관련 dtoexception: 관련 예외presentation: 관련 api 엔드포인트 작성한 controller 위치team/domain/repository 에 속한 repository와 infrastructure 내의 respository의 차이?team/domain/repository의 repository를 TeamRepository,
infrastructure 내의 repository를 JpaTeamRepository라고 할 때,
TeamRepository: 인터페이스
public interface TeamRepository {
Team save(Team team);
Optional<Team> findById(Long id);
List<Team> findAll();
}
구현체가 JPA를 사용하든, NoSQL을 사용하든 상관없이 동일한 메서드 호출 방식으로 데이터를 다룰 수 있음
JpaTeamRepository: JPA를 사용한 구현체
TeamRepository 인터페이스를 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();
}
}
구현 기술에 대한 의존성을 최소화하기 위해서
테스트 용이성
TeamRepository 인터페이스를 통해 Mock Repository를 만들어 테스트할 수 있음관심사의 분리
JpaTeamRepository는 데이터베이스와의 상호작용을 담당하고, TeamService나 도메인 계층은 비즈니스 로직에만 집중할 수 있음유연성
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 etcpresentation: 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);
}
아직 구현이 안 된 부분은 주석처리 해두었다.
fav-> 관심 팀 조회
요청에 fav가 존재하고 True이면서 userId가 있으면 관심 팀 조회를 반환한다.
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);
}
위의 두 경우가 모두 아니라면 전체 팀을 조회, 반환한다.
위의 메서드에 대한 테스트코드도 작성했다.
관심 팀 조회 테스트
@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);
}
비슷한 맥락으로 나머지도 테스트했다.