메모장

[spring-sagan]코드 개선하기 #renderer 모듈 분석 본문

공부/Spring-Sagan

[spring-sagan]코드 개선하기 #renderer 모듈 분석

aeomhs 2020. 3. 11. 21:45

개요

마지막 포스팅을 하고 `React.js`와 `Spring Data REST`를 사용한 튜토리얼 실습을 진행했었습니다.

약 3일간 꾸준히 따라해보면서 실습을 진행했는데요, 스프링의 심플하고 강력한 기능과 React.js의 깔끔한 컴포넌트 구조를 경험해볼 수 있었습니다. 그리고 무언가에 홀린 듯이 JavaScript 책을 읽다가 채용 공고에 정신차리고

React 공부는 미래의 저에게 맡기며 알고리즘 공부를 다시 ... 하고 있었습니다. 

 

그렇게 약 3주? 넘게 미친듯이 코딩 테스트와 알고리즘 공부에만 전념하다가 머리도 식힐 겸 다시 스프링 프로젝트를 뒤적거리는데,

포스팅이 밀렸더군요! 

 

 

오늘은 스프링 홈페이지 프로젝트 Spring sagan 애플리케이션 중

`sagan-renderer` 모듈을 분석하고 

이전 포스팅에서 발생한 중복 코드를 개선해보고자 합니다.

 

 

 

결과

눈에 보이는 결과물로 보자면, 오늘 포스팅 결과는 이전 포스팅의 결과와 동일합니다.

코드 내부의 구조를 개선해보았고, 결과물은 기존에 존재하는 모든 테스트 코드를 통과합니다.

그리고 수정한 코드에 대한 새로운 테스트 코드 또한 생성하고 통과합니다.

이번 포스팅의 최종 커밋입니다.

테스트 결과

 

과정

일단 돌아가는 내 스프링 홈페이지를 위해 발생한 중복을 제거하기 위해서 전체적인 구조를 파악합니다.

이후 중복 코드를 제거할 수 있는 방법을 제안하고, 개선합니다.

이전 테스트 코드가 통과하는지 확인합니다.

변경 사항에 대해서 테스트 코드를 작성합니다.

 

1. 가이드를 가져오는 과정과 구조 파악하기

2. GithubClient 클래스

3. Client 인터페이스 추출

4. GithubClient 분리

5. 테스트

 

 

가이드를 가져오는 과정과 구조 파악하기

웹 방문자의 요청으로 가이드 컨텐츠에 대한 렌더링을 필요로 할 경우,

sagan-site 모듈로 요청이 발생하고, sagan-renderer 모듈로 렌더링 요청을 전송합니다.

sagan-renderer 모듈 실행 결과창 혹은 `main/resources/application.yml` 파일에서 8081 포트가 할당되는 것을 확인할 수 있습니다.

 

가이드: Controller

렌더링 요청은 `GuidesController`에게 전달되고 이때부터 가이드를 Github으로부터 가져오는 작업이 시작됩니다.

컨트롤러는 간단하게 2가지 요청을 담당합니다.

 

아래 예제 코드는 몸통 구현을 모두 생략했습니다. 자세한 코드는 여기를 참고해주세요.

/**
 * API for listing guides repositories and rendering them as {@link GuideContentResource}
 */
@RestController
@RequestMapping(path = "/guides", produces = MediaTypes.HAL_JSON_VALUE)
public class GuidesController {
	...
    
	@GetMapping("/")
	public Resources<GuideResource> listGuides() {
		...
	}

	@GetMapping("/{type}/{guide}")
	public ResponseEntity<GuideResource> showGuide(@PathVariable String type, @PathVariable String guide) {
		...
	}

	@GetMapping("/{type}/{guide}/content")
	public ResponseEntity<GuideContentResource> renderGuide(@PathVariable String type, @PathVariable String guide) {
		...
	}
}

1. `/` : 가이드의 모든 리스트를 보여준다.

2. `/{type}/{guides}/`: 해당 타입의 가이드를 렌더링한다.

 

가이드: Object

또한 가이드는 2가지 형태로 제공됩니다.

1. `GuideResource` : 가이드의 추상적인 정보를 담고 있습니다. (title, repoName, url, ssl, cloneUrl ... )

