해당 예제는

01. RESTful 개념과 사전 지식

에서 정리하였던 일부 내용들을 복습하는 용도로 작성된 글입니다.

 

 

 

Spring RESTful API


개발환경 구성하기

 

프로젝트 생성

https://start.spring.io/

기호에 맞게 Maven, Gradle, Java 버전 등을 선택하시면 됩니다.

 

사용되는 의존성은

  • Spring Web
  • Lombok
  • H2 Database
  • Validation
  • Spring Data JDBC입니다.

 

해당 프로젝트는 단일 Entity를 가지는 단순한 RESTful API 예제입니다.

 

HATEOAS를 만족시키진 않았습니다. 해당 내용과 관련해서 인프런에 백기선 님의 RESTful 강의를 수강해보시길 추천드립니다.

 

 

 

Entity, DTO 만들기


@Builder
@Getter
public class Notice {

    private final Long id;
    private final String author;
    private final String title;
    private final String content;
    private final LocalDateTime createDate;
    private final LocalDateTime modifyDate;

}

Builder 패턴과 Getter, final을 사용하여 불변 객체로 만듭니다. 이를 통해 변경될 수 있는 지점을 제거하고, 별도 동기화 없이 멀티스레드 환경에서 안전하게 사용하는 것이 주목적입니다.

 

 

불변 객체를 만드는 방법?

  • 모든 필드를 Final로 만듭니다.
  • 모든 필드를 비공개(Private)로 설정합니다.
  • 필드에 대한 접근자(Getter)만을 제공합니다.
  • 필드에 대한 변경자(Setter)를 제공하지 않습니다.
  • 컬랙션이나 Date(변경되는 객체 등)를 사용하는 필드에 대하여서는 복사본이나 수정 불가능한 타입의 구현체로 반환합니다. Collections.UnmodifiableCollection 등

 

모든 코드가 멀티스레드에 취약한 것은 아닙니다. 상태를 가지는 싱글톤 객체를 사용할 때 , 다른 스레드가 가시 할 수 있는 필드 등에서 여러 변경을 시도하는 경우 문제가 발생할 수 있습니다.

 

 

해당 코드에서 사용된 Lombok Annotation

  • @Builder : 객체 생성 방식을 빌더 패턴으로 제공합니다. 몇몇 다른 라이브러리 관점에서는 (Mybatis, Jackson 등) AllArgumentContructor와 동일한 취급을 받습니다.

      // example
      Notice notice = Notice.builder()
                                          .author()
                                          .title()
                                          .content()
                                          .createDate()
                                          .modifyDate()
                                          .build();
  • @Getter : 현재 객체의 필드에 대한 Getter Method를 생성해줍니다. 생성되는 메서드 이름은 Java Bean Properties를 따릅니다. (getXxx, setXxx, isXxx, hasXxx)

 

 

 

CreateNoticeDto

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class CreateNoticeDto {

    @NotBlank
    private String author;

    @NotBlank
    private String title;

    @NotBlank
    private String content;

    public Notice toEntity() {
        return Notice.builder()
                .author(author)
                .title(title)
                .content(content)
                .createDate(LocalDateTime.now())
                .modifyDate(LocalDateTime.now())
                .build();
    }
}

이 DTO는 Client에서 넘어온 요청을 담는 객체입니다. 해당 프로젝트에서도 별도의 처리 없이 Data Transfer Object의 역할만을 담당하여 Controller와 Service를 이동하게 됩니다.

 

toEntity라는 Converting 메서드가 존재하는데요. 해당 DTO는 Service단에서 Entity로 변환되고 DAO로 접근하게 됩니다. 해당 메서드가 Entity에 있지 않고 DTO에 있는 이유는 Client와 밀접한 관계를 가지는 DTO의 요구사항 변경이 Entity에 영향을 주는 것을 방지하는 목적으로 작성하게 되었습니다.

 

 

해당 코드에서 사용된 Lombok Annotation

  • @AllArgsConstructor : 객체의 모든 필드를 가지는 생성자를 만드는 어노테이션입니다.
  • @NoArgsConstructor : 인자가 없는 생성자를 만드는 어노테이션입니다.

 

해당 코드에서 사용된 javax의 validation Annotation

  • @NotBlank : String에 적용되는 Annotation으로 null, "", " "인지 확인합니다. 조건이 충족된다면 MethodArgumentNotValidException이 발생하게 됩니다.

    문자열과 관련해서 NotNull과 NotEmpty가 합쳐진 Annotation이라고 보아도 무방합니다.

 

MethodArgumentNotValidException?

