자율학습단 스프링부트 3주차

하파타카·2023년 9월 11일
0

11일차

HTTP와 REST controller

1) REST
HTTP URL로 서버의 자원(resource)을 명시하고, HTTP 메서드(POST, GET, PATCH/PUT, DELETE)로 해당 자원에 대해 CRUD하는 것을 말함.

2) API
클라이언트가 서버의 자원을 요청할 수 있도록 서버에서 제공하는 인터페이스.

REST API의 구현 과정

- FirstApiController -

@RestController     // REST API용 controller
public class FirstApiController {

    @GetMapping("/api/hello")
    public String hello(){
        return "hello world!";
    }
}


http://localhost:8081/api/hello 로 접속하여 확인.
postman으로도 확인해보면 정상적으로 출력됨을 확인할 수 있다.

@RestController와 @Controller의 차이
@Controller 어노테이션은 resources.templates패키지 아래에서 return된 문자열의 이름을 가진(확장자는 따지지 않음) view파일을 찾아 화면에 출력한다.
@RestController 어노테이션은 JSON이나 텍스트 같은 데이터를 return한다.

REST API의 구현 2

- ArticleApiController -

@RestController
public class ArticleApiController {
    @Autowired
    private ArticleRepository articleRepository;

    // GET
    @GetMapping("/api/articles")
    public List<Article> index(){
        return articleRepository.findAll();
    }

    // POST
    @PostMapping("/api/articles")
    public Article create(@RequestBody ArticleForm dto) {
        Article article = dto.toEntity();
        return articleRepository.save(article);
    }

    // PATCH
    @PatchMapping("/api/articles/{id}")
    public ResponseEntity<Article> update(@PathVariable Long id,
                          @RequestBody ArticleForm dto) {
        // 1. DTO -> Entity 변환
        Article target = dto.toEntity();
        log.info("id: {}, article: {},", id, target.toString());
        // 2. 타깃 조회하기
        Optional<Article> article = articleRepository.findById(id);
        // 3. 잘못된 요청 처리하기
        if (article.isEmpty() || !id.equals(target.getId())){
            // article객체가 비었거나 매개변수로 받아온 id와 article객체의 id가 동일하지 않을 경우
            log.info("잘못된 요청! id: {}, article: {}", id, article.toString());
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
        }
        // 4. 업데이트 및 정상 응답(200)하기
        Article update = articleRepository.save(target);
        return ResponseEntity.status(HttpStatus.OK).body(update);
    }

    // DELETE
}

1. GET 방식은 앞서 실습한 방식과 동일함.

2. POST 방식에서는 dto객체를 view에서 바로 받아와 생성할 수 없다.

REST API에서 데이터를 생성할때는 JSON 데이터를 받아와야 하기 때문으로, 이때는 @RequestBody어노테이션을 추가하면 본문(BODY)에서 실어 보내는 데이터를 create() 메서드의 매개변수로 받아올 수 있다.

아래는 POST 통신으로 새 게시글을 추가한 결과.

3. PATCH 방식은 네 부분으로 나누어 작성함.

1) 수정용 Entity 생성
2) DB에 대상 Entity가 있는지 조회
3) 대상 Entity가 없거나 수정하려는 id가 잘못되었을 경우에 대한 처리
4) 대상 Entity가 있으면 수정 내용으로 업데이트 후 정상 응답(200) 보내기
매개변수로 받아온 id값으로 DB를 조회해 해당 데이터가 존재하는지 확인 후 update는 수정용 Entity로 해야함에 주의.

- ArticleApiController -

// PATCH
@PatchMapping("/api/articles/{id}")
public ResponseEntity<Article> update(@PathVariable Long id,
                      @RequestBody ArticleForm dto) {
    // 1. DTO -> Entity 변환
    Article target = dto.toEntity();
    log.info("id: {}, article: {},", id, target.toString());
    // 2. 타깃 조회하기
    Optional<Article> article = articleRepository.findById(id);
    // 3. 잘못된 요청 처리하기
    if (article.isEmpty() || !id.equals(target.getId())){
        // article객체가 비었거나 매개변수로 받아온 id와 article객체의 id가 동일하지 않을 경우
        log.info("잘못된 요청! id: {}, article: {}", id, article.toString());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
    }
    // 4. 업데이트 및 정상 응답(200)하기
    // 4-1. 클라이언트로부터 전달받지 못한 속성은 DB에서 조회해온 기존의 데이터로 대체
    target.patch(article.get());
    Article update = articleRepository.save(target);
    return ResponseEntity.status(HttpStatus.OK).body(update);
}

