내용 추가 : resultType을 entity, dto 등의 Value Object으로 지정할 경우 resultMap을 생성한다.

 

 

마이바티스와 롬복을 같이 사용하는 것은 처음이다보니 사소한 실수를 통해 여러 예외를 만나게 되는 것 같다.

User 도메인을 개발하기 시작했기에 엔티티를 작성하고 테스트 데이터베이스 스키마와 데이터를 추가한 뒤 MapperTest를 통해 조회 테스트를 진행하던 중 문제가 발생하였다.

Caused by: org.apache.ibatis.exceptions.PersistenceException: 
### Error querying database.  Cause: java.lang.IndexOutOfBoundsException: Index 9 out of bounds for length 9
### The error may exist in file [C:\Users\serrl\Desktop\Mentoring\Somaeja\out\production\resources\mybatis\mapper\user.xml]
### The error may involve com.somaeja.user.mapper.UserMapper.findByAll
### The error occurred while handling results
### SQL: SELECT *         FROM USER
### Cause: java.lang.IndexOutOfBoundsException: Index 9 out of bounds for length 9
    at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30)
    at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:149)
    at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:140)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:566)
    at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:426)
    ... 82 more
Caused by: java.lang.IndexOutOfBoundsException: Index 9 out of bounds for length 9
    at java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:64)
    at java.base/jdk.internal.util.Preconditions.outOfBoundsCheckIndex(Preconditions.java:70)
    at java.base/jdk.internal.util.Preconditions.checkIndex(Preconditions.java:248)
    at java.base/java.util.Objects.checkIndex(Objects.java:372)
    at java.base/java.util.ArrayList.get(ArrayList.java:458)
    at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createUsingConstructor(DefaultResultSetHandler.java:708)
    at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createByConstructorSignature(DefaultResultSetHandler.java:693)
    at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createResultObject(DefaultResultSetHandler.java:657)
    at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createResultObject(DefaultResultSetHandler.java:630)
    at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.getRowValue(DefaultResultSetHandler.java:397)
    at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValuesForSimpleResultMap(DefaultResultSetHandler.java:354)
    at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValues(DefaultResultSetHandler.java:328)
    at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleResultSet(DefaultResultSetHandler.java:301)
    at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleResultSets(DefaultResultSetHandler.java:194)
    at org.apache.ibatis.executor.statement.PreparedStatementHandler.query(PreparedStatementHandler.java:65)
    at org.apache.ibatis.executor.statement.RoutingStatementHandler.query(RoutingStatementHandler.java:79)
    at org.apache.ibatis.executor.SimpleExecutor.doQuery(SimpleExecutor.java:63)
    at org.apache.ibatis.executor.BaseExecutor.queryFromDatabase(BaseExecutor.java:325)
    at org.apache.ibatis.executor.BaseExecutor.query(BaseExecutor.java:156)
    at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:109)
    at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:89)
    at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:147)
    ... 88 more

난데없이 IndexOutOfBoundsException가 발생하여 (발생할 꺼리도 없는데????) 약간 헤매긴 하였는데 몇몇 요소를 검증하니 찾을 수 있었다.

 

문제는 바로 Entity 객체였다. (사실은 내 문제다..!)

@Builder
@Getter
@ToString
public class User {

    private final Long id;
    private final Long locationId;
    private final String nickName;
    private final String password;
    private final String email;
    private final String phoneNumber;
    // 계정 권한
    private final String role;
    private final LocalDateTime createdDate;
    private final LocalDateTime modifyDate;

    // Inner Join 을 통해 가져오는 데이터
  // 이것이 누락되었다..!
    private final String cityCountryTown;

}
<select id="findByAll" resultType="com.somaeja.user.entity.User">
    SELECT *
    FROM USER
</select>

Entity를 별도의 설정자 없이 빌더 패턴으로 생성하다보니 모든 필드 값을 final로 설정하였었는데, 테스트를 작성하기 전에 간단하게 작성했던 xml 에서 Join을 통해 가져올 데이터를 누락시키고 있었기에 해당 Entity 생성 로직 자체가 실패한 것이였다.

