java.util.ConcurrentModificationException null (with ehcache)

|

Triggering a ConcurrentModificationException

병렬 프로그래밍을 하다 보면 만나게 되는 흔한 오류 중 하나가 java.util.ConcurrentModificationException입니다. thread-safe 하지 않은 ArrayList 같은 객체를 여러 스레드에서 동시에 조작하다 보면 발생하게 되는데 주된 원인은 for-loop 도중 조작할 경우 입니다.

// java
List<Integer> intList = new ArrayList<>();
for (Integer i : intList) {
    ...
}

// class
List<Integer> intList = new ArrayList<>();
Iterator<Integer> iter = intList.iterator();
while(iter.hasNext()) {
    Integer i = iter.next();
    ...
}

for-loop 구문을 class 컴파일 하면 위와 같은 코드로 변환되는데 ArrayList가 Iterator를 생성할 때 내부 클래스인 ArrayList.Itr 클래스로 생성하여 반환되며 Itr 클래스는 생성시 ArrayList의 상위클래스인 AbstractList의 modCount 변수를 자신의 지역변수인 expectedModCount에 할당합니다. modCount는 ArrayList의 변경사항이 생길 때 마다 변경된 카운트를 기록하는 변수로 add / remove / trimToSize 등등 다수의 메서드에서 증가됩니다.

private class Itr implements Iterator<E> {
        int cursor;       // index of next element to return
        int lastRet = -1; // index of last element returned; -1 if no such
        int expectedModCount = modCount;
        ...
}

expectedModCount 변수는 next() 메서드를 실행할 때 기존 자신이 참조하고 있는 ArrayList의 변경사항이 있는지 체크하게 되는데 이 때 참조 리스트의 modCount와 자신의 지역변수인 expectedModCount를 비교하여 다를 경우 java.util.ConcurrentModificationException를 발생시킵니다.

// ArrayList.Itr class
public E next() {
    checkForComodification();
    ...
}

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

static 변수로 선언되어 여러 스레드에 조작되다 발생 할 수도 있으나 아래 예제와 같이 단일 스레드 내에서도 동일하게 발생 할 수 있습니다. 다른 블로그나 StackOverFlow의 질문들을 보면 대부분 remove 구문을 예시로 들지만 remove 뿐만 아닌 modCount가 조작되는 모든 메서드에 해당됩니다.

@Test(expected = ConcurrentModificationException.class)
public void concurrentModificationExceptionTest() {
    List<Integer> list = Lists.newArrayList(1,2,3,4,5,6,7);
    for (Integer i : list) {
        if (i==2) {
            list.add(8);
        }
    }
}

회피 하는 방법은 여러가지가 존재하는데 Avoiding the ConcurrentModificationException in Java문서를 참고하시면 좋습니다.

ConcurrentModificationException with Ehcache

자신이 병렬 스레드를 사용하지 않았고, for-loop문 내에서 조작하지 않았다고 해서 java.util.ConcurrentModificationException이 발생하지 않을꺼라 생각할 수 있으나 in-memory cache를 사용할 때 역시 주의해야 합니다.

// MyCacheService.class
@Cacheable
public List<Integer> getCacheList() { ... }

// OtherService
@Autowired private MyCacheService myCacheService;

public void sortList() {
    List<Integer> cachedList = myCacheService.getCacheList();
    cachedList.sort(Integer::compareTo);
    for(Integer i : cachedList) {
        System.out.println(i);
    }
}

얼핏 보기엔 sortList 메서드는 안전해 보이지만 Ehcache를 사용하고 있다면 부하 상황에서 반드시 java.util.ConcurrentModificationException이 발생하게 됩니다. for-loop 구문 안에서 조작하지 않았는데 발생하는 이유는 Ehcache가 데이터를 heap과 disk에 나눠 관리하기 때문입니다. 해당 데이터가 heap에 존재할 경우 캐시에서 동일한 레퍼런스 객체를 반환하기 때문에 static으로 공유된 객체를 사용하는 것과 동일한 이슈를 발생 시키게 되는데 아래 테스트케이스에서 볼 수 있듯이 캐시에서 받아온 List객체를 조작하게 되면 기존 캐시에 저장된 객체가 같이 바뀌어 있는것을 확인 할 수 있습니다.

