ActiveRecord 패턴은 Java 개발자에게 생소할 수 있다. jOOQ는 이 패턴을 지원하여 객체가 직접 데이터베이스 작업을 수행할 수 있게 한다.
ActiveRecord 패턴
핵심 개념: 데이터베이스 테이블의 행(Row)를 감싼 객체가 데이터와 함께 CRUD 작업을 직접 수행하는 메서드를 포함
UML 다이어그램 비교
일반적인 ActiveRecord
┌─────────────────────┐ │ Person │ ├─────────────────────┤ │ - id: Long │ │ - name: String │ │ - email: String │ ├─────────────────────┤ │ + insert(): void │ 데이터와 행위가 함께 │ + update(): void │ │ + delete(): int │ │ + save(): void │ └─────────────────────┘
jOOQ의 ActiveRecord
┌─────────────────────────┐ │ ActorRecord │ ├─────────────────────────┤ │ - actorId: Long │ │ - firstName: String │ │ - lastName: String │ │ - lastUpdate: LocalDT │ ├─────────────────────────┤ │ + store(): void │ CRUD 메서드 내장 │ + insert(): void │ │ + update(): void │ │ + delete(): int │ │ + refresh(): void │ └─────────────────────────┘
ActiveRecord의 기원 – Ruby on Rails
ActiveRecord 패턴은 Java보다 Ruby on Rails에서 널리 사용된다
Ruby on Rails 예제
# User 모델 (ActiveRecord 상속) class User < ApplicationRecord validates :name, presence: true end # 사용 예시 user = User.new(name: "John", email: "john@example.com") user.save # INSERT 수행 user.name = "Jane" user.save # UPDATE 수행 user.destroy # DELETE 수행 # 조회도 직관적 User.find(1) # SELECT WHERE id = 1 User.where(age: 25) # SELECT WHERE age = 25
특징
- 매우 직관적이고 간결한 API
- 소규모 프로젝트에서 빠른 개발 가능
- Convention over Configuration 철학
ActiveRecord vs Data Mapper 패턴
Java에서 일반적으로 사용하는 방식은 Data Mapper 패턴이다
Data Mapper 패턴
// 데이터 객체 (POJO)
public class Actor {
private Long actorId;
private String firstName;
private String lastName;
// getter/setter만 존재
}
// 데이터 접근 객체 (분리된 책임)
@Repository
public class ActorRepository {
public void save(Actor actor) { ... }
public Actor findById(Long id) { ... }
public void update(Actor actor) { ... }
public void delete(Long id) { ... }
}
특징: 데이터(Actor)와 데이터 접근 로직(ActorRepository)이 분리된다
두 패턴의 비교 – 응집도와 결합도
결합도 (Coupling)
ActiveRecord – 강한 결합
// 객체와 DB 스키마가 강하게 결합
ActorRecord actor = new ActorRecord();
actor.setFirstName("John");
actor.insert(); // DB 구조에 직접 의존
문제점
- 객체 변경이 데이터베이스 스키마에 영향
- 데이터베이스 변경이 객체에 영향
- 코드 생성으로 일부 완화되지만 여전히 강한 결합
Data Mapper – 느슨한 결합
// 객체와 DB가 Mapper로 분리
Actor actor = new Actor();
actor.setFirstName("John");
actorRepository.save(actor); // Mapper가 추상화 제공
장점
- 객체는 데이터베이스를 몰라도 된다
- Mapper 계층에서 매핑 관계 관리
- 높은 유연성
응집도 (Cohesion)
ActiveRecord – 낮은 응집도
public class ActorRecord {
// 1. 데이터 표현 책임
private Long actorId;
private String firstName;
// 2. 비즈니스 로직 책임
public String getFullName() { ... }
// 3. 데이터베이스 작업 책임
public void insert() { ... }
public void update() { ... }
public void delete() { ... }
}
문제점
- 단일 책임 원칙(SRP) 위배
- 객체의 역할이 너무 많다
- 테스트 작성이 어렵다
Data Mapper – 높은 응집도
// 데이터만 담당
public class Actor {
private Long actorId;
private String firstName;
// 단일 책임: 데이터 표현
}
// DB 작업만 담당
@Repository
public class ActorRepository {
public void save(Actor actor) { ... }
// 단일 책임: 데이터 접근
}
// 비즈니스 로직만 담당
@Service
public class ActorService {
public void promoteActor(Long actorId) { ... }
// 단일 책임: 비즈니스 규칙
}
장점
- 각 클래스가 하나의 책임만 가진다
- 변경 이유가 명확하다
- 테스트 작성 용이하다
테스트 용이성
ActiveRecord – 테스트 어려움
@Test
void testActorCreation() {
ActorRecord actor = new ActorRecord();
actor.setFirstName("John");
actor.insert(); // 실제 DB 연결 필요!
// 목(Mock) 객체 생성 어려움
// DB 없이 단위 테스트 불가능
}
Data Mapper – 테스트 쉬움
@Test
void testActorCreation() {
Actor actor = new Actor();
actor.setFirstName("John");
ActorRepository mockRepo = mock(ActorRepository.class);
mockRepo.save(actor); // Mock으로 DB 없이 테스트 가능
verify(mockRepo).save(actor);
}
jOOQ의 ActiveRecord 구조
jOOQ는 코드 생성을 통해 ActiveRecord를 제공한다
생성된 파일 구조
src/generated/
└── org/jooq/generated/
├── tables/
│ ├── JActor.java (테이블 정의)
│ ├── pojos/
│ │ └── Actor.java (POJO)
│ ├── daos/
│ │ └── ActorDao.java (DAO)
│ └── records/
│ └── ActorRecord.java ⭐ ActiveRecord
└── ...
ActorRecord 구조
public class ActorRecord
extends UpdatableRecordImpl<ActorRecord> // 핵심 인터페이스
implements Record4<Long, String, String, LocalDateTime> {
// 데이터 필드
private Long actorId;
private String firstName;
private String lastName;
private LocalDateTime lastUpdate;
// CRUD 메서드
public void insert() { ... }
public void update() { ... }
public int delete() { ... }
public void store() { ... }
public void refresh() { ... }
}
핵심: UpdatableRecordImpl을 상속하여 CRUD 기능을 제공한다
jOOQ ActiveRecord 활용
환경 설정
@Repository
public class ActorRepository {
private final DSLContext dslContext;
private final ActorDao actorDao;
public ActorRepository(DSLContext dslContext, Configuration configuration) {
this.dslContext = dslContext;
this.actorDao = new ActorDao(configuration);
}
}
SELECT
Repository 메서드
public ActorRecord findRecordByActorId(Long actorId) {
return dslContext.fetchOne(ACTOR, ACTOR.ACTOR_ID.eq(actorId));
}
중요: New 연산자 사용 금지
// 잘못된 방식: Configuration이 설정되지 않음 ActorRecord actor = new ActorRecord(); actor.setActorId(1L); actor.refresh(); // 동작하지 않음! // 올바른 방식: DSLContext로부터 생성 ActorRecord actor = dslContext.fetchOne(ACTOR, ACTOR.ACTOR_ID.eq(1L)); actor.refresh(); // 정상 동작
이유: new 연산자로 생성하면 Spring이 관리하는 Configuration(JDBC 연결 정보 등)이 설정되지 않는다
테스트
@Test
@DisplayName("SELECT 절 예제")
void activeRecord_조회_예제() {
// given
Long actorId = 1L;
// when
ActorRecord actorRecord = actorRepository.findRecordByActorId(actorId);
// then
assertThat(actorRecord).hasNoNullFieldsOrProperties();
}
생성된 SQL
SELECT `actor`.`actor_id`,
`actor`.`first_name`,
`actor`.`last_name`,
`actor`.`last_update`
FROM `actor`
WHERE `actor`.`actor_id` = 1
REFRESH – 데이터 새로고침
Record의 데이터를 데이터베이스로부터 다시 로드한다
전체 필드 새로고침
@Test
@DisplayName("activeRecord refresh 예제")
void activeRecord_refresh_예제() {
// given
Long actorId = 1L;
ActorRecord actorRecord = actorRepository.findRecordByActorId(actorId);
// 메모리상 데이터 변경
actorRecord.setFirstName(null);
// when
actorRecord.refresh(); // DB로부터 다시 로드
// then
assertThat(actorRecord.getFirstName()).isNotBlank();
}
생성된 SQL
-- 1. 초기 조회 SELECT * FROM `actor` WHERE `actor`.`actor_id` = 1 -- 2. refresh() 호출 시 SELECT * FROM `actor` WHERE `actor`.`actor_id` = 1
특정 필드만 새로고침
@Test
void activeRecord_refresh_특정_필드() {
// given
ActorRecord actorRecord = actorRepository.findRecordByActorId(1L);
actorRecord.setFirstName(null);
// when
actorRecord.refresh(JActor.ACTOR.FIRST_NAME); // ⭐ 특정 필드만
// then
assertThat(actorRecord.getFirstName()).isNotBlank();
}
생성된 SQL
SELECT `actor`.`first_name` -- firstName만 조회 FROM `actor` WHERE `actor`.`actor_id` = 1
사용 시나리오
- 다른 트랜잭션에서 변경된 데이터 확인
- 낙관적 락 충돌 후 재조회
- DB 트리거/기본값으로 설정된 값 확인
INSERT – 데이터 삽입
store() 메서드
@Test
@DisplayName("activeRecord store 예제 - insert")
@Transactional
void activeRecord_insert_예제() {
// given
ActorRecord actorRecord = dslContext.newRecord(JActor.ACTOR);
// when
actorRecord.setFirstName("John");
actorRecord.setLastName("Doe");
actorRecord.store(); // INSERT 또는 UPDATE
// then
assertThat(actorRecord.getActorId()).isNotNull(); // PK 자동 설정
}
생성된 SQL
INSERT INTO `actor` (`first_name`, `last_name`)
VALUES ('John', 'Doe')
DB 기본값 가져오기
@Test
@Transactional
void activeRecord_insert_with_refresh() {
// given
ActorRecord actorRecord = dslContext.newRecord(JActor.ACTOR);
actorRecord.setFirstName("John");
actorRecord.setLastName("Doe");
// when
actorRecord.store();
actorRecord.refresh(); // DB 기본값 로드
// then
assertThat(actorRecord.getLastUpdate()).isNotNull(); // DB 기본값 확인
}
- store() 후 actorRecord.getLastUpdate()는 null
- DB의 DEFAULT CURRENT_TIMESTAMP는 적용되었지만 객체에는 반영 안 됨
- refresh()로 DB로부터 다시 로드해야 최신 값 확인 가능
store() vs insert()
// jOOQ 내부 코드 (간략화)
public void store() {
if (getPrimaryKey() != null) {
update(); // PK가 있으면 UPDATE
} else {
insert(); // PK가 없으면 INSERT
}
}
- store(): INSERT/UPDATE를 자동으로 판단 (권장)
- insert(): 명시적으로 INSERT만 수행
UPDATE – 데이터 수정
store() 메서드
@Test
@DisplayName("activeRecord store 예제 - update")
@Transactional
void activeRecord_update_예제() {
// given
Long actorId = 1L;
String newName = "Updated Name";
ActorRecord actor = actorRepository.findRecordByActorId(actorId);
// when
actor.setFirstName(newName);
actor.store(); // 또는 actor.update()
// then
assertThat(actor.getFirstName()).isEqualTo(newName);
}
생성된 SQL
-- 1. 조회 SELECT * FROM `actor` WHERE `actor`.`actor_id` = 1 -- 2. 업데이트 (변경된 필드만) UPDATE `actor` SET `actor`.`first_name` = 'Updated Name' WHERE `actor`.`actor_id` = 1
Changed Fields 추적
Record는 변경된 필드만 UPDATE 한다
ActorRecord actor = dslContext.fetchOne(ACTOR, ACTOR.ACTOR_ID.eq(1L));
actor.setFirstName("John"); // changed
// actor.setLastName() 호출 안 함 // not changed
actor.store();
// SQL: UPDATE actor SET first_name = 'John' WHERE ...
// lastName은 UPDATE 되지 않음
update() vs store()
// update(): 무조건 UPDATE actor.update(); // store(): INSERT/UPDATE 자동 선택 actor.store();
권장: 조회 후 수정하는 경우 둘 다 동일하게 동작하므로 store() 사용
DELETE – 데이터 삭제
@Test
@DisplayName("activeRecord delete 예제")
@Transactional
void activeRecord_delete_예제() {
// given
ActorRecord actorRecord = dslContext.newRecord(JActor.ACTOR);
actorRecord.setFirstName("John");
actorRecord.setLastName("Doe");
actorRecord.store(); // INSERT
// when
int result = actorRecord.delete(); // DELETE
// then
assertThat(result).isEqualTo(1); // 1개 행 삭제
}
생성된 SQL
-- 1. INSERT
INSERT INTO `actor` (`first_name`, `last_name`)
VALUES ('John', 'Doe')
-- 2. DELETE (PK 기반)
DELETE FROM `actor`
WHERE `actor`.`actor_id` = 230
- delete()는 Record의 PK를 기반으로 WHERE 절 자동 생성
- 삭제된 행의 개수 반환 (보통 1)
ActiveRecord 메서드 요약
| 메서드 | 기능 | SQL |
| refresh() | DB로부터 데이터 다시 로드 | SELECT |
| refresh(field) | 특정 필드만 다시 로드 | SELECT (특정 컬럼) |
| insert() | 무조건 INSERT | INSERT |
| update() | 무조건 UPDATE | UPDATE (changed 필드만) |
| store() | INSERT 또는 UPDATE | INSERT or UPDATE |
| delete() | 삭제 | delete |
ActiveRecord 사용 시기
사용하기 좋은 경우
Repository 계층 내부
@Repository
public class ActorRepository {
public int updatePartially(Long actorId, ActorUpdateRequest request) {
ActorRecord record = dslContext.fetchOne(ACTOR, ACTOR.ACTOR_ID.eq(actorId));
if (StringUtils.hasText(request.getFirstName())) {
record.setFirstName(request.getFirstName());
}
record.store(); // 변경된 필드만 UPDATE
return 1;
}
}
간단한 CRUD 작업
// 직관적이고 간결
ActorRecord actor = dslContext.newRecord(ACTOR);
actor.setFirstName("John");
actor.setLastName("Doe");
actor.store();
Changed 필드 추적이 필요한 경우
// 자동으로 변경된 필드만 UPDATE
ActorRecord actor = dslContext.fetchOne(...);
actor.setFirstName("New Name"); // firstName만 changed
actor.store(); // firstName만 UPDATE됨
사용하지 말아야 할 경우
Service 계층에서 직접 사용
// 나쁜 예: Service에서 ActiveRecord 직접 사용
@Service
public class ActorService {
private final DSLContext dslContext;
public void promoteActor(Long actorId) {
ActorRecord actor = dslContext.fetchOne(ACTOR, ...);
actor.setFirstName("Star " + actor.getFirstName());
actor.store(); // Service에서 DB 직접 접근
}
}
// 좋은 예: Repository를 통한 추상화
@Service
public class ActorService {
private final ActorRepository actorRepository;
public void promoteActor(Long actorId) {
Actor actor = actorRepository.findById(actorId);
actor.setFirstName("Star " + actor.getFirstName());
actorRepository.update(actor); // Repository로 분리
}
}
복잡한 비즈니스 로직
// ActiveRecord에 비즈니스 로직 추가
public class ActorRecord extends UpdatableRecordImpl<ActorRecord> {
public void promote() {
this.setFirstName("Star " + this.getFirstName());
this.store();
}
public boolean isEligibleForAward() {
// 복잡한 비즈니스 로직...
}
}
//별도 도메인 모델로 분리
public class Actor {
private String firstName;
public void promote() {
this.firstName = "Star " + this.firstName;
}
public boolean isEligibleForAward() {
// 비즈니스 로직은 도메인 모델에
}
}
테스트가 중요한 비즈니스 로직
// ActiveRecord는 Mock 어려움
@Test
void testPromotion() {
ActorRecord actor = new ActorRecord(); // DB 필요
actor.promote();
// DB 없이 테스트 불가능
}
// Data Mapper는 Mock 쉬움
@Test
void testPromotion() {
Actor actor = new Actor("John", "Doe");
ActorRepository mockRepo = mock(ActorRepository.class);
actor.promote();
verify(mockRepo).update(actor); // DB 없이 테스트
}
권장 사용 범위
┌─────────────────────────────────────┐
│ Controller Layer │
│ (HTTP 요청/응답 처리) │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Service Layer │
│ (비즈니스 로직) │
│ ❌ ActiveRecord 사용 금지 │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Repository Layer │
│ (데이터 접근 로직) │
│ ✅ ActiveRecord 사용 가능 │ 이 계층에서만
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Database │
└─────────────────────────────────────┘
실전 패턴
@Repository
public class ActorRepository {
private final DSLContext dslContext;
// 내부에서만 ActiveRecord 사용
public int updatePartially(Long actorId, ActorUpdateRequest request) {
ActorRecord record = dslContext.fetchOne(ACTOR, ACTOR.ACTOR_ID.eq(actorId));
// Changed 필드만 추적하여 UPDATE
if (StringUtils.hasText(request.getFirstName())) {
record.setFirstName(request.getFirstName());
}
if (StringUtils.hasText(request.getLastName())) {
record.setLastName(request.getLastName());
}
record.store(); // 변경된 필드만 UPDATE
return 1;
}
// 반환은 POJO로
public Actor findById(Long actorId) {
return dslContext
.selectFrom(ACTOR)
.where(ACTOR.ACTOR_ID.eq(actorId))
.fetchOneInto(Actor.class); // POJO로 반환
}
}
실전 팁
Record ↔ POJO 변환
// Record → POJO
ActorRecord record = dslContext.fetchOne(ACTOR, ...);
Actor pojo = record.into(Actor.class);
// POJO → Record
Actor pojo = new Actor();
pojo.setFirstName("John");
ActorRecord record = dslContext.newRecord(ACTOR, pojo);
Batch 작업
public void batchInsert(List<Actor> actors) {
List<ActorRecord> records = actors.stream()
.map(actor -> dslContext.newRecord(ACTOR, actor))
.toList();
// Batch INSERT
dslContext.batchInsert(records).execute();
}
조건부 UPDATE
public int conditionalUpdate(Long actorId, String newName, LocalDateTime expectedUpdate) {
ActorRecord record = dslContext.fetchOne(ACTOR, ACTOR.ACTOR_ID.eq(actorId));
// Optimistic Lock
if (!record.getLastUpdate().equals(expectedUpdate)) {
throw new OptimisticLockException();
}
record.setFirstName(newName);
return record.update();
}
jOOQ의 ActiveRecord 패턴
- 직관적이지만 신중하게: 간결하고 직관적이지만 결합도가 높다
- Repository 계층에서만: Service 계층에서는 사용 금지
- Changed 추적 활용: 변경된 필드만 UPDATE하는 장점 활용
- POJO 반환: 외부에는 POJO로 반환하여 계층 분리