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 등이 사용할 객체의 타입을 지정해준다는 뜻이다. 이는 다룰 객체의 타입을 미리 명시하여 객체의 형변환이 필요없게 하며, 내가 사용하고 싶은 데이터 타입만 사용할 수 있게 해 준다.
사용 예시
응답 JSON
ResponseEntity<>의 단점
실전에선 응답 상태 코드 및 헤더, 일부 응답 메시지를 설정하는 추가적인 작업이 필요
응답을 완성하기 위한 추가 로직이 Controller에 섞여들어갈 것을 강요한다. (관심사 분리 실패)
응답 객체 관리를 따로 분리시켜, Controller은 본래의 목적에 맞게 응답 그 자체에만 초점을 맞추게 해보자!
커스텀 응답객체: ApiResponse
API 응답을 좀 더 구체적으로 표현하고 헤더와 data로 분리하여 구체적인 정보를 포함하는 데 최적화
보낼 데이터와 보낼 http 메시지의 생성 방법을 정적 메서드로 고정시킴과 동시에, 성공/실패라는 가장 핵심적인 가치로 응답객체 생성 방법을 분리 가능
근데 꼭 응답 객체 커스터마이징 해야 하나요? ResponseEntity 쓰면 안 되나요?
@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
}
@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;
}
@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());
}
}
@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();
}
}
@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);
}
}
@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());
}
}
@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());
}
@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);
}
}
@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는 클라이언트가 서버에게 전송하는 요청 데이터, 즉 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;
}
}
/**
* 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); // 이름으로 회원 찾기
}
@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);
}
}
public static Member createMember(MemberDto memberDto) {
Member member = new Member();
member.name = memberDto.getName();
member.email = memberDto.getEmail();
return member;
}
@AllArgsConstructor
@Getter
public class BaseException extends RuntimeException{
private final ResponseCode responseCode;
@Override
public String getMessage() {
return responseCode.getMessage();
}
}
@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();
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CustomException.class) // CustomException 발생하면 자동으로 호출!
public ResponseEntity<Void> handleUserException(CustomException e) {
return ResponseEntity.status(e.getResponseCode().getHttpStatus()).build();
}
}
@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());
}
}