해당 Exception은 Validation Annotation에 의해 발생하게 되는 RuntimeException으로 특이하게 BindingResult 타입의 필드 변수가 존재하는데요. 해당 변수에는 Validation을 통과하지 못한 필드 명과 메시지가 저장되게 됩니다.

 

 

 

UpdateNoticeDto

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class UpdateNoticeDto {

    @NotBlank
    private String author;

    @NotBlank
    private String title;

    @NotBlank
    private String content;

}

해당 DTO는 위와 다르지 않으므로 넘어가겠습니다.

 

 

 

DAO 만들기


해당 프로젝트는 빠른 시작(?)을 위하여서 JDBCTemplate를 사용하였습니다.

 

Notice를 저장하기

public int save(Notice notice) {
        KeyHolder keyHolder = new GeneratedKeyHolder();
        final String sql = "INSERT INTO NOTICE(AUTHOR, TITLE, CONTENT, CREATE_DATE, MODIFY_DATE) VALUES(?, ?, ?, ?, ?)";

        jdbcTemplate.update(con -> {
            PreparedStatement ps = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
            ps.setString(1, notice.getAuthor());
            ps.setString(2, notice.getTitle());
            ps.setString(3, notice.getContent());
            ps.setString(4, String.valueOf(notice.getCreateDate()));
            ps.setString(5, String.valueOf(notice.getModifyDate()));
            return ps;
        }, keyHolder);

        return Objects.requireNonNull(keyHolder.getKey()).intValue();
    }

CRUD 중 C! 객체를 DB에 저장하는 로직입니다. 단순히 저장만 할 것이라면 쿼리와 인자만을 사용해도 되지만, 위와 같이 Key(Notice ID) 값을 클라이언트에게 넘겨줌으로써 그에 대한 정보나,  상태를 가질 수 있도록 할 수 있습니다.

 

예를 들어 HATEOAS를 지원하는 RESTful API라면, 현재 호출된 URI가 api/notices 일 텐데요. LinkBuilder를 이용하여서 클라이언트에게 api/notices/{생성된 id}를 전달할 수 있고, 클라이언트는 이 링크를 사용만 함으로써 쉽게 생성된 정보를 확인할 수 있는 페이지로 전환 가능할 것입니다.

 

 

Notice를 조회하기 + RowMapper

public List<Notice> findAll(Long page, Long offset) {
        final String sql = "SELECT * FROM NOTICE ORDER BY CREATE_DATE DESC LIMIT " + page + " OFFSET " + offset;
        return jdbcTemplate.query(sql, rowMapper());
}

private RowMapper<Notice> rowMapper() {
        return (rs, rowNum) -> Notice.builder()
                .id(rs.getLong("ID"))
                .author(rs.getString("AUTHOR"))
                .title(rs.getString("TITLE"))
                .content(rs.getString("CONTENT"))
                .createDate(rs.getTimestamp("CREATE_DATE").toLocalDateTime())
                .modifyDate(rs.getTimestamp("MODIFY_DATE").toLocalDateTime())
                .build();
}

CRUD 중 R! DB에서 조건에 맞는 객체를 가져와 클라이언트에게 전달하는 로직입니다. 쿼리에 LIMIT와 OFFSET을 이용함으로써 간단한 페이징 기능을 구현하였습니다.

 

RowMapper는 반환되는 ResultSet을 객체로 변환하는 로직을 구현하는 것입니다. 각각의 결과 행들을 Mapping 합니다.

 

 

Notice를 수정하기

public int updateById(UpdateNoticeDto noticeDto, Long noticeId) {
        final String sql = "UPDATE NOTICE SET AUTHOR = ?, TITLE = ?, CONTENT = ?, MODIFY_DATE = ? WHERE ID = ?";
        return jdbcTemplate.update(sql, noticeDto.getAuthor(), noticeDto.getTitle(),
                noticeDto.getContent(), LocalDateTime.now(), noticeId);
}

CRUD 중 U! Client에서 전달한 notice의 수정 정보를 DB에 반영하는 로직이 작성되어 있습니다.

 

 

Notice를 삭제하기

public int deleteById(Long id) {
        final String sql = "DELETE FROM NOTICE WHERE ID = ?";
        return jdbcTemplate.update(sql, id);
}

마지막으로 CRUD 중 D! Client에서 전달한 Notice ID를 삭제하는 로직이 작성되어 있습니다. 현재 작성된 로직은 Hard Delete 방식으로 바로 데이터를 삭제하게 되는데, 다른 방법으로는 Soft Delete라는 것이 존재합니다.

 

