서론

현재 운영 중인 서비스는 같은 데이터베이스를 공유하는 5개의 프로젝트가 존재하는데, 모두 Spring or Spring boot + JPA를 사용하는 프로젝트들이다.

이 때문에, 한 프로젝트에서 도메인(Entity) 클래스에 변수를 추가한 경우에 다른 모든 프로젝트에서도 동일하게 추가 해주지 않으면 데이터베이스 조회 시 등 에러가 발생 할 수 밖에 없는데 이런 copy & paste 반복 작업을 개발자가 손수 하다보면 휴먼에러가 발생할 수 밖에 없음은 물론 같은 데이터베이스를 사용하는 Entity 임에도 불구하고 코드가 모두 다른 이상한 상황이 발생 할 수 밖에 없는 구조였다.

그렇기에 어떻게 할까 고민하던 중 멀티모듈 설계 이야기 with Spring, Gradle 이라는 글을 보고 필자의 서비스에도 단일 프로젝트 멀티모듈 구조로 가져갈 까 하다, 각각의 프로젝트마자 깃을 따로 관리하는게 서비스에는 맞다는 생각이 들어 멀티 프로젝트에 Nexus maven 저장소를 이용해서 도메인 클래스들을 분리 시키기로 했다.


본론

현재 시스템의 구조

이 처럼 여러 개의 프로젝트가 하나의 데이터베이스를 공유하는 구조로, 같은 데이터베이스 이므로 동일한 내용의 도메인 클래스가 각자 프로젝트에 각각 중복으로 존재하기 때문에 관리가 힘든 상황이다.

때문에 도메인 클래슬들만 모아놓은 새로운 프로젝트를 생성하고, 그 프로젝트를 jar파일로 만들어 라이브러리화 한 후, 내부 Maven 저장소인 Nexus에 업로드해서 다른 프로젝트들에서 dependency에 추가해서 시스템적으로 정합성을 유지하면서 사용 할 수 있게 하려고 한다.

간단하게 정리하면

  1. Nexus에 모듈화된 도메인 프로젝트 업로드
  2. 다른 프로젝트들에서 라이브러리화 된 도메인 프로젝트를 dependency 추가하여 사용

하는 것이 이번 글의 목표이다.

이 포스트에 Nexus를 설치하는 내용까지 담으려고 하니 내용이 너무 길어질 것 같아 nexus는 이미 설치되어 있다고 가정한다.


1. Nexus 설정

다른 분들의 글들을 보고 nexus를 무사히 설치하고 실행하면 아래와 같은 화면을 볼 수 있을 것이다.

그 후 Sign in 버튼을 클릭하면 초기 아이디와 비밀번호가 저장된 파일의 위치를 알려준다.


1
cat /설치경로/nexus3/admin.password


  • 설정 하다보면 아래처럼 익명 사용자도 접근하게 할 것인지 물어보는데 당연히도 수정 할 수 있으므로 적당히 선택해주면 된다.


2. Repository 생성

우선 모듈화한 도메인 프로젝트를 업로드 할 레파지토리부터 생성해야한다.



레파지토리 type은 group, hosted, proxy가 있지만, 우리는 위 처럼 maven2의 hosted로 생성한다.

  • 이때 hosted는 레파지토리 관리자가 직접 호스팅하는 레파지토리 type이고,
  • 다른 것들과의 차이점은 여기서 확인 해 볼 수 있다.



  • Version policy는

    • Release
    • Snapshot
    • Mixed

    3 가지가 존재하는데, 보통 Snapshot 레파지토리, Release 레파지토리를 따로 만들어 관리해야하지만, 필자는 Mixed로 지정하고 사용하였다.



  • Deployment policy는

    • Allow redeploy
      • 같은 버전으로 재배포 가능하고 덮어씀
    • Disable redeploy
      • 같은 버전으로 다시 배포 할 수 없음
    • Read-only
      • 아예 배포가 허용되지 않음
    • Deploy by Replication Only
      • 레플리카를 사용하는 경우 레플리카로만 배포되고 다른 배포는 차단됨
  • 자세한 내용은 여기서 확인 할 수 있다.



3. Repository access 유저 생성

Role 생성



