갑자기 사용해보는 JPA

TrainingDummy1·2021년 1월 19일
0

 경력 뻥튀기로 사기쳐먹는 회사에서 한 달 만에 퇴사했다. 엄마가 적금까지 깨면서 원룸을 잡아주셨기에 정말 월세 생각하면서 한 달 죄책감때문에 잠도 제대로 못 자면서 견뎠다. 한 달 견디고 월급 어느정도 나올 정도 되고 퇴사해서 빠른 구직중이다. 덕분에 마음이 좀 심란해서 너무 늦어지는 와중에 OKKY라는 곳에 SQL 클래스 방향성에 대한 질문을 올렸는데 댓글로 JPA를 추천해주셔서 한 번 써보기로 했다. (mybatis쯤은 익숙하지)새로운 것을 배우는 것이 재밌으니까!
 급할 일도 없겠다 충분히 이해한 후에 쓰려고 여기저기 찾아보는데 이전 방식과는 많이 달라서 오래걸렸다.
 mybatis대신 JPA를 쓰게 된 만큼 구조도 꽤 바뀌었다.

구조

 이젠 form 패키지 및 하위 클래스, mapper 패키지 및 하위 클래스, sql 패키지및 하위 클래스는 필요가 없다.
 그리고 Entity 클래스가 따로 필요해서 Dto랑 구분해놨다.
 또 클래스가 많이 추가 될 예정이라 member 패키지 안에 기존의 것들을 몰아넣었다. wordbooklist, wordbook, notice 등을 추가 할 예정이다.

 Gradle에도 뭔가가 추가됐다.

dependencies
spring-boot-starter-jdbc -> db 사용
spring-boot-starter-web
jackson-module-kotlin
kotlin-reflect
kotlin-stdlib-jdk8
mybatis-spring-boot-starter -> db 설정
ojdbc8 -> oracle 사용
spring-boot-starter-tomcat
spring-boot-starter-test

spring-boot-starter-data-jpa -> jpa 사용, 신규 추가

plugins
id ("org.jetbrains.kotlin.plugin.jpa") version "1.4.21" -> jpa 사용시 data class 편의성 증가, 신규 추가

 properties 파일 또한 바뀐게 있다.

application.properties

server.port=9080

spring.datasource.driver-class-name=oracle.jdbc.OracleDriver
spring.datasource.url=jdbc:oracle:thin:@localhost:1521/xe
spring.datasource.username=c##dummy
spring.datasource.password=123

server.servlet.context-path=/

spring.thymeleaf.encoding=UTF-8
server.servlet.encoding.charset=UTF-8
server.servlet.encoding.force=true

spring.profiles.active=dev
spring.thymeleaf.cache=false

spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true

 application 실행마다 테이블을 새로 만듦. 또한 쿼리 실행 시 쿼리문 출력.
 회사에서 집으로 개발 환경이 변해 Oracle 버전이 달라져서 username 정책 변경으로 앞에 c## 추가.

 덩달아 클래스들 내부도 많이 바뀌었다.

Member.kt

import java.util.*
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.GenerationType
import javax.persistence.Id
import javax.persistence.SequenceGenerator

