⚖️ AI 규제/윤리

이동권

Right to Data Portability

자신의 개인정보를 다른 사업자에게 이동시킬 권리. GDPR 제20조 및 개인정보보호법 제35조의2에서 보장.

상세 설명

이동권(Right to Data Portability)은 정보주체가 자신이 제공한 개인정보를 체계적이고 일반적으로 사용되며 기계 판독이 가능한 형식으로 제공받고, 해당 정보를 다른 처리자에게 전송할 수 있는 권리입니다. GDPR 제20조에서 처음 도입된 이 권리는 디지털 경제에서 개인의 데이터 통제권을 강화하고 서비스 간 전환을 용이하게 합니다.

이동권이 적용되려면 세 가지 조건을 충족해야 합니다. 첫째, 처리의 법적 근거가 동의(제6조 제1항 a호) 또는 계약 이행(제6조 제1항 b호)이어야 합니다. 둘째, 처리가 자동화된 수단으로 이루어져야 합니다. 셋째, 정보주체가 직접 제공한 데이터여야 합니다. 관찰된 데이터(센서 데이터, 거래 내역 등)도 포함되나, 추론된 데이터(신용 점수, 건강 예측 등)는 제외됩니다.

한국 개인정보보호법 제35조의2(개인정보 전송 요구권)는 2023년 개정으로 도입되어 2025년 3월부터 시행됩니다. 정보주체는 컴퓨터 등 정보처리장치로 처리가 가능한 형태로 본인 또는 본인이 지정하는 자에게 개인정보 전송을 요구할 수 있습니다. 다만, 기술적으로 불가능하거나 과도한 비용이 발생하는 경우 예외가 적용됩니다.

AI 시스템에서 이동권은 특히 중요합니다. 사용자의 학습 데이터, 활동 기록, 개인화 설정 등을 다른 AI 서비스로 이전할 수 있어야 합니다. Open Banking, Data Transfer Project(Google, Apple, Facebook, Microsoft, Twitter) 등이 이동권 실현을 위한 표준화 노력입니다. 위반 시 GDPR 기준 최대 2,000만 유로 또는 전 세계 연간 매출의 4% 과징금이 부과될 수 있습니다.

코드 예제

Python을 사용한 데이터 이동권 처리 시스템 구현 예제입니다.

"""
데이터 이동권(Data Portability) 처리 시스템
GDPR 제20조 및 개인정보보호법 제35조의2 준수
"""
import json
import csv
from datetime import datetime
from typing import Dict, List, Optional
from dataclasses import dataclass, asdict
from enum import Enum
import hashlib
import zipfile
import io


class DataFormat(Enum):
    """지원되는 데이터 포맷"""
    JSON = "application/json"
    CSV = "text/csv"
    XML = "application/xml"


class TransferStatus(Enum):
    """전송 상태"""
    PENDING = "pending"
    PROCESSING = "processing"
    COMPLETED = "completed"
    FAILED = "failed"
    EXPIRED = "expired"


@dataclass
class PortabilityRequest:
    """이동권 요청 데이터 구조"""
    request_id: str
    subject_id: str
    requested_at: datetime
    data_categories: List[str]
    target_format: DataFormat
    recipient_id: Optional[str]  # 직접 전송 시 수신자 ID
    status: TransferStatus
    expiry_date: datetime


