NewsFeed Project

SJ.CHO·2024년 10월 25일

담당기능

API 명세서

ERD

  • Member Table 에 많은게 귀속된형태로 ERD가 작성이 되었다.
  • 친구 Table 과 친구대기 Table 을 나눈 이유는 컬럼으로 상태값을 검증하기 위해선 서비스로직 혹은 DB에서 검증작업이 필요, 서비스가 길어질수록 대기신청과 친구관계 표기가 합쳐서 사이즈가 너무커진다.
    이를 막기위해 상태표현을 2개의 Table로 분리 하여 성능과 서비스 확장성을 높혔다.
  • 단점으로는 DB의 무결성을 지키기위한 구현이 어렵다는 점.

SQL

    create table members (
        id bigint not null auto_increment,
        created_at datetime(6),
        modified_at datetime(6),
        country varchar(50) not null,
        deleted_at datetime(6),
        email varchar(50) not null,
        nickname varchar(50) not null,
        password varchar(255) not null,
        status enum ('ACTIVE','INACTIVE') not null,
        primary key (id)
    )
    create table news (
        id bigint not null auto_increment,
        created_at datetime(6),
        modified_at datetime(6),
        content TEXT not null,
        title varchar(100) not null,
        member_id bigint not null,
        primary key (id)
    )
    create table comments (
        id bigint not null auto_increment,
        created_at datetime(6),
        modified_at datetime(6),
        comment varchar(100) not null,
        member_id bigint,
        news_id bigint,
        primary key (id)
    )
    create table likes (
        id bigint not null auto_increment,
        comment_id bigint,
        member_id bigint,
        news_id bigint,
        primary key (id)
    )
    create table friend (
        id bigint not null auto_increment,
        friend_id bigint,
        member_id bigint,
        primary key (id)
    )
    create table friend_request (
        id bigint not null auto_increment,
        receiver_id bigint,
        requester_id bigint,
        primary key (id)
    )

진행과정

회원가입

Controller
    @PostMapping("/signup")
    public ResponseEntity<MemberSignUpResponseDto> signUpMember(@Valid @RequestBody MemberSignUpRequestDto req) {
        MemberSignUpCommand command = new MemberSignUpCommand(req);
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(memberService.signUpMember(command));
    }
================================================================================================================================================
Service
    @Transactional
    public MemberSignUpResponseDto signUpMember(MemberSignUpCommand command) {
        this.isDuplicateMember(command);
        String password = passwordEncoder.encode(command.getPassword());
        Member member = Member.builder()
                .email(command.getEmail())
                .nickName(command.getNickName())
                .password(password)
                .country(command.getCountry())
                .status(MembershipStatus.ACTIVE)
                .build();
        memberRepository.save(member);
        return MemberSignUpResponseDto.builder()
                .id(member.getId())
                .message("회원가입 되었습니다.")
                .build();
    }
================================================================================================================================================
중복조회 메소드
     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);
            }
        }
    }
  • 요구조건 :
    • 유저들의 이메일은 DB에 암호화 되어서 보관되어야함.
    • PasswordEncoder 를 이용해 DB에 암호화하여 저장.
    • 닉네임 혹은 이메일은 중복사용이 불가능하기에 중복일경우 예외를 발생시킨다.
    • CustomValidator 를 통하여 유저가 입력한 국가가 실존하는지 검사한다.
DTO

    @NotBlank(message = "거주 국가를 입력해주세요.")
    @Country(message = "존재하지 않는 국가 이거나 국가명을 영문으로 입력해주세요.")
    private String country;
================================================================================================================================================
Validator

@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();
}
================================================================================================================================================
@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);
    }
}
================================================================================================================================================
    @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();
        }
    }
  • 나라정보들이 저장되어있는 외부 API의 접근하여 나라의 이름만을 List 화 하여 저장. 이후 캐싱데이터로써 유저의 회원가입 요청이 올때마다 값을 검증한다.

