REST API

컨테이너·2025년 11월 29일

Spring Boot

목록 보기
7/7
post-thumbnail

1. REST / REST API 개념 정리

1-1. REST란?

  • Representational State Transfer의 약자.
  • “자원(Resource)” 에 URL로 이름을 붙이고, 그 자원에 대한 작업을 HTTP 메서드(GET/POST/PUT/DELETE)로 표현하는 아키텍처 스타일.
  • 설계 중심: ROA(Resource Oriented Architecture)

“URL은 자원, HTTP Method는 동사, 응답 JSON은 자원의 표현”

이 조합으로 CRUD를 표현하는 것이 REST 스타일.


1-2. REST의 구성 요소

  1. 자원(Resource) : URL
    • 예: /orders/1, /users/10
  2. 행위(Verb) : HTTP Method
    • GET: 조회
    • POST: 생성
    • PUT: 전체 수정
    • DELETE: 삭제
  3. 표현(Representation)
    • 대부분 JSON으로 주고받음 (과거 XML -> 용량 크고 번거로움)

1-3. JSON 요약

  • Javascript Object Notation : JS 객체 표기법을 차용한 문자열 데이터 포맷
  • 장점
    • XML보다 가볍고(태그 반복 없음), 개발자에게 친숙.
    • 파싱이 쉽고, 네트워크 전송에 유리.
  • 기본 규칙
    • { "key": "value" }
    • key는 문자열, 여러 개는 ,로 구분
    • 숫자/boolean은 따옴표 없이 사용 가능
    • 배열 [], 객체 {} 중첩 가능
{
  "name": "홍길동",
  "age": 18,
  "address": "서울특별시"
}

1-4. REST의 특징

  1. 클라이언트 / 서버 분리
    • 클라이언트: 화면, 인증, 로그인 상태 관리
    • 서버: 비즈니스 로직, 데이터 저장
  2. 무상태성(Stateless)
    • 서버는 요청 간 상태를 기억하지 않음 → 단순하고 확장에 유리
  3. 캐시 가능(Cacheable)
    • HTTP 캐시 기능 활용 가능 → 속도, 자원 효율 ↑
  4. 자체 표현 구조(Self-Descriptive)
    • URL + Method + Body만 봐도 무슨 의미인지 대략 이해 가능
  5. 계층화(Layered System)
    • 중간에 프록시, 게이트웨이, 보안 계층 등 자유롭게 끼울 수 있음
  6. 유니폼 인터페이스(Uniform Interface)
    • HTTP 표준만 따르면 언어/플랫폼에 상관없이 사용 가능

이런 REST 스타일을 잘 지켜서 만든 API를 보통 “RESTful하다”라고 표현.


1-5. REST API 설계 규칙 정리

핵심 규칙

  • URI는 “자원”을 표현
  • 동사는 되도록 HTTP Method로 표현
    • /users/create 보다는 POST /users가 좋음

세부 규칙

  1. / 슬래시는 계층 관계 표현
    • /users/1/orders
  2. URI 마지막에 / 붙이지 않기
    • /users (O), /users/ (지양)
  3. 가독성을 위해 하이픈(-) 사용, 언더스코어(_) 지양
  4. 소문자 사용 권장
  5. 파일 확장자 포함 X
    • GET /orders/2 + Accept: application/json
  6. 관계 있을 때
    • /users/{userId}/orders

2. @RestController와 응답 방식 정리

2-1. @RestController 개념

@RestController
@RequestMapping("/response")
public class ResponseRestController {}
  • @Controller + @ResponseBody 조합
  • 반환값이 뷰 이름이 아니라 HTTP Body에 그대로 응답됨
  • HTML/JSP 뷰가 아니라 JSON 같은 데이터 응답에 적합 → REST API에 사용

2-2. 여러 타입 응답

1) 문자열 응답

@GetMapping("/hello")
public String helloworld() {
    return "hello world";
}
  • 넘겨줄 것이 없다면 주소창으로도 내용을 넘겨줄 수 있다.

결과

Hello World!

2) 기본 타입 응답