class DataPortabilityHandler:
    """
    데이터 이동권 처리 핸들러
    GDPR Article 20 준수 구현
    """

    # 이동 가능한 데이터 카테고리 (직접 제공 + 관찰 데이터)
    PORTABLE_CATEGORIES = {
        "profile": "사용자 프로필 정보",
        "activity": "활동 기록 및 거래 내역",
        "preferences": "설정 및 선호도",
        "content": "사용자 생성 콘텐츠",
        "communications": "메시지 및 커뮤니케이션",
        "sensor_data": "센서/디바이스 관찰 데이터"
    }

    # 제외 카테고리 (추론 데이터)
    EXCLUDED_CATEGORIES = {
        "credit_score": "AI 기반 신용 점수",
        "recommendations": "추천 알고리즘 결과",
        "risk_assessment": "위험 평가 결과",
        "derived_insights": "파생 분석 데이터"
    }

    RESPONSE_DEADLINE_DAYS = 30  # GDPR 기준 응답 기한

    def __init__(self, user_data_store, audit_logger):
        self.user_data_store = user_data_store
        self.audit_logger = audit_logger
        self.pending_requests: Dict[str, PortabilityRequest] = {}

    def create_portability_request(
        self,
        subject_id: str,
        data_categories: List[str],
        target_format: DataFormat = DataFormat.JSON,
        recipient_id: Optional[str] = None
    ) -> PortabilityRequest:
        """
        이동권 요청 생성

        Args:
            subject_id: 정보주체 식별자
            data_categories: 요청 데이터 카테고리
            target_format: 출력 포맷
            recipient_id: 직접 전송 시 수신자 (GDPR Article 20(2))
        """
        # 요청 ID 생성
        request_id = hashlib.sha256(
            f"{subject_id}:{datetime.now().isoformat()}".encode()
        ).hexdigest()[:16]

        # 유효한 카테고리만 필터링
        valid_categories = [
            cat for cat in data_categories
            if cat in self.PORTABLE_CATEGORIES
        ]

        # 제외 카테고리 경고
        excluded = [
            cat for cat in data_categories
            if cat in self.EXCLUDED_CATEGORIES
        ]
        if excluded:
            self.audit_logger.log_warning(
                f"Excluded derived data categories: {excluded}",
                subject_id=subject_id,
                request_id=request_id
            )

        request = PortabilityRequest(
            request_id=request_id,
            subject_id=subject_id,
            requested_at=datetime.now(),
            data_categories=valid_categories,
            target_format=target_format,
            recipient_id=recipient_id,
            status=TransferStatus.PENDING,
            expiry_date=datetime.now() + timedelta(days=self.RESPONSE_DEADLINE_DAYS)
        )

        self.pending_requests[request_id] = request

        # 감사 로그 기록
        self.audit_logger.log_event(
            event_type="PORTABILITY_REQUEST_CREATED",
            subject_id=subject_id,
            request_id=request_id,
            details={
                "categories": valid_categories,
                "format": target_format.value,
                "has_recipient": recipient_id is not None
            }
        )

        return request

    def process_request(self, request_id: str) -> bytes:
        """
        이동권 요청 처리 및 데이터 패키징

        Returns:
            bytes: 구조화된 포터블 데이터 (ZIP 아카이브)
        """
        request = self.pending_requests.get(request_id)
        if not request:
            raise ValueError(f"Request not found: {request_id}")

        # 상태 업데이트
        request.status = TransferStatus.PROCESSING

        try:
            # 데이터 수집
            collected_data = self._collect_portable_data(
                request.subject_id,
                request.data_categories
            )

            # 포맷 변환
            formatted_data = self._format_data(
                collected_data,
                request.target_format
            )

            # 메타데이터 추가
            package = self._create_data_package(
                formatted_data,
                request
            )

            request.status = TransferStatus.COMPLETED

            # 직접 전송 요청인 경우
            if request.recipient_id:
                self._transfer_to_recipient(
                    request.recipient_id,
                    package,
                    request
                )

            self.audit_logger.log_event(
                event_type="PORTABILITY_REQUEST_COMPLETED",
                subject_id=request.subject_id,
                request_id=request_id,
                details={"data_size_bytes": len(package)}
            )

            return package

        except Exception as e:
            request.status = TransferStatus.FAILED
            self.audit_logger.log_error(
                f"Portability request failed: {e}",
                request_id=request_id
            )
            raise

    def _collect_portable_data(
        self,
        subject_id: str,
        categories: List[str]
    ) -> Dict:
        """카테고리별 포터블 데이터 수집"""
        data = {}

        for category in categories:
            if category == "profile":
                data["profile"] = self.user_data_store.get_profile(subject_id)
            elif category == "activity":
                data["activity"] = self.user_data_store.get_activities(subject_id)
            elif category == "preferences":
                data["preferences"] = self.user_data_store.get_preferences(subject_id)
            elif category == "content":
                data["content"] = self.user_data_store.get_user_content(subject_id)
            elif category == "communications":
                data["communications"] = self.user_data_store.get_communications(subject_id)
            elif category == "sensor_data":
                data["sensor_data"] = self.user_data_store.get_sensor_data(subject_id)

        return data

    def _format_data(self, data: Dict, format: DataFormat) -> Dict[str, bytes]:
        """데이터를 요청된 포맷으로 변환"""
        formatted = {}

        for category, content in data.items():
            if format == DataFormat.JSON:
                formatted[f"{category}.json"] = json.dumps(
                    content,
                    ensure_ascii=False,
                    indent=2,
                    default=str
                ).encode('utf-8')
            elif format == DataFormat.CSV:
                formatted[f"{category}.csv"] = self._to_csv(content)

        return formatted

    def _to_csv(self, data: List[Dict]) -> bytes:
        """리스트 데이터를 CSV로 변환"""
        if not data:
            return b""

        output = io.StringIO()
        writer = csv.DictWriter(output, fieldnames=data[0].keys())
        writer.writeheader()
        writer.writerows(data)
        return output.getvalue().encode('utf-8')

    def _create_data_package(
        self,
        formatted_data: Dict[str, bytes],
        request: PortabilityRequest
    ) -> bytes:
        """데이터 패키지(ZIP) 생성 with 메타데이터"""
        buffer = io.BytesIO()

        with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
            # 메타데이터 파일 추가
            metadata = {
                "export_version": "1.0",
                "gdpr_article": "20",
                "exported_at": datetime.now().isoformat(),
                "subject_id_hash": hashlib.sha256(
                    request.subject_id.encode()
                ).hexdigest(),
                "categories": request.data_categories,
                "format": request.target_format.value,
                "data_controller": {
                    "name": "KAITRUST AI Platform",
                    "contact": "privacy@kaitrust.ai"
                },
                "interoperability": {
                    "standard": "Data Transfer Project",
                    "schema_version": "2024.1"
                }
            }

            zf.writestr(
                "metadata.json",
                json.dumps(metadata, ensure_ascii=False, indent=2)
            )

            # 데이터 파일 추가
            for filename, content in formatted_data.items():
                zf.writestr(f"data/{filename}", content)

            # 무결성 체크섬
            checksums = {}
            for filename, content in formatted_data.items():
                checksums[filename] = hashlib.sha256(content).hexdigest()

            zf.writestr(
                "checksums.json",
                json.dumps(checksums, indent=2)
            )

        return buffer.getvalue()

    def _transfer_to_recipient(
        self,
        recipient_id: str,
        package: bytes,
        request: PortabilityRequest
    ):
        """
        다른 컨트롤러에게 직접 전송 (Article 20(2))
        기술적으로 가능한 경우에만 실행
        """
        # 수신자 엔드포인트 조회
        recipient = self._lookup_recipient_endpoint(recipient_id)

        if recipient and recipient.get("supports_portability"):
            # 보안 전송 수행
            self._secure_transfer(
                recipient["endpoint"],
                package,
                request.request_id
            )
        else:
            self.audit_logger.log_warning(
                "Direct transfer not supported by recipient",
                request_id=request.request_id,
                recipient_id=recipient_id
            )


