예제: Flashcard

예제 앱은 spring-jdbc-book의 ch07-jdbc-plus와 같은 기능을 Spring Boot 없이 만든다: 덱, 카드, 태그, 간격 반복 학습, 스마트 덱, CSV 임포트와 익스포트, 통계다.

DB

리포지토리는 NamedParameterJdbcTemplate을 직접 사용한다. 실행되는 SQL이 코드에 그대로 보인다. insert는 예외다: SimpleJdbcInsert가 테이블 메타데이터를 읽고 생성된 키를 반환하며, SimplePropertySqlParameterSource가 도메인 레코드에서 값을 바로 가져온다. SmartDeckRepository는 여전히 파라미터를 하나하나 적는데, 이 리포지토리의 SmartCondition enum이 enum이 아니라 name()으로 드라이버에 도달해야 하기 때문이다. 행 매핑은 DataClassRowMapper를 거친다. DataClassRowMapper와 SimplePropertySqlParameterSource가 네이티브 이미지에서 필요로 하는 리플렉션 설정은 빌드가 도메인 패키지에서 생성한다. FlashcardDatabase는 H2 DataSource를 만들고, 내주기 전에 schema.sql을 적용한다. file()은 실행되는 앱에 쓰이고, inMemory()는 테스트마다 따로 쓸 데이터베이스를 준다.

DI

컨테이너는 없다. FlashcardContext가 생성자를 직접 호출하여 모든 것을 연결하며, Spring의 ApplicationContext 역할을 손으로 대신한다.

트랜잭션

AOP 대신 서비스가 TransactionTemplate을 얇게 감싼 Transactions.write()/read()를 호출한다. 감싼 블록이 정확히 트랜잭션 범위다.

JSON API

ch07-jdbc-plus의 기능에 더해, 프레임워크의 REST 지원을 보여 주기 위해 JSON API(/api/decks, /api/decks/{id}/cards)가 같은 서비스 계층 위에 얹혀 있다. 그 와이어 포맷은 flashcard.web.Codecs에 손으로 작성한 JsonWriter/JsonReader 람다와 생성되는 코덱 둘로 들어 있다: 본문 레코드 NewDeck은 @JsonBound를 지니고, 도메인 레코드 DeckSummary는 같은 클래스의 믹스인을 통해 바인딩된다.

인트로스펙션

/_routes는 모든 라우트를 나열하고, /openapi.json은 같은 목록을 OpenAPI 3.1 문서로 낸다. 둘 다 라우팅 테이블을 데이터로 노출하는 app.routes()로 만든다. 문서는 spider-silk-openapi를 거치고, 무엇을 다룰지는 앱이 정한다: 여기서는 /api 라우트인데, 나머지는 모두 HTML로 응답하기 때문이다. 그 라우트들은 설명을 붙여 등록한 유일한 라우트이기도 하며, /_routes는 설명을 별도 열에 보여 주고 /openapi.json은 각 오퍼레이션의 summary에 담는다.

모든 응답

FlashcardContext.createApp은 실제 애플리케이션처럼 응답 전반의 관심사 세 가지를 모두 설정한다. securityHeaders(…​)는 기본값에 이 앱이 돌아갈 수 있는 정책을 더해 받는다: default-src 'self'가 앱이 로드하는 모든 것을 포괄하고, style-src는 인라인도 허용하는데 stats.jte가 차트 막대의 크기를 style 속성으로 정하기 때문이며, 이는 스타일시트로는 할 수 없는 일이다. HSTS는 의도적으로 뺐는데, main이 앱을 http://localhost로 제공하기 때문이다. gzip()은 페이지, 스타일시트, JSON, CSV 익스포트를 똑같이 포괄한다. cors(…​)는 /openapi.json에만 한정되고, 덱을 생성하는 /api/decks에는 적용되지 않는다. 그 문서는 다른 곳에서 제공되는 Swagger UI처럼 이 애플리케이션이 아닌 클라이언트가 읽도록 만든 유일한 리소스다.

로깅

애플리케이션은 slf4j API로 로깅하며, 백엔드는 Logback이고 설정은 src/main/resources/logback.xml이다. FlashcardContext.createApp은 요청마다 한 줄을 flashcard.access 로거에 기록하는 요청 로거를 등록한다. Jetty의 로그와 프레임워크의 서블릿 로그는 같은 설정을 따르고, spring-jdbc의 로그도 그러한데, spring-jdbc의 commons-logging이 클래스패스에서 slf4j를 찾기 때문이다. 테스트는 logback-test.xml로 돌며, 이 파일은 콘솔에 경고와 에러만 남긴다. 로깅이 같은 설정을 Log4j와 JDK 로깅으로 보여 준다.

라우팅

Controller 인터페이스는 없다. FlashcardContext.registerRoutes가 테이블 전체이고, 핸들러는 Action 클래스, public 메서드 참조, 또는 람다로 도착한다.

실행

gradle :example-flashcard:run                # precompiled templates
gradle :example-flashcard:run --args=--dev   # hot-reloads edited .jte files
# http://localhost:8080

두 명령은 jte의 프로덕션 모드와 개발 모드다: 첫 번째는 빌드가 컴파일한 템플릿 클래스를 렌더링하고, 두 번째는 파일이 바뀐 템플릿을 다음 렌더링 때 다시 컴파일한다. FlashcardApp은 --dev를 FlashcardContext의 devMode 플래그로 넘기고, FlashcardContext는 그 플래그로 TemplateRenderer를 고른다. 데이터베이스는 FlashcardDatabase.file()이 여는 H2 파일(~/db/spider-silk/flashcard)이므로 데이터가 재시작 후에도 남는다.

gradle :example-flashcard:nativeCompile은 같은 앱을 GraalVM 네이티브 이미지로 빌드한다.

테스트

gradle test

리포지토리와 서비스 테스트는 목 없이 FlashcardDatabase.inMemory()가 만드는 인메모리 H2에 대해 실행된다. DeckServiceTest의 롤백 테스트는 TransactionTemplate 경계가 실제로 동작하는 것을 보여 준다.