- Article -

public void patch(Article article) {
    // 수정할 내용이 없는 경우 null대신 기존의 Article객체가 가진 값으로 대체함 
    if (article.title !=null){
        this.title = article.title;    
    }
    if (article.content !=null){
        this.content = article.content;
    }
}

아래는 PATCH 통신을 값을 일부 수정한 결과

4. DELETE

HttpStatus클래스를 이용해 처리에 대한 HTTP 응답코드(200, 500 등)를 설정하여 반환한다.

- ArticleApiController -

// DELETE
@DeleteMapping("/api/articles/{id}")
public ResponseEntity<Article> delete(@PathVariable Long id) {
    // 1. DB에서 대상 Entity가 있는지 조회
    Optional<Article> article = articleRepository.findById(id);
    // 2. 대상 Entity가 없는 경우 처리
    if (article.isEmpty()){
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
    }
    // 3. Entity가 있을 경우 삭제 후 정상 응답(200) 반환
    articleRepository.delete(article.get());
    return ResponseEntity.status(HttpStatus.OK).build();
}

@RequestBody와 @ResponseBody

@RequestBody / @ResponseBody 어노테이션 이란?
비동기 통신을 위해서는 서버와 클라이언트가 요청 및 응답을 보낼 때 본문에 데이터를 실어보내야 한다.
클라이언트가 서버에 데이터를 보낼때는 @RequestBody, 서버가 클라이언트에게 응답을 보낼때는 @ResponseBody 어노테이션을 사용한다.

ResponseEntity

REST API의 응답을 위해 사용하는 클래스.
REST API요청을 받아 응답할 때 HTTP 상태코드, 헤더, 본문을 실어보낼 수 있다.

HttpStatus

HTTP상태코드를 관리하는 클래스.

12일차

Service계층과 Transaction

Service계층

각 트랜잭션에 대한 처리를 service에서 처리할 수 있도록 변경.
controller에서는 service에 요청을 보내고, repository에 직접 sql요청을 보내는 작업은 service에서 담당하도록 역할을 분리한다.

트랜잭션 (Transaction) : 더 이상 쪼갤 수 없는 하나의 기능단위.
(예시 - 은행의 이체의 경우 내 계좌에서 돈이 빠지는 동작과 상대 계좌로 돈이 들어가는 동작의 두 동작이 더 이상 쪼갤 수 없는 하나의 기능단위가 됨.)

- ArticleApiController -

@Slf4j
@RestController
public class ArticleApiController {
    @Autowired
    private ArticleService articleService;

    // GET
    @GetMapping("/api/articles")
    public List<Article> index() {
        return articleService.index();
    }

    @GetMapping("/api/articles/{id}")
    public Article show(@PathVariable Long id) {
        return articleService.show(id);
    }

    // POST
    @PostMapping("/api/articles")
    public ResponseEntity<Article> create(@RequestBody ArticleForm dto) {
        Article created = articleService.create(dto);
        return (created != null) ?
                ResponseEntity.status(HttpStatus.OK).body(created) :
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

    // PATCH
    @PatchMapping("/api/articles/{id}")
    public ResponseEntity<Article> update(@PathVariable Long id,
                                          @RequestBody ArticleForm dto) {
       Article updated = articleService.update(id, dto);
       return (updated != null) ?
               ResponseEntity.status(HttpStatus.OK).body(updated) :
               ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }

    // DELETE
    @DeleteMapping("/api/articles/{id}")
    public ResponseEntity<Article> delete(@PathVariable Long id) {
        Article deleted = articleService.delete(id);
        return (deleted != null) ?
                ResponseEntity.status(HttpStatus.NO_CONTENT).build() :
                ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    }
}

- ArticleService -

@Slf4j
@Service
public class ArticleService {
    @Autowired
    private ArticleRepository articleRepository;