# 사용 예시
if __name__ == "__main__":
    from datetime import timedelta

    # 목업 데이터 저장소
    class MockUserDataStore:
        def get_profile(self, uid):
            return {"name": "홍길동", "email": "hong@example.com"}
        def get_activities(self, uid):
            return [{"action": "login", "timestamp": "2024-01-15T10:00:00"}]
        def get_preferences(self, uid):
            return {"language": "ko", "theme": "dark"}
        def get_user_content(self, uid):
            return [{"type": "post", "content": "AI 관련 글"}]
        def get_communications(self, uid):
            return []
        def get_sensor_data(self, uid):
            return []

    class MockAuditLogger:
        def log_event(self, **kwargs): print(f"EVENT: {kwargs}")
        def log_warning(self, msg, **kwargs): print(f"WARN: {msg}")
        def log_error(self, msg, **kwargs): print(f"ERROR: {msg}")

    handler = DataPortabilityHandler(MockUserDataStore(), MockAuditLogger())

    # 이동권 요청 생성
    request = handler.create_portability_request(
        subject_id="user_12345",
        data_categories=["profile", "activity", "preferences", "credit_score"],
        target_format=DataFormat.JSON
    )

    print(f"Request ID: {request.request_id}")
    print(f"Valid categories: {request.data_categories}")
    print(f"Deadline: {request.expiry_date}")

    # 요청 처리
    package = handler.process_request(request.request_id)
    print(f"Package size: {len(package)} bytes")

