Spring NewsFeed 트러블슈팅

SJ.CHO·2024년 10월 25일

1. DB 조회 최적화

public class Member extends Auditable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name = "email", unique = true, nullable = false, length = 50)
    private String email;
    @Column(name = "nickname", unique = true, nullable = false, length = 50)
    private String nickName;
    @Column(name = "password", nullable = false)
    private String password;
    @Column(name = "country", nullable = false, length = 50)
    private String country;
    @Column(name = "status", nullable = false)
    @Enumerated(value = EnumType.STRING)
    private MembershipStatus status;

    @Column
    @Temporal(TemporalType.TIMESTAMP)
    private LocalDateTime deletedAt;
}
private void isDuplicateMember(MemberSignUpCommand command) {
    // 이메일 중복 확인
    Optional<Member> foundByEmail = memberRepository.findByEmail(command.getEmail());
    if (foundByEmail.isPresent()) {
        throw new CustomException(ALREADY_EMAIL);
    }

    // 닉네임 중복 확인
    Optional<Member> foundByNickName = memberRepository.findByNickName(command.getNickName());
    if (foundByNickName.isPresent()) {
        throw new CustomException(ALREADY_NICKNAME);
    }
}
  1. 문제 발생 : 현재 규모의 프로젝트에선 EmailNickNameunique 값으로 두어 중복문제를 해결하였음. 유저의 입력값의 중복검증을 하기위해 2번의 쿼리를 날려야하는 상황이 발생.

  2. 원인 : 각각의 입력요소에 대해서 2번의 DB접속의 요구가 필요한 코드가 작성되어짐.

  3. 가설 : 굳이 2개의 요소가 모두 중복일 필요없이 Email 혹은 NickName 이 중복되는 Row 가 단 1개라도 존재하면 어차피 불가능한게 아닐까?

  4. 문제해결 : 문제의 코드를 리팩토링 하여 findByEmailOrNickName 쿼리메소드를 사용 그 후 찾아온 Member 객체가 존재한다면 사용이 불가능한 Email 혹은 NickName 이기에 예외를 발생시키도록 해결

    private void isDuplicateMember(MemberSignUpCommand command) {
        Optional<Member> foundMember = memberRepository.findByEmailOrNickName(
                command.getEmail(), command.getNickName()
        );
        if (foundMember.isPresent()) {
            Member existingMember = foundMember.get();
            if (existingMember.getEmail().equals(command.getEmail())) {
                throw new CustomException(ALREADY_EMAIL);
            }

            if (existingMember.getNickName().equals(command.getNickName())) {
                throw new CustomException(ALREADY_NICKNAME);
            }
        }
    }
  1. 생각해볼 점 : 지금까지는 코드단에서 만 가공을 진행하고 DB에서는 순수한 데이터의 조회등만을 진행하는 방식을 생각했는데 가공을해서 가져올수있다면? 혹은 의사결정 자체를 DB에게 넘겨줘버린다면? ( existsByNickName 같은) 더 설명적인 코드와 간결성을 유지 할 수 있을거라고 생각,
    SQL 및 JPA 사용법의 중요성을 느낄수 있었다.

2. 외부 API 사용 접근

    @Column(name = "country", nullable = false, length = 50)
    private String country;
  1. 문제 발생 : 유저의 Entity 값으로 거주 국가의 대한 데이터가 들어가야하는 설계구조 에서 국가가 실존하는 국가인지 검증하는 작업이 필요.

  2. 원인 : 유저의 Entity 값으로 거주 국가의 대한 데이터가 들어가야하는 설계구조

  3. 가설 :

      1. 열거상수인 ENUM 클래스를 통해 국가이름들을 전부 지정해두면 사용자의 값을 검증하기 쉽지않을까?
        • 찾아보니 모든 나라의 수는 모두 252개... 입력도 고생이지만 변경사항이나 추가사항이 발생한다면 유지보수적으로도 생산성으로도 좋지않은 선택임. 장점으론 허용국가들만 지정할수도 있지만 이부분은 전부가져와서 제외하는식으로도 가능함.
      1. 외부 API 중 나라들의 정보를 모아둔 API가 있었음.
        • 기존의 구조처럼 필요마다 접근시 가입회원수만큼 접근이 필요, 성능이 너무 떨어지고 접근횟수가 많다.
        • 외부 API에 대한 의존성이 생겨서 외부 API가 작동을 안한다면 국가검증이 불가능해짐. 하지만 1번의 방법보다 현실적임.
        • 그렇다면 이 중 이름만 뽑아와서 컬렉션으로 저장한다면 252개의 문자열 컬렉션정도는 서버의 큰무리가 없을거임. 또한 기점을 서버실행시간으로 잡으면 큰 문제가없을거라고 판단. (서버점검내에 서버실행시간까지 포함하여 진행됌)
      • 이러한 형태의 JSON API 를 가져와서 이름만 뽑아오는 구조를 사용.
  4. 문제해결 :

