@Override public List<Review> myReviews(){ returnnew ArrayList<>(); } }
그림으로
Service에서 Repository를 선언하여 사용하고, Repository는 CustomRepository를 상속받고, CustomRepositroy를 CustomRepositoryImpl가 구현체로 작성된 상태이다.
이때, ReviewFindService의 findMyReviews()를 호출하면, 아래와 같은 exception이 발생하고, 코드가 정상적으로 작동하지 않았다.
Caused by: java.lang.IllegalArgumentException: Failed to create query for method publicabstract java.util.List com.jgji.sokdak.domain.review.domain.ReviewCustomRepository.myReviews()! No property 'myReviews' found for type 'Review' at org.springframework.data.jpa.repository.query.PartTreeJpaQuery.<init>(PartTreeJpaQuery.java:96) at org.springframework.data.jpa.repository.query.JpaQueryLookupStrategy$CreateQueryLookupStrategy.resolveQuery(JpaQueryLookupStrategy.java:119) at org.springframework.data.jpa.repository.query.JpaQueryLookupStrategy$CreateIfNotFoundQueryLookupStrategy.resolveQuery(JpaQueryLookupStrategy.java:259) at org.springframework.data.jpa.repository.query.JpaQueryLookupStrategy$AbstractQueryLookupStrategy.resolveQuery(JpaQueryLookupStrategy.java:93) at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.lookupQuery(QueryExecutorMethodInterceptor.java:103) ... 134 more Caused by: org.springframework.data.mapping.PropertyReferenceException: No property 'myReviews' found for type 'Review' at org.springframework.data.mapping.PropertyPath.<init>(PropertyPath.java:91) at org.springframework.data.mapping.PropertyPath.create(PropertyPath.java:438) at org.springframework.data.mapping.PropertyPath.create(PropertyPath.java:414) at org.springframework.data.mapping.PropertyPath.lambda$from$0(PropertyPath.java:367) at java.base/java.util.concurrent.ConcurrentMap.computeIfAbsent(ConcurrentMap.java:330) at org.springframework.data.mapping.PropertyPath.from(PropertyPath.java:349) at org.springframework.data.mapping.PropertyPath.from(PropertyPath.java:332) at org.springframework.data.repository.query.parser.Part.<init>(Part.java:81) at org.springframework.data.repository.query.parser.PartTree$OrPart.lambda$new$0(PartTree.java:250) at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:195) at java.base/java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:177) at java.base/java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948) at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:484) at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:474) at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:913) at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234) at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:578) at org.springframework.data.repository.query.parser.PartTree$OrPart.<init>(PartTree.java:251) at org.springframework.data.repository.query.parser.PartTree$Predicate.lambda$new$0(PartTree.java:384) at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:195) at java.base/java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:177) at java.base/java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948) at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:484) at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:474) at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:913) at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234) at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:578) at org.springframework.data.repository.query.parser.PartTree$Predicate.<init>(PartTree.java:385) at org.springframework.data.repository.query.parser.PartTree.<init>(PartTree.java:93) at org.springframework.data.jpa.repository.query.PartTreeJpaQuery.<init>(PartTreeJpaQuery.java:89) ... 138 more
우선, 구현체인 ReviewCustomRepositoryImpl에 Break point 걸어보니, 아예 ReviewCustomRepositoryImpl의 myReviews()가 호출조차 되지 않는 것을 확인했다. 그 후 이것저것 코드도 수정해 보고 하였는데, ReviewCustomRepository를 따로 선언하여 사용하니 정상적으로 호출되었다.
public Review findById(long id){ returnthis.reviewRepository.findById(id) .orElseThrow(EntityNotFoundException::new); }
public List<Review> findMyReviews(){ returnthis.reviewCustomRepository.myReviews(); } }
기존에 계속 사용하던 구조이기에, CustomRepository를 선언하여 사용하는 방식으로는 문제 해결이 안 된다고 판단하여, 서론에서 말했던 것처럼 최근에 작업한 패키지 구조 수정 중 무언가 문제가 발생하였다고 판단되어 패키지 구조를 열어보니…
위 이미지처럼, Custom Repository interface와 구현체인 Custom Repository Impl 클래스가 서로 다른 패키지에 존재하고 있었고,
위처럼 같은 패키지 아래 존재 하도록 위치를 조정해주니 정상적으로 동작하는 것을 확인할 수 있었다. interface는 기본적으로 접근제어자가 public으로 선언되는데, 패키지 위치가 다른 것이 어째서 문제가 되는지 찾아보니.. Spring 공식 문서에서 아래와 같은 가이드를 찾을 수 있었다.
The repository infrastructure tries to autodetect custom implementation fragments by scanning for classes below the package in which it found a repository. These classes need to follow the naming convention of appending a postfix defaulting to Impl.
결론
결론적으로, Custom Repository를 작성할 때는 구현체 클래스에 postfix로 Impl가 붙게 만들어야 하고, interface와 같은 위치, 혹은 하위 패키지에 존재하도록 하여야 한다는 것을 알았다.
토이 프로젝트를 진행 중, 장소의 위치의 위도, 경도 좌표를 저장해야 했는데, float type의 ‘latitude’, ‘longitude’라는 2개의 컬럼을 만들어서 각각 따로 저장하려고 했는데, 우연찮은 기회에 공간 데이터 타입이라는 것이 따로 있다는 것을 발견하여 적용해본 경험을 글로 작성하여 보려고 한다.
본론
공간(空間, 영어: space)은 어떤 물질 또는 물체가 존재할 수 있거나 어떤 일이 일어날 수 있는 장소이다.
공간 데이터란, 위 같은 점, 선, 면의 공간을 데이터화 한 것을 의미하는데, 필자는 지도에서 위치를 표시하기 위해 2D 공간 데이터인 점(Point) 데이터를 저장할 필요가 있었다. 필자는 Spring boot + JPA(Hibernate) + H2 환경을 사용하고 있어서, 공간 데이터를 다루기 위해 hibernate-spatial 의존성을 추가해주었다.
Caused by: org.h2.jdbc.JdbcSQLDataException: Value too longfor column "LOCATION BINARY VARYING(255)": "X'aced00057372001f6f72672e6c6f636174696f6e746563682e6a74732e67656f6d2e506f696e74... (1151)"; SQL statement: insert into place(id, create_date, update_date, jibun, location, road, zip, category_id, name)values(default, ?, ?, ?, ?, ?, ?, ?, ?) [22001-214] at org.h2.message.DbException.getJdbcSQLException(DbException.java:506) at org.h2.message.DbException.getJdbcSQLException(DbException.java:477) at org.h2.message.DbException.get(DbException.java:223) at org.h2.message.DbException.getValueTooLongException(DbException.java:322) at org.h2.value.Value.getValueTooLongException(Value.java:2573) at org.h2.value.Value.convertToVarbinary(Value.java:1371) at org.h2.value.Value.convertTo(Value.java:1125) at org.h2.value.Value.convertForAssignTo(Value.java:1092) at org.h2.table.Column.validateConvertUpdateSequence(Column.java:369) at org.h2.table.Table.convertInsertRow(Table.java:926) at org.h2.command.dml.Insert.insertRows(Insert.java:167) at org.h2.command.dml.Insert.update(Insert.java:135) at org.h2.command.CommandContainer.executeUpdateWithGeneratedKeys(CommandContainer.java:242) at org.h2.command.CommandContainer.update(CommandContainer.java:163) at org.h2.command.Command.executeUpdate(Command.java:252) at org.h2.jdbc.JdbcPreparedStatement.executeUpdateInternal(JdbcPreparedStatement.java:209) at org.h2.jdbc.JdbcPreparedStatement.executeUpdate(JdbcPreparedStatement.java:169) at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61) at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeUpdate(HikariProxyPreparedStatement.java) at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:197) ... 136 more
이처럼 예상하지 못한 결과가 발생하였는데… 로그를 보니, table create 시 좌표를 저장하는 컬럼의 타입이 기대했던 GEOMETRY이 아닌, varbinary 타입으로 생성되어서 발생한 에러인가 싶어서 컬럼 타입을 “GEOMETRY”로 지정하고 생성해봤지만, 결과는 마찬가지였다.
수정한 코드 및 실행된 SQL
1 2 3 4 5 6 7 8 9
...
// columnDefinition 속성으로 컬럼 타입 지정 @Column(name = "location", nullable = false, columnDefinition = "GEOMETRY") private Point location;
...
1 2 3 4 5 6 7 8 9 10 11 12
createtable place ( idbigintgeneratedbydefaultasidentity, create_date timestamp, update_date timestamp, jibun varchar(255), location GEOMETRY notnull, -- 정상적으로 컬럼의 데이터 타입이 GEOMETRY로 변경 road varchar(255), zip varchar(255) notnull, category_id bigint, namevarchar(255) notnull, primary key (id) )
다시 원인을 검색해본 결과… hibernate-spatial에서는 H2가 아닌 H2의 확장 데이터베이스인 GeoDB를 지원한다고 되어있는 것을 확인할 수 있었다.
The GeoDBDialect supports the GeoDB a spatial extension of the H2 in-memory database.
그 후 GeoDB를 적용하기 위해 properties 혹은 yaml에 아래처럼 dialect 설정에 org.hibernate.spatial.dialect.h2geodb.GeoDBDialect를 추가해 주면,
jpa: database-platform:org.hibernate.dialect.H2Dialect defer-datasource-initialization:true hibernate: ddl-auto:create-drop show-sql:true properties: hibernate: dialect:org.hibernate.spatial.dialect.h2geodb.GeoDBDialect# 추가 혹은 변경
dialect 설정 전 dialect
1
2022-11-3023:37:47.975 INFO 15720 --- [ main] org.hibernate.dialect.Dialect : HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
dialect 설정 후 dialect
1
2022-11-3023:35:42.124 INFO 18400 --- [ main] org.hibernate.dialect.Dialect : HHH000400: Using dialect: org.hibernate.spatial.dialect.h2geodb.GeoDBDialect
필자는 항상 단순하고 반복적인 일은 기계가 하고, 사람은 사색해야 한다고 생각한다. 당연하지만 이러한 생각은, 코딩할 때도 마찬가지다. class를 생성할 때마다 붙여야 하는 Annotation들을 (예를 들면 @Getter 같은) 붙이는 게 그렇게 귀찮을 수가 없었는데… 그러던 중 Intelli J의 Live Template 이라는 기능을 발견하여 필자 같은 사람들의 고통을 줄여 주고자 이 글을 작성한다.
본론
우선 위 이미지처럼 preference(settings)을 열어서 'Live Templates'를 검색해서 Live Templates 메뉴를 클릭한다.
열면 오른쪽에 여러 Template 그룹들이 존재하는데 필자처럼 Java가 없다면 Java 그룹을 생성해주거나, 기존 그룹에 탬플릿만 추가하여도 된다.
필자는 Entity 클래스를 생성하거나, request, response dto를 생성할 때마다
java.lang.IllegalStateException: Neither BindingResult nor plain target object for bean name 'command' available as request attribute at org.springframework.web.servlet.support.BindStatus.<init>(BindStatus.java:153) at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getBindStatus(AbstractDataBoundFormElementTag.java:178) at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getPropertyPath(AbstractDataBoundFormElementTag.java:199) at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getName(AbstractDataBoundFormElementTag.java:164) at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.writeDefaultAttributes(AbstractDataBoundFormElementTag.java:123) at org.springframework.web.servlet.tags.form.AbstractHtmlElementTag.writeDefaultAttributes(AbstractHtmlElementTag.java:460) at org.springframework.web.servlet.tags.form.SelectTag.writeTagContent(SelectTag.java:405) at org.springframework.web.servlet.tags.form.AbstractFormTag.doStartTagInternal(AbstractFormTag.java:87) at org.springframework.web.servlet.tags.RequestContextAwareTag.doStartTag(RequestContextAwareTag.java:83) at org.apache.jsp.WEB_002dINF.xxxx.xxxx.xxxx_jsp._jspService(new_jsp.java:263) at org.apache.jasper.runtime.HttpJspBase.service(HttpJspBase.java:70) at javax.servlet.http.HttpServlet.service(HttpServlet.java:741) at org.apache.jasper.servlet.JspServletWrapper.service(JspServletWrapper.java:476) at org.apache.jasper.servlet.JspServlet.serviceJspFile(JspServlet.java:386) at org.apache.jasper.servlet.JspServlet.service(JspServlet.java:330) at javax.servlet.http.HttpServlet.service(HttpServlet.java:741) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:728) at org.apache.catalina.core.ApplicationDispatcher.doInclude(ApplicationDispatcher.java:591) at org.apache.catalina.core.ApplicationDispatcher.include(ApplicationDispatcher.java:527) at org.apache.jasper.runtime.JspRuntimeLibrary.include(JspRuntimeLibrary.java:868) at org.apache.jasper.runtime.PageContextImpl.doInclude(PageContextImpl.java:679) at org.apache.jasper.runtime.PageContextImpl.include(PageContextImpl.java:673) at org.apache.tiles.request.jsp.JspRequest.doInclude(JspRequest.java:123) at org.apache.tiles.request.AbstractViewRequest.dispatch(AbstractViewRequest.java:47) at org.apache.tiles.request.render.DispatchRenderer.render(DispatchRenderer.java:45) at org.apache.tiles.request.render.ChainedDelegateRenderer.render(ChainedDelegateRenderer.java:68) at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:259) at org.apache.tiles.template.InsertAttributeModel.renderAttribute(InsertAttributeModel.java:188) at org.apache.tiles.template.InsertAttributeModel.execute(InsertAttributeModel.java:132) at org.apache.tiles.jsp.taglib.InsertAttributeTag.doTag(InsertAttributeTag.java:299) at org.apache.jsp.WEB_002dINF.xxxx.xxxx.xxxx_jsp._jspx_meth_t_005finsertAttribute_005f4(layout_jsp.java:10881) at org.apache.jsp.WEB_002dINF.xxxx.xxxx.xxxx_jsp._jspService(layout_jsp.java:1242) at org.apache.jasper.runtime.HttpJspBase.service(HttpJspBase.java:70) at javax.servlet.http.HttpServlet.service(HttpServlet.java:741) at org.apache.jasper.servlet.JspServletWrapper.service(JspServletWrapper.java:476) at org.apache.jasper.servlet.JspServlet.serviceJspFile(JspServlet.java:386) at org.apache.jasper.servlet.JspServlet.service(JspServlet.java:330) at javax.servlet.http.HttpServlet.service(HttpServlet.java:741) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:728) at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:470) at org.apache.catalina.core.ApplicationDispatcher.doForward(ApplicationDispatcher.java:395) at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:316) at org.apache.tiles.request.servlet.ServletRequest.forward(ServletRequest.java:265) at org.apache.tiles.request.servlet.ServletRequest.doForward(ServletRequest.java:228) at org.apache.tiles.request.AbstractClientRequest.dispatch(AbstractClientRequest.java:57) at org.apache.tiles.request.render.DispatchRenderer.render(DispatchRenderer.java:45) at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:259) at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:397) at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:238) at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:221) at org.apache.tiles.renderer.DefinitionRenderer.render(DefinitionRenderer.java:59) at org.springframework.web.servlet.view.tiles3.TilesView.renderMergedOutputModel(TilesView.java:147) at org.springframework.web.servlet.view.AbstractView.render(AbstractView.java:316) at org.springframework.web.servlet.DispatcherServlet.render(DispatcherServlet.java:1404) at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1148) at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1087) at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963) at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) at javax.servlet.http.HttpServlet.service(HttpServlet.java:634) at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883) at javax.servlet.http.HttpServlet.service(HttpServlet.java:741) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) at org.apache.catalina.filters.ExpiresFilter.doFilter(ExpiresFilter.java:1228) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) at org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter.doFilterInternal(OpenEntityManagerInViewFilter.java:186) at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) at org.springframework.web.filter.HiddenHttpMethodFilter.doFilterInternal(HiddenHttpMethodFilter.java:94) at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:330) at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.invoke(FilterSecurityInterceptor.java:118) at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.doFilter(FilterSecurityInterceptor.java:84) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:113) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.session.SessionManagementFilter.doFilter(SessionManagementFilter.java:103) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:113) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter.doFilter(RememberMeAuthenticationFilter.java:146) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:154) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:45) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:199) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:199) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:110) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:57) at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:87) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:50) at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:192) at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:160) at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:354) at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:267) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193) at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166) at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:199) at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:96) at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:543) at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:139) at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:81) at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:678) at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:87) at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343) at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:609) at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65) at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:810) at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1623) at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128) at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628) at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) at java.base/java.lang.Thread.run(Thread.java:829)
해결 과정
에러로그 일부
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
...
java.lang.IllegalStateException: Neither BindingResult nor plain target object for bean name 'command' available as request attribute at org.springframework.web.servlet.support.BindStatus.<init>(BindStatus.java:153) at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getBindStatus(AbstractDataBoundFormElementTag.java:178) at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getPropertyPath(AbstractDataBoundFormElementTag.java:199) at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getName(AbstractDataBoundFormElementTag.java:164) at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.writeDefaultAttributes(AbstractDataBoundFormElementTag.java:123) at org.springframework.web.servlet.tags.form.AbstractHtmlElementTag.writeDefaultAttributes(AbstractHtmlElementTag.java:460) at org.springframework.web.servlet.tags.form.SelectTag.writeTagContent(SelectTag.java:405) at org.springframework.web.servlet.tags.form.AbstractFormTag.doStartTagInternal(AbstractFormTag.java:87) at org.springframework.web.servlet.tags.RequestContextAwareTag.doStartTag(RequestContextAwareTag.java:83) at org.apache.jsp.WEB_002dINF.xxxx.xxxx.xxxx_jsp._jspService(new_jsp.java:263)
....
> Neither BindingResult nor plain target object for bean name 'command' available as request attribute
짧은 영어 실력과 구글 번역기에 도움으로 해석해보자면 BindingResult이나 ‘command’ 이라는 이름의 bean 을 request의 속성으로 사용할 수 없어서 발생하는 에러인 것 같은데, 에러 로그를 확인 한 결과… 위에서 보이는 것 처럼, JSP의 Form Tag에서 에러가 발생하는 것으로 추측되어 4.3.3 version에서 Form Tag를 구현하는 FormTag를 확인해보니 commandName 관련된 메서드들이 @Deprecated 로 선언되어서 곧 없어진다고 알려주고 있었는데, 이번에 업그레이드한 5.3.22 version의 FormTag에선 commandName의 getter, setter 메서드들이 없는 것을 확인할 수 있었다.
그리고 이미지를 보면 알 수 있듯이 기존의 setCommandName 메서드에서도 modelAttribute에 commandName을 저장해서 쓰는 것을 확인할 수 있는데, 5.3.22 에선 해당 메서드가 없어지면서 변수 modelAttribute에 ‘command’가 할당되어 있기에, ‘command’ 라는 bean을 속성으로 사용할 수 없다고 에러가 발생하는 것이었다.
이렇게 된 것이니 당연하게도 해결 방법은 modelAttribute에 우리가 지정한 속성값이 set 되도록 하면 되는데, 위에서 본 것처럼 기존의 setCommandName에서도 modelAttribute의 값을 사용하고 있었으므로, setModelAttribute로 modelAttribute 값이 저장되도록 하면 문제는 해결될 것이기에, JSP form tag에 commandName 속성을 모두 modelAttribute로 변경한 후 확인해 보면 정상적으로 동작 하는 것을 확인할 수 있을 것이다.
현재 운영 중인 서비스는 같은 데이터베이스를 공유하는 5개의 프로젝트가 존재하는데, 모두 Spring or Spring boot + JPA를 사용하는 프로젝트들이다.
이 때문에, 한 프로젝트에서 도메인(Entity) 클래스에 변수를 추가한 경우에 다른 모든 프로젝트에서도 동일하게 추가 해주지 않으면 데이터베이스 조회 시 등 에러가 발생 할 수 밖에 없는데 이런 copy & paste 반복 작업을 개발자가 손수 하다보면 휴먼에러가 발생할 수 밖에 없음은 물론 같은 데이터베이스를 사용하는 Entity 임에도 불구하고 코드가 모두 다른 이상한 상황이 발생 할 수 밖에 없는 구조였다.
그렇기에 어떻게 할까 고민하던 중 멀티모듈 설계 이야기 with Spring, Gradle 이라는 글을 보고 필자의 서비스에도 단일 프로젝트 멀티모듈 구조로 가져갈 까 하다, 각각의 프로젝트마자 깃을 따로 관리하는게 서비스에는 맞다는 생각이 들어 멀티 프로젝트에 Nexus maven 저장소를 이용해서 도메인 클래스들을 분리 시키기로 했다.
본론
현재 시스템의 구조
이 처럼 여러 개의 프로젝트가 하나의 데이터베이스를 공유하는 구조로, 같은 데이터베이스 이므로 동일한 내용의 도메인 클래스가 각자 프로젝트에 각각 중복으로 존재하기 때문에 관리가 힘든 상황이다.
때문에 도메인 클래슬들만 모아놓은 새로운 프로젝트를 생성하고, 그 프로젝트를 jar파일로 만들어 라이브러리화 한 후, 내부 Maven 저장소인 Nexus에 업로드해서 다른 프로젝트들에서 dependency에 추가해서 시스템적으로 정합성을 유지하면서 사용 할 수 있게 하려고 한다.
간단하게 정리하면
Nexus에 모듈화된 도메인 프로젝트 업로드
다른 프로젝트들에서 라이브러리화 된 도메인 프로젝트를 dependency 추가하여 사용
하는 것이 이번 글의 목표이다.
이 포스트에 Nexus를 설치하는 내용까지 담으려고 하니 내용이 너무 길어질 것 같아 nexus는 이미 설치되어 있다고 가정한다.
1. Nexus 설정
다른 분들의 글들을 보고 nexus를 무사히 설치하고 실행하면 아래와 같은 화면을 볼 수 있을 것이다.
그 후 Sign in 버튼을 클릭하면 초기 아이디와 비밀번호가 저장된 파일의 위치를 알려준다.
1
cat /설치경로/nexus3/admin.password
설정 하다보면 아래처럼 익명 사용자도 접근하게 할 것인지 물어보는데 당연히도 수정 할 수 있으므로 적당히 선택해주면 된다.
2. Repository 생성
우선 모듈화한 도메인 프로젝트를 업로드 할 레파지토리부터 생성해야한다.
레파지토리 type은 group, hosted, proxy가 있지만, 우리는 위 처럼 maven2의 hosted로 생성한다.
필터란에 생성한 레파지토리명으로 검색해서 nx-repository-view-… 로 시작하는 권한들을 넣어주고, 해당 Role로 레파지토리에 업로드도 할 수 있게 하기 위해 nx-component-upload 권한도 넣어준다.
유저 생성
각 항목들은 각자에 맞게 넣어주고 Role엔 조금 전에 생성한 Role을 넣어주고 생성을 완료하면 우리가 처음에 로그인한 어드민 계정과는 달리 우리가 생성한 repository만 접근 할 수 있는 것을 확인 할 수 있다.
4. 라이브러리 업로드
settings.xml 설정
nexus 레파지토리에 접근하기 위해서는 우선, maven 설정 파일인 settings.xml을 수정해야야 한다. 기본적으로는 ~/.m2/settings.xml 으로 설정되어 있으므로 이 곳에 생성해서 사용하거나, 이미 settings.xml을 사용하고 있다면 그 파일을 수정해주면 되는데.. 이도 저도 모르겠으면 아래 이미지 처럼 인텔리제이에서 확인하여 그 위치의 settings.xml을 수정해준다.
이 때, dependency의 속성들이 헷갈린다면, nexus에서 업로드된 jar를 선택 할 경우 아래 이미지처럼 친절하게 알려주므로 복사해서 사용하면 된다.
Entity 호출하기
위 이미지 처럼 dependency 추가 후 maven을 새로고침하면 보이는 것 처럼 추가도 잘 되고 코드에서도 사용 할 수 있는 것 처럼 보이는데 막상 코드에서 사용하려고 하면 아래 이미지에서 보는 것 처럼 정상적으로 호출되지 않는 것을 알 수 있다.
결론부터 이야기하면 우리가 jar로 만든 프로젝트는 Spring boot 프로젝트를 그대로 jar로 패키징 한 것으로, 일반적인 jar 파일이 아닌 Spring boot가 실행 가능한 jar 형태로 패키징 되었기 때문에, jar에 포함된 클래스들을 호출 할 수가 없는 것이다.
여기서 말하는 실행 가능한 jar는 META-INF에 저장된 MANIFEST.MF에 저장된 정보를 읽어서 실행시키기 때문에 우리가 일반적으로 호출하는 형태로는 사용 할 수 없다.
이 실행 가능한 jar를 일반적인 jar로 만들어서 repository에 배포하기 위해서 모듈화하는 프로젝트의 pom.xml에서 아래 설정을 제거 해주고 다른 dependency들에게 각각 version을 지정한 후 maven compiler plugin도 알맞게 추가해주면 되는데
이 때 우리가 삭제한 spring-boot-parent에 포함된 의존성들은 여기서 확인 할 수 있으므로, 필요한 것이나 각각 dependency들을 무슨 버전으로 지정해야 할 지 모르겠다면 여기서 확인 후 추가해주면 된다.
이제 verison을 올린 후 새로고침 해보면 이전과 다르게 BOOT-INF가 없어지고 바로 com. 으로 시작하는 자바 패키지가 노출되는 것을 확인 할 수 있다.
이제 테스트 코드를 작성해서 돌려보면 테스트가 성공 할 줄 알았으나.. 애석하게도 아래와 같이 Not a managed type 라는 에러를 내뿜으며 우리의 기대를 무참히 박살내는데…
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
Caused by: java.lang.IllegalArgumentException: Not a managed type: classcom.junggu.ji.demo.domain.Junggu at org.hibernate.metamodel.internal.MetamodelImpl.managedType(MetamodelImpl.java:582) at org.hibernate.metamodel.internal.MetamodelImpl.managedType(MetamodelImpl.java:85) at org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformation.<init>(JpaMetamodelEntityInformation.java:75) at org.springframework.data.jpa.repository.support.JpaEntityInformationSupport.getEntityInformation(JpaEntityInformationSupport.java:66) at org.springframework.data.jpa.repository.support.JpaRepositoryFactory.getEntityInformation(JpaRepositoryFactory.java:233) at org.springframework.data.jpa.repository.support.JpaRepositoryFactory.getTargetRepository(JpaRepositoryFactory.java:182) at org.springframework.data.jpa.repository.support.JpaRepositoryFactory.getTargetRepository(JpaRepositoryFactory.java:165) at org.springframework.data.jpa.repository.support.JpaRepositoryFactory.getTargetRepository(JpaRepositoryFactory.java:76) at org.springframework.data.repository.core.support.RepositoryFactorySupport.getRepository(RepositoryFactorySupport.java:325) at org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport.lambda$afterPropertiesSet$5(RepositoryFactoryBeanSupport.java:323) at org.springframework.data.util.Lazy.getNullable(Lazy.java:231) at org.springframework.data.util.Lazy.get(Lazy.java:115) at org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport.afterPropertiesSet(RepositoryFactoryBeanSupport.java:329) at org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean.afterPropertiesSet(JpaRepositoryFactoryBean.java:144) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1863) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1800) ... 85 more
이는 우리가 추가한 라이브러리의 Entithy가 component scan에 인식되지 않아서 Bean context에 등록되지 않았기 때문에 발생하는 에러이다.
왜냐면, spring boot는 기본적으로 @SpringBootApplication안에 있는 @EnableAutoConfiguration으로 스프링이 정의한 외부 의존성들과 @ComponentScan으로 해당 어노테이션 하위 패키지에 있는 클래스들을 탐색해서 Bean으로 등록하는데,
우리가 추가한 라이브러리는 둘 중 어느 위치에도 존재하지 않으므로 당연히 Bean에 등록 될 수없는 것이다.
라이브러리안에 있는 Entity Bean context 등록하기
여러 방법이 있겠지만, 필자는 SessionFactory Bean을 따로 만들어서 우리가 만든 Entity가 있는 라이브러리의 패키지까지 스캔 하도록 추가하는 방식을 사용하였다.
1 2 3 4 5 6 7 8
@Bean public LocalSessionFactoryBean entityManagerFactory(DataSource dataSource){ LocalSessionFactoryBean localSessionFactory = new LocalSessionFactoryBean(); localSessionFactory.setDataSource(dataSource); localSessionFactory .setPackagesToScan("com.junggu.ji.usenexus", "com.junggu.ji.demo"); return localSessionFactory; }
이렇게 우리는 여러 프로젝트에서 공통으로 사용되는 부분을 따로 빼서 모듈화 하는 방법을 알아보았다.
필자는 단순히 Entity 클래스의 관리를 편하게 하기 위해 이번 모듈화를 진행했지만, 레이어 별로 프레젠테이션 레이어, 서비스 레이어, 도메인 레이어등으로 구분하여 모듈을 구분 할 수도 있을 것 이다.
다만, 이렇게 멀티 프로젝트에 nexus를 이용해서 모듈화를 구현하는 경우, 모듈화한 프로젝트를 수정 했을 때 모듈화된 라이브러리를 사용하는 다른 프로젝트 모든 프로젝트에서도 손수 version을 올려줘야한다는 단점이 있기 때문에(물론 Deployment policy를 Allow redeploy로 설정했다면 상관없다.), 각자의 서비스에서 단일 프로젝트 멀티 모듈 구조가 맞을 지 아니면 필자처럼 멀티 프로젝트인 상태로 모듈화를 구성 할지는 잘 판단해서 구조화를 해야 할 것 이다.
P.S
여담으로.. 필자는 이번 모듈화를 진행하면서 DTO에 의존적인 Entity들에 대해 굉장히 많이 리팩토링을 진행 할 수 있었고, 레이어 별 분리에 대해 다시 한번 생각해보는 계기가 되어서 고생은 좀 많이 했지만, 개인적으로 굉장히 뜻 깊은 시간을 보낼 수 있었다.
어쩌다 보니 연속해서 @RequestBody에 관한 글을 작성하게 되었는데… 이전에 말했던 것 처럼 @RequestBody는 Jackson 라이브러리를 사용하기 때문에 기본생성자를 이용해 object(DTO)를 바인딩한다. 그 때문에 다른 생성자에서 validtion등을 처리했다고 해도 당연히 처리되지 않는데 이 때 사용할 수 있는 @JsonCreator에 대해 알아본다.
본론
현재 상황
위 이미지들 처럼 Controller와 dto를 만들었다고 했을 때, /test 를 호출해보면 어떻게 될까?
@RequestBody를 사용 할 경우 Jackson 라이브러리를 사용해서 기본 생성자를 이용해 매핑시키기 때문에 “여기타고 생성이 될까요?” 라는 로그는 찍히지 않는다.
이 때, DTO에 처리 혹은 Assert 등을 이용한 validtion을 추가하고 싶다면 어떻게 될까?
생성자에 추가해서 처리하면 좋겠지만, 기본 생성자를 이용해서 생성되므로, controller 혹은 service 단에서 처리해야 한다.(물론 validtion의 경우엔 @Valid를 통해 어느정도 처리 가능하다.)
이런 경우, 기본 생성자가 아닌 다른 생성자를 이용해 dto를 바인딩 시키고 싶은 경우 사용 할 수 있는 annotation이 바로 @JsonCreator이다.
이제 다시 코드로 돌아와서, 이 @JsonCreator를 생성자에 선언 해놓고 다시 /test를 요청한다면
아까와 다르게 @Builder 패턴이 적용된 생성자를 통해 바인딩 되어서 “여기타고 생성이 될까요?” 라는 로그가 정상적으로 찍힌 걸 볼 수 있다.
결론
이제 우리는 @RequestBody를 사용할 때 기본 생성자만이 아닌 다른 생성자로도 DTO를 바인딩 할 수 있게 되었으므로, @Valid보다 더 정교한 validtion이나, String을 encoding 해야 할 경우에도 service나 controller가 아닌 생성자에서 처리 할 수 있게 되었으므로 테스트도 더 간편하게 할 수 있게 되었다.
번외로 보통 @JsonCreator는 인스턴스화 할 때 사용 할 생성자를 정의 할 때 사용 하는 annotation으로, 보통 아래 코드처럼 @JsonProperty와 같이 사용해서 엔티티와 완벽히 일치되지 않는 JSON을 역직렬화할 때 주로 사용하는 것 같다.
1 2 3 4 5 6 7 8 9 10 11 12
publicclassBeanWithCreator{ publicint id; public String name;
라는 결론으로 글을 마무리 하였는데, 김영한님의 강의에서 해당 내용과 관련해서 자세하게 알려주시는 부분이 있어, 해당 내용을 정리해본다.
본론
우선 Spring에서는 Controller에서 @RequestBody로 넘어오는 파라미터들에 대해서 내부적으로 ObjectMapper를 이용해 JSON 데이터를 우리가 만든 DTO 객체로 변환시켜주고, @ResponseBody인 경우엔, DTO 객체를 JSON 데이터로 변환해서 클라이언트에 넘겨준다.
이렇게해서 해소 될 궁금증이었으면 이 글을 읽으러 오지 않았을테니, 해당 내용에 대하여 코드 레벨에서 자세히 알아보기로 한다.
우선, Spring MVC의 동작 순서를 간략하게 서술하자면(구글에 검색하면 훌륭한 분들이 작성하신 훌륭한 글들이 많으므로)
클라이언트에서 요청이 넘어오면 FrontController 역할을 수행하는 DispatcherServlet에서 먼저 확인해서,
핸들러 매핑 리스트 중 해당 요청을 처리 할 수 있는 핸들러를 가져오고,
핸들러 어댑터 리스트 중에서 해당 핸들러를 처리 할 수 있는 핸들러 어댑터를 가져와서,
해당 어댑터에서 우리가 만든 컨트롤러에 호출해서 비즈니스 로직을 처리 한 후,
리턴되는 데이터에 맞게 뷰 리졸버나 메시지컨버터가 동작하고,
알맞게 클라이언트로 반환된다.
여기서, @RequestMapping에 대한 request는 RequestMappingHandlerAdapter에서 처리하는데 이 RequestMappingHandlerAdapter는 1. byte[]를 처리하기 위한 ByteArrayHttpMessageConverter 2. String을 처리하기 위한 StringHttpMessageConverter 3. json을 처리하기 위한 MappingJackson2HttpMessageConverter 등을 가지고 있다.
이 RequestResponseBodyMethodProcessor의 readWithMessageConverters 메서드에서 메시지 컨버터 리스트를 loop 돌면서 대상 클래스 타입과 미디어 타입 등을 체크해서 알맞은 메시지 컨버터를 호출해서 사용하는데 이 때 호출되는 MessageConverter가 MappingJackson2HttpMessageConverter라는 MessageConverter이다.
요청을 처리 할 수 있는 MessageConverter 호출
MessageConverter의 canRead() 메서드로 해당 요청을 처리 할 수 있는 컨버터 인지 확인.
처리 할 수 있으면 read() 메서드로 메시지에서 객체를 읽고 반환한다.
MappingJackson2HttpMessageConverter에서는 최종적으로 readJavaType 메서드가 호출되는데 이 때 objectMapper를 사용해서 json 데이터를 DTO 객체로 변환하는 것을 볼 수 있다.
ObjectMapper를 이용해서 JSON데이터를 변환하는 부분
결론
보이지 않는 곳에서 이렇게 많은 부분을 스프링에서 처리해주고 있었기 때문에 우리는 Controller에서 편하게 파라미터를 사용 할 수 있고, JSON 데이터도 바로 객체로 받을 수 있는 것이었다. 이런 내용을 알아도, 몰라도 결국 같은 방식으로 개발 하겠지만… 언젠가 도움이 되겠거니 생각하고 지금은 궁금증을 해결 했다는 것에 대해 만족한다. 이 글을 읽는 다른 분들도 필자와 같은 궁금증이 있었다면 조금이나마 해소가 됐길 바라며..