iOS앱을 개발하다 보면 임의의 데이터를 저장할 일이 꼭 있더군요. (굳이 iOS앱이 아니더라도…) 그럴려면 거창하게 코어 데이터(Core Data)나 SQLite를 쓰는 방법을 생각하게 되는데요. 매번 이런 환경을 만드는게 너무 귀찮았습니다. 단순한 방법으로 데이터를 쓰기/읽기/삭제를 하고 싶었습니다.
예를 들어 다음처럼 쉽게 쓰면 얼마나 좋을까요?
(참고로 모바일에서 코드 가독성을 높이기 위해 줄바꿈 처리 했습니다.)
//특정배열 저장하기 [bsIO storageSet:@"arr" data:@[@(1),@(2),@(3)] ]; //.... 무엇인가 한 뒤 NSLog( @"[storage]arr = %@", [bsIO storageGet:@"arr"] ); //[storage]arr = (1,2,3) //임의의 이미지 데이터 저장 [bsIO storageSet:@"folder/image.png" data:UIImagePNGRepresentation(image) ]; //.... 무엇인가 한 뒤 NSData *imgData; imgData = [bsIO storageGet:@"folder/image.png" ]; UIImage *img = [UIImage imageWithData:imgData];
그래서 만들어 봤습니다.
사용한 기술 소개
iOS앱은 앱 내부의 저장 공간을 크게 3가지로 제공하는 것 같았습니다.
- 번들 : 런타임 읽기 전용. 컴파일시에 ipa내에 확정된 데이터 입니다.
- 임시 스토리지(Cache) : 런타임 쓰기/읽기/삭제가 가능. 다만, 캐시용도로만 쓸 수 있으며 언제든 OS사정에 따라 삭제가 될 수 있습니다.
- 영구 스토리지(Document) : 런타임 쓰기/읽기/삭제가 가능. 앱이 삭제되기 전까지 영구적으로 저장됩니다.(그래도 너무 많이 쓰면 곤란할 듯… 한계가 있는지는 모르겠네요.)
이 공간들은 전부 NSFileManager를 통해 접근이 가능합니다. 각각의 저장공간의 경로를 얻는 방법은 다음과 같습니다.
- 번들
[[NSBundle mainBundle] bundlePath];
-
임시 스토리지 :
[NSSearchPathForDirectoriesInDomains( NSCachesDirectory, //여기! NSUserDomainMask, YES ) objectAtIndex:0];
-
영구 스토리지 :
[NSSearchPathForDirectoriesInDomains( NSDocumentDirectory, //여기! NSUserDomainMask, YES ) objectAtIndex:0];
우리는 이 저장 공간에 특정 데이터를 NSData로 아카이브(archive)한 뒤 NSFileManager로 저장할 수 있습니다(번들 제외). 그리고 반대로 NSData로 읽어와 언아카이브(unarchive)해서 데이터를 참고할 수 있죠.
우선 번들 데이터는 아래와 같은 방법으로 읽어올 수 있습니다.
NSFileManager *fm = [NSFileManager defaultManager]; //번들 데이터 가져오기 NSData *data = [fm contentsAtPath: [[NSBundle mainBundle] bundlePath] ];
영구 스토리지같은 경우는 우선 경로를 해결해야합니다.
NSString *root = //루트(영구) [NSSearchPathForDirectoriesInDomains( NSDocumentDirectory, NSUserDomainMask, YES ) objectAtIndex:0]; NSString *filePath = //실제 파일 경로 [NSString stringWithFormat:@"%@/%@", root, @"myfileName"];
아래와 같은 데이터가 있다고 할 때..
NSData *data = //저장할 데이터....
저장/읽기/삭제는 다음과 같이 사용할 수 있습니다.
NSFileManager *fm = [NSFileManager defaultManager]; //데이터 저장 [fm createFileAtPath:filePath contents:data attributes:nil]; //데이터 읽기 data = [fm contentsAtPath:filePath]; //데이터 삭제 [fm removeItemAtPath:filePath error:nil];
임시 스토리지의 경우는 영구 스토리지에서 root 경로만 빼고 전부 같습니다.
//임시 스토리지에 쓰기/읽기/삭제 NSString *root = //루트(캐쉬) [NSSearchPathForDirectoriesInDomains( NSCachesDirectory, NSUserDomainMask, YES ) objectAtIndex:0]; NSString *filePath = //실제 파일 경로 [NSString stringWithFormat:@"%@/%@", root, @"myfileName"]; NSFileManager *fm = [NSFileManager defaultManager]; //데이터 저장 [fm createFileAtPath:filePath contents:data attributes:nil]; //데이터 읽기 data = [fm contentsAtPath:filePath]; //데이터 삭제 [fm removeItemAtPath:filePath error:nil];
기술적 설명은 이 정도로 정리할께요.
다음부턴 이 기술을 기초로 본격적으로 만들어 봅니다.
인터페이스 만들기 : bsIO.h
기초적인 인터페이스는 다음 코드와 같습니다.
잠깐 구경해 볼까요?
#import <Foundation/Foundation.h> #import <UIKit/UIKit.h> @interface bsIO : NSObject #pragma mark - asset //번들 데이타 읽기 +(NSData* _Nullable)assetGet: (NSString* _Nonnull)key; //번들에서 JSON 데이타 읽기. // JSON형태에 따라 NSArray 또는 // NSDicationay로 반환 +(id _Nullable)assetGetJSON: (NSString* _Nonnull)key; //번들에서 이미지 데이타 읽기. // UIImage로 반환 +(UIImage* _Nullable)assetGetImage: (NSString* _Nonnull)key; #pragma mark - storage //영구 스토리지에서 데이터 읽기 +(id _Nullable)storageGet: (NSString* _Nonnull)key; //영구 스토리지에 데이터 쓰기 +(BOOL)storageSet: (NSString* _Nonnull)key data:(id _Nonnull)data; //영구 스토리지의 데이터 삭제 +(BOOL)storageDel: (NSString* _Nonnull)key; #pragma mark - cache //임시 스토리지에 데이터 읽기 +(id _Nullable)cacheGet: (NSString* _Nonnull)key; //임시 스토리지에 데이터 쓰기 +(BOOL)cacheSet: (NSString* _Nonnull)key data:(id _Nonnull)data; //임시 스토리지의 데이터 삭제 +(BOOL)cacheDel: (NSString* _Nonnull)key; @end
기본적으로 모든 메소드는 static으로 접근할 수 있게 했어요. 구현체는 singleton으로 만들지만 외부에서 사용할 때는 단순히 [bsIO assetGet:@”key”] 형태로 정적 메소드로 쓰는게 좋을 것 같아서요.
그리고 각 메서드들은 각 저장소 형태에 맞게 접두어로 명명했어요. 가령, 임시 스토리지는 cache접두어를 써서 cacheGet, cacheSet, cacheDel 메소드 명을 썼습니다.
또 영구 스토리지는 storage접두어를 써서 storageGet, storageSet, storageDel를 쓰고요…(지금 생각해보니 doc으로 해야 하나?)
번들의 경우 읽기 전용이므로 assetGet 정도만 써도 될 것 같습니다.
또한 swift와 연동시 optional 연동을 하고 구현시 처음부터 null 분기 코드를 제거하기 위해 _Nonnull과 _Nullable를 지정했습니다.
구현체 만들기 : bsIO.m
위에서 정의한 인터페이스에 따라서 동작할 수 있도록 구현체를 만들어 볼께요.
참고로 특별히 번들 저장소는 assets 폴더가 있다고 가정합니다. 아래 그림 처럼요.
초기화 메소드와 싱글톤 메소드 만들기
아래 코드는 실제 구현하기전 초기화와 싱글톤을 다루고 있습니다.
#import "bsIO.h"; @interface bsIO () @property(atomic,strong) NSString* assetPath; //번들 경로 @property(atomic,strong) NSString* storagePath;//영구저장 경로 @property(atomic,strong) NSString* cachePath; //임시저장 경로 @property(atomic,weak) NSFileManager *fm; //파일 관리자 @end @implementation bsIO //싱글톤 객체 반환(외부 노출 안함) +(bsIO* _Nonnull)sharedInstance{ static dispatch_once_t pred; static id sharedObject; dispatch_once(&pred, ^{ sharedObject = [[bsIO alloc]init]; }); return sharedObject; } //초기화 -(id)init{ if(self = [super init]){ //영구저장 경로 self.storagePath = [NSSearchPathForDirectoriesInDomains( NSDocumentDirectory, NSUserDomainMask, YES ) objectAtIndex:0]; //임시저장 경로 self.cachePath = [NSSearchPathForDirectoriesInDomains( NSCachesDirectory, NSUserDomainMask, YES ) objectAtIndex:0]; //번들 경로 self.assetPath = [NSString stringWithFormat:@"%@/assets", [[NSBundle mainBundle] bundlePath] ]; //파일 관리자 참조 self.fm = [NSFileManager defaultManager]; //영구저장 경로가 없으면 생성 BOOL isExist = [self.fm fileExistsAtPath:self.storagePath ]; if(!isExist){ NSError *error = nil; [self.fm createDirectoryAtPath: self.storagePath withIntermediateDirectories:YES attributes:nil error:&error ]; if(error) [NSException raise:NSInvalidArgumentException format:@"%s(%d)%@", __FUNCTION__, __LINE__, @"스토리지 생성 실패" ]; } } return self; } #pragma mark - asset //....(번들 구현) #pragma mark - storage //....(영구저장 구현) #pragma mark - cache //....(임시저장 구현) @end
위 코드를 보면 먼저 초기화 메소드와 싱글톤을 구현했습니다. 싱글톤 메소드는 외부에 노출하지 않습니다. 초기화 메소드 내부를 보면 각각의 저장소 경로를 셋팅하고 FileMananger를 weak로 참조한 것을 확인할 수 있습니다.
이제 #pragma mark 로 표시한 부분을 구현해야 해보겠습니다.
번들 데이터 가져오기 구현
먼저 번들 데이터를 가져오는 부분을 구현하겠습니다.
#pragma mark - asset //번들 데이타 읽기 +(NSData* _Nullable)assetGet: (NSString* _Nonnull)key { return [[bsIO sharedInstance] assetGet:key]; } //번들에서 JSON 데이타 읽기. +(id _Nullable)assetGetJSON: (NSString* _Nonnull)key{ NSData *jsonData = [bsIO assetGet:key]; if(!jsonData) return nil; NSError *error = nil; id json = [NSJSONSerialization JSONObjectWithData:jsonData options:kNilOptions error:&error ]; if(error) return nil; return json; } //번들에서 이미지 데이타 읽기. +(UIImage* _Nullable)assetGetImage: (NSString* _Nonnull)key{ NSData *imgData = [bsIO assetGet:key]; if(!imgData) return nil; return [UIImage imageWithData:imgData]; } //번들 데이터 경로 만들기 -(NSString* _Nonnull)__assetPath: (NSString* _Nonnull)key{ return [NSString stringWithFormat:@"%@/%@", self.assetPath, key ]; } //번들 데이터 가져오기(구현체) -(NSData* _Nullable)assetGet: (NSString* _Nonnull)key{ NSString *path = [self __assetPath:key]; return [self.fm contentsAtPath:path]; }
위 코드는 번들 저장소 접근해서 데이터를 가져오는 메소드들을 정의했습니다. 이미 언급했듯이 +가 붙어 있는 정적 메소드만 해더 파일에 선언했으므로 외부에 노출됩니다. -가 붙어 있는 인스턴스 메소드들은 실제 번들 저장소로부터 데이터를 읽어오는 실제 구현체들입니다.
위 구현 메소드 중 [bsIO assetGet:key]가 기본 메소드로 NSData를 반환합니다. 하지만 NSData는 가장 로우레벨의 데이터로 직접쓰는 경우는 드물죠. 그래서 도우미 메소드들을 추가할 수 있습니다.
도우미 메소드로 [bsIO assetGetJSON:key], [bsIO assetGetImage:key]등이 있습니다. 이 메소드들의 역할은 json문자열을 NSArray나 NSDictionary로 받을 수 있거나 UIImage를 받도록 합니다. 필요할 때 이들 도우미 메소드를 사용하면 호스트 코드가 심플해지겠죠. 이외도 필요하면 계속 추가하면 좋을 겁니다.
영구 데이터 관리 구현
아래 코드는 영구 저장소 관련 구현체 입니다. 영구 저장소는 쓰기/읽기/삭제가 모두 되기 때문에 get/set/del 정적 메소드를 제공합니다.
#pragma mark - storage //영구 스토리지에서 데이터 읽기 +(id _Nullable)storageGet: (NSString* _Nonnull)key{ id data = [[bsIO sharedInstance] storageGet:key ]; if(data == nil) return nil; return [NSKeyedUnarchiver unarchiveObjectWithData:data ]; } //영구 스토리지에 데이터 쓰기 +(BOOL)storageSet: (NSString* _Nonnull)key data:(id _Nonnull)data{ NSData *t = [NSKeyedArchiver archivedDataWithRootObject:data ]; return [[bsIO sharedInstance] storageSet:key data:t ]; } //영구 스토리지의 데이터 삭제 +(BOOL)storageDel: (NSString* _Nonnull)key{ return [[bsIO sharedInstance] storageDel:key]; } //영구 스토리지 데이터 경로 만들기 -(NSString* _Nonnull)__storagePath: (NSString* _Nonnull)key{ return [NSString stringWithFormat:@"%@/%@", self.storagePath, key]; } //영구 스토리지 폴더 자동 생성 -(BOOL)__checkStoragePath: (NSString* _Nonnull)key{ NSArray *keys = [key componentsSeparatedByString:@"/"]; BOOL success = YES; if([keys count] > 1){ NSInteger i = [key length] - [keys[[keys count]-1] length]; NSString *path = [self __storagePath: [key substringToIndex:i] ]; BOOL isExist = [self.fm fileExistsAtPath:path ]; if(!isExist){ success = [self.fm createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:nil ]; } } return success; } //영구 스토리지에서 데이터 얻기 -(NSData* _Nullable)storageGet: (NSString* _Nonnull)key{ return [self.fm contentsAtPath: [self __storagePath:key] ]; } //영구 스토리지에 데이터 쓰기 -(BOOL)storageSet: (NSString* _Nonnull)key data:(NSData* _Nonnull)data{ BOOL check = [self __checkStoragePath:key]; if(NO == check) return NO; return [self.fm createFileAtPath: [self __storagePath:key] contents:data attributes:nil ]; } //영구 스토리지의 데이터 삭제 -(BOOL)storageDel: (NSString* _Nonnull)key { return [self.fm removeItemAtPath: [self __storagePath:key] error:nil ]; }
위 코드를 보면 저장소에 임의의 폴더를 자동으로 만들 수 있도록 구현했습니다. __checkStroagePath: 메소드가 그 역할을 하는데요. 결국 아래 코드처럼 사용이 가능합니다.
[bsIO storageSet: @"folder1/folder2/filename.txt" data:@"데이터" ];
위처럼 사용하면 __checkStroagePath:를 통해 folder1과 folder2가 자동으로 만들어줍니다. 따로 폴더를 생성할 필요가 없이 자동으로 만들어 주는거죠.
그리고 이렇게 저장된 데이터는 아래 코드로 가져올 수 있죠.
id data = [bsIO storageGet: @"folder1/folder2/filename.txt" ];
임시 데이터 관리 구현
영구 스토리지를 커버했으니 임시 스토리지는 거의 복사판입니다. 그냥 같다고 봐도 무방할 것 같네요. 중복되는 코드가 많아서 많은 부분 중복을 제거할 수 있을 겁니다. (여기선 생략~ ^^)
#pragma mark - cache //임시 스토리지에 데이터 읽기 +(id _Nullable)cacheGet: (NSString* _Nonnull)key{ id data = [[bsIO sharedInstance] cacheGet:key ]; if(data == nil) return nil; return [NSKeyedUnarchiver unarchiveObjectWithData: data ]; } //임시 스토리지에 데이터 쓰기 +(BOOL)cacheSet: (NSString* _Nonnull)key data:(id _Nonnull)data{ NSData *t = [NSKeyedArchiver archivedDataWithRootObject: data ]; return [[bsIO sharedInstance] cacheSet:key data:t ]; } //임시 스토리지의 데이터 삭제 +(BOOL)cacheDel: (NSString* _Nonnull)key{ return [[bsIO sharedInstance] cacheDel:key ]; } //임시 스토리지 데이터 경로 만들기 -(NSString* _Nonnull)__cachePath: (NSString* _Nonnull)key{ return [NSString stringWithFormat:@"%@/%@", self.cachePath, key ]; } //임시 스토리지 폴더 자동 생성 -(BOOL)__checkCachePath: (NSString* _Nonnull)key{ NSArray *keys = [key componentsSeparatedByString:@"/" ]; BOOL success = YES; if([keys count] > 1){ NSInteger i = [key length] - [keys[[keys count]-1] length]; NSString *path = [self __cachePath: [key substringToIndex:i] ]; BOOL isExist = [self.fm fileExistsAtPath:path ]; if(!isExist){ success = [self.fm createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:nil ]; } } return success; } //임시 스토리지에 데이터 읽기 -(NSData* _Nullable)cacheGet: (NSString* _Nonnull)key{ NSString *path = [self __cachePath:key]; NSData *data = nil; BOOL isExist = [self.fm fileExistsAtPath:path ]; if(isExist){ data = [self.fm contentsAtPath:path ]; } return data; } //임시 스토리지에 데이터 쓰기 -(BOOL)cacheSet: (NSString* _Nonnull)key data:(NSData* _Nonnull)data { BOOL check = [self __checkCachePath:key]; if(NO == check ) return NO; return [self.fm createFileAtPath: [self __cachePath:key] contents:data attributes:nil ]; } //임시 스토리지의 데이터 삭제 -(BOOL)cacheDel: (NSString* _Nonnull)key { return [self.fm removeItemAtPath: [self __cachePath:key] error:nil ]; }
사용해보기
만들어진 코드를 사용해 볼께요. 다양한 데이터를 저장할 수 있음을 확인할 수 있습니다.
먼저 번들 데이타를 읽어오는 예제입니다.
//json데이터 읽어오기 NSData *jsonData = [bsIO assetGet:@"test.json"]; NSError *error = nil; NSDictionary *json = [NSJSONSerialization JSONObjectWithData:jsonData options:kNilOptions error:&error ]; NSLog( @"[asset]json = %@", json ); //{k1:"테스트",k2:[1,2,3].....} //json데이터 읽어오기 //(위 보다 아래 코드를 쓰면 한 줄로 할 수 있음) NSLog( @"[asset]json = %@", [bsIO assetGetJSON:@"test.json"] ); //{k1:"테스트",k2:[1,2,3].....} //이미지 데이터 읽어오기 NSData *imgData = [bsIO assetGet:@"img/arrow.png"]; UIImage *img = [UIImage imageWithData:imgData]; NSLog( @"[asset]img = %@", img ); // <UIImage: 0x60000009d920>, {54, 88} //이미지 데이터 읽어오기 //(위 보다 아래 코드를 쓰면 한 줄로 할 수 있음) NSLog( @"[asset]img = %@", [bsIO assetGetImage:@"img/arrow.png"] ); // <UIImage: 0x60000009d920>, {54, 88}
다음 코드는 영구 스토리지에 쓰기/읽기 등을 하는 예제입니다.
//저장되어 있지 않은 키에 대한 값 id none = [bsIO storageGet:@"none"]; NSLog( @"[storage]none = %@", none ); //null //NSArray 저장/읽기 [bsIO storageSet:@"arr" data:@[@(1),@(2),@(3)] ]; NSArray* arr = [bsIO storageGet:@"arr"]; NSLog( @"[storage]arr = %@", arr ); //(1,2,3) //NSDictionary 저장/읽기 [bsIO storageSet:@"dic" data:@{ @"k1":@(1), @"k2":@"abcd", @"k3":@"한글" } ]; NSDictionary *dic = [bsIO storageGet:@"dic"]; NSLog( @"[storage]dic = %@", dic ); //{k1:1, k2:abcd, k3:한글} //NSString 저장/읽기 [bsIO storageSet:@"str" data:@"test" ]; NSString* str = [bsIO storageGet:@"str"]; NSLog( @"[storage]str = %@", str ); //test NSLog( @"[cache]dic[k3] = %@", dic[@"k3"] ); //한글 //NSData 저장/읽기 NSString * m = @"Save NSData type"; const char *c = [m UTF8String]; // 일반 char type 으로 변환 NSData * data = [NSData dataWithBytes:c length:strlen(c) ]; // 생성자이므로 메모리할당이 필요없음 [bsIO storageSet:@"data" data:data ]; data = [bsIO storageGet:@"data"]; char *bytePtr = (char *)[data bytes]; NSLog( @"[storage]char = %s", bytePtr ); //Save NSData type //하위경로 저장/읽기 [bsIO storageSet:@"folder1/name" data:@"특정폴더에 저장" ]; NSString* s1 = [bsIO storageGet:@"folder1/name"]; NSLog( @"[storage]folder1/name = %@", s1 ); //특정폴더에 저장
임시 스토리지에 쓰기/읽기 예제는 위 코드와 흡사하므로 생략하겠습니다.
직접 만든 클래스로 생성한 객체를 저장
직접 만든 클래스로 생성한 객체를 저장하려면 특별한 처리가 필요합니다.
예를 들어 아래와 같은 클래스를 만들었다고 해보죠.
@interface Member : NSObject @property(nonatomic,strong) NSString* userName; //회원명 @property(nonatomic) NSInteger age; //회원나이 @end
위 클래스의 객체를 저장해 봅시다.
//회원 생성 Member *m = [Member new]; m.userName = @"지돌스타"; m.age = 30; //회원 저장 [bsIO storageSet:@"member" data:m]; //에러!!
우린 불행히도 바로 다음과 같은 런타임 에러를 만나게 될 겁니다.
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[Member encodeWithCoder:]: unrecognized selector sent to instance
이 에러가 발생하는 이유는 NSKeyedArchiver를 이용해 NSData로 아카이브하려는 객체는 반드시 해당 클래스가 NSCoding을 구현해야 하기 때문입니다.
그래서 에러 메시지에도 [Member encodeWithCoder:]를 찾을 수 없다고 나오는 겁니다.
그럼 Member에 NSCoding을 구현해 볼께요.
//Member.h #import <Foundation/Foundation.h> @interface Member : NSObject <NSCoding> @property(nonatomic,strong) NSString* userName; @property(nonatomic) NSInteger age; @end
//Member.m #import "Member.h" @implementation Member - (void)encodeWithCoder: (NSCoder *)aCoder{ [aCoder encodeObject:self.userName forKey:@"userName" ]; [aCoder encodeInteger:self.age forKey:@"age" ]; } - (instancetype)initWithCoder: (NSCoder *)aDecoder{ if(self = [super init]){ self.userName = [aDecoder decodeObjectForKey: @"userName" ]; self.age = [aDecoder decodeIntegerForKey: @"age" ]; } return self; } //Log출력하기 위해 description을 구현했습니다. - (NSString *)description{ return [NSString stringWithFormat: @"Member userName=%@, age=%ld", self.userName, self.age ]; } @end
클래스 단위로 명시적으로 데이터를 관리하는 것은 좋다만, 모든 데이터를 이렇게 쓴다면 여간 귀찮은게 아닐 것 같네요.
이쯤되면 그냥 Core Data를 써야하지 않을까요?
암튼 NSCoding을 구현한 Member클래스의 객체를 저장하는 것은 문제없이 잘되는 것을 확인할 수 있습니다.
//회원 생성 Member *m = [Member new]; m.userName = @"지돌스타"; m.age = 30; //저장 [bsIO storageSet:@"member" data:m ]; //읽어서 로그에 출력 NSLog( @"member = %@", [bsIO storageGet:@"member"] ); //Member userName=지돌스타, age=30
결론
이상으로 구현은 귀찮지만 한번 만들어 놓으면 두고두고 써먹을 수 있는 단순한 데이터 관리를 위한 정적 클래스를 구현해 봤습니다. 실제로 실무에 쓸 때 편리했습니다. 단순하고 복잡하지 않은 설정 데이터 관리나 번들 데이터 참조시 사용하면 유용하지 않을까 생각됩니다.
좋은 하루 되세요~
recent comment