<select id="findByAll" resultType="com.somaeja.user.entity.User">
    SELECT USER.*, LOCATION.CITY_COUNTRY_TOWN as location
    FROM USER INNER JOIN LOCATION
    ON USER.LOCATION_ID = LOCATION.LOCATION_ID
</select>

이렇게 변경하여 해당 필드 데이터로 가져옴으로써 해결하게 되었다.

내부 로직을 보지않아 정확히 알 수는 없지만 요소가 빠짐으로써 발생하는 Exception이라면 IndexOutOfBoundsException이 아니라 IligalArgumentException을 사용하는 것이 더 좋지 않았을까 라는 생각이 들었다. (물론 내부 로직에 그럴만한 이유가 있겠지...)

 

다른 상황?

다른 문제로도 해당 예외가 발생할 수 있는데, 이는 resultType에서 객체를 사용할 때와 resultMap을 사용할 때 나타나는 경우이다.

 

 

예시 Entity

@Builder
@Getter
public class Account {

        private Long id;
    private String name;
    private String nickname;
    private String phoneNumber;
    private Boolean phoneVerified;

}

위와 비슷하게 빌더 패턴을 이용해서만 Entity를 생성하고 제공한다고 가정한다.

<resultMap id="AccountResultMap" type="....Account">
        <result property="id" column="id"/>
    <result property="name" column="name"/>
    <result property="nickname" column="nickname"/>
    <result property="phoneNumber" column="phone_number"/>
    <result property="phoneVerified" column="phone_verified"/>
</resultMap>

<select id="findById" parameterType="long" resultMap="AccountResultMap">
        SELECT *
        FROM Account
        WHERE id = #{accountId}
</select>

이러한 resultMap을 정의하고 단순하게 id를 통해 조회하는 SELECT 쿼리를 작성하였다.

이 경우에도

Caused by: org.apache.ibatis.exceptions.PersistenceException: 
### Error querying database.  Cause: java.lang.IndexOutOfBoundsException: Index 5 out of bounds for length 5
### The error may exist in file [mybatis\mapper\account.xml]
### The error may involve ...mapper.AccountMapper.findById
### The error occurred while handling results
### SQL: SELECT *         FROM Account         WHERE id = #{accountId}
### Cause: java.lang.IndexOutOfBoundsException: Index 5 out of bounds for length 5

이러한 에러가 발생할 것이다. 이는 resultMap의 특성 때문인데, 해당 기능을 사용할 경우 (+resultType 을 사용할 때 객체를 넘겨줄 경우 동일하다.) Mybatis가 미리 해당 인스턴스를 생성하게 된다. 하지만 위의 엔티티는 모든 인자가 포함된 생성자 (Builder) 만이 존재하기 때문에 인스턴스를 생성할 수 없어 문제가 발생하게 되는 것이다.

이 것을 해결하기 위해서는 인자가 없는 생성자를 추가하면 된다.

@Builder
@Getter
@NoArgsConstructor <-- 이것을 추가해주면 된다.
public class Account {

        private Long id;
    private String name;
    private String nickname;
    private String phoneNumber;
    private Boolean phoneVerified;

}

이렇게 한다면 앞서 언급한 특징에 의해 발생한 문제일 경우 해결될 것이다.

 

 

 

@Builder에 대한 토막글

Finally, applying @Builder to a class is as if you added @AllArgsConstructor(access = AccessLevel.PACKAGE) to the class and applied the @Builder annotation to this all-args-constructor. This only works if you haven't written any explicit constructors yourself.

 

참고 자료

프로젝트에 작성된 통합 테스트들이 개별적으로 동작할 때는 아무 문제가 발생하지 않았었는데, Gradle Test를 통해 전체적으로 진행하니 몇몇 문제가 발생하게 되었다.

 

BUG!

