공통 [REST API] REST 기반 파일 업로드와 다운로드 구현하기
2020.08.31 15:27
이 글에서는 REST API 기반 파일 업로드와 다운로드 구현방안을 설명합니다.
REST 서버와 REST 클라이언트를 이용해 기능을 구현했습니다.
REST 기반 파일 업로드와 다운로드 구현
REST API 구현 시 파일을 제공해야하는 경우가 있습니다. 파일 업로드 시 기존의 데이터와 함께 파일을 업로드할 수도 있고, 별도의 파일 전용 엔드포인트를 추가해 구현할 수 있습니다. 이 두가지 방법 모두에 대해 설명합니다.
이 글에 앞서 다음 내용을 이해하고 있어야 합니다. 미리 선행 학습이 필요합니다.
- [REST API] REST API 이해하기
 - [REST API][실습] REST API 서버 개발하기(엔드포인트 구현, RAD 서버 이용)
 - [REST API][실습] REST API 클라이언트 개발하기(REST Client 이용)
 
이 글에서는 다음 내용을 다룹니다.
- 파일 엔드포인트 추가 구성
 - 파일 업로드 구현 방안
	
- 서버 측 구현
 - 클라이언트 측 구현
 
 - 파일 다운로드 구현 방안
	
- 서버 측 구현
 - 클라이언트 측 구현
 
 
파일 엔드포인트 추가 구성
파일을 제공하는 기능을 추가하기 위해서는, 1) 기존 엔드포인트에서 파일 항목을 추가하는 방법과 2) 별도의 엔드포인트를 추가하는 방법으로 구현할 수 있습니다.
이 글에서는 별도의 엔드포인트를 추가해 파일 업로드와 다운로드 기능을 구현하는 방법을 설명합니다.
저는 images라는 리소스이름으로 RAD 서버 패키지 프로젝트를 생성했습니다.
1, File > New > Other
2, RAD Server > RAD Server Package
3, Create package with resource > Next
4, Resource name: images, File type: Data Module > Next
5, 모든 항목 선택 해제 > Finish
다음과 같이 엔드포인트를 추가합니다.(선언부에 추가 후 Ctrl + Shift + C를 눌러 구현부를 자동 생성할 수 있습니다.)
| 
			 1 
			2 
			3 
			4 
			5 
			6 
			7 
			8 
			9 
			 | 
			
			type  [ResourceName('images')]  TImagesResource1 = class(TDataModule)  published    [ResourceSuffix('{item}/photo')]    procedure GetItemPhoto(const AContext: TEndpointContext; const ARequest: TEndpointRequest; const AResponse: TEndpointResponse);    [ResourceSuffix('{item}/photo')]    procedure PostItemPhoto(const AContext: TEndpointContext; const ARequest: TEndpointRequest; const AResponse: TEndpointResponse);  end; | 
		
엔드포인트는 ResourceSuffix(리소스 접미사)와 메소드로 구성됩니다.
메소드는 Get, Post, Put, Delete 접두사로 시작해야 합니다. 리소스 호출 시 HTTP 메소드와 접두사가 매핑되어 메소드가 호출 됩니다.
즉, GET으로 요청된 경우 Get*으로 시작된 메소드가, POST로 요청한 경우 Post*로 시작된 메소드가 실행됩니다.
ResourceSuffix에 지정된 항목과 매핑된 요청이 온 경우 메소드가 실행됩니다.
중괄호({})로 감싼 부분은 파라미터화된 항목으로 구현부에서 ARequest.Params.Values['item'] 등의 코드로 그부분의 내용을 취득할 수 있습니다.
예를 들어
GET http://localhost:8080/images/1/photo 호출 시 GetItemPhoto 메소드가 호출되고
POST http://localhost:8080/images/1/photo 호출 시 PostItemPhoto 메소드가 호출됩니다.
ARequest.Params.Values['item']은 1을 반환합니다.
파일 업로드 구현 방안
서버(RAS Server) 측 구현
파일 업로드는 PostItemPhoto 메소드에서 구현합니다.
주의할 점은, 파일 전송을 위해서는 multipart/form-data 인코딩 타입으로 데이터가 전달됩니다.
현재(2020년 08월)에 RAD 서버에서 multipart/form-data 타입의 데이터를 처리하는 기능이 구현되어있지 않은 것으로 파악되어, 직접 데이터를 분석해 필요한 데이터를 사용해야 합니다.
저는 다음과 같은 DecodeParamsAndStream 함수를 이용해 데이터를 분석했습니다.
(Indy에서 재공하는 TIdMessageDecoderMIME 객체를 이용했습니다.)
| 
			 1 
			2 
			3 
			4 
			5 
			6 
			7 
			8 
			9 
			10 
			11 
			12 
			 | 
			
			type  TStreamParams = class(TDictionary<string, tstream="">)  private    function GetStream(const Name: string): TStream;    procedure SetStream(const Name: string; const Value: TStream);  public    property Streams[const Name: string]: TStream read GetStream write SetStream;    destructor Destroy; override;  end;procedure DecodeParamsAndStream(AStream: TStream; AContentType: string;  Params: TStrings; StreamParams: TStreamParams);</string,> | 
		
