본문으로 바로가기
반응형

본 예제는 Spring 5.x &Spring boot 2.3.0 버전을 사용합니다.

또한 Kotlin (v1.4.20)로 예제를 작성하며 IntelliJ CE를 사용합니다.



JPA를 이용하면 앞선 내용에서 사용했던 JdbcTemplate보다 좀 더 간략한 코드를 만들 수 있습니다.

앞서 언급했던 내용들 중에 중복되는 작업이나 표현은 가능한 생략 합니다.

따라서 앞선 포스팅을 먼저 읽고 오시기를 추천합니다.

2020/12/29 - [개발이야기/Spring Framework] - [Spring] 스프링 DB 접근 및 활용 - JPA (2/2) with kotlin #4

 

환경설정

JPA를 사용하기 위해 pom.xml에 dependency를 추가합니다

<!-- db 접근을 위한 jpa 추가 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

 

Emtity 생성

JPA에서는 따로 create Table 명령어를 사용하여 테이블을 생성하지 않습니다.

따라서 table의 정보를 나타내는 Entity model을 만들어 사용합니다.

앞선 포스팅에서는 "dept", "employee" 테이블을 만들어 사용했는데, 여기서는 "dept2", "employee2"로 테이블을 만들어 보겠습니다.

먼저 Entity가 될 class를 정의합니다.

/**
 * JPA 이용시 속성은 val 이용이 가능하다.
 * empty constructor가 필요하나 default value를 설정하여 이를 커버한다.
 */
@Entity
@Table(name="employee2")
data class Employee2(
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    val id: Long = -1,
    val name: String? = null,
    val address: String? = null,
    val phoneNumber: String? = null,
    var crDate: Date = Date()) {

    //"dept_id" 컬럼이 자동 추가된다.
    @ManyToOne(targetEntity = Dept2::class)
    var dept: Dept2? = null

    @PrePersist
    private fun setCrDate() {
        crDate = Date()
    }
}

@Entity 선언을 통해 이 클래스의 구성대로 table이 생성되도록 합니다.

기본적으로 class 이름이 테이블 이름이 됩니다. 다만 다른 이름을 쓰려면 @Table을 이용하여 테이블 이름을 직접 지정 가능합니다.

또한 @Id 설정은 반드시 필요하며, auto_increament를 위해 @GeneratiedValue도 id 속성에 추가합니다.

그리고 @ManyToOne annotation을 통해  dept와의 관계를 정해 줍니다.

앞선 JdbcTemplate에서는 deptId 컬럼을 만들어 두었으나, 여기서는 아예 dept 객체를 담습니다.

또한 dept 하나가 여러 개의 imployee를 담을 수 있기 때문에 @ManyToOne을 사용합니다.

물론 다른 형태의 annotation도 존재하면 만약 @ManyToMany으로 표현되었다면 이 두 개의 관계를 나타내는 연관 table이 하나 더 생성됩니다.

마지막으로 @PrePersist는 해당 model이 저장되기 전에 해당 값이 호출됩니다.

즉 저장 시점에 Date() 값이 입력됩니다.

 

여기서 중요한 부분은 모든 속성에 기본값을 설정했다는 것입니다.

JPA는 인자가 없는 생성자가 필요합니다.

따라서 private constructor를 만들어야 하나 위 예제의 경우 전부 기본값을 정해줌으로써 실제 생성하는 부분에서 Employee()로도 생성 가능하도록 만들어 줍니다.

실제로 자바를 사용할 경우 아래와 같이 여러 개의 annotation이 붙어야 합니다.

@Data
@RequiredArgsConstructor
@NoArgsConstructor(access=AccessLevel.PRIVATE, force=true)
@Entity
public class Ingredient {

	@Id
	private final String id;
	
    ...
}
  • @Data: lombok을 이용해 getter / setter를 자동 생성함.
  • @NoArgsConstructor: JPA가 인자 없는 생성자를 필요로 합니다. 따라서 이 annotation이 필요합니다.
  • @RequiredArgsConstructor: @NoArgsConstructor가 있으면 lombok에서 인자가 있는 기본 constructor를 만들지 않습니다. 하지만 인자를 넣어서 constructor를 만들 수도 있어야 하니 이 annotation을 추가합니다.