    public List<Article> index() {
        return articleRepository.findAll();
    }

    public Article show(Long id) {
        return articleRepository.findById(id).orElse(null);
    }

    public Article create(ArticleForm dto) {
        Article article = dto.toEntity();
        if (article.getId() != null) {
            return null;
        }
        return articleRepository.save(article);
    }

    public Article update(Long id, ArticleForm dto) {
        // 1. DTO -> Entity 변환
        Article target = dto.toEntity();
        log.info("id: {}, article: {},", id, target.toString());
        // 2. 타깃 조회하기
        Optional<Article> article = articleRepository.findById(id);
        // 3. 잘못된 요청 처리하기
        if (article.isEmpty() || !id.equals(target.getId())) {
            // article객체가 비었거나 매개변수로 받아온 id와 article객체의 id가 동일하지 않을 경우
            log.info("잘못된 요청! id: {}, article: {}", id, article.toString());
            return null;
        }
        // 4. 업데이트 및 정상 응답(200)하기
        // 4-1. 클라이언트로부터 전달받지 못한 속성은 DB에서 조회해온 기존의 데이터로 대체
        article.get().patch(target);
        log.info("article id: {}, article: {}", id, article.toString());
        Article updated = articleRepository.save(article.get());
        return updated;
    }

    public Article delete(Long id) {
        // 1. DB에서 대상 Entity가 있는지 조회
        Optional<Article> target = articleRepository.findById(id);
        // 2. 대상 Entity가 없는 경우 처리
        if (target.isEmpty()) {
            return null;
        }
        // 3. Entity가 있을 경우 삭제 후 정상 응답(200) 반환
        articleRepository.delete(target.get());
        return target.get();
    }
}

conrtoller에서 repository에 요청을 직접 보내던것을 수정하여 controller는 service에, service는 repository에 요청을 넣도록 변경.
Optional을 사용했기 때문에 책의 코드와는 약간 다르나, 기능은 같음.

Transaction

아래는 Transaction을 적용하기 전 일반적인 실행에서의 예외상황.

- ArticleApiController -

// Transaction 테스트
@PostMapping("/api/transaction-test")
public ResponseEntity<List<Article>> transactionTest(@RequestBody List<ArticleForm> dtos){
    List<Article> createList = articleService.createArticles(dtos);
    return (createList != null) ?
            ResponseEntity.status(HttpStatus.OK).body(createList) :
            ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}

- ArticleService -

@Transactional
public List<Article> createArticles(List<ArticleForm> dtos) {
    // 1. dto 묶음을 Entity 묶음으로 변환
    List<Article> articleList = dtos.stream()
            .map(dto -> dto.toEntity())
            .collect(Collectors.toList());
    // 2. Entity 묶음을 DB에 저장
    articleList.stream()
            .forEach(article -> articleRepository.save(article));
    // 3. 강제 예외 발생시키기
    articleRepository.findById(-1L) // id가 -1인 데이터 검색
            .orElseThrow(() -> new IllegalArgumentException("결제 실패!")); // 찾는 데이터가 없으면 예외 발생
    // 4. 결과 값 반환하기
    return articleList;
}




의도적으로 예외 상황을 발생시켰으나 예외가 발생하기 전 DB에 insert작업이 진행되었음.
Transaction을 사용하면 요청일 실패했을때 실행자체를 롤백시킬 수 있다.

이때 Transaction은 보통 service단에서 사용한다.

- ArticleService -

@Transactional
public List<Article> createArticles(List<ArticleForm> dtos) {
	// 생략
}

수정 후 postman을 통해 다시 똑같은 요청을 보내보면 이번에는 요청이 롤백되어 DB에 insert작업이 진행되지 않는다.

13일차

테스트 코드

테스트 코드를 이용하면 다양한 문제를 미리 예방하고 코드 변경으로 인해 발생하는 문제역시 조기에 발견할 수 있다.
요즘은 테스트 도구를 통해 반복적인 검증절차를 자동화할 수 있다.

테스트 코드의 3단계
1. 예상 데이터 작성하기
2. 실제 데이터 획득하기
3. 예상 데이터와 실제 데이터 비교해 검증하기

CRUD 중 Read작업을 제외한 나머지 작업을 테스트 할 경우 테스트를 마치고 데이터가 롤백되어야 하므로 @Test와 함께 @Transactional어노테이션도 추가한다.


ArticleService에서 우클릭 > Generate > Test > Junit5 확인 후 원하는 메서드 체크 후 test클래스 생성

test패키지의 service패키지 아래에 생성됨을 확인.

- ArticleServiceTest -

@SpringBootTest
class ArticleServiceTest {

