Skip to content
협의용 초안API 경로·필드·유효기간은 제안입니다. 운영 API 제공 완료를 의미하지 않습니다.

암호화 코드로 학습 시작 ​

아발론 서버가 생성한 일회용 암호화 코드를 학습 창의 최초 요청에 전달합니다. 플랭은 그 요청에서 검증·자동 로그인·학습 준비를 처리합니다. 별도 URL 발급·코드 교환 API를 호출하지 않습니다.

진입 요청 ​

환경진입 주소
테스트POST https://avalon-stg.plang.ai/integrations/v1/entry
운영개통 시 별도 안내
http
Content-Type: application/x-www-form-urlencoded
text
code=<ENCRYPTED_ENTRY_CODE>

이 주소는 서버가 form POST를 처리하는 학습 진입 주소이며 문서 사이트 주소와 다릅니다. 진입 응답과 학습 화면이 같은 주소에서 열려야 하므로 다른 도메인으로 바꾸거나 프록시하지 않습니다. code 외 다른 필드는 받지 않으며 GET 요청으로는 코드를 소비하지 않습니다.

아발론 서버가 암호화할 업무 정보 예시:

json
{
  "studyType": "WR",
  "memIdx": "001234567",
  "level": "MA",
  "layer": "RED",
  "unitCd": "1",
  "regYear": "2026",
  "season": "3",
  "regYm": "202609"
}

9개 필드는 모두 필수입니다. 플랭 활동 ID와 학생 이름은 받지 않으며, 보내도 활동 지정에 쓰지 않습니다. studyType·level·layer·unitCd·regYear·season 6개 값으로 활동을 찾습니다. 필드 의미와 학습 기록 규칙은 학생·기수·활동 지정을 따릅니다. 요청 UUID와 Idempotency-Key는 요구하지 않습니다. 활동 CSV·결과·학습이력 조회에 사용하는 장기 Bearer key를 학생 창으로 보내지 않습니다.

암호화 규약 ​

AES-256-GCM을 사용하는 JWE compact 형식으로 암호화와 위변조 검증을 함께 처리합니다. 양쪽 서버가 공유하는 256비트 비밀키를 사용합니다.

json
{
  "alg": "dir",
  "enc": "A256GCM",
  "kid": "stg-2026a",
  "ver": 1
}

JWE protected header입니다. alg·enc는 위 값으로 고정이며 다른 값은 거절합니다. kid는 플랭이 발급한 키를 선택하는 공개 식별자이며 비밀키 자체가 아닙니다. 등록되지 않은 kid는 거절합니다. ver는 규약 버전으로 1을 넣습니다. alg=dir이므로 compact 형식의 두 번째 조각(encrypted key)은 비워 둡니다. AAD는 RFC 7516 규칙대로 base64url 인코딩한 protected header입니다.

암호화 payload의 추가 필드타입·의미
iss발급 주체. avalon
aud대상 환경. 테스트 plang-stg, 운영은 개통 시 안내
iat코드 생성 시각, UTC epoch seconds 정수
exp코드 만료 시각, UTC epoch seconds 정수

이 필드는 위 업무 정보와 같은 payload에 포함해 함께 암호화합니다.

운영 수치값검증
코드 수명최대 5분exp - iat가 300초를 넘으면 거절
허용 시간 오차±60초iat가 현재보다 60초 넘게 미래이거나, 현재가 exp + 60초를 지나면 거절
실행 권한 수명진입 후 고정 4시간연장하지 않음

검증된 JWE 라이브러리로 매 생성 시 새로운 96비트 IV를 만들고 동일 키·IV를 재사용하지 않습니다. 플랭은 고정 알고리즘·키 ID·인증 태그·발급 주체·대상·생성/만료 시각·업무 권한을 검증합니다.

키는 플랭이 환경별로 생성해 보안 경로로 전달하며 양쪽 서버에만 보관합니다. 코드 생성용 키와 조회 API용 Bearer key는 분리합니다. 키를 교체할 때는 새 kid를 먼저 등록해 구·신 키를 함께 허용한 뒤 구 키를 내립니다. 암호문 원문과 복호화된 학생 정보를 로그에 남기지 않습니다. 형식과 암호화 파라미터는 RFC 7516 JWE, RFC 7518 JWA를 따릅니다.

암호화 코드 만들기 ​