필터란에 생성한 레파지토리명으로 검색해서 nx-repository-view-… 로 시작하는 권한들을 넣어주고, 해당 Role로 레파지토리에 업로드도 할 수 있게 하기 위해 nx-component-upload 권한도 넣어준다.

유저 생성



  • 각 항목들은 각자에 맞게 넣어주고 Role엔 조금 전에 생성한 Role을 넣어주고 생성을 완료하면 우리가 처음에 로그인한 어드민 계정과는 달리 우리가 생성한 repository만 접근 할 수 있는 것을 확인 할 수 있다.


4. 라이브러리 업로드

settings.xml 설정

nexus 레파지토리에 접근하기 위해서는 우선, maven 설정 파일인 settings.xml을 수정해야야 한다. 기본적으로는 ~/.m2/settings.xml 으로 설정되어 있으므로 이 곳에 생성해서 사용하거나, 이미 settings.xml을 사용하고 있다면 그 파일을 수정해주면 되는데.. 이도 저도 모르겠으면 아래 이미지 처럼 인텔리제이에서 확인하여 그 위치의 settings.xml을 수정해준다.


1
2
3
4
5
6
7
8
9
10
11
12
13
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
http://maven.apache.org/xsd/settings-1.0.0.xsd">

<servers>
<server>
<id>junggu-demo-repository</id>
<username>junggu-demo-user</username>
<password>qweqwe</password>
</server>
</servers>
</settings>
  • id : 레파지토리 서버의 ID (자유롭게 지정)
  • username : 레파지토리 접근을 위해 생성했던 nexus access 계정 Id
  • password: 레파지토리 접근을 위해 생성했던 nexus access 계정 password

코드 작성

모듈화할 코드를 작성하면 되는데, 우리는 도메인 영역을 모듈화 할 것이므로 Entity 클래스를 생성해서 테스트 해본다.


POM.xml 설정

코드 작성을 완료했으면 이제 해당 프로젝트를 nexus repository에 업로드 하기 위해 pom.xml에 아래 처럼 설정을 추가해줘야한다.

1
2
3
4
5
6
7
<distributionManagement>
<repository>
<id>junggu-demo-repository</id>
<name>junggu nexus repository~ name is free</name>
<url>http://localhost:8081/repository/junggu-nexus-demo/</url>
</repository>
</distributionManagement>
  • id : settings.xml에서 설정했던 id와 동일해야함.
  • name : 사람이 식별 할 수 있게 설정.
  • url : 생성한 repository의 url

  • <distributionManagement> 와 <repository>대한 자세한 설명은 이 쪽으로

배포하기

1
mvn clean compile deploy
  • 위 명령어로 deploy 하거나 혹은 Intellij에서 deploy를 실행시켜도 된다.


  • 무사히 deploy가 성공했다면 nexus에서 jar 파일이 정상적으로 업로드된 것을 확인 할 수 있고, 이 후엔 이 라이브러리화된 jar파일을 다른 프로젝트들에서 dependency에 추가해서 사용하면 된다.


업로드된 라이브러리 사용하기

업로드된 라이브러리를 사용하기 위해서 사용하고자 하는 프로젝트의 pom.xml에 아래와 같은 설정을 추가해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
...


<!-- 모듈화되서 업로드된 도메인 프로젝트 -->
<dependency>
<groupId>com.junggu.ji</groupId>
<artifactId>demo</artifactId>
<version>0.0.1</version>
</dependency>

...


<!-- 라이브러리가 업로드된 Nexus Repository 정보 -->
<repositories>
<repository>
<id>junggu-demo-repository</id>
<name>junggu nexus repository</name>
<url>http://localhost:8081/repository/junggu-nexus-demo/</url>
</repository>
</repositories>
  • 우선 라이브러리가 업로드된 nexus repository의 정보를 입력하고
  • 우리가 업로드한 라이브러리 정보도 dependency에 추가해준다.
  • 이 때, 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들을 무슨 버전으로 지정해야 할 지 모르겠다면 여기서 확인 후 추가해주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<plugins>

...


<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>11</source>
<target>11</target>
<compilerArgument>-Xlint:all</compilerArgument>
</configuration>
</plugin>

...

<plugins>
  • 이제 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: class com.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;
}

