강의

멘토링

커뮤니티

강의 소개

강의 소개

먼저 다양한 데이터 접근 기술들을 학습할 것이다.

JdbcTemplate, MyBatis, JPA, 스프링 데이터 JPA, Querydsl 같은 실무에서 주로 사용하는 다양한 데이터 접근 기술들을 실전 예제를 통해 점진적으로 발전시키면서 학습한다.

 

이 과정을 통해 각각의 기술들이 왜 필요한지, 각 기술들의 장단점들을 코드로 직접 개발하면서 자연스럽게 이해할 수 있다.

스프링 트랜잭션을 사용하면 주로 AOP를 사용한다. 이때 실무에서 반드시 주의해야 할 점들이 있는데 이런 것도 나중에 배운다.

 

그리고 스프링 트랜잭션이 가지는 다양한 옵션들이 있다. 옵션들도 하나하나 배운다.

 

그리고 예외 처리와 스프링 트랜잭션이 커밋되고, 롤백되는 내부 원리들도 배운다.

 

그리고 마지막으로 스프링 트랜잭션의 전파 옵션과 내부 동작 방식을 배운다.

데이터 접근 기술 - 시작

데이터 접근 기술 진행 방식 소개

JPA를 쓰면 그 구현체로 대부분 Hibernate를 자연스럽게 사용한다.

우선 웹 애플리케이션으로 동작하는, 메모리 기반 프로젝트를 만들고, 그다음에 메모리에서 JdbcTemplate을 써 보고, MyBatis로 바꿔 보고(Repository만 바꾸면 되는 듯), JPA로도 바꾸고, 스프링 데이터 JPA도 써 보고, Querydsl도 써 보는 식으로 점진적으로 Repository를 바꿔 보겠다.

 

앞의 강의에서 트랜잭션 추상화하고 하면서 Repository 구현 기술들을 바꿀 수 있다는 것을 이해했었다. 이제 실제 그 과정을 해 볼 거고, 코드를 하나하나 해 보면서, 이 기술의 장점은 무엇이고, 이 기술을 이렇게 바꿨더니 이런 장점과 단점이 있네? 이런 단점을 극복하려면 어떻게 해야 할까? 같은 전체적인 그림을 볼 수 있도록 할 것이다.

프로젝트 설정과 메모리 저장소

상품명, 가격 제한 등 조건으로 검색하는 부분들에서 동적 쿼리 같은 걸 확인해 볼 수 있다.

프로젝트 구조 설명1 - 기본

이번 프로젝트 구조에서 얻을 수 있는 인사이트들도 있을 것이다.

분석을 할 땐 도메인을 먼저 분석하자.

List<Item> findAll(ItemSearchCond cond);

findAll()엔 검색 조건이 넘어간다.

 

zxc.jpg.webp

상품명이랑 가격 제한, 이 검색 조건으로 이 리스트를 뽑는다.

public interface ItemRepository {

    Item save(Item item);

    void update(Long itemId, ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findAll(ItemSearchCond cond);

}

이 인터페이스가 사용하는 게 ItemSearchCond랑 ItemUpdateDto다.

 

void update(Long itemId, ItemUpdateDto updateParam);

이번 프로젝트에선 이렇게 분리했지만 ItemUpdateDto 안에 itemId를 넣어서 합쳐도 된다.

zxzxzx.jpg.webp

여기서 수정할 수 있는 3개의 데이터가

 

@Data
public class ItemUpdateDto {
    private String itemName;
    private Integer price;
    private Integer quantity;
.
.
.

여기에 들어가서 수정된다.

@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
    Item findItem = findById(itemId).orElseThrow();
    findItem.setItemName(updateParam.getItemName());
    findItem.setPrice(updateParam.getPrice());
    findItem.setQuantity(updateParam.getQuantity());
}

값만 세팅해 줘도 된다.

메모리의 참조를 가져온 거고, 참조에 있는 값을 변경하면 실제 컬렉션 안에 있는 값도 같이 변경된다. 그래서 다시 세이브할 필요가 없다.

Optional은 쉽게 말해서 통이다. 값(item)이 있을 수도 있고 없을 수도 있는 통이다.

findAll()은 ItemSearchCond라는 검색 조건을 받아서 내부에서 데이터를 검색하는 기능을 한다. 데이터베이스로 보면 where 구문을 사용해서 필요한 데이터를 필터링하는 과정을 거치는 것이다.

 

복잡하다. 그래서 SQL이 좋다. where에 몇 개 적으면 된다.

인터페이스를 도입한다는 건 미래에 이 구현체를 바꿀 가능성이 있을 때 도입한다.

 

다만 서비스는 핵심 비즈니스 로직들이 그냥 거기에 있기 때문에 보통 그 로직을 수정하지, 그걸 DI로 바꾸는 경우는 많지 않다. 가끔은 있다.

DTO를 어디에 놓는 것이 좋을까? 별도의 패키지에 둬도 된다.

@Service
@RequiredArgsConstructor
public class ItemServiceV1 implements ItemService {

    private final ItemRepository itemRepository;

    @Override
    public Item save(Item item) {
        return itemRepository.save(item);
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        itemRepository.update(itemId, updateParam);
    }

    @Override
    public Optional<Item> findById(Long id) {
        return itemRepository.findById(id);
    }

    @Override
    public List<Item> findItems(ItemSearchCond cond) {
        return itemRepository.findAll(cond);
    }
}

다만 ItemUpdateDto를 서비스에 두는 건 좀 아니다.

 

public interface ItemService {

    Item save(Item item);

    void update(Long itemId, ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findItems(ItemSearchCond itemSearch);
}

ItemService가 ItemSearchCond를 가지고 있다.

그런데 ItemService는 Repository를 호출한다.

 

public interface ItemRepository {

    Item save(Item item);

    void update(Long itemId, ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findAll(ItemSearchCond cond);

}

그런데 Repository에서 ItemSearchCond를 가지고 있다.

ItemSearchCond는 결과적으로 최종 호출되는 거가 소유자다. ItemRepository가 ItemSearchCond 이걸 만든 거다. 그리고 이걸 가져다 쓰는 게 ItemService다.

 

@Service
@RequiredArgsConstructor
public class ItemServiceV1 implements ItemService {

    private final ItemRepository itemRepository;

    @Override
    public Item save(Item item) {
        return itemRepository.save(item);
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        itemRepository.update(itemId, updateParam);
    }

    @Override
    public Optional<Item> findById(Long id) {
        return itemRepository.findById(id);
    }

    @Override
    public List<Item> findItems(ItemSearchCond cond) {
        return itemRepository.findAll(cond);
    }
}

 

@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
    itemRepository.update(itemId, updateParam);
}

itemRepository.update(itemId, updateParam); 이걸 호출하기 위해 가져다 쓰기 위해 이렇게 된 거다.

 

결과적으로 ItemUpdateDto나 ItemSearchCond는 지금 현재 상황에선(구조마다 좀 다를 수는 있지만) ItemRepository가 둘 다 본인 소유로 가지고 있다.

 

ItemRepository를 쓸 때 ItemUpdateDto랑 ItemSearchCond는 무조건 필요하다. 결과적으로 이건 의존 관계상 Repository랑 같이 두는 게 지금 패키지 흐름으로는 맞다.

 

물론 DTO가 서비스나 컨트롤러에서 가지고 있는 게 더 맞는 경우도 있다. 서비스에서 사용이 끝나고 더 이상 Repository로 넘기지 않는 DTO라면 서비스에서 가지고 있는 게 맞다.

그리고 검색 조건이 있는데 서비스에서까지만 쓰고 Repository까지 넘기지 않으면 그건 서비스에 있는 게 맞다.

 

다만 지금은 ItemUpdateDto든 ItemSearchCond든 Repository가 필요해서 만든 거고 가져다 쓰고 있다. 이런 게 서비스에 있으면 안 된다. 왜냐하면 그런 경우, 서비스가 ItemRepository를 호출하고, ItemRepository에서 ItemUpdateDto를 쓰기 위해 서비스 패키지를 참조해야 한다. 그러면 패키지 순환이 꼬인다. 순환 참조가 일어난다.

전체 흐름에선 컨트롤러 -> 서비스 -> Repository로 흐른다. 이렇게 봤을 때, DTO를 제공하는 곳이, 마지막 단이 Repository라면 Repository에서 DTO를 가지고 있으면 된다.

 

만약 서비스에서 더 이상 Repository로 DTO를 안 넘기고 서비스 계층에서 쓰면, 서비스에서 만든 거기 때문에 그 서비스에서 쓰면 된다. 컨트롤러에서 끝나는 DTO라면 컨트롤러에서 쓰면 된다.

 

만약 애매하면? 여러 곳에서 이런 거에 대한 거 없이 그냥 사용하면 별도의 패키지를 만들어서 쓰면 된다.

 

이렇게를 기본 베이스로 잡고 우리들이 고민하면 된다.

프로젝트 구조 설명2 - 설정

@Slf4j
@RequiredArgsConstructor
public class TestDataInit {

    private final ItemRepository itemRepository;

