스프링 개발 회고 (09.30)
2025. 10. 1. 09:47

오늘 프로젝트를 진행하면서 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;
    }
}

이렇게 하면:

  1. ID는 생성자에서 받지 않는다 → JPA가 자동으로 생성
  2. @Builder 패턴 사용 가능 → 가독성 좋은 객체 생성
  3. @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이 필요한 이유:

  1. 원자성(Atomicity): 리뷰 저장과 평점 업데이트가 모두 성공하거나 모두 실패해야 함
  2. 일관성(Consistency): 중간에 예외가 발생하면 모든 변경사항이 롤백됨
  3. 영속성 컨텍스트: 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는 컴파일러가 자동으로 다음을 생성한다:

  1. private final 필드 - 모든 필드가 불변
  2. public 생성자 - 모든 필드를 받는 생성자
  3. Getter 메서드 - 각 필드에 대한 접근자 (단, getContent()가 아니라 content() 형태)
  4. equals(), hashCode() - 모든 필드 기반 동등성 비교
  5. 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 사용 시 주의사항

  1. Getter 이름이 다르다
  2. // 일반 클래스: dto.getContent() // Record: dto.content()
  3. 불변이다
  4. record ReviewDto(String content, Integer rating) {} ReviewDto dto = new ReviewDto("맛있어요", 5); // dto.content = "변경"; // ❌ 컴파일 에러! 변경 불가
  5. Jackson이 잘 지원한다
  6. @RestController public class ReviewController { @PostMapping("/reviews") public ResponseEntity<?> createReview( @RequestBody ReviewCreateDto dto // ✅ Record도 자동 역직렬화됨! ) { // ... } }

Record는 언제 사용할까?

좋은 사용처:

  • DTO (Data Transfer Object)
  • API 요청/응답 객체
  • 설정 값을 담는 객체

피해야 할 곳:

  • JPA 엔티티 (Record는 불변이라 JPA가 관리하기 어려움)
  • 상속이 필요한 경우 (Record는 상속 불가)
  • 변경 가능한 데이터를 다루는 경우