적당히 작성한 테스트 코드와 성공한 모습
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource("classpath:application-test.properties")
@Import(DatabaseConfig.class)
@DataJpaTest
public class NexusTest {

@Autowired
private JungguRepository jungguRepository;

private final String name = "지중구";
private final int age = 30;
private final Gender gender = Gender.MALE;

private Junggu junggu = null;

@BeforeEach
void setUp() {

junggu = Junggu.builder()
.name(name)
.age(age)
.gender(gender)
.build();
}

@Test
void save() {
Junggu when = jungguRepository.save(junggu);

assertThat(when.getName()).isEqualTo(junggu.getName());
assertThat(when.getAge()).isEqualTo(junggu.getAge());
assertThat(when.getGender()).isEqualTo(junggu.getGender());
}
}


결론

이렇게 우리는 여러 프로젝트에서 공통으로 사용되는 부분을 따로 빼서 모듈화 하는 방법을 알아보았다.

필자는 단순히 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
public class BeanWithCreator {
public int id;
public String name;

@JsonCreator
public BeanWithCreator(
@JsonProperty("id") int id,
@JsonProperty("theName") String name) {
this.id = id;
this.name = name;
}
}

개인적으로 이렇게 쓸 일이 몇 번이나 있을까 싶긴하지만…


전체 코드

참고 사이트

댓글 공유

서론

이전에 작성한 글 중에 @Requestbody Annotation 사용 시 기본 생성자 필요 라는
글을 올린 적이 있는데 글의 마지막에 보면

@Requestbody가 jackson 라이브러리를 이용해 매핑하기 때문이다.

JG.JI@Requestbody Annotation 사용 시 기본 생성자 필요

라는 결론으로 글을 마무리 하였는데, 김영한님의 강의에서 해당 내용과 관련해서 자세하게 알려주시는 부분이 있어, 해당 내용을 정리해본다.


본론

우선 Spring에서는 Controller에서 @RequestBody로 넘어오는 파라미터들에 대해서 내부적으로 ObjectMapper를 이용해 JSON 데이터를 우리가 만든 DTO 객체로 변환시켜주고,
@ResponseBody인 경우엔, DTO 객체를 JSON 데이터로 변환해서 클라이언트에 넘겨준다.

이렇게해서 해소 될 궁금증이었으면 이 글을 읽으러 오지 않았을테니, 해당 내용에 대하여 코드 레벨에서 자세히 알아보기로 한다.

우선, Spring MVC의 동작 순서를 간략하게 서술하자면(구글에 검색하면 훌륭한 분들이 작성하신 훌륭한 글들이 많으므로)

  1. 클라이언트에서 요청이 넘어오면 FrontController 역할을 수행하는 DispatcherServlet에서 먼저 확인해서,
  2. 핸들러 매핑 리스트 중 해당 요청을 처리 할 수 있는 핸들러를 가져오고,
  3. 핸들러 어댑터 리스트 중에서 해당 핸들러를 처리 할 수 있는 핸들러 어댑터를 가져와서,
  4. 해당 어댑터에서 우리가 만든 컨트롤러에 호출해서 비즈니스 로직을 처리 한 후,
  5. 리턴되는 데이터에 맞게 뷰 리졸버나 메시지컨버터가 동작하고,
  6. 알맞게 클라이언트로 반환된다.

여기서, @RequestMapping에 대한 request는 RequestMappingHandlerAdapter에서 처리하는데
이 RequestMappingHandlerAdapter는
1. byte[]를 처리하기 위한 ByteArrayHttpMessageConverter
2. String을 처리하기 위한 StringHttpMessageConverter
3. json을 처리하기 위한 MappingJackson2HttpMessageConverter
등을 가지고 있다.

RequestMappingHandlerAdapter에 설정된 MessageConverter들

RequestMappingHandlerAdapter에 설정된 MessageConverter들

그리고 자신이 가지고 있는 ArgumentResolver들 중
@RequestBody형식으로 들어오는 파라미터를 처리하기 위해 RequestResponseBodyMethodProcessor 라는 ArgumentResolver를 호출한다.

RequestMappingHandlerAdapter에 설정된 ArgumentResolver들


이 RequestResponseBodyMethodProcessor의 readWithMessageConverters 메서드에서 메시지 컨버터 리스트를 loop 돌면서
대상 클래스 타입과 미디어 타입 등을 체크해서 알맞은 메시지 컨버터를 호출해서 사용하는데
이 때 호출되는 MessageConverter가 MappingJackson2HttpMessageConverter라는 MessageConverter이다.

