API를 구현해보자!

maketheworldwise·2022년 5월 15일
0


개요

솔직하게 말하자면 내가 게으른 부분도 있지만, 공부하는 것에 더 초점을 더 맞춰 시간을 보내다보니 프로젝트에 집중을 하지 못했다. (RealMySQL 넘나 어려운것...😭) 기능 하나만이라도 제대로 구현해보고자 했고, 그 과정에서 발생한 문제들을 정리해보자.

입점 신청에 필요한 클래스

우선 내가 구현한 기능은 입점 신청 API다. 기능 구현에 필요한 클래스는 다음과 같다.

  • Store Entity
  • Store Repository
  • Store Service
  • Store Service Implementation
  • Store Controller

구조 디자인

처음에는 이전에 정리한 내용처럼 도메인형 패키지 구조로 작업을 했고, 거기에 추가적으로 Role 별로 나누어 디자인했다. Owner, Buyer, Admin 역할로 구분했고, 각 역할당 할 수 있는 일들을 한 곳에서 관리할 수 있도록 구성했다.

└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── yousinsa
    │   │           ├── global
    │   │           │   └── config
    │   │           │       └── JpaAuditingConfig.java
    │   │           ├── user
    │   │           └── store
    │   │               ├── domain
    │   │               ├── enums
    │   │               ├── exceptions
    │   │               └── v1
    │   │                   ├── owner
    │   │                   │   ├── controller
    │   │                   │   │   └── OwnerController.java
    │   │                   │   ├── converter
    │   │                   │   │   └── OwnerDtoConverter.java
    │   │                   │   ├── daos
    │   │                   │   ├── dtos
    │   │                   │   └── service
    │   │                   │       ├── UserService.java
    │   │                   │       └── UserServiceImpl.java
    │   │                   ├── buyer
    │   │                   └── admin

(생략...)

하지만 이 구조에서 - K님의 의견으로는, 회원이 가질 수 있는 Role과 패키지명이 동일하여 협업하는 사람에게 혼란을 줄 수 있을 것 같다 말씀하셨고, 이 의견을 받아들여 owner를 manager로, buyer를 purchase로 변경했다.

패키지명을 바꾸고 API 기능 개발을 시작했지만, 또 다시 리소스 기반으로 분리하는 편이 일반적이고 깔끔하다는 의견을 듣어 디자인을 재수정했다. (+ 클래스명도 함께 수정)

store
domain
enums
exceptions
v1
  controller
  	StoreController
  converter
  daos
  dtos
  service
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── yousinsa
    │   │           ├── global
    │   │           │   └── config
    │   │           │       └── JpaAuditingConfig.java
    │   │           ├── user
    │   │           └── store
    │   │               ├── domain
    │   │               ├── enums
    │   │               ├── exceptions
    │   │               └── v1
    │   │                   ├── controller
    │   │                   │   └── OwnerController.java
    │   │                   ├── converter
    │   │                   │   └── OwnerDtoConverter.java
    │   │                   ├── daos
    │   │                   ├── dtos
    │   │                   └── service
    │   │                       ├── UserService.java
    │   │                       └── UserServiceImpl.java

(생략...)

처음에 역할별로 나누었던 것도 나쁘지 않다고 생각했는데, 막상 적용하고 보니 기존보다 훨씬 더 깔끔해진 것을 확인할 수 있었다. 😂

이러한 디자인은 특정 규칙을 따라서 구성하도록 초기에 잡아두는게 좋다고 했는데, 이번에 계속 수정을 하면서 너무 공감이 되었다. 앞으로는 프로젝트 시작 전 설계 단계에서 구조와 클래스 디자인에 대해 사전에 의논해야할 것 같다.

StoreController

처음 구현한 Controller의 코드를 살펴보자.

@RequiredArgsConstructor
@RequestMapping("/api/v1")
@RestController
public class OwnerController {

	private final OwnerService ownerService;