@Entity
@SequenceGenerator(name="MEMBER_SEQ_GENERATOR",
	sequenceName="MEMBER_SEQ",
	initialValue=21,
	allocationSize=1
)
data class Member(
	@Id
	@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBER_SEQ_GENERATOR")
	val id: Long?,
	@Column(unique = true, nullable = false, length = 15)
	val memberId: String,
	@Column(nullable = false, length = 20)
	val password: String,
	@Column(unique = true, nullable = false)
	val email: String,
	@Column(unique = true, nullable = false)
	val phone: String,
	val address: String?,
	@Column(insertable = false, updatable = false, columnDefinition="TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
	val regDate: Date?,
	@Column(nullable = false, columnDefinition = "number(1,0) default 0")
	val certified: Boolean
) {
	override fun hashCode(): Int {
		return 1
	}

	constructor(memberId: String, password: String, email: String, phone: String, address: String?)
	: this(null, memberId, password, email, phone,address, null,  null)
}

 id 1~20은 관리자 전용으로 쓸 예정이라 id의 GenerationType을 IDENTITY가 아닌 SEQUENCE를 썼다. @Entity 바로 아래가 시퀀스다. '시작값 21부터 시작해서 1씩 증가하는 자동 생성키'이다.
 자동 생성이 되는 id, regDate(생성 시간 자동 생성), certified(인증 여부. 0으로 자동 생성) 및 필수 입력값이 아닌 address는 nullable로 설정했다.
 Kotlin의 data class는 copy(), toString(), hashCode(), equals() 함수를 자동으로 만들어주는데, 이 중 hashCode() 때문에 equlas()까지 문제가 생긴다고 한다. 그래서 override로 상수 반환을 한다.
 그리고 Dto 사용을 위한 생성자를 추가한다.

 Oracle은 auto_increment 기능을 시퀀스로 따로 써야 해서 JPA도 상관 없겠지만, MySQL은 기본으로 제공하는 Workbench에서 auto_increment에 체크상자 클릭만 하면 되기에 JPA로 하는건 번거로울 듯 하다.

MemberRepository.kt

import org.springframework.data.jpa.repository.JpaRepository

interface MemberRepository : JpaRepository<Member, Long>{
	public fun findByMemberId(memberId: String): Member
}

 JpaRepository<class, T>를 상속하면 알아서 기본 crud 쿼리는 생긴다. 그래서 memberId로 select하는 findByMemberId 함수만 따로 만들었다. 함수 이름을 findBy로 시작하면 알아서 select 쿼리를 짜준다고 한다.

 쿼리를 자동으로 짜주는건 고맙긴 한데, insert 쿼리 기능을 하는 save 함수의 인자가 Entity object인건 좀 불편하다.

MemberDto.kt

import com.dummy.wordbook.member.entity.Member
import java.util.*
import kotlin.properties.Delegates

data class MemberDto(
	val id: Long?,
    	val memberId: String,
	val password: String,
	val email: String,
	val phone: String,
	val address: String?,
	val regDate: Date?,
	val certified: Boolean?
) {
	constructor(memberId: String, password: String, 
    		email: String, phone: String, address: String?)
	: this(null, memberId, password,
    		email, phone, address, null, null)

	public fun toEntity(): Member {
		return Member(this.memberId, this.password, this.email, this.phone, this.address)
	}
}

 Controller에서 쓰기 위한 보조 생성자와 toEntity() 함수를 선언. JPA에서 자동으로 생성되는 쿼리 함수가 인자로 Entity class를 받는다.

 Entity class를 그냥 쓰는 것은 좋지 않다고 들어서 굳이 Dto를 쓰겠다고 Entity class에도 생성자 추가하고 여기도 toEntity() 함수를 추가하는 수고를 했다. 근데 정말인지는 좀 더 공부해봐야 하겠다.

MemberService.kt

import com.dummy.wordbook.member.entity.Member

interface MemberService {
    public fun findByMemberId(memberId: String): Member
    public fun save(member: Member)
}

MemberServiceImpl.kt

import com.dummy.wordbook.member.entity.Member
import com.dummy.wordbook.member.entity.MemberRepository
import org.springframework.stereotype.Service

@Service
class MemberServiceImpl(private val memberRepository: MemberRepository) : MemberService {
	override fun findByMemberId(memberId: String): Member {
		return memberRepository.findByMemberId(memberId)
	}

	override fun save(member: Member) {
		memberRepository.save(member)
	}
}

 생성자 의존성 주입. MemberRepository.kt 코드를 보면 알겠지만, save(Member) 함수는 직접 선언하지 않았다. 즉, 자동으로 생성해주는 쿼리 함수다. 인자는 Entity class.

 굳이 Service interface랑 ServiceImpl class를 나눠야 하는지 모르겠다. 근데 이게 표준이라니 이렇게 쓰긴 한다만, 일단은 넘어가고 나중에 공부해봐야겠다.
 그리고 자동 생성 쿼리의 인자를 Entity로 고정시킨건 어쩔 수 없다는 것을 알지만 역시 좀 아쉽다.

MemberController.kt

import com.dummy.wordbook.member.dto.MemberDto
import com.dummy.wordbook.member.service.MemberService
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import javax.servlet.http.HttpServletRequest

@Controller
class MemberController(private val memberService: MemberService) {
	@RequestMapping("/")
	public fun indexPage(m: Model): String {
		memberService.findByMemberId("qwe")?.let {loginMember -> 
			m.addAttribute("loginMember", loginMember)
			m.addAttribute("memberDto", MemberDto("", "",
				"", "", null))
		}

		return "index"
	}

	@PostMapping("/insertMember")
	public fun insertMember(req: HttpServletRequest, m: Model): String {
		var memberId: String = req.getParameter("memberId")
		memberService.save(MemberDto(null, memberId, req.getParameter("password"),
			req.getParameter("email"), req.getParameter("phone"), req.getParameter("address"),
			null, null).toEntity()).run {
				m.addAttribute("newMember", memberService.findByMemberId(memberId))
		}

		return "insertComplete"
	}
}

 역시 생성자 의존성 주입. "/"로 접속 시 memberId가 qwe인 멤버를 찾아서 null이 아닐 시 let 안의 코드 진행. loginMember -> ~ 의 의미는 findByMember(String)의 결과값을 loginMember로 정의하고 뒤의 코드를 진행하겠다는 의미.
 memberDto를 굳이 보내는 이유는 Thymeleaf에서 필요하기 때문.
 아직 유효성 검사를 하지 않아 insertMember 내부가 좀 불안정하다. 나중에 추가할 예정. 어쨋든 저 코드는 index 페이지에서 받은 매개변수들을 DB에 insert하고, 그 과정이 끝난 후 run 내부를 진행한다. run 내부는 추가한 member의 정보를 가져와서 Model에 추가해 insertComplete 페이지에 전달하는 것.
 또한 toEntity()를 사용해 Entity 직접 사용 대신 Dto를 Entity로 형변환을 해서 사용.

 여기는 크게 바뀐 건 없다. 원래 이번 포스트에서 로그인 페이지, 회원가입 페이지를 나누려고 했는데, 퇴사 및 JPA 도입으로 많이 늦어졌다.

 이제 뷰를 볼 차례다.

index.html @RequestMapping("/")

<!DOCTYPE html >
<html lang="ko" xmlns:th="http://thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>인덱스 페이지!</title>
</head>
<body>
<p th:if="${loginMember != null}" th:text="|로그인 중(${loginMember.memberId})|">
    <form th:if="${member != null}" th:object="${memberDto}" method="post" th:action="@{/insertMember}">
        <label for="memberId">ID: </label>
            <input th:field="*{memberId}" id="memberId" name="memberId">
        <label for="password">비밀번호: </label>
            <input th:field="*{password}" id="password" name="password" type="password">
        <label for="email">이메일: </label>
            <input th:field="*{email}" id="email" name="email" type="email"  />
        <label for="phone">휴대폰: </label>
            <input th:field="*{phone}" id="phone" name="phone" type="tel">
        <label for="address">주소: </label>
            <input th:field="*{address}" id="address" name="address">
        <button>ㅇㅅㅇ!!!</button>
    </form>
</p>
<p th:unless="${loginMember != null}">로그인 필요</p>
</body>
</html>

 저번과 거의 같음. loginMember 존재 시 입력창이 뜨고, 없으면 "로그인 필요"라는 메세지만 뜸.
 form 안의 th:object 때문에 Controller에서 굳이 memberDto를 Model에 담아서 보냄.

 저번과 거의 같아서 딱히 할 말은 없다.

insertComplete.html @PostMapping("/insertMember")

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>추가 완료!</title>
</head>
<body>
<h1 th:text="${newMember}"></h1>
<button th:href="'/'">ㅇㅅㅇ</button>
</body>
</html>

 그냥 추가한 멤버 정보 보여주고 버튼 눌러서 index 페이지로 이동 가능. 주의할 점은 th:href="" 안에 / 만 넣어서는 안되고, 문자열 형식으로 '/'을 넣어야함.

 역시 테스트 용으로 만든 페이지라 딱히 기능은 없다.

 이로써 이 글을 마무리한다. JPA라는 것을 처음 듣게 되고, 객체 형식으로 DB를 다룬다는 것을 보고 '객체 지향 언어에는 JPA가 어울리나보다' 하고 덥석 물었는데, 아직은 mybatis가 더 많이 쓴다는 것 같다. 근데 mybatis야 어려울 게 쿼리 자체가 어려운거지 mybatis 사용이 어려운게 아니니 그러려니 하고 JPA를 공부 중이다. 그리고 생각보다 Thymeleaf 얘기가 안보이는 것을 보니 차라리 jsp나 vue.js를 선택할걸 그랬나 싶긴 하다... 아무래도 지금은 구직중이라 자주 쓰는걸 선택하는게 좋을테니...

 퇴사를 했다곤 해도 월세가 계속 나간다는 불안감과, 어쨋든 정상적인 취업이 상당히 늦어져서 취업이 더 어려울 거라는 생각이 자꾸 괴롭히긴 하지만, 그래도 어쩌겠나. 사기를 치면서 살기엔 내가 내키지 않았던것을... 하다못해 사기를 쳐도 그거에 죄책감이나 회의감을 느껴야 할텐데, 그러긴 커녕 더 큰 사기를 치려고 하면서 그게 왜 잘못인지도 모르고, 퇴사하려는 순간까지 사기를 종용하는 모습에 경악했다.
 그래도 회사에 다니며 남는 시간에 공부를 하면서 재밌긴 했으니, 이 길이 내 길은 맞다는 생각이 든다. 다만 내가 생각하는거랑 회사가 생각하는거랑 같을지는 모르겠다. 빨리 정상적인 누군가가 날 고용해줬으면 좋겠다...

참조 링크
findBy 함수명: https://javaengine.tistory.com/entry/JPA-%EC%82%AC%EC%9A%A9%EB%B2%95-JpaRepository

profile
연습용더미1

0개의 댓글