요청을 처리 할 수 있는 MessageConverter 호출

  1. MessageConverter의 canRead() 메서드로 해당 요청을 처리 할 수 있는 컨버터 인지 확인.
  2. 처리 할 수 있으면 read() 메서드로 메시지에서 객체를 읽고 반환한다.

MappingJackson2HttpMessageConverter에서는 최종적으로 readJavaType 메서드가 호출되는데 이 때 objectMapper를 사용해서 json 데이터를 DTO 객체로 변환하는 것을 볼 수 있다.

ObjectMapper를 이용해서 JSON데이터를 변환하는 부분


결론

보이지 않는 곳에서 이렇게 많은 부분을 스프링에서 처리해주고 있었기 때문에 우리는 Controller에서 편하게 파라미터를 사용 할 수 있고, JSON 데이터도 바로 객체로 받을 수 있는 것이었다.
이런 내용을 알아도, 몰라도 결국 같은 방식으로 개발 하겠지만… 언젠가 도움이 되겠거니 생각하고 지금은 궁금증을 해결 했다는 것에 대해 만족한다.
이 글을 읽는 다른 분들도 필자와 같은 궁금증이 있었다면 조금이나마 해소가 됐길 바라며..


참고 사이트

댓글 공유

서론

오늘도 어김없이 영한님의 스프링 MVC 강의를 듣고 있었는데, html form 데이터를 body에 보낼 때는 post 방식 밖에 안된다는 말씀을 하시기에…
‘그럼 GET 방식도 안된다는건가?’ 라는 궁금증이 들어서 직접 확인해본 결과에 대해 작성한다.


본론

우선… 기본적으로 지금 필요한 GET방식과 POST방식의 차이점만을 다시 상기해보자면, GET방식은 데이터가 URL의 query string으로 들어간다는 것과,
POST방식은 데이터가 http message-body에 들어간다는 차이만 알아두면 될 것 같다. (다른 많은 차이가 있지만)

그렇기 때문에 우리가 html form 태그 에서 method attribute에 GET라고 작성한 후 데이터를 submit하면 데이터는 query string에 담겨 서버로 전송되고
POST라고 작성한 후 데이터를 submit하면 데이터는 message-body에 담겨서 서버로 전성되고 이 때 content-type은 application/x-www-form-urlencoded로 전송된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form action="/request-param" method="post">
username: <input type="text" name="username" />
age: <input type="text" name="age" />
<button type="submit">전송</button>
</form>
</body>
</html>

그럼 method는 GET이면서 content-type은 application/x-www-form-urlencoded 일 경우엔 어떻게 될까?

postman으로 아래처럼 실행해본 결과 보는 것과 같이 400 error가 응답되며 요청에 실패하였다.

어째서 이런 일이 발생하였나를 코드 레벨에서 확인해보니…

tomcat의 Request class에 있는 parseParameters()라는 메서드에서 넘어온 데이터를 parsing 하는데,
이 때 http method가 post인지 확인하고 post가 아니라면 message-body의 데이터를 파싱 할 수 없게 되어 있는 것을 확인했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
if( !getConnector().isParseBodyMethod(getMethod()) ) {
success = true;
return;
}

...


// 이 아래부턴 Connector.java

protected boolean isParseBodyMethod(String method) {
return parseBodyMethodsSet.contains(method);
}

...

if (null == parseBodyMethodsSet) {
setParseBodyMethods(getParseBodyMethods());
}

...

/**
* Set list of HTTP methods which should allow body parameter
* parsing. This defaults to <code>POST</code>.
*
* @param methods Comma separated list of HTTP method names
*/
public void setParseBodyMethods(String methods) {

HashSet<String> methodSet = new HashSet<>();

if (null != methods) {
methodSet.addAll(Arrays.asList(methods.split("\\s*,\\s*")));
}

if (methodSet.contains("TRACE")) {
throw new IllegalArgumentException(sm.getString("coyoteConnector.parseBodyMethodNoTrace"));
}

this.parseBodyMethods = methods;
this.parseBodyMethodsSet = methodSet;
setProperty("parseBodyMethods", methods);
}

...

/**
* @return the HTTP methods which will support body parameters parsing
*/
public String getParseBodyMethods() {
return this.parseBodyMethods;
}

