💡전시회 기록 서비스의 푸시 알림 중 다중 디바이스에 관한 내용입니다. 알림이 발생하는 상황들과 현재 상태를 소개하고, 다중 디바이스를 지원할 수 있는 설계로의 고민들을 적어보았습니다. 여러 기기에 알림을 보내는 것을 넘어 추가와 삭제, 업데이트를 고려하여 설계하고자 했습니다.
0. 소개
해당 서비스는 안드로이드 앱이며, 사용자에게 푸시 알림을 보내고 사용자는 서비스의 알림 탭에서 해당 알림을 조회할 수 있습니다. 푸시 알림이 어떤 상황에서 발생하는지, 단일 디바이스의 한계는 무엇이었는지를 알아본 후 다중 디바이스로의 변경을 중심으로 예외 상황(앱 삭제, 로그아웃 등)을 어떻게 처리했는지 등의 설계와 구현을 정리했습니다.
1) 단일 디바이스
기존 설계는 User 테이블에 fcm_token 컬럼을 두어 사용자의 기기를 식별하고, 푸시 알림을 보냈습니다. 그렇기에 한 명의 회원은 하나의 기기만을 갖고 있다는 가정으로 구현되어 있습니다. 사용자가 새로운 기기에서 앱을 사용하면 해당 토큰은 업데이트되어 이전 기기로는 푸시 알림이 가지 않게 됩니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Getter
public class User extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
@Column(name = "nickname", unique = true, nullable = false)
private String nickname;
@Column(name = "fcm_token")
private String fcmToken;
//...
}
하지만, 평소에 사용하는 여러 앱들은 하나의 아이디로 여러 기기에서 사용하더라도 알림을 잘 보내곤 했습니다. 기존에는 하나의 사용자 계정이 한 기기만 사용한다고 가정해 알림을 처리했지만, 실제 사용 환경은 그렇지 않다는 점을 인식하고 여러 기기를 사용할 수 있도록 구조를 개선하게 되었습니다.
2) 알림 발생
서비스는 사용자의 활동에 따라, 그리고 전시회 일정에 따라 크게 두 가지로 분류하여 푸시 알림을 전송합니다.
사용자는 알림 탭에서 전송받은 푸시 알림의 자세한 내용을 확인할 수 있으며, 각 종류별 알림 수신 여부를 결정할 수 있습니다.

활동 알림
먼저, 활동 알림은 유저의 행위에 의해 발생하며, 4가지 경우로 나누어집니다.
- 타 유저가 나를 팔로우 한 경우
- 내가 팔로우 한 유저가 감상평을 남기는 경우
- 내가 팔로우 한 유저가 별점을 남기는 경우
- 내 감상평에 댓글이 달린 경우
전시 알림
전시 알림은 매일 정해진 시간에 발생하며, 2가지 경우로 나누어집니다.
- 관심 전시회로 설정했으며, 전시 시작 7일, 3일, 1일 전인 전시회에 대한 리마인드 알림
- 관심 전시회로 설정했으며, 댓글과 별점을 모두 남기지 않은 전시 종료 7일, 3일, 1일 전인 전시회에 대한 리마인드 알림
1. 다중 디바이스 지원으로 변경
User 테이블에 컬럼으로 fcm_token을 저장했기에, 여러 기기 토큰을 저장하지 못하여 다중 디바이스를 지원하지 못하는 점을 가장 먼저 해결해야 했습니다. 따라서, 우선 테이블 설계를 변경하여 다중 디바이스를 지원할 수 있도록 한 뒤 추가 고려 사항을 이야기 해보겠습니다.
1) 테이블 설계 변경