로그인

Spring Security 설정

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

JWTUtil 변경점

    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");
    }
}
  • 기존과 동일하게 로그인 유저는 로그인시 유저고유식별값인 (Email) 을 토큰의 담는다

  • Spring Security + JWT 토큰을 합쳐 만든 로그인 시스템으로 HTTP 의 무상태성 비연결성을 살리기위해 기존의 방식인 세션방식을 차단하였음.

  • 현재의 프로젝트는 인가의 대한 조건이없기에 Spring Security 기능의 활용성이 적지만 후에 추가됄시 쉽게 추가가 가능하다.

  • 기존 시큐리티가 제공하는 UsernamePasswordAuthenticationFilter 를 사용하지않은 이유는 첫번째론 기존의 폼로그인형태가 아님, 두번째론 OncePerRequestFilter 를 사용하면 구조에 따른 커스텀 검증로직을 작성하기 쉽기때문에 확장성이 더 좋다고 판단했다.

  • AuthenticationSecurityContextHolder 를 활용하여 유저의 로그인정보를 토큰으로부터 받아 HandlerMethodArgumentResolver 를 통해 로그인된 유저를 객체로써 컨트롤러에서 받아오기가 가능하다.

회원 탈퇴

Controller
    @PostMapping("/signup")
    public ResponseEntity<MemberSignUpResponseDto> signUpMember(@Valid @RequestBody MemberSignUpRequestDto req) {
        MemberSignUpCommand command = new MemberSignUpCommand(req);
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(memberService.signUpMember(command));
    }
================================================================================================================================================

Service

    @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();
    }
  • 요구조건 중 하나인 탈퇴한 유저의 이메일을 재사용하지 못하기위해 SoftDelete 방식을 채용.

  • 탈퇴의 요청이올시 비밀번호를 확인, 이후 유저의 개인정보를 익명화 한 후 댓글을 제외한 연관된 모든 정보를 삭제한다.

  • 현재 복구는 불가능한 조건이지만 이후 유저의정보를 복구해야할시 구현이 간단하다.

  • DB 테이블의 사이즈가 증대해지기전에 스케쥴러를 통해 일정기간이 지난 탈퇴유저를 HardDelete 하면 개선이 가능하다.

좋아요 추가

Controller
    @PostMapping
    public ResponseEntity<LikeNewsResponseDto> addLikeNews(@PathVariable("newsid") final Long newsId, @LoginUser Member member) {
        return ResponseEntity
                .status(HttpStatus.OK)
                .body(likeService.addNewsLike(newsId, member.getId()));
    }

================================================================================================================================================

Service

    @Transactional
    public LikeNewsResponseDto addNewsLike(Long newsId, Long memberId) {
        Member member = memberRepository.findById(memberId).orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
        News news = newsRepository.findById(newsId).orElseThrow(() -> new CustomException(ErrorCode.NEWS_NOT_FOUND));
        if (news.isValidateCreator(member.getId())) {
            throw new CustomException(ErrorCode.CANNOT_LIKE_YOURSELF);
        }
        if (likeRepository.existsByMemberIdAndNewsId(member.getId(), news.getId())) {
            throw new CustomException(ErrorCode.ALREADY_LIKE);
        }
        Like like = new Like();
        like.addLikeNews(member, news);
        likeRepository.save(like);
        return LikeNewsResponseDto
                .builder()
                .newsId(news.getId())
                .message("좋아요를 눌렀습니다.")
                .build();
    }
  • 댓글 또한 거의 동일한 로직을 가지기에 생략.

  • 로그인된 유저가 자신의 게시글/댓글 혹은 이미 좋아요를 누른 게시글/댓글 이라면 예외가 발생한다.

  • 아쉬운 점은 선택받지 못한 곳은 Null값이 들어가진다는것, 개발자는 항상 Null 을 주의해야한다.
    디폴트값을 지정해주거나 테이블을 2개로 나뉘어서 개선이 가능할것 같다.