...


/**
* Comma-separated list of HTTP methods that will be parsed according
* to POST-style rules for application/x-www-form-urlencoded request bodies.
*/
protected String parseBodyMethods = "POST";

위에 정리한 것 처럼

  1. isParseBodyMethod()에서 지금 request의 method가 body를 파싱 할 수 있는 메서드 인지 확인하는데, 이 때 가능한 method들은 인스턴스 변수 parseBodyMethodsSet에 저장되어 있고,
  2. 인스턴스 변수 parseBodyMethodsSet는 setParseBodyMethods() 메서드에서 저장되고 있고~
  3. parseBodyMethodsSet가 null인 경우에
  4. setParseBodyMethods(getParseBodyMethods()) 메서드를 실행해서 이 때 method를 저장 시키는데,
  5. getParseBodyMethods() 메서드를 호출하면
  6. 인스턴스변수 parseBodyMethods를 return 하는데
  7. 이 때 이 인스턴스 변수 parseBodyMethods에는 이미 “POST”로 method가 지정되어 있기 때문에
  8. GET방식 일 경우에는 message-body를 파싱 할 수 있는 method가 아닌 것으로 처리 되므로~
  9. 파싱 할 수 없다는 에러 메시지를 출력하면서 프로세스가 끝이난다.

결론

생각해보면… content-type은 message-body이 어떤 형식의 데이터로 담겨 있는가를 알려주는 일종의 os의 파일 확장자같은 개념인데
당연히 get방식의 경우 message-body에 데이터가 담기는 것이 아니니 get방식으로 content-type만 application/x-www-form-urlencoded로 한다고 될 턱이 없는데
강의를 듣기만 했을 때는 왜 생각하지 못했는 지 모르겠다.. 머리가 나쁘면 몸이 고생이라더니 옛말에 틀린 말이 없다.


참고 사이트

댓글 공유

서론

우선 사과의 말씀부터 올린다. 제목은 ‘spring에서~’ 지만 정확히는 tomcat에서 ~ 로 보는 것이 맞다.

인프런에서 김영한 님의 Spring MVC 강의를 보던 도중 … ‘http request를 정확히 어떠한 방식으로 파싱하고 있을까?’라는 궁금증이 생겨서 강의를 듣다가 말고 삽질을 한 내용을 기록해둔다.


본론

우선, 처음으로 궁금증을 해결하기 위해 들어가본 곳은 http request 요청을 받기 위한 HttpServletRequest interface의 tomcat 구현체인 RequestFacade class부터 시작했다.

이 중 getHeader 메서드에 대해 찾아보면,

Returns the value of the specified request header as a String.
If the request did not include a header of the specified name, this method returns null.
If there are multiple headers with the same name, this method returns the first head in the request.
The header name is case insensitive.
You can use this method with any request header.

라고 설명되어 있고… 이 때 코드에서 request의 header를 살펴보면

이미 header에 정보들이 저장되어 있는 상태

이미지와 같이 이미 header에 대한 정보들이 각각 저정되어 있는 걸 볼 수 있다.

필자가 궁금했던 것은 ‘이 request에 header는 언제 저장되는가?’ 였으므로
막연하게 ‘여기서 더 타고 올라가면 찾아 낼 수 있겠구나..’ 라고 안일한(?) 생각을 하고 안으로 더 들어가보았다.

타고타고 가다보니 …
http 1.1 header에 의한 요청이므로 Http11Processor의 service 메서드에서

1
2
3
4
5
6
7
8
// Don't parse headers for HTTP/0.9
if (!http09 && !inputBuffer.parseHeaders()) {
// We've read part of the request, don't recycle it
// instead associate it with the socket
openSocket = true;
readComplete = false;
break;
}

