본문으로 바로가기

팩토리 메서드 패턴이란?
팩토리 메서드 패턴은 객체 생성을 캡슐화하여, 객체 생성 로직을 별도의 메서드로 분리하는 디자인 패턴 이를 통해 객체 생성 방식을 통제하고, 생성 과정이 변경되더라도 클라이언트 코드의 수정을 최소화

 

기존방식 (팩토리 메서드 미적용)

val user = User(
    password = encoder.encode(signUpRequest.password),
    name = signUpRequest.name,
    age = signUpRequest.age,
    email = signUpRequest.email
)

- 기존 방식의코드는 api 메소드 마다 user 값을 setting 해주려면 하나하나 전부 setting 해줘야하는 단점이 있다.

 

변경된 방식 (팩토리 메서드 적용)

val user = User.from(signUpRequest, encoder)

- 팩토리 메서드를 적용하면 api 메서드 마다 .from을 호출하는식으로 캡슐화를 해서, 코드도 더 간결해 보인다.

 

- 팩토리 메서드 역할

from 메서드는 SignUpRequest DTO와 PasswordEncoder를 받아, User 객체를 생성하는 역할
비밀번호는 평문으로 저장하면 보안상 문제가 있기 때문에, encoder.encode(request.password)를 통해 암호화된 비밀번호를 저장하는 로직을 캡슐화
이를 통해 User 객체를 직접 생성하지 않고, from 메서드를 통해 일관된 방식으로 안전하게 객체를 생성

 

- 팩토리 메서드 패턴의 장점 적용

객체 생성을 캡슐화
User 객체의 생성 로직이 한 곳(from)에 모여 있어, 객체 생성을 단순화하고 코드 중복을 방지
유지보수성 증가
만약 User 생성 방식이 바뀌어도 from 메서드만 수정하면 되므로, 다른 코드에 영향을 주지 않습니다.
의미 있는 생성 메서드 제공
from(request, encoder)는 User 객체를 생성하는 의미를 직관적으로 전달
생성자를 직접 호출하는 것보다 가독성이 좋아지고, 명확한 객체 생성 방법을 제공

 

그러면 이전에 작성한 코드에서 팩토리 메서드 패턴을 적용해 보자

 

User.kt

package com.contact.management.entity

import com.contact.management.audit.BaseEntity
import com.contact.management.dto.SignUpRequest
import com.contact.management.dto.UserUpdateRequest
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import org.springframework.security.crypto.password.PasswordEncoder

@Entity
@Table(name = "users")
class User (
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long?= null,
    var password: String,
    var name: String,
    var age: Int,
    var email: String
) : BaseEntity(){
    companion object {
        fun from(request: SignUpRequest, encoder: PasswordEncoder) = User(
            password = encoder.encode(request.password),
            name = request.name,
            age = request.age,
            email = request.email
        )
    }

    fun update(request: UserUpdateRequest, encoder: PasswordEncoder){
        this.password = encoder.encode(request.password)
        this.name = request.name
        this.age = request.age
    }

}

 

UserController.kt

package com.contact.management.controller

import com.contact.management.dto.SignUpRequest
import com.contact.management.dto.UserResponse
import com.contact.management.dto.UserUpdateRequest
import com.contact.management.service.QuerydslUserService
import com.contact.management.service.UserService
import jakarta.validation.Valid
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/api/users")
class UserController(
     private val userService: UserService
    ,private val querydslUserService: QuerydslUserService
) {

    @GetMapping
    fun getUsers(): ResponseEntity<List<UserResponse>> {
        val users = userService.getAllUsers()
        return ResponseEntity.ok(users)
    }

    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): ResponseEntity<UserResponse> {
        return ResponseEntity.ok(userService.getUserById(id))
    }

    @PostMapping
    fun createUser(@Valid @RequestBody request: SignUpRequest): ResponseEntity<UserResponse> {
        val createdUser = userService.createdUser(request)
        return ResponseEntity
            .status(HttpStatus.CREATED) // 201 Created
            .header(HttpHeaders.LOCATION, "/api/users/${createdUser.id}") // 생성된 리소스의 URI 반환
            .body(createdUser)
    }

    @PutMapping("/{id}")
    fun updateUser(@PathVariable id: Long,@Valid @RequestBody request: UserUpdateRequest): ResponseEntity<UserResponse> {
        val updatedUser = userService.updateUser(id, request)
        return ResponseEntity.ok(updatedUser)
    }

    @DeleteMapping("/{id}")
    fun deleteUser(@PathVariable id: Long): ResponseEntity<Void> {
        userService.deleteUser(id)
        return ResponseEntity.noContent().build() // 204 No Content
    }

    @GetMapping("/paged")
    fun getUsersWithPaging(pageable: Pageable, @RequestParam searchVal: String?): Page<UserResponse> {
        // 페이징된 사용자 목록 반환
        return querydslUserService.getUsersWithPaging(pageable, searchVal)
    }
}

- 기존에 dto를 UserDto 하나만 사용해서 실제 api를 만들때는 그렇게 하지 않기때문에 나눴습니다.

UserDto -> SignUpRequest, UserUpdateRequest

SignUpRequest -> 사용자 등록할때 사용하는 요청 파라미터

UserUpdateRequest -> 사용자 수정할때 사용하는 요청 파라미터

UserResponse 추가

 

SignUpRequest.kt

package com.contact.management.dto

import jakarta.validation.constraints.Email
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Pattern
import jakarta.validation.constraints.Size

