메모장

[spring-sagan]일단 돌아가는 내 스프링 홈페이지 본문

공부/Spring-Sagan

[spring-sagan]일단 돌아가는 내 스프링 홈페이지

aeomhs 2020. 2. 14. 15:15

개요

저번 포스팅에서는 spring sagan 프로젝트를 기반으로

로컬 환경에서 spring 홈페이지를 따라해보았습니다.

과정은 아주 간단하고 쉬웠습니다.

- Github Repository를 fork 한 뒤에 로컬 환경에서 clone하고 빌드해서 실행

 

이번에는 가이드를 개인 사용자 Github 저장소로부터 가져오는 것입니다. 

가이드는 기존 스프링 가이드 프로젝트 중 하나를 fork 한 뒤에, `README.adoc` 파일을 번역합니다. 

번역된 프로젝트는 저의 Github 저장소에 존재할 것이고, 

이를 로컬 환경의 spring 홈페이지에서 확인하는 것이 이번 포스팅의 목적입니다.

 

저의 활동 기록을 목적으로 한 포스팅이기 때문에,

포스팅에서는 `어떻게 하는가`에 대한 글보다는

`왜 이렇게 생각했는가`에 대한 글이 많아서, 다소 지루할 수 있습니다.

의식의 흐름 기법...?

 

동시에, 누군가가 저와 같은 고민을 할 수도 있을 것이고,

sagan 프로젝트를 접할 수도 있을 것이라 생각합니다.

때문에 그런 분들에게 조금이라도 도움이 되고자 남기는 흔적이기도 합니다.

 

결과물을 확인하고 싶으신 분은 해당 커밋 혹은 이후의 커밋을 확인하시기 바랍니다.

가능한, 커밋 메시지에 많은 내용을 함축해서 담으려고 노력했습니다.

하지만 아직 커밋에 대한 경험이 부족해서, 좋은 내용이 담겨있을지는 모르겠습니다.

관련해서는 언제든지 피드백 혹은 질문 부탁드립니다!

 

 

결과

이번 포스팅의 결과는 해당 commit의 결과와 같습니다.

 

Github 저장소에는 `gs-` 접두어를 사용하는 `gs-local-env-test`, `gs-testing-web` 2개의 가이드가 존재합니다.

현재 `tl-` 접두어를 사용하여 가이드를 가져오는 방식을 구현하여 이름이 바뀌어 있을 예정입니다.

Github Pinned List (2개의 gs-* 프로젝트)

애플리케이션을 실행할 경우 아래과 같이 가이드 페이지에서 2개의 가이드를 확인할 수 있습니다.

http://localhost:8080/guides

 

물론 상세보기도 가능합니다.

http://localhost:8080/guides/gs/local-env-test/

 

 

과정

spring-sagan 프로젝트는 `sagan-client`, `sagan-common`, `sagan-site`, `sagan-renderer` 4개의 모듈로 구성되어있습니다. 이전 포스팅에서는 `sagan-site`, `sagan-renderer` 2개의 애플리케이션만 실행하면 스프링 홈페이지를 따라해볼 수 있었습니다. 나머지 2개의 모듈에 대한 설명은 없었지만, `sagan-site`의 `build.gradle`를 확인하면 빌드 단계에서 가장 먼저 컴파일되는 것을 확인할 수 있습니다.

 

 

# 요약

이제 더 나아가, 저만의 가이드를 로컬 환경의 스프링 홈페이지에서 확인하고자 합니다.

기존에 이러한 기능을 지원하는지, 지원하지 않는지 알아보고

어떻게 해야할지, 왜 그래야만 하는지 고민해보고

앞으로 일어날 일들을 생각해보면서 구현합니다.

 

1. WiKi 다시 읽어보기

2. 문제점 찾기

3. 원인 파악하기

4. 기능 명세하기

5. 생각하기

6. 구현하기

7. 테스트하기

 

 

 

# WiKi 다시 읽어보기

먼저 sagan 프로젝트에 대한 문서를 다시 읽어봅시다. 