파라메터로는
- AStream : Body의 전체 스트림
 - AContentType : 요청의 컨텐트 타입, boundary 포함
 - Params : 문자열 형식의 데이터(파라미터)
 - StreamParams : Stream 형식의 데이터(파라미터), TStreamParams 객체는 <string, TStream> 쌍의 딕셔너리 사용
 
파라미터 데이터들은 컨텐트타입의 boundary 값을 앞뒤에 두어 구분합니다.
주의할 점은 문자열 형식의 파라미터 데이터의 경우 Content-Type이 누락되어 있습니다.(분석 시 누락된 경우에 대해 예외처리가 필요합니다.)
| 
			 1 
			2 
			3 
			4 
			5 
			6 
			7 
			8 
			9 
			10 
			11 
			12 
			13 
			14 
			15 
			16 
			17 
			18 
			19 
			20 
			21 
			22 
			23 
			24 
			25 
			26 
			27 
			28 
			29 
			30 
			31 
			32 
			33 
			34 
			35 
			36 
			37 
			38 
			39 
			40 
			41 
			42 
			43 
			44 
			45 
			46 
			47 
			48 
			49 
			50 
			51 
			52 
			53 
			54 
			55 
			56 
			57 
			58 
			59 
			60 
			61 
			62 
			63 
			64 
			65 
			66 
			67 
			68 
			69 
			70 
			71 
			72 
			73 
			74 
			75 
			76 
			77 
			78 
			79 
			80 
			81 
			82 
			83 
			 | 
			
			uses  System.IOUtils,  IdGlobalProtocols, IdMessageCoder, IdMessageCoderMIME;procedure DecodeParamsAndStream(AStream: TStream; AContentType: string;  Params: TStrings; StreamParams: TStreamParams);var  Boundary: string;  Decoder, NewDecoder: TIdMessageDecoderMIME;  MsgEnd: Boolean;  FieldName: string;  StringStream: TStringStream;  MemoryStream: TMemoryStream;begin  Boundary := ExtractHeaderSubItem(AContentType, 'boundary', QuoteHTTP);  Decoder := TIdMessageDecoderMIME.Create(nil);  try    MsgEnd := False;    repeat      Decoder.MIMEBoundary := Boundary;      Decoder.SourceStream := AStream;      Decoder.FreeSourceStream := False;      Decoder.ReadHeader;        // Content-Type이 없는 경우 mcptAttachment로 인식해 기본값 설정         // RESTClient에서 MultiPart 전송 시 일반 파라메터의 경우 Content-Type 누락해 전송 함      if Decoder.Headers.Values['Content-Type'] = '' then      begin        Decoder.Headers.Values['Content-Type'] := 'text/plain';        Decoder.CheckAndSetType(Decoder.Headers.Values['Content-Type'], Decoder.Headers.Values['Content-Disposition']);      end;      case Decoder.PartType of        mcptText:          begin            FieldName := ExtractHeaderSubItem(Decoder.Headers.Values['Content-Disposition'], 'name', QuoteMIME);            StringStream := TStringStream.Create;            try              NewDecoder := Decoder.ReadBody(StringStream, MsgEnd) as TIdMessageDecoderMIME;              try                Params.Values[FieldName] := StringStream.DataString.Trim;              finally                Decoder.Free;                Decoder := NewDecoder;              end;            finally              StringStream.Free;            end;          end;        mcptAttachment:          begin            var HL: string := Decoder.Headers.Values['Content-Disposition'];            FieldName := ExtractHeaderSubItem(Decoder.Headers.Values['Content-Disposition'], 'name', QuoteMIME);            MemoryStream := TMemoryStream.Create;            NewDecoder := Decoder.ReadBody(MemoryStream, MsgEnd) as TIdMessageDecoderMIME;            try              StreamParams.Streams[FieldName] := MemoryStream;            finally              Decoder.Free;              Decoder := NewDecoder;            end;          end;        mcptIgnore:          begin            FreeAndNil(Decoder);            Decoder := TIdMessageDecoderMIME.Create(nil);            TIdMessageDecoderMIME(Decoder).MIMEBoundary := Boundary;          end;        mcptEOF:          begin            FreeAndNil(Decoder);            MsgEnd := True;          end;      end;    until (Decoder = nil) or MsgEnd;  finally    Decoder.Free;  end;end; | 
		
