개발자로 후회없는 삶 살기

spring PART.중간점검 4 본문

[백엔드]/[spring | 학습기록]

spring PART.중간점검 4

몽이장쥰 2023. 5. 17. 11:49

서론

중간점검 3에 작성된 순수 jdbc 레포지토리를 sql 맵퍼를 사용하도록 바꿔봅니다. 기획과 설계는 동일하고 레포지토리 구현체만 바꿔끼울 것입니다. 스프링의 OCP를 제대로 활용해 봅니다.

 

본론

- 개발 시작

1. 도메인별 역할 개발

1) 회원 도메인

왼 : 기존/ 오 : 이후

기존에는 web과 domain 폴더만 있었고 domain에 레포와 서비스가 있었습니다. 여기서 서비스를 domain과 같은 계층으로 올립니다. 서비스 패키지를 만드고 하위 패키지 만들지 말고 그 안에 그냥 바로 서비스 구현체 클래스를 넣습니다. 서비스는 보통 인터 안 만듭니다.

 

왼 : 이전/ 오 : 이후

레포지토리도 같은 계층으로 올립니다. 레포 패키지를 만들고 하위에 도메인 처럼 멤버, item 패키지를 만들고 그 안에 member면 member인터, member구현체를 만듭니다.

 

최종 변경 결과

최종 dir 구조 모습입니다. 도메인에 있던 서비스와 레포를 뺐는데 그러면 도메인에는 member 하위 패키지가 없어지고 인터티 도메인 클래스만 있습니다.

 

2. 도메인별 구현 개발

1) 회원 도메인 비즈니스 로직 구현

기존에 순수 jdbc를 탬플릿과 mybatis로 교체합니다.

@Slf4j
@Repository
public class JdbcTemplateMemberRepository implements MemberRepository {
    private final NamedParameterJdbcTemplate template;
    private final DataSource dataSource;

    public JdbcTemplateMemberRepository(DataSource dataSource) {
        this.dataSource = dataSource;
        this.template = new NamedParameterJdbcTemplate(dataSource);
    }

    @Override
    public Member save(Member member) {
        String sql = "insert into member(LOGINID, PASSWORD, USERNAME, MONEY) values(:loginId, :password, :username, :money)";

        KeyHolder keyHolder = new GeneratedKeyHolder();
        SqlParameterSource param = new BeanPropertySqlParameterSource(member);
        template.update(sql, param, keyHolder);
        log.info("save");
        return member;
    }

탬플릿이고

 

<?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="transaction.account_transfer.repository.member.mybatis.MemberMapper">
    <insert id="save">
        insert into member (LOGINID, PASSWORD, USERNAME, MONEY)
        values (#{loginId}, #{password}, #{username}, #{money})
    </insert>
    <update id="update">
        update member
        set MONEY = #{money}
        where LOGINID = #{id}
    </update>
    <select id="findAll" resultType="Member">
        select LOGINID, PASSWORD, USERNAME, MONEY
        from member
    </select>
    <select id="findByLoginId" resultType="Member">
        select LOGINID, PASSWORD, USERNAME, MONEY
        from member
        where LOGINID = #{loginId}
    </select>
</mapper>

mybatis입니다. 순수 jdbc를 사용했을 때보다 훨씬 간결해집니다.

 

 

3. 비즈니스 로직 단위 테스트

@Slf4j
@Transactional
class JdbcTemplateMemberRepositoryNoSpringTest {
    private MemberRepository memberRepository;
    @BeforeEach
    void beforeEach() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource("jdbc:h2:tcp://localhost/~/test", "sa", "");
        this.memberRepository = new JdbcTemplateMemberRepository(dataSource);
    }

    @Test
    void saveEx() {
        Member member = new Member("q", "h", "h", 10);
        memberRepository.save(member);
    }
}

테스트 작성할 때 빈 등록한 것을 사용하려면 무조건 SpringBootTest 써야합니다. TestConfig도 스프링 빈을 직접 등록하는 것이라서 SpringBootTest 써야합니다. 안 쓰고 하려면 그냥 단위 테스트해야하고 빈 등록한 것을 사용하면 안 되고 BeforeEach에서 직접 생성자로 생성해야 합니다.

 

+ 테스트는 다른 프로퍼티스하라고 했는데 프로퍼티스 작성하는 순간 springboottest되는 것입니다 그리고 그렇게 하는 순간 app 실행은 초기화 데이터 넣어야 하는 거고 그러면 app 클래스에 프로파일도 따로 해야하는 것이고 그렇습니다. 단위 테스트에 내장 db를 쓰는 방법은 없습니다. 스프링이 내장 db를 제공하는 것이라서 내장 db 쓸 거면 SpringBootTest 해야하는 것입니다.

 

- 이슈 사항

1. JDBC 탬플릿 JDBC 종속 예외 발생

 

탬플릿 레포로 전환하고 어플리케이션에서 PK 중복을 일으켰더니 JdbcSQLIntegrityConstraintViolationException이 발생합니다.

 

이것이 앞에 JDBC가 붙어서 "설마 JDBC 종속 예외겠어?" 하고 DataAccessException에 찾아보니 없는 것으로 봐서 JDBC 종속 예외가 맞습니다. 탬플릿이 알아서 스프링 예외 추상화로 변환해주는 줄 알았는데 황당했습니다.

 

-> 해결

1) 테스트 코드