2. `GuideContentResource` : 가이드의 세부 내용을 포함합니다. (name, tableOfContents, content ...)

 

즉, 가이드 리스트에 대한 정보를 필요로 할 경우 `GuideResouce` 형태로 모든 가이드 정보가 제공됩니다.

하나의 가이드 상세 페이지에 접근할 경우 추가적으로 `GuideContentResouce` 형태의 가이드가 제공됩니다.

 

가이드: Client

이 과정에서 `sagan-renderer` 모듈은 `GithubClient` 객체를 통해 Github Repository로부터

가이드 정보를 `Fetch` 하거나 `Download` 합니다.

 

`Fetch` :  Github REST API로부터 Github Repository 정보를 요청하고 응답 데이터를 `Repository` 객체에 매핑하여 반환합니다.

`Download` : Github REST API로부터 Github Repository 데이터를 `zip` 파일로 다운로드하여 byte[] 형태로 반환합니다.

 

`Repository` 객체에 담긴 정보는 `GuideResource` 객체에 매핑되며, 

다운로드한 `byte[]` 파일은 `GuideRenderer` 객체에 의해 `GuideContentResource` 객체에 매핑됩니다.

 

 

GithubClient 클래스

과정과 구조를 파악해보니,

Github Repository로부터 가이드에 대한 정보를 가져오는 객체 `GithubClient`는

정해진 URL(Github REST API)에 대하여 `Fetch`, `Download` 기능을 수행하는 것으로 요약할 수 있습니다.

 

지금 수정된 코드의 `GithubClient` 클래스를 요약하면 다음과 같습니다.

private static final String REPOS_LIST_PATH = "/orgs/%s/repos?per_page=100";
private static final String USER_REPOS_LIST_PATH = "/%s/%s/repos?per_page=100";

private static final String REPO_INFO_PATH = "/repos/{organization}/{repositoryName}";
private static final String USER_REPO_INFO_PATH = "/repos/{userName}/{repositoryName}"

private static final String REPO_ZIPBALL_PATH = REPO_INFO_PATH + "/zipball";
private static final String USER_REPO_ZIPBALL_PATH = USER_REPO_INFO_PATH + "/zipball";



downloadRepositoryAsZipball() ...

downloadUsersRepositoryAsZipball() ...

fetchOrgRepositories() ...

fetchUsersRepositories() ...

fetchOrgRepository() ...

fetchUserRepository() ...

 

 

Client 인터페이스 추출

User Repositories로부터 가이드를 가져오기 위한 메서드를 추가하기 이전과 이후를 보면,

Client라는 객체가 User 혹은 Organization 영역으로부터 Repository를 Fetch 혹은 Download하는 것을 볼 수 있습니다.

이에 대하여 `Client`라는 인터페이스를 추출하고 공통 행위를 정의했습니다.

public interface Client {

  byte[] downloadRepositoryAsZipball(String repositoryOwner, String repository);

  List<Repository> fetchRepositories(String repositoryOwner);

  Repository fetchRepository(String repositoryOwner, String repositoryName);
  
}

 

 

GithubClient 분리

이제 `GithubClient` 클래스는 `Client` 인터페이스를 구현합니다.

하지만 User와 Organization에 대한 분리가 이루어지지 않았기 때문에, 두 영역에 대한 Client를 분리하고자 합니다.

공통적인 행위와 속성은 `GithubClient` 클래스에 남기고

public abstract class GithubClient implements Client {

	public static final String API_URL_BASE = "https://api.github.com";

	private static final Pattern NEXT_LINK_PATTERN = Pattern.compile(".*<([^>]*)>;\\s*rel=\"next\".*");

	...

	protected Optional<String> findNextPageLink(ResponseEntity response) {
		...
	}

	protected static class GithubAppTokenInterceptor implements ClientHttpRequestInterceptor {
		...

	}

	protected static class GithubAcceptInterceptor implements ClientHttpRequestInterceptor {
		...
	}

}

 

`UserGithubClient` 클래스와 `OrgGithubClient`클래스로 확장합니다.

두 클래스는 자신의 영역에 알맞은 Github REST API 요청 URL을 가지며,