이미 존재하는 기능이지만, 제가 설명서를 잘못 읽었을 수도 있고,

지원하지 않는 기능이지만, 설명서가 최신화되지 않은 것일 수도 있습니다.

참조할 문서는 2개입니다.

sagan 프로젝트 위키와 guides 조직(Github Organization)의 getting-started 프로젝트 위키입니다.

 

sagan 프로젝트 위키는 말 그대로 스프링 홈페이지 애플리케이션에 대해서 전반적인 설명이 되어있습니다.

로컬 환경에서 구축하는 방법, Cloud 플랫폼에서 구축하는 방법, 다양한 운영 방법 등이 있습니다.

그리고 로컬 환경에서 구축하는 방법이 이전 포스팅 내용과 거의 동일합니다.

 

Getting-started-guides 프로젝트가이드에 초점이 맞추어져있습니다.

새로운 가이드를 제안하고, 관련된 이슈를 등록하는 방법, 

로컬 환경에서 자신의 가이드를 작성하고 테스트하는 방법 등이 존재합니다.

이 프로젝트 위키를 조금 더 자세히 볼 필요가 있겠네요.

 

1. Run to test your own content

당신의 가이드를 작성하기 위해서, 먼저 로컬 환경에서 테스트를 진행합니다.
...
  3. Sagan 설정을 변경하여 당신의 gs- repository를 사용합니다.
  ...
  6. http://localhost:8080/guides 에 방문합니다.
...
이 과정은 당신의 개발 환경에서 sagan 애플리케이션을 체크 아웃하고 실행합니다.
당신의 GitHub 저장소에 `gs-*`로 시작하는 모든 프로젝트는 Guides 리스트에 나타날 것입니다.
...

 

현 상황에 필요한 내용만 번역했습니다. 내용을 보아하니, 몇 가지 설정과 함께

프로젝트 이름 `gs-` 접두어 규칙만 지키면, 쉽게 해결할 수 있을 것 같습니다.

sagan 설정을 변경하는 방법을 알아봅시다.

 

2. Testing edits to your guide and macros

...
Example 1. sagan-site/src/main/resources/application.yml
=========================================================================
...
  guides:
    owner:
      name: ${GITHUB_GUIDES_OWNER_NAME:/*your githubusername*/}
      type: ${GITHUB_GUIDES_OWNER_TYPE:users} 
...
=========================================================================
수정하고 sagan 애플리케이션을 실행해서 페이지에 방문하면, 
입력한 github username에 해당하는 저장소로부터 가이드를 가져올 것입니다. 
이는 로컬 환경의 테스트를 수월하게 합니다.
...

 

여기까지 읽고, 저 코드를 어디에 붙여넣어야할지 열심히 고민해봤지만,

`sagan-site`에는 위와 같은 properties를 추가할만한 공간은 없습니다. (파일은 있습니다.)

길을 잃었다...

 

 

# 문제점 찾기

그렇게 한참을 고민하면서, 의심을 하기 시작합니다.

wiki에 오타가 있는 것은 아닐까? `sagan-site`가 아닌건 아닐까?

`sagan-renderer`는 무엇을 하는 녀석일까? `sagan-client`는? `sagan-common`은?

그리고 이곳저곳을 파헤치고 다닙니다.

 

`sagan-client` 모듈은 역할(기능)을 쉽게 파악할 수 있습니다.

README 파일을 보면, 프론트 엔드와 관련된 것을 알 수 있고, 

무엇보다 resources 경로에는 application.yml 파일이 없습니다. 이 친구는 범인이 아닙니다.

 

`sagan-common` 모듈은 common 이라는 네이밍이 뭔가 라이브러리를 모아둔 것 같습니다.

의심할 필요는 있었지만, `sagan-renderer` 모듈보다는 덜 수상합니다.

 

`sagan-renderer` 모듈을 보면 충분히 의심할만한 것들이 있습니다.

resources 경로에 application.yml 파일이 있고,

설명과 비슷한 설정 구조를 확인할 수 있습니다.