JdbcSQLSyntaxErrorException: Table "POST" already exists;... [42101-200]

Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'postController' defined in file [C:\Users\serrl\Desktop\Mentoring\Somaeja\build\classes\java\main\com\somaeja\post\controller\PostController.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'postService' defined in file [C:\Users\serrl\Desktop\Mentoring\Somaeja\build\classes\java\main\com\somaeja\post\service\PostService.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'postMapper' defined in file [C:\Users\serrl\Desktop\Mentoring\Somaeja\build\classes\java\main\com\somaeja\post\mapper\PostMapper.class]: Unsatisfied dependency expressed through bean property 'sqlSessionFactory'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'sqlSessionFactory' defined in class path resource [com/somaeja/common/config/PersistenceConfig.class]: Unsatisfied dependency expressed through method 'sqlSessionFactory' parameter 0; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'dataSource' defined in class path resource [org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration$Hikari.class]: Initialization of bean failed; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'org.springframework.boot.autoconfigure.jdbc.DataSourceInitializerInvoker': Invocation of init method failed; nested exception is org.springframework.jdbc.datasource.init.ScriptStatementFailedException: Failed to execute SQL script statement #1 of URL [file:/C:/Users/serrl/Desktop/Mentoring/Somaeja/build/resources/main/schema.sql]: CREATE TABLE POST ( POST_ID INTEGER PRIMARY KEY auto_increment, USER_ID INTEGER NOT NULL, LOCATION_ID INTEGER NOT NULL, IMAGE_ID INTEGER NOT NULL, TITLE VARCHAR(255) NOT NULL, CONTENT VARCHAR(255) NOT NULL, PRICE VARCHAR(255) NOT NULL, IS_NEGOTIABLE TINYINT(1) NOT NULL, IS_DIRECTTRADE TINYINT(1) NOT NULL, CREATEDATE VARCHAR(255) NOT NULL, MODIFYDATE VARCHAR(255) NOT NULL ); nested exception is org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "POST" already exists; SQL statement:
CREATE TABLE POST ( POST_ID INTEGER PRIMARY KEY auto_increment, USER_ID INTEGER NOT NULL, LOCATION_ID INTEGER NOT NULL, IMAGE_ID INTEGER NOT NULL, TITLE VARCHAR(255) NOT NULL, CONTENT VARCHAR(255) NOT NULL, PRICE VARCHAR(255) NOT NULL, IS_NEGOTIABLE TINYINT(1) NOT NULL, IS_DIRECTTRADE TINYINT(1) NOT NULL, CREATEDATE VARCHAR(255) NOT NULL, MODIFYDATE VARCHAR(255) NOT NULL ) [42101-200]
    at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:797)
    at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:227)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1356)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1203)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:556)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:516)
    at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:324)
    at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
    at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:322)
    at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:897)
    at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:879)
    at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:551)
    at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:758)
    at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:750)
    at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:405)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:315)
    at org.springframework.boot.test.context.SpringBootContextLoader.loadContext(SpringBootContextLoader.java:120)
    at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContextInternal(DefaultCacheAwareContextLoaderDelegate.java:99)
    at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:124)
    ... 88 more

 

Table "POST" already exists; [42101-200] 에러는 무엇인가?

해당 H2 버전부터 테이블 삭제 명령의 동작 이 SQL 표준을 준수하도록 변경되었다고 한다. 즉 이전 버전에서는 테이블을 삭제할 때 제약 조건을 무시하였지만, 현재 버전부터 제약 조건에 의해 삭제가 실패하게 된다는 것이다.

 

짐작해보자면? 현재 Post Table에는 FK가 연결되어 있으며, 해당 테이블 삭제시 참조 무결성으로 인해 삭제가 되지 않고 중복되는 일이 발생하는 것 같다.

 

 

Gradle Test 시 예상 실행 흐름

해결 방법?

  1. 삭제를 시도했으나 실패하고 SQL 파일 실행 시 데이터가 존재하는 것이라면 강제 삭제를 할 수 있게끔 CASCADE가 명시된 삭제를 진행
  2. Test Class에 롤백 적용하기??

 