@PostConstruct
    public void initCountryNames() {
        Object[] countries = restTemplate.getForObject(API_URL, Object[].class);
        if (countries != null) {
            countryNames = Arrays.stream(countries)
                    .map(country -> (Map<String, Object>) country)
                    .map(countryMap -> (Map<String, String>) countryMap.get("name"))
                    .map(nameMap -> nameMap.get("common"))
                    .toList();
        }
    }
  • 해당 형태의 나라의 이름을 뽑아오는 형태로 List 를 만들어주는 Client 를 이용해 값을 받아온다. 일종의 캐싱데이터를 구현한셈.
[South Georgia, Grenada, Switzerland, Sierra Leone, Hungary, Taiwan, 
Wallis and Futuna, Barbados, Pitcairn Islands, Ivory Coast, Tunisia, 
Italy, Benin, Indonesia, Cape Verde, Saint Kitts and Nevis, ...]
  • 의 형태로 저장된 List로 서버가 살아있는동안 나라의 이름겅증이 가능해진다!.
  1. 생각해볼점 : 나라이름의 추상화정도는 꽤 높은수준이기에 그렇게 변동성이 높지않다. 굳이 서버실행마다 최신화가 아니더라도 DB 테이블내로 가지고있다가 Spring Batch 를 활용하여 스케쥴링을 월~년 단위 정도로 해도 충분할 것 같다.

3. 커스텀 밸리데이터

    @NotBlank(message = "거주 국가를 입력해주세요.")
    @Country(message = "존재하지 않는 국가 이거나 국가명을 영문으로 입력해주세요.")
    private String country;
  1. 문제 발생 : 위 2번의 문제에서 파생되는 문제로 이제는 들어오는 국가명이 정상적인지 확인할 필요 가 생김.
  2. 원인 : 실존하지 않거나 제공하는 나라의 명을 다르게 입력할경우 DB의 무결성이 깨지는 현상이 발생 할 것 임.
  3. 가설 : Spring은 유저의 입력값을 쉽게 검증해주는 @Valid 를 지원하지만 나라에대한 검증은 기존 라이브러리로 제공하지않음. 그렇다고 서비스에서 검증까지 갈 문제는 아니라 판단. 예외처리 처럼 내가 하나 만들면되지 않을까?
  4. 문제해결 :
@RequiredArgsConstructor
public class CountryValidator implements ConstraintValidator<Country, String> {

    private final CountryService countryService;

    @Override
    public void initialize(Country constraintAnnotation) {
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
        return countryService.getCountryNames().contains(value);
    }
}
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CountryValidator.class)
public @interface Country {

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    String message();
}
  • 생성하는 방법은 생각보다 간단했음. ConstraintValidator 의 구현체와 사용될 어노테이션 클래스를 생성하여 해당 Validator의 메소드를 오버라이딩하여 검증로직을 구현.
  • @Target : 어노테이션을 적용하는 위치를 지정, 필드 혹은 파라미터에서 사용가능하도록 설정.
  • @Retention : 어노테이션의 유지범위를 지정. 서버가 실행되는 동안 으로 설정.
  • @Constratin : 해당 필드값을 검증할 검증클래스를 지정한다.

참조 : https://wildeveloperetrain.tistory.com/157

4. 스프링시큐리티+JWT 로그인구현

  1. 문제 발생 : 프로젝트를 진행하면서 로그인 인증처리가 필요.

  2. 원인 : 대부분의 API 서비스에서 로그인 등의 인증절차를 통해야만 하는 기능들이 존재

  3. 가설 : 기존의 JWT필터만 사용하는게 아니라 Spring Security 의 기반을 활용하여 개발이 불가능 할까? 강의와 레퍼런스들에서는 Spring Security 만을 사용하는게 아니라 혼합해서 사용하는것이 일반적이라고 판단.

  4. 문제 해결 :

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final JwtUtil jwtUtil;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.csrf((csrf) -> csrf.disable())
                .formLogin((csrf) -> csrf.disable())
                .httpBasic((csrf) -> csrf.disable())
                .authorizeRequests((authorizeRequests) ->
                        authorizeRequests
                                .requestMatchers("/api/member/signup", "/api/member/login").permitAll()
                                .anyRequest().authenticated()
                );
        http.sessionManagement(sessionManagement ->
                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );
        http.addFilterBefore(authenticationExceptionHandlerFilter(), UsernamePasswordAuthenticationFilter.class);
        http.addFilterBefore(authFilter(), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public AuthFilter authFilter() {
        return new AuthFilter(jwtUtil);
    }

    @Bean
    public AuthenticationExceptionHandlerFilter authenticationExceptionHandlerFilter() {
        return new AuthenticationExceptionHandlerFilter();
    }
  • 형태 자체는 securityFilterChain 에서 JWT 기능을 사용하기위해 기존의 폼로그인 화면과 세션방식을 OFF 하고 http.addFilterBefor 를 이용하여 구현한 로그인 filer 를 붙여주는 형태로
    security 를 설정하였다. 또한 회원가입, 로그인외에는 모든 요청은 검증처리하였음.
    public Authentication getAuthentication(String token) {
        Claims claims = getUserInfoFromToken(token);
        String email = claims.getSubject();

        Member member = memberRepository.findByEmail(email).orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));

        List<SimpleGrantedAuthority> authorities = Collections.emptyList(); // 권한이 필요 없다면 빈 리스트
        return new UsernamePasswordAuthenticationToken(member, token, authorities);
    }