user 테이블에 있던 토큰을 분리하여 별도 테이블로 만들었습니다. 1:M 관계를 설정해, 유저 한 명에 대해 여러 기기의 토큰을 저장할 수 있습니다. token 컬럼은 Unique 제약 조건을 걸어주어 중복된 기기에 대한 write 작업이 발생하면 update할 수 있도록 해, 하나의 기기에 여러 유저가 존재하는 상황을 방지했습니다.
각 device_token 테이블에 알림 수신 여부를 넣어 기기별로 알림 수신 여부를 결정하도록 할 수도 있지만, 초기 요구사항에 따라 우선 사용자 단위 방식을 유지하기로 했습니다.
2) 기본적인 등록 및 알림 요청
테이블 설계 변경에 따라 유저가 가지고 있는 모든 기기에 푸시 알림을 보내야 합니다. 기존에 작성된 기기 토큰 저장 로직은 단순히 User 테이블의 fcm_token을 업데이트하는 형식이었는데, 기기 토큰의 존재 여부에 따라 수행할 작업이 변경될 필요가 있습니다.
변경된 테이블 구조에 따라 필수적인 로직들만 우선 변경하고, 아래에서 상황을 따져보며 변경 사항을 추가로 반영했습니다.
[토큰 등록]
@Transactional
public void registerToken(UserPrincipal userPrincipal, DeviceTokenReq deviceTokenReq) {
User user = userService.validateUserByToken(userPrincipal);
String token = deviceTokenReq.getDeviceToken();
deviceTokenRepository.findByDeviceToken(token)
.ifPresentOrElse(
existingToken -> existingToken.updateUser(user),
() -> {
DeviceToken deviceToken = DeviceToken.builder()
.user(user)
.deviceToken(token)
.build();
deviceTokenRepository.save(deviceToken);
}
);
}
위와 같이 Unique한 token 컬럼으로 Device Token을 찾은 후에 존재하면 업데이트, 존재하지 않으면 삽입을 진행했습니다. 동시성을 생각하고 쿼리를 줄이고자 한다면 ON DUPLICATE KEY UPDATE 문으로 한 번에 수행할 수 있지만, 앱 특성 상 매우 드물게 발생하는 로직이기 때문에 도메인을 좀 더 활용하고 가독성을 높일 수 있는 방법으로 구현했습니다.
[푸시 알림 생성 요청]
// 기존 단일 기기 푸시 알림
fcmService.makeActiveAlarm(receiver.getFcmToken(), sender.getNickname() + " 님이 별점을 남겼어요");
// 여러 기기 푸시 알림
deviceTokenList.stream()
.map(DeviceToken::getDeviceToken)
.forEach(token -> fcmService.makeActiveAlarm(token, message));
푸시 알림을 생성할 때에도 마찬가지로 모든 기기에 대해 생성할 수 있도록 변경했습니다.
2. 생각해 볼 문제들
여러 기기를 허용하면 그만큼 사용자의 앱 사용 경우의 수가 늘어나게 됩니다. 이에 따라 여러 상황을 따져보아야 하는데, 계정(회원)과 기기(Device Token)의 관계를 위주로 살펴보면 여러 상황에 대비할 수 있습니다.
- 1개의 계정으로 여러 기기에서 접속한 경우
- 1개의 기기에서 여러 계정으로 접속한 경우
- 로그아웃?
- 앱 삭제 시
위 순서로 살펴보겠습니다.
1) 같은 유저, 다른 토큰
일반적인 흐름으로, 여러 기기에서 하나의 계정으로 접속한 경우입니다. 이렇게 되면 푸시 이벤트가 발생했을 때, 등록된 모든 기기에 푸시 알림이 전송됩니다. 이미 토큰을 가지고 있는 회원도, 추가로 등록할 수 있으므로 이상적인 상황입니다.

앞에서 변경한 테이블 구조에 따라 로그인 시 토큰 등록을 함께 수행하여 하나의 계정에 여러 기기를 등록하여 서비스를 사용할 수 있습니다.
2) 같은 토큰, 다른 유저
문제가 될 수 있는 상황으로, 하나의 기기에서 다른 아이디로 접속한 경우입니다. 하나의 기기에 여러 계정을 사용한 경우인데, 내 알림을 다른 사람이 받게 될 수 있다는 심각한 문제를 가지고 있습니다.

이러한 상황을 방지하기 위해 기기 토큰을 UK로 설정하여 로그인 시 토큰 등록을 함께 수행하는데, 이미 존재하는 토큰이 들어오는 경우 user_id(fk)를 변경하여 기기를 사용하는 계정이 유일할 수 있게끔 유지해야 합니다. 앞에서 소개한 등록 코드에 포함되어 있는 내용입니다.
3) 로그아웃
사용자가 로그아웃을 하는 경우입니다. 로그아웃 시 앱 관련 소식에 관심이 없다는 것으로 보아 푸시 알림을 보내지 않기로 결정했습니다. 앞에서 변경된 토큰 등록 코드와 등록 시 두 상황을 확인했는데, 이제는 토큰 삭제를 고려해야 합니다.

로그아웃을 했음에도 푸시 알림을 받게 되는 상황을 방지하기 위해서는 로그아웃 시 토큰 삭제를 함께 수행해야 합니다.
@Transactional
public void deleteToken(UserPrincipal userPrincipal, DeviceTokenReq deviceTokenReq) {
User user = userService.validateUserByToken(userPrincipal);
deviceTokenRepository.deleteByDeviceToken(deviceTokenReq.getDeviceToken());
}
위와 같이 계정이 아닌 기기 단위로 삭제를 수행합니다. 로그아웃 시 서버가 해당 기기를 알림 대상으로 계속 유지하면 개인 정보 노출 등의 문제가 생길 수 있습니다. 따라서 로그아웃은 단순한 세션 종료가 아니라 '푸시 수신 중단'이라는 의미도 포함되며, 이에 따라 기기 단위로 토큰을 제거하는 정책을 세웠습니다.
로그아웃 뿐만 아니라 회원 탈퇴의 경우에도 마찬가지로 알림을 보내면 안되므로 토큰 삭제가 필요합니다.
4) 앱 삭제
기기에서 앱 삭제 시에는 해당 기기로 푸시 알림이 보내지면 안됩니다. 토큰 삭제를 고려해야 한다는 점에서 로그아웃과 비슷한데, 서버는 앱 삭제 여부를 직접 알 수 없다는 충격적인 문제가 있습니다.