Soft Delete는 테이블 칼럼의 Flag를 변경하여 최종 결과에서 필터링하거나, 별도의 테이블에 데이터를 이동시켜 관리하는 것으로, Batch와 조건식을 통해서 일정 시간, 일정 상황에 데이터가 삭제되게끔 작성할 수 있습니다.

 

 

Domain Service 만들기


해당 프로젝트에서 Service는 단순하게 Controller와 DAO를 분리하는 Layer의 용도로만 작성되었습니다. 해당 내용에 대해서는 계층형 아키텍처를 참고하는 것이 좋습니다.

 

Service Layer의 주요 목적은 비즈니스 로직 수행, 트랜잭션 관리(글로벌 트랜잭션 경계 설정 등), 접근 권한 확인, Controller와 DAO의 결합 분리 등이 있다고 생각합니다.

 

Service에서 save 호출하기

@Service
public class NoticeService {

    private final NoticeDao noticeDao;

    public NoticeService(NoticeDao noticeDao) {
            this.noticeDao = noticeDao;
    }

    public int save(CreateNoticeDto noticeDto) {
            Notice notice = noticeDto.toEntity();

            int result = noticeDao.save(notice);
            if (isNotReflected(result)) {
                    throw new RuntimeException("Notice save Failed");
            }
            return result;
    }

크게 복잡한 로직은 존재하지 않습니다. DTO를 Entity로 Converting 하고 DAO의 save를 호출합니다. 호출된 결과를 int 값으로 받게 되는데, 1 이상이 아니라면 결과가 반영되지 않았으므로 Runtime Exception을 발생시키게 됩니다.

Service 로직에서 발생하는 Exception들은 이후 ExceptionHandler를 통해 Handling 합니다.

@Service Annotation은 Component를 확장한 MetaAnnotation 중 하나로 ComponentScan의 대상입니다.

 

 

Service에서 findAll 호출하기

public List<Notice> findAll(Long page, Long offset) {
        return noticeDao.findAll(page, offset);
}

Dao의 findAll을 호출하고 그 결과를 Controller로 전달합니다.

 

 

Service에서 update 호출하기

public void updateById(UpdateNoticeDto noticeDto, Long noticeId) {
        if (isNotReflected(noticeDao.updateById(noticeDto, noticeId))) {
            throw new RuntimeException("Notice update Failed");
        }
}

save와 마찬가지로 로직을 호출하고 그 반영 결과를 검증하여 Runtime Exception을 발생시킵니다.

 

 

Service에서 delete 호출하기

public void deleteById(Long noticeId) {
        if (isNotReflected(noticeDao.deleteById(noticeId))) {
            throw new RuntimeException("Notice delete Failed");
        }
}

update와 동일함으로 넘어가겠습니다.

 

 

Service의 결과 검증 메서드

private boolean isNotReflected(int result){
        return result < 1;
}

 

 

 

 

Domain Controller 만들기


 

Controller의 save

@RestController
@RequestMapping("/api")
public class NoticeController {

    private final NoticeService noticeService;

    public NoticeController(NoticeService noticeService) {
        this.noticeService = noticeService;
    }

    /**
     * 사용자의 글 작성
     *
     * @return 작성된 notice 에 대한 id 반환
     * @author lob
     */
    @PostMapping("/notices")
    public ResponseEntity<NoticeInfo> createNotice(@Valid @RequestBody CreateNoticeDto noticeDto) {

        int result = noticeService.save(noticeDto);
        return ResponseEntity.status(HttpStatus.OK).body(new NoticeInfo(result, "notice created"));
    }

Controller 코드의 최상단입니다. Class Level에는 @RequestMapping("path 정보")를 적용하여 모든 메서드의 URL prefix를 설정하였습니다. 이후 NoticeService를 생성자 주입으로 DI 받고, 메서드를 호출합니다.

 

createNotice는 @PostMapping("/notices")로 설정되어 있는 상태인데, 이는 POST.../api/notices 형태의 요청과 Mapping 되는 것임을 나타냅니다.

 

메서드의 인자로 DTO를 받고 해당 DTO에 대한 @Valid와 @RequestBody를 적용하였습니다. 이는 json으로 된 요청 정보를 DTO 생성 후 매핑하고, DTO Field Level에 설정된 Valid Annotation을 통해 Validation을 진행한다는 것을 나타냅니다. @NotBlank, NotEmpty..

 

json Mapping 정보를 DTO로 Mapping 할 때 필드가 private로 캡슐화되어 있는 상태라면, Getter를 통해 필드 이름을 특정하고 직렬 화합니다.

 

 

해당 코드에서 사용된 Spring Annotation