...
sagan:
  renderer:
    guides:
      organization: spring-guides
...

이제 이 모듈을 조금 더 자세히 봐야할 것 같습니다.

 

NOTE: 이 과정에서 구글에 검색을 해보았는데 저와 같은 고민을 하던 을 보았습니다.

결국은 properties를 추가해도 아무런 일이 일어나지 않는다는 것과,

spring-guides를 githubUserName으로 수정하면 오류가 발생한다는 것,

그리고 이후 자신이 코드를 수정해서 해결했다는 답변이 있었습니다.

하지만, 저는 조금 더 나은 방법이 있는지 고민해보기로 했습니다.

 

 

## Sagan Renderer 분석하기

이 모듈은 Spring boot를 사용하여 구현되었고, 가이드 렌더링 서비스를 제공합니다.

렌더링은 Github 저장소로부터 가이드 프로젝트를 Fetch 하고, Download해서 가공하는 기능입니다.

가공된 데이터는 `sagan-site` 모듈로 전달됩니다.

NOTE: 전달이라기보다, sagan-site 모듈의 요청에 대한 응답이라고 하는 것이 더 정확한 것 같습니다.

 

단번에 찾을 수는 없지만, `GuidesController`는 `sagan-renderer` 애플리케이션의 서비스 구조를 파악하는데 많은 도움을 줍니다.

해당 컨트롤러에서 제어하는 URL은 크게 3종류입니다.

 1. "/guides/" - `listGuides`

 2. "/guides/{type}/{guide}" - `showGuide`

 3. "/guides/{type}/{guide}/content" - `renderGuide`

뭔가 심상치않습니다. "가이드"라니.

 

그리고 `GithubClient`를 읽어보면 의심은 확신이됩니다.

이곳에는 제가 찾고 있는, 굉장히 유용한 정보가 담겨있습니다.

... String API_URL_BASE = "https://api.github.com";
... String REPO_INFO_PATH = "/repos/{organization}/{repositoryName}";

그리고 메서드 이름에서 보이는 Fetch, Download, Repository ... 단어들...

가이드 프로젝트가 `어디로부터 오는지` 여기서 결정되고 있습니다.

`sagan-renderer` 모듈이 범인입니다!

 

이제 어떻게 이 모듈의 Properties(application.yml)를 설정해야하는지 파악해봅시다.

 

 

# 원인 파악하기

스프링 프레임워크는 Configuration을 외부로부터 받아올 수 있습니다. [관련 문서]

이를 통해, 애플리케이션 구동 환경과 같은 설정을 따로 모아 관리할 수 있습니다.

잘 활용하면, 설정 변경으로 인한 잦은(혹은 반복되는) 코드 수정을 최소화할 수 있습니다.

sagan 프로젝트는 yaml 파일을 통해 해당 기능을 사용하고 있습니다.

 

application.yml 에서 설정한 properties는 `RendererProperties` 클래스에 바인딩됩니다.

하지만 해당 클래스에는 owner{name, type}라는 멤버는 그 어디에도 없습니다. (어디로 가야 하오)

 

사실 이 순간, "어라,, 여기가 아닌가?" 하는 마음에 다른 모듈을 뒤적거렸지만,

`GithubClient` 만큼 적절한 클래스를 찾지는 못했습니다.

그리고 무턱대고 properties를 추가해보았지만 당연하게도 효과는 없었습니다. 

 

즉, 아직 구현되지 않았다는 것을 알 수 있습니다.

이젠 이 문제(기능)를 어떻게 해결(구현)할 수 있을지 고민해봅시다.

 

 

# 기능 명세하기

구현되지 않은 기능이기 때문에, 먼저 기능을 명세하는 것이 목표를 세우는데 도움이 될 것 같습니다.

wiki 문서에 나와있는 것과 같이 `application.yml` 파일을 설정하면 
사용자의 Github 저장소로부터 `gs-` 접두어가 붙은 프로젝트를 가져와서 렌더링한다.

 

 

여기서(명세 이후) 바로 테스트 코드를 작성하는 것도 좋은 방법이라고 생각합니다.

