-
Notifications
You must be signed in to change notification settings - Fork 1
Spring Design
Spring Boot를 단순히 "사용"하는 수준을 넘어, 프레임워크가 제공하는 확장 포인트를 의도적으로 활용한 설계 결정들을 기록합니다. 각 결정에는 그 근거와, 실제 구현 과정에서 부딪혔던 어려운 지점이 함께 담겨 있습니다.
이전 프로젝트에서 Controller → Service 단층 구조를 유지했을 때, Service 메서드 하나가 파라미터 검증·비즈니스 규칙 확인·DB 조회·상태 변경·DTO 변환을 전부 담당하며 100줄을 금방 넘었습니다. 인턴 경험에서 "메서드가 너무 많은 책임을 가지고 있다"는 피드백을 직접 받았고, Timefit에서는 처음부터 전 도메인 표준 구조를 설계했습니다.
graph TD
CTRL["Controller\n@RestController"]
FACADE["FacadeService\n오케스트레이터\n@Transactional(readOnly=true)"]
QS["QueryService\n읽기 전용 조회 + DTO 변환"]
CS["CommandService\n상태 변경 실행"]
VALID["Validator\n단일 검증 책임 → 예외 throw"]
CONV["Converter / Factory\nEntity ↔ DTO 변환, 생성"]
HELP["Helper\n재사용 가능한 복잡한 보조 로직"]
REPO["Repository\nQueryDSL / JPA"]
CTRL -->|단일 진입점| FACADE
FACADE -->|읽기 요청| QS
FACADE -->|"쓰기 요청 @Transactional"| CS
QS --> CONV
QS --> REPO
CS --> VALID
CS --> CONV
CS --> HELP
CS --> REPO
| 레이어 | 책임 | 핵심 규칙 |
|---|---|---|
FacadeService |
Controller의 단일 진입점. Query/Command 조합 | 직접 DB 접근 금지. 위임만 |
QueryService |
읽기 전용 조회 + DTO 변환 | 상태 변경 코드 금지 |
CommandService |
상태 변경 실행 | 비즈니스 판단 자체는 Validator·Helper에 위임 |
Validator |
단일 조건 검증. 실패 시 예외 throw | 하나의 메서드 = 하나의 검증 조건 |
Converter / Factory |
순수 변환·생성 로직 | 조회·수정 금지 |
Helper |
복잡한 보조 로직 (여러 Repository·도메인 조합) | 단일 책임 |
이 구조의 실용적인 효과: 장애 발생 시 레이어 이름만으로 어디를 먼저 확인할지 즉시 판단할 수 있습니다. 검증 실패면 Validator, 변환 오류면 Converter, 복잡한 도메인 조합 문제면 Helper.
설계 원칙을 세워도 실제 구현에서는 "이 코드가 어느 레이어, 어느 도메인에 있어야 하는가"를 계속 판단해야 합니다. 그 판단이 가장 어려웠던 케이스가 Menu 등록 시 BookingSlot 자동 생성 로직이었습니다.
요구사항: 사업자가 RESERVATION_BASED Menu를 등록하면 OperatingHours 기준에 맞춰 BookingSlot이 자동 생성되어야 합니다.
1차 시도 — MenuCommandService 직접 처리 (실패):
// MenuCommandService 안에서 직접 처리
public MenuResponse createMenu(...) {
Menu menu = menuRepository.save(...);
operatingHoursRepository.findByBusinessId(...); // Menu가 OperatingHours 직접 조회
List<BookingSlot> slots = generateSlots(...);
bookingSlotRepository.saveAll(slots); // Menu가 BookingSlot 직접 저장
}Menu CommandService가 3개 도메인(Menu, OperatingHours, BookingSlot)을 직접 알아야 하는 강결합이 형성됐습니다. 레이어 원칙은 지켰지만 도메인 경계가 무너졌습니다.
2차 시도 — Adapter 도입 (절반의 성공):
오케스트레이션 전담 MenuBookingSlotAdapter를 만들었지만, Adapter 내부에 BookingSlot Repository 직접 조회·삭제, DTO 변환 로직까지 뒤섞이는 문제가 반복됐습니다.
최종 결론 — 책임을 각 도메인으로 완전 이동:
graph LR
MC["MenuCommandService"]
HELP["MenuBookingSlotHelper\n순수 오케스트레이션"]
MSC["MenuScheduleConverter\n(Menu 도메인)\nMenu DTO → DailySlotSchedule"]
BSV["BookingSlotValidator\n(BookingSlot 도메인)\n생성 가능 여부 검증"]
BSCH["BookingSlotCreationHelper\n(BookingSlot 도메인)\n슬롯 배치 생성"]
BSCS["BookingSlotCommandService\n(BookingSlot 도메인)\n슬롯 삭제"]
MC -->|생성 완료 후 호출| HELP
HELP --> BSV
HELP --> MSC
HELP --> BSCH
HELP -->|수정 시 재생성| BSCS
// MenuBookingSlotHelper — 오케스트레이션만. 직접 조작 없음.
@Transactional(propagation = Propagation.MANDATORY)
public void generateForMenu(Menu menu, CreateUpdateMenu request) {
bookingSlotValidator.validateCreationFromMenu(menu, request); // 검증 위임
if (request.orderType() != RESERVATION_BASED
|| !Boolean.TRUE.equals(request.autoGenerateSlots())) {
return;
}
List<DailySlotSchedule> schedules =
menuScheduleConverter.convertToBookingSlotSchedules(request); // 변환 위임
bookingSlotCreationHelper.createSlots(
menu.getBusiness(), menu, schedules,
request.slotSettings().slotIntervalMinutes()); // 생성 위임
}트랜잭션 설계에서의 추가 판단 — 왜 비동기를 쓰지 않았는가:
슬롯이 수백 개 생성되는 구조상, 비동기로 처리하면 API 응답 속도를 단축할 수 있습니다. 실제로 비동기(@Async) 도입을 검토했습니다. 그러나 비동기로 전환하면 Menu 저장은 성공했는데 BookingSlot 생성이 실패했을 때 예약형 서비스인데 슬롯이 없는 반쪽짜리 상태가 만들어집니다. 이 데이터 일관성 문제가 응답 속도보다 더 크리티컬하다고 판단해 동기 처리(Propagation.MANDATORY)를 유지했습니다. MANDATORY는 Menu 생성 트랜잭션에 Helper가 참여하므로, 슬롯 생성 실패 시 Menu 저장도 함께 롤백됩니다.
Menu 수정 시에는 durationMinutes(소요시간)가 변경된 경우에만 기존 슬롯을 삭제하고 재생성합니다. 서비스명·가격 변경은 슬롯에 영향이 없기 때문입니다.
Validator 도메인 경계도 함께 정리:
Before:
MenuValidator → BookingSlot 날짜·시간 검증 포함 (도메인 침범)
MenuErrorCode.SLOT_DATE_INVALID 사용 (잘못된 에러 코드)
After:
MenuValidator → Menu 도메인 검증만 (orderType, durationMinutes)
BookingSlotValidator → 슬롯 설정 검증 (날짜 범위, 슬롯 간격) + BookingErrorCode 사용
MenuValidator가 BookingSlotValidator에 위임
전 도메인에 두 가지 원칙을 일관 적용했습니다.
Java Record로 불변 DTO:
public class ReservationRequestDto {
public record CreateReservation(UUID bookingSlotId, String specialRequest) {}
public record CancelReservation(String cancelReason) {}
public record UpdateReservation(...) {}
}Record는 불변성 보장, equals/hashCode/toString 자동 생성을 제공합니다. 비즈니스 판단 로직은 DTO 안에 두지 않습니다. "이 예약이 RESERVATION_BASED인가?"를 DTO가 판단하면 SRP 위반입니다. 그 판단은 Validator의 몫입니다.
Inner class로 도메인별 응집:
public class ReservationResponseDto {
public record CustomerReservation(...) {} // 고객용 단건
public record CustomerReservationList(...) {} // 고객용 목록
public record BusinessReservation(...) {} // 사업자용 단건
public record BusinessReservationList(...) {} // 사업자용 목록
public record ReservationActionResult(...) {} // 상태 변경 결과
}기존에는 관련 DTO들이 여러 파일에 흩어져 있었습니다. Inner class 구조로 전환 후 도메인 관련 DTO가 한 파일에 응집되고, 이름만으로 어떤 목적의 DTO인지 즉시 파악됩니다.
인증이 필요한 모든 API에서 JWT에서 userId를 꺼내는 코드가 Controller마다 반복됐습니다. HandlerMethodArgumentResolver를 구현해 어노테이션 하나로 해결했습니다.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUserId {}
@Component
public class CurrentUserIdArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(CurrentUserId.class)
&& parameter.getParameterType().equals(UUID.class);
}
@Override
public Object resolveArgument(...) {
// JWT Filter가 이미 검증 완료. SecurityContext에서 꺼내기만.
return UUID.fromString(
SecurityContextHolder.getContext().getAuthentication().getName());
}
}// Controller: JWT 파싱 방법을 모름. 주입만 받음.
@GetMapping("/reservations")
public ResponseEntity<?> getReservations(
@Parameter(hidden = true) @CurrentUserId UUID currentUserId) { ... }@Parameter(hidden = true): Swagger UI가 이 파라미터를 사용자 입력 필드로 렌더링하지 않도록 숨깁니다. ArgumentResolver가 자동 주입하는 값이므로 API 문서에 노출할 필요가 없습니다.
로그인 API는 응답 헤더에 Access Token과 Refresh Token을 담아야 합니다. 초기에는 AuthController가 직접 HttpServletResponse.setHeader()를 호출했습니다. HTTP 헤더 조작과 비즈니스 처리라는 두 책임이 Controller에 혼재했습니다.
HandlerInterceptor로 분리했습니다.
sequenceDiagram
participant C as Client
participant AC as AuthController
participant AS as AuthService
participant I as JwtResponseInterceptor
C->>AC: POST /api/auth/signin
AC->>AS: signIn(request)
AS-->>AC: TokenResponse
AC->>I: postHandle() 실행
I->>I: TokenResponse 감지 → 헤더 주입
I-->>C: Authorization: Bearer {accessToken}\nRefresh-Token: {refreshToken}
Controller는 TokenResponse 객체만 반환합니다. 토큰이 헤더에 어떻게 담기는지는 Interceptor가 담당합니다. WebConfig에서 /api/auth/** 경로에만 적용합니다.
도메인별 ErrorCode enum → 전용 Exception 클래스 → GlobalExceptionHandler 일괄 처리.
public enum ReservationErrorCode {
RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "예약을 찾을 수 없습니다."),
SLOT_NOT_AVAILABLE(HttpStatus.CONFLICT, "예약 가능한 슬롯이 없습니다."),
UNAUTHORIZED_RESERVATION_ACCESS(HttpStatus.FORBIDDEN, "해당 예약에 접근 권한이 없습니다.");
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ReservationException.class)
public ResponseEntity<ErrorResponse> handle(ReservationException e) {
return ResponseEntity.status(e.getErrorCode().getStatus())
.body(ErrorResponse.of(e.getErrorCode()));
}
}새 에러 케이스 추가: enum에 한 줄. Validator에서 throw: throw new ReservationException(SLOT_NOT_AVAILABLE) 한 줄. HTTP 상태 코드·메시지 매핑은 enum이 담당합니다.
E2E HTTP 테스트 중 다음 순서를 실행하면 두 번째 요청이 404를 반환했습니다.
1. DELETE /booking-slot/past → 성공 (과거 슬롯 삭제)
2. DELETE /menu/{menuId} → 404 MENU_NOT_FOUND ← Menu가 이미 사라짐
BookingSlot만 삭제했는데 Menu까지 사라진 상황이었습니다. 처음에는 BookingSlot Entity의 @OnDelete(action = OnDeleteAction.CASCADE) 설정을 의심했습니다. 그러나 이 설정의 실제 의미는 "부모(Menu)가 삭제될 때 자식(BookingSlot)을 CASCADE 삭제" 입니다. BookingSlot 삭제 → Menu 삭제는 이 설정으로 발생하지 않습니다.
실제 원인: DB 레벨 FK 제약조건이 일부 누락된 상태에서 Service 로직의 삭제 코드가 의도치 않게 연쇄 실행되고 있었습니다. 원인을 찾는 과정에서 @OnDelete(DB DDL 레벨)와 JPA CascadeType.REMOVE(Java 객체 그래프 레벨)가 완전히 다른 레벨에서 동작한다는 점도 혼동을 키웠습니다.
해결로 CASCADE 체인을 명시적으로 재정의했습니다.
의도된 CASCADE 방향:
Menu 삭제
↓ ON DELETE CASCADE
├── BookingSlot 삭제
│ ↓ ON DELETE CASCADE
│ └── Reservation 삭제
└── Reservation 삭제 (Menu 직접 참조분)
BookingSlot 삭제 (과거 슬롯 정리 등)
↓ ON DELETE CASCADE
└── Reservation 삭제
※ Menu는 절대 삭제되지 않음
이 경험에서 얻은 원칙: 삭제 시나리오를 구현할 때는 E2E 테스트에 "이 엔티티를 삭제하면 어디까지 연쇄 삭제가 일어나야 하는가"를 먼저 시나리오로 기술하고, 통과하는지 확인하는 순서로 진행합니다.
@Operation, @ApiResponses, @RequestBody를 Controller에 직접 붙이면 실제 비즈니스 코드보다 Swagger 어노테이션이 3~4배 많아집니다. SpringDoc이 @Operation을 메타 어노테이션으로 지원하는 점을 활용해 도메인·동작별 커스텀 어노테이션을 분리했습니다.
web/common/swagger/
├── operation/ ← @Operation + @ApiResponses 포장
│ ├── reservation/CreateReservationOperation.java
│ ├── review/UpdateReviewOperation.java
│ └── ...
└── requestbody/ ← @RequestBody + @ExampleObject 포장
├── reservation/CreateReservationBody.java
└── ...
// After: Controller는 비즈니스 로직에만 집중
@CreateReservationOperation // Swagger 문서화
@CreateReservationBody // 요청 예시
@PostMapping("/reservations")
public ResponseEntity<?> createReservation(
@Valid @RequestBody ReservationRequestDto.CreateReservation request,
@Parameter(hidden = true) @CurrentUserId UUID currentUserId) { ... }전체 API 엔드포인트에 적용 완료. API 변경 시 해당 Operation 어노테이션만 수정하면 Swagger 자동 갱신. 프론트 팀과 협업 시 Swagger URL 하나로 API 소통 기준점을 통일했습니다.
sequenceDiagram
participant C as Client
participant F as Security Filter
participant AR as ArgumentResolver
participant CTRL as Controller
participant FAC as FacadeService
participant CS as CommandService
participant V as Validator
participant H as Helper / Converter
participant REPO as Repository
C->>F: HTTP Request (JWT)
F->>F: 서명 검증 → SecurityContext 설정
F->>AR: @CurrentUserId 파라미터 감지
AR->>CTRL: UUID currentUserId 자동 주입
CTRL->>FAC: service.doSomething(request, userId)
alt 읽기 요청
FAC->>QS: QueryService 위임
note over QS,REPO: 단순 조회 → Spring Data JPA<br/>복합 조건·fetchJoin → QueryDSL
QS->>REPO: findById / findAll (JPA) 또는 queryFactory.selectFrom().fetchJoin() (QueryDSL)
REPO-->>FAC: Entity → Converter → ResponseDto
else 쓰기 요청
FAC->>CS: @Transactional
CS->>V: validate() → 실패 시 GlobalExceptionHandler
CS->>H: 복잡한 보조 로직 위임 (MANDATORY 참여)
note over CS,REPO: 단순 저장 → JPA save()<br/>복합 조건 삭제·조회 → QueryDSL
H->>REPO: save / delete (JPA) 또는 queryFactory (QueryDSL)
CS-->>FAC: ResponseDto
end
FAC-->>CTRL: ResponseDto
CTRL-->>C: ResponseEntity<ResponseData<T>>
시스템
백엔드 포트폴리오