  • @RestController : Controller와 ResponseBody 기능이 Annotation입니다.

    • @Controller는 요청을 받고 결과를 반환하는 역할을 하는 Bean을 등록할 때 사용되며 내부적으로 Component Annotation이 적용되어 있습니다.

    • @ResponseBody는 컨트롤러가 반환하는 결과를 Http Message Body에 저장합니다.

      JSON 형식을 반환한다라는 글들이 많은데, 실제로는 HTTP Header의 Content-type 값을 따릅니다. 즉 byte 값, XML, TEXT 등으로도 반환된다는 것입니다.

  • @PostMapping : HTTP POST Method 형식을 처리하는 것을 나타내는 Annotation입니다. 이는 RequestMapping을 확장한 것이며, URL 값을 나타내는 value, Headers, 요청 타입을 협상하고 반환 타입을 지정하는 produces 필드를 자주 사용하게 됩니다.

  • @RequestBody : 요청된 HTTP Message Body에 저장된 값을 직렬 화하여 객체로 변환하는 데 사용되는 Annotation입니다. JSON, XML, Text 등을 가져올 수 있습니다.

  • @Valid : Object 필드에 선언된 Valid 조건들을 검증하도록 하는 Annotation입니다.

 

 

Controller의 findAll

/**
     * @return 작성된 순서대로 10개씩 notice 반환
     * @author lob
     */
    @GetMapping("/notices")
    public ResponseEntity<List<Notice>> findAll(
            @RequestParam(defaultValue = "10", required = false) Long page,
            @RequestParam(defaultValue = "0", required = false) Long offset) {

        List<Notice> notices = noticeService.findAll(page, offset);
        if (CollectionUtils.isEmpty(notices)) {
            return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
        }
        return ResponseEntity.status(HttpStatus.OK).body(notices);
}

findAll은 @GetMapping("/notices")으로 설정된 상태인데 이는 GET.../api/notices 형태의 요청과 Mapping 됨을 알 수 있습니다.

 

 

해당 코드에서 사용된 Spring Annotation

  • @GetMapping *: HTTP GET Method 형식을 처리하는 것을 나타내는 Annotation입니다. 이것도 RequestMapping을 확장한 Annotation입니다.*

  • @RequestParam : HTTP URL에 붙어서 날아오는 QueryString을 변수에 매핑하는 Annotation입니다. defaultValute를 통해 요청에 담겨오지 않는 경우의 값을 설정할 수 있으며, required를 통해 요청에 QueryString 존재 유무에 따라서 Exception을 발생시킬지를 설정할 수 있습니다.

    required의 기본 값은 True입니다.

 

 

Controller의 update

    /**
     * @return notice 수정 후 안내 문자열 반환
     * @author lob
     */
    @PutMapping("/notices/{noticeId}")
    public ResponseEntity<String> updateById(@Valid @RequestBody UpdateNoticeDto noticeDto,
                                             @PathVariable Long noticeId) {

        noticeService.updateById(noticeDto, noticeId);
        return ResponseEntity.status(HttpStatus.CREATED).body("Notice Updated");
    }

updateById은 @PutMapping("/notices/{noticeId}")으로 설정된 상태인데 이는 PUT.../api/notices 형태의 요청과 Mapping 됨을 알 수 있습니다.

 

 

해당 코드에서 사용된 Spring Annotation

  • @PutMapping : HTTP PUT Method 형식을 처리하는 것을 나타내는 Annotation입니다. RequestMapping을 확장하였습니다.

 

 

Controller의 delete, NoticeInfo Object

    /**
     * @return notice 삭제 후 안내 문자열 반환
     * @author lob
     */
    @DeleteMapping("/notices/{noticeId}")
    public ResponseEntity<String> deleteById(@PathVariable Long noticeId) {
        noticeService.deleteById(noticeId);
        return ResponseEntity.status(HttpStatus.OK).body("Notice Deleted");
    }

    @Getter
    @AllArgsConstructor
    private static class NoticeInfo {
        private final int NoticeId;
        private final String message;
    }

deleteById은 @DeleteMapping("/notiecs/{noticeId}")으로 설정된 상태인데 이는 Delete.../api/notieces 형태의 요청과 Mapping 됨을 알 수 있습니다.

 

 

해당 코드에서 사용된 Spring Annotation