@GetMapping("/random")
public int getRandomNumber() {
    return (int) (Math.random() * 10) + 1;
}

3) 객체(Object) 응답 → JSON으로 변환

@AllArgsConstructor
@Getter
public class Message {
    private int httpStatusCode;
    private String message;
}

@GetMapping("/message")
public Message getMessage() {
    return new Message(200, "메세지를 응답합니다.");
}

객체의 결과는 아래와 같이 JSON 형태로 반환된다.

{
    "httpStatusCode": 200,
    "message": "메시지를 응답합니다."
}
  • 스프링 부트가 객체에 대한 형식을 넘겨주면 jackson converter를 통해 객체를 JSON 객체로 변환해 준다.

4) 리스트(List) 응답 → JSON 배열

@GetMapping("/list")
public List<String> getList() {
    return List.of("사과", "바나나", "복숭아");
}

결과

[
    "사과",
    "바나나",
    "복숭아"
]
@GetMapping("/listMessage")
public List<Message> getList() {
		list.of(
			new Message(200, "메시지를 응답합니다."),
			new Message(201, "메시지1를 응답합니다."),
			new Message(202, "메시지2를 응답합니다.")
		);
}

결과

[
    {
        "httpStatusCode": 200,
        "message": "메시지를 응답합니다."
    },
    {
        "httpStatusCode": 201,
        "message": "메시지1를 응답합니다."
    },
    {
        "httpStatusCode": 202,
        "message": "메시지2를 응답합니다."
    }
]

5) 맵(Map) 응답 → JSON 객체

@GetMapping("map")
public Map<Integer, String> getMap() {

    List<Message> messageList = new ArrayList<>();
    messageList.add(new Message(200, "정상 응답"));
    messageList.add(new Message(404, "페이지를 찾을 수 없습니다"));
    messageList.add(new Message(500, "개발자의 잘못입니다"));

    return messageList.stream()
            .collect(Collectors.toMap(Message::getHttpStatusCode, Message::getMessage));
}

6) 이미지(byte[]) 응답

@GetMapping(value="/image", produces=MediaType.IMAGE_PNG_VALUE)
public byte[] getImage() throws IOException {
    return getClass()
            .getResourceAsStream("/images/sample.PNG")
            .readAllBytes();
}
  • producescontent-type을 이미지로 지정해야 브라우저에서 이미지로 인식.

2-3. ResponseEntity 응답

  • HTTP 응답을 더 세밀하게 제어할 때 사용. 위 와같이 값만 가져오는 거로는 안됨.
    • 상태 코드(HttpStatus)
    • 헤더(HttpHeaders)
    • 바디(body)
@GetMapping("/entity")
public ResponseEntity<Message> getEntity() {
    return ResponseEntity.ok(new Message(123, "hello world"));
}

GET 할 때 헤더 정보까지 가져오고 싶다면, 이런 방식(JSON넘기기/문자열 값)으론 안됨

상태 코드값, 본문을 넘기려면 ResponseEntity를 통해서 넘겨주어야 한다.

{
    "httpStatusCode": 123,
    "message": "hello world"
}

이런식으로 httpStatusCode 까지 불러올 수 있는 것.


3. ResponseEntity를 활용한 CRUD 예제

공통 DTO들:

@AllArgsConstructor
@Getter
@Setter
public class UserDTO {
    private int no;
    private String id;
    private String pwd;
    private String name;
    private Date enrollDate;
}
@AllArgsConstructor
@Getter
@Setter
public class ResponseMessage {
    private int httpStatus;
    private String message;
    private Map<String, Object> results;
}
  • ResponseMessage `httpstatus` : 응답 코드가 성공했는지 실패했는지 알려주는 코드이다. 200: 요청 성공적 처리 201: 요청이 성공하여 새로운 리소스 생성 등의 요청 결과를 `message`에 담아 전달한다. 해당 message와 Obejct 정보를 result에 담아 반환하는 것.

컨트롤러 기본 구조:

@RestController
@RequestMapping("/entity")
public class ResponseEntityTestController {

    private List<UserDTO> users;