	/**
	 * 입점 신청
	 * [POST] /store
	 *
	 * @param request 입점 신청시 필요한 데이터
	 * @param user 세션에 담긴 user 데이터
	 * @return 입점 신청 결과
	 */
	@PostMapping("/store")
	public ResponseEntity<?> entryStore(
		@RequestBody OwnerDto.Post request,
		@SessionAttribute(name = "user") UserEntity user) {

		Long id = ownerService.entryStore(request, user);
		return ResponseEntity.created(URI.create("/api/v1/store/" + id)).build();
	}
}

이 코드에 대한 리뷰는 다음과 같다.

  • entryStore는 '명사 + 명사' 조합이라 올바르지 않은 네이밍이다.
  • 리소스 URI는 일반적으로 복수형을 사용한다.
  • UserEntity를 사용하는 코드는 잠재적인 문제가 있다.

지적받은 부분들을 어떻게 해결했는지 정리해보자.

먼저, 메서드명의 경우에는 처음에 applyStoreEntry로 수정하려했으나, 일반적으로 CRUD를 많이 사용한다는 점과 더 직관적인 표현이 좋다고 하여 createStore로 수정했다.

그 다음으로 리소스 URI를 복수형으로 한다는 것은 코딩 컨벤션에 따라 달라질 수 있는 부분이라고 생각했는데, 대부분 복수형을 사용한다고 하니 대중적인 것을 따라 복수형으로 수정했다.

마지막으로 UserEntity를 수정했을 때 Controller 부분도 바꿔야 되는 잠재적인 문제는 - 당시 로그인 관련된 코드가 간단하게만 구현되어있어 Controller와 Service Layer에 넘기는 인자값으로 UserEntity 클래스를 사용할 수 밖에 없었기 때문에 발생한 문제였고, 이를 해결하기 위해서는 K님이 개선시킨 로그인 코드로 적용해야했다.

다른 브랜치에서 작업한 내용을 가져오는 부분에서 많이 헤매긴했지만, 결국 LGMT 👍 리뷰를 받을 정도의 수준으로 개선시켰다.

@RequiredArgsConstructor
@RequestMapping("/api/v1")
@RestController
public class StoreController {

	private final StoreService ownerService;

	/**
	 * 입점 신청
	 * [POST] /stores
	 *
	 * @param request 입점 신청시 필요한 데이터
	 * @param user 세션에 담긴 user 데이터
	 * @return 입점 신청 결과
	 */
	@SessionAuth
	@PostMapping("/stores")
	public ResponseEntity<?> createStore(
		@RequestBody StoreDto.Post request,
		AuthUser user) {

		Long id = ownerService.createStore(request, user);
		return ResponseEntity.created(URI.create("/api/v1/stores/" + id)).build();
	}
}

StoreService

Service에 작성된 비즈니스 로직은 실제로 회원이 존재하는지와 이미 입점된 Store인지 유효성 검사 이후 저장하도록 간단하게 구성했다.

@Primary
@RequiredArgsConstructor
@Service
public class OwnerServiceImpl implements OwnerService {

	private final OwnerDtoConverter ownerDtoConverter;
	private final StoreRepository storeRepository;

	// 입점 신청
	@Override
	public Long entryStore(OwnerDto.Post request, UserEntity user) {
		validateOwner(user);
		Store entry = ownerDtoConverter.convertOwnerRequestToEntity(request, user);
		Store store = storeRepository.save(entry);
		return store.getId();
	}

	private void validateOwner(UserEntity user) {
		boolean isPresent = storeRepository.existsByStoreOwner(user);
		if (isPresent) {
			throw new NotValidOwnerException("Already exists.");
		}
	}
}

여기서도 마찬가지로 UserEntity를 넘겨받지 않고 K님이 구현하신 로그인 기능에 맞게 수정 작업을 했다.

@Primary
@RequiredArgsConstructor
@Service
public class StoreServiceImpl implements StoreService {

	private final UserRepository userRepository;
	private final StoreDtoConverter storeDtoConverter;
	private final StoreRepository storeRepository;

