co-cherry
ERD + DB + JPA 시작 본문
*UMC 워크북 내용을 일부 참고하였다.
기능 목록 확정
게시판 기능을 구현하기 위한 기능 목록을 작성하였다.
(후에 이미지 추가 및 댓글 부분 추가 예정)
- 게시글 생성
- 게시글 수정
- 게시글 삭제
- 게시글 단건 조회 (상세 보기)
- 게시글 목록 조회 (페이지, 최신순)
ERD 작성
위 게시판 기능을 기준으로 User와 Post 엔티티를 최소로 정의하였다.
Enum의 사용 예시를 보여 주기 위해 부가적으로 필드를 추가했지만...
한 명의 회원(1)은 여러 개의 게시글(N)을 작성할 수 있으므로 User와 Post는 1:N의 관계로 설정하였다.
또한, Post 엔티티는 작성자의 식별을 위해 user_id를 외래키로 가진다.

https://www.erdcloud.com/d/hFup7EsMsQsaM3cYJ
springboot-study
Draw ERD with your team members. All states are shared in real time. And it's FREE. Database modeling tool.
www.erdcloud.com
위 링크를 통해 직접 작성한 ERD를 볼 수 있다.
Docker 로 DB 실행 & DB 연결 설정
https://hd301.tistory.com/entry/101
도커(Docker)로 MySQL 컨테이너 띄우기
세 줄 요약- 도커 데스크탑을 통해 손쉽게 도커 컨테이너를 관리할 수 있다.- WSL2에 도커 엔진을 설치하면 도커 CLI 클라이언트를 통해 도커 데스크탑 없이도 컨테이너를 생성할 수 있다.- 도커 컨
hd301.tistory.com
위 링크를 참고하면 쉽게 따라할 수 있다.


연결에 성공하면 다음과 같은 창을 볼 수 있다.
나의 경우에는 로컬 환경에서 기존 MySQL이 3306 포트를 사용하고 있었기 때문에, Docker 컨테이너에서는 포트 충돌을 방지하기 위해 3307 포트를 사용하였다.
User, Post 엔티티 작성

위 ERD를 바탕으로 엔티티를 작성했다.
User.java
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Table(name = "users")
@EntityListeners(AuditingEntityListener.class)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
@Column(name = "nickname", nullable = false)
private String nickname;
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "password", nullable = false)
private String password;
@Column(name = "gender")
@Enumerated(EnumType.STRING)
private Gender gender;
@Column(name = "phone_number")
private String phoneNumber;
@CreatedDate
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}
어노테이션
- @Entity 클래스가 DB 테이블과 연결되는 클래스라고 JPA에게 알려주는 역할
- @Getter 모든 필드에 대해 getter 메서드 자동 구현 (예: getNickname())
- @NoArgsConstructor(access = AccessLevel.PROTECTED) 매개변수 없는 기본 생성자 생성
- JPA 엔티티는 내부적으로 프록시 생성을 위해 기본 생성자 필요 → 직접 호출을 막기 위해 PROTECTED 설정
- @AllArgsConstructor(access = AccessLevel.PRIVATE) 모든 필드를 매개변수로 받는 생성자 생성
- 객체 생성 방식을 builder()를 통해서만 구현하기 위해서 PRIVATE 설정
- @Builder Lombok의 빌더 패턴 어노테이션으로 객체 생성 시 가독성을 높이고 필요한 값을 명확하게 넣을 수 있음
- @Table(name = "") 엔티티가 매핑될 실제 테이블 이름을 지정
- @EntityListeners(AuditingEntityListener.class) Auditing 기능을 사용하기 위한 설정으로 createdAt과 updatedAt 자동 관리 가능
Q. 왜 Table(name = "user")가 아닌 Table(name = "users")를 했는지?
MySQL에서 user는 예약어로, 사용 시 충돌 가능성이 있어 이를 피하기 위해 users를 사용했다.
https://dev.mysql.com/doc/refman/8.4/en/keywords.html?utm_source=chatgpt.com (자세한 내용은 링크 참조)
MySQL :: MySQL 8.4 Reference Manual :: 11.3 Keywords and Reserved Words
11.3 Keywords and Reserved Words Keywords are words that have significance in SQL. Certain keywords, such as SELECT, DELETE, or BIGINT, are reserved and require special treatment for use as identifiers such as table and column names. This may also be true
dev.mysql.com
필드
ID 필드
- @Id 이 필드가 기본키임을 나타내는 어노테이션
- @GeneratedValue(strategy = GenerationType.IDENTITY) 기본키 값을 직접 넣는 게 아닌, DB가 자동으로 증가시키며 생성하도록 설정
- @Column(name = "user_id") Java 필드명은 id이지만, DB 내 컬럼명은 user_id로 매핑(ERD와 일치)
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
Nickname 필드
- @Column(name = "nickname", nullable = false) nullable = false로 반드시 값 있어야 함을 의미
@Column(name = "nickname", nullable = false)
private String nickname;
email 필드
- @Column(name = "email", nullable = false, unique = true) 반드시 값이 있어야 하며 중복 불가(unique)
*unique를 사용하면 같은 이메일을 가진 회원이 두 명 생길 수 없음
@Column(name = "email", nullable = false, unique = true)
private String email;
password 필드
@Column(name = "password", nullable = false)
private String password;
gender 필드