    public ResponseEntityTestController() {
        users = new ArrayList<>();
        users.add(new UserDTO(1, "user01", "pass01", "홍길동", new Date()));
        users.add(new UserDTO(2, "user02", "pass02", "유관순", new Date()));
        users.add(new UserDTO(3, "user03", "pass03", "이순신", new Date()));
    }
}

3-1. 전체 조회 (GET /entity/users)

유저 목록 조회 상황이므로 GET HTTP method로 설정한다. ResponseEntity를 구성하는 응답 바디, 응답 헤더, 응답 상태 코드를 ResponseEntity 생성자를 통해 전달한다.

  • 응답 헤더 설정 : JSON응답이 default이기는 하나, 변경이 필요한 경우 HttpHeaders 설정 변경을 할 수 있다.
@GetMapping("/users")
public ResponseEntity<ResponseMessage> findAllUsers() {

    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(new MediaType("application", "json", Charset.forName("UTF-8")));

    Map<String, Object> responseMap = new HashMap<>();
    responseMap.put("users", users);

    ResponseMessage responseMessage =
            new ResponseMessage(200, "조회 성공", responseMap);

    return new ResponseEntity<>(responseMessage, headers, HttpStatus.OK);
}

3-2. 단건 조회 (GET /entity/users/{userNo})

유저 조회 상황이므로 GET HTTP method로 설정한다. @PathVariable 로 유저 번호를 받는다. ResponseEntity를 구성하는 응답 바디, 응답 헤더, 응답 상태 코드를 빌더 패턴 형태로 작성한다.

@GetMapping("/users/{userNo}")
public ResponseEntity<ResponseMessage> findUserByNo(@PathVariable int userNo) {

    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(new MediaType("application", "json", Charset.forName("UTF-8")));

    UserDTO foundUser = users.stream()
            .filter(user -> user.getNo() == userNo)
            .toList().get(0);

    Map<String, Object> responseMap = new HashMap<>();
    responseMap.put("user", foundUser);

    return ResponseEntity
            .ok()
            .headers(headers)
            .body(new ResponseMessage(200, "조회 성공", responseMap));
}

3-3. 등록 (POST /entity/users)

유저 등록 상황이므로 POST HTTP method를 사용한다.

@RequestBody 어노테이션으로 클라이언트 측에서 JSON 형태로 넘어온 데이터를 전달 받는다. Controller라 클라이언트랑 소통해야하니 RequestBody / ResponseBody를 통해 소통하는데, 등록하는 과정이니 Request 형식이고, RequestBody를 통해 원하는 값을 JSON형식으로 받아옴.

조회 시에는 200번 코드를 응답하지만 삽입 시에는 201번 코드를 응답한다. 201 번 코드는 요청이 성공적으로 처리 되었으며, 자원이 생성 되었음을 나타내는 성공 상태 응답 코드이다. 해당 자원에 대한 요청 url을 location으로 설정하여 응답한다.

@PostMapping("/users")
public ResponseEntity<?> registUser(@RequestBody UserDTO newUser) {

    int lastUserNo = users.get(users.size() - 1).getNo();
    newUser.setNo(lastUserNo + 1);
    newUser.setEnrollDate(new Date());

    users.add(newUser);

    return ResponseEntity
            .created(URI.create("/entity/users/" + newUser.getNo()))
            .build();      // body 없이 201 Created 응답
}

3-4. 수정 (PUT /entity/users/{userNo})

@PutMapping("/users/{userNo}")
public ResponseEntity<?> modifyUser(
        @PathVariable int userNo, @RequestBody UserDTO modifyInfo) {
	
    UserDTO foundUser = users.stream()
            .filter(user -> user.getNo() == userNo)
            .toList().get(0);

    foundUser.setId(modifyInfo.getId());
    foundUser.setPwd(modifyInfo.getPwd());
    foundUser.setName(modifyInfo.getName());

    return ResponseEntity
            .created(URI.create("/entity/users/" + userNo))
            .build();
}

3-5. 삭제 (DELETE /entity/users/{userNo})

