jOOQ ActibeRecord 패턴

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
특징

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()무조건 INSERTINSERT
update()무조건 UPDATEUPDATE (changed 필드만)
store()INSERT 또는 UPDATEINSERT 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로 반환하여 계층 분리

출처 – 실전 jOOQ! Type Safe SQL with Java