    @Autowired
    ArticleService articleService;

    @Test
    void show() {
        // 1. 예상 데이터 작성하기
        Long id = 1L;
        Article expected = new Article(id, "첫번쨰 게시글", "첫 게시글의 내용입니다.");
        // 2. 실제 데이터 획득하기
        Article article = articleService.show(id);
        // 3. 예상 데이터와 실제 데이터 비교해 검증하기
        assertEquals(expected.toString(), article.toString());
    }

    @Test
    void show_fail() {
        Long id = -1L;      // 존재하지 않는 데이터
        Article expected = null;
        Article article = articleService.show(id);
        assertEquals(expected.toString(), article.toString());
    }
    
    @Test
    @Transactional
    void create() {
        String title = "가나다라";
        String content = "마바사아자차카타파하";
        ArticleForm dto = new ArticleForm(null, title, content);
        Article expected = new Article(42L, title, content);
        // 실제 데이터
        Article article = articleService.create(dto);
        // 비교 및 검증
        assertEquals(expected.toString(), article.toString());
    }
}


show(), show_fail() 실행결과 show()메서드는 성공, show_fail()메서드는 실패로 나온다.



create() 실행결과 성공.
위는 @Transactional어노테이션을 붙이기 전 실행결과이므로 어노테이션 적용 시 실제로 데이터가 들어가지 않게 됨.

update delete test는 직접 해볼 것.

14일차

댓글 Entity와 repository 만들기

게시글과 댓글은 1:N 의 관계를 가짐.

- Comment -

@NoArgsConstructor
@AllArgsConstructor
@ToString
@Getter
@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;            // 기본키
    
    @ManyToOne
    @JoinColumn(name = "article_id")    // 외래키 생성, Article Entity의 기본키(id)와 매핑
    private Article article;    // 해당 댓글의 게시글
    @Column
    private String nickname;    // 댓글 작성자
    @Column
    private String body;        // 댓글 본문
}

- CommentrRepository -

public interface CommentRepository extends JpaRepository<Comment, Long> {
    // 특정 게시글의 모든 댓글 조회
    @Query(value = "SELECT * FROM comment WHERE article_id = :articleId", nativeQuery = true)
    List<Comment> findByArticleId(Long articleId);

    @Query(value = "SELECT count(*) FROM comment WHERE article_id = :articleId", nativeQuery = true)
    Integer findByArticleIdCnt(Long articleId);
    
    // 특정 닉네임의 모든 댓글 조회
    List<Comment> findByNickname(String nickname);
}

이번에는 CrudRepository 대신 JpaRepository를 상속받음.

단순 CRUD작업만 한다면 CrudRepository를 상속받으면 되고,
페이징과 정렬작업까지 필요하다면 JpaRepository를 상속받아 사용하면 된다.

DB table 생성

CREATE TABLE hongongspringboot.`comment` (
	id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
	body VARCHAR(255),
	nickname VARCHAR(255),
	article_id BIGINT NOT NULL,
	FOREIGN KEY(article_id) REFERENCES article(id)
)

INSERT INTO comment(article_id, nickname, body)
VALUES ('1', 'Park', '가나다라');
INSERT INTO comment(article_id, nickname, body)
VALUES ('1', 'Kim', '마바사아');
INSERT INTO comment(article_id, nickname, body)
VALUES ('1', 'Park', '자차카타');
INSERT INTO comment(article_id, nickname, body)
VALUES ('1', 'Lee', '파하');

덧글 조회 test