이 복잡한 annotation들을 kotlin에서는 data class의 속성을 잘 활용하여 간단하게 표현이 가능합니다.

 

다음으로 Dept2 클래스도 유사하게 작성합니다.

/**
 * IMPL_NOTE: 값이 변경되기 때문에 var로 속성값을 넣는다.
 * empty constructor가 필요하나 default value를 설정하여 이를 커버한다.
 */
@Entity
@Table(name="dept2")
data class Dept2(
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    val id: Long = -1,
    val deptName: String? = null,
    var crDate: Date = Date()) {

    @PrePersist
    private fun setCrDate() {
        crDate = Date()
    }
}

 

기본 데이터 추가

앞선 JDBC에서는 data.sql과 sheme.sql을 통해서 테이블을 생성하고 기본 데이터를 넣었습니다.

하지만 JPA에서는 @SpringBootApplication class에 dataLoader()를 추가하여 기본 데이터를 넣습니다.

dataLoader()는 어플리케이션이 시작되면 자동으로 호출됩니다.

@SpringBootApplication
class CarFactoryApplication {
    @Bean
    fun dataLoader(dept2Repo: IDept2Repository,
                   emp2Repo: IEmployee2Repository): CommandLineRunner {
        return CommandLineRunner { args ->
            val dept = dept2Repo.save(Dept2(100, "Infra"))
            val employee2 = Employee2(100, "admin","office","01011112222")
            employee2.dept = dept
            emp2Repo.save(employee2)
        }
    }
}

fun main(args: Array<String>) {
	runApplication<CarFactoryApplication>(*args)
}

이번에는 infra 부서와 admin 계정을 기본값으로 넣었습니다.

아직 두 entity의 repository를 만들지 않았기에 에러가 뜨겠지만 이제 해당 repository를 추가해 보겠습니다.

 

Repository 생성

먼저 Dept2를 위한 repository는 아래와 같습니다.

interface IDept2Repository: CrudRepository<Dept2, Long>

간단하게 interface 생성 후 CrudRepository<'Entity객체', 'id 타입'>를 상속받도록 합니다.

이렇게만 해 놓으면 기본적인 db operation이 자동으로 생성됩니다.

어떤 함수들이 기본적으로 추가되는지 CrudRepository의 코드를 잠깐 보겠습니다.

@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {
    <S extends T> S save(S var1);

    <S extends T> Iterable<S> saveAll(Iterable<S> var1);

    Optional<T> findById(ID var1);

    boolean existsById(ID var1);

    Iterable<T> findAll();

    Iterable<T> findAllById(Iterable<ID> var1);

    long count();

    void deleteById(ID var1);

    void delete(T var1);

    void deleteAll(Iterable<? extends T> var1);

    void deleteAll();
}

위에 선언된 함수들이 기본적으로 JPA에 의해서 자동으로 생성되므로 JdbcTemplate때처럼 우리가 따로 해당 함수들을 각각 만들 필요가 없습니다.

코드가 엄청 줄었네요.

Employee의 repository interface를 추가해 보겠습니다.

다만 이때는 기본적인 operation 이외에 특정 부서에 속한 직원을 반환하는 함수를 추가로 정의합니다.

// Query 사용법https://www.baeldung.com/spring-data-jpa-query
interface IEmployee2Repository : CrudRepository<Employee2, Long> {
    @Query(value ="""
         select e2.* from employee2 e2, dept2 d2
         where e2.dept_id = d2.id
         and d2.id = :deptId
    """, nativeQuery = true)
    
    fun findByDeptId(@Param("deptId") deptId: Long): List<Employee2>
    
    fun findAll(pageable: Pageable): List<Employee2>
}

@Query annotation을 통해 원하는 sql을 수행하는 함수를 만들었습니다.

다만 JPA는 함수명 만으로도 해당 동작을 만들어 낼 수 있습니다.

사실 annotation 없이 findByDeptId(deptId:Long): List <Employee2>만 지정하더라도 똑똑한 JPA가 알아서 해당 결과를 반환합니다.

예를 들면 getByDeptId(deptId:Long): List<Employee2>, readByDeptId(deptId:Long): List<Employee2>

도 같은 작업을 수행합니다.

