나는 지난주 이커머스 기능을 개발하며, Point를 VO(Value Object)로 할지, 별도의 Entity 테이블로 만들지 고민하다가 후자를 선택하였다.
가장 큰 이유는 별도의 테이블로 구현하는 것이 VO로 구현하여 User 테이블에 포함되게 하는 것보다 추후 복잡한 연산이나 비즈니스 로직을 적용하기에 적합하다고 판단했기 때문이다. (물론 이 또한 지난주 피드백을 통해 처음부터 미리 이렇게 설계하지 말고, 점진적으로 발전시키라고 하셨다)
그리하여 별 문제 없이
public class UserController {
private final UserService userService;
private final UserDtoMapper userDtoMapper;
private final PointService pointService;
@Operation(summary = "회원 가입")
@PostMapping("/users")
public ApiResponse<UserResponseDto> signUp(
@RequestBody UserRequestDto userRequestDto){
User user = userDtoMapper.toEntity(userRequestDto);
User saved = userService.saveUser(user);
pointService.create(saved.getId());
return ApiResponse.success(userDtoMapper.toResponse(saved));
}
다음과 같이 Controller를 구현하였고, 멘토님께 중요한 부분을 피드백 받을 수 있었다.

멘토님의 피드백에 의하면 내 코드에선 2가지가 잘못 되었다.
1. 컨트롤러에서 도메인을 조합하고 있어 레이어 책임을 위반 한다는 점
2. User 저장과 Point 생성이 별도 트랜잭션이라는 점
Controller는 단순히 인터페이싱 역할을 해야하기에 HTTP 요청/응답 처리와 DTO 변환 정도만 작성해야 한다.
내가 지금 구현한 도메인 조합은 Application Layer(Facade)에서 진행해야 한다. 그 것이 '레이어의 책임 분리'다.
추가적으로 나는 피드백 받은 내용 중 2번을 실제로 검증해보기 위해 E2E 테스트 한 개를 작성해보았다.
@DisplayName("회원 가입 시 Point 생성이 실패하면, User만 저장되어 데이터 불일치가 발생한다")
@Test
void data_inconsistency_when_point_creation_fails() {
//given
UserRequestDto requestDto = new UserRequestDto(
"testUser",
"test@test.com",
"1998-02-21",
"MALE"
);
doThrow(new RuntimeException("Point 생성 실패"))
.when(pointService).create(anyString());
//when
try {
testRestTemplate.exchange(
"/api/v1/users",
HttpMethod.POST,
new HttpEntity<>(requestDto),
new ParameterizedTypeReference<ApiResponse<UserResponseDto>>() {}
);
} catch (Exception e) {
}
//then
assertAll(
() -> assertThat(userJpaRepository.findById("testUser")).isPresent(), // User는 저장됨
() -> assertThat(pointJpaRepository.findById("testUser")).isEmpty() // Point는 생성되지 않음
);
}

이를 통해 "회원가입 = 유저 생성 + 포인트 생성" 을 원자적(Atomic)하게 처리해야 한다는 것을 알게 되었다.
그리하여 나는 Facade 패턴을 적용하여
@RequiredArgsConstructor
public class UserFacade {
private final UserService userService;
private final PointService pointService;
@Transactional
public User execute(User user) {
userService.saveUser(user);
pointService.create(user.getId());
return user;
}
}
다음과 같이 유저 생성과 포인트 생성을 한 트랜잭션에 처리되도록 하였고, 그 말인 즉, 하나의 작업이라도 실패하면 모두 실패해야 한다는 ACID 특성 중 Atomicity를 보장하도록 하였다.
//개선된 Controller
@RestController
public class UserController {
private final UserFacade userFacade;
private final UserDtoMapper userDtoMapper;
@PostMapping("/users")
public ApiResponse<UserResponseDto> signUp(@RequestBody UserRequestDto userRequestDto) {
User user = userDtoMapper.toEntity(userRequestDto);
User saved = userFacade.execute(user);
return ApiResponse.success(userDtoMapper.toResponse(saved));
}
}
개선 후에 다시 테스트 해본 결과


원하는 대로 동작하는 것을 확인하였다.
이번 기회를 통해 각 레이어의 책임과 역할에 대해 생각해볼 수 있었다. 지금까지는 1인 프로젝트를 하는 일이 많아 기능 작동에만 초점을 두었다면, 앞으로는 협업을 염두에 둔 구조와 테스트를 해야겠다고 느꼈다.
'개발 > 루퍼스(Loopers)' 카테고리의 다른 글
| Kafka, 동작 과정 샅샅이 파해쳐보기 (0) | 2025.12.19 |
|---|---|
| ApplicationEvent 를 활용해 Facade 경량화 하기 (0) | 2025.12.12 |
| 인덱스를 이용하여 50만건의 데이터 빠르게 조회하기 + k6를 이용한 성능 테스트 (0) | 2025.11.28 |
| JPA CountBy 조회 vs 엔티티 필드 저장, DDD에서는 무엇을 선택해야 할까? (0) | 2025.11.21 |
| 복잡한 테스트를 단순하게, 테스트 더블로 풀어본 경험 (0) | 2025.10.31 |