테스트를 돌려보면 DuplicateKeyException 예외로 바뀌었고

cause by JdbcSQLIntegrityConstraintViolationException가 나옵니다. 정답은 레포에서는 jdbc 예외가 발생하고 레포를 호출한 곳에서는 스프링 예외가 뜨는 것이었습니다. 처음에 JdbcSQLIntegrityConstraintViolationException 이게 발생한 이유는 레포에서 error 로그를 찍었기 때문입니다.

 

> 레포에서 예외 로그를 찍으면 jdbc예외가 뜨는 것이고 레포를 호출한 쪽에서 찍으면 스프링 예외가 뜹니다. 레포는 특정 기술에 종속될 수밖에 없으니 상관없고 레포를 호출한 측으로 던질 때 변환해서 던지는 것이었습니다.

 

2) 컨트로러 Exception Handler

@ExceptionHandler(DuplicateKeyException.class)
public void DuplicateExHandler(DuplicateKeyException e) {
    log.error("[ex = {}]", e.getMessage());
}

jdbc 탬플릿 레포를 호출하는 컨트롤러에서 ExceptionHandler를 사용해보면

 

DuplicateKeyException이 잘 잡힙니다. 역시 레포를 호출한 측은 스프링 예외가 전달됩니다. ★여기서 또 알 게 된 것이 jpa든 my든 탬플릿이든 JDBC 드라이버에서 발생하는 SQL 예외를 스프링 예외 로 자동으로 변환 해준다는 것입니다.★

 

 

2. DB에 enum 타입 저장

@Setter
@Getter
@ToString
public class Item {
    private Long id;
    private String itemName;
    private int price;
    private int quantity;
    private Boolean open;
    private Region[] regions;
    private ItemType itemType;
    private String deliveryCode;
    private UploadFile attachFile; // 일단 파일명 하나만 저장하자
    private List<UploadFile> imageFiles;

엔터티 필드에 enum 타입도 있고 UploadFile 클래스 타입도 있습니다. 이를 DB에 저장하는 방법을 알아봅니다.

 

1) enum 타입 저장

Item item = new Item("A", 1, 1, true, Region.values(),
        ItemType.BOOK, "A",  new UploadFile("A", "A"),
        Arrays.asList(new UploadFile("A", "A")));

저장할 엔터티를 알아보면 enum 배열인 Region.values()와 enum인 ItemType이 있습니다.

 

create table item
(
    item_id bigint generated by default as identity,
    item_name varchar(10),
    price integer,
    quantity integer,
    open boolean,
    regions varchar(255),
    item_type varchar(255),
    delivery_code varchar(10),
    attach_file varchar(255),
    image_files varchar(255),
    primary key (item_id),
    check ( regions in ('BOOK', 'FOOD', 'ETC') )
    check ( item_type in ('BOOK', 'FOOD', 'ETC') )
);

테이블을 보면 enum 타입을 받기 위해서는 regions와 item_type에 check in 구문을 사용하고 type은 varchar()로 해야합니다.

 

그렇다면 엔터티의 필드도 String으로 바꿔야할까요? ✅

public Item save(Item item) {
    String sql = "insert into item(item_name, price, quantity, open, regions, item_type, delivery_code, attach_file, image_files)" +
            " values(:itemName, :price, :quantity, :open, :regions, :itemType, :deliveryCode, :attachFile, :imageFiles)";

    KeyHolder keyHolder = new GeneratedKeyHolder();
    SqlParameterSource param = new MapSqlParameterSource()
            .addValue("itemName", item.getItemName())
            .addValue("price", item.getPrice())
            .addValue("quantity", item.getQuantity())
            .addValue("open", item.getOpen())
            .addValue("regions", Arrays.toString(item.getRegions()))
            .addValue("itemType", item.getItemType().name())
            .addValue("deliveryCode", item.getDeliveryCode())
            .addValue("attachFile", item.getAttachFile().getStoreFileName())
            .addValue("imageFiles", item.getImageFiles().get(0).getStoreFileName());

결과적으로 엔터티는 건들면 안 됩니다. 우리는 항상 엔터티 중심적으로 개발을 해야하지 DB에 의존하면 안됩니다. 따라서 엔터티 필드와 초기화는 그대로 두고 insert 문에서 Arrays.toString으로 enum 배열을 저장하였고

 

create table item
(
    item_id bigint generated by default as identity,
    item_name varchar(10),
    price integer,
    quantity integer,
    open boolean,
    regions varchar(255),
    item_type varchar(255),
    delivery_code varchar(10),
    attach_file varchar(255),
    image_files varchar(255),
    primary key (item_id),
    check ( item_type in ('BOOK', 'FOOD', 'ETC') )
);

enum 배열에 해당하는 쿼리는 문자열을 저장하기 위해 check in 구문이 아닌 단순 varcher로 바꿨습니다.

 

또한 enum 타입은 ItemType.BOOK을 했을 경우 타입이 ItemType이라서 DB에 Varchar로 저장할 수 없습니다. 따라서 name()으로 string으로 변환 후 저장합니다.

 

결과를 보면 enum의 desc는 빼고 영어만 저장되어있습니다. name은 시스템 코드이고, desc가 화면에 보여줄 사용자 코드이므로 DB에는 영어를 저장해야 합니다.

 

2) enum 타입 조회

