오늘 프로젝트를 진행하면서 JPA 엔티티 설계와 Spring 개발 패턴에 대해 새롭게 알게 된 점들을 정리해본다.
1. @GeneratedValue와 생성자 패턴
문제 상황
JPA 엔티티에서 ID를 자동 생성하도록 설정할 때 다음과 같이 작성한다:
@Entity
public class Review {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
private Integer rating;
// ... 기타 필드들
}
여기서 GenerationType.IDENTITY는 데이터베이스의 AUTO_INCREMENT를 사용해 ID를 자동으로 생성한다는 의미다.
왜 @AllArgsConstructor를 쓰면 안 될까?
처음에는 Lombok의 @AllArgsConstructor를 사용하면 편하지 않을까 생각했다. 하지만 문제가 있다.
@Entity
@AllArgsConstructor // ❌ 이렇게 하면 안 됨!
public class Review {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
private Integer rating;
}
@AllArgsConstructor는 모든 필드를 매개변수로 받는 생성자를 만든다. 즉, 다음과 같은 생성자가 생성된다:
public Review(Long id, String content, Integer rating) {
this.id = id;
this.content = content;
this.rating = rating;
}
그런데 ID는 데이터베이스에서 자동으로 생성되어야 하는데, 생성자로 직접 ID를 넣을 수 있게 되면서 의도하지 않은 사용이 가능해진다.
올바른 방법: ID를 제외한 생성자 + @Builder
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Review {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
private Integer rating;
private String userId;
@Builder
public Review(String content, Integer rating, String userId) {
this.content = content;
this.rating = rating;
this.userId = userId;
}
}
이렇게 하면:
- ID는 생성자에서 받지 않는다 → JPA가 자동으로 생성
- @Builder 패턴 사용 가능 → 가독성 좋은 객체 생성
- @NoArgsConstructor(PROTECTED) → JPA가 리플렉션으로 사용할 기본 생성자 제공
// 사용 예시
Review review = Review.builder()
.content("너무 맛있어요!")
.rating(5)
.userId("user01")
.build(); // ID는 없음! DB에 저장될 때 자동 생성됨
2. @Transactional은 언제 써야 할까?
기본 원칙
Service 계층에서 @Transactional의 사용 기준은 간단하다:
- 데이터를 변경하는 작업(CUD) → @Transactional 필요 ✅
- 데이터를 조회만 하는 작업(R) → @Transactional(readOnly = true) 권장 ✅
왜 변경 작업에 필요할까?
@Service
@RequiredArgsConstructor
public class ReviewService {
private final ReviewRepository reviewRepository;
private final StoreRepository storeRepository;
@Transactional // ✅ 필수!
public void createReview(ReviewCreateDto dto) {
Store store = storeRepository.findById(dto.storeId())
.orElseThrow(() -> new EntityNotFoundException("가게를 찾을 수 없습니다"));
Review review = Review.builder()
.content(dto.content())
.rating(dto.rating())
.storeId(store.getId())
.build();
reviewRepository.save(review);
// 가게의 평균 평점도 업데이트
store.updateAverageRating();
}
}
@Transactional이 필요한 이유:
- 원자성(Atomicity): 리뷰 저장과 평점 업데이트가 모두 성공하거나 모두 실패해야 함
- 일관성(Consistency): 중간에 예외가 발생하면 모든 변경사항이 롤백됨
- 영속성 컨텍스트: JPA의 변경 감지(Dirty Checking)가 작동함
조회 작업은?
@Transactional(readOnly = true) // ✅ 성능 최적화
public ReviewResponseDto getReview(Long reviewId) {
Review review = reviewRepository.findById(reviewId)
.orElseThrow(() -> new EntityNotFoundException("리뷰를 찾을 수 없습니다"));
return ReviewResponseDto.from(review);
}
조회 전용 메서드에 @Transactional(readOnly = true)를 붙이면:
- 영속성 컨텍스트를 플러시하지 않음 → 성능 향상
- 데이터베이스에게 읽기 전용 힌트 제공 → 최적화 가능
- 실수로 데이터 변경하는 것 방지 → 안전성
정리
작업 유형 @Transactional 이유
| 생성(Create) | ✅ 필수 | 데이터 변경 + 무결성 보장 |
| 수정(Update) | ✅ 필수 | 데이터 변경 + 무결성 보장 |
| 삭제(Delete) | ✅ 필수 | 데이터 변경 + 무결성 보장 |
| 조회(Read) | readOnly=true 권장 | 성능 최적화 |
3. DTO를 Record로 만들어보니...
Record란?
Java 14에서 도입된 Record는 불변 데이터를 간결하게 표현하기 위한 특별한 클래스다.
기존 DTO vs Record
기존 방식:
@Getter
@AllArgsConstructor
public class ReviewCreateDto {
private String content;
private Integer rating;
private Long storeId;
}
Record 방식:
public record ReviewCreateDto(
String content,
Integer rating,
Long storeId
) {}
훨씬 간결하다! 그런데 이게 어떻게 동작하는 걸까?
Record가 자동으로 만들어주는 것들
Record는 컴파일러가 자동으로 다음을 생성한다:
- private final 필드 - 모든 필드가 불변
- public 생성자 - 모든 필드를 받는 생성자
- Getter 메서드 - 각 필드에 대한 접근자 (단, getContent()가 아니라 content() 형태)
- equals(), hashCode() - 모든 필드 기반 동등성 비교
- toString() - 보기 좋은 문자열 표현
// 이 Record는...
public record ReviewCreateDto(String content, Integer rating, Long storeId) {}
// 컴파일러가 다음과 같이 변환
public final class ReviewCreateDto {
private final String content;
private final Integer rating;
private final Long storeId;
public ReviewCreateDto(String content, Integer rating, Long storeId) {
this.content = content;
this.rating = rating;
this.storeId = storeId;
}
public String content() { return content; }
public Integer rating() { return rating; }
public Long storeId() { return storeId; }
// equals, hashCode, toString도 자동 생성
}
Record 사용 시 주의사항
- Getter 이름이 다르다
- // 일반 클래스: dto.getContent() // Record: dto.content()
- 불변이다
- record ReviewDto(String content, Integer rating) {} ReviewDto dto = new ReviewDto("맛있어요", 5); // dto.content = "변경"; // ❌ 컴파일 에러! 변경 불가
- Jackson이 잘 지원한다
- @RestController public class ReviewController { @PostMapping("/reviews") public ResponseEntity<?> createReview( @RequestBody ReviewCreateDto dto // ✅ Record도 자동 역직렬화됨! ) { // ... } }
Record는 언제 사용할까?
✅ 좋은 사용처:
- DTO (Data Transfer Object)
- API 요청/응답 객체
- 설정 값을 담는 객체
❌ 피해야 할 곳:
- JPA 엔티티 (Record는 불변이라 JPA가 관리하기 어려움)
- 상속이 필요한 경우 (Record는 상속 불가)
- 변경 가능한 데이터를 다루는 경우
'👍코드 회고' 카테고리의 다른 글
| 장바구니 코드 속 store_id는 어디에 둘까? (10.15) (0) | 2025.10.15 |
|---|---|
| 서비스단 깔끔하게 하기 (10.02) (0) | 2025.10.03 |
| 스프링 개발 회고 (10.01) (0) | 2025.10.01 |
| 프로젝트 설계 회고 (09.29) (0) | 2025.09.29 |
| ERD 설계 회고 (09.27) (0) | 2025.09.27 |