API란?
- Application Programming Interface
- 응용 프로그램 사이 통신에 사용되는 공통적인 규약
- 주로 클라이언트와 서버가 소통하기 위한 메서드 및 데이터 형식을 정의
- 클라이언트가 서버로 보내는 요청 데이터 형식 = RequestDto
- 게시물 수정, 회원정보 수정 등
- 서버가 클라이언트로 보내는 응답 데이터 형식 = ResponseDto
- 게시물 목록 조회, 내 정보 조회 등
- Springboot에서 주로 Controller 계층에서 API 기능을 구현
- 클라이언트를 가장 가까이 마주하는 최전선이자 요청의 입구 & 응답의 출구 역할
REST API
REST API의 주요 메서드
REST API의 주요 4가지 상황에 대해 기본적으로 정리해보자.
- GET: 주로 리소스의 조회나 검색에 사용, 서버에서 클라이언트로 데이터(ResponseDto)를 반환
- POST: 주로 새로운 리소스를 생성하는 데 사용, 클라이언트가 서버에 데이터(RequestDto)를 보내고, 서버가 이를 처리하고 관련 리소스 관리
- PUT: 주로 특정 리소스를 수정하는 데 사용
- DELETE: 주로 특정 리소스를 삭제하는 데 사용
중요: POST vs PUT
- 멱등성: 같은 요청을 서버에 연속으로 가했을 때 결과가 동일한 성질
- POST
- POST는 주로 리소스를 생성하는 데 사용
- POST 요청은 멱등성을 갖지 않음
- 새 게시물을 생성하는 POST 요청을 여러 번 실행하면 각각 다른 게시물이 생성
- PUT
- PUT은 주로 리소스를 수정하는 데 사용
- PUT 요청은 멱등성을 가짐
- 특정 게시물을 수정하는 PUT 요청을 여러 번 실행하면 항상 동일한 게시물 상태가 유지
HttpsCode
클라이언트가 자신이 서버에 가한 HTTP 요청의 응답에 대한 상태 코드에 대해 기본적으로 정리해보자.
성공 상태 코드: 200대
- 200 OK: 요청이 성공적으로 처리되었고 적절한 응답을 제공하였다.
- 201 Created: 요청이 성공적으로 처리되어 새로운 리소스가 생성되었다.
실패 상태 코드
- 400 Bad Request: 클라이언트의 요청이 잘못되었거나 유효하지 않음. 보통 요청 데이터의 형식이나 내용이 올바르지 않을 때 나타난다.
- 403 Forbidden: 클라이언트에 요청한 리소스에 대한 권한이 없을 때 나타난다. 서버가 요청을 이해했으나, 게시물 삭제 요청 등 클라이언트가 해당 리소스에 접근할 권한이 있어야만 할 때 사용한다.
- 404 Not Found: 요청한 리소스를 서버에서 찾을 수 없음을 나타내며, 클라이언트가 존재하지 않은 리소스에 접근할 때 발생한다.
- 405 Method Not Allowed: 클라이언트가 요청한 http 메서드가 해당 리소스에서 허용되지 않음을 의미한다. 예를 들어 GET 요청만 가능한 리소스에 POST 요청을 보낼 때가 해당된다.
- 409 Conflict: 서버가 요청을 처리하던 중 상태 충돌이 발생했음을 의미한다. 예를 들어 이미 좋아요를 누른 게시물에 다시 좋아요를 누르려 한다거나, 이미 구독을 취소한 태그에 다시 구독을 취소하려는 시도 등이 해당된다. 동일한 존재로 확인된 유저가 다시 회원가입을 시도하는 경우 등에 해당한다.
- 500 Internal Servor Error: 서버에서 처리 중 예상치 못한 오류가 발생하여 요청을 처리할 수 없는 경우에 나타난다.
기본 제공되는 스프링 응답 객체: ResponseEntity<>
- 스프링에서 기본적으로 제공하는 응답용 객체!
- ResponseEntity<Member> 등의 형태로 선언한다.
- 참고: <>는 제네릭(Generics)이라 하는데, 이 <>안에 어떠한 타입을 선언해주어 해당 ArrayList, List 등이 사용할 객체의 타입을 지정해준다는 뜻이다. 이는 다룰 객체의 타입을 미리 명시하여 객체의 형변환이 필요없게 하며, 내가 사용하고 싶은 데이터 타입만 사용할 수 있게 해 준다.
- 사용 예시
@RequiredArgsConstructor @RestController @RequestMapping("/api/user") public class UserController { private final UserService userService; @GetMapping("{userId}/info/simple") public ResponseEntity<ResponseSimpleUserDto> getSimpleUserInfo(@PathVariable Long userId) { ResponseSimpleUserDto userInfo = userService.getSimpleUserInfo(userId); return ResponseEntity.ok(userInfo); } }
@Getter @AllArgsConstructor(access = AccessLevel.PROTECTED) public class ResponseSimpleUserDto { private String name; private String image; public static ResponseSimpleUserDto of(String name, String image) { return new ResponseSimpleUserDto(name, image); } }
- 응답 JSON
{ "name": "John Doe", "image": "https://example.com/johndoe.jpg" }
- ResponseEntity<>의 단점
- 실전에선 응답 상태 코드 및 헤더, 일부 응답 메시지를 설정하는 추가적인 작업이 필요
- 응답을 완성하기 위한 추가 로직이 Controller에 섞여들어갈 것을 강요한다. (관심사 분리 실패)
@RequestMapping(value = "/{customerId}", method=GET) public ResponseEntity<Customer> findById(@PathVariable int customId) throws DataNotFoundException{ //1 Customer customer = customerService.findById(customerId); HttpHeaders headers = new HttpHeaders(); headers.setContentType(new MediaType("text","xml", Charset.forName("UTF-8")); headers.set("My-Header","MyHeaderValue"); return new ResponseEntity<Customer>(customer, headers, HttpStatus.OK); //3 }
- 응답 객체 관리를 따로 분리시켜, Controller은 본래의 목적에 맞게 응답 그 자체에만 초점을 맞추게 해보자!
커스텀 응답객체: ApiResponse
@Getter @RequiredArgsConstructor public class ApiResponse<T> { private ApiHeader header; private T data; // T는 어떤 데이터 형식 private String msg; private static final int SUCCESS = 200; private ApiResponse(ApiHeader header, T data, String msg) { this.header = header; this.data = data; this.msg = msg; } public static <T> ApiResponse<T> success(T data, String message) { return new ApiResponse<T>(new ApiHeader(SUCCESS, "SUCCESS"), data, message); } public static <T> ApiResponse<T> fail(ResponseCode responseCode, T data) { return new ApiResponse<T>(new ApiHeader(responseCode.getHttpStatusCode(), responseCode.getMessage()), data, responseCode.getMessage()); } }
@Getter @AllArgsConstructor public class ApiHeader { private int code; private String message; }
- API 응답을 좀 더 구체적으로 표현하고 헤더와 data로 분리하여 구체적인 정보를 포함하는 데 최적화
- 보낼 데이터와 보낼 http 메시지의 생성 방법을 정적 메서드로 고정시킴과 동시에, 성공/실패라는 가장 핵심적인 가치로 응답객체 생성 방법을 분리 가능
- 근데 꼭 응답 객체 커스터마이징 해야 하나요? ResponseEntity 쓰면 안 되나요?
- 상황에 따라 본인이 판단, 배우는 입장에선 ResponseEntity가 다루기 편함
- 잠깐! 근데 T가 뭐죠?
- 커스텀 응답객체 사용 예시
@RequiredArgsConstructor @RestController @RequestMapping("/api/user") public class UserController { private final UserService userService; @GetMapping("{userId}/info/simple") public ApiResponse<ResponseSimpleUserDto> getSimpleUserInfo(@PathVariable Long userId) { return ApiResponse.success(userService.getSimpleUserInfo(userId), ResponseCode.USER_CREATE_SUCCESS.getMessage()); } }
- 응답 JSON
{ "header": { "code": 200, "message": "SUCCESS" }, "data": { "name": "John Doe", "image": "https://example.com/johndoe.jpg" }, "msg": "User created successfully." }
- 헤더에 응답코드와 성공 여부, 구체적인 응답 데이터와 응답 메시지를 빠르고 간결하게 생성 가능
ResponseCode 열거형
- 위에서 언급했다시피 서버는 클라이언트에게 성공/실패 관련 메시지와 Http 코드 등을 전달해야 한다.
- “유저 조회 성공”, “유저 삭제 성공” 등
- 굳이 직접 한 메서드마다 하드코딩해야하나?
- 내용을 바꿔야 하면 싹 다 일일이 바꿔야 한다.
- 그냥 한 곳에 모아놓고 관리하면 안 되나? 에서 출발
@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter public enum ResponseCode { // 400 Bad Request BAD_REQUEST(HttpStatus.BAD_REQUEST, false, "잘못된 요청입니다."), // 403 Forbidden FORBIDDEN(HttpStatus.FORBIDDEN, false, "권한이 없습니다."), // 404 Not Found USER_NOT_FOUND(HttpStatus.NOT_FOUND, false, "사용자를 찾을 수 없습니다."), // 405 Method Not Allowed METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, false, "허용되지 않은 메소드입니다."), // 409 Conflict USER_ALREADY_EXIST(HttpStatus.CONFLICT, false, "이미 가입한 사용자입니다."), USER_NAME_ALREADY_EXIST(HttpStatus.CONFLICT, false, "이미 존재하는 닉네임입니다."), // 500 Internal Server Error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "서버에 오류가 발생하였습니다."), // 200 OK USER_READ_SUCCESS(HttpStatus.OK, true, "사용자 정보 조회 성공"), USER_UPDATE_SUCCESS(HttpStatus.OK, true, "사용자 정보 수정 성공"), USER_LOGIN_SUCCESS(HttpStatus.OK, true, "사용자 로그인 성공"), // 201 Created USER_CREATE_SUCCESS(HttpStatus.CREATED, true, "사용자 생성 성공"), private final HttpStatus httpStatus; private final Boolean success; private final String message; public int getHttpStatusCode() { return httpStatus.value(); } }
- 메시지에 대응하는 HttpStatus 코드와 성공/실패 여부, 메시지 내용 등을 열거형으로 통합적인 관리 가능
- 그냥 필요할 때마다 추가하면 끝!
- 유지보수, 재사용성에 확실한 이점
Exception
여러 API 요청에 대해 처리할 때 서버 내부에서 발생하는 예외처리에 대응하는 기본적인 방법에 대해 알아보자.
@AllArgsConstructor @Getter public class BaseException extends RuntimeException { private final ResponseCode responseCode; @Override public String getMessage() { return responseCode.getMessage(); } } // user 패키지에서 벌어지는 예외처리를 관리하려는 용도로 또다른 Exception 객체를 생성할 수 있음 public class UserException extends BaseException { public UserException(ResponseCode responseCode) { super(responseCode); } }
위에서 만들어둔 ResponseCode 열거형을 기반으로 만든 Exception 객체이다. 어떤 이유로 Exception이 발생했는지 관련된 정보를 열거형을 통해 포함할 수 있다.
GlobalExceptionHandler
회원정보 조회 기능에 대한 구체적인 코드 실행 상황에 대해 살펴보자.
@RequiredArgsConstructor @RestController @RequestMapping("/api/user") public class UserController { private final UserService userService; // 회원정보 조회 @GetMapping("{userId}/info/simple") public ApiResponse<ResponseSimpleUserDto> getSimpleUserInfo(@PathVariable Long userId) { return ApiResponse.success(userService.getSimpleUserInfo(userId), ResponseCode.USER_CREATE_SUCCESS.getMessage()); } }
- 먼저 Client가 userId 1을 기준으로 회원정보 조회를 요청한다고 가정하자. (baseUrl/api/user/1/info/simple에 GET 요청)
- UserController는 userService.getSimpleUserInfo 메서드를 호출하여 그 반환값인 ResponseSimpleUserDto를 받아오려고 시도할 것이다.
@Transactional(readOnly = true) public ResponseSimpleUserDto getSimpleUserInfo(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new UserException(ResponseCode.USER_NOT_FOUND)); return ResponseSimpleUserDto.of(user.getName(), user.getImage()); }
- 만약 findById의 결과가 존재하지 않는, 즉 1을 id로 갖는 유저가 없을 때 서버는 UserException을 발생시켜 문제가 생겼음을 내부에 알린다.
- 그런데 이 실패 상황을 클라이언트에게 어떻게 전달해야 할까?
- 이렇게 예외가 발생했을 때는 더 이상의 진행이 의미가 없기 때문에, 사고를 수습해줄 전문가(?)가 필요하다.
@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BaseException.class) // UserException 발생하면 자동으로 호출 public ApiResponse<Void> handleUserException(UserException e) { log.info("UserException: {}", e.getMessage()); return ApiResponse.fail(e.getResponseCode(), null); } }
- GlobalExceptionHandler는 @ControllerAdvice를 통해 스프링 애플리케이션 내에서 발생하는 특정 예외에 대응하는 Exception Handling을 중앙에서 통제할 권한을 갖는다.
- @ControllerAdvice + @ResponseBody → @RestControllerAdvice
- Springboot에서 Controller의 메서드가 요청받은 작업을 완수하지 못하고 Exception을 당했을(?) 때 이를 수습하고 질서를 유지하는 스프링의 경찰(?)같은 존재라고 이해하면 편하다.
- fail 객체를 반환하기 때문에 클라이언트는 구체적인 사고의 원인을 Http 코드 및 메시지로 알 수 있다.
실습
- 지난 시간에 작성한 실습 코드를 기반으로 API 요청과 응답을 시도해보자!
Member
@Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "member_id") private Long id; // 구분하기 위해 시스템이 저장하는 아이디 private String name; private String email; // 생성 메서드 public static Member createMember(String name, String email) { Member member = new Member(); member.name = name; member.email = email; return member; } // 수정 메서드 public void update(String name, String email) { this.name = name; this.email = email; } }
MemberDto
/** * MemberDto는 클라이언트가 서버에게 전송하는 요청 데이터, 즉 RequestDto입니다. * (다만 이 프로젝트에선 간단한 예제이기에 ResponseDto의 역할도 겸합니다.) * RequestDto는 @Getter와 @NoArgsConstructor가 요구되며, ResponseDto는 @Getter가 요구됩니다. */ @Getter @NoArgsConstructor public class MemberDto { private String name; private String email; public static MemberDto from(Member member) { MemberDto memberDto = new MemberDto(); memberDto.name = member.getName(); memberDto.email = member.getEmail(); return memberDto; } public static MemberDto of(String name, String email) { MemberDto memberDto = new MemberDto(); memberDto.name = name; memberDto.email = email; return memberDto; } }
MemberRepository
/** * Repository 계층은 실제로 DB에 접근하는 계층입니다. * 단순하게 CRUD 기능만 필요하다면 CrudRepository를 상속받아서 사용해도 충분합니다. * 다만 고급 기능 (정렬, 페이징 등)이 필요하다면 이를 상속받은 PagingAndSortingRepository를 사용하는데, * JpaRepository는 바로 이 PagingAndSortingRepository를 상속받기에, 입문 단계인 경우 편하게 JpaRepository를 사용하면 됩니다. * 필요한 메서드가 있다면 직접 인터페이스만 선언해주면 JPA가 알아서 구현체를 만들어서 이를 사용할 수 있도록 합니다. * 즉, 일종의 기능 명세서라고 생각하면 됩니다. */ public interface MemberRepository extends CrudRepository<Member, Long> { Member save(Member member); // 회원 저장 Optional<Member> findById(Long id); // id로 회원 찾기 Optional<Member> findByName(String name); // 없으면 null이 반환되는데, 그렇게 반환하지 않고 Optional로 감싸서 반환한다. List<Member> findAll(); // 지금까지 저장된 모든 회원 리스트로 반환 boolean existsByName(String name); // 이름으로 회원 찾기 }
MemberService
@Service public class MemberService { @Autowired private MemberRepository memberRepository; /** * 회원 가입 * 생성 메서드를 통해 회원 객체를 생성하고, 이를 DB에 저장합니다. * 실제 서비스를 만들 때는 특정 값의 중복 여부 등을 판정해야 하는 상황이 존재할 수 있기에 이를 위한 메서드를 별도로 만들어서 사용하는 경우가 많습니다. * */ public Long join(MemberDto memberDto){ // 같은 이름이 있는 중복 회원은 안됨. validateDuplicateMember(memberDto.getName()); Member member = Member.createMember(memberDto.getName(), memberDto.getEmail()); // 멤버 객체 생성 return memberRepository.save(member).getId(); // 멤버 객체 DB 테이블에 저장하고, 이 멤버의 고유식별자 반환 } /** * 중복 회원 검증용 메서드 * 이름을 통해 중복 여부를 검증하는 내부 메서드이기에, private을 사용했습니다. * 존재 여부만 판정할 때는 existsBy 메서드를 사용하는 것이 서버 리소스를 아낄 수 있습니다. * 그러나 실제로 member 객체를 가져와야 하는 경우에는 findBy 메서드를 사용해야 합니다. * 만약 직접 객체를 가져와야 하는 경우엔 validate보다는 getMemberBy~와 같은 메서드명으로 작성하는 것이 좋습니다. */ private void validateDuplicateMember(String name) { if(memberRepository.existsByName(name)){ throw new IllegalStateException("이미 존재하는 회원입니다."); } /*memberRepository.findByName(name).ifPresent(m ->{ throw new IllegalStateException("이미 존재하는 회원입니다."); });*/ } /** * 전체 회원 조회 * findAll()은 특정 테이블에 존재하는 모든 데이터를 조회하는 메서드입니다. * 다만 모든 데이터를 가져오는 만큼 서버에 부하가 걸리기 때문에, 실제로 사용할 때는 페이징(10개씩) 처리를 해서 사용하는 것이 좋습니다. * 페이징의 경우에는 추후 기회가 된다면 다루도록 하겠습니다. * */ public List<MemberDto> findMembers(){ List<Member> members = memberRepository.findAll(); List<MemberDto> memberDtos = new ArrayList<>(); for(Member member : members){ memberDtos.add(MemberDto.from(member)); } return memberDtos; // 객체를 Dto로 바꾸는 위 for문은 아래 stream을 사용한 코드와 같은 결과를 반환합니다. stream을 통해 코드를 간결하게 만들 수 있습니다. return members.stream().map(MemberDto::from).collect(Collectors.toList()); } /** * 회원 한명 조회 (Optional) * Optional은 null이 반환될 가능성이 있는 객체를 감싸서 NullPointException을 방지하기 위해 사용합니다. * 다만 Optional을 사용하면 성능이 느려질 수 있기에, 실전에서는 Optional 없이 orElseThrow()를 사용하는 경우가 많습니다. * */ public Optional<MemberDto> findOneOptional(Long memberId){ Optional<Member> memberOpt = memberRepository.findById(memberId); // Optional.map()은 Optional 객체가 존재할 경우에만 실행되며, 존재하지 않을 경우에는 실행되지 않고 빈 Optional 객체를 반환합니다. return memberOpt.map(MemberDto::from); } /** * 회원 한명 조회 (Optional 없이 orElseThrow() 사용) * 보다 실전적인 코드이며, 클라이언트에게 빈 값을 반환하는 게 의미가 없는 상황일 경우 사용합니다. * orElseThrow() 메서드를 사용하면, 값이 없는 경우에는 Optional처럼 빈 값을 반환하지 않고 Exception을 발생시킵니다. * 다만 실제로 findBy를 통해 Repository 계층에서 뽑아내는 객체 자체는 여전히 Optional로 감싸져 있습니다. * */ public MemberDto findOne(Long memberId){ Member member = memberRepository.findById(memberId).orElseThrow(() -> new IllegalArgumentException("해당하는 회원이 없습니다.")); return MemberDto.from(member); } }
- Question: Dto를 Entity로 바꾸는 작업, 또는 그 역에 해당하는 작업은 어디서 해야 하나요?
- 사람마다 Dto의 활동 범위에 대한 의견이 다양합니다.
- 보편적으로는 Service가 스프링의 중추인 만큼 여기서 처리하는 경우가 많지만, Controller에서 처리하는 분도 있고, 아예 Repository에서 Entity를 Dto로 바로 바꾸는 경우도 있습니다.
- JPA에선 Mapper 객체를 활용한 계층을 하나 더 추가한 특별한 패턴도 존재합니다만, 프로젝트가 더 복잡해지기 때문에 일단은 넘어갑시다.
- 다만 아래처럼 Entity가 Dto에 직접 의존하는 상황은 피해야 합니다.
public static Member createMember(MemberDto memberDto) { Member member = new Member(); member.name = memberDto.getName(); member.email = memberDto.getEmail(); return member; }
BaseException
@AllArgsConstructor @Getter public class BaseException extends RuntimeException{ private final ResponseCode responseCode; @Override public String getMessage() { return responseCode.getMessage(); } }
ResponseCode
@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter public enum ResponseCode { // 400 Bad Request BAD_REQUEST(HttpStatus.BAD_REQUEST, false, "잘못된 요청입니다."), // 401 Unauthorized UNAUTHORIZED(HttpStatus.UNAUTHORIZED, false, "인증되지 않은 사용자입니다."), // 403 Forbidden FORBIDDEN(HttpStatus.FORBIDDEN, false, "권한이 없습니다."), // 404 Not Found USER_NOT_FOUND(HttpStatus.NOT_FOUND, false, "사용자를 찾을 수 없습니다."), // 405 Method Not Allowed METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, false, "허용되지 않은 메소드입니다."), // 409 Conflict USER_ALREADY_EXIST(HttpStatus.CONFLICT, false, "이미 가입한 사용자입니다."), // 500 Internal Server Error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "서버에 오류가 발생하였습니다."), // 200 OK USER_READ_SUCCESS(HttpStatus.OK, true, "사용자 정보 조회 성공"), USER_UPDATE_SUCCESS(HttpStatus.OK, true, "사용자 정보 수정 성공"), // 201 Created USER_CREATE_SUCCESS(HttpStatus.CREATED, true, "사용자 생성 성공"); private final HttpStatus httpStatus; private final Boolean success; private final String message; public int getHttpStatusCode() { return httpStatus.value(); } }
GlobalExceptionHandler
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(CustomException.class) // CustomException 발생하면 자동으로 호출! public ResponseEntity<Void> handleUserException(CustomException e) { return ResponseEntity.status(e.getResponseCode().getHttpStatus()).build(); } }
- MemberService에 가서 예외처리를 모두 CustomException으로 교체해야 합니다.
MemberController (with ResponseEntity)
@RequiredArgsConstructor // 의존성 주입을 위해 Service에 final을 붙이고 @RequiredArgsConstructor를 붙여줍니다. @RestController // 이제 REST API를 처리하는 Controller로 성질이 바뀝니다 (@Controller + @ResponseBody) public class MemberController { // 멤버 서비스를 스프링이 스프링 컨테이너에 있는 멤버서비스를 가져와서 딱 붙여줌. private final MemberService memberService; // 회원 가입 @PostMapping("/members/new") public ResponseEntity<Long> create(@RequestBody MemberDto memberDto){ return ResponseEntity.ok(memberService.join(memberDto)); } // 회원 수정 @PutMapping("/members/{id}") public ResponseEntity<Void> update(@PathVariable Long id, @RequestBody MemberDto memberDto){ memberService.update(id, memberDto); return ResponseEntity.ok().build(); } // 특정 회원 조회 @GetMapping("/members/{id}") public ResponseEntity<MemberDto> getMember(@PathVariable Long id){ return ResponseEntity.ok(memberService.findOne(id)); } // 전체 회원 조회 @GetMapping("/members") public ResponseEntity<List<MemberDto>> getAllUsers(){ return ResponseEntity.ok(memberService.findMembers()); } }
Postman으로 기능 테스트
- 스프링을 빌드하고 postman을 켠 뒤 Body에서 raw, JSON을 선택합니다.
{ "name": "승운", "email": "winluck@cau.ac.kr" }
- 회원 가입 및 수정 API의 경우 위와 같이 직접 MemberDto를 서버로 전송할 수 있습니다.
- 회원가입 API를 실행하여 회원 1을 만들어봅시다.
- [특정 회원 조회] API를 실행해봅시다.
- [회원 수정] API를 실행하여 회원 1의 정보를 수정해봅시다.
- 다시 회원가입 API를 실행하여 회원 2를 만들어봅시다.
- [전체 회원 조회] API를 실행하여 회원 1의 수정과 회원 2의 생성이 성공적으로 이루어졌는지 확인합니다.