처럼 작성되어 있는 코드를 발견했고, 여기에 있는 inputBuffer.parseHeaders() 을 보니
이름부터 여기서 http request에 대해 파싱을 할 것 같아 이 안으로 들어가보았다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
while (headerParsePos == HeaderParsePosition.HEADER_NAME) {

// Read new bytes if needed
if (byteBuffer.position() >= byteBuffer.limit()) {
if (!fill(false)) { // parse header
return HeaderParseStatus.NEED_MORE_DATA;
}
}

int pos = byteBuffer.position();
chr = byteBuffer.get();
if (chr == Constants.COLON) {
headerParsePos = HeaderParsePosition.HEADER_VALUE_START;
headerData.headerValue = headers.addValue(byteBuffer.array(), headerData.start,
pos - headerData.start);
pos = byteBuffer.position();
// Mark the current buffer position
headerData.start = pos;
headerData.realPos = pos;
headerData.lastSignificantChar = pos;
break;
} else if (!HttpParser.isToken(chr)) {
// Non-token characters are illegal in header names
// Parsing continues so the error can be reported in context
headerData.lastSignificantChar = pos;
byteBuffer.position(byteBuffer.position() - 1);
// skipLine() will handle the error
return skipLine();
}

// chr is next byte of header name. Convert to lowercase.
if ((chr >= Constants.A) && (chr <= Constants.Z)) {
byteBuffer.put(pos, (byte) (chr - Constants.LC_OFFSET));
}
}

들어가서보니 해당 코드처럼

현재 header data의 파싱된 위치가 key, value 형식의 데이터인 header의 name 부분 이라면 계속 반복하면서 버퍼에 저장하고 COLON(:) 을 만나게 되면,

1
2
headerData.headerValue = headers.addValue(byteBuffer.array(), headerData.start,
pos - headerData.start);

코드가 실행되면서 아까 봤던 request의 MimeHeaders 타입인 header에 name으로 저장되고

value에도 위와 같은 방식으로 byte 단위로 이동하고 저장되면서 header에 value에 저장된다.


결론

서블릿이 http 통신을 위해 귀찮은 것들을 해준다는 것은 알고 있었지만
실제로 어떻게 하고 지에 대해서는 신경을 안 쓰고 있었는데 이렇게 강의를 듣다 보니 궁금해서 한번 확인해보았다.

만일 이 작업을 서블릿에서 대신 안해주지 않고 개발자가 개발할 때마다 해줘야한다고 생각하면…
머리가 어지러워지는 것 같아 이만 글을 줄인다…


참고 사이트

댓글 공유

상황

spring으로 된 프로젝트를 spring boot으로 전환 중에 spring-security-taglibs의 버전을 3.x 에서 5.5.1로 업데이트 했는데 아래와 같은 에러가 발생하여 이에 관한 내용을 정리 해둔다.


발생한 에러

1
2
3
4
5
6
7
8
9
10
11
12
13
14
org.apache.jasper.JasperException: /WEB-INF/views/layout/layout.jsp (line: [126], column: [0]) Attribute [ifAnyGranted] invalid for tag [authorize] according to TLD
at org.apache.jasper.compiler.DefaultErrorHandler.jspError(DefaultErrorHandler.java:41) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.ErrorDispatcher.dispatch(ErrorDispatcher.java:292) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.ErrorDispatcher.jspError(ErrorDispatcher.java:115) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Validator$ValidateVisitor.checkXmlAttributes(Validator.java:1287) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Validator$ValidateVisitor.visit(Validator.java:900) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Node$CustomTag.accept(Node.java:1558) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Node$Nodes.visit(Node.java:2385) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Node$Visitor.visitBody(Node.java:2437) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Node$Visitor.visit(Node.java:2443) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Node$Root.accept(Node.java:471) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Node$Nodes.visit(Node.java:2385) ~[tomcat-embed-jasper-9.0.48.jar!/:na]

...

왜 발생했을까?

This section describes all of the deprecated APIs within the spring-security-taglibs module. If you are not using the spring-security-taglibs module or have already completed this task, you can safely skip to spring-security-web.

Spring Security’s authorize JSP tag deprecated the properties ifAllGranted, ifAnyGranted, and ifNotGranted in favor of using expressions.

문서에 적혀 있는 것 처럼 spring-security-taglibs 4.x 버전 이상 부터는 ifAllGranted, ifAnyGranted, ifNotGranted 속성들을 지원하지 않기 때문에 3.x 버전에서 5 버전으로 업그레이드하면서 해당 속성들을 사용한 코드들이 있는 경우 발생하는 문제였다.

해결방법

1
2
3
4
5
6
7
8
9
10
11