암호화 코드는 JWE compact 형식입니다. 아래 다섯 조각을 점(.)으로 이은 문자열이며, 한 덩어리의 Base64(IV + 암호문)이 아닙니다.

text
BASE64URL(헤더) . (빈 문자열) . BASE64URL(IV) . BASE64URL(암호문) . BASE64URL(인증 태그)
  1. 헤더 JSON을 UTF-8 바이트로 바꿔 base64url로 인코딩합니다. 이 문자열이 첫 조각(encodedHeader)입니다.
  2. 12바이트 IV를 코드마다 새로 무작위 생성합니다.
  3. AES-256-GCM으로 payload JSON(UTF-8)을 암호화합니다. AAD에는 encodedHeader 문자열의 ASCII 바이트를 넣습니다.
  4. 암호문과 16바이트 인증 태그를 나눕니다. Java처럼 태그를 암호문 끝에 붙여 주는 라이브러리는 마지막 16바이트를 떼어 냅니다.
  5. encodedHeader, 빈 문자열, IV, 암호문, 태그 순서로 점을 찍어 잇습니다. 두 번째 조각은 alg=dir이라 비어 있으므로 결과에 ..가 나타납니다.

base64url은 + 대신 -, / 대신 _를 쓰고 끝의 =를 붙이지 않습니다. 표준 base64나 패딩이 섞이면 INVALID_ENTRY_CODE로 거절합니다.

Java 예시 (JDK 기본 라이브러리)
java
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;

public class EntryCode {
    private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding();

    /** key: 전달받은 진입 키(base64 디코딩한 32바이트), kid: 전달받은 키 ID */
    public static String create(byte[] key, String kid, String payloadJson) throws Exception {
        byte[] iv = new byte[12];
        new SecureRandom().nextBytes(iv);
        return create(key, kid, payloadJson, iv);
    }

    static String create(byte[] key, String kid, String payloadJson, byte[] iv) throws Exception {
        String header = "{\"alg\":\"dir\",\"enc\":\"A256GCM\",\"kid\":\"" + kid + "\",\"ver\":1}";
        String encodedHeader = BASE64URL.encodeToString(header.getBytes(StandardCharsets.UTF_8));

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, iv));
        cipher.updateAAD(encodedHeader.getBytes(StandardCharsets.US_ASCII));
        byte[] encrypted = cipher.doFinal(payloadJson.getBytes(StandardCharsets.UTF_8));

        // Java 는 암호문 뒤에 16바이트 인증 태그를 붙여 돌려준다. JWE 에서는 따로 나눠 보낸다.
        byte[] ciphertext = Arrays.copyOfRange(encrypted, 0, encrypted.length - 16);
        byte[] tag = Arrays.copyOfRange(encrypted, encrypted.length - 16, encrypted.length);

        return encodedHeader + ".." + BASE64URL.encodeToString(iv) + "."
                + BASE64URL.encodeToString(ciphertext) + "." + BASE64URL.encodeToString(tag);
    }
}
Node.js 예시 (node:crypto, Node 16 이상)
js
import { createCipheriv, randomBytes } from 'node:crypto'

const base64url = (buffer) => Buffer.from(buffer).toString('base64url')

// key: 전달받은 진입 키(base64 디코딩한 32바이트 Buffer), kid: 전달받은 키 ID
export function createEntryCode(key, kid, payloadJson, iv = randomBytes(12)) {
  const encodedHeader = base64url(JSON.stringify({ alg: 'dir', enc: 'A256GCM', kid, ver: 1 }))
  const cipher = createCipheriv('aes-256-gcm', key, iv)
  cipher.setAAD(Buffer.from(encodedHeader, 'ascii'))
  const ciphertext = Buffer.concat([cipher.update(payloadJson, 'utf8'), cipher.final()])
  const tag = cipher.getAuthTag()
  return [encodedHeader, '', base64url(iv), base64url(ciphertext), base64url(tag)].join('.')
}

직접 조립하는 대신 JWE 라이브러리(Java의 nimbus-jose-jwt, PHP의 web-token/jwt-framework 등)의 dir + A256GCM 암호화를 써도 같은 형식이 나옵니다. 라이브러리를 쓰는 경우에도 아래 테스트 벡터로 결과를 확인합니다.

테스트 벡터 ​

구현이 맞는지 확인하는 공개용 값입니다. 실제 키가 아니며 확인을 위해 IV를 고정했습니다. 운영 구현에서는 IV를 매번 무작위로 생성합니다. 헤더와 payload 문자열은 공백·순서까지 아래와 같아야 같은 결과가 나옵니다. 위 두 예시는 이 값으로 아래 code와 똑같이 나오는 것을 확인했습니다.

