조회 구현에서 권한 작업을 하고 나니 수정 구현도 동일하게 권한을 위주로 생각해보게 되었다.
닉네임 변경과 같은 경우, 사용자의 악의적 변경을 막기 위해 30일에 한 번만 변경할 수 있는 로직이 추가될 수 있다.
이 로직을 구현하려면 닉네임 변경 이력 시간을 관리해야 하는데,
방법으로는 User에 필드를 추가하거나, p_user_nickname_histories와 같은 테이블로 이력을 관리할 수 있다.
테이블로 관리하면 필드 방식에 비해 다른 테이블로의 Insert 쿼리와 조회 쿼리가 추가된다는 단점이 있지만, 닉네임 수정 이력 통계 등의 기능으로 확장될 수 있고, 추가로
마스터 권한의 경우 이러한 로직 제한 없이 원하는 대로 정보를 수정할 수 있어야 한다.
MVP 구현 단계라서 마스터의 권한으로만 PATCH 엔드포인트 하나만을 생각했는데,
인가 분리를 if 분기가 아닌 요청 자체로 처리하기 위해서 RESTful 관례에는 맞지 않지만
인가(Authorization) 로직의 복잡도를 낮추고 본인 정보 수정과 관리자의 강제 수정이라는 비즈니스 행위를 명확히 분리하기 위해서/me 엔드포인트를 추가하기로 했다.
단, 서비스 메서드는 같은 것을 사용하고 Role을 인수로 넘겨서 USER와 MASTER if 분기로 나누기로 했는데, 그 이유는 사용하다가 Role 종류가 더 많아졌을 때는 다형성을 이용해 분기를 감추는 Strategy 패턴으로 리펙토링하는것으로 추후 확장을 할 수 있다 생각했기 때문이다.
수정: 각 서비스 메서드로 분기 (Profile/Info dto를 하나의 서비스에서 받으려면 dto를 인터페이스로 추상화해야한다. 현상태에서는 너무 과한 오버엔지니어링 같다고 판단했다.)
/**
* [최종 구조]
* 1. Controller: 요청 주체와 데이터 성격에 따른 엔드포인트 분리 (me vs {userId})
* 2. Service: 인가 권한에 따른 비즈니스 로직(검증 정책) 분리
* 3. Entity: 데이터 성격(Profile vs Info)에 따른 도메인 행위 분리
*/
// --- 1. Controller (Web Layer) ---
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
/** [USER] 본인 프로필 수정 (닉네임 등 감성 정보) */
@PatchMapping("/me")
public ResponseEntity<Void> updateMyProfile(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@RequestBody UpdateProfileRequest req) {
userService.updateMyProfile(userDetails.getUserId(), req);
return ResponseEntity.ok().build();
}
/** [MASTER] 유저 시스템 정보 수정 (등급, 상태 등 비즈니스 정보) */
@PatchMapping("/{userId}")
@PreAuthorize("hasRole('MASTER')")
public ResponseEntity<Void> updateUserInfoByMaster(
@PathVariable UUID userId,
@RequestBody UpdateInfoRequest req) {
userService.updateUserProfileByMaster(userId, req);
return ResponseEntity.ok().build();
}
}
// --- 2. Service (Business Layer) ---
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final NicknameHistoryRepository historyRepository;
/** 본인 프로필 수정: 30일 쿨타임 정책 적용 */
@Transactional
public void updateMyProfile(UUID userId, UpdateProfileRequest req) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(UserErrorCode.NOT_FOUND));
// 닉네임 이력 테이블에서 최신 변경일 조회
LocalDateTime lastChangedAt = historyRepository.findLatestUpdateDate(userId);
user.updateProfile(req.getNickname(), lastChangedAt);
}
/** 마스터의 유저 정보 수정: 정책 검증 우회 및 시스템 필드 변경 */
@Transactional
public void updateUserProfileByMaster(UUID targetId, UpdateInfoRequest req) {
User targetUser = userRepository.findById(targetId)
.orElseThrow(() -> new BusinessException(UserErrorCode.NOT_FOUND));
targetUser.adminUpdateInfo(req.getRole(), req.getRating(), req.getIsSuspended());
}
}
// --- 3. Entity (Domain Layer) ---
@Entity
@Table(name = "p_users")
public class User extends BaseEntity {
// ... 필드 생략 (id, loginId, role, nickname, rating, isSuspended 등)
/** [도메인 메서드] Profile 정보 업데이트 (개인화 데이터) */
public void updateProfile(String nickname, LocalDateTime lastChangedAt) {
validateNicknameInterval(lastChangedAt);
this.nickname = nickname;
}
/** [도메인 메서드] 시스템 Info 업데이트 (관리자 전용) */
public void adminUpdateInfo(Role role, Integer rating, Boolean isSuspended) {
this.role = role;
this.rating = rating;
this.isSuspended = isSuspended;
}
/** 닉네임 변경 30일 제한 검증 */
private void validateNicknameInterval(LocalDateTime lastChangedAt) {
if (lastChangedAt != null && lastChangedAt.plusDays(30).isAfter(LocalDateTime.now())) {
throw new BusinessException(UserErrorCode.NICKNAME_CHANGE_LIMIT);
}
}
}
서비스간 비동기 요청에 팀원 모두에게 익숙한 도구였기에 Feign Client를 사용했다.
수정 엔드포인트를 설계할 땐 외부 API만을 설계해두었기 때문에 문제가 되지 않았는데,
Feign의 기본 클라이언트가 PATCH를 지원하지 않는다는 기술적 제약이 있었다.
요청하는 곳에서 OkHttp(io.github.openfeign:feign-okhttp)나 Apache HttpClient(io.github.openfeign:feign-hc5) 등을 의존하는 방법도 있었다.
하지만 이미 팀원들과 Feign Client를 사용해 개발을 진행하고 있고, PATCH를 쓰기 위해 호출하는 쪽(Consumer)에 특정 라이브러리 추가를 강제하는 것은 제공하는 쪽(Provider)이 본인의 설계 편의를 위해 호출자에게 환경 설정을 떠넘기는 것이 된다.
MSA에서 각 서비스는 독립적으로 운영되어야 하며, 호출하는 서비스가 API 스펙 준수를 위해 불필요한 의존성 관리 리스크(버전 충돌, 인프라 설정 오버헤드 등)를 떠안게 하는 것은 시스템 전체의 결합도를 높이고 유지보수 효율을 떨어뜨린다.
(실제로 어제는 UserContext 관련 공통 모듈 배포후 동작 테스트를 하는데 어떤 의존성을 담아 배포해야할지 고민하고 캐싱때문에 이전 버전의 모듈이 남아있는 문제를 해결하는 데 시간을 많이 사용했다.)
Internal에서는 명시적으로 수정하는 리소스 정보를 담은 POST 엔드포인트를 구성하기로 했다.
유저에 대한 평가 시스템이 도입되지 않은 MVP 단계에서는 외부 서비스에서 유저 정보를 수정할 일이 없어서 internal 구현은 일단 패스!









(구현Skipp)