@Test
public void cacheValueReferenceTest() {
    final Cache cache = getCache();
    final String key = "TEST_KEY";
    List<Integer> value = Lists.newArrayList(7,6,5,4,3,2,1);
    cache.put(key, value);
    
    value.sort(Integer::compareTo);
    
    List<Integer> cacheValue = cache.get(key, List.class);
    assertTrue(cacheValue.get(0) == 1);
}

동시성 문제가 발생하는 케이스는 아래 테스트케이스에서 확인 할 수 있습니다.

@Test
public void concurrentModifiedExceptionTest() throws InterruptedException {
    final Cache cache = getCache();
    final String key = "TEST_KEY";
    List<Integer> value = Lists.newArrayList(7,6,5,4,3,2,1);
    cache.put(key, value);

    AtomicBoolean isDetectException = new AtomicBoolean(false);
    CountDownLatch latch1 = new CountDownLatch(1);
    CountDownLatch latch2 = new CountDownLatch(1);
    
    Runnable thread1 = () -> {
        try {
            List<Integer> value1 = cache.get(key, List.class);
            for (Integer v : value1) {
                latch2.await();
            }
        } catch (Exception e) {
            log.error("thread1 error : {} {}", e.getClass().getName(), e.getMessage());
            isDetectException.set(e instanceof ConcurrentModificationException);
        } finally {
            latch1.countDown();
        }
    };
    
    Runnable thread2 = () -> {
        List<Integer> value2 = cache.get(key, List.class);
        value2.sort(Integer::compareTo);
        latch2.countDown();
    };

    new Thread(thread1).start();
    Thread.sleep(100);
    new Thread(thread2).start();
    latch1.await();
    assertTrue(isDetectException.get());
}

1번 스레드가 캐시에서 데이터를 가져와 for-loop 지점에 진입한 상태에서 2번 스레드가 캐시값에 접근하여 변경(sort와 같은..)작업을 시도하면 내부적으로 ArrayList의 modCount값이 바뀌면서 1번 스레드의 Iterator.next 구문 실행시 java.util.ConcurrentModificationException를 발생시키게 됩니다.

Conclusion

동시성 문제는 평시엔 잘 발견되지 않기 때문에 일반적인 테스트만 진행하고 실 서비스에 배포되면 운영중 가장 중요한 시기에 장애가 터지게 되어 난감할 때가 많습니다. 매번 성능 테스트를 할 수 없다면 위와 같은 경우에 항상 주의하며 코딩해야하며, 필자는 Ehcache의 프로세스를 확인하지 않아 디버깅에 오래걸렸지만 java.util.ConcurrentModificationException의 경우 반드시 for-loop 안에서 타켓 리스트객체를 수정했거나, 공유되는 리스트객체를 동시에 조작한 두가지 경우에 발생함을 유의하고 빠르게 디버깅 하시기 바랍니다.

Reference

Avoiding the ConcurrentModificationException in Java : https://www.baeldung.com/java-concurrentmodificationexception

Spliterator의 소개 및 활용 (in JDK 1.8)

|

Overview

Spliterator 인터페이스를 접한건 Stream을 복제할 수 있는 방법이 없을까 고민하다 포함된 메서드들 중 spliterator()는 어떤건지 궁금해 찾아보게 됬습니다.
처음에 여기저기 블로그에 정리된 글을 찾아봤을땐 병렬처리를 지원하기 위해 Stream에 종속된 기능 정도로만 생각했는데, Oracle 공식 JDK 문서에는 특정 소스의 객체를 순회하거나 파티셔닝 하기 위한 인터페이스로 Stream의 파이프라이닝 처리를 위해 java.util.stream.Sink 클래스와 함께 핵심적인 역활을 맡고 있습니다. Stream외에도 배열/Collection/IOChannel 등 복수개의 개체를 지닌 모든 소스들에서 구현되거나 사용 할 수 있으며 생성 방법은 아래와 같습니다.