테스트 주도 개발(TDD)은 추측보다 근거를 기반으로 코드를 작성하도록 도와준다고 생각합니다.

하지만, 당시에는 어디서부터 수정해야할지 고민하느라, 테스트 코드를 작성할 생각을 하지 못했습니다.

포스팅을 작성하는 지금에 와서야 그렇게 했었더라면 어땠을까하는 생각이 들면서

지금까지 미루어 둔 테스트 코드를 작성해야겠다는 생각이 듭니다.

 

 

# 생각하기

우선 기능 명세에 따르면 `RendererProperties` 클래스에 적당한 Property를 추가해야합니다.

추가하는 것은 어렵지 않으니, 추가하고 테스트 해봅니다. (Owner. name/type)

 

이제 고민해볼 것은 어떻게 `GithubClient`를 수정할 것인가 입니다.

위에서 언급한 것처럼, 가이드를 `어디로부터 가져오느냐`에 대한 설정이 해당 클래스에서 결정됩니다.

또한 이 클래스의 인스턴스를 소유하거나 사용하는 `GuidesController`와 `GuideRenderer` 클래스도 주의깊게 봐야합니다.

 

코드를 읽다보니 `spring-guides`(Github Organization)로부터 모든 프로젝트를 가져오도록 구현되어있습니다.

즉, 다른 곳에서는 가이드 프로젝트를 가져올 수 없다는 것입니다.

물론 `application.yml` 파일에 spring-guides 대신, 다른 organization name을 입력한다면,

동시에 그 저장소에 가이드 프로젝트가 존재한다면, 문제는 없을 것 같아보입니다.

 

하지만, 저는 로컬 환경에서 기존 spring 가이드를 모두 확인하고 싶고,

개인 저장소로부터 존재하는 가이드도 출력하고 싶습니다.

무엇보다도 저는 organization을 굳이 만들고 싶지 않습니다.

어떻게 해야할까요?

 

이때부터 한참을 고민해보았습니다.

더 좋은 방법, 더 세련된 방식은 없을까 

그림도 그려보고, 혼잣말도 해보고

그렇게 한참을 고민하던 끝에, 

이러다간 더 이상 진전도 없겠다는 생각이 들었습니다.

 

이러지도 저러지도 못하고 있는 저에게 밥 마틴 아저씨가 응원의 메시지를 보냈습니다.

`깨끗한 코드를 짜려면 먼저 지저분한 코드를 짠 뒤에 정리해야 한다.` [로버트.C.마틴. 클린 코드]

꼭, 정리하겠다는 다짐과 굳은 의지를 가지고 지저분한 코드를 작성하기 시작했습니다.

 

 

# 구현하기

`GithubClient` 클래스를 자세히보니 Github REST API를 사용하고 있습니다.

저도 어떻게 사용하는지 알아야하기 때문에 곧바로 찾아보았습니다. [Github API docs]

 

그리고 소스 코드를 참고하여 실제로 어떻게 응답이 오는지 확인해보았습니다.

... String API_URL_BASE = "https://api.github.com";
... String REPOS_LIST_PATH = "/orgs/{organization}/repos?per_page=100";
... String REPO_INFO_PATH = "/repos/{organization}/{repositoryName}";

 

터미널을 통해 확인합니다.

$ curl -i https://api.github.com/repos/spring-guides/gs-rest-service

 

문서를 읽고, 몇 번의 시도를 해본다면 다음과 같은 규칙이 필요함을 알 수 있습니다.

... String USER_REPOS_LIST_PATH = "/users/{userName}/repos?per_page=100";
... String USER_REPO_INFO_PATH = "/repos/{userName}/{repositoryName}";

 

이어서 `GithubClient`에 사용자 저장소로부터 프로젝트를 `Fetch, Download`하는 메서드를 모두 추가했습니다.

아래와 같이 엄청난 중복이 발생하고 있었지만, 돌아갈 것이란 확신개선될 것이라는 믿음으로 즐겁게 진행했습니다.

