개요
저번 포스팅에서는 브랜치 API 중 IP 기반 국가 코드 조회 API를 개발 및 테스트하는 시간을 가졌었습니다.
이번 포스팅에서는 프로모션 API 중 사전예약 API를 개발 및 테스트 코드까지 작성하는 시간을 가져보겠습니다.
사전예약 API
사전예약 API는 위 사진처럼 사전예약에 참가하고 싶은 사용자의 개인 정보와 같은 정보들을 입력받아 저장/등록하는 API입니다.
사전예약은 게임이 출시하기 이전이나 특정 빅 이벤트가 있을 때 게임사의 웹페이지에 종종 등장하는 것을 자주 보셨을 겁니다.
유저가 사전예약에 참여하면 게임사는 이들의 정보를 바탕으로 차후에 아이템을 지급해 주기도 하고 내부에선 마케팅 지표로 사용하기도 합니다.
사전예약 API 같은 경우에는 보통 웹에서 많이 진행되기 때문에 사내에 웹팀이 있다면 해당 팀에서 자체적으로 만들어서 사용하기도 하지만 개인적으로 플랫폼 조직에서 프로모션 API 명목으로 내려줘도 괜찮다고 생각합니다.
저희 사내에서는 웹팀에서 웹에서 진행하는 사전예약 + 인게임 사전예약(웹뷰 형태)로 진행하는데 다른 회사는 어떠한 방식으로 진행하는지 궁금하기도 합니다.
사전예약 API - AES256 암호화 클래스
package com.gamepleconnect.common.security;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class AES256 {
public static String alg = "AES/CBC/PKCS5Padding";
private static final String key = "01234567890123456789012345678901";
private static final String iv = key.substring(0, 16);
public static String encrypt(String text) throws Exception {
Cipher cipher = Cipher.getInstance(alg);
SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), "AES");
IvParameterSpec ivParamSpec = new IvParameterSpec(iv.getBytes());
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivParamSpec);
byte[] encrypted = cipher.doFinal(text.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(encrypted);
}
public static String decrypt(String cipherText) throws Exception {
Cipher cipher = Cipher.getInstance(alg);
SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), "AES");
IvParameterSpec ivParamSpec = new IvParameterSpec(iv.getBytes());
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivParamSpec);
byte[] decodedBytes = Base64.getDecoder().decode(cipherText);
byte[] decrypted = cipher.doFinal(decodedBytes);
return new String(decrypted, "UTF-8");
}
}
사전예약으로 저장해야 될 정보에는 유저의 이메일, 전화번호 등과 같은 개인정보가 들어있기 때문에 암호화해서 저장해야 추후에 ISMS(정보보호관리체계)와 같은 심사에 통과할 수 있고 내부자가 유저의 개인정보를 유출하지 않도록 방지할 수 있습니다.
보통 암호화는 DB 단에서 하는 방법도 있고 애플리케이션 단계에서 하는 방법 등 여러 가지 방법들이 존재하는데 Gameple 서버에서는 애플리케이션 단계에서 암/복호화를 처리할 수 있도록 구현해 보겠습니다.
위 클래스는 자바에서 AES256을 사용할 수 있도록 구현해놓은 코드입니다.
Encrypt로 암호화를 Decrypt로 복호화를 할 수 있습니다.
(AES-256에 대한 더 자세한 내용은 https://doshisha.tistory.com/121 이 글을 참고해 주세요.)
사전예약 API - API 시퀀스 다이어그램
간단하게 API 시퀀스 다이어그램을 작성해 봤습니다.
Client가 Gameple Server에 사전예약 API 요청을 하면 그 데이터들을 Gameple Server에서 Encrypt 하여 DB에 INSERT 하고 최종적으로 Client에 응답 결과를 보내줍니다.
간단한 API이기 때문에 굳이 작성해야 할까 싶기도 하지만 확실히 API가 어떤 플로우를 가지는지 직관적으로 파악하기도 쉽고 사내 인수인계나 외부에 공개할 때도 시퀀스 다이어그램을 참고하여 이해하기 쉽게 만들기도 좋습니다.
사전예약 API - 예외 케이스
이제 본격적으로 API를 개발하기 전에 사전예약에서 어떠한 예외 케이스들이 있을지 파악해 보겠습니다.
- 유효하지 않은 Request (필수 파라미터 누락 등)
- 존재하지 않는 게임 코드
- 이미 사전예약 참여한 이메일
제가 생각해 봤던 케이스는 이렇게 총 3가지 케이스가 있습니다.
제가 놓친 예외가 있다면 댓글로 알려주시면 감사드리겠습니다.
사전예약 API - 요청과 응답 설계
그렇다면 Request와 Response에는 어떤 프로퍼티들이 필요할지 설계해보겠습니다.
@Getter
public class ReservationRequestDto {
@Schema(description = "이메일" , example = "test@test.com")
@Email()
@NotBlank()
private String email;
@Schema(description = "게임 코드" , example = "1")
@NotNull()
private Long gameCode;
@Schema(description = "국가" , example = "KR")
@NotBlank()
private String region;
@Schema(description = "디바이스 OS" , example = "AOS")
private String deviceOs;
@Schema(description = "디바이스 기종" , example = "iPhone 7")
private String deviceModel;
@Schema(description = "마케팅 수신 동의" , example = "true")
@NotNull()
private boolean promotionAgree;
@Builder
public ReservationRequestDto(String email, Long gameCode, String region, String deviceOs, String deviceModel, boolean promotionAgree) {
this.email = email;
this.gameCode = gameCode;
this.region = region;
this.deviceOs = deviceOs;
this.deviceModel = deviceModel;
this.promotionAgree = promotionAgree;
}
}
위 클래스는 API 요청 시 필요한 DTO 클래스입니다.
사전예약 시에 이메일, 폰번호, 이메일 + 폰번호 이렇게 사용자의 정보를 받는 방법은 여러 가지이지만 일단 이메일만 받게 설계해 보도록 하겠습니다.
게임 코드도 받을 수 있게 하여 사전예약 시 어떤 게임의 사전예약인지 구분할 수 있도록 해놓았고 디바이스 기종/OS는 수집할 수 있으면 수집하도록 Nullable 하게 해놓도록 하겠습니다.
@Data
public class ApiResponse implements Serializable {
@Schema(description = "서버 정의 상태코드" , example = "1")
private String statusCode;
@Schema(description = "서버 정의 메세지" , example = "1")
private String message;
@Schema(description = "데이터")
private Object data;
@Builder
public ApiResponse(String statusCode, String message, Object data) {
this.statusCode = statusCode;
this.message = message;
this.data = data;
}
}
위 클래스는 API 응답으로 내려줄 DTO 클래스입니다.
이전 글에서도 언급했듯이 모든 API는 위 클래스를 사용하여 응답을 내려줄 수 있도록 하겠습니다.
따로 응답 후에 Data는 내려줄 필요가 없다고 생각되기 때문에 상태 코드값만 내려줄 수 있도록 하겠습니다.
사전예약 API - Entity 설계
@Getter
@Entity
@Table(name = "gameple_promotion_advance_reservation")
@NoArgsConstructor
@ToString
public class Reservation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "reservation_id")
private Long id;
@Column(name = "user_email")
private String email;
@Column(name = "created_ip")
private String createdIp;
@Enumerated(EnumType.STRING)
@Column(name = "device_os")
private DeviceOS deviceOS;
@Column(name = "device_model")
private String deviceModel;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "game_code")
private Game game;
@Column(name = "user_region")
private String region;
@Column(name = "promotion_agree")
@ColumnDefault("false")
private boolean promotionAgree;
@CreationTimestamp
@Temporal(TemporalType.TIMESTAMP)
private Date createDate;
@Builder
public Reservation(String email, String createdIp, DeviceOS deviceOS, String deviceModel, Game game, String region, boolean promotionAgree) {
this.email = email;
this.createdIp = createdIp;
this.deviceOS = deviceOS;
this.deviceModel = deviceModel;
this.game = game;
this.region = region;
this.promotionAgree = promotionAgree;
}
}
어떤 값들이 Request로 들어오는지 설계했으니 본격적으로 엔티티를 설계해 보겠습니다.
기본적으로 이메일, 국가, IP 등을 저장할 수 있어야 하고 등록일은 Default Now로 설정해두겠습니다.
디바이스 OS(DeviceOS)는 모바일 기준 AOS/IOS로 분류하는 게 일반적이기 때문에 Enum Type으로 관리할 수 있도록 하겠습니다.
추가로 사전예약 엔티티는 Game 엔티티와 다대일 관계이므로 @ManyToOne이 됩니다.
public enum DeviceOS {
AOS("AOS"),
IOS("IOS");
private final String value;
private static final Map<String, DeviceOS> enumMap = new HashMap<>();
static {
for (DeviceOS os : DeviceOS.values()) {
enumMap.put(os.getValue(), os);
}
}
DeviceOS(String value) {
this.value = value;
}
public String getValue() {
return value;
}
public static DeviceOS valueOfOrNull(String value) {
return value != null ? enumMap.get(value) : null;
}
}
사전예약 API - API 구현
@Tag(name = "Reservation", description = "프로모션 - 사전예약 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/promotion")
public class ReservationController {
private final ReservationService reservationService;
@Operation(summary = "사전예약 등록 API", description = "사전예약 정보를 등록하는 API입니다.")
@PostMapping("/pre-register")
public ApiResponse preRegister(@RequestBody @Valid ReservationRequestDto requestDto) throws Exception {
return reservationService.preRegister(requestDto);
}
}
@Service
@RequiredArgsConstructor
@Slf4j
public class ReservationService {
private final ReservationRepository reservationRepository;
private final GameRepository gameRepository;
@Transactional
public ApiResponse preRegister(ReservationRequestDto requestDto) throws Exception {
Game game = gameRepository.findByGameCode(requestDto.getGameCode())
.orElseThrow(GameNotFoundException::new);
if(reservationRepository.existsByEmail(AES256.encrypt(requestDto.getEmail()), game)) {
throw new DuplicatedEmailException();
}
Reservation reservation = Reservation.builder()
.email(AES256.encrypt(requestDto.getEmail()))
.createdIp(AES256.encrypt(CommonUtil.getIp()))
.deviceOS(DeviceOS.valueOfOrNull(requestDto.getDeviceOs()))
.deviceModel(requestDto.getDeviceModel())
.promotionAgree(requestDto.isPromotionAgree())
.game(game)
.region(requestDto.getRegion())
.build();
reservationRepository.save(reservation);
log.info("RESERVATION INFO SAVE : {}", reservation.toString());
return ApiResponse.builder()
.statusCode(StatusCode.SUCCESS.getStatusCode())
.message(StatusCode.SUCCESS.getMessage())
.data(null)
.build();
}
}
public class ReservationCustomRepositoryImpl implements ReservationCustomRepository {
private final JPAQueryFactory jpaQueryFactory;
public ReservationCustomRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
this.jpaQueryFactory = jpaQueryFactory;
}
@Override
public boolean existsByEmail(String email, Game game) {
Integer fetchOne = jpaQueryFactory
.selectOne()
.from(reservation)
.where(reservation.game.eq(game))
.where(reservation.email.eq(email))
.fetchFirst();
return fetchOne != null;
}
}
본격적으로 API를 구현해 보겠습니다.
Controller에서는 RequestBody + Valid로 RequestDto를 받아 Request에 대한 유효성 검증(실패 시 400)을 해줍니다.
Service 레이어에서는 첫 번째로 RequestBody를 통해 얻을 수 있는 GameCode를 바탕으로 게임 엔티티를 조회해옵니다. (존재하지 않는 게임 정보라면 GameNotFoundException을 Throw)
두 번째로는 게임 엔티티 정보가 존재한다면 이미 유저가 사전예약에 참여했을 수도 있기 때문에 ReservationRepository에서 이미 등록된 이메일인지 체크해 줍니다.
=> 이미 등록된 이메일인지 체크하는 부분에서 JPA의 exists 쿼리가 아닌 fetchFirst()를 사용해 줬는데 JPA의 exists보다 limit 1로 1건만 조회하여 존재 여부를 확인하는 것이 성능상 훨씬 좋습니다.
(자세한 내용은 https://jojoldu.tistory.com/516 동욱님의 블로그를 참고하면 됩니다.)
위 두 조건에서 예외가 발생하지 않았다면 문제가 없는 요청이라 판단하여 사전예약 엔티티를 Builder를 통해 생성하여 save() 해줍니다.
사전예약 API - API 호출
위와 같이 정상적으로 응답이 오는 것을 확인할 수 있습니다.
DB에도 정상적으로 저장된 것을 확인할 수 있습니다.
사전예약 API - 테스트 코드 작성
@AutoConfigureCache
@AutoConfigureMockMvc
@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ReservationControllerTest {
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
@Autowired
private MockMvc mockMvc;
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
@Autowired
ObjectMapper objectMapper;
@Autowired
GameRepository gameRepository;
@Autowired
private ReservationRepository reservationRepository;
@BeforeEach
@Order(1)
void cleanRepository() {
reservationRepository.deleteAll();
gameRepository.deleteAll();
}
@BeforeEach
@Order(2)
void saveDummyData() {
Game game = Game.builder()
.gameCode(1L)
.gameAlias("GAME - 1")
.build();
gameRepository.save(game);
}
@Test
@DisplayName("사전예약 정보 등록 - 성공")
void test() throws Exception{
ReservationRequestDto requestDto = ReservationRequestDto.builder()
.email("test@test.com")
.gameCode(1L)
.region("KR")
.deviceOs("IOS")
.promotionAgree(false)
.build();
String json = objectMapper.writeValueAsString(requestDto);
mockMvc.perform(post("/promotion/pre-register")
.contentType(APPLICATION_JSON)
.content(json)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value("1"))
.andExpect(jsonPath("$.message").value("OK"))
.andExpect(jsonPath("$.data").isEmpty())
.andDo(print());
}
@Test
@DisplayName("사전예약 정보 등록 - 실패 - 존재하지 않는 게임 정보")
void test1() throws Exception{
ReservationRequestDto requestDto = ReservationRequestDto.builder()
.email("test@test.com")
.gameCode(2L)
.region("KR")
.deviceOs("IOS")
.promotionAgree(false)
.build();
String json = objectMapper.writeValueAsString(requestDto);
mockMvc.perform(post("/promotion/pre-register")
.contentType(APPLICATION_JSON)
.content(json)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value("-5"))
.andExpect(jsonPath("$.message").value("GAME_NOT_FOUND"))
.andExpect(jsonPath("$.data").isEmpty())
.andDo(print());
}
@Test
@DisplayName("사전예약 정보 등록 - 실패 - 이미 동록된 이메일 정보")
void test2() throws Exception {
ReservationRequestDto requestDto = ReservationRequestDto.builder()
.email("test@test.com")
.gameCode(1L)
.region("KR")
.deviceOs("IOS")
.promotionAgree(false)
.build();
String json = objectMapper.writeValueAsString(requestDto);
mockMvc.perform(post("/promotion/pre-register")
.contentType(APPLICATION_JSON)
.content(json)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value("1"))
.andExpect(jsonPath("$.data").isEmpty())
.andDo(print());
mockMvc.perform(post("/promotion/pre-register")
.contentType(APPLICATION_JSON)
.content(json)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value("-7"))
.andExpect(jsonPath("$.message").value("ALREADY_EXISTS_EMAIL"))
.andExpect(jsonPath("$.data").isEmpty())
.andDo(print());
}
@Test
@DisplayName("사전예약 정보 등록 - 실패 - BAD REQUEST")
void test3() throws Exception {
ReservationRequestDto requestDto = ReservationRequestDto.builder()
.gameCode(2L)
.deviceOs("IOS")
.promotionAgree(false)
.build();
String json = objectMapper.writeValueAsString(requestDto);
mockMvc.perform(post("/promotion/pre-register")
.contentType(APPLICATION_JSON)
.content(json)
)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.statusCode").value("-1"))
.andExpect(jsonPath("$.message").value("BAD_REQUEST"))
.andExpect(jsonPath("$.data").isEmpty())
.andDo(print());
}
}
@AutoConfigureCache
@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ReservationServiceTest {
@Autowired
ReservationService reservationService;
@Autowired
ReservationRepository reservationRepository;
@Autowired
GameRepository gameRepository;
@BeforeEach
@Order(1)
void cleanRepository() {
reservationRepository.deleteAll();
gameRepository.deleteAll();
}
@BeforeEach
@Order(2)
void saveDummyData() {
Game game = Game.builder()
.gameCode(1L)
.gameAlias("GAME - 1")
.build();
gameRepository.save(game);
}
@Test
@DisplayName("사전예약 정보 등록 - 성공")
void test1() throws Exception {
ReservationRequestDto requestDto = ReservationRequestDto.builder()
.email("test@test.com")
.gameCode(1L)
.region("KR")
.deviceOs("IOS")
.promotionAgree(false)
.build();
reservationService.preRegister(requestDto);
assertEquals(1L, reservationRepository.count());
Reservation reservation = reservationRepository.findAll().get(0);
assertEquals(AES256.encrypt("test@test.com"), reservation.getEmail());
}
@Test
@DisplayName("사전예약 정보 등록 - 실패 - 존재하지 않는 게임 정보")
void test2() {
ReservationRequestDto requestDto = ReservationRequestDto.builder()
.email("test@test.com")
.gameCode(2L)
.region("KR")
.deviceModel("iPhone 22")
.deviceOs("IOS")
.promotionAgree(false)
.build();
assertThrows(GameNotFoundException.class, () -> {
reservationService.preRegister(requestDto);
});
}
@Test
@DisplayName("사전예약 정보 등록 - 실패 - 이미 등록된 이메일 정보")
void test4() throws Exception {
ReservationRequestDto firstRequestDto = ReservationRequestDto.builder()
.email("test@test.com")
.gameCode(1L)
.region("KR")
.deviceModel("iPhone 22")
.deviceOs("IOS")
.promotionAgree(false)
.build();
reservationService.preRegister(firstRequestDto);
ReservationRequestDto secondRequestDto = ReservationRequestDto.builder()
.email("test@test.com")
.gameCode(1L)
.region("KR")
.deviceModel("iPhone 22")
.deviceOs("IOS")
.promotionAgree(false)
.build();
assertThrows(DuplicatedEmailException.class, () -> {
reservationService.preRegister(secondRequestDto);
});
}
}
위와 같이 ReservationController 와 ReservationService 클래스에 대한 테스트 코드를 작성합니다.
- 사전예약 - 성공
- 사전예약 - 실패 1(BAD_REQUEST)
- 사전예약 - 실패 2(존재하지 않는 게임 정보)
- 사전예약 - 실패 3(이미 등록된 이메일)
총 4개의 테스트 케이스에 대한 테스트 코드를 작성했고
모두 성공한 것을 확인할 수 있습니다.
레포지토리 주소
'Project > Gameple' 카테고리의 다른 글
[Project - Gameple] Project Gameple(7) - 브랜치 API - IP 기반 국가 코드 조회 API (0) | 2023.09.13 |
---|---|
[Project - Gameple] Project Gameple(6) - Game Entity 설계 (0) | 2023.07.16 |
[Project - Gameple] Project Gameple(5) - Spring Security 적용하기 (0) | 2023.07.07 |
[Project - Gameple] Project Gameple(4) - Swagger 적용하기 (0) | 2023.06.29 |
[Project - Gameple] Project Gameple(3) - 프로젝트 생성 (0) | 2023.06.27 |