repository에서 쿼리 입력 시 주의.
매개변수랑 쿼리문 내 변수이름을 다르게 적은걸 못찾아서 한참동안 오류랑 씨름했음.
=> 이거 안되는데 왜 안되는지 모르겠음.
나중에 다시 해볼것.

15일차

댓글 Controller와 Service 만들기

- Controller -

@RestController
public class CommentApiController {
    @Autowired
    private CommentService commentService;

    @GetMapping("/api/articles/{articleId}/comments")
    public ResponseEntity<List<CommentDto>> comments(@PathVariable Long articleId) {
        List<CommentDto> dtos = commentService.comments(articleId);
        return ResponseEntity.status(HttpStatus.OK).body(dtos);
    }
    @PostMapping("/api/articles/{articleId}/comments")
    public ResponseEntity<CommentDto> create(@PathVariable Long articleId,
                                             @RequestBody CommentDto dto) {
        CommentDto createDto = commentService.create(articleId, dto);
        return ResponseEntity.status(HttpStatus.OK).body(createDto);
    }

    @PatchMapping("/api/comments/{id}")
    public ResponseEntity<CommentDto> update(@PathVariable Long id,
                                             @RequestBody CommentDto dto) {
        CommentDto updateDto = commentService.update(id, dto);
        return ResponseEntity.status(HttpStatus.OK).body(updateDto);
    }

    @DeleteMapping("/api/comments/{id}")
    public ResponseEntity<CommentDto> delete(@PathVariable Long id) {
        CommentDto deleteDto = commentService.delete(id);
        return ResponseEntity.status(HttpStatus.OK).body(deleteDto);
    }
}

- CommentService -

@Service
public class CommentService {
    @Autowired
    private CommentRepository commentRepository;

    @Autowired
    private ArticleRepository articleRepository;

    public List<CommentDto> comments(Long articleId) {
        return commentRepository.findByArticleId(articleId)
                .stream()       // 댓글 Entity목록을 stream으로 변환
                .map(comment -> CommentDto.createCommentDto(comment))   // Entity를 dto로 매핑
                .collect(Collectors.toList());  // stream을 list로 변환
    }

    @Transactional
    public CommentDto create(Long articleId, CommentDto dto) {
        // 1. 댓글 조회 및 예외 발생
        Article article = articleRepository.findById(articleId)
                .orElseThrow(() -> new IllegalArgumentException("댓글 생성 실패! " + "대상 게시글이 없습니다."));
        // 2. 댓글 Entity 생성
        Comment commnet = Comment.createComment(dto, article);
        // 3. 댓글 Entity를 DB에 저장
        Comment created = commentRepository.save(commnet);
        // 4. DTO로 변환해 반환
        return CommentDto.createCommentDto(created);
    }
    @Transactional
    public CommentDto update(Long id, CommentDto dto) {
        Comment target = commentRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("댓글 수정 실패! " + "대상 댓글이 없습니다."));
        target.patch(dto);
        Comment updated = commentRepository.save(target);
        return CommentDto.createCommentDto(updated);
    }
    @Transactional
    public CommentDto delete(Long id) {
        Comment target = commentRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("댓글 삭제 실패! " + "대상 댓글이 없습니다."));
        commentRepository.delete(target);
        return CommentDto.createCommentDto(target);
    }
}

- CommentDto -

@AllArgsConstructor
@NoArgsConstructor
@ToString
@Getter
public class CommentDto {
    private Long id;
    private Long article_id;
    private String nickname;
    private String body;

    public static CommentDto createCommentDto(Comment comment) {
        return new CommentDto(
                comment.getId(),
                comment.getArticle().getId(),
                comment.getNickname(),
                comment.getBody()
        );
    }
}

- Comment -
메서드 추가

public static Comment createComment(CommentDto dto, Article article) {
    // 예외 발생
    if (dto.getId() != null) {
        throw new IllegalArgumentException("댓글 생성 실패! 댓글의 id가 없어야 합니다.");
    }
    if (!Objects.equals(dto.getArticle_id(), article.getId())) {
        throw new IllegalArgumentException("댓글 생성 실패! 게시글의 id가 잘못되었습니다.");
    }
    // Entity생성 및 반환
    return new Comment(
            dto.getId(),
            article,
            dto.getNickname(),
            dto.getBody()
    );
}