위의 코드가 좀 길고 생소할 수 있습니다.
중요한 부분은 디코더로 분석한 데이터의 Decoder.PartType이 mcptText(텍스트)인 경우 Params 항목에 데이터를 추가하고, mcptAttachment(첨부 파일)인 경우 StreamParams 항목에 데이터를 추가했습니다.
위 함수를 사용한 PostItemPhoto 메소드의 코드는 다음과 같습니다.
| 
			 1 
			2 
			3 
			4 
			5 
			6 
			7 
			8 
			9 
			10 
			11 
			12 
			13 
			14 
			15 
			16 
			17 
			18 
			19 
			20 
			21 
			22 
			23 
			24 
			25 
			26 
			27 
			28 
			29 
			30 
			31 
			32 
			33 
			34 
			35 
			36 
			37 
			38 
			39 
			40 
			41 
			42 
			43 
			44 
			45 
			46 
			47 
			48 
			49 
			50 
			51 
			 | 
			
			const  ROOT_PATH = 'D:\Projects\DelphiDemos\OpenAPI\RESTUpload\Server';procedure TImagesResource1.PostItemPhoto(const AContext: TEndpointContext; const ARequest: TEndpointRequest; const AResponse: TEndpointResponse);var  LItem: string;  Stream: TStream;  ContentType: string;  Params: TStringList;  StreamParams: TStreamParams;  Path, RawPath: string;begin  LItem := ARequest.Params.Values['item'];  Params := TStringList.Create;  StreamParams := TStreamParams.Create;  Stream := ARequest.Body.GetStream;  ContentType := ARequest.Headers.GetValue('Content-Type');    // e.g. multipart/form-data; boundary=--------070120105641002  Path := TPath.Combine(ROOT_PATH, 'images');  RawPath := TPath.Combine(Path, 'raw_' + LItem + '.jpg');  Path := TPath.Combine(Path, LItem + '.jpg');  // Save raw data  TMemoryStream(Stream).SaveToFile(RawPath);  // Decode parameters and streams from body stream.  DecodeParamsAndStream(Stream, ContentType, Params, StreamParams);  // Using parameters as a below  if Params.Values['user_id'] = '123' then  begin  end;  // Save photo parameter to a file.  if Assigned(StreamParams.Streams['photo']) then    TMemoryStream(StreamParams.Streams['photo']).SaveToFile(Path);  StreamParams.Free;  Params.Free;end; | 
		