근데 여기 치명적인 실수가 있었습니다. 지금부터 위의 문제를 해결해봅니다.

 

문제 : 조회시 DB에 저장된 Region 배열 값을 가져오지 못하는 상황

findById시 '[SEOUL'이 Region의 상수가 아니라는 에러가 발생합니다.

 

private Region[] regions;

regions는 필드에 배열 형식으로 저장이 되는데 insert 문에서 .addValue("regions", Arrays.toString(item.getRegions()))으로 저장하여

 

regions = "[SEOUL, PUSAN, JEJU]"

 

findById를 하면 이렇게 enum 타입에 문자열이 들어갑니다. DB의 regions 속성은 varchar()인데 필드의 regions 속성은 enum 타입이라서 생기는 차이를 해결해야 합니다.

 

-> 해결 방법 :

DB의 regions에 저장된 문자열을 array로 변환하고 ENUM 배열로 만든 후 Regions에 대입

Region[] regions = {BOOK, FOOD};

최종적으로 findById에서 이런 대입이 되어야합니다.

 

1) '[SEOUL'에 대괄호 없애기

.addValue("regions", Arrays.toString(item.getRegions()))

대괄호가 생기는 이유는 insert 문에서 getRegions로 이넘 배열을 받고 [ ]가 있는 채로 문자열로 변환해서 그렇습니다.

 

.addValue("regions", Arrays.stream(item.getRegions())
        .map(Object::toString)
        .collect(Collectors.joining(", ")))

따라서 먼저 스트림으로 대괄호를 벗깁니다. 그러면 "SEOUL, PUSAN, JEJU"의 형식이 되고 DB에 이렇게 저장이 됩니다.

 

private RowMapper<Member> memberRowMapper() {
    return BeanPropertyRowMapper.newInstance(Member.class);
}

이제 DB에 저장된 문자열을 id로 조회하기 위해 mapper를 손봐야합니다. 원래는 위처럼 추상화 되어있던 것인데 내부에서 타입변환을 하기 위해 직접 작성합니다.

 

private RowMapper<Item> itemRowMapper() {
    return ((rs, rowNum) -> {
        Item item = new Item();
        Region[] regions = new Region[3];

        item.setId(rs.getLong("item_id"));
        item.setItemName(rs.getString("item_name"));
        item.setPrice(rs.getInt("price"));
        item.setQuantity(rs.getInt("quantity"));
        item.setOpen(rs.getBoolean("open"));

        // regions enum 배열 타입
        String[] stringRegionArray = rs.getString("regions").split(", ");
        for(int i = 0; i < stringRegionArray.length; i++){
            regions[i] = Region.valueOf(stringRegionArray[i]);
            log.info("regions = {}", stringRegionArray[i]);
        }
        item.setRegions(regions);

        item.setItemType(ItemType.valueOf(rs.getString("item_type")));
        item.setDeliveryCode(rs.getString("delivery_code"));

        log.info("{}", regions);
        return item;
    });
}

regions enum 배열 타입 주석 부분을 보면 문자열을 split하고 선언해둔 Region 배열에 대입한 후 item에 set합니다.

 

@Test
void findById() {
    // given
    Item item = new Item("A", 1, 1, true, Region.values(),
            BOOK, "A");

    // when
    Item saveItem = itemRepository.save(item);
    Item findItem = itemRepository.findById(1L);

    log.info("item = {}", item);
    log.info("saveItem = {}", saveItem);

    // then
    assertThat(saveItem).isEqualTo(findItem);
}

테스트를 돌려보면 find를 성공하고

 

원하는 대로 저장이 됩니다.

'[백엔드] > [spring | 학습기록]' 카테고리의 다른 글

spring PART.데이터 접근 기술 활용 방안  (0) 2023.05.19
spring PART.Querydsl  (0) 2023.05.18
spring PART.스프링 데이터 JPA  (0) 2023.05.17
spring PART.JPA  (0) 2023.05.16
spring PART.MyBatis  (0) 2023.05.15
Comments