Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a26e358
[test] 통합테스트 displayname 수정 (#69)
buzz0331 Jul 14, 2025
a33c8e3
[feat] BaseDomainEntity에 status 변경 메서드 추가 (#69)
buzz0331 Jul 14, 2025
0594ade
[feat] 필요한 에러코드 추가 (#69)
buzz0331 Jul 14, 2025
cd4742c
[feat] Following 관련 도메인 규칙 추가 (#69)
buzz0331 Jul 14, 2025
bf2fda7
[feat] Following 관련 repository 주입 (#69)
buzz0331 Jul 14, 2025
12f1378
[feat] Following 테이블 소프트 삭제 전략설정 (#69)
buzz0331 Jul 14, 2025
13b2738
[feat] Following 관련 adapter (#69)
buzz0331 Jul 14, 2025
8433d9e
[feat] Following 관련 controller (#69)
buzz0331 Jul 14, 2025
2d0ad52
[feat] Following 관련 dto (#69)
buzz0331 Jul 14, 2025
52d4917
[feat] Following 관련 서비스 및 유즈케이스 (#69)
buzz0331 Jul 14, 2025
155a795
[test] Following 도메인 단위테스트
buzz0331 Jul 14, 2025
4df0745
[test] 팔로잉 상태변경 서비스 단위테스트
buzz0331 Jul 14, 2025
c799eb7
[test] 팔로잉 상태변경 api 통합 테스트
buzz0331 Jul 14, 2025
57edf6b
[test] 팔로잉 상태변경 controller 단위 테스트
buzz0331 Jul 14, 2025
00c49ea
[fix] conflict 해결
buzz0331 Jul 14, 2025
51595e0
[fix] 잘못된 에러코드 수정 (#69)
buzz0331 Jul 14, 2025
c604db0
[fix] 필요없는 import 제거 (#69)
buzz0331 Jul 14, 2025
db279de
[fix] followRepository 삭제 순서 보장 (배치 삭제 전략 도입) (#74)
buzz0331 Jul 14, 2025
b6dcd4b
[refactor] 파라미터 불변 보장 (#69)
buzz0331 Jul 14, 2025
799145e
[refactor] request dto -> command dto 변환 책임을 adaterp 계층에서 갖도록 수정 (#69)
buzz0331 Jul 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,12 @@ public class BaseDomainEntity {
private LocalDateTime modifiedAt;

private StatusType status;

protected void changeStatus() {
if (this.status == StatusType.ACTIVE) {
this.status = StatusType.INACTIVE;
} else {
this.status = StatusType.ACTIVE;
}
}
}
10 changes: 10 additions & 0 deletions src/main/java/konkuk/thip/common/exception/code/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ public enum ErrorCode implements ResponseCode {
* 70000 : user error
*/
USER_NOT_FOUND(HttpStatus.NOT_FOUND, 70000, "존재하지 않는 USER 입니다."),
USER_ALREADY_FOLLOWED(HttpStatus.BAD_REQUEST, 70001, "이미 팔로우한 사용자입니다."),

/**
* 75000 : follow error
*/
FOLLOW_NOT_FOUND(HttpStatus.NOT_FOUND, 75001, "존재하지 않는 FOLLOW 입니다."),
USER_ALREADY_UNFOLLOWED(HttpStatus.BAD_REQUEST, 75002, "이미 언팔로우한 사용자입니다."),
USER_CANNOT_FOLLOW_SELF(HttpStatus.BAD_REQUEST, 75003, "사용자는 자신을 팔로우할 수 없습니다."),



/**
* 80000 : book error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
import jakarta.validation.Valid;
import konkuk.thip.common.dto.BaseResponse;
import konkuk.thip.common.security.annotation.Oauth2Id;
import konkuk.thip.common.security.annotation.UserId;
import konkuk.thip.common.security.util.JwtUtil;
import konkuk.thip.user.adapter.in.web.request.UserFollowRequest;
import konkuk.thip.user.adapter.in.web.request.UserSignupRequest;
import konkuk.thip.user.adapter.in.web.request.UserVerifyNicknameRequest;
import konkuk.thip.user.adapter.in.web.response.UserFollowResponse;
import konkuk.thip.user.adapter.in.web.response.UserSignupResponse;
import konkuk.thip.user.adapter.in.web.response.UserVerifyNicknameResponse;
import konkuk.thip.user.application.port.in.UserFollowUsecase;
import konkuk.thip.user.application.port.in.UserSignupUseCase;
import konkuk.thip.user.application.port.in.UserVerifyNicknameUseCase;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -25,11 +30,12 @@ public class UserCommandController {

private final UserSignupUseCase userSignupUseCase;
private final UserVerifyNicknameUseCase userVerifyNicknameUseCase;
private final UserFollowUsecase userFollowUsecase;
private final JwtUtil jwtUtil;

@PostMapping("/users/signup")
public BaseResponse<UserSignupResponse> signup(@Valid @RequestBody UserSignupRequest request,
@Oauth2Id String oauth2Id,
public BaseResponse<UserSignupResponse> signup(@Valid @RequestBody final UserSignupRequest request,
@Oauth2Id final String oauth2Id,
HttpServletResponse response) {
Long userId = userSignupUseCase.signup(request.toCommand(oauth2Id));
String accessToken = jwtUtil.createAccessToken(userId);
Expand All @@ -38,9 +44,19 @@ public BaseResponse<UserSignupResponse> signup(@Valid @RequestBody UserSignupReq
}

@PostMapping("/users/nickname")
public BaseResponse<UserVerifyNicknameResponse> verifyNickname(@Valid @RequestBody UserVerifyNicknameRequest request) {
public BaseResponse<UserVerifyNicknameResponse> verifyNickname(@Valid @RequestBody final UserVerifyNicknameRequest request) {
return BaseResponse.ok(UserVerifyNicknameResponse.of(
userVerifyNicknameUseCase.isNicknameUnique(request.nickname()))
);
}

// 팔루우 상태 변경 : true -> 팔로우, false -> 언팔로우
@PostMapping("/users/following/{followingUserId}")
public BaseResponse<UserFollowResponse> followUser(@UserId final Long userId,
@PathVariable final Long followingUserId,
@RequestBody @Valid final UserFollowRequest request) {
return BaseResponse.ok(UserFollowResponse.of(userFollowUsecase.changeFollowingState(
UserFollowRequest.toCommand(userId, followingUserId, request.type())
)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package konkuk.thip.user.adapter.in.web.request;

import jakarta.validation.constraints.NotNull;
import konkuk.thip.user.application.port.in.dto.UserFollowCommand;

public record UserFollowRequest(
@NotNull(message = "type은 필수 파라미터입니다.")
Boolean type // true -> 팔로우, false -> 언팔로우
) {
public static UserFollowCommand toCommand(Long userId, Long targetUserId, Boolean type) {
return new UserFollowCommand(userId, targetUserId, type);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package konkuk.thip.user.adapter.in.web.response;

public record UserFollowResponse(
boolean isFollowing
) {
public static UserFollowResponse of(boolean isFollowing) {
return new UserFollowResponse(isFollowing);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import jakarta.persistence.*;
import konkuk.thip.common.entity.BaseJpaEntity;
import lombok.*;
import org.hibernate.annotations.SQLDelete;

@Entity
@Table(name = "followings")
@Getter
@SQLDelete(sql = "UPDATE followings SET status = 'INACTIVE' WHERE following_id = ?")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package konkuk.thip.user.adapter.out.persistence;

import konkuk.thip.common.exception.EntityNotFoundException;
import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity;
import konkuk.thip.user.adapter.out.jpa.UserJpaEntity;
import konkuk.thip.user.adapter.out.mapper.FollowingMapper;
import konkuk.thip.user.application.port.out.FollowingCommandPort;
import konkuk.thip.user.domain.Following;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.Optional;

import static konkuk.thip.common.exception.code.ErrorCode.FOLLOW_NOT_FOUND;
import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND;

@Repository
@RequiredArgsConstructor
public class FollowingCommandPersistenceAdapter implements FollowingCommandPort {

private final FollowingJpaRepository followingJpaRepository;
private final UserJpaRepository userJpaRepository;

private final FollowingMapper followingMapper;

@Override
public Optional<Following> findByUserIdAndTargetUserId(Long userId, Long targetUserId) {
Optional<FollowingJpaEntity> followingJpaEntity = followingJpaRepository.findByUserAndTargetUser(userId, targetUserId);
return followingJpaEntity.map(followingMapper::toDomainEntity);
}

@Override
public void save(Following following) { // insert용
UserJpaEntity userJpaEntity = userJpaRepository.findById(following.getUserId()).orElseThrow(
() -> new EntityNotFoundException(USER_NOT_FOUND));

UserJpaEntity targetUser = userJpaRepository.findById(following.getFollowingUserId()).orElseThrow(
() -> new EntityNotFoundException(USER_NOT_FOUND));

followingJpaRepository.save(followingMapper.toJpaEntity(userJpaEntity, targetUser));
}

@Override
public void updateStatus(Following following) { // 상태변경 용
FollowingJpaEntity entity = followingJpaRepository.findByUserAndTargetUser(following.getUserId(), following.getFollowingUserId())
.orElseThrow(() -> new EntityNotFoundException(FOLLOW_NOT_FOUND));

entity.setStatus(following.getStatus());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface FollowingJpaRepository extends JpaRepository<FollowingJpaEntity, Long>, FollowingQueryRepository {


public interface FollowingJpaRepository extends JpaRepository<FollowingJpaEntity, Long>,FollowingQueryRepository {
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package konkuk.thip.user.adapter.out.persistence;

import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity;

import java.util.Optional;
import java.util.List;
import java.util.Map;

public interface FollowingQueryRepository {
Map<Long, Integer> countByFollowingUserIds(List<Long> userIds);
Optional<FollowingJpaEntity> findByUserAndTargetUser(Long userId, Long targetUserId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

CQRS 분리 원칙에 대한 검토가 필요합니다.

메서드명과 Optional 반환 타입 사용은 적절하지만, CQRS 분리 관점에서 확인이 필요합니다. 학습된 컨벤션에 따르면 "CommandPort에는 findByXXX를 통해 도메인 엔티티를 찾아오는 메서드를 추가"해야 하는데, 이 메서드는 QueryRepository에 위치하고 있습니다.

이 메서드가 실제로 command 연산(팔로우/언팔로우 상태 변경)에 사용된다면 CommandPort로 이동하는 것이 CQRS 원칙에 더 부합할 수 있습니다.

다음 스크립트로 이 메서드의 사용 패턴을 확인해보세요:


🏁 Script executed:

#!/bin/bash
# 이 메서드가 command 연산에 사용되는지 확인
rg -A 5 -B 5 "findByUserAndTargetUser" --type java

Length of output: 7749


🏁 Script executed:

#!/bin/bash
# Display the FollowingJpaRepository to see its declarations
rg -A 5 -B 5 "interface FollowingJpaRepository" -n src/main/java/konkuk/thip/user/adapter/out/persistence

Length of output: 1179


🏁 Script executed:

#!/bin/bash
# 포트 인터페이스 확인
rg -A 5 -B 5 "interface FollowingCommandPort" -n src/main/java/konkuk/thip
rg -A 5 -B 5 "interface FollowingQueryPort" -n src/main/java/konkuk/thip

Length of output: 2175


CQRS 포트 분리: Command용 findByUserAndTargetUser 메서드 위치 조정 필요

현재 findByUserAndTargetUser가 FollowingQueryRepository에 선언되어 있지만, 이 메서드는 Command 연산(조회 후 상태 변경·저장)에만 사용됩니다. 학습된 컨벤션에 따라 다음과 같이 수정해주세요:

  • src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryRepository.java에서
    - Optional<FollowingJpaEntity> findByUserAndTargetUser(Long userId, Long targetUserId);
    제거
  • src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingJpaRepository.java
    Optional<FollowingJpaEntity> findByUserAndTargetUser(Long userId, Long targetUserId);
    선언 추가 (Spring Data JPA가 자동 구현)
  • src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryRepositoryImpl.java에서
    해당 메서드 구현(findByUserAndTargetUser 오버라이드) 삭제

위 변경으로
• CommandPort(및 CommandPersistenceAdapter)만 Command용 조회 메서드를 갖게 되고
• QueryPort에는 오직 조회 API 응답용 countByFollowingUserIds만 남습니다.

🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryRepository.java
at line 11, remove the method declaration of findByUserAndTargetUser as it is
used only for command operations. Then, add this method declaration to
src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingJpaRepository.java
to let Spring Data JPA implement it automatically. Finally, delete the
implementation of findByUserAndTargetUser from
src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryRepositoryImpl.java
to keep query and command responsibilities separated.

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import com.querydsl.core.Tuple;
import com.querydsl.jpa.impl.JPAQueryFactory;
import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity;
import konkuk.thip.user.adapter.out.jpa.QFollowingJpaEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.Optional;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -35,4 +37,17 @@ public Map<Long, Integer> countByFollowingUserIds(List<Long> userIds) {
tuple -> tuple.get(following.count()).intValue()
));
}

@Override
public Optional<FollowingJpaEntity> findByUserAndTargetUser(Long userId, Long targetUserId) {
QFollowingJpaEntity following = QFollowingJpaEntity.followingJpaEntity;

FollowingJpaEntity followingJpaEntity = jpaQueryFactory
.selectFrom(following)
.where(following.userJpaEntity.userId.eq(userId)
.and(following.followingUserJpaEntity.userId.eq(targetUserId)))
.fetchOne();

return Optional.ofNullable(followingJpaEntity);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package konkuk.thip.user.application.port.in;


import konkuk.thip.user.application.port.in.dto.UserFollowCommand;

public interface UserFollowUsecase {
Boolean changeFollowingState(UserFollowCommand followCommand);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package konkuk.thip.user.application.port.in.dto;

public record UserFollowCommand(
Long userId,
Long targetUserId,
Boolean type // true -> 팔로우, false -> 언팔로우
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package konkuk.thip.user.application.port.out;

import konkuk.thip.user.domain.Following;

import java.util.Optional;

public interface FollowingCommandPort {

Optional<Following> findByUserIdAndTargetUserId(Long userId, Long targetUserId);

void save(Following following);

void updateStatus(Following following);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package konkuk.thip.user.application.service;

import konkuk.thip.common.exception.InvalidStateException;
import konkuk.thip.user.application.port.in.UserFollowUsecase;
import konkuk.thip.user.application.port.in.dto.UserFollowCommand;
import konkuk.thip.user.application.port.out.FollowingCommandPort;
import konkuk.thip.user.domain.Following;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Objects;
import java.util.Optional;

import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_UNFOLLOWED;
import static konkuk.thip.common.exception.code.ErrorCode.USER_CANNOT_FOLLOW_SELF;

@Service
@RequiredArgsConstructor
public class UserFollowService implements UserFollowUsecase {

private final FollowingCommandPort followingCommandPort;

@Override
@Transactional
public Boolean changeFollowingState(UserFollowCommand followCommand) {
Long userId = followCommand.userId();
Long targetUserId = followCommand.targetUserId();
Boolean type = followCommand.type();

validateParams(userId, targetUserId);

Optional<Following> optionalFollowing = followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId);

if (optionalFollowing.isPresent()) { // 이미 팔로우 관계가 존재하는 경우
Following following = optionalFollowing.get();
boolean isFollowing = following.changeFollowingState(type);
followingCommandPort.updateStatus(following);
return isFollowing;
} else { // 팔로우 관계가 존재하지 않는 경우
if (!type) {
throw new InvalidStateException(USER_ALREADY_UNFOLLOWED); // 언팔로우 요청인데 팔로우 관계가 존재하지 않으므로 이미 언팔로우 상태
}
followingCommandPort.save(Following.withoutId(userId, targetUserId));
return true; // 새로 팔로우한 경우
}
}

private void validateParams(Long userId, Long targetUserId) {
if(Objects.equals(userId, targetUserId)) {
throw new InvalidStateException(USER_CANNOT_FOLLOW_SELF);
}
}
}
31 changes: 31 additions & 0 deletions src/main/java/konkuk/thip/user/domain/Following.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package konkuk.thip.user.domain;

import konkuk.thip.common.entity.BaseDomainEntity;
import konkuk.thip.common.entity.StatusType;
import konkuk.thip.common.exception.InvalidStateException;
import lombok.Getter;
import lombok.experimental.SuperBuilder;

import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_FOLLOWED;
import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_UNFOLLOWED;

@Getter
@SuperBuilder
public class Following extends BaseDomainEntity {
Expand All @@ -13,4 +18,30 @@ public class Following extends BaseDomainEntity {
private Long userId;

private Long followingUserId;

public static Following withoutId(Long userId, Long followingUserId) {
return Following.builder()
.userId(userId)
.followingUserId(followingUserId)
.status(StatusType.ACTIVE)
.build();
}

public boolean changeFollowingState(boolean isFollowRequest) {
StatusType currentStatus = getStatus();
validateFollowingState(isFollowRequest, currentStatus);

super.changeStatus();
return isFollowRequest;
}

private void validateFollowingState(boolean isFollowRequest, StatusType currentStatus) {
if (isFollowRequest && currentStatus == StatusType.ACTIVE) { // 팔로우 요청일 때 이미 팔로우 중인 경우
throw new InvalidStateException(USER_ALREADY_FOLLOWED);
}

if (!isFollowRequest && currentStatus == StatusType.INACTIVE) { // 언팔로우 요청일 때 이미 언팔로우 중인 경우
throw new InvalidStateException(USER_ALREADY_UNFOLLOWED);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ void setUp() {

@AfterEach
void tearDown() {
followingJpaRepository.deleteAll();
followingJpaRepository.deleteAllInBatch();
userRoomJpaRepository.deleteAll();
roomJpaRepository.deleteAll();
bookJpaRepository.deleteAll();
Expand Down
Loading