// Stream과 Collection의 경우 기본적으로 spliterator 메서드를 제공합니다.     
Spliterator<Integer> byStream = IntStream.range(0, 100).spliterator();    
Spliterator<Integer> byCollection = (new ArrayList<Integer>()).spliterator();    
// 배열의 경우 Spliterators 클래스를 통해 Spliterator 객체를 생성 할 수 있습니다.
Spliterator<Integer> byArray = Spliterators.spliterator(new int[]{1,2,3,4}, Spliterator.SORTED);

Methods

characteristics

Spliterator 객체의 특성에 대한 int값을 반환하는 메서드로 속성은 ORDERED, DISTINCT, SORTED, SIZED, NONNULL, IMMUTABLE, CONCURRENT, SUBSIZED 가 있으며 각 특성은 어떤 Spliterator 객체인가에 따라 다르며 그에 따른 각 메서드들의 내부적인 동작이 다를 수 있습니다.
예를 IntStream.of의 Spliterator의 경우 “IMMUTABLE, ORDERED, SIZED, SUBSIZED”의 특성을 가지고 있으나 IntStream.generate의 Spliterator의 경우 “IMMUTABLE”의 특성만을 지니고 있습니다. 또한 Set의 Spliterator의 경우 “DISTINCT, SIZED”의 특성을 가지고 있으며 List의 Spliterator의 경우 “ORDERED, SIZED, SUBSIZED”의 특성을 가집니다.

characteristics메서드의 반환값은 int형인데 Spliterator 객체에 포함된 모든 특성값의 합을 반환합니다.

// Set의 경우 DISTINCT=1, SIZED=64 의 합인 65를 반환합니다.
new HashSet().spliterator().characteristics();  // 65
// List의 경우 ORDERED=16, SIZED=64, SUBSIZED=16384 의 합인 16464를 반환합니다.
new ArrayList().spliterator().characteristics(); // 16464

Spliterator의 각 특성은 hasCharacteristics메서드를 통해 확인 할 수 있습니다.

estimateSize

순회할 개체의 사이즈를 알 수 있는 메서드로 SIZED/SUBSIZED 특성을 지녔으며 순회할 남은 개체의 사이즈를 반환해줍니다. 쉽게 말해 개수가 제한되어 있는 Stream의 사이즈를 알 수 있습니다.
Spliterator의 총 개체수가 아닌 순회 할 수 있는 개체의 개수이기 때문에 이미 순회한 개체의 수는 포함되지 않습니다.

@Test
public void estimateSize_test() {
    Spliterator<Integer> spliterator = IntStream.of(1,2,3,4,5).spliterator();
    System.out.println(spliterator.estimateSize()); // print 5
    spliterator.tryAdvance(t -> {});
    spliterator.tryAdvance(t -> {});
    spliterator.tryAdvance(t -> {});
    System.out.println(spliterator.estimateSize()); // print 2
}

tryAdvance

Spliterator의 요소가 남아 있을 경우 인자로 주어진 액션을 실행하고 요소의 존재 유무를 반환하는데, 특성에 ORDERED가 포함되어 있을 경우 순차적으로 요소를 제공하게 됩니다.
이 메서드의 주의할 점은 리턴값이 hasNext의 개념이 아니라 isExecuted의 개념이란 점입니다. 아래 예제를 보면 요소가 3개이나 3번째 호출까지 계속 true를 반환하는 것을 볼 수 있습니다.

@Test
public void tryAdvance_test() {
    Spliterator<Integer> spliterator = IntStream.of(1,2,3).spliterator();
    
    boolean r1 = spliterator.tryAdvance(System.out::println); // print 1
    boolean r2 = spliterator.tryAdvance(System.out::println); // print 2
    boolean r3 = spliterator.tryAdvance(System.out::println); // print 3
    boolean r4 = spliterator.tryAdvance(System.out::println); // not execute

    System.out.println(r1 + ", " + r2 + ", " + r3 + ", " + r4); // true, true, true, false
}

Spliterator 인터페이스에선 본 메서드를 사용하여 순회하는 forEachRemaining 메서드를 기본적으로 제공하며 코드는 아래와 같습니다.

default void forEachRemaining(Consumer<? super T> action) {
    do { } while (tryAdvance(action));
}

구현 메서드가 위와 같이 hasNext / isExecuted의 양쪽 개념을 다 포괄 할 수 있기 때문에 어느쪽이든 크게 상관없다 싶지만 가능하면 isExecuted 개념으로 구현하는 것을 권장합니다.

