첫 구현을 시작하면서 Spring 프로젝트를 준비했다. 이제 설계한 내용을 실제 테이블과 코드로 옮길 차례였다.
그런데 코드보다 먼저 고민했던 것이 있었다.
AI가 코드를 작성할 수 있는 상황에서, 나는 어떤 방식으로 이 프로젝트를 만들어야 할까?
페어 코딩 vs 바이브 코딩
AI에게 구현을 맡기면 빠르게 결과를 볼 수 있다. 나도 속도가 필요할 때는 그 도움을 받을 생각이었다.
다만 이번 프로젝트에는 기존 Java/Spring 경험을 되살리고, 직접 손으로 코드를 작성하는 연습을 하려는 목표도 있었다. 결과물이 나오는 것만큼, 내가 그 코드를 이해하고 수정할 수 있는지도 중요했다.
그래서 처음에는 직접 작성하고, 진행 속도가 필요할 때 AI의 도움을 받는 방식으로 접근했다.
나만의 페어 코딩
AI Agent에게 질문하고 설명·힌트·리뷰를 받으며 페어 코딩을 시도했다. AI가 기능 전체를 작성하는 방식은 정말 필요한 경우가 아니라면 우선 미뤘다.
작업도 나눴다. 구현과 설계 판단은 직접 해보는 것을 우선하고, GitHub 푸시나 Docker 시작·종료 같은 주변 작업은 AI Agent에 맡겼다.
물론 처음부터 이 구분이 완성되어 있었던 것은 아니다. 작업하면서 조금씩 역할을 정리했다.
궁금해서 물어본 질문과 답변은 기록으로 남겼다. 이후에는 기록 절차를 스킬로 묶어, 매번 설명하지 않아도 AI가 작업 맥락에서 인식하고 수행하도록 했다. 지금 이 글도 그렇게 남긴 질문과 설명에서 출발했다.
작은 시행착오
직접 작성하니 기억이 흐릿했던 부분부터 드러났다.
DDL에서 컬럼 사이 쉼표를 빠뜨리기도 했고, PostgreSQL 문자열 타입 자리에 Java처럼 String을 적기도 했다. SQL에서는 varchar나 text를 써야 하는데, 언어의 구분부터 다시 확인한 셈이다.
그런 실수는 고치면 됐다. 조금 더 오래 생각하게 만든 것은 다음 질문들이었다.
- JDBC와 DAO에서 직접 하던 일은 JPA에서는 어디로 가는가?
- DB에서 ID 자동 생성을 정했는데 왜 JPA에도 적어야 하는가?
- 엔티티로 테이블을 만들 수 있는데 왜 SQL과 Flyway를 따로 쓰는가?
테이블 하나를 클래스로 연결하는 작업이었지만, 막상 시작하니 각 도구가 무엇을 맡는지부터 다시 이해해야 했다.
JDBC와 DAO에서 하던 일은 JPA에서 어디로 가는가?
이전에 Java_101에서 JDBC를 공부한 적이 있다. H2 데이터베이스를 연결하고 DAO를 직접 작성하면서 데이터가 Java 객체가 되는 과정을 연습했다.
그때는 SQL을 작성하고, Connection과 PreparedStatement를 준비하고, 쿼리를 실행했다. 조회 결과는 ResultSet에서 읽어 객체에 하나씩 옮겨 담았다.
그런데 이번에는 RoomRepository라는 인터페이스를 만든다고 했다.
내가 직접 작성하던 SQL과 객체 변환 코드는 어디에 있는 걸까?
먼저 이름과 역할부터 나눠 이해해야 했다.
| 구성 요소 | 역할 |
|---|---|
| JDBC | Java에서 DB에 접근하는 API |
| JPA | 객체와 관계형 DB의 매핑·저장·조회에 관한 표준 |
| Hibernate | 이번 프로젝트에서 사용하는 JPA 구현체 |
| Spring Data JPA | Repository의 반복적인 데이터 접근 구현을 제공하는 도구 |
Spring Data JPA에서는 JpaRepository를 상속하면 기본적인 findAll(), save() 등의 메서드를 사용할 수 있다. 기본 작업마다 DAO 구현체와 SQL을 직접 작성하는 부담이 줄어드는 것이다.
저장 시에는 엔티티 상태에 따라 JPA의 persist() 또는 merge()를 사용한다.
Spring Data JPA 문서
조회 결과를 엔티티로 구성하는 일은 Hibernate가 매핑 정보를 바탕으로 처리한다. @Entity, @Id, @Column은 그때 필요한 정보를 표현한다.
예전에 직접 작성하던 구조와 비교하면 대략 이렇다.
이전:
호출 코드 → DAO → JDBC → DB
이번에 배우는 구조:
Service → Repository → Spring Data JPA → Hibernate → JDBC → PostgreSQL
Repository와 DAO가 완전히 같은 개념인 것은 아니다.
다만 이번 학습에서는 서비스 코드에서 데이터 접근을 분리한다는 역할을 연결해서 이해할 수 있었다.
여기서 깨달은 것은 JDBC 공부가 사라진 게 아니라는 점이다.
내가 직접 작성하던 일 중 여러 부분을 라이브러리에 맡기게 된 것이다.
SQL은 여전히 실행되고, 데이터는 여전히 DB에 저장된다.
그래서 앞으로 Repository를 사용할 때도 메서드 이름만 보고 이전 DAO와 똑같이 동작한다고 생각하지 않기로 했다.
예를 들어 직접 작성했던 findById는 없으면 null을 반환했지만, Spring Data JPA에서는 빈 Optional을 반환한다.
정렬도 필요하면 별도로 지정해야 한다.
이날은 이 구조와 Repository의 역할을 이해하는 단계였고, 실제 RoomRepository 작성과 조회 확인은 다음 작업으로 남겼다.
DB가 생성하는 ID를 왜 JPA에도 명시해야 하는가?
Room 테이블의 ID는 DB가 생성하도록 정했다.
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY
그런데 Java 엔티티에도 다음 설정이 필요했다.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
처음에는 같은 내용을 두 번 적는 것처럼 느껴졌다.
DB에서 이미 자동 생성하도록 정했는데, Java에도 알려줘야 하나?
알고보니, 둘은 서로 다른 곳에 전달하는 정보였다.
SQL의 IDENTITY는 PostgreSQL에 ID 생성 방식을 지정한다. @Id는 JPA에 엔티티의 식별자가 무엇인지 알려주고, @GeneratedValue는 그 식별자를 어떤 전략으로 생성하는지 알려준다.
IDENTITY 전략을 사용하면 Hibernate는 INSERT를 통해 DB에서 생성된 ID를 얻어 엔티티에 반영한다. DB의 자동 생성 정책이 JPA 매핑 설정을 대신해 주는 것은 아니었다.
Hibernate의 IDENTITY 설명
이 차이를 이해하고 나니 생성자도 다시 보게 됐다.
이후 생성자를 정리하면서, ID를 포함한 모든 필드를 받는 생성자 대신 방 이름과 운영
정보만 받는 생성자를 두었다. ID는 새 방을 만드는 코드에서 지정할 값이 아니라 DB가 생성할 값이기 때문이다.
그전에는 Lombok의 @AllArgsConstructor를 편하게 쓰는 경우가 많았다. 하지만 이제는 모든 필드를 받는 생성자가 이 객체의 생성 방식에 맞는지 먼저 생각해야겠다는 기준이 생겼다.
JPA용 인자 없는 생성자는 별도로 protected로 두었다. JPA 엔티티에는 public 또는 protected인 인자 없는 생성자가 필요하다. 첫날에는 명시적인 생성자가 없어 Java의 기본 생성자를 사용했고, 인자를 받는 생성자를 추가하면서 이 부분도 명시했다.
왜 Flyway를 쓰는가?
테이블을 만드는 과정에서도 비슷한 질문이 있었다.
DBeaver에서 SQL을 실행하면 되는 것 아닌가? 엔티티로 테이블을 만들 수도 있다는데, 왜 Flyway가 필요하지?
현재 구조와 변경 과정은 다르다.
Hibernate의 ddl-auto 설정으로 엔티티 매핑에서 테이블을 생성하거나 스키마 변경을 시도할 수 있다. 하지만 현재 엔티티의 모습과 기존 DB를 그 모습으로 바꾸는 과정은 다른 문제였다.
예를 들어 name이라는 필드를 title로 바꿨다고 해보자. 기존 컬럼 이름을 바꾸려는 것일 수도 있고, 새로운 컬럼을 추가하려는 것일 수도 있다. 기존 데이터를 어떻게 처리할지도 결정해야 한다.
최종 클래스만으로는 그 의도를 충분히 표현하기 어렵다.
실제 서비스를 운영하려면 테이블을 처음 만드는 것뿐 아니라, 데이터가 들어 있는 테이블을 나중에 바꾸는 과정도 관리해야 했다. 그래서 변경 SQL을 파일로 남기고, Flyway가 실행과 적용 이력을 관리하도록 했다.
직접 작성한 SQL을 변경 이력으로 남기기
첫 테이블은 다음 파일에 작성했다. V1__create_room.sql
이후 운영 시간 제약을 추가할 때는 별도의 migration을 작성했다.
V2__add_room_operating_time_constraint.sql
Flyway의 버전 migration은 변경 내용을 순서대로 적용하고, 적용 이력과 체크섬을 스키마 이력 테이블에 기록한다. 이미 공유·적용한 파일을 계속 고치는 대신 새로운 migration으로 다음 변경을 표현하는 방식이다.
처음에는 ‘DB판 Git인가?’라고 생각했다. 변경에 버전과 이력을 붙인다는 점은 비슷하게 느껴졌지만, 찾아보니 DB끼리 실시간 동기화하거나 이전 상태로 자동 복구하는 도구는 아니었다.
로컬 DB와 배포 환경의 DB는 각각 자신의 적용 이력을 기준으로 migration을 실행한다. 같은 변경 파일을 배포하더라도 각 DB에서 실제로 실행되어야 반영된다.
내가 SQL을 직접 작성한다는 점은 그대로였다. DBeaver도 계속 사용한다. 달라진 것은 테이블 생성과 변경을 그때그때 수동 실행하는 대신, 파일과 적용 이력으로 관리한다는 점이었다.
Flyway가 변경하고, Hibernate는 검사하도록
이번 프로젝트에서는 역할을 나눴다.
spring:
jpa:
hibernate:
ddl-auto: validate
스키마 변경은 Flyway가 담당하고, Hibernate는 validate로 엔티티 매핑과 실제 스키마가 맞는지 검사하도록 했다. Spring Boot도 스키마 생성에는 하나의 방식을 사용하는 것을 권장한다.
다만 validate가 통과했다고 모든 DB 제약이 검증된 것은 아니다. 길이·필수값·CHECK 같은 조건까지 전부 확인했다고 볼 수는 없다.
그래서 이후 추가한 다음 제약도 migration에 SQL로 명시했다.
CHECK (open_at <> close_at)
이 제약은 DB가 잘못된 값을 거부하도록 하는 규칙이다. Hibernate의 매핑 검사가 그 규칙 자체를 검증해 주는 것은 아니다.
여기서도 기준이 하나 생겼다. 엔티티 설정과 DB 제약을 같은 것으로 생각하지 말고, 각각 무엇을 보장하는지 확인해야 한다.
테이블 하나를 연결하며 알게 된 것
이날 완성한 것은 Room 테이블과 JPA 엔티티의 연결이었다.
DBeaver에서 Flyway V1 적용 이력을 확인했고, validate 설정 후 기존 Spring 기동 테스트도 통과했다. Repository와 API를 통한 실제 저장·조회는 아직 다음 단계였다.
코드 양은 많지 않았지만, 테이블 하나를 연결하면서 생각보다 많은 구분이 필요했다.
DB가 ID를 생성하는 것과 JPA가 그 값을 받아 사용하는 것. 현재 객체 구조를 표현하는 것과 기존 DB의 변경 과정을 관리하는 것. 내가 직접 작성하던 일을 라이브러리에 맡기는 것과 그 동작을 이해하지 않아도 되는 것은 각각 달랐다.
직접 손으로 작성한 덕분에 무엇이 기억나지 않고, 어떤 개념이 연결되지 않는지 질문으로 드러났다. 다음에는 RoomRepository를 작성하고, 이번에 연결한 엔티티가 실제 SELECT를 통해 어떻게 조회되는지 확인해볼 차례다.