1번을 통해 문제를 해결하였다.

 

 

Schema.sql 최상단에 추가!

이를 통해 제대로 초기화되지 않았을 데이터들을 다음 테스트를 위한 데이터 생성 이전에 한번 더 정리한다.

DROP TABLE IF EXISTS POST CASCADE;
DROP TABLE IF EXISTS LOCATION CASCADE;

 

 

Post Entity, DTO 들을 만들고 생성 테스트를 진행한 뒤에 Post Controller와 Service를 생성하고 PostMapper에 Mapper 어노테이션 작성과 Mapper.xml 작성을 완료한 후 Teliend API를 통한 테스트를 진행하였다.

 

그런데.. POST 요청 보냈더니 이러한 에러가 발생하였다.

org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.somaeja.post.mapper.PostMapper.save
at org.apache.ibatis.binding.MapperMethod$SqlCommand.<init>(MapperMethod.java:235) ~[mybatis-3.5.5.jar:3.5.5]
at org.apache.ibatis.binding.MapperMethod.<init>(MapperMethod.java:53) ~[mybatis-3.5.5.jar:3.5.5]
at org.apache.ibatis.binding.MapperProxy.lambda$cachedInvoker$0(MapperProxy.java:115) ~[mybatis-3.5.5.jar:3.5.5]
at java.base/java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1705) ~[na:na]
at org.apache.ibatis.binding.MapperProxy.cachedInvoker(MapperProxy.java:102) ~[mybatis-3.5.5.jar:3.5.5]
at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:85) ~[mybatis-3.5.5.jar:3.5.5]
at com.sun.proxy.$Proxy54.save(Unknown Source) ~[na:na]
at com.somaeja.post.service.PostService.savePost(PostService.java:20) ~[classes/:na]
...
...
...

2시간 정도 고생을 하다보니 설정된 코드에서 문제점을 발견하였다...

 

 

문제점

@EnalbeAutoConfiguration <- 문제점 
@MapperScan(basePackages = "com.somaeja")
public class PersistenceConfig {
        <- 문제점 
}

 

문제점 파악.

 

@EnableAutoConfiguration 은 Spring Boot 관련 자동 구성 어노테이션이었는데,

Dependencies > spring-boot-autoconfigure > META-INF > spring.factories

디렉터리 내부의 Definition 들을 Bean으로 등록해 준다고 하여서 configuration 대신에 사용하였었다.

 

ContextLoad Test

@SpringBootTest
class MainApplicationTests {
    @Test
    void contextLoads() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PersistenceConfig .class);
        String[] beanDefinitionNames = ac.getBeanDefinitionNames();
        for (String beanDefinitionName : beanDefinitionNames) {
            System.out.println("beanDefinitionName = " + beanDefinitionName);
        }
    }
}

ContextLoad 테스트를 진행하였을 때에도 Datasource, sqlSessionFactory, postMapper 빈이 등록되어 있어 문제가 없다고 판단하였지만, 사실은 Mybatis 설정이 적용되지 않은 sqlSessionFacroty와 Root Context (MainApplication.class)에 등록되지 않은 postMapper 이였다는 것을 알지 못하였고, 헤맬 수밖에 없었다.

 

 

문제 해결 방법.

@Configuration // @EnableAutoConfiguration 에서 Configuration 으로 변경
@MapperScan(basePackages = "com.somaeja")
public class PersistenceConfig {

    // Custom Initializer -> Mybatis MapperLocations 설정이 적용된 SqlSessionFactory 생성 
    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        final SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean();
        sqlSessionFactory.setDataSource(dataSource);

        // 스프링에서 Xml 파싱을 통한 Bean 생성시 사용하는 PathMatchingResourcePatternResolver 를 사용
        // 하여 classpath: 패턴의 경로를 작성하고 Mapper Location에 설정하였다.
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        sqlSessionFactory.setMapperLocations(resolver.getResources("classpath:mybatis/mapper/*.xml"));
        return sqlSessionFactory.getObject();
    }

}

 

