Spring

연습용 주문 서버 만들기 02 멤버십 발급 API 작성하기

voider 2023. 1. 15. 13:08

연습용 주문 서버 만들기

가장 간단한 아이디 발급 API부터 만들겠습니다.
지난 번 글에도 말했듯이 편의상 회원가입없이 아이디 발급 요청하면 임의의 UUID를 발급하겠습니다.

이 아이디를 membership이라고 하겠습니다. 사용자는 나중에 발급받은 membership를 가지고 포인트를 충전하거나 결제할 수 있습니다.

구현할 요구사항은 아래와 같습니다.

  • 사용자는 누구나 membership 발급 API를 통해 멤버십 번호를 발급받을 수 있다.

따로 무엇을 검증하지 않을 것이므로 매우 간단한 구현이 되겠습니다. 아이디 발급은 실패하는 케이스가 없다고 가정하겠습니다.

api 모듈 구현

api 모듈은 HTTP 요청을 처리하는 역할만 합니다. 실제 비즈니스 로직을 처리하는 부분은 core 모듈로 위임합니다.

order-api/src/main/com/order/api/MembershipPublishApiController 구현

@RestController  
@RequestMapping("/api/v1")  
class MembershipPublishApiController(  
    private val membershipService: MembershipService  
) {  

    @PostMapping("membership")  
    fun publishMembership(): ResponseEntity<MembershipIdResponse> {  

        val membershipId = membershipService.publish()  

        return ResponseEntity(membershipId, HttpStatus.CREATED)  
    }  

}
  1. membershipService 에 멤버십 번호를 publish 요청합니다.
  2. 그 결과를 ResponseEntity 객체에 담아서 반환합니다.

아직은 MembershipService를 만들지 않은 상태여서 컴파일 에러가 발생합니다.

core 모듈 구현

코어 모듈은 비즈니스 로직을 처리합니다. 여기서는 간단한 UUID를 발급하는 역할만 합니다.

order-core/src/main/com/order/core/membership/service/MembershipService

interface MembershipService {  
    fun publish(): MembershipIdResponse  
}
  1. 모듈 간 통신은 인터페이스를 통해서만 합니다. 이렇게 하는 이유는 모듈 간 결합도를 낮추기 위해서입니다.

order-core/src/main/com/order/core/membership/service/code/MembershipDefaultService

@Service  
internal class MembershipDefaultService(  
    private val publishService: MembershipPublishService  
): MembershipService {  

    override fun publish(): MembershipIdResponse {  
        return publishService.createMembership()  
    }  

}
  1. ~DefaultService는 인터페이스에 대한 기본 구현입니다. 디폴트 서비스에서 실제 비즈니스 로직을 담당하는 객체에게 요청을 위임합니다.
  2. 멤버십 발급을 담당하는 MembershipPublishService을 주입받아서 실제로 멤버십을 생성하는 일을 위임합니다. 이렇게 하는 이유는 MembershipServiceImpl 같은 클래스를 만들어서 모든 비즈니스 로직을 처리하는 거대한 클래스를 만들지 않기 위해서입니다.
  3. 여기서 클래스를 internal로 선언했습니다. internal은 같은 모듈 안에서만 해당 클래스에 접근할 수 있다는 코틀린의 접근제어자입니다. 이렇게 접근 범위를 좁힌 이유는 다른 모듈에서 혹시라도 이 구체 클래스를 참조하는 일이 없게 만들기 위해서입니다. 모듈간 소통은 오직 public interface로만 하고, 그외 다른 구체 클래스에는 접근할 수 없습니다.

core/src/main/com/order/core/membership/service/code/MembershipPublishService

@Service  
internal class MembershipPublishService(  
    private val membershipRepository: MembershipRepository  
) {  
    fun createMembership(): MembershipIdResponse {  
        val newMembership = membershipRepository.save(Membership())  

        return MembershipIdResponse.of(newMembership)  
    }  
}