public class AuthFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException {
        if (isFilterApplicable(req)) {
            chain.doFilter(req, res);
            return;
        }
        String tokenValue = jwtUtil.getTokenFromRequest(req);
        if (!StringUtils.hasText(tokenValue)) {
            throw new UnAuthorizationException(TOKEN_NOT_FOUND);
        }
        String accessToken = jwtUtil.substringToken(tokenValue);
        if (!jwtUtil.validateToken(accessToken)) {
            throw new UnAuthorizationException(INVALID_TOKEN);
        }

        // JWT 로부터 인증 정보를 가져옴
        Authentication auth = jwtUtil.getAuthentication(accessToken);
        // 인증 정보를 설정
        SecurityContextHolder.getContext().setAuthentication(auth);

        chain.doFilter(req, res);
    }

    private boolean isFilterApplicable(HttpServletRequest req) {
        String path = req.getRequestURI();
        return path.startsWith("/api/member/signup") || path.startsWith("/api/member/login");
    }
}
  • 기존의 로그인 필터와 달라진 부분은 Authentication auth = jwtUtil.getAuthentication(accessToken);SecurityContextHolder.getContext().setAuthentication(auth); 부분이다
  • Authentication : 사용자의 인증정보를 저장하는 토큰의 개념으로 현재의 구조로는 이미 검증이 완료된 토큰이기에 사용자정보를 담기위해 사용했다.
  • SecurityContextHolder : 유저의 정보가 들어있는 UserDetails 를 담고있으며, 인증이 완료된 auth 정보를 넣어줬다.
  1. 생각해볼 점 :
  • 현재 프로젝트는 인가 의 대한 요구처리가 없기에 Spring Security 를 크게 활용할 점은 없음. 다만 요구조건이 추가됄 경우 활용성이 높기에 확장성이 높다고 생각한다.
  • 일반적으론 Spring Security 로 로그인하는 경우 UsernamePasswordAuthenticationFilter 를 구현하여 인증 처리를 하는것 같음. 하지만 폼로그인 방식을 사용하지도 않았고 OncePerRequestFilter 를 사용할 경우 인증처리에 대해서 좀 더 개발자가 커스텀을 할수 있는 장점이 있음.