참고로, 파일 저장 방식은 파일로 저장하는 방식과 Blob 필드에 저장하는 방식이 있으며, 이 예제에서는 지정경로(ROOT_PATH) 하위 images 디렉토리에 {item}항목 이름으로 저장했습니다.
파일로 저장시의 주의점은 RAD 서버의 로컬 디스크로 저장 시 서버를 병렬화(여러대 구성) 시 접근이 제한됩니다. 네트워크 경로 또는 공유 파일 시스템을 이용해야 합니다.(또는 파일 공유 솔루션 등을 이용할 수도 있습니다.)
Blob 필드로 저장 시 주의점은 데이터와 별도의 테이블에 Blob 필드를 구성하는 것을 추천드립니다. 성능 측면과 향후 데이터 관리(백업 등) 시 유리할 수 있습니다.
클라이언트(REST Client) 측 구현
REST 클라이언트를 이용해 파일 업로드 구현은 데이터 전송과 크게 차이나지 않습니다.
| 
			 1 
			2 
			3 
			4 
			5 
			6 
			7 
			8 
			9 
			10 
			11 
			12 
			13 
			14 
			15 
			16 
			17 
			18 
			19 
			20 
			21 
			22 
			 | 
			
			procedure TForm1.Button1Click(Sender: TObject);var  Filepath: string;  Stream: TFileStream;begin  if not OpenDialog1.Execute then    Exit;  Filepath := OpenDialog1.FileName;  Stream := TFileStream.Create(Filepath, fmOpenRead);  RESTRequest2.Method := rmPOST;  RESTRequest2.Resource := 'images/{item}/photo';  RESTRequest2.Params.ParameterByName('item').Value := Edit1.Text;  RESTRequest2.Params.AddItem('user_id', '123');  RESTRequest2.Params.AddItem('photo', Stream, pkFILE, [], ctAPPLICATION_OCTET_STREAM);  RESTRequest2.Execute;  Stream.Free;end; | 
		
파일을 추가할 경우, TStream 이용하므로, TStream을 상속받는 객체들(TFileStream, TMemoryStream 등)을 이용할 수 있습니다.
스트림을 파라미터로 추가하는 코드는 다음과 같습니다.
| 
			 1 
			 | 
			
			RESTRequest2.Params.AddItem('photo', Stream, pkFILE, [], ctAPPLICATION_OCTET_STREAM); | 
		
파라미터 종류를 pkFILE로 지정시 내부적으로 multipart/form-data 인코딩 타입으로 데이터가 전송되며, 컨텐트타입을 ctAPPLICATION_OCTET_STREAM으로 지정해야 합니다.
파일 다운로드 구현 방안
서버(RAD Server) 측 구현
| 
			 1 
			2 
			3 
			4 
			5 
			6 
			7 
			8 
			9 
			10 
			11 
			12 
			13 
			14 
			15 
			16 
			17 
			18 
			 | 
			
			procedure TImagesResource1.GetItemPhoto(const AContext: TEndpointContext; const ARequest: TEndpointRequest; const AResponse: TEndpointResponse);var  LItem: string;  Path: string;  Stream: TStream;begin  LItem := ARequest.Params.Values['item'];  Path := TPath.Combine(ROOT_PATH, 'images');  Path := TPath.Combine(Path, LItem + '.jpg');  if not TFile.Exists(Path) then    AResponse.RaiseNotFound('Not found', '''' + LItem + ''' is not found');  Stream := TFileStream.Create(Path, fmOpenRead);  AResponse.Body.SetStream(Stream, 'image/jpeg', True);end; | 
		
클라이언트(REST Client) 측 구현
procedure TForm1.Button2Click(Sender: TObject);var  WICImage: TWICImage;  Stream: TMemoryStream;begin  RESTRequest1.Method := rmGET;  RESTRequest1.Resource := 'images/{item}/photo';  RESTRequest1.Params.ParameterByName('item').Value := Edit1.Text;  RESTRequest1.ExecuteAsync(procedure    begin      if RESTResponse1.StatusCode = 404 then        Exit;      Stream := TMemoryStream.Create;      Stream.WriteData(RESTResponse1.RawBytes, RESTResponse1.ContentLength);      WICImage := TWICImage.Create;      WICImage.LoadFromStream(Stream);      Image1.Picture.Assign(WICImage);      WICImage.Free;      Stream.Free;    end);end;
                                            
 RESTUpload.zip