입력값
key (base64)AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
iv (hex)a0a1a2a3a4a5a6a7a8a9aaab
header{"alg":"dir","enc":"A256GCM","kid":"test-vector","ver":1}
tag (hex)43f34b406f65ff15a1453ab07dfc47fb
json
{"studyType":"WR","memIdx":"1870974","level":"DA","layer":"RED","unitCd":"3","regYear":"2026","season":"3","regYm":"202610","iss":"avalon","aud":"plang-stg","iat":1790755444,"exp":1790755744}

기대하는 code:

text
eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoidGVzdC12ZWN0b3IiLCJ2ZXIiOjF9..oKGio6Slpqeoqaqr.nToPWTCve-sbFeLxPViXjFKAe3332gsI5CwcpE6TQjHrQXPdgwA_WCn5aOozWMe4ZTdkJAOpfwxjZCkM4TSnnJbJ6wZE5oDCzsDMOqPsvt-qUs7Ft-OjWNxFDKyTtm_n6uVYxG2Mtbmt-Spy9-2KcUlyW4zsBakKHv_8iJY8gT7nK5IepSPseFl70qlzP_X0svvdB9--naGOudrXg-VdSrI6JYCBU3Ahtz8fFlv-lIQPVt-iM1GXDrW3Qw5otmI.Q_NLQG9l_xWhRTqwffxH-w

실제 요청에는 kid와 키를 전달받은 값으로 바꾸고, iat·exp는 코드를 만드는 시각으로 채웁니다.

학습 창 열기 ​

아발론 서버가 생성한 코드를 학생 페이지에 전달하고, 시작 버튼의 사용자 동작에서 새 창을 연 뒤 그 창을 대상으로 form POST합니다. 코드 생성이 비동기라면 사용자 클릭 시 빈 창을 먼저 열어 팝업 차단을 피합니다.

아래는 코드가 이미 준비된 경우의 HTML·JavaScript 예시입니다. 아발론 서버가 hidden input 값을 안전하게 렌더링해야 하며 브라우저에서 암호화를 수행하지 않습니다.

html
<form id="learning-entry" method="post"
      action="https://avalon-stg.plang.ai/integrations/v1/entry"
      target="plangLearningWindow">
  <input type="hidden" name="code" value="ENCRYPTED_ENTRY_CODE">
  <button type="submit">학습 시작</button>
</form>
<p id="learning-entry-error" role="alert"></p>
<script>
  const entryForm = document.getElementById('learning-entry');
  const errorMessage = document.getElementById('learning-entry-error');
  entryForm.addEventListener('submit', (event) => {
    const learningWindow = window.open('', 'plangLearningWindow');
    if (!learningWindow) {
      event.preventDefault();
      errorMessage.textContent = '팝업을 허용한 뒤 다시 시도해 주세요.';
    }
  });
</script>

성공 응답은 해당 창에서 학습을 부팅하는 HTML입니다. 아발론 서버가 파싱할 URL 발급 JSON 응답은 없습니다. 코드나 실행 권한을 주소의 query·fragment에 넣지 않습니다. 학습 상태와 결과는 결과 조회 API로 확인합니다.

일회용 코드와 재진입 ​

  • 플랭은 소비한 코드의 해시를 기록해 같은 코드의 재사용을 거절합니다. 새 창 진입에는 새 코드를 생성합니다.
  • 새 코드를 만들더라도 학생과 6개 값이 같으면 기존 학습 기록을 재사용합니다. 새 코드만으로 새 회차를 만들지 않습니다.
  • 진입 HTML 응답을 받지 못한 경우 새 코드로 같은 활동을 다시 요청합니다. 미완료 학습 기록을 이어갑니다.
  • 정상 진입 후 창 새로고침·활성화는 플랭이 저장된 실행 권한을 확인해 처리합니다. 이것만으로 새 실행을 만들지 않습니다.
  • 코드의 만료와 이미 시작한 실행 권한의 만료는 별개입니다. 코드 만료만으로 진행 중인 학습을 종료하지 않습니다.
  • 실행 권한은 진입 후 4시간이 지나면 만료됩니다. 학습 창은 “만료되었습니다. 다시 시작해 주세요.”를 알리고, 확인을 누르면 창을 닫습니다. 이어서 학습하려면 새 코드로 같은 활동을 다시 요청합니다. 저장된 답안은 유지됩니다.