실무 대화

PM: 경쟁사로 고객 이탈 시 데이터 이동권 요청이 들어오고 있습니다. 어떻게 대응해야 할까요?
개발자: GDPR 제20조에 따르면 30일 이내 응답해야 합니다. JSON이나 CSV 같은 기계 판독 가능 형식으로 제공해야 하고요, 고객이 요청하면 경쟁사에 직접 전송도 해야 합니다.
PM: 모든 데이터를 다 줘야 하나요? AI 추천 결과나 분석 데이터도요?
개발자: 아닙니다. 이동권은 고객이 직접 제공한 데이터와 관찰된 데이터에만 적용됩니다. 신용 점수나 추천 알고리즘 결과 같은 추론 데이터는 제외됩니다. 단, 요청 시 해당 카테고리가 왜 제외되는지 설명은 해야 합니다.
면접관: 데이터 이동권과 열람권의 차이를 설명해주세요.
지원자: 열람권(제15조)은 어떤 데이터가 처리되는지 확인하는 권리이고, 이동권(제20조)은 그 데이터를 기계 판독 가능 형식으로 받아서 다른 서비스로 옮길 수 있는 권리입니다. 열람권은 모든 처리 데이터에 적용되지만, 이동권은 동의나 계약에 기반한 자동화 처리 데이터에만 적용됩니다.
면접관: 이동권의 기술적 제한은 무엇인가요?
지원자: 직접 전송의 경우 '기술적으로 가능한 경우'라는 조건이 있습니다. 표준 API가 없거나 보안 위험이 있으면 거부할 수 있어요. 하지만 Data Transfer Project 같은 업계 이니셔티브가 상호운용성을 높이고 있습니다.
리뷰어: 이 export 함수에서 추론 데이터도 포함하고 있네요. 이동권 대상이 아닌 데이터까지 내보내면 안 됩니다.
작성자: 맞습니다. GDPR 가이드라인을 다시 확인했는데, 'provided by' 데이터만 해당됩니다. 신용점수나 추천 결과는 'inferred' 데이터라서 제외해야겠네요.
리뷰어: 그리고 출력 형식도 확인해주세요. PDF나 이미지는 '기계 판독 가능'하지 않습니다. JSON, CSV, XML 같은 구조화된 형식이어야 합니다.
작성자: 네, 표준 스키마도 적용하겠습니다. Data Transfer Project 형식을 따르면 다른 서비스와의 상호운용성도 확보됩니다.

주의사항

이동권 적용 범위 한정

이동권은 정보주체가 '제공한' 데이터(프로필, 설정)와 '관찰된' 데이터(활동 기록, 센서 데이터)에만 적용됩니다. AI가 분석하거나 추론한 데이터(신용점수, 추천 결과, 위험 평가)는 제외됩니다. 또한 동의나 계약에 기반한 자동화 처리에만 적용되므로, 법적 의무 수행이나 정당한 이익에 기반한 처리는 대상이 아닙니다.

제3자 권리 보호

이동권 행사가 다른 사람의 권리와 자유에 부정적 영향을 미쳐서는 안 됩니다. 예를 들어, 공유된 대화 기록을 이동할 때 상대방의 프라이버시를 침해하거나, 영업 비밀이 포함된 데이터를 경쟁사로 이전하는 것은 제한될 수 있습니다. 이런 경우 데이터를 익명화하거나 제3자 관련 부분을 제거해야 합니다.

기술적 상호운용성 확보

직접 전송(controller-to-controller)은 '기술적으로 가능한 경우'에만 의무입니다. 하지만 표준 API를 제공하지 않는 것은 이동권을 회피하려는 시도로 해석될 수 있습니다. Data Transfer Project, Open Banking 표준 등을 적극 지원하고, 제공하는 데이터 형식과 스키마를 문서화해야 합니다. 보안 위험 없이 전송이 가능하도록 암호화와 인증 메커니즘도 갖춰야 합니다.

더 배우기