    /**
     * 확인용 초기 데이터 추가
     */
    @EventListener(ApplicationReadyEvent.class)
    public void initData() {
        log.info("test data init");
        itemRepository.save(new Item("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 20000, 20));
    }

}

@EventListener()는 스프링이 어떤 타이밍이 됐을 때 이벤트가 발생해서 이걸 호출해 준다.

 

ApplicationReadyEvent는 애플리케이션이 준비가 됐다는 이벤트이다.

 

물론 TestDataInit을 스프링 빈으로 등록해야 이 @EventListener()가 호출된다. 스프링에서 발생하는 이벤트이기 때문에 스프링 빈이어야 영향을 줄 수 있다.

@EventListener(ApplicationReadyEvent.class): 스프링 컨테이너가 완전히 초기화를 다 끝내고, 실행 준비가 되었을 때 발생하는 이벤트이다. 스프링이 이 시점에 해당 애노테이션이 붙은 initData() 메서드를 호출해 준다.

vbn.jpg.webp

스프링이 뜬 이후에 test data init이 출력된다.

package hello.itemservice;

@Import(MemoryConfig.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {
.
.
.
}

여기서 @SpringBootApplication에 아무것도 지정하지 않으면 현재 위치 hello.itemservice랑 하위가 전부 컴포넌트 스캔 대상이 된다.

프로젝트 구조 설명3 - 테스트

clearStore()는 인터페이스에 만들기엔 너무 위험한 메서드이다. DB의 데이터를 다 날려야 하므로.

그래서 MemoryItemRepository에만 만들었다.

ObjectUtils.isEmpty(itemName)

이건 null이거나 문자가 없을 때를 말한다.

assertThat(result).containsExactly(items);

containsExactly()는 순서도 다 맞아야 한다. 순서를 바꾸면 오류가 난다.

test("", null, item1, item2, item3);

이건 테스트 통과하지만

 

test(" ", null, item1, item2, item3);

이건 실패한다.

테스트할 땐 기본적으로 인터페이스를 기반으로 테스트하는 게 좋다. 그런데 하다 보면 구현체를 테스트해야 할 수도 있다. 그 기능 자체에 대한 검증이 필요할 수도 있다.

메모리로 하는 건 다 알아보았다. 다음 시간부터 DB로 넘어갈 거다.

데이터베이스 테이블 생성

대리 키(surrogate key): 비즈니스와 관련 없는 임의로 만들어진 키, 대체 키로도 불린다.

예: 오라클 시퀀스, auto_increment, identity, 키 생성 테이블 사용

 

완전히 비즈니스와 상관없이 그냥 시스템이 자동으로 값을 생성한다.

자연 키보다는 대리 키를 권장한다 -> 이건 의견이 갈릴 수는 있다.

정리

다음 시간부터는 실제 데이터베이스에 붙어서 하는, 그중에서도 첫 번째인 JdbcTemplate에 대해 알아보겠다.

데이터 접근 기술 - 스프링 JdbcTemplate

JdbcTemplate 적용1 - 기본

public class JdbcTemplateItemRepositoryV1 implements ItemRepository {

    private final JdbcTemplate template;

    public JdbcTemplateItemRepositoryV1(DataSource dataSource) {
        this.template = new JdbcTemplate(dataSource);
    }
.
.
.
}

JdbcTemplate에서 dataSource가 필요하다. 왜냐하면 여기서 커넥션도 만들고 그러기 때문이다.

JdbcTemplate을 그냥 쓸 땐 DB에서 생성해 준 id 값을 가져올 때 KeyHolder를 쓴다.

@Override
public Item save(Item item) {
    String sql = "insert into item(item_name, price, quantity) values(?, ?, ?)";
    KeyHolder keyHolder = new GeneratedKeyHolder();

    template.update(connection -> {
        // 자동 증가 키
        PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"});
        ps.setString(1, item.getItemName());
        ps.setInt(2, item.getPrice());
        ps.setInt(3, item.getQuantity());

        return ps;
    }, keyHolder);
    long key = keyHolder.getKey().longValue();
    item.setId(key);
    
    return item;
}

이 부분은 깊게 공부할 필요 없다.

그냥 값을 가져오려면 JdbcTemplate에선 이렇게 해야 한다 정도만 알면 된다.

@Override
public Optional<Item> findById(Long id) {
    String sql = "select id, item_name, price, quantity from item where id = ?";

    try {
        Item item = template.queryForObject(sql, itemRowMapper(), id);
        return Optional.of(item);
     } catch (EmptyResultDataAccessException e) {
        return Optional.empty();
    }
}

queryForObject()는 결과가 없으면 예외가 발생한다.

Optional.of()는 안이 null이면 안 된다.

Optional.ofNullable()은 null이어도 된다.

@Override
public List<Item> findAll(ItemSearchCond cond) {
    String itemName = cond.getItemName();
    Integer maxPrice = cond.getMaxPrice();

    String sql = "select id, item_name, price, quantity from item";

    // 동적 쿼리
    if (StringUtils.hasText(itemName) || maxPrice != null) {
        sql += " where";
    }
    boolean andFlag = false;
    List<Object> param = new ArrayList<>();
    if (StringUtils.hasText(itemName)) {
        sql += " item_name like concat('%',?,'%')";
        param.add(itemName);
        andFlag = true;
    }
    if (maxPrice != null) {
        if (andFlag) {
            sql += " and";
        }
        sql += " price <= ?";
        param.add(maxPrice);
    }
    log.info("sql={}", sql);

    return template.query(sql, itemRowMapper());
}

동적 쿼리 부분은 그냥 복붙으로 해결했다

findAll()은 결과가 하나 이상일 때 사용한다.

즉, 하나일 때도 사용한다.

RowMapper는 데이터베이스의 반환 결과인 ResultSet을 객체로 변환한다.

하나일 때도 쓰이고 여러 개일 때도 쓰인다.

findAll()은 결과가 없으면 빈 컬렉션을 반환한다. findById() 때랑 다른 듯

itemRowMapper(): 데이터베이스의 조회 결과를 객체로 변환할 때 사용한다. JDBC를 직접 사용할 때 ResultSet을 사용했던 부분을 떠올리면 된다. 차이가 있다면 다음과 같이 JdbcTemplate이 루프를 대신 돌려준다.

 

과거의 JDBC에선 개발자가 ResultSet에서 커서 이동하고 while 돌리고 했었다. 이젠 템플릿이 그런 부분을 해 준다. 개발자는 RowMapper를 구현해서 그 내부 코드만 채운다고 이해하면 된다.

JdbcTemplate 적용2 - 동적 쿼리 문제

JdbcTemplate의 단점은 동적 쿼리를 이렇게 개발자가 직접 짜야 한다는 점이다.

 

MyBatis의 가장 큰 장점은 SQL을 직접 사용할 때 동적 쿼리를 쉽게 작성할 수 있다는 점이다.

JdbcTemplate 적용3 - 구성과 실행

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=

여기서 spring.datasource.password=는 빼도 될지도

JdbcTemplate도 시간이 지나면서 더 편리한 기능들이 생겼다. 다음 시간에 배운다.

JdbcTemplate - 이름 지정 파라미터 2

Map<String, Object> param = Map.of("id", id);

new HashMap()을 해도 상관없다.

@Override
public Item save(Item item) {
    String sql = "insert into item(item_name, price, quantity) " +
            "values(:itemName, :price, :quantity)";

    SqlParameterSource param = new BeanPropertySqlParameterSource(item);
    KeyHolder keyHolder = new GeneratedKeyHolder();
    template.update(sql, param, keyHolder);

    long key = keyHolder.getKey().longValue();
    item.setId(key);

    return item;
}

SqlParameterSource param = new BeanPropertySqlParameterSource(item); 이걸 하면

Item 클래스에 있는 id, itemName, price, quantity가 다 파라미터로 만들어진다.

 

template.update(sql, param, keyHolder);

이렇게 하면 된다. 그중에서 매핑되는 건 쓰고, 매핑 안 되는 건 안 쓴다.

update()에서는 SQL에 :id를 바인딩 해야 하는데, update()에서 사용하는 ItemUpdateDto에는 itemId가 없다. 따라서 BeanPropertySqlParameterSource를 사용할 수 없고, 대신에 MapSqlParameterSource를 사용했다.

 

아니면 Map을 직접 사용해도 된다.

JdbcTemplate - 이름 지정 파라미터 3

JdbcTemplateV1 때가 깔끔한 건 있지만 순서가 바뀌는 순간 큰일 난다.

 

JdbcTemplateV2는 순서가 바뀌어도 문제없다.

동적 쿼리의 경우에도 BeanPropertySqlParameterSource를 써서 살짝 나아졌다. 미리 파라미터를 넣고 마지막에 바인딩 하기 때문에. 순서를 꼭 안 맞춰도 된다.

 

BeanPropertyRowMapper는 굉장히 많이 쓴다.

JdbcTemplate - SimpleJdbcInsert

        this.jdbcInsert = new SimpleJdbcInsert(dataSource)
                .withTableName("item")
                .usingGeneratedKeyColumns("id");
//                .usingColumns("item_name", "price", "quantity");  // 생략 가능

SimpleJdbcInsert가 dataSource를 통해서 DB에서 메타 데이터를 읽는다. 그래서 어떤 필드들이 있는지 자동으로 인지할 수 있다. 그래서 생략할 수 있다.

@Override
public Item save(Item item) {
    SqlParameterSource param = new BeanPropertySqlParameterSource(item);
    Number key = jdbcInsert.executeAndReturnKey(param);
    item.setId(key.longValue());

    return item;
}

DB에 있는 테이블명만 알면 그 테이블이 어떤 컬럼을 가지고 있는지는 메타 데이터로 알 수 있다. SimpleJdbcInsert 내부적으로 item_name, price, quantity 같은 이름을 다 알고 있다. 여기에 매칭되는 value 값만 넣으면 된다.

SqlParameterSource param = new BeanPropertySqlParameterSource(item);

SimpleJdbcInsert는 insert에서만 도움이 된다. 나머지 코드는 바뀌는 게 없다.

SimpleJdbcInsert를 스프링 빈으로 직접 등록하고 주입받아도 된다. 하지만

        this.jdbcInsert = new SimpleJdbcInsert(dataSource)
                .withTableName("item")
                .usingGeneratedKeyColumns("id");
//                .usingColumns("item_name", "price", "quantity");  // 생략 가능

테이블명을 item으로 항상 같은 걸 써야 한다. 그래서 빈으로 등록하지 않는 게 낫다.

usingGeneratedKeyColumns: key를 생성하는 PK 컬럼명을 지정한다. DB에서 key를 생성할 때 쓰면 된다.

 

usingColumns: INSERT SQL에 사용할 컬럼을 지정한다. 특정 값만 저장하고 싶을 때 사용한다. 생략하면 모든 컬럼을 쓴다.

113.jpg.webp

애플리케이션을 실행만 했는데 insert 쿼리가 보인다.

JdbcTemplate의 주요한 기능들을 다 사용해 봤다.

데이터 접근 기술 - 테스트

테스트 - 데이터베이스 연동

사실 이전 JdbcTemplate을 개발하고 나서 테스트 케이스에서 실행해 본 적은 없었다. 서버 실행을 해서 기능 확인했었다.

테스트에서 중요한 건 격리성이다. 테스트를 실행할 땐 데이터가 없어야 한다. 물론 의도적으로 몇 가지 데이터를 넣어 두고 테스트하는 경우도 있기는 한다.

테스트 - 데이터 롤백

@Test
void save() {
    //given
    Item item = new Item("itemA", 10000, 10);

    //when
    Item savedItem = itemRepository.save(item);

    //then
    Item findItem = itemRepository.findById(item.getId()).get();
    assertThat(findItem).isEqualTo(savedItem);
}

저장을 해도 굳이 커밋할 필요는 없다. 왜냐하면 내 트랜잭션 안에서는 굳이 커밋을 하지 않아도, 임시 상태로 데이터가 저장되어 있어도 나는 조회할 수 있다. 다른 세션이나 다른 트랜잭션에서 조회가 안 되는 거지 내가 시작한 세션에선 임시 상태 데이터를 커밋하지 않아도 다 조회할 수 있다.

@BeforeEach: 각각의 테스트 케이스를 실행하기 직전에 호출된다. 따라서 여기서 트랜잭션을 시작하면 된다. 그러면 각각의 테스트를 트랜잭션 범위 안에서 실행할 수 있다.

 

@Test
void save() {
    //given
    Item item = new Item("itemA", 10000, 10);

    //when
    Item savedItem = itemRepository.save(item);

    //then
    Item findItem = itemRepository.findById(item.getId()).get();
    assertThat(findItem).isEqualTo(savedItem);
}

Repository에서 save()를 호출한다.

 

@Override
public Item save(Item item) {
    SqlParameterSource param = new BeanPropertySqlParameterSource(item);
    Number key = jdbcInsert.executeAndReturnKey(param);
    item.setId(key.longValue());

    return item;
}

jdbcInsert든 jdbcTemplate이든 트랜잭션 동기화 매니저에 있는 커넥션을 가져다 쓴다. 그래서 같은 트랜잭션을 사용한다.

테스트 - @Transactional

테스트에 @Transactional이 있으면 먼저 트랜잭션을 시작하고 테스트를 돌린다.

@Transactional이 클래스 레벨에 있으면 모든 테스트에 적용이 되고, 메서드 레벨에 있으면 메서드에만 적용된다.

커밋을 안 해도 조회할 수 있다. 왜냐하면 임시 데이터라고 해도 내가 넣은 데이터이므로 조회 가능하다. 다른 세션에선 못 본다.

트랜잭션에 참여한다는 말은 기존 트랜잭션에 이어진다는 것이다. 테스트에서 트랜잭션을 시작했는데 서비스나 Repository에도 @Transactional이 있으면, 테스트에서 이미 트랜잭션을 시작했기 때문에 다른 건 다 그 트랜잭션을 같이 쓴다.

제대로 저장됐는지 보고 싶으면 @Transactional을 걸고, @Commit을 하면 된다.

 

@Transactional
@Commit
@Test
void save() {
    //given
    Item item = new Item("itemA", 10000, 10);

    //when
    Item savedItem = itemRepository.save(item);

    //then
    Item findItem = itemRepository.findById(item.getId()).get();
    assertThat(findItem).isEqualTo(savedItem);
}

또는 @Commit 대신 @Rollback(false)를 해도 된다.

 

물론 @Transactional이 클래스에도 있으니 이건 빼도 된다.

@Commit을 클래스 레벨에 붙이면 모든 테스트에 적용된다.

테스트 - 임베디드 모드 DB

테스트용으로는 임베디드 모드도 편리하다.

dataSource.setDriverClassName("org.h2.Driver");

H2 Driver를 지정해 준거다. 이 드라이버를 쓰겠다는 거다.

@Bean
@Profile("test")
public DataSource dataSource() {
    log.info("메모리 데이터베이스 초기화");
    DriverManagerDataSource dataSource = new DriverManagerDataSource();
    dataSource.setDriverClassName("org.h2.Driver");
    dataSource.setUrl("jdbc:h2:mem:db;DB_CLOSE_DELAY=-1");
    dataSource.setUsername("sa");
    dataSource.setPassword("");

    return dataSource;
}

이렇게 하면 JVM 내에 DB를 만들고 거기에 데이터를 쌓는다.

테스트를 실행하기 전에 테이블을 먼저 생성해 주어야 한다.

 

JdbcTemplate에서 execute()를 통해 DDL을 넣을 수도 있다. 하지만 스프링 부트를 사용한다면 더 편한 방법이 있다.

 

메모리 DB는 애플리케이션이 종료될 때 함께 사라지기 때문에, 애플리케이션 실행 시점에 데이터베이스 테이블도 새로 만들어 주어야 한다. 스프링 부트는 SQL 스크립트를 실행해서 애플리케이션 로딩 시점에 데이터베이스를 초기화하는 기능을 제공한다.

 

테스트에서 할 거니깐 src/test/resources/schema.sql 이 위치에 생성해야 한다.

 

drop table if exists item CASCADE;
create table item
(
 id bigint generated by default as identity,
 item_name varchar(10),
 price integer,
 quantity integer,
 primary key (id)
);

사실 drop에 대한 부분은 없어도 된다.

 

이거 먼저 실행해서 테이블을 만들고, 그다음에 테스트를 돌린다.

실행하면 메모리 모드로 H2 DB가 뜬다. 그리고 스프링 부트가 schema.sql을 실행해서 DB 테이블을 만들어 준다. 그러면서 스프링 부트가 완전히 초기화된다. 그리고 나서 데이터베이스가 메모리 모드로 커넥션을 획득한다.

g.jpg.webp

 

Executed SQL script 같은 로그가 남는 이유는

logging.level.org.springframework.jdbc=debug

이거 때문인 듯

이렇게 해서 DB를 직접 안 띄워도 되게 되었다.

테스트 - 스프링 부트와 임베디드 모드

c.jpg.webp

스프링이 자동으로 만들어 준 URL이다.

jdbc:h2:mem...

메모리 모드로 동작한다는 거다.

mem 뒤에 있는 문자들은 스프링이 임의로 만들어 준 데이터베이스 이름이다.

임베디드 데이터베이스 이름을 스프링 부트가 기본으로 제공하는 jdbc:h2:mem:testdb로 고정하고 싶으면 application.properties에 다음 설정을 추가하면 된다.

spring.datasource.generate-unique-name=false

 

굳이 안 하는 게 낫다.

지금까지 과정을 알기 위해 쭉 배웠지만

 

결론적으로 테스트 파일에서 @Transactional 넣으면 된다.

메모리 DB로 뜨기 때문에 설정에서 세팅 안 해도 된다.

설정이 없으면 데이터 소스가 기본적으로 메모리 모드로 동작하는 데이터 소스가 만들어진다.

정리

데이터 접근 기술의 경우엔 테스트할 땐 데이터베이스를 붙여서 확인해 봐야 한다. 그렇지 않으면 데이터 접근 기술이 제대로 동작하는지 확인하기 어렵다. 실제 DB에 데이터가 들어가고 빠지는 이 과정을 위해 데이터 접근 기술이 존재한다.

테스트가 끝나면 그 데이터들은 필요 없다.

자바가 뜰 때 테스트를 쭉 수행하고, JVM이 내려가면 DB도 같이 사라져도 된다.

@Bean
@Profile("test")
public DataSource dataSource() {
log.info("메모리 데이터베이스 초기화");
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName("org.h2.Driver");
dataSource.setUrl("jdbc:h2:mem:db;DB_CLOSE_DELAY=-1");
dataSource.setUsername("sa");
dataSource.setPassword("");
return dataSource;
}
}

이 데이터 소스를 통해 얻는 커넥션은 다 메모리 DB로 접근한다.

우리가 데이터 소스를 직접 만들지 않으면 스프링 부트가 데이터 소스를 만들어 준다. 그런데 데이터 소스 등의 아무 설정이 없으면 스프링 부트가 그냥 임베디드 데이터베이스를 만들어서(물론 H2처럼 임베디드 데이터베이스가 가능한 경우에), 스프링 부트가 임베디드 데이터베이스에 접근하는 데이터 소스를 만들어서 우리에게 제공한다.

 

그래서 테스트에서 이런 설정을 안 하고 그냥 데이터 소스를 주입받으면 임베디드 데이터베이스로 바로 돌릴 수 있다.

데이터 접근 기술 - MyBatis

MyBatis 소개

<select id="findAll" resultType="Item">
 select id, item_name, price, quantity
 from item
 <where>
 <if test="itemName != null and itemName != ''">
 and item_name like concat('%',#{itemName},'%')
 </if>
 <if test="maxPrice != null">
 and price &lt;= #{maxPrice}
 </if>
 </where>
</select>
<where>

이게 있으면 조건에 값이 있을 때 where가 출력된다.

MyBatis는 약간의 설정이 필요하다. 그런데 이것도 최근엔 스프링 부트랑 MyBatis를 연동하는 모듈들을 쓰면 꽤 편리하게 통합해서 사용할 수 있다.

ORM 기술을 사용할 땐 JPA, 스프링 데이터 JPA, Querydsl을 보통 조합해서 쓴다. 이럴 땐 동적 쿼리도 어느 정도 해결이 된다. 그래도 가끔 네이티브 SQL을 써야 할 때가 있다. 간단하면 JdbcTemplate으로 거의 대부분 해결된다. 동적 쿼리는 Querydsl을 쓰면 많이 해결된다.

이 경우엔 대부분 SQL(네이티브 SQL)을 직접 써야 하는 경우엔 MyBatis나 JdbcTemplate 중 선택해야 한다. 기술 스택을 ORM을 선택한 경우엔 대부분 JdbcTemplate 쓰게 될 듯? 물론 그래도 복잡한 네이티브 SQL을 직접 써야 할 일이 많다면, 동적 쿼리로 써야 한다면 MyBatis를 같이 쓰면 된다.

MyBatis 설정

#MyBatis
mybatis.type-aliases-package=hello.itemservice.domain
mybatis.configuration.map-underscore-to-camel-case=true
logging.level.hello.itemservice.repository.mybatis=trace

 

mybatis.type-aliases-package=hello.itemservice.domain

마이바티스에서 특정한 파라미터나 응답 값 같은 걸 원래는 패키지명까지 xml에 다 적어야 하는데 그걸 다 안 적어도 되도록 해 준다. hello.itemservice.domain 이 패키지 관련된 부분들은 마이바티스에서 자동으로 인식해서 파라미터나 응답 값에 패키지명을 지정하지 않아도 되게 한다.

 

mybatis.configuration.map-underscore-to-camel-case=true

이렇게 하면 데이터베이스엔 underscore로 되어 있어도, 객체에 카멜 표기법으로 자동 변환해 준다. 기본이 false이므로 넣어 줘야 한다.

 

logging.level.hello.itemservice.repository.mybatis=trace

여기 로그들이 출력된다.

MyBatis가 과거엔 iBatis였다. 패키지에 과거의 흔적이 아직 남아 있을 수 있다.

MyBatis 적용1 - 기본

@Mapper
public interface ItemMapper {

    void save(Item item);

    void update(@Param("id") Long id, @Param("updateParam")ItemUpdateDto updateDto);

    List<Item> findAll(ItemSearchCond itemSearch);

    Optional<Item> findById(Long id);
    
}

인터페이스에서 xml에 있는 SQL을 부를 거다.

마이바티스가 Optional도 지원해 준다.

List<Item> findAll(ItemSearchCond itemSearch);

이건 파라미터가 하나 들어간다. ItemSearchCond 안에 있는

@Data
public class ItemSearchCond {

    private String itemName;
    private Integer maxPrice;
.
.
.
}

이 안에 있는 걸 이 이름(itemName, maxPrice인 듯)으로 파라미터로 쓸 수 있다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="hello.itemservice.repository.mybatis.ItemMapper">
.
.
.
</mapper>

namespace에 앞서 만든 인터페이스가 지정되어야 한다. 연동된다.

<insert id="save" useGeneratedKeys="true" keyProperty="id">
    insert into item (item_name, price, quantity)
    values (#{itemName}, #{price}, #{quantity})
</insert>

여기의 #{itemName} 같은 파라미터는 뭐로 바인딩이 되냐 하면,

 

ItemMapper의 void save(Item item);에서 Item 클래스를 보면

 

@Data
public class Item {

    private Long id;

    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }

}

여기의 속성들을 getItemName() 이런 식으로 꺼내 온다.

 

useGeneratedKeys="true" keyProperty="id"

이러면 Item 객체의 id에 값 세팅까지 해 주는 듯

<update id="update">
    update item
    set item_name = #{updateParam.itemName},
        price = #{updateParam.price},
        quantity = #{updateParam.quantity}
    where id = #{id}
</update>

ItemMapper에서

void update(@Param("id") Long id, @Param("updateParam")ItemUpdateDto updateDto);

이것의 경우 파라미터가 2개이다. 이 경우엔 @Param에 있는 "updateParam"을 사용해서

 

updateParam.itemName 이런 식으로 써야 한다.

<select id="findById" resultType="Item">
    select id, item_name, price, quantity
    from item
    where id = #{id}
</select>

원래는 resultType="hello.itemservice.domain.Item" 이렇게 적어야 한다.

설정에서

mybatis.type-aliases-package=hello.itemservice.domain

이걸 써서 생략 가능해졌다. 사실

mybatis.type-aliases-package=hello.itemservice

또는

mybatis.type-aliases-package=hello

이렇게 해도 된다. 그러면 그 밑에 있는 건 다 생략할 수 있다. 다만 패키지 충돌 등 때문에 자세히 적는 게 나을 수 있다. 예를 들어 패키지명은 다른데 파일명은 같은 경우 충돌이 날 수 있다.

<select id="findById" resultType="Item">
    select id, item_name, price, quantity
    from item
    where id = #{id}
</select>

만약 파라미터가 하나뿐이라면

 

<select id="findById" resultType="Item">
    select id, item_name, price, quantity
    from item
    where id = #{values}
</select>

이런 식으로 아무 이름 적어도 인식이 될 듯. 정말 맞는지 확인해 보기

1112.jpg.webp

기본적으로 xml 파일 위치를 mapper랑 맞춰야 한다. 패키지명에 인터페이스명까지 맞춰야 한다. 이름은 대소문자 구분 안 하게 할 수 있을지도.

xml 파일들이 여러 군데에 분산될 수 있다. 그래서 xml 파일들을 원하는 위치에 모아 두고 싶다거나 나만의 분류 체계를 가지고 싶다면 다음을 참고하자.

 

XML 파일을 원하는 위치에 두고 싶으면 application.properties에 다음과 같이 설정하면 된다.

mybatis.mapper-locations=classpath:mapper/**/*.xml

이렇게 하면 resources/mapper를 포함한 그 하위 폴더에 있는 XML을 XML 매핑 파일로 인식한다. 이 경우 파일 이름은 자유롭게 설정해도 된다. 왜냐하면 어차피

<mapper namespace="hello.itemservice.repository.mybatis.ItemMapper">

이거로 구분이 된다.

#{} 문법을 사용하면 PreparedStatement를 사용한다. JDBC의 ?를 치환한다고 생각하면 된다.

 

어차피 이름 기반으로 동작하기 때문에 NamedParameter처럼 동작한다.

<select id="findById" resultType="Item">
    select id, item_name, price, quantity
    from item
    where id = #{id}
</select>

JdbcTemplate의 BeanPropertyRowMapper처럼 SELECT SQL 쿼리 결과를 편리하게 Item 객체로 바로 변환해 준다.

Optional<Item> findById(Long id);

여기의 Item 객체로 반환해 준다.

BeanPropertyRowMapper의 기능이 자동으로 적용되어 있다고 보면 된다.

MyBatis 적용2 - 설정과 실행

@Repository
@RequiredArgsConstructor
public class MyBatisItemRepository implements ItemRepository {

    private final ItemMapper itemMapper;
.
.
.
}

의존 관계 주입이 된다.

 

@Mapper
public interface ItemMapper {
.
.
.
}

이렇게 @Mapper라고 해 두면 마이바티스 스프링 모듈에서 이걸 자동으로 인식한다. 그리고 이것의 구현체를 알아서 만들어 내서 스프링 빈에 등록해 준다. 그래서 이 구현체를 의존 관계 주입받을 수 있다.

@Configuration
@RequiredArgsConstructor
public class MyBatisConfig {

    private final ItemMapper itemMapper;

    @Bean
    public ItemService itemService() {
        return new ItemServiceV1(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository() {
        return new MyBatisItemRepository(itemMapper);
    }
}

JdbcTemplate의 Config들에선 데이터 소스를 받았는데, 여기선 데이터 소스를 받지 않았다. 마이바티스 모듈이 데이터 소스나 트랜잭션 매니저 같은 걸 다 읽어서 itemMapper랑 연결시켜 준다.

지금은 메모리 모드로 동작하기 때문에 H2 DB가 없어도 된다. ItemRepositoryTest를 실행해 보자.

 

물론 애플리케이션을 실행할 땐 H2 DB를 실행해야 한다.

MyBatis 적용3 - 분석

우리가 주입받은 건 인터페이스가 아니라, 마이바티스 스프링 연동 모듈이 만든 동적 프록시 객체다.

@Override
public Item save(Item item) {
    log.info("itemMapper class = {}", itemMapper.getClass());
    itemMapper.save(item);

    return item;
}

이렇게 로그를 찍어 보면 내 인텔리제이 기준으로

 

56.jpg.webp

itemMapper class = class jdk.proxy3.$Proxy72

이게 출력되었다.

ItemMapper를 구현한 클래스 이름이 $Proxy72이다. 그리고 이걸 객체 인스턴스로 만들었다.

 

보통 우리는 클래스를 정의해서 만들지만, 이건 라이브러리 안에서 실시간으로 만든 거다.

마이바티스 스프링 연동 모듈이 만들어 주는 ItemMapper의 구현체 덕분에 인터페이스만으로 편리하게 XML의 데이터를 찾아서 호출할 수 있다.

 

원래대로라면 개발자가 직접 xml에 있는 뭐를 찾아서 호출하고... 그게 List면 어떻게 하고 같은 로직을 다 넣어야 하는데 그걸 다 해 준다.

매퍼 구현체는 예외 변환까지 처리해 준다. MyBatis에서 발생한 예외를 스프링 예외 추상화인 DataAccessException에 맞게 변환해서 반환해 준다.

<insert id="save" useGeneratedKeys="true" keyProperty="id">
    insertqqq into item (item_name, price, quantity)
    values (#{itemName}, #{price}, #{quantity})
</insert>

insertqqq 일부러 오타를 내고 실행하면 로그에 BadSqlGrammarException이 뜬다.

 

xc.jpg.webp

원래 터진 이유는 JdbcSQLSyntaxErrorException 이거인데, BadSqlGrammarException으로 바뀐 듯

MyBatis 기능 정리1 - 동적 쿼리

JPA나 스프링 데이터 JPA, Querydsl 같은 기술들을 쓰면

foreach

<select id="selectPostIn" resultType="domain.blog.Post">
SELECT *
FROM POST P
<where>
    <foreach item="item" index="index" collection="list"
             open="ID in (" separator="," close=")" nullable="true">
        #{item}
    </foreach>
</where>
</select>

이런 거 없어도 파라미터를 컬렉션으로 넘기면 스프링 데이터 JPA나 Querydsl에서는 자동으로 해결해 준다.

MyBatis 기능 정리2 - 기타 기능

@Select("select id, item_name, price, quantity from item where id=#{id}")

이런 식으로는 사실 잘 안 쓴다.

 

참고로 이건 ItemMapper 인터페이스에 썼다.

@Select("select * from user where ${column} = #{value}")
User findByColumn(@Param("column") String column, @Param("value") String value);

where 뒤의 column은 ? 파라미터 바인딩으로 못 한다. 그래서 $를 써야 한다. ?로 파라미터 바인딩을 할 수 없는 위치이다.

 

${}를 사용하면 SQL 인젝션 공격을 당할 수 있다. 따라서 가급적 사용하면 안 된다. 사용하더라도 매우 주의 깊게 사용해야 한다.

정리

동적 SQL에서 거의 where 쓰는 듯

결과 매핑하는 게 복잡하면 Result Map을 쓴다. 대부분은 그냥 간단하게 as를 쓴다.

데이터 접근 기술 - JPA

JPA 시작

스프링 데이터 JPA는 JPA를 편리하게 사용할 수 있도록 스프링이 제공하는 유틸리티들의 모음 같은 거다.

ORM 개념1 - SQL 중심적인 개발의 문제점

지금 시대엔 애플리케이션은 객체로, 데이터 저장은 관계형 DB에 저장한다.

지금은 객체를 관계형 DB에 관리하는 시대다.

 

그런데 관계형 DB는 SQL만 알아들을 수 있다.

보통 insert, update, select, delete, 자바 객체를 SQL로 혹은 그 반대로... 이런 걸 무한 반복하는 코드를 짰었다. 물론 마이바티스나 JdbcTemplate을 쓰면 많은 부분들이 해결되지만 그래도 결국 내가 SQL을 직접 작성해야 한다.

12.jpg.webp

만약 여기에 private String tel이란 필드가 추가되면 모든 쿼리에 다 연락처 필드를 넣어야 한다.

 

결국 SQL에 의존적인 개발을 피하기 어렵다. SQL에 다 잘 입력하면 괜찮은데 실수로 update 쿼리에 연락처 수정하는 걸 까먹으면?

객체에 연락처 컬럼을 넣었는데 SQL엔 이 컬럼을 빼먹으면 버그가 생긴다.

객체와 다르게 테이블은 원칙적으로 상속이란 개념이 없다. 슈퍼 타입, 서브 타입 관계라는 게 있기는 한데, 이건 객체에서 말하는 상속 관계가 아니라 그나마 비슷한 거다.

123.jpg.webp

왼쪽의 Album 객체를 저장하려고 한다. insert를 하려고 한다.

우선 Album에 대한 데이터를 분해한 다음에 Album에 대한 데이터는 오른쪽 ALBUM에 insert를 하고, Item에 대한 데이터는 오른쪽 ITEM에 insert를 해야 한다. 이렇게 쿼리를 두 번으로 쪼개서 insert 해야 한다.

 

이번엔 Album을 조회한다고 생각해 보자. 조회하려면 오른쪽의 ITEM이랑 ALBUM을 조인해야 한다. 그다음에 데이터를 Album에 맞게 설정하고... ITEM에 있는 데이터는 Item 객체에 맞게 넣고 해야 한다. 이런 걸 각각마다 반복해야 한다. 그래서 DB에 저장할 객체엔 상속 관계를 잘 안 쓴다.

 

그럼 자바 컬렉션에 album을 저장한다고 가정하면?

list.add(album);

 

자바 컬렉션에서 조회하면?

Album album = list.get(albumId);

 

부모 타입으로 조회 후 다형성 활용

Item item = list.get(albumId);

 

이렇게 코드 한 줄로 다 끝난다. 어차피 자바 것이기 때문에 그냥 컬렉션에 넣었다 빼면 된다.

23.jpg.webp

객체는 참조를 사용한다. Member가 Team을 가지고 있으면 member.getTeam() 이런 식으로 Team을 조회할 수 있다.

 

테이블은 외래키를 사용한다. 그래서 외래키로 조인해야 한다.

24.jpg.webp

이런 식으로 하는 이유는 테이블 연관 관계에서 MEMBER 테이블에 TEAM_ID가 외래키로 있다. 여기에 값을 넣어야 하는데 Team 객체를 외래키 값으로 넣을 순 없다.

 

25.jpg.webp

이런 식으로 한다. 그런데

 

26.jpg.webp

객체다운 모델링은 Member에 외래키 값이 들어가는 게 아니고, Member가 Team을 참조해야 한다. 참조를 통해 연관 관계를 맺는 게 더 객체다운 모델링이다.

27.jpg.webp

그런데 여기에 문제가 있다. Team을 어떻게 insert 해야 할까?

member.getTeam().getId(); 이렇게 team의 id를 꺼내야 한다. 이런 식으로 해도 된다. 하지만 번잡하다.

23.jpg.webp

개발자 입장에서 이렇게 하려면 상당히 번잡하다.

 

28.jpg.webp

그런데 자바 컬렉션에서 관리하면 번잡하진 않다.

221.jpg.webp

객체는 자유롭게 객체 그래프를 탐색할 수 있어야 한다. 항상 그렇지는 않다.

 

Team에서 Member 조회하고, Member에서 Order 조회하고 이런 식으로 객체 그래프를 탐색할 수 있어야 한다.

 

그런데 SQL을 사용하는 순간 처음 실행하는 SQL에 따라서 탐색 범위가 결정된다.

 

zxc.jpg.webp

처음 쿼리를 이렇게 짰다. Member랑 Team만 조회(조인?)했다. Member랑 Team에 대한 연관 관계만 세팅했다. member.getTeam()이라고 하면 조회가 된다. 그런데 member.getOrder()라고 하면 안 된다.

 

처음 실행하는 SQL에 따라서, member를 조회할 때 어디까지 이 객체 그래프를 탐색할 수 있는지 범위가 결정된다. 그러면서 엔터티에 대한 신뢰 문제가 생긴다.

223.jpg.webp

Member란 비즈니스 객체를 엔터티라고 하자.

MemberRepository 이런 데에서 memberId를 넣어서 Member를 조회했다. 조회하는 입장에서 위 코드만 볼 때 getTeam()을 할 때 이 값이 null인지 아닌지 어떻게 알 것인가? member.getOrder().getDelivery() 이렇게 하면 배송 정보까지 이 값이 있는지 없는지를 확인하는 방법은, memberDAO의 코드를 까서 memberDAO.find(memberId)에서 어떤 SQL이 실행되었는지를 다 뒤져 봐야 한다. 이런 문제가 있다.

모든 객체를 미리 로딩할 수 없다.

Member 조회할 때 위 객체 그래프에 있는 걸 다 한 번에 조회하면 되지 않을까? 그러려면 조인을 너무 많이 해야 한다. 그리고 불가능에 가깝다. 메모리가 폭주한다.

 

그래서 이렇게 짤 수 있다.

224.jpg.webp

상황에 따라 동일한 회원 조회 메서드를 여러 벌 생성해야 한다.

memberDAO.getMember()를 하면 Member만 조회된다.

memberDAO.getMemberWithTeam()을 하면 Member와 Team이 조회된다. 그런데 이렇게 케이스별로 다 만들 수도 없다.

memberDAO.getMemberWithOrderWithDelivery() 이런 것도 만들어야 하는데 힘들다.

진정한 의미의 계층 분할이 어렵다.

223.jpg.webp

(memberDAO를 Repository라고 하자)

이 코드를 봤을 때 member에 대해서 Team이 있는지, Order가 있는지를 판단하려면 이 코드만 봐선 믿을 수 없다.

MemberService랑 memberDAO(Repository)랑 물리적으로는 구분되어 있다. 그런데 논리적으로는 memberDAO.find(memberId)에서 실행한 SQL에 따라서 member가 어디까지 탐색 범위를 가질지가 결정된다.

 

결론적으로 내가 이 서비스 로직을 작성하려면 memberDAO 코드를 다 파서 어떤 SQL이 실행돼서 데이터를 어떤 걸 담았는지를 눈으로 확인하기 전까지는 이 코드를 함부로 못 짠다.

 

즉, 물리적으로는 계층이 분할되었지만, 논리적으로는 계층이 분할되어 있지 않다. 그래서 진정한 의미의 계층 분할이 어렵다.

29.jpg.webp

코드를 보면 비교했을 때 다르게 나온다.

 

30.jpg.webp

그런데 자바 컬렉션에서 조회하면 같다고 나온다.

객체답게 모델링을 할수록... member.setTeam() 연관 관계를 만들수록 매핑 작업만 늘어난다.

 

그러면 객체를 자바 컬렉션에 저장하듯이 DB에 저장할 수는 없을까? 그래서 제공되는 게 JPA다.

ORM 개념2 - JPA 소개

JPA는 자바 진영의 ORM 기술 표준이다. 기술 표준이란 건 뭐냐 하면, 이거 가지고 뭔가 동작하는 건 아니고, 인터페이스이다. 그에 대한 구현체는 따로 있다.

ORM(Object-relational mapping(객체 관계 매핑))

객체랑 관계형 데이터베이스를 어떻게 매핑할지에 대한 얘기다.

 

객체는 객체대로, 관계형 DB는 관계형 DB대로 설계한다. 그러면 이 두 개에 차이가 있을 텐데 이걸 ORM 프레임워크가 중간에서 매핑해 준다. 그래서 우리는 객체처럼 쓴다.

이번 PPT에서도 MemberDAO를 Repository라고 생각하자.

343.jpg.webp

PERSIST는 저장하는 것인 듯

MemberDAO에서 Member 객체(Entity Object)를 JPA에 넘겨주면 JPA가 이 객체를 분석한다. 그리고 insert SQL을 만든다. 그리고 JDBC API를 사용해서 DB에 넣는다.

 

중요한 건, 패러다임 불일치를 해결한다. 뒤에서 설명한다.

555.jpg.webp

JPA는 자바 컬렉션처럼 생겼다. find(id) 이렇게 id를 넘기면 select 쿼리를 만든다. 그래서 DB에 넘긴 다음에 결과가 오면 ResultSet 매핑을 다 해 준다. 그리고 Member 객체를 만들어서 우리에게 반환해 준다. 자바 컬렉션에 조회하는 것처럼 할 수 있다.

 

SQL 만들고 이런 건 JPA가 해 준다. 그리고 더 중요한 건 패러다임 불일치를 해결해 준다.

31.jpg.webp

JPA는 인터페이스의 모음이다. JPA라는 표준 인터페이스를 하이버네이트, EclipseLink 등의 오픈 소스 진영에서 구현해 냈다. 상용 벤더로 구현한 곳도 있다. 그런데 거의 다 하이버네이트를 사용한다.

JPA Criteria는 별로 안 중요한 듯

JPA를 왜 사용해야 하는가?

 

SQL 중심적인 개발에서 객체 중심으로 개발

생산성 - 개발자는 개발자가 할 일, 나머지는 JPA 프레임워크에 맡긴다.

유지 보수 관점에서도 편리하다

패러다임의 불일치 해결

성능 관점에서도 최적화할 수 있는 게 많다.

데이터 접근 추상화와 벤더 독립성

표준

22222.jpg.webp

수정이 특이하다. 그냥 member.setName("변경할 이름")

이렇게 하면 된다.

 

자바 컬렉션을 생각해 보자. 자바 컬렉션에서 객체를 조회하고 값 변경은 어떻게 하나? 그냥 조회한 객체의 값만 변경하면 된다. 내가 그걸 다시 컬렉션에 넣을 필요가 없다. 어차피 컬렉션에 있는 거랑 같은 참조다.

332333.jpg.webp

유지 보수 관점에서도 tel이란 새로운 필드를 넣으려고 하면 기존엔 모든 SQL을 뒤져서 넣어야 한다.

 

그런데 JPA는 Member에 필드만 추가하면 SQL에 자동으로 추가된다. 이런 건 JPA가 처리한다. 물론 테이블도 변경해야 한다.

a.jpg.webp

JPA가 객체와 관계형 DB 사이의 패러다임 불일치도 해결해 준다.

b.jpg.webp

JPA는 이 두 개를 매핑해 준다.

 

c.jpg.webp

개발자가 album을 저장하고 싶어 한다. album을 저장만 하면 된다. insert 쿼리를 ITEM에도 하고 ALBUM에도 해야 한다. 두 번을 해야 한다.

 

그런데 album을 저장하면 나머지는 JPA가 insert 쿼리 두 번 쪼개서 쿼리 두 번 날려서 저장해 준다.

d.jpg.webp

album을 조회하려고 한다. 원래 album을 조회하려면 ALBUM과 ITEM 테이블을 조인해야 한다. 왜냐하면 Album 객체를 만드려면 Album 객체와 부모인 Item 객체 다 만들어야 하기 때문이다.

 

그런데 jpa.find(Album.class, albumId) 이런 식으로 하면

JPA가 알아서 ITEM과 ALBUM을 조인해서 그 데이터를 가져와서 다 채워서 반환해 준다.

 

이러한 패러다임의 불일치까지 JPA가 해결해 준다.

e.jpg.webp

member.setTeam(team); 이렇게 연관 관계를 넣었다.

그다음에 jpa.persist(member); 하면 외래키 값 다 JPA가 알아서 고민해서 insert 쿼리 다 만들어 준다. 그러면 DB의 Member에 외래키 값에 이 team의 외래키 값(team의 id)이 들어가 있다.

 

객체 그래프 탐색도 된다.

Member member = jpa.find(Member.class, memberId); 이렇게 Member를 조회하면 member.getTeam() 하면 team의 값이 나온다. 이 이유는 뒤에서 설명한다.

f.jpg.webp

Member를 이렇게 조회해 오면 JPA에선 이렇게 member.getTeam()을 하면(물론 멤버와 연관된 팀이 있다는 전제하에) 자유롭게 객체 그래프를 탐색한다. 주문도 마찬가지다. 물론 회원이 한 번이라도 주문을 했어야 한다.

String memberId = "100";

Member member1 = jpa.find(Member.class, memberId);

Member member2 = jpa.find(Member.class, memberId);

member1 == member2; //같다.

 

JPA는 마치 자바 컬렉션과 같다. 자바 컬렉션이 DB랑 알아서 해결해 준다고 이해하면 편하다. 100으로 같은 PK 넘기면 JPA는 같은 객체를 반환한다.

 

생각해 보면 자바 컬렉션에서 같은 id 식별자를 넣어서 조회하면 같은 객체가 반환된다.

 

JPA는 항상 이렇게 되는 건 아니고 동일한 트랜잭션에서 조회한 엔터티는 같음을 보장한다.

xc.jpg.webp

JPA는 애플리케이션이랑 DB 사이에 하나의 계층이 있는 거다.

둘 사이에 계층이 있으면 두 가지 기능을 할 수 있다.

첫 번째는 캐시

두 번째는 버퍼링 라이트

이걸 통해 성능 최적화를 할 수 있다.

 

JPA는 많은 부분을 성능 최적화를 중간에서 자동으로 해 준다.

fd.jpg.webp

2번은 좀 어려운 내용이라 넘어가겠다.

 

같은 트랜잭션 안에서는 같은 엔터티를 반환한다. 이게 약간의 조회 성능을 향상시킬 수 있다.

String memberId = "100";

Member m1 = jpa.find(Member.class, memberId); // SQL

Member m2 = jpa.find(Member.class, memberId); // 캐시

println(m1 == m2) // true SQL 1번만 실행

 

Member m1 = jpa.find(Member.class, memberId);를 처음 하면 이땐 SQL이 날아간다. 그런데 또 같은 식별자로 애플리케이션에서 조회하면 자동으로 캐시가 적용된다. 1차 캐시라고 한다. 그래서 true가 나온다.

 

물론 이렇게 단순한 경우엔 두 개를 이렇게 조회 안 할 거다. 그런데 여러 메서드에 흩어져 있고 그럴 때 같은 걸 조회할 땐 1차 캐시 효과를 얻을 수 있다.

zxc.jpg.webp

방금 전까지 캐시 얘기였다면 이건 버퍼링 라이트다. 버퍼를 모아서 한 번에 라이트한다. 트랜잭션을 커밋할 때까지 JPA는 insert SQL을 기본적으로 모은다.

 

트랜잭션을 시작하고, memberA, B, C를 JPA에 저장한다. 그러면 JPA가 member A, B, C를 일단 모아 둔다. 트랜잭션을 커밋하기 전까진 항상 DB에 실제로 반영되는 게 아니다. 그러니까 모아 둔다. 커밋을 하는 순간 이 세 개를 모아서 한 번에 DB에 보낸다. 네트워크 통신 한 번으로 보낸다. 이렇게 성능 최적화를 할 수 있다. 이런 걸 옵션을 켜면 할 수 있다.

 

과거엔 이런 걸 하려면 JDBC BATCH SQL이란 기능이 있다. 이걸 쓰면 되는데 매우 로직이 복잡하다. 이런 걸 중간에서 다 자동으로 해 준다.

32.jpg.webp

update는 내용이 복잡해서 넘어가겠다.

xxc.jpg.webp

지연 로딩은 객체가 실제 사용될 때 로딩하는 거고, 즉시 로딩은 처음 조회할 때 한 번에 조인으로 필요한 연관 관계들을 다 조회하는 거다.

 

지연 로딩을 먼저 말하자면

객체 그래프를 자유롭게 탐색할 수 있어야 한다고 했었다. 그래야 이걸 신뢰할 수 있다.

Member member = memberDAO.find(memberId); 이렇게 Member를 조회하면 이 안에서 select 쿼리로 조회한다. 그럼 Member가 나온다.

그리고 member.getTeam()을 한다. 그런데 위 select 쿼리엔 팀이 없다.

이런 경우엔 팀을 가져온 다음에, 실제 team.getName() 즉, team의 속성을 사용할 때 JPA가 내부에서 팀 객체를 불러서 팀의 데이터를 넣어 준다. 그렇게 해서 team의 이름을 가져온다.

 

지연 로딩은 내가 member를 조회했는데 어떨 때는 팀을 사용하고, 어떨 땐 팀을 사용 안 할 때도 있을 것이다. 그래서 내가 팀을 실제로 사용할 때까지 미뤘다가 팀의 데이터를 쓸 때 그때 팀에 대한 걸 DB에서 조회해서 반환해 준다. 이런 것도 자동으로 해 준다.

 

즉시 로딩이라는 건 뭘까? 위의 경우 쿼리가 두 번이나 나갔다. 하지만 때로는 애플리케이션 로직을 봤을 때 멤버를 쓸 땐 팀도 거의 같이 쓴다면 그냥 쿼리 한 번으로 처음 조회할 때 같이 가져오는 게 낫다. 이럴 땐 JPA가 즉시 로딩이라는 기능을 지원한다. 이렇게 설정해 두면 멤버를 조회할 때 멤버랑 팀은 항상 같이 조회한다.

 

즉 개발할 땐 일단 지연 로딩으로 쭉 개발한다. 그다음에 이 부분은 최적화가 필요하다 싶으면 페치 조인? 등등이 있는데 몇 가지 설정으로 분리되어 있는 쿼리를 하나의 조인 쿼리로 바꿔서 처음부터 미리 로딩하도록, 이걸 간단한 코드 한두 줄로 할 수 있다.

ORM이 어려운 이유는 객체 지향에 대해서도 알아야 하고, 관계형 DB에 대해서도 알아야 한다는 점 때문이다.

실무에서 장애의 90%는 DB에서 일어난다. 관계형 DB에 대해 어떻게 설계하는지, 어떻게 개발하는지에 대해선 굉장히 깊이 있게 학습해야 한다.

JPA 적용1 - 개발

@Data
@Entity
@Table(name = "item")
public class Item {
.
.
.
}

@Table(name = "item")이라는 것도 있는데 객체명이랑 같으면 생략해도 된다. 대소문자 구분해야 하는지는 잘 모르겠다.

@Column(name = "item_name", length = 10)
private String itemName;

이렇게 했는데 사실

private String itemName;

이렇게 해도 된다. 지금은 이해를 위해 넣었다.

JPA는 public 또는 protected 기본 생성자가 필수이다. 기본 생성자를 꼭 넣어 주자.

public Item() {

}

 

이걸 기반으로 프록시 기술 같은 걸 쓸 수 있다. public이나 protected 기반의 생성자가 있어야 프록시 기술을 쓰기가 편하다. 깊은 내용이라 그냥 스펙이라는 정도만 일단 기억해 두자. JPA 강의에서 배운다.

JPA에서 데이터를 변경할 땐 항상 @Transactional이 있어야 한다. 클래스 레벨에서 하거나 아니면 최소한

 

@Override
@Transactional
public Item save(Item item) {
    return null;
}

@Override
@Transactional
public void update(Long itemId, ItemUpdateDto updateParam) {

}

이 두 군데엔 해야 하는 듯. 나머지 조회 부분은 안 해도 될 듯

private final EntityManager em;

이게 JPA다. 여기에 저장하고 조회하고 해야 한다.

 

원래는 이걸 만들 때 데이터 소스도 넣어 주고, EntityManagerFactory라는 걸 가지고 세팅을 복잡하게 해야 한다. 그런데 그런 과정을 우리가 지금 스프링 부트랑 통합했기 때문에 스프링에서 자동으로 해 준다. 우리는 그냥 주입받아서 쓰면 된다.

@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
    Item findItem = em.find(Item.class, itemId);
    findItem.setItemName(updateParam.getItemName());
    findItem.setPrice(updateParam.getPrice());
    findItem.setQuantity(updateParam.getQuantity());
}

마치 자바 컬렉션에서 조회한 걸 변경하는 느낌이다.

 

@Transactional이 있으면 이 메서드가 끝날 때 커밋된다. 그러면서 JPA가 update 쿼리를 만들어서 먼저 날리고 그다음에 커밋된다.

String jpql = "select i from Item i";

대소문자 구분해야 한다. Item은 Item 엔터티 객체이다. 클래스로 만든 그거다.

jpql 문법은 sql이랑 거의 비슷한데 테이블을 대상으로 하는 게 아니라 Item 엔터티를 대상으로 한다. 성능 최적화 등 편리한 기능들이 많다.

JPA는 동적 쿼리에 약하다.

일반적으로는 비즈니스 로직을 시작하는 서비스 계층에 트랜잭션을 걸어 주는 것이 맞다. 근데 하다 보면 Repository에 트랜잭션을 걸 때도 있다. 서비스 계층이 없는 경우도 있다.

참고로 트랜잭션 매니저도 이전엔 DataSourceTransactionManager, JdbcTransactionManager 그런 게 저장되어 있었다면, 이젠 JPA 라이브러리가 들어오는 순간 JpaTransactionManager가 등록이 되어서 사용된다.

테스트에서

@Test
void updateItem() {
    //given
    Item item = new Item("item1", 10000, 10);
    Item savedItem = itemRepository.save(item);
    Long itemId = savedItem.getId();

    //when
    ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
    itemRepository.update(itemId, updateParam);

    //then
    Item findItem = itemRepository.findById(itemId).get();
    assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
    assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
    assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
}

이것만 실행해 보자.

 

1.jpg.webp

update 쿼리가 눈에 안 보인다.

 

save()를 하면 JPA 내부 캐시에 이게 잠깐 저장이 된다. 그리고 update()를 해도 그 캐시의 데이터가 바뀐다. 그러면 find()를 하게 되면 또 JPA 캐시에 있는 데이터가 조회된다.

 

이 경우엔 update를 보고 싶으면 @Commit을 해야 한다. 사실 flush?라는 걸 강제로 호출해도 되는 듯

 

@Test
@Commit
void updateItem() {
.
.
.
}
cbv.jpg.webp

update 쿼리가 보인다.

JPA 적용2 - 리포지토리 분석

em.update() 같은 메서드는 없다.

JPA는 트랜잭션이 커밋되는 시점에, 변경된 엔터티 객체가 있는지 확인한다. 특정 엔터티 객체가 변경된 경우에는 UPDATE SQL을 실행한다

 

변경되었는지를 JPA가 어떻게 알까? JPA가 처음 조회하는 시점에 원본 객체를 복사해서 내부에 스냅샷으로 가지고 있다. 우리 눈엔 보이지 않는다.

 

그거랑 지금 눈에 보이는 findItem이랑 바꼈는지, 처음 조회한 시점과 지금과 바뀐 게 있는지를 트랜잭션 커밋 시점에 확인한다. 바뀐 게 있으면 update 쿼리를 만들어서 보낸다.

JPA를 제대로 이해하려면 영속성 컨텍스트라는 단어를 제대로 배워야 한다.

JPA에서 단순히 PK를 기준으로 조회하는 것이 아닌, 여러 데이터를 복잡한 조건으로 데이터를 조회하려면 어떻게 하면 될까? jpql이란 걸 사용하면 된다.

jpql에 있는 이름 기반 파라미터가 sql로 바뀔 때 ?로 바뀌는 듯

jpql도 동적 쿼리 문제가 있다. JPA를 사용해도 동적 쿼리 문제가 남아 있다.

JPA 적용3 - 예외 변환

JPA는 PersistenceException, IllegalStateException, IllegalArgumentException 이렇게 3가지 종류 예외를 발생시킬 수 있다. 물론 PersistenceException 하위에 많은 예외가 있다.

 

EntityManager에서 예외가 터지면 위 세 개 예외 중 하나가 나온다.

PersistenceException은 런타임 예외인데 서비스 계층이 JPA 기술에 종속되는 건가?

 

inflearn.com/course/lecture?courseSlug=스프링-db-2&unitId=114657&category=questionDetail&tab=community&q=846619&subtitleLanguage=ko

EntityManager는 스프링 예외 변환에 대해 전혀 모른다.

xzc.jpg.webp

@Repository를 사용하면 JpaItemRepository의 AOP 프록시가 만들어진다.

 

여기서 뭘 하냐 하면, JPA 예외를 스프링 예외 추상화로 바꾼다. 그리고 그것을 Repository를 호출한 쪽(서비스 계층 혹은 지금 우리의 경우엔 Test 케이스)에 넘겨준다.

 

그래서 서비스 계층에선 스프링 예외 추상화 계층을 그대로 사용할 수 있다.

@Override
public List<Item> findAll(ItemSearchCond cond) {
    String jpql = "selectxxx i from Item i";
.
.
.
}

Repository에 일부러 문법 오류를 내고, 테스트 코드에서

 

@Test
void findItems() {
    //given
    Item item1 = new Item("itemA-1", 10000, 10);
    Item item2 = new Item("itemA-2", 20000, 20);
    Item item3 = new Item("itemB-1", 30000, 30);

    log.info("repository = {}", itemRepository.getClass());
.
.
.
}

로그를 남겨 보자.

 

123.jpg.webp

repository = class hello.itemservice.repository.jpa.JpaItemRepository$$SpringCGLIB$$0

이런 게 뜬다.(강의에선 JpaItemRepository$$EnhancerBySpringCGLIB$$c48d2e07이 떴다.)

 

프록시가 만들어졌다. 이건 예외 변환을 해 주는 프록시다.

 

그런데 @Repository를 안 해도

repository = class hello.itemservice.repository.jpa.JpaItemRepository$$SpringCGLIB$$0

이렇게 뜬다. 왜 이렇게 뜰까? 이건 @Transactional 때문이다. @Transactional도 빼고 다시 실행하면

 

repository = class hello.itemservice.repository.jpa.JpaItemRepository

이렇게 뜬다.

 

이렇게 @Transactional이랑 @Repository 두 개 다 넣으면, AOP에서 트랜잭션도 적용하고, 예외 추상화로 변환해 주는 것까지 다 적용해 준다.

AOP 프록시에서 EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible() 이런 코드를 부른다. 이것까지 깊이 공부할 필요는 없다.

@Repository를 통해 프록시 객체가 생성되려면 어떤 조건이 필요할까?

 

https://www.inflearn.com/community/questions/1455566/repository%EB%A5%BC-%ED%86%B5%ED%95%B4-%ED%94%84%EB%A1%9D%EC%8B%9C%EA%B0%80-%EC%83%9D%EC%84%B1%EB%90%98%EB%A0%A4%EB%A9%B4

 

PersistenceExceptionTranslationPostProcessor가 있어야 @Repository에 AOP를 적용한다.

PersistenceExceptionTranslationPostProcessor는 spring-tx(트랜잭션 관련) 라이브러리에 포함되어 있다. 스프링 부트는 이 클래스가 라이브러리에 있으면 자동으로 스프링 빈으로 등록하고 이 클래스를 통해 @Repository에 AOP를 적용한다.

그리고 spring-tx는 spring-jdbc, spring-data-jpa 같은 스프링 라이브러리를 사용할 때 함께 라이브러리에 포함된다.

스프링 핵심 원리 기본 편 강의에서는 jdbc, jpa 같은 기능을 사용하지 않았기 때문에 spring-tx 라이브러리가 빠지게 되므로 해당 기능이 활성화되지 않는다.

지금 코드 기준으론 @Repository를 해도 컴포넌트 스캔 대상이 아니다.

JdbcTemplate 같은 건 스프링이 어차피, 자기 것이기 때문에 그 안에서 예외 변환까지 다 해 주는 거고,

MyBatis의 매퍼도, 마이바티스 스프링 모듈에서 그걸 해 주는 거다.

 

근데 EntityManager는 순수한 JPA 것이기 때문에 예외 변환을 못 시킨다. 그래서 이런 작업을 AOP 프록시에서 대신 해 준다.

JDBC는 스프링 기술 자체가 아니고, JdbcTemplate은 스프링 프레임워크 기술인 듯

정리

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

이걸 넣으면 JPA랑 스프링 데이터 JPA 다 넣어 주고, JPA랑 스프링 부트 통합하는 것까지 다 자동으로 해 준다.

jpql 문법을 배우면 그 안에 페치 조인... 성능 최적화 등 여러 가지가 있다. SQL이랑 90%는 비슷하고 나머지 좀 차이가 나는 게 있는데 그 부분들 위주로 배우면 된다.

JPA에선 예외 변환 AOP 프록시가 필요하다.

데이터 접근 기술 - 스프링 데이터 JPA

스프링 데이터 JPA 소개1 - 등장 이유

mongoDB, 하둡, Neo4j, redis 등 여러 기술들은 결국 데이터를 어딘가에 저장하고, 조회하는 거다. 사실 비슷한 거다. 내가 이 데이터를 mongoDB에 저장할지, redis에 저장할지 등의 약간의 차이가 있는 거지, 비슷하다. 이걸 개발자가 항상 다르게 만들고 있었다. 이런 걸 더 큰 레벨로 추상화해서, 기본적인 등록, 수정, 삭제, 조회에 대해서 비슷한 인터페이스를 만들고 그걸 편리하게 사용할 수 있도록 개발자에게 제공하자는 사상에서 나온 게 Spring Data(스프링 데이터)다

 

그리고 거기에서도 각각에 대한 구현 기술이 있다. 스프링 데이터라는 공통 기술이 있다. 거기서 등록, 수정, 삭제, 조회 같은 기본적인 인터페이스를 제공하고, 그럼에도 각각의 구현 기술들마다 특징이 있다. 그 특징에 맞춰서 기능을 확장해서 제공하는 게 있는데, 스프링 데이터 몽고, 스프링 데이터 JPA, 스프링 데이터 레디스 같은 프로젝트들이 있다.

 

스프링 데이터라는 인터페이스 기반으로 이것들을 사용할 수 있게 된다. 우리는 스프링 데이터 JPA에 대해 배운다.

1.jpg.webp

스프링 데이터는 사실 단순한 통합 그 이상이다. 단순하게 CRUD만 하는 정도가 아니고, 쿼리에 대한 부분도 어느 정도 제공되고, 동일한 인터페이스로 제공된다. CRUD 저장소 같은 인터페이스가 제공된다. 페이징에 대한 부분도 어느 정도 통합이 되어 있다. 나머지 부가 기능도 제공된다.

 

이런 부분들을 mongoDB, redis 같은 저장소와 관계없이 추상화해서 공통으로 제공한다. 물론 스프링 데이터만으론 안 되고, 각 기술에 맞게 스프링 데이터 JPA, 스프링 데이터 레디스 등등으로 받아서 쓰면 된다.

스프링 데이터 JPA 소개2 - 기능

지금부터 스프링 데이터 JPA에 대해 배운다.

Spring Data Commons라는 스프링 데이터 기술, 공통적인 인터페이스가 있고, 그걸 JPA에서 더 적절하게 쓸 만한 기술들이 추가된 게 스프링 데이터 JPA이다.

2.jpg.webp

옛날엔 이런 JDBC 코드를 짰었다.

 

3.jpg.webp

그러다가 스프링이 JdbcTemplate을 제공해 주면서(혹은 마이바티스 같은 걸 사용하면서) 굉장히 편리하게 코드를 작성하게 됐다. 이러면서 개발 생산성이 많이 늘게 되었다.

그러다 이젠 스프링이랑 JPA 조합을 많이 사용하게 되었다.

4.jpg.webp

EntityManager라는 걸 주입받고, persist()로 한 줄로 적으면 DB에 저장된다.

스프링과 JPA를 쓰다가 스프링 데이터 JPA라는 기술이 등장하게 된다. 이 기술을 사용하면

5.jpg.webp

MemberRepository 인터페이스가 스프링 데이터가 제공하는 JpaRepository라는 인터페이스만 상속받으면

 

6.jpg.webp

이런 걸 기본으로 제공한다.

 찾아보니 deleteById라는 게 따로 있는 듯.

delete는 그냥 객체를 파라미터로 받는 듯

 

인터페이스를 구현한 클래스는 어디 있을까?

7.jpg.webp

동적 프록시 기술이 인터페이스에 대한 구현체를 자동으로 생성한다. 우리는 인터페이스만 만들어 두면 된다.

스프링 데이터 JPA는 어떤 기능을 가지고 있을까?

위에서 얘기했듯이 공통 인터페이스 기능이 제공된다. 그런데 그것들만으로는 부족하다. 왜냐하면 공통 외에 또 기능들을 넣고 싶을 수 있다.

8.jpg.webp

그래서 메서드 이름으로 쿼리를 자동으로 생성해 주는 기능이 있다. jpql을 자동으로 생성해 준다.

메서드 이름을

List<User> findByEmailAndName(String email, String name);이라고 하면(User가 아니라 Member 아닌가?)

 

select m from Member m where m.email = ?1 and m.name = ?2 이런 jpql을 생성한다. 이름만 보고 분석해서 만든다.

9.jpg.webp

그리고 사진에 표시된 부분에다 바로 jpql을 작성하고 싶거나 바로 sql을 작성하고 싶을 수 있다.(JPA에서도 sql 그대로 넣을 수 있다.)

@Query 애노테이션으로 작성할 수 있도록 지원해 준다. 이건 JPA에 대해 좀 이해를 해야 이해할 수 있다.

10.jpg.webp

수정도 가능하다.

11.jpg.webp

팀 프로젝트는 주로 이렇게 구현을 많이 한다. 보통 기술을 여러 개 조합하면 지저분해지는데 하이버네이트, JPA, SpringDataJPA, QueryDSL은 조합하면 코드가 단순해진다.

 

하이버네이트는 어차피 JPA 구현체이기 때문에 같이 묶어서 가고, 스프링 데이터 JPA를 쓰면 JPA로 코딩하는 양 자체가 인터페이스로 이미 여러 개 제공되기 때문에 코딩 양이 줄어든다. 그럼에도 불구하고 동적 쿼리 등 해결이 안 되는 부분이 있는데 그런 부분은 QueryDSL로 해결한다.

 

이렇게 기술 스택을 사용하면 SQL을 직접 다룰 때(JdbcTemplate 혹은 마이바티스만 쓸 때)랑 비교해서

12.jpg.webp

확실히 코딩 양이 줄어들고, 이때부턴 엔터티를 사용하기 때문에 도메인 클래스를 중요하게 다루고, 이러면서 비즈니스 로직이 이해하기 쉬워진다. 왜냐하면 SQL을 직접 작성하게 되면 SQL 코드 다 열어 봐야 하고, SQL에 비즈니스 로직이 녹아드는 문제가 생기게 된다. 그런데 이건 도메인 클래스를 중요하게 다루기 때문에 엔터티 같은 거만 봐도... 여러 가지 코드만으로 해결된다. 그리고 SQL 작성할 시간에 더 많은 테스트 케이스를 작성하는 데 집중할 수 있다.

 

그리고 JPA를 사용하게 되면 도메인 클래스 중심으로 동작하기 때문에 어떤 장점이 생기냐 하면, 자연스럽게 테스트를 작성하기 더 쉬워진다. SQL로 하면 SQL에 의존적으로 로직들이 동작하기 때문에 테스트 작성이 어려워질 때가 많다. 물론 잘 짜면 되는데 그게 쉽지가 않다. 그런데 JPA를 사용하면 마치 자바 컬렉션에 객체를 넣었다 빼는 것처럼 단순해지기 때문에 테스트도 훨씬 단순하고 깔끔하게 작성할 수 있게 된다.

13.jpg.webp

편하고 비즈니스 로직에 집중할 수 있다.

때로는 JPA나 스프링 데이터 JPA로 해결이 안 되는 복잡한 쿼리가 있다. 그런 건 JdbcTemplate 같은 거로 SQL 그대로 쓰면 된다.

스프링 데이터 JPA 주의 사항이 있다.

14.jpg.webp

스프링 데이터 JPA는 밑의 기술들을 편리하게 사용하도록 도와주는 도구다. 결론적으로

 

15.jpg.webp

JPA 자체에 대한 이해가 꼭 필요하다.

그리고 데이터베이스 설계에 대해 잘 이해해야 한다.

스프링 데이터 JPA를 사용하고 싶다면 우선 JPA에 대해 공부를 잘 해 놓아야 한다.

 

  • 본인이 작성한 JPQL이나 로직들이 어떤 SQL로 생성되어서 나갈지를 이해해야 한다. JPA 기본만 배우면 크게 어렵진 않다.

  • 즉시 로딩, 지연 로딩 전략들에 대해 잘 이해하고 있어야 한다.

  • 영속성 컨텍스트를 이해해야 한다.

  • 변경 감지

  • 언제 영속성 컨텍스트가 플러시가 되는가

  • 연관 관계 매핑 중에 mappedBy(inverse) 이해

  • jpql엔 어떤 한계가 있는가

이런 것들에 대해 학습해야 한다.

가장 중요한 건 JPA 자체를 이해해야 한다.

스프링 데이터 JPA 주요 기능

x.jpg.webp

버전이 오르면서 약간 바뀌긴 했다.

Ctrl + N을 두 번 누르면 JpaRepository 찾을 수 있다.

상위에 있는 Repository 인터페이스는 @Repository랑 다른 거인 듯

스프링 데이터 JPA도 내부에서 JPA를 사용한다.

LIMIT: findFirst3, findFirst, findTop, findTop3

 

이렇게 쿼리 메서드 기능에서 리미트 조건도 할 수 있다. 하지만 리미트는 보통 이렇게 잘 사용하진 않고 다른 방법으로 한다.

파라미터가 너무 많으면 쿼리 메서드 기능을 쓸 때 메서드명이 너무 길어진다. 혹은 쿼리가 복잡할 땐 그냥 @Query로 직접 적어도 된다. 둘 중 한 가지 방법을 선택하면 된다.

 

둘 다 있으면 @Query가 우선순위가 높은 듯

스프링 데이터 JPA 적용1

public interface SpringDataJpaItemRepository extends JpaRepository<Item, Long> {
    
}

이렇게만 해도 기본적인 CRUD 기능은 추가된 거다. 여기에 새로 추가하면 된다.

// 쿼리 직접 실행
@Query("select i from Item i where i.itemName like :itemName and i.price <= :price")
List<Item> findItems(@Param("itemName") String itemName, @Param("price") Integer price);

@Param 반드시 넣어야 한다. 이게 없으면 자바 빌드 환경에 따라 될 수도 있고 안 될 수도 있으니 항상 넣자.

 

@Param 안에 있는 거랑 :itemName이랑 매칭된다.

// 쿼리 메서드(아래 메서드와 같은 기능 수행)
List<Item> findByItemNameLikeAndPriceLessThanEqual(String itemName, Integer price);

// 쿼리 직접 실행
@Query("select i from Item i where i.itemName like :itemName and i.price <= :price")
List<Item> findItems(@Param("itemName") String itemName, @Param("price") Integer price);

이 두 메서드는 같은 기능을 수행한다.

여기서는 데이터를 조건에 따라 4가지로 분류해서 검색한다.

 

  1. 모든 데이터 조회

  2. 이름 조회

  3. 가격 조회

  4. 이름 + 가격 조회

 

모든 데이터 조회하는 기능은 굳이 안 만들어도 이미 JpaRepositiory에서 findAll()로 제공한다.

// 쿼리 직접 실행
@Query("select i from Item i where i.itemName like :itemName and i.price <= :price")
List<Item> findItems(@Param("itemName") String itemName, @Param("price") Integer price);

사실 이런 건 동적 쿼리로 해야 하는데 스프링 데이터 JPA는 동적 쿼리에 약해서 이건 뒤에서 Querydsl로 해결하겠다.

스프링 데이터 JPA 적용2

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

        if (StringUtils.hasText(itemName) && maxPrice != null) {
//            return repository.findByItemNameLikeAndPriceLessThanEqual("%" + itemName + "%", maxPrice);
            return repository.findItems("%" + itemName + "%", maxPrice);
        } else if (StringUtils.hasText(itemName)) {
            return repository.findByItemNameLike("%" + itemName + "%");
        } else if (maxPrice != null) {
            return repository.findByPriceLessThanEqual(maxPrice);
        } else {
            return repository.findAll();
        }
    }

사실 실무에선 이렇게 안 한다. 동적 쿼리를 사용한다. 조건이 2개밖에 없으면 그냥 이렇게 하기도 한다.

스프링 데이터 JPA도 스프링 예외 추상화를 지원한다. 스프링 데이터 JPA가 만들어 주는 프록시에서 이미 예외 변환을 처리하기 때문에, @Repository와 관계없이 예외가 변환된다.

 

그래서

@Repository
@Transactional
@RequiredArgsConstructor
public class JpaItemRepositoryV2 implements ItemRepository {
.
.
.
}

여기에 @Repository가 없어도 예외 변환이 된다. 근데 2개 있어도 상관은 없다.

 

@Override
public Item save(Item item) {
    return repository.save(item);
}

여기의 repository.save(item);에서 예외가 터지면, 어차피 스프링 것이니 스프링이 예외 변환 다 해서 넘겨준다.

 

예외가 변환되어서 오면 또 @Repository에서 예외 변환을 시도한다. 그런데 이미 변환된 예외이기 때문에 무시하고 넘어간다.

정리

스프링 데이터 JPA는 페이징에 대한 구현체들도 제공한다. 실무에선 페이징 처리 기본으로 해야 한다.

스프링 데이터 JPA는 단순히 편리함을 넘어서 많은 개발자들이 똑같은 코드로 중복 개발하는 부분을 개선해 준다.

1.jpg.webp

ItemService에서 SpringDataJpaItemRepository를 그대로 사용할 수 없기 때문에...

지금 ItemService를 코드를 수정하지 않고 보존하는 게 가장 중요하다. 그래서 위 그림처럼 했다.

 

사실 이 방식이 좋은 건지에 대해선 고민할 필요가 있다.

단순하게 처음부터 ItemService에서 SpringDataJpaItemRepository를 쓰면 되지 않나?라는 고민을 할 필요는 있다. 이 고민에 대한 부분은 뒤에서 다시 배운다.

데이터 접근 기술 - Querydsl

Querydsl 소개1 - 기존 방식의 문제점

갑자기 요구 사항이 추가되기도 한다.

예를 들어 기획자가 와서 나이와 이름에 대한 검색 조건을 추가해 달라고 할 수 있다.

 

1.jpg.webp

이렇게 하고 컴파일 완료하고 배포도 완료했다고 하자.

 

그런데 버그가 발생했다.

2.jpg.webp

띄어쓰기를 신경 쓰지 않았다. SQL 문법 오류가 발생한다.

 

3.jpg.webp

sql이든 jpql이든 쿼리는 문자다. 그래서 타입 체크가 불가능하다. 실행해 봐야 작동되는지 확인할 수 있다.

에러는 크게 컴파일 에러와 런타임 에러가 있다.

 

컴파일 에러가 좋다. 왜냐하면 빌드도 안 된다. 개발자가 IDE에서 바로 확인할 수 있다. 문제가 터지기 전에 바로 인지할 수 있다.

 

런타임 에러는 안 좋다. 런타임 에러도 종류가 있는데, 애플리케이션을 띄울 때 발생하는 런타임 에러가 있고 이건 그나마 낫다. 그런데 고객이 직접 호출할 때 생기는 런타임 에러, 즉 방금 sql 같은 경우가 가장 안 좋은 오류다. 배포까지 하고 고객이 사용하면서 발생한다. 그런데 컴파일 에러는 그런 단계까지 가기 전에 알 수 있다.

4.jpg.webp

SQL을 작성할 때 컬럼명을 다 외울 수 있을까? 외우는 사람도 있지만 힘들다.

 

5.jpg.webp

만약 SQL이 클래스처럼 타입이 있고 이걸 자바 코드로 작성할 수 있다면? 오타가 생기면 오류 잡아 주기도 한다면 정말 편할 것이다. 이런 걸 type-safe라고 한다.

6.jpg.webp

자바 코드 작성할 때랑 비슷하다.

7.jpg.webp

쿼리를 마치 자바 코드를 짜듯이 type-safe하게 개발할 수 있게 지원하는 프레임워크가 QueryDSL이다. 정확하게는 쿼리를 자바 코드로 작성할 수 있게 도와준다.

 

Querydsl이 SQL도 지원하기는 하는데 그건 너무 복잡해서 잘 쓰진 않고, 주로 JPA 쿼리(jpql)를 사용할 때 많이 사용한다.

9.jpg.webp

이걸 해 보도록 하자.

 

10.jpg.webp

JPA 엔터티가 이렇게 있고

 

11.jpg.webp

회원 테이블이 이렇게 있다.

 

12.jpg.webp

JPA에서 데이터를 쿼리하는 방법은 크게 세 가지가 있다. 여기에 추가로 SQL을 그대로 사용할 수도 있기는 한다.

13.jpg.webp

from 뒤의 Member는 테이블이 아니라 엔터티다.

 

3명 출력해야 한다. 페이징이 들어가야 한다.

 

JPA에선 setMaxResults(3)을 하면 자동으로 나중에 SQL로 번역될 때 limit(오라클은 rownum인 듯) 같은 걸 넣어 준다. 이렇게 해서 페이징 처리가 된다.

14.jpg.webp

jpql은 SQL 쿼리랑 비슷해서 금방 익숙해진다. 다만 type-safe가 아니고 동적 쿼리 생성이 어렵다. 문자로 그대로 만들어야 한다.

15.jpg.webp

JPA에서 이런 걸 제공한다. 자바 코드로 쿼리를 작성할 수 있게 된다. 동적 쿼리를 좀 편리하게 작성할 수 있다.

이 코드를 실행하면

 

16.jpg.webp

이런 SQL이 실행된다.

 

그런데 너무 어렵다.

17.jpg.webp

장점은 동적 쿼리 생성이 쉽다고는 하지만 실제로 해 보면 코드가 어렵다. 그리고 코드를 보면 "age"나 "name" 이런 식으로 문자를 넣어야 하니 type-safe는 아니다. select나 from, where, and 같은 건 빠질 수는 있긴 해도 age나 name 같은 건 해결이 안 된다.

18.jpg.webp

이건 필드명을 문자가 아니라 자바 코드로 작성할 수 있게 도와준다. Criteria API에 MetaModel을 합치면 이런 게 된다. 그런데 여전히 복잡하다. 이 내용은 이해할 필요 없다.

Querydsl 소개2 - 해결

1.jpg.webp

DSL이라는 건 Domain, Specific, Language

도메인 특화 언어라는 거다.

2.jpg.webp

특정 도메인에 맞췄기 때문에 단순하고 간결하고 유창하다.

3.jpg.webp

QueryDSL이라는 건 쿼리에 특화된 프로그래밍 언어라고 보면 된다.

4.jpg.webp

SQL, JPQL 이런 걸 떠나서 자바 컬렉션, 몽고 DB, 하이버네이트 서치, Lucene 이런 것까지 다 쿼리 기능을 DSL 문법으로 제공하는 거다.

 

마치 스프링 데이터 JPA(스프링 데이터?)가 기본 CRUD 같은 기능들을 추상화해 보려고 나온 것처럼,

QueryDSL은 굉장히 다양한 기술들에 대해서... 쿼리에 대해서 되게 편리하게 추상화하려고 나온 거다. 그리고 쿼리들을 자바 코드로 쓸 수 있게 해 주려고 나왔다.

6.jpg.webp

type-safe한 쿼리를 작성하려면 코드가 좀 필요하다.

 

JPA의 경우엔 Member 엔터티 또는 SQL은 테이블에서 정보를 뽑는다. 그래서 코드 생성기가 돌아서 QMember, 쿼리용 Member 객체를 만들어 준다.

7.jpg.webp

Annotation Processing Tool이라는 게 필요하다. 이게 JPA의 경우엔 @Entity를 읽어서 QMember(우리 같은 경우엔 QItem)라는 클래스를 만들어 준다. 그러면 그걸 가지고 쿼리를 자바 코드로 작성할 수 있게 된다.

8.jpg.webp

여러 가지 모듈이 있다. SQL, 자바 컬렉션, 몽고 DB 등 여러 가지가 있는데, 거의 쓰는 건 QueryDSL - JPA다.

10.jpg.webp

이 조건으로 사람을 찾아보자.

 

11.jpg.webp

회원 테이블 만들고,

 

12.jpg.webp

회원 엔터티 만들고

그다음에

 

13.jpg.webp

JPA에서 Member 엔터티에 대고 APT라는 걸 QueryDSL이 제공해 준다. 그걸 실행하면 QMember.java라는 코드가 만들어진다.

14.jpg.webp

이런 코드가 만들어진다. 그냥 이런 게 만들어진다고 알면 된다. 그냥 넘어가면 될 듯

15.jpg.webp

위의 JPAQueryFactory~~ 이 부분은 크게 중요하지 않고,

List<Member> list~~ 부분부터 중요하다.

 

이렇게 하면 이 조건에 맞는 사람들 리스트를 반환한다.

 

16.jpg.webp

이런 SQL이 만들어진다.

17.jpg.webp

이렇게 동작한다. QueryDSL로 작성하면 이게 먼저 JPQL로 생성된다. 그리고 이 JPQL을 생성해서 하이버네이트나 JPA 실행하면 걔가 SQL로 번역해서 실행한다.

 

QueryDSL JPA버전은 이 QueryDSL을 가지고 JPQL을 만들어 주는 빌더라고 생각하면 된다.

18.jpg.webp

결국 QueryDSL은 JPA 자체를 알아야 하고, 결국 JPA가 제공하는 JPQL에 대해 문제를 해결해 주는 것이기 때문에, 즉 JPQL 빌더 역할을 하는 것이기 때문에 JPQL을 먼저 알아야 한다. 그리고 QueryDSL을 사용해야 한다.

 

단점으론 APT 세팅이 좀 복잡하다. 한번 세팅하고 나면 편하다.

20.jpg.webp

이런 것들을 지원하고,

 

21.jpg.webp

jpql에서 제공하는 기능들도 다 제공한다.

 

22.jpg.webp

이런 것도 제공한다.

23.jpg.webp

단순한 쿼리는 이렇게 쭉 짜면 된다.

24.jpg.webp

동적 쿼리도 지원한다. or로 넣을 수도 있다.

BooleanBuilder라는 게 있다. builder에 동적 조건을 넣어 놓고, 그다음에 쿼리를 할 때 where에 builder를 넣으면 동적 쿼리가 자동으로 완성된다. 이거보다 더 깔끔하게 짤 수도 있다.

25.jpg.webp

당연히 조인도 지원한다. 이건 jpql에서도 지원한다. left join 같은 거 다 된다.

26.jpg.webp

페이징 관련된 것도 지원해 준다.

27.jpg.webp

실무에선 스프링 데이터 JPA를 기본으로 사용하는데 스프링 데이터 프로젝트의 약점은 조회다. QueryDSL로 복잡한 조회 기능을 보완할 수 있다.

 

복잡한 쿼리나 동적 쿼리를 QueryDSL로 하면 쿼리를 자바 코드로 짤 수 있고, 코드 어시스턴트도 지원되고, 문제 생기면 컴파일 오류로 잡아 줘서 편해진다.

 

단순한 경우엔 스프링 데이터 JPA 기능들을 쓰면 되고, 복잡한 경우엔 QueryDSL을 직접 사용하면 된다.

28.jpg.webp

QueryDSL은 jpql을 만들어 주는 거다. jpql 문법엔 한계가 있다. 웬만한 건 되지만 너무 복잡한 건 어려울 수 있다. 이 한계는 JPA 기본 편 강의에서 배운다. 너무 어려우면 네이티브 SQL을 써야 하는데 그땐 JdbcTemplate이나 MyBatis랑 같이 섞어서 쓰면 된다.

Querydsl 설정

//Querydsl 추가
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

annotationProcessor와 관련된 것들을 넣어 줘야 한다. 이게 동작해서 중간에 @Entity 같은 애노테이션을 읽어서 QItem이라는 QueryDSL을 처리하기 위한 클래스를 만들어 주기 위한 처리기라고 생각하면 된다.

Querydsl 적용

JpaItemRepositoryV3에서 save() 같은 건 그냥 JPA로 하겠다.

강의 자료엔

@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
    Item findItem = findById(itemId).orElseThrow();
    findItem.setItemName(updateParam.getItemName());
    findItem.setPrice(updateParam.getPrice());
    findItem.setQuantity(updateParam.getQuantity());
}

이렇게 였는데 직접 작성할 땐

 

@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
    Item findItem = em.find(Item.class, itemId);
    findItem.setItemName(updateParam.getItemName());
    findItem.setPrice(updateParam.getPrice());
    findItem.setQuantity(updateParam.getQuantity());
}

이렇게 했다. 상관없을 듯

@Override
public List<Item> findAll(ItemSearchCond cond) {
    String itemName = cond.getItemName();
    Integer maxPrice = cond.getMaxPrice();

    QItem item = new QItem("i");

    List<Item> result = query.select(item)
            .from(item)
            .where()
            .fetch();

    return result;
}

i가 별칭인 듯

 

그리고 이것을

@Override
public List<Item> findAll(ItemSearchCond cond) {
    String itemName = cond.getItemName();
    Integer maxPrice = cond.getMaxPrice();

    List<Item> result = query.select(QItem.item)
            .from(QItem.item)
            .where()
            .fetch();

    return result;
}

이렇게 바꿀 수 있다.

QItem 코드 내부에

public static final QItem item = new QItem("item");

이런 게 있다. 그리고 static 이므로 static import를 할 수 있다.

@Override
public List<Item> findAll(ItemSearchCond cond) {
    String itemName = cond.getItemName();
    Integer maxPrice = cond.getMaxPrice();

    List<Item> result = query
            .select(item)
            .from(item)
            .where()
            .fetch();

    return result;
}
List<Item> result = query
        .select(item)
        .from(item)
        .where(builder)
        .fetch();

이 부분이 만약 동적 쿼리가 아니었다면

 

List<Item> result = query
        .select(item)
        .from(item)
        .where(item.itemName.like("%" + itemName + "%").and(item.price.loe(maxPrice)))
        .fetch();

이런 식으로 짤 수도 있다. 그런데 지금은 동적 쿼리이므로 이렇게 하면 안 된다.

private BooleanExpression likeItemName(String itemName) {
    if (StringUtils.hasText(itemName)) {
        return item.itemName.like("%" + itemName + "%");
    }

    return null;
}

이렇게 하고

 

List<Item> result = query
        .select(item)
        .from(item)
        .where(likeItemName(itemName))
        .fetch();

이렇게 하면 if 조건에 맞으면 item.itemName.like("%" + itemName + "%"); 이런 조건이 반환되어서 적용되는 거고, 아니면 null이 반환된다. null이면 where 조건에서 무시된다.

List<Item> result = query
        .select(item)
        .from(item)
        .where(likeItemName(itemName), maxPrice(maxPrice))
        .fetch();

where(likeItemName(itemName), maxPrice(maxPrice)) 이렇게 하면 and 조건이다.

Ctrl + Alt + N은 인라인 단축키

return query
        .selectqqq(item)
        .from(item)
        .where(likeItemName(itemName), maxPrice(maxPrice))
        .fetch();

이런 식으로 오타가 생겨도 컴파일 오류이다. 빌드가 안 된다.

 

QueryDSL의 강점이다.

Querydsl은 별도의 스프링 예외 추상화를 지원하지 않는다. 대신에 JPA에서 학습한 것처럼 @Repository에서 스프링 예외 추상화를 처리해 준다.

 

QueryDSL은 jpql 빌더이기 때문에 문제가 jpql에서 다 발생한다. 결국 JPA, 하이버네이트 레벨에서 문제가 발생한다. 예외들은 @Repository에서 다 해결된다.

정리

findAllOld()처럼 쭉 작성하는 방법도 많이 쓴다.

데이터 접근 기술 - 활용 방안

스프링 데이터 JPA 예제와 트레이드 오프

우리 프로젝트가 만약 되게 크고 이미 성장이 됐고 거기서 구조적으로 개선이 되어야 하면(몽고 DB로 바꾸는 등) DI, OCP를 지키는 선택을 하는 게 나을 수 있다. 물론 이 경우에도 항상 나은 건 아니다.

 

프로젝트를 처음 시작해서 점점 늘려 가는 상황이면 그냥 간단한 게 나을 수 있다. 단순하면서 빨리 해결하는 게 나을 수 있다. 이렇게 선택해 놓고 프로젝트가 더 커지면 리팩터링을 통해 그 부분들을 정리하는 게 더 나을 수 있다. 추상화가 필요하다고 느끼면 그때 도입하는 거다. 물론 이것도 무조건 정답은 아니다.

실용적인 구조

마지막에 Querydsl을 사용한 Repository는 스프링 데이터 JPA를 사용하지 않는 아쉬움이 있었다. 물론 Querydsl을 사용하는 Repository가 스프링 데이터 JPA Repository를 사용하도록 해도 된다.

 

@Repository
@Transactional
public class JpaItemRepositoryV3 implements ItemRepository {

    private final EntityManager em;
    private final JPAQueryFactory query;
.
.
.
}

이전에 작성한 이 코드에서

 

private final SpringDataJpaItemRepository repository;

이걸 넣어도 된다는 뜻인 듯

@Configuration
@RequiredArgsConstructor
public class V2Config {

    private final EntityManager em;
    private final ItemRepositoryV2 itemRepositoryV2;    // SpringDataJPA

    @Bean
    public ItemService itemService() {
        return new ItemServiceV2(itemRepositoryV2, itemQueryRepositoryV2());
    }

    @Bean
    public ItemQueryRepositoryV2 itemQueryRepositoryV2() {
        return new ItemQueryRepositoryV2(em);
    }

    @Bean
    public ItemRepository itemRepository() {
        return new JpaItemRepositoryV3(em);
    }
    
}

원래는 그냥 컴포넌트 스캔하면 된다.

 

여기서

@Bean
public ItemRepository itemRepository() {
    return new JpaItemRepositoryV3(em);
}

이 부분도 여전히

 

@Slf4j
@RequiredArgsConstructor
public class TestDataInit {

    private final ItemRepository itemRepository;

    /**
     * 확인용 초기 데이터 추가
     */
    @EventListener(ApplicationReadyEvent.class)
    public void initData() {
        log.info("test data init");
        itemRepository.save(new Item("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 20000, 20));
    }

}

여기서 필요하다.

사실 ItemRepositoryTest를 지금 테스트하는 건 별로 의미가 없다. 사실 다시 만들어야 한다. 왜냐하면 이건 지금 ItemRepository를 기반으로 테스트하기 때문이다.

스프링 데이터 JPA가 제공하는 커스텀 리포지토리를 사용해도 비슷하게 문제를 해결할 수는 있다. 이건 스프링 데이터 JPA 강의에서 배운다.

d.jpg.webp

이렇게 만들면 나중에 Repository를 바꾸거나, 스프링 데이터 JPA를 다른 기술로 바꾸거나 할 때 구조적인 유연성은 떨어진다.

 

하지만 실용적으로 빠르게 개발할 땐 좋다.

 

처음엔 프로젝트를 할 때 이렇게 단순하게 접근하고, 프로젝트가 나중에 커지면 그땐 추상화에 대한 고민이 생긴다. 그때 고민해도 괜찮을 수 있다.

 

물론 상황에 따라 프로젝트가 처음부터 크거나, 이런 추상화가 가까운 미래에 도움이 될 것 같다고 할 땐 처음부터 추상화를 고려할 수도 있다.

다양한 데이터 접근 기술 조합

JdbcTemplate, MyBatis와 같은 기술들은 내부에서 JDBC를 직접 사용하기 때문에 DataSourceTransactionManager 또는 JdbcTransactionManager를 사용한다.

JPA는 플러시라는 기능을 제공한다. 이것은 트랜잭션 커밋과 상관없이 변경 사항을 강제로 데이터베이스에 반영하는 거다.

정리

지금 상황에서 이 구조가 나을지 저 구조가 나을지(물론 구조들에 대해 공부도 해야 한다.) 고민을 하는 시간이 쌓이고 선택할 것이다. 그리고 이 선택이 좋았는지 나빴는지를 쌓아 가고 고민하면서 좋은 개발자가 된다.

스프링 트랜잭션 이해

스프링 트랜잭션 소개

트랜잭션 프록시라는 용어가 정확하진 않은 듯. 스프링 핵심 원리 고급 편에서 더 자세히 배운다.

다음 시간부턴 @Transactional을 할 때 어떤 일이 발생하는지, @Transactional 안의 옵션들이 어떻게 사용되는지를 코드로 배운다.

트랜잭션 적용 확인

AOP 등이 다 동작해야 하므로 @SpringBootTest가 필요하다.

스프링 컨테이너에는 실제 객체 대신에 프록시가 스프링 빈으로 등록되어 있다.

 

실제 객체는 스프링 컨테이너에 등록되지도 않는다.

logging.level.org.springframework.transaction.interceptor=TRACE

TransactionInterceptor가 로그를 남겨 주는 듯

 

@Test
void txTest() {
    basicService.tx();
    basicService.nonTx();
}

 

@Slf4j
static class BasicService {

    @Transactional
    public void tx() {
        log.info("call tx");
        boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
        log.info("tx active = {}", txActive);
    }

    public void nonTx() {
        log.info("call nonTx");
        boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
        log.info("tx active = {}", txActive);
    }
}
1.jpg.webp

Getting transaction for [hello.springtx.apply.TxBasicTest$BasicService.tx](이게 트랜잭션 이름인 듯)

@Transactional이 있으니 프록시가 먼저 호출된다.

 

call tx 호출하고

 

Getting transaction for [hello.springtx.apply.TxBasicTest$BasicService.tx] 여기서 트랜잭션 활성화해 줬으니

tx active = true로 나온다.

다 끝나고 나서 리턴이 되면 트랜잭션 프록시에서 Completing transaction for [hello.springtx.apply.TxBasicTest$BasicService.tx] 이 로그를 찍는다. 트랜잭션이 완료되었다. 사실 이 앞에 커밋이 되는데 그것까진 안 찍은 듯

 

그다음에 nonTx()가 호출된다. 여기선 TransactionInterceptor가 Getting transaction 같은 로그를 안 찍는다.

 

추가적으로 테스트)

nonTx()에도 @Transactional를 적용하면(또는 메서드 말고 클래스 레벨에 @Transactional을 적용하면)

2.jpg.webp

이렇게 출력된다.

TransactionSynchronizationManager.isActualTransactionActive() 현재 스레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능이다.

 

스레드라고 표현되어 있다. 고급 편 강의에서 배우겠지만 스레드 로컬이 내부적으로 들어 있기 때문이다.

트랜잭션 적용 위치

인터페이스에도 @Transactional을 적용할 수 있다. 다만 잘 사용하진 않는다.

다음 시간에 배우는 건 면접에서 물어볼 수도 있다.

트랜잭션 AOP 주의 사항 - 프록시 내부 호출1

이번 강의는 매우 중요하다.

@Autowired
CallService callService;

여기에 프록시가 주입된다.

트랜잭션 AOP 주의 사항 - 프록시 내부 호출2

private이나 protected 등의 메서드는 애초에 트랜잭션을 걸 이유가 없을 확률이 높다.

트랜잭션 AOP 주의 사항 - 초기화 시점

@SpringBootTest
public class InitTxTest {

    @Autowired
    Hello hello;

    @Test
    void go() {
        // 초기화 코드는 스프링이 초기화 시점에 호출한다.
    }

    @TestConfiguration
    static class InitTxTestConfig {
        @Bean
        Hello hello() {
            return new Hello();
        }
    }

    @Slf4j
    static class Hello {

        @PostConstruct
        @Transactional
        public void initV1() {
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("Hello init @PostConstruct tx active = {}", isActive);
        }

        public void initV2() {

        }
    }
}

여기서

 

@Test
void go() {
    // 초기화 코드는 스프링이 초기화 시점에 호출한다.
}

이것만 호출해도

 

@PostConstruct
@Transactional
public void initV1() {
    boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
    log.info("Hello init @PostConstruct tx active = {}", isActive);
}

이 코드가 호출된다.

Config 보면 스프링 컨테이너가 스프링 빈에 등록한다. 그 타이밍에 빈에 등록하고 나서 초기화 메서드가 자동으로 호출된다.

 

내가 직접 호출하면 안 된다. 직접 호출하면 트랜잭션이 적용된다. 그런데 지금 우리는 이걸 확인하려는 게 아니다.

@Test
void go() {
    // 초기화 코드는 스프링이 초기화 시점에 호출한다.
}

이걸 실행할 때

 

1.jpg.webp

여기서 보면 안 보인다.

스프링 컨테이너가 올라오는 로그는

 

2.jpg.webp

상위에서 봐야 한다.

@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initV2() {
.
.
.
}

ApplicationReadyEvent를 하면 스프링 컨테이너가 완전히 뜬 이후에 호출하게 한다. 스프링 컨테이너가 완전히 떴다는 뜻은 단순히 빈 하나의 차원이 아니라, 스프링 AOP, 트랜잭션 등 다 적용해서 스프링이 완전히 완성이 된 후다.

3.jpg.webp

Hello init @PostConstruct tx active = false라고 나온 후에

Started InitTxTest in 4.322 seconds (process running for 7.223) 스프링 컨테이너가 완료되어서 스프링이 뜬 거다.

 

스프링이 다 뜨고 @EventListener(ApplicationReadyEvent.class) 이게 있는 데에는 메서드를 호출해 준다.

 

그리고 Getting transaction for [hello.springtx.apply.InitTxTest$Hello.initV2] 이 메시지가 뜬다. 프록시에서 호출해 준 거다.

 

그리고

Hello init ApplicationReadyEvent tx active = true

이렇게 true로 나오고, 메서드가 끝나면

Completing transaction for [hello.springtx.apply.InitTxTest$Hello.initV2] 프록시에서 트랜잭션을 종료한다.

트랜잭션 안에서 뭔가 수행해야 하면 @EventListener(ApplicationReadyEvent.class) 이걸 사용하면 되고, 트랜잭션이 아니라 일반적으로 초기화를 할 거면 @PostConstruct를 쓰면 된다.

트랜잭션 옵션 소개

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Reflective
public @interface Transactional {
    @AliasFor("transactionManager")
    String value() default "";

    @AliasFor("value")
    String transactionManager() default "";

    String[] label() default {};

    Propagation propagation() default Propagation.REQUIRED;

    Isolation isolation() default Isolation.DEFAULT;

    int timeout() default -1;

    String timeoutString() default "";

    boolean readOnly() default false;

    Class<? extends Throwable>[] rollbackFor() default {};

    String[] rollbackForClassName() default {};

    Class<? extends Throwable>[] noRollbackFor() default {};

    String[] noRollbackForClassName() default {};
}

value에 넣으면 transactionManager가 지정된다.

보통 트랜잭션 매니저는 하나고 디폴트로 등록해 주는 걸 사용하기 때문에 생략하면 된다.

 

그런데 사용하는 트랜잭션 매니저가 둘 이상이라면 다음과 같이 트랜잭션 매니저의 이름을 지정해서 구분하면 된다.

isolation

트랜잭션 격리 수준을 지정할 수 있다. 보통 데이터베이스에서 설정한 트랜잭션 격리 수준을 사용하는 DEFAULT이다.

 

보통 데이터베이스 기본 설정대로 사용하는데 특별한 순간에 이런 걸 직접 사용할 수 있다. 그러면 이 트랜잭션에선 이거대로 한다.

readOnly = true 옵션을 사용하면 읽기 전용 트랜잭션이 생성된다. 이 경우 등록, 수정, 삭제가 안 되고 읽기 기능만 작동한다.

드라이버나 데이터베이스에 따라 정상 동작하지 않는 경우도 있다. readOnly = true라고 했는데도 DB에 insert가 될 수도 있다.

readOnly 옵션을 사용하면 읽기에서 다양한 성능 최적화가 발생할 수 있다.

 

이 옵션을 쓰는 이유가 주로 최적화 때문이다.

플러시는 뭐가 변경되었는지를 찾아서 DB에 변경 쿼리를 보내는 거다.

데이터베이스를 구축할 때 write가 가능한 DB 하나 만들어 놓고, read가 가능한 DB 여러 군데 만들어 놓는 식으로 쓴다.

 

읽기 전용 트랜잭션의 경우 읽기 데이터베이스 커넥션을 획득해서 사용한다.

 

그냥 읽기, 쓰기의 경우엔 읽기가 가능한 데이터베이스에 write를 하고 readOnly가 true이면 조회... 보통 읽기는 하나만 만들고 조회는 두세 개 이상 만든다. 그러면 읽기에 최대한 부하를 주지 않아야 한다. 조회는 여러 군데로 늘릴 수 있기 때문에 조회에서 부하를 여러 군데에 분산해서 받는 게 낫다.

보통 JPA를 사용할 때 읽기 관련된 곳에선 readOnly 옵션을 true로 주는 게 좋다. JPA에서 최적화가 많이 발생한다.

 

JdbcTemplate 같은 경우 그냥 SQL만 쓰면 성능에 있어서 큰 효과가 있진 않다.

 

상황에 따라선 readOnly를 했는데 더 느려질 수도 있다. 성능 테스트를 해 봐야 한다. readOnly를 하면 네트워크를 통해 데이터베이스에 전송이 된다. 그런 과정에서 상황에 따라 느려질 수도 있다. 이것도 DB마다, 로직마다 다르다. 일반적으로는 readOnly를 쓰는 게 낫다.

예외와 트랜잭션 커밋, 롤백 - 기본

@Test
void runtimeException() {
    service.runtimeException();
}

이걸 실행하면

 

1.jpg.webp

롤백했는지 커밋했는지 지금 로그로 확인 못 한다.

지금은 다음만 적용되어 있다.

logging.level.org.springframework.transaction.interceptor=TRACE

TransactionInterceptor는 완료되었는지만 알려 주는 듯

 

로그로 확인하려면 application.properties에 다음을 추가하자.

logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
#JPA log
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.org.hibernate.resource.transaction=DEBUG

 

추가하고 다시 실행하면

2.jpg.webp

트랜잭션을 만들었다는 로그가 뜬다. 트랜잭션 이름은 hello.springtx.exception.RollbackTest$RollbackService.runtimeException이다.

 

롤백이 되고 나서 complete가 되는 건데 JpaTransactionManager랑 TransactionInterceptor 두 개가 로그를 찍어 주느라 로그 순서가 좀 다르게 보이는 듯

@Test
void checkedException() {
    Assertions.assertThatThrownBy(() -> service.checkedException())
                    .isInstanceOf(MyException.class);
}

이걸 실행하면

 

3.jpg.webp

커밋이라고 나온다.

@Test
void rollbackFor() {
    Assertions.assertThatThrownBy(() -> service.rollbackFor())
                    .isInstanceOf(MyException.class);
}

이걸 실행하면 체크 예외이지만 롤백을 한다.

 

4.jpg.webp
logging.level.org.hibernate.resource.transaction=DEBUG

이걸 남긴 이유가 있는데 이건 나중에 설명하겠다.

예외와 트랜잭션 커밋, 롤백 - 활용

개발할 때 예외는 시스템 예외와 비즈니스 예외 2가지로 구분해야 한다.

#JPA SQL
logging.level.org.hibernate.SQL=DEBUG

이걸 추가한 후

 

@Test
void complete() throws NotEnoughMoneyException {
    // given
    Order order = new Order();
    order.setUsername("정상");

    // when
    orderService.order(order);

    //then
    Order findOrder = orderRepository.findById(order.getId()).get();
    assertThat(findOrder.getPayStatus()).isEqualTo("완료");
}

이걸 실행하자

 

1.jpg.webp

complete() 말고 Test Results에서 create table을 확인할 수 있다.

 

스프링이 뜰 때 create table orders라는 걸 만든다.

메모리 DB를 쓰면서, 별도의 설정이 없으면 스프링이 애플리케이션 실행할 때 JPA의 엔터티를 보고 테이블을 자동으로 만들어 준다.

 

JPA가 자동으로 테이블을 만드는 모드가 있다. 그걸 활성화해서 자동으로 테이블 만든다. 더 자세한 내용은 JPA 강의에서 배운다.

@Test
void runtimeException() {
    // given
    Order order = new Order();
    order.setUsername("예외");

    // when
    assertThatThrownBy(() -> orderService.order(order))
            .isInstanceOf(RuntimeException.class);

    //then
    Optional<Order> orderOptional = orderRepository.findById(order.getId());
    assertThat(orderOptional.isEmpty()).isTrue();
}

이걸 실행하면

 

2.jpg.webp

insert 쿼리가 안 보인다. JPA는 트랜잭션이 커밋될 때 insert 쿼리가 날아간다. 그런데 롤백이 되면 insert 쿼리를 DB에 날릴 필요가 없다.

 

지금 Order 클래스에 id가

@Id
@GeneratedValue
private Long id;

이렇게 되어 있다.

 

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

이렇게 바꾸면 insert 쿼리가 보인다.

Order findOrder = orderRepository.findById(order.getId()).get();

원래 이렇게 Optional에서 get()을 직접 사용하는 게 좋은 패턴은 아니지만 테스트니까 이렇게 하겠다.

로그 순서가 바뀔 수 있는데 이건 JPA를 이해해야 알 수 있다.

NotEnoughMoneyException은 시스템에 문제가 발생한 것이 아니라, 비즈니스 문제 상황을 예외를 통해 알려 준다. 마치 예외가 리턴 값처럼 사용된다.

 

지금처럼 예외를 발생시키지 않고 무언가를 리턴해서 로직을 짜도 된다.

정리

스프링에서 제공하는 트랜잭션은 public 메서드에만 적용된다. 사실 다른 데에도 적용할 수 있는데 설정이 복잡하다.

 

public에만 적용되는 이유가 있기 때문에 그냥 지금 그대로 사용하는 게 좋다.

체크 예외를 커밋하는 데 쓸 건지, 체크 예외도 롤백할 건지는 우리의 선택이다.

 

근데 굳이 rollbackFor를 굳이 안 쓰고 스프링의 기본 설정대로 사용하는 게 나을지도 모른다.

스프링 트랜잭션 전파1 - 기본

스프링 트랜잭션 전파1 - 커밋, 롤백

@Slf4j
@SpringBootTest
public class BasicTxTest {

    @Autowired
    PlatformTransactionManager txManager;

    @TestConfiguration
    static class Config {
        
        @Bean
        public PlatformTransactionManager platformTransactionManager(DataSource dataSource) {
            return new DataSourceTransactionManager(dataSource);
        }
    }

}

원래는 스프링 부트가 트랜잭션 매니저도 자동으로 등록해 준다. 그런데 내가 빈으로 직접 등록하게 되면 내가 등록한 게 대신 사용된다.

DefaultTransactionAttribute랑 DefaultTransactionDefinition 둘 중 아무거나 써도 될 듯

 

DefaultTransactionAttribute가 기능이 더 많고 DefaultTransactionDefinition이 부모다.

@Test
void commit() {
    log.info("트랜잭션 시작");
    TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
    
    log.info("트랜잭션 커밋 시작");
    txManager.commit(status);
    log.info("트랜잭션 커밋 완료");
}

이걸 실행하면

 

1.jpg.webp

Releasing JDBC Connection [HikariProxyConnection@1043796104 wrapping conn0: url=jdbc:h2:mem:97b8b3ac-7fa0-4514-8b95-e2f674ada34b user=SA] after transaction

커밋 후에 DB 커넥션을 다시 커넥션 풀에 돌려준다.

스프링 트랜잭션 전파2 - 트랜잭션 두 번 사용

Acquired Connection [HikariProxyConnection@1064414847 wrapping conn0] for JDBC transaction

 

트랜잭션1을 시작하고, 커넥션 풀에서 conn0 커넥션을 획득했다. 물리 데이터베이스에 연결된 실제 물리 커넥션이라고 이해하면 된다.

로그를 보면 트랜잭션1과 트랜잭션2가 같은 conn0 커넥션을 사용 중이다. 다만 둘은 완전히 다른 커넥션으로 인지하는 것이 맞다.

 

커넥션 풀이 아니었다면 conn0과 conn1이었을 것이다.

@TestConfiguration
static class Config {

    @Bean
    public PlatformTransactionManager platformTransactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

지금은 우리가 DataSource를 직접 만든 게 아니고 스프링이 DataSource를 만들어서 주입했다. 이건 Hikari 커넥션 풀을 써서 제공해 준다.

히카리 커넥션 풀에서 커넥션을 획득하면 실제 커넥션을 그대로 반환하는 것이 아니라 내부 관리를 위해 히카리 프록시 커넥션이라는 객체를 생성해서 반환한다. 물론 내부에는 실제 물리 커넥션이 포함되어 있다

 

커넥션 풀에 커넥션을 달라고 할 때마다 객체를 새로 만든다. 그때마다 참조하는 주소가 다르다. 다만 실제 DB랑 연결된 내부의 물리 커넥션은 같은 거다.

 

트랜잭션1이 풀에 조회를 하면 히카리 객체를 생성해서 이 안에 conn0을 담아서 반환해 준다. 다 끝나고 반납을 하면 히카리 객체는 파괴되지만 conn0은 풀 안에서 살아 있다.

 

트랜잭션2가 풀에다 달라고 하면 히카리 프록시 커넥션은 새로 생성하고 그 안엔 풀에 있는 걸 담아서 우리에게 반환한다. 물론 이것도 반환하고 나면 안의 커넥션은 반납하지만 히카리 객체는 파괴된다.

 

객체 안에 딱히 데이터가 없기 때문에 생성하고 파괴한다고 해서 크게 메모리를 쓰진 않는다.

2.jpg.webp

이 그림은 커넥션 풀을 사용하지 않는다고 가정한 그림인 듯

히카리 풀을 사용하면 같은 커넥션을 재사용할 수 있지만 반납한 걸 얻은 것이기 때문에 다르다고 인식하면 될 듯

스프링 트랜잭션 전파3 - 전파 기본

트랜잭션 전파라는 건 순수한 트랜잭션 자체에서 말하는 것도 있고, 지금 설명하는 건 스프링이 제공하는 트랜잭션 전파 기능에 대해서다.

스프링 트랜잭션 전파4 - 전파 예제

Switching JDBC Connection [HikariProxyConnection@1981418429 wrapping conn0: url=jdbc:h2:mem:a4fcfb6e-1f0b-49f8-9d3e-5ea83f631c74 user=SA] to manual commit

 

manual commit? 수동 커밋 모드를 한다는 뜻인 듯. 트랜잭션을 시작한다는 거다.

1.jpg.webp

내부 트랜잭션 커밋 메시지는 떴는데 아무 로그가 안 나오고 바로 외부 트랜잭션 커밋 로그가 나온다. 즉, 내부에선 아무것도 안 했다.

내부 트랜잭션을 시작하거나 커밋할 때는 DB 커넥션을 통해 커밋하는 로그를 전혀 확인할 수 없다.

 

시작할 때도 마찬가지다. JDBC 커넥션 획득하는 로그가 안 뜬다. 그냥 Participating in existing transaction 이렇게만 나온다.

7. txManager.getTransaction()을 호출해서 내부 트랜잭션을 시작한다.

8. 트랜잭션 매니저는 트랜잭션 동기화 매니저를 통해서 기존 트랜잭션이 존재하는지 확인한다.

 

사실 외부 트랜잭션 때도 기존 트랜잭션이 존재하는지 확인한다.

결국 내부 트랜잭션에선 트랜잭션 시작할 때 새로운 커넥션도 안 만들고 커밋 호출도 안 한다. 결국 외부에 다 맡긴다. 그게 트랜잭션에 참여한다는 것의 진짜 뜻이다.

모든 논리 트랜잭션이 커밋하면 물리 트랜잭션 커밋이 일어난다고 이해해도 되고,

 

물리 트랜잭션이 커밋할지 롤백할지는 외부 트랜잭션에서 판단한다고 생각해도 된다.

정리하면 외부 트랜잭션만 물리 트랜잭션을 시작하고, 커밋한다.

 

스프링은 이렇게 여러 트랜잭션이 함께 사용되는 경우, 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리하도록 한다. 이를 통해 트랜잭션 중복 커밋 문제를 해결한다.

스프링 트랜잭션 전파5 - 외부 롤백

내부 트랜잭션이 시작할 때 그냥 아무것도 안 하고 참여만 한다.

 

내부 트랜잭션에서 insert나 update를 하면 트랜잭션 동기화 매니저에 있는 외부 커넥션을 가져와서 쓴다. 근데 그 외부 커넥션은 어차피 외부 트랜잭션이 다 수행해 놓은 것이기 때문에 그냥 그대로 그걸 쓰면 된다.

 

물론 내부 트랜잭션 커밋할 때도 아무것도 안 한다.

 

사실 내부 트랜잭션 시작할 때나 커밋할 때 뭔가를 하긴 하는데 커넥션에 대고 뭔가를 하진 않는 듯

내부는 어차피 외부에 참여한 것이기 때문에 외부만 롤백되면 전체가 다 물리적으로 롤백된다. 내부에서 insert를 하고 커밋하더라도 결국 롤백된다.

스프링 트랜잭션 전파6 - 내부 롤백

@Test
void inner_rollback() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());

    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("내부 트랜잭션 롤백");
    txManager.rollback(inner);

    log.info("외부 트랜잭션 커밋");
    txManager.commit(outer);
}

이걸 실행하면 예외가 터진다.

 

2.jpg.webp

내부 트랜잭션 롤백 후 다음 메시지가 뜬다.

Participating transaction failed - marking existing transaction as rollback-only

 

rollback-only라는 걸 현재 내가 참여 중인 트랜잭션(외부 트랜잭션)에 표시한다.

 

Setting JDBC transaction [HikariProxyConnection@220038608 wrapping conn0] rollback-only

 

JDBC 트랜잭션에 rollback-only를 세팅했다.

Global transaction is marked as rollback-only but transactional code requested commit

 

전체 트랜잭션이 rollback-only라고 되어 있다. 하지만 현재 트랜잭션 코드는 commit을 요구했다. 그래서 최종적으로 트랜잭션 매니저는 다음과 같이 롤백한다.

 

Initiating transaction rollback Rolling back JDBC transaction on Connection [HikariProxyConnection@220038608 wrapping conn0] Releasing JDBC Connection [HikariProxyConnection@220038608 wrapping conn0] after transaction

스프링 트랜잭션 전파7 - REQUIRES_NEW

1.jpg.webp

Suspending current transaction, creating new transaction with name [null]

현재 트랜잭션(외부 트랜잭션)을 잠깐 미루고 새 트랜잭션을 만든다. 그리고

Acquired Connection [HikariProxyConnection@313945225 wrapping conn1: url=jdbc:h2:mem:071afb6c-709f-4fb5-86dd-86559234ba68 user=SA] for JDBC transaction

완전히 다른 커넥션을 획득한다. 이전엔 conn0이었지만 지금은 conn1이다.

 

Switching JDBC Connection [HikariProxyConnection@313945225 wrapping conn1: url=jdbc:h2:mem:071afb6c-709f-4fb5-86dd-86559234ba68 user=SA] to manual commit

그리고 manual commit으로 바꾼다.

Resuming suspended transaction after completion of inner transaction

내부 트랜잭션 때문에 미뤄 뒀던 트랜잭션(외부 트랜잭션)을 다시 재개한다.

내부 트랜잭션은 외부 트랜잭션에 참여하지 않는다. 기존 것은 잠시 미뤄 두고, 내부 트랜잭션이 커넥션을 새로 획득해서 여기서부터 다시 시작한다.

내부 트랜잭션 로직 안에선 내부 커넥션만 쓴다.

미뤄 놨다는 게 실제 물리 커넥션에 손을 대는 건 아니고 그냥 내부에서 사용을 안 하는 거다. 잠시 안 쓴다는 거지, 커넥션은 여전히 동기화 매니저 안에 살아 있다.

REQUIRES_NEW를 사용하면 데이터베이스 커넥션이 동시에 2개 사용된다는 점을 주의해야 한다. 물론 내부 트랜잭션에서 conn2를 쓰면서 conn1은 잠시 지연된다. 그래도 결국 두 개가 다 떠 있는 거다.

 

현재 스레드 로직에서 2개를 다 가지고 있다. 한 번의 HTTP 요청이 왔는데 커넥션 2개가 동시에 사용되기 때문에 잘못하면 데이터베이스 커넥션이 빨리 고갈될 수 있다. 빨리 처리되면 상관없는데, 이런 데에서 로직을 잘못 짜서 내부에서 엄청 오래 걸리면, 고객이 500명밖에 요청을 안 했는데, DB엔 천 명의 커넥션이 연결될 수 있다. 그래서 500개로 맞춰 놨으면 천 개 요구할 때 커넥션을 못 얻어서 장애가 날 수 있다.

 

실제로 이런 일이 많지는 않은데 트래픽이 많거나 성능이 중요한 데에선 이런 부분도 조심히 사용해야 한다.

정리

@TestConfiguration
static class Config {

    @Bean
    public PlatformTransactionManager platformTransactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

JpaTransactionManager 써도 된다.

UnexpectedRollbackException이 나오면 잡거나 던지거나 해서 처리하면 된다. 그대로 두면 런타임 에러이기 때문에 오류 페이지까지 넘어간다.

11.jpg.webp

여기선 어차피 일대일이므로 논리 트랜잭션 이런 개념이 필요 없다.

스프링 트랜잭션 전파2 - 활용

트랜잭션 전파 활용1 - 예제 프로젝트 시작

JPA는 스펙상 기본 생성자가 필요하다.

스프링 데이터 JPA는 아직 익숙하지 않고 눈에 안 보이는 게 많아서 지금은 그냥 JPA를 쓰겠다.

JPA의 모든 데이터 변경은 트랜잭션 안에서 이루어져야 한다. 스프링 데이터 JPA나 QueryDSL은?

public Optional<Member> find(String username) {
    return em.createQuery("select m from Member m where m.username = :username", Member.class)
            .setParameter("username", username)
            .getResultList().stream().findAny();
}

find() 메서드는 username으로 찾을 수 있도록 만들 것이다.

id로 조회하면 id가 기본키이므로 em.find()로 찾으면 되는데, id로 조회하는 게 아니기 때문에 jpql을 사용해야 한다.

 

하나를 찾는 경우 getSingleResult()라는 게 있다. 그런데 값이 없으면 NoResultException이 터진다. 그래서 지금은 이렇게 안 하겠다. 물론 이렇게 쓸 때도 있다.

 

.getResultList().stream().findAny();

만약 결과가 2개가 나오면 둘 중 하나만 찾아서 반환한다. 만약 값이 없으면 Optional 안이 empty다.

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {

    private final EntityManager em;
.
.
.

}

이렇게 하면 스프링이 EntityManager 주입해 준다.

예전엔 private final EntityManager em; 위에 @PersistenceContext를 넣어야 했다. 그런데 스프링이 버전이 오르면서 알아서 주입해 준다.

public void joinV1(String username) {
    Member member = new Member(username);
    Log logMessage = new Log(username);

    log.info("memberRepository 호출 시작");
    memberRepository.save(member);
    log.info("memberRepository 호출 종료");
    
    log.info("logRepository 호출 시작");
    logRepository.save(logMessage);
    log.info("logRepository 호출 종료");
}

로그 메시지는 그냥 username으로 적겠다.

Log log라고 하면 Slf4j의 log랑 겹쳐서 logMessage라고 하겠다.

assertTrue(memberRepository.find(username).isPresent());

이번엔 import static org.junit.jupiter.api.Assertions.*; 여기 것을 쓰겠다.


Optional이기 때문에 뭔가를 담고 있다. 그래서 isPresent() 같은 메서드가 있다.

트랜잭션 전파 활용2 - 커밋, 롤백

MemberRepository에서 em.persist(member); 이 로직을 수행하면 내부에서 con1을 쓴다.

추가적으로 테스트)

 

/**
 * memberService        @Transactional: OFF
 * memberRepository     @Transactional: ON
 * logRepository        @Transactional: ON Exception
 */
@Test
void outerTxOff_fail() {
    // given
    String username = "로그 예외_outerTxOff_fail";

    // when
    assertThatThrownBy(() -> memberService.joinV1(username))
            .isInstanceOf(RuntimeException.class);

    // then: 완전히 롤백되지 않고, member 데이터가 남아서 저장된다.
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isEmpty());
}

이 테스트는 성공했다.

그런데 joinV1 코드 들어가서

 

memberRepository.save(member);
logRepository.save(logMessage);

이 순서를

 

logRepository.save(logMessage);
memberRepository.save(member);

이 순서로 바꾸면 테스트 실패한다.

트랜잭션 전파 활용3 - 단일 트랜잭션

zxc.jpg.webp

MemberService의 프록시가 트랜잭션 매니저를 통해 커넥션 생성하고 setAutoCommit(false)도 한 후에 트랜잭션 동기화 매니저에 커넥션을 넣는다. 그러면 이후의 나의 로직들은 이걸 커밋하거나 롤백하기 전까진 전부 다 같은 커넥션을 쓴다.

이런 원리로 모든 범위가 같은 트랜잭션 범위가 된다.

 

같은 스레드를 사용하면 트랜잭션 동기화 매니저는 같은 커넥션을 반환한다. 이건 스레드 로컬 때문인데 이건 고급 편 강의에서 배운다.

 

-> REQUIRES_NEW를 사용하면, 같은 스레드라도 트랜잭션 동기화 매니저는 다른 커넥션을 반환하는 듯(GPT 답변이라서 다시 확인해 보기)

123.jpg.webp

JPA의 특징인데 트랜잭션 커밋할 때 JPA가 insert 쿼리를 날리는 게 있다.

즉, JPA는 em.persist(member);를 실행할 때 insert 쿼리를 날리지 않는다.(GPT에 의하면 Identity 전략을 사용하거나 em.flush()를 명시적으로 호출하면 persist() 후에도 즉시 insert 쿼리가 실행될 수 있는 듯)

 

트랜잭션 커밋을 하면... 플러시라고 하는데, JPA insert 쿼리나 update 쿼리를 DB에 날리고 그다음에 바로 DB 트랜잭션 커밋을 한다.

트랜잭션 전파 활용4 - 전파 커밋

같은 물리 트랜잭션을 사용한다는 것은 같은 동기화 커넥션을 사용한다는 뜻이다.

지금은 MemberServiceTest에 @Transactional을 걸면 안 된다. 여기서 트랜잭션 시작하면 나머지가 트랜잭션 전파 때문에 우리가 원하는 테스트가 안 된다.

 

그렇기 때문에 DB에 데이터가 남아 있다. 따라서 username은 테스트별로 각각 다르게 설정해야 한다. 그렇지 않으면 다음 테스트에 영향을 준다.(모든 테스트가 완료되어야 DB가 사라진다.)

트랜잭션 전파 활용5 - 전파 롤백

/**
 * memberService        @Transactional: ON
 * memberRepository     @Transactional: ON
 * logRepository        @Transactional: ON Exception
 */
@Test
void outerTxOn_fail() {
    // given
    String username = "로그 예외_outerTxOn_fail";

    // when
    org.assertj.core.api.Assertions.assertThatThrownBy(() -> memberService.joinV1(username))
            .isInstanceOf(RuntimeException.class);

    // then: 모든 데이터가 롤백된다.
    assertTrue(memberRepository.find(username).isEmpty());
    assertTrue(logRepository.find(username).isEmpty());
}
11111.jpg.webp

논리 트랜잭션C에서 롤백이 생기는데 논리 트랜잭션A에서도 롤백이 생긴다. Exception을 또 못 잡기 때문이다.

 

잡았다면 몰라도 잡지 않았기 때문에 또 터진다. MemberService에서 RuntimeException 예외를 잡지 않아서 MemberService의 프록시에 던진다.

그래서 논리 트랜잭션A에서도 트랜잭션 매니저에 롤백 요청을 한다.

 

신규 트랜잭션이므로 물리 롤백을 한다.

참고로 이 경우 어차피 롤백이 되었기 때문에, rollbackOnly 설정은 참고하지 않는다. 논리 트랜잭션A에서 커밋을 했다면 내부에서 rollbackOnly 설정한 게 문제가 되는데, 어차피 밖에서도 롤백이 된 거다.

 

롤백을 하고 이 예외가 또 밖으로 던져진다. 그래서 클라이언트 코드인

org.assertj.core.api.Assertions.assertThatThrownBy(() -> memberService.joinV1(username))
        .isInstanceOf(RuntimeException.class);

여기까지 날아온다.

회원과 회원 이력 로그를 처리하는 부분을 하나의 트랜잭션으로 묶은 덕분에 문제가 발생했을 때 회원과 회원 이력 로그가 모두 함께 롤백된다. 따라서 데이터 정합성에 문제가 발생하지 않는다.

 

비록 논리 트랜잭션B에서 커밋을 했지만 어차피 다 하나의 트랜잭션으로 묶이기 때문에, 논리 트랜잭션 중 하나라도 롤백이 되면 롤백이 되기 때문에(여기선 논리 트랜잭션 3개 중 2개가 롤백되었다.) 전부 롤백된다.

트랜잭션 전파 활용6 - 복구 REQUIRED

MemberService의 joinV2()에도 @Transactional을 적용하자.

어떻게 해야 다음 요구 사항을 만족할 수 있을까?

회원 가입을 시도한 로그를 남기는 데 실패하더라도 회원 가입은 유지되어야 한다.

 

여러 가지 선택할 수 있는 옵션들이 있다.

우리는 REQUIRES_NEW 옵션을 가지고 해결할 것이다.

트랜잭션 전파 활용7 - 복구 REQUIRES_NEW

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage) {
    log.info("log 저장");
    em.persist(logMessage);
    
    if (logMessage.getMessage().contains("로그 예외")) {
        log.info("log 저장 시 예외 발생");
        
        throw new RuntimeException("예외 발생");
    }
}

여기서 예외가 터진다. 근데 이건 완전 별도의 트랜잭션 영역이다. 별도의 데이터베이스 커넥션을 사용한다. 여기서 롤백이 생기면 여기만 롤백한다.

 

다만 롤백이 되더라도 RuntimeException이 밖으로 나간다. 예외를 처리한 건 아니기 때문이다.

 

@Transactional
public void joinV2(String username) {
    Member member = new Member(username);
    Log logMessage = new Log(username);

    log.info("memberRepository 호출 시작");
    memberRepository.save(member);
    log.info("memberRepository 호출 종료");

    log.info("logRepository 호출 시작");
    try {
        logRepository.save(logMessage);
    } catch (RuntimeException e) {
        log.info("log 저장에 실패했습니다. logMessage = {}", logMessage.getMessage());
        log.info("정상 흐름 반환");
    }
    log.info("logRepository 호출 종료");
}

그걸 서비스에서 잡는다. 그리고 커밋한다. rollbackOnly도 없어서 그대로 커밋한다.

 

그래서

@Test
void recoverException_success() {
    // given
    String username = "로그 예외_recoverException_success";

    // when
    memberService.joinV2(username);

    // then: member 저장, log 롤백
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isEmpty());
}

회원은 커밋, 로그는 롤백된다.

22222.jpg.webp

LogRepository를 시작할 땐 완전히 별도의 데이터베이스 커넥션을 쓰고, 이 로직을 사용하는 동안엔 con1은 잠시 미뤄 둔다. 이 안에선 con2만 사용한다.

333.jpg.webp

REQUIRES_NEW를 쓰지 않고 이렇게 할 수도 있다. 참고로 이 그림에선 MemberFacade엔 @Transactional이 없다.

 

상황에 따라선 이렇게 할 수도 있고, 못 할 수도 있다.

 

REQUIRES_NEW를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있다면, 그 방법을 선택하는 것이 더 좋다. 상황에 따라선 어쩔 수 없이 REQUIRES_NEW를 써야 할 때도 있다.

정리

트랜잭션 전파가 없었다면 트랜잭션이 있는 메서드, 없는 메서드 다 분리하고 복잡해질 것이다.

섹션 12 퀴즈

내부에서 예외 발생 시, 스프링은 현재 트랜잭션(여기서는 외부 트랜잭션)을 롤백 전용으로 표시한다. 이는 외부 트랜잭션 관리자에게 전달된다.

다음으로

다음으로

스프링 핵심 원리 고급 편과 스프링 부트 강의는 뭘 먼저 들을지는 상관없다.