<sec:authorize ifAllGranted="ROLE_ADMIN,ROLE_USER">
<p>Must have ROLE_ADMIN and ROLE_USER</p>
</sec:authorize>
<sec:authorize ifAnyGranted="ROLE_ADMIN,ROLE_USER">
<p>Must have ROLE_ADMIN or ROLE_USER</p>
</sec:authorize>
<sec:authorize ifNotGranted="ROLE_ADMIN,ROLE_USER">
<p>Must not have ROLE_ADMIN or ROLE_USER</p>
</sec:authorize>

위 처럼 되어 있을 경우

1
2
3
4
5
6
7
8
9
10
11
12


<sec:authorize access="hasRole('ROLE_ADMIN') and hasRole('ROLE_USER')">
<p>Must have ROLE_ADMIN and ROLE_USER</p>
</sec:authorize>
<sec:authorize access="hasAnyRole('ROLE_ADMIN','ROLE_USER')">
<p>Must have ROLE_ADMIN or ROLE_USER</p>
</sec:authorize>
<sec:authorize access="!hasAnyRole('ROLE_ADMIN','ROLE_USER')">
<p>Must not have ROLE_ADMIN or ROLE_USER</p>
</sec:authorize>

아래 처럼 치환해주면 정상적으로 작동하는 것을 확인 할 수 있을 것이다.


참고 사이트

댓글 공유

  • page 1 of 1

Junggu Ji

author.bio


author.job