IsGreaterThanEqual, IsNull, Like, Is, Equals 등등의 sql로 변환될 수 있는 예약어들이 존재하며 이를 잘 조합하려 원하는 sql을 조합할 수도 있습니다.

자세한 내용은 https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#reference의 6.3.2 Query creation 섹션에서 확인 가능합니다.

하지만 저는 함수명을 조합하기에는 헷갈려서 @Query annotion을 이용했습니다.

@Query의 자세한 사용법은 https://www.baeldung.com/spring-data-jpa-query에서 확인할 수 있습니다.

마지막으로 특정 개수를 반환 가능하도록 하는 Pageable param을 받는 findAll 함수를 추가하여 마무리합니다.

 

Html 구성

구성은 JdbcTemplate에서 사용한 것과 크게 다르지 않습니다.

<body>
<form method="POST" th:action="@{/employee_regi2}">
    <label for="name">Username: </label>
    <input type="text" name="name"/><br/>

    <label for="address">Address: </label>
    <input type="text" name="address" /><br/>

    <label for="phoneNumber">PhoneNumber: </label>
    <input type="text" name="phoneNumber" /><br/>

    <label for="deptName">Dept. Name: </label>
    <input type="text" name="deptName" /><br/>

    <input type="submit" value="Register"/>
</form>
</body>

 th 속성 값을 다 빼내고 name으로 객체의 속성값을 사용했습니다.

즉 modelAttribute로 employee 객체를 등록해 놓으면 name="name"으로 되어있는 input 값은 employee.name에 알아서 들어갑니다.

추가적으로 register 버튼을 눌렀을 때 정보를 표시할 html입니다.

<body>
<h1>Register Employee2 & Dept Completed</h1>
<br>

<ul>
    <li>Employee ID: <span th:text="${employee2.id}">사번</span></li>
    <li>Name: <span th:text="${employee2.name}">이름</span></li>
    <li>Address: <span th:text="${employee2.address}">주소</span></li>
    <li>PhoneNumber: <span th:text="${employee2.phoneNumber}">전화번호</span></li>
    <li>Dept ID: <span th:text="${employee2.dept.id}">부서 ID</span></li>
    <li>DeptName: <span th:text="${dept2.deptName}">부서명</span></li>
    <li>Number of dept employee: <span th:text="${numberOfEmployeeInDept}">부서 사람수</span></li>
</ul>

<br>

<p>Total employees</p>
<ul>
    <li th:each="person: ${employees}"><span th:text="${person.name}">직원이름</span></li>
</ul>

</body>

기본적으로 employee 정보를 표기합니다.

추가적으로 IDept2 Repository에서 정의한 findEmployeeByDeptId(), findAll() 함수를 사용하도록 해당 부서의 사람 수와 전체 직원 이름을 추가로 출력하도록 합니다.

완성되어 출력된 결과는 controller 작성 이후에 확인하도록 합니다.

Controller의 구성

먼저 직원 입력 페이지인 resources/templates/employee_regi_complete2.html을 처리할 controller를 작성합니다.

@Slf4j
@Controller
@RequestMapping("/employee_regi2")
class Employee2Controller(
    @Autowired val employee2Repository: IEmployee2Repository,
    @Autowired val dept2Repository: IDept2Repository,
    val request: HttpServletRequest
) {

    private val log = LoggerFactory.getLogger("EmployeeController2")

    @GetMapping
    fun showAddressView(model: Model): String {
        model.addAttribute("employee2", Employee2())
        model.addAttribute("dept2", Dept2())
        return "employee_dept_input2"
    }

    @PostMapping
    fun processForm(employee2: Employee2, dept2: Dept2): String {
        log.info("dept2: $dept2 | employee2: $employee2")
        // 부서를 먼저 넣는다.
        val insertedDept = dept2Repository.save(dept2)
        employee2.dept = insertedDept
        val insertedEmployee = employee2Repository.save(employee2)

        request.session.setAttribute("employee2_id", insertedEmployee.id)

        log.info("employee_id: ${request.session.getAttribute("employee2_id")}")
        log.info(" inserted dept2: $insertedDept | employee2: $insertedEmployee")

        return "redirect:/regi_complete2"
    }
}

