
백엔드 개발을 하다 보면 Controller, Service, Repository를 나누는 이유는 어느 정도 익숙해집니다.
그런데 막상 API 하나를 구현하고 나면 이런 생각이 들 때가 있습니다.
“그래서 실제 요청이 들어오면, 이 계층들은 어떤 순서로 동작하는 걸까?”
저도 처음에는 Controller가 요청을 받고 Service가 로직을 처리하고 Repository가 저장한다는 정도로만 이해했습니다.
하지만 회원가입 API처럼 요청값 검증도 들어가고, 여러 테이블 저장도 필요하고, 마지막에는 공통 응답 형식으로 감싸서 반환하는 흐름을 하나씩 따라가다 보니 각 계층이 왜 분리되어 있는지 훨씬 선명하게 보였습니다.
그래서 이번 글에서는 회원가입 API 하나를 기준으로, 클라이언트가 요청을 보낸 순간부터 DB 저장, DTO 변환, 최종 응답 반환까지 어떤 흐름으로 이어지는지 정리해보려고 합니다.
이번 글에서 다룰 내용은 다음과 같습니다.
- 클라이언트의 회원가입 요청이 어디로 들어오는가
- Spring은 어떤 컨트롤러 메서드를 실행하는가
- JSON 요청은 어떻게 DTO로 바뀌는가
- @Valid는 어떤 방식으로 요청을 검증하는가
- Service에서는 어떤 비즈니스 로직이 실행되는가
- Repository는 어떤 데이터를 저장하고 조회하는가
- 응답 DTO와 ApiResponse는 어떻게 만들어지는가
기준이 되는 회원가입 API
이번 글에서 기준으로 볼 API는 아래 메서드입니다.
@PostMapping("/signup")
public ResponseEntity<ApiResponse<MemberSignUpResponse>> signUp(
@Valid @RequestBody MemberSignUpRequest request
) {
MemberSignUpResponse response = memberService.signUp(request);
return ResponseEntity.status(GeneralSuccessCode.CREATED.getHttpStatus())
.body(ApiResponse.onSuccess(GeneralSuccessCode.CREATED, response));
}
겉으로 보면 코드가 아주 길지는 않습니다.
하지만 이 짧은 메서드 안에는 요청 매핑, JSON 변환, 유효성 검증, 서비스 호출, 공통 응답 생성까지 API의 핵심 흐름이 모두 들어 있습니다.
이번에는 이 흐름을 위에서 아래로, 순서대로 따라가보겠습니다.
회원가입 요청의 전체 흐름부터 먼저 보기
회원가입 요청이 왔을 때 큰 흐름은 이렇게 볼 수 있습니다.
클라이언트
→ MemberController
→ MemberService
→ MemberRepository / FoodCategoryRepository / MemberFoodPreferenceRepository
→ DB 저장
→ MemberConverter
→ ApiResponse
→ 클라이언트 응답