gender 필드는 위 필드들과 다르게 enum을 사용했다.
enum을 사용하면 정해진 값들만 들어가게 할 수 있다!
만약, String으로 성별을 지정한다고 한다면,
- male
- MALE
- Male
- 남자
- mle (오타)
등 전부 값이 들어가고 데이터가 더러워질 수 있다.
이때, ENUM을 사용하면 MALE, FEMALE, NONE 이 세 가지 값만 사용할 수 있게 되며 잘못된 값 자체가 들어갈 수 없게 된다.
- Enumerated(EnumType.STRING) Enum 값을 문자열(String) 형태로 저장
(숫자로 저장하는 ORDINAL 방식을 사용하면 데이터베이스에 Enum의 순서가 저장되어 순서를 바꾸면 에러가 발생한다.)
@Column(name = "gender")
@Enumerated(EnumType.STRING)
private Gender gender;
phoneNumber 필드
@Column(name = "phone_number")
private String phoneNumber;
createdAt 필드
- @CreatedDate 엔티티가 처음 저장될 때 현재 시간을 자동으로 삽입
@CreatedDate
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
updatedAt 필드
- @LastModifiedDate 처음 저장 시에도 값이 들어가고 이후 엔티티가 수정될 때마다 자동으로 최신 시각으로 갱신
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
JPA Auditing을 이용해 자동화 기능을 구현했다.
간단히 설명하자면, 엔티티 생성 시점과 수정 시점 등의 변경 이력을 자동으로 기록해주는 기능이다.
이를 위해서, @EntityListeners(AuditingEntityListener.class) 를 설정해 엔티티의 변경 이벤트를 감지하고,
@CreatedDate, @LastModifiedDate 를 통해 생성 및 수정 시점을 자동으로 저장한다.
단, JPA Auditing을 활성화시키기 위해서는 메인 클래스에 @EnableJpaAuditing 을 꼭 넣어줘야 한다!
@SpringBootApplication
@EnableJpaAuditing
public class Application {
}
https://dwc04112.tistory.com/271 (자세한 내용 참고)
JPA) JPA Auditing으로 자동화
JPA Auditing 이란 Spring boot JPA 에서 지원하는 기능 중 하나인 Auditing은 뜻 그대로 엔티티가 생성되고 수정되는 시점을 감시하여 생성일, 생성자, 수정일, 수정자 를 자동으로 기록할 수 있다. 생성일
dwc04112.tistory.com
Post.java
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Table(name = "post")
@EntityListeners(AuditingEntityListener.class)
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Long id;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", nullable = false)
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@CreatedDate
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}
user 필드
- @ManyToOne(fetch = FetchType.LAZY) Post(n) : User(1)의 관계를 의미
- FetchType.LAZY Post 조회 시, User 데이터를 즉시 가져오지 않고, post.getUser() 호출 시 쿼리 실행
- @JoinColumn(name = "user_id", nullable = false) Post 테이블의 외래키 컬럼 user_id 생성, User PK 참조
*User → Post 방향으론 관계 설정하지 않았으나, 필요에 따라 구현할 수 있음

게시글 생성 및 상세 조회 API 구현
게시글 생성 API는 이전에 구현했지만, User에 대한 정보가 없었으므로 이전의 코드를 개선해보겠다.
PostRequestDto
작성자의 ID를 추가로 입력받도록 수정하였다.
Q. NotBlank 가 아닌 NotNull을 사용한 이유는?
userId는 Long 타입(숫자)이므로 문자열 전용인 @NotBlank 대신 @NotNull을 사용하였다.
@Getter
public class PostRequestDto {
@NotNull(message = "작성자 ID는 필수입니다.")
private Long userId;
@NotBlank(message = "제목은 필수입니다.")
@Size(max = 50, message = "제목은 50자 이하여야 합니다.")
private String title;
@NotBlank(message = "내용은 필수입니다.")
@Size(max = 1000, message = "내용은 1000자 이하여야 합니다.")
private String content;
}
PostResponseDto
이전에 없었던 userId를 return 하도록 추가하였다.
@Getter
@AllArgsConstructor
public class PostResponseDto {
private Long postId;
private Long userId;
private String title;
private String content;
}
그 후, 서비스 로직에 UserRepository를 사용하기 위해 UserRepository를 만들어줬다.
public interface UserRepository extends JpaRepository<User, Long> {
}
마지막으로, 서비스에 작성자 ID 검증 부분을 추가한다.
userRepository를 통해 DB에 해당(입력한) userId가 있는지 확인하고 없다면 USER_NOT_FOUND 에러 코드를 반환한다.
public PostResponseDto createPost(PostRequestDto request) {
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new GeneralException(PostErrorCode.USER_NOT_FOUND));
Post post = Post.builder()
.title(request.getTitle())
.content(request.getContent())
.user(user)
.build();
Post savedPost = postRepository.save(post);
return new PostResponseDto(
savedPost.getId(),
savedPost.getUser().getId(),
savedPost.getTitle(),
savedPost.getContent()
);
}
성공 시

