개인 웹 프로젝트를 하면서 NullPointException으로 인해 고통받았다...
현재 진행중인 프로젝트에서 Time 엔티티와 Seat 엔티티가 존재하고, 서로 양방향 연관관계 매핑이 되어있는 상황이다.
(Time (1) <--> Seat (다))
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString(exclude = "seats")
public class Time extends BaseEntity{
/** 다른 필드는 생략 **/
@OneToMany(mappedBy = "time", cascade = CascadeType.REMOVE)
private List<Seat> seats = new ArrayList<>();
}
@OneToMany 어노테이션으로 Seat 엔티티와 일대다 매핑하였고, cascade = CascadeType.REMOVE를 통해 Time 엔티티 객체가 지워지면 연관된 Seat 엔티티 객체가 지워지도록 하였다.
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString(exclude = "time")
public class Seat {
/** 다른 필드는 생략 **/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "time_id")
private Time time;
// 연관관계 편의 메서드
public void setTime(Time time) {
if(this.time != null){
this.time.getSeats().remove(this);
}
this.time = time;
time.getSeats().add(this);
}
}
@ManyToOne 어노테이션으로 Time 엔티티와 다대일 매핑하였고, 연관관계 편의 메소드를 작성했다. 양방향으로 참조를 저장한다.
TimeService 클래스에서 Time을 저장하면서 동시에 Seat 객체를 만들어서 저장한다. (최적화가 덜 된 코드임을 감안해주세요..)
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Log4j2
public class TimeServiceImpl implements TimeService{
private final TimeRepository timeRepository;
private final CinemaRepository cinemaRepository;
private final SeatService seatService;
@Override
@Transactional
public Long save(TimeSaveDTO timeSaveDTO) {
Cinema cinema = cinemaRepository.findById(timeSaveDTO.getCinemaId())
.orElseThrow(() -> new IllegalStateException("해당 Cinema가 없음"));
Time time = dtoToEntity(timeSaveDTO, cinema);
// 처음 극장 시간 정할 때, 좌석을 모두 사용 가능한 상태로 넣어줌.
time = seatService.makeSeats(timeSaveDTO.getSeatNum(), time);
timeRepository.save(time);
return time.getId();
}
}
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Log4j2
public class SeatServiceImpl implements SeatService{
private final SeatRepository seatRepository;
@Override
@Transactional
public Time makeSeats(int seatNum, Time time) {
log.info("Make Seats.... : " + seatNum);
log.info("Time : " + time);
// 좌석이 100석일 때.
if(seatNum == 100) {
for(int i = 1; i <= 10; i++) {
for(int j = 1; j <= 10; j++) {
Seat seat = Seat.builder().row(i).col(j).isAvailable(true).build();
seat.setTime(time);
seatRepository.save(seat);
}
}
}
// 좌석이 120석일 때.
else if(seatNum == 120) {
for(int i = 1; i <= 10; i++) {
for (int j = 1; j <= 12; j++) {
Seat seat = Seat.builder().row(i).col(j).isAvailable(true).build();
seat.setTime(time);
seatRepository.save(seat);
}
}
}
return time;
}
}
Controller를 통해 TimeSaveDTO 객체를 받아서 entity로 변환 후 Time을 만든다.
SeatService의 makeSeats 메소드를 통해 방금 만든 Time 객체 참조를 넘겨준다.
SeatService에서 받은 Time 객체 참조를 통해 Seat 객체들을 만든다. 이 때 위에서 봤던 연관관계 메소드를 사용한다.
Seat 객체들을 주입받은 Repository 객체를 통해 영속상태로 만든 뒤 DB에 저장한다.
그 후에 Time 객체도 Repository 객체를 통해 저장한다.
여기서 문제가 발생한다...
공포의 NullPointException이 뜬 것!
처음에는 순서가 잘못되었나 싶어서 Time 객체를 먼저 영속상태로 만들고, 그 후에 Seat객체를 만들어보았지만 문제는 없었다.
Time 객체가 null인가 싶어 로깅을 해보았지만 정상이었다.
도대체 무엇이 문제일까... 계속 시도 끝에 드디어 발견했다.
바로 Builder와 관련된 문제였다. Builder에 대한 이해가 부족했다..
객체를 생성할 때 인자들을 선택적으로 받아서 생성하고 싶을 때 여러개의 생성자를 만들게 된다. 만약 인자 개수가 많아지면, 생성자를 많이 생성해야 하고, 같은 자료형의 인자라면 실수로 값이 바뀌어서 객체가 생성 될 수 있다.
Setter를 통해 객체 필드값들을 채울 수 있지만, 이렇게 하면 "객체 일관성"이 깨진다. 언제 어디서든 Setter 를 통해서 객체의 필드값을 변경해버릴 수 있어서 위험하다.
이러한 문제들을 보완하기 위해 나온 것이 Builder 패턴이다.
Builder 패턴은 메소드 체이닝 기법을 사용해서 Builder 객체를 활용해 객체를 생성하는 방법이다.
예를 들어 Time 객체를 TimeSaveDTO 객체의 필드들을 통해 생성하는 코드는 다음과 같다.
default Time dtoToEntity(TimeSaveDTO timeSaveDTO) {
return Time.builder()
.time(LocalTime.of(timeSaveDTO.getHour(),timeSaveDTO.getMinute()))
.seatNum(timeSaveDTO.getSeatNum())
.availableNum(timeSaveDTO.getSeatNum())
.build();
}
Builder 객체를 build 한 뒤에는 필드 값을 변경하기 위해선 setter 메소드를 만들거나, 리플렉션 기법밖에 방법이 없다.
그래서 "객체 일관성"이 깨지지 않는다. 또한 인자를 선택적으로 넣을 수 있고, 가독성도 좋다.
프로젝트에서는 Lombok의 @Builder annotation을 통해서 더욱 손쉽게 구현하였다. (Lombok의 @Builder 를 사용하려면 @AllArgsConstructor와 @NoArgsConstructor를 넣어주어야 함)
Builder 패턴에 대해 간단히 알아보았으니 무엇이 문제였을지 알아보자..
범인이 된 코드를 여기 적겠다
@OneToMany(mappedBy = "time", cascade = CascadeType.REMOVE)
private List<Seat> seats = new ArrayList<>();
List는 Java의 Collections 인터페이스로, Collection에 객체들을 저장하려면 (정확히는 참조들) 구현체를 지정해주어야 한다.
위와 같은 경우에는 ArrayList를 구현체로 지정해 준 상황이다.
그런데 @Builder를 통해 Builder 패턴을 사용할 때 위처럼만 작성하면 ArrayList가 저장이 안되는 문제점이 발생했다.
@Builder사용시 Class Type 기본 값은 null이 된다. 위와 같이 작성한다고 기본값 지정이 안되었다.
위의 의도대로 ArrayList 구현체를 default로 지정해 주고 싶으면
@Builder.Default 라는 어노테이션을 추가로 붙여주어야만 했다!
밑의 코드처럼 @Builder.Default 어노테이션을 통해 기본값이 설정 가능하다.
@OneToMany(mappedBy = "time", cascade = CascadeType.REMOVE)
@Builder.Default
private List<Seat> seats = new ArrayList<>();
사실 얼핏 보면 간단하게 해결한 것 같지만, 필자는 jpa의 연관관계 매핑에 문제가 있다고 생각해 엉뚱한 곳을 건드리느라 해결하는데 시간이 많이 걸렸다. (삽질했다..) 혹시라도 이와 같은 문제가 있는 사람이 있다면 이글을 보고 손쉽게 해결할 수 있었으면 좋겠다.
감사합니다 .. 덕분에 오래동안 삽질하던 부분을 해결했습니다 ㅠㅠ