trySplit

Spliterator를 분할 하는 메서드로 원 객체에서 분할된 Spliterator를 반환하는데 ORDERED 특성일 경우 반환된 값이 앞부분이 되고 원 객체가 뒷부분이 됩니다.

@Test
public void trySplit_test() {
    Spliterator<Integer> origin = IntStream.range(0, 19).spliterator();
    Spliterator<Integer> dest = origin.trySplit();

    System.out.println(dest.estimateSize());  // size is 9
    dest.forEachRemaining(System.out::print); // print 0~8
    System.out.println();
    System.out.println(origin.estimateSize());  // size is 10
    origin.forEachRemaining(System.out::print);  // print 9 ~ 18
}

아쉽게도 분할할 사이즈나 범위는 제어 할 수 없도록 되어 있으며 절반으로 나뉘게 됩니다. 일반적인 Stream의 경우 parallel일 경우에만 분할이 가능하며 아닐 경우 분할한 Spliterator는 null로 반환합니다.

// java.util.stream.StreamSpliterators.AbstractWrappingSpliterator
@Override
public Spliterator<P_OUT> trySplit() {
    if (isParallel && !finished) {
        init();

        Spliterator<P_IN> split = spliterator.trySplit();
        return (split == null) ? null : wrap(split);
    }
    else
        return null;
}

Customize

Stream을 분할 할 수도 있고, size를 미리 알수 있으며, 흐름에 따라 일괄 처리되던 Stream을 단 건으로 실행 할 수 있는 등 이미 그 자체로도 충분히 활용할 곳이 많지만 Spliterator 자체를 커스텀하여 만들 경우 좀 더 다양하게 활용 가능합니다.
다음 Spliterator는 카운트를 지정하여 해당 카운트 만큼 Stream이 실행되면 pinned 메소드에 등록한 리스너를 실행합니다.

public class PinPointSpliterator<T> implements Spliterator<T> {
    private final Spliterator<T> spliterator;
    private final AtomicInteger counter = new AtomicInteger();
    private int pinCount = -1;
    private IntFunction<Boolean> listener = null;
    private boolean isListen = false;
    
    public PinPointSpliterator(Spliterator<T> spliterator) {
        this.spliterator = spliterator;
    }
    
    public PinPointSpliterator(Stream<T> stream) {
        this(stream.spliterator());
    }
    
    public static <T> PinPointSpliterator<T> newInstance(Stream<T> stream) {
        return new PinPointSpliterator<>(stream);
    }
    
    public PinPointSpliterator pinCount(int pinCount) {
        this.pinCount = pinCount;
        return this;
    }

    public PinPointSpliterator pinned(IntFunction<Boolean> listener) {
        this.isListen = listener != null;
        this.listener = listener;
        return this;
    }

    @Override
    public boolean tryAdvance(Consumer<? super T> action) {
        boolean hasNext = this.spliterator.tryAdvance(action);
        if (isListen && pinCount > 0 && counter.incrementAndGet() % pinCount == 0) {
            isListen = listener.apply(counter.get());
        }
        return hasNext;
    }

    @Override
    public Spliterator<T> trySplit() {
        return new PinPointSpliterator(this.spliterator.trySplit());
    }

    @Override
    public long estimateSize() {
        return this.spliterator.estimateSize();
    }

    @Override
    public int characteristics() {
        return this.spliterator.characteristics();
    }
}

실행은 아래와 같이 할 수 있습니다.

@Test
public void pinPointSpliterator_test() {
    List<Integer> buffer = new ArrayList<>();
    PinPointSpliterator<Integer> spliterator = PinPointSpliterator.newInstance(IntStream.range(0, 1000).boxed())
            .pinCount(100)
            .pinned(count -> {
                System.out.println("do listen. buffer size is " + buffer.size());
                buffer.clear();
                return count < 500;
            });
    StreamSupport.stream(spliterator, false)
        .peek(i -> buffer.add(i))
        .collect(Collectors.toList());

    System.out.println("final buffer size is " + buffer.size());
}