의존관계도 있음
  • 런타임 의존관계 혹은 오브젝트 의존관계라고 함
  • 의존 오브젝트

    의존관계 주입

    의존관계 검색

    DI를 원하는 오브젝트는 먼저 자기 자신이 컨테이너가 관리하는 빈이 돼야 한다는 사실을 잊지 말자.

    의존관계 주입의 장점

    1. 코드에는 런타임 클래스에 대한 의존관계가 나타나지 않음
    2. 인터페이스를 통해 결합도가 낮은 코드가 되므로 의존관계에 있는 대상이 바뀌거나 변경되더라도 자신이 영향 받지 않음
    3. 변경을 통한 다양한 확장에는 자유로움

    1장 마무리

    스프링이란, 어떻게 오브젝트가 설계되고, 만들어지고, 어떻게 관계를 맺고 사용되는 지에 관심을 갖는 프레임워크

    댓글 공유

    관심사 분리

    • 관심이 같은 것 끼리는 하나의 객체 안으로 또는 친한 객체로 모이게하고,
      관심이 다른 것은 가능한 따로 떨어져서 서로 영향을 주지 않도록 분리하는 것

    템플릿 메서드 패턴

    • 슈퍼클래스에 기본적인 로직의 흐름을 만들고,
      그 기능의 일부를 추상 메서드나 오버라이딩 가능한 protected 메서드 등으로 만든 뒤 서브클래스에서 이런 메서드를 필요에 맞게 구현해서 사용하도록 하는 방법

    팩토리 메서드 패턴

    • 서브클래스에서 구체적인 오브젝트 생성 방법을 결정하게 하는 것

    중요한 건 디자인 패턴이 아닌 상속구조를 통해 성격이 다른 관심사항을
    분리한 코드를 만들어내고, 서로 영향을 덜 주도록 했는지를 이해하는 것


    상속을 통한 확장의 문제점

    1. 만약 다른 목적을 위해 이미 상속을 사용하고 있다면?

    2. 상속을 통한 상하위 클래스의 관계는 생각보다 밀접

    3. 서브클래스는 슈퍼클래스의 기능을 직접 사용할 수 있음
      그래서 슈퍼클래스 내부의 변경이 있을 때 모든 서브클래스를 함께 수정하거나 다시 개발해야 할 수도 있음
      반대로 그런 변화에 따른 불편을 주지 않기 위해 슈퍼클래스가 더 이상 변화하지 않도록 제약을 가해야 할지도 모름

    추상 클래스를 만들고 이를 상속한 서브클래스에서
    변화가 필요한 부분을 바꿔서 쓸수 있게 만든 이유는
    변화의 성격이 다른 것을 분리해서 서로 영향을 주지 않은 채로
    각각 필요한 시점에 독립적으로 변경할 수 있게 하기 위해서

    그렇다면 아예 클래스로 따로 때 버린다면?

    • 코드로 새로 만든 클래스에 종속되어 버림
    • 상속 했을 때 처럼 코드 수정없이 기능을 변경할 방법이 없음

    클래스를 통한 확장의 문제점

    1. 현재 a 메서드를 사용해 기능을 제공하고 있는데
      만약 a 메서드 대신 b 메서드를 사용하도록 수정 된다면
      일일이 a 메서드를 사용한 수십 수백개의 메서드를 수정해야 함

    2. 기능을 제공하는 클래스가 어떤 것인지 클라이언트가 구체적으로 알고 있어야 함
      만약 다른 클래스를 구현하면 또 클라이언트 자체를 수정해야 함

    3. 근본적인 원인은 클라이언트가 바뀔 수 있는 정보(db 커넥션 등)을 가져오는 클래스에 대해 너무 많이 알고 있기 때문

    해결

    • 두 클래스가 서로 긴밀하게 연결되지 않도록 중간에 추상적인 연결고리를 만드는 것(java의 interface)

    • 클라이언트 입장에서는 인터페이스를 상속 받는 클래스의 오브젝트라면 어떤 클래스로 만들었건
      메서드를 호출하기만 하면 약속된 오브젝트를 만들어서 돌려줄 것으로 기대함


    Interface를 통한 확장의 문제점

    1. 아직도 클래스의 생성자를 호출해서 오브젝트를 생성하는 코드가 남아있음

      • 초기에 한번 어떤 클래스의 오브젝트를 사용할지를 결정하는 생성자의 코드는 남아 있음
      • ex) conntion = new DConnectionMaker();
    2. 결국 클라이언트가 어떤 구현 클래스의 오브젝트를 이용하게 할지 결정하는 관심사가 남아 있음

    해결

    • 오브젝트와 오브젝트 사이에 관계를 설정해야함

    • 오브젝트 사이의 관계는 런타임 시에 한쪽이 다른 오브젝트의 레퍼런스를 갖고 잇는 방식으로 만들어짐(보통 알고 있는 인스턴스 생성 방식)

      • ex)conntion = new DConnectionMaker();
      • DConnectionMaker의 오브젝트의 레퍼런스를 클라이언트의 connection 변수에 넣어서 사용하게 함

    클래스 사이의 관계는 코드에 다른 클래스 이름이 나타나기 때문에 만들어지는 것

    오브젝트 사이의 관계는 코드에서는 특정한 클래스를 전혀 알지 못하더라도 해당 클래스가 구현한 인터페이스를 사용했다면, 그 클래스의 오브젝트를 인터페이스 타입을 받아서 사용 할 수 있음

    이것이 객체지향의 다형성


    개방 폐쇄 원칙

    • 클래스나 모듈은 확장에는 열려 있어야하고 변경에는 닫혀 있어야 함

    높은 응집도와 낮은 결합도

    높은 응집도

    • 하나의 모듈, 클래스가 하나의 책임 또는 관심사에만 집중되어 있음
    • 작업은 항상 전체적으로 일어나고 무엇을 변경할지 명확하며, 그것이 클라이언트의 다른 로직에 수정을 요구하지 않을 뿐더라
      기능에 영향을 주지 않음
    • 변경이 일어난 경우에 이를 검증하려고 하면 변경한 구현 클래스만 직접 테스트 해보는 것으로 충분하기 때문
    • 응집도를 높인 덕분

    낮은 결합도

    • 하나의 오브젝트가 변경이 일어날 떄에 관계를 맺고 잇는 다른 오브젝트에게 변화를 요구하는 정도
    • 하나의 변경이 발생할 때 여타 모듈과 객체로 변경에 대한 요구가 전파되지 않는 상태
    • 책임과 관심사가 다른 오브젝트 또는 모듈과는 낮은 결합도를 유지해야 함
    • 느슨하게 연결된 형태를 유지하는 것이 바람직
    • 꼭 필요한 최소한의 방법만 제공
    • 결합도가 낮아지면 대응하는 속도가 높아지고 구성이 깔금해짐

    전략패턴

    • 자신의 기능 맥락(context)에서 필요에 따라 변경이 필요한 알고리즘을 인터페스를 통해 통째로 외부로 분리시키고 이률 구현한 구체적인 알고리즘 클래스를 필요에 따라 바꿔 사용할 수 있게 하는 디자인 패턴

    댓글 공유

    Junggu Ji

    author.bio


    author.job