예상 읽기 시간: 20~30분
Day 1에서는 Spring Boot 프로젝트의 큰 지도를 만들었습니다. Day 2에서는 프론트엔드와 백엔드 사이의 API 계약을 읽었고, Day 3에서는 잘못된 입력과 실패 상황을 어떻게 검증하고 에러 응답으로 돌려주는지 봤습니다.
오늘은 백엔드가 실제 데이터를 어디에 저장하고, 다시 어떻게 꺼내 오는지 봅니다.
프론트엔드에서 글 작성 버튼을 누르면 화면에는 단순히 "등록 완료"가 보일 수 있습니다. 하지만 백엔드 안쪽에서는 보통 이런 일이 일어납니다.
요청 DTO 받기
→ 검증하기
→ 현재 사용자/권한 확인하기
→ Entity 만들기
→ Repository로 DB에 저장하기
→ 저장된 결과를 응답 DTO로 바꾸기
→ 프론트엔드에 JSON으로 돌려주기
이 중에서 오늘 집중할 부분은 Entity와 Repository입니다.
AI가 만든 백엔드가 데이터를 올바른 테이블 구조로 저장하고, 필요한 조건으로 다시 조회하는가?
오늘 글을 읽고 나면 아래 질문에 답할 수 있어야 합니다.
Entity는 DTO와 무엇이 다른가?@Entity, @Id, @GeneratedValue, @Column은 무엇을 표현하는가?Repository는 왜 직접 SQL을 쓰지 않아도 DB와 대화할 수 있는가?findById, save, delete, findAll, findBy... 메서드는 어떻게 읽는가?처음에는 DB를 단순히 "데이터를 저장하는 곳"이라고 이해해도 됩니다. 하지만 AI가 만든 백엔드 코드를 리뷰할 때는 조금 더 구체적으로 봐야 합니다.
예를 들어 게시글 기능이 있다고 해 봅시다.
사용자가 입력한 데이터는 이런 모양일 수 있습니다.
{
"title": "오늘 배운 내용",
"content": "Controller와 Service 흐름을 봤다.",
"categoryId": 3
}
이 요청은 그대로 DB에 저장되지 않습니다. 백엔드는 요청 DTO를 받고, 서비스 규칙을 확인한 뒤, DB에 저장할 수 있는 객체로 바꿉니다. 그 객체가 보통 Entity입니다.
CreatePostRequest DTO
→ Post Entity
→ posts 테이블의 한 행(row)
여기서 중요한 구분이 있습니다.
DTO와 Entity를 헷갈리면 AI가 만든 코드를 볼 때 위험합니다. 요청 DTO는 사용자가 보낸 값만 담을 수 있지만, Entity는 DB의 저장 구조와 서비스 내부 규칙을 더 많이 담습니다.
예를 들어 게시글 Entity에는 요청에는 없던 값이 들어갈 수 있습니다.
id: DB가 붙여 주는 고유 번호author: 작성자 사용자createdAt: 생성 시각updatedAt: 수정 시각deleted: 삭제 여부viewCount: 조회 수프론트엔드는 이런 값을 직접 다 보내지 않습니다. 백엔드가 현재 로그인 사용자, 서버 시간, 기본값, DB 규칙을 이용해서 채웁니다.
AI가 만든 코드를 볼 때 첫 번째 질문은 이것입니다.
사용자가 보낸 값과 서버가 책임져야 하는 값이 제대로 나뉘어 있는가?
예를 들어 게시글 작성 요청 DTO에 authorId, createdAt, viewCount 같은 값을 프론트엔드가 마음대로 보내게 되어 있다면 의심해야 합니다. 작성자는 보통 로그인 세션이나 토큰에서 결정해야 하고, 생성 시각은 서버가 정해야 하며, 조회 수는 사용자가 조작하면 안 됩니다.
Spring Boot에서 JPA를 쓰는 프로젝트라면 Entity는 보통 이렇게 생겼습니다.
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
private LocalDateTime createdAt;
protected Post() {
처음 보면 어노테이션이 많아서 어렵게 느껴질 수 있습니다. 하지만 읽는 관점에서는 아래처럼 해석하면 됩니다.
@Entity
→ 이 클래스는 DB에 저장되는 대상이다.
@Table(name = "posts")
→ DB에서는 posts 테이블과 연결된다.
@Id
→ 이 필드는 각 행을 구분하는 고유 ID다.
@GeneratedValue
→ ID는 직접 입력하지 않고 DB/시스템이 자동으로 만든다.
@Column(nullable = false, length = 100)
→ 이 컬럼은 비어 있으면 안 되고 길이 제한이 있다.
문법을 외우는 것보다 중요한 것은 책임을 읽는 것입니다.
Post Entity는 게시글이라는 데이터의 장기 저장 모양입니다. API 요청 한 번을 처리하기 위해 잠깐 생기는 DTO보다 더 오래 살아남습니다. 그래서 Entity에는 서비스의 중요한 규칙이 반영됩니다.
AI가 만든 Entity를 볼 때는 아래를 확인하면 좋습니다.
id가 있는가?nullable = false 또는 생성자/비즈니스 로직으로 보호되는가?처음에는 "그냥 Entity를 API 응답으로 보내면 안 되나?"라고 생각할 수 있습니다. 작은 예제에서는 그렇게 해도 돌아갑니다. 하지만 실제 서비스에서는 위험해집니다.
예를 들어 User Entity가 있다고 해 봅시다.
@Entity
public class User {
@Id
private Long id;
private String email;
private String passwordHash;
private String nickname;
private String role;
}
이 Entity를 그대로 응답으로 보내면 passwordHash 같은 내부 값이 노출될 수 있습니다. 해시라고 해도 외부로 나가면 안 되는 값입니다.
그래서 응답 DTO를 따로 만듭니다.
public record UserProfileResponse(
Long id,
String email,
String nickname
) {
}
읽을 때는 이렇게 보면 됩니다.
Entity는 내부 저장 구조다.
Response DTO는 외부에 보여 줄 모양이다.
둘은 비슷해 보여도 책임이 다르다.
AI가 만든 코드에서 Entity를 Controller가 바로 반환하고 있다면 항상 의심해야 합니다.
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}
이 코드는 간단해 보이지만 위험할 수 있습니다.
더 안전한 방향은 보통 이렇습니다.
@GetMapping("/users/{id}")
public UserProfileResponse getUser(@PathVariable Long id) {
User user = userService.getUser(id);
return new UserProfileResponse(user.getId(), user.getEmail(), user.getNickname());
}
완벽한 정답 형태를 외울 필요는 없습니다. 리뷰할 때는 이렇게 질문하면 됩니다.
API로 나가도 되는 데이터만 DTO로 골라서 보내고 있는가?
Repository는 백엔드 코드가 DB와 대화하는 통로입니다.
JPA를 쓰는 Spring Boot 프로젝트에서는 보통 이런 인터페이스가 있습니다.
public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByAuthorId(Long authorId);
List<Post> findByTitleContaining(String keyword);
}
처음 보면 구현 코드가 없어서 이상하게 느껴질 수 있습니다. findByAuthorId의 본문이 없는데 어떻게 동작할까요?
Spring Data JPA는 메서드 이름을 읽어서 쿼리를 만들어 줍니다.
findByAuthorId(Long authorId)
→ authorId가 같은 Post 목록을 찾아라.
findByTitleContaining(String keyword)
→ title에 keyword가 포함된 Post 목록을 찾아라.
그리고 JpaRepository<Post, Long>을 상속하면 기본 메서드도 생깁니다.
save(entity): 저장하거나 수정한다findById(id): ID로 한 개를 찾는다findAll(): 전체를 찾는다delete(entity): 삭제한다existsById(id): 존재 여부를 확인한다count(): 개수를 센다AI가 만든 코드를 볼 때 Repository는 아주 중요한 단서입니다. 서비스가 어떤 조건으로 DB를 찾는지 거의 여기서 드러납니다.
예를 들어 게시글 상세 조회가 이렇게 되어 있다고 해 봅시다.
Post post = postRepository.findById(postId)
.orElseThrow(() -> new NotFoundException("게시글을 찾을 수 없습니다."));
이 코드는 postId만 맞으면 게시글을 찾습니다. 공개 게시글이라면 괜찮을 수 있습니다. 하지만 팀/방/프로젝트 안에 있는 게시글이라면 부족할 수 있습니다.
postId만 맞으면 다른 방의 글도 볼 수 있지 않은가?
현재 사용자가 접근할 수 있는 글인지 확인하는가?
그런 경우에는 조회 조건에 소유 범위가 들어가야 합니다.
Optional<Post> findByIdAndRoomId(Long postId, Long roomId);
또는 Service에서 조회 후 권한을 확인해야 합니다.
AI가 만든 코드에서 findById만 반복적으로 보인다면 무조건 틀린 것은 아닙니다. 하지만 "이 데이터가 누구의 것인지"가 중요한 기능에서는 반드시 한 번 더 봐야 합니다.
save는 단순 저장이 아니라 상태 변경이다Repository에서 가장 자주 보이는 메서드는 save입니다.
Post post = new Post(request.title(), request.content());
Post savedPost = postRepository.save(post);
읽을 때는 이렇게 보면 됩니다.
요청 DTO로부터 새 Entity를 만든다.
Repository가 DB에 저장한다.
저장된 Entity에는 id 같은 DB 생성 값이 붙을 수 있다.
주의할 점은 save가 새로 만들기만 하는 메서드가 아니라는 것입니다. 이미 존재하는 Entity를 수정할 때도 쓰일 수 있습니다.
Post post = postRepository.findById(id).orElseThrow();
post.changeTitle(request.title());
postRepository.save(post);
JPA에서는 트랜잭션 안에서 Entity를 바꾸면 save를 명시하지 않아도 변경이 반영되는 경우도 있습니다. 이 부분은 나중에 트랜잭션을 다룰 때 다시 보겠습니다.
오늘 단계에서 중요한 것은 이것입니다.
저장 전후로 서비스 규칙이 적용되는가?
게시글 작성이라면 아래를 봅니다.
AI가 만든 코드는 종종 "돌아가는 예제"를 빠르게 만듭니다. 하지만 실제 서비스에서는 저장 전 규칙이 빠지면 데이터가 쌓인 뒤에 고치기 어려워집니다.
프론트엔드에서 목록 화면이 비어 있거나, 다른 사용자의 데이터가 보이거나, 정렬이 이상하면 백엔드의 조회 조건을 의심해야 합니다.
예를 들어 내 게시글 목록 API가 있다고 해 봅시다.
@GetMapping("/me/posts")
public List<PostResponse> myPosts() {
return postService.getMyPosts();
}
Service 안쪽이 이렇게 되어 있다면 문제가 있습니다.
public List<PostResponse> getMyPosts() {
return postRepository.findAll().stream()
.map(PostResponse::from)
.toList();
}
findAll()은 전체 게시글을 가져옵니다. "내 게시글" API인데 전체를 가져온다면 권한 문제이자 기능 버그입니다.
더 적절한 흐름은 보통 이렇습니다.
public List<PostResponse> getMyPosts(Long currentUserId) {
return postRepository.findByAuthorIdOrderByCreatedAtDesc(currentUserId)
.stream()
.map(PostResponse::from)
.toList();
}
읽을 때는 메서드 이름에서 조건을 찾습니다.
findByAuthorId
→ 작성자 기준으로 필터링한다.
OrderByCreatedAtDesc
→ 생성일 기준 최신순으로 정렬한다.
AI가 만든 Repository 메서드 이름은 길어질 수 있습니다.
List<Post> findByRoomIdAndDeletedFalseOrderByCreatedAtDesc(Long roomId);
길어도 쪼개서 읽으면 됩니다.
findBy
RoomId
And
DeletedFalse
OrderBy
CreatedAt
Desc
→ 특정 방의, 삭제되지 않은 글을, 생성일 최신순으로 찾는다.
이렇게 읽으면 프론트엔드 요구사항과 비교할 수 있습니다.
프론트엔드에서 "왜 이 카드가 여기에 보이지?"라는 문제가 생겼을 때, Repository 메서드 이름만 봐도 원인을 좁힐 수 있습니다.
DB에는 데이터 사이의 관계가 있습니다.
JPA Entity에서는 이런 관계가 어노테이션으로 표현됩니다.
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id", nullable = false)
private User author;
}
처음에는 @ManyToOne, @OneToMany, fetch = FetchType.LAZY 같은 말을 전부 외우지 않아도 됩니다. 대신 문장으로 바꿔 읽어 보세요.
Post many to one User
→ 여러 게시글이 한 사용자에 속할 수 있다.
@JoinColumn(name = "author_id")
→ posts 테이블에는 author_id라는 컬럼으로 사용자를 가리킨다.
AI 코드 리뷰에서는 관계의 문법보다 방향과 노출을 먼저 봅니다.
예를 들어 User가 List<Post>를 가지고, Post가 다시 User를 가지고 있는데 둘 다 그대로 JSON으로 반환하면 문제가 생길 수 있습니다.
User → posts → author → posts → author → ...
그래서 관계가 있는 Entity는 특히 DTO 변환이 중요합니다.
public record PostResponse(
Long id,
String title,
String authorNickname
) {
public static PostResponse from(Post post) {
return new PostResponse(
post.getId(),
post.getTitle(),
post.getAuthor().getNickname()
);
}
}
이 응답은 필요한 작성자 닉네임만 골라서 내보냅니다. Entity 전체를 그대로 내보내는 것보다 안전합니다.
삭제 API를 볼 때도 Repository 흐름을 확인해야 합니다.
postRepository.delete(post);
이 코드는 DB에서 실제로 행을 삭제할 수 있습니다. 이를 물리 삭제라고 부를 수 있습니다.
하지만 서비스에 따라서는 글을 완전히 지우지 않고 deleted = true처럼 표시만 하는 경우가 많습니다. 이를 소프트 삭제 또는 논리 삭제라고 부릅니다.
post.markDeleted();
둘 중 무엇이 항상 정답인 것은 아닙니다. 중요한 것은 서비스 요구사항과 맞는지입니다.
물리 삭제가 맞을 수 있는 경우:
소프트 삭제가 맞을 수 있는 경우:
AI가 만든 삭제 코드를 볼 때는 아래를 확인합니다.
특히 소프트 삭제를 쓰기로 했는데 목록 Repository가 findAll()을 쓰면 삭제된 글이 다시 화면에 나올 수 있습니다.
List<Post> findByDeletedFalseOrderByCreatedAtDesc();
이런 조건이 들어가야 하는지 확인해야 합니다.
AI가 백엔드 코드를 빠르게 만들 때 자주 보이는 위험 신호를 정리해 보겠습니다.
findAll()이 너무 쉽게 쓰인다관리자 화면이나 작은 내부 기능이 아니라면 findAll()은 조심해야 합니다.
return postRepository.findAll();
질문해야 할 것:
orElseThrow()는 있는데 에러 의미가 없다Post post = postRepository.findById(id).orElseThrow();
이 코드는 데이터가 없을 때 예외를 던지지만, 어떤 HTTP 응답으로 바뀔지 명확하지 않습니다. Day 3에서 본 것처럼 전역 예외 처리와 연결되어야 합니다.
더 읽기 좋은 형태는 이런 식입니다.
Post post = postRepository.findById(id)
.orElseThrow(() -> new NotFoundException("게시글을 찾을 수 없습니다."));
그리고 NotFoundException이 404 응답으로 바뀌는지 확인해야 합니다.
Post post = new Post(request.title(), request.content(), request.authorId());
작성자 ID를 요청에서 받아 그대로 저장하면 사용자가 다른 사람 ID로 글을 만들 수 있습니다. 보통 작성자는 현재 로그인 사용자에서 가져와야 합니다.
User author = currentUserProvider.getCurrentUser();
Post post = Post.create(request.title(), request.content(), author);
인증은 뒤에서 더 자세히 다루겠지만, DB 저장 흐름에서도 이미 의심할 수 있습니다.
return postRepository.save(post);
저장된 Entity를 그대로 반환하면 내부 필드와 관계가 노출될 수 있습니다. 응답 DTO로 바꾸는지 확인합니다.
Post saved = postRepository.save(post);
return PostResponse.from(saved);
목록 API에서 정렬이 없으면 DB가 어떤 순서로 줄지 보장하기 어렵습니다.
List<Post> findByAuthorId(Long authorId);
프론트엔드가 최신순을 기대한다면 명시해야 합니다.
List<Post> findByAuthorIdOrderByCreatedAtDesc(Long authorId);
데이터가 많아질 기능이라면 Pageable 같은 페이지네이션도 필요할 수 있습니다. 지금은 문법을 몰라도 됩니다. 다만 "목록인데 무한히 전부 가져오는가?"라는 질문은 할 수 있어야 합니다.
AI가 만든 기능을 리뷰할 때는 파일을 무작정 열기보다 한 요청을 기준으로 따라가는 것이 좋습니다.
예를 들어 게시글 작성 기능이라면 이렇게 봅니다.
@PostMapping("/posts")
public PostResponse createPost(@Valid @RequestBody CreatePostRequest request) {
return postService.createPost(request);
}
확인할 것:
@Valid가 붙어 있는가?public PostResponse createPost(CreatePostRequest request) {
Post post = new Post(request.title(), request.content());
Post saved = postRepository.save(post);
return PostResponse.from(saved);
}
확인할 것:
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
}
확인할 것:
public interface PostRepository extends JpaRepository<Post, Long> {
}
확인할 것:
findAll()로 때우고 있지 않은가?이 루틴은 문법을 몰라도 쓸 수 있습니다. 핵심은 "요청 하나가 어떤 파일을 지나 DB에 닿는지"를 따라가는 것입니다.
진규가 프론트엔드 작업을 하다가 백엔드 코드를 리뷰해야 하는 상황을 생각해 봅시다. 화면에서 이상한 일이 생겼을 때 DB 흐름을 이렇게 의심할 수 있습니다.
가능한 백엔드 원인:
roomId, userId, categoryId 필터가 잘못됐다.볼 파일:
findBy... 메서드가능한 백엔드 원인:
findAll()을 사용한다.userId를 그대로 믿는다.볼 파일:
가능한 백엔드 원인:
deleted = true로 바꾸지만 목록 API가 deleted = false 조건을 안 쓴다.볼 파일:
가능한 백엔드 원인:
authorId를 그대로 사용한다.볼 파일:
오늘은 Entity와 Repository를 처음 읽는 날입니다. 아래는 지금 당장 완벽히 외우지 않아도 됩니다.
이 내용들은 중요하지만, 처음부터 다 외우려 하면 오히려 흐름을 놓칩니다. 지금 필요한 것은 AI가 만든 코드를 보고 큰 위험을 감지하는 능력입니다.
오늘 기준으로는 아래 정도만 잡으면 충분합니다.
Entity는 DB 저장 구조다.
DTO와 Entity는 분리해야 한다.
Repository는 DB 조회/저장 통로다.
조회 조건은 화면에 보이는 데이터와 직접 연결된다.
findAll, Entity 직접 반환, 권한 없는 findById는 의심 포인트다.
AI에게 Spring Boot 코드를 만들게 할 때도 요구를 구체적으로 주면 더 안전합니다.
나쁜 요청:
게시글 CRUD 만들어줘.
조금 더 좋은 요청:
Spring Boot로 게시글 CRUD를 만들어줘.
Controller, Service, Repository, Entity, DTO를 분리해줘.
Entity를 API 응답으로 직접 반환하지 말고 Response DTO로 변환해줘.
작성자는 요청 바디에서 받지 말고 현재 로그인 사용자에서 가져오는 구조로 만들어줘.
목록 조회는 삭제되지 않은 게시글만 최신순으로 반환하게 해줘.
데이터가 없으면 404 에러 응답으로 처리해줘.
더 좋은 리뷰 요청:
이 코드에서 Repository 조회 조건이 프론트엔드 요구사항과 맞는지 검토해줘.
findAll 사용, 권한 없는 findById, Entity 직접 반환, 삭제된 데이터 노출 가능성을 우선 확인해줘.
수정이 필요하면 어떤 파일을 바꿔야 하는지 Controller → Service → Repository 흐름으로 설명해줘.
이렇게 요청하면 AI가 단순히 코드를 늘리는 대신 위험 지점을 기준으로 설명할 가능성이 높아집니다.
AI가 만든 Spring Boot DB 접근 코드를 볼 때 아래 체크리스트를 사용해 보세요.
@Entity가 붙은 클래스가 어떤 테이블을 의미하는지 알 수 있다.id 필드가 있고 자동 생성 전략이 자연스럽다.@ManyToOne 등으로 표현되어 있거나 명시적으로 ID 검증을 한다.findAll()만 쓰지 않는다.오늘은 백엔드의 DB 흐름을 Entity와 Repository 중심으로 읽었습니다.
처음부터 JPA를 깊게 이해할 필요는 없습니다. 대신 AI가 만든 코드에서 아래 흐름을 따라갈 수 있으면 됩니다.
Controller가 요청을 받는다.
Service가 규칙을 판단한다.
Entity가 DB에 저장될 모양을 만든다.
Repository가 저장하거나 조회한다.
Response DTO가 프론트엔드에 필요한 모양만 돌려준다.
그리고 DB 코드를 볼 때 가장 중요한 질문은 이것입니다.
이 데이터는 누구의 것이고, 어떤 조건으로 저장/조회되어야 하는가?
이 질문을 붙잡으면 findAll()이 위험한지, findById만으로 충분한지, Entity를 그대로 반환해도 되는지, 삭제된 데이터가 다시 보일 수 있는지 판단할 수 있습니다.
다음 Day에서는 트랜잭션을 다루겠습니다. 사용자가 결제하거나, 게시글과 첨부파일을 함께 저장하거나, 여러 테이블을 한 번에 바꿀 때 "중간에 하나만 성공하면 어떻게 되는가?"라는 문제를 볼 예정입니다.