위 예제에서 100회 실행시마다 pinned에 설정된 리스너를 실행하는데 buffer의 사이즈를 출력하고 초기화하며, 500회보다 적게 실행된 경우에만 동작하므로 500회 이후엔 모두 buffer에 쌓이므로 500개가 쌓이게 됩니다. 최종 출력은 다음과 같습니다.

do listen. buffer size is 100
do listen. buffer size is 100
do listen. buffer size is 100
do listen. buffer size is 100
do listen. buffer size is 100
final buffer size is 500

Conclusion

워낙 Stream에서 많은 기능을 제공하기에 잘 사용하지 않았었지만 Stream을 쓰다보면 무언가 조금씩 아쉬운 부분들이 존재했습니다. 예를 들어 1000개씩 분할해서 List로 담고 싶다던가, 특정 실행 횟수마다 어떤 동작을 한다던가, Stream의 전체 개수에 따라 다른 액션이 필요할 경우 등등 … 기존 Stream으로는 번거로운 작업들을 좀 더 쉽게 처리 할 수 있어 잘 쓰기만 한다면 많이 활용 할 수 있을것 같습니다.

[Spring] Spring Data Rest Repository Not Working

|

이슈

오랜만에 SpringBoot로 간단한 REST API 서버를 만들어 볼 일이 생겨서 Spring Initializr를 통해 아래 모듈을 추가하여 작업했습니다.

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-data-rest'
	implementation 'org.springframework.data:spring-data-rest-hal-browser'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
spring :
    datasource :
        url : jdbc:h2:mem:data
        driverClassName : org.h2.Driver
        username : sa
        password : 1234
    jpa :
        database-platform : org.hibernate.dialect.H2Dialect
        show-sql: true
        generate-ddl: true
    h2.console :
        enabled : true
        path : /h2-console
        settings :
            trace : false
            web-allow-others : false
    data:
        rest:
            base-path: /api
@Data
@Entity
@Table(name = "article")
public class Article implements Serializable {
    @Id
    @GeneratedValue
    private long articleNo;
    
    @NotNull
    private String title;
    
    @Lob
    @NotNull
    private String content;
    
    ...
}

@RepositoryRestResource
public interface ArticleDao extends CrudRepository<Article, Long> {
    List<Article> findById(@Param("articleNo") long articleNo);
}

@Configuration
@EnableJpaRepositories
public class JpaConfig {
    
}

@SpringBootApplication
public class DemoApplication {
	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}
}

워낙에 spring-data-rest는 별도 설정할게 없는지라 잘 되겠지 하고 bootRun!! 1.5.X 버전때는 맵핑되는 Request 주소들이 기동시 로그에 다 찍혔는데 2.x 버전에서는 기본적으로 찍히지가 않았습니다. (그냥 그려려니…)

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.1.9.RELEASE)

