CRUD 다음에 필요한 생각
지난 장에서는 Post 하나를 데이터베이스에 저장했습니다. 이제 실제 게시판에 조금 더 가까워져 보겠습니다. 게시판에는 글만 있지 않습니다. 글을 쓰는 회원이 있고, 글 아래에는 댓글이 달립니다.
처음에는 모든 정보를 Post 클래스 하나에 넣고 싶을 수 있습니다.
private String writerName;
private String commentContent;하지만 이렇게 만들면 글 하나에 댓글이 여러 개 달릴 때 구조가 금방 어색해집니다. 댓글마다 작성자도 다를 수 있습니다. 그래서 실제 서비스에서는 중요한 개념을 각각의 클래스로 나눕니다. 이것을 도메인 모델링이라고 합니다. 도메인은 애플리케이션이 다루는 핵심 대상입니다. 게시판에서는 회원, 게시글, 댓글이 도메인입니다.
비유하면 도메인 모델링은 서류함을 정리하는 일과 같습니다. 회원 서류, 게시글 서류, 댓글 서류를 한 파일에 뒤섞어 넣지 않고 각각의 서류함에 나누어 둡니다. 그리고 "이 게시글은 어느 회원이 썼는가", "이 댓글은 어느 게시글에 달렸는가"를 연결합니다.
AI 생성 이미지
회원, 게시글, 댓글로 나누기
먼저 회원 Entity를 만듭니다.
package com.example.board.domain;
import jakarta.persistence.*;
@Entity
@Table(name = "members")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String name;
protected Member() {
}
public Member(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}Member는 회원 테이블과 연결됩니다. 회원 한 명은 여러 게시글을 쓸 수 있습니다. 반대로 게시글 하나는 작성자 한 명을 가집니다. 이 관계는 게시글 쪽에서 다음처럼 표현합니다.
package com.example.board.domain;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member author;
@Column(nullable = false, length = 100)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
protected Post() {
}
public Post(Member author, String title, String content) {
this.author = author;
this.title = title;
this.content = content;
}
public void addComment(Comment comment) {
comments.add(comment);
comment.connectPost(this);
}
public Long getId() { return id; }
public Member getAuthor() { return author; }
public String getTitle() { return title; }
public String getContent() { return content; }
public List<Comment> getComments() { return Collections.unmodifiableList(comments); }
}위 코드에는 새 개념이 몇 가지 있습니다.
@ManyToOne은 "여러 게시글이 한 회원을 가리킨다"는 뜻입니다. 게시글 입장에서 보면 작성자는 한 명입니다. 데이터베이스에서는 posts 테이블에 member_id 컬럼이 생깁니다.
@JoinColumn(name = "member_id")는 외래 키 컬럼 이름을 정합니다. 외래 키는 다른 테이블의 행을 가리키는 값입니다. 게시글의 member_id는 members 테이블의 특정 회원을 가리킵니다.
@OneToMany(mappedBy = "post")는 "게시글 하나에 댓글이 여러 개 있다"는 뜻입니다. 여기서 mappedBy = "post"가 중요합니다. 실제 외래 키는 댓글 테이블에 있습니다. 그래서 Post.comments는 관계를 보여 주는 거울이고, 실제 연결의 주인은 Comment.post입니다.
getComments()에서 Collections.unmodifiableList(comments)를 반환한 것도 눈여겨볼 만합니다. 외부 코드가 post.getComments().add(comment)처럼 컬렉션을 직접 바꾸지 못하게 하려는 의도입니다. 댓글을 추가할 때는 아래에서 볼 addComment() 메서드를 사용하면 됩니다.
댓글 Entity는 다음처럼 만듭니다.
package com.example.board.domain;
import jakarta.persistence.*;
@Entity
@Table(name = "comments")
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private Post post;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member author;
@Column(nullable = false, length = 500)
private String content;
protected Comment() {
}
public Comment(Member author, String content) {
this.author = author;
this.content = content;
}
void connectPost(Post post) {
this.post = post;
}
public Long getId() { return id; }
public String getContent() { return content; }
public Member getAuthor() { return author; }
}중요한 점은 댓글을 게시글에 추가할 때 post.getComments().add(comment)처럼 컬렉션만 직접 수정하지 않는 것입니다. 그렇게 하면 자바 리스트에는 들어가지만, 댓글의 post 필드가 비어 있을 수 있습니다. 그러면 데이터베이스에 저장될 때 comments.post_id 값이 제대로 정해지지 않을 수 있습니다. 그래서 Post에 addComment() 같은 메서드를 만들고 양쪽 값을 함께 맞춥니다.
일대다와 다대일을 볼 때의 기준
관계 매핑이 헷갈릴 때는 "외래 키가 어느 테이블에 들어가는가"를 먼저 봅니다.
게시글 여러 개는 회원 한 명을 가리킵니다. 외래 키는
posts.member_id입니다. 그래서Post -> Member는@ManyToOne입니다.댓글 여러 개는 게시글 한 개를 가리킵니다. 외래 키는
comments.post_id입니다. 그래서Comment -> Post도@ManyToOne입니다.게시글 하나에서 댓글 목록을 보고 싶다면 반대 방향에
@OneToMany(mappedBy = "post")를 둡니다.
처음에는 @ManyToOne을 먼저 정확히 이해하는 것이 좋습니다. 데이터베이스의 외래 키가 있는 쪽이 보통 더 중요합니다. @OneToMany는 편하게 목록을 탐색하기 위해 추가한다고 생각하면 됩니다.
Repository는 Entity마다 하나씩 만들 수 있습니다.
public interface MemberRepository extends JpaRepository<Member, Long> {
}
public interface PostRepository extends JpaRepository<Post, Long> {
}
public interface CommentRepository extends JpaRepository<Comment, Long> {
}구현 클래스는 직접 만들지 않습니다. 이전 장처럼 Spring Data JPA가 기본 구현을 만들어 줍니다.
지연 로딩과 즉시 로딩
관계가 생기면 JPA는 선택해야 합니다. 게시글을 조회할 때 작성자와 댓글까지 바로 가져올까요, 아니면 필요할 때 가져올까요?
즉시 로딩은 음식을 주문하자마자 반찬, 후식, 음료까지 한 번에 모두 가져오는 방식입니다. 당장은 편하지만 필요 없는 것까지 가져올 수 있습니다.
지연 로딩은 먼저 주문한 음식만 가져오고, 반찬이 필요하다고 말할 때 추가로 가져오는 방식입니다. 처음 조회가 가볍습니다. 대신 나중에 관계 데이터를 사용할 때 추가 조회가 발생합니다.
@ManyToOne(fetch = FetchType.LAZY)
private Member author;위 설정은 게시글을 조회할 때 작성자를 바로 가져오지 말고, post.getAuthor()를 실제로 사용할 때 가져오라는 뜻입니다.
초보자에게는 한 가지 규칙을 권합니다. 관계 매핑에는 가능하면 fetch = FetchType.LAZY를 명시하세요. 특히 @ManyToOne은 기본값이 즉시 로딩이라서, 명시하지 않으면 예상보다 많은 SQL이 실행될 수 있습니다.
다만 지연 로딩에는 주의할 점이 있습니다. 트랜잭션이 끝난 뒤에 지연 로딩 값을 꺼내려고 하면 JPA가 데이터베이스에 다시 접근할 통로를 잃을 수 있습니다. 이때 could not initialize proxy - no Session 같은 오류가 날 수 있습니다.1
그래서 Entity를 컨트롤러까지 그대로 보내기보다, 서비스 안에서 필요한 값을 DTO로 바꿔 반환하는 편이 안전합니다.
@Transactional(readOnly = true)
public PostResponse findById(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return PostResponse.from(post);
}PostResponse.from(post) 안에서 작성자 이름이나 댓글 수를 읽는다면, 트랜잭션 안에서 처리됩니다. 컨트롤러는 이미 완성된 DTO만 응답하면 됩니다.
다만 DTO로 바꾼다고 해서 모든 성능 문제가 자동으로 해결되는 것은 아닙니다. 예를 들어 게시글 목록 20개를 조회한 뒤 각 게시글의 작성자 이름을 하나씩 읽으면 추가 SQL이 여러 번 실행될 수 있습니다. 지금은 "트랜잭션 안에서 필요한 값을 DTO로 바꾼다"는 기본 흐름을 익히고, 더 효율적인 조회 방법은 이후 단계에서 조금씩 다루면 됩니다.
트랜잭션은 하나의 작업 단위다
이제 저장 흐름을 안전하게 만들어 보겠습니다. 트랜잭션은 여러 데이터 변경을 하나의 작업 단위로 묶는 기능입니다. 쉽게 말해 "전부 성공하거나, 전부 취소되게 하는 약속"입니다.
예를 들어 게시글을 만들면서 첫 댓글도 함께 저장한다고 해 보겠습니다.
회원을 조회한다.
게시글을 저장한다.
첫 댓글을 저장한다.
중간에 오류가 나면 게시글과 댓글 저장을 모두 취소한다.
이때 서비스 메서드에 @Transactional을 붙입니다.
@Service
public class PostService {
private final MemberRepository memberRepository;
private final PostRepository postRepository;
public PostService(MemberRepository memberRepository, PostRepository postRepository) {
this.memberRepository = memberRepository;
this.postRepository = postRepository;
}
@Transactional
public PostResponse createWithFirstComment(Long memberId, PostCreateRequest request) {
Member author = memberRepository.findById(memberId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
Post post = new Post(author, request.title(), request.content());
post.addComment(new Comment(author, "첫 댓글입니다."));
Post savedPost = postRepository.save(post);
if (request.title().contains("오류")) {
throw new IllegalStateException("저장 중 오류 발생");
}
return PostResponse.from(savedPost);
}
}여기서 CommentRepository를 따로 호출하지 않았는데도 댓글이 함께 저장됩니다. Post.comments에 cascade = CascadeType.ALL을 설정했기 때문입니다. 즉, Post를 저장할 때 그 안에 연결된 새 Comment도 함께 저장 대상이 됩니다. 물론 이때도 앞에서 본 것처럼 addComment()로 Post와 Comment의 관계를 모두 맞춰 두어야 합니다.
@Transactional이 붙은 메서드가 시작되면 Spring은 트랜잭션을 엽니다. 메서드가 정상적으로 끝나면 커밋합니다. 커밋은 변경 내용을 데이터베이스에 확정하는 일입니다.
반대로 메서드 실행 중 RuntimeException이 밖으로 던져지면 롤백합니다. 롤백은 변경 내용을 없던 일로 되돌리는 일입니다. Spring의 기본 규칙은 RuntimeException과 Error가 발생하면 롤백하고, 체크 예외는 기본적으로 롤백하지 않습니다. 체크 예외까지 롤백하고 싶다면 @Transactional(rollbackFor = SomeException.class)처럼 설정할 수 있습니다.2
위 예제에서 제목에 오류가 포함되면 save()를 호출한 뒤 예외가 발생합니다. 하지만 트랜잭션 안에서 발생한 예외이므로 게시글과 댓글 저장은 함께 취소됩니다. 실제 SQL 실행 시점은 JPA의 내부 동작에 따라 커밋 직전일 수도 있지만, 중요한 핵심은 같습니다. 하나의 트랜잭션 안에서 처리한 변경은 성공하면 함께 확정되고, 실패하면 함께 되돌아갑니다.
트랜잭션을 붙일 위치
@Transactional은 보통 컨트롤러보다 서비스에 붙입니다. 컨트롤러는 HTTP 요청과 응답을 다루는 입구입니다. 서비스는 "게시글을 만들고 댓글을 붙인다" 같은 실제 작업 흐름을 담당합니다. 트랜잭션은 작업 흐름의 경계에 두는 것이 자연스럽습니다.
조회 전용 메서드에는 다음처럼 readOnly = true를 붙일 수 있습니다.
@Transactional(readOnly = true)
public List<PostResponse> findAll() {
return postRepository.findAll()
.stream()
.map(PostResponse::from)
.toList();
}readOnly = true는 "이 메서드는 데이터를 바꾸지 않는다"는 의도를 드러냅니다. 성능 최적화에 도움이 될 수 있고, 코드를 읽는 사람에게도 의미가 분명합니다.
자주 만나는 실수
@OneToMany만 설정하고 @ManyToOne을 빼면 관계의 주인이 어디인지 헷갈리기 쉽고, 원하는 외래 키 구조가 나오지 않을 수 있습니다. 처음에는 외래 키가 있는 @ManyToOne부터 작성하세요.
양방향 관계에서 한쪽만 값을 넣으면 데이터가 예상대로 저장되지 않을 수 있습니다. addComment() 같은 메서드로 양쪽 값을 함께 맞추는 습관을 들이세요.
Entity를 그대로 JSON 응답으로 반환하면 회원, 게시글, 댓글이 서로를 계속 참조하면서 순환 문제가 생길 수 있습니다. API 응답에는 DTO를 사용하세요.
지연 로딩 오류가 난다면 트랜잭션 밖에서 관계 데이터를 읽고 있는지 확인하세요. 서비스 안에서 DTO로 변환하거나, 필요한 데이터를 조회하는 전용 쿼리를 나중에 사용할 수 있습니다.
예외를 try-catch로 잡고 밖으로 다시 던지지 않으면 Spring은 실패를 모른 채 커밋할 수 있습니다. 롤백이 필요하다면 예외를 삼키지 말고 밖으로 전달하세요.
이번 장에서 얻은 것
이번 장에서는 게시판을 Member, Post, Comment 도메인으로 나누었습니다. 그리고 @ManyToOne, @OneToMany, @JoinColumn, mappedBy로 Entity 사이의 관계를 표현했습니다.
또 지연 로딩과 즉시 로딩의 차이를 배웠습니다. 처음에는 관계마다 fetch = FetchType.LAZY를 명시하고, 서비스 안에서 DTO로 변환하는 흐름을 권장합니다.
마지막으로 @Transactional로 여러 저장 작업을 하나의 단위로 묶었습니다. 이제 애플리케이션은 단순 CRUD를 넘어, 데이터가 중간에 깨지지 않도록 일관성 있게 저장하는 구조에 가까워졌습니다.
다음 장에서는 사용자가 보낸 값이 비어 있거나 형식에 맞지 않을 때 어떻게 검증하고, 오류를 API 응답으로 깔끔하게 돌려줄지 다룹니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.