



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


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

Spring Security + JWT 토큰을 합쳐 만든 로그인 시스템으로 HTTP 의 무상태성 비연결성을 살리기위해 기존의 방식인 세션방식을 차단하였음.
현재의 프로젝트는 인가의 대한 조건이없기에 Spring Security 기능의 활용성이 적지만 후에 추가됄시 쉽게 추가가 가능하다.
기존 시큐리티가 제공하는 UsernamePasswordAuthenticationFilter 를 사용하지않은 이유는 첫번째론 기존의 폼로그인형태가 아님, 두번째론 OncePerRequestFilter 를 사용하면 구조에 따른 커스텀 검증로직을 작성하기 쉽기때문에 확장성이 더 좋다고 판단했다.
Authentication 와 SecurityContextHolder 를 활용하여 유저의 로그인정보를 토큰으로부터 받아 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();
}
댓글 또한 거의 동일한 로직을 가지기에 생략.
로그인된 유저가 자신의 게시글/댓글 혹은 이미 좋아요를 누른 게시글/댓글 이라면 예외가 발생한다.

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

📦
├─ .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