좋아요 삭제

Controller

    @DeleteMapping
    public ResponseEntity<LikeNewsResponseDto> removeLikeNews(@PathVariable("newsid") final Long newsId, @LoginUser Member member) {
        return ResponseEntity
                .status(HttpStatus.OK)
                .body(likeService.removeNewsLike(newsId, member.getId()));
    }
    
================================================================================================================================================

Service

    public LikeNewsResponseDto removeNewsLike(Long newsId, Long memberId) {
        Member member = memberRepository.findById(memberId).orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
        News news = newsRepository.findById(newsId).orElseThrow(() -> new CustomException(ErrorCode.NEWS_NOT_FOUND));
        Like like = likeRepository.findByMemberIdAndNewsId(member.getId(), news.getId()).orElseThrow(() -> new CustomException(ErrorCode.LIKE_NOT_FOUND));
        likeRepository.delete(like);
        return LikeNewsResponseDto
                .builder()
                .newsId(news.getId())
                .message("좋아요를 취소했습니다.")
                .build();
    }
  • 삭제의 경우 는 특별한 점 없이 DB에서 삭제되는 형태를 띈다.

트러블 슈팅

링크(클릭)

구현기능

Stacks

Architecture

📦 
├─ .gitattributes
├─ .gitignore
├─ README.md
├─ build.gradle
├─ gradle
│  └─ wrapper
│     ├─ gradle-wrapper.jar
│     └─ gradle-wrapper.properties
├─ gradlew
├─ gradlew.bat
├─ settings.gradle
└─ src
   ├─ main
   │  ├─ java
   │  │  └─ com
   │  │     └─ sparta
   │  │        └─ newsfeedproject
   │  │           ├─ NewsFeedProjectApplication.java
   │  │           └─ domain
   │  │              ├─ audit
   │  │              │  └─ Auditable.java
   │  │              ├─ comment : 댓글관련 API
   │  │              │  ├─ controller
   │  │              │  │  └─ CommentController.java
   │  │              │  ├─ dto
   │  │              │  │  └─ CommentDTO.java
   │  │              │  ├─ entity
   │  │              │  │  └─ Comment.java
   │  │              │  ├─ repository
   │  │              │  │  └─ CommentRepository.java
   │  │              │  └─ service
   │  │              │     └─ CommentService.java
   │  │              ├─ config : 로그인 인증 관련 설정
   │  │              │  ├─ WebConfig.java
   │  │              │  └─ security
   │  │              │     ├─ PasswordEncoder.java
   │  │              │     └─ WebSecurityConfig.java
   │  │              ├─ exception : 공통예외 처리 API
   │  │              │  ├─ CustomException.java
   │  │              │  ├─ UnAuthorizationException.java
   │  │              │  ├─ controller
   │  │              │  │  └─ GlobalExceptionHandler.java
   │  │              │  ├─ dto
   │  │              │  │  ├─ CommentRequestDto.java
   │  │              │  │  ├─ CommentResponseDto.java
   │  │              │  │  └─ ErrorDto.java
   │  │              │  └─ eunm
   │  │              │     └─ ErrorCode.java
   │  │              ├─ filter : 로그인 인증 관련 Filter
   │  │              │  ├─ AuthFilter.java
   │  │              │  └─ AuthenticationExceptionHandlerFilter.java
   │  │              ├─ friend : 친구관계 설정 관련 API
   │  │              │  ├─ controller
   │  │              │  │  └─ FriendController.java
   │  │              │  ├─ dto
   │  │              │  │  ├─ FriendNewsResponseDto.java
   │  │              │  │  ├─ FriendResponseDto.java
   │  │              │  │  └─ MessageResponseDto.java
   │  │              │  ├─ entity
   │  │              │  │  ├─ Friend.java
   │  │              │  │  └─ FriendRequest.java
   │  │              │  ├─ repository
   │  │              │  │  ├─ FriendRepository.java
   │  │              │  │  └─ FriendRequestRepository.java
   │  │              │  └─ service
   │  │              │     └─ FriendService.java
   │  │              ├─ jwt : JWT 관리를 위한 Util 클래스
   │  │              │  └─ JwtUtil.java
   │  │              ├─ like : 좋아요 관련 API
   │  │              │  ├─ controller
   │  │              │  │  └─ LikeController.java
   │  │              │  ├─ dto
   │  │              │  │  ├─ LikeCommentResponseDto.java
   │  │              │  │  └─ LikeNewsResponseDto.java
   │  │              │  ├─ entity
   │  │              │  │  └─ Like.java
   │  │              │  ├─ repository
   │  │              │  │  └─ LikeRepository.java
   │  │              │  └─ service
   │  │              │     └─ LikeService.java
   │  │              ├─ member : 유저 관련 API
   │  │              │  ├─ client : 유저 거주국가 검증을 위한 외부 API
   │  │              │  │  └─ CountryService.java
   │  │              │  ├─ command
   │  │              │  │  └─ MemberSignUpCommand.java
   │  │              │  ├─ controller
   │  │              │  │  └─ MemberController.java
   │  │              │  ├─ dto
   │  │              │  │  ├─ MemberDeleteRequestDto.java
   │  │              │  │  ├─ MemberDeleteResponseDto.java
   │  │              │  │  ├─ MemberLoginRequestDto.java
   │  │              │  │  ├─ MemberLoginResponseDto.java
   │  │              │  │  ├─ MemberProfileResponseDto.java
   │  │              │  │  ├─ MemberSignUpRequestDto.java
   │  │              │  │  ├─ MemberSignUpResponseDto.java
   │  │              │  │  ├─ ProfileUpdateRequestDto.java
   │  │              │  │  └─ ProfileUpdateResponseDto.java
   │  │              │  ├─ entity
   │  │              │  │  └─ Member.java
   │  │              │  ├─ eunm
   │  │              │  │  └─ MembershipStatus.java
   │  │              │  ├─ repository
   │  │              │  │  └─ MemberRepository.java
   │  │              │  ├─ resolver : 로그인한 유저를 받아오기위한 resolver
   │  │              │  │  ├─ LoginUserResolver.java
   │  │              │  │  └─ util
   │  │              │  │     └─ LoginUser.java
   │  │              │  └─ service
   │  │              │     └─ MemberService.java
   │  │              ├─ news : 뉴스피드 관련 API
   │  │              │  ├─ controller
   │  │              │  │  └─ NewsController.java
   │  │              │  ├─ dto
   │  │              │  │  ├─ NewsCreateRequestDTO.java
   │  │              │  │  ├─ NewsCreateResponseDTO.java
   │  │              │  │  ├─ NewsDeleteResponseDTO.java
   │  │              │  │  ├─ NewsPageReadResponseDto.java
   │  │              │  │  ├─ NewsReadResponseDTO.java
   │  │              │  │  ├─ NewsUpdateRequestDTO.java
   │  │              │  │  └─ NewsUpdateResponseDTO.java
   │  │              │  ├─ entity
   │  │              │  │  └─ News.java
   │  │              │  ├─ repository
   │  │              │  │  └─ NewsRepository.java
   │  │              │  └─ service
   │  │              │     └─ NewsService.java
   │  │              └─ validator : 유저 거주국가 검증을 위한 Custom validator
   │  │                 ├─ Country.java
   │  │                 └─ CountryValidator.java
   │  └─ resources
   │     └─ application.properties
   └─ test
      └─ java
         └─ com
            └─ sparta
               └─ newsfeedproject
                  └─ NewsFeedProjectApplicationTests.java

Github 링크

링크(클릭)

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

0개의 댓글