localhost:8080/emplyoee_regi2로 접속 시 @GetMapping에 정의된 함수를 이용하여 employee_dept_input2.html을 보여 주도록 합니다.

이때 html에서 접근할 "employee2", "dept2" 객체를 생성하여 attribute로 등록해 줍니다.

@PostMapping에서는 form에 값이 입력되어 register 버튼이 눌리면 처리되는 내용들을 넣습니다.

먼저 부서 정보를 넣은 뒤 넘겨받은 부서의 id를 employee객체에 넣어 직원 정보도 save 시킵니다.

그리고 jdbcTemplate에서 처리했던 것과는 다른 방식으로 session에 employee id를 저장합니다.

이때 session에 접근하기 위해서 미리 class 생성자로 HttpServletRequest를 주입받아 둡니다.

 

이번엔 등록이 완료된 후 결과를 보여줄 페이지 controller를 작성하겠습니다.

@Slf4j
@Controller
@RequestMapping("/regi_complete2")
class RegiCompleteController2(
    @Autowired val employee2Repository: IEmployee2Repository,
    @Autowired val dept2Repository: IDept2Repository
) {

    private val log = LoggerFactory.getLogger("RegiCompleteController2")

    @GetMapping
    fun showCompleteData(model: Model, request: HttpServletRequest, sessionStatus: SessionStatus): String {
        // Emplyee 정보를 DB에서 가져온다
        val employeeId = request.session.getAttribute("employee2_id") as Long
        log.info("employee2_id:$employeeId")
        val employee2 = employee2Repository.findById(employeeId).orElseGet { Employee2() }

        // dept 정보를 DB에서 가져온다
        val dept2 = dept2Repository.findById(employee2.dept?.id ?: -1).orElseGet { Dept2() }

        model.addAttribute("employee2", employee2)
        model.addAttribute("dept2", dept2)

        // 세션 정보 삭제
        sessionStatus.setComplete()

        //  dept에 속한 employee 정보를 가져온다
        val employee2List = employee2Repository.findEmployeeByDeptId(dept2.id)
        log.info("employee2List: $employee2List")
        model.addAttribute("numberOfEmployeeInDept", employee2List.size)

        // 전체 employee 정보를 가져온다 최근 기중 최대 10명까지
        val employees = employee2Repository.findAll(PageRequest.of(0,3))
        model.addAttribute("employees", employees)

        return "employee_regi_complete2"
    }
}

 

localhost:8080/emplyoee_regi2로 redirect 되면 seesion에 담아 두었던 employee의 id를 꺼내옵니다.

그리고 해당 id를 조회하여 employee 객체를 받아옵니다.

이때 return값은 Optional<Employee2> 이므로 orElseGet{...}을 사용했습니다.

유사하게 dept 정보도 꺼내와 두 개의 객체를 model attribute로 등록해 줍니다.

그리고 사용이 끝난 session정보를 해제하기 위해 sessionStatus.setComplete()를 호출해 줍니다.

마지막을 해당 부서에 속한 직원수와, 현재 등록되어 있는 직원을 최대 10명까지 가져와 model attribute로 등록해 둡니다.

이렇게 등록된 객체들의 정보를 읽어 html에 표시합니다.

 

동작 확인

마지막으로 실제 동작을 시켜 봅니다.

위와 같이 접근하여 register를 클릭합니다.

등록된 정보를 표시해 주고 전체 직원수도 표시해 줍니다.

dataLoader를 통해 기본값으로 넣었던 admin도 정상적으로 잘 표시됨을 알 수 있습니다.

 

H2 table의 구성

위에서 Entity만 지정했을 뿐 실제로 Table을 만들지 않았습니다.

그중에 Employee2는 dpet2 객체를 속성으로 가지면서 관계를 @ManyToOne으로 표기했었습니다.

이 부분이 실제 어떻게 처리되었는지 h2 db를 열어 확인해 보겠습니다.

실제 Employee2 table에 "DEPT_ID"라는 컬럼이 생겨 있습니다.

따라서 IEmployeeRepository에서 @Query에 들어있는 sql을 자세히 보면 제가 join을 위해서 dept_id로 표기한 부분을 확인할 수 있습니다.

 

이로써 Spring에서 DB 접근에 대한 부분을 마무리합니다.

반응형