public void patch(CommentDto dto) {
    // 예외 발생
    if (!Objects.equals(this.id, dto.getId())) {
        throw new IllegalArgumentException("댓글 수정 실패! 잘못된 id가 입력되었습니다.");
    }
    // 객체 갱신
    if (dto.getNickname() != null) {
        this.nickname = dto.getNickname();
    }
    if (dto.getBody() != null) {
        this.body = dto.getBody();
    }
}

테스트



http://localhost:8081/api/articles/1/comments
DB에서 1번 게시글에 대한 댓글인 4개가 모두 정상조회됨을 확인할 수 있음.


http://localhost:8081/api/articles/1/comments
1번 게시글에 대한 덧글을 추가.


수정 테스트.
수정한 댓글의 데이터를 반환한다.


http://localhost:8081/api/comments/4
삭제 테스트.
댓글을 삭제한 후 삭제한 댓글의 데이터를 반환한다.


셀프체크

11장 셀프체크

coffee 데이터를 REST API로 구현하라.

CREATE TABLE hongongspringboot.coffee(
	id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
	NAME VARCHAR(255) NOT NULL,
	price INT NOT NULL
)

INSERT INTO coffee(NAME, price)
VALUES ('아메리카노', 2000);
INSERT INTO coffee(NAME, price)
VALUES ('카페라떼', 3000);

- CoffeeController -

@RestController
public class CoffeeController {

    @Autowired
    CoffeeService coffeeService;

    @GetMapping("/api/coffees")
    public List<Coffee> list() {
        return coffeeService.list();
    }
}

- CoffeeService -

@Service
public class CoffeeService {

    @Autowired
    CoffeeRepository coffeeRepository;

    public List<Coffee> list() {
        return coffeeRepository.findAll();
    }
}
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Getter
@Entity
public class Coffee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column
    private String name;
    @Column
    private int price;
}
@AllArgsConstructor
@Getter
public class CoffeeDto {
    private Long id;
    private String name;
    private int price;

    private static CoffeeDto create(Coffee coffee){
        return new CoffeeDto(coffee.getId(), coffee.getName(), coffee.getPrice());
    }
}

13장 셀프체크

ArticleServiceTest에 update, delete 테스트 케이스 코드를 추가하라.

@Test
@Transactional
void update() {
    String title = "수정test";
    ArticleForm dto = new ArticleForm(2L, title, (String) null);
    Article expected = new Article(2L, title, "testtest");

    // 실제 데이터
    Article article = articleService.update(2L, dto);
    // 비교 및 검증
    assertEquals(expected.toString(), article.toString());
}

@Test
@Transactional
void delete() {
    Article expected = new Article(2L, "test", "testtest");
    Article article = articleService.delete(2L);
    assertEquals(expected.toString(), article.toString());
}

번외

셀프체크 coffeeController를 활용해 페이징 적용하기 1

JPA 페이징 참고링크

- CoffeeController -
api메서드 수정.
page는 배열처럼 0페이지부터 시작이므로 URI로 입력받은 페이지에 -1을 한 페이지를 검색해야 함.

@GetMapping("/api/coffees/{pageNum}")
public Page<Coffee> list(@PathVariable int pageNum) {
    PageRequest pageRequest = PageRequest.of(pageNum+1, 5);
    return coffeeService.list(pageRequest);
}

- CoffeeService -

@Service
public Page<Coffee> list(PageRequest pageRequest) {
    return coffeeRepository.findAll(pageRequest);
}


http://localhost:8081/api/coffees/2
위 경로로 접근 시 6번부터 10번에 해당하는 coffee데이터가 출력됨.
이걸 어떻게 화면에 출력할지, 페이지네이션은 어떻게 추가할지는 책 끝까지 진행 후 좀 더 생각해보기.


더 공부할 것

  • HTTP 요청 및 응답과 head, body에 대한 이야기가 계속해서 다루어진다.
    HTTP 통신에 대한 간단한 서적이라도 하나 읽어둬야겠다.
profile
천 리 길도 가나다라부터

0개의 댓글