@DeleteMapping("/users/{userNo}")
public ResponseEntity<?> removeUser(@PathVariable int userNo) {

    UserDTO foundUser = users.stream()
            .filter(user -> user.getNo() == userNo)
            .toList().get(0);

    users.remove(foundUser);

    return ResponseEntity
            .noContent()   // 204
            .build();
}

정리

주요 어노테이션

Requestparam 하나씩

modelattribute :폼데이터 가져오기

RequestBody : 문자열, 객체 modelattribute등으로 가져옴


4. 예외 처리 + Validation(검증) 정리

  • 내가 보내주는 값과 받는 값을 front에서 체크하고, 둘째로 요청을 서버로 보냈을 때 유효성을 항상 체크해야한다. 마지막으로 DB에 들어가기전에 제약조건을 통해 검증을 한 번 더 하게 된다.
    • 어디에 예외나 검증이 필요한 내용이 들어갈지 모르기 떄문에 해당 검증을 진행한다.

4-1. 사용자 정의 예외 + 전역 예외 처리

예외 클래스:

public class UserNotFoundException extends RuntimeException{
    public UserNotFoundException(String msg) {
        super(msg);
    }
}

에러 응답 DTO:

@AllArgsConstructor
@Getter
@Setter
public class ErrorResponse {
    private String code;
    private String description;
    private String detail;
}

전역 예외 처리(@ControllerAdvice):

@RestControllerAdvice
public class ExceptionController {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserRegistException(
            UserNotFoundException e) {

        String code = "ERROR_CODE_00000";
        String description = "회원 정보 조회 실패";
        String detail = e.getMessage();

        return new ResponseEntity<>(
                new ErrorResponse(code, description, detail),
                HttpStatus.NOT_FOUND
        );
    }
}

사용 예시 (항상 예외 발생):

@GetMapping("/users/{userNo}")
public ResponseEntity<?> findUserByNo() throws UserNotFoundException {

    boolean check = true;
    if (check) {
        throw new UserNotFoundException("회원 정보를 찾을 수 없습니다.");
    }

    return ResponseEntity.ok().build();
}
{
    "code": "ERROR_CODE_00000",
    "description": "회원 정보 조회 실패",
    "detail": "❌회원 정보를 찾을 수 없습니다."
}

4-2. Validation (@Validated + Bean Validation)

의존성:

implementation 'org.springframework.boot:spring-boot-starter-validation'

DTO에 검증 어노테이션 추가:

@AllArgsConstructor
@Getter
@Setter
public class UserDTO {

    private int no;

    @NotNull(message = "아이디는 반드시 입력 되어야 합니다.")
    @NotBlank(message = "아이디는 공백일 수 없습니다.")
    private String id;

    private String pwd;

    @NotNull(message = "이름은 반드시 입력 되어야 합니다.")
    @Size(min = 2, message = "이름은 2글자 이상이어야 합니다.")
    private String name;

    @Past
    private Date enrollDate;
}

컨트롤러에서 검증 활성화:

@PostMapping("/users")
public ResponseEntity<?> registUser(@Validated @RequestBody UserDTO user) {

    System.out.println(user);

    return ResponseEntity
            .created(URI.create("/valid/users/" + "userNo"))
            .build();
}

검증 실패 시 발생하는 MethodArgumentNotValidException 처리:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> methodValidException(
        MethodArgumentNotValidException e) {

    String code = "";
    String description = "";
    String detail = "";

    if (e.getBindingResult().hasErrors()) {
        detail = e.getBindingResult().getFieldError().getDefaultMessage();
        String bindResultCode =
                e.getBindingResult().getFieldError().getCode();

        switch (bindResultCode) {
            case "NotNull":
                code = "ERROR_CODE_00001";
                description = "필수 값이 누락되었습니다.";
                break;
            case "NotBlank":
                code = "ERROR_CODE_00002";
                description = "필수 값이 공백으로 처리되었습니다.";
                break;
            case "Size":
                code = "ERROR_CODE_00003";
                description = "알맞은 크기의 값이 입력되지 않았습니다.";
                break;
        }
    }

    ErrorResponse errorResponse =
            new ErrorResponse(code, description, detail);

    return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}

