안녕하세요.
제가 요번에 진행 중인 프로젝트에서 IOS Push 알람을 보내는 기능을 맡게 되었습니다. 이것만으로 새로운 도전인데 이를 TDD 방법론을 사용하여 구현하기로 목표를 잡아봤습니다.
0. 준비 단계
https://tangy-tibia-f80.notion.site/APNs-008035271c0743599b0575ce4e855296?pvs=4
APNs | Notion
Push Notification
tangy-tibia-f80.notion.site
IOS는 치사하지만? 직접 Notification을 넣어줄 수 없습니다. Apns라는 IOS 서버를 통해서 Notification을 보낼 수 있습니다.
즉 저희 서비스 서버와 Apns 서버 간의 통신을 구현해야 합니다. 아래는 구현 계획 단계에서 작성한 그림입니다.
- Device Token은 app을 사용 중인 기기에 대한 고유 식별값입니다.
- app bundle id는 app 등록 id입니다.
Firebase를 사용하여 구현하는 예제들이 많았지만, 저는 서버와 APNs를 직접 연결하여 알람을 보내는 방식을 선택하였습니다. 다른 서드파티 서버를 사용하지 않은 이유는 현재 단계에서 알람 서버를 분리해야 할 필요성을 찾지 못했기 때문입니다.
(추후 통합 테스트 등 기능적 이슈가 생기면 고려)
++
Apns와 Spring 서버 간의 통신을 위한 인증서 절차가 필요합니다
https://developthreefeet.tistory.com/6
Spring 서버에서 사용할 APNS 인증서 준비
APNS(apple Push Notification Service)는 Apple device에서 앱이 보안 연결을 통해 원격 서버에서 사용자에게 푸시 알림을 보낼 수 있게 하는 클라우드 서비스입니다. 간략한 로직은 아래와 같습니다. 이 글은
developthreefeet.tistory.com
저는 MacOs 사용자도 아니고..IosDeveloper 계정도 없어서 프로젝트 팀원분에게 부탁하여 .p12 인증서를 받았습니다. 이 영광을 matthew 님에게 돌립니다.
1. 구현 과정
1.1 환경
- java 17
- spring 3.x
- gradle
1.2 의존성
- pushy 라이브러리 추가
-Test 환경 H2 DB 추가
implementation 'com.eatthepath:pushy:0.15.4'
runtimeOnly 'com.h2database:h2'
1.3 Config 파일 작성
Apns 서버와 연결시킬 객체를 Bean으로 등록하여 관리하도록 하였습니다. (https://jsonobject.tistory.com/490)
Spring Boot, APNs, 푸시 서버 제작하기
개요 애플이 제공하는 APNs를 이용하면, 내가 제작한 앱이 설치된 애플의 제품군에 해당하는 모든 기기에 메시지를 전송할 수 있다. 이번 글에서는 Kotlin, Spring Boot 기반 프로젝트에서 APNs를 이용
jsonobject.tistory.com
Test Code
import com.eatthepath.pushy.apns.ApnsClient;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
public class ApnsBeanTest {
@Autowired
private ApnsClient apnsClient;
@Test
@DisplayName("Apns Bean Create Test")
void ConnectionTest() throws IOException {
assertThat(apnsClient).isNotNull();
apnsClient.close();
}
}
- 통합 build 테스트가 아닌 특정 유닛 테스트에서 @SpringBootTest를 사용하는 것이 적절한가에 대한 의문이 계속 남아있습니다. 하지만 @SpringBootTest가 아닌 환경에서 코드 진행 시 .yml에 존재하는 secretvalue 값(.p12 password) 을 읽어 오지 못했습니다.
@Value variables are NULL in Unit Test with @TestPropertySource
In the following unit test, where I provide properties both manually and try to read them from an existing YAML resource file (different strategies were tried with @TestPropertySource), the @Value{...
stackoverflow.com
"Here's what I've finally settled on. We must use @SpringBootTest to load in the test properties (application-test.yaml) and get the @Value(..), but that unfortunately causes the whole application to run."
- @ActiveProfiles('test) 는 yml에 profile 설정이 가능해져서 테스트 yml을 따로 작성하지 않고 profile을 통해 세팅을 구분하였습니다.
spring:
profiles:
group:
"local" : "local,test,jwt,oauth"
active : local
---
server
# 생략
# test profile
---
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
username: root
password: 1234
jpa:
generate-ddl: true
hibernate:
ddl-auto: create-drop
open-in-view: false
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
apns:
password: ***
host: api.sandbox.push.apple.com:443
jwt:
secretKey: ***
access:
header: AccessToken
expiration: 30
refresh:
header: RefreshToken
expiration: 120
Code
import com.eatthepath.pushy.apns.ApnsClient;
import com.eatthepath.pushy.apns.ApnsClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
@Configuration
public class ApnsConfig {
@Value("${apns.host}")
private String APNS_SERVER;
@Value("${apns.password}")
private String PASSWORD;
ApnsClientBuilder apnsClientBuilder = new ApnsClientBuilder();
@Bean
public ApnsClient AgnAPNsClient() throws IOException {
return apnsClientBuilder
.setApnsServer(APNS_SERVER)
.setClientCredentials(new ClassPathResource("incertifcation.p12").getInputStream(),PASSWORD)
.build();
}
}
1.4 기능 개발
Method : getDeviceToken()
Test Code
@Test
@DisplayName("Error:Not Match UUid")
void error_NotMatchUUid() {
//given
doThrow(new AgnException(ErrorCode.MEMBER_NOT_FOUND))
.when(memberRepository)
.findByUuid(any(byte[].class));
//when
final AgnException result = assertThrows(AgnException.class,
() -> apnsService.getDeviceToken(new byte[10])
);
//then
assertThat(result.getErrorCode()).isEqualTo(ErrorCode.MEMBER_NOT_FOUND);
}
@Test
@DisplayName("Error:DeviceToken Format")
void error_DeviceTokenFormatError(){
//given
doReturn(getIncorrectDeviceTokenMember())
.when(memberRepository)
.findByUuid(any(byte[].class));
//when
final AgnException result = assertThrows(AgnException.class,
() -> apnsService.getDeviceToken(new byte[10])
);
//then
assertThat(result.getErrorCode()).isEqualTo(ErrorCode.MEMBER_INCORRECT_DEVICETOKEN);
}
@Test
@DisplayName("Success:Found DeviceToken")
void success_FoundDeviceToken(){
//given
doReturn(getNormalMember())
.when(memberRepository)
.findByUuid(any(byte[].class));
//when
String deviceToken = apnsService.getDeviceToken(new byte[20]);
//then
assertThat(deviceToken).isEqualTo("12345678");
}
public Optional<Member> getNormalMember(){
return Optional.of(new Member("tester", Role.USER,"12345678"));
}
public Optional<Member> getIncorrectDeviceTokenMember(){
return Optional.of(new Member("tester",Role.USER,"1"));
}
- 지난번 CRUD Test Code와 크게 다를 것 없습니다. Mock 객체를 사용하여 apnsService에 deviceToken을 찾는 기능에 대한 테스트 코드를 작성했습니다.
Code
public String getDeviceToken(byte[] uuid){
Member member = memberRepository.findByUuid(uuid).orElseThrow(()->new AgnException(ErrorCode.MEMBER_NOT_FOUND));
try{
// 토큰 유효성 검사
if(member.getDeviceToken().length() != 8){
throw new AgnException(ErrorCode.MEMBER_INCORRECT_DEVICETOKEN);
}
}catch (NullPointerException e){
throw new AgnException(ErrorCode.MEMBER_INCORRECT_DEVICETOKEN);
}
return member.getDeviceToken();
}
- NullPointerException을 잡아준 이유는 토큰 유효성 검사에서 토큰 값이 null 인 경우 예외가 발생하기 때문입니다.
- 슬프지만 실제 deviceToken을 받아본 적이 없어서 '8자리'라는 임시 조건을 통해 유효성 검사코드를 작성하였습니다.
Method : sendNotification ()
처음 코드는 동일하게 apnsClient를 Mocking 하여 테스트 코드를 작성했습니다. apnsClient 를 통해 서버로 Noti를 보내면
PushNotificationFuture<simpleapnspushnotification,pushnotificationresponse<simpleapnspushnotification,pushnotificationresponse 의 타입의 객체가 반환됩니다.
final PushNotificationFuture<SimpleApnsPushNotification,PushNotificationResponse<SimpleApnsPushNotification>>
sendNotificationFuture = apnsClient.sendNotification(pushNotification);
복잡한 객체인 만큼 mocking 과정에서 반환할 테스트를 위한 객체를 만드는 과정에서 이슈가 생겼습니다. 객체의 구조가 너무 복잡하기도 하고, 테스트 검증을 위한 특정 객체를 만드는 과정에서 잘 이뤄지지 않았습니다.
그러던 중 pushy git 레포에서 test를 위해 pushyMockServer를 구현하는 코드를 찾아서 이를 활용하여 TestCode를 작성하였습니다.
https://github.com/jchambers/pushy
GitHub - jchambers/pushy: A Java library for sending APNs (iOS/macOS/Safari) push notifications
A Java library for sending APNs (iOS/macOS/Safari) push notifications - jchambers/pushy
github.com
Set MockApnsServer
public class AbstractClientServerTest {
protected static NioEventLoopGroup SERVER_EVENT_LOOP_GROUP;
protected static final String CA_CERTIFICATE_FILENAME = "/ca.pem";
protected static final String SERVER_CERTIFICATES_FILENAME = "/server-certs.pem";
protected static final String SERVER_KEY_FILENAME = "/server-key.pem";
protected static final String MULTI_TOPIC_CLIENT_KEYSTORE_FILENAME = "/multi-topic-client.p12";
protected static final String KEYSTORE_PASSWORD = "pushy-test";
protected static final String HOST = "localhost";
protected static final int PORT = 8443;
protected static final String TEAM_ID = "team-id";
protected static final String KEY_ID = "key-id";
protected static final String TOPIC = "com.eatthepath.pushy";
protected static final String DEVICE_TOKEN = generateRandomDeviceToken();
protected static final String PAYLOAD = generateRandomPayload();
protected static final Map<String, Set<String>> DEVICE_TOKENS_BY_TOPIC =
Collections.singletonMap(TOPIC, Collections.singleton(DEVICE_TOKEN));
protected static final Map<String, Instant> EXPIRATION_TIMESTAMPS_BY_DEVICE_TOKEN = Collections.emptyMap();
protected static final int TOKEN_LENGTH = 32; // bytes
protected ApnsSigningKey signingKey;
protected Map<String, ApnsVerificationKey> verificationKeysByKeyId;
protected Map<ApnsVerificationKey, Set<String>> topicsByVerificationKey;
@BeforeAll
public static void setUpBeforeClass() {
SERVER_EVENT_LOOP_GROUP = new NioEventLoopGroup(2);
}
@BeforeEach
public void setUp() throws Exception {
final KeyPair keyPair = KeyPairUtil.generateKeyPair();
this.signingKey = new ApnsSigningKey(KEY_ID, TEAM_ID, (ECPrivateKey) keyPair.getPrivate());
final ApnsVerificationKey verificationKey =
new ApnsVerificationKey(KEY_ID, TEAM_ID, (ECPublicKey) keyPair.getPublic());
this.verificationKeysByKeyId = Collections.singletonMap(KEY_ID, verificationKey);
this.topicsByVerificationKey = Collections.singletonMap(verificationKey, Collections.singleton(TOPIC));
}
@AfterAll
public static void tearDownAfterClass() throws Exception {
final PromiseCombiner combiner = new PromiseCombiner(ImmediateEventExecutor.INSTANCE);
combiner.addAll(SERVER_EVENT_LOOP_GROUP.shutdownGracefully());
final Promise<Void> shutdownPromise = new DefaultPromise<>(GlobalEventExecutor.INSTANCE);
combiner.finish(shutdownPromise);
shutdownPromise.await();
}
protected ApnsClient buildTlsAuthenticationClient() throws IOException {
return this.buildTlsAuthenticationClient(null);
}
protected ApnsClient buildTlsAuthenticationClient(final ApnsClientMetricsListener metricsListener) throws IOException {
try (final InputStream p12InputStream = getClass().getResourceAsStream(MULTI_TOPIC_CLIENT_KEYSTORE_FILENAME)) {
return new ApnsClientBuilder()
.setApnsServer(HOST, PORT)
.setClientCredentials(p12InputStream, KEYSTORE_PASSWORD)
.setTrustedServerCertificateChain(getClass().getResourceAsStream(CA_CERTIFICATE_FILENAME))
.setMetricsListener(metricsListener)
.build();
}
}
protected MockApnsServer buildServer(final PushNotificationHandlerFactory handlerFactory) throws SSLException {
return this.buildServer(handlerFactory, null);
}
protected MockApnsServer buildServer(final ValidatingPushNotificationHandlerFactory handlerFactory, final boolean generateApnsUniqueId) throws SSLException {
return this.buildServer(handlerFactory, null, generateApnsUniqueId);
}
protected MockApnsServer buildServer(final PushNotificationHandlerFactory handlerFactory, final MockApnsServerListener listener) throws SSLException {
return this.buildServer(handlerFactory, listener, false);
}
protected MockApnsServer buildServer(final PushNotificationHandlerFactory handlerFactory, final MockApnsServerListener listener, final boolean generateApnsUniqueId) throws SSLException {
return new MockApnsServerBuilder()
.setServerCredentials(getClass().getResourceAsStream(SERVER_CERTIFICATES_FILENAME), getClass().getResourceAsStream(SERVER_KEY_FILENAME), null)
.setTrustedClientCertificateChain(getClass().getResourceAsStream(CA_CERTIFICATE_FILENAME))
.setEventLoopGroup(SERVER_EVENT_LOOP_GROUP)
.setHandlerFactory(handlerFactory)
.setListener(listener)
.generateApnsUniqueId(generateApnsUniqueId)
.build();
}
protected static String generateRandomDeviceToken() {
final byte[] tokenBytes = new byte[TOKEN_LENGTH];
new Random().nextBytes(tokenBytes);
final StringBuilder builder = new StringBuilder(TOKEN_LENGTH * 2);
for (final byte b : tokenBytes) {
builder.append(String.format("%02x", b));
}
return builder.toString();
}
protected static String generateRandomPayload() {
final ApnsPayloadBuilder payloadBuilder = new SimpleApnsPayloadBuilder();
payloadBuilder.setAlertBody(UUID.randomUUID().toString());
return payloadBuilder.build();
}
}
- 위 코드는 git 주소에서 tls 인증 방법을 통한 서버 세팅에 관련된 부분 및 인증 파일을 복사해 왔습니다.
Test Code
@ExtendWith(SpringExtension.class)
@ExtendWith(MockitoExtension.class)
public class ApnsServiceTest extends AbstractClientServerTest{
private static class TestMockApnsServerListener extends ParsingMockApnsServerListenerAdapter{
private final AtomicInteger acceptedNotifications = new AtomicInteger(0);
private final AtomicInteger rejectedNotifications = new AtomicInteger(0);
private ApnsPushNotification mostRecentPushNotification;
private RejectionReason mostRecentRejectionReason;
private Instant mostRecentDeviceTokenExpiration;
@Override
public void handlePushNotificationAccepted(ApnsPushNotification apnsPushNotification) {
this.mostRecentPushNotification = apnsPushNotification;
this.mostRecentRejectionReason = null;
this.mostRecentDeviceTokenExpiration = null;
this.acceptedNotifications.incrementAndGet();
synchronized (this.acceptedNotifications) {
this.acceptedNotifications.notifyAll();
}
}
@Override
public void handlePushNotificationRejected(ApnsPushNotification apnsPushNotification, RejectionReason rejectionReason, Instant instant) {
this.mostRecentPushNotification = apnsPushNotification;
this.mostRecentRejectionReason = rejectionReason;
this.mostRecentDeviceTokenExpiration = instant;
this.rejectedNotifications.incrementAndGet();
synchronized (this.rejectedNotifications) {
this.rejectedNotifications.notifyAll();
}
}
}
@Mock
private ApnsClient apnsClient;
@Mock
private MemberRepository memberRepository;
@InjectMocks
private ApnsService apnsService;
@Test
@DisplayName("Error:server shutdown")
void error_ServerShutdown() throws IOException {
//given
final TestMockApnsServerListener listener = new TestMockApnsServerListener();
server = this.buildServer(new AcceptAllPushNotificationHandlerFactory(), listener);
apnsClient = this.buildTlsAuthenticationClient();
//when
apnsService = new ApnsService(apnsClient);
server.shutdown();
final AgnException result = assertThrows(AgnException.class,()->
apnsService.sendNotification(DEVICE_TOKEN,TOPIC)
);
//then
assertThat(result.getErrorCode()).isEqualTo(ErrorCode.APNS_SERVER_CLOSE);
}
@Test
@DisplayName("Error: Bad DeviceToken")
void error_aboutDeviceToken() throws IOException, ExecutionException, InterruptedException {
//given
final TestMockApnsServerListener listener = new TestMockApnsServerListener();
final MockApnsServer server = this.buildServer(sslSession -> (headers,payload) ->{
throw new RejectedNotificationException(RejectionReason.BAD_DEVICE_TOKEN);
},listener);
server.start(PORT);
apnsClient = this.buildTlsAuthenticationClient();
apnsService = new ApnsService(apnsClient);
//when
final AgnException result = assertThrows(AgnException.class,()->
apnsService.sendNotification(DEVICE_TOKEN,TOPIC)
);
assertThat(result.getErrorCode()).isEqualTo(ErrorCode.APNS_WRONG_DEVICETOKEN);
server.shutdown();
}
@Test
@DisplayName("Error:Bad Topic")
void error_badTopic() throws IOException {
//given
final MockApnsServerListener mockApnsServerListener = new TestMockApnsServerListener();
final MockApnsServer server = this.buildServer(sslSession -> (headers,payload)->{
throw new RejectedNotificationException(RejectionReason.BAD_TOPIC);
},mockApnsServerListener);
server.start(PORT);
//when
apnsClient = this.buildTlsAuthenticationClient();
apnsService = new ApnsService(apnsClient);
final AgnException result = assertThrows(AgnException.class,()->
apnsService.sendNotification(DEVICE_TOKEN,TOPIC)
);
//then
assertThat(result.getErrorCode()).isEqualTo(ErrorCode.APNS_BAD_TOPIC);
server.shutdown();
}
@Test
@DisplayName("Success:Publish Notification")
void success_publishNotification() throws IOException {
//given
final MockApnsServerListener mockApnsServerListener = new TestMockApnsServerListener();
final MockApnsServer server = this.buildServer(new AcceptAllPushNotificationHandlerFactory(),mockApnsServerListener);
server.start(PORT);
apnsClient = this.buildTlsAuthenticationClient();
apnsService = new ApnsService(apnsClient);
//when
apnsService.sendNotification(DEVICE_TOKEN,TOPIC);
server.shutdown();
}
Code
@Service
@Slf4j
public class ApnsService {
@Autowired
private MemberRepository memberRepository;
@Autowired
private final ApnsClient apnsClient;
public ApnsService(ApnsClient apnsClient) {
this.apnsClient = apnsClient;
}
public void sendNotification(String deviceToken,String Topic){
final SimpleApnsPushNotification pushNotification;
{
final ApnsPayloadBuilder payloadBuilder = new SimpleApnsPayloadBuilder();
payloadBuilder.setAlertBody("Example");
final String payload = payloadBuilder.build();
final String token = TokenUtil.sanitizeTokenString(deviceToken);
pushNotification = new SimpleApnsPushNotification(token,Topic,payload);
}
final PushNotificationFuture<SimpleApnsPushNotification,PushNotificationResponse<SimpleApnsPushNotification>>
sendNotificationFuture = apnsClient.sendNotification(pushNotification);
try {
final PushNotificationResponse<SimpleApnsPushNotification> pushNotificationResponse =
sendNotificationFuture.get();
if(pushNotificationResponse.isAccepted()){
log.info("Push Notification accept");
}else{
log.error("Notification rejected bt APNs gateway :"+pushNotificationResponse.getRejectionReason());
log.info(pushNotificationResponse.getRejectionReason().get());
if(pushNotificationResponse.getRejectionReason().toString().contains("BadDeviceToken"))
throw new AgnException(ErrorCode.APNS_WRONG_DEVICETOKEN);
if(pushNotificationResponse.getRejectionReason().get().toString().contains("BadTopic"))
throw new AgnException(ErrorCode.APNS_BAD_TOPIC);
}
} catch (ExecutionException e) {
log.error(e.toString());
throw new AgnException(ErrorCode.APNS_SERVER_CLOSE);
}catch (InterruptedException | NullPointerException e){
log.info(e.toString());
throw new AgnException(ErrorCode.APNS_CLIENT_CLOSE);
}
}
- apnsClient 생성자를 통한 의존성 주입 방식을 통해 테스트 환경에서 적절한 객체를 주입가능 하도록 코드를 작성하였습니다.
위 과정을 통해 Spring 서버와 Apns 서버의 tls기반 통신을 구현하였습니다. 서드파티를 TDD에 어떻게 녹여내야 하나 고민을 많이 해본 것 같습니다. 그리고 어떤 식으로 Test 코드 계획하고 작성하는지에 대한 개념이 아직 많이 부족하다는 것을 느꼈습니다. TDD는 참 어렵군요..
+IOS Swft 팀과의 기능테스트를 진행하며 코드가 수정 및 변경이 이뤄질 예정입니다. 아마도..
(변경 사항 및 각종 이슈에 대해 추가 작성 예정)
'어차피 공부는 해야한다. > Spring' 카테고리의 다른 글
[Spring] TDD 도입기 - 3 H2가 밉다. (0) | 2024.07.12 |
---|---|
[Spring] TDD 도입기 - 2. 아직은 잘 모르겠어요 (0) | 2024.06.28 |
[Spring] TDD 도입기 - 1. 후회하긴 늦었죠? (0) | 2024.06.24 |
리눅스(linux) 기본 배우기 (1) | 2023.11.19 |