`Client` 인터페이스에서 정의한 행위를 구현합니다.

@Component
public class OrgGithubClient extends GithubClient {

    private static final String REPOS_LIST_PATH = "/orgs/%s/repos?per_page=100";

    private static final String REPO_INFO_PATH = "/repos/{organization}/{repositoryName}";

    private static final String REPO_ZIPBALL_PATH = REPO_INFO_PATH + "/zipball";

    public OrgGithubClient(RestTemplateBuilder restTemplateBuilder,
                        RendererProperties properties) {
        super(restTemplateBuilder, properties);
    }

    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);
        }
    }

    public List<Repository> fetchRepositories(String organization) {
        List<Repository> repositories = new ArrayList<>();
        Optional<String> nextPage = Optional.of(String.format(REPOS_LIST_PATH, organization));
        while (nextPage.isPresent()) {
            ResponseEntity<Repository[]> page = this.restTemplate
                    .getForEntity(nextPage.get(), Repository[].class, organization);
            repositories.addAll(Arrays.asList(page.getBody()));
            nextPage = findNextPageLink(page);
        }
        return repositories;
    }

    public Repository fetchRepository(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);
        }
    }

}

 

@Component
public class UserGithubClient extends GithubClient {

    private static final String REQ_TYPE = "users";

    private static final String USER_REPOS_LIST_PATH = "/%s/%s/repos?per_page=100";

    private static final String USER_REPO_INFO_PATH = "/repos/{userName}/{repositoryName}";

    private static final String USER_REPO_ZIPBALL_PATH = USER_REPO_INFO_PATH + "/zipball";

    public UserGithubClient(RestTemplateBuilder restTemplateBuilder,
                        RendererProperties properties) {
        super(restTemplateBuilder, properties);
    }