	// 입점 신청
	@Override
	public Long createStore(StoreDto.Post request, AuthUser user) {

		UserEntity userEntity = validateStoreOwnerByUserId(user.getId());

		Store store = storeDtoConverter.convertOwnerRequestToEntity(request, userEntity);
		Store createdStore = storeRepository.save(store);
		return createdStore.getId();
	}

	private UserEntity validateStoreOwnerByUserId(Long userId) {
		UserEntity userEntity = userRepository.findById(userId)
			.orElseThrow(() -> new UserNotFoundException("User not found."));

		boolean isPresent = storeRepository.existsByStoreOwner(userEntity);
		if (isPresent) {
			throw new NotValidStoreException("Already exists.");
		}

		return userEntity;
	}
}

최종적으로 Approve를 받았지만, StoreServiceImpl에 UserRepository를 주입받는 것이 좋은건지에 대해 고민했다. 개인적으로 의존성이 높아진다는 점과 UserRepository를 이용한 로직들은 중복될 것 같다는 점에서 단점으로 다가왔다. 더 나아가 잘은 모르지만 많이 사용하는 MSA 아키텍처에서도 문제가 될 수 있는 부분이라고 생각했다.

Spring Security와 같은 솔루션들에 대해 이야기를 나누며 K님과 의논해보았으나, 시간적인 여유가 없어 일단 해당 부분의 우선 순위를 뒤로 미루었다. 이 부분을 어떻게 해결할 수 있는지에 대해서는 계속 고민해봐야할 것 같다. 🫠

테스트 코드

작성한 테스트 코드는 다음과 같다.

  • Store Repository Test
  • Store Service Test
  • Store Controller Test

테스트 코드 작성에는 아직 익숙하지가 않아 많은 시간을 소비했다. 유튜브 영상도 찾아보며 어떻게 구성하는지에 대해서 숙지는 했으나, 막상 내 코드에 맞게 테스트 코드를 짜는 것은 생각보다 힘들었다.

StoreRepositoryTest는 다음과 같이 구성했다. Repository 테스트 코드를 작성하면서 생긴 문제는 테스트 코드에서의 JPA Auditing 문제에서 확인할 수 있다.

@DataJpaTest
class StoreRepositoryTest {

	@Autowired
	StoreRepository storeRepository;

	UserEntity user;

	@BeforeEach
	public void setUp() {
		user = new UserEntity("test","test@test.com","test", UserRole.BUYER);
	}

	@Test
	@DisplayName("입점 신청")
	public void createStore() {
		// given
		Store createStore = Store.builder()
			.storeName("store")
			.storeOwner(user)
			.storeStatus(StoreStatus.REQUESTED)
			.build();

		// when
		Store store = storeRepository.save(createStore);

		// then
		Assertions.assertEquals(createStore.getStoreName(), store.getStoreName());
		Assertions.assertEquals(createStore.getStoreOwner().getUserName(), store.getStoreOwner().getUserName());
	}
}

다음에는 StoreServiceImplTest를 살펴보자. StoreControllerTest에서도 어려웠던 부분인데 - given 코드에서 막혔다. Mock으로 객체를 만들어 의존성을 StoreServiceImpl에 주입해주었는데, 처음에는 그것만으로도 잘 동작할거라고 생각했었다.

하지만 주입한 Mock 객체가 수행하는 일이 어떤 것인지를 정의해줘야 할 필요가 있었고, 그 부분에 대한 이해가 낮아 많이 헤맸었다. 결국 내가 이해하지 못했던 것은 BDD의 given으로 Mocking한 객체가 수행하는 역할에 대해서 정의를 해주지 못해 테스트가 통과되지 않았던 것이다.

@ExtendWith(MockitoExtension.class)
class StoreServiceImplTest {

	@Mock
	UserRepository userRepository;

	@Mock
	StoreDtoConverter storeDtoConverter;

	@Mock
	StoreRepository storeRepository;

	@InjectMocks
	StoreServiceImpl storeServiceImpl;