5. HATEOAS 정리

5-1. 개념

  • HATEOAS: Hypermedia As The Engine Of Application State
  • 응답 JSON 안에 “다음에 어디로 갈 수 있는지”를 알려주는 링크 정보를 함께 담는 것.
  • 클라이언트는 링크를 보고 다음 액션을 결정할 수 있음.

5-2. 예제 코드

컨트롤러 기본:

@RestController
@RequestMapping("/hateoas")
public class HateoasTestController {

    private List<UserDTO> users;

    public HateoasTestController() {
        users = new ArrayList<>();
        users.add(new UserDTO(1, "user01", "pass01", "홍길동", new Date()));
        users.add(new UserDTO(2, "user02", "pass02", "유관순", new Date()));
        users.add(new UserDTO(3, "user03", "pass03", "이순신", new Date()));
    }
}

전체 조회 + 링크 추가:

@GetMapping("/users")
public ResponseEntity<ResponseMessage> findAllUsers() {

    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(
            new MediaType("application", "json", Charset.forName("UTF-8")));

    List<EntityModel<UserDTO>> userWithRel =
            users.stream().map(
                    user -> EntityModel.of(
                            user,
                            linkTo(
                                    methodOn(HateoasTestController.class)
                                            .findUserByNo(user.getNo())
                            ).withSelfRel(),          // 자신 링크
                            linkTo(
                                    methodOn(HateoasTestController.class)
                                            .findAllUsers()
                            ).withRel("users")        // 전체 목록 링크
                    )
            ).collect(Collectors.toList());

    Map<String, Object> responseMap = new HashMap<>();
    responseMap.put("users", userWithRel);

    ResponseMessage responseMessage =
            new ResponseMessage(200, "조회 성공", responseMap);

    return new ResponseEntity<>(responseMessage, headers, HttpStatus.OK);
}

@GetMapping("/users/{userNo}")
public ResponseEntity<ResponseMessage> findUserByNo(@PathVariable int userNo) {
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(
            new MediaType("application", "json", Charset.forName("UTF-8")));

    UserDTO foundUser = users.stream()
            .filter(user -> user.getNo() == userNo)
            .toList().get(0);

    Map<String, Object> responseMap = new HashMap<>();
    responseMap.put("user", foundUser);

    return ResponseEntity
            .ok()
            .headers(headers)
            .body(new ResponseMessage(200, "조회 성공", responseMap));
}

응답 예시에서 각 user마다:

"links": [
  {
    "rel": "self",
    "href": "http://localhost:8001/hateoas/users/1"
  },
  {
    "rel": "users",
    "href": "http://localhost:8001/hateoas/users"
  }
]

처럼 링크 배열이 붙음.


6. Swagger(OpenAPI) 정리

6-1. Swagger란?

  • API 문서를 자동으로 생성해주는 도구.
  • 브라우저에서 바로 파라미터 넣고 API 테스트 가능.
  • 단순 시스템의 경우, Swagger UI 주소만 공유해도 충분한 문서 역할을 함.

6-2. 의존성 + 설정

의존성:

implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'

application.yml:

springdoc:
  packages-to-scan: com.ohgiraffers.restapi.section05
  default-consumes-media-type: application/json;charset=UTF-8
  default-produces-media-type: application/json;charset=UTF-8
	enable-hateoas: false
  • scan : 스캔할 패키지 설정(지정한 패키지 하위만 API문서에 포함된다.)
  • consumes : 요청 데이터 타입
  • produces : 응답 데이터 타입

SwaggerConfig:

@Configuration
public class SwaggerConfig {

    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI().info(swaggerInfo());
    }

    private Info swaggerInfo() {
        return new Info()
                .title("Ohgiraffers API")
                .description("SpringBoot Swagger 연동 테스트")
                .version("1.0.0");
    }
}

6-3. Swagger 어노테이션

  • @Tag : API 그룹 이름/설명
  • @Operation : 각 API 메소드의 요약, 설명
  • @ApiResponses, @ApiResponse : 응답 코드 설명
  • @Schema : DTO 필드 설명