--------
# DTO 객체
data class MembershipIdResponse(  
    val id: Long,  
    val membershipId: String  
) {  
    companion object {  
        internal fun of(membership: Membership) = MembershipIdResponse(  
            id = membership.id,  
            membershipId = membership.membershipUUID  
        )  
    }  
}
  1. MembershipPublishServiceMembership 객체를 생성하여 DB에 저장한 뒤 DTO 객체를 반환합니다.
  2. DTO 객체는 internal이 아닌데 Controller에서도 반환용으로 사용하기 때문에 public합니다.

Repository & Membership
실제 데이터베이스에 접근하는 영역을 또다른 모듈로 분리할 수도 있을 것 같습니다. 하지만 규모가 작을 때는 하나의 모듈에서 처리하는 것이 좀더 경제적일 수도 있을 것 같다는 생각도 듭니다. 이번에는 구현 편의상 한 모듈에서

repository

internal interface MembershipRepository: JpaRepository<Membership, Long>

entity

@Entity  
internal class Membership(  
    membershipUUID: String = UUID.randomUUID().toString()  
) {  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    val id: Long = 0;  

    val membershipUUID: String = membershipUUID  

    val createdAt: LocalDateTime = LocalDateTime.now()  
}

아주 간단하게 이 정도로만 구현하겠습니다.

서버가 실행되는지 확인해보기 전에 몇가지 설정을 해주겠습니다.

 

order-api/src/main/resources/application.yml

spring.profiles.active: local  

server:  
  port: 8080  

spring:  
  config:  
    import:  
      - application-db.yml  
  main:  
    allow-bean-definition-overriding: true

 

order-core/src/main/resources/application-db.yml

spring:  
  jpa:  
    open-in-view: false  
    hibernate:  
      ddl-auto: none  
    properties:  
      hibernate:  
        default_batch_fetch_size: 100  

---  
spring.config.activate.on-profile: local  

spring:  
  jpa:  
    hibernate:  
      ddl-auto: create-drop  
    properties:  
      hibernate:  
        show_sql: true  
        format_sql: true  
    database-platform: org.hibernate.dialect.MySQL8Dialect  
    database: mysql  
  datasource:  
    driver-class-name: com.mysql.cj.jdbc.Driver  
    username: 'root'  
    password: ''  
    url: jdbc:mysql://localhost:3306/order?useSSL=false&serverTimezone=Asia/Seoul  

  sql:  
    init:  
      mode: ALWAYS  

logging:  
  level:  
    org.hibernate.type.descriptor.sql: trace

 

root/docker-compose.yml

version: '3'  

services:  
  mysql:  
    container_name: order-db  
    image: mysql  
    environment:  
      MYSQL_DATABASE: "order"  
      MYSQL_ROOT_HOST: '%'  
      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'  
      TZ: 'Asia/Seoul'  
    ports:  
      - '3306:3306'  
    command:  
      - 'mysqld'  
      - '--character-set-server=utf8mb4'  
      - '--collation-server=utf8mb4_unicode_ci'  
    restart: always

이 설정을 마치고 docker compose up으로 도커를 실행합니다.
그리고 서버를 실행하면, 아래와 같이 테이블이 생성되며 서버가 실행됩니다.

간단한 테스트 코드로 정말로 멤버쉽 아이디를 발급해주는지 검증해보겠습니다.

@AutoConfigureMockMvc
@SpringBootTest
internal class MembershipPublishApiControllerTest {

    @Autowired
    lateinit var mockMvc: MockMvc

    @Test
    fun `membership 발급 요청 201 Created`() {
        mockMvc.post("/api/v1/membership")
            .andExpect {
                status { isCreated() }
                content {
                    contentType(MediaType.APPLICATION_JSON)
                    jsonPath("id") { exists() }
                    jsonPath("membershipId") { exists() }
                    jsonPath("membershipId") { isString() }
                }
            }.andDo {
                print()
            }
    }
}

 

테스트를 실행하면 예상한대로 membershipId를 발급해주는 것을 알 수 있습니다.