참고 문서

MyBatis - 마이바티스 3 | 소개

MyBatis - 마이바티스 3 | 매퍼 XML 파일

Mybatis-Spring-Boot-Starter 소개 문서 번역

'프로젝트' 카테고리의 다른 글

Mybatis의 IndexOutOfBoundsException?  (2) 2021.01.12
Spring Boot Gradle Test 실패?  (0) 2020.12.31
[Somaeja : 소매자] 01. 프로젝트 개요  (0) 2020.11.15

Somaeja

모든 사람이 소매상이 될 수 있는 거래 플랫폼

 

지역 정보 기반의 물품 거래 플랫폼 RESTful API 서버 프로젝트입니다.

당근마켓과 유사한 서비스를 제공함으로써 사용자들은 설정된 지역 정보를 가지고 다른 사람들의 물품을 확인하고 구매 의사를 밝힐 수 있습니다.

 

Kakao Oven을 이용하여 프로토타입 설계를 진행하였으며, API 서버만 집중하여 개발을 진행합니다.



프로젝트의 대략적인 인프라 구조

 



프로젝트 설계 정보

 

프로젝트 UML

 

 

구조 변경 사항

  • AFFICHE

    • USER FK 를 가짐으로 USER의 NICKNAME 제외 (닉네임 변경시 처리가 불편하다.)

    • 11.25 테이블 네이밍 변경 AFFICHE → POST (이해하기 쉬운 익숙한 네이밍이 좋다)

    • 11.25 게시글 삭제 유무 DELETE_YN 이라는 Flag 를 추가

       히스토리 테이블 혹은 로그 테이블 생성하여 대체하자. (제거)

    • 테이블 이름 변경

  • AFFICHE INFO

    생성 이유 : 컨텐츠 사이즈의 따른 성능 부하 여부, 목록 조회시에 컨텐츠가 보이지 않는다.

    • AFFICHE Table에 컨텐츠를 두고 조회할 때 컨텐츠를 제외하는 방법이 있다.
    • 단순히 생성 이유 때문에 분리하는 것은 이득보다 관리하는 비용이 늘어날 수 있다.
    • AFFICHE TABLE 와 병합
  • LOCATION

    • PK, FK 를 통한 USER 와의 양방향 종속성(의존성) 제거
  • HASHTAG

    생성 이유 : AFFICHE 테이블에서 사용시 태그별 분할 문제점

    • 1 대 N 관계 해소를 위해 중간의 AFFICHE_HASHTAG 테이블 생성 (Toxi solution)

      → 과한 정규화, 복잡해질 수 있는 쿼리, 데이터의 고아 상태 발생 가능 (Scuttle으로 변경)

  • AFFICHE_LOCATION

    생성 이유 : POST가 여러 지역정보를 가지게 되면 다대다 관계가 됨으로 이를 분리하기 위해 작성

    • POST의 LOCATION 설정 정보를 하나만 제공하도록 요구사항을 변경함으로써 제거

 

 

 

프로젝트 개발 시 집중하는 부분

 

지속적인 코드 개선

  • 작성된 코드를 방치하는 것이 아니라 계속해서 개선합니다.
  • 가독성과 Depth 뿐만 아니라 호출 로직들도 개선하려 노력합니다.

 

멀티스레드 환경에서 안전한 코드 작성

  • 공유하는 자원을 최소화하고 최대한 모든 객체를 불변 객체로 작성합니다.



 

프로젝트 개발 시 준수 사항

 

Convention

 

Work Flow

  • Git Branch Strategy "Git-Flow"

 

Tech Stack

  • Spring Boot 2.3.5
  • JDK 11
  • Gradle
  • H2(Test, InMemory) + MySQL(Operational DB) + MyBatis
  • Naver Cloud Platform

+ Recent posts