컨트롤러 예시:

@Tag(name = "Spring Boot Swagger 연동 (user)")
@RestController
@RequestMapping("/swagger")
public class SwaggerTestController {

    private List<UserDTO> users;

    public SwaggerTestController() {
        users = new ArrayList<>();
        users.add(new UserDTO(1, "user01", "pass01", "홍길동", new Date()));
        users.add(new UserDTO(2, "user02", "pass02", "유관순", new Date()));
        users.add(new UserDTO(3, "user03", "pass03", "이순신", new Date()));
    }

    @Operation(
            summary = "전체 회원 조회",
            description = "전체 회원 목록을 조회한다."
    )
    @GetMapping("/users")
    public ResponseEntity<ResponseMessage> findAllUsers() {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(
                new MediaType("application", "json", StandardCharsets.UTF_8));

        Map<String, Object> responseMap = new HashMap<>();
        responseMap.put("users", users);

        ResponseMessage responseMessage =
                new ResponseMessage(200, "조회 성공", responseMap);

        return new ResponseEntity<>(responseMessage, headers, HttpStatus.OK);
    }

    @Operation(
            summary = "회원번호로 회원 조회",
            description = "회원번호를 통해 해당하는 회원 정보를 조회한다."
    )
    @GetMapping("/users/{userNo}")
    public ResponseEntity<ResponseMessage> findUserByNo(@PathVariable int userNo) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(
                new MediaType("application", "json", StandardCharsets.UTF_8));

        UserDTO foundUser = users.stream()
                .filter(user -> user.getNo() == userNo)
                .toList().get(0);

        Map<String, Object> responseMap = new HashMap<>();
        responseMap.put("user", foundUser);

        return ResponseEntity
                .ok()
                .headers(headers)
                .body(new ResponseMessage(200, "조회 성공", responseMap));
    }

    @Operation(summary = "신규 회원 등록")
    @PostMapping("/users")
    public ResponseEntity<?> registUser(@RequestBody UserDTO newUser) {
        int lastUserNo = users.get(users.size() - 1).getNo();
        newUser.setNo(lastUserNo + 1);
        users.add(newUser);

        return ResponseEntity
                .created(URI.create("/entity/users/" + newUser.getNo()))
                .build();
    }

    @Operation(summary = "회원정보 수정")
    @PutMapping("/users/{userNo}")
    public ResponseEntity<?> modifyUser(
            @PathVariable int userNo, @RequestBody UserDTO modifyInfo) {

        UserDTO foundUser = users.stream()
                .filter(user -> user.getNo() == userNo)
                .toList().get(0);

        foundUser.setId(modifyInfo.getId());
        foundUser.setPwd(modifyInfo.getPwd());
        foundUser.setName(modifyInfo.getName());

        return ResponseEntity
                .created(URI.create("/entity/users/" + userNo))
                .build();
    }

    @Operation(summary = "회원정보 삭제")
    @ApiResponses({
            @ApiResponse(responseCode = "204", description = "회원정보 삭제 성공"),
            @ApiResponse(responseCode = "400", description = "잘못 입력된 파라미터")
    })
    @DeleteMapping("/users/{userNo}")
    public ResponseEntity<?> removeUser(@PathVariable int userNo) {

        UserDTO foundUser = users.stream()
                .filter(user -> user.getNo() == userNo)
                .toList().get(0);

        users.remove(foundUser);

        return ResponseEntity.noContent().build();
    }
}

DTO에 @Schema:

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Schema(description = "회원정보 DTO")
public class UserDTO {

    @Schema(description = "회원번호(PK)")
    private int no;

    @Schema(description = "회원 ID")
    private String id;

    @Schema(description = "회원 비밀번호")
    private String pwd;

    @Schema(description = "회원 성명")
    private String name;

    @Schema(description = "회원 등록일")
    private Date enrollDate;
}

접속:

  • http://localhost:포트/swagger-ui/index.html

에서 모든 API를 문서 + 테스트 UI로 확인 가능.


profile
백엔드

0개의 댓글