JPA에 대해 공부하면서 실제로 어떻게 적용해 사용하는지 알아보기 위해 REST API 방식의 CRUD를 구현해복자 한다. 게시판을 만든다는 가정하게 코드를 작성할 것이다. 따라서 REST API + SPA 로 분리하여 만들계획이고 화면은 vue를 이용해 만들려고 한다.
😀 우선 API부터 작성할 껀데 프로젝트부터 생성해보자
이런식으로 초기 셋팅을 마치고 생성한다. 디펜던시는 필요한 것만 넣어도 되고 만들고자하는 것에 따라 더 추가해주면 된다.
build.gradle
plugins {
id 'org.springframework.boot' version '2.4.1'
id 'io.spring.dependency-management' version '1.0.10.RELEASE'
id 'java'
}
group = 'kr.co'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
compile 'net.sf.json-lib:json-lib:2.4:jdk15'
compile group: 'org.jsoup', name: 'jsoup', version: '1.11.3'
compile group: 'org.modelmapper', name: 'modelmapper', version: '2.1.1'
compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.7.0'
implementation 'javax.annotation:javax.annotation-api:1.3.2'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-undertow'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation('org.springframework.boot:spring-boot-starter-web') {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
}
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'mysql:mysql-connector-java'
runtimeOnly 'org.webjars:bootstrap:4.5.0'
annotationProcessor 'org.projectlombok:lombok'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
exclude group: "com.vaadin.external.google", module:"android-json"
}
testImplementation group: 'org.hamcrest', name: 'hamcrest-library', version: '1.3'
testImplementation 'org.springframework.security:spring-security-test'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
}
test {
useJUnitPlatform()
}
이런식으로 build.gradle에 개인적으로 사용할 것들을 추가해놓은 상태이다.
application.properties
spring.jpa.hibernate.ddl-auto=update
spring.datasource.url={DB HOST}
spring.datasource.username={DB ID}
spring.datasource.password={DB PASSWORD}
먼저 DB 연결정보를 설정하고, 우선 저장된 데이터를 조회하는 기능부터 만들어보자.
Posts(Entity)
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class Posts {
@Id
@GeneratedValue
private Long id;
@Column(length = 10, nullable = false)
private String author;
@Column(length = 100, nullable = false)
private String title;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime modifiedDate;
@Builder
public Posts(Long id, String author, String title, String content) {
this.id = id;
this.author = author;
this.title = title;
this.content = content;
}
엔티티를 생성하는데 JPA에서 제공하는 어노테이션이 있다. 사용된 어노테이션들을 살펴보자면
@Entity
클래스와 테이블과 매핑한다고 JPA에게 전달하며, @Entity 어노테이션이 선언된 클래스를
엔티티 클래스라고 표현한다.
@Table
엔티티 클래스에 매핑할 테이블 정보를 전달하며, 생략 시 클래스명을 테이블명으로 매핑한다.
@Id
엔티티 클래스의 필드를 테이블에 기본 키(PK, Primary key)로 매핑한다.
@GeneratedValue
생성 전략(strategy)에 따라 기본 키를 지정한다.
AUTO(default) : JPA가 자동으로 생성 전략을 결정
IDENTIT - 기본키 생성을 데이터베이스에 위임
SEQUENCE - 데이터베이스의 특별한 오브젝트 시퀀스를 사용하여 기본키를 생성
TABLE - 데이터베이스에 키 생성 전용 테이블을 하나 만들고 이를 사용하여 기본키를 생성합니다
@Column
엔티티 클래스의 필드를 컬럼에 매핑하며, 생략 시 필드명을 컬럼에 매핑
@CreatedDate
이름 그대로 엔티티가 생성되는 시간을 기록하는 것을 의미한다.
@LastModifiedDate
위와 마찬가지로 이름따라간다. 엔티티의 마지막 수정시간을 기록하는 것을 의미한다.
그리고 DTO를 생성한다. DTO는 분리해서 작성하는것이 추후에 관리할 때 좋은 것 같다.
해당 DTO는 조회결과를 담아낼 역할을 하는 DTO다.
PostsResDto
@Getter
@Setter
@ToString
@NoArgsConstructor
public class PostsResDto {
private Long id;
private String author;
private String title;
private String content;
private LocalDateTime createdDate;
private LocalDateTime modifiedDate;
}
Spring Data JPA는 레포지토리 계층의 반복작업을 피하기위해 JpaRepository 인터페이스를 제공한다.
이 인터페이스는 기본적은 CRUD를 실행할 수 있는 메서드들이 선언되어있다.
PostsRepository
/**
* Repository는 데이터 조작을 담당하며, Jpa Repository를 상속받는다.
* JpaRepository의 값은 매핑할 Entity와 ID의 타입이다.
*/
public interface PostsRepository extends JpaRepository<Posts, Long>{
}
이런식으로 JpaRepository를 상속받은 인터페이스를 생성했다.
다음 차례는 서비스를 작성한다.
PostsService(Before)
@Service("postsService")
@AllArgsConstructor
public class PostsService {
private PostsRepository postsRepository;
public List<PostsResDto> getPostsService() {
List<Posts> entityList = postsRepository.findAll();
List<PostsResDto> result = new ArrayList<>();
entityList.forEach(entity -> {
PostsResDto dto = new PostsResDto();
dto.setId(entity.getId());
dto.setAuthor(entity.getAuthor());
dto.setTitle(entity.getTitle());
dto.setContent(entity.getContent());
dto.setCreatedDate(entity.getCreatedDate());
dto.setModifiedDate(entity.getModifiedDate());
result.add(dto);
});
return result;
}
findAll() : JpaRepository에서 제공하는 메서드중 하나이며 모든 레코드를 조회할 때 사용한다.
자 이런식으로 데이터를 조회하는 서비스를 만들었는데 음..뭔가 좀 보기가 그렇다.
바로 일일히 setter를 통해 값을 업데이트하는 부분이다.
지금처럼 데이터가 많지 않은 경우에는 사실 큰 불편함이 없겠지만 데이터가 많아질 수록 위처럼 일일히
setter를 통해 값을 넣어주는것은 귀찮고 보기에도 별로다. 이같은 모습을 보지 않기 위한 방법이 몇가지 있는데
나는 그중 선택한것이 ModelMapper다. ModelMapper는 오브젝트간의 매핑을 도와주며
위처럼 반복적인 작업을 최소화하여 개발 생산성을 높여주는 도구이다. 더 자세한 설명은 따로 다루기로 하고
ModelMapper를 적용해서 코드를 개선해보자. 그전에 선행되어야 할 작업이 있는데 아래를 보자
compile group: 'org.modelmapper', name: 'modelmapper', version: '2.1.1'
@Configuration
public class SpringServletConfig {
// bean 설정
@Bean
public ModelMapper modelMapper() {
return new ModelMapper();
}
}
/**
* bean으로 등록된 ModelMapper에 생성자를 통해 의존성을 주입받는다.
* 오로지 Entity -> DTO 매핑만을 목적으로 하는 메서드를 만들었음(개인적인 방식입니다. 정답이 아니에요)
*/
@Component
public class CustomModelMapper {
private ModelMapper modelMapper;
public CustomModelMapper(ModelMapper modelMapper) {
this.modelMapper = modelMapper;
}
/**
* @Method 설명 : entity의 데이터들을 dto로 modelmapper 라이브러리를 통해 같은 네임을 가진 객체에게 값을 매핑시킨다.
* @param <T> Dto 타입의 제네릭 변수
* @param <V> Entity 타입의 제네릭 변수
*/
public <T, V> T toDto(V entity, Class<T> dto) {
return dto.cast(modelMapper.map(entity, dto));
}
}
다시 서비스로 돌아가 문제가 되었던 부분을 수정해보자면
PostsService(After)
@Service("postsService")
@AllArgsConstructor
public class PostsService {
private PostsRepository postsRepository;
private CustomModelMapper customModelMapper;
public List<PostsResDto> getPostsService() {
List<Posts> entityList = postsRepository.findAll();
return entityList.stream()
.map(entity -> customModelMapper.toDto(entity, PostsResDto.class))
.collect(Collectors.toList());
}
}
이런식으로 좀 더 간략하게 작성할 수 있다. 물론 정답은 아니다. 지극히 개인적인 의견이 들어간 코드다.
더 좋은 방법이 있다면 당연히 그 방법을 택하면 된다.(저도 좀 알려주시면 좋구요...)
마지막으로 컨트롤러를 작성해보자.
PostsController
@RestController
@RequestMapping(value = {"/posts"}, produces = MediaType.APPLICATION_JSON_VALUE)
@AllArgsConstructor
public class PostsController {
private PostsService postsService;
@GetMapping(value = {""})
public ResponseEntity<List<PostsResDto>> getPosts() {
return ResponseEntity.ok()
.body(postsService.getPostsService());
}
}
이렇게 조회에 필요한 코드를 작성했으니 테스트를 해봐야겠지?? 우선 서버를 실행 후
테이블이 생성되었는지 mysql 서버에 직접 접속하여 확인부터 해보자.
테이블은 잘 실행되었고 수동으로 레코드 몇건을 등록한 후 조회를 해보자.
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@AutoConfigureMockMvc
class SpringJpaApplicationTests {
private MockMvc mockMvc;
@Autowired
private WebApplicationContext context;
@BeforeAll
public void setUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.addFilters(new CharacterEncodingFilter("UTF-8", true))
.alwaysDo(print())
.build();
}
@Test
void contextLoads_게시판_글_목록조회() throws Exception{
mockMvc.perform(get("/posts"))
.andDo(print())
.andExpect(status().isOk());
}
}
이상없이 잘 실행되었고 결과도 잘 나온다.
이런식으로 상세조회, 등록, 삭제를 추가해보려고 한다.