하지만, 기기에서 앱 삭제 여부를 알기 위해 푸시 알림을 활용할 수 있습니다. 서버에서 매일 주기적으로 삭제 확인을 위한 Silent Push를 보낸 후 응답을 통해 삭제 여부를 알아냅니다. FCM에 푸시 전송 시 응답으로 "이 토큰은 무효"라는 메시지가 오면 해당 응답을 기반으로 삭제하는 방법입니다.
// 매일 확인
@Scheduled(cron = "0 0 8 * * *")
public void sendSilentCheckPush() {
List<DeviceToken> tokenList = deviceTokenRepository.findAll();
int total = tokenList.size();
tokenList.stream()
.map(DeviceToken::getDeviceToken)
.forEach(fcmService::sendSilentCheck);
}
// silent push 생성
public void sendSilentCheck(String token) {
JSONObject jsonData = new JSONObject();
jsonData.put(TYPE, SILENT_CHECK);
JSONObject jsonMessage = new JSONObject();
jsonMessage.put(TOKEN, token);
jsonMessage.put(DATA, jsonData);
JSONObject message = new JSONObject();
message.put(MESSAGE, jsonMessage);
pushAlarm(message);
}
// 만들어진 알림을 받아서 푸시
private void pushAlarm(JSONObject jsonMessage) {
try {
OkHttpClient okHttpClient = new OkHttpClient();
Request request = new Request.Builder()
.addHeader("Authorization", "Bearer " + getAccessToken())
.addHeader("Content-Type", "application/json; UTF-8")
.url(API_URL)
.post(RequestBody.create(jsonMessage.toString(), MediaType.parse("application/json")))
.build();
Response response = okHttpClient.newCall(request).execute();
String body = response.body().string();
if (!response.isSuccessful() && body.contains(SILENT_PUSH_BODY))
deleteDeviceToken(jsonMessage.getJSONObject(MESSAGE).getString(TOKEN));
} catch (Exception e) {
// ...
}
}
@Scheduled를 사용하여 매일 Silent Push 알림을 보내도록 합니다. 푸시 알림을 생성하고, 이를 토대로 알림 서버에 요청을 보냅니다. 알림 요청의 응답에 따라 사용자가 앱을 삭제했는지(토큰이 유효한지) 알 수 있고, 만약 앱을 삭제했다면 해당 기기를 더 이상 서비스에서 관리하지 않기 위해 토큰을 삭제해줍니다. (Firebase Docs)
IOS는 푸시를 보내는 순간 Notification이 만들어지므로 추가적인 설정이 필요하다고 하는데, IOS와 다르게 Android는 Notification을 직접 만들기 때문에 이 경우에는 Notification 자체를 만들지 않으면 된다고 합니다. 즉, 안드로이드 개발자와 협의하여 Silent Push인 경우 아무 작업을 하지 않도록 하면 됩니다.
프로젝트를 진행하던 시기에 푸시 알림 기능은 막바지에 추가되어서 많은 사항을 고려하지 못했지만, 팀원들과 어느 정도 이야기를 해 본 부분이라 다중 디바이스를 포함해 푸시 알림에 대한 개선 방안은 마음에 품고 있었습니다. 토큰의 신선도 관리 혹은 다중 디바이스 상황에서도 활성 기기에만 푸시를 보내는 등 더욱 세밀한 관리가 들어가면 좋을 것 같다는 이야기까지 했었는데, 현재 서비스는 스토어에서 내려갔고, 사용자가 없기 때문에 다른 작업들을 먼저 하면 좋을 것 같다고 생각했습니다.
짧은 시간 동안 개발하기도 했고, 팀원이 개발한 내용이라 어렴풋이 알고 있었는데, 푸시 알림에 대해 어느 정도 알 수 있었습니다. 코드를 보니 개선점이 더 보이기도 하고, 사용자를 고려해서 늘 다양한 상황을 생각한 개발을 할 필요가 있다고 느낍니다.
참고 :
'Project' 카테고리의 다른 글
| [O+T] 홈 화면 인기 플레이리스트 API 개선하기 (0) | 2026.05.09 |
|---|---|
| [O+T] 테스트 준비하기 (2) | 2026.04.28 |
| 상황별 조회수 성능을 위해 고려할 수 있는 것 (0) | 2025.04.01 |
| 조회수 카운팅 시 동시성 문제 (0) | 2025.03.23 |
| 회원과 비회원을 고려한 복합 조회수 카운팅 정책 (0) | 2025.03.20 |