	UserEntity user;
	Store store;

	@BeforeEach
	public void setup() {
		user = new UserEntity("test","test@test.com","test", UserRole.BUYER);
		store = Store.builder().id(1L).storeName("store").storeOwner(user).storeStatus(StoreStatus.REQUESTED).build();
	}

	@Test
	@DisplayName("입점 신청")
	public void createStore() {
		// given
		StoreDto.Post request = new StoreDto.Post();
		request.setStoreName("store");

		given(userRepository.save(any(UserEntity.class))).willReturn(user);
		given(userRepository.findById(anyLong())).willReturn(Optional.ofNullable(user));
		given(storeDtoConverter.convertOwnerRequestToEntity(any(StoreDto.Post.class), any(UserEntity.class))).willReturn(store);
		given(storeRepository.save(any(Store.class))).willReturn(store);

		// when
		UserEntity userEntity = userRepository.save(user);
		AuthUser authUser = new AuthUser(1L, userEntity.getUserName(), userEntity.getUserEmail(), userEntity.getUserRole());

		Long result = storeServiceImpl.createStore(request, authUser);

		// then
		then(storeRepository).should().save(store);
		assertThat(result).isEqualTo(1L);
	}
}

마지막으로 StoreControllerTest를 보자. 결론적으로는 잘 구성했지만, K님이 구현하신 로그인 코드를 가져와서 작업을 할 때 문제가 발생했다. 순서대로 이야기해보자.

내가 구현한 코드에 따르면, Response로 상태 코드가 201로 넘어와야하며, 헤더에 다음으로 이동할 URI 주소가 명시된 Location이 담겨있어야했다. 하지만, 결과는 헤더에 아무런 데이터 없이 상태 코드가 200으로 전달되었다. 앞에서도 이미 정리했지만, 초기 Controller 코드와 당시 작성한 테스트 코드는 다음과 같다.

@RequiredArgsConstructor
@RequestMapping("/api/v1")
@RestController
public class OwnerController {

	private final OwnerService ownerService;

	/**
	 * 입점 신청
	 * [POST] /store
	 *
	 * @param request 입점 신청시 필요한 데이터
	 * @param user 세션에 담긴 user 데이터
	 * @return 입점 신청 결과
	 */
	@PostMapping("/store")
	public ResponseEntity<?> entryStore(
		@RequestBody OwnerDto.Post request,
		@SessionAttribute(name = "user") UserEntity user) {

		Long id = ownerService.entryStore(request, user);
		return ResponseEntity.created(URI.create("/api/v1/store/" + id)).build();
	}
}
@ActiveProfiles("test")
@WebMvcTest(OwnerController.class)
class OwnerControllerTest {

	@Autowired
	ObjectMapper objectMapper;

	@Autowired
	MockMvc mockMvc;

	@MockBean
	OwnerService ownerService;

	MockHttpSession session;

	UserEntity user;

	@BeforeEach
	public void setup() {
		user = new UserEntity("test","test@test.com","test", UserRole.BUYER);
		session = new MockHttpSession();
		session.setAttribute("user", user);
	}

	@AfterEach
	public void cleanup() {
		session.clearAttributes();
	}

	@Test
	@DisplayName("입점 신청")
	public void storeEntry() throws Exception {
		// given
		OwnerDto.Post request = new OwnerDto.Post();
		request.setStoreName("store");

		given(ownerService.entryStore(request, user)).willReturn(1L);

		// when
		String json = objectMapper.writeValueAsString(request);

		mockMvc.perform(post("/api/v1/store")
				.contentType(MediaType.APPLICATION_JSON)
				.session(session)
				.content(json))
			.andExpect(status().isCreated())
			.andDo(print());

		// then
		verify(ownerService).entryStore(refEq(request), refEq(user));
	}
}

즉, K님이 구현하신 로그인 코드를 작업중인 나의 브랜치에 가져왔을 뿐인데, 이전에 통과했던 테스트 코드가 동작하지 않았다는 것이다.