5. 논리삭제(SoftDelete)

  1. 문제 발생 : 유저가 해당 NewsFeed 프로그램을 탈퇴시 유저의 Email 을 보존해야하는 상황 발생.

  2. 원인 : 프로그램의 요구조건으로 탈퇴한 유저의 Email을 사용하지 못하도록 제한되어있음.

  3. 가설 : 해당 요구조건을 만족시킬 수 있는 2가지의 방법이 생각났음.

  • 유저의 정보를 하드델리트하고 임의의 Table 에다가 탈퇴한 유저의 Email 을 모아두고 가입시 해당 이메일을 조회.
    • HardDelete 시 연관관계에 따른 다른 외래키 컬럼또한 같이 삭제되기에 관련 정보 삭제가편함.
    • 요구조건상에서 복구에 따른 조건이 없기에 복구에 대해 신경 쓸 필요없음.
    • 유저가 Email 중복을 확인하기 위해 2개의 Table (탈퇴 유저, 활성 유저) 에서 검증을 진행해야함.
    • 일반적인 현업에서도 HardDelete 방법은 지양되는 방법임.
  • 유저의 정보를 남겨두거나 SoftDelete 방식으로 상태값을 통해 유저의 상태를 표현함.
    • 모든 유저의 대한 정보를 하나의 Table로 관리가 가능함.
    • 요구조건은 복구가 불가능하지만 후에 복구가 추가될경우 확장성이 용이함.
    • 연관관계가 복잡할 경우 유저와 연관된 해당 정보의 삭제 순서와 수동적으로 개발자가 삭제를 진행해야함. (양방향이면 자동삭제가가능 But 흐름을 찾기가 힘들다)
    • 유저의 개인정보가 그대로 남아있기에 보안적으로 좋지않을 수 있음.
  1. 문제 해결 : 각각의 장단점에서 SoftDelete 방식의 장점이 더 많다고 판단하고 진행하였음.
    @Transactional
    public MemberDeleteResponseDto deleteMember(Long id, MemberDeleteRequestDto req) {
        Member member = memberRepository.findById(id).orElseThrow(() -> new CustomException(USER_NOT_FOUND));
        if (!member.isValidPassword(req.getPassword(), passwordEncoder)) {
            throw new CustomException(LOGIN_FAILED);
        }
        member.anonymizeMember();
        memberRepository.save(member);
        likeRepository.deleteByMemberId(member.getId());
        newsRepository.deleteByMemberId(member.getId());
        friendRepository.deleteAllByMemberOrFriend(member.getId());
        friendRequestRepository.deleteByReceiverIdOrRequesterId(member.getId());
        return MemberDeleteResponseDto
                .builder()
                .id(member.getId())
                .message("회원탈퇴 되었습니다.")
                .build();
    }
  • 유저는 자신이 탈퇴 됄때 자신의 개인정보를 익명화 하고 사용자간의 대화흐름이 어색하지 않도록 댓글을 제외한 유저관련 정보를 모두 삭제한다.
  • 메소드를 통해 삭제 로직을 하나로 묶을수도 있지만 협업적으로는 명시적으로 어떤 객체가 삭제되는지 알려줄수도있을거라 판단하여 묶지않았음.
  • 댓글의 사용자 표시의 경우 조회는 탈퇴유저 처리하면되고 유저이름 표출의 경우 댓글은 유저의 객체를 담고있기에 익명표시가 가능.
  • 복구시에도 일정의 규칙 (익명_ + 유저이름 + PK) 를 통해 변경되기에 중복되지않고 해당유저 닉네임 사용가능.
  1. 생각해볼점 :
  • 해당 프로그램의 서비스기간이 길어질수록 사용불가능한 유저이름 + Email 의 경우가 많아진다. 이를 해결하려면 Spring Batch 를 활용하여 일정기간이 지난 삭제유저를 HardDelete 로 해결가능해보임.

  • 유저가 자신을 복구시 자신의 유저이름을 가진 ROW 가 존재할시 중복현상이 발생. 유저정보를 수정할수있도록 유도하면 괜찮을것 같음.

6. Git 협업 전략

  1. 문제 발생 : 협업으로 Git Hub를 통해 코드를 공유하면서 최대한 충돌과 코드오염을 적게 하기위한 방법이 필요.

  2. 원인 : GitHub 내에서 코드를 공유히면서 같은줄에 다른코드가 있거나 했을때의 충돌발생. 범위가 커서 Table 별로 인원을 분배하는게 가장 베스트같지만 그렇게 규모가 큰 프로젝트가 아니었음.

  3. 가설 : GitHub 자체는 매우 대중적인 코드공유저장소로 많은 전략이나 응용프로그램이 있을거라 판단.

  4. 문제 해결 : 예상대로 Git으로 협업을위한 많은 전략이 있었고 그중 Git Flow 전략을 사용하기로 결정. 트리구조의 형태로 개인의 기능브랜치를 이슈번호로 매핑하여 하나씩 합치는 전략을 사용했음.

Git 트리구조 예시
---------------------------------------------------------------------------------
Main
    ㄴ
      dev
         ㄴ
           Member
                ㄴ feat - 1 (회원탈퇴)
                ㄴ feat - 27(친구요청)
           News
                ㄴ feat - 5 (게시물생성)
           Comment
                ㄴ feat - 8 (댓글생성)
  • 장점 으론 기능별 단위의 아주 작은 브랜치로 문제를 지정했기에 코드리뷰를 보는쪽에도 편했고 단위별로 테스트가 진행 된 후 합쳐지기때문에 테스트에 대한 신뢰성이 보장되었음.
  • 단점 으론 공통의 코드가 업데이트된다면 해당 테스트의 신뢰성이 떨어지고 업데이트될때마다 적극적으로 패치를 진행해줘야했음.
  • 다양한 Git 브랜치전략을 시도해서 지금 프로젝트의 규모에맞는 전략을 찾아볼 필요성을 느꼇음.

참조 : https://velog.io/@bourgeois46/github-%EA%B7%B8%EB%A3%B9-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%ED%95%98%EB%8A%94-%EB%B2%95

profile
70살까지 개발하고싶은 개발자

0개의 댓글