Trip-Linker 개발일지 1 - user 도메인 전체 레이어 구현 — Controller·Service·Repository

2026. 6. 4. 19:30카테고리 없음

반응형

user 도메인 전체 레이어 구현 — Controller·Service·Repository

📋 한줄 요약

프로필 조회·닉네임 변경·회원 탈퇴 API를 위한 user 도메인 전체 레이어(Controller→Service→Repository)를 구현했다.


🎯 왜 만들었나

마이페이지에서 내 정보를 보고, 닉네임을 바꾸고, 탈퇴할 수 있어야 한다.

Controller / Service 인터페이스 / ServiceImpl / Repository / DTO 를 한 번에 만들었다.


📁 작업 순서 (파일별)

1. UserRepository.java

📦 domain/user/repository

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}

왜 이렇게 만들었나

Spring Data JPA 메서드 이름 규칙으로 findByUsername 하나만 추가했다.

JpaRepository가 findById, save, delete 등 기본 CRUD를 모두 제공하므로 추가 작성이 거의 없다.


2. UserInfoResponseDto.java

📦 domain/user/dto

@Getter
public class UserInfoResponseDto {
    private Long id;
    private String username;
    private String name;
    private String email;
    private LocalDate birthDate;
    private String gender;
    private String mbti;
    private String region;
    private String status;

    public UserInfoResponseDto(User user) {
        this.id = user.getId();
        this.username = user.getUsername();
        // ... 나머지 필드
    }
}

왜 이 구조로 만들었나

Entity를 그대로 반환하면 passwordHash 같은 민감한 필드가 노출된다.

DTO를 만들어서 응답에 필요한 필드만 골라 담았다.

생성자에 User를 직접 받아서 변환하는 방식을 택했다 — Mapper 없이 단순하게 처리.


3. UserNicknameUpdateRequest.java

📦 domain/user/dto

@Getter
@NoArgsConstructor
public class UserNicknameUpdateRequest {
    private String name; // 새로 변경할 닉네임
}

왜 따로 만들었나

닉네임 변경 요청에 필요한 필드는 name 하나뿐이다.

UserInfoResponseDto를 재사용하면 불필요한 필드가 섞이므로 요청 전용 DTO를 분리했다.


4. UserService.java

📦 domain/user/service

public interface UserService {
    UserInfoResponseDto getProfile(Long userId);
    void updateNickname(Long userId, UserNicknameUpdateRequest request);
    void withdraw(Long userId);
}

왜 인터페이스를 만들었나

Controller가 구현체(UserServiceImpl)가 아닌 인터페이스에 의존하게 한다.

나중에 구현체를 교체하거나 Mock으로 테스트할 때 Controller 코드를 바꾸지 않아도 된다.


5. UserServiceImpl.java

📦 domain/user/service/impl

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)  // 기본값: 읽기 전용
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;

    @Override
    public UserInfoResponseDto getProfile(Long userId) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("해당 유저를 찾을 수 없습니다. ID: " + userId));
        return new UserInfoResponseDto(user);
    }

    @Override
    @Transactional  // 쓰기 작업만 별도로 @Transactional 추가
    public void updateNickname(Long userId, UserNicknameUpdateRequest request) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("해당 유저를 찾을 수 없습니다. ID: " + userId));
        user.updateNickname(request.getName());
    }

    @Override
    @Transactional
    public void withdraw(Long userId) {
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("해당 유저를 찾을 수 없습니다. ID: " + userId));
        user.withdraw(); // Entity 메서드 호출 → status = "DELETED"
    }
}

@Transactional(readOnly = true) 를 클래스에 건 이유

조회가 대부분이므로 클래스 레벨에 readOnly = true를 걸면

JPA가 변경 감지(Dirty Checking)를 건너뛰어 성능이 약간 좋아진다.

쓰기가 필요한 메서드만 @Transactional을 따로 붙여서 오버라이드한다.

user.withdraw() 처럼 Entity 메서드를 호출하는 이유

user.status = "DELETED" 를 Service에서 직접 세팅하지 않았다.

탈퇴 조건(deletedAt 기록 등)이 바뀌어도 User.withdraw() 한 곳만 수정하면 된다.


6. UserController.java

📦 domain/user/controller

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    // GET /api/users/{userId}
    @GetMapping("/{userId}")
    public ResponseEntity<UserInfoResponseDto> getProfile(@PathVariable Long userId) {
        return ResponseEntity.ok(userService.getProfile(userId));
    }

    // PATCH /api/users/{userId}/nickname
    @PatchMapping("/{userId}/nickname")
    public ResponseEntity<String> updateNickname(
            @PathVariable Long userId,
            @RequestBody UserNicknameUpdateRequest request) {
        userService.updateNickname(userId, request);
        return ResponseEntity.ok("닉네임이 성공적으로 변경되었습니다.");
    }

    // DELETE /api/users/{userId}
    @DeleteMapping("/{userId}")
    public ResponseEntity<String> withdraw(@PathVariable Long userId) {
        userService.withdraw(userId);
        return ResponseEntity.ok("회원 탈퇴가 완료되었습니다.");
    }
}

@PatchMapping 을 쓴 이유

닉네임 하나만 바꾸는 건 리소스 일부 수정이라 PUT(전체 교체)보다 PATCH(부분 수정)가 맞다.


7. test.http

📦 src/test

### 닉네임 변경
PATCH <http://localhost:8080/api/users/1/nickname>
Content-Type: application/json

{ "name": "흑인42호" }

### 회원 탈퇴
DELETE <http://localhost:8080/api/users/1>

IntelliJ HTTP Client 파일로 Postman 없이 IDE에서 바로 API 테스트할 수 있다.


💡 배운 점

@Transactional(readOnly = true)를 클래스에 걸고 쓰기 메서드에만 @Transactional을

오버라이드하는 패턴이 표준적인 Service 작성 방식이다.

Entity에 상태 변경 메서드를 넣으면(도메인 모델 패턴) Service가 단순해지고, 조건 변경 시 수정 범위가 줄어든다.