무엇이 문제인지 확인하기 위해 Controller 코드에 브레이크 포인트를 달아 디버깅을 해보았는데, 이상하게도 테스트시 내가 작성한 Controller 로직으로 이동하지 않는 것을 확인할 수 있었다.

지금와서 생각해보면 단순한 이유였다. 간단하게 말하자면, K님이 구현한 로그인 코드에서는 인터셉터를 이용해 세션에 담긴 값을 가져오도록 되어있었는데, Controller에서 Session을 처리하는 로직과 맞지 않았던 것이다.

임시적으로 테스트를 통과시키기 위해서는 두 군데를 수정해주어야 했다.

// StoreControllerTest
@BeforeEach
public void setup() {
	user = new UserEntity("test","test@test.com","test", UserRole.BUYER);
	session = new MockHttpSession();
	session.setAttribute("AuthUser", user);
}
    
// StoreController
@PostMapping("/store")
public ResponseEntity<?> entryStore(
	@RequestBody OwnerDto.Post request,
	@SessionAttribute(name = "AuthUser") UserEntity user) {

	Long id = ownerService.entryStore(request, user);
	return ResponseEntity.created(URI.create("/api/v1/store/" + id)).build();
}

계속 생각할 수록 K님이 구현하신 코드를 가져오면서 발생할 수 있는 문제를 생각하지 못했다는 점이 너무 바보같았다. 이번에는 임시로 통과하도록 하지않고, K님이 구현하신 코드에 맞게 Controller와 테스트 코드를 수정했다. Controller 수정은 위에서 이미 언급했으니 변경된 테스트 코드만 확인해보자. (+ 테스트 코드를 작성하면서 추가적으로 RestDocs 코드도 추가했다! 😎)

@ExtendWith({RestDocumentationExtension.class})
@AutoConfigureRestDocs
@WebMvcTest(StoreController.class)
class StoreControllerTest {

	@Autowired
	ObjectMapper objectMapper;

	@Autowired
	MockMvc mockMvc;

	@MockBean
	StoreService storeService;

	MockHttpSession session;

	AuthUser authUser;

	@BeforeEach
	public void setup() {
		authUser = new AuthUser(1L, "test","test@test.com", UserRole.BUYER);
		session = new MockHttpSession();
		session.setAttribute(AuthWebConfig.Session.AUTH_USER, authUser);
	}

	@AfterEach
	public void cleanup() {
		session.clearAttributes();
	}

	@Test
	@DisplayName("입점 신청")
	public void createStore() throws Exception {
		// given
		StoreDto.Post request = new StoreDto.Post();
		request.setStoreName("store");

		given(storeService.createStore(any(StoreDto.Post.class), any(AuthUser.class))).willReturn(1L);

		// when
		String json = objectMapper.writeValueAsString(request);

		ResultActions result = mockMvc
			.perform(post("/api/v1/stores")
				.contentType(MediaType.APPLICATION_JSON)
				.accept(MediaType.APPLICATION_JSON)
				.session(session)
				.content(json)
			);

		result
			.andExpect(status().isCreated())
			.andExpect(header().exists(HttpHeaders.LOCATION))
			.andDo(
				document("create-store",
					getDocumentRequest(),
					getDocumentResponse(),
					requestFields(
						fieldWithPath("storeName").type(JsonFieldType.STRING).description("입점명")
					),
					responseHeaders(
						headerWithName(HttpHeaders.LOCATION).description("입점 신청 후 이동할 URI")
					)
				)
			);

		// then
		then(storeService).should().createStore(refEq(request), refEq(authUser));
	}
}

테스트용 Profile

프로젝트에 내가 기여를 가장 크게 했다고 한다면 Profile 설정이지 않을까 싶다. local, dev, prod 환경 외에도 추가적으로 test 환경(ex. 인메모리 H2)에 필요한 Profile을 구성했고, 테스트 코드에서는 @ActiveProfile("test")로 테스트 Profile로 테스트 코드가 동작하도록 구성했다.

