Spring Boot와 JPA를 연습할 겸 게시판을 만들고자 한다.
거창한걸 하기엔 혼자 만들기도 하고 자바 8 이후에 추가된 기능들 부터 해서 다 배워가는 입장이기 때문에 간단하게 만들고자 하였다.
컨셉은 모바일 게임 블루 아카이브의 등장인물들이 실명으로 이용하는 게시판이다.
오타쿠 같아도 어쩔 수 없다. 이렇게라도 컨셉 안 잡으면 정말 테이블이 member_info 랑 post, comment 밖에 없는 것을 만들테니깐…
기능은 회원가입 탈퇴, 로그인 로그아웃, 게시글 작성 수정 삭제, 댓글 작성 수정 삭제로 크게 4개로 구분이 된다.
데이터 베이스는 MySQL 8.0을 사용하며 Java 17, Spring Boot 3.1.5 버전을 사용한다.
우선 ERD 먼저 만들었다.
블루 아카이브에는 ‘학교’와 학교에 소속된 ‘동아리’, ‘동아리 내부 직책’, ‘학생 타입’ 등이 있다. 이외에도 학년, 사용하는 총기 등이 있지만 이건 넘어가겠다.
학생은 하나의 아이디만 만들 수 있지만 ‘모모톡’ 이라고 하는 카카오톡 비슷한 SNS 계정으로도 가입할 수 있다.
학생은 하나의 학교, 동아리에만 속할 수 있으며 학교, 동아리는 여러명의 학생을 가질 수 있다.
동아리 역시 하나의 학교에만 속할수 있으며 학교는 여러 동아리를 가질 수 있다.
직책은 여러 동아리에서 같은 이름을 돌려쓰는 경우가 많지만(부장, 부부장 등) 일단 하나의 동아리가 여러 직책을 가지는 것으로 설정했다.
학생 타입은 erd에는 그려놓긴 했는데 불변 정보 테이블이라 Enum 으로 대체했다.
지금 생각해보니 학교, 동아리, 직책도 불변 정보들이라 Enum으로 설정했으면 더 간편하지 않았을까 싶지만 공부를 위해서 남겨두는 것이 나을 것 같다.
이제 ERD를 만들었으니 프로젝트를 만들어야 한다.
위에 써놓은대로 Java 17, Spring Boot 3.1.5, MySQL을 사용한다.
개발 편의를 위해 Lombok을 추가로 의존성에 추가해줬다.
Java 17의 경우 나는 path 등록을 안하고 사용중이다. 그렇기 때문에 Gradle이 Java 17을 사용할 수 있도록 Gradle의 JAVA HOME을 설정해주었다.
Gradle이 Java 17을 인식하게 되었다면 application.properties를 설정해준다.
위의 설정 4개의 경우 Django에서는 기본적으로 제공해주는 템플릿에 따라 설정이 가능한걸로 기억하는데 Spring에서는 백지에서 작성해야 해서 좀 난감했다.
여하튼 설정도 끝났으니 작성한 ERD에 따라 엔티티 클래스를 작성하겠다.
@Getter
@Entity
@Table(name = "student_default_information")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "student_id")
private Long id;
@Column(name = "student_name")
private String name;
@Column(name = "student_age")
private int age;
@Column(name = "student_email")
private String email;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "school_id")
private School school;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "club_id")
private Club club;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "position_id")
private ClubPosition position;
@Enumerated(EnumType.STRING)
private StudentType type;
@OneToMany(mappedBy = "student", cascade = CascadeType.REMOVE)
private List<Post> posts;
@OneToOne(mappedBy = "student")
private Account account;
@OneToOne(mappedBy = "student")
private Momotalk momotalkAccount;
@Override
public String toString() {
return "student_id: " + this.id + "student_name: " + this.name + "student_age: " + this.age
+ "student_email: " + this.email + "student_type: " + this.type;
}
public Student() {
}
@Builder
public Student(Long id, String name, int age, String email, School school, Club club, ClubPosition position,
String type, List<Post> posts, Account account, Momotalk momotalkAccount) {
this.id = id;
this.name = name;
this.age = age;
this.email = email;
this.school = school;
this.club = club;
this.position = position;
this.posts = posts;
this.account = account;
this.momotalkAccount = momotalkAccount;
if (type.equals("BACK")) {
this.type = StudentType.BACK;
} else if (type.equals("MIDDLE")) {
this.type = StudentType.MIDDLE;
} else if (type.equals("FRONT")) {
this.type = StudentType.FRONT;
}
}
}
엔티티 클래스들의 전체 소스코드이다. import 선언 부분은 생략했다.
@Builder 어노테이션은 lombok에서 제공해주는 어노테이션으로 builder 패턴을 이용하여 객체 생성에 편의성을 더해주는 역할을 한다.
이외의 어노테이션들과 코드는 따로 글을 써서 설명하고자 한다.
자꾸 Django랑 비교하게 되는데 Django에서는 엔티티(model) 설정을 어노테이션이 아니라 클래스 변수 선언 및 초기화, 매개변수로 진행한다.
from django.db import models
class WinPurchase(models.Model):
purchase_id = models.AutoField(primary_key=True)
user = models.ForeignKey("user.WinUser", models.CASCADE) #
purchase_time = models.DateTimeField()
purchase_number = models.IntegerField()
purchase_price = models.IntegerField()
class Meta:
# managed = False
db_table = "win_purchase"
class WinPurchaseDetail(models.Model):
purchase_detail_id = models.AutoField(primary_key=True)
purchase = models.ForeignKey(
WinPurchase, models.CASCADE, related_name="purchasePurchaseDetail"
) #
sell = models.ForeignKey(
"store.WinSell", models.CASCADE, related_name="sellPurchaseDetail"
) #
purchase_det_number = models.IntegerField()
purchase_det_price = models.IntegerField()
purchase_det_state = models.IntegerField()
class Meta:
# managed = False
db_table = "win_purchase_detail"
class WinCart(models.Model):
cart_id = models.AutoField(primary_key=True)
user = models.ForeignKey("user.WinUser", models.CASCADE) #
cart_time = models.DateTimeField()
cart_state = models.IntegerField()
class Meta:
# managed = False
db_table = "win_cart"
class WinCartDetail(models.Model):
cart_det_id = models.AutoField(primary_key=True)
sell = models.ForeignKey("store.WinSell", models.CASCADE) #
cart = models.ForeignKey("WinCart", models.CASCADE) #
cart_det_qnty = models.IntegerField()
class Meta:
# managed = False
db_table = "win_cart_detail"
class WinReceiveCode(models.Model):
receive_code_id = models.AutoField(primary_key=True)
purchase_detail = models.ForeignKey("WinPurchaseDetail", models.CASCADE)
receive_code = models.BinaryField(max_length=500)
class Meta:
# managed = False
db_table = "win_receive_code"
사람 취향 차이인지는 모르겠지만 나는 Django쪽이 더 직관적이라고 생각한다. 아니면 Django를 한달 더 해봐서 익숙해진 탓일지도 모른다.
엔티티 클래스들을 다 작성했다면 의도한대로 테이블이 생성되는지 테스트를 해보자.
나는 행여라도 잘못 생성될 것에 대비하여 application.properties의 spring.jpa.hibernate.ddl-auto 옵션을 create-drop으로 변경하고 어플리케이션을 실행했다.
콘솔에서 SQL문을 볼 수 있도록 설정했기 때문에 결과를 확인할 수 있었다.
Hibernate:
alter table momotalk_account_information
drop
foreign key FK3tkkhj2gad4hk3i87cp4x3jnm
Hibernate:
alter table post
drop
foreign key FKpm9a60b4cdtreqm1hdehsqs8v
Hibernate:
alter table student_default_information
drop
foreign key FKo5pyw7qvr44n6pdp6bp97xhxj
Hibernate:
alter table student_default_information
drop
foreign key FK8end5imw5ygo4d9j4aay9spo8
Hibernate:
alter table student_default_information
drop
foreign key FK8i86r0jbsor2u0aqp4ur4pl3m
Hibernate:
drop table if exists club_info
Hibernate:
drop table if exists club_position
Hibernate:
drop table if exists comment
Hibernate:
drop table if exists member_account_information
Hibernate:
drop table if exists momotalk_account_information
Hibernate:
drop table if exists post
Hibernate:
drop table if exists school_info
Hibernate:
drop table if exists student_default_information
Hibernate:
create table club_info (
club_id bigint not null auto_increment,
school_id bigint,
club_name varchar(255),
primary key (club_id)
) engine=InnoDB
Hibernate:
create table club_position (
club_id bigint,
position_id bigint not null auto_increment,
position_name varchar(255),
primary key (position_id)
) engine=InnoDB
Hibernate:
create table comment (
comment_date datetime(6),
comment_id bigint not null auto_increment,
post_id bigint,
student_id bigint,
comment_comment varchar(255),
primary key (comment_id)
) engine=InnoDB
Hibernate:
create table member_account_information (
student_id bigint,
account_id varchar(255) not null,
account_passwd varchar(255),
primary key (account_id)
) engine=InnoDB
Hibernate:
create table momotalk_account_information (
momotalk_account bigint,
momotalk_id varchar(255) not null,
primary key (momotalk_id)
) engine=InnoDB
Hibernate:
create table post (
post_date datetime(6),
post_id bigint not null auto_increment,
post_view bigint,
student_id bigint,
post_post varchar(255),
primary key (post_id)
) engine=InnoDB
Hibernate:
create table school_info (
school_id bigint not null auto_increment,
school_name varchar(255),
primary key (school_id)
) engine=InnoDB
Hibernate:
create table student_default_information (
student_age integer,
club_id bigint,
position_id bigint,
school_id bigint,
student_id bigint not null auto_increment,
student_email varchar(255),
student_name varchar(255),
type enum ('BACK','FRONT','MIDDLE'),
primary key (student_id)
) engine=InnoDB
Hibernate:
alter table member_account_information
add constraint UK_4j17mxxlq6te81e0fbf2m81ct unique (student_id)
Hibernate:
alter table momotalk_account_information
add constraint UK_nbsua2ybipc05nesa5y11416e unique (momotalk_account)
Hibernate:
alter table school_info
add constraint school_name_unique unique (school_name)
Hibernate:
alter table club_info
add constraint FKb9k2im6ylityba5gephir6pec
foreign key (school_id)
references school_info (school_id)
Hibernate:
alter table club_position
add constraint FK8hl4mebt8uokxs9w8tna5t275
foreign key (club_id)
references club_info (club_id)
Hibernate:
alter table comment
add constraint FKs1slvnkuemjsq2kj4h3vhx7i1
foreign key (post_id)
references post (post_id)
Hibernate:
alter table comment
add constraint FKt2rqyvyoxaksy3nwe1df8rqc9
foreign key (student_id)
references student_default_information (student_id)
Hibernate:
alter table member_account_information
add constraint FKhxh5e8uuwu6yf0nd9y496bgek
foreign key (student_id)
references student_default_information (student_id)
Hibernate:
alter table momotalk_account_information
add constraint FK3tkkhj2gad4hk3i87cp4x3jnm
foreign key (momotalk_account)
references student_default_information (student_id)
Hibernate:
alter table post
add constraint FKpm9a60b4cdtreqm1hdehsqs8v
foreign key (student_id)
references student_default_information (student_id)
Hibernate:
alter table student_default_information
add constraint FKo5pyw7qvr44n6pdp6bp97xhxj
foreign key (club_id)
references club_info (club_id)
Hibernate:
alter table student_default_information
add constraint FK8end5imw5ygo4d9j4aay9spo8
foreign key (position_id)
references club_position (position_id)
Hibernate:
alter table student_default_information
add constraint FK8i86r0jbsor2u0aqp4ur4pl3m
foreign key (school_id)
references school_info (school_id)
Enum으로 선언한 student_type 빼고는 다 잘 생성이 되었다.
그러면 바로 student_default_information과 member_account_information에 insert를 하는, 중복 회원 검사 같은거 없이 바로 아이디 비밀번호와 기본 정보(학교, 동아리, 동아리 직책 포함)를 입력하고 회원가입을 하는 기능을 만들어보겠다.
우선 학교, 동아리, 동아리 직책은 모두 사전에 DB에 입력해놓았다.
@Repository
public class AccountRepository {
private final EntityManager em;
public AccountRepository(EntityManager em) {
this.em = em;
}
public Account save(Account account) {
em.persist(account);
return account;
}
}
우선 계정 관리를 위한 AccountRepository 클래스를 만들었다.
EntityManager.persist(엔티티 클래스) 는 insert문과 같은 역할을 수행한다.
엔티티 클래스에 영속성을 부여해준다고도 할 수 있는데, 객체인 엔티티를 사전에 매핑된 DB Table에 저장하는 것이다.
일단 AccountRepository 에서는Account 엔티티를 member_account_information에 저장하는 save() 메서드를 만들었다.
@Repository
public class MemberRepository {
private final EntityManager em;
@Autowired
public MemberRepository(EntityManager em) {
this.em = em;
}
public Student save(Student student) {
em.persist(student);
return student;
}
}
MemberRepository역시 Student 엔티티를 매핑해둔 student_default_information 테이블에 저장하는 save() 메서드를 만들었다.
하지만 Student 엔티티에는
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "school_id")
private School school;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "club_id")
private Club club;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "position_id")
private ClubPosition position;
@Enumerated(EnumType.STRING)
private StudentType type;
@OneToMany(mappedBy = "student", cascade = CascadeType.REMOVE)
private List<Post> posts;
@OneToOne(mappedBy = "student")
private Account account;
@OneToOne(mappedBy = "student")
private Momotalk momotalkAccount;
여러개의 객체 멤버들이 있다. 다른건 몰라도 일단 Student 객체에 영속성을 부여할 때 School, Club, ClubPosition 객체는 같이 들어가줘야한다.
그래서 InformationRepository 클래스를 만들었다.
@Repository
public class InformationRepository {
private final EntityManager em;
@Autowired
public InformationRepository(EntityManager em) {
this.em = em;
}
public School schoolFindById(int id) {
School school = em.find(School.class, id);
return school;
}
public Club clubFindById(int id) {
Club club = em.find(Club.class, id);
return club;
}
public ClubPosition clubPositionFindById(int id) {
ClubPosition clubPosition = em.find(ClubPosition.class, id);
return clubPosition;
}
}
각각 School, Club, ClubPosition 객체의 @Id 어노테이션이 붙은 멤버 변수를 바탕으로 DB에서 검색하여 객체를 반환하는 기능을 한다.
이제 회원가입하는 기능을 만들어보자. 나는Controller단에서 Service단으로 데이터를 넘기기 위해 DTO 클래스를 하나 만들었다.
@Transactional
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final AccountRepository accountRepository;
private final InformationRepository informationRepository;
@Autowired
public MemberService(MemberRepository memberRepository, AccountRepository accountRepository,
InformationRepository informationRepository) {
this.memberRepository = memberRepository;
this.accountRepository = accountRepository;
this.informationRepository = informationRepository;
}
public StudentAndAccountDTO join(StudentAndAccountDTO studentAndAccountDto) {
Student student = Student.builder()
.name(studentAndAccountDto.getName())
.age(studentAndAccountDto.getAge())
.email(studentAndAccountDto.getEmail())
.school(informationRepository.schoolFindById(studentAndAccountDto.getSchoolId()))
.club(informationRepository.clubFindById(studentAndAccountDto.getClubId()))
.position(informationRepository.clubPositionFindById(studentAndAccountDto.getPositionId()))
.type(studentAndAccountDto.getStudentType())
.build();
Account account = Account.builder()
.id(studentAndAccountDto.getAccountId())
.passwd(studentAndAccountDto.getAccountPasswd())
.student(student)
.build();
memberRepository.save(student);
accountRepository.save(account);
studentAndAccountDto.setName(student.getName());
studentAndAccountDto.setAge(student.getAge());
studentAndAccountDto.setEmail(student.getEmail());
studentAndAccountDto.setSchoolId(student.getSchool().getId().intValue());
studentAndAccountDto.setClubId(student.getClub().getId().intValue());
studentAndAccountDto.setPositionId(student.getPosition().getId().intValue());
studentAndAccountDto.setAccountId(account.getId());
studentAndAccountDto.setAccountPasswd(account.getPasswd());
return studentAndAccountDto;
}
}
뭔가 길다. 그리고 이상하다.
우리의 머릿속에서 생각해봤을 때(기본적인 insert문에대한 이해가 있다면) 외래키 컬럼도 입력하고자 하는 값이 부모 테이블에 있다는 전제 하에 그냥 평범하게 값을 입력해줄 수 있다고 생각할 수 있다.
그런데 JPA를 사용하니 부모 테이블과 매핑된 객체를 id를 이용해서 값을 찾아오고 그걸 Student 객체에 집어넣어주는 작업이 필요해졌다.
이 말은 한 테이블에 insert문 하나를 날리기 위해 세번의 select문을 날렸다는 소리다. 무언가 잘못되어도 단단히 잘못된것 같다.
하지만 기능은 만들어야 하니 계속 진행해보자.
@Controller
public class MemberController {
private final MemberService memberService;
@Autowired
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@GetMapping("/join")
public String join() {
return "member/joinPage";
}
@PostMapping("/join")
public @ResponseBody StudentAndAccountDTO join(StudentAndAccountDTO studentAndAccountDTO) {
StudentAndAccountDTO result = memberService.join(studentAndAccountDTO);
return result;
}
}
이번엔 컨트롤러다.
@GetMapping 어노테이션은 설정된 URL로 GET 요청이 들어왔을 때 수행할 메서드를매핑해줄 때 사용한다. 나는 resource/member 경로에 joinPage.html 을 만들어주었다. 해당 html에는 입력 폼을 만들어두었다.
<form method="post" action="/join">
<input type="text" name="schoolId" placeholder="1">
<input type="text" name="clubId" placeholder="1">
<input type="text" name="positionId" placeholder="1">
<input type="text" name="studentType" placeholder="FRONT, MIDDLE, BACK 중에서 입력">
<input type="text" name="name" placeholder="이름">
<input type="number" name="age" placeholder="나이">
<input type="email" name="email" placeholder="이메일">
<input type="text" name="accountId" placeholder="아이디">
<input type="passwd" name="accountPasswd" placeholder="비밀번호">
<input type="submit" value="등록">
</form>
input 태그의 name 어트리뷰트들을 잘 눈여겨보자.
아까전에 데이터 이동을 위해 DTO 클래스를 하나 만들었다고 했다.
@Getter
@Setter
public class StudentAndAccountDTO {
private int schoolId;
private int clubId;
private int positionId;
private String studentType;
private String name;
private int age;
private String email;
private String accountId;
private String accountPasswd;
@Override
public String toString() {
return "StudentAndAccountDTO [schoolId=" + schoolId + ", clubId=" + clubId + ", positionId=" + positionId
+ ", studentType=" + studentType + ", name=" + name + ", age=" + age + ", email=" + email
+ ", accountId=" + accountId + ", accountPasswd=" + accountPasswd + "]";
}
}
name 어트리뷰트의 값들과 DTO 클래스의 필드 변수명이 같은 것을 볼 수 있다. 이는 PostMapping 어노테이션이 붙은 join()메서드에서 유용하게 쓰인다.
@PostMapping("/join")
public @ResponseBody StudentAndAccountDTO join(StudentAndAccountDTO studentAndAccountDTO) {
StudentAndAccountDTO result = memberService.join(studentAndAccountDTO);
return result;
}
매개변수를 해당 DTO 클래스로 지정해서 매개변수를 여러개 받을 필요 없이 바로 객체에 값을 담을 수 있게 된다.
이를 커멘드 객체라고 한다. 커멘드 객체는 Spring MVC에서 제공해주는 기능이라고 하며 자세한 것은 더 찾아봐야한다.
나는 입력값이 곧 DB에 저장할 값이기 때문에 DTO를 Service단까지 내려보냈지만 UI 변동이 Service까지 영향을 줄 수 있기 때문에 Controller에서 Service로 데이터를 보낼 때는 다른 DTO나 VO를 쓰도록 하자.
이제 화면도 만들었으니 직접 브라우저에서 값을 입력해보자.
나는 테스트할 때 키움 히어로즈 선수들 이름을 집어넣는다.
이렇게 집어넣고 등록 버튼을 누르면
잘 표시가 된다.
Json 형태인 이유는 객체로 반환해서 그렇다.
DB에는 잘 들어갔을까?
잘 들어갔다.
일단 이렇게 해서 간단한 회원가입 기능을 만들었다.
입력값 검증도 없고 insert 하나 하는데 select문 세개가 곁다리로 따라오는 문제가 있긴 하다. 이 중 insert문 관련 문제는 다음 글에서 해결해보고자 한다.
작성된 코드는 깃허브에 커밋 해두었다.