2019-10-13 23:54:49.092  INFO 9085 --- [  restartedMain] com.example.demo.DemoApplication         : Starting DemoApplication on documents.ct.infn.it with PID 9085 (/Users/jistol/Downloads/demo/out/production/classes started by jistol in /Users/jistol/Downloads/demo)
2019-10-13 23:54:49.096  INFO 9085 --- [  restartedMain] com.example.demo.DemoApplication         : No active profile set, falling back to default profiles: default
2019-10-13 23:54:49.161  INFO 9085 --- [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable
2019-10-13 23:54:49.161  INFO 9085 --- [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG'
2019-10-13 23:54:49.979  INFO 9085 --- [  restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data repositories in DEFAULT mode.
2019-10-13 23:54:50.006  INFO 9085 --- [  restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 8ms. Found 0 repository interfaces.
2019-10-13 23:54:50.570  INFO 9085 --- [  restartedMain] trationDelegate$BeanPostProcessorChecker : Bean 'org.springframework.transaction.annotation.ProxyTransactionManagementConfiguration' of type [org.springframework.transaction.annotation.ProxyTransactionManagementConfiguration$$EnhancerBySpringCGLIB$$3c00740f] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2019-10-13 23:54:50.592  INFO 9085 --- [  restartedMain] trationDelegate$BeanPostProcessorChecker : Bean 'org.springframework.hateoas.config.HateoasConfiguration' of type [org.springframework.hateoas.config.HateoasConfiguration$$EnhancerBySpringCGLIB$$bb80c141] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2019-10-13 23:54:51.019  INFO 9085 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8099 (http)
2019-10-13 23:54:51.043  INFO 9085 --- [  restartedMain] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2019-10-13 23:54:51.043  INFO 9085 --- [  restartedMain] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.26]
2019-10-13 23:54:51.135  INFO 9085 --- [  restartedMain] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2019-10-13 23:54:51.136  INFO 9085 --- [  restartedMain] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 1975 ms
2019-10-13 23:54:51.436  INFO 9085 --- [  restartedMain] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2019-10-13 23:54:51.702  INFO 9085 --- [  restartedMain] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2019-10-13 23:54:51.779  INFO 9085 --- [  restartedMain] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [
	name: default
	...]
2019-10-13 23:54:51.890  INFO 9085 --- [  restartedMain] org.hibernate.Version                    : HHH000412: Hibernate Core {5.3.12.Final}
2019-10-13 23:54:51.892  INFO 9085 --- [  restartedMain] org.hibernate.cfg.Environment            : HHH000206: hibernate.properties not found
2019-10-13 23:54:52.125  INFO 9085 --- [  restartedMain] o.hibernate.annotations.common.Version   : HCANN000001: Hibernate Commons Annotations {5.0.4.Final}
2019-10-13 23:54:52.323  INFO 9085 --- [  restartedMain] org.hibernate.dialect.Dialect            : HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
Hibernate: drop table article if exists
Hibernate: drop table post if exists
Hibernate: drop sequence if exists hibernate_sequence
Hibernate: create sequence hibernate_sequence start with 1 increment by 1
Hibernate: create table article (article_no bigint not null, content clob not null, created_by varchar(255) not null, created_date timestamp not null, title varchar(255) not null, update_by varchar(255) not null, updated_date timestamp not null, primary key (article_no))
Hibernate: create table post (post_no bigint not null, title varchar(255) not null, primary key (post_no))
2019-10-13 23:54:53.233  INFO 9085 --- [  restartedMain] o.h.t.schema.internal.SchemaCreatorImpl  : HHH000476: Executing import script 'org.hibernate.tool.schema.internal.exec.ScriptSourceInputNonExistentImpl@7f47a56f'
2019-10-13 23:54:53.237  INFO 9085 --- [  restartedMain] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2019-10-13 23:54:53.319  INFO 9085 --- [  restartedMain] o.s.b.d.a.OptionalLiveReloadServer       : LiveReload server is running on port 35729
2019-10-13 23:54:54.058  INFO 9085 --- [  restartedMain] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2019-10-13 23:54:54.120  WARN 9085 --- [  restartedMain] aWebConfiguration$JpaWebMvcConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2019-10-13 23:54:54.584  INFO 9085 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8099 (http) with context path ''
2019-10-13 23:54:54.589  INFO 9085 --- [  restartedMain] com.example.demo.DemoApplication         : Started DemoApplication in 6.029 seconds (JVM running for 7.371)

그래도 H2 DB에 알아서 테이블이 잘 생성됬길래 Entity랑 Repository 설정은 다 잘 붙었나보다 하며 만들어진 API를 보기 위해 Hal-Browser로 접속했는데 profile 외에 아무것도 생성되지 않았습니다.

not working REST Repository

해결

원인은 패키지 구조에서 찾았는데 Configuration을 선언해둔 JpaConfig의 위치가 이슈였습니다.

wrong position

Entity 클래스는 com.example.demo.entity 하위에 위치하는데 @EnableJpaRepositories을 선언한 설정 클래스는 com.example.demo.config 하위에 위치해 있었던 거죠.
JPA 설정은 알아서 잘 찾길래 딱히 basePackage 위치도 지정하지 않았는데 Rest Repository 설정엔 영향을 끼치는 것 같습니다. 사실 위 설정 클래스 자체가 없어도 @SpringBootApplication가 설정된 클래스 하위에 위치하면 알아서 스캔하기 때문에 JPA가 잘 동작하는데 말이죠.

/*  그냥 날려 버리거나 basePackages 설정을 추가합니다.
@Configuration
@EnableJpaRepositories(basePackages = "com.example.demo")
public class JpaConfig {
    
}
*/

resolve issue

위와 같이 Entity들의 CRUD Request Path가 잘 생성 된 것을 확인 할 수 있습니다.

(ES6) Canvas + Javascript로 웹 게임 만들기 - 이벤트 만들기

|

비게임 업종 서버개발자가 HTML5의 <canvas>와 Javascript(ES6)를 이용하여 취미로 개발해 본 웹게임에 대한 글로 개발 코드 자체에 대한 설명보다는 어떤 원리와 방식으로 개발하였는지에 대한 내용을 기술하고 있습니다.

순서 제목 링크
1 캐릭터 그리기 https://jistol.github.io/frontend/2019/09/25/create-webgame-1/
2 캐릭터 움직이기 https://jistol.github.io/frontend/2019/09/28/create-webgame-2/
3 이벤트 만들기 https://jistol.github.io/frontend/2019/09/29/create-webgame-3/

게임명은 “도마뱀플라이트”로 앱게임 “드래곤플라이트”를 모방하여 일부 기능을 따라 구현하였으며 실제 게임은 아래 링크에서 실행 해 볼 수 있습니다.

GAME : https://jistol.github.io/lizard/

SOURCE : https://github.com/jistol/lizard-flight

game capture

키 이벤트 만들기

캐릭터를 그렸으니 조정하는 방법을 추가 하도록 합니다. 전 포스팅에서도 언급했듯이 Worker내에서는 직접 DOM을 접근 할 수가 없습니다. 따라서 메인 페이지에서 키 이벤트를 받아 Worker에게 전달해야하는데, 이 때도 역시 postMessage를 사용하게 됩니다.

// main page
const worker = new Worker('worker.js');
const onKeyEvent = (e) => {
    worker.postMessage({ key : event.key });
};
document.addEventListener('keydown', onKeyEvent, false);

// worker.js
self.onmessage = function(e) {
  console.log(e.data.key);
}

이전 코드와 달라진 부분은 keydown 이벤트시 움직이는 방향으로 원을 이동시키고 keyup 이벤트시 움직임을 멈추도록 direct라는 변수를 추가하였으며 화면밖으로 원이 나가지 않도록 x의 크기를 제어하는 부분입니다.

터치 이벤트 만들기

요즘 대부분의 접속 환경이 모바일인 만큼 터치 이벤트를 이용한 움직임도 처리해보도록 하겠습니다. 키의 경우 명확하게 어떤 키를 누르고 땠는지 확인이 가능하나 터치는 같은 이벤트로 다른 처리를 해야하기에 키 이벤트와는 다르게 코딩 되어야 합니다.
터치 이벤트는 대표적으로 아래와 같이 존재합니다.

이벤트명 설명
touchstart 디바이스 화면에 손가락이 닿는 순간 발생하는 이벤트입니다.
touchmove 디바이스 화면에 손가락이 닿은 상태에서 손가락을 움직이면 발생하는 이벤트 입니다
touchend 디바이스 화면에 손가락이 닿은 상태에서 손가락을 떼어내면 발생하는 이벤트입니다.
touchcancel 터치 이벤트가 시스템으로 인해 취소 될 때 발생하는 이벤트입니다.

좀 더 자세한 설명은 Javascript Mobile Events의 이해를 참고하시기 바랍니다.
다시 구현으로 돌아가서, 캐릭터를 이동 시키기 위해서는 두가지 선택지가 있습니다. 첫번째로는 터치된 포인트 지점으로 캐릭터를 바로 이동시키는 방법과 두번째는 터치 후 움직이는 방향으로 이동시키는 방법입니다. 본 코드에서는 두번째 방법을 이용해 구현했습니다.

(ES6) Canvas + Javascript로 웹 게임 만들기 - 캐릭터 움직이기

|

비게임 업종 서버개발자가 HTML5의 <canvas>와 Javascript(ES6)를 이용하여 취미로 개발해 본 웹게임에 대한 글로 개발 코드 자체에 대한 설명보다는 어떤 원리와 방식으로 개발하였는지에 대한 내용을 기술하고 있습니다.

순서 제목 링크
1 캐릭터 그리기 https://jistol.github.io/frontend/2019/09/25/create-webgame-1/
2 캐릭터 움직이기 https://jistol.github.io/frontend/2019/09/28/create-webgame-2/
3 이벤트 만들기 https://jistol.github.io/frontend/2019/09/29/create-webgame-3/

게임명은 “도마뱀플라이트”로 앱게임 “드래곤플라이트”를 모방하여 일부 기능을 따라 구현하였으며 실제 게임은 아래 링크에서 실행 해 볼 수 있습니다.

GAME : https://jistol.github.io/lizard/

SOURCE : https://github.com/jistol/lizard-flight

game capture

setTimeout을 이용하여 캐릭터 움직이기

캐릭터를 움직이는 원리는 애니메이션의 원리와 같습니다. canvas에 캐릭터를 빠르게 다시 그려서 마치 위치가 바뀐것처럼 보이게 하는겁니다.
본 코드에서는 전체화면을 지우고 다시 그리는데 화면이 끊기거나 속도가 느려지지 않을까 하는 우려와는 달리 빠르게 처리되고 크게 이질감도 없었습니다.

화면을 다시 그리도록 반복하는 방법은 두가지가 있는데 그 중 가장 쉬운 방법은 setTimeout(또는 setInterval)을 이용하는 방법입니다.

위와 같이 코딩시에는 단점이 있습니다. setTimeout의 경우 해당 시간에 특정 함수를 동작시킬뿐 화면 프레임을 전혀 고려하지 않습니다.
브라우저가 화면을 그리기까지 몇 가지 단계가 있는데 그 단계를 모두 기다리지 못하거나 더 많은 텀을 가질 확률이 큽니다.
더 자세한 설명은 requestAnimationFrame() 개념 정리하기를 참고하세요.

requestAnimationFrame을 이용하여 캐릭터 움직이기

requestAnimationFrame 함수는 브라우저가 다음 리페인트를 수행하기 전에 해당 함수를 실행시켜 변경된 데이터로 그리도록 실행해주는 함수입니다.
자세한 설명은 MDN - window.requestAnimationFrame() 참고하시길 바라며 위 setTimeout을 이용한 코드에서 반복 부분만 requestAnimationFrame로 바꾼 코드입니다.

Web Worker를 이용하여 별도 스레드에서 그려보자

JavaScript는 기본적으로 단일 스레드 기반으로 동작합니다. 동시에 작업이 이뤄지는듯 보이지만 빠르게 하나씩 처리하거나 시분할하여 자원을 나눠쓰게 됩니다.
화면을 계속 갱신해야하는데 중간에 메인 스레드에 오래걸리는 작업을 실행하게 된다면 화면이 끊기거나 딜레이 될 수 있는데, 이 때 해결 할 수 있는 방법이 Web Worker를 이용하는 것입니다.

Web Worker는 메인 스레드가 아닌 별도 스레드를 이용하여 동작하기 때문에 메인 스레드의 부하에 영향없이 동작 할 수 있는 장점이 있으나, DOM 개체에 직접 접근 제어 할 수 없으며 메인 스레드와 message 이벤트를 통해서만 통신이 가능한 단점이 존재합니다. 추가로 new Worker()사용시 기본적으로 Dedicated Worker를 사용하게 되는데 이 때 모듈방식을 사용 할 수 없으며 import, export 방식 대신 importScripts() 함수를 이용하여 추가 가능합니다.
자세한 설명은 MDN - 웹 워커 사용하기을 참고하세요.

Worker는 직접 DOM 제어를 할 수 없기 때문에 canvas에 렌더링 하기 위해서는 메인 스레드로 부터 canvas를 전달 받아야 합니다. postMessage로 전달 가능한데 이 때 전달 가능한 객체는 Transferable인터페이스를 구현한 객체만 가능하며 나머지 객체는 오류를 발생 시킵니다. 관련한 사항은 MDN - Worker.postMessage() MDN - Transferable를 참고하세요.
다행히도 canvas는 위 인터페이스를 구현한 객체를 제공하는데 OffscreenCanvas객체로 HTMLCanvasElement.transferControlToOffscreen()함수를 통해 얻을 수 있습니다.
아직 모든 브라우저에서 제공하고 있진 않습니다.