이에 대해 - 매번 테스트 코드에 동작할 Profile을 설정하는 것보다 테스트 환경의 application.yml 안에서 Profile을 활성화시키는것이 어떤가에 대해 리뷰를 받았다.

즉, 테스트용 Profile을 테스트 폴더의 리소스안에서 application.yml로 만들어 동일한 효과를 얻을 수 있도록 구성하자는 의견이였고, 즉시 실행에 옮겼다.

spring:
  profiles:
    default: test

  datasource:
    url: jdbc:h2:mem:testdb
    username: sa

  jpa:
    open-in-view: true
    hibernate:
      ddl-auto: create-drop
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
      use-new-id-generator-mappings: false
    show-sql: true
    properties:
      hibernate:
        format_sql: true
      dialect: org.hibernate.dialect.MySQL5InnoDBDialect

  messages:
    basename: i18n/messages/message, i18n/exceptions/exception
    encoding: UTF-8
    cache-duration: 30
    always-use-message-format: true
    use-code-as-default-message: true
    fallback-to-system-locale: true

logging:
  level:
    org:
      hibernate:
        SQL: debug
        type: trace

테스트 Profile을 옮기고 실행하는데, schemal.sql 파일에서 작성한 쿼리를 읽는 부분에서 문제가 생겼었다. 해당 스키마 파일에서는 USE DDL을 이용하여 데이터베이스를 선택했는데, 인메모리 H2에서는 해당 데이터베이스가 존재하지 않는다는 에러 문구가 출력되었다. 따라서 테스트용 Profile을 구성하는 것과 동시에 스키마 파일도 함께 수정하여 테스트 환경을 구축을 완료했다.

Git Cooperation

신경을 많이 썼던 부분중 하나가 바로 Git Commit이다. 어떻게 해야 리뷰하는 사람이 더 쉽게 리뷰를 할 수 있을지 고민을 많이 했다. K님의 의견에 따라 Commit을 Squash할 때 기준을 크게 Controller, Service, Domain으로 묶어 처리했다.

  • Controller, Controller Advice, Exceptions, Dto, Controller Test
  • Service, Service Implementation, Service Test
  • Entity, Repository, Dao, Repsitory Test
  • Etc

혹은 리뷰하기 쉽게 PR 자체를 더 작은 단위로 구성하는 것도 방안이라고도 했다. 하지만, 이미 작업한 내용이 많은 상황에서 PR을 나누기에는 이미 먼 길(?)을 와버렸기에, 위에서 말한 방법으로 커밋을 묶어 PR을 반영했다. (마음에 안들었던 부분이라고 한다면, Github PR에 모든 Commit 내역이 남겨져 더러워진 히스토리를 정리하지 못한다는 점이다.)

두 번째로는 다른 사람이 작업한 내용을 가져오는 부분에서 문제가 있었다. 나의 경우에는 K님이 구현하신 로그인 코드를 가져오는 과정이었다. 완성되어있지 않은 K님의 코드를 가져오다보니, 미완성이었던 만큼 에러가 발생하여 내가 구성하고자 하는 입점 신청 API 작업에도 영향이 갔었다.

이 경험을 통해 PR을 빠르고 쉽게 정리하여 올리지 않으면, 다른 API 작업에 딜레이가 벌어질 수 있다는 점을 인지했고, PR 서로가 의존적이지 않도록 작은 단위로 쪼개는 훈련은 지속적으로 해봐야한다는 생각이 들었다.

요약해보자

그저 API 하나만을 만드는 것임에도 신경을 써야하는 부분들이 정말 많다는 것을 알 수 있었다. 하지만 초기에 기반을 잘 다져만 둔다면, API 만드는 속도는 오를 것으로 예상된다.

앞으로 공부해야할 것들이 계속 쌓여가고 있다.

  • 패키지, 클래스 디자인, DDD
  • Git으로 협업하기 위한 전략
  • 기술 문서 작성
profile
세상을 현명하게 이끌어갈 나의 성장 일기 📓

0개의 댓글