  • @DeleteMapping : HTTP DELETE Method 형식을 처리하는 것을 나타내는 Annotation입니다. RequestMapping을 확장하였습니다.

 

 

Exception Handler 적용해보기


@ControllerAdvice("com.example.rest.notice")
public class NoticeExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    protected ResponseEntity<ErrorResponse> HandlerRuntimeException(RuntimeException exception) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse(exception.getMessage()));
    }

    @Getter
    @AllArgsConstructor
    private static class ErrorResponse {
        private final String errorMessage;
    }
}

간단하게 작성한 Handler입니다. Service에서 발생하는 RuntimeException에 의한 White Page를 방지하고, Client에게 별도의 데이터를 제공합니다.

예제의 간소화를 위하여 RuntimeException 형식만을 지정하였습니다.

 

 

해당 코드에서 사용된 Spring Annotation

  • @ControllerAdvice : Spring Application에서 전역적인 예외 처리를 위해 사용되는 객체에 적용하는 Annotation입니다. Controller에서 결과를 반환한 이후 즉 AfterReturning 시점에서 적용되며, 내부에 정의된 ExceptionHandler 설정에 따라 처리하게 됩니다.

    Class Level Annotation이며, 특정 Package에만 적용하는 것이 가능하고 Order Annotation을 통해 적용 우선순위도 지정할 수 있습니다.

  • @ExceptionHandler : Spring Application에서 특정 예외 처리를 위해 사용되는 Method Level의 Annotation입니다. 기본적으로 @ExceptionHandler(XxxException.class) 형식으로 정의되어 해당 Exception을 가로채고 매개변수로 받아올 수 있습니다.

 

 

02-01 12 : 58 추가

간단한 MockMvc Test 작성해보기


@SpringBootTest
@AutoConfigureMockMvc
class NoticeControllerTest {

	@Autowired
	MockMvc mockMvc;

	@Autowired
	ObjectMapper objectMapper;

	CreateNoticeDto createNoticeDto;
	UpdateNoticeDto updateNoticeDto;

	@BeforeEach
	void setUp() {
		createNoticeDto = new CreateNoticeDto("author", "title", "content");
		updateNoticeDto = new UpdateNoticeDto("update", "update", "update");
	}

	@Test
	void noticeControllerTest_createAndFind() throws Exception {

		mockMvc.perform(post("/api/notices")
				.content(objectMapper.writeValueAsString(createNoticeDto))
				.contentType(MediaType.APPLICATION_JSON))
				.andDo(print())
				.andExpect(status().isCreated());

		mockMvc.perform(post("/api/notices")
				.content(objectMapper.writeValueAsString(createNoticeDto))
				.contentType(MediaType.APPLICATION_JSON))
				.andDo(print())
				.andExpect(status().isCreated());

		mockMvc.perform(post("/api/notices")
				.content(objectMapper.writeValueAsString(createNoticeDto))
				.contentType(MediaType.APPLICATION_JSON))
				.andDo(print())
				.andExpect(status().isCreated());

		mockMvc.perform(get("/api/notices")
				.contentType(MediaType.APPLICATION_JSON))
				.andDo(print())
				.andExpect(status().isOk());

	}

	@Test
	void noticeControllerTest_createAndFindAndUpdate() throws Exception {

		mockMvc.perform(post("/api/notices")
				.content(objectMapper.writeValueAsString(createNoticeDto))
				.contentType(MediaType.APPLICATION_JSON))
				.andDo(print())
				.andExpect(status().isCreated());

		mockMvc.perform(put("/api/notices/{noticeId}", 1L)
				.content(objectMapper.writeValueAsString(updateNoticeDto))
				.contentType(MediaType.APPLICATION_JSON))
				.andDo(print())
				.andExpect(status().isOk());

		mockMvc.perform(get("/api/notices")
				.contentType(MediaType.APPLICATION_JSON))
				.andDo(print())
				.andExpect(status().isOk());
	}

	@Test
	void noticeControllerTest_deleteById() throws Exception {

		mockMvc.perform(delete("/api/notices/{noticeId}", 2L)
				.contentType(MediaType.APPLICATION_JSON))
				.andDo(print())
				.andExpect(status().isOk());
	}

}

 

 

코드 설명 추가 예정

 

 

 

지금까지 간단하게 Controller부터 Service, Dao까지 구현해보았습니다. 

 

 

관련된 기능, 어노테이션에 대하여서 추가적인 예제가 필요하신 분들은

www.baeldung.com/rest-with-spring-series

 

REST with Spring Tutorial | Baeldung

Step by step tutorial on building a REST API with Spring (and securing it with Spring Security).

www.baeldung.com

해당 사이트를 확인해보시길 바랍니다.

+ Recent posts