...
	public byte[] downloadRepositoryAsZipball(String organization, String repository) {
		try {
			byte[] response = this.restTemplate.getForObject(REPO_ZIPBALL_PATH,
					byte[].class, organization, repository);
			return response;
		}
		catch (HttpClientErrorException ex) {
			throw new GithubResourceNotFoundException(organization, ex);
		}
	}

	// TODO Refactoring duplicate code with fetchOrgRepositories (Upper Method)
	public byte[] downloadUsersRepositoryAsZipball(String userName, String repository) {
		try {
			byte[] response = this.restTemplate.getForObject(USER_REPO_ZIPBALL_PATH,
					byte[].class, userName, repository);
			return response;
		}
		catch (HttpClientErrorException ex) {
			throw new GithubResourceNotFoundException(userName, ex);
		}
	}
...
	public Repository fetchOrgRepository(String organization, String repositoryName) {
		try {
			return this.restTemplate
					.getForObject(REPO_INFO_PATH, Repository.class, organization, repositoryName);
		}
		catch (HttpClientErrorException ex) {
			throw new GithubResourceNotFoundException(organization, repositoryName, ex);
		}
	}

	// TODO Refactoring duplicate code with fetchOrgRepositories (Upper Method)
	public Repository fetchUserRepository(String userName, String repositoryName) {
		try {
			return this.restTemplate
					.getForObject(USER_REPO_INFO_PATH, Repository.class, userName, repositoryName);
		}
		catch (HttpClientErrorException ex) {
			throw new GithubResourceNotFoundException(userName, repositoryName, ex);
		}
	}
...

 

이어서 `GithubClient` 클래스의 인스턴스를 사용하는 `GuidesController`와 `GuideRenderer` 또한 수정했습니다.

 

`GuideRenderer` 클래스는 `GithubClient`를 통해 프로젝트를 zip 파일로 받은 뒤,

`GuideContentResource` 형태로 렌더링합니다. 하나의 메서드 크기가 꽤 컸는데, 역시나 2배로 늘어났습니다.

 

`GuidesController` 클래스는 조금 더 보기 힘든 코드로 수정되었습니다.

모든 가이드 리스트를 보여주는 listGuides()의 경우 사용자 저장소를 추가하는데 큰 문제가 없었지만,

특정 가이드에 대한 정보를 보여주는 showGuide(), renderGuide()의 경우는 조금 다른 특징을 가지고 있었습니다.

`GithubClient`를 통해 `spring-guides`로부터 프로젝트를 fetch 한 뒤,

해당 이름의 프로젝트가 없을 경우 `GithubResourceNotFoundException`를 던집니다.

즉, 어디로부터 오는지 미리 정해져있지 않을 경우, 해결할 수 없는 문제가 발생합니다.

 

이 문제를 해결하기 위해서는 분명 더 높은 단계의 수정이 필요할 것 같았습니다.

때문에 반드시 고치고 말겠다는 의지가 담긴 `TODO` 주석과 함께 

지금 당장은 어쩔 수 없이 예외를 통한 분기 제어를 통해 해결했습니다.