data class SignUpRequest(
    val id: Long?= null,

    @field:Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.")
    @field:Pattern(
        regexp = "^(?=.*[0-9])(?=.*[!@#\$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).{8,}\$",
        message = "비밀번호는 최소 1개 이상의 숫자와 특수문자를 포함해야 합니다."
    )
    val password:String,

    @field:NotBlank(message = "이름은 필수 입력 항목입니다.")
    @field:Size(min = 2, max = 50, message = "이름은 2~50자 사이여야 합니다.")
    val name: String,

    @field:Min(value = 0, message = "나이는 0 이상이어야 합니다.")
    @field:Max(value = 150, message = "나이는 150 이하여야 합니다.")
    val age: Int,

    @field:NotBlank(message = "이메일은 필수 입력 항목입니다.")
    @field:Email(message = "올바른 이메일 형식을 입력하세요.")
    val email: String
)

- validation을 추가

 

UserUpdate.kt

package com.contact.management.dto

import jakarta.validation.constraints.*

data class UserUpdateRequest(

    @field:Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.")
    @field:Pattern(
        regexp = "^(?=.*[0-9])(?=.*[!@#\$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).{8,}\$",
        message = "비밀번호는 최소 1개 이상의 숫자와 특수문자를 포함해야 합니다."
    )
    val password:String,

    @field:NotBlank(message = "이름은 필수 입력 항목입니다.")
    @field:Size(min = 2, max = 50, message = "이름은 2~50자 사이여야 합니다.")
    val name: String,

    @field:Min(value = 0, message = "나이는 0 이상이어야 합니다.")
    @field:Max(value = 150, message = "나이는 150 이하여야 합니다.")
    val age: Int,

    @field:NotBlank(message = "이메일은 필수 입력 항목입니다.")
    @field:Email(message = "올바른 이메일 형식을 입력하세요.")
    val email: String
)

- validation을 추가

 

UserResponse.kt

package com.contact.management.dto

import com.contact.management.entity.User
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.Size
import org.springframework.security.crypto.password.PasswordEncoder

data class UserResponse(
    val id: Long?= null,
    val name: String,
    val age: Int,
    val email: String
){
    companion object {
        fun from(user: User) = UserResponse(
            id = user.id,
            name = user.name,
            age = user.age,
            email = user.email
        )
    }
}

- UserResponse 추가

 

UserService.kt

package com.contact.management.service

import com.contact.management.dto.SignUpRequest
import com.contact.management.dto.UserResponse
import com.contact.management.dto.UserUpdateRequest
import com.contact.management.entity.QUser
import com.contact.management.entity.User
import com.contact.management.exception.CommonException
import com.contact.management.exception.CommonExceptionCode.EMAIL_ALREADY_EXISTS
import com.contact.management.exception.CommonExceptionCode.USER_NOT_FOUND
import com.contact.management.repository.UserRepository
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional
class UserService(
     private val userRepository: UserRepository
    ,private val queryFactory: JPAQueryFactory
    ,private val encoder: PasswordEncoder
) {

    @Transactional(readOnly = true)
    fun getAllUsers() : List<UserResponse> {
        return userRepository.findAll().map { UserResponse.from(it) }
    }

    @Transactional(readOnly = true)
    fun getUserById(id: Long): UserResponse {
        val user = userRepository.findById(id).orElseThrow{ CommonException(USER_NOT_FOUND) }
        return UserResponse.from(user)
    }

    fun createdUser(request: SignUpRequest): UserResponse {
        userRepository.findByEmail(request.email)?.let {
            throw CommonException(EMAIL_ALREADY_EXISTS)
        }

        val user = userRepository.save(User.from(request, encoder))
        return UserResponse.from(user)
    }

    // 사용자 업데이트
    fun updateUser(id: Long, request: UserUpdateRequest): UserResponse{
        val user = userRepository.findById(id).orElseThrow{ CommonException(USER_NOT_FOUND) }

        user.update(request, encoder)

        return UserResponse.from(user)
    }

    fun deleteUser(id: Long){
        if(!userRepository.existsById(id)){
            throw CommonException(USER_NOT_FOUND)
        }
        userRepository.deleteById(id)
    }

    @Transactional(readOnly = true)
    fun getUsersWithPaging(pageable: Pageable): Page<UserResponse> {
        val qUser = QUser.user

        // JPAQueryFactory를 사용하여 쿼리 작성
        val query = queryFactory
            .selectFrom(qUser)
            .orderBy(qUser.id.asc()) // ID 기준으로 오름차순 정렬 (필요에 따라 변경 가능)

        // 실제 데이터 조회
        val users = query
            .offset(pageable.offset) // 페이지의 시작 인덱스
            .limit(pageable.pageSize.toLong()) // 페이지 크기
            .fetch() // 결과 가져오기

        // 총 개수를 구하는 쿼리 작성 (countQuery)
        val countQuery = queryFactory
            .select(qUser.count()) // 총 개수 계산
            .from(qUser)
        val total = countQuery.fetchOne() ?: 0L // fetchOne()으로 단일 값 가져오기

        // Page 객체로 반환
        val userDtos = users.map { UserResponse.from(it) }
        return PageImpl(userDtos, pageable, total)
    }

}

 

User.kt에서 작성한 from, update를 호출하게 service단에서 변경

passwordEncoder는 아직 Bean으로 등록을 하지않아서, 다음 포스팅에 security 적용하면서 작성할 예정입니다.