동일 학생의 중복 실행 ​

아발론 연동에서는 동일 학생의 최신 정상 실행 창 하나만 유효합니다. 다른 차시·활동·기기에서 새로 정상 진입해도 이전 실행 창은 모두 만료됩니다. 이미 저장한 답안·녹음과 학습 이력은 유지됩니다.

이전 창이 다시 활성화되면 플랭이 서버에서 상태를 확인하고 “다른 창에서 학습을 시작해 이 창은 만료되었습니다.”를 표시합니다. 저장·제출 요청도 서버가 검사합니다. 이전 창을 클릭하는 것만으로 실행 권한이 복구되지는 않습니다. 상태 확인 중 통신 오류가 발생하면 재확인을 안내하며 이를 곧바로 만료로 표시하지 않습니다.

잘못되거나 이미 사용된 코드, 진입 준비 실패, 이미 완료된 활동의 완료 화면은 현재 정상 실행을 만료시키지 않습니다. 새로운 학습 실행(다시 해보기 포함)이 성공했을 때만 교체합니다.

오류와 종료 ​

진입 오류는 학습 창에 HTTP 오류 상태와 함께 안내합니다. 플랭은 일반 로그인 화면으로 보내지 않습니다. 오류 화면에는 아래 코드가 함께 표시됩니다.

HTTP코드처리
401INVALID_ENTRY_CODE암호화 키·kid·알고리즘·발급 주체·대상·시각·수명 확인
404CONTAINER_NOT_FOUND6개 값 조합이 활동 목록 CSV에 있는지 확인
409ENTRY_CODE_ALREADY_USED새 코드로 같은 활동 재진입
409LEARNING_CONTEXT_CONFLICT제출 전 학습의 귀속 월(regYm)과 다르게 요청했거나, 같은 기수에 다른 level·layer로 요청함
410ENTRY_CODE_EXPIRED새 코드 생성
422INVALID_INPUT필수 필드·형식 확인
422CONTAINER_NOT_READY활동 구성 확인 (단일 태스크가 아님 등)
422UNSUPPORTED_TASKstudyType이 활동 유형과 맞는지, 지원 유형인지 확인
503TEMPORARILY_UNAVAILABLE잠시 후 새 코드로 같은 활동 재진입

완료한 활동과 다시 해보기 ​

이미 제출을 완료한 활동을 다시 요청하면 오류가 아니라 완료 화면(200) 을 보여 주며 새 실행을 만들지 않습니다. 완료 화면에는 다시 해보기 버튼이 있습니다.

  • 학생이 버튼을 누르면 같은 창에서 같은 활동의 새 회차를 빈 답안으로 시작합니다. 아발론 서버가 새 코드를 만들 필요는 없습니다.
  • 버튼은 완료 화면을 연 뒤 5분 안에 한 번만 쓸 수 있습니다. 시간이 지나면 410 ENTRY_CODE_EXPIRED, 이미 눌렀으면 409 ENTRY_CODE_ALREADY_USED입니다. 이때는 아발론에서 새 코드로 다시 진입하면 완료 화면부터 다시 열립니다.
  • 새 회차의 귀속 월은 완료 화면을 연 진입 코드의 regYm입니다.
  • 이전 회차의 답안·결과는 그대로 보존되며, 결과 조회의 attempt로 회차별로 조회합니다. 정산(학습이력)은 회차가 늘어도 1건입니다.
  • 다시 해보기로 새 실행이 시작되면 같은 학생의 다른 실행 창은 만료됩니다.

진입 후 학습 중에 실행 권한이 만료되거나 다른 창의 새 실행으로 교체되면 학습 창이 EXECUTION_EXPIRED 또는 EXECUTION_SUPERSEDED 상태로 안내합니다. 이 두 코드는 진입 응답이 아니라 학습 창 안에서만 나타납니다.

지정 태스크의 제출이 완료되면 플랭이 실행을 종료하고 창 닫기를 시도합니다. 분석·종합 코멘트 생성을 기다리지는 않습니다. 브라우저가 닫기를 허용하지 않으면 완료 화면과 수동 닫기 안내를 표시합니다. 창 닫힘 자체를 제출 완료로 판단하지 않습니다.

아발론 파트너 개발 문서 · 개발 명세 v0.4