이 구조를 보면 요청이 한 번에 DB로 가는 것이 아니라, 각 계층을 지나면서 역할이 분리되어 있다는 걸 알 수 있습니다.
- Controller는 요청과 응답을 담당하고
- Service는 회원가입 로직을 수행하고
- Repository는 DB와 소통하고
- Converter는 응답 DTO를 만들고
- ApiResponse는 공통 응답 형식으로 감싸는 역할을 합니다
즉, API 흐름을 이해한다는 것은 단순히 메서드 호출 순서를 외우는 것이 아니라, 각 계층이 어느 시점에 어떤 책임을 수행하는지 이해하는 것에 가깝다고 생각했습니다.
1. 클라이언트가 회원가입 요청을 보낸다
먼저 클라이언트는 회원가입을 위해 다음 주소로 POST 요청을 보냅니다.
POST /api/v1/members/signup
요청 body에는 JSON 데이터가 담깁니다.
{
"email": "test@example.com",
"password": "password123",
"nickname": "mingyu",
"gender": "MALE",
"birthDate": "2000-01-01",
"address": "Seoul",
"foodCategoryIds": [1, 2]
}
이 주소가 만들어지는 방식도 한 번 보면 이해가 쉽습니다.
클래스의 @RequestMapping에서 공통 경로가 정해지고,
@RequestMapping("/api/v1/members")
메서드의 @PostMapping에서 세부 경로가 붙습니다.
@PostMapping("/signup")
그래서 최종 주소는 /api/v1/members/signup이 됩니다.
이 부분은 사소해 보이지만, 실제로 API를 읽을 때 공통 경로는 클래스, 세부 경로는 메서드에서 결정된다는 흐름을 알고 있으면 컨트롤러 구조를 훨씬 빨리 파악할 수 있습니다.
2. Spring이 요청을 받을 컨트롤러를 찾는다
클라이언트가 요청을 보내면 그다음은 Spring이 처리합니다.
Spring은 요청의 HTTP Method와 URL을 보고 어떤 메서드를 실행할지 찾습니다.
이번 요청은
- Method: POST
- URL: /api/v1/members/signup
이므로 아래 메서드와 매칭됩니다.
@PostMapping("/signup")
public ResponseEntity<ApiResponse<MemberSignUpResponse>> signUp(...)
즉, Spring은 이 요청을 MemberController.signUp()이 받아야 한다고 판단하고, 이제 메서드를 실행할 준비를 합니다.
이 과정을 보면 Controller는 단순히 코드를 모아두는 클래스가 아니라, HTTP 요청과 자바 메서드를 연결해주는 진입점이라는 걸 알 수 있습니다.
3. JSON 요청을 DTO로 변환한다
컨트롤러의 파라미터를 보면 이렇게 되어 있습니다.
@Valid @RequestBody MemberSignUpRequest request
여기서 먼저 동작하는 것은 @RequestBody입니다.
@RequestBody는 요청 body에 담긴 JSON을 자바 객체로 변환해주는 역할을 합니다.
즉, 아까 클라이언트가 보낸 이 JSON이
{
"email": "test@example.com",
"password": "password123",
"nickname": "mingyu",
"gender": "MALE",
"birthDate": "2000-01-01",
"address": "Seoul",
"foodCategoryIds": [1, 2]
}
이 객체로 바뀌는 것입니다.
MemberSignUpRequest request
그래서 이후 컨트롤러나 서비스에서는 이런 식으로 값을 사용할 수 있습니다.
request.email()
request.password()
request.nickname()
request.gender()
request.birthDate()
request.address()
request.foodCategoryIds()
이 흐름을 보고 나면 DTO가 왜 필요한지도 더 잘 보입니다.
DTO는 단순히 데이터를 담는 객체가 아니라, 클라이언트가 어떤 구조의 데이터를 보내야 하는지 명확하게 정의하는 역할을 합니다.
4. 요청값 검증이 진행된다
DTO 변환이 끝나면 @Valid가 동작합니다.
회원가입 요청 DTO는 다음과 같이 정의되어 있습니다.
public record MemberSignUpRequest(
@NotBlank
@Email
@Size(max = 100)
String email,
@NotBlank
@Size(min = 8, max = 255)
String password,
@NotBlank
@Size(max = 20)
String nickname,
@NotNull
MemberGender gender,
@NotNull
@Past
LocalDate birthDate,
@NotBlank
@Size(max = 100)
String address,
@NotEmpty
List<Long> foodCategoryIds
) {
}
이 안에는 각 필드에 대한 검증 조건이 들어 있습니다.
예를 들어,
- email은 비어 있으면 안 되고, 이메일 형식이어야 하며, 100자 이하여야 합니다.
- password는 비어 있으면 안 되고, 8자 이상이어야 합니다.
- nickname은 비어 있으면 안 되고, 20자 이하여야 합니다.
- birthDate는 null이면 안 되고, 과거 날짜여야 합니다.
- foodCategoryIds는 null도 안 되고, 비어 있어도 안 됩니다.
즉, 컨트롤러 메서드 내부로 들어가기 전에 Spring이 먼저 “이 요청이 유효한가?”를 검사하는 것입니다.
예를 들어 이런 요청은 검증에 실패합니다.
{
"email": "wrong-email",
"password": "123",
"nickname": "",
"gender": null,
"birthDate": "2999-01-01",
"address": "",
"foodCategoryIds": []
}
이 경우 signUp() 메서드 내부는 아예 실행되지 않습니다.
대신 MethodArgumentNotValidException이 발생하고, 전역 예외 처리기인 GlobalExceptionHandler가 이를 처리하게 됩니다.
결과적으로 클라이언트는 400 Bad Request 응답을 받습니다.
{
"isSuccess": false,
"code": "COMMON400",
"message": "잘못된 요청입니다.",
"result": null
}
이 부분은 API 흐름에서 꽤 중요합니다.
검증은 Service에 들어가기 전에 끝나기 때문에, Service는 이미 기본적으로 유효한 요청이 들어온다는 전제 위에서 비즈니스 로직에 집중할 수 있습니다.
5. 검증이 끝나면 컨트롤러 메서드가 실행된다
검증에 성공하면 이제 컨트롤러 메서드 본문이 실행됩니다.
MemberSignUpResponse response = memberService.signUp(request);
컨트롤러는 여기서 직접 DB에 저장하지 않습니다.
실제 회원가입 비즈니스 로직은 MemberService에게 맡깁니다.
즉, 이 시점에서 컨트롤러가 하는 일은 다음과 같습니다.
- 요청 받기
- 요청 검증하기
- 서비스 호출하기
- 응답 반환하기
이 구조를 보고 나면 Controller는 정말로 입구와 출구 역할에 가깝다는 걸 알 수 있습니다.
비즈니스 로직이 길어질수록 Controller가 아니라 Service가 중심이 되어야 하는 이유도 여기서 드러납니다.
6. MemberService에서 실제 회원가입 로직이 실행된다
서비스의 회원가입 메서드는 대략 이런 구조입니다.
@Transactional
public MemberSignUpResponse signUp(MemberSignUpRequest request) {
Member member = Member.create(
request.email(),
request.password(),
request.nickname(),
request.gender(),
request.birthDate(),
request.address()
);
Member savedMember = memberRepository.save(member);
List<FoodCategory> foodCategories = foodCategoryRepository.findAllById(request.foodCategoryIds());
if (foodCategories.size() != request.foodCategoryIds().size()) {
throw new FoodCategoryException(FoodCategoryErrorCode.FOOD_CATEGORY_NOT_FOUND);
}
List<MemberFoodPreference> preferences = foodCategories.stream()
.map(foodCategory -> MemberFoodPreference.create(savedMember, foodCategory))
.toList();
memberFoodPreferenceRepository.saveAll(preferences);
savedMember.getFoodPreferences().addAll(preferences);
return memberConverter.toSignUpResponse(savedMember);
}
이 메서드에서 먼저 눈에 띄는 부분은 @Transactional입니다.
@Transactional
이 어노테이션 덕분에 회원 저장과 음식 선호 저장이 하나의 트랜잭션으로 묶입니다.
즉, 중간에 예외가 발생하면 앞에서 실행한 작업도 모두 롤백됩니다.
예를 들어 회원은 저장했는데 음식 카테고리 검증에서 실패했다면, 회원 데이터도 최종적으로는 저장되지 않습니다.
이 점이 중요한 이유는 회원가입이 단순히 member 테이블 하나만 저장하는 작업이 아니라, 여러 저장 작업이 하나의 논리적 동작으로 묶여 있기 때문입니다.
7. 먼저 Member 엔티티를 만든다
서비스는 가장 먼저 전달받은 request DTO를 회원 엔티티로 변환합니다.
Member member = Member.create(
request.email(),
request.password(),
request.nickname(),
request.gender(),
request.birthDate(),
request.address()
);
여기서 Member.create()는 정적 팩토리 메서드입니다.
public static Member create(
String email,
String password,
String nickname,
MemberGender gender,
LocalDate birthDate,
String address
) {
Member member = new Member();
member.email = email;
member.password = password;
member.nickname = nickname;
member.gender = gender;
member.birthDate = birthDate;
member.address = address;
member.totalPoint = 0;
member.status = MemberStatus.ACTIVE;
return member;
}
이 메서드 안에서는 요청값을 엔티티에 채워 넣는 것뿐 아니라, 회원의 기본 상태도 함께 설정합니다.
member.totalPoint = 0;
member.status = MemberStatus.ACTIVE;
즉, 신규 회원은 가입 시점에 자동으로
- totalPoint = 0
- status = ACTIVE
상태로 시작하게 됩니다.
이 과정을 보면 Service는 단순히 저장 버튼만 누르는 계층이 아니라, 회원가입이라는 비즈니스 규칙을 실제 엔티티에 반영하는 계층이라는 점이 잘 드러납니다.
8. memberRepository가 회원을 DB에 저장한다
그다음 서비스는 memberRepository를 통해 회원을 저장합니다.
Member savedMember = memberRepository.save(member);
memberRepository는 JpaRepository<Member, Long>를 상속한 인터페이스입니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
}
그래서 save()를 호출하면 JPA가 내부적으로 member 테이블에 INSERT를 실행합니다.
이후 저장이 끝나면 DB에서 생성된 기본 키가 savedMember 안에 들어가게 됩니다.
예를 들면 이런 데이터가 저장될 수 있습니다.
- member_id: 1
- email: test@example.com
- password: password123
- nickname: mingyu
- gender: MALE
- birth_date: 2000-01-01
- address: Seoul
- total_point: 0
- status: ACTIVE
이 단계에서 중요한 점은 Repository가 단순히 “데이터를 보관하는 곳”이 아니라, Service가 만든 엔티티를 실제 DB와 연결해주는 계층이라는 점입니다.
9. 음식 카테고리가 실제로 존재하는지 확인한다
회원가입 요청에는 음식 카테고리 ID 목록도 들어 있습니다.
"foodCategoryIds": [1, 2]
서비스는 이 값들이 실제 DB에 존재하는지 확인해야 합니다.
그래서 foodCategoryRepository.findAllById(...)를 호출합니다.
List<FoodCategory> foodCategories =
foodCategoryRepository.findAllById(request.foodCategoryIds());
그리고 요청한 개수와 실제 조회된 개수를 비교합니다.
if (foodCategories.size() != request.foodCategoryIds().size()) {
throw new FoodCategoryException(FoodCategoryErrorCode.FOOD_CATEGORY_NOT_FOUND);
}
예를 들어 요청은 [1, 2, 999]였는데 DB에는 1, 2만 존재한다면,
- 요청한 ID 개수는 3개
- 실제 조회된 카테고리는 2개
가 되므로, 없는 카테고리가 포함되었다고 판단하고 예외를 던집니다.
throw new FoodCategoryException(FoodCategoryErrorCode.FOOD_CATEGORY_NOT_FOUND);
이 예외는 도메인 예외이기 때문에 전역 예외 처리기에서 받아 처리합니다.
응답은 대략 이런 형식이 됩니다.
{
"isSuccess": false,
"code": "FOOD404",
"message": "Food category not found",
"result": null
}
그리고 @Transactional 덕분에 앞에서 저장했던 회원 정보도 롤백됩니다.
이 부분을 보면 검증은 두 단계로 나뉜다는 걸 알 수 있습니다.
- 형식 검증은 @Valid
- 비즈니스 검증은 Service
즉, “빈 값이냐” 같은 기본 검증은 DTO에서 하고, “실제로 존재하는 음식 카테고리냐” 같은 도메인 검증은 Service에서 하는 구조입니다.
10. 회원과 음식 카테고리를 연결하는 엔티티를 만든다
음식 카테고리가 모두 존재한다면, 이제 회원과 음식 카테고리를 연결하는 엔티티를 생성합니다.
List<MemberFoodPreference> preferences = foodCategories.stream()
.map(foodCategory -> MemberFoodPreference.create(savedMember, foodCategory))
.toList();
여기서 MemberFoodPreference는 회원과 음식 카테고리 사이를 연결하는 역할을 합니다.
즉, 회원이 어떤 음식 취향을 선택했는지 저장하는 중간 엔티티라고 볼 수 있습니다.
예를 들어 회원 ID가 1이고, 카테고리 ID가 1과 2라면 이런 관계가 만들어집니다.
- member_id: 1, food_category_id: 1
- member_id: 1, food_category_id: 2
이 흐름은 “회원은 여러 음식 카테고리를 선호할 수 있다”는 비즈니스 규칙이 실제 DB 구조로 어떻게 표현되는지를 보여주는 좋은 예라고 생각했습니다.
11. 회원 음식 선호 데이터를 DB에 저장한다
생성한 연결 엔티티들은 saveAll()로 한 번에 저장합니다.
memberFoodPreferenceRepository.saveAll(preferences);
saveAll()은 여러 엔티티를 한 번에 저장하는 메서드입니다.
즉, 회원이 선택한 카테고리 개수만큼 member_food_preference 테이블에 row가 생성됩니다.
이 단계까지 오면 비로소 회원가입에 필요한 주요 데이터 저장이 완료된 셈입니다.
12. 메모리 안의 연관관계도 맞춰준다
그다음 코드가 조금 흥미롭습니다.
savedMember.getFoodPreferences().addAll(preferences);
이 코드는 DB 저장과는 별개로, 현재 자바 메모리 안에 있는 savedMember 객체에도 방금 저장한 음식 선호 목록을 넣어주는 역할을 합니다.
왜 필요할까요?
바로 다음 단계에서 응답 DTO를 만들 때 이 값을 사용하기 때문입니다.
return memberConverter.toSignUpResponse(savedMember);
실제로 컨버터 내부에서는 이렇게 음식 카테고리 ID를 꺼냅니다.
List<Long> foodCategoryIds = member.getFoodPreferences().stream()
.map(MemberFoodPreference::getFoodCategory)
.map(foodCategory -> foodCategory.getId())
.toList();
즉, DB에는 저장이 되어 있어도 현재 메모리 안의 savedMember 객체에 연관 목록이 비어 있다면 응답 DTO를 만들 때 문제가 생길 수 있습니다.
그래서 이 코드는 단순한 덧붙이기가 아니라, 응답 변환 시점에 필요한 연관관계를 메모리에서도 맞춰주는 작업이라고 볼 수 있습니다.
개인적으로는 이 부분이 API 흐름을 볼 때 꽤 중요하다고 느꼈습니다.
DB 저장만 끝났다고 모든 게 끝나는 것이 아니라, 현재 객체 상태도 응답 생성에 맞게 정리해야 한다는 점이 드러나기 때문입니다.
13. MemberConverter가 응답 DTO를 만든다
이제 서비스는 MemberConverter를 사용해 엔티티를 응답 DTO로 바꿉니다.
return memberConverter.toSignUpResponse(savedMember);
컨버터는 대략 이런 식으로 동작합니다.
public MemberSignUpResponse toSignUpResponse(Member member) {
List<Long> foodCategoryIds = member.getFoodPreferences().stream()
.map(MemberFoodPreference::getFoodCategory)
.map(foodCategory -> foodCategory.getId())
.toList();
return new MemberSignUpResponse(
member.getId(),
member.getEmail(),
member.getNickname(),
member.getGender(),
member.getBirthDate(),
member.getAddress(),
foodCategoryIds,
member.getStatus(),
member.getTotalPoint()
);
}
여기서 중요한 점은 엔티티를 그대로 응답으로 내보내지 않는다는 것입니다.
이유는 다음과 같습니다
1. 클라이언트에게 필요한 값만 내려주기 위해
2. DB 구조와 API 응답 구조를 분리하기 위해
3. 민감한 값이나 불필요한 연관관계가 노출되는 것을 막기 위해
실제로 현재 응답 DTO에는 password가 없습니다.
즉, 회원가입에 비밀번호를 사용했더라도 응답에는 포함되지 않습니다.
이 부분을 보면 DTO와 Converter가 왜 필요한지도 더 분명해집니다.
- Entity는 DB 중심 객체
- DTO는 API 중심 객체
- Converter는 그 둘을 연결하는 객체
이 역할 분리가 있기 때문에 응답 구조를 더 안전하게 제어할 수 있습니다.
14. 다시 컨트롤러로 돌아와 공통 응답 형식으로 감싼다
서비스가 MemberSignUpResponse를 반환하면, 흐름은 다시 컨트롤러로 돌아옵니다.
MemberSignUpResponse response = memberService.signUp(request);
그다음 컨트롤러는 이 응답 DTO를 공통 응답 형식으로 감쌉니다.
return ResponseEntity.status(GeneralSuccessCode.CREATED.getHttpStatus())
.body(ApiResponse.onSuccess(GeneralSuccessCode.CREATED, response));
여기서 GeneralSuccessCode.CREATED는 대략 이런 의미를 가집니다.
- HTTP Status: 201 Created
- code: COMMON201
- message: 생성되었습니다.
즉, 컨트롤러는 서비스가 만들어준 실제 응답 데이터를 ApiResponse에 넣고, 동시에 HTTP 상태 코드도 201 Created로 설정하는 것입니다.
이 흐름을 보면 ApiResponse는 응답 구조를 통일하는 역할을 하고, ResponseEntity는 HTTP 상태 코드를 제어하는 역할을 한다는 것도 함께 이해할 수 있습니다.
15. 최종적으로 클라이언트는 이런 응답을 받는다
회원가입이 성공하면 클라이언트는 이런 응답을 받게 됩니다.
{
"isSuccess": true,
"code": "COMMON201",
"message": "생성되었습니다.",
"result": {
"memberId": 1,
"email": "test@example.com",
"nickname": "mingyu",
"gender": "MALE",
"birthDate": "2000-01-01",
"address": "Seoul",
"foodCategoryIds": [1, 2],
"status": "ACTIVE",
"totalPoint": 0
}
}
이 응답을 보면 지금까지의 흐름이 한 번에 정리됩니다.
- isSuccess, code, message는 공통 응답 포맷
- result는 회원가입 결과 데이터
- password는 응답에 포함되지 않음
- 상태 코드는 201 Created
즉, API는 단순히 저장만 하고 끝나는 것이 아니라, 마지막까지 클라이언트가 사용하기 좋은 형태로 데이터를 정리해서 반환하는 과정이라고 볼 수 있습니다.
예외가 발생하면 흐름은 어떻게 달라질까?
회원가입 API는 성공 흐름도 중요하지만, 실패 흐름도 함께 봐야 더 잘 이해됩니다.
예를 들어 잘못된 이메일 형식이나 빈 닉네임처럼 요청 자체가 잘못된 경우에는 @Valid 단계에서 걸립니다.
이 경우에는 Service까지 가지 않고 바로 예외가 발생하며, 전역 예외 처리기가 400 응답을 반환합니다.
반대로 요청 형식은 맞지만 존재하지 않는 음식 카테고리 ID가 들어온 경우에는 Service 내부에서 예외가 발생합니다.
이 경우에는 이미 member 저장이 시도되었더라도 @Transactional 덕분에 전체 작업이 롤백됩니다.
즉, 실패 흐름도 단계에 따라 나뉩니다.
- 형식이 잘못된 요청 → 컨트롤러 진입 전 검증 실패
- 비즈니스 규칙 위반 → 서비스 내부 예외 발생
- 둘 다 최종적으로는 공통 에러 응답 형식으로 반환
이 부분을 보면 예외 처리도 API 흐름의 바깥이 아니라, 흐름 안에 포함된 중요한 설계 요소라는 생각이 들었습니다.
전체 흐름을 다시 한 번 정리하면
회원가입 API의 전체 흐름은 다음과 같습니다.
- 클라이언트가 POST /api/v1/members/signup 요청을 보낸다
- Spring이 MemberController.signUp() 메서드를 찾는다
- @RequestBody가 JSON을 MemberSignUpRequest로 변환한다
- @Valid가 DTO 필드 검증을 수행한다
- 검증에 성공하면 memberService.signUp(request)를 호출한다
- Member.create()로 회원 엔티티를 생성한다
- memberRepository.save(member)로 회원을 저장한다
- foodCategoryRepository.findAllById(...)로 음식 카테고리를 조회한다
- 요청한 카테고리 개수와 조회된 개수가 다르면 예외를 발생시키고 롤백한다
- MemberFoodPreference.create(...)로 회원-카테고리 연결 엔티티를 만든다
- memberFoodPreferenceRepository.saveAll(...)로 선호 카테고리를 저장한다
- savedMember.getFoodPreferences().addAll(...)로 메모리 안의 연관 목록도 맞춘다
- memberConverter.toSignUpResponse(...)로 응답 DTO를 만든다
- ApiResponse.onSuccess(...)로 공통 응답 body를 만든다
- ResponseEntity.status(201)로 HTTP 상태 코드를 지정한다
- 클라이언트에게 JSON 응답을 반환한다
이 흐름을 따라가 보니 Controller, Service, Repository를 왜 나누는지가 훨씬 선명해졌습니다.
이번 내용을 정리하며 느낀 점
이번 회원가입 API 흐름을 정리하면서 가장 크게 느낀 점은, API 하나가 생각보다 많은 단계를 거쳐 동작한다는 점이었습니다.
처음에는 Controller에서 요청을 받고 Service에서 저장하면 끝이라고 단순하게 생각했는데, 실제로는 그 사이에
- JSON → DTO 변환
- 유효성 검증
- 트랜잭션 처리
- 도메인 검증
- 여러 Repository 호출
- Entity → DTO 변환
- 공통 응답 포맷 적용
같은 단계들이 차례대로 이어지고 있었습니다.
특히 인상 깊었던 부분은 두 가지였습니다.
첫 번째는 검증이 두 단계로 나뉜다는 점입니다.
형식 검증은 @Valid가 처리하고, 비즈니스 검증은 Service가 처리한다는 점이 API 구조를 훨씬 깔끔하게 만들어준다고 느꼈습니다.
두 번째는 응답도 마지막까지 설계의 일부라는 점입니다.
DB 저장이 끝났다고 API가 끝나는 것이 아니라, DTO 변환과 공통 응답 포맷 적용까지 완료되어야 비로소 하나의 API가 완성된다는 점이 더 선명하게 보였습니다.
마무리
이번 글에서는 회원가입 API를 기준으로, 클라이언트의 요청이 들어온 순간부터 최종 응답이 반환되기까지의 전체 흐름을 정리해보았습니다.
Controller는 요청과 응답을 담당하고, Service는 회원가입 비즈니스 로직을 수행하고, Repository는 DB 저장과 조회를 담당합니다.
그리고 Entity는 DB와 연결되는 객체이고, DTO는 API 요청과 응답을 위한 객체이며, Converter는 그 둘을 안전하게 이어주는 역할을 합니다.
결국 API 흐름을 이해한다는 것은 단순히 “어느 메서드가 먼저 실행되는가”를 외우는 것이 아니라, 각 계층이 어떤 책임을 가지고 협력하는지 이해하는 것이라고 생각합니다.
회원가입 API 하나만 제대로 따라가 봐도, 스프링 백엔드의 전체 구조가 왜 이렇게 설계되는지 훨씬 더 잘 보이는 것 같습니다.