    public byte[] downloadRepositoryAsZipball(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 List<Repository> fetchRepositories(String userName) {
        List<Repository> repositories = new ArrayList<>();
        Optional<String> nextPage = Optional.of(String.format(USER_REPOS_LIST_PATH, REQ_TYPE, userName));
        while (nextPage.isPresent()) {
            ResponseEntity<Repository[]> page = this.restTemplate
                    .getForEntity(nextPage.get(), Repository[].class, REQ_TYPE, userName);
            repositories.addAll(Arrays.asList(page.getBody()));
            nextPage = findNextPageLink(page);
        }
        return repositories;
    }

    public Repository fetchRepository(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` 객체를 사용하는 `GuideRenderer`와 `GuidesController`를 수정합니다.

그리고 관련된 테스트 코드를 모두 수정하고, 통과를 확인합니다.

 

전체적으로 보면 없던 기능을 추가한 것이 아니라,

기존에 있던 Client 코드를 복사하고,

Github REST API 요청에 대한 URL path를 추가했습니다.

 

기존 테스트 코드를 분석해보면,

Client와 관련된 테스트 코드는 MockServer(Github REST API 역할)에게 요청을 전송하고

미리 저장해둔 실제 응답과 일치하는 json 파일을 반환합니다.

즉, Github REST API 요청 URL이 맞는지 테스트하는 것과 동일한 역할을 하고 있습니다.

간단하게 하나의 테스트 코드를 보자면, 다음과 같습니다.

// 2번코드로부터 올바른 헤더와 요청을 받을 경우 "gs-rest-service.json"을 반환한다.
1: this.server.expect(requestTo(expectedUrl))		
				.andExpect(header(HttpHeaders.AUTHORIZATION, authorization))
				.andExpect(header(HttpHeaders.ACCEPT, GITHUB_PREVIEW.toString()))
				.andRespond(withSuccess(getClassPathResource("gs-rest-service.json"), GITHUB_PREVIEW));
2: Repository repository = this.client.fetchRepository(org, repo);

// client 객체가 올바르게 repository 데이터를 가공하는지 확인한다.
3: assertThat(repository).extracting("name").containsOnly("gs-rest-service");

 

당시에 개선 작업 이후,

User 영역에 대한 요청과 Organization 영역에 대한 요청이 크게 다르지 않다는 이유와

명령어창을 통한 수 차례의 확인으로 테스트 코드를 추가하지 않았습니다.

 

그런데 지금 포스팅을 하면서 당연히 추가해야할 코드가 생각났고, 

늦었지만... 바로 테스트 코드를 추가했습니다. ㅠㅠ

`UserGithubClient`의 모든 기능에 대한 것과

`GuidesController`에서 User 영역으로부터 가이드를 가져오기까지의 과정을 추가했습니다.

public void fetchGuideFromUserRepositories() throws Exception {
	Repository restService = new Repository(12L, "gs-rest-service",
			"aeomhs/gs-rest-service",
            "REST service sample :: Building a REST service :: spring-boot,spring-framework",
			"http://example.org/aeomhs/gs-rest-service",
			"git://example.org/aeomhs/gs-rest-service.git",
			"git@example.org:aeomhs/gs-rest-service.git",
			"https://example.org/aeomhs/gs-rest-service.git",
			Arrays.asList("spring-boot", "spring-framework"));

	// 컨트롤러는 orgGithubClient를 통해 먼저 가이드를 Fetch 한다.
	given(this.orgGithubClient.fetchRepository("spring-guides", "gs-rest-service"))
			.willThrow(new GithubResourceNotFoundException("spring-guides", "gs-rest-service", new HttpClientErrorException(HttpStatus.NOT_FOUND)));
	// 예외가 발생하면 userGithubClient를 통해 가이드를 Fetch 한다.
	given(this.userGithubClient.fetchRepository("aeomhs", "gs-rest-service"))
			.willReturn(restService);

	this.mvc.perform(get("/guides/getting-started/rest-service"))
			.andExpect(status().isOk())
			.andExpect(jsonPath("$.name").value("rest-service"))
			.andExpect(jsonPath("$.repositoryName").value("aeomhs/gs-rest-service"))
			.andExpect(jsonPath("$.title").value("REST service sample"))
			.andExpect(jsonPath("$.description").value("Building a REST service"))
			.andExpect(jsonPath("$.type").value("getting-started"))
			.andExpect(jsonPath("$.githubUrl").value("http://example.org/aeomhs/gs-rest-service"))
			.andExpect(jsonPath("$.gitUrl").value("git://example.org/aeomhs/gs-rest-service.git"))
			.andExpect(jsonPath("$.sshUrl").value("git@example.org:aeomhs/gs-rest-service.git"))
			.andExpect(jsonPath("$.cloneUrl").value("https://example.org/aeomhs/gs-rest-service.git"))
			.andExpect(jsonPath("$.projects[0]").value("spring-boot"))
			.andExpect(hasLink("self", "http://localhost/guides/getting-started/rest-service"));
}

 

 

결론

Github Repositories로부터 Repository에 대한 데이터를 `Fetch` 혹은 `Download`하는 `GithubClient`를 수정해보았습니다.

원하는 기능을 추가했고, 이로 인해 발생한 중복 코드를 개선했습니다.

그리고 기존 테스트 코드는 여전히 작동하며 새로운 테스트 코드도 작성하여 통과시켰습니다.

하지만 아직 고쳐야할 문제가 많이 존재합니다.

 

첫 번째 문제는 이전 포스팅의 결과와 마찬가지로

Organization 영역으로부터 가이드를 가져오는 것이 실패했을때,

User 영역으로부터 가이드를 가져오도록 합니다.

이러한 로직은 예외에 의한 분기로 처리되며, 

이를 수정하기 위해 정말 많은 고민을 했었습니다.

그리고 결국 수정했습니다.

 

두 번째 문제는 첫 번째 문제에서 파생되는 문제로

User 영역에 존재하는 가이드 이름이

Organization 영역안에 존재할 경우,

User 영역에서는 가져오지 못하는 것입니다.

사실 이는 큰 문제가 되지는 않았습니다.

(구차해보이지만 Github Repository 이름을 수정해주면 해결되는 문제였습니다.)

이 문제는 첫 번째 문제가 해결되면서 자연스럽게 해결되었습니다.

 

다음 포스팅은 많은 고민 끝에

`sagan-site` 모듈을 수정하고

User 영역을 위한 가이드 타입을 새로 정의하고

위에서 언급한 두 문제점을 해결하기까지의 과정으로 이어갈 예정입니다.

Comments