...
	@GetMapping("/{type}/{guide}")
	public ResponseEntity<GuideResource> showGuide(@PathVariable String type, @PathVariable String guide) {
		GuideType guideType = GuideType.fromSlug(type);
		if (GuideType.UNKNOWN.equals(guideType)) {
			return ResponseEntity.notFound().build();
		}
		// TODO Don't control by exception
		Repository repository;
		GuideResource guideResource;
		try {
			repository = this.githubClient.fetchOrgRepository(properties.getGuides().getOrganization(),
					guideType.getPrefix() + guide);
			guideResource = this.guideAssembler.toResource(repository);
			if (guideResource.getType().equals(GuideType.UNKNOWN)) {
				return ResponseEntity.notFound().build();
			}
		} catch (GithubResourceNotFoundException ex) {
			// user repo
			repository = this.githubClient.fetchUserRepository(properties.getGuides().getOwner().getName(),
					guideType.getPrefix() + guide);
			guideResource = this.guideAssembler.toResource(repository);
			if (guideResource.getType().equals(GuideType.UNKNOWN)) {
				return ResponseEntity.notFound().build();
			}
		}

		return ResponseEntity.ok(guideResource);
	}

	@GetMapping("/{type}/{guide}/content")
	public ResponseEntity<GuideContentResource> renderGuide(@PathVariable String type, @PathVariable String guide) {
		GuideType guideType = GuideType.fromSlug(type);
		if (GuideType.UNKNOWN.equals(guideType)) {
			return ResponseEntity.notFound().build();
		}

		// TODO Don't control by exception
		GuideContentResource guideContentResource;
		try {
			guideContentResource = this.guideRenderer.render(guideType, guide);
		} catch (GithubResourceNotFoundException ex) {
			guideContentResource = this.guideRenderer.userGuideRender(guideType, guide);
		}

		guideContentResource.add(linkTo(methodOn(GuidesController.class).renderGuide(guideType.getSlug(), guide)).withSelfRel());
		guideContentResource.add(linkTo(methodOn(GuidesController.class).showGuide(guideType.getSlug(), guide)).withRel("guide"));
		return ResponseEntity.ok(guideContentResource);
	}
...

 

 

# 테스트하기

조금 찝찝한 구석이 있지만, (다수의 중복과 예외 처리를 통한 분기 제어)

일단 원하는 결과가 나오는지 확인합니다.

 

http://localhost:8080/guides

 

http://localhost:8080/gs/local-testing-web/

추가적으로, 사용자 Github 저장소에 가이드 이름이 `gs-testing-web`일 경우,

혹은 이와같이 `spring-guides` 저장소에 같은 이름의 가이드가 존재할 경우,

오늘 포스팅의 결과에서는 `spring-guides`의 프로젝트만을 확인할 수 있습니다. 

이유는, 프로젝트를 가져오는 과정이

`spring-guides`에서 가져오는 것이 실패할 경우,

사용자 저장소에서 가져오는 것을 시도하기 때문입니다.

 

 

어쨌든, 일단 돌아가는 내 스프링 홈페이지는 구현되었습니다.

 

 

결론

많은 아쉬움과 동시에 원하는 결과를 출력해내는 기쁨을 얻을 수 있었습니다.

부족한 것은 점진적인 개선을 통해 채워나가기로 합니다.

 

이번 과정에서 크게 놓친 것이 하나 있었습니다.

테스트 코드를 작성하자고 `TODO`에 작성했지만 정작

기존에 있었던 테스트 모듈은 확인하지 않았던 것입니다.

다행이도 바로 다음 과정 도중에 이러한 실수를 알아차릴 수 있었고,

이후 단계별로, 지속적으로 테스트 모듈을 실행했습니다.

 

다음 과정은 `GithubClient` 클래스에 대한 추상화를 진행합니다.

사용자 저장소로부터 가져오는 기능을 추가(확장)하기 쉽게하기 위해서 입니다.

`Client` 인터페이스와 `GithubClient`추상 클래스를 활용한 설계는

Organization 저장소와 User 저장소에 대한 클라이언트로 확장하는 것을 유연하게 도와주었습니다.

 

하지만, 이러한 구조 변경에도 해결되지 않은 문제도 있었습니다.

조금 더 근본적인 문제를 찾고자 몇 일을 고민하기도 했습니다.

그래도 천천히 하나씩 잊혀지기 전에 기록해보고자 합니다.

 

 

참고 자료

1. Spring sagan project wiki. https://github.com/spring-io/sagan/wiki

2. Spring sagan guides wiki. https://github.com/spring-guides/getting-started-guides/wiki 

3. 로버트.C.마틴. 2013. "Clean Code". 박재호와 이해영 옮김. 서울: 인사이트. 254p.

4. Github API Docs. https://developer.github.com/v3/

5. Spring Reference Docs. https://docs.spring.io/spring-boot/docs/2.2.4.RELEASE/reference/html/spring-boot-features.html

 

Comments