실패 시


게시글 조회 API
먼저, 게시글 조회 시에 필요한 데이터를 Response로 작성한다.
나의 경우, post ID, 제목, 내용, 작성자, 작성일을 조회하기로 하였다.
@Getter
@AllArgsConstructor
public class PostDetailResponseDto {
private Long postId;
private String title;
private String content;
private String author;
private LocalDateTime createdAt;
}
그 후, 서비스에서 postId를 입력하면, postRepository를 이용해 DB에 해당 postId가 있는지 검증 뒤에,
있다면 → ResponseDto 대로 내용을 채워서 반환
없다면 → POST_NOT_FOUND 에러 코드를 반환한다.
public PostDetailResponseDto getPost(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new GeneralException(PostErrorCode.POST_NOT_FOUND));
return new PostDetailResponseDto(
post.getId(),
post.getTitle(),
post.getContent(),
post.getUser().getNickname(),
post.getCreatedAt()
);
}
마지막으로, 컨트롤러에서 endPoint를 설정하고 값을 조회하기 위해 @GetMapping으로 get 요청을 연결한다.
@GetMapping("/{postId}")
public ApiResponse<PostDetailResponseDto> getPost(@PathVariable Long postId){
return ApiResponse.onSuccess(GeneralSuccessCode.OK, postService.getPost(postId));
}
성공 시

실패 시

Trouble Shooting
1. Unknown column 'p1_0.post_id' in field list'

원인
application.yml 설정에서 ddl-auto: update 설정은 새 컬럼 추가나 테이블 신규 생성은 가능하나 기존 컬럼 이름은 반영하지 않음
처음 테이블 생성 시, @Column(name = "post_id") 어노테이션이 없었거나, 이미 id로 컬럼이 만들어진 상태에서 어노테이션을 추가한다면 DB에는 그대로 id 컬럼으로 남아 있음 (post_id 컬럼 없음)
→ 따라서, 엔티티의 코드(post_id)와 실제 DB 컬럼명(id)이 불일치해서 JPA가 쿼리를 만들 때 없는 컬럼을 참조하게 되는 문제 發
해결
DB에서 직접 컬럼명을 변경하거나, 데이터가 없는 경우 ddl-auto: create로 잠깐 변경해 테이블을 재생성한다.
(단, create로 변경 후에 다시 update로 되돌려줘야 하는데 그렇지 않으면 프로젝트 실행 시마다 새로 테이블을 생성해 기존 데이터들이 날아가는 불상사가 발생한다... )
2. LazyInitializationException

원인
JPA에서 연관 엔티티를 FetchType.LAZY로 설정 시, 실제 데이터는 해당 필드를 호출하는 시점에 쿼리가 실행된다.
이때, JPA 세션(영속성 컨텍스트)이 열려 있어야 하는데, @Transactional 이 없으면 findById() 직후에 세션이 닫힌다.
따라서 post.getUser().getNickname()을 호출하는 시점에는 이미 세션이 없으므로 프록시 초기화에 실패하게 된다.
- 영속성 컨텍스트 JPA가 DB에서 가져온 엔티티를 임시로 보관하는 메모리 공간 (= 세션)
- 프록시(Proxy) Lazy 로딩일 때, JPA가 진짜 대신 넣어두는 가짜 객체로 post.getUser()를 하면 진짜 User가 아닌 User처럼 생긴 가짜(프록시) 반환 → post.getUser().getNickname()처럼 실제 데이터에 접근 시, DB에 쿼리 날려 진짜 User로 교체
→ 프록시가 진짜 데이터로 교체되려면 영속성 컨텍스트(세션)가 열려 있어야 DB에 쿼리를 날릴 수 있다.

해결
서비스 메서드에 @Transactional 을 붙여 메서드가 끝날 때까지 세션을 유지시킨다.
3. LocalDateTime 배열 직렬화 문제

원인
Jackson이 LocalDateTime을 직렬화할 때 기본 설정이 write-dates-as-timestamps: true이다.
이 때문에 날짜를 문자열이 아닌 [년, 월. 일, 시, 분, 초, 나노초]의 배열 형태로 변환한다.
해결
application.yml에 전역 설정( write-dates-as-timestamps: false )을 추가하고 DTO 필드에 @JsonFormat으로 포맷을 지정한다.

이렇게 반환받고 싶은 포맷을 지정하면 아래 사진과 같은 형태로 응답을 받을 수 있다.

'Springboot' 카테고리의 다른 글
| 게시글 목록 API 구현하기 (페이징 · 검색, N+1) (0) | 2026.05.07 |
|---|---|
| 회원가입 및 로그인 구현 (0) | 2026.04.28 |
| 게시판 CRUD (0) | 2026.03.30 |
| 예외 처리와 Validation (1) | 2026.03.13 |
| Spring Boot 시작하기 & REST API 기초 (0) | 2026.03.09 |