저장소: https://github.com/naneun/issue-tracker
프로젝트 주제 : 이슈 관리 프로젝트
프로젝트 기간 : 6/13 ~ 7/1
팀원 :
안드로이드 앱 데모
BE 사용 기술:
Swagger API 문서
간단한 방식으로 프로젝트를 진행해서인 지 마지막 전체 공유 시간에 안드로이드 팀이 시연하기 전까지 모든 기능을 완료했다! 물론 당일 오전 내내 로그인 및 이슈 수정, 일괄 삭제 등과 같은 기능들을 클라이언트와 다시 한 번 점검하면서 발생하는 에러들을 수정하느라 정신이 없었지만.. 😅 무엇보다 3주 간 오전 회의를 하루도 빠짐 없이 참여하여 클라이언트와의 호흡을 맞춰갈 수 있었다!
다음과 같이 @BC 와 함께 기존 기획서로 요구사항을 분석하며 필요한 내용들을 작성해놨더니 오전 회의 시간에 단순히 시간을 채우는 것이 아닌 밀도 있는 소통을 할 수 있어서 좋았다. 나는 특히 안드로이드 팀에서 이슈 관련 기능을 담당한 @히데 와 대부분의 기능들이 겹쳐서 요구사항 분석 이후에도 특히 많은 의견을 나눌 수 있었다. 로그인 처리 방식 Jwt 토큰 갱신 로직, 이미지 업로드 방식 및 누락된 필드 추가 등 하루에 한 두 개씩 꾸준히 의견을 주고 받았더니 크게 놓치는 부분이 없이 진행될 수 있었다.
기존 TODO List
, Sidedish
, AirBnb
프로젝트에선 다음과 같은 순서로 프로젝트를 진행했었다.
요구사항 분석 -> 테이블 설계 -> 도메인 설계 -> 컨트롤러 클래스 작성 -> Swagger 로 API 문서 자동화 -> 레포지토리 클래스 작성 -> 서비스 클래스 작성 -> 예외 처리 및 유효성 검사 -> 테스트 코드 작성
세 번의 프로젝트 모두 같은 방식으로 진행하다보니 마지막 프로젝트는 조금 색다르게 진행해보고 싶었다. 그 동안 프로젝트를 진행하면서 TODO List 를 제외하곤 테스트 코드를 제대로 작성하지 않았었다. 지속적으로 변경되는 설계로 인해 테스트 코드 또한 수정해야했고 2주, 3주라는 짧은 기간 동안 구현해야할 기능들이 많았기에 테스트 코드 작성을 소홀히 했었다. 팀원 @BC 의 동의를 얻어 요구사항 분석을 바탕으로 테스트 코드를 먼저 작성하고 그에 맞춰 설계를 진행해보기로 했다.
요구사항 분석 -> 레포지토리 테스트 코드 작성 -> 도메인 설계 -> 레포지토리 클래스 작성 -> 서비스 테스트 코드 작성 -> 서비스 클래스 작성 -> 컨트롤러 클래스 작성 -> Swagger 로 API 문서 자동화 -> 예외 처리 및 유효성 검사
이러한 순서로 프로젝트를 진행하기로 하고,
https://github.com/naneun/issue-tracker/issues/10
https://github.com/naneun/issue-tracker/issues/16
https://github.com/naneun/issue-tracker/issues/22
위와 같이 이슈를 등록해가며 순차적으로 처리했다.
/**
* @implNote - 클라이언트가 닫기 버튼을 클릭하면 이슈가 닫히고, 실행 취소를 하면 이슈가 다시 열린다. - '해당하는 ID를 가진 이슈를 닫는다' ->
* '해당하는 ID를 가진 이슈의 상태를 변경한다' 로 수정한다. - 2022/06/15
*/
@Test
void 해당하는_ID를_가진_이슈의_상태를_변경한다() {
// given
Long id = 1L;
Issue foundIssue = issueRepository.findById(id)
.orElseThrow(IssueException::new);
foundIssue.changeState();
// when
Issue changedIssue = issueRepository.save(foundIssue);
// then
assertAll(
() -> assertThat(changedIssue.getState()).isEqualTo(CLOSE),
() -> assertThat(changedIssue).usingRecursiveComparison().isEqualTo(foundIssue)
);
}
테스트 코드 메서드 위에 요구사항을 분석하며 생각한 내용들을 javaDoc
주석으로 작성해가며 테스트 코드를 작성하다보니 요구사항을 좀 더 세밀하게 분석할 수 있었다.
// data.sql
/* emoji */
insert into emoji(unicode, description)
values ('❤', '좋아요'),
('👍', '최고에요'),
('👎', '싫어요'),
('✅', '확인했어요');
data.sql 을 사용하여 테스트 시 사용할 데이터를 추가하고,
static List<Emoji> registeredEmojis = List.of(
Emoji.of(1L, "❤", "좋아요"),
Emoji.of(2L, "👍", "최고에요"),
Emoji.of(3L, "👎", "싫어요"),
Emoji.of(4L, "✅", "확인했어요")
);
테스트 클래스 정적 변수로 동일한 데이터를 생성한 뒤,
@Test
void 등록된_모든_이모지를_조회한다() {
// given
// when
List<Emoji> emojis = emojiRepository.findAll();
// then
emojis.forEach((emoji) -> assertThat(emoji)
.usingRecursiveComparison()
.isEqualTo(registeredEmojis.get(emojis.indexOf(emoji))));
}
forEach(), usingRecursiveComparison(), indexOf() 메서드의 조합으로 다음과 같이 간결하게 테스트 코드를 작성하여 설계가 변경되더라도 유연하게 대처할 수 있도록 했다.
@Import(DataJpaConfig.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class EmojiRepositoryTest {
final EmojiRepository emojiRepository;
static List<Emoji> registeredEmojis = List.of(
Emoji.of(1L, "❤", "좋아요"),
Emoji.of(2L, "👍", "최고에요"),
Emoji.of(3L, "👎", "싫어요"),
Emoji.of(4L, "✅", "확인했어요")
);
@Autowired
EmojiRepositoryTest(EmojiRepository emojiRepository) {
this.emojiRepository = emojiRepository;
}
@Test
void 등록된_모든_이모지를_조회한다() {
// given
// when
List<Emoji> emojis = emojiRepository.findAll();
// then
emojis.forEach((emoji) -> assertThat(emoji)
.usingRecursiveComparison()
.isEqualTo(registeredEmojis.get(emojis.indexOf(emoji))));
}
}
@Test
void 오픈된_모든_이슈를_조회한다() {
// given
// when
List<Issue> foundOpenedIssues = issueRepository.findByState(OPEN);
// then
foundOpenedIssues.forEach(issue -> {
Issue comparisonTarget = registeredOpenedIssues.get(foundOpenedIssues.indexOf(issue));
assertThat(issue).usingRecursiveComparison()
.comparingOnlyFields()
.ignoringFields("comments")
.ignoringFields("milestone.issues")
.isEqualTo(comparisonTarget);
});
}
.comparingOnlyFields()
.ignoringFields("comments")
.ignoringFields("milestone.issues")
또한 JPA Entity 의 경우 fetch 전략이 LAZY or EAGER 에 따라 의도한 대로 테스트가 진행이 되지 않는 경우가 있었다. 따라서, 연관 관계를 맺고 있는 참조 객체 중 동등성 비교에서 제외할 필드들을 지정하여 테스트 메서드 실행 시에 컨트롤할 수 있었다.
public class OpenEntityManagerInViewInterceptor extends EntityManagerFactoryAccessor implements AsyncWebRequestInterceptor {
/**
* Suffix that gets appended to the EntityManagerFactory toString
* representation for the "participate in existing entity manager
* handling" request attribute.
* @see #getParticipateAttributeName
*/
public static final String PARTICIPATE_SUFFIX = ".PARTICIPATE";
@Override
public void preHandle(WebRequest request) throws DataAccessException {
String key = getParticipateAttributeName();
WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
if (asyncManager.hasConcurrentResult() && applyEntityManagerBindingInterceptor(asyncManager, key)) {
return;
}
EntityManagerFactory emf = obtainEntityManagerFactory();
if (TransactionSynchronizationManager.hasResource(emf)) {
// Do not modify the EntityManager: just mark the request accordingly.
Integer count = (Integer) request.getAttribute(key, WebRequest.SCOPE_REQUEST);
int newCount = (count != null ? count + 1 : 1);
request.setAttribute(getParticipateAttributeName(), newCount, WebRequest.SCOPE_REQUEST);
}
else {
logger.debug("Opening JPA EntityManager in OpenEntityManagerInViewInterceptor");
try {
EntityManager em = createEntityManager();
EntityManagerHolder emHolder = new EntityManagerHolder(em);
TransactionSynchronizationManager.bindResource(emf, emHolder);
AsyncRequestInterceptor interceptor = new AsyncRequestInterceptor(emf, emHolder);
asyncManager.registerCallableInterceptor(key, interceptor);
asyncManager.registerDeferredResultInterceptor(key, interceptor);
}
catch (PersistenceException ex) {
throw new DataAccessResourceFailureException("Could not create JPA EntityManager", ex);
}
}
}
.
.
.
@Override
public void afterCompletion(WebRequest request, @Nullable Exception ex) throws DataAccessException {
if (!decrementParticipateCount(request)) {
EntityManagerHolder emHolder = (EntityManagerHolder)
TransactionSynchronizationManager.unbindResource(obtainEntityManagerFactory());
logger.debug("Closing JPA EntityManager in OpenEntityManagerInViewInterceptor");
EntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager());
}
}
}
OpenEntityManagerInViewInterceptor
는 현재 스레드에서 JPA EntityManagers
를 사용할 수 있도록 하며, 이는 transaction managers
가 자동으로 감지한다.
@Configuration
@EnableJpaAuditing
public class DataJpaConfig {
@Bean
public OpenEntityManagerInViewInterceptor openEntityManagerInViewInterceptor() {
return new OpenEntityManagerInViewInterceptor();
}
}
따라서, 인터셉터에서 영속성 컨텍스트를 유지하기 위해 위와 같이 직접 OpenEntityManagerInViewInterceptor 빈을 등록해준다.
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
private final JwtTokenProvider jwtProvider;
private final LoginService loginService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
return validateJwtToken(response, header);
}
private boolean validateJwtToken(HttpServletResponse response, String header) {
if (header.isBlank() || !header.startsWith(BEARER)) {
response.setStatus(HttpStatus.FORBIDDEN.value());
return false;
}
Claims claims;
try {
String jwtToken = header.replaceFirst(BEARER, Strings.EMPTY).trim();
claims = jwtProvider.verifyToken(jwtToken);
} catch (ExpiredJwtException e) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
} catch (JwtException e) {
response.setStatus(HttpStatus.FORBIDDEN.value());
return false;
}
loginService.updateLoginMemberById(Long.parseLong(claims.getAudience()));
return true;
}
}
loginService.updateLoginMemberById(Long.parseLong(claims.getAudience())); - Jwt Payload 에 저장된 Audience(MemberId) 를 조회하여 LoginService 의 updateLoginMemberById 메서드를 실행시켜준다.
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
private final LoginMember loginMember;
@Transactional(readOnly = true)
public void updateLoginMemberById(Long id) {
Member member = memberRepository.findById(id)
.orElseThrow(MemberException::new);
loginMember.update(member);
}
}
해당 메서드는 LoginMember 빈을 영속성 컨텍스트에서 찾은 Member 을 사용하여 필드 값들을 초기화시킨다. 그렇다면 LoginMember 빈은 어떻게 되어있을까?
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class LoginMember {
private Long id;
private String serialNumber;
private ResourceServer resourceServer;
private String name;
private String email;
private String profileImage;
private String oAuthAccessToken;
public void update(Member member) {
this.id = member.getId();
this.serialNumber = member.getSerialNumber();
this.resourceServer = member.getResourceServer();
this.name = member.getName();
this.email = member.getEmail();
this.profileImage = member.getProfileImage();
this.oAuthAccessToken = member.getOAuthAccessToken();
}
public Member toEntity() {
return Member.builder()
.id(id)
.serialNumber(serialNumber)
.resourceServer(resourceServer)
.name(name)
.email(email)
.profileImage(profileImage)
.oAuthAccessToken(oAuthAccessToken)
.build();
}
}
스코프 값을 "request" 로 하여 빈의 생명주기를 요청이 들어오고 나갈 때까지 스프링 컨테이너에서 관리하도록 한다. 따라서, 요청이 새로 들어올 때마다 빈이 생성되기 때문에 해당 API 로 요청한 로그인 회원 정보를 LoginMember 에 저장하여 관련된 기능들에서 범용적으로 사용할 수 있도록한다.
@Component
@RequiredArgsConstructor
public class LoginMemberAuditorAware implements AuditorAware<Member> {
private final LoginMember loginMember;
@Override
public Optional<Member> getCurrentAuditor() {
return Optional.of(loginMember.toEntity());
}
}
마지막으로 AuditorAware
을 상속받아 getCurrentAuditor
을 구현해준다. 이 때 LoginMember 를 다시 Entity(Member) 로 변환하여 반환하도록 한다.
@Entity
@Getter
@ToString
@EqualsAndHashCode(of = "id", callSuper = false)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Issue extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
.
.
.
@CreatedBy
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(updatable = false)
private Member creator;
@LastModifiedBy
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn
private Member modifier;
}
상속 받은 BaseTimeEntity 클래스의 @EntityListeners(AuditingEntityListener.class)
어노테이션과 Issue 엔티티의 @CreatedBy
, @LastModifiedBy
어노테이션 사용으로 Issue 엔티티에 포함된 Member 타입의 creator (처음 등록되었을 때만), modifier (수정 시 마다) 필드들에 자동으로 로그인한 회원 객체가 등록된다.
결론적으로 웹 계층(Controller) 에서 로그인한 사용자의 Id (MemberId) 를 알고 있지 않아도 되는 방식으로 구현할 수 있었다.
@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
@Component
@RequiredArgsConstructor
public class LoginUserResolver implements HandlerMethodArgumentResolver {
private final LoginMember loginMember;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(LoginUser.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
return loginMember.toEntity();
}
}
위와 같이(이전 버전) 커스텀 어노테이션 (@LoginUser) 을 생성하고 ArgumentResolver 를 사용하여 불필요하게 웹 계층 (Controller) 에서 엔티티 (Member) 를 알고 있을 필요 또한 없어진다.
이전에 혼자 구현해봤던 OAuth, Jwt 토큰을 사용하여 로그인하는 기능을 이번 프로젝트에서 팀원 @BC 와 같이 개선하면서 가장 많이 바뀐 부분이다.
oauth:
github-client-id: ${GITHUB_CLIENT_ID}
github-client-secret: ${GITHUB_CLIENT_SECRET}
github-access-token-uri: ...
github-user-uri: ...
google-client_id: ${GOOGLE_CLIENT_ID}
google-client-secret: ${GOOGLE_CLIENT_SECRET}
google-access-token-uri: ...
google-user-uri: ...
기존에 위와 작성했던 oauth.yml 파일을
oauth:
vendors:
google:
client_id: ${GOOGLE_CLIENT_ID}
client_secret: ${GOOGLE_CLIENT_SECRET}
access_token_uri: ..
user_info_uri: ..
redirect_uri: ..
code_request_uri: ..
github:
client_id: ${GITHUB_CLIENT_ID}
client_secret: ${GITHUB_CLIENT_SECRET}
access_token_uri: ..
user_info_uri: ..
user_email_info_uri: ..
code_request_uri: ..
다음과 같이 vendors, vendor - 'google', 'github' 로 계층을 두어 세밀하게 변경하였다.
@Getter
@RequiredArgsConstructor
public class VendorProperties {
private final String clientId;
private final String clientSecret;
private final String accessTokenUri;
private final String userInfoUri;
private final String redirectUri;
private final String userEmailInfoUri;
private final String codeRequestUri;
}
@Getter
@RequiredArgsConstructor
@ConstructorBinding
@ConfigurationProperties(prefix = "oauth")
public class AuthProperties {
private final Map<String, VendorProperties> vendors;
public VendorProperties getVendorProperties(String vendor) {
return vendors.get(vendor);
}
}
Map 타입 필드인 vendors 에 key 값으로 vendor - 'google', 'github' 이 들어가고, vendor 의 하위 값 (client_id, client_secret ...) 들이 VendorProperties 클래스의 필드들과 매핑되어 각 vendor 별로 설정 값들을 관리할 수 있게 되었다.
@Service("github")
public class GithubOAuthService implements OAuthService {
private final VendorProperties vendorProperties;
@Autowired
public GithubOAuthService(AuthProperties oAuthProperties) {
this.vendorProperties = oAuthProperties.getVendorProperties("github");
}
.
.
.
}
@Service("google")
public class GoogleOAuthService implements OAuthService {
private final VendorProperties vendorProperties;
@Autowired
public GoogleOAuthService(AuthProperties oAuthProperties) {
this.vendorProperties = oAuthProperties.getVendorProperties("google");
}
.
.
.
}
각 OAuthService 추상화 인터페이스의 구체화 클래스 (GithubOAuthService, GoogleOAuthService) 의 생성자가 한결 간결해졌다. 다만, "google" 과 같은 매직 리터럴이 아닌 @Service("google") 에서 명시된 빈 이름으로 해당 vendor 의 설정 값을 생성자에서 불러와 사용하고 싶었으나 빈 생명주기에 대한 학습이 부족하여 보류한 상태이다.
@Component
@RequiredArgsConstructor
public class LoginFilter implements Filter {
private final AuthProperties authProperties;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain chain) throws IOException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String requestUri = request.getRequestURI();
response.sendRedirect((authProperties.getVendorProperties(
requestUri.substring(requestUri.lastIndexOf("/") + 1))
.getCodeRequestUri()));
}
}
또한 OAuth Login Url 요청을 OAuth Authorization Code 요청 url 로 리다이렉트 시키는 작업 또한 Controller 까지 가지 않고 간단하게 Filter 에서 작업할 수 있도록 구현했다.
IssueService 의 경우 LabelRepository, MilestoneRepository, MemberRepository 와 같이 여러 레포지토리에 의존하고 있기에 해당 로직들을 따로 분리해보고자 했었다. 그래서 작성했던 코드가 아래와 같다. (현재는 삭제되어있다..) 작성하면서도 "하.. 이건 아닌거 같은데.." 를 수차례 되내였는데 역시나!
였다.
@Configuration
@RequiredArgsConstructor
public class DomainMapperConfig {
private final LabelRepository labelRepository;
private final MilestoneRepository milestoneRepository;
private final MemberRepository memberRepository;
@Bean
@Qualifier("addRequestToIssueMapper")
public ModelMapper addRequestToIssueMapper() {
ModelMapper modelMapper = new ModelMapper();
modelMapper
.getConfiguration()
.setSkipNullEnabled(true);
modelMapper
.typeMap(IssueAddRequest.class, Issue.class)
.addMappings(
mapper -> {
mapper.map(IssueAddRequest::getTitle, Issue::changeTitle);
mapper.map(IssueAddRequest::getContent, Issue::changeContent);
mapper.map(IssueAddRequest::getState, Issue::setState);
mapper.using((Converter<Long, Label>) converter ->
labelRepository.findById(converter.getSource())
.orElseThrow(LabelException::new)
)
.map(IssueAddRequest::getLabelId, Issue::changeLabel);
mapper.using((Converter<Long, Milestone>) converter ->
milestoneRepository.findById(converter.getSource())
.orElseThrow(MilestoneException::new)
)
.map(IssueAddRequest::getMilestoneId, Issue::changeMilestone);
mapper.using((Converter<Long, Member>) converter ->
memberRepository.findById(converter.getSource())
.orElseThrow(MemberException::new)
)
.map(IssueAddRequest::getAssigneeId, Issue::changeAssignee);
}
);
return modelMapper;
}
.
.
.
}
이게 진정한 스압의 글이군요;;; 좋은 내용 공유해주셔서 감사합니다..
그리고 말씀 중에